From ab7b3532fcc9a8e411b62c25d528ceaf736f7f01 Mon Sep 17 00:00:00 2001 From: chylex Date: Sun, 9 May 2021 00:26:57 +0200 Subject: [PATCH] Build a DHT app for desktop --- .idea/.gitignore | 8 +- .idea/php.xml | 4 + app/.gitignore | 7 + .../.idea/.gitignore | 12 + .../.idea.DiscordHistoryTracker/.idea/.name | 1 + .../.idea/avalonia.xml | 19 + .../.idea/codeStyles/Project.xml | 445 +++++++++ .../.idea/codeStyles/codeStyleConfig.xml | 5 + .../.idea/encodings.xml | 6 + .../.idea/indexLayout.xml | 10 + .../.idea/inspectionProfiles/Project.xml | 850 ++++++++++++++++++ .../inspectionProfiles/Project_Default.xml | 10 + .../inspectionProfiles/profiles_settings.xml | 6 + .../.idea/jsonSchemas.xml | 36 + .../.idea/libraries/Generated_files.xml | 22 + .../.idea/runConfigurations/Desktop.xml | 20 + .../.idea/scopes/Resources.xml | 3 + .../.idea/sqldialects.xml | 6 + .../.idea.DiscordHistoryTracker/.idea/vcs.xml | 11 + .../.idea/watcherTasks.xml | 8 + app/Desktop/App.axaml | 73 ++ app/Desktop/App.axaml.cs | 20 + app/Desktop/Arguments.cs | 50 ++ app/Desktop/Desktop.csproj | 66 ++ app/Desktop/Dialogs/Dialog.cs | 56 ++ app/Desktop/Dialogs/DialogResult.cs | 49 + app/Desktop/Dialogs/MessageDialog.axaml | 41 + app/Desktop/Dialogs/MessageDialog.axaml.cs | 35 + app/Desktop/Dialogs/MessageDialogModel.cs | 11 + app/Desktop/Main/AboutWindow.axaml | 67 ++ app/Desktop/Main/AboutWindow.axaml.cs | 19 + app/Desktop/Main/AboutWindowModel.cs | 33 + app/Desktop/Main/Controls/StatusBar.axaml | 58 ++ app/Desktop/Main/Controls/StatusBar.axaml.cs | 14 + app/Desktop/Main/Controls/StatusBarModel.cs | 45 + app/Desktop/Main/MainContentScreen.axaml | 91 ++ app/Desktop/Main/MainContentScreen.axaml.cs | 14 + app/Desktop/Main/MainContentScreenModel.cs | 57 ++ app/Desktop/Main/MainWindow.axaml | 22 + app/Desktop/Main/MainWindow.axaml.cs | 26 + app/Desktop/Main/MainWindowModel.cs | 101 +++ app/Desktop/Main/Pages/DatabasePage.axaml | 21 + app/Desktop/Main/Pages/DatabasePage.axaml.cs | 14 + app/Desktop/Main/Pages/DatabasePageModel.cs | 58 ++ app/Desktop/Main/Pages/TrackingPage.axaml | 54 ++ app/Desktop/Main/Pages/TrackingPage.axaml.cs | 38 + app/Desktop/Main/Pages/TrackingPageModel.cs | 150 ++++ app/Desktop/Main/Pages/ViewerPage.axaml | 44 + app/Desktop/Main/Pages/ViewerPage.axaml.cs | 21 + app/Desktop/Main/Pages/ViewerPageModel.cs | 128 +++ app/Desktop/Main/WelcomeScreen.axaml | 40 + app/Desktop/Main/WelcomeScreen.axaml.cs | 14 + app/Desktop/Main/WelcomeScreenModel.cs | 91 ++ app/Desktop/Models/BaseModel.cs | 22 + app/Desktop/Program.cs | 31 + app/Desktop/Resources/ResourceLoader.cs | 44 + app/Desktop/Resources/icon.ico | Bin 0 -> 33834 bytes app/DiscordHistoryTracker.sln | 22 + app/Resources/Icons/16.png | Bin 0 -> 1049 bytes app/Resources/Icons/24.png | Bin 0 -> 1467 bytes app/Resources/Icons/256.png | Bin 0 -> 15660 bytes app/Resources/Icons/32.png | Bin 0 -> 1889 bytes app/Resources/Icons/48.png | Bin 0 -> 2695 bytes app/Resources/Icons/icon.afdesign | Bin 0 -> 65102 bytes app/Resources/Schemas/track-channel.yml | 42 + app/Resources/Schemas/track-messages.yml | 81 ++ app/Resources/Schemas/track-users.yml | 19 + app/Resources/Tracker/bootstrap.js | 160 ++++ app/Resources/Tracker/scripts.min/discord.js | 1 + app/Resources/Tracker/scripts.min/dom.js | 1 + app/Resources/Tracker/scripts.min/gui.js | 17 + app/Resources/Tracker/scripts.min/settings.js | 1 + app/Resources/Tracker/scripts.min/state.js | 1 + app/Resources/Tracker/scripts/discord.js | 271 ++++++ app/Resources/Tracker/scripts/dom.js | 74 ++ app/Resources/Tracker/scripts/gui.js | 156 ++++ app/Resources/Tracker/scripts/settings.js | 65 ++ app/Resources/Tracker/scripts/state.js | 299 ++++++ app/Resources/Tracker/styles/controller.css | 31 + app/Resources/Tracker/styles/settings.css | 28 + app/Resources/Viewer/index.html | 76 ++ app/Resources/Viewer/scripts/bootstrap.js | 39 + app/Resources/Viewer/scripts/discord.js | 257 ++++++ app/Resources/Viewer/scripts/dom.js | 54 ++ app/Resources/Viewer/scripts/gui.js | 249 +++++ app/Resources/Viewer/scripts/processor.js | 41 + app/Resources/Viewer/scripts/settings.js | 80 ++ app/Resources/Viewer/scripts/state.js | 303 +++++++ app/Resources/Viewer/scripts/template.js | 20 + app/Resources/Viewer/styles/channels.css | 42 + app/Resources/Viewer/styles/main.css | 13 + app/Resources/Viewer/styles/menu.css | 78 ++ app/Resources/Viewer/styles/messages.css | 254 ++++++ app/Resources/Viewer/styles/modal.css | 46 + app/Server/Collections/MultiDictionary.cs | 19 + app/Server/Data/Attachment.cs | 9 + app/Server/Data/Channel.cs | 10 + app/Server/Data/Embed.cs | 5 + app/Server/Data/EmojiFlags.cs | 9 + app/Server/Data/Filters/MessageFilter.cs | 11 + app/Server/Data/Message.cs | 16 + app/Server/Data/Reaction.cs | 8 + app/Server/Data/Server.cs | 7 + app/Server/Data/ServerType.cs | 36 + app/Server/Data/User.cs | 8 + app/Server/Database/DatabaseStatistics.cs | 32 + app/Server/Database/DummyDatabaseFile.cs | 47 + .../Exceptions/DatabaseTooNewException.cs | 13 + .../InvalidDatabaseVersionException.cs | 11 + .../Database/Export/ViewerJsonExport.cs | 153 ++++ .../Export/ViewerJsonSnowflakeSerializer.cs | 15 + app/Server/Database/IDatabaseFile.cs | 24 + app/Server/Database/Sqlite/Schema.cs | 110 +++ .../Database/Sqlite/SqliteDatabaseFile.cs | 375 ++++++++ .../Database/Sqlite/SqliteMessageFilter.cs | 29 + app/Server/Database/Sqlite/SqliteUtils.cs | 40 + app/Server/Endpoints/BaseEndpoint.cs | 57 ++ app/Server/Endpoints/TrackChannelEndpoint.cs | 40 + app/Server/Endpoints/TrackMessagesEndpoint.cs | 85 ++ app/Server/Endpoints/TrackUsersEndpoint.cs | 40 + app/Server/Json/JsonExtensions.cs | 92 ++ app/Server/Logging/Log.cs | 31 + app/Server/Server.csproj | 27 + app/Server/Service/HttpException.cs | 12 + app/Server/Service/ServerLauncher.cs | 125 +++ app/Server/Service/ServerParameters.cs | 5 + app/Server/Service/ServerStartup.cs | 40 + app/Server/Service/ServerUtils.cs | 40 + app/build.bat | 15 + web/img/app-tracker.png | Bin 0 -> 7896 bytes web/img/app-viewer.png | Bin 0 -> 6825 bytes web/index.php | 78 +- web/style.css | 27 +- 133 files changed, 7690 insertions(+), 39 deletions(-) create mode 100644 .idea/php.xml create mode 100644 app/.gitignore create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/.gitignore create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/.name create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/avalonia.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/codeStyles/Project.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/codeStyles/codeStyleConfig.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/encodings.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/indexLayout.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/inspectionProfiles/Project.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/inspectionProfiles/Project_Default.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/inspectionProfiles/profiles_settings.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/jsonSchemas.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/libraries/Generated_files.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/runConfigurations/Desktop.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/scopes/Resources.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/sqldialects.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/vcs.xml create mode 100644 app/.idea/.idea.DiscordHistoryTracker/.idea/watcherTasks.xml create mode 100644 app/Desktop/App.axaml create mode 100644 app/Desktop/App.axaml.cs create mode 100644 app/Desktop/Arguments.cs create mode 100644 app/Desktop/Desktop.csproj create mode 100644 app/Desktop/Dialogs/Dialog.cs create mode 100644 app/Desktop/Dialogs/DialogResult.cs create mode 100644 app/Desktop/Dialogs/MessageDialog.axaml create mode 100644 app/Desktop/Dialogs/MessageDialog.axaml.cs create mode 100644 app/Desktop/Dialogs/MessageDialogModel.cs create mode 100644 app/Desktop/Main/AboutWindow.axaml create mode 100644 app/Desktop/Main/AboutWindow.axaml.cs create mode 100644 app/Desktop/Main/AboutWindowModel.cs create mode 100644 app/Desktop/Main/Controls/StatusBar.axaml create mode 100644 app/Desktop/Main/Controls/StatusBar.axaml.cs create mode 100644 app/Desktop/Main/Controls/StatusBarModel.cs create mode 100644 app/Desktop/Main/MainContentScreen.axaml create mode 100644 app/Desktop/Main/MainContentScreen.axaml.cs create mode 100644 app/Desktop/Main/MainContentScreenModel.cs create mode 100644 app/Desktop/Main/MainWindow.axaml create mode 100644 app/Desktop/Main/MainWindow.axaml.cs create mode 100644 app/Desktop/Main/MainWindowModel.cs create mode 100644 app/Desktop/Main/Pages/DatabasePage.axaml create mode 100644 app/Desktop/Main/Pages/DatabasePage.axaml.cs create mode 100644 app/Desktop/Main/Pages/DatabasePageModel.cs create mode 100644 app/Desktop/Main/Pages/TrackingPage.axaml create mode 100644 app/Desktop/Main/Pages/TrackingPage.axaml.cs create mode 100644 app/Desktop/Main/Pages/TrackingPageModel.cs create mode 100644 app/Desktop/Main/Pages/ViewerPage.axaml create mode 100644 app/Desktop/Main/Pages/ViewerPage.axaml.cs create mode 100644 app/Desktop/Main/Pages/ViewerPageModel.cs create mode 100644 app/Desktop/Main/WelcomeScreen.axaml create mode 100644 app/Desktop/Main/WelcomeScreen.axaml.cs create mode 100644 app/Desktop/Main/WelcomeScreenModel.cs create mode 100644 app/Desktop/Models/BaseModel.cs create mode 100644 app/Desktop/Program.cs create mode 100644 app/Desktop/Resources/ResourceLoader.cs create mode 100644 app/Desktop/Resources/icon.ico create mode 100644 app/DiscordHistoryTracker.sln create mode 100644 app/Resources/Icons/16.png create mode 100644 app/Resources/Icons/24.png create mode 100644 app/Resources/Icons/256.png create mode 100644 app/Resources/Icons/32.png create mode 100644 app/Resources/Icons/48.png create mode 100644 app/Resources/Icons/icon.afdesign create mode 100644 app/Resources/Schemas/track-channel.yml create mode 100644 app/Resources/Schemas/track-messages.yml create mode 100644 app/Resources/Schemas/track-users.yml create mode 100644 app/Resources/Tracker/bootstrap.js create mode 100644 app/Resources/Tracker/scripts.min/discord.js create mode 100644 app/Resources/Tracker/scripts.min/dom.js create mode 100644 app/Resources/Tracker/scripts.min/gui.js create mode 100644 app/Resources/Tracker/scripts.min/settings.js create mode 100644 app/Resources/Tracker/scripts.min/state.js create mode 100644 app/Resources/Tracker/scripts/discord.js create mode 100644 app/Resources/Tracker/scripts/dom.js create mode 100644 app/Resources/Tracker/scripts/gui.js create mode 100644 app/Resources/Tracker/scripts/settings.js create mode 100644 app/Resources/Tracker/scripts/state.js create mode 100644 app/Resources/Tracker/styles/controller.css create mode 100644 app/Resources/Tracker/styles/settings.css create mode 100644 app/Resources/Viewer/index.html create mode 100644 app/Resources/Viewer/scripts/bootstrap.js create mode 100644 app/Resources/Viewer/scripts/discord.js create mode 100644 app/Resources/Viewer/scripts/dom.js create mode 100644 app/Resources/Viewer/scripts/gui.js create mode 100644 app/Resources/Viewer/scripts/processor.js create mode 100644 app/Resources/Viewer/scripts/settings.js create mode 100644 app/Resources/Viewer/scripts/state.js create mode 100644 app/Resources/Viewer/scripts/template.js create mode 100644 app/Resources/Viewer/styles/channels.css create mode 100644 app/Resources/Viewer/styles/main.css create mode 100644 app/Resources/Viewer/styles/menu.css create mode 100644 app/Resources/Viewer/styles/messages.css create mode 100644 app/Resources/Viewer/styles/modal.css create mode 100644 app/Server/Collections/MultiDictionary.cs create mode 100644 app/Server/Data/Attachment.cs create mode 100644 app/Server/Data/Channel.cs create mode 100644 app/Server/Data/Embed.cs create mode 100644 app/Server/Data/EmojiFlags.cs create mode 100644 app/Server/Data/Filters/MessageFilter.cs create mode 100644 app/Server/Data/Message.cs create mode 100644 app/Server/Data/Reaction.cs create mode 100644 app/Server/Data/Server.cs create mode 100644 app/Server/Data/ServerType.cs create mode 100644 app/Server/Data/User.cs create mode 100644 app/Server/Database/DatabaseStatistics.cs create mode 100644 app/Server/Database/DummyDatabaseFile.cs create mode 100644 app/Server/Database/Exceptions/DatabaseTooNewException.cs create mode 100644 app/Server/Database/Exceptions/InvalidDatabaseVersionException.cs create mode 100644 app/Server/Database/Export/ViewerJsonExport.cs create mode 100644 app/Server/Database/Export/ViewerJsonSnowflakeSerializer.cs create mode 100644 app/Server/Database/IDatabaseFile.cs create mode 100644 app/Server/Database/Sqlite/Schema.cs create mode 100644 app/Server/Database/Sqlite/SqliteDatabaseFile.cs create mode 100644 app/Server/Database/Sqlite/SqliteMessageFilter.cs create mode 100644 app/Server/Database/Sqlite/SqliteUtils.cs create mode 100644 app/Server/Endpoints/BaseEndpoint.cs create mode 100644 app/Server/Endpoints/TrackChannelEndpoint.cs create mode 100644 app/Server/Endpoints/TrackMessagesEndpoint.cs create mode 100644 app/Server/Endpoints/TrackUsersEndpoint.cs create mode 100644 app/Server/Json/JsonExtensions.cs create mode 100644 app/Server/Logging/Log.cs create mode 100644 app/Server/Server.csproj create mode 100644 app/Server/Service/HttpException.cs create mode 100644 app/Server/Service/ServerLauncher.cs create mode 100644 app/Server/Service/ServerParameters.cs create mode 100644 app/Server/Service/ServerStartup.cs create mode 100644 app/Server/Service/ServerUtils.cs create mode 100644 app/build.bat create mode 100644 web/img/app-tracker.png create mode 100644 web/img/app-viewer.png diff --git a/.idea/.gitignore b/.idea/.gitignore index c84016a..ff1ceb8 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -1,5 +1,9 @@ -/inspectionProfiles/ +/codeStyles +/dataSources.local.xml +/deployment.xml /httpRequests/ -/shelf/ +/inspectionProfiles /misc.xml +/shelf/ +/webServers.xml /workspace.xml diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 0000000..7341688 --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..9648236 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,7 @@ +.vscode/ +.vs/ + +bin/ +obj/ + +*.user diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/.gitignore b/app/.idea/.idea.DiscordHistoryTracker/.idea/.gitignore new file mode 100644 index 0000000..9ccc579 --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/.gitignore @@ -0,0 +1,12 @@ +/.idea.DiscordHistoryTracker.iml +/contentModel.xml +/dataSources +/dataSources.local.xml +/dataSources.xml +/dictionaries +/httpRequests/ +/misc.xml +/modules.xml +/projectSettingsUpdater.xml +/shelf/ +/workspace.xml diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/.name b/app/.idea/.idea.DiscordHistoryTracker/.idea/.name new file mode 100644 index 0000000..d26353a --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/.name @@ -0,0 +1 @@ +DiscordHistoryTracker \ No newline at end of file diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/avalonia.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/avalonia.xml new file mode 100644 index 0000000..a5b28eb --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/avalonia.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/codeStyles/Project.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7f79f02 --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/codeStyles/Project.xml @@ -0,0 +1,445 @@ + + + + + diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/codeStyles/codeStyleConfig.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/encodings.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/encodings.xml new file mode 100644 index 0000000..c2bae49 --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/indexLayout.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/indexLayout.xml new file mode 100644 index 0000000..d8f4569 --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/indexLayout.xml @@ -0,0 +1,10 @@ + + + + + Resources + + + + + diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/inspectionProfiles/Project.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/inspectionProfiles/Project.xml new file mode 100644 index 0000000..1abdc1d --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/inspectionProfiles/Project.xml @@ -0,0 +1,850 @@ + + + + \ No newline at end of file diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/inspectionProfiles/Project_Default.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..146ab09 --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/inspectionProfiles/profiles_settings.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..bdc0816 --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/jsonSchemas.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/jsonSchemas.xml new file mode 100644 index 0000000..25060dc --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/jsonSchemas.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/libraries/Generated_files.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/libraries/Generated_files.xml new file mode 100644 index 0000000..e585f11 --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/libraries/Generated_files.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/runConfigurations/Desktop.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/runConfigurations/Desktop.xml new file mode 100644 index 0000000..66c2905 --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/runConfigurations/Desktop.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/scopes/Resources.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/scopes/Resources.xml new file mode 100644 index 0000000..d16f6d3 --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/scopes/Resources.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/sqldialects.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/sqldialects.xml new file mode 100644 index 0000000..c0e01ca --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/vcs.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/vcs.xml new file mode 100644 index 0000000..41a42f1 --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/vcs.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/watcherTasks.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/watcherTasks.xml new file mode 100644 index 0000000..221d40a --- /dev/null +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/watcherTasks.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/Desktop/App.axaml b/app/Desktop/App.axaml new file mode 100644 index 0000000..38d95c5 --- /dev/null +++ b/app/Desktop/App.axaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/Desktop/App.axaml.cs b/app/Desktop/App.axaml.cs new file mode 100644 index 0000000..5a8ff93 --- /dev/null +++ b/app/Desktop/App.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using DHT.Desktop.Main; + +namespace DHT.Desktop { + public class App : Application { + public override void Initialize() { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { + desktop.MainWindow = new MainWindow(new Arguments(desktop.Args)); + } + + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/app/Desktop/Arguments.cs b/app/Desktop/Arguments.cs new file mode 100644 index 0000000..4ee2d6f --- /dev/null +++ b/app/Desktop/Arguments.cs @@ -0,0 +1,50 @@ +using System; +using DHT.Server.Logging; + +namespace DHT.Desktop { + public class Arguments { + public static Arguments Empty => new(Array.Empty()); + + public string? DatabaseFile { get; } + public ushort? ServerPort { get; } + public string? ServerToken { get; } + + public Arguments(string[] args) { + for (int i = 0; i < args.Length; i++) { + string key = args[i]; + + if (i >= args.Length - 1) { + Log.Warn("Missing value for command line argument: " + key); + continue; + } + + string value = args[++i]; + + switch (key) { + case "-db": + DatabaseFile = value; + continue; + + case "-port": { + if (ushort.TryParse(value, out var port)) { + ServerPort = port; + } + else { + Log.Warn("Invalid port number: " + value); + } + + continue; + } + + case "-token": + ServerToken = value; + continue; + + default: + Log.Warn("Unknown command line argument: " + key); + break; + } + } + } + } +} diff --git a/app/Desktop/Desktop.csproj b/app/Desktop/Desktop.csproj new file mode 100644 index 0000000..01da6f7 --- /dev/null +++ b/app/Desktop/Desktop.csproj @@ -0,0 +1,66 @@ + + + WinExe + net5.0 + enable + DHT.Desktop + DiscordHistoryTracker + chylex + DiscordHistoryTracker + DiscordHistoryTracker + ./Resources/icon.ico + true + en + DiscordHistoryTracker + 31.0.0.0 + $(Version) + $(Version) + $(Version) + + + true + none + + + + + + + + + + + + MainWindow.axaml + Code + + + MessageDialog.axaml + Code + + + + + + + + + + Tracker\%(RecursiveDir)%(Filename)%(Extension) + Resources/Tracker/%(RecursiveDir)%(Filename)%(Extension) + + + Tracker\scripts\%(RecursiveDir)%(Filename)%(Extension) + Resources/Tracker/scripts/%(RecursiveDir)%(Filename)%(Extension) + + + Tracker\styles\%(RecursiveDir)%(Filename)%(Extension) + Resources/Tracker/styles/%(RecursiveDir)%(Filename)%(Extension) + + + Viewer\%(RecursiveDir)%(Filename)%(Extension) + Resources/Viewer/%(RecursiveDir)%(Filename)%(Extension) + + + + diff --git a/app/Desktop/Dialogs/Dialog.cs b/app/Desktop/Dialogs/Dialog.cs new file mode 100644 index 0000000..0bcfb99 --- /dev/null +++ b/app/Desktop/Dialogs/Dialog.cs @@ -0,0 +1,56 @@ +using System.Threading.Tasks; +using Avalonia.Controls; + +namespace DHT.Desktop.Dialogs { + public static class Dialog { + public static async Task ShowOk(Window owner, string title, string message) { + await new MessageDialog { + DataContext = new MessageDialogModel { + Title = title, + Message = message, + IsOkVisible = true + } + }.ShowDialog(owner); + } + + public static async Task ShowOkCancel(Window owner, string title, string message) { + var result = await new MessageDialog { + DataContext = new MessageDialogModel { + Title = title, + Message = message, + IsOkVisible = true, + IsCancelVisible = true + } + }.ShowDialog(owner); + + return result.ToOkCancel(); + } + + public static async Task ShowYesNo(Window owner, string title, string message) { + var result = await new MessageDialog { + DataContext = new MessageDialogModel { + Title = title, + Message = message, + IsYesVisible = true, + IsNoVisible = true + } + }.ShowDialog(owner); + + return result.ToYesNo(); + } + + public static async Task ShowYesNoCancel(Window owner, string title, string message) { + var result = await new MessageDialog { + DataContext = new MessageDialogModel { + Title = title, + Message = message, + IsYesVisible = true, + IsNoVisible = true, + IsCancelVisible = true + } + }.ShowDialog(owner); + + return result.ToYesNoCancel(); + } + } +} diff --git a/app/Desktop/Dialogs/DialogResult.cs b/app/Desktop/Dialogs/DialogResult.cs new file mode 100644 index 0000000..b373f59 --- /dev/null +++ b/app/Desktop/Dialogs/DialogResult.cs @@ -0,0 +1,49 @@ +using System; + +namespace DHT.Desktop.Dialogs { + public static class DialogResult { + public enum All { + Ok, Yes, No, Cancel + } + + public enum OkCancel { + Closed, Ok, Cancel + } + + public enum YesNo { + Closed, Yes, No + } + + public enum YesNoCancel { + Closed, Yes, No, Cancel + } + + public static OkCancel ToOkCancel(this All? result) { + return result switch { + null => OkCancel.Closed, + All.Ok => OkCancel.Ok, + All.Cancel => OkCancel.Cancel, + _ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel.") + }; + } + + public static YesNo ToYesNo(this All? result) { + return result switch { + null => YesNo.Closed, + All.Yes => YesNo.Yes, + All.No => YesNo.No, + _ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no.") + }; + } + + public static YesNoCancel ToYesNoCancel(this All? result) { + return result switch { + null => YesNoCancel.Closed, + All.Yes => YesNoCancel.Yes, + All.No => YesNoCancel.No, + All.Cancel => YesNoCancel.Cancel, + _ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.") + }; + } + } +} diff --git a/app/Desktop/Dialogs/MessageDialog.axaml b/app/Desktop/Dialogs/MessageDialog.axaml new file mode 100644 index 0000000..1deec44 --- /dev/null +++ b/app/Desktop/Dialogs/MessageDialog.axaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/Desktop/Dialogs/MessageDialog.axaml.cs b/app/Desktop/Dialogs/MessageDialog.axaml.cs new file mode 100644 index 0000000..2341d9b --- /dev/null +++ b/app/Desktop/Dialogs/MessageDialog.axaml.cs @@ -0,0 +1,35 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; + +namespace DHT.Desktop.Dialogs { + public class MessageDialog : Window { + public MessageDialog() { + InitializeComponent(); + #if DEBUG + this.AttachDevTools(); + #endif + } + + private void InitializeComponent() { + AvaloniaXamlLoader.Load(this); + } + + public void ClickOk(object? sender, RoutedEventArgs e) { + Close(DialogResult.All.Ok); + } + + public void ClickYes(object? sender, RoutedEventArgs e) { + Close(DialogResult.All.Yes); + } + + public void ClickNo(object? sender, RoutedEventArgs e) { + Close(DialogResult.All.No); + } + + public void ClickCancel(object? sender, RoutedEventArgs e) { + Close(DialogResult.All.Cancel); + } + } +} diff --git a/app/Desktop/Dialogs/MessageDialogModel.cs b/app/Desktop/Dialogs/MessageDialogModel.cs new file mode 100644 index 0000000..7bab158 --- /dev/null +++ b/app/Desktop/Dialogs/MessageDialogModel.cs @@ -0,0 +1,11 @@ +namespace DHT.Desktop.Dialogs { + public class MessageDialogModel { + public string Title { get; init; } = ""; + public string Message { get; init; } = ""; + + public bool IsOkVisible { get; init; } = false; + public bool IsYesVisible { get; init; } = false; + public bool IsNoVisible { get; init; } = false; + public bool IsCancelVisible { get; init; } = false; + } +} diff --git a/app/Desktop/Main/AboutWindow.axaml b/app/Desktop/Main/AboutWindow.axaml new file mode 100644 index 0000000..f9711ec --- /dev/null +++ b/app/Desktop/Main/AboutWindow.axaml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + Discord History Tracker was created by chylex and released under the MIT license. + + + + + + + + + Third-Party Software + License + Link + + .NET 5 + MIT + + + Avalonia + MIT + + + SQLite + Public Domain + + + Microsoft.Data.Sqlite + Apache-2.0 + + + + + diff --git a/app/Desktop/Main/AboutWindow.axaml.cs b/app/Desktop/Main/AboutWindow.axaml.cs new file mode 100644 index 0000000..e8739dd --- /dev/null +++ b/app/Desktop/Main/AboutWindow.axaml.cs @@ -0,0 +1,19 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace DHT.Desktop.Main { + public class AboutWindow : Window { + public AboutWindow() { + InitializeComponent(); + #if DEBUG + this.AttachDevTools(); + #endif + } + + private void InitializeComponent() { + AvaloniaXamlLoader.Load(this); + } + } +} + diff --git a/app/Desktop/Main/AboutWindowModel.cs b/app/Desktop/Main/AboutWindowModel.cs new file mode 100644 index 0000000..bcee621 --- /dev/null +++ b/app/Desktop/Main/AboutWindowModel.cs @@ -0,0 +1,33 @@ +using System.Diagnostics; + +namespace DHT.Desktop.Main { + public class AboutWindowModel { + public void ShowOfficialWebsite() { + OpenUrl("https://dht.chylex.com"); + } + + public void ShowSourceCode() { + OpenUrl("https://github.com/chylex/Discord-History-Tracker"); + } + + public void ShowLibraryAvalonia() { + OpenUrl("https://www.nuget.org/packages/Avalonia"); + } + + public void ShowLibrarySqlite() { + OpenUrl("https://www.sqlite.org"); + } + + public void ShowLibrarySqliteAdoNet() { + OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite"); + } + + public void ShowLibraryNetCore() { + OpenUrl("https://github.com/dotnet/core"); + } + + private static void OpenUrl(string url) { + Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); + } + } +} diff --git a/app/Desktop/Main/Controls/StatusBar.axaml b/app/Desktop/Main/Controls/StatusBar.axaml new file mode 100644 index 0000000..e4fa501 --- /dev/null +++ b/app/Desktop/Main/Controls/StatusBar.axaml @@ -0,0 +1,58 @@ + + + + + + + + #546A9F + + + + + + + + + + + + Status + + + + + Servers + + + + + Channels + + + + + Messages + + + + + diff --git a/app/Desktop/Main/Controls/StatusBar.axaml.cs b/app/Desktop/Main/Controls/StatusBar.axaml.cs new file mode 100644 index 0000000..731bcee --- /dev/null +++ b/app/Desktop/Main/Controls/StatusBar.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace DHT.Desktop.Main.Controls { + public class StatusBar : UserControl { + public StatusBar() { + InitializeComponent(); + } + + private void InitializeComponent() { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/app/Desktop/Main/Controls/StatusBarModel.cs b/app/Desktop/Main/Controls/StatusBarModel.cs new file mode 100644 index 0000000..3504c83 --- /dev/null +++ b/app/Desktop/Main/Controls/StatusBarModel.cs @@ -0,0 +1,45 @@ +using System; +using DHT.Desktop.Models; +using DHT.Server.Database; + +namespace DHT.Desktop.Main.Controls { + public class StatusBarModel : BaseModel { + public DatabaseStatistics DatabaseStatistics { get; } + + private Status status = Status.Stopped; + + public Status CurrentStatus { + get => status; + set { + status = value; + OnPropertyChanged(nameof(StatusText)); + } + } + + public string StatusText { + get { + return CurrentStatus switch { + Status.Starting => "STARTING", + Status.Ready => "READY", + Status.Stopping => "STOPPING", + Status.Stopped => "STOPPED", + _ => "" + }; + } + } + + [Obsolete("Designer")] + public StatusBarModel() : this(new DatabaseStatistics()) {} + + public StatusBarModel(DatabaseStatistics databaseStatistics) { + this.DatabaseStatistics = databaseStatistics; + } + + public enum Status { + Starting, + Ready, + Stopping, + Stopped + } + } +} diff --git a/app/Desktop/Main/MainContentScreen.axaml b/app/Desktop/Main/MainContentScreen.axaml new file mode 100644 index 0000000..9065892 --- /dev/null +++ b/app/Desktop/Main/MainContentScreen.axaml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/Desktop/Main/MainContentScreen.axaml.cs b/app/Desktop/Main/MainContentScreen.axaml.cs new file mode 100644 index 0000000..9f1aae5 --- /dev/null +++ b/app/Desktop/Main/MainContentScreen.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace DHT.Desktop.Main { + public class MainContentScreen : UserControl { + public MainContentScreen() { + InitializeComponent(); + } + + private void InitializeComponent() { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/app/Desktop/Main/MainContentScreenModel.cs b/app/Desktop/Main/MainContentScreenModel.cs new file mode 100644 index 0000000..2a741b0 --- /dev/null +++ b/app/Desktop/Main/MainContentScreenModel.cs @@ -0,0 +1,57 @@ +using System; +using Avalonia.Controls; +using DHT.Desktop.Main.Controls; +using DHT.Desktop.Main.Pages; +using DHT.Server.Database; +using DHT.Server.Service; + +namespace DHT.Desktop.Main { + public class MainContentScreenModel : IDisposable { + public DatabasePage DatabasePage { get; } + private DatabasePageModel DatabasePageModel { get; } + + public TrackingPage TrackingPage { get; } + private TrackingPageModel TrackingPageModel { get; } + + public ViewerPage ViewerPage { get; } + private ViewerPageModel ViewerPageModel { get; } + + public StatusBarModel StatusBarModel { get; } + + public event EventHandler? DatabaseClosed { + add { DatabasePageModel.DatabaseClosed += value; } + remove { DatabasePageModel.DatabaseClosed -= value; } + } + + [Obsolete("Designer")] + public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {} + + public MainContentScreenModel(Window window, IDatabaseFile db) { + DatabasePageModel = new DatabasePageModel(window, db); + DatabasePage = new DatabasePage { DataContext = DatabasePageModel }; + + TrackingPageModel = new TrackingPageModel(window, db); + TrackingPage = new TrackingPage { DataContext = TrackingPageModel }; + + ViewerPageModel = new ViewerPageModel(window, db); + ViewerPage = new ViewerPage { DataContext = ViewerPageModel }; + + StatusBarModel = new StatusBarModel(db.Statistics); + TrackingPageModel.ServerStatusChanged += TrackingPageModelOnServerStatusChanged; + StatusBarModel.CurrentStatus = ServerLauncher.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped; + } + + public void Initialize() { + TrackingPageModel.Initialize(); + } + + private void TrackingPageModelOnServerStatusChanged(object? sender, StatusBarModel.Status e) { + StatusBarModel.CurrentStatus = e; + } + + public void Dispose() { + TrackingPageModel.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/app/Desktop/Main/MainWindow.axaml b/app/Desktop/Main/MainWindow.axaml new file mode 100644 index 0000000..d75a877 --- /dev/null +++ b/app/Desktop/Main/MainWindow.axaml @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/app/Desktop/Main/MainWindow.axaml.cs b/app/Desktop/Main/MainWindow.axaml.cs new file mode 100644 index 0000000..8bbfd0f --- /dev/null +++ b/app/Desktop/Main/MainWindow.axaml.cs @@ -0,0 +1,26 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using JetBrains.Annotations; + +namespace DHT.Desktop.Main { + public class MainWindow : Window { + [UsedImplicitly] + public MainWindow() { + InitializeComponent(Arguments.Empty); + } + + public MainWindow(Arguments args) { + InitializeComponent(args); + } + + private void InitializeComponent(Arguments args) { + AvaloniaXamlLoader.Load(this); + DataContext = new MainWindowModel(this, args); + + #if DEBUG + this.AttachDevTools(); + #endif + } + } +} diff --git a/app/Desktop/Main/MainWindowModel.cs b/app/Desktop/Main/MainWindowModel.cs new file mode 100644 index 0000000..56a1db8 --- /dev/null +++ b/app/Desktop/Main/MainWindowModel.cs @@ -0,0 +1,101 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Avalonia.Controls; +using DHT.Desktop.Dialogs; +using DHT.Desktop.Main.Pages; +using DHT.Desktop.Models; +using DHT.Server.Database; + +namespace DHT.Desktop.Main { + public class MainWindowModel : BaseModel { + public WelcomeScreen WelcomeScreen { get; } + private WelcomeScreenModel WelcomeScreenModel { get; } + + public MainContentScreen? MainContentScreen { get; private set; } + private MainContentScreenModel? MainContentScreenModel { get; set; } + + public bool ShowWelcomeScreen => db == null; + public bool ShowMainContentScreen => db != null; + + private readonly Window window; + + private IDatabaseFile? db; + + [Obsolete("Designer")] + public MainWindowModel() : this(null!, Arguments.Empty) {} + + public MainWindowModel(Window window, Arguments args) { + this.window = window; + + WelcomeScreenModel = new WelcomeScreenModel(window); + WelcomeScreen = new WelcomeScreen { DataContext = WelcomeScreenModel }; + + WelcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged; + + var dbFile = args.DatabaseFile; + if (!string.IsNullOrWhiteSpace(dbFile)) { + async void OnWindowOpened(object? o, EventArgs eventArgs) { + window.Opened -= OnWindowOpened; + + // https://github.com/AvaloniaUI/Avalonia/issues/3071 + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + await Task.Delay(500); + } + + if (File.Exists(dbFile)) { + await WelcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile); + } + else { + await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile); + } + } + + window.Opened += OnWindowOpened; + } + + if (args.ServerPort != null) { + TrackingPageModel.ServerPort = args.ServerPort.ToString()!; + } + + if (args.ServerToken != null) { + TrackingPageModel.ServerToken = args.ServerToken; + } + } + + private void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) { + if (e.PropertyName == nameof(WelcomeScreenModel.Db)) { + if (MainContentScreenModel != null) { + MainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed; + MainContentScreenModel.Dispose(); + } + + db?.Dispose(); + db = WelcomeScreenModel.Db; + + if (db == null) { + MainContentScreenModel = null; + MainContentScreen = null; + } + else { + MainContentScreenModel = new MainContentScreenModel(window, db); + MainContentScreenModel.Initialize(); + MainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed; + MainContentScreen = new MainContentScreen { DataContext = MainContentScreenModel }; + OnPropertyChanged(nameof(MainContentScreen)); + } + + OnPropertyChanged(nameof(ShowWelcomeScreen)); + OnPropertyChanged(nameof(ShowMainContentScreen)); + + window.Focus(); + } + } + + private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) { + WelcomeScreenModel.CloseDatabase(); + } + } +} diff --git a/app/Desktop/Main/Pages/DatabasePage.axaml b/app/Desktop/Main/Pages/DatabasePage.axaml new file mode 100644 index 0000000..964bdcf --- /dev/null +++ b/app/Desktop/Main/Pages/DatabasePage.axaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + diff --git a/app/Desktop/Main/Pages/DatabasePage.axaml.cs b/app/Desktop/Main/Pages/DatabasePage.axaml.cs new file mode 100644 index 0000000..7aa8b73 --- /dev/null +++ b/app/Desktop/Main/Pages/DatabasePage.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace DHT.Desktop.Main.Pages { + public class DatabasePage : UserControl { + public DatabasePage() { + InitializeComponent(); + } + + private void InitializeComponent() { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/app/Desktop/Main/Pages/DatabasePageModel.cs b/app/Desktop/Main/Pages/DatabasePageModel.cs new file mode 100644 index 0000000..865f2e8 --- /dev/null +++ b/app/Desktop/Main/Pages/DatabasePageModel.cs @@ -0,0 +1,58 @@ +using System; +using System.Diagnostics; +using System.IO; +using Avalonia.Controls; +using DHT.Desktop.Dialogs; +using DHT.Desktop.Models; +using DHT.Server.Database; +using DHT.Server.Service; + +namespace DHT.Desktop.Main.Pages { + public class DatabasePageModel : BaseModel { + public IDatabaseFile Db { get; } + + public event EventHandler? DatabaseClosed; + + private readonly Window window; + + [Obsolete("Designer")] + public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {} + + public DatabasePageModel(Window window, IDatabaseFile db) { + this.window = window; + this.Db = db; + } + + public async void OpenDatabaseFolder() { + string file = Db.Path; + string? folder = Path.GetDirectoryName(file); + + if (folder == null) { + return; + } + + switch (Environment.OSVersion.Platform) { + case PlatformID.Win32NT: + Process.Start("explorer.exe", "/select,\"" + file + "\""); + break; + + case PlatformID.Unix: + Process.Start("xdg-open", new string[] { folder }); + break; + + case PlatformID.MacOSX: + Process.Start("open", new string[] { folder }); + break; + + default: + await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system."); + break; + } + } + + public void CloseDatabase() { + ServerLauncher.Stop(); + DatabaseClosed?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/app/Desktop/Main/Pages/TrackingPage.axaml b/app/Desktop/Main/Pages/TrackingPage.axaml new file mode 100644 index 0000000..e26d7d9 --- /dev/null +++ b/app/Desktop/Main/Pages/TrackingPage.axaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + To start tracking messages, copy the tracking script and paste it into the console of either the Discord app (Ctrl+Shift+I), or your browser with Discord open. + + + + + + + + + + + + + + diff --git a/app/Desktop/Main/Pages/TrackingPage.axaml.cs b/app/Desktop/Main/Pages/TrackingPage.axaml.cs new file mode 100644 index 0000000..26c3b73 --- /dev/null +++ b/app/Desktop/Main/Pages/TrackingPage.axaml.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; + +namespace DHT.Desktop.Main.Pages { + public class TrackingPage : UserControl { + private bool isCopyingScript; + + public TrackingPage() { + InitializeComponent(); + } + + private void InitializeComponent() { + AvaloniaXamlLoader.Load(this); + } + + public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) { + if (DataContext is TrackingPageModel model) { + var button = this.FindControl + + + + + Filter by Date + + + + + + + + + + diff --git a/app/Desktop/Main/Pages/ViewerPage.axaml.cs b/app/Desktop/Main/Pages/ViewerPage.axaml.cs new file mode 100644 index 0000000..ba937f7 --- /dev/null +++ b/app/Desktop/Main/Pages/ViewerPage.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace DHT.Desktop.Main.Pages { + public class ViewerPage : UserControl { + public ViewerPage() { + InitializeComponent(); + } + + private void InitializeComponent() { + AvaloniaXamlLoader.Load(this); + } + + public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) { + if (DataContext is ViewerPageModel model) { + model.StartDate = this.FindControl("StartDatePicker").SelectedDate; + model.EndDate = this.FindControl("EndDatePicker").SelectedDate; + } + } + } +} diff --git a/app/Desktop/Main/Pages/ViewerPageModel.cs b/app/Desktop/Main/Pages/ViewerPageModel.cs new file mode 100644 index 0000000..70ac9a3 --- /dev/null +++ b/app/Desktop/Main/Pages/ViewerPageModel.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using System.Web; +using Avalonia.Controls; +using DHT.Desktop.Models; +using DHT.Desktop.Resources; +using DHT.Server.Data.Filters; +using DHT.Server.Database; +using DHT.Server.Database.Export; + +namespace DHT.Desktop.Main.Pages { + public class ViewerPageModel : BaseModel { + public string ExportedMessageText { get; private set; } = ""; + + private bool filterByDate = false; + + public bool FilterByDate { + get => filterByDate; + set => Change(ref filterByDate, value); + } + + private DateTime? startDate = null; + + public DateTime? StartDate { + get => startDate; + set => Change(ref startDate, value); + } + + private DateTime? endDate = null; + + public DateTime? EndDate { + get => endDate; + set => Change(ref endDate, value); + } + + private readonly Window window; + private readonly IDatabaseFile db; + + [Obsolete("Designer")] + public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {} + + public ViewerPageModel(Window window, IDatabaseFile db) { + this.window = window; + this.db = db; + + this.PropertyChanged += OnPropertyChanged; + this.db.Statistics.PropertyChanged += OnDbStatisticsChanged; + UpdateStatistics(); + } + + private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { + if (e.PropertyName is nameof(FilterByDate) or nameof(StartDate) or nameof(EndDate)) { + UpdateStatistics(); + } + } + + private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) { + if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) { + UpdateStatistics(); + } + } + + private MessageFilter CreateFilter() { + MessageFilter filter = new(); + + if (FilterByDate) { + filter.StartDate = StartDate; + filter.EndDate = EndDate; + } + + return filter; + } + + private void UpdateStatistics() { + ExportedMessageText = "Will export " + db.CountMessages(CreateFilter()) + " out of " + db.Statistics.TotalMessages + " message(s)."; + OnPropertyChanged(nameof(ExportedMessageText)); + } + + private async Task GenerateViewerContents() { + string json = ViewerJsonExport.Generate(db, CreateFilter()); + + string index = await ResourceLoader.ReadTextAsync("Viewer/index.html"); + string viewer = index.Replace("/*[JS]*/", await ResourceLoader.ReadJoinedAsync("Viewer/scripts/", '\n')) + .Replace("/*[CSS]*/", await ResourceLoader.ReadJoinedAsync("Viewer/styles/", '\n')) + .Replace("/*[ARCHIVE]*/", HttpUtility.JavaScriptStringEncode(json)); + return viewer; + } + + public async void OnClickOpenViewer() { + string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker"); + string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd"); + string fullPath = Path.Combine(rootPath, filenameBase + ".html"); + int counter = 0; + + while (File.Exists(fullPath)) { + fullPath = Path.Combine(rootPath, filenameBase + "-" + (++counter) + ".html"); + } + + Directory.CreateDirectory(rootPath); + await File.WriteAllTextAsync(fullPath, await GenerateViewerContents()); + + Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true }); + } + + public async void OnClickSaveViewer() { + var dialog = new SaveFileDialog { + Title = "Save Viewer", + InitialFileName = "archive.html", + Directory = Path.GetDirectoryName(db.Path), + Filters = new List { + new() { + Name = "Discord History Viewer", + Extensions = { "html" } + } + } + }.ShowAsync(window); + + string path = await dialog; + if (!string.IsNullOrEmpty(path)) { + await File.WriteAllTextAsync(path, await GenerateViewerContents()); + } + } + } +} diff --git a/app/Desktop/Main/WelcomeScreen.axaml b/app/Desktop/Main/WelcomeScreen.axaml new file mode 100644 index 0000000..48615e4 --- /dev/null +++ b/app/Desktop/Main/WelcomeScreen.axaml @@ -0,0 +1,40 @@ + + + + + + + + #546A9F + + + + + + + + + + + + + + + + + + + diff --git a/app/Desktop/Main/WelcomeScreen.axaml.cs b/app/Desktop/Main/WelcomeScreen.axaml.cs new file mode 100644 index 0000000..014d1a1 --- /dev/null +++ b/app/Desktop/Main/WelcomeScreen.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace DHT.Desktop.Main { + public class WelcomeScreen : UserControl { + public WelcomeScreen() { + InitializeComponent(); + } + + private void InitializeComponent() { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/app/Desktop/Main/WelcomeScreenModel.cs b/app/Desktop/Main/WelcomeScreenModel.cs new file mode 100644 index 0000000..67c7abb --- /dev/null +++ b/app/Desktop/Main/WelcomeScreenModel.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Controls; +using DHT.Desktop.Dialogs; +using DHT.Desktop.Models; +using DHT.Server.Database; +using DHT.Server.Database.Exceptions; +using DHT.Server.Database.Sqlite; +using DHT.Server.Logging; + +namespace DHT.Desktop.Main { + public class WelcomeScreenModel : BaseModel { + public string Version => Program.Version; + + public IDatabaseFile? Db { get; private set; } + public bool HasDatabase => Db != null; + + private readonly Window window; + + private string? dbFilePath; + + [Obsolete("Designer")] + public WelcomeScreenModel() : this(null!) {} + + public WelcomeScreenModel(Window window) { + this.window = window; + } + + public async void OpenOrCreateDatabase() { + var dialog = new SaveFileDialog { + Title = "Open or Create Database File", + InitialFileName = "archive.dht", + Directory = Path.GetDirectoryName(dbFilePath), + Filters = new List { + new() { + Name = "Discord History Tracker Database", + Extensions = { "dht" } + } + } + }.ShowAsync(window); + + string path = await dialog; + if (!string.IsNullOrWhiteSpace(path)) { + await OpenOrCreateDatabaseFromPath(path); + } + } + + public async Task OpenOrCreateDatabaseFromPath(string path) { + if (Db != null) { + Db = null; + } + + dbFilePath = path; + + try { + Db = await SqliteDatabaseFile.OpenOrCreate(path, CheckCanUpgradeDatabase); + } catch (InvalidDatabaseVersionException ex) { + await Dialog.ShowOk(window, "Database Error", "This database appears to be corrupted (invalid version: " + ex.Version + ")."); + } catch (DatabaseTooNewException ex) { + await Dialog.ShowOk(window, "Database Error", "This database was opened in a newer version of DHT (database version " + ex.DatabaseVersion + ", app version " + ex.CurrentVersion + ")."); + } catch (Exception ex) { + Log.Error(ex); + await Dialog.ShowOk(window, "Database Error", ex.Message); + } + + OnPropertyChanged(nameof(Db)); + OnPropertyChanged(nameof(HasDatabase)); + } + + private async Task CheckCanUpgradeDatabase() { + return DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT. Do you want to proceed?"); + } + + public void CloseDatabase() { + Db = null; + + OnPropertyChanged(nameof(Db)); + OnPropertyChanged(nameof(HasDatabase)); + } + + public async void ShowAboutDialog() { + await new AboutWindow() { DataContext = new AboutWindowModel() }.ShowDialog(this.window); + } + + public void Exit() { + window.Close(); + } + } +} diff --git a/app/Desktop/Models/BaseModel.cs b/app/Desktop/Models/BaseModel.cs new file mode 100644 index 0000000..d1bb0e2 --- /dev/null +++ b/app/Desktop/Models/BaseModel.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; + +namespace DHT.Desktop.Models { + public abstract class BaseModel : INotifyPropertyChanged { + public event PropertyChangedEventHandler? PropertyChanged; + + [NotifyPropertyChangedInvocator] + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + protected void Change(ref T field, T newValue, [CallerMemberName] string? propertyName = null) { + if (!EqualityComparer.Default.Equals(field, newValue)) { + field = newValue; + OnPropertyChanged(propertyName); + } + } + } +} diff --git a/app/Desktop/Program.cs b/app/Desktop/Program.cs new file mode 100644 index 0000000..6304d35 --- /dev/null +++ b/app/Desktop/Program.cs @@ -0,0 +1,31 @@ +using System.Globalization; +using System.Reflection; +using Avalonia; + +namespace DHT.Desktop { + internal static class Program { + public static string Version { get; } + + static Program() { + Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? ""; + while (Version.EndsWith(".0")) { + Version = Version[..^2]; + } + + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; + CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; + CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; + } + + public static void Main(string[] args) { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + private static AppBuilder BuildAvaloniaApp() { + return AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); + } + } +} diff --git a/app/Desktop/Resources/ResourceLoader.cs b/app/Desktop/Resources/ResourceLoader.cs new file mode 100644 index 0000000..5332d27 --- /dev/null +++ b/app/Desktop/Resources/ResourceLoader.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace DHT.Desktop.Resources { + public static class ResourceLoader { + private static Stream GetEmbeddedStream(string filename) { + Stream? stream = null; + Assembly assembly = Assembly.GetExecutingAssembly(); + foreach (var embeddedName in assembly.GetManifestResourceNames()) { + if (embeddedName.Replace('\\', '/') == filename) { + stream = assembly.GetManifestResourceStream(embeddedName); + break; + } + } + + return stream ?? throw new ArgumentException("Missing embedded resource: " + filename); + } + + private static async Task ReadTextAsync(Stream stream) { + using var reader = new StreamReader(stream, Encoding.UTF8); + return await reader.ReadToEndAsync(); + } + + public static async Task ReadTextAsync(string filename) { + return await ReadTextAsync(GetEmbeddedStream(filename)); + } + + public static async Task ReadJoinedAsync(string path, char separator) { + StringBuilder joined = new(); + + Assembly assembly = Assembly.GetExecutingAssembly(); + foreach (var embeddedName in assembly.GetManifestResourceNames()) { + if (embeddedName.Replace('\\', '/').StartsWith(path)) { + joined.Append(await ReadTextAsync(assembly.GetManifestResourceStream(embeddedName)!)).Append(separator); + } + } + + return joined.ToString(0, Math.Max(0, joined.Length - 1)); + } + } +} diff --git a/app/Desktop/Resources/icon.ico b/app/Desktop/Resources/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..9456b10f9bb4b84f30dbd3b68a60f89760e605b6 GIT binary patch literal 33834 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 J>e7a`{{uuDq4fX& literal 0 HcmV?d00001 diff --git a/app/DiscordHistoryTracker.sln b/app/DiscordHistoryTracker.sln new file mode 100644 index 0000000..2a6f11d --- /dev/null +++ b/app/DiscordHistoryTracker.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Desktop", "Desktop\Desktop.csproj", "{6E7E573B-B13E-4F45-B3E3-1BE722DCAACD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server", "Server\Server.csproj", "{7F94B470-B06F-43C0-9655-6592A9AE2D92}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6E7E573B-B13E-4F45-B3E3-1BE722DCAACD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E7E573B-B13E-4F45-B3E3-1BE722DCAACD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E7E573B-B13E-4F45-B3E3-1BE722DCAACD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E7E573B-B13E-4F45-B3E3-1BE722DCAACD}.Release|Any CPU.Build.0 = Release|Any CPU + {7F94B470-B06F-43C0-9655-6592A9AE2D92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F94B470-B06F-43C0-9655-6592A9AE2D92}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F94B470-B06F-43C0-9655-6592A9AE2D92}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F94B470-B06F-43C0-9655-6592A9AE2D92}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/app/Resources/Icons/16.png b/app/Resources/Icons/16.png new file mode 100644 index 0000000000000000000000000000000000000000..3578edd99d4ae256d2cf96b53d5f6112ac56b1cf GIT binary patch literal 1049 zcmV+!1m^pRP)!eTsSu$g(@ zn^804_1=?Yt8AwzvGHrcU%*ybD@CMar>uz$({rAgnn~u~?meITz2}_HJr_JmQcYPE zy^u=VW;hh|%|+&Y)h|?0Ple2`sA?HgGh+%R$LBp|uD$iUxk~%}JL;A+ONFu;;bGM< zZQ*g@$u-+>c}KWeO+=T3&xE^8DNy*%)nhq+bvd5nZ7mOJ#wN%eD81kG%FIT%5JcZ z$ue;g*mR4!D%mx$y6BqBBLZnfu!!s*RZz_o#NlH_yZ!?XUCW5)P?{6010qNS#tmY3ljhU3ljkVnw%H_00KQpL_t(I zjg^znOH@%D#XtAH_ng-x{-7qa7&E~!G7L>HYL%Nnq9~Dss8uaov~e?kgPU2lFrt+d zl+mJPSx^fF9Y~FIBrzp*8XKF&#^=nudt1C2wJD*_?tbt0oO`&OBPc=tKuiF~fHOdQ zVLT2@0G}h_klwv7O2~(Rdq8)oZ~?m39MY4MHG=fshg*JcG)0QOZSY7p)aingN6uJW$hk zUEH|)tm$M^aOUd8I`TQi)a)9sM_1|TI?kK1INws5o0o&kEhIVHc4XVa%XiC+M(3NP z?XPOLeHzEfGxU6#mJKRw8s6RC=J1C9z2?4ZZSheRc)(j zmDz1*aJF2Q;~8GOiqUz#9)PY34W!arR9EaHvzaHA&Z4v?`9t9^_u;ecR`a*75dt9u z%Jq(`i8RUeO>(Y_5E3c9b!k{g!$e9$%Usrp0@yx-+JKKsosCR>Tmc|D^_92daW;Nt zF$@c7Se}41y@#|Kmzi{8!r21bHo0}Jg{j#DWAE2lT1lgX58GeG!cq!^MVW6ubMr~8 zG8-Wb(0W4D96e=q-+uT+sr(hK)s9uM^O9ByAteA>DbK!P4NGbCnDH;uIon@(2RQei zLF$WyLs|k9vfW#__!n|-u@AC1Eo8eHuP}SzDPDv}iUU!)QK)krf3NdmX`O!oWx(7& TV$R{a00000NkvXXu0mjfL-*j; literal 0 HcmV?d00001 diff --git a/app/Resources/Icons/24.png b/app/Resources/Icons/24.png new file mode 100644 index 0000000000000000000000000000000000000000..ce111748cb94ceee70c323a043c373cd8b6e50c2 GIT binary patch literal 1467 zcmV;s1w{IZP)!eTsSu$g(@ zn^804_1=?Yt8AwzvGHrcU%*ybD@CMar>uz$({rAgnn~u~?meITz2}_HJr_JmQcYPE zy^u=VW;hh|%|+&Y)h|?0Ple2`sA?HgGh+%R$LBp|uD$iUxk~%}JL;A+ONFu;;bGM< zZQ*g@$u-+>c}KWeO+=T3&xE^8DNy*%)nhq+bvd5nZ7mOJ#wN%eD81kG%FIT%5JcZ z$ue;g*mR4!D%mx$y6BqBBLZnfu!!s*RZz_o#NlH_yZ!?XUCW5)P?{6010qNS#tmY3ljhU3ljkVnw%H_00ZAiL_t(Y ziM5trh}2aS$3N%Z`}@t#?5_Jq$lMfj!O6(Kx&0xKp45lxB?*IwdC3%*Z{Q8QkekG04IRML%D1;Z-Tyo z;kCeLz}kC7nBW|+eJGbbKPN%oz_0=Ofp-BjPv>>Sz&_x>P%c}o3)L*8e&Bk@~7hgM)ZeO_UYiFY_Yh#?P;+@6g!F!yyc<*ZF-s7Fa zx)^6;tgT|JQEk~6=c?K}`&?^#*RhdbzppBQN3$Dy#TC{xnF38ZkE@JPo+=QPZxcmD zjL#FQNvd%fYa?t_pgFjWjV4H&JZW7>_6gFafMIgYidCC;0m=Y4XE#5&s)g5IUfnq7 z_0c?^etCgwqZMA=x`M8US{OWf8Kn%%9){e`XF2rokM!=?cvp^oIQbi29KW>*7&LtY z!!lJIU+2BUdDwH{I2~;nw!ZKLy>C8E-+SLclAcO=60HNQ4FMRNsIceYcQ|L+-@k>g zzP-fH=Wh^IL$q#Px9E{oD4-jB8{Q+{b8WQ9#VdJ^96d!#I^>!4UDTedr&7vOp338@ zwKo;Vmf!!ZaDB8`BOIUNk6Tr4S5hbww7;_B*fPWP^9R6~kRZ%ZC`OEpmsq@L0RT&v zbTYVi3jj?)z0d@0!bT~IQfQMx>ky?3Iu&fVtKJ1cL?KF0B3N5X&(u`RrOP(~=xocd zurq^*pp?cb-LO_lBT6G8h$wHUHLeD%jgVN=xj@m;k>SS8N!r`ejQusqC!e21>wuNr z?ewf&f{1Wew-G_6MW+=o0^~vPA}n3p%wx+lytTcDVzJE0v)3Eisisy^=>=#L)^mbV zwKeTu)k(*Jt14-^=R^c0uz&X!cE9~RnRLkDM?c{ujZlj;)zm_mYDVdxabMbmXcbQ1 zfi|^MAZLbhSuX%P_nz7lsOZB~P{lb*6kAFagVrIU6q%GmYoQdSP@?g!Ol!tdD1y>y zymz#wBT8k5lV(iNgoxx0^{+f=0Ipm-ap>{&FKsKu)z#j65J+b= zKBN>oOsZB8cuy$~L4#2S5jiKK4#WS~?0SZB*$XhET_@dd-vc@ac)#xa4uS*+fOnE8 z?iX=lJ1|rDUyjT-W=1|IK|)d=`kSZ?{f=2G<4h0zBlk7*|A9<^<=pvgxjLV3%il#p Vf-3JH9=HGi002ovPDHLkV1m;hy#fFL literal 0 HcmV?d00001 diff --git a/app/Resources/Icons/256.png b/app/Resources/Icons/256.png new file mode 100644 index 0000000000000000000000000000000000000000..7be71398366ee6633ca16a60913bea8773444e8f GIT binary patch literal 15660 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 b$?EzCJ3b`#*ih>j00000NkvXXu0mjfsDF`j literal 0 HcmV?d00001 diff --git a/app/Resources/Icons/48.png b/app/Resources/Icons/48.png new file mode 100644 index 0000000000000000000000000000000000000000..448f7cfb033fc0ab7ef3e96b10200fb0b9eb4fd8 GIT binary patch literal 2695 zcmV;23V8L2P)!eTsSu$g(@ zn^804_1=?Yt8AwzvGHrcU%*ybD@CMar>uz$({rAgnn~u~?meITz2}_HJr_JmQcYPE zy^u=VW;hh|%|+&Y)h|?0Ple2`sA?HgGh+%R$LBp|uD$iUxk~%}JL;A+ONFu;;bGM< zZQ*g@$u-+>c}KWeO+=T3&xE^8DNy*%)nhq+bvd5nZ7mOJ#wN%eD81kG%FIT%5JcZ z$ue;g*mR4!D%mx$y6BqBBLZnfu!!s*RZz_o#NlH_yZ!?XUCW5)P?{6010qNS#tmY3ljhU3ljkVnw%H_00^r|L_t(& zf#sTOj9pa~$A5e8bIyItv(txt0v5O+MGP+s#u7tlAOvJ^X(L||~JxE1(5ux%;zJK!3nnl)RJ;}VF%T`mu*Dz-ZY$(-zgURyI}#WiDh9y4!0qqS zu*IT)pH_B0p?L`m4i$TW-vQSyqhX7|!@%bX`P{@@0&@~bdj3zGKI5x+7`T3}kV@l6GQ|-gwg>b4rRyLxF$eE7@w#xaWYE~rqM=ug0@U1oM2_2 zLB#Q&qcdo&S+_df(2g_>h#x$a{Au1e8l%D$+pga6&C84beCR1aY6P^>JFG1emnQkr z%@^^3)jh2P4ZxdYQ#|z7BK!6qA>B2A2~r@!#?^*9c3uI%-hD-OKk_C@8ue$(%`+oX3*-XgJTdwHXPA7cl{>M2w9utJ=8-N3V)E2QIH;Onawmh$rHLJS$ z!e=kx>tESQq$Y?S+FT>_DR&wb_HlY=qw|Nz}d}a$jeBd9LAi_zb6T4T$z%$6o<1g2+Z*}LxrQkh;-=^nx;OBnU~b*D68D!UyR zu1=vd8i%P7L6|0$=_T7eKv&NyUK@$o{pV+E<)7MqzMn$kn(8^5c(O{2O`c{bOpqc< zcazTakzM;u2ZT|EFx5pW(@Q$r z&!bO{aI)mr%DT0^SR1zx5aM{6+0qoHnG=*wjuV$A+d9XXfFMXEz3Yu@B48rIFzw63 z490}$x?@-mbvHUE0i_gL8;pt2+JFdgkYV)LBxh|JU}b+7QK*}O(ZwI$z#S{P+H`GP z+t;F7``*;r>((uqjnXJ(ywSeb8%q~PAklbux=$v*J2`T zR`sxERcANcL32IYwx(vP9?4aE+O{6%Pp9W@QYlPOuNs%<-87w2R7c=23po}M@6)X< zvwEQ0zjDHnqhk#8Zm5Yp@$|ph^~Zx~6QYzK87CD!dG!Tcv+dmFvi)!?f%z==K}0Oh zRtR;OwW~aVW5=hkR_aL`FgaObSnSNZOb)D}j-_qcb7C`CD* z<`Y}j6GpygUmqDqnXqL6Dx!2ZsZ1YHDvOAqP0&Pg8IA;-2ppPA%G#K6JX58^S?dCB zytY2uc0c&64=yds$(WEJOc8_`K(MyFeCL=RZ$O>EBmSRXpfw|u=9+EiqLd+%2|4@B zm3;K#jReN`{{Dl9ICOZLFv_AEU0l^;CGHOq?aXp$290xNoO@pt7e0hZkI{$ zTp^!(twsOZIM=Ik3me%!1tWcOB34BVS#7Fcd z`;^9*&<8W6(OP?%1_UNT>!3A&fjE3Z^$$p$AF==afldLTy~7SsYs=J2%=2u6ui8U&pq8V0$Q7=#qqZwh(3PgxKJ*aaMm{DI0~)% z>Fy0MLFiM9AVe#_!?J1xuOE$Zu7cJPVHlxJP+L>RgiM!vm^k9|CS&>uf>hGO3bY~9 zJ;3XuvsepC1w<*|hH9Qdj{yhUMj#o5D}X1Rt02zrVXK=Eg-Y@f<#TjxYI|Nm5a)1W z{fH@rR!RPnG$G<@`(nSh)lD*z#2QJ`a8TY6BvY(m9c(S+a|b#S02sXI)I*jdQuhFf2D%tn*Th5JsqNef4z)43?jpHANi{Nou002ovPDHLkV1hq& B8l?aL literal 0 HcmV?d00001 diff --git a/app/Resources/Icons/icon.afdesign b/app/Resources/Icons/icon.afdesign new file mode 100644 index 0000000000000000000000000000000000000000..5f8d011f54391e2cc1056c5635d5bcf76a1c2fc0 GIT binary patch literal 65102 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

E92>9z6y|*<1jFW?1xZG@XlUM+aD@FW z>|Q>Yxug=|kAQ#IOjEUA8Wr3*gMOQ)@GM%ohxnJXGsq?ODd?R}NRntKLAV8BH*aL; zRC8G#cyA2N5#HRa?2sly#R`1=h}x}-C-sW9LjnIs((Tt&(cBLbRA3N_jC2G>j9-TN zsic6JP~2}kPk1KSG#SeC_kk(0W4l=mV>L9)tD01dK9cr?U-rQIG=`q5_YSC$=}=0~ ze|((0e3sQydinV*0Aw-o*I!~~O+i9J78^Ou?jNj#vG6=R~Gw3JW0Y*JPd}fFNEd9lRG(ZSqNa5ATPz5VE`o< z#uq33=25y;cw?^71VhTKZd=Vl@i*go8XXe}7;R7zlV{&_QX2Dn@cqs7WhJE}2sR@z zzd+E)ctdH7?v`Mz{7+&+6VG*5#%U`dFM&;O>GJfU#%Y%hiq)d3>ubX_N2cJ3c;&sp zD!;5_&HGqQ!?=kK}l9DPHERF;J?)LKixVtN0FdvwLV`rAcF7XHkacTXF(I?f4^k7rUGQ zi^1+lx*PHT(e&l?>^xx+(5A*P6Nr9}^3XpJ*lhA|aQHGF`h5!Pm!&!x&; zg0{rS4{RRC1TY=H?#Y$T+}*hk$2}O*^nqN69dj3wxD?UxJTQwl{?6VOymq6dvMJa7 zR|B*84X(K{;IGDoXN=3QzqQpQxILlEyg|WBG3}Qz=qh=Sek6-y(j%t!*DaE*?O;r< z(cP~j{sQ1D7hwhIbZhZD&zr7+R<0lNfLMRJNL5;O!>IO0&BrY{z$*2$cfgH6exM-u zeXhhswZMdD%)3vAE{#J`6Gb^il4pBVZiLIUgdzV+rU^(Q56dm4)Min+EP;^now|#c zwTSW1xt33o`{5KykP!*@D>s#*WM(w#_}YVzR6TWfKZl&A-PnEj=l1FDyRaZ9N$C*0 z`p(wntPQJKt86EUt<>!uD=DB8Ucq>cY-a!F<&O`dC(2)pITt9IUhwsl@$d4sK&S2{YCJ1us*_jt%hDt3#$ zZf$3FFXM)ru?TxLWwq?@2TFPx-4K2rs+bt+U7qdhk53g5FKQ;PVXWEp68`*-#-Wd2 zp5LDnM6p1%q=AcqHqSLaaOb5LzM(}WYcPqV|6$eGikE^B*OOS-S`3;HQ7=t4(ymqE zg|IL0ZKT&RyW&B+PN6Q--0}2IG4SSC3r9d-)TexI`0a9P?PZG_osadf6ep=ue{0g$ zRE?`Xzi&RgIFmkSvL)Evt#7qtL`M)tQ!gG*YpRH*{SXw-xOZKi>neyc>{GL1Cn-G` z5t2QLmHwkuT0mf!#zH*PTnVHr<%1&SyW!s|i3ji6pAOr9@vN{^u$Zo{b(?#2{q*PE zDEEro&kyg7e(i$Aw#H5jn9#u-9Js!$*_!h0n7iBaacTufd#&iJl}C;qPrqc@O&@uI zf*FV!{H7EWP3RrERSk!60;!F*B4xn!<7`h0CN9|wtQmCYms){5^Dp`{wTlS`NtW=z9qD`v1a|-d!TG`~dhbfQrlME$-3FgyV}YR%=e? zeF(QvfVIk2B{i`csKDF0l6|AP^N3W#_*dj}om(ju9TSd*$388A5}#oP{DiSa{%P(M zVKfbNTs4vQRhj;5&bfKj2i8`;@S>MnN&+E_iyOEel) z9-7R^oDYFeJ^=i$4Jg{S--jgpx?TgRrkpT$m~<{tl1lqT+2$GP>A-4qYL5FYoiCTgS!TITyRuct zYToN<^!DY>>U7fcshSE&H=5 z*dNnd={0^!b2Qvu5RIf>Tna)8z#xyJ0Gw8R1glQ-h*)iVaWs#Pxw5lWB=`~9H*{_` z*E)vCH4G8ZOHzQ+9yM5NWRASB_}wt=hn2Spyz)25xO!QZ=JJ&pSHZ~BjE&w-FBtY2 z2eEBAq@tiK?z+A5vdkJ%5ZG4N{KLFMwW&eF#5NklnK$#*G@@PlwID64y9{x`XV&v3 z3)6>C)ZF@>pUD!uAc_#YC2-Zq!YF29>`i@zz6=+&nR<(}H0}reHs*Nf+65b9ZI>zq zrFz)QJNJBUcobN6})6!xCr<(s+}ZNDvLMk>F^4rHl3+ABtN%b4DDhtF{VK*V|? z`v_)(!NFQn?a@>3nEIn$!$QH>Cs}(euYU(mmK?ONO8?nijRx$vF-KiuLS1m#HCX!Zy$v{|gM@ukFvgX)PM2A$ zJ!!c+s@1M4+H)UJM#!)Ol2Np13svU|@+_A@FS@v=)uiLl4lQ_GVvCaiW47ct`CH84&tGcudc1Su-LwsZ~8#FRp`VAD365s@WjY#enk0WFJ{UhP&X|Vw9#!x}A`0t29p!?7Y}%jG zp*>IU-6Rb>*qGsMW(T%SFkX{n?ta$tLn%ODRtwnXh!;c22$Spn8@XShFTNSKpzmX# z?o&>&!f<8qB^k1sNTbTrVxWGl*jv15;->KM7m0;*gOg(c4sgTXDq!2wXnSK@VQk+p z&X-p`bUkg>UR^vVL9D@?J`i@mb zyby~#X@j>eW4|lDm(xOE`1NQYGl}{7?@r#2>0j%=Wf}?Ucoqcmf)qJ|{SEU$ELdf{Br^m&kilol=kYNsitm)MWhu(DlHeyh@uw z;;rf3G=u*>pM`u-mH?=(wH=%>4pZ2cU)uBT8jka`otnPkLk@cJZZj{j_5I}i3I^Ge zy{BH(H;;XX#F{qCQf82^T+|--G=KZaj$(*{LMTc)#!fT^dV`s*bRR7 z1M$jr&DOyfF!%Y>qL~4`gl6s|nOu8v##rlx)|SWZ6UEoczR(i7AQkXAfy;me%iZTw z?l|*!A>qE(Npk1?HPZSWr}Z||j2oNEl#`R>9$TgsViy0= zk}M;GAuswSAk%9oB!mbDfP~h~Z!lYRu&coTS=M-m74%X~Vb%k1Fo{ZgJCz)`eBLza zCqg}``04ahwoTL(&Ec(KRIcXE&CCKj!2!tR>}(^DPz7Yf!yyyy%$IcSIJ8_KbDrKU zv?wiO*m5rEr70WbmCQl6>goPea#0z$^3Q7uJ97rz_E*T=?SP^+T8AhBjQXcYIL1c* za`ul#Hnx44j&9C4NV1Z|7hs0|@B?Y>-SrnRenKq2<9L5bq8~&)QFGXtnZkRQs&{3k z5wr*8HIuQi=m(C?RyXxb8kL)AF_yufsF|m{zba(bHo1}p5<%#`dJ}YRn)t|llFc!1 zg?exHgIgclu(2gjaMl1lyoUUF7QhU%y((^l#<@9!iHaq~;`!c#p}Z|U1lldKwkmX3 zTN-ENw?(7rV+bGiQ86r_r$A^XJ##?yOsJOqg=UPsX8n&wkauRL+FA9>D$N#es^$3A z3!d*X21RRWoviOG4g9hOR-P*q=3$t$LqcQ>ndWZBc5n}=y7C`5tjP_Y43%EDcFT)| zB{Gu>c)lxR)av*Ry-sDC`hj;RTy76u_Yk1 zWdW*11x*|~IC?6bl9i5jXtGRFOxjTFy}>VER6O$Rh~yb4L5sk>*iZt&Y0=@%4jh1I zncM3u?x@CH@kIFO7O$D9Oe_f3Dj^|WY05NRREDF10$&pu^fleydG~V}9b2!4O^_@i z6;`V$nW!L?j&~?h~x%wEwrX)>lxv?{jQB0B+&1t zU28OdoCY)WG!y%@Z0fb^IorrIzXwVZm~bV7P;OYA_4~v;u9qIFWBO6Fs^03o;7@io zHnwI}u!_*N26Tbr*u20<0uNT>l9lIYcJ#Uq7sidyAQCzcpaqJb7kJ|0NV$@w`BA)&Mw{x`^X#gA%Hx4yYHLDGmCWKhlz-be(x_l~Q?_fW81Tm^qggfxZlT zPB-BEN({V|sh%CzKcSGU7y2=WZmNcYww7worx?P^>JJ02SU+q30kt1~a5HAr_NI6P zaH3>fA1ELyo=}{-Fxvwbdw)Cs)$FR5Fq8G+;Zx_^?+00QrvZt#dFOW;uHOS3D5uGf zy3IAg$uaPLi6Y$Yhnx10wFN>;)$YhZzO+VI2q7w=l#cq8KPRk!kB05ee(!7meuU`> zieO#X!|p3BCKfmvSVfz+4?P}F(L1g3!YccOA%%^`im~g^ieZY}U{7G=F!BVMLW?n7 zOxFZ-FN04Nh~#?StIAbQ4HaVJBy_hCCMStzpdzrnG+6XW5Rw| zmS&>2!Ks`zVW?gquO=Site~>FLK*nDW$)_e zX&^|5(Ek9)F3)d`ea@bjmp4b8yF>xG&Y$N@(x7D-n9Ugxxq?FiJ>8_7tOvRQ9YpQ* zExU68Ch$Ec@QNdI?*d|hTfbd8&er?Br&eBN)nL}bpFiJq*vn-}shIELJ)W!j{lB2# z0K!TQ@LU;uwf*YpViPUTV$5>srT@_w5_kTKP;p?w z31OvS^*!xXPp(OV_o=^_5ue%opt1NEdjxpXV-!X`jRW|ymjEkg2bR7Wrk=9SDOfKAq2Dx? znKp3&|6&VAOB$xMY|@N*;tEk_DW?tr|0q^Z=L;o_O9CGb$sc(+5;#SwTFyE%l~cHw z$C7&9zY->Yz46^hRrGhzwJ~_!!wUqrUOZD67}NEY@kxwbZjr#rvtN};l3c1UE=Xux z9gq#v@nOU@nt^<0!zm<_3Fcgbm3jbx4+cjvXmj8qcQR3g?7{r%&q#z?$csAYMrc#5 z>+yI&55s98c!+Ltmh$fhHh>G{cNBaM!wtq0_|Gj0aE*VZODg5SX_1f|ajbJdE=iVJs`36^*y=}2dMI#t+#si$g`e&|Io=B(uJ z@8^NS1>YwylTXGxRY7qFl(Mo+;=k3lJ{>ODd~sS9uwa%HXq=e&likxxr2h3y?_jx3 zcgyj)?-LD4&tayf$@Z~HgZ7Y~=@J+u43=GxtJW~5p^Xyz>6h{H$SheF$U!0y22|Ic z`^vM}fcI(Itg!H?e4uC{Sr=&IzJ30=wa-4%WB8&^jo`&UKL@dQ*lFTct9WejQ*-M? z`j#0tFc-^a@c;^!{K|F;X=!Gp^q66R)xG%`M+2(<+54f-x|k|R*ca#b+z0&Lk&SwQ z&z$H{VL`}WU#sn0iM{v{$}EqV<{8f~!wg z3KtAEzc)>alZG|0dc&^CpCN@fJ_FY)w;mRMkBCq&V}Xt3gNx(!)Rb$)NFQN}+`Kj{T)gMqz48hpPD|(x#QfSg*3P zZFFTx?Rv#Q#Gr(jj%3w5a^~yvlURW~Gn!PMmi1h-bb1vzb;Qsy3XLREq4Z;e9<cBHnfynZX((*eby zo{}#b3&<$Dzmv-BqE!8JBj(}-U8tB?&%BUq(EC4d)0Q7eQLTET$GN!=lLCTial&9u z$g3J4jSkkv=|g{te8`SC@AQT711+49$gdU_z{m^~CCLM0dc_IE-Vp+}RXS9z(`pLH z@575Ib|tKJX2d!qG#8T|Iqt8=(u6jZewXJ{sRiooa&9|-IV`-J35iUos|Rd;ZB@*D zA?V-XI+kQD7S82qP z-;E3&S%***6E}fbqX{FKFM%K(S+*>PIMiMBZm%bb)KF|la5`vfTubeC^ccN=#yDw) zs94(w6{t((NP9b7zE#3CD8>wj-)|ll#y(F0;^CNnnJ%~B%120E`)@;6e3#xO)(K)A zK85r3N6UQBWy)(zfD^b>X)(jkTJ}u}iGjepcm#E}_v_{_H0l>+osYi=6>Wq`!-`Qb zAr46Lu7g5g9{?0h>OZ>Wdg`0i9Ncq7nS1l=jLu)6%m@~}^Cdyd*&aVyoHysowXj$i zg=2vo%b{e~-m8yIODB5_`)HN-Qfo8`MEI_dua}ytauS4jOdLu$#^opIi&&vw3%ZX; zTVoqiCQA;^E0LVJ@c)F=zO$;+LVQ&bK#1qk#R^U|id3)0PzL}qCGAeN*2IdUn<#0f zdL%sj3gR-+>C2_(+gta{0@3nR9Ui%U2k$N5hKIuAKK)3k68o*fOzdGQ7 zy$ovclw4QJ4%AL^m`vuc*Q;XMBz~-3TgiWP?WU*?Z5+Tv4KcjBz{mj+*C~>P+}a8k z{A!mD$HDdO`BS3M0KH(3EhQ{rPa&c6)*aJ1aK#_=gg8%^BOpjh>QWYb)I0iIZHyIm(R&nF8>4Z3s8u(Bg9!X(E}viT`Fyq#uWJ`%KB~; zX){HM9loGZ$6gZg%+o&yndGaWv=V@OLgiQjNx}JGWWhz4|M=v3JtTHL`WGoK4mt6f zL_K64J|861Ee!*!7D{oXQXm|Obc0|XYo|_C%xMeca4Fw9rN<=rUj~+AJaWF30GMfj zlLyhmHR@N^?&={rH127&1;upGXsCYaHc)9(la4qR5V+ZE`)~^4xcVq|5g6h`Ox0}W zFiJ3LU&h@;Kphh{O;MreVUm;{akI`6Tw=!~f;-+|MY5s)wOeXH4Cuj8xWL6Ya7PRx zJv}r-Nidm+lf7Z8~b)K&MR zo3h0JdWvQYiWIN^5rE=eJOJoNcqIS}TA+D^)<%Rx9!d&(Kp7r*N8|d^k3o$k223A> zdO)$d?f<+0pU@jeTiu*!cg9E)Vl7uBIrr#53K-e<3gAXVhRl^Qno(<5olaX*8FLUg z+?N9Qa8kIMGml#^+^yDUi_yidK{_o$UL2f}H9Ze!&$kL1OpMQUD4)~)p-DUU60Ikz zwG-c8^;~5&e*YgI?VK^@z7B6>8GY)SyCOegNaXqT%RIE`4J5n4QoTOO_>UICKnmi* zBd(*f1JgvQ{5&7L_^OAz6-u_B>;wqOytKfa%EO+w0a}D&q4J0bABWf^tL$5Vb0_`1 z@&>!sla$lV;BJ%bE5AsBd-&lo<=;uYv$dxstydwKkKO@tt;F}-e>J{^pynE2fC!dC z_2~m|$QYW?10{q%0#2Wvr@^!7ZZ)%2Yt2zXTV1YkDg>D=2X? zC^720sNS(4HP_| zGu~>n!|;B;xXx$<+3_>3l{I%YNFvFp7pZktho#7W?jfSF0Og_L?u^=iE{jP< zj+8{17HT!N=7(z!(n)7OghB$^bl5pkB+wb8h8xV)Mowp>parLJotdV*JPZ=lslak0 z@V}0o+XCIIYd$p5%2RX~`C&rc1yUkdZ;Eg4uNK@?z~&R(L}5&j;kdR@72c zJ8S;WPb8lZY2O_Tx4$@d8j@E#wT+ibB1=CbAH?=gx0PSsHAvyb2syr@z3UA%QX!TL z1xvFT><(H05!<9o%{Ii6_iy>v@iiZ3F!Q#L_}Thq_D zTa;-D?v}QW=UDCo{6w`y`DPE+Lo|fkQ0A%in3Cn|^70jUUQ9@0G27?C&_^uLkG7pmGRGxN}2@`nB~Ip38Bkh zkf(QgZPCwgq*GmqhId?Jp(Fv}4V>`i3XG8W9{`T>2tLqKXTq*NePjE8)MW`|1KtB+ zWRefvR6iD!BLAt8py*ypAC7%9DYO&(?Vf8ROu(v)#Fv;DC6=*HaYDNn%EAj&+R~_h z36DOWNXdg;umq;gl0J#t>qHg3eB2q^%w>0&zO~A>e8+VA@thFH2@8eyBO#U073UIS z_nE`)Y75l&+eHO)gu!2)S#e=3o;xa%pBBi5<3bztw8b6Hh_g4gm&0C2A1Q$@CXD|| zIF@T_)2qs(5){76>c-qXnDTORlttRqcc^!%-rxMXnVBF|eFA!Z6Zyi=#jmHIjtcJ> zX~y=UGNVM9fgb_}ZsvnmmaQ$fJCOH*2X9PR1YhEBsOzcf`vd-3kN(XdK3Kh2^O~YTKc|z|a z^QZD@GMq4dPt7i*E+Qg=4~+g?*MC5AL)TJAue$iP4-loYENT^JX@x6=PQ_mN-bblA z<;i)H6?>led)Eo;|02#1QsnME{;kA6lq_1Ga}%_|pzr$Y5oo!9yqX<3@-=D2!29kS z;IW>ol)0Kkx<|%~$t#|?Z{G`}htdSspRsug#K$OSI-fK>gQguB#0!4B7iVn9_VHl_ zkfH1q2Pe>qIb1#h(~mtTGWQz7bpAg++2nlamd2oWfNE!mxmD?G;&DLS)oFU% z4PFod|Fd!Bjs%8SIy6k#H*Idn@1^*!rM=-2UQb7#J+7Y4xA9Zlj6nT&CzM{r+qNxlLHJE;Lee!XC8!J!j zC}8fUF<6M<yfCZ#wWQr$o9r9`IG9pnv*CHI(Or-s zvCFu2%&D&6e$e)vj~!4=d&peQRIKk~k8e|DcI@vNob0Gh^fj>)M@LGHRUE)Hgx&5+ zr8a2>TPsHjW#PaXUN_lQo9hO(!8dP4{AuPq*1G_vOpV)kK`o;qfpp0`+~YYOC3zWp zL0)X%kw3uFC&XMVat>-x+x{Q0F9l5fx>66g=g{3%XSV4~N^XhyK?~Aw2!m+wqvUX^ z1uDihn|P}_T9wjlgTm1W(qkAMCtuk~}chaC17X3qGFu{p^+$h96@A41Gc7 zX?k8fY82>q0@s>xO>EfFau+S&m&5dj8G^fmNQda=Fr>&=TdFtdZy~8V!_K9-&nE1awMEle**Zba! zpSqZ*&-C-3=)bZwYi5JmZ)t{j{_S<($CbBgaboDasye@_A{MH;@0Gye4wP1NCv}OHl?wrk$3d()6COVz|oJ203bUlckKf6 zNt;(6@rMaY3c_!uaWKB_XxsIEQ2XThTWiiGFUgZ&ew;g&eEmNQpk)ReY2ER;%d?Y` zXCE(*ZOx{TLDPk=6bx6ZJIXyvf%-ub{H9S4S(x|T(@_>#63Z#zYon3z*86=PqjSb6 z5&1W}HSf#MmqT^R`+K)QQ37TGU|LM10C%{|i%*Pgw>W^Hc@5G@xjTG}K#3}u;)i2= z`~5W3FZ8PJ;eA%ZuJCjv&r&AH<$ivPi-y%gqYmb6$Z;AYXQa&rJ8oKpZ!!{|IN?H6 zl8Zh8kM%3}ugEe$X!BEehlG~r^9M3>8@k6I)5RI(N$5aG9T_MUSj~7xns|gRO zw63%B6a696BF+E!mbXZIY_{q*cMkU0@F8DXHHajJT#auVfn?5&BS!C^_+KO>(gRQK z^ySqh-cDP~hNE9%N1ExgJQI9yUYBMdO`1uMhkaQ69}F1Ssz*QNkPUh9w_Dntoh^aw zYRhsNwtQu>4gpZMlJVZ$nLtK<$TD-$;B;y;^Mv|V%rF?a1y1|~#au~52mPnKP@hFU zC{A`{sa(4QFF>J7$78+&DR=l#gCuF#@BkE)*_VaQrDkFiZLXPq6eNe&> z_6_Mv-T89bu2KWXP|i_yLQ!-;D-B@<8$n42|2FH)$x{p|Z0iLgmmPtbs1;d$qU6dvf`0uLi%;Mek{akx+O)T90(p1aTGPD*lSQ zqy=KE+hBz$lf@EtqS7sG>$TlC%6tCzQ_kPIk8FPo^FOj({=TKrAr4G}zQJisC78P& zCa!WS9b$*0!^jAA@)N~o{)MCC-=W_hJ0iD~w*{KiH>0*`$((H2G~%=Bf^^+jb_kA& z!V((ji*KrX^t-*anj}~?<8w(EXs8U4Sg#>P9qT6H3?Y6d z8OChy6ztT?LHRnODT^o9Q}lTE;WI30nZfq&D(Gju^Pkq;lpMO>&<8FFnxiiN@{<&$hD*RB%xRe`~b$^`Yb&vKr0bwA>a9?Z3SVt;`Q8VbDiQ(JW6tH)8AmZ zTknp2HQGm|ABTL&ohR+lLlaJ34~ zR;%j#zRSYjNMC6wQ{bqS^uW2yv2SMo#5|q^8z2z2-Df4YpFc<3HYN?g(G$8kE`9o4 zFY)w;@sWVw52wvp1PiOMer3OGKJaznVw~V|&dLQwwMDkO0RQhn z8c54=$IlOH#ja!it(F4L@ptNNO#Op&)IRa2@@j+f|IWe8;U?MFi%7(im%He9A4fJ# ze)v{3c=nd&dz#Msn%s@mhn21EsRw7$_vjUQRd1P;<-!aTDyY%xe+`0;b>i;1rM)(Ft11EN2i!GZ= z=TjxbRDQAV>`6VI5_L8W1a+Gcb~`gxk(#z5k^WT$^;wghlL3Z}y^~?Oquf<+GYX8^ zy*K;P*W+781xj47V9a+f(#(cQZm-wD!_>-3*6_5APtErNT2pUb4Iaj`K2qD6+*N9! z#W2Ibz|xD!hGW8+5kMz&4h<87zJgxTZ&CDZi5M~H5xc1VpR#LaIs20kw*nEJr9qwd z7k2J_oxUkvOq205YW!#BmecS*|4&u}@!P5b$OP40`{&^JXAZ35**k%y#vL57u5P}Z z!1Gq+xhQaal4{YA+)1*v1;@sD9{9kn(sjsBW%s~pn2KEQ{x(@RUotemozdKB?7969 zDyg=A?^7&!SIP=dS*`&Y_9JJGe1A`KNwCkUDeYp^Ux>P^O{ z3q(K9IA{N3nO{Z+PT7->-odO0(qI2S5vL`!Sk+vN3P`g~gc1EbnvQmA+8*q1im6qw zcD%$Z`Y#}Bfpg-h)A^}K__AyK@<(P4VEiCy^fqmLPWnd;1|T4%dU?%>Niq#Awj*{l zcco~7!U~`SBREoTE6@a}EzgTOoBa3Bp8o%JmPuFe_evEtI5YAdF-+kXIrUp^e%ZdO zp9a}I`gCb`amQ-+Uf9*F5%Vhi>p6e*&gp5kqg*XVa)^zC**($)zc>gnfZ!ZiJM3(g z_-+^kHvgooe;QMggxa<8&?LYkJ9(*^{Ih1k0f==#zO(@tMvCoOzLDGS{Q}2$1QAU0 zWkOvHaEg{-g!jLW=;NXV)UJnXWpu7+*OlCT`wyybwah>P^=XsJ@!G>Wm(p<_G!vAm zHUKn->te6BTJbc~qQ$o;|FxvOXMc2C_33QLNzr2QDei-p=$R+ZivD0@N zQ{%b4c?Jas*L)1YT2^~xY?kZqZ_r@cIG3bXCGzq}R>OE7Es!*a3W~jq_>^yfqbXw; z)t{0M;+kXElV|f@98pad=Ky%>lH|<4%&Tuf{oaD`2@dqXR|q?Lt2^muUbsB|M~y+Y ze3g+hhyXvo3QcD|lVtkow=4yn%1KK1dsq`@w&=X^-Q)~W zOPE^}JLYc9#8UOW_2t35p+yT?P}@Y`qXn=h!^nOl`TX7h(8@yQF-Kqm2yFfld5m6= zig$sZ>^L{5k`Bsi*po-HD5c{;TseAt=n%KXXavSc3T3~m{tLdKo(o*iT;SI)e?%MzZ=Vozypmf)rf^LVY291_5tanAk7u{{pB}g!= zg-CMZ4@F$DFYRJ^tEyk}e)w%N%|@dxk|67Bsrq%+zQGTI1%=NC^Jncp{7fKOTMiF^34EGE!p5TT;GJPzb2r+ye=-GAG{Js!sfBrg2%ey;w z+Q`lpPgY9R*w%xfdV!=nPk!Xn(5j16D?0P6AJxg7du$(9`%{oDAaf5Z4_dH79l11+ zvmOnYf3#-!Im#%EFOZRbrHrelGFtYp)Toc+V~rUj^{PO9KVC7}435vU*pH?Ggvnl{ zayWpx^77HKkTyFp{Ih#Rv~vjtBth0il`B!FeTx>n!K@o;yfvc; zEhDm#zxTw6=eKg^>QyZSmr+`{G2vUy6Ahgvv*XVp*NccG!RS-ACB|kcgpBUx zb|pZ49tK5^#PqaG2K>E$4x&N6xo&4(t_B+=P@dk|Z|)l2n+P}yern@TIR*B82tFpt z>~w45eoP_V$obb~lbLaseSI+zZ`}mmnM~|W2JNa6g8R1X_pXvM;zJLDuqQ2bJdcGg zU-?M_$#2E`A#K;CfP9t5XHtjRD;#jAa79X!;e3$qHHk@&=36;B1GQHtzBO#m$e9Bh1yIQO}YwQcN&k2S|o@$delef9`G;&+o;=RNwi! z)xx8kD_Fg^@>%d}`m?XTTMh{uSG(Ea&I?x=V)aS! zl2o{IuBAu7l`>}8iY2cXLn>vFElkN5IpSMki#|Th$kfxaD}}`a8$tSotp;+hg%3zd z+^U@mTmEVXXF|$-8(8rH4~X(@I&^Y}b%;fIZa`~2fdlue@nHBY?95a2^kh=w4H!~- zldh^4yqyY)L(7+|Mg~U!rQ=(!5CoAF-PJWs8e8xxL1XGUsBiEj_R9Y{|A38s+WqVg z*#aNWOk?v!ve#5k+(Kk^|BZv=AiJGTP^w;j;Jufg>EE$BgO7Q#A0Fb_R>6L3;NsL) zwR(I{<#LOeK%IK#qb49f3Gd(ctFd#hkCJiFO-;RYXYwdI=@1)Yx%jX$Ty~e@eMwsG zAvvZR2;2=we3FFjUHxVWbP(&+4@w}TE*7T&gUeoZ%L|P+nIbjrzj9y7fp=iQy> z({fuB-5U2)ZI)CV8I(RJYFtbMEfmgH9=*0tM)w?Zorzyi=-9odXOe+;8P3}U-$tlmOT6Mg~X8F=9$0DW6(Qsc3$=T+GvCPAeAL#W}%!;+d|}Mob^yC^YlV; z=H{Y(N<>JPvG>o=8NQ60oIqIpY!f&^e;YNt%^(=$%)A7NFw{i@^xcj8 z1Z+rfYxyikV4$U4gS-2XEw6~-^TyqW`sYT4-plI`vTB-LFXZc^^mCv{L z4kCX4IB36!kvvR1!u5zw-!_ez(cKad?PoG70gwLj>pb%2b_z-*OYBM>KEqR`?R@{B z7iUd#AQlYq9%`d?U%}7OajW;Un0k9SdsIyOTa?xYKAoBCZl}SA+Rh>IEpxNvMe3V_ zyiW#ZXZ=bVloxKq+0vJsr4W6Wa;t!fLu3M3#G_aLmp!bY|B=LGF}^fKjl8&upY>r2 zHrB0GS&HsWen4YY&IEn&-T5snOR5VZL2&NH>Jx8#RlGQGrVQErd~oKN9P$-P=+z&& z_7=ckLOEy@BL@*x96QIIhUbUP@%!7Ce@JV!DxgHK{+P%c_Gc+78@4FrRZ}b7Id%*^ z#}h3UCK*);gTup=(tV}Zz6T5&Ri8@EuQrmXHPIGKl)N4nDF`!DM83=t7g*+{-v9Pv zihPi$ihbDvH$Byvj9Z%O;+)LrXPBPjIIiwQi{Ya-yQKZ;?{|W}IREN>b#?n9&3)8u zeBmyGOWAGwAj3uDB&^!nAT-U$7IX4z5aLafJpm_#)1otKx49K@^RzhX4VdQbZI~vE zxkDe|o;0P)toiYJQVd%%SF)`c_P37R`g~-aQkquv^X%~O#Eb2t>aO-h9xxhi@G@76 z>MzGr+kS9GbM>TPW6#qg$vs}Vs6w)}WsfJWms(KM@N(E%jo>yzfYugG@9Da#G4q0# z-1Lg70dM>iuf{8jujjVxLL*V8y?zobl8=Kf4&9L(9NEL>RsyF2eFk&ut;&Cn*juN1 zg~+jaI^`X*E90qiub=?NNg8TTy#mkYZ7;3&%24#eH67hniR*eawP+J6&*e}X>LkGNnREWTO@|tSo=f$wDbEJIMa5?kWv&sj0xZx z-6O>Abw#UPuNY!OY80gmejj$*eZ@I+cBFFbmLUISJzrmO-axVhbI+6N#YSNB@U65v z?;a$|G^}!KyIAS`ZDjtrB@qq>-k2Owr>QPW;&{RxZbqc`!-4-*4@GbsQpXZ_V~2bf zo{lMc%8^u6bGF>cW5p(XI%9LD`fOxmYPNc0iM;RLbVS_TN;*bjg9&)jnILI%7P(W0 z*H{2!eT;y=%Ns)aj3qNKA-`=<`DB+UH{GU!%n>PNdEiaR4c@#BL4pT?YGYiV--vVujpPD18}bwf1*6Mx4an%-6+*bSz;_ z#jd~Gop!f!SCo2Vqj>yF_xSOvf81GH;@>)tYA@qf9_$1jdvDuT4=~UtrP4oP)Uofe zh5E!iVM1S^Ksz}n%pzzc*ocYnxuJboP0bLdNX~15FQxCuPrXX`qWqa9z@YQ>cDz$M zn{R?zRX2In+;n00-F`A>pl)2Qs!`L>Ct#~IEcb%D0v58(mnKKVJpEU(-S~JScViQp z{2L>jE(+k=UWUQfDV0CoL-U-|c#q+NPp&-6s|RjGJVE8w{wlkPjW-##0~;MJ6fMdm zThGSNAI!~y&A;E?=?;&plfFBLu9>zNA|O;LSkEbPo|NN5fb}DFiX8Fw@6b77v$n_A zPG%~_m(CkY{m=M?o;Q!Mbxu6f7Wh9e0Q%VNZPSgTsNGLhxen*8EOXXUcA}|63iq86 z##hP`Lcb)Z35D^Ar>4}m!K<>K>7CS|Ki!I8#u*vX>^PLna%xdY0BX=<`ZTcX`09St zm*4i;RzNc2K6Ter+W@2YHbrl3$EKzlkuk1Ri}XGs{MYH#k62K>vQV(NTxU^M3qqIZ za%^P!a@XOBtyMmzYICTT$HXlUlKeSGqu@iZoREeN=`h6SGBZNbAXd+F2s#&G6sq9k zgYa|_D0NUnRq7$=?VaVVKVp~d zs%-I24|z$Zt$X%gf8sw+osIHGY&25C9CX(03!W~r1 zNFr8}BKLA+i#C3QzM z(TG-lOfoUgI)VKveeZvoCE~ihg^K$V9j?(*4WeNM6-S1@eP~mYik(u7MASbZdtYoT zUsLK9e%Nbom>d;1&78-SKy)+x&b2upp z8K>z`kV=r7|C7kSUPqZ&!gpPPKc7EkY*w&fsA$)(xzAd@mNS^0~up~IkS|VhQO?svqzDk+3#0P|3v@lUeK*Ai=J@QnW z21G)qUF37R{CVZiL1oBw81u!m(o#vWEf+gU$;&~R$-v7%IpsexI@|Clrj4oxc}0`u z1jm&33OtCA6^ z+O|fP#E70{so2-6j20JBqTOE6$Ay8 z7%9yNVZiD+@6YS|4?O!}zwDfQ*L~er-&b_VG@l)|rfP&zc(k|am%C3l$2agiY$=Nn zxDny$id^0am-KN)=Q(v<_$k^pPNRE{R>_J4&RgJO@!aweA)vnS+@hp@1^tw>sto5P z3!vYmRG>{?pYJMsIn-oys`BY;DW7+<;Y`>~-m6U;eLf6mW;3xg>MU=6HQFWXI{7q-$Sh>j#r!4G7U z8v@xU<2heoTRY?6<<7r7T2pGsJPA>i087Lrj;ku%d%>7v%?o-u<(2GURMMG9;p^Z5 zriw2|eeC|s29$HUbjX1B_1U@h6jfJPvoFA^Nd}?C4JHN}3&mPKdU?3=kG{~T1D3Xd zKQC-)6)t5RQ`W12IpE=+K7%pD{}Kw_!MZ|yH|brOHEnd@#l26N4`%$eWSh@U=2+!3 zDa)=)23~4+Itt)yW=TJ?es{;(Nn5NnyIuH>bny&cd4IC`S0uOu2H6GHJ)q@oB0Y+x zds>|W4)BvpTYi}*%}J*xE?3A17iC4kOYr;Am0qNRy0Sc9RQG> ze^!N?Q2Iia_~F8y0~zqv+RY@7>P+DAr9Li$8Rz^569y5oO_UBh98A^^EF@x7sHa@G zZ{#k-G0?#is4og^@EZJmCfBi{L7p3RVjn)UH+}e({!pPJoZ*$~A(U2d?o$wGVj6P! z6`nhP@4wQY^=w~QIW+UKl?Gd4ykVnw&G>EAYn{*f&g+kV9!EwuCwwTQWWN>6 zR0vJ3kCehhSpEHd>&!On$pbJ4UrRgiX6@aFa`s5Im(y2xUUKYMrkyzWd|tlza5^0r z$OmLdti+Ulj7Ly~tRYRn3c;j`bHq0i6PTEIHi{s663;e}WT*Pn4)5~7X!NctxiS!; z&-*zyWTzr$)chCiz`Z8Ex}v!+P7SI>AZl|!5Py|Z_KBXH<6#G- zjB#@%fcqaU-n^3u1_jqaF4VpBMe?AhUj)6#E_8?`sP)5gy(LvrX(KZ^t=Yxm*kH2k zFwC_5sFz@Hk94X|v)@K72X6Gw8!(uCHSxIF+M6j$m2*9MXS(0yIcJE;L0N zP`y?u6vm0%`1HK(2poP!8>RD1(LFstGwBV*i|L9Tz?SyODV=NZg899Sv$FKdrTJOD zLIrR4uMeSY=y*bARQSrvXJ#MX2E&7u?~J;>yCOf_$~Pcf-G*Q6z4a(s|Gk5C?&g5s zo%Mn_153fA&MgvnpWcgW0@AZy{XU$}Y~E)H+&b(g9kO{SkI-xVo7S-o$#sZyz` z2VSC?8QSbzF?d&>_6Y6E^*hp(ZI*Eq_51_Sc4d^jK7pCgVCbm3I@mexA*1w~1rxmX zU7#os)pPamF#XI98OK8}Y36RsAX(6^oBB^H7^8wT?2{KLT5A$CWqdf5yY9UeY-M2HE2fMwWMg!=)_Nua1*e`xj4Nh}t>hL2N+b&WS_7Celw{%9A zQJCGjd5hxJ@^(b#`kQuJLaH18Eu_Eb3GQ3&!kYT#xC>yVzC12hI!7TWWTNpY6na@j zMIj-sxsG7s05tl+U01asS5PI4Mq1ybTLiv$WgaZg4Wz{N34TBV!dMKWVXbGGuwj~c7jgwS5Mv>yor7-dNh*y6ZW>TUtEnw?z-OE0$u8*iu;nl zfIYPzhdd!-_gpp>38^)0Tpjk)-l#xkF#{~sMYNq=#5d?tncklo$1hga4c0I&1Fo}{ zio!n3F%Jp#H*c}cM!$cAJCoavFu)J}#B{eauG2I$plkF;(m1&<)cU`W!_l4+R2NT@ z1g?=Z9=%pl0K01)AG1NkpCS~#8~OLePZvh%(oqh`cQWUi_T}X%^FUQq>;fsCFVkKr zzrryo^4TX^U%!Z5NBE`A=l!nfAtgbT80*>Ggt)^&p2^xp4La`5&@&yH^HEB{DuQcF zz#4>wh3|@`kzn6R&5QJl)$Bvntl*xcI%OHbfyA}N{SceKuZn(r2toPYSrYvrc$)O% zj88xJ89VzYqa%SSTNAW2>IGRrk}-s^t;H7hiLx0k03 zLUk)TUxJS%{_R06GhFYJxQdzuK0LF}B=`y&V1ACv7ptCSOSAh__#&Q}yc(fx*~{Q& z?Xs~hcsR(~`Iu&QbmVxVz1)Ir-BG2=CPp&i0^5Bu`1^V!HEj{}G9>TaO&0yFD*@zB z6B3%qL&tG!$JF!i_E?bD)UObqChGyM@q_25Y%()&uFp#Hf)zu(m zJ$cI9Z)R$>W-MqA7tZ~ts9nBk=@H3}6f-5g7lgtl{jx$N$dx6-z? z>7`4N9a;MsW8G`5GBSv(AEt(*OO2>$MsBy7wq2AprM&upwiVQKMfz!~-kjIYO2Q{d zTM`RaCdmMt)|FXzAg9qLmv?q(@aMir?r-)>3;cezHs6`*A+I``9#rVLQ%@`jCA(d7 zn;&X@!gPa|hbU=TlFtRdw|)_|zN)EsQ_6ol7>r|Sq?A=@vm|d#6?ha^UokU&v&9l; zCCH33bG06z?c2zF5b7gDt*e#kG@h&505)fMs7KRXXcp|fqG>;Ed$V?eiwBzf!DKoc zbu#EKfA?1Yi?{7U7faYn<-Y<_HA=%?{Y%&VlBJjH9J|-8RZ7te@AVKAmzRiyP*-n@ zL9q)OM`@>i7UjuJ?G*CsYSNfze`(@Yaz;uh7RFlHH*21qs1m0q(dxOM2nRwh>7M-z zBz)hF2>G_@-loJ)@k80S`y&Yk8BNeSLjO)9S_Ydz^m?zWb+zur%%*KX689lL1%E2o z$K0`Oq8Cw?u=sB<;V z9VVBUGlv1m*@TC!a{ll_PI8e1#oK784Q1unGbctTTS`;u!cJ*kT0eFbyK}46e9gCQ z79#};S`M9P5g$4Fsz)weR)TO5DU)sehfA1Z3bObp=0{|7o3n1GHs|TlFJ!G1GNEwS)l62@qcD2Wc*Hp3jzG;|f^&9& z`B+R>KAjLM@e4gs!#C*>ru$Nw=LZjO(4p#A+i1&KfZaRD z^TRcnnW>zjhRbn5QonuE5yjdN!H-w})idO!+9eZ~gHuOG*-RRUrqe+=UQ6VfwRuP9 z{x8SE%znOFlSmmbT#RD=9_iNa$DtbU1+N~WDl3eMjL_U>_j6=Qk`WBzqiyPYJ&&yP z6^r!JDXcj)0NOWGkKg3S97fQi`F)l68;rw((}Rv2q}#8g01;zo!}>u#i>-2Kx^DcF zxOHuN zvpp}=b%dIMI@encjlEDumP5Ai3--8n?~Xmav#8%4V$zEEuM#muA*wsJe)4{Bc-jBtK;4+KorL_u2Q9&hA94$r4495f%@V?=i>yj(l2+ zzINmX9ZqQI&y zL}E38^*r(sK}S|45ok16B%8=U8wV*uPz>~aOeT#K2jsYe09W|EpbGN(CgO5w(lU);O<#+diEZx;&Z=+1DGDcpEt`gtVJbfYP5p_qIxp(QAnEQ0i6(lv=8O;H3dOaW-;_XVywx0~D63ow2PvYA=H2 zCR+qL*oTD_df4SzNsC3u6Mh0VBW6LK&ivOVfe@uOFjKcMm5Lr2YbtEa#e&-i1pK^tU_k;V}^ zu<^F83u-2)Kk|D5%+_@hZ1DiV`gf_$DVNZmUx=xC&l1{F*vP!qXZD8A*dgvFPGrGN zEl3_5i}AWu)3umHrye(pcbr}Np)Zp~8(3>NfLMRgn)Mf7=?@EL1@d$;Ol#)C4EZXC zFA6)#NqD=eBb;BC5_YvFvcF|lRVd(ZRs7W22d&)rdktk@JKF3F3EqfWb{?T^rra*f zavkP@U@vjvT2IYz9#SS^``OO(Zu;JzB6SKx-hV7RYa`dFAwMGMpLhq7-C8G)xZXOm zm_;k38VDIJxRw33M=>h%-fEaDyR-9Aj-r+vN@`n8!s1VV<}b`m`e2RXzmq4pliXM= z697xPMCLyKF|$B7$O{6bC&M{Tmyz8Z1MJNaD87W-X0I#N^Mbz79A3kORB-ma0WiGj zU!c#c(Tza-fOjCV2^d>`m4u6mfy9R z(aL5U^3UEBFw|NU>-_CfVm(O{l@ORUCF{H>c6i_V-AOpaz?wbR?Kw-b8g`}S(yqch8pRD@ zE$Sly@0ctW1_w;%6Hn`U&g;{PFs)kMSU+cL%h;K2M}*Y)gx=Rjr25oh31O+c0rVXz z?vvy`pW!P)ugfXM{vmnMHLmeUv-32J0>M46^d-st^R;(YHxj*%TDI1%R_#SDl*bPr zE&d27YmCQ*->bXjV5q5;9`n;T0?q&eVW!y5fAswC(W zy-K3#Rjx3tm;tj;P|s$n(7>9SOfuG178-xjY|8hmQULs1n>4527P>hYti|w&==;172l))FJ+>T=GfJD)0W9T`&+||F*_@YTlFc zYK-mpq<$dSBdY7Vrtp)d$)=7+hX-ocE48}+)UvI)ak|fC_V8g@w(&ClQ5#(z5p!|t zdq@1+Eiz0t%}cgAGCk^jY^l}3B&1ck0*^|Sfjp0+ zTE5x6H2dVn?_cpxz|`ce^AH!6Y!+_F(JS*~=GO&M(c?i&Xm0I&NJyGc5&hXAza$W3vFQeX%SrpCs8FL2_=6u(1Uud-15y7^nDn?4qlyh4YXo!E+68GbRpwS34 zrtqzYXH4wusW}y+XgT?Dy8T=-Kyy%5oBlYN9-NO8_17*p)PoxSizBj3y7{4=piWV> zB8>w?=XaTYK%0+uIXu*#C`EbnNhC8W-%?lZW8gvSoQUeHApL(Ch!yjbm#LdjzWZ-O z{XY*>;uhEcJpd@4h?p^5h% zs3|ja(Z|yGuc;sL5IlRXJ)K`nlcocx5JEb5cPQs6109Xh?`$U3N0+INSTMhjAL4X2 zwdJoc*zR=36ucS!nd8pKJ}2JUTWLC~)*@>rCDJ71f0;=NZKswSlSD=kD4wsT!p0&D z2Uk_n1jR0zI&vD&ca?CMp7JBP6Wo3F7GtipeQp$qX$ce`uz5^i5P9g+P1t~{EWk?X zV7n5K0ID2J8D~)T5+n6H?OZ3arF?QTwIivox*wmLvsV6;>G`#b{%1I<)h)JO3%yjj zpqYZyd4$LB+q3ZqA69$)YgfWiA(o%6jr)gFL((oRAOVubhJBAbKk+&HzP|oN83EO_ z?WWiJ{%3OPan<7=DShR1D%mlyKiiQW99jzzB}EOm0#ZENWmlp)mUCnN(PeuilOTz% z;wNS^S^K$`v@eo`W{$u;2vUC(EHNMm1nD;ds1(&;Tp;y7)qI!p-hzGjrfs@7>fq9I zYqkh+tW7ezxbvT6@ef&Bc1D`tPXatVDvzsWvyGlE3JvDQdj4$py*IIX`5$7XPat4L zWDor`rPKB3rhtJV_uYcPZi7k#A2*YK%5Z#9GW&z?ycmD%;LBU)rWv9s<@^j`OPpOE zWYTIW?q_oxE|W`$3llbbsrro3^#Nl1-XLA(=shRHZ|?UWK3)AD$gJ6g)?RNvjwHqG zc{$FT-N3(&$2Q#B#_GgU9gs56AWb#^L@GFp0LCy6B0K9SvcUF!OTmzM)O}JY-~qwY zJuF6j?{YKT{KZjFcPJ!vF>-LpfArJf>#X5WuQa}&?r!CD@x0GIluKPeP7(Y(qrnfWIxNSU0*(d6hrKMJ)>~ zE);M)mRA4+YcOofi16LU$tgugth;SvpL_KM(!JjUA&tP|Afv&1UzAF`Ey(n!=44k` z!X2KtFcT!Wj(1N3A(Ed$DeQB55E;4?<>h(T+E%E&)B@lZ*^=kqH8_}VheTb5VT8>7 z!mrC=yRS<{n3}b%cG8tr3g%?dP@njx7fwPf#BpUbr5`Q1HB zy7r{{Z>4?GyY~`d$?YP$9|TZ2s|;$?CRxl8d=j&WuSH%CCW zZR~XJolFcORo6;0i;BbIn4oHaoa8hC1H3GO1ZB~~vm-qKFuB-g+D-wu|MaHG?v%m3 zFAJYtjN*m-eh;{rr|2R(?@8n^ed(E;iiwHBsd}_BiW^uXV^&?;UGD#u{?b)GeM=%! z`HauDz2!^0deOhwjiyo(e;4vOR&f0SgDH*%{&dR10tGf6muz^@)lONP?>1U5JJuy19Q#=Ol13zyLfRf43M z$&KPyHQILA_I0kQZxcJw{h|$w0cnp3ngfK=nkG$BnQpI)H?VVNjbe59FoO-!+~_%MYhfvEG_r2bb}rsx{IB zLfN0l8=SXE?d_(ch_P{>7FZ*>$6vX@1F;!v^o-q6VBEKJ0kg652U}Qt&eVCpIh9Ee zF)S{JRsh>>Ja&ridF+&Abe{9+(Aw&`7Lbi74c%RP%Spyok8 zOtQv~v3oXztsMRUR%!1s=AcoQqtk$3Y-k2MT$bk$$6>?c5mw_H$m)&u_4WKRm>c@l z64{jF96JL%&U*p2#eSof9%#uba&+jn`lA!hx}gB|WNbB&VA3njn#K2w$iM&o3;OE~ zrp)rNSgNxJ^9)=)pyBk7%E``mnDKY*CUugS7FrheFzY+A9`jQ41gP`64vzu8Oa1~dx&{aW93wO`diopcQ|NTEMdglL|=bI$DV*r4YJ z$7SM#GA|8(rYW2HhHwSGaQEx$Ok&4?Z*?35^^UCEQ{oGMd^v+sk_1Kgmym{<>$Y;U z%Dop4sc?Q5+sLar(@~h%^^>Wijm=o?Equ!|B@zkMbyp?H}5 z<#8BE-R~+;-}ni|6P~}(Sg=ir5^iK(bg82~TcN8N$4`5E?HzF8%N|a^f;zZopVrLm zm~9|YPhl_#eC*BzKe=OHG>J`S6-IRoM1s8grnmn3<19C+&)`KH5LkPSAvV+5IczB) z63u?+GCe&0bL`7r>z%i@W1UBvp*(UL=w837@s=3Cs?qg}#t$HfN$(GLV__kY&DGuc z#3z;(PjMFN1S8{z=Jb4>*vAHzdO6Y-&dG`=GZ&x~-|V_A0x*UnwJ|z9XCIQ4Q~_X@ z&lZ{8Z+{g&qd%><^S=A-h)n9jmN~Uh-`gPVDZ%Ym9*S{I`Z74_&Z9I%1)$>QrN$g7 zf=31=iogGSk_>%5bbcL*DW>QsV*)@QPHyDPM#&`1tFdIXu$){HH8 z8g-6DJGGL$I_QChcJj}Su|HSDPrWRlOo~k1cuB^a)IvsLZGB3lh{RUNlE_X@27lt@ zG+`sF;J+hiK+a2izfFX<5xb+w<{x3Hl0jJHQQAPe)4c8puR0ACqmU zTcnCF7_!#KAI3?Jy<|6_7K#?*v+)>wH}1MveYCxvd10D;Ak7UjMz*!bAMs|guBVoC zS_3>#ORr(L-4Hdi0rd}Fo!yNmo5Tj6_ISi;%hRoj(<%Q!_!Ta|&S`#~qj?ARM9eob z5NM#zg$O1yS8>jl!C3kj;0zY$$lmCQA%Ke|04Vh_<5IOaC&A;Ko*s1ZY*h$?SXqgl zhX(2Io+CiUN`FrB8NS8~6!#Uto;)L-UmLKjWA&8M@+jqy1b6uOFYA=F$?Ww|wkcUe zV;@p`z;o7GZQwTm-+TkXHHLpI2aukr-35Z5-^#;5ge)HXR(8=}^!)0_Ii*@Ipm3F& zwD% z{YLvAx^FD*khGgu(Wq_0&T(khJ(-F$cO=SyfjIwRa8rC zcYw$)XFo0A*)U|t+1 zqrAuN(>2M!k}FX_NQd1LhfUkK_aZ!I7KXEk44_n{Wi?1Q>mD3c>-lO}Dubgu2D0KB zNCEZ>U{mMryCu(B(xpg)PW-671NSgmc7ycc?!ghn&4@-Jiu&%eNkZSEZ5`9uc`#L_n)M4+BB!F1!gz74cagIVycgJ{`~tCsYa zBn9-Sh29w}JM+#`{`gGP_5A9UIlF@#E>XMKy6*RKS+tDI?R$C|8I19W5qDsnZYem> zh5QVX3z`3nQv>^P2=bPNK*wM{gx4aS@$Dxn)FIQS1a1J6F)ZL|mXI^?Q%@-Qt&+|P zmX((qLx~!9nukHBSlE03i{$q)QGQ=i{c!RWZBQ*LBE{K;AlmyEF!lCUBeQ`$P2fpP z!XrSJjKqMq&TVlrU1Gi#dvet8oQAZM5;qu534*uBou;<|a*MlO@*OowW#u681etY^ zU_JZiyt4$fCh?czxSn4DF+^O5e5n;TC;{d73M$JcBw9lrkxYbua-8jzjt+CT$B) z0}H8wyy?N;AgB!mP}{}cuaHQ>r}Yb+zF^e1=Ep?WI?%Knp<{b}8gyjSDmni^t&?Y?Sc#tC5YkWugZ(a1I{2No<; z#>2>o0A8^!=i`0I|A;yy@9ISxD*PN~y(VjhE(ZDFa`xsm^M277kPo!i0?wjnkEzGJ zmJ^ige1oLw{!i?GaP?G-!~AVY5W9%#Bx!^PBHmutUC4znAJS2Kdk;O#mmT+5@hIj`9m9;E^gxezp2ZPyXe zKzKTvhaW&*2%Sg(&!F8Y5GWQ%K(I#XmGWOp2>~ULw^Jk;VwACH7c1_JX32v8$H6&k zlIGc3K$)5B2#*7I5S`{-WvA!EkLrvLuKo|3PG>=g)y4o;b3%N72jDWR?W#y(90E48 zcmbfMqzBQ{L$=6cJH#t0RA2%+K;}qJx%&ErsV z1AN{?p589zAb<=(DGM%4jo+pzo6n>F_twkL+Don@NHq|{nxGo<93gULlF)Ci$p%rr zrw6yM$R(!;UCP~r1VNWL#{{npUbK|vY;p}UMgh@lAfazjv}TGuuVWjusQ2Qmj3KjX z%seE^^q>(+-h3(^j(O+_=I~(o?`>!Ku<1ZYQGQA2 z1s`ZC>U_IV=%;&J;){pnAT1k$T%qU0ZujF6PN|va+;!?xtKqegTImY&A=q4I+ z7^Ew$$j-Q3ZDiFgKG{97ON3Okl8Qy3OVVbyDR32n#S$n?f^PDO`hiMbf2BYj$S4IO zW519r)2ek{B#hU>Kt91$PTvgXkx37S^nJ#6?Czm6W#pva{7U941F4ux$R{+r* z%|89eI}SN>7RY=Xmx|6?5l~P9D6KWL?#bh0ElSyz79iFMgUg+vLjbdZ1tVw45qf}? zsKcuHuB*QXz|CsghY{714FI)1P!O?C14HXV9JFVaP6!{LWVik7m22S9knj4`#|vz< zr;6bSJkxv+*}}3Jur@JhUOh?_LxCvGVt*l#Nk3lHff}FAO|4wwc!zSG98`+EmuxCM zJZXutileZn-Y?N{6WOFU&v{ND_GP4tanh0pX%GEiw(T@`RP0o&Rro6EK%Uab@IQ5` z2esSQ86Gdd&jXP;DbVfY=51S|T8 zyqcqSeNM3GJWg=`Y(`-n#LUfm$!W@-Tof5qW-u<=j=dN@b|45$#+=K6tn__ zMf9i@yQ&J|o_wsIo>{T0m);~D+jp)OT?@sN)Uy8`dD)XK)| zaZ75Ot1pJPy}n&{YWXM?FZaMH0vYDH3jSM~q3&%VCKPpB-v~6KVB>xtYj;iGbT>Br z7zN}z~<_wgauqSc-X$6&C2IaMHgJRPI20=j>`Azf_8}& zv0jMk3nfePb{%8CeY@&_CJEJTt(F~>uO7rJlKBEzjHLX?@H#cxBa7eKV}d2!S`fNr z>>30lLI(7C`wj`rq+9eR;L#A5GwL86B4xXX$-6vK2FHW zYgUm(_vnWNL9$|VJlj7Fym@(}Jwb|y=-qx5yYqhv^xB;~Att@3)}9`KpX0zPvAE<5 zp8h>!q*19zt^^Gz%oGj=bjsHv(!cx*`z?6&1nh#v5}wn}(|!2l>JT8wR6JV@-KAUK zZxUJI+>WWctEW7)cM!i$NiCE*d?|Pambmb0(o1J&SsinNv>pq~A4Q7(_Zp~CjZkb= zw+M{Kz~wY-G&QiRTq>}gM=K;=CayRQgD=!fD-@H)ovP3_!d=HmBYyZ~MQgAbO>c7S zjU^cej!kt43^c=;Se;Q>kU;f1XEK9y{a|B@KgYCh7L0V5pMmfG_UJY4jUCC*0@lSI zy@A~E3-mrDQr-#H?WRwXC}C+VNgtr20~LcbL1awXdGOP@TJP8Fu#-9J2Hb3@H7mQA z5v5*KFcc*q!1`g^ZPy$t>mV1IoI=H)f|V8(Oy&~x*U8@yjdCMV?Z&S;69a(OQ4dNz zGaKrPzGRyoxv9P&)MW>j+-$bf1Os&1$#-f-XKEWu#zsCOl{X1wKOD!7+ob7QVs2es zZTz}1C8{Ks8>(8TeZZt&-r_94JV;yfncOv*S@`vHQ;jP8<++6ho6@D?`WVyY96nk| z7EObD*FCw0x;NIU3h!g-a4Tgj$|2ZBF9R&R-Kz{VX6!cb5A(BOlMB?iQ6x54>fw5K}Rc=|7hrFROP9uP`iEhEHU#;_>1 zjbG{-6%WrjjL8OVYvUW9^fb$GmlCB2=bn5(fa<(_`mkqFHHbr%k=4LRF2jw$)0B;g z6DU6(&{-TMk+t4@j*b+DRtWokCg1mIC!u{tNLZ(~ig6YvR2Fc;!0fFwymP_Zw-qZf zD@Wo49lXRS$oAV}*z23r6&h=w$+f1@DDOyXNgnEm#&`&wR~Zdb;?>Rc?UnlIh+~1Y z6Sz^g57?RGc#e9!wiwo*3%SX6Pzi+NJV%ENrEz`NjC?T!z4-_)4%`{Wc+nDBGYP*GXV4md zg$~Lg5g8eY?&=lqAVLFF0y8_#gu%YA#JGoP$mi*I&oC?t`0Z7XU|+zVR}JW}$#pp~ zX}SVBMdLuR`4}~P(-`zfsT@m=uysT?nJy&1?@KNcx;u-kVJ8BLYA1X0M7Sj&g~kgQ z93{B^{a8jU6GQ_@%e9wlUo>a&5TU-vpj5w80nd>*=-H)6K3|a-LSc11LiC6AK4}}I z8UxVPE9(wFTjX7mdUFP&eZN7`DsxjsP{l0Skl7=LDDyZayp8r#v9W4#w@_B1{YCc(b<$0$KGI4GeN2TSAz#@V8h zxyj@fEhqXr$wt_it6(plR~KBkZXraEXzcKE0#ear+4?kbRp|jERVkk2V=OUb^Swua z7Wn}?9ZU5svU1)=F5hx*5n%>i)~bk(sGRom1;dDvI;xCa@Afz*$S(5^5erwAg5`-o z^_s-&Y32vm&mURT*7zj_uxn8tF(1>m%CNN4zRSyHCIp*3T$w_#cS)R_Z+C(N_iFs& zOiJ!9v0t+(Lf%UY0gNQ%GM@LOIsH8lx2Z)7MMKLyza?2oGIA!)!t|(rXqLa4LKU8Y zW52M?ojbEuV6QtWb{9f)DO#Jxph=JkzL~+y5!7a^2+kXzzKBi0fT}u&*Bxa*8XMogkps9lH7b;R zvawrO(b}`zAxSUYOvv%9hTR9hKZ5;|-A2^)x`DJ(9DqG3L~gRR-7!Y4ts2GA9}02g zxDFQL-Mr)BqatH22a@>mV%96VdKNtXB61|+L$+rfgJ+#DJ^s8W<<736cg}ycc>H^+ z1ylvHTI;=7$7n_gNOc&|GieP()2s<4&h&+YQ1^#{Y9LBsb#XB(t*eWi{883>F3RQr<>WdYXC5Y5O9{ztn5zWXFASDWBgq z-J~xv_jYkdl%Kc#3gA_*z7{{TKJU9GXpN%?VmMWN4i<)e)v6(&=k9~ zkNNTYKXOSIHPJJRt;%{!f3xt}KXHa&(fdzlH3>lba=hq1zO!$Fin%Q^K=0z(C4@=E zm8ARmceilWovz*I5$umGBr96`EnZ7k{kd##djGhOtV>-zhGZ<1C8_S9$ z$mE!7n4-OxSwBuKd7qZ2tlh0~Lqcr$I)t7{{5!AiTs4}ciCwJ4V#DjT*`zOZ5c1ibF7jnfa2Kb7|!b$SC#{$RBDwmx51VmyW4Tu2=OSdH{QZcj9dS?&&1s|;W;t{_G)>XbC?8yT5xHMWFP+n1Rxueg{*Nw!g!yh@zN53_N*5#8y%ahXJpgrjSeY#*su_K{t;$b6i+-3 zQ-{omku{qGKRx+`0)P}CnTy%X7!v#3AfTqAL7EExXXDQg^C*voan(7k{g(WS=L9~# meLC<80t}M;e|)7Yu8|<#@fg7Ko_8<+e$0%mjOq { + STATE.setIsTracking(false); + delayedStopRequests--; + + if (callback) { + callback(); + } + }, 200); // give the user visual feedback after clicking the button before switching off + }; + + let hasJustStarted = false; + let isSending = false; + + const onError = function(e) { + console.log(e); + GUI.setStatus(e.status === "DISCONNECTED" ? "Disconnected" : "Error"); + stopTrackingDelayed(() => isSending = false); + }; + + const onTrackingContinued = function(anyNewMessages) { + if (!STATE.isTracking()) { + return; + } + + GUI.setStatus("Tracking"); + + if (hasJustStarted) { + anyNewMessages = true; + hasJustStarted = false; + } + + isSending = false; + + if (SETTINGS.autoscroll) { + let action = null; + + if (!anyNewMessages) { + action = SETTINGS.afterSavedMsg; + } + else if (!DISCORD.hasMoreMessages()) { + action = SETTINGS.afterFirstMsg; + } + + if (action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING) { + DISCORD.loadOlderMessages(); + } + else if (action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE || (action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel())) { + GUI.setStatus("Reached End"); + STATE.setIsTracking(false); + } + } + }; + + let waitUntilSendingFinishedTimer = null; + + const onMessagesUpdated = async messages => { + if (!STATE.isTracking() || delayedStopRequests > 0) { + return; + } + + if (isSending) { + window.clearTimeout(waitUntilSendingFinishedTimer); + + waitUntilSendingFinishedTimer = window.setTimeout(() => { + waitUntilSendingFinishedTimer = null; + onMessagesUpdated(messages); + }, 100); + + return; + } + + const info = DISCORD.getSelectedChannel(); + + if (!info) { + GUI.setStatus("Stopped"); + stopTrackingDelayed(); + return; + } + + isSending = true; + + try { + await STATE.addDiscordChannel(info.server, info.channel); + } catch (e) { + onError(e); + return; + } + + try { + if (!messages.length) { + DISCORD.loadOlderMessages(); + isSending = false; + } + else { + const anyNewMessages = await STATE.addDiscordMessages(info.id, messages); + onTrackingContinued(anyNewMessages); + } + } catch (e) { + onError(e); + } + }; + + const callbackTimer = DISCORD.setupMessageCallback(onMessagesUpdated); + window.DHT_ON_UNLOAD.push(() => window.clearTimeout(callbackTimer)); + + STATE.onTrackingStateChanged(enabled => { + if (enabled) { + if (DISCORD.getSelectedChannel() == null) { + stopTrackingDelayed(() => alert("The selected channel is not visible in the channel list.")); + return; + } + + const messages = DISCORD.getMessages(); + + if (messages == null) { + stopTrackingDelayed(() => alert("Cannot see any messages.")); + return; + } + + GUI.setStatus("Starting"); + hasJustStarted = true; + // noinspection JSIgnoredPromiseFromCall + onMessagesUpdated(messages); + } + else { + isSending = false; + } + }); + + GUI.showController(); + + if (IS_FIRST_RUN) { + GUI.showSettings(); + } +})(); diff --git a/app/Resources/Tracker/scripts.min/discord.js b/app/Resources/Tracker/scripts.min/discord.js new file mode 100644 index 0000000..3d1def6 --- /dev/null +++ b/app/Resources/Tracker/scripts.min/discord.js @@ -0,0 +1 @@ +class DISCORD{static getMessageOuterElement(){return DOM.queryReactClass("messagesWrapper")}static getMessageScrollerElement(){return this.getMessageOuterElement().querySelector("[class*='scroller-']")}static hasMoreMessages(){return document.querySelector("#messagesNavigationDescription + [class^=container]")===null}static loadOlderMessages(){const view=this.getMessageScrollerElement();if(view.scrollTop>0){view.scrollTop=0}}static setupMessageCallback(callback){let skipsLeft=0;let waitForCleanup=false;const previousMessages=new Set;return window.setInterval(()=>{if(skipsLeft>0){--skipsLeft;return}const view=this.getMessageOuterElement();if(!view){skipsLeft=2;return}const anyMessage=this.getMessageOuterElement().querySelector("[class*='message-']");const messageCount=anyMessage?anyMessage.parentElement.children.length:0;if(messageCount>300){if(waitForCleanup){return}skipsLeft=3;waitForCleanup=true;window.setTimeout(()=>{const view=this.getMessageScrollerElement();view.scrollTop=view.scrollHeight/2},1)}else{waitForCleanup=false}const messages=this.getMessages();let hasChanged=false;for(const message of messages){if(!previousMessages.has(message.id)){hasChanged=true;break}}if(!hasChanged){return}previousMessages.clear();for(const message of messages){previousMessages.add(message.id)}callback(messages)},200)}static getSelectedChannel(){try{let obj;let channelListEle=DOM.queryReactClass("privateChannels");if(channelListEle){const channel=DOM.queryReactClass("selected",channelListEle);if(!channel||!("href"in channel)||!channel.href.includes("/@me/")){return null}const linkSplit=channel.href.split("/");const id=linkSplit[linkSplit.length-1];if(!/^\d+$/.test(id)){return null}let name;for(const ele of channel.querySelectorAll("[class^='name-'] *")){const node=Array.prototype.find.call(ele.childNodes,node=>node.nodeType===Node.TEXT_NODE);if(node){name=node.nodeValue;break}}if(!name){return null}const icon=channel.querySelector("img[class*='avatar']");const iconParent=icon&&icon.closest("foreignObject");const iconMask=iconParent&&iconParent.getAttribute("mask");obj={server:{id:id,name:name,type:iconMask&&iconMask.includes("#svg-mask-avatar-default")?"GROUP":"DM"},channel:{id:id,name:name}}}else{channelListEle=document.getElementById("channels");const channel=channelListEle.querySelector("[class*='modeSelected']").parentElement;const props=DOM.getReactProps(channel).children.props;if(!props){return null}const channelObj=props.channel||props.children().props.channel;if(!channelObj){return null}obj={server:{id:channelObj.guild_id,name:document.querySelector("nav header > h1").innerText,type:"SERVER"},channel:{id:channelObj.id,name:channelObj.name,extra:{position:channelObj.position,topic:channelObj.topic,nsfw:channelObj.nsfw}}}}return obj.channel.length===0?null:obj}catch(e){console.error(e);return null}}static getMessages(){try{const scroller=this.getMessageScrollerElement();const props=DOM.getReactProps(scroller);let wrappers;try{wrappers=props.children.props.children.props.children.props.children.find(ele=>Array.isArray(ele))}catch(e){wrappers=props.children.find(ele=>Array.isArray(ele))}const messages=[];for(const obj of wrappers){const nested=obj.props;if(nested&&nested.message){messages.push(nested.message)}}return messages}catch(e){console.error(e);return null}}static selectNextTextChannel(){const dms=DOM.queryReactClass("privateChannels");if(dms){const currentChannel=DOM.queryReactClass("selected",dms);const nextChannel=currentChannel&¤tChannel.nextElementSibling;if(!nextChannel||!nextChannel.getAttribute("class").includes("channel-")||!("href"in nextChannel)||!nextChannel.href.includes("/@me/")){return false}else{nextChannel.click();nextChannel.scrollIntoView(true);return true}}else{const channelIconNormal="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41045 9L8.35045 15H14.3504L15.4104 9H9.41045Z";const channelIconSpecial="M14 8C14 7.44772 13.5523 7 13 7H9.76001L10.3657 3.58738C10.4201 3.28107 10.1845 3 9.87344 3H8.88907C8.64664 3 8.43914 3.17391 8.39677 3.41262L7.76001 7H4.18011C3.93722 7 3.72946 7.17456 3.68759 7.41381L3.51259 8.41381C3.45905 8.71977 3.69449 9 4.00511 9H7.41001L6.35001 15H2.77011C2.52722 15 2.31946 15.1746 2.27759 15.4138L2.10259 16.4138C2.04905 16.7198 2.28449 17 2.59511 17H6.00001L5.39427 20.4126C5.3399 20.7189 5.57547 21 5.88657 21H6.87094C7.11337 21 7.32088 20.8261 7.36325 20.5874L8.00001 17H14L13.3943 20.4126C13.3399 20.7189 13.5755 21 13.8866 21H14.8709C15.1134 21 15.3209 20.8261 15.3632 20.5874L16 17H19.5799C19.8228 17 20.0306 16.8254 20.0724 16.5862L20.2474 15.5862C20.301 15.2802 20.0655 15 19.7549 15H16.35L16.6758 13.1558C16.7823 12.5529 16.3186 12 15.7063 12C15.2286 12 14.8199 12.3429 14.7368 12.8133L14.3504 15H8.35045L9.41045 9H13C13.5523 9 14 8.55228 14 8Z";const isValidChannelClass=cls=>cls.includes("wrapper-")&&!cls.includes("clickable-");const isValidChannelType=ele=>!!ele.querySelector('path[d="'+channelIconNormal+'"]')||!!ele.querySelector('path[d="'+channelIconSpecial+'"]');const isValidChannel=ele=>ele.childElementCount>0&&isValidChannelClass(ele.children[0].className)&&isValidChannelType(ele);const channelListEle=document.querySelector("div[class*='sidebar'] > nav[class*='container'] > div[class*='scroller']");if(!channelListEle){return false}const allChannels=Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"),isValidChannel);let nextChannel=null;for(let index=0;indexkey.startsWith("__reactInternalInstance"));if(key){return ele[key].memoizedProps}key=keys.find(key=>key.startsWith("__reactProps$"));return key?ele[key]:null}} \ No newline at end of file diff --git a/app/Resources/Tracker/scripts.min/gui.js b/app/Resources/Tracker/scripts.min/gui.js new file mode 100644 index 0000000..7804203 --- /dev/null +++ b/app/Resources/Tracker/scripts.min/gui.js @@ -0,0 +1,17 @@ +const GUI=function(){let controller=null;let settings=null;const stateChangedEvent=()=>{if(settings){settings.ui.cbAutoscroll.checked=SETTINGS.autoscroll;settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked=true;settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked=true;const autoscrollDisabled=!SETTINGS.autoscroll;Object.values(settings.ui.optsAfterFirstMsg).forEach(ele=>ele.disabled=autoscrollDisabled);Object.values(settings.ui.optsAfterSavedMsg).forEach(ele=>ele.disabled=autoscrollDisabled)}};return{showController(){if(controller){return}const html=` + + + +

Waiting

`;controller={styles:DOM.createStyle(`/*[CSS-CONTROLLER]*/`),ele:DOM.createElement("div",document.body,"dht-ctrl",html)};controller.ui={btnSettings:DOM.id("dht-ctrl-settings"),btnTrack:DOM.id("dht-ctrl-track"),btnClose:DOM.id("dht-ctrl-close"),textStatus:DOM.id("dht-ctrl-status")};controller.ui.btnSettings.addEventListener("click",()=>{this.showSettings()});controller.ui.btnTrack.addEventListener("click",()=>{const isTracking=!STATE.isTracking();STATE.setIsTracking(isTracking);if(!isTracking){controller.ui.textStatus.innerText="Stopped"}});controller.ui.btnClose.addEventListener("click",()=>{this.hideController();window.DHT_ON_UNLOAD.forEach(f=>f());window.DHT_LOADED=false});STATE.onTrackingStateChanged(isTracking=>{controller.ui.btnTrack.innerText=isTracking?"Pause Tracking":"Start Tracking";controller.ui.btnSettings.disabled=isTracking});SETTINGS.onSettingsChanged(stateChangedEvent);stateChangedEvent()},hideController(){if(controller){DOM.removeElement(controller.ele);DOM.removeElement(controller.styles);controller=null}},showSettings(){if(settings){return}const radio=(type,id,label)=>"
";const html=` +
+
+
+${radio("afm","nothing","Do Nothing")} +${radio("afm","pause","Pause Tracking")} +${radio("afm","switch","Switch to Next Channel")} +
+
+${radio("asm","nothing","Do Nothing")} +${radio("asm","pause","Pause Tracking")} +${radio("asm","switch","Switch to Next Channel")} +

It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.

`;settings={styles:DOM.createStyle(`/*[CSS-SETTINGS]*/`),overlay:DOM.createElement("div",document.body,"dht-cfg-overlay"),ele:DOM.createElement("div",document.body,"dht-cfg",html)};settings.overlay.addEventListener("click",()=>{this.hideSettings()});settings.ui={cbAutoscroll:DOM.id("dht-cfg-autoscroll"),optsAfterFirstMsg:{},optsAfterSavedMsg:{}};settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING]=DOM.id("dht-cfg-afm-nothing");settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE]=DOM.id("dht-cfg-afm-pause");settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH]=DOM.id("dht-cfg-afm-switch");settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING]=DOM.id("dht-cfg-asm-nothing");settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE]=DOM.id("dht-cfg-asm-pause");settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH]=DOM.id("dht-cfg-asm-switch");settings.ui.cbAutoscroll.addEventListener("change",()=>{SETTINGS.autoscroll=settings.ui.cbAutoscroll.checked});Object.keys(settings.ui.optsAfterFirstMsg).forEach(key=>{settings.ui.optsAfterFirstMsg[key].addEventListener("click",()=>{SETTINGS.afterFirstMsg=key})});Object.keys(settings.ui.optsAfterSavedMsg).forEach(key=>{settings.ui.optsAfterSavedMsg[key].addEventListener("click",()=>{SETTINGS.afterSavedMsg=key})});stateChangedEvent()},hideSettings(){if(settings){DOM.removeElement(settings.overlay);DOM.removeElement(settings.ele);DOM.removeElement(settings.styles);settings=null}},setStatus(state){if(controller){controller.ui.textStatus.innerText=state}}}}(); \ No newline at end of file diff --git a/app/Resources/Tracker/scripts.min/settings.js b/app/Resources/Tracker/scripts.min/settings.js new file mode 100644 index 0000000..d06389c --- /dev/null +++ b/app/Resources/Tracker/scripts.min/settings.js @@ -0,0 +1 @@ +const CONSTANTS={AUTOSCROLL_ACTION_NOTHING:"optNothing",AUTOSCROLL_ACTION_PAUSE:"optPause",AUTOSCROLL_ACTION_SWITCH:"optSwitch"};let IS_FIRST_RUN=false;const SETTINGS=function(){const settingsChangedEvents=[];const saveSettings=function(){DOM.saveToCookie("DHT_SETTINGS",root,60*60*24*365*5)};const triggerSettingsChanged=function(property){for(const callback of settingsChangedEvents){callback(property)}saveSettings()};const defineTriggeringProperty=function(obj,property,value){const name="_"+property;Object.defineProperty(obj,property,{get:()=>obj[name],set:value=>{obj[name]=value;triggerSettingsChanged(property)}});obj[name]=value};let loaded=DOM.loadFromCookie("DHT_SETTINGS");if(!loaded){loaded={_autoscroll:true,_afterFirstMsg:CONSTANTS.AUTOSCROLL_ACTION_PAUSE,_afterSavedMsg:CONSTANTS.AUTOSCROLL_ACTION_PAUSE};IS_FIRST_RUN=true}const root={onSettingsChanged(callback){settingsChangedEvents.push(callback)}};defineTriggeringProperty(root,"autoscroll",loaded._autoscroll);defineTriggeringProperty(root,"afterFirstMsg",loaded._afterFirstMsg);defineTriggeringProperty(root,"afterSavedMsg",loaded._afterSavedMsg);if(IS_FIRST_RUN){saveSettings()}return root}(); \ No newline at end of file diff --git a/app/Resources/Tracker/scripts.min/state.js b/app/Resources/Tracker/scripts.min/state.js new file mode 100644 index 0000000..c48cffb --- /dev/null +++ b/app/Resources/Tracker/scripts.min/state.js @@ -0,0 +1 @@ +const STATE=function(){let serverPort=-1;let serverToken="";const post=function(endpoint,json){const aborter=new AbortController;const timeout=window.setTimeout(()=>aborter.abort(),5e3);return new Promise(async(resolve,reject)=>{let r;try{r=await fetch("http://127.0.0.1:"+serverPort+endpoint,{method:"POST",headers:{"Content-Type":"application/json","X-DHT-Token":serverToken},credentials:"omit",redirect:"error",body:JSON.stringify(json),signal:aborter.signal})}catch(e){if(e.name==="AbortError"){reject({status:"DISCONNECTED"});return}else{reject({status:"ERROR",message:e.message});return}}finally{window.clearTimeout(timeout)}if(r.status===200){resolve(r);return}try{const message=await r.text();reject({status:"ERROR",message:message})}catch(e){reject({status:"ERROR",message:e.message})}})};const trackingStateChangedListeners=[];let isTracking=false;const addedChannels=new Set;const addedUsers=new Set;return{setup(port,token){serverPort=port;serverToken=token},onTrackingStateChanged(callback){trackingStateChangedListeners.push(callback);callback(isTracking)},isTracking(){return isTracking},setIsTracking(state){if(isTracking!==state){isTracking=state;if(isTracking){addedChannels.clear();addedUsers.clear()}for(const callback of trackingStateChangedListeners){callback(isTracking)}}},async addDiscordChannel(serverInfo,channelInfo){if(addedChannels.has(channelInfo.id)){return}const server={id:serverInfo.id,name:serverInfo.name,type:serverInfo.type};const channel={id:channelInfo.id,name:channelInfo.name};if("extra"in channelInfo){channel.position=channelInfo.extra.position;channel.topic=channelInfo.extra.topic;channel.nsfw=channelInfo.extra.nsfw}await post("/track-channel",{server:server,channel:channel});addedChannels.add(channelInfo.id)},async addDiscordMessages(channelId,discordMessageArray){const userInfo={};let hasNewUsers=false;for(const msg of discordMessageArray){const user=msg.author;if(!addedUsers.has(user.id)){const obj={id:user.id,name:user.username};if(user.avatar){obj.avatar=user.avatar}if(!user.bot){obj.discriminator=user.discriminator}userInfo[user.id]=obj;hasNewUsers=true}}if(hasNewUsers){await post("/track-users",Object.values(userInfo));for(const id of Object.keys(userInfo)){addedUsers.add(id)}}const response=await post("/track-messages",discordMessageArray.map(msg=>{const obj={id:msg.id,sender:msg.author.id,channel:msg.channel_id,text:msg.content,timestamp:msg.timestamp.toDate().getTime()};if(msg.editedTimestamp!==null){obj.editTimestamp=msg.editedTimestamp.toDate().getTime()}if(msg.messageReference!==null){obj.repliedToId=msg.messageReference.message_id}if(msg.attachments.length>0){obj.attachments=msg.attachments.map(attachment=>{const mapped={id:attachment.id,name:attachment.filename,size:attachment.size,url:attachment.url};if(attachment.content_type){mapped.type=attachment.content_type}return mapped})}if(msg.embeds.length>0){obj.embeds=msg.embeds.map(embed=>{const mapped={};for(const key of Object.keys(embed)){if(key==="id"){continue}if(key==="rawTitle"){mapped["title"]=embed[key]}else if(key==="rawDescription"){mapped["description"]=embed[key]}else{mapped[key]=embed[key]}}return JSON.stringify(mapped)})}if(msg.reactions.length>0){obj.reactions=msg.reactions.map(reaction=>{const emoji=reaction.emoji;const mapped={count:reaction.count};if(emoji.id){mapped.id=emoji.id}if(emoji.name){mapped.name=emoji.name}if(emoji.animated){mapped.isAnimated=emoji.animated}return mapped})}return obj}));const anyNewMessages=await response.text();return anyNewMessages==="1"}}}(); \ No newline at end of file diff --git a/app/Resources/Tracker/scripts/discord.js b/app/Resources/Tracker/scripts/discord.js new file mode 100644 index 0000000..d19522b --- /dev/null +++ b/app/Resources/Tracker/scripts/discord.js @@ -0,0 +1,271 @@ +class DISCORD { + static getMessageOuterElement() { + return DOM.queryReactClass("messagesWrapper"); + } + + static getMessageScrollerElement() { + return this.getMessageOuterElement().querySelector("[class*='scroller-']"); + } + + static hasMoreMessages() { + return document.querySelector("#messagesNavigationDescription + [class^=container]") === null; + } + + static loadOlderMessages() { + const view = this.getMessageScrollerElement(); + + if (view.scrollTop > 0) { + view.scrollTop = 0; + } + } + + /** + * Calls the provided function with a list of messages whenever the currently loaded messages change. + * Returns a setInterval handle. + */ + static setupMessageCallback(callback) { + let skipsLeft = 0; + let waitForCleanup = false; + const previousMessages = new Set(); + + return window.setInterval(() => { + if (skipsLeft > 0) { + --skipsLeft; + return; + } + + const view = this.getMessageOuterElement(); + + if (!view) { + skipsLeft = 2; + return; + } + + const anyMessage = this.getMessageOuterElement().querySelector("[class*='message-']"); + const messageCount = anyMessage ? anyMessage.parentElement.children.length : 0; + + if (messageCount > 300) { + if (waitForCleanup) { + return; + } + + skipsLeft = 3; + waitForCleanup = true; + + window.setTimeout(() => { + const view = this.getMessageScrollerElement(); + // noinspection JSUnusedGlobalSymbols + view.scrollTop = view.scrollHeight / 2; + }, 1); + } + else { + waitForCleanup = false; + } + + const messages = this.getMessages(); + let hasChanged = false; + + for (const message of messages) { + if (!previousMessages.has(message.id)) { + hasChanged = true; + break; + } + } + + if (!hasChanged) { + return; + } + + previousMessages.clear(); + for (const message of messages) { + previousMessages.add(message.id); + } + + callback(messages); + }, 200); + } + + /** + * Returns an object containing the selected server and channel information. + * For types DM and GROUP, the server and channel ids and names are identical. + * @returns {{}|null} + */ + static getSelectedChannel() { + try { + let obj; + let channelListEle = DOM.queryReactClass("privateChannels"); + + if (channelListEle) { + const channel = DOM.queryReactClass("selected", channelListEle); + + if (!channel || !("href" in channel) || !channel.href.includes("/@me/")) { + return null; + } + + const linkSplit = channel.href.split("/"); + const id = linkSplit[linkSplit.length - 1]; + + if (!(/^\d+$/.test(id))) { + return null; + } + + let name; + + for (const ele of channel.querySelectorAll("[class^='name-'] *")) { + const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE); + + if (node) { + name = node.nodeValue; + break; + } + } + + if (!name) { + return null; + } + + const icon = channel.querySelector("img[class*='avatar']"); + const iconParent = icon && icon.closest("foreignObject"); + const iconMask = iconParent && iconParent.getAttribute("mask"); + + obj = { + "server": { id, name, type: (iconMask && iconMask.includes("#svg-mask-avatar-default")) ? "GROUP" : "DM" }, + "channel": { id, name } + }; + } + else { + channelListEle = document.getElementById("channels"); + + const channel = channelListEle.querySelector("[class*='modeSelected']").parentElement; + // noinspection JSUnresolvedVariable + const props = DOM.getReactProps(channel).children.props; + + if (!props) { + return null; + } + + // noinspection JSUnresolvedVariable + const channelObj = props.channel || props.children().props.channel; + + if (!channelObj) { + return null; + } + + // noinspection JSUnresolvedVariable + obj = { + "server": { + "id": channelObj.guild_id, + "name": document.querySelector("nav header > h1").innerText, + "type": "SERVER" + }, + "channel": { + "id": channelObj.id, + "name": channelObj.name, + "extra": { + "position": channelObj.position, + "topic": channelObj.topic, + "nsfw": channelObj.nsfw + } + } + }; + } + + return obj.channel.length === 0 ? null : obj; + } catch (e) { + console.error(e); + return null; + } + } + + /** + * Returns an array containing currently loaded messages, or null if the messages cannot be retrieved. + */ + static getMessages() { + try { + const scroller = this.getMessageScrollerElement(); + const props = DOM.getReactProps(scroller); + let wrappers; + + try { + // noinspection JSUnresolvedVariable + wrappers = props.children.props.children.props.children.props.children.find(ele => Array.isArray(ele)); + } catch (e) { // old version compatibility + wrappers = props.children.find(ele => Array.isArray(ele)); + } + + const messages = []; + + for (const obj of wrappers) { + // noinspection JSUnresolvedVariable + const nested = obj.props; + + if (nested && nested.message) { + messages.push(nested.message); + } + } + + return messages; + } catch (e) { + console.error(e); + return null; + } + } + + /** + * Selects the next text channel and returns true, otherwise returns false if there are no more channels. + */ + static selectNextTextChannel() { + const dms = DOM.queryReactClass("privateChannels"); + + if (dms) { + const currentChannel = DOM.queryReactClass("selected", dms); + const nextChannel = currentChannel && currentChannel.nextElementSibling; + + if (!nextChannel || !nextChannel.getAttribute("class").includes("channel-") || !("href" in nextChannel) || !nextChannel.href.includes("/@me/")) { + return false; + } + else { + nextChannel.click(); + nextChannel.scrollIntoView(true); + return true; + } + } + else { + const channelIconNormal = "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41045 9L8.35045 15H14.3504L15.4104 9H9.41045Z"; + const channelIconSpecial = "M14 8C14 7.44772 13.5523 7 13 7H9.76001L10.3657 3.58738C10.4201 3.28107 10.1845 3 9.87344 3H8.88907C8.64664 3 8.43914 3.17391 8.39677 3.41262L7.76001 7H4.18011C3.93722 7 3.72946 7.17456 3.68759 7.41381L3.51259 8.41381C3.45905 8.71977 3.69449 9 4.00511 9H7.41001L6.35001 15H2.77011C2.52722 15 2.31946 15.1746 2.27759 15.4138L2.10259 16.4138C2.04905 16.7198 2.28449 17 2.59511 17H6.00001L5.39427 20.4126C5.3399 20.7189 5.57547 21 5.88657 21H6.87094C7.11337 21 7.32088 20.8261 7.36325 20.5874L8.00001 17H14L13.3943 20.4126C13.3399 20.7189 13.5755 21 13.8866 21H14.8709C15.1134 21 15.3209 20.8261 15.3632 20.5874L16 17H19.5799C19.8228 17 20.0306 16.8254 20.0724 16.5862L20.2474 15.5862C20.301 15.2802 20.0655 15 19.7549 15H16.35L16.6758 13.1558C16.7823 12.5529 16.3186 12 15.7063 12C15.2286 12 14.8199 12.3429 14.7368 12.8133L14.3504 15H8.35045L9.41045 9H13C13.5523 9 14 8.55228 14 8Z"; + + const isValidChannelClass = cls => cls.includes("wrapper-") && !cls.includes("clickable-"); + const isValidChannelType = ele => !!ele.querySelector("path[d=\"" + channelIconNormal + "\"]") || !!ele.querySelector("path[d=\"" + channelIconSpecial + "\"]"); + const isValidChannel = ele => ele.childElementCount > 0 && isValidChannelClass(ele.children[0].className) && isValidChannelType(ele); + + const channelListEle = document.querySelector("div[class*='sidebar'] > nav[class*='container'] > div[class*='scroller']"); + + if (!channelListEle) { + return false; + } + + const allChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), isValidChannel); + let nextChannel = null; + + for (let index = 0; index < allChannels.length - 1; index++) { + if (allChannels[index].children[0].className.includes("modeSelected")) { + nextChannel = allChannels[index + 1]; + break; + } + } + + if (nextChannel === null) { + return false; + } + + const nextChannelLink = nextChannel.querySelector("a[href^='/channels/']"); + if (!nextChannelLink) { + return false; + } + + nextChannelLink.click(); + nextChannel.scrollIntoView(true); + return true; + } + } +} diff --git a/app/Resources/Tracker/scripts/dom.js b/app/Resources/Tracker/scripts/dom.js new file mode 100644 index 0000000..ea6fb34 --- /dev/null +++ b/app/Resources/Tracker/scripts/dom.js @@ -0,0 +1,74 @@ +class DOM { + /** + * Returns a child element by its ID. Parent defaults to the entire document. + * @returns {HTMLElement} + */ + static id(id, parent) { + return (parent || document).getElementById(id); + } + + /** + * Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document. + */ + static queryReactClass(cls, parent) { + return (parent || document).querySelector(`[class*="${cls}-"]`); + } + + /** + * Creates an element, adds it to the DOM, and returns it. + */ + static createElement(tag, parent, id, html) { + /** @type HTMLElement */ + const ele = document.createElement(tag); + ele.id = id || ""; + ele.innerHTML = html || ""; + parent.appendChild(ele); + return ele; + } + + /** + * Removes an element from the DOM. + */ + static removeElement(ele) { + return ele.parentNode.removeChild(ele); + } + + /** + * Creates a new style element with the specified CSS and returns it. + */ + static createStyle(styles) { + return this.createElement("style", document.head, "", styles); + } + + /** + * Utility function to save an object into a cookie. + */ + static saveToCookie(name, obj, expiresInSeconds) { + const expires = new Date(Date.now() + 1000 * expiresInSeconds).toUTCString(); + document.cookie = name + "=" + encodeURIComponent(JSON.stringify(obj)) + ";path=/;expires=" + expires; + } + + /** + * Utility function to load an object from a cookie. + */ + static loadFromCookie(name) { + const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1"); + return value.length ? JSON.parse(decodeURIComponent(value)) : null; + } + + /** + * Returns internal React state object of an element. + */ + static getReactProps(ele) { + const keys = Object.keys(ele || {}); + let key = keys.find(key => key.startsWith("__reactInternalInstance")); + + if (key) { + // noinspection JSUnresolvedVariable + return ele[key].memoizedProps; + } + + key = keys.find(key => key.startsWith("__reactProps$")); + return key ? ele[key] : null; + } +} diff --git a/app/Resources/Tracker/scripts/gui.js b/app/Resources/Tracker/scripts/gui.js new file mode 100644 index 0000000..6f879da --- /dev/null +++ b/app/Resources/Tracker/scripts/gui.js @@ -0,0 +1,156 @@ +// noinspection FunctionWithInconsistentReturnsJS +const GUI = (function() { + let controller = null; + let settings = null; + + const stateChangedEvent = () => { + if (settings) { + settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll; + settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true; + settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true; + + const autoscrollDisabled = !SETTINGS.autoscroll; + Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollDisabled); + Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollDisabled); + } + }; + + return { + showController() { + if (controller) { + return; + } + + const html = ` + + + +

Waiting

`; + + controller = { + styles: DOM.createStyle(`/*[CSS-CONTROLLER]*/`), + ele: DOM.createElement("div", document.body, "dht-ctrl", html) + }; + + controller.ui = { + btnSettings: DOM.id("dht-ctrl-settings"), + btnTrack: DOM.id("dht-ctrl-track"), + btnClose: DOM.id("dht-ctrl-close"), + textStatus: DOM.id("dht-ctrl-status") + }; + + controller.ui.btnSettings.addEventListener("click", () => { + this.showSettings(); + }); + + controller.ui.btnTrack.addEventListener("click", () => { + const isTracking = !STATE.isTracking(); + STATE.setIsTracking(isTracking); + + if (!isTracking) { + controller.ui.textStatus.innerText = "Stopped"; + } + }); + + controller.ui.btnClose.addEventListener("click", () => { + this.hideController(); + window.DHT_ON_UNLOAD.forEach(f => f()); + window.DHT_LOADED = false; + }); + + STATE.onTrackingStateChanged(isTracking => { + controller.ui.btnTrack.innerText = isTracking ? "Pause Tracking" : "Start Tracking"; + controller.ui.btnSettings.disabled = isTracking; + }); + + SETTINGS.onSettingsChanged(stateChangedEvent); + stateChangedEvent(); + }, + + hideController() { + if (controller) { + DOM.removeElement(controller.ele); + DOM.removeElement(controller.styles); + controller = null; + } + }, + + showSettings() { + if (settings) { + return; + } + + const radio = (type, id, label) => "
"; + const html = ` +
+
+
+${radio("afm", "nothing", "Do Nothing")} +${radio("afm", "pause", "Pause Tracking")} +${radio("afm", "switch", "Switch to Next Channel")} +
+
+${radio("asm", "nothing", "Do Nothing")} +${radio("asm", "pause", "Pause Tracking")} +${radio("asm", "switch", "Switch to Next Channel")} +

It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.

`; + + settings = { + styles: DOM.createStyle(`/*[CSS-SETTINGS]*/`), + overlay: DOM.createElement("div", document.body, "dht-cfg-overlay"), + ele: DOM.createElement("div", document.body, "dht-cfg", html) + }; + + settings.overlay.addEventListener("click", () => { + this.hideSettings(); + }); + + settings.ui = { + cbAutoscroll: DOM.id("dht-cfg-autoscroll"), + optsAfterFirstMsg: {}, + optsAfterSavedMsg: {} + }; + + settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing"); + settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE] = DOM.id("dht-cfg-afm-pause"); + settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH] = DOM.id("dht-cfg-afm-switch"); + + settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-asm-nothing"); + settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE] = DOM.id("dht-cfg-asm-pause"); + settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH] = DOM.id("dht-cfg-asm-switch"); + + settings.ui.cbAutoscroll.addEventListener("change", () => { + SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked; + }); + + Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => { + settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => { + SETTINGS.afterFirstMsg = key; + }); + }); + + Object.keys(settings.ui.optsAfterSavedMsg).forEach(key => { + settings.ui.optsAfterSavedMsg[key].addEventListener("click", () => { + SETTINGS.afterSavedMsg = key; + }); + }); + + stateChangedEvent(); + }, + + hideSettings() { + if (settings) { + DOM.removeElement(settings.overlay); + DOM.removeElement(settings.ele); + DOM.removeElement(settings.styles); + settings = null; + } + }, + + setStatus(state) { + if (controller) { + controller.ui.textStatus.innerText = state; + } + } + }; +})(); diff --git a/app/Resources/Tracker/scripts/settings.js b/app/Resources/Tracker/scripts/settings.js new file mode 100644 index 0000000..190f0e3 --- /dev/null +++ b/app/Resources/Tracker/scripts/settings.js @@ -0,0 +1,65 @@ +const CONSTANTS = { + AUTOSCROLL_ACTION_NOTHING: "optNothing", + AUTOSCROLL_ACTION_PAUSE: "optPause", + AUTOSCROLL_ACTION_SWITCH: "optSwitch" +}; + +let IS_FIRST_RUN = false; + +const SETTINGS = (function() { + const settingsChangedEvents = []; + + const saveSettings = function() { + DOM.saveToCookie("DHT_SETTINGS", root, 60 * 60 * 24 * 365 * 5); + }; + + const triggerSettingsChanged = function(property) { + for (const callback of settingsChangedEvents) { + callback(property); + } + + saveSettings(); + }; + + const defineTriggeringProperty = function(obj, property, value) { + const name = "_" + property; + + Object.defineProperty(obj, property, { + get: (() => obj[name]), + set: (value => { + obj[name] = value; + triggerSettingsChanged(property); + }) + }); + + obj[name] = value; + }; + + let loaded = DOM.loadFromCookie("DHT_SETTINGS"); + + if (!loaded) { + loaded = { + "_autoscroll": true, + "_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE, + "_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE + }; + + IS_FIRST_RUN = true; + } + + const root = { + onSettingsChanged(callback) { + settingsChangedEvents.push(callback); + } + }; + + defineTriggeringProperty(root, "autoscroll", loaded._autoscroll); + defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg); + defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg); + + if (IS_FIRST_RUN) { + saveSettings(); + } + + return root; +})(); diff --git a/app/Resources/Tracker/scripts/state.js b/app/Resources/Tracker/scripts/state.js new file mode 100644 index 0000000..6035501 --- /dev/null +++ b/app/Resources/Tracker/scripts/state.js @@ -0,0 +1,299 @@ +// noinspection FunctionWithInconsistentReturnsJS +const STATE = (function() { + let serverPort = -1; + let serverToken = ""; + + const post = function(endpoint, json) { + const aborter = new AbortController(); + const timeout = window.setTimeout(() => aborter.abort(), 5000); + + return new Promise(async (resolve, reject) => { + let r; + try { + r = await fetch("http://127.0.0.1:" + serverPort + endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-DHT-Token": serverToken + }, + credentials: "omit", + redirect: "error", + body: JSON.stringify(json), + signal: aborter.signal + }); + } catch (e) { + if (e.name === "AbortError") { + reject({ status: "DISCONNECTED" }); + return; + } + else { + reject({ status: "ERROR", message: e.message }); + return; + } + } finally { + window.clearTimeout(timeout); + } + + if (r.status === 200) { + resolve(r); + return; + } + + try { + const message = await r.text(); + reject({ status: "ERROR", message }); + } catch (e) { + reject({ status: "ERROR", message: e.message }); + } + }); + }; + + const trackingStateChangedListeners = []; + let isTracking = false; + + const addedChannels = new Set(); + const addedUsers = new Set(); + + /** + * @name DiscordUser + * @property {String} id + * @property {String} username + * @property {String} discriminator + * @property {String} [avatar] + * @property {Boolean} [bot] + */ + + /** + * @name DiscordMessage + * @property {String} id + * @property {String} channel_id + * @property {DiscordUser} author + * @property {String} content + * @property {Timestamp} timestamp + * @property {Timestamp|null} editedTimestamp + * @property {DiscordAttachment[]} attachments + * @property {Object[]} embeds + * @property {DiscordMessageReaction[]} [reactions] + * @property {DiscordMessageReference} [messageReference] + */ + + /** + * @name DiscordAttachment + * @property {String} id + * @property {String} filename + * @property {String} [content_type] + * @property {String} size + * @property {String} url + */ + + /** + * @name DiscordMessageReaction + * @property {DiscordEmoji} emoji + * @property {Number} count + */ + + /** + * @name DiscordMessageReference + * @property {String} [message_id] + */ + + /** + * @name DiscordEmoji + * @property {String|null} id + * @property {String|null} name + * @property {Boolean} animated + */ + + /** + * @name Timestamp + * @property {Function} toDate + */ + + return { + setup(port, token) { + serverPort = port; + serverToken = token; + }, + + onTrackingStateChanged(callback) { + trackingStateChangedListeners.push(callback); + callback(isTracking); + }, + + isTracking() { + return isTracking; + }, + + setIsTracking(state) { + if (isTracking !== state) { + isTracking = state; + + if (isTracking) { + addedChannels.clear(); + addedUsers.clear(); + } + + for (const callback of trackingStateChangedListeners) { + callback(isTracking); + } + } + }, + + async addDiscordChannel(serverInfo, channelInfo) { + if (addedChannels.has(channelInfo.id)) { + return; + } + + const server = { + id: serverInfo.id, + name: serverInfo.name, + type: serverInfo.type + }; + + const channel = { + id: channelInfo.id, + name: channelInfo.name + }; + + if ("extra" in channelInfo) { + channel.position = channelInfo.extra.position; + channel.topic = channelInfo.extra.topic; + channel.nsfw = channelInfo.extra.nsfw; + } + + await post("/track-channel", { server, channel }); + addedChannels.add(channelInfo.id); + }, + + /** + * @param {String} channelId + * @param {DiscordMessage[]} discordMessageArray + */ + async addDiscordMessages(channelId, discordMessageArray) { + const userInfo = {}; + let hasNewUsers = false; + + for (const msg of discordMessageArray) { + const user = msg.author; + + if (!addedUsers.has(user.id)) { + const obj = { + id: user.id, + name: user.username + }; + + if (user.avatar) { + obj.avatar = user.avatar; + } + + if (!user.bot) { + // noinspection JSUnusedGlobalSymbols + obj.discriminator = user.discriminator; + } + + userInfo[user.id] = obj; + hasNewUsers = true; + } + } + + if (hasNewUsers) { + await post("/track-users", Object.values(userInfo)); + + for (const id of Object.keys(userInfo)) { + addedUsers.add(id); + } + } + + const response = await post("/track-messages", discordMessageArray.map(msg => { + const obj = { + id: msg.id, + sender: msg.author.id, + channel: msg.channel_id, + text: msg.content, + timestamp: msg.timestamp.toDate().getTime() + }; + + if (msg.editedTimestamp !== null) { + // noinspection JSUnusedGlobalSymbols + obj.editTimestamp = msg.editedTimestamp.toDate().getTime(); + } + + if (msg.messageReference !== null) { + // noinspection JSUnusedGlobalSymbols + obj.repliedToId = msg.messageReference.message_id; + } + + if (msg.attachments.length > 0) { + obj.attachments = msg.attachments.map(attachment => { + const mapped = { + id: attachment.id, + name: attachment.filename, + size: attachment.size, + url: attachment.url + }; + + if (attachment.content_type) { + mapped.type = attachment.content_type; + } + + return mapped; + }); + } + + if (msg.embeds.length > 0) { + obj.embeds = msg.embeds.map(embed => { + const mapped = {}; + + for (const key of Object.keys(embed)) { + if (key === "id") { + continue; + } + + if (key === "rawTitle") { + mapped["title"] = embed[key]; + } + else if (key === "rawDescription") { + mapped["description"] = embed[key]; + } + else { + mapped[key] = embed[key]; + } + } + + return JSON.stringify(mapped); + }); + } + + if (msg.reactions.length > 0) { + obj.reactions = msg.reactions.map(reaction => { + const emoji = reaction.emoji; + + const mapped = { + count: reaction.count + }; + + if (emoji.id) { + mapped.id = emoji.id; + } + + if (emoji.name) { + mapped.name = emoji.name; + } + + if (emoji.animated) { + // noinspection JSUnusedGlobalSymbols + mapped.isAnimated = emoji.animated; + } + + return mapped; + }); + } + + return obj; + })); + + const anyNewMessages = await response.text(); + return anyNewMessages === "1"; + } + }; +})(); diff --git a/app/Resources/Tracker/styles/controller.css b/app/Resources/Tracker/styles/controller.css new file mode 100644 index 0000000..f9f1baf --- /dev/null +++ b/app/Resources/Tracker/styles/controller.css @@ -0,0 +1,31 @@ +#app-mount > div[class*="app-"] { + margin-bottom: 48px !important; +} + +#dht-ctrl { + position: absolute; + bottom: 0; + width: 100%; + height: 48px; + background-color: #fff; +} + +#dht-ctrl button { + height: 32px; + margin: 8px 0 8px 8px; + font-size: 16px; + padding: 0 12px; + background-color: #7289da; + color: #fff; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75); +} + +#dht-ctrl button:disabled { + background-color: #7a7a7a; + cursor: default; +} + +#dht-ctrl p { + display: inline-block; + margin: 14px 12px; +} diff --git a/app/Resources/Tracker/styles/settings.css b/app/Resources/Tracker/styles/settings.css new file mode 100644 index 0000000..e21cc9a --- /dev/null +++ b/app/Resources/Tracker/styles/settings.css @@ -0,0 +1,28 @@ +#dht-cfg-overlay { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: #000; + opacity: 0.5; + display: block; + z-index: 1000; +} + +#dht-cfg { + position: absolute; + left: 50%; + top: 50%; + width: 800px; + height: 262px; + margin-left: -400px; + margin-top: -131px; + padding: 8px; + background-color: #fff; + z-index: 1001; +} + +#dht-cfg-note { + margin-top: 22px; +} diff --git a/app/Resources/Viewer/index.html b/app/Resources/Viewer/index.html new file mode 100644 index 0000000..28280a9 --- /dev/null +++ b/app/Resources/Viewer/index.html @@ -0,0 +1,76 @@ + + + + + Discord Offline History + + + + + + + +
+
+
+
+ + + + diff --git a/app/Resources/Viewer/scripts/bootstrap.js b/app/Resources/Viewer/scripts/bootstrap.js new file mode 100644 index 0000000..021c030 --- /dev/null +++ b/app/Resources/Viewer/scripts/bootstrap.js @@ -0,0 +1,39 @@ +document.addEventListener("DOMContentLoaded", () => { + DISCORD.setup(); + GUI.setup(); + + GUI.onOptionMessagesPerPageChanged(() => { + STATE.setMessagesPerPage(GUI.getOptionMessagesPerPage()); + }); + + STATE.setMessagesPerPage(GUI.getOptionMessagesPerPage()); + + GUI.onOptMessageFilterChanged(filter => { + STATE.setActiveFilter(filter); + }); + + GUI.onNavigationButtonClicked(action => { + STATE.updateCurrentPage(action); + }); + + STATE.onUsersRefreshed(users => { + GUI.updateUserList(users); + }); + + STATE.onChannelsRefreshed((channels, selected) => { + GUI.updateChannelList(channels, selected, STATE.selectChannel); + }); + + STATE.onMessagesRefreshed(messages => { + GUI.updateNavigation(STATE.getCurrentPage(), STATE.getPageCount()); + GUI.updateMessageList(messages); + GUI.scrollMessagesToTop(); + }); + + try { + STATE.uploadFile(JSON.parse(window.DHT_EMBEDDED)); + } catch (e) { + console.error(e); + alert("Could not parse embedded file, see console for details."); + } +}); diff --git a/app/Resources/Viewer/scripts/discord.js b/app/Resources/Viewer/scripts/discord.js new file mode 100644 index 0000000..c9f853c --- /dev/null +++ b/app/Resources/Viewer/scripts/discord.js @@ -0,0 +1,257 @@ +const DISCORD = (function() { + const regex = { + formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g, + formatItalic: /(.)?\*([\s\S]+?)\*(?!\*)/g, + formatUnderline: /__([\s\S]+?)__(?!_)/g, + formatStrike: /~~([\s\S]+?)~~(?!~)/g, + formatCodeInline: /(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/g, + formatCodeBlock: /```(?:([A-z0-9\-]+?)\n+)?\n*([^]+?)\n*```/g, + formatUrl: /(\b(?:https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig, + formatUrlNoEmbed: /<(\b(?:https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])>/ig, + specialEscapedBacktick: /\\`/g, + specialEscapedSingle: /\\([*\\])/g, + specialEscapedDouble: /\\__|_\\_|\\_\\_|\\~~|~\\~|\\~\\~/g, + specialUnescaped: /([*_~\\])/g, + mentionRole: /<@&(\d+?)>/g, + mentionUser: /<@!?(\d+?)>/g, + mentionChannel: /<#(\d+?)>/g, + customEmojiStatic: /<:([^:]+):(\d+?)>/g, + customEmojiAnimated: /<a:([^:]+):(\d+?)>/g + }; + + let templateChannelServer; + let templateChannelPrivate; + let templateMessageNoAvatar; + let templateMessageWithAvatar; + let templateUserAvatar; + let templateAttachmentDownload; + let templateEmbedImage; + let templateEmbedRich; + let templateEmbedRichNoDescription; + let templateEmbedUrl; + let templateEmbedUnsupported; + let templateReaction; + let templateReactionCustom; + + const processMessageContents = function(contents) { + let processed = DOM.escapeHTML(contents.replace(regex.formatUrlNoEmbed, "$1")); + + if (SETTINGS.enableFormatting) { + const escapeHtmlMatch = (full, match) => "&#" + match.charCodeAt(0) + ";"; + + processed = processed + .replace(regex.specialEscapedBacktick, "`") + .replace(regex.formatCodeBlock, (full, ignore, match) => "" + match.replace(regex.specialUnescaped, escapeHtmlMatch) + "") + .replace(regex.formatCodeInline, (full, ignore, match) => "" + match.replace(regex.specialUnescaped, escapeHtmlMatch) + "") + .replace(regex.specialEscapedSingle, escapeHtmlMatch) + .replace(regex.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch)) + .replace(regex.formatBold, "$1") + .replace(regex.formatItalic, (full, pre, match) => pre === "\\" ? full : (pre || "") + "" + match + "") + .replace(regex.formatUnderline, "$1") + .replace(regex.formatStrike, "$1"); + } + + const animatedEmojiExtension = SETTINGS.enableAnimatedEmoji ? "gif" : "png"; + + // noinspection HtmlUnknownTarget + processed = processed + .replace(regex.formatUrl, "
$1") + .replace(regex.mentionChannel, (full, match) => "#" + STATE.getChannelName(match) + "") + .replace(regex.mentionUser, (full, match) => "@" + STATE.getUserName(match) + "") + .replace(regex.customEmojiStatic, ":$1:") + .replace(regex.customEmojiAnimated, ":$1:"); + + return "

" + processed + "

"; + }; + + return { + setup() { + templateChannelServer = new TEMPLATE([ + "
", + "
#{name}{nsfw}{msgcount}
", + "{server.name} ({server.type})", + "
" + ].join("")); + + templateChannelPrivate = new TEMPLATE([ + "
", + "
{name}{msgcount}
", + "({server.type})", + "
" + ].join("")); + + templateMessageNoAvatar = new TEMPLATE([ + "
", + "
{reply}
", + "

{user.name}{timestamp}{edit}{jump}

", + "
{contents}{embeds}{attachments}
", + "{reactions}", + "
" + ].join("")); + + templateMessageWithAvatar = new TEMPLATE([ + "
", + "
{reply}
", + "
", + "
{avatar}
", + "
", + "

{user.name}{timestamp}{edit}{jump}

", + "
{contents}{embeds}{attachments}
", + "{reactions}", + "
", + "
", + "
" + ].join("")); + + templateUserAvatar = new TEMPLATE([ + "" + ].join("")); + + // noinspection HtmlUnknownTarget + templateAttachmentDownload = new TEMPLATE([ + "Download {filename}" + ].join("")); + + // noinspection HtmlUnknownTarget + templateEmbedImage = new TEMPLATE([ + "(image attachment not found)
" + ].join("")); + + // noinspection HtmlUnknownTarget + templateEmbedRich = new TEMPLATE([ + "
{title}

{description}

" + ].join("")); + + // noinspection HtmlUnknownTarget + templateEmbedRichNoDescription = new TEMPLATE([ + "" + ].join("")); + + // noinspection HtmlUnknownTarget + templateEmbedUrl = new TEMPLATE([ + "{url}" + ].join("")); + + templateEmbedUnsupported = new TEMPLATE([ + "

(Unsupported embed)

" + ].join("")); + + templateReaction = new TEMPLATE([ + "{n}{c}" + ].join("")); + + templateReactionCustom = new TEMPLATE([ + ":{n}:{c}" + ].join("")); + }, + + isImageAttachment(attachment) { + const dot = attachment.url.lastIndexOf("."); + const ext = dot === -1 ? "" : attachment.url.substring(dot).toLowerCase(); + return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg"; + }, + + getChannelHTML(channel) { // noinspection FunctionWithInconsistentReturnsJS + return (channel.server.type === "server" ? templateChannelServer : templateChannelPrivate).apply(channel, (property, value) => { + if (property === "nsfw") { + return value ? "NSFW" : ""; + } + }); + }, + + getMessageHTML(message) { // noinspection FunctionWithInconsistentReturnsJS + return (SETTINGS.enableUserAvatars ? templateMessageWithAvatar : templateMessageNoAvatar).apply(message, (property, value) => { + if (property === "avatar") { + return value ? templateUserAvatar.apply(value) : ""; + } + else if (property === "user.tag") { + return value ? value : "????"; + } + else if (property === "timestamp") { + return DOM.getHumanReadableTime(value); + } + else if (property === "contents") { + return value == null || value.length === 0 ? "" : processMessageContents(value); + } + else if (property === "embeds") { + if (!value) { + return ""; + } + + return value.map(embed => { + if (!embed.url) { + return templateEmbedUnsupported.apply(embed); + } + else if ("image" in embed && embed.image.url) { + return SETTINGS.enableImagePreviews ? templateEmbedImage.apply({ url: embed.url, src: embed.image.url }) : ""; + } + else if ("thumbnail" in embed && embed.thumbnail.url) { + return SETTINGS.enableImagePreviews ? templateEmbedImage.apply({ url: embed.url, src: embed.thumbnail.url }) : ""; + } + else if ("title" in embed && "description" in embed) { + return templateEmbedRich.apply(embed); + } + else if ("title" in embed) { + return templateEmbedRichNoDescription.apply(embed); + } + else { + return templateEmbedUrl.apply(embed); + } + }).join(""); + } + else if (property === "attachments") { + if (!value) { + return ""; + } + + return value.map(attachment => { + if (this.isImageAttachment(attachment) && SETTINGS.enableImagePreviews) { + return templateEmbedImage.apply({ url: attachment.url, src: attachment.url }); + } + else { + const sliced = attachment.url.split("/"); + + return templateAttachmentDownload.apply({ + "url": attachment.url, + "filename": sliced[sliced.length - 1] + }); + } + }).join(""); + } + else if (property === "edit") { + return value ? "Edited " + DOM.getHumanReadableTime(value) + "" : ""; + } + else if (property === "jump") { + return STATE.hasActiveFilter ? "Jump to message" : ""; + } + else if (property === "reply") { + if (value === null) { + return ""; + } + + const user = "" + value.user.name + ""; + const avatar = SETTINGS.enableUserAvatars && value.avatar ? "" + templateUserAvatar.apply(value.avatar) + "" : ""; + const contents = value.contents ? "" + processMessageContents(value.contents) + "" : ""; + + return "Jump to reply" + avatar + user + "" + contents; + } + else if (property === "reactions"){ + if (value === null){ + return ""; + } + + return "
" + value.map(reaction => { + if ("id" in reaction){ + // noinspection JSUnusedGlobalSymbols, JSUnresolvedVariable + reaction.ext = reaction.a && SETTINGS.enableAnimatedEmoji ? "gif" : "png"; + return templateReactionCustom.apply(reaction); + } + else { + return templateReaction.apply(reaction); + } + }).join("") + "
"; + } + }); + } + }; +})(); diff --git a/app/Resources/Viewer/scripts/dom.js b/app/Resources/Viewer/scripts/dom.js new file mode 100644 index 0000000..60211cf --- /dev/null +++ b/app/Resources/Viewer/scripts/dom.js @@ -0,0 +1,54 @@ +const HTML_ENTITY_MAP = { + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'" +}; + +const HTML_ENTITY_REGEX = /[&<>"']/g; + +class DOM { + /** + * Returns a child element by its ID. Parent defaults to the entire document. + */ + static id(id, parent) { + return (parent || document).getElementById(id); + } + + /** + * Returns an array of all child elements containing the specified class. Parent defaults to the entire document. + */ + static cls(cls, parent) { + return Array.prototype.slice.call((parent || document).getElementsByClassName(cls)); + } + + /** + * Returns an array of all child elements that have the specified tag. Parent defaults to the entire document. + */ + static tag(tag, parent) { + return Array.prototype.slice.call((parent || document).getElementsByTagName(tag)); + } + + /** + * Returns the first child element containing the specified class. Parent defaults to the entire document. + */ + static fcls(cls, parent) { + return (parent || document).getElementsByClassName(cls)[0]; + } + + /** + * Converts characters to their HTML entity form. + */ + static escapeHTML(html) { + return String(html).replace(HTML_ENTITY_REGEX, s => HTML_ENTITY_MAP[s]); + } + + /** + * Converts a timestamp into a human readable time, using the browser locale. + */ + static getHumanReadableTime(timestamp) { + const date = new Date(timestamp); + return date.toLocaleDateString() + ", " + date.toLocaleTimeString(); + }; +} diff --git a/app/Resources/Viewer/scripts/gui.js b/app/Resources/Viewer/scripts/gui.js new file mode 100644 index 0000000..aeb5ca5 --- /dev/null +++ b/app/Resources/Viewer/scripts/gui.js @@ -0,0 +1,249 @@ +const GUI = (function() { + let eventOnOptMessagesPerPageChanged; + let eventOnOptMessageFilterChanged; + let eventOnNavButtonClicked; + + const getActiveFilter = function() { + const active = DOM.fcls("active", DOM.id("opt-filter-list")); + + return active && active.value !== "" ? { + "type": active.getAttribute("data-filter-type"), + "value": active.value + } : null; + }; + + const triggerFilterChanged = function() { + eventOnOptMessageFilterChanged && eventOnOptMessageFilterChanged(getActiveFilter()); + }; + + const showModal = function(width, html) { + const dialog = DOM.id("dialog"); + dialog.innerHTML = html; + dialog.style.width = width + "px"; + dialog.style.marginLeft = (-width / 2) + "px"; + + DOM.id("modal").classList.add("visible"); + return dialog; + }; + + // ------------- + // Modal dialogs + // ------------- + + const showSettingsModal = function() { + showModal(560, ` +
+
+
+
`); + + const setupCheckBox = function(id, settingName) { + const ele = DOM.id(id); + ele.checked = SETTINGS[settingName]; + ele.addEventListener("change", () => SETTINGS[settingName] = ele.checked); + }; + + setupCheckBox("dht-cfg-imgpreviews", "enableImagePreviews"); + setupCheckBox("dht-cfg-formatting", "enableFormatting"); + setupCheckBox("dht-cfg-useravatars", "enableUserAvatars"); + setupCheckBox("dht-cfg-animemoji", "enableAnimatedEmoji"); + }; + + const showInfoModal = function() { + const linkGH = "https://github.com/chylex/Discord-History-Tracker"; + + showModal(560, ` +

Discord History Tracker is developed by chylex as an open source project.

+

Please, report any issues and suggestions to the tracker. If you want to support the development, please spread the word and consider becoming a patron or buying me a coffee. Any support is appreciated!

+

Issue Tracker  —  GitHub Repository  —  Developer's Twitter

`); + }; + + return { + + // --------- + // GUI setup + // --------- + + setup() { + const inputMessageFilter = DOM.id("opt-messages-filter"); + const containerFilterList = DOM.id("opt-filter-list"); + + const resetActiveFilter = function() { + inputMessageFilter.value = ""; + inputMessageFilter.dispatchEvent(new Event("change")); + + DOM.id("opt-filter-contents").value = ""; + }; + + inputMessageFilter.value = ""; // required to prevent browsers from remembering old value + + inputMessageFilter.addEventListener("change", () => { + DOM.cls("active", containerFilterList).forEach(ele => ele.classList.remove("active")); + + if (inputMessageFilter.value) { + containerFilterList.querySelector("[data-filter-type='" + inputMessageFilter.value + "']").classList.add("active"); + } + + triggerFilterChanged(); + }); + + Array.prototype.forEach.call(containerFilterList.children, ele => { + ele.addEventListener(ele.tagName === "SELECT" ? "change" : "input", () => triggerFilterChanged()); + }); + + DOM.id("opt-messages-per-page").addEventListener("change", () => { + eventOnOptMessagesPerPageChanged && eventOnOptMessagesPerPageChanged(); + }); + + DOM.tag("button", DOM.fcls("nav")).forEach(button => { + button.disabled = true; + + button.addEventListener("click", () => { + eventOnNavButtonClicked && eventOnNavButtonClicked(button.getAttribute("data-nav")); + }); + }); + + DOM.id("btn-settings").addEventListener("click", () => { + showSettingsModal(); + }); + + DOM.id("btn-about").addEventListener("click", () => { + showInfoModal(); + }); + + DOM.id("messages").addEventListener("click", e => { + const jump = e.target.getAttribute("data-jump"); + + if (jump) { + resetActiveFilter(); + + const index = STATE.navigateToMessage(jump); + DOM.id("messages").children[index].scrollIntoView(); + } + }); + + DOM.id("overlay").addEventListener("click", () => { + DOM.id("modal").classList.remove("visible"); + DOM.id("dialog").innerHTML = ""; + }); + }, + + // ----------------- + // Event registering + // ----------------- + + /** + * Sets a callback for when the user changes the messages per page option. The callback is not passed any arguments. + */ + onOptionMessagesPerPageChanged(callback) { + eventOnOptMessagesPerPageChanged = callback; + }, + + /** + * Sets a callback for when the user changes the active filter. The callback is passed either null or an object such as { type: , value: }. + */ + onOptMessageFilterChanged(callback) { + eventOnOptMessageFilterChanged = callback; + }, + + /** + * Sets a callback for when the user clicks a navigation button. The callback is passed one of the following strings: first, prev, next, last. + */ + onNavigationButtonClicked(callback) { + eventOnNavButtonClicked = callback; + }, + + // ---------------------- + // Options and navigation + // ---------------------- + + /** + * Returns the selected amount of messages per page. + */ + getOptionMessagesPerPage() { + /** @type HTMLInputElement */ + const messagesPerPage = DOM.id("opt-messages-per-page"); + return parseInt(messagesPerPage.value, 10); + }, + + updateNavigation(currentPage, totalPages) { + DOM.id("nav-page-current").innerHTML = currentPage; + DOM.id("nav-page-total").innerHTML = totalPages || "?"; + + DOM.id("nav-first").disabled = currentPage === 1; + DOM.id("nav-prev").disabled = currentPage === 1; + DOM.id("nav-pick").disabled = (totalPages || 0) <= 1; + DOM.id("nav-next").disabled = currentPage === (totalPages || 1); + DOM.id("nav-last").disabled = currentPage === (totalPages || 1); + }, + + // -------------- + // Updating lists + // -------------- + + /** + * Updates the channel list and sets up their click events. The callback is triggered whenever a channel is selected, and takes the channel ID as its argument. + */ + updateChannelList(channels, selected, callback) { + const eleChannels = DOM.id("channels"); + + if (!channels) { + eleChannels.innerHTML = ""; + } + else { + if (getActiveFilter() != null) { + channels = channels.filter(channel => channel.msgcount > 0); + } + + eleChannels.innerHTML = channels.map(channel => DISCORD.getChannelHTML(channel)).join(""); + + Array.prototype.forEach.call(eleChannels.children, ele => { + ele.addEventListener("click", () => { + const currentChannel = DOM.fcls("active", eleChannels); + + if (currentChannel) { + currentChannel.classList.remove("active"); + } + + ele.classList.add("active"); + callback(ele.getAttribute("data-channel")); + }); + }); + + if (selected) { + const activeChannel = eleChannels.querySelector("[data-channel='" + selected + "']"); + activeChannel && activeChannel.classList.add("active"); + } + } + }, + + updateMessageList(messages) { + DOM.id("messages").innerHTML = messages ? messages.map(message => DISCORD.getMessageHTML(message)).join("") : ""; + }, + + updateUserList(users) { + /** @type HTMLSelectElement */ + const eleSelect = DOM.id("opt-filter-user"); + + while (eleSelect.length > 1) { + eleSelect.remove(1); + } + + const options = []; + + for (const key of Object.keys(users)) { + const option = document.createElement("option"); + option.value = key; + option.text = users[key].name; + options.push(option); + } + + options.sort((a, b) => a.text.toLocaleLowerCase().localeCompare(b.text.toLocaleLowerCase())); + options.forEach(option => eleSelect.add(option)); + }, + + scrollMessagesToTop() { + DOM.id("messages").scrollTop = 0; + } + }; +})(); diff --git a/app/Resources/Viewer/scripts/processor.js b/app/Resources/Viewer/scripts/processor.js new file mode 100644 index 0000000..5eee06e --- /dev/null +++ b/app/Resources/Viewer/scripts/processor.js @@ -0,0 +1,41 @@ +const PROCESSOR = {}; + +// ------------------------ +// Global filter generators +// ------------------------ + +PROCESSOR.FILTER = { + byUser: ((userindex) => message => message.u === userindex), + byTime: ((timeStart, timeEnd) => message => message.t >= timeStart && message.t <= timeEnd), + byContents: ((substr) => message => ("m" in message ? message.m : "").indexOf(substr) !== -1), + byRegex: ((regex) => message => regex.test("m" in message ? message.m : "")), + withImages: (() => message => (message.e && message.e.some(embed => embed.type === "image")) || (message.a && message.a.some(DISCORD.isImageAttachment))), + withDownloads: (() => message => message.a && message.a.some(attachment => !DISCORD.isImageAttachment(attachment))), + withEmbeds: (() => message => message.e && message.e.length > 0), + withAttachments: (() => message => message.a && message.a.length > 0), + isEdited: (() => message => ("te" in message) ? message.te : (message.f & 1) === 1) +}; + +// -------------- +// Global sorters +// -------------- + +PROCESSOR.SORTER = { + oldestToNewest: (key1, key2) => { + if (key1.length === key2.length) { + return key1 > key2 ? 1 : key1 < key2 ? -1 : 0; + } + else { + return key1.length > key2.length ? 1 : -1; + } + }, + + newestToOldest: (key1, key2) => { + if (key1.length === key2.length) { + return key1 > key2 ? -1 : key1 < key2 ? 1 : 0; + } + else { + return key1.length > key2.length ? -1 : 1; + } + } +}; diff --git a/app/Resources/Viewer/scripts/settings.js b/app/Resources/Viewer/scripts/settings.js new file mode 100644 index 0000000..0b61f9b --- /dev/null +++ b/app/Resources/Viewer/scripts/settings.js @@ -0,0 +1,80 @@ +const SETTINGS = (function() { + /** + * @type {{}} + * @property {Function} onSettingsChanged + * @property {Boolean} enableImagePreviews + * @property {Boolean} enableFormatting + * @property {Boolean} enableUserAvatars + * @property {Boolean} enableAnimatedEmoji + */ + const root = { + onSettingsChanged(callback) { + settingsChangedEvents.push(callback); + } + }; + + const settingsChangedEvents = []; + + const triggerSettingsChanged = function(property) { + for (const callback of settingsChangedEvents) { + callback(property); + } + }; + + const getStorageItem = (property) => { + try { + return localStorage.getItem(property); + } catch (e) { + console.error(e); + return null; + } + }; + + const setStorageItem = (property, value) => { + try { + localStorage.setItem(property, value); + } catch (e) { + console.error(e); + } + }; + + const defineSettingProperty = (property, defaultValue, storageToValue) => { + const name = "_" + property; + + Object.defineProperty(root, property, { + get: (() => root[name]), + set: (value => { + root[name] = value; + triggerSettingsChanged(property); + setStorageItem(property, value); + }) + }); + + let stored = getStorageItem(property); + + if (stored !== null) { + stored = storageToValue(stored); + } + + root[name] = stored === null ? defaultValue : stored; + }; + + const fromBooleanString = (value) => { + if (value === "true") { + return true; + } + else if (value === "false") { + return false; + } + else { + return null; + } + }; + + defineSettingProperty("enableImagePreviews", true, fromBooleanString); + defineSettingProperty("enableFormatting", true, fromBooleanString); + defineSettingProperty("enableUserAvatars", true, fromBooleanString); + defineSettingProperty("enableAnimatedEmoji", true, fromBooleanString); + + return root; +})(); diff --git a/app/Resources/Viewer/scripts/state.js b/app/Resources/Viewer/scripts/state.js new file mode 100644 index 0000000..d367454 --- /dev/null +++ b/app/Resources/Viewer/scripts/state.js @@ -0,0 +1,303 @@ +// noinspection FunctionWithInconsistentReturnsJS +const STATE = (function() { + /** + * @type {{}} + * @property {{}} users + * @property {String[]} userindex + * @property {{}[]} servers + * @property {{}} channels + */ + let loadedFileMeta; + let loadedFileData; + + let loadedMessages; + + let filterFunction; + let selectedChannel; + let currentPage; + let messagesPerPage; + + const getUser = function(index) { + return loadedFileMeta.users[loadedFileMeta.userindex[index]] || { "name": "<unknown>" }; + }; + + const getUserId = function(index) { + return loadedFileMeta.userindex[index]; + }; + + const getUserList = function() { + return loadedFileMeta ? loadedFileMeta.users : []; + }; + + const getChannelList = function() { + if (!loadedFileMeta) { + return []; + } + + const channels = loadedFileMeta.channels; + + return Object.keys(channels).map(key => ({ + "id": key, + "name": channels[key].name, + "server": loadedFileMeta.servers[channels[key].server] || { "name": "<unknown>", "type": "unknown" }, + "msgcount": getFilteredMessageKeys(key).length, + "topic": channels[key].topic || "", + "nsfw": channels[key].nsfw || false, + "position": channels[key].position || -1 + })).sort((ac, bc) => { + const as = ac.server; + const bs = bc.server; + + return as.type.localeCompare(bs.type, "en") || + as.name.toLocaleLowerCase().localeCompare(bs.name.toLocaleLowerCase(), undefined, { numeric: true }) || + ac.position - bc.position || + ac.name.toLocaleLowerCase().localeCompare(bc.name.toLocaleLowerCase(), undefined, { numeric: true }); + }); + }; + + const getMessages = function(channel) { + return loadedFileData[channel] || {}; + }; + + const getMessageList = function() { + if (!loadedMessages) { + return []; + } + + const messages = getMessages(selectedChannel); + const startIndex = messagesPerPage * (root.getCurrentPage() - 1); + + return loadedMessages.slice(startIndex, !messagesPerPage ? undefined : startIndex + messagesPerPage).map(key => { + /** + * @type {{}} + * @property {Number} u + * @property {Number} t + * @property {String} m + * @property {Number} [te] + * @property {String} [r] + * @property {{}[]} [a] + * @property {String[]} [e] + * @property {{}[]} [re] + */ + const message = messages[key]; + const user = getUser(message.u); + const avatar = user.avatar ? { id: getUserId(message.u), path: user.avatar } : null; + + const reply = ("r" in message && message.r in messages) ? messages[message.r] : null; + const replyUser = reply ? getUser(reply.u) : null; + const replyAvatar = replyUser && replyUser.avatar ? { id: getUserId(reply.u), path: replyUser.avatar } : null; + const replyObj = reply ? { + "id": message.r, + "user": replyUser, + "avatar": replyAvatar, + "contents": reply.m + } : null; + + return { + user, + avatar, + "timestamp": message.t, + "contents": ("m" in message) ? message.m : null, + "embeds": ("e" in message) ? message.e.map(embed => JSON.parse(embed)) : [], + "attachments": ("a" in message) ? message.a : [], + "edit": ("te" in message) ? message.te : null, + "jump": key, + "reply": replyObj, + "reactions": ("re" in message) ? message.re : null + }; + }); + }; + + let eventOnUsersRefreshed; + let eventOnChannelsRefreshed; + let eventOnMessagesRefreshed; + + const triggerUsersRefreshed = function() { + eventOnUsersRefreshed && eventOnUsersRefreshed(getUserList()); + }; + + const triggerChannelsRefreshed = function(selectedChannel) { + eventOnChannelsRefreshed && eventOnChannelsRefreshed(getChannelList(), selectedChannel); + }; + + const triggerMessagesRefreshed = function() { + eventOnMessagesRefreshed && eventOnMessagesRefreshed(getMessageList()); + }; + + const getFilteredMessageKeys = function(channel) { + const messages = getMessages(channel); + let keys = Object.keys(messages); + + if (filterFunction) { + keys = keys.filter(key => filterFunction(messages[key])); + } + + return keys; + }; + + const root = { + onChannelsRefreshed(callback) { + eventOnChannelsRefreshed = callback; + }, + + onMessagesRefreshed(callback) { + eventOnMessagesRefreshed = callback; + }, + + onUsersRefreshed(callback) { + eventOnUsersRefreshed = callback; + }, + + /** + * @param {{ meta, data }} file + */ + uploadFile(file) { + if (loadedFileMeta != null) { + throw "A file is already loaded!"; + } + + if (!file || typeof file.meta !== "object" || typeof file.data !== "object") { + throw "Invalid file format!"; + } + + loadedFileMeta = file.meta; + loadedFileData = file.data; + loadedMessages = null; + + selectedChannel = null; + currentPage = 1; + + triggerUsersRefreshed(); + triggerChannelsRefreshed(); + triggerMessagesRefreshed(); + + SETTINGS.onSettingsChanged(() => triggerMessagesRefreshed()); + }, + + getChannelName(channel) { + return loadedFileMeta.channels[channel].name || channel; + }, + + getUserTag(user) { + return loadedFileMeta.users[user].tag; + }, + + getUserName(user) { + return loadedFileMeta.users[user].name || user; + }, + + selectChannel(channel) { + currentPage = 1; + selectedChannel = channel; + + loadedMessages = getFilteredMessageKeys(channel).sort(PROCESSOR.SORTER.oldestToNewest); + triggerMessagesRefreshed(); + }, + + setMessagesPerPage(amount) { + messagesPerPage = amount; + triggerMessagesRefreshed(); + }, + + updateCurrentPage(action) { + switch (action) { + case "first": + currentPage = 1; + break; + + case "prev": + currentPage = Math.max(1, currentPage - 1); + break; + + case "next": + currentPage = Math.min(this.getPageCount(), currentPage + 1); + break; + + case "last": + currentPage = this.getPageCount(); + break; + + case "pick": + const page = parseInt(prompt("Select page:", currentPage), 10); + + if (!page && page !== 0) { + return; + } + + currentPage = Math.max(1, Math.min(this.getPageCount(), page)); + break; + } + + triggerMessagesRefreshed(); + }, + + getCurrentPage() { + const total = this.getPageCount(); + + if (currentPage > total && total > 0) { + currentPage = total; + } + + return currentPage || 1; + }, + + getPageCount() { + return !loadedMessages ? 0 : (!messagesPerPage ? 1 : Math.ceil(loadedMessages.length / messagesPerPage)); + }, + + navigateToMessage(id) { + if (!loadedMessages) { + return 0; + } + + const index = loadedMessages.indexOf(id); + + if (index === -1) { + return 0; + } + + currentPage = Math.max(1, Math.min(this.getPageCount(), 1 + Math.floor(index / messagesPerPage))); + triggerMessagesRefreshed(); + return index % messagesPerPage; + }, + + setActiveFilter(filter) { + switch (filter ? filter.type : "") { + case "user": + filterFunction = PROCESSOR.FILTER.byUser(loadedFileMeta.userindex.indexOf(filter.value)); + break; + + case "contents": + filterFunction = PROCESSOR.FILTER.byContents(filter.value); + break; + + case "withimages": + filterFunction = PROCESSOR.FILTER.withImages(); + break; + + case "withdownloads": + filterFunction = PROCESSOR.FILTER.withDownloads(); + break; + + case "edited": + filterFunction = PROCESSOR.FILTER.isEdited(); + break; + + default: + filterFunction = null; + break; + } + + this.hasActiveFilter = filterFunction != null; + + triggerChannelsRefreshed(selectedChannel); + + if (selectedChannel) { + this.selectChannel(selectedChannel); // resets current page and updates messages + } + } + }; + + root.hasActiveFilter = false; + return root; +})(); diff --git a/app/Resources/Viewer/scripts/template.js b/app/Resources/Viewer/scripts/template.js new file mode 100644 index 0000000..8a6303e --- /dev/null +++ b/app/Resources/Viewer/scripts/template.js @@ -0,0 +1,20 @@ +const TEMPLATE_REGEX = /{([^{}]+?)}/g; + +class TEMPLATE { + constructor(contents) { + this.contents = contents; + }; + + apply(obj, processor) { + return this.contents.replace(TEMPLATE_REGEX, (full, match) => { + const value = match.split(".").reduce((o, property) => o[property], obj); + + if (processor) { + const updated = processor(match, value); + return typeof updated === "undefined" ? DOM.escapeHTML(value) : updated; + } + + return DOM.escapeHTML(value); + }); + } +} diff --git a/app/Resources/Viewer/styles/channels.css b/app/Resources/Viewer/styles/channels.css new file mode 100644 index 0000000..1674a56 --- /dev/null +++ b/app/Resources/Viewer/styles/channels.css @@ -0,0 +1,42 @@ +#channels { + width: 15vw; + min-width: 215px; + max-width: 300px; + overflow-y: auto; + background-color: #1c1e22; +} + +#channels > div { + cursor: pointer; + padding: 10px 12px; + color: #eee; + font-size: 15px; + border-bottom: 1px solid #333333; +} + +#channels > div:hover, #channels > div.active { + background-color: #282b30; +} + +#channels .info { + display: flex; + height: 16px; + margin-bottom: 4px; +} + +#channels .name { + flex-grow: 1; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +#channels .tag { + flex-shrink: 1; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 4px; + margin-left: 4px; + margin-top: 1px; + padding: 2px 5px; + font-size: 11px; +} diff --git a/app/Resources/Viewer/styles/main.css b/app/Resources/Viewer/styles/main.css new file mode 100644 index 0000000..5e33bfb --- /dev/null +++ b/app/Resources/Viewer/styles/main.css @@ -0,0 +1,13 @@ +body { + font-family: Whitney, "Helvetica Neue", Helvetica, Verdana, "Lucida Grande", sans-serif; + line-height: 1; + margin: 0; + padding: 0; + overflow: hidden; +} + +#app { + height: calc(100vh - 48px); + display: flex; + flex-direction: row; +} diff --git a/app/Resources/Viewer/styles/menu.css b/app/Resources/Viewer/styles/menu.css new file mode 100644 index 0000000..9a68d35 --- /dev/null +++ b/app/Resources/Viewer/styles/menu.css @@ -0,0 +1,78 @@ +#menu { + width: 100%; + height: 48px; + display: flex; + flex-direction: row; + background-color: #17181c; + border-bottom: 1px dotted #5d626b; +} + +#menu .splitter { + width: 1px; + margin: 9px 4px; + background-color: #5d626b; +} + +#menu .separator { + flex: 1 1 0; +} + +#menu :disabled { + background-color: #555; + cursor: default; +} + +#menu button, #menu select, #menu input[type="text"] { + margin: 8px; + background-color: #7289da; + color: #fff; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75); +} + +#menu button { + font-size: 17px; + padding: 0 12px; + border: 0; + cursor: pointer; +} + +#menu select { + font-size: 14px; + padding: 6px; + border: 0; + cursor: pointer; +} + +#menu input[type="text"] { + font-size: 14px; + padding: 7px 12px; + border: 0; +} + +#menu .nav { + display: flex; + flex-direction: row; + margin: 0 8px; +} + +#menu .nav > button { + font-size: 14px; +} + +#menu .nav > button.icon { + font-family: Lucida Console, monospace; + font-size: 17px; + padding: 0 8px; +} + +#menu .nav > button, #menu .nav > p { + margin: 8px 1px; +} + +#opt-filter-list > select, #opt-filter-list > input { + display: none; +} + +#opt-filter-list > .active { + display: block; +} diff --git a/app/Resources/Viewer/styles/messages.css b/app/Resources/Viewer/styles/messages.css new file mode 100644 index 0000000..f14636f --- /dev/null +++ b/app/Resources/Viewer/styles/messages.css @@ -0,0 +1,254 @@ +#messages { + flex: 1 1 0; + overflow-y: auto; + background-color: #36393E; +} + +#messages > div { + margin: 0 24px; + padding: 4px 0 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +#messages h2 { + margin: 0; + padding: 0; + display: block; +} + +#messages .avatar-wrapper { + display: flex; + flex-direction: row; + align-items: flex-start; + align-content: flex-start; +} + +#messages .avatar-wrapper > div { + flex: 1 1 auto; +} + +#messages .avatar { + flex: 0 0 38px !important; + margin: 8px 14px 0 0; +} + +#messages .avatar img { + width: 38px; + border-radius: 50%; +} + +#messages .username { + color: #FFF; + font-size: 15px; + font-weight: 600; + margin-right: 3px; + letter-spacing: 0; +} + +#messages .info { + color: rgba(255, 255, 255, 0.4); + font-size: 12px; + font-weight: 500; + letter-spacing: 0; +} + +#messages .info::before { + content: "\2022"; + text-align: center; + display: inline-block; + width: 14px; +} + +#messages .jump { + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.message { + margin-top: 6px; + color: rgba(255, 255, 255, 0.7); + font-size: 15px; + line-height: 1.1em; + white-space: pre-wrap; + word-wrap: break-word; +} + +.message .link, .reply-message .link { + color: #7289DA; + background-color: rgba(115, 139, 215, 0.1); +} + +.message a, .reply-message a { + color: #0096CF; + text-decoration: none; +} + +.message a:hover { + text-decoration: underline; +} + +.message p { + margin: 0; +} + +.message .embed { + display: inline-block; + margin-top: 8px; +} + +.message .embed .title { + font-weight: bold; + display: inline-block; +} + +.message .embed .desc { + margin-top: 4px; +} + +.message .thumbnail { + max-width: calc(100% - 20px); + max-height: 320px; +} + +.message .thumbnail img { + max-width: 100%; + max-height: 320px; + border-radius: 3px; +} + +.message .download { + margin-right: 8px; + padding: 8px 9px; + border: 1px solid rgba(255, 255, 255, 0.5); + border-radius: 3px; +} + +.message .embed:first-child, .message .download + .download { + margin-top: 0; +} + +.message code { + background-color: #2E3136; + border-radius: 5px; + font-family: Menlo, Consolas, Monaco, monospace; + font-size: 14px; +} + +.message code.inline { + display: inline; + padding: 2px; +} + +.message code.block { + display: block; + border: 2px solid #282B30; + margin-top: 6px; + padding: 7px; +} + +.message .emoji { + width: 22px; + height: 22px; + margin: 0 1px; + vertical-align: -30%; + object-fit: contain; +} + +.reply-message { + display: flex; + align-items: baseline; + flex-wrap: wrap; + line-height: 120%; + white-space: nowrap; +} + +.reply-message-with-avatar { + margin: 0 0 -2px 52px; +} + +.reply-message .jump { + color: rgba(255, 255, 255, 0.4); + font-size: 12px; + text-underline-offset: 1px; + margin-right: 7px; +} + +.reply-message .emoji { + width: 16px; + height: 16px; + vertical-align: -20%; + object-fit: contain; +} + +.reply-message .user { + margin-right: 5px; +} + +.reply-avatar { + margin-right: 4px; +} + +.reply-avatar img { + width: 16px; + border-radius: 50%; + vertical-align: middle; +} + +.reply-username { + color: #FFF; + font-size: 12px; + font-weight: 600; + letter-spacing: 0; +} + +.reply-contents { + display: inline-block; + color: rgba(255, 255, 255, 0.7); + font-size: 12px; + max-width: calc(80%); +} + +.reply-contents p { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.reply-contents code { + background-color: #2E3136; + font-family: Menlo, Consolas, Monaco, monospace; + padding: 1px 2px; +} + +.reactions { + margin-top: 4px; +} + +.reactions .reaction-wrapper { + display: inline-block; + border-radius: 4px; + margin: 3px 2px 0 0; + padding: 3px 6px; + background: #42454a; + cursor: default; +} + +.reactions .reaction-emoji { + margin-right: 5px; + font-size: 16px; + display: inline-block; + text-align: center; + vertical-align: -5%; +} + +.reactions .reaction-emoji-custom { + height: 15px; + margin-right: 5px; + vertical-align: -10%; +} + +.reactions .count { + color: rgba(255, 255, 255, 0.45); + font-size: 14px; +} diff --git a/app/Resources/Viewer/styles/modal.css b/app/Resources/Viewer/styles/modal.css new file mode 100644 index 0000000..62c3acd --- /dev/null +++ b/app/Resources/Viewer/styles/modal.css @@ -0,0 +1,46 @@ +#modal div { + position: absolute; + display: none; +} + +#modal.visible div { + display: block; +} + +#modal #overlay { + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: #000; +} + +#modal.visible #overlay { + opacity: 0.5; +} + +#dialog { + left: 50%; + top: 50%; + padding: 16px; + background-color: #fff; + transform: translateY(-50%); +} + +#dialog p { + line-height: 1.2; +} + +#dialog p:first-child, #dialog p:last-child { + margin-top: 1px; + margin-bottom: 1px; +} + +#dialog a { + color: #0096cf; + text-decoration: none; +} + +#dialog a:hover { + text-decoration: underline; +} diff --git a/app/Server/Collections/MultiDictionary.cs b/app/Server/Collections/MultiDictionary.cs new file mode 100644 index 0000000..afc5a5a --- /dev/null +++ b/app/Server/Collections/MultiDictionary.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace DHT.Server.Collections { + public class MultiDictionary where TKey : notnull { + private readonly Dictionary> dict = new(); + + public void Add(TKey key, TValue value) { + if (!dict.TryGetValue(key, out var list)) { + dict[key] = list = new List(); + } + + list.Add(value); + } + + public List? GetListOrNull(TKey key) { + return dict.TryGetValue(key, out var list) ? list : null; + } + } +} diff --git a/app/Server/Data/Attachment.cs b/app/Server/Data/Attachment.cs new file mode 100644 index 0000000..7e4fe95 --- /dev/null +++ b/app/Server/Data/Attachment.cs @@ -0,0 +1,9 @@ +namespace DHT.Server.Data { + public readonly struct Attachment { + public ulong Id { get; init; } + public string Name { get; init; } + public string? Type { get; init; } + public string Url { get; init; } + public ulong Size { get; init; } + } +} diff --git a/app/Server/Data/Channel.cs b/app/Server/Data/Channel.cs new file mode 100644 index 0000000..33ecaa8 --- /dev/null +++ b/app/Server/Data/Channel.cs @@ -0,0 +1,10 @@ +namespace DHT.Server.Data { + public readonly struct Channel { + public ulong Id { get; init; } + public ulong Server { get; init; } + public string Name { get; init; } + public int? Position { get; init; } + public string? Topic { get; init; } + public bool? Nsfw { get; init; } + } +} diff --git a/app/Server/Data/Embed.cs b/app/Server/Data/Embed.cs new file mode 100644 index 0000000..d0d9a40 --- /dev/null +++ b/app/Server/Data/Embed.cs @@ -0,0 +1,5 @@ +namespace DHT.Server.Data { + public readonly struct Embed { + public string Json { get; init; } + } +} diff --git a/app/Server/Data/EmojiFlags.cs b/app/Server/Data/EmojiFlags.cs new file mode 100644 index 0000000..3502ef5 --- /dev/null +++ b/app/Server/Data/EmojiFlags.cs @@ -0,0 +1,9 @@ +using System; + +namespace DHT.Server.Data { + [Flags] + public enum EmojiFlags : ushort { + None = 0, + Animated = 0b1 + } +} diff --git a/app/Server/Data/Filters/MessageFilter.cs b/app/Server/Data/Filters/MessageFilter.cs new file mode 100644 index 0000000..d86361c --- /dev/null +++ b/app/Server/Data/Filters/MessageFilter.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace DHT.Server.Data.Filters { + public class MessageFilter { + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + + public HashSet MessageIds { get; } = new(); + } +} diff --git a/app/Server/Data/Message.cs b/app/Server/Data/Message.cs new file mode 100644 index 0000000..d276418 --- /dev/null +++ b/app/Server/Data/Message.cs @@ -0,0 +1,16 @@ +using System.Collections.Immutable; + +namespace DHT.Server.Data { + public readonly struct Message { + public ulong Id { get; init; } + public ulong Sender { get; init; } + public ulong Channel { get; init; } + public string Text { get; init; } + public long Timestamp { get; init; } + public long? EditTimestamp { get; init; } + public ulong? RepliedToId { get; init; } + public ImmutableArray Attachments { get; init; } + public ImmutableArray Embeds { get; init; } + public ImmutableArray Reactions { get; init; } + } +} diff --git a/app/Server/Data/Reaction.cs b/app/Server/Data/Reaction.cs new file mode 100644 index 0000000..284eb07 --- /dev/null +++ b/app/Server/Data/Reaction.cs @@ -0,0 +1,8 @@ +namespace DHT.Server.Data { + public readonly struct Reaction { + public ulong? EmojiId { get; init; } + public string? EmojiName { get; init; } + public EmojiFlags EmojiFlags { get; init; } + public int Count { get; init; } + } +} diff --git a/app/Server/Data/Server.cs b/app/Server/Data/Server.cs new file mode 100644 index 0000000..2cf5bde --- /dev/null +++ b/app/Server/Data/Server.cs @@ -0,0 +1,7 @@ +namespace DHT.Server.Data { + public readonly struct Server { + public ulong Id { get; init; } + public string Name { get; init; } + public ServerType? Type { get; init; } + } +} diff --git a/app/Server/Data/ServerType.cs b/app/Server/Data/ServerType.cs new file mode 100644 index 0000000..7d826a4 --- /dev/null +++ b/app/Server/Data/ServerType.cs @@ -0,0 +1,36 @@ +namespace DHT.Server.Data { + public enum ServerType { + Server, + Group, + DirectMessage + } + + public static class ServerTypes { + public static ServerType? FromString(string? str) { + return str switch { + "SERVER" => ServerType.Server, + "GROUP" => ServerType.Group, + "DM" => ServerType.DirectMessage, + _ => null + }; + } + + public static string ToString(ServerType? type) { + return type switch { + ServerType.Server => "SERVER", + ServerType.Group => "GROUP", + ServerType.DirectMessage => "DM", + _ => "UNKNOWN" + }; + } + + public static string ToJsonViewerString(ServerType? type) { + return type switch { + ServerType.Server => "server", + ServerType.Group => "group", + ServerType.DirectMessage => "user", + _ => "unknown" + }; + } + } +} diff --git a/app/Server/Data/User.cs b/app/Server/Data/User.cs new file mode 100644 index 0000000..4801e65 --- /dev/null +++ b/app/Server/Data/User.cs @@ -0,0 +1,8 @@ +namespace DHT.Server.Data { + public readonly struct User { + public ulong Id { get; init; } + public string Name { get; init; } + public string? AvatarUrl { get; init; } + public string? Discriminator { get; init; } + } +} diff --git a/app/Server/Database/DatabaseStatistics.cs b/app/Server/Database/DatabaseStatistics.cs new file mode 100644 index 0000000..806ee38 --- /dev/null +++ b/app/Server/Database/DatabaseStatistics.cs @@ -0,0 +1,32 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace DHT.Server.Database { + public class DatabaseStatistics : INotifyPropertyChanged { + private long totalServers; + private long totalChannels; + private long totalMessages; + + public long TotalServers { + get => totalServers; + internal set => Change(out totalServers, value); + } + + public long TotalChannels { + get => totalChannels; + internal set => Change(out totalChannels, value); + } + + public long TotalMessages { + get => totalMessages; + internal set => Change(out totalMessages, value); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void Change(out T field, T value, [CallerMemberName] string? propertyName = null) { + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/app/Server/Database/DummyDatabaseFile.cs b/app/Server/Database/DummyDatabaseFile.cs new file mode 100644 index 0000000..7d0eac7 --- /dev/null +++ b/app/Server/Database/DummyDatabaseFile.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using DHT.Server.Data; +using DHT.Server.Data.Filters; + +namespace DHT.Server.Database { + public class DummyDatabaseFile : IDatabaseFile { + public static DummyDatabaseFile Instance { get; } = new(); + + public string Path => ""; + public DatabaseStatistics Statistics { get; } = new(); + + private DummyDatabaseFile() {} + + public void AddServer(Data.Server server) {} + + public List GetAllServers() { + return new(); + } + + public void AddChannel(Channel channel) {} + + public List GetAllChannels() { + return new(); + } + + public void AddUsers(User[] users) {} + + public List GetAllUsers() { + return new(); + } + + public void AddMessages(Message[] messages) {} + + public int CountMessages(MessageFilter? filter = null) { + return 0; + } + + public List GetMessages(MessageFilter? filter = null) { + return new(); + } + + public void Dispose() { + GC.SuppressFinalize(this); + } + } +} diff --git a/app/Server/Database/Exceptions/DatabaseTooNewException.cs b/app/Server/Database/Exceptions/DatabaseTooNewException.cs new file mode 100644 index 0000000..40712ab --- /dev/null +++ b/app/Server/Database/Exceptions/DatabaseTooNewException.cs @@ -0,0 +1,13 @@ +using System; +using DHT.Server.Database.Sqlite; + +namespace DHT.Server.Database.Exceptions { + public class DatabaseTooNewException : Exception { + public int DatabaseVersion { get; } + public int CurrentVersion => Schema.Version; + + public DatabaseTooNewException(int databaseVersion) : base("Database is too new: " + databaseVersion + " > " + Schema.Version) { + this.DatabaseVersion = databaseVersion; + } + } +} diff --git a/app/Server/Database/Exceptions/InvalidDatabaseVersionException.cs b/app/Server/Database/Exceptions/InvalidDatabaseVersionException.cs new file mode 100644 index 0000000..f1e6fda --- /dev/null +++ b/app/Server/Database/Exceptions/InvalidDatabaseVersionException.cs @@ -0,0 +1,11 @@ +using System; + +namespace DHT.Server.Database.Exceptions { + public class InvalidDatabaseVersionException : Exception { + public string Version { get; } + + public InvalidDatabaseVersionException(string version) : base("Invalid database version: " + version) { + this.Version = version; + } + } +} diff --git a/app/Server/Database/Export/ViewerJsonExport.cs b/app/Server/Database/Export/ViewerJsonExport.cs new file mode 100644 index 0000000..3653b59 --- /dev/null +++ b/app/Server/Database/Export/ViewerJsonExport.cs @@ -0,0 +1,153 @@ +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using System.Text.Json; +using DHT.Server.Data; +using DHT.Server.Data.Filters; + +namespace DHT.Server.Database.Export { + public static class ViewerJsonExport { + public static string Generate(IDatabaseFile db, MessageFilter? filter = null) { + JsonSerializerOptions opts = new(); + opts.Converters.Add(new ViewerJsonSnowflakeSerializer()); + + var users = GenerateUserList(db, out var userindex, out var userIndices); + var servers = GenerateServerList(db, out var serverindex); + var channels = GenerateChannelList(db, serverindex); + + return JsonSerializer.Serialize(new { + meta = new { users, userindex, servers, channels }, + data = GenerateMessageList(db, filter, userIndices) + }, opts); + } + + private static dynamic GenerateUserList(IDatabaseFile db, out List userindex, out Dictionary userIndices) { + var users = new Dictionary(); + userindex = new List(); + userIndices = new Dictionary(); + + foreach (var user in db.GetAllUsers()) { + var id = user.Id.ToString(); + + dynamic obj = new ExpandoObject(); + obj.name = user.Name; + + if (user.AvatarUrl != null) { + obj.avatar = user.AvatarUrl; + } + + if (user.Discriminator != null) { + obj.tag = user.Discriminator; + } + + userIndices[user.Id] = users.Count; + userindex.Add(id); + users[id] = obj; + } + + return users; + } + + private static dynamic GenerateServerList(IDatabaseFile db, out Dictionary serverIndices) { + var servers = new List(); + serverIndices = new Dictionary(); + + foreach (var server in db.GetAllServers()) { + serverIndices[server.Id] = servers.Count; + servers.Add(new { + name = server.Name, + type = ServerTypes.ToJsonViewerString(server.Type) + }); + } + + return servers; + } + + private static dynamic GenerateChannelList(IDatabaseFile db, Dictionary serverIndices) { + var channels = new Dictionary(); + + foreach (var channel in db.GetAllChannels()) { + dynamic obj = new ExpandoObject(); + obj.server = serverIndices[channel.Server]; + obj.name = channel.Name; + + if (channel.Position != null) { + obj.position = channel.Position; + } + + if (channel.Topic != null) { + obj.topic = channel.Topic; + } + + if (channel.Nsfw != null) { + obj.nsfw = channel.Nsfw; + } + + channels[channel.Id.ToString()] = obj; + } + + return channels; + } + + private static dynamic GenerateMessageList(IDatabaseFile db, MessageFilter? filter, Dictionary userIndices) { + var data = new Dictionary>(); + + foreach (var grouping in db.GetMessages(filter).GroupBy(message => message.Channel)) { + var channel = grouping.Key.ToString(); + var channelData = new Dictionary(); + + foreach (var message in grouping) { + dynamic obj = new ExpandoObject(); + obj.u = userIndices[message.Sender]; + obj.t = message.Timestamp; + + if (!string.IsNullOrEmpty(message.Text)) { + obj.m = message.Text; + } + + if (message.EditTimestamp != null) { + obj.te = message.EditTimestamp; + } + + if (message.RepliedToId != null) { + obj.r = message.RepliedToId.Value; + } + + if (!message.Attachments.IsEmpty) { + obj.a = message.Attachments.Select(attachment => new { + url = attachment.Url + }).ToArray(); + } + + if (!message.Embeds.IsEmpty) { + obj.e = message.Embeds.Select(embed => embed.Json).ToArray(); + } + + if (!message.Reactions.IsEmpty) { + obj.re = message.Reactions.Select(reaction => { + dynamic r = new ExpandoObject(); + + if (reaction.EmojiId != null) { + r.id = reaction.EmojiId.Value; + } + + if (reaction.EmojiName != null) { + r.n = reaction.EmojiName; + } + + r.a = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated); + r.c = reaction.Count; + return r; + }); + } + + channelData[message.Id.ToString()] = obj; + } + + data[channel] = channelData; + } + + return data; + } + } +} diff --git a/app/Server/Database/Export/ViewerJsonSnowflakeSerializer.cs b/app/Server/Database/Export/ViewerJsonSnowflakeSerializer.cs new file mode 100644 index 0000000..3ef169c --- /dev/null +++ b/app/Server/Database/Export/ViewerJsonSnowflakeSerializer.cs @@ -0,0 +1,15 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DHT.Server.Database.Export { + public class ViewerJsonSnowflakeSerializer : JsonConverter { + public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + return ulong.Parse(reader.GetString()!); + } + + public override void Write(Utf8JsonWriter writer, ulong value, JsonSerializerOptions options) { + writer.WriteStringValue(value.ToString()); + } + } +} diff --git a/app/Server/Database/IDatabaseFile.cs b/app/Server/Database/IDatabaseFile.cs new file mode 100644 index 0000000..247f28a --- /dev/null +++ b/app/Server/Database/IDatabaseFile.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using DHT.Server.Data; +using DHT.Server.Data.Filters; + +namespace DHT.Server.Database { + public interface IDatabaseFile : IDisposable { + string Path { get; } + DatabaseStatistics Statistics { get; } + + void AddServer(Data.Server server); + List GetAllServers(); + + void AddChannel(Channel channel); + List GetAllChannels(); + + void AddUsers(User[] users); + List GetAllUsers(); + + void AddMessages(Message[] messages); + int CountMessages(MessageFilter? filter = null); + List GetMessages(MessageFilter? filter = null); + } +} diff --git a/app/Server/Database/Sqlite/Schema.cs b/app/Server/Database/Sqlite/Schema.cs new file mode 100644 index 0000000..08104f9 --- /dev/null +++ b/app/Server/Database/Sqlite/Schema.cs @@ -0,0 +1,110 @@ +using System; +using System.Threading.Tasks; +using DHT.Server.Database.Exceptions; +using Microsoft.Data.Sqlite; + +namespace DHT.Server.Database.Sqlite { + internal class Schema { + internal const int Version = 1; + + private readonly SqliteConnection conn; + + public Schema(SqliteConnection conn) { + this.conn = conn; + } + + private SqliteCommand Sql(string sql) { + var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + return cmd; + } + + private void Execute(string sql) { + Sql(sql).ExecuteNonQuery(); + } + + public async Task Setup(Func> checkCanUpgradeSchemas) { + Execute(@"CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)"); + + var dbVersionStr = Sql("SELECT value FROM metadata WHERE key = 'version'").ExecuteScalar(); + if (dbVersionStr == null) { + InitializeSchemas(); + } + else if (!int.TryParse(dbVersionStr.ToString(), out int dbVersion) || dbVersion < 1) { + throw new InvalidDatabaseVersionException(dbVersionStr.ToString() ?? ""); + } + else if (dbVersion > Version) { + throw new DatabaseTooNewException(dbVersion); + } + else if (dbVersion < Version) { + var proceed = await checkCanUpgradeSchemas(); + if (!proceed) { + return false; + } + + UpgradeSchemas(dbVersion); + } + + return true; + } + + private void InitializeSchemas() { + Execute(@"CREATE TABLE users ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + avatar_url TEXT, + discriminator TEXT)"); + + Execute(@"CREATE TABLE servers ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL)"); + + Execute(@"CREATE TABLE channels ( + id INTEGER PRIMARY KEY NOT NULL, + server INTEGER NOT NULL, + name TEXT NOT NULL, + position INTEGER, + topic TEXT, + nsfw INTEGER)"); + + Execute(@"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, + edit_timestamp INTEGER, + replied_to_id INTEGER)"); + + Execute(@"CREATE TABLE attachments ( + message_id INTEGER NOT NULL, + attachment_id INTEGER NOT NULL PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + type TEXT, + url TEXT NOT NULL, + size INTEGER NOT NULL)"); + + Execute(@"CREATE TABLE embeds ( + message_id INTEGER NOT NULL, + json TEXT NOT NULL)"); + + Execute(@"CREATE TABLE reactions ( + message_id INTEGER NOT NULL, + emoji_id INTEGER, + emoji_name TEXT, + emoji_flags INTEGER NOT NULL, + count INTEGER NOT NULL)"); + + Execute("CREATE INDEX attachments_message_ix ON attachments(message_id)"); + Execute("CREATE INDEX embeds_message_ix ON embeds(message_id)"); + Execute("CREATE INDEX reactions_message_ix ON reactions(message_id)"); + + Execute("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")"); + } + + private void UpgradeSchemas(int dbVersion) { + Execute("UPDATE metadata SET value = " + Version + " WHERE key = 'version'"); + } + } +} diff --git a/app/Server/Database/Sqlite/SqliteDatabaseFile.cs b/app/Server/Database/Sqlite/SqliteDatabaseFile.cs new file mode 100644 index 0000000..0bbc254 --- /dev/null +++ b/app/Server/Database/Sqlite/SqliteDatabaseFile.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; +using DHT.Server.Collections; +using DHT.Server.Data; +using DHT.Server.Data.Filters; +using Microsoft.Data.Sqlite; + +namespace DHT.Server.Database.Sqlite { + public class SqliteDatabaseFile : IDatabaseFile { + public static async Task OpenOrCreate(string path, Func> checkCanUpgradeSchemas) { + string connectionString = new SqliteConnectionStringBuilder { + DataSource = path, + Mode = SqliteOpenMode.ReadWriteCreate + }.ToString(); + + var conn = new SqliteConnection(connectionString); + conn.Open(); + + return await new Schema(conn).Setup(checkCanUpgradeSchemas) ? new SqliteDatabaseFile(path, conn) : null; + } + + public string Path { get; } + public DatabaseStatistics Statistics { get; } + + private readonly SqliteConnection conn; + + private SqliteDatabaseFile(string path, SqliteConnection conn) { + this.conn = conn; + this.Path = path; + this.Statistics = new DatabaseStatistics(); + UpdateServerStatistics(); + UpdateChannelStatistics(); + UpdateMessageStatistics(); + } + + public void Dispose() { + conn.Dispose(); + GC.SuppressFinalize(this); + } + + public void AddServer(Data.Server server) { + using var cmd = conn.Upsert("servers", new[] { + "id", "name", "type" + }); + + var serverParams = cmd.Parameters; + serverParams.AddAndSet(":id", server.Id); + serverParams.AddAndSet(":name", server.Name); + serverParams.AddAndSet(":type", ServerTypes.ToString(server.Type)); + cmd.ExecuteNonQuery(); + UpdateServerStatistics(); + } + + public List GetAllServers() { + var list = new List(); + + using var cmd = conn.Command("SELECT id, name, type FROM servers"); + using var reader = cmd.ExecuteReader(); + + while (reader.Read()) { + list.Add(new Data.Server { + Id = (ulong) reader.GetInt64(0), + Name = reader.GetString(1), + Type = ServerTypes.FromString(reader.GetString(2)) + }); + } + + return list; + } + + public void AddChannel(Channel channel) { + using var cmd = conn.Upsert("channels", new[] { + "id", "server", "name", "position", "topic", "nsfw" + }); + + var channelParams = cmd.Parameters; + channelParams.AddAndSet(":id", channel.Id); + channelParams.AddAndSet(":server", channel.Server); + channelParams.AddAndSet(":name", channel.Name); + channelParams.AddAndSet(":position", channel.Position); + channelParams.AddAndSet(":topic", channel.Topic); + channelParams.AddAndSet(":nsfw", channel.Nsfw); + cmd.ExecuteNonQuery(); + UpdateChannelStatistics(); + } + + public List GetAllChannels() { + var list = new List(); + + using var cmd = conn.Command("SELECT id, server, name, position, topic, nsfw FROM channels"); + using var reader = cmd.ExecuteReader(); + + while (reader.Read()) { + list.Add(new Channel { + Id = (ulong) reader.GetInt64(0), + Server = (ulong) reader.GetInt64(1), + Name = reader.GetString(2), + Position = reader.IsDBNull(3) ? null : reader.GetInt32(3), + Topic = reader.IsDBNull(4) ? null : reader.GetString(4), + Nsfw = reader.IsDBNull(5) ? null : reader.GetBoolean(5) + }); + } + + return list; + } + + public void AddUsers(User[] users) { + using var tx = conn.BeginTransaction(); + using var cmd = conn.Upsert("users", new[] { + "id", "name", "avatar_url", "discriminator" + }); + + var userParams = cmd.Parameters; + userParams.Add(":id", SqliteType.Integer); + userParams.Add(":name", SqliteType.Text); + userParams.Add(":avatar_url", SqliteType.Text); + userParams.Add(":discriminator", SqliteType.Text); + + foreach (var user in users) { + userParams.Set(":id", user.Id); + userParams.Set(":name", user.Name); + userParams.Set(":avatar_url", user.AvatarUrl); + userParams.Set(":discriminator", user.Discriminator); + cmd.ExecuteNonQuery(); + } + + tx.Commit(); + } + + public List GetAllUsers() { + var list = new List(); + + using var cmd = conn.Command("SELECT id, name, avatar_url, discriminator FROM users"); + using var reader = cmd.ExecuteReader(); + + while (reader.Read()) { + list.Add(new User { + Id = (ulong) reader.GetInt64(0), + Name = reader.GetString(1), + AvatarUrl = reader.IsDBNull(2) ? null : reader.GetString(2), + Discriminator = reader.IsDBNull(3) ? null : reader.GetString(3) + }); + } + + return list; + } + + public void AddMessages(Message[] messages) { + using var tx = conn.BeginTransaction(); + using var messageCmd = conn.Upsert("messages", new[] { + "message_id", "sender_id", "channel_id", "text", "timestamp", "edit_timestamp", "replied_to_id" + }); + + using var deleteAttachmentsCmd = conn.Command("DELETE FROM attachments WHERE message_id = :message_id"); + using var attachmentCmd = conn.Insert("attachments", new[] { + "message_id", "attachment_id", "name", "type", "url", "size" + }); + + using var deleteEmbedsCmd = conn.Command("DELETE FROM embeds WHERE message_id = :message_id"); + using var embedCmd = conn.Insert("embeds", new[] { + "message_id", "json" + }); + + using var deleteReactionsCmd = conn.Command("DELETE FROM reactions WHERE message_id = :message_id"); + using var reactionCmd = conn.Insert("reactions", new[] { + "message_id", "emoji_id", "emoji_name", "emoji_flags", "count" + }); + + var messageParams = messageCmd.Parameters; + messageParams.Add(":message_id", SqliteType.Integer); + messageParams.Add(":sender_id", SqliteType.Integer); + messageParams.Add(":channel_id", SqliteType.Integer); + messageParams.Add(":text", SqliteType.Text); + messageParams.Add(":timestamp", SqliteType.Integer); + messageParams.Add(":edit_timestamp", SqliteType.Integer); + messageParams.Add(":replied_to_id", SqliteType.Integer); + + var deleteAttachmentsParams = deleteAttachmentsCmd.Parameters; + deleteAttachmentsParams.Add(":message_id", SqliteType.Integer); + + var attachmentParams = attachmentCmd.Parameters; + attachmentParams.Add(":message_id", SqliteType.Integer); + attachmentParams.Add(":attachment_id", SqliteType.Integer); + attachmentParams.Add(":name", SqliteType.Text); + attachmentParams.Add(":type", SqliteType.Text); + attachmentParams.Add(":url", SqliteType.Text); + attachmentParams.Add(":size", SqliteType.Integer); + + var deleteEmbedsParams = deleteEmbedsCmd.Parameters; + deleteEmbedsParams.Add(":message_id", SqliteType.Integer); + + var embedParams = embedCmd.Parameters; + embedParams.Add(":message_id", SqliteType.Integer); + embedParams.Add(":json", SqliteType.Text); + + var deleteReactionsParams = deleteReactionsCmd.Parameters; + deleteReactionsParams.Add(":message_id", SqliteType.Integer); + + var reactionParams = reactionCmd.Parameters; + reactionParams.Add(":message_id", SqliteType.Integer); + reactionParams.Add(":emoji_id", SqliteType.Integer); + reactionParams.Add(":emoji_name", SqliteType.Text); + reactionParams.Add(":emoji_flags", SqliteType.Integer); + reactionParams.Add(":count", SqliteType.Integer); + + foreach (var message in messages) { + object messageId = message.Id; + + messageParams.Set(":message_id", messageId); + messageParams.Set(":sender_id", message.Sender); + messageParams.Set(":channel_id", message.Channel); + messageParams.Set(":text", message.Text); + messageParams.Set(":timestamp", message.Timestamp); + messageParams.Set(":edit_timestamp", message.EditTimestamp); + messageParams.Set(":replied_to_id", message.RepliedToId); + messageCmd.ExecuteNonQuery(); + + deleteAttachmentsParams.Set(":message_id", messageId); + deleteAttachmentsCmd.ExecuteNonQuery(); + + deleteEmbedsParams.Set(":message_id", messageId); + deleteEmbedsCmd.ExecuteNonQuery(); + + deleteReactionsParams.Set(":message_id", messageId); + deleteReactionsCmd.ExecuteNonQuery(); + + if (!message.Attachments.IsEmpty) { + foreach (var attachment in message.Attachments) { + attachmentParams.Set(":message_id", messageId); + attachmentParams.Set(":attachment_id", attachment.Id); + attachmentParams.Set(":name", attachment.Name); + attachmentParams.Set(":type", attachment.Type); + attachmentParams.Set(":url", attachment.Url); + attachmentParams.Set(":size", attachment.Size); + attachmentCmd.ExecuteNonQuery(); + } + } + + if (!message.Embeds.IsEmpty) { + foreach (var embed in message.Embeds) { + embedParams.Set(":message_id", messageId); + embedParams.Set(":json", embed.Json); + embedCmd.ExecuteNonQuery(); + } + } + + if (!message.Reactions.IsEmpty) { + foreach (var reaction in message.Reactions) { + reactionParams.Set(":message_id", messageId); + reactionParams.Set(":emoji_id", reaction.EmojiId); + reactionParams.Set(":emoji_name", reaction.EmojiName); + reactionParams.Set(":emoji_flags", (int) reaction.EmojiFlags); + reactionParams.Set(":count", reaction.Count); + reactionCmd.ExecuteNonQuery(); + } + } + } + + tx.Commit(); + UpdateMessageStatistics(); + } + + public int CountMessages(MessageFilter? filter = null) { + using var cmd = conn.Command("SELECT COUNT(*) FROM messages" + filter.GenerateWhereClause()); + using var reader = cmd.ExecuteReader(); + + return reader.Read() ? reader.GetInt32(0) : 0; + } + + public List GetMessages(MessageFilter? filter = null) { + var attachments = GetAllAttachments(); + var embeds = GetAllEmbeds(); + var reactions = GetAllReactions(); + + var list = new List(); + + using var cmd = conn.Command("SELECT message_id, sender_id, channel_id, text, timestamp, edit_timestamp, replied_to_id FROM messages" + filter.GenerateWhereClause()); + using var reader = cmd.ExecuteReader(); + + while (reader.Read()) { + ulong id = (ulong) reader.GetInt64(0); + + list.Add(new Message { + Id = id, + Sender = (ulong) reader.GetInt64(1), + Channel = (ulong) reader.GetInt64(2), + Text = reader.GetString(3), + Timestamp = reader.GetInt64(4), + EditTimestamp = reader.IsDBNull(5) ? null : reader.GetInt64(5), + RepliedToId = reader.IsDBNull(6) ? null : (ulong) reader.GetInt64(6), + Attachments = attachments.GetListOrNull(id)?.ToImmutableArray() ?? ImmutableArray.Empty, + Embeds = embeds.GetListOrNull(id)?.ToImmutableArray() ?? ImmutableArray.Empty, + Reactions = reactions.GetListOrNull(id)?.ToImmutableArray() ?? ImmutableArray.Empty + }); + } + + return list; + } + + private MultiDictionary GetAllAttachments() { + var dict = new MultiDictionary(); + + using var cmd = conn.Command("SELECT message_id, attachment_id, name, type, url, size FROM attachments"); + using var reader = cmd.ExecuteReader(); + + while (reader.Read()) { + ulong messageId = (ulong) reader.GetInt64(0); + + dict.Add(messageId, new Attachment { + Id = (ulong) reader.GetInt64(1), + Name = reader.GetString(2), + Type = reader.IsDBNull(3) ? null : reader.GetString(3), + Url = reader.GetString(4), + Size = (ulong) reader.GetInt64(5) + }); + } + + return dict; + } + + private MultiDictionary GetAllEmbeds() { + var dict = new MultiDictionary(); + + using var cmd = conn.Command("SELECT message_id, json FROM embeds"); + using var reader = cmd.ExecuteReader(); + + while (reader.Read()) { + ulong messageId = (ulong) reader.GetInt64(0); + + dict.Add(messageId, new Embed { + Json = reader.GetString(1) + }); + } + + return dict; + } + + private MultiDictionary GetAllReactions() { + var dict = new MultiDictionary(); + + using var cmd = conn.Command("SELECT message_id, emoji_id, emoji_name, emoji_flags, count FROM reactions"); + using var reader = cmd.ExecuteReader(); + + while (reader.Read()) { + ulong messageId = (ulong) reader.GetInt64(0); + + dict.Add(messageId, new Reaction { + EmojiId = reader.IsDBNull(1) ? null : (ulong) reader.GetInt64(1), + EmojiName = reader.IsDBNull(2) ? null : reader.GetString(2), + EmojiFlags = (EmojiFlags) reader.GetInt16(3), + Count = reader.GetInt32(4) + }); + } + + return dict; + } + + private void UpdateServerStatistics() { + using var cmd = conn.Command("SELECT COUNT(*) FROM servers"); + Statistics.TotalServers = cmd.ExecuteScalar() as long? ?? 0; + } + + private void UpdateChannelStatistics() { + using var cmd = conn.Command("SELECT COUNT(*) FROM channels"); + Statistics.TotalChannels = cmd.ExecuteScalar() as long? ?? 0; + } + + private void UpdateMessageStatistics() { + using var cmd = conn.Command("SELECT COUNT(*) FROM messages"); + Statistics.TotalMessages = cmd.ExecuteScalar() as long? ?? 0L; + } + } +} diff --git a/app/Server/Database/Sqlite/SqliteMessageFilter.cs b/app/Server/Database/Sqlite/SqliteMessageFilter.cs new file mode 100644 index 0000000..3403ac8 --- /dev/null +++ b/app/Server/Database/Sqlite/SqliteMessageFilter.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DHT.Server.Data.Filters; + +namespace DHT.Server.Database.Sqlite { + public static class SqliteMessageFilter { + public static string GenerateWhereClause(this MessageFilter? filter) { + if (filter == null) { + return ""; + } + + List conditions = new(); + + if (filter.StartDate != null) { + conditions.Add("timestamp >= " + new DateTimeOffset(filter.StartDate.Value).ToUnixTimeMilliseconds()); + } + if (filter.EndDate != null) { + conditions.Add("timestamp <= " + new DateTimeOffset(filter.EndDate.Value).ToUnixTimeMilliseconds()); + } + + if (filter.MessageIds.Count > 0) { + conditions.Add("(" + string.Join(" OR ", filter.MessageIds.Select(id => "message_id = " + id)) + ")"); + } + + return conditions.Count == 0 ? "" : " WHERE " + string.Join(" AND ", conditions); + } + } +} diff --git a/app/Server/Database/Sqlite/SqliteUtils.cs b/app/Server/Database/Sqlite/SqliteUtils.cs new file mode 100644 index 0000000..7453689 --- /dev/null +++ b/app/Server/Database/Sqlite/SqliteUtils.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using Microsoft.Data.Sqlite; + +namespace DHT.Server.Database.Sqlite { + public static class SqliteUtils { + public static SqliteCommand Command(this SqliteConnection conn, string sql) { + var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + return cmd; + } + + public static SqliteCommand Insert(this SqliteConnection conn, string tableName, string[] columns) { + string columnNames = string.Join(',', columns); + string columnParams = string.Join(',', columns.Select(c => ':' + c)); + + return conn.Command("INSERT INTO " + tableName + " (" + columnNames + ")" + + "VALUES (" + columnParams + ")"); + } + + public static SqliteCommand Upsert(this SqliteConnection conn, string tableName, string[] columns) { + string columnNames = string.Join(',', columns); + string columnParams = string.Join(',', columns.Select(c => ':' + c)); + string columnUpdates = string.Join(',', columns.Skip(1).Select(c => c + " = excluded." + c)); + + return conn.Command("INSERT INTO " + tableName + " (" + columnNames + ")" + + "VALUES (" + columnParams + ")" + + "ON CONFLICT (" + columns[0] + ")" + + "DO UPDATE SET " + columnUpdates); + } + + public static void AddAndSet(this SqliteParameterCollection parameters, string key, object? value) { + parameters.AddWithValue(key, value ?? DBNull.Value); + } + + public static void Set(this SqliteParameterCollection parameters, string key, object? value) { + parameters[key].Value = value ?? DBNull.Value; + } + } +} diff --git a/app/Server/Endpoints/BaseEndpoint.cs b/app/Server/Endpoints/BaseEndpoint.cs new file mode 100644 index 0000000..b31ff4e --- /dev/null +++ b/app/Server/Endpoints/BaseEndpoint.cs @@ -0,0 +1,57 @@ +using System; +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; +using DHT.Server.Database; +using DHT.Server.Logging; +using DHT.Server.Service; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; + +namespace DHT.Server.Endpoints { + public abstract class BaseEndpoint { + protected IDatabaseFile Db { get; } + private readonly ServerParameters parameters; + + protected BaseEndpoint(IDatabaseFile db, ServerParameters parameters) { + this.Db = db; + this.parameters = parameters; + } + + public async Task Handle(HttpContext ctx) { + var request = ctx.Request; + var response = ctx.Response; + + Log.Info("Request: " + request.GetDisplayUrl() + " (" + request.ContentLength + " B)"); + + var requestToken = request.Headers["X-DHT-Token"]; + if (requestToken.Count != 1 || requestToken[0] != parameters.Token) { + Log.Error("Token: " + (requestToken.Count == 1 ? requestToken[0] : "")); + response.StatusCode = (int)HttpStatusCode.Forbidden; + return; + } + + try { + var (statusCode, output) = await Respond(ctx); + response.StatusCode = (int)statusCode; + + if (output != null) { + await response.WriteAsJsonAsync(output); + } + } catch (HttpException e) { + Log.Error(e); + response.StatusCode = (int)e.StatusCode; + await response.WriteAsync(e.Message); + } catch (Exception e) { + Log.Error(e); + response.StatusCode = (int)HttpStatusCode.InternalServerError; + } + } + + protected abstract Task<(HttpStatusCode, object?)> Respond(HttpContext ctx); + + protected static async Task ReadJson(HttpContext ctx) { + return await ctx.Request.ReadFromJsonAsync() ?? throw new HttpException(HttpStatusCode.UnsupportedMediaType, "This endpoint only accepts JSON."); + } + } +} diff --git a/app/Server/Endpoints/TrackChannelEndpoint.cs b/app/Server/Endpoints/TrackChannelEndpoint.cs new file mode 100644 index 0000000..88febef --- /dev/null +++ b/app/Server/Endpoints/TrackChannelEndpoint.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; +using DHT.Server.Data; +using DHT.Server.Database; +using DHT.Server.Json; +using DHT.Server.Service; +using Microsoft.AspNetCore.Http; + +namespace DHT.Server.Endpoints { + public class TrackChannelEndpoint : BaseEndpoint { + public TrackChannelEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {} + + protected override async Task<(HttpStatusCode, object?)> Respond(HttpContext ctx) { + var root = await ReadJson(ctx); + var server = ReadServer(root.RequireObject("server"), "server"); + var channel = ReadChannel(root.RequireObject("channel"), "channel", server.Id); + + Db.AddServer(server); + Db.AddChannel(channel); + + return (HttpStatusCode.OK, null); + } + + private static Data.Server ReadServer(JsonElement json, string path) => new() { + Id = json.RequireSnowflake("id", path), + Name = json.RequireString("name", path), + Type = ServerTypes.FromString(json.RequireString("type", path)) ?? throw new HttpException(HttpStatusCode.BadRequest, "Server type must be either 'SERVER', 'GROUP', or 'DM'.") + }; + + private static Channel ReadChannel(JsonElement json, string path, ulong serverId) => new() { + Id = json.RequireSnowflake("id", path), + Server = serverId, + Name = json.RequireString("name", path), + Position = json.HasKey("position") ? json.RequireInt("position", path, min: 0) : null, + Topic = json.HasKey("topic") ? json.RequireString("topic", path) : null, + Nsfw = json.HasKey("nsfw") ? json.RequireBool("nsfw", path) : null + }; + } +} diff --git a/app/Server/Endpoints/TrackMessagesEndpoint.cs b/app/Server/Endpoints/TrackMessagesEndpoint.cs new file mode 100644 index 0000000..2dd9da9 --- /dev/null +++ b/app/Server/Endpoints/TrackMessagesEndpoint.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; +using DHT.Server.Data; +using DHT.Server.Data.Filters; +using DHT.Server.Database; +using DHT.Server.Json; +using DHT.Server.Service; +using Microsoft.AspNetCore.Http; + +namespace DHT.Server.Endpoints { + public class TrackMessagesEndpoint : BaseEndpoint { + public TrackMessagesEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {} + + protected override async Task<(HttpStatusCode, object?)> Respond(HttpContext ctx) { + var root = await ReadJson(ctx); + + if (root.ValueKind != JsonValueKind.Array) { + throw new HttpException(HttpStatusCode.BadRequest, "Expected root element to be an array."); + } + + MessageFilter addedMessageIdFilter = new(); + Message[] messages = new Message[root.GetArrayLength()]; + + int i = 0; + foreach (JsonElement ele in root.EnumerateArray()) { + var message = ReadMessage(ele, "message"); + messages[i++] = message; + addedMessageIdFilter.MessageIds.Add(message.Id); + } + + bool anyNewMessages = Db.CountMessages(addedMessageIdFilter) < messages.Length; + Db.AddMessages(messages); + + return (HttpStatusCode.OK, anyNewMessages ? 1 : 0); + } + + private static Message ReadMessage(JsonElement json, string path) => new() { + Id = json.RequireSnowflake("id", path), + Sender = json.RequireSnowflake("sender", path), + Channel = json.RequireSnowflake("channel", path), + Text = json.RequireString("text", path), + Timestamp = json.RequireLong("timestamp", path), + EditTimestamp = json.HasKey("editTimestamp") ? json.RequireLong("editTimestamp", path) : null, + RepliedToId = json.HasKey("repliedToId") ? json.RequireSnowflake("repliedToId", path) : null, + Attachments = json.HasKey("attachments") ? ReadAttachments(json.RequireArray("attachments", path + ".attachments"), path + ".attachments[]").ToImmutableArray() : ImmutableArray.Empty, + Embeds = json.HasKey("embeds") ? ReadEmbeds(json.RequireArray("embeds", path + ".embeds"), path + ".embeds[]").ToImmutableArray() : ImmutableArray.Empty, + Reactions = json.HasKey("reactions") ? ReadReactions(json.RequireArray("reactions", path + ".reactions"), path + ".reactions[]").ToImmutableArray() : ImmutableArray.Empty + }; + + private static IEnumerable ReadAttachments(JsonElement.ArrayEnumerator array, string path) => array.Select(ele => new Attachment { + Id = ele.RequireSnowflake("id", path), + Name = ele.RequireString("name", path), + Type = ele.HasKey("type") ? ele.RequireString("type", path) : null, + Url = ele.RequireString("url", path), + Size = (ulong)ele.RequireLong("size", path) + }); + + private static IEnumerable ReadEmbeds(JsonElement.ArrayEnumerator array, string path) => array.Select(ele => new Embed { + Json = ele.ValueKind == JsonValueKind.String ? ele.ToString()! : throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + path + "' to be a string.") + }); + + private static IEnumerable ReadReactions(JsonElement.ArrayEnumerator array, string path) => array.Select(ele => { + var reaction = new Reaction { + EmojiId = ele.HasKey("id") ? ele.RequireSnowflake("id", path) : null, + EmojiName = ele.HasKey("name") ? ele.RequireString("name", path) : null, + EmojiFlags = ReadEmojiFlag(ele, "isAnimated", path, EmojiFlags.Animated), + Count = ele.RequireInt("count", path) + }; + + if (reaction.EmojiId == null && reaction.EmojiName == null) { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + path + ".id' and/or '" + path + ".name' to be present."); + } + + return reaction; + }); + + private static EmojiFlags ReadEmojiFlag(JsonElement ele, string key, string path, EmojiFlags flag) { + return ele.HasKey(key) && ele.RequireBool(key, path) ? flag : EmojiFlags.None; + } + } +} diff --git a/app/Server/Endpoints/TrackUsersEndpoint.cs b/app/Server/Endpoints/TrackUsersEndpoint.cs new file mode 100644 index 0000000..60242cc --- /dev/null +++ b/app/Server/Endpoints/TrackUsersEndpoint.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; +using DHT.Server.Data; +using DHT.Server.Database; +using DHT.Server.Json; +using DHT.Server.Service; +using Microsoft.AspNetCore.Http; + +namespace DHT.Server.Endpoints { + public class TrackUsersEndpoint : BaseEndpoint { + public TrackUsersEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {} + + protected override async Task<(HttpStatusCode, object?)> Respond(HttpContext ctx) { + var root = await ReadJson(ctx); + + if (root.ValueKind != JsonValueKind.Array) { + throw new HttpException(HttpStatusCode.BadRequest, "Expected root element to be an array."); + } + + User[] users = new User[root.GetArrayLength()]; + int i = 0; + + foreach (JsonElement user in root.EnumerateArray()) { + users[i++] = ReadUser(user, "user"); + } + + Db.AddUsers(users); + + return (HttpStatusCode.OK, null); + } + + private static User ReadUser(JsonElement json, string path) => new() { + Id = json.RequireSnowflake("id", path), + Name = json.RequireString("name", path), + AvatarUrl = json.HasKey("avatar") ? json.RequireString("avatar", path) : null, + Discriminator = json.HasKey("discriminator") ? json.RequireString("discriminator", path) : null + }; + } +} diff --git a/app/Server/Json/JsonExtensions.cs b/app/Server/Json/JsonExtensions.cs new file mode 100644 index 0000000..e33c4ab --- /dev/null +++ b/app/Server/Json/JsonExtensions.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Text.Json; +using DHT.Server.Service; + +namespace DHT.Server.Json { + public static class JsonExtensions { + public static bool HasKey(this JsonElement json, string key) { + return json.TryGetProperty(key, out _); + } + + public static JsonElement RequireObject(this JsonElement json, string key, string? path = null) { + if (json.TryGetProperty(key, out var result)) { + return result; + } + else { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + (path == null ? key : path + '.' + key) + "' to be an object."); + } + } + + public static JsonElement.ArrayEnumerator RequireArray(this JsonElement json, string key, string? path = null) { + if (json.TryGetProperty(key, out var result) && result.ValueKind == JsonValueKind.Array) { + return result.EnumerateArray(); + } + else { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + (path == null ? key : path + '.' + key) + "' to be an array."); + } + } + + public static string RequireString(this JsonElement json, string key, string path) { + if (json.TryGetProperty(key, out var result) && result.ValueKind == JsonValueKind.String) { + return result.ToString()!; + } + else { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + path + '.' + key + "' to be a string."); + } + } + + public static bool RequireBool(this JsonElement json, string key, string path) { + if (json.TryGetProperty(key, out var result) && result.ValueKind is JsonValueKind.True or JsonValueKind.False) { + return result.GetBoolean(); + } + else { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + path + '.' + key + "' to be a boolean."); + } + } + + public static int RequireInt(this JsonElement json, string key, string path, int min = int.MinValue, int max = int.MaxValue) { + if (json.TryGetProperty(key, out var result) && result.ValueKind == JsonValueKind.Number && result.TryGetInt32(out var i) && i >= min && i <= max) { + return i; + } + else if (min == int.MinValue && max == int.MaxValue) { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + path + '.' + key + "' to be a 32-bit integer."); + } + else if (max == int.MaxValue) { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + path + '.' + key + "' to be a 32-bit integer (> " + min + ")."); + } + else if (min == int.MinValue) { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + path + '.' + key + "' to be a 32-bit integer (< " + max + ")."); + } + else { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + path + '.' + key + "' to be an integer (between " + min + " and " + max + ")."); + } + } + + public static long RequireLong(this JsonElement json, string key, string path, long min = long.MinValue, long max = long.MaxValue) { + if (json.TryGetProperty(key, out var result) && result.ValueKind == JsonValueKind.Number && result.TryGetInt64(out var l) && l >= min && l <= max) { + return l; + } + else if (min == long.MinValue && max == long.MaxValue) { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + path + '.' + key + "' to be a 64-bit integer."); + } + else if (max == long.MaxValue) { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + path + '.' + key + "' to be a 64-bit integer (> " + min + ")."); + } + else if (min == long.MinValue) { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + path + '.' + key + "' to be a 64-bit integer (< " + max + ")."); + } + else { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + path + '.' + key + "' to be an integer (between " + min + " and " + max + ")."); + } + } + + public static ulong RequireSnowflake(this JsonElement json, string key, string path) { + if (ulong.TryParse(json.RequireString(key, path), out var snowflake)) { + return snowflake; + } + else { + throw new HttpException(HttpStatusCode.BadRequest, "Expected key '" + path + '.' + key + "' to be a Snowflake ID (64-bit unsigned integer in a string)."); + } + } + } +} diff --git a/app/Server/Logging/Log.cs b/app/Server/Logging/Log.cs new file mode 100644 index 0000000..6ac2935 --- /dev/null +++ b/app/Server/Logging/Log.cs @@ -0,0 +1,31 @@ +using System; +using System.Diagnostics; + +namespace DHT.Server.Logging { + public static class Log { + private static void LogLevel(ConsoleColor color, string level, string text) { + Console.ForegroundColor = color; + foreach (string line in text.Replace("\r", "").Split('\n')) { + string formatted = $"[{level}] {line}"; + Console.WriteLine(formatted); + Trace.WriteLine(formatted); + } + } + + public static void Info(string message) { + LogLevel(ConsoleColor.Blue, "INFO", message); + } + + public static void Warn(string message) { + LogLevel(ConsoleColor.Yellow, "WARN", message); + } + + public static void Error(string message) { + LogLevel(ConsoleColor.Red, "ERROR", message); + } + + public static void Error(Exception e) { + LogLevel(ConsoleColor.Red, "ERROR", e.ToString()); + } + } +} diff --git a/app/Server/Server.csproj b/app/Server/Server.csproj new file mode 100644 index 0000000..bf811a2 --- /dev/null +++ b/app/Server/Server.csproj @@ -0,0 +1,27 @@ + + + + net5.0 + DHT.Server + enable + DiscordHistoryTrackerServer + chylex + DiscordHistoryTracker + DiscordHistoryTrackerServer + 31.0.0.0 + $(Version) + $(Version) + $(Version) + + + + true + none + + + + + + + + diff --git a/app/Server/Service/HttpException.cs b/app/Server/Service/HttpException.cs new file mode 100644 index 0000000..795a5d0 --- /dev/null +++ b/app/Server/Service/HttpException.cs @@ -0,0 +1,12 @@ +using System; +using System.Net; + +namespace DHT.Server.Service { + public class HttpException : Exception { + public HttpStatusCode StatusCode { get; } + + public HttpException(HttpStatusCode statusCode, string message) : base(message) { + StatusCode = statusCode; + } + } +} diff --git a/app/Server/Service/ServerLauncher.cs b/app/Server/Service/ServerLauncher.cs new file mode 100644 index 0000000..6773266 --- /dev/null +++ b/app/Server/Service/ServerLauncher.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using DHT.Server.Database; +using DHT.Server.Logging; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace DHT.Server.Service { + public static class ServerLauncher { + private static IWebHost? Server { get; set; } = null; + + public static bool IsRunning { get; private set; } + public static event EventHandler? ServerStatusChanged; + public static event EventHandler? ServerManagementExceptionCaught; + + private static Thread? ManagementThread { get; set; } = null; + private static readonly Mutex ManagementThreadLock = new(); + private static readonly BlockingCollection Messages = new(new ConcurrentQueue()); + + private static void EnqueueMessage(IMessage message) { + ManagementThreadLock.WaitOne(); + + try { + if (ManagementThread == null) { + ManagementThread = new Thread(RunManagementThread) { + IsBackground = true + }; + ManagementThread.Start(); + } + + Messages.Add(message); + } finally { + ManagementThreadLock.ReleaseMutex(); + } + } + + [SuppressMessage("ReSharper", "FunctionNeverReturns")] + private static void RunManagementThread() { + foreach (IMessage message in Messages.GetConsumingEnumerable()) { + try { + switch (message) { + case IMessage.StartServer start: + StopServerFromManagementThread(); + StartServerFromManagementThread(start.Port, start.Token, start.Db); + break; + case IMessage.StopServer: + StopServerFromManagementThread(); + break; + } + } catch (Exception e) { + ServerManagementExceptionCaught?.Invoke(null, e); + } + } + } + + private static void StartServerFromManagementThread(int port, string token, IDatabaseFile db) { + Log.Info("Starting server on port " + port + "..."); + + void AddServices(IServiceCollection services) { + services.AddSingleton(typeof(IDatabaseFile), db); + services.AddSingleton(typeof(ServerParameters), new ServerParameters { + Token = token + }); + } + + void SetKestrelOptions(KestrelServerOptions options) { + options.Limits.MaxRequestBodySize = null; + options.Limits.MinResponseDataRate = null; + options.ListenLocalhost(port, listenOptions => listenOptions.Protocols = HttpProtocols.Http1); + } + + Server = WebHost.CreateDefaultBuilder() + .ConfigureServices(AddServices) + .UseKestrel(SetKestrelOptions) + .UseStartup() + .Build(); + + Server.Start(); + + Log.Info("Server started"); + IsRunning = true; + ServerStatusChanged?.Invoke(null, EventArgs.Empty); + } + + private static void StopServerFromManagementThread() { + if (Server != null) { + Log.Info("Stopping server..."); + Server.StopAsync().Wait(); + + Log.Info("Server stopped"); + IsRunning = false; + ServerStatusChanged?.Invoke(null, EventArgs.Empty); + Server = null; + } + } + + public static void Relaunch(int port, string token, IDatabaseFile db) { + EnqueueMessage(new IMessage.StartServer(port, token, db)); + } + + public static void Stop() { + EnqueueMessage(new IMessage.StopServer()); + } + + private interface IMessage { + public sealed class StartServer : IMessage { + public int Port { get; } + public string Token { get; } + public IDatabaseFile Db { get; } + + public StartServer(int port, string token, IDatabaseFile db) { + this.Port = port; + this.Token = token; + this.Db = db; + } + } + + public sealed class StopServer : IMessage {} + } + } +} diff --git a/app/Server/Service/ServerParameters.cs b/app/Server/Service/ServerParameters.cs new file mode 100644 index 0000000..4bb1288 --- /dev/null +++ b/app/Server/Service/ServerParameters.cs @@ -0,0 +1,5 @@ +namespace DHT.Server.Service { + public struct ServerParameters { + public string Token { get; init; } + } +} diff --git a/app/Server/Service/ServerStartup.cs b/app/Server/Service/ServerStartup.cs new file mode 100644 index 0000000..c68efa2 --- /dev/null +++ b/app/Server/Service/ServerStartup.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using DHT.Server.Database; +using DHT.Server.Endpoints; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace DHT.Server.Service { + public class Startup { + public void ConfigureServices(IServiceCollection services) { + services.Configure(options => { + options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict; + }); + + services.AddCors(cors => { + cors.AddDefaultPolicy(builder => { + builder.WithOrigins("https://discord.com").AllowCredentials().AllowAnyMethod().AllowAnyHeader(); + }); + }); + } + + [SuppressMessage("ReSharper", "UnusedMember.Global")] + public void Configure(IApplicationBuilder app, IHostApplicationLifetime lifetime, IDatabaseFile db, ServerParameters parameters) { + app.UseRouting(); + app.UseCors(); + app.UseEndpoints(endpoints => { + TrackChannelEndpoint trackChannel = new(db, parameters); + endpoints.MapPost("/track-channel", async context => await trackChannel.Handle(context)); + + TrackUsersEndpoint trackUsers = new(db, parameters); + endpoints.MapPost("/track-users", async context => await trackUsers.Handle(context)); + + TrackMessagesEndpoint trackMessages = new(db, parameters); + endpoints.MapPost("/track-messages", async context => await trackMessages.Handle(context)); + }); + } + } +} diff --git a/app/Server/Service/ServerUtils.cs b/app/Server/Service/ServerUtils.cs new file mode 100644 index 0000000..70272eb --- /dev/null +++ b/app/Server/Service/ServerUtils.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.NetworkInformation; +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +namespace DHT.Server.Service { + public static class ServerUtils { + public static int FindAvailablePort(int min, int max) { + var properties = IPGlobalProperties.GetIPGlobalProperties(); + var occupied = new HashSet(); + occupied.UnionWith(properties.GetActiveTcpListeners().Select(tcp => tcp.Port)); + occupied.UnionWith(properties.GetActiveTcpConnections().Select(tcp => tcp.LocalEndPoint.Port)); + + for (int port = min; port < max; port++) { + if (!occupied.Contains(port)) { + return port; + } + } + + return min; + } + + private static Regex TokenFilter { get; } = new("[^25679bcdfghjkmnpqrstwxyz]", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + public static string GenerateRandomToken(int length) { + byte[] bytes = new byte[length * 3 / 2]; // Extra bytes compensate for filtered out characters. + var rng = new RNGCryptoServiceProvider(); + + string token = ""; + while (token.Length < length) { + rng.GetBytes(bytes); + token = TokenFilter.Replace(Convert.ToBase64String(bytes), ""); + } + + return token[..length]; + } + } +} diff --git a/app/build.bat b/app/build.bat new file mode 100644 index 0000000..c472fde --- /dev/null +++ b/app/build.bat @@ -0,0 +1,15 @@ +@echo off +set list=win-x64 linux-x64 osx-x64 + +rmdir /S /Q bin + +(for %%a in (%list%) do ( + dotnet publish Desktop -c Release -r %%a -o ./bin/%%a -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishReadyToRun=false -p:PublishTrimmed=true --self-contained true + powershell "Compress-Archive -Path ./bin/%%a/* -DestinationPath ./bin/%%a.zip -CompressionLevel Optimal" +)) + +dotnet publish Desktop -c Release -o ./bin/portable --self-contained false +powershell "Compress-Archive -Path ./bin/portable/* -DestinationPath ./bin/portable.zip -CompressionLevel Optimal" + +echo Done +pause diff --git a/web/img/app-tracker.png b/web/img/app-tracker.png new file mode 100644 index 0000000000000000000000000000000000000000..b430c0a518637eb72887f003e85bbb0abfec4b46 GIT binary patch literal 7896 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 H(D45P%%zD> literal 0 HcmV?d00001 diff --git a/web/index.php b/web/index.php index b619a28..831fad4 100644 --- a/web/index.php +++ b/web/index.php @@ -1,5 +1,5 @@ - + @@ -14,14 +14,16 @@

Discord History Tracker {{{version:web}}} | Release Notes

-

Discord History Tracker is a browser script that lets you locally save chat history in your servers, groups, and private conversations.

-

When the script is active, it will load history of the selected text channel up to the first message, and let you download it for offline viewing in your browser.

+

Discord History Tracker lets you save chat history in your servers, groups, and private conversations, and view it offline.

+

You can use Discord History Tracker either entirely in your browser, or download a desktop app for Windows / Linux / Mac. While the browser-only method is simpler and works on any device that has a modern web browser, it has significant limitations and fewer features than the app. Please read about both methods below.

-

How to Save History

-

Running the Script

+

Method 1: Browser-Only

+

A tracking script will load messages according to your settings, and save them in your browser.

+

Because everything happens in your browser, if the browser tab is closed, or your browser or computer crashes, you will lose all progress. Your browser may also be unable to process large amounts of messages. If this is a concern, use the app method.

+

Setup the Tracking Script

Option 1: Userscript

Preferred option. Requires a browser addon, but DHT will stay up-to-date and be easily accessible on the Discord website.

@@ -40,12 +42,14 @@
-

Option 2: Browser Console

+

Option 2: Browser / Discord Console

The console is the only way to use DHT directly in the desktop app.

    -
  1. Click Copy to Clipboard to copy the script
  2. +
  3. Click Copy to Clipboard to copy the tracking script + +
  4. Press Ctrl+Shift+I in your browser or the Discord app, and select the Console tab
  5. Paste the script into the console, and press Enter to run it
  6. Press Ctrl+Shift+I again to close the console
  7. @@ -67,18 +71,46 @@

Old Versions

-
-

Whenever DHT is fixed to work with a recent Discord update, it will no longer work on the previous version of Discord.

-

If you haven't received that Discord update yet, see Release Notes for information about recent updates, and Old Versions if you need to use an older version of DHT.

-
+

Whenever DHT is updated to work with a new version of Discord, it may no longer work with the previous version of Discord.

+

If you haven't received that Discord update yet, see Release Notes for information about recent updates, and Old Versions if you need to use an older version of DHT.

-

Using the Script

-

When running for the first time, you will see a Settings dialog where you can configure the script. These settings will be remembered as long as you don't delete cookies in your browser.

-

By default, Discord History Tracker is set to pause tracking after it reaches a previously saved message to avoid unnecessary history loading. You may also set it to load all channels in the server or your friends list by selecting Switch to Next Channel.

-

Once you have configured everything, upload your previously saved archive (if you have any), click Start Tracking, and let it run. After the script saves all messages, download the archive.

+

How to Track Messages

+

When using the script for the first time, you will see a Settings dialog where you can configure the script. These settings will be remembered as long as you don't delete cookies in your browser.

+

By default, Discord History Tracker is set to automatically scroll up to load the channel history, and pause tracking if it reaches a previously saved message to avoid unnecessary history loading.

+

Before you Start Tracking, you may use Upload & Combine to load messages from a previously saved archive file into the browser.

+

When you click Download, the browser will generate an archive file from saved messages, and lets you save it to your computer.

-

How to View History

-

Download the Viewer, open it in your browser, and load the archive. By downloading it to your computer, you can view archives offline, and allow the browser to load image previews that might otherwise not load if the remote server prevents embedding them.

+

How to View History

+

First, save the Viewer file to your computer. Then you can open the downloaded viewer in your browser, click Load File, and select the archive to view.

+ +

Method 2: Desktop App

+

The app can be downloaded from GitHub. Every release includes 4 versions available:

+
    +
  • win-x64 is for Windows (64-bit)
  • +
  • linux-x64 is for Linux (64-bit)
  • +
  • osx-x64 is for macOS (Intel)
  • +
  • portable requires .NET 5 to be installed, but should run on any operating system supported by .NET
  • +
+

The three non-portable versions include an executable named DiscordHistoryTracker you can launch. For the portable version, extract the archive into a folder, open the folder in a terminal and type: dotnet DiscordHistoryTracker.dll

+ +

How to Track Messages

+

The app saves messages into a database file stored on your computer. When you open the app, you are given the option to create a new database file, or open an existing one.

+

In the Tracking tab, click Copy Tracking Script to generate a tracking script that works similarly to the browser-only version of Discord History Tracker, but instead of saving messages in the browser, the tracking script sends them to the app which saves them in the database file.

+ Screenshot of the App (Tracker tab) +

See Option 2: Browser / Discord Console above for more detailed instructions on how to paste the tracking script into the browser or Discord app console.

+

When using the script for the first time, you will see a Settings dialog where you can configure the script. These settings will be remembered as long as you don't delete cookies in your browser.

+

By default, Discord History Tracker is set to automatically scroll up to load the channel history, and pause tracking if it reaches a previously saved message to avoid unnecessary history loading.

+ +

How to View History

+

In the Viewer tab, you can open a viewer in your browser, or save it as a file you can open in your browser later. You also have the option to apply filters to only view a portion of the saved messages.

+ Screenshot of the App (Viewer tab) + +

Technical Details

+
    +
  1. The app uses SQLite, which means that you can use SQL to manually query or manipulate the database file.
  2. +
  3. The app communicates with the script using an integrated server. The server only listens for local connections (i.e. connections from programs running on your computer, not the internet). When you copy the tracking script, it will contain a randomly generated token that ensures only the tracking script is able to talk to the server.
  4. +
  5. You can use the -port <p> and -token <t> command line arguments to configure the server manually — otherwise, they will be assigned automatically in a way that allows running multiple separate instances of the app.
  6. +

External Links

- -

Disclaimer

-

Discord History Tracker and the viewer are fully client-side and do not communicate with any servers – the terms 'Upload' and 'Download' only refer to your browser. If you close your browser while the script is running, all unsaved progress will be lost.

-

Please, do not use this script for large or public servers. The script was made as a convenient way of keeping a local copy of private and group chats, as Discord is currently lacking this functionality.

diff --git a/web/style.css b/web/style.css index 2eea4d9..a57a10d 100644 --- a/web/style.css +++ b/web/style.css @@ -1,13 +1,12 @@ body { font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif; margin: 0; - padding: 0; + padding: 0 0 20px; font-size: 18px; text-shadow: 1px 1px 0 #111; color: rgba(255, 255, 255, 0.8); - background-color: #3B3E45; + background-color: #3b3e45; box-sizing: border-box; - -moz-box-sizing: border-box; } .inner { @@ -22,7 +21,7 @@ p { } a { - color: #0EB3E0; + color: #1ecfff; text-decoration: none; } @@ -64,30 +63,31 @@ h1 span.notes { } h2 { - margin: 36px 0 0; + margin: 40px 0 0; font-size: 32px; - color: #ffb67b; + color: #f9d288; } h3 { - margin: 24px 0 0; + margin: 30px 0 12px; font-size: 22px; color: rgba(255, 255, 255, 0.9); } h2 + h3, h3 + h4 { - margin-top: 12px; + margin-top: 15px; } h4 { - margin: 22px 0 0; - font-size: 19px; + margin: 25px 0 0; + font-size: 20px; color: rgba(255, 255, 255, 0.75); } ul, ol { margin-top: -6px; margin-left: -6px; + margin-bottom: -2px; } li { @@ -102,6 +102,10 @@ li > img { margin-top: 8px; } +code { + margin: 0 3px; +} + .dht { max-width: 100%; max-height: auto; @@ -116,11 +120,10 @@ li > img { border: 2px dashed rgba(255, 255, 255, 0.25); border-radius: 3px; box-sizing: border-box; - -moz-box-sizing: border-box; } .quote { - border-left: 2px dashed rgba(255, 255, 255, 0.1); + border-left: 2px dashed rgba(255, 253, 123, 0.5); margin-left: 2px; padding-left: 12px; }