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

51 Commits
v33.2 ... v35.1

Author SHA1 Message Date
8e2ec4dfe2 Release v35.1 2022-03-31 14:30:47 +02:00
3431f091ad Update build instructions 2022-03-31 10:54:04 +02:00
a988003bdd Fix server name detection broken by a Discord update
Closes #172
2022-03-31 09:08:57 +02:00
5561f574cf Release v35 (app) 2022-03-24 00:03:12 +01:00
8fd4561721 Try to fix inconsistency with embedded resource generation 2022-03-24 00:03:12 +01:00
9fe68be3f4 Fix bad command for executing python script in .csproj 2022-03-23 20:40:38 +01:00
90dac674eb Update website 2022-03-22 03:52:17 +01:00
1ebf15b039 Add '/app' to excluded folder in root IDEA project 2022-03-22 01:30:38 +01:00
86acef1a22 Add option to Advanced tab to vacuum the database 2022-03-21 14:18:21 +01:00
277e241183 Speed up opening database by deferring the initial refresh of total message count 2022-03-21 14:18:21 +01:00
3b41ea7b5f Fix showing potentially outdated message count when merging databases 2022-03-21 14:03:21 +01:00
6ce0ef7d55 Refresh message statistics after tracking messages in a background thread 2022-03-21 01:38:41 +01:00
fd09ac496e Increase batch size when adding randomly generated data & fix magic numbers 2022-03-21 01:19:20 +01:00
9ca56bd910 Enable write-ahead log for SQLite connections to prevent blocking concurrent writes and reads 2022-03-21 00:38:24 +01:00
3e891e19c3 Avoid redundant database queries when the Viewer tab is not visible 2022-03-20 23:11:39 +01:00
9341988017 Add debug tab with random database data generator 2022-03-20 23:11:38 +01:00
461e403733 Fix viewer filter not including the whole last day 2022-03-20 21:20:10 +01:00
c03e2d328d Fix redundant XAML 2022-03-20 20:43:43 +01:00
f3723ee43b Split browser-only version into a separate branch & update README 2022-03-20 16:46:08 +01:00
8f7b566db7 Release v34 (app) 2022-03-20 13:40:31 +01:00
70a2a01ec3 Fix switching to next channel in DMs after a recent Discord update 2022-03-20 13:40:31 +01:00
c31155738e Disable debug symbols for Utils project in Release configuration 2022-03-19 21:50:45 +01:00
c23fac465f Rework app build setup to call minification script automatically 2022-03-19 21:49:56 +01:00
51a2ac2d66 Fix app minification script on non-Windows systems 2022-03-19 21:18:51 +01:00
a5e8366f1b Redesign status bar in the app 2022-03-17 20:38:25 +01:00
3b698dbf33 Change assembly names of subprojects 2022-03-15 12:57:55 +01:00
dc2c2d7ce8 Remove unnecessary .csproj tags and hide embedded resources from IDEs 2022-03-15 12:55:39 +01:00
bb5634adc4 Move screens to a different namespace 2022-03-15 12:55:27 +01:00
d26e16eadf Move advanced tracking settings into a separate tab 2022-03-14 18:17:17 +01:00
8f5f6065d8 Refactor text channel switching to detect more types of text channels
Closes #119
Closes #159
2022-03-13 18:31:28 +01:00
ad299bf762 Fix stalling on empty channels
Closes #164
2022-03-13 17:42:38 +01:00
f70bbd53d9 Fix ignoring settings for reaching the first message in a channel if no new messages were saved 2022-03-13 17:05:27 +01:00
ae821f738e Fix app memory leaks caused by UI code 2022-03-13 14:47:25 +01:00
ab7f5d0a41 Add SQLite connection pooling and fix not releasing file lock after closing database
Closes #167
2022-03-13 13:50:26 +01:00
1bddde7ccd Fix not fully disposing internal app server when stopped 2022-03-13 13:17:58 +01:00
58259c0bb4 Update Avalonia to 0.10.13 2022-03-12 18:12:31 +01:00
a84a453990 Redesign the app 2022-03-12 18:12:30 +01:00
563c644f48 Fix new databases containing columns that were removed in an earlier commit 2022-03-12 18:12:30 +01:00
f8331a571e Fix right margins in Viewer filter panel, causing early wrapping 2022-03-10 03:39:48 +01:00
1ed26a16ea Add more performance logging to the app 2022-03-06 15:49:44 +01:00
72c13cbb58 Fix more database disposal issues 2022-03-06 15:22:03 +01:00
e420add646 Split edit timestamps and message reply ids to separate tables to reduce database size 2022-03-06 15:11:23 +01:00
6f1149ad5e Add utilities to simplify working with SQLite 2022-03-05 22:58:47 +01:00
b9899922e0 Optimize viewer export in the app 2022-03-05 21:35:56 +01:00
6a2933ea0a Add utilities for performance logging 2022-03-05 21:05:43 +01:00
be5c76c3bd Add debug log level and reset console colors after logging 2022-03-05 20:09:24 +01:00
217c1f9e10 Tell users to backup database file(s) before a schema upgrade 2022-03-05 18:43:48 +01:00
725ab7accf Update SQLite version to 3.35.0 2022-03-05 17:18:33 +01:00
9a7a2cffc2 Allow database file path to be passed as the first command line argument to the app
This adds support for directly opening files with the DHT app, for ex. in Windows Explorer by using "Open With", or by associating the ".dht" extension with the app.
2022-03-05 16:43:58 +01:00
6d3db23f80 Fix not manually disposing of resources when the app window is closed 2022-03-05 13:36:04 +01:00
4bc9626013 Add name to server management thread 2022-03-05 13:36:04 +01:00
106 changed files with 2020 additions and 6105 deletions

View File

@@ -8,6 +8,7 @@
<excludeFolder url="file://$MODULE_DIR$/.vscode" />
<excludeFolder url="file://$MODULE_DIR$/bld" />
<excludeFolder url="file://$MODULE_DIR$/lib" />
<excludeFolder url="file://$MODULE_DIR$/app" />
</content>
<orderEntry type="jdk" jdkName="Python 3.8" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />

52
.vscode/tasks.json vendored
View File

@@ -1,52 +0,0 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "Build Raw",
"type": "shell",
"command": "python ./build.py --nominify --copytracker",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "shared",
"echo": true,
"focus": false,
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Build Minified",
"type": "shell",
"command": "python ./build.py --copytracker",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared",
"echo": true,
"focus": false,
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Build Website",
"type": "shell",
"command": "python ./build.py --website",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared",
"echo": true,
"focus": false,
"showReuseMessage": false
},
"problemMatcher": []
}
]
}

View File

@@ -1,48 +1,53 @@
# Welcome
All you need to **use Discord History Tracker** is either an up-to-date browser, or the [Discord desktop client](https://discord.com/download). Visit the [official website](https://dht.chylex.com) for instructions.
For instructions on how to **use Discord History Tracker**, visit the [official website](https://dht.chylex.com).
To **report an issue or suggestion**, first please see the [issues](https://github.com/chylex/Discord-History-Tracker/issues) page and make sure someone else hasn't already created a similar issue report. If you do find an existing issue, comment on it or add a reaction. Otherwise, either click [New Issue](https://github.com/chylex/Discord-History-Tracker/issues/new), or contact me via email [contact@chylex.com](mailto:contact@chylex.com) or Twitter [@chylexmc](https://twitter.com/chylexmc).
If you are interested in **creating your own version** from the source code, continue reading the [build instructions](#Build-Instructions) below.
If you are interested in **building from source code**, continue reading the [build instructions](#Build-Instructions) below.
This branch is dedicated to the Discord History Tracker desktop app. If you are looking for the older browser-only version, visit the [master-browser-only](https://github.com/chylex/Discord-History-Tracker/tree/master-browser-only) branch.
# Build Instructions
Follow the steps below to create your own version of Discord History Tracker.
### Setup
Fork the repository and clone it to your computer (if you've never used git, you can download the [GitHub Desktop](https://desktop.github.com) client to get started quickly).
Now you can modify the source code:
* `src/tracker/` contains JS files that are automatically combined into the **tracker bookmark/script**
* `src/viewer/` contains HTML, CSS, JS files that are then combined into the **offline viewer page**
Folder organization:
* `app/` contains a Visual Studio solution for the desktop app
* `lib/` contains utilities required to build the project
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
To start editing source code for the desktop app, install the [.NET 5 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/5.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
### Building
After you've done changes to the source code, you will need to build it. Before that, download and install:
* (**required**) [Python 3](https://www.python.org/downloads)
* Use to run the build script
* (optional) [Node + npm](https://nodejs.org/en) & command line [uglify-js](https://www.npmjs.com/package/uglify-js)
* Not required on Windows
* Only required for optional [JS minification](#Minification) on Linux/Mac
To build a `Debug` version of the desktop app, there are no additional requirements.
Now open the folder that contains `build.py` in a command line, and run `python build.py` to create a build with default settings. The following files will be created:
* `bld/track.js` is the raw tracker script that can be pasted into a browser console
* `bld/track.html` is the tracker script but sanitized for inclusion in HTML (see `web/index.php` for examples)
* `bld/viewer.html` is the complete offline viewer
To build a `Release` version of the desktop app, follow the instructions for your operating system.
You can tweak the build process using the following flags:
* `python build.py --nominify` to disable [minification](#Minification)
#### Release Windows (64-bit)
### Minification
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable is in your `PATH`
2. Install [Powershell 5](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) or newer (on Windows 10, the included version of Powershell should be enough)
The build process automatically minifies JS using `UglifyJS@3`, and CSS using a custom minifier.
The `lib/` folder contains an installation of [Node](https://nodejs.org/en) and [uglify-js](https://www.npmjs.com/package/uglify-js), which are used to minify the tracking script. This installation will only work on 64-bit Windows; building on 32-bit Windows is not supported, but you can try.
* If the `--nominify` flag is used, minification will be completely disabled
* If `uglify-js` is not available from the command line, JS minification will be skipped
* When building on Windows 64-bit, the build script will use the included Node runner and packages
* When building on Windows 32-bit, you will need to download [Node 32-bit](https://nodejs.org/en/download) and replace the included one in `lib/`
* When building on Linux/Mac, the build script will attempt to find `uglifyjs` in the command line
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
#### Release Other Operating Systems
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable exists and launches Python 3
- On Debian and derivatives, you can install `python-is-python3`
- On other distributions, you can create a link manually, for ex. `ln -s /usr/bin/python3 /usr/bin/python`
- If you don't want `python` to mean Python 3, then edit `Desktop.csproj` and change `python` to `python3`
2. Install [Node + npm](https://nodejs.org/en)
3. Install [uglify-js](https://www.npmjs.com/package/uglify-js) globally (`npm install -g uglify-js`)
4. Install the `zip` package from your repository
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
#### Distribution
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 5 to be installed.

View File

@@ -9,13 +9,16 @@
<entry key="Desktop/Dialogs/Progress/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/FilterPanel.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/ServerConfigurationPanel.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/MainContentScreen.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/AdvancedPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/DatabasePage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/DebugPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/TrackingPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/ViewerPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/WelcomeScreen.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Screens/MainContentScreen.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Screens/WelcomeScreen.axaml" value="Desktop/Desktop.csproj" />
</map>
</option>
</component>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions">
<enabled-global>
<option value="UglifyJS (Tracker)" />
</enabled-global>
</component>
</project>

View File

@@ -1,74 +1,41 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:common="clr-namespace:DHT.Desktop.Common"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
x:Class="DHT.Desktop.App">
<Application.Styles>
<FluentTheme Mode="Light" />
<Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader">
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="Button">
<Setter Property="Padding" Value="10 7 10 6" />
<Setter Property="Background" Value="#DDDDDD" />
<Setter Property="BorderBrush" Value="#999999" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="TextBlock.Foreground" Value="#060606" />
<Setter Property="Background" Value="#DDDDDD" />
<Setter Property="BorderBrush" Value="#999999" />
</Style>
<Style Selector="Button:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#E7E7E7" />
<Setter Property="BorderBrush" Value="#A2A2A2" />
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="Button:pressed">
<Setter Property="RenderTransform" Value="none" />
</Style>
<Style Selector="Button:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#EBEBEB" />
<Setter Property="BorderBrush" Value="#A5A5A5" />
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="Button:disabled /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="TextBlock.Foreground" Value="#7A7A7A" />
<Setter Property="Background" Value="#E9E9E9" />
<Setter Property="BorderBrush" Value="#BFBFBF" />
</Style>
<Style Selector="TextBox">
<Setter Property="BorderBrush" Value="#999999" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="SelectionBrush" Value="#72C0FF" />
<Setter Property="Background" Value="#F4F4F4" />
<Setter Property="Padding" Value="6 0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="TextBox:pointerover /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="#999999" />
<Setter Property="Background" Value="#F8F8F8" />
</Style>
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="#546A9F" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Background" Value="#FBFBFB" />
</Style>
<Style Selector="TextBox:disabled /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="#999999" />
<Setter Property="Background" Value="#BBBBBB" />
</Style>
<Style Selector="TextBox /template/ TextBlock#PART_Watermark">
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Expander">
<Setter Property="CornerRadius" Value="0" />
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="TextBox:disabled"><!-- TODO bug in Avalonia (https://github.com/AvaloniaUI/Avalonia/pull/7792) -->
<Setter Property="Foreground" Value="{DynamicResource TextControlForegroundDisabled}" />
</Style>
<Style Selector="Expander /template/ ToggleButton#ExpanderHeader">
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Template">
@@ -108,6 +75,30 @@
</ControlTemplate>
</Setter>
</Style>
<Style Selector="Expander:expanded /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
<Style.Animations>
<Animation FillMode="Both" Duration="0:0:0.0625">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="180" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="180" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="Expander:not(:expanded) /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
<Style.Animations>
<Animation FillMode="Both" Duration="0:0:0.0625">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="0" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="0" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
</Application.Styles>
@@ -115,9 +106,61 @@
<common:NumberValueConverter x:Key="NumberValueConverter" />
<system:Double x:Key="ControlContentThemeFontSize">14</system:Double>
<CornerRadius x:Key="ControlCornerRadius">0</CornerRadius>
<Color x:Key="SystemAccentColor">#3C5F95</Color>
<Color x:Key="SystemAccentColorDark1">#3C5F95</Color>
<Color x:Key="SystemAccentColorDark2">#3C5F95</Color>
<Color x:Key="SystemAccentColorDark3">#3C5F95</Color>
<Color x:Key="SystemAccentColorLight1">#3C5F95</Color>
<Color x:Key="SystemAccentColorLight2">#3C5F95</Color>
<Color x:Key="SystemAccentColorLight3">#3C5F95</Color>
<system:Double x:Key="ScrollBarSize">14</system:Double>
<TransformOperations x:Key="VerticalSmallScrollThumbScaleTransform">scaleX(0.5) translateX(-3px)</TransformOperations>
<TransformOperations x:Key="HorizontalSmallScrollThumbScaleTransform">scaleY(0.5) translateY(-3px)</TransformOperations>
<SolidColorBrush x:Key="ScrollBarPanningThumbBackground" Color="#8F8F8F" />
<SolidColorBrush x:Key="ButtonBorderBrush" Color="#3C5F95" />
<SolidColorBrush x:Key="ButtonBorderBrushPointerOver" Color="#0E2B59" />
<SolidColorBrush x:Key="ButtonBorderBrushPressed" Color="#061742" />
<SolidColorBrush x:Key="ButtonBorderBrushDisabled" Color="#9B9B9B" />
<SolidColorBrush x:Key="ButtonBackground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ButtonBackgroundPointerOver" Color="#F6F9FD" />
<SolidColorBrush x:Key="ButtonBackgroundPressed" Color="#EDF3FD" />
<SolidColorBrush x:Key="ButtonBackgroundDisabled" Color="#FFFFFF" />
<SolidColorBrush x:Key="ButtonForeground" Color="#112961" />
<SolidColorBrush x:Key="ButtonForegroundPointerOver" Color="#050E41" />
<SolidColorBrush x:Key="ButtonForegroundPressed" Color="#010320" />
<SolidColorBrush x:Key="ButtonForegroundDisabled" Color="#8C8C8C" />
<SolidColorBrush x:Key="TextControlBorderBrush" Color="#515151" />
<SolidColorBrush x:Key="TextControlBorderBrushPointerOver" Color="#000000" />
<SolidColorBrush x:Key="TextControlBorderBrushFocused" Color="#3C5F95" />
<SolidColorBrush x:Key="TextControlBorderBrushDisabled" Color="#9B9B9B" />
<SolidColorBrush x:Key="TextControlBackground" Color="#FFFFFF" />
<SolidColorBrush x:Key="TextControlBackgroundPointerOver" Color="#FFFFFF" />
<SolidColorBrush x:Key="TextControlBackgroundFocused" Color="#F8FCFF" />
<SolidColorBrush x:Key="TextControlBackgroundDisabled" Color="#FFFFFF" />
<SolidColorBrush x:Key="TextControlForeground" Color="#000000" />
<SolidColorBrush x:Key="TextControlForegroundPointerOver" Color="#000000" />
<SolidColorBrush x:Key="TextControlForegroundFocused" Color="#000000" />
<SolidColorBrush x:Key="TextControlForegroundDisabled" Color="#8C8C8C" />
<SolidColorBrush x:Key="TextControlSelectionHighlightColor" Color="#DEE9F8" />
<SolidColorBrush x:Key="TextControlPlaceholderForeground" Color="#AAAAAA" />
<SolidColorBrush x:Key="TextControlPlaceholderForegroundPointerOver" Color="#AAAAAA" />
<SolidColorBrush x:Key="TextControlPlaceholderForegroundFocused" Color="#AAAAAA" />
<SolidColorBrush x:Key="TextControlPlaceholderForegroundDisabled" Color="#AAAAAA" />
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
<Thickness x:Key="ExpanderContentPadding">15</Thickness>
<SolidColorBrush x:Key="ExpanderDropDownBackground" Color="#FCFCFC" />
<SolidColorBrush x:Key="ExpanderBorderBrush" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderBackground" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderForeground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderChevronForeground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderDropDownBorderBrush" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderDropDownBackground" Color="#FFFFFF" />
</Application.Resources>

View File

@@ -15,12 +15,25 @@ namespace DHT.Desktop {
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);
switch (key) {
case "-debug":
Log.IsDebugEnabled = true;
continue;
}
string value = args[++i];
string value;
if (i == 0 && !key.StartsWith('-')) {
value = key;
key = "-db";
}
else if (i >= args.Length - 1) {
Log.Warn("Missing value for command line argument: " + key);
continue;
}
else {
value = args[++i];
}
switch (key) {
case "-db":

View File

@@ -56,11 +56,11 @@ namespace DHT.Desktop.Common {
}
public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) {
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT. Do you want to proceed with the upgrade?");
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT.\n\nPlease ensure you have a backup of the database. Do you want to proceed with the upgrade?");
}
public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) {
return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped. Do you want to proceed with the upgrade?");
return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped.\n\nPlease ensure you have a backup of the databases. Do you want to proceed with the upgrade?");
}
}
}

View File

@@ -5,7 +5,7 @@ using Avalonia.Data.Converters;
namespace DHT.Desktop.Common {
sealed class NumberValueConverter : IValueConverter {
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
return string.Format(Program.Culture, "{0:n0}", value);
return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value);
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {

View File

@@ -3,6 +3,7 @@
<OutputType>WinExe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
<AssemblyName>DiscordHistoryTracker</AssemblyName>
<RootNamespace>DHT.Desktop</RootNamespace>
<PackageId>DiscordHistoryTracker</PackageId>
<Authors>chylex</Authors>
@@ -11,7 +12,6 @@
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<AssemblyName>DiscordHistoryTracker</AssemblyName>
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
@@ -21,56 +21,52 @@
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="0.10.12" />
<PackageReference Include="Avalonia.Desktop" Version="0.10.12" />
<ProjectReference Include="..\Server\Server.csproj" />
<PackageReference Include="Avalonia" Version="0.10.13" />
<PackageReference Include="Avalonia.Desktop" Version="0.10.13" />
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.13" Condition=" '$(Configuration)' == 'Debug' " />
</ItemGroup>
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.12" />
<ItemGroup>
<ProjectReference Include="..\Server\Server.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Version.cs" Link="Version.cs" />
</ItemGroup>
<ItemGroup>
<Compile Update="Windows\MainWindow.axaml.cs">
<DependentUpon>MainWindow.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Dialogs\CheckBox\CheckBoxDialog.axaml.cs">
<DependentUpon>CheckBoxDialog.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Dialogs\Progress\ProgressDialog.axaml.cs">
<DependentUpon>ProgressDialog.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Dialogs\Message\MessageDialog.axaml.cs">
<DependentUpon>MessageDialog.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
</ItemGroup>
<ItemGroup>
<UpToDateCheckInput Remove="Pages\DatabasePage.axaml" />
<UpToDateCheckInput Remove="Pages\TrackingPage.axaml" />
<UpToDateCheckInput Remove="Pages\ViewerPage.axaml" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Resources/icon.ico" />
<EmbeddedResource Include="../Resources/Tracker/bootstrap.js">
<LogicalName>Tracker\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Tracker/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource>
<EmbeddedResource Include="../Resources/Tracker/scripts.min/**">
<EmbeddedResource Include="../Resources/Tracker/scripts/**" Condition=" '$(Configuration)' == 'Debug' ">
<LogicalName>Tracker\scripts\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Tracker/scripts/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource>
<EmbeddedResource Include="../Resources/Tracker/styles/**">
<LogicalName>Tracker\styles\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Tracker/styles/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource>
<EmbeddedResource Include="../Resources/Viewer/**">
<LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Viewer/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource>
<AvaloniaResource Include="Resources/icon.ico" />
</ItemGroup>
<Target Name="MinifyResources" BeforeTargets="PrepareForBuild" Condition=" '$(Configuration)' == 'Release' ">
<PropertyGroup>
<MinifiedResourceDir>$(ProjectDir)bin/.res/scripts</MinifiedResourceDir>
</PropertyGroup>
<ItemGroup>
<UpToDateCheckInput Include="$(ProjectDir)../Resources/Tracker/scripts/**" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/discord.js" LogicalName="Tracker\scripts\discord.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/dom.js" LogicalName="Tracker\scripts\dom.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/gui.js" LogicalName="Tracker\scripts\gui.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/settings.js" LogicalName="Tracker\scripts\settings.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/state.js" LogicalName="Tracker\scripts\state.js" Visible="false" />
</ItemGroup>
<RemoveDir Directories="$(ProjectDir)bin/.res/scripts" />
<Exec Command="python $(ProjectDir)../Resources/minify.py" WorkingDirectory="$(ProjectDir)../Resources" IgnoreExitCode="false" />
</Target>
</Project>

View File

@@ -15,6 +15,9 @@
<Setter Property="Margin" Value="0 20 40 0" />
<Setter Property="Spacing" Value="4" />
</Style>
<Style Selector="WrapPanel > StackPanel:nth-last-child(1)">
<Setter Property="Margin" Value="0 20 0 0" />
</Style>
<Style Selector="Grid > Label">
<Setter Property="VerticalAlignment" Value="Center" />
</Style>

View File

@@ -14,7 +14,7 @@ using DHT.Server.Database;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls {
sealed class FilterPanelModel : BaseModel {
sealed class FilterPanelModel : BaseModel, IDisposable {
private static readonly HashSet<string> FilterProperties = new () {
nameof(FilterByDate),
nameof(StartDate),
@@ -103,6 +103,10 @@ namespace DHT.Desktop.Main.Controls {
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
}
public void Dispose() {
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
}
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
FilterPropertyChanged?.Invoke(sender, e);
@@ -207,7 +211,7 @@ namespace DHT.Desktop.Main.Controls {
if (FilterByDate) {
filter.StartDate = StartDate;
filter.EndDate = EndDate;
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
}
if (FilterByChannel) {

View File

@@ -0,0 +1,48 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel">
<Design.DataContext>
<controls:ServerConfigurationPanelModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="TextBox">
<Setter Property="FontFamily" Value="Consolas,Courier" />
<Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="WrapPanel > StackPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="Margin" Value="0 0 10 10" />
</Style>
</UserControl.Styles>
<StackPanel>
<Button Command="{Binding OnClickToggleServerButton}" Content="{Binding ToggleServerButtonText}" IsEnabled="{Binding IsToggleServerButtonEnabled}" />
<TextBlock TextWrapping="Wrap" Margin="0 15">
The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy and apply the tracking script again.
</TextBlock>
<WrapPanel>
<StackPanel>
<Label Target="Port">Port</Label>
<TextBox x:Name="Port" Width="70" Text="{Binding InputPort}" />
</StackPanel>
<StackPanel>
<Label Target="Token">Token</Label>
<TextBox x:Name="Token" Width="200" Text="{Binding InputToken}" />
</StackPanel>
<StackPanel VerticalAlignment="Bottom">
<Button Command="{Binding OnClickRandomizeToken}">Randomize Token</Button>
</StackPanel>
</WrapPanel>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickApplyChanges}">Apply &amp; Restart</Button>
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickCancelChanges}">Cancel</Button>
</StackPanel>
</StackPanel>
</UserControl>

View File

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

View File

@@ -0,0 +1,116 @@
using System;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Server;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls {
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
private string inputPort;
public string InputPort {
get => inputPort;
set {
Change(ref inputPort, value);
OnPropertyChanged(nameof(HasMadeChanges));
}
}
private string inputToken;
public string InputToken {
get => inputToken;
set {
Change(ref inputToken, value);
OnPropertyChanged(nameof(HasMadeChanges));
}
}
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
private bool isToggleServerButtonEnabled = true;
public bool IsToggleServerButtonEnabled {
get => isToggleServerButtonEnabled;
set => Change(ref isToggleServerButtonEnabled, value);
}
public string ToggleServerButtonText => serverManager.IsRunning ? "Stop Server" : "Start Server";
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
private readonly Window window;
private readonly ServerManager serverManager;
[Obsolete("Designer")]
public ServerConfigurationPanelModel() : this(null!, new ServerManager(DummyDatabaseFile.Instance)) {}
public ServerConfigurationPanelModel(Window window, ServerManager serverManager) {
this.window = window;
this.serverManager = serverManager;
this.inputPort = ServerManager.Port.ToString();
this.inputToken = ServerManager.Token;
}
public void Initialize() {
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
}
public void Dispose() {
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
}
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
ServerStatusChanged?.Invoke(this, serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
OnPropertyChanged(nameof(ToggleServerButtonText));
IsToggleServerButtonEnabled = true;
}
private void BeforeServerStart() {
IsToggleServerButtonEnabled = false;
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
}
private void StartServer() {
BeforeServerStart();
serverManager.Launch();
}
private void StopServer() {
IsToggleServerButtonEnabled = false;
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
serverManager.Stop();
}
public void OnClickToggleServerButton() {
if (serverManager.IsRunning) {
StopServer();
}
else {
StartServer();
}
}
public void OnClickRandomizeToken() {
InputToken = ServerUtils.GenerateRandomToken(20);
}
public async void OnClickApplyChanges() {
if (!ushort.TryParse(InputPort, out ushort port)) {
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
return;
}
BeforeServerStart();
serverManager.Relaunch(port, InputToken);
OnPropertyChanged(nameof(HasMadeChanges));
}
public void OnClickCancelChanges() {
InputPort = ServerManager.Port.ToString();
InputToken = ServerManager.Token;
}
}
}

View File

@@ -11,32 +11,35 @@
</Design.DataContext>
<UserControl.Background>
<SolidColorBrush>#546A9F</SolidColorBrush>
<SolidColorBrush>#3C4F79</SolidColorBrush>
</UserControl.Background>
<UserControl.Styles>
<Style Selector="StackPanel > TextBlock">
<Setter Property="Foreground" Value="#E0E0E0" />
<Setter Property="Foreground" Value="#E7E7E7" />
</Style>
<Style Selector="StackPanel > TextBlock.label">
<Setter Property="FontSize" Value="15" />
<Setter Property="FontWeight" Value="SemiLight" />
</Style>
<Style Selector="StackPanel > TextBlock.value">
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="SemiLight" />
<Setter Property="TextAlignment" Value="Right" />
<Setter Property="Margin" Value="0 1" />
</Style>
<Style Selector="StackPanel > Rectangle">
<Setter Property="Margin" Value="14 0" />
<Setter Property="Stroke" Value="#3B5287" />
<Setter Property="Margin" Value="14 1" />
<Setter Property="Stroke" Value="#697899" />
<Setter Property="StrokeThickness" Value="2" />
<Setter Property="VerticalAlignment" Value="Stretch" />
</Style>
</UserControl.Styles>
<StackPanel Orientation="Horizontal" Margin="4 3">
<StackPanel Orientation="Horizontal" Margin="6 3">
<StackPanel Orientation="Vertical" Width="65">
<TextBlock Classes="label">Status</TextBlock>
<TextBlock FontSize="12" Margin="0 2 0 0" Text="{Binding StatusText}" />
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding StatusText}" />
</StackPanel>
<Rectangle />
<StackPanel Orientation="Vertical">

View File

@@ -1,91 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:main="clr-namespace:DHT.Desktop.Main"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.MainContentScreen">
<Design.DataContext>
<main:MainContentScreenModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="TabControl ItemsPresenter > WrapPanel">
<Setter Property="Background" Value="#546A9F" />
</Style>
<Style Selector="TabItem">
<Setter Property="Foreground" Value="#E9E9E9" />
<Setter Property="FontSize" Value="20" />
</Style>
<Style Selector="TabItem[TabStripPlacement=Left] /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Margin" Value="5 0" />
</Style>
<Style Selector="TabItem:pointerover">
<Setter Property="Background" Value="#1F2E45" />
</Style>
<Style Selector="TabItem:pointerover /template/ Border">
<Setter Property="Background" Value="#1F2E45" />
</Style>
<Style Selector="TabItem:pointerover > TextBlock">
<Setter Property="Foreground" Value="#E9E9E9" />
</Style>
<Style Selector="TabItem:selected:pointerover /template/ Border">
<Setter Property="Background" Value="#FFFFFF" />
</Style>
<Style Selector="TabItem:selected:pointerover > TextBlock">
<Setter Property="Foreground" Value="#1A2234" />
</Style>
<Style Selector="TabItem:selected">
<Setter Property="Foreground" Value="#1A2234" />
<Setter Property="Background" Value="#FFFFFF" />
</Style>
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="TabItem:disabled > TextBlock">
<Setter Property="Foreground" Value="#B2B2B2" />
<Setter Property="TextDecorations" Value="Strikethrough" />
</Style>
<Style Selector="TabItem.first">
<Setter Property="Margin" Value="0 13 0 0" />
</Style>
<Style Selector="TabControl">
<Setter Property="Padding" Value="0" />
</Style>
<Style Selector="ContentPresenter.page">
<Setter Property="Margin" Value="15 21" />
</Style>
</UserControl.Styles>
<DockPanel>
<TabControl x:Name="TabControl" TabStripPlacement="Left">
<TabItem x:Name="TabDatabase" Header="Database" Classes="first">
<DockPanel>
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Bottom" />
<ScrollViewer>
<ContentPresenter Content="{Binding DatabasePage}" Classes="page" />
</ScrollViewer>
</DockPanel>
</TabItem>
<TabItem x:Name="TabTracking" Header="Tracking">
<DockPanel>
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Bottom" />
<ScrollViewer>
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
</ScrollViewer>
</DockPanel>
</TabItem>
<TabItem x:Name="TabViewer" Header="Viewer">
<DockPanel>
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Bottom" />
<ScrollViewer>
<ContentPresenter Content="{Binding ViewerPage}" Classes="page" />
</ScrollViewer>
</DockPanel>
</TabItem>
</TabControl>
</DockPanel>
</UserControl>

View File

@@ -1,61 +0,0 @@
using System;
using System.Threading.Tasks;
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 {
sealed 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 async Task Initialize() {
await TrackingPageModel.Initialize();
}
private void TrackingPageModelOnServerStatusChanged(object? sender, StatusBarModel.Status e) {
StatusBarModel.CurrentStatus = e;
}
public void Dispose() {
TrackingPageModel.Dispose();
}
}
}

View File

@@ -8,15 +8,15 @@
Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="800" Height="500"
MinWidth="480" MinHeight="240"
WindowStartupLocation="CenterScreen">
MinWidth="500" MinHeight="275"
WindowStartupLocation="CenterScreen"
Closed="OnClosed">
<Design.DataContext>
<main:MainWindowModel />
</Design.DataContext>
<Panel>
<ContentPresenter Content="{Binding WelcomeScreen}" IsVisible="{Binding ShowWelcomeScreen}" />
<ContentPresenter Content="{Binding MainContentScreen}" IsVisible="{Binding ShowMainContentScreen}" />
<ContentPresenter Content="{Binding CurrentScreen}" />
</Panel>
</Window>

View File

@@ -1,3 +1,4 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls;
@@ -24,5 +25,11 @@ namespace DHT.Desktop.Main {
this.AttachDevTools();
#endif
}
public void OnClosed(object? sender, EventArgs e) {
if (DataContext is IDisposable disposable) {
disposable.Dispose();
}
}
}
}

View File

@@ -5,24 +5,24 @@ using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Pages;
using DHT.Desktop.Main.Screens;
using DHT.Desktop.Server;
using DHT.Server.Database;
using DHT.Utils.Models;
namespace DHT.Desktop.Main {
sealed class MainWindowModel : BaseModel {
sealed class MainWindowModel : BaseModel, IDisposable {
private const string DefaultTitle = "Discord History Tracker";
public string Title { get; private set; } = DefaultTitle;
public WelcomeScreen WelcomeScreen { get; }
private WelcomeScreenModel WelcomeScreenModel { get; }
public UserControl CurrentScreen { get; private set; }
public MainContentScreen? MainContentScreen { get; private set; }
private MainContentScreenModel? MainContentScreenModel { get; set; }
private readonly WelcomeScreen welcomeScreen;
private readonly WelcomeScreenModel welcomeScreenModel;
public bool ShowWelcomeScreen => db == null;
public bool ShowMainContentScreen => db != null;
private MainContentScreen? mainContentScreen;
private MainContentScreenModel? mainContentScreenModel;
private readonly Window window;
@@ -34,10 +34,11 @@ namespace DHT.Desktop.Main {
public MainWindowModel(Window window, Arguments args) {
this.window = window;
WelcomeScreenModel = new WelcomeScreenModel(window);
WelcomeScreen = new WelcomeScreen { DataContext = WelcomeScreenModel };
welcomeScreenModel = new WelcomeScreenModel(window);
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
CurrentScreen = welcomeScreen;
WelcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
welcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
var dbFile = args.DatabaseFile;
if (!string.IsNullOrWhiteSpace(dbFile)) {
@@ -50,7 +51,7 @@ namespace DHT.Desktop.Main {
}
if (File.Exists(dbFile)) {
await WelcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
}
else {
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
@@ -61,40 +62,40 @@ namespace DHT.Desktop.Main {
}
if (args.ServerPort != null) {
TrackingPageModel.ServerPort = args.ServerPort.ToString()!;
ServerManager.Port = args.ServerPort.Value;
}
if (args.ServerToken != null) {
TrackingPageModel.ServerToken = args.ServerToken;
ServerManager.Token = args.ServerToken;
}
}
private async void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(WelcomeScreenModel.Db)) {
if (MainContentScreenModel != null) {
MainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
MainContentScreenModel.Dispose();
if (e.PropertyName == nameof(welcomeScreenModel.Db)) {
if (mainContentScreenModel != null) {
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
mainContentScreenModel.Dispose();
}
db?.Dispose();
db = WelcomeScreenModel.Db;
db = welcomeScreenModel.Db;
if (db == null) {
Title = DefaultTitle;
MainContentScreenModel = null;
MainContentScreen = null;
mainContentScreenModel = null;
mainContentScreen = null;
CurrentScreen = welcomeScreen;
}
else {
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle;
MainContentScreenModel = new MainContentScreenModel(window, db);
await MainContentScreenModel.Initialize();
MainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
MainContentScreen = new MainContentScreen { DataContext = MainContentScreenModel };
OnPropertyChanged(nameof(MainContentScreen));
mainContentScreenModel = new MainContentScreenModel(window, db);
await mainContentScreenModel.Initialize();
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
CurrentScreen = mainContentScreen;
}
OnPropertyChanged(nameof(ShowWelcomeScreen));
OnPropertyChanged(nameof(ShowMainContentScreen));
OnPropertyChanged(nameof(CurrentScreen));
OnPropertyChanged(nameof(Title));
window.Focus();
@@ -102,7 +103,14 @@ namespace DHT.Desktop.Main {
}
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
WelcomeScreenModel.CloseDatabase();
welcomeScreenModel.CloseDatabase();
}
public void Dispose() {
welcomeScreenModel.Dispose();
mainContentScreenModel?.Dispose();
db?.Dispose();
db = null;
}
}
}

View File

@@ -0,0 +1,25 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.AdvancedPage">
<Design.DataContext>
<pages:AdvancedPageModel />
</Design.DataContext>
<StackPanel Orientation="Vertical" Spacing="10">
<Expander Header="Internal Server Configuration" IsExpanded="True">
<controls:ServerConfigurationPanel DataContext="{Binding ServerConfigurationModel}" />
</Expander>
<Expander Header="Database Tools" IsExpanded="True">
<StackPanel Orientation="Vertical" Spacing="10">
<TextBlock TextWrapping="Wrap">Recreates the database to remove any traces of deleted data, which frees up space and prevents forensic recovery.</TextBlock>
<Button Command="{Binding VacuumDatabase}">Vacuum Database</Button>
</StackPanel>
</Expander>
</StackPanel>
</UserControl>

View File

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

View File

@@ -0,0 +1,39 @@
using System;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Server;
using DHT.Server.Database;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages {
sealed class AdvancedPageModel : BaseModel, IDisposable {
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
private readonly Window window;
private readonly IDatabaseFile db;
[Obsolete("Designer")]
public AdvancedPageModel() : this(null!, DummyDatabaseFile.Instance, new ServerManager(DummyDatabaseFile.Instance)) {}
public AdvancedPageModel(Window window, IDatabaseFile db, ServerManager serverManager) {
this.window = window;
this.db = db;
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager);
}
public void Initialize() {
ServerConfigurationModel.Initialize();
}
public void Dispose() {
ServerConfigurationModel.Dispose();
}
public async void VacuumDatabase() {
db.Vacuum();
await Dialog.ShowOk(window, "Vacuum Database", "Done.");
}
}
}

View File

@@ -8,7 +8,6 @@ using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Logging;
using DHT.Utils.Models;
@@ -76,7 +75,6 @@ namespace DHT.Desktop.Main.Pages {
}
public void CloseDatabase() {
ServerLauncher.Stop();
DatabaseClosed?.Invoke(this, EventArgs.Empty);
}
@@ -94,6 +92,8 @@ namespace DHT.Desktop.Main.Pages {
}
var oldStatistics = target.Statistics.Clone();
var oldMessageCount = target.CountMessages();
int successful = 0;
int finished = 0;
@@ -134,7 +134,7 @@ namespace DHT.Desktop.Main.Pages {
var newStatistics = target.Statistics;
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages;
long newMessages = target.CountMessages() - oldMessageCount;
StringBuilder message = new StringBuilder();
message.Append("Processed ");

View File

@@ -0,0 +1,45 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.DebugPage">
<Design.DataContext>
<pages:DebugPageModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="TextBox">
<Setter Property="FontFamily" Value="Consolas,Courier" />
<Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="WrapPanel > StackPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="Margin" Value="0 0 10 10" />
</Style>
</UserControl.Styles>
<StackPanel Orientation="Vertical" Spacing="10">
<Expander Header="Generate Random Data" IsExpanded="True">
<WrapPanel>
<StackPanel>
<Label Target="Channels">Channels</Label>
<TextBox x:Name="Channels" Width="100" Text="{Binding GenerateChannels}" />
</StackPanel>
<StackPanel>
<Label Target="Users">Users</Label>
<TextBox x:Name="Users" Width="100" Text="{Binding GenerateUsers}" />
</StackPanel>
<StackPanel>
<Label Target="Messages">Messages</Label>
<TextBox x:Name="Messages" Width="100" Text="{Binding GenerateMessages}" />
</StackPanel>
<StackPanel VerticalAlignment="Bottom">
<Button Command="{Binding OnClickAddRandomDataToDatabase}">Add to Database</Button>
</StackPanel>
</WrapPanel>
</Expander>
</StackPanel>
</UserControl>

View File

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

View File

@@ -0,0 +1,185 @@
#if DEBUG
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Server.Data;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages {
sealed class DebugPageModel : BaseModel {
public string GenerateChannels { get; set; } = "0";
public string GenerateUsers { get; set; } = "0";
public string GenerateMessages { get; set; } = "0";
private readonly Window window;
private readonly IDatabaseFile db;
[Obsolete("Designer")]
public DebugPageModel() : this(null!, DummyDatabaseFile.Instance) {}
public DebugPageModel(Window window, IDatabaseFile db) {
this.window = window;
this.db = db;
}
public async void OnClickAddRandomDataToDatabase() {
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!");
return;
}
if (!int.TryParse(GenerateUsers, out int users) || users < 1) {
await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!");
return;
}
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
return;
}
ProgressDialog progressDialog = new ProgressDialog {
DataContext = new ProgressDialogModel(async callback => await GenerateRandomData(channels, users, messages, callback)) {
Title = "Generating Random Data"
}
};
await progressDialog.ShowDialog(window);
}
private const int BatchSize = 500;
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
var rand = new Random();
var server = new DHT.Server.Data.Server {
Id = RandomId(rand),
Name = RandomName("s"),
Type = ServerType.Server
};
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
Id = RandomId(rand),
Server = server.Id,
Name = RandomName("c"),
ParentId = null,
Position = i,
Topic = RandomText(rand, 10),
Nsfw = rand.Next(4) == 0
}).ToArray();
var users = Enumerable.Range(0, userCount).Select(_ => new User {
Id = RandomId(rand),
Name = RandomName("u"),
AvatarUrl = null,
Discriminator = rand.Next(0, 9999).ToString()
}).ToArray();
db.AddServer(server);
db.AddUsers(users);
foreach (var channel in channels) {
db.AddChannel(channel);
}
var now = DateTimeOffset.Now;
int batchIndex = 0;
while (messageCount > 0) {
int hourOffset = batchIndex;
var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => {
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes((i * 60.0) / BatchSize);
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
var timeMillis = time.ToUnixTimeMilliseconds();
var editMillis = edit?.ToUnixTimeMilliseconds();
return new Message {
Id = (ulong) timeMillis,
Sender = RandomBiasedIndex(rand, users).Id,
Channel = RandomBiasedIndex(rand, channels).Id,
Text = RandomText(rand, 100),
Timestamp = timeMillis,
EditTimestamp = editMillis,
RepliedToId = null,
Attachments = ImmutableArray<Attachment>.Empty,
Embeds = ImmutableArray<Embed>.Empty,
Reactions = ImmutableArray<Reaction>.Empty
};
}).ToArray();
db.AddMessages(messages);
messageCount -= BatchSize;
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
}
}
private static ulong RandomId(Random rand) {
ulong h = unchecked((ulong) rand.Next());
ulong l = unchecked((ulong) rand.Next());
return (h << 32) | l;
}
private static string RandomName(string prefix) {
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
}
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
}
private static readonly string[] RandomWords = {
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
"cabbage", "cacao", "cantaloupe", "caper", "carambola", "carrot", "cauliflower", "celery", "chard", "cherry", "chokeberry", "citron", "clementine", "coconut", "corn", "crabapple", "cranberry", "cucumber", "currant",
"daikon", "date", "dewberry", "durian",
"edamame", "eggplant", "elderberry", "endive",
"fig",
"garlic", "ginger", "gooseberry", "grape", "grapefruit", "guava",
"honeysuckle", "horseradish", "huckleberry",
"jackfruit", "jicama",
"kale", "kiwi", "kohlrabi", "kumquat",
"leek", "lemon", "lentil", "lettuce", "lime",
"mandarin", "mango", "mushroom", "myrtle",
"nectarine", "nut",
"olive", "okra", "onion", "orange",
"papaya", "parsnip", "pawpaw", "peach", "pear", "pea", "pepper", "persimmon", "pineapple", "plum", "plantain", "pomegranate", "pomelo", "potato", "prune", "pumpkin",
"quandong", "quinoa",
"radicchio", "radish", "raisin", "raspberry", "redcurrant", "rhubarb", "rutabaga",
"spinach", "strawberry", "squash",
"tamarind", "tangerine", "tomatillo", "tomato", "turnip",
"vanilla",
"watercress", "watermelon",
"yam",
"zucchini"
};
private static string RandomText(Random rand, int maxWords) {
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
}
}
}
#else
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages {
sealed class DebugPageModel : BaseModel {
public string GenerateChannels { get; set; } = "0";
public string GenerateUsers { get; set; } = "0";
public string GenerateMessages { get; set; } = "0";
public void OnClickAddRandomDataToDatabase() {}
}
}
#endif

View File

@@ -10,52 +10,14 @@
<pages:TrackingPageModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="TextBox">
<Setter Property="FontFamily" Value="Consolas,Courier" />
<Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="WrapPanel > StackPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="Margin" Value="0 0 10 10" />
</Style>
</UserControl.Styles>
<StackPanel Spacing="10">
<TextBlock TextWrapping="Wrap">
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I.
</TextBlock>
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button>
<Button Command="{Binding OnClickToggleTrackingButton}" Content="{Binding ToggleTrackingButtonText}" IsEnabled="{Binding IsToggleButtonEnabled}" />
</StackPanel>
<Expander Header="Advanced Tracking Settings">
<StackPanel Spacing="10">
<TextBlock TextWrapping="Wrap">
The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy and apply the tracking script again.
</TextBlock>
<WrapPanel>
<StackPanel>
<Label Target="Port">Port</Label>
<TextBox x:Name="Port" Width="70" Text="{Binding InputPort}" />
</StackPanel>
<StackPanel>
<Label Target="Token">Token</Label>
<StackPanel Orientation="Horizontal">
<TextBox x:Name="Token" Width="200" Text="{Binding InputToken}" />
</StackPanel>
</StackPanel>
<StackPanel VerticalAlignment="Bottom">
<Button Command="{Binding OnClickRandomizeToken}">Randomize Token</Button>
</StackPanel>
</WrapPanel>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickApplyChanges}">Apply &amp; Restart</Button>
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickCancelChanges}">Cancel</Button>
</StackPanel>
</StackPanel>
</Expander>
<TextBlock TextWrapping="Wrap" Margin="0 15 0 0">
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the Ctrl+Shift+I shortcut is enabled.
</TextBlock>
<Button DockPanel.Dock="Right" Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />

View File

@@ -5,51 +5,12 @@ using Avalonia;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Discord;
using DHT.Desktop.Main.Controls;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Logging;
using DHT.Desktop.Server;
using DHT.Utils.Models;
using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages {
sealed class TrackingPageModel : BaseModel, IDisposable {
private static readonly Log Log = Log.ForType<TrackingPageModel>();
internal static string ServerPort { get; set; } = ServerUtils.FindAvailablePort(50000, 60000).ToString();
internal static string ServerToken { get; set; } = ServerUtils.GenerateRandomToken(20);
private string inputPort = ServerPort;
public string InputPort {
get => inputPort;
set {
Change(ref inputPort, value);
OnPropertyChanged(nameof(HasMadeChanges));
}
}
private string inputToken = ServerToken;
public string InputToken {
get => inputToken;
set {
Change(ref inputToken, value);
OnPropertyChanged(nameof(HasMadeChanges));
}
}
public bool HasMadeChanges => ServerPort != InputPort || ServerToken != InputToken;
private bool isToggleTrackingButtonEnabled = true;
public bool IsToggleButtonEnabled {
get => isToggleTrackingButtonEnabled;
set => Change(ref isToggleTrackingButtonEnabled, value);
}
public string ToggleTrackingButtonText => ServerLauncher.IsRunning ? "Pause Tracking" : "Resume Tracking";
sealed class TrackingPageModel : BaseModel {
private bool areDevToolsEnabled;
private bool AreDevToolsEnabled {
@@ -72,28 +33,16 @@ namespace DHT.Desktop.Main.Pages {
}
}
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
private readonly Window window;
private readonly IDatabaseFile db;
[Obsolete("Designer")]
public TrackingPageModel() : this(null!, DummyDatabaseFile.Instance) {}
public TrackingPageModel() : this(null!) {}
public TrackingPageModel(Window window, IDatabaseFile db) {
public TrackingPageModel(Window window) {
this.window = window;
this.db = db;
}
public async Task Initialize() {
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
if (int.TryParse(ServerPort, out int port)) {
string token = ServerToken;
ServerLauncher.Relaunch(port, token, db);
}
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
if (devToolsEnabled.HasValue) {
AreDevToolsEnabled = devToolsEnabled.Value;
@@ -104,55 +53,10 @@ namespace DHT.Desktop.Main.Pages {
}
}
public void Dispose() {
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
ServerLauncher.Stop();
}
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
ServerStatusChanged?.Invoke(this, ServerLauncher.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
OnPropertyChanged(nameof(ToggleTrackingButtonText));
IsToggleButtonEnabled = true;
}
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Server Error", ex.Message);
}
private async Task<bool> StartServer() {
if (!int.TryParse(InputPort, out int port) || port is < 0 or > 65535) {
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
return false;
}
IsToggleButtonEnabled = false;
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
ServerLauncher.Relaunch(port, InputToken, db);
return true;
}
private void StopServer() {
IsToggleButtonEnabled = false;
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
ServerLauncher.Stop();
}
public async Task<bool> OnClickToggleTrackingButton() {
if (ServerLauncher.IsRunning) {
StopServer();
return true;
}
else {
return await StartServer();
}
}
public async Task<bool> OnClickCopyTrackingScript() {
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerPort + ";")
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerToken))
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerManager.Port + ";")
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerManager.Token))
.Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
.Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css"))
.Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css"));
@@ -172,23 +76,6 @@ namespace DHT.Desktop.Main.Pages {
}
}
public void OnClickRandomizeToken() {
InputToken = ServerUtils.GenerateRandomToken(20);
}
public async void OnClickApplyChanges() {
if (await StartServer()) {
ServerPort = InputPort;
ServerToken = InputToken;
OnPropertyChanged(nameof(HasMadeChanges));
}
}
public void OnClickCancelChanges() {
InputPort = ServerPort;
InputToken = ServerToken;
}
public async void OnClickToggleAppDevTools() {
const string DialogTitle = "Discord App Settings File";

View File

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

View File

@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
@@ -12,5 +13,17 @@ namespace DHT.Desktop.Main.Pages {
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
public void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) {
if (DataContext is ViewerPageModel model) {
model.SetPageVisible(true);
}
}
public void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) {
if (DataContext is ViewerPageModel model) {
model.SetPageVisible(false);
}
}
}
}

View File

@@ -16,7 +16,7 @@ using DHT.Utils.Models;
using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages {
sealed class ViewerPageModel : BaseModel {
sealed class ViewerPageModel : BaseModel, IDisposable {
public string ExportedMessageText { get; private set; } = "";
public bool DatabaseToolFilterModeKeep { get; set; } = true;
@@ -34,6 +34,8 @@ namespace DHT.Desktop.Main.Pages {
private readonly Window window;
private readonly IDatabaseFile db;
private bool isPageVisible = false;
[Obsolete("Designer")]
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
@@ -41,11 +43,22 @@ namespace DHT.Desktop.Main.Pages {
this.window = window;
this.db = db;
this.FilterModel = new FilterPanelModel(window, db);
this.FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
this.db.Statistics.PropertyChanged += OnDbStatisticsChanged;
FilterModel = new FilterPanelModel(window, db);
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
}
public void Dispose() {
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
FilterModel.Dispose();
}
public void SetPageVisible(bool isPageVisible) {
this.isPageVisible = isPageVisible;
if (isPageVisible) {
UpdateStatistics();
}
}
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
UpdateStatistics();
@@ -53,13 +66,17 @@ namespace DHT.Desktop.Main.Pages {
}
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
if (isPageVisible && e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
UpdateStatistics();
}
}
private void UpdateStatistics() {
ExportedMessageText = "Will export " + db.CountMessages(FilterModel.CreateFilter()).Format() + " out of " + db.Statistics.TotalMessages.Format() + " message(s).";
var filter = FilterModel.CreateFilter();
var allMessagesCount = db.Statistics.TotalMessages?.Format() ?? "?";
var filteredMessagesCount = filter.IsEmpty ? allMessagesCount : db.CountMessages(filter).Format();
ExportedMessageText = "Will export " + filteredMessagesCount + " out of " + allMessagesCount + " message(s).";
OnPropertyChanged(nameof(ExportedMessageText));
}

View File

@@ -0,0 +1,114 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Screens.MainContentScreen">
<Design.DataContext>
<screens:MainContentScreenModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="Border.statusBar">
<Setter Property="Background" Value="#3C4F79" />
</Style>
<Style Selector="TextBlock.invisibleTabItem">
<Setter Property="FontSize" Value="20" />
<Setter Property="FontWeight" Value="SemiLight" />
<Setter Property="Margin" Value="17 0" />
<Setter Property="Opacity" Value="0" />
</Style>
<Style Selector="TabControl ItemsPresenter > Grid">
<Setter Property="Background" Value="#546A9F" />
</Style>
<Style Selector="TabItem">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Foreground" Value="#E9E9E9" />
<Setter Property="FontSize" Value="20" />
</Style>
<Style Selector="TabItem[TabStripPlacement=Left] /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Margin" Value="5 0" />
</Style>
<Style Selector="TabItem:pointerover">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="#455785" />
</Style>
<Style Selector="TabItem:pointerover /template/ Border">
<Setter Property="Background" Value="#455785" />
</Style>
<Style Selector="TabItem:pointerover > TextBlock">
<Setter Property="Foreground" Value="#E9E9E9" />
</Style>
<Style Selector="TabItem:selected:pointerover /template/ Border">
<Setter Property="Background" Value="#FFFFFF" />
</Style>
<Style Selector="TabItem:selected:pointerover > TextBlock">
<Setter Property="Foreground" Value="#1A2234" />
</Style>
<Style Selector="TabItem:selected">
<Setter Property="Foreground" Value="#1A2234" />
<Setter Property="Background" Value="#FFFFFF" />
</Style>
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="TabItem:disabled > TextBlock">
<Setter Property="Foreground" Value="#B2B2B2" />
<Setter Property="TextDecorations" Value="Strikethrough" />
</Style>
<Style Selector="TabItem.first">
<Setter Property="Margin" Value="0 13 0 0" />
</Style>
<Style Selector="TabControl">
<Setter Property="Padding" Value="0" />
</Style>
<Style Selector="ContentPresenter.page">
<Setter Property="Margin" Value="15 21 20 21" />
</Style>
</UserControl.Styles>
<DockPanel>
<Border Classes="statusBar" DockPanel.Dock="Bottom">
<DockPanel>
<TextBlock Classes="invisibleTabItem" DockPanel.Dock="Left">Advanced</TextBlock>
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Right" />
</DockPanel>
</Border>
<TabControl x:Name="TabControl" TabStripPlacement="Left" DockPanel.Dock="Top">
<TabControl.ItemsPanel>
<ItemsPanelTemplate>
<Grid ColumnDefinitions="Auto" RowDefinitions="Auto,Auto,Auto,*,Auto,Auto" />
</ItemsPanelTemplate>
</TabControl.ItemsPanel>
<TabItem x:Name="TabDatabase" Header="Database" Classes="first" Grid.Row="0">
<ScrollViewer>
<ContentPresenter Content="{Binding DatabasePage}" Classes="page" />
</ScrollViewer>
</TabItem>
<TabItem x:Name="TabTracking" Header="Tracking" Grid.Row="1">
<ScrollViewer>
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
</ScrollViewer>
</TabItem>
<TabItem x:Name="TabViewer" Header="Viewer" Grid.Row="2">
<ScrollViewer>
<ContentPresenter Content="{Binding ViewerPage}" Classes="page" />
</ScrollViewer>
</TabItem>
<TabItem x:Name="TabAdvanced" Header="Advanced" Grid.Row="4">
<ScrollViewer>
<ContentPresenter Content="{Binding AdvancedPage}" Classes="page" />
</ScrollViewer>
</TabItem>
<TabItem x:Name="TabDebug" Header="Debug" Grid.Row="5" IsVisible="{Binding HasDebugPage}">
<ScrollViewer>
<ContentPresenter Content="{Binding DebugPage}" Classes="page" />
</ScrollViewer>
</TabItem>
</TabControl>
</DockPanel>
</UserControl>

View File

@@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main {
namespace DHT.Desktop.Main.Screens {
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class MainContentScreen : UserControl {
public MainContentScreen() {

View File

@@ -0,0 +1,112 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Main.Pages;
using DHT.Desktop.Server;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Logging;
namespace DHT.Desktop.Main.Screens {
sealed class MainContentScreenModel : IDisposable {
private static readonly Log Log = Log.ForType<MainContentScreenModel>();
public DatabasePage DatabasePage { get; }
private DatabasePageModel DatabasePageModel { get; }
public TrackingPage TrackingPage { get; }
private TrackingPageModel TrackingPageModel { get; }
public ViewerPage ViewerPage { get; }
private ViewerPageModel ViewerPageModel { get; }
public AdvancedPage AdvancedPage { get; }
private AdvancedPageModel AdvancedPageModel { get; }
public DebugPage? DebugPage { get; }
#if DEBUG
public bool HasDebugPage => true;
private DebugPageModel DebugPageModel { get; }
#else
public bool HasDebugPage => false;
#endif
public StatusBarModel StatusBarModel { get; }
public event EventHandler? DatabaseClosed {
add {
DatabasePageModel.DatabaseClosed += value;
}
remove {
DatabasePageModel.DatabaseClosed -= value;
}
}
private readonly Window window;
private readonly ServerManager serverManager;
[Obsolete("Designer")]
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {}
public MainContentScreenModel(Window window, IDatabaseFile db) {
this.window = window;
this.serverManager = new ServerManager(db);
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
DatabasePageModel = new DatabasePageModel(window, db);
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
TrackingPageModel = new TrackingPageModel(window);
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
ViewerPageModel = new ViewerPageModel(window, db);
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
AdvancedPageModel = new AdvancedPageModel(window, db, serverManager);
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
#if DEBUG
DebugPageModel = new DebugPageModel(window, db);
DebugPage = new DebugPage { DataContext = DebugPageModel };
#else
DebugPage = null;
#endif
StatusBarModel = new StatusBarModel(db.Statistics);
AdvancedPageModel.ServerConfigurationModel.ServerStatusChanged += OnServerStatusChanged;
DatabaseClosed += OnDatabaseClosed;
StatusBarModel.CurrentStatus = serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
}
public async Task Initialize() {
await TrackingPageModel.Initialize();
AdvancedPageModel.Initialize();
serverManager.Launch();
}
public void Dispose() {
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
ViewerPageModel.Dispose();
serverManager.Dispose();
}
private void OnServerStatusChanged(object? sender, StatusBarModel.Status e) {
StatusBarModel.CurrentStatus = e;
}
private void OnDatabaseClosed(object? sender, EventArgs e) {
serverManager.Stop();
}
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
}
}
}

View File

@@ -2,12 +2,12 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:main="clr-namespace:DHT.Desktop.Main"
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.WelcomeScreen">
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen">
<Design.DataContext>
<main:WelcomeScreenModel />
<screens:WelcomeScreenModel />
</Design.DataContext>
<UserControl.Background>

View File

@@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main {
namespace DHT.Desktop.Main.Screens {
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class WelcomeScreen : UserControl {
public WelcomeScreen() {

View File

@@ -7,8 +7,8 @@ using DHT.Desktop.Dialogs.Message;
using DHT.Server.Database;
using DHT.Utils.Models;
namespace DHT.Desktop.Main {
sealed class WelcomeScreenModel : BaseModel {
namespace DHT.Desktop.Main.Screens {
sealed class WelcomeScreenModel : BaseModel, IDisposable {
public string Version => Program.Version;
public IDatabaseFile? Db { get; private set; }
@@ -52,8 +52,7 @@ namespace DHT.Desktop.Main {
}
public void CloseDatabase() {
Db = null;
Dispose();
OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase));
}
@@ -65,5 +64,10 @@ namespace DHT.Desktop.Main {
public void Exit() {
window.Close();
}
public void Dispose() {
Db?.Dispose();
Db = null;
}
}
}

View File

@@ -0,0 +1,50 @@
using System;
using DHT.Server.Database;
using DHT.Server.Service;
namespace DHT.Desktop.Server {
sealed class ServerManager : IDisposable {
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
private static ServerManager? instance;
public bool IsRunning => ServerLauncher.IsRunning;
private readonly IDatabaseFile db;
public ServerManager(IDatabaseFile db) {
if (db != DummyDatabaseFile.Instance) {
if (instance != null) {
throw new InvalidOperationException("Only one instance of ServerManager can exist at the same time!");
}
instance = this;
}
this.db = db;
}
public void Launch() {
ServerLauncher.Relaunch(Port, Token, db);
}
public void Relaunch(ushort port, string token) {
Port = port;
Token = token;
Launch();
}
public void Stop() {
ServerLauncher.Stop();
}
public void Dispose() {
Stop();
if (instance == this) {
instance = null;
}
}
}
}

View File

@@ -42,6 +42,10 @@
stopTrackingDelayed(() => isSending = false);
};
const isNoAction = function(action) {
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
};
const onTrackingContinued = function(anyNewMessages) {
if (!STATE.isTracking()) {
return;
@@ -59,14 +63,14 @@
if (SETTINGS.autoscroll) {
let action = null;
if (!anyNewMessages) {
action = SETTINGS.afterSavedMsg;
}
else if (!DISCORD.hasMoreMessages()) {
if (!DISCORD.hasMoreMessages()) {
action = SETTINGS.afterFirstMsg;
}
if (isNoAction(action) && !anyNewMessages) {
action = SETTINGS.afterSavedMsg;
}
if (action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING) {
if (isNoAction(action)) {
DISCORD.loadOlderMessages();
}
else if (action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE || (action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel())) {
@@ -113,8 +117,8 @@
try {
if (!messages.length) {
DISCORD.loadOlderMessages();
isSending = false;
onTrackingContinued(false);
}
else {
const anyNewMessages = await STATE.addDiscordMessages(info.id, messages);

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
class DOM{static id(id,parent){return(parent||document).getElementById(id)}static queryReactClass(cls,parent){return(parent||document).querySelector(`[class*="${cls}-"]`)}static createElement(tag,parent,id,html){const ele=document.createElement(tag);ele.id=id||"";ele.innerHTML=html||"";parent.appendChild(ele);return ele}static removeElement(ele){return ele.parentNode.removeChild(ele)}static createStyle(styles){return this.createElement("style",document.head,"",styles)}static saveToCookie(name,obj,expiresInSeconds){const expires=new Date(Date.now()+1e3*expiresInSeconds).toUTCString();document.cookie=name+"="+encodeURIComponent(JSON.stringify(obj))+";path=/;expires="+expires}static loadFromCookie(name){const value=document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)"+name+"\\s*\\=\\s*([^;]*).*$)|^.*$"),"$1");return value.length?JSON.parse(decodeURIComponent(value)):null}static getReactProps(ele){const keys=Object.keys(ele||{});let key=keys.find(key=>key.startsWith("__reactInternalInstance"));if(key){return ele[key].memoizedProps}key=keys.find(key=>key.startsWith("__reactProps$"));return key?ele[key]:null}}

View File

@@ -1,17 +0,0 @@
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=`
<button id='dht-ctrl-close'>X</button>
<button id='dht-ctrl-settings'>Settings</button>
<button id='dht-ctrl-track'></button>
<p id='dht-ctrl-status'>Waiting</p>`;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)=>"<label><input id='dht-cfg-"+type+"-"+id+"' name='dht-"+type+"' type='radio'> "+label+"</label><br>";const html=`
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
<br>
<label>After reaching the first message in channel...</label><br>
${radio("afm","nothing","Do Nothing")}
${radio("afm","pause","Pause Tracking")}
${radio("afm","switch","Switch to Next Channel")}
<br>
<label>After reaching a previously saved message...</label><br>
${radio("asm","nothing","Do Nothing")}
${radio("asm","pause","Pause Tracking")}
${radio("asm","switch","Switch to Next Channel")}
<p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`;settings={styles:DOM.createStyle(`/*[CSS-SETTINGS]*/`),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}}}}();

View File

@@ -1 +0,0 @@
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}();

View File

@@ -1 +0,0 @@
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){const extra=channelInfo.extra;if("parent"in extra){channel.parent=extra.parent}channel.position=extra.position;channel.topic=extra.topic;channel.nsfw=extra.nsfw}await post("/track-channel",{server:server,channel:channel});addedChannels.add(channelInfo.id)},async addDiscordMessages(channelId,discordMessageArray){discordMessageArray=discordMessageArray.filter(msg=>(msg.type===0||msg.type===19||msg.type===21)&&msg.state==="SENT");if(discordMessageArray.length===0){return false}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"}}}();

View File

@@ -67,14 +67,7 @@ class DISCORD {
}
const messages = this.getMessages();
let hasChanged = false;
for (const message of messages) {
if (!previousMessages.has(message.id)) {
hasChanged = true;
break;
}
}
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !this.hasMoreMessages();
if (!hasChanged) {
return;
@@ -140,6 +133,16 @@ class DISCORD {
try {
let obj;
try {
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
if (child && child.props && child.props.channel) {
obj = child.props.channel;
break;
}
}
} catch (e) {
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
for (const ele of this.getMessageElements()) {
const props = this.getMessageElementProps(ele);
@@ -148,8 +151,9 @@ class DISCORD {
break;
}
}
}
if (!obj) {
if (!obj || typeof obj.id !== "string") {
return null;
}
@@ -189,7 +193,7 @@ class DISCORD {
else if (obj.guild_id) {
const server = {
"id": obj.guild_id,
"name": document.querySelector("nav header > h1").innerText,
"name": document.querySelector("nav header h1[class*='name-']").innerText,
"type": "SERVER"
};
@@ -215,7 +219,7 @@ class DISCORD {
return null;
}
} catch (e) {
console.error(e);
console.error("[DHT] Error retrieving selected channel.", e);
return null;
}
}
@@ -228,42 +232,38 @@ class DISCORD {
if (dms) {
const currentChannel = DOM.queryReactClass("selected", dms);
const nextChannel = currentChannel && currentChannel.nextElementSibling;
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel-']");
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel-") || !("href" in nextChannel) || !nextChannel.href.includes("/@me/")) {
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel-")) {
return false;
}
else {
nextChannel.click();
nextChannel.scrollIntoView(true);
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
if (!nextChannelLink) {
return false;
}
nextChannelLink.click();
nextChannelLink.scrollIntoView(true);
return true;
}
}
else {
const channelIcons = [
/* normal */ "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",
/* normal + thread */ "M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.40991 9H3.55274C3.47819 9 3.42168 8.93274 3.43453 8.85931L3.74253 7.09931C3.75258 7.04189 3.80244 7 3.86074 7H7.75991L8.45234 3.09903C8.46251 3.04174 8.51231 3 8.57049 3H10.3267C10.4014 3 10.4579 3.06746 10.4449 3.14097L9.75991 7H15.7599L16.4523 3.09903C16.4625 3.04174 16.5123 3 16.5705 3H18.3267C18.4014 3 18.4579 3.06746 18.4449 3.14097L17.7599 7H21.6171C21.6916 7 21.7481 7.06725 21.7353 7.14069L21.4273 8.90069C21.4172 8.95811 21.3674 9 21.3091 9H17.4099L17.0495 11.04H15.05L15.4104 9H9.41035L8.35035 15H10.5599V17H7.99991L7.30749 20.901C7.29732 20.9583 7.24752 21 7.18934 21H5.43309Z",
/* nsfw or private */ "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",
/* nsfw + thread */ "M14.4 7C14.5326 7 14.64 7.10745 14.64 7.24V8.76C14.64 8.89255 14.5326 9 14.4 9H9.41045L8.35045 15H10.56V17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657C5.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 7H14.4Z",
/* private + thread */ "M15.44 6.99992C15.5725 6.99992 15.68 7.10737 15.68 7.23992V8.75992C15.68 8.89247 15.5725 8.99992 15.44 8.99992H9.41045L8.35045 14.9999H10.56V16.9999H8.00001L7.36325 20.5873C7.32088 20.826 7.11337 20.9999 6.87094 20.9999H5.88657C5.57547 20.9999 5.3399 20.7189 5.39427 20.4125L6.00001 16.9999H2.59511C2.28449 16.9999 2.04905 16.7197 2.10259 16.4137L2.27759 15.4137C2.31946 15.1745 2.52722 14.9999 2.77011 14.9999H6.35001L7.41001 8.99992H4.00511C3.69449 8.99992 3.45905 8.71969 3.51259 8.41373L3.68759 7.41373C3.72946 7.17448 3.93722 6.99992 4.18011 6.99992H7.76001L8.39677 3.41254C8.43914 3.17384 8.64664 2.99992 8.88907 2.99992H9.87344C10.1845 2.99992 10.4201 3.28099 10.3657 3.58731L9.76001 6.99992H15.44Z"
];
const isValidChannelClass = cls => cls.includes("wrapper-") && !cls.includes("clickable-");
const isValidChannelType = ele => channelIcons.some(icon => !!ele.querySelector("path[d=\"" + icon + "\"]"));
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']");
const channelListEle = document.getElementById("channels");
if (!channelListEle) {
return false;
}
const allChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), isValidChannel);
function getLinkElement(channel) {
return channel.querySelector("a[href^='/channels/'][role='link']");
}
const allTextChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), ele => getLinkElement(ele) !== null);
let nextChannel = null;
for (let index = 0; index < allChannels.length - 1; index++) {
if (allChannels[index].children[0].className.includes("modeSelected")) {
nextChannel = allChannels[index + 1];
for (let index = 0; index < allTextChannels.length - 1; index++) {
if (allTextChannels[index].className.includes("selected-")) {
nextChannel = allTextChannels[index + 1];
break;
}
}
@@ -272,7 +272,7 @@ class DISCORD {
return false;
}
const nextChannelLink = nextChannel.querySelector("a[href^='/channels/']");
const nextChannelLink = getLinkElement(nextChannel);
if (!nextChannelLink) {
return false;
}

27
app/Resources/minify.py Normal file
View File

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

View File

@@ -1,11 +1,11 @@
namespace DHT.Server.Data {
public readonly struct Channel {
public ulong Id { get; internal init; }
public ulong Server { get; internal init; }
public string Name { get; internal init; }
public ulong? ParentId { get; internal init; }
public int? Position { get; internal init; }
public string? Topic { get; internal init; }
public bool? Nsfw { get; internal init; }
public ulong Id { get; init; }
public ulong Server { get; init; }
public string Name { get; init; }
public ulong? ParentId { get; init; }
public int? Position { get; init; }
public string? Topic { get; init; }
public bool? Nsfw { get; init; }
}
}

View File

@@ -3,11 +3,17 @@ using System.Collections.Generic;
namespace DHT.Server.Data.Filters {
public sealed class MessageFilter {
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public DateTime? StartDate { get; set; } = null;
public DateTime? EndDate { get; set; } = null;
public HashSet<ulong>? ChannelIds { get; set; } = null;
public HashSet<ulong>? UserIds { get; set; } = null;
public HashSet<ulong>? MessageIds { get; set; } = null;
public bool IsEmpty => StartDate == null &&
EndDate == null &&
ChannelIds == null &&
UserIds == null &&
MessageIds == null;
}
}

View File

@@ -2,15 +2,15 @@ using System.Collections.Immutable;
namespace DHT.Server.Data {
public readonly struct Message {
public ulong Id { get; internal init; }
public ulong Sender { get; internal init; }
public ulong Channel { get; internal init; }
public string Text { get; internal init; }
public long Timestamp { get; internal init; }
public long? EditTimestamp { get; internal init; }
public ulong? RepliedToId { get; internal init; }
public ImmutableArray<Attachment> Attachments { get; internal init; }
public ImmutableArray<Embed> Embeds { get; internal init; }
public ImmutableArray<Reaction> Reactions { get; internal init; }
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<Attachment> Attachments { get; init; }
public ImmutableArray<Embed> Embeds { get; init; }
public ImmutableArray<Reaction> Reactions { get; init; }
}
}

View File

@@ -1,7 +1,7 @@
namespace DHT.Server.Data {
public readonly struct Server {
public ulong Id { get; internal init; }
public string Name { get; internal init; }
public ServerType? Type { get; internal init; }
public ulong Id { get; init; }
public string Name { get; init; }
public ServerType? Type { get; init; }
}
}

View File

@@ -1,8 +1,8 @@
namespace DHT.Server.Data {
public readonly struct User {
public ulong Id { get; internal init; }
public string Name { get; internal init; }
public string? AvatarUrl { get; internal init; }
public string? Discriminator { get; internal init; }
public ulong Id { get; init; }
public string Name { get; init; }
public string? AvatarUrl { get; init; }
public string? Discriminator { get; init; }
}
}

View File

@@ -1,38 +1,30 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using DHT.Utils.Models;
namespace DHT.Server.Database {
public sealed class DatabaseStatistics : INotifyPropertyChanged {
public sealed class DatabaseStatistics : BaseModel {
private long totalServers;
private long totalChannels;
private long totalUsers;
private long totalMessages;
private long? totalMessages;
public long TotalServers {
get => totalServers;
internal set => Change(out totalServers, value);
internal set => Change(ref totalServers, value);
}
public long TotalChannels {
get => totalChannels;
internal set => Change(out totalChannels, value);
internal set => Change(ref totalChannels, value);
}
public long TotalUsers {
get => totalUsers;
internal set => Change(out totalUsers, value);
internal set => Change(ref totalUsers, value);
}
public long TotalMessages {
public long? TotalMessages {
get => totalMessages;
internal set => Change(out totalMessages, value);
}
public event PropertyChangedEventHandler? PropertyChanged;
private void Change<T>(out T field, T value, [CallerMemberName] string? propertyName = null) {
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
internal set => Change(ref totalMessages, value);
}
public DatabaseStatistics Clone() {

View File

@@ -41,6 +41,8 @@ namespace DHT.Server.Database {
public void RemoveMessages(MessageFilter filter, MessageFilterRemovalMode mode) {}
public void Vacuum() {}
public void Dispose() {}
}
}

View File

@@ -1,13 +1,17 @@
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using System.Text.Json;
using DHT.Server.Data;
using DHT.Server.Data.Filters;
using DHT.Utils.Logging;
namespace DHT.Server.Database.Export {
public static class ViewerJsonExport {
private static readonly Log Log = Log.ForType(typeof(ViewerJsonExport));
public static string Generate(IDatabaseFile db, MessageFilter? filter = null) {
var perf = Log.Start();
var includedUserIds = new HashSet<ulong>();
var includedChannelIds = new HashSet<ulong>();
var includedServerIds = new HashSet<ulong>();
@@ -27,23 +31,29 @@ namespace DHT.Server.Database.Export {
}
}
var opts = new JsonSerializerOptions();
opts.Converters.Add(new ViewerJsonSnowflakeSerializer());
var users = GenerateUserList(db, includedUserIds, out var userindex, out var userIndices);
var servers = GenerateServerList(db, includedServerIds, out var serverindex);
var channels = GenerateChannelList(includedChannels, serverindex);
return JsonSerializer.Serialize(new {
perf.Step("Collect database data");
var opts = new JsonSerializerOptions();
opts.Converters.Add(new ViewerJsonSnowflakeSerializer());
var json = JsonSerializer.Serialize(new {
meta = new { users, userindex, servers, channels },
data = GenerateMessageList(includedMessages, userIndices)
}, opts);
perf.Step("Serialize to JSON");
perf.End();
return json;
}
private static dynamic GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<string> userindex, out Dictionary<ulong, int> userIndices) {
var users = new Dictionary<string, dynamic>();
private static object GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<string> userindex, out Dictionary<ulong, object> userIndices) {
var users = new Dictionary<string, object>();
userindex = new List<string>();
userIndices = new Dictionary<ulong, int>();
userIndices = new Dictionary<ulong, object>();
foreach (var user in db.GetAllUsers()) {
var id = user.Id;
@@ -51,15 +61,16 @@ namespace DHT.Server.Database.Export {
continue;
}
dynamic obj = new ExpandoObject();
obj.name = user.Name;
var obj = new Dictionary<string, object> {
["name"] = user.Name
};
if (user.AvatarUrl != null) {
obj.avatar = user.AvatarUrl;
obj["avatar"] = user.AvatarUrl;
}
if (user.Discriminator != null) {
obj.tag = user.Discriminator;
obj["tag"] = user.Discriminator;
}
var idStr = id.ToString();
@@ -71,9 +82,9 @@ namespace DHT.Server.Database.Export {
return users;
}
private static dynamic GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, int> serverIndices) {
var servers = new List<dynamic>();
serverIndices = new Dictionary<ulong, int>();
private static object GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, object> serverIndices) {
var servers = new List<object>();
serverIndices = new Dictionary<ulong, object>();
foreach (var server in db.GetAllServers()) {
var id = server.Id;
@@ -82,37 +93,38 @@ namespace DHT.Server.Database.Export {
}
serverIndices[id] = servers.Count;
servers.Add(new {
name = server.Name,
type = ServerTypes.ToJsonViewerString(server.Type)
servers.Add(new Dictionary<string, object> {
["name"] = server.Name,
["type"] = ServerTypes.ToJsonViewerString(server.Type)
});
}
return servers;
}
private static dynamic GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, int> serverIndices) {
var channels = new Dictionary<string, dynamic>();
private static object GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, object> serverIndices) {
var channels = new Dictionary<string, object>();
foreach (var channel in includedChannels) {
dynamic obj = new ExpandoObject();
obj.server = serverIndices[channel.Server];
obj.name = channel.Name;
var obj = new Dictionary<string, object> {
["server"] = serverIndices[channel.Server],
["name"] = channel.Name
};
if (channel.ParentId != null) {
obj.parent = channel.ParentId;
obj["parent"] = channel.ParentId;
}
if (channel.Position != null) {
obj.position = channel.Position;
obj["position"] = channel.Position;
}
if (channel.Topic != null) {
obj.topic = channel.Topic;
obj["topic"] = channel.Topic;
}
if (channel.Nsfw != null) {
obj.nsfw = channel.Nsfw;
obj["nsfw"] = channel.Nsfw;
}
channels[channel.Id.ToString()] = obj;
@@ -121,54 +133,55 @@ namespace DHT.Server.Database.Export {
return channels;
}
private static dynamic GenerateMessageList(List<Message> includedMessages, Dictionary<ulong, int> userIndices) {
var data = new Dictionary<string, Dictionary<string, dynamic>>();
private static object GenerateMessageList(List<Message> includedMessages, Dictionary<ulong, object> userIndices) {
var data = new Dictionary<string, Dictionary<string, object>>();
foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) {
var channel = grouping.Key.ToString();
var channelData = new Dictionary<string, dynamic>();
var channelData = new Dictionary<string, object>();
foreach (var message in grouping) {
dynamic obj = new ExpandoObject();
obj.u = userIndices[message.Sender];
obj.t = message.Timestamp;
var obj = new Dictionary<string, object> {
["u"] = userIndices[message.Sender],
["t"] = message.Timestamp
};
if (!string.IsNullOrEmpty(message.Text)) {
obj.m = message.Text;
obj["m"] = message.Text;
}
if (message.EditTimestamp != null) {
obj.te = message.EditTimestamp;
obj["te"] = message.EditTimestamp;
}
if (message.RepliedToId != null) {
obj.r = message.RepliedToId.Value;
obj["r"] = message.RepliedToId.Value;
}
if (!message.Attachments.IsEmpty) {
obj.a = message.Attachments.Select(static attachment => new {
obj["a"] = message.Attachments.Select(static attachment => new {
url = attachment.Url
}).ToArray();
}
if (!message.Embeds.IsEmpty) {
obj.e = message.Embeds.Select(static embed => embed.Json).ToArray();
obj["e"] = message.Embeds.Select(static embed => embed.Json).ToArray();
}
if (!message.Reactions.IsEmpty) {
obj.re = message.Reactions.Select(static reaction => {
dynamic r = new ExpandoObject();
obj["re"] = message.Reactions.Select(static reaction => {
var r = new Dictionary<string, object>();
if (reaction.EmojiId != null) {
r.id = reaction.EmojiId.Value;
r["id"] = reaction.EmojiId.Value;
}
if (reaction.EmojiName != null) {
r.n = reaction.EmojiName;
r["n"] = reaction.EmojiName;
}
r.a = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated);
r.c = reaction.Count;
r["a"] = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated);
r["c"] = reaction.Count;
return r;
});
}

View File

@@ -21,5 +21,7 @@ namespace DHT.Server.Database {
int CountMessages(MessageFilter? filter = null);
List<Message> GetMessages(MessageFilter? filter = null);
void RemoveMessages(MessageFilter filter, MessageFilterRemovalMode mode);
void Vacuum();
}
}

View File

@@ -1,32 +1,29 @@
using System;
using System.Threading.Tasks;
using DHT.Server.Database.Exceptions;
using Microsoft.Data.Sqlite;
using DHT.Server.Database.Sqlite.Utils;
using DHT.Utils.Logging;
namespace DHT.Server.Database.Sqlite {
sealed class Schema {
internal const int Version = 2;
internal const int Version = 3;
private readonly SqliteConnection conn;
private static readonly Log Log = Log.ForType<Schema>();
public Schema(SqliteConnection conn) {
private readonly ISqliteConnection conn;
public Schema(ISqliteConnection 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();
conn.Command(sql).ExecuteNonQuery();
}
public async Task<bool> Setup(Func<Task<bool>> 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();
var dbVersionStr = conn.SelectScalar("SELECT value FROM metadata WHERE key = 'version'");
if (dbVersionStr == null) {
InitializeSchemas();
}
@@ -74,9 +71,7 @@ namespace DHT.Server.Database.Sqlite {
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)");
timestamp INTEGER NOT NULL)");
Execute(@"CREATE TABLE attachments (
message_id INTEGER NOT NULL,
@@ -97,6 +92,9 @@ namespace DHT.Server.Database.Sqlite {
emoji_flags INTEGER NOT NULL,
count INTEGER NOT NULL)");
CreateMessageEditTimestampTable();
CreateMessageRepliedToTable();
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)");
@@ -104,12 +102,50 @@ namespace DHT.Server.Database.Sqlite {
Execute("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")");
}
private void CreateMessageEditTimestampTable() {
Execute(@"CREATE TABLE edit_timestamps (
message_id INTEGER PRIMARY KEY NOT NULL,
edit_timestamp INTEGER NOT NULL)");
}
private void CreateMessageRepliedToTable() {
Execute(@"CREATE TABLE replied_to (
message_id INTEGER PRIMARY KEY NOT NULL,
replied_to_id INTEGER NOT NULL)");
}
private void UpgradeSchemas(int dbVersion) {
var perf = Log.Start("from version " + dbVersion);
Execute("UPDATE metadata SET value = " + Version + " WHERE key = 'version'");
if (dbVersion <= 1) {
Execute("ALTER TABLE channels ADD parent_id INTEGER");
}
perf.Step("Upgrade to version 2");
}
if (dbVersion <= 2) {
CreateMessageEditTimestampTable();
CreateMessageRepliedToTable();
Execute(@"INSERT INTO edit_timestamps (message_id, edit_timestamp)
SELECT message_id, edit_timestamp FROM messages
WHERE edit_timestamp IS NOT NULL");
Execute(@"INSERT INTO replied_to (message_id, replied_to_id)
SELECT message_id, replied_to_id FROM messages
WHERE replied_to_id IS NOT NULL");
Execute("ALTER TABLE messages DROP COLUMN replied_to_id");
Execute("ALTER TABLE messages DROP COLUMN edit_timestamp");
perf.Step("Upgrade to version 3");
Execute("VACUUM");
perf.Step("Vacuum");
}
perf.End();
}
}
}

View File

@@ -5,101 +5,132 @@ using System.Text;
using System.Threading.Tasks;
using DHT.Server.Data;
using DHT.Server.Data.Filters;
using DHT.Server.Database.Sqlite.Utils;
using DHT.Utils.Collections;
using DHT.Utils.Logging;
using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite {
public sealed class SqliteDatabaseFile : IDatabaseFile {
private const int DefaultPoolSize = 5;
public static async Task<SqliteDatabaseFile?> OpenOrCreate(string path, Func<Task<bool>> checkCanUpgradeSchemas) {
string connectionString = new SqliteConnectionStringBuilder {
var connectionString = new SqliteConnectionStringBuilder {
DataSource = path,
Mode = SqliteOpenMode.ReadWriteCreate
}.ToString();
Mode = SqliteOpenMode.ReadWriteCreate,
};
var conn = new SqliteConnection(connectionString);
conn.Open();
var pool = new SqliteConnectionPool(connectionString, DefaultPoolSize);
return await new Schema(conn).Setup(checkCanUpgradeSchemas) ? new SqliteDatabaseFile(path, conn) : null;
using (var conn = pool.Take()) {
if (!await new Schema(conn).Setup(checkCanUpgradeSchemas)) {
return null;
}
}
return new SqliteDatabaseFile(path, pool);
}
public string Path { get; }
public DatabaseStatistics Statistics { get; }
private readonly SqliteConnection conn;
private readonly Log log;
private readonly SqliteConnectionPool pool;
private readonly SqliteMessageStatisticsThread messageStatisticsThread;
private SqliteDatabaseFile(string path, SqliteConnectionPool pool) {
this.log = Log.ForType(typeof(SqliteDatabaseFile), System.IO.Path.GetFileName(path));
this.pool = pool;
this.messageStatisticsThread = new SqliteMessageStatisticsThread(pool, UpdateMessageStatistics);
private SqliteDatabaseFile(string path, SqliteConnection conn) {
this.conn = conn;
this.Path = path;
this.Statistics = new DatabaseStatistics();
UpdateServerStatistics();
UpdateChannelStatistics();
UpdateUserStatistics();
UpdateMessageStatistics();
using (var conn = pool.Take()) {
UpdateServerStatistics(conn);
UpdateChannelStatistics(conn);
UpdateUserStatistics(conn);
}
messageStatisticsThread.RequestUpdate();
}
public void Dispose() {
conn.Dispose();
messageStatisticsThread.Dispose();
pool.Dispose();
}
public void AddServer(Data.Server server) {
using var conn = pool.Take();
using var cmd = conn.Upsert("servers", new[] {
"id", "name", "type"
("id", SqliteType.Integer),
("name", SqliteType.Text),
("type", SqliteType.Text)
});
var serverParams = cmd.Parameters;
serverParams.AddAndSet(":id", server.Id);
serverParams.AddAndSet(":name", server.Name);
serverParams.AddAndSet(":type", ServerTypes.ToString(server.Type));
cmd.Set(":id", server.Id);
cmd.Set(":name", server.Name);
cmd.Set(":type", ServerTypes.ToString(server.Type));
cmd.ExecuteNonQuery();
UpdateServerStatistics();
UpdateServerStatistics(conn);
}
public List<Data.Server> GetAllServers() {
var perf = log.Start();
var list = new List<Data.Server>();
using var conn = pool.Take();
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),
Id = reader.GetUint64(0),
Name = reader.GetString(1),
Type = ServerTypes.FromString(reader.GetString(2))
});
}
perf.End();
return list;
}
public void AddChannel(Channel channel) {
using var conn = pool.Take();
using var cmd = conn.Upsert("channels", new[] {
"id", "server", "name", "parent_id", "position", "topic", "nsfw"
("id", SqliteType.Integer),
("server", SqliteType.Integer),
("name", SqliteType.Text),
("parent_id", SqliteType.Integer),
("position", SqliteType.Integer),
("topic", SqliteType.Text),
("nsfw", SqliteType.Integer)
});
var channelParams = cmd.Parameters;
channelParams.AddAndSet(":id", channel.Id);
channelParams.AddAndSet(":server", channel.Server);
channelParams.AddAndSet(":name", channel.Name);
channelParams.AddAndSet(":parent_id", channel.ParentId);
channelParams.AddAndSet(":position", channel.Position);
channelParams.AddAndSet(":topic", channel.Topic);
channelParams.AddAndSet(":nsfw", channel.Nsfw);
cmd.Set(":id", channel.Id);
cmd.Set(":server", channel.Server);
cmd.Set(":name", channel.Name);
cmd.Set(":parent_id", channel.ParentId);
cmd.Set(":position", channel.Position);
cmd.Set(":topic", channel.Topic);
cmd.Set(":nsfw", channel.Nsfw);
cmd.ExecuteNonQuery();
UpdateChannelStatistics();
UpdateChannelStatistics(conn);
}
public List<Channel> GetAllChannels() {
var list = new List<Channel>();
using var conn = pool.Take();
using var cmd = conn.Command("SELECT id, server, name, parent_id, 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),
Id = reader.GetUint64(0),
Server = reader.GetUint64(1),
Name = reader.GetString(2),
ParentId = reader.IsDBNull(3) ? null : (ulong) reader.GetInt64(3),
ParentId = reader.IsDBNull(3) ? null : reader.GetUint64(3),
Position = reader.IsDBNull(4) ? null : reader.GetInt32(4),
Topic = reader.IsDBNull(5) ? null : reader.GetString(5),
Nsfw = reader.IsDBNull(6) ? null : reader.GetBoolean(6)
@@ -110,163 +141,175 @@ namespace DHT.Server.Database.Sqlite {
}
public void AddUsers(User[] users) {
using var conn = pool.Take();
using var tx = conn.BeginTransaction();
using var cmd = conn.Upsert("users", new[] {
"id", "name", "avatar_url", "discriminator"
("id", SqliteType.Integer),
("name", SqliteType.Text),
("avatar_url", SqliteType.Text),
("discriminator", SqliteType.Text)
});
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.Set(":id", user.Id);
cmd.Set(":name", user.Name);
cmd.Set(":avatar_url", user.AvatarUrl);
cmd.Set(":discriminator", user.Discriminator);
cmd.ExecuteNonQuery();
}
tx.Commit();
UpdateUserStatistics();
UpdateUserStatistics(conn);
}
public List<User> GetAllUsers() {
var perf = log.Start();
var list = new List<User>();
using var conn = pool.Take();
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),
Id = reader.GetUint64(0),
Name = reader.GetString(1),
AvatarUrl = reader.IsDBNull(2) ? null : reader.GetString(2),
Discriminator = reader.IsDBNull(3) ? null : reader.GetString(3)
});
}
perf.End();
return list;
}
public void AddMessages(Message[] messages) {
static SqliteCommand DeleteByMessageId(ISqliteConnection conn, string tableName) {
return conn.Delete(tableName, ("message_id", SqliteType.Integer));
}
static void ExecuteDeleteByMessageId(SqliteCommand cmd, object id) {
cmd.Set(":message_id", id);
cmd.ExecuteNonQuery();
}
using var conn = pool.Take();
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"
("message_id", SqliteType.Integer),
("sender_id", SqliteType.Integer),
("channel_id", SqliteType.Integer),
("text", SqliteType.Text),
("timestamp", SqliteType.Integer)
});
using var deleteEditTimestampCmd = DeleteByMessageId(conn, "edit_timestamps");
using var deleteRepliedToCmd = DeleteByMessageId(conn, "replied_to");
using var deleteAttachmentsCmd = DeleteByMessageId(conn, "attachments");
using var deleteEmbedsCmd = DeleteByMessageId(conn, "embeds");
using var deleteReactionsCmd = DeleteByMessageId(conn, "reactions");
using var editTimestampCmd = conn.Insert("edit_timestamps", new [] {
("message_id", SqliteType.Integer),
("edit_timestamp", SqliteType.Integer)
});
using var repliedToCmd = conn.Insert("replied_to", new [] {
("message_id", SqliteType.Integer),
("replied_to_id", SqliteType.Integer)
});
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"
("message_id", SqliteType.Integer),
("attachment_id", SqliteType.Integer),
("name", SqliteType.Text),
("type", SqliteType.Text),
("url", SqliteType.Text),
("size", SqliteType.Integer)
});
using var deleteEmbedsCmd = conn.Command("DELETE FROM embeds WHERE message_id = :message_id");
using var embedCmd = conn.Insert("embeds", new[] {
"message_id", "json"
("message_id", SqliteType.Integer),
("json", SqliteType.Text)
});
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"
("message_id", SqliteType.Integer),
("emoji_id", SqliteType.Integer),
("emoji_name", SqliteType.Text),
("emoji_flags", SqliteType.Integer),
("count", SqliteType.Integer)
});
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.Set(":message_id", messageId);
messageCmd.Set(":sender_id", message.Sender);
messageCmd.Set(":channel_id", message.Channel);
messageCmd.Set(":text", message.Text);
messageCmd.Set(":timestamp", message.Timestamp);
messageCmd.ExecuteNonQuery();
deleteAttachmentsParams.Set(":message_id", messageId);
deleteAttachmentsCmd.ExecuteNonQuery();
ExecuteDeleteByMessageId(deleteEditTimestampCmd, messageId);
ExecuteDeleteByMessageId(deleteRepliedToCmd, messageId);
deleteEmbedsParams.Set(":message_id", messageId);
deleteEmbedsCmd.ExecuteNonQuery();
ExecuteDeleteByMessageId(deleteAttachmentsCmd, messageId);
ExecuteDeleteByMessageId(deleteEmbedsCmd, messageId);
ExecuteDeleteByMessageId(deleteReactionsCmd, messageId);
deleteReactionsParams.Set(":message_id", messageId);
deleteReactionsCmd.ExecuteNonQuery();
if (message.EditTimestamp is {} timestamp) {
editTimestampCmd.Set(":message_id", messageId);
editTimestampCmd.Set(":edit_timestamp", timestamp);
editTimestampCmd.ExecuteNonQuery();
}
if (message.RepliedToId is {} repliedToId) {
repliedToCmd.Set(":message_id", messageId);
repliedToCmd.Set(":replied_to_id", repliedToId);
repliedToCmd.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.Set(":message_id", messageId);
attachmentCmd.Set(":attachment_id", attachment.Id);
attachmentCmd.Set(":name", attachment.Name);
attachmentCmd.Set(":type", attachment.Type);
attachmentCmd.Set(":url", attachment.Url);
attachmentCmd.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.Set(":message_id", messageId);
embedCmd.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.Set(":message_id", messageId);
reactionCmd.Set(":emoji_id", reaction.EmojiId);
reactionCmd.Set(":emoji_name", reaction.EmojiName);
reactionCmd.Set(":emoji_flags", (int) reaction.EmojiFlags);
reactionCmd.Set(":count", reaction.Count);
reactionCmd.ExecuteNonQuery();
}
}
}
tx.Commit();
UpdateMessageStatistics();
messageStatisticsThread.RequestUpdate();
}
public int CountMessages(MessageFilter? filter = null) {
using var conn = pool.Take();
using var cmd = conn.Command("SELECT COUNT(*) FROM messages" + filter.GenerateWhereClause());
using var reader = cmd.ExecuteReader();
@@ -274,32 +317,39 @@ namespace DHT.Server.Database.Sqlite {
}
public List<Message> GetMessages(MessageFilter? filter = null) {
var perf = log.Start();
var list = new List<Message>();
var attachments = GetAllAttachments();
var embeds = GetAllEmbeds();
var reactions = GetAllReactions();
var list = new List<Message>();
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 conn = pool.Take();
using var cmd = conn.Command(@"
SELECT m.message_id, m.sender_id, m.channel_id, m.text, m.timestamp, et.edit_timestamp, rt.replied_to_id
FROM messages m
LEFT JOIN edit_timestamps et ON m.message_id = et.message_id
LEFT JOIN replied_to rt ON m.message_id = rt.message_id" + filter.GenerateWhereClause("m"));
using var reader = cmd.ExecuteReader();
while (reader.Read()) {
ulong id = (ulong) reader.GetInt64(0);
ulong id = reader.GetUint64(0);
list.Add(new Message {
Id = id,
Sender = (ulong) reader.GetInt64(1),
Channel = (ulong) reader.GetInt64(2),
Sender = reader.GetUint64(1),
Channel = reader.GetUint64(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),
RepliedToId = reader.IsDBNull(6) ? null : reader.GetUint64(6),
Attachments = attachments.GetListOrNull(id)?.ToImmutableArray() ?? ImmutableArray<Attachment>.Empty,
Embeds = embeds.GetListOrNull(id)?.ToImmutableArray() ?? ImmutableArray<Embed>.Empty,
Reactions = reactions.GetListOrNull(id)?.ToImmutableArray() ?? ImmutableArray<Reaction>.Empty
});
}
perf.End();
return list;
}
@@ -309,33 +359,38 @@ namespace DHT.Server.Database.Sqlite {
return;
}
var perf = log.Start();
// Rider is being stupid...
StringBuilder build = new StringBuilder()
.Append("DELETE ")
.Append("FROM messages")
.Append(whereClause);
using var conn = pool.Take();
using var cmd = conn.Command(build.ToString());
cmd.ExecuteNonQuery();
UpdateMessageStatistics();
UpdateMessageStatistics(conn);
perf.End();
}
private MultiDictionary<ulong, Attachment> GetAllAttachments() {
var dict = new MultiDictionary<ulong, Attachment>();
using var conn = pool.Take();
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);
ulong messageId = reader.GetUint64(0);
dict.Add(messageId, new Attachment {
Id = (ulong) reader.GetInt64(1),
Id = reader.GetUint64(1),
Name = reader.GetString(2),
Type = reader.IsDBNull(3) ? null : reader.GetString(3),
Url = reader.GetString(4),
Size = (ulong) reader.GetInt64(5)
Size = reader.GetUint64(5)
});
}
@@ -345,11 +400,12 @@ namespace DHT.Server.Database.Sqlite {
private MultiDictionary<ulong, Embed> GetAllEmbeds() {
var dict = new MultiDictionary<ulong, Embed>();
using var conn = pool.Take();
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);
ulong messageId = reader.GetUint64(0);
dict.Add(messageId, new Embed {
Json = reader.GetString(1)
@@ -362,14 +418,15 @@ namespace DHT.Server.Database.Sqlite {
private MultiDictionary<ulong, Reaction> GetAllReactions() {
var dict = new MultiDictionary<ulong, Reaction>();
using var conn = pool.Take();
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);
ulong messageId = reader.GetUint64(0);
dict.Add(messageId, new Reaction {
EmojiId = reader.IsDBNull(1) ? null : (ulong) reader.GetInt64(1),
EmojiId = reader.IsDBNull(1) ? null : reader.GetUint64(1),
EmojiName = reader.IsDBNull(2) ? null : reader.GetString(2),
EmojiFlags = (EmojiFlags) reader.GetInt16(3),
Count = reader.GetInt32(4)
@@ -379,24 +436,26 @@ namespace DHT.Server.Database.Sqlite {
return dict;
}
private void UpdateServerStatistics() {
using var cmd = conn.Command("SELECT COUNT(*) FROM servers");
Statistics.TotalServers = cmd.ExecuteScalar() as long? ?? 0;
public void Vacuum() {
using var conn = pool.Take();
using var cmd = conn.Command("VACUUM");
cmd.ExecuteNonQuery();
}
private void UpdateChannelStatistics() {
using var cmd = conn.Command("SELECT COUNT(*) FROM channels");
Statistics.TotalChannels = cmd.ExecuteScalar() as long? ?? 0;
private void UpdateServerStatistics(ISqliteConnection conn) {
Statistics.TotalServers = conn.SelectScalar("SELECT COUNT(*) FROM servers") as long? ?? 0;
}
private void UpdateUserStatistics() {
using var cmd = conn.Command("SELECT COUNT(*) FROM users");
Statistics.TotalUsers = cmd.ExecuteScalar() as long? ?? 0;
private void UpdateChannelStatistics(ISqliteConnection conn) {
Statistics.TotalChannels = conn.SelectScalar("SELECT COUNT(*) FROM channels") as long? ?? 0;
}
private void UpdateMessageStatistics() {
using var cmd = conn.Command("SELECT COUNT(*) FROM messages");
Statistics.TotalMessages = cmd.ExecuteScalar() as long? ?? 0L;
private void UpdateUserStatistics(ISqliteConnection conn) {
Statistics.TotalUsers = conn.SelectScalar("SELECT COUNT(*) FROM users") as long? ?? 0;
}
private void UpdateMessageStatistics(ISqliteConnection conn) {
Statistics.TotalMessages = conn.SelectScalar("SELECT COUNT(*) FROM messages") as long? ?? 0L;
}
}
}

View File

@@ -4,31 +4,35 @@ using DHT.Server.Data.Filters;
namespace DHT.Server.Database.Sqlite {
static class SqliteMessageFilter {
public static string GenerateWhereClause(this MessageFilter? filter, bool invert = false) {
public static string GenerateWhereClause(this MessageFilter? filter, string? tableAlias = null, bool invert = false) {
if (filter == null) {
return "";
}
if (tableAlias != null) {
tableAlias += ".";
}
List<string> conditions = new();
if (filter.StartDate != null) {
conditions.Add("timestamp >= " + new DateTimeOffset(filter.StartDate.Value).ToUnixTimeMilliseconds());
conditions.Add(tableAlias + "timestamp >= " + new DateTimeOffset(filter.StartDate.Value).ToUnixTimeMilliseconds());
}
if (filter.EndDate != null) {
conditions.Add("timestamp <= " + new DateTimeOffset(filter.EndDate.Value).ToUnixTimeMilliseconds());
conditions.Add(tableAlias + "timestamp <= " + new DateTimeOffset(filter.EndDate.Value).ToUnixTimeMilliseconds());
}
if (filter.ChannelIds != null) {
conditions.Add("channel_id IN (" + string.Join(",", filter.ChannelIds) + ")");
conditions.Add(tableAlias + "channel_id IN (" + string.Join(",", filter.ChannelIds) + ")");
}
if (filter.UserIds != null) {
conditions.Add("sender_id IN (" + string.Join(",", filter.UserIds) + ")");
conditions.Add(tableAlias + "sender_id IN (" + string.Join(",", filter.UserIds) + ")");
}
if (filter.MessageIds != null) {
conditions.Add("message_id IN (" + string.Join(",", filter.MessageIds) + ")");
conditions.Add(tableAlias + "message_id IN (" + string.Join(",", filter.MessageIds) + ")");
}
if (conditions.Count == 0) {

View File

@@ -0,0 +1,54 @@
using System;
using System.Threading;
using DHT.Server.Database.Sqlite.Utils;
namespace DHT.Server.Database.Sqlite {
sealed class SqliteMessageStatisticsThread : IDisposable {
private readonly SqliteConnectionPool pool;
private readonly Action<ISqliteConnection> action;
private readonly CancellationTokenSource cancellationTokenSource = new();
private readonly CancellationToken cancellationToken;
private readonly AutoResetEvent requestEvent = new (false);
public SqliteMessageStatisticsThread(SqliteConnectionPool pool, Action<ISqliteConnection> action) {
this.pool = pool;
this.action = action;
this.cancellationToken = cancellationTokenSource.Token;
var thread = new Thread(RunThread) {
Name = "DHT message statistics thread",
IsBackground = true
};
thread.Start();
}
public void Dispose() {
try {
cancellationTokenSource.Cancel();
} catch (ObjectDisposedException) {}
}
public void RequestUpdate() {
try {
requestEvent.Set();
} catch (ObjectDisposedException) {}
}
private void RunThread() {
try {
while (!cancellationToken.IsCancellationRequested) {
if (requestEvent.WaitOne(TimeSpan.FromMilliseconds(100))) {
using var conn = pool.Take();
action(conn);
}
}
} finally {
cancellationTokenSource.Dispose();
requestEvent.Dispose();
}
}
}
}

View File

@@ -1,40 +0,0 @@
using System;
using System.Linq;
using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite {
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(static 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(static c => ':' + c));
string columnUpdates = string.Join(',', columns.Skip(1).Select(static 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;
}
}
}

View File

@@ -0,0 +1,8 @@
using System;
using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Utils {
interface ISqliteConnection : IDisposable {
SqliteConnection InnerConnection { get; }
}
}

View File

@@ -0,0 +1,116 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using DHT.Utils.Logging;
using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Utils {
sealed class SqliteConnectionPool : IDisposable {
private static string GetConnectionString(SqliteConnectionStringBuilder connectionStringBuilder) {
connectionStringBuilder.Pooling = false;
return connectionStringBuilder.ToString();
}
private readonly object monitor = new ();
private volatile bool isDisposed;
private readonly BlockingCollection<PooledConnection> free = new (new ConcurrentStack<PooledConnection>());
private readonly List<PooledConnection> used;
public SqliteConnectionPool(SqliteConnectionStringBuilder connectionStringBuilder, int poolSize) {
var connectionString = GetConnectionString(connectionStringBuilder);
for (int i = 0; i < poolSize; i++) {
var conn = new SqliteConnection(connectionString);
conn.Open();
var pooledConn = new PooledConnection(this, conn);
using (var cmd = pooledConn.Command("PRAGMA journal_mode=WAL")) {
cmd.ExecuteNonQuery();
}
free.Add(pooledConn);
}
used = new List<PooledConnection>(poolSize);
}
private void ThrowIfDisposed() {
if (isDisposed) {
throw new ObjectDisposedException(nameof(SqliteConnectionPool));
}
}
public ISqliteConnection Take() {
PooledConnection? conn = null;
while (conn == null) {
ThrowIfDisposed();
lock (monitor) {
if (free.TryTake(out conn, TimeSpan.FromMilliseconds(100))) {
used.Add(conn);
break;
}
else {
Log.ForType<SqliteConnectionPool>().Warn("Thread " + Thread.CurrentThread.ManagedThreadId + " is starving for connections.");
}
}
}
return conn;
}
private void Return(PooledConnection conn) {
ThrowIfDisposed();
lock (monitor) {
if (used.Remove(conn)) {
free.Add(conn);
}
}
}
public void Dispose() {
if (isDisposed) {
return;
}
isDisposed = true;
lock (monitor) {
while (free.TryTake(out var conn)) {
Close(conn.InnerConnection);
}
foreach (var conn in used) {
Close(conn.InnerConnection);
}
free.Dispose();
used.Clear();
}
}
private static void Close(SqliteConnection conn) {
conn.Close();
conn.Dispose();
}
private sealed class PooledConnection : ISqliteConnection {
public SqliteConnection InnerConnection { get; }
private readonly SqliteConnectionPool pool;
public PooledConnection(SqliteConnectionPool pool, SqliteConnection conn) {
this.pool = pool;
this.InnerConnection = conn;
}
void IDisposable.Dispose() {
pool.Return(this);
}
}
}
}

View File

@@ -0,0 +1,67 @@
using System;
using System.Linq;
using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Utils {
static class SqliteExtensions {
public static SqliteCommand Command(this ISqliteConnection conn, string sql) {
var cmd = conn.InnerConnection.CreateCommand();
cmd.CommandText = sql;
return cmd;
}
public static SqliteTransaction BeginTransaction(this ISqliteConnection conn) {
return conn.InnerConnection.BeginTransaction();
}
public static object? SelectScalar(this ISqliteConnection conn, string sql) {
using var cmd = conn.Command(sql);
return cmd.ExecuteScalar();
}
public static SqliteCommand Insert(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type)[] columns) {
string columnNames = string.Join(',', columns.Select(static c => c.Name));
string columnParams = string.Join(',', columns.Select(static c => ':' + c.Name));
var cmd = conn.Command("INSERT INTO " + tableName + " (" + columnNames + ")" +
"VALUES (" + columnParams + ")");
CreateParameters(cmd, columns);
return cmd;
}
public static SqliteCommand Upsert(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type)[] columns) {
string columnNames = string.Join(',', columns.Select(static c => c.Name));
string columnParams = string.Join(',', columns.Select(static c => ':' + c.Name));
string columnUpdates = string.Join(',', columns.Skip(1).Select(static c => c.Name + " = excluded." + c.Name));
var cmd = conn.Command("INSERT INTO " + tableName + " (" + columnNames + ")" +
"VALUES (" + columnParams + ")" +
"ON CONFLICT (" + columns[0].Name + ")" +
"DO UPDATE SET " + columnUpdates);
CreateParameters(cmd, columns);
return cmd;
}
public static SqliteCommand Delete(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type) column) {
var cmd = conn.Command("DELETE FROM " + tableName + " WHERE " + column.Name + " = :" + column.Name);
CreateParameters(cmd, new [] { column });
return cmd;
}
private static void CreateParameters(SqliteCommand cmd, (string Name, SqliteType Type)[] columns) {
foreach (var (name, type) in columns) {
cmd.Parameters.Add(":" + name, type);
}
}
public static void Set(this SqliteCommand cmd, string key, object? value) {
cmd.Parameters[key].Value = value ?? DBNull.Value;
}
public static ulong GetUint64(this SqliteDataReader reader, int ordinal) {
return (ulong) reader.GetInt64(ordinal);
}
}
}

View File

@@ -3,6 +3,7 @@
<TargetFramework>net5.0</TargetFramework>
<RootNamespace>DHT.Server</RootNamespace>
<Nullable>enable</Nullable>
<AssemblyName>DiscordHistoryTracker.Server</AssemblyName>
<PackageId>DiscordHistoryTrackerServer</PackageId>
<Authors>chylex</Authors>
<Company>DiscordHistoryTracker</Company>
@@ -19,7 +20,7 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="5.0.5" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="6.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Utils\Utils.csproj" />

View File

@@ -29,6 +29,7 @@ namespace DHT.Server.Service {
try {
if (ManagementThread == null) {
ManagementThread = new Thread(RunManagementThread) {
Name = "DHT server management thread",
IsBackground = true
};
ManagementThread.Start();
@@ -92,11 +93,12 @@ namespace DHT.Server.Service {
if (Server != null) {
Log.Info("Stopping server...");
Server.StopAsync().Wait();
Server.Dispose();
Server = null;
Log.Info("Server stopped");
IsRunning = false;
ServerStatusChanged?.Invoke(null, EventArgs.Empty);
Server = null;
}
}

View File

@@ -7,7 +7,7 @@ using System.Text.RegularExpressions;
namespace DHT.Server.Service {
public static class ServerUtils {
public static int FindAvailablePort(int min, int max) {
public static ushort FindAvailablePort(ushort min, ushort max) {
var properties = IPGlobalProperties.GetIPGlobalProperties();
var occupied = new HashSet<int>();
occupied.UnionWith(properties.GetActiveTcpListeners().Select(static tcp => tcp.Port));
@@ -15,7 +15,7 @@ namespace DHT.Server.Service {
for (int port = min; port < max; port++) {
if (!occupied.Contains(port)) {
return port;
return (ushort) port;
}
}

View File

@@ -1,8 +1,18 @@
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;
namespace DHT.Utils.Logging {
public sealed class Log {
public static bool IsDebugEnabled { get; set; }
static Log() {
#if DEBUG
IsDebugEnabled = true;
#endif
}
public static Log ForType<T>() {
return ForType(typeof(T));
}
@@ -11,19 +21,54 @@ namespace DHT.Utils.Logging {
return new Log(type.Name);
}
private readonly string tag;
public static Log ForType<T>(string context) {
return ForType(typeof(T), context);
}
private Log(string tag) {
public static Log ForType(Type type, string context) {
return new Log(type.Name, context);
}
private readonly string tag;
private readonly string? context;
private Log(string tag, string? context = null) {
this.tag = tag;
this.context = context;
}
private void FormatTags(StringBuilder builder) {
builder.Append('[').Append(tag).Append("] ");
if (context != null) {
builder.Append('[').Append(context).Append("] ");
}
}
private void LogLevel(ConsoleColor color, string level, string text) {
ConsoleColor prevColor = Console.ForegroundColor;
Console.ForegroundColor = color;
StringBuilder builder = new StringBuilder();
foreach (string line in text.Replace("\r", "").Split('\n')) {
string formatted = $"[{level}] [{tag}] {line}";
builder.Clear();
builder.Append('[').Append(level).Append("] ");
FormatTags(builder);
builder.Append(line);
string formatted = builder.ToString();
Console.WriteLine(formatted);
Trace.WriteLine(formatted);
}
Console.ForegroundColor = prevColor;
}
public void Debug(string message) {
if (IsDebugEnabled) {
LogLevel(ConsoleColor.Gray, "DEBUG", message);
}
}
public void Info(string message) {
@@ -41,5 +86,9 @@ namespace DHT.Utils.Logging {
public void Error(Exception e) {
LogLevel(ConsoleColor.Red, "ERROR", e.ToString());
}
public Perf Start(string? context = null, [CallerMemberName] string callerMemberName = "") {
return Perf.Start(this, context, callerMemberName);
}
}
}

47
app/Utils/Logging/Perf.cs Normal file
View File

@@ -0,0 +1,47 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace DHT.Utils.Logging {
public sealed class Perf {
internal static Perf Start(Log log, string? context = null, [CallerMemberName] string callerMemberName = "") {
return new Perf(log, callerMemberName, context);
}
private readonly Log log;
private readonly string method;
private readonly string? context;
private readonly Stopwatch totalStopwatch;
private readonly Stopwatch stepStopwatch;
private Perf(Log log, string method, string? context) {
this.log = log;
this.method = method;
this.context = context;
this.totalStopwatch = new Stopwatch();
this.totalStopwatch.Start();
this.stepStopwatch = new Stopwatch();
this.stepStopwatch.Start();
}
public void Step(string name) {
stepStopwatch.Stop();
if (Log.IsDebugEnabled) {
string ctx = context == null ? string.Empty : " " + context;
log.Debug($"Finished step '{name}' of '{method}'{ctx} in {stepStopwatch.ElapsedMilliseconds} ms.");
}
stepStopwatch.Restart();
}
public void End() {
totalStopwatch.Stop();
stepStopwatch.Stop();
if (Log.IsDebugEnabled) {
string ctx = context == null ? string.Empty : " " + context;
log.Debug($"Finished '{method}'{ctx} in {totalStopwatch.ElapsedMilliseconds} ms.");
}
}
}
}

View File

@@ -3,6 +3,7 @@
<TargetFramework>net5.0</TargetFramework>
<RootNamespace>DHT.Utils</RootNamespace>
<Nullable>enable</Nullable>
<AssemblyName>DiscordHistoryTracker.Utils</AssemblyName>
<PackageId>DiscordHistoryTrackerUtils</PackageId>
<Authors>chylex</Authors>
<Company>DiscordHistoryTracker</Company>

View File

@@ -7,6 +7,6 @@ using DHT.Utils;
namespace DHT.Utils {
static class Version {
public const string Tag = "32.2.0.0";
public const string Tag = "35.1.0.0";
}
}

Binary file not shown.

View File

@@ -1,15 +0,0 @@
# Python 3
import glob
import os
uglifyjs = os.path.abspath("../lib/uglifyjs")
input_dir = os.path.abspath("./Resources/Tracker/scripts")
output_dir = os.path.abspath("./Resources/Tracker/scripts.min")
for file in glob.glob(input_dir + "/*.js"):
name = os.path.basename(file)
print("Minifying {0}...".format(name))
os.system("{0} {1} -o {2}/{3}".format(uglifyjs, file, output_dir, name))
print("Done!")

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

225
build.py
View File

@@ -1,225 +0,0 @@
# Python 3
import fileinput
import glob
import shutil
import sys
import os
import re
import distutils.dir_util
VERSION_SHORT = "v.31a"
VERSION_FULL = VERSION_SHORT + ", released 12 Feb 2022"
EXEC_UGLIFYJS_WIN = "{2}/lib/uglifyjs.cmd --parse bare_returns --compress --mangle toplevel --mangle-props keep_quoted,reserved=[{3}] --output \"{1}\" \"{0}\""
EXEC_UGLIFYJS_AUTO = "uglifyjs --parse bare_returns --compress --mangle toplevel --mangle-props keep_quoted,reserved=[{3}] --output \"{1}\" \"{0}\""
USE_UGLIFYJS = "--nominify" not in sys.argv
USE_MINIFICATION = "--nominify" not in sys.argv
BUILD_WEBSITE = "--website" in sys.argv
CLIPBOARD_TRACKER = "--copytracker" in sys.argv
WORKING_DIR = os.getcwd()
# UglifyJS Setup
if os.name == "nt":
EXEC_UGLIFYJS = EXEC_UGLIFYJS_WIN
else:
EXEC_UGLIFYJS = EXEC_UGLIFYJS_AUTO
if USE_UGLIFYJS and shutil.which("uglifyjs") is None:
USE_UGLIFYJS = False
print("Could not find 'uglifyjs', JS minification will be disabled")
if USE_UGLIFYJS:
with open("reserve.txt", "r") as reserved:
RESERVED_PROPS = ",".join(line.strip() for line in reserved.readlines())
# File Utilities
def combine_files(input_pattern, output_file):
is_first_file = True
with fileinput.input(sorted(glob.glob(input_pattern))) as stream:
for line in stream:
if stream.isfirstline():
if is_first_file:
is_first_file = False
else:
output_file.write("\n")
output_file.write(line.replace("{{{version:full}}}", VERSION_FULL))
def minify_css(input_file, output_file):
if not USE_MINIFICATION:
if input_file != output_file:
shutil.copyfile(input_file, output_file)
return
with open(input_file, "r") as fin:
css = fin.read()
css = re.sub(r"^\s+(.+?):\s*(.+?)(?:\s*(!important))?;\n", r"\1:\2\3;", css, flags = re.M) # remove spaces after colons
css = re.sub(r"\{\n", r"{", css, flags = re.M) # remove new lines after {
css = re.sub(r"\n\}", r"}", css, flags = re.M) # remove new lines before }
css = re.sub(r"\n\n", r"\n", css, flags = re.M) # remove empty lines
css = re.sub(r";\}$", r"}", css, flags = re.M) # remove last semicolons
css = re.sub(r"rgb\((.*?),\s*(.*?),\s*(.*?)\)", r"rgb(\1,\2,\3)", css, flags = re.M) # remove spaces after commas in rgb()
css = re.sub(r"rgba\((.*?),\s*(.*?),\s*(.*?),\s*(.*?)\)", r"rgba(\1,\2,\3,\4)", css, flags = re.M) # remove spaces after commas in rgba()
with open(output_file, "w") as out:
out.write(css)
# Build System
def build_tracker_html():
output_file_raw = "bld/track.js"
output_file_html = "bld/track.html"
output_file_tmp = "bld/track.tmp.js"
input_pattern = "src/tracker/*.js"
with open(output_file_raw, "w") as out:
if not USE_UGLIFYJS:
out.write("(function(){\n")
combine_files(input_pattern, out)
if not USE_UGLIFYJS:
out.write("})()")
if USE_UGLIFYJS:
os.system(EXEC_UGLIFYJS.format(output_file_raw, output_file_tmp, WORKING_DIR, RESERVED_PROPS))
with open(output_file_raw, "w") as out:
out.write("javascript:(function(){")
with open(output_file_tmp, "r") as minified:
out.write(minified.read().replace("\n", " ").replace("\r", ""))
out.write("})()")
os.remove(output_file_tmp)
with open(output_file_raw, "r") as raw:
script_contents = raw.read().replace("&", "&amp;").replace('"', "&quot;").replace("'", "&#x27;").replace("<", "&lt;").replace(">", "&gt;")
with open(output_file_html, "w") as out:
out.write(script_contents)
def build_tracker_userscript():
output_file = "bld/track.user.js"
input_pattern = "src/tracker/*.js"
userscript_base = "src/base/track.user.js"
with open(userscript_base, "r") as base:
userscript_contents = base.read().replace("{{{version}}}", VERSION_SHORT).split("{{{contents}}}")
with open(output_file, "w") as out:
out.write(userscript_contents[0])
combine_files(input_pattern, out)
out.write(userscript_contents[1])
def build_viewer():
output_file = "bld/viewer.html"
input_html = "src/viewer/index.html"
input_css_pattern = "src/viewer/styles/*.css"
tmp_css_file_combined = "bld/viewer.tmp.css"
tmp_css_file_minified = "bld/viewer.min.css"
with open(tmp_css_file_combined, "w") as out:
combine_files(input_css_pattern, out)
minify_css(tmp_css_file_combined, tmp_css_file_minified)
os.remove(tmp_css_file_combined)
input_js_pattern = "src/viewer/scripts/*.js"
tmp_js_file_combined = "bld/viewer.tmp.js"
tmp_js_file_minified = "bld/viewer.min.js"
with open(tmp_js_file_combined, "w") as out:
combine_files(input_js_pattern, out)
if USE_UGLIFYJS:
os.system(EXEC_UGLIFYJS.format(tmp_js_file_combined, tmp_js_file_minified, WORKING_DIR, RESERVED_PROPS))
else:
shutil.copyfile(tmp_js_file_combined, tmp_js_file_minified)
os.remove(tmp_js_file_combined)
tokens = {
"/*{js}*/": tmp_js_file_minified,
"/*{css}*/": tmp_css_file_minified
}
with open(output_file, "w") as out:
with open(input_html, "r") as fin:
for line in fin:
token = None
for token in (token for token in tokens if token in line):
with open(tokens[token], "r") as token_file:
embedded = token_file.read()
out.write(embedded)
os.remove(tokens[token])
if token is None:
out.write(line)
def build_website():
tracker_file_html = "bld/track.html"
tracker_file_userscript = "bld/track.user.js"
viewer_file = "bld/viewer.html"
web_style_file = "bld/web/style.css"
distutils.dir_util.copy_tree("web", "bld/web")
index_file = "bld/web/index.php"
with open(index_file, "r") as index:
index_contents = index.read()
with open(index_file, "w") as index:
index.write(index_contents.replace("{{{version:web}}}", VERSION_SHORT.replace(" ", "&nbsp;")))
shutil.copyfile(tracker_file_html, "bld/web/build/track.html")
shutil.copyfile(tracker_file_userscript, "bld/web/build/track.user.js")
shutil.copyfile(viewer_file, "bld/web/build/viewer.html")
minify_css(web_style_file, web_style_file)
# Build Process
os.makedirs("bld", exist_ok = True)
print("Building tracker html...")
build_tracker_html()
print("Building tracker userscript...")
build_tracker_userscript()
print("Building viewer...")
build_viewer()
if BUILD_WEBSITE:
print("Building website...")
build_website()
if CLIPBOARD_TRACKER:
if os.name == "nt":
print("Copying to clipboard...")
os.system("clip < bld/track.js")
else:
print("Clipboard is only supported on Windows")
print("Done")

View File

@@ -1,73 +0,0 @@
autoscroll
_autoscroll
afterFirstMsg
_afterFirstMsg
afterSavedMsg
_afterSavedMsg
enableImagePreviews
_enableImagePreviews
enableFormatting
_enableFormatting
enableAnimatedEmoji
_enableAnimatedEmoji
enableUserAvatars
_enableUserAvatars
DHT_LOADED
DHT_EMBEDDED
meta
data
users
userindex
servers
channels
u
t
m
f
e
a
t
te
d
r
re
c
n
an
tag
avatar
author
type
state
name
position
topic
nsfw
id
username
bot
discriminator
timestamp
content
editedTimestamp
mentions
embeds
attachments
title
description
reply
reactions
emoji
count
animated
ext
toDate
memoizedProps
props
children
channel
messages
msSaveBlob
messageReference
message_id
guild_id

View File

@@ -1,50 +0,0 @@
// ==UserScript==
// @name Discord History Tracker
// @version {{{version}}}
// @license MIT
// @namespace https://chylex.com
// @homepageURL https://dht.chylex.com/
// @supportURL https://github.com/chylex/Discord-History-Tracker/issues
// @include https://discord.com/*
// @run-at document-idle
// @grant none
// ==/UserScript==
const start = function(){
{{{contents}}}
};
const css = document.createElement("style");
css.innerText = `
#dht-userscript-trigger { cursor: pointer; margin-top: 5px }
#dht-userscript-trigger svg { opacity: 0.6 }
#dht-userscript-trigger:hover svg { opacity: 1 }
`;
document.head.appendChild(css);
window.setInterval(function(){
if (document.getElementById("dht-userscript-trigger")){
return;
}
const help = document.querySelector("section[class^='title'] a[href*='support.discord.com']");
if (help){
help.insertAdjacentHTML("afterend", `
<span id="dht-userscript-trigger">
<span style="margin: 0 4px" role="button">
<svg width="28" height="16" viewBox="0 0 11 6" fill="#fff">
<path d="M3.133,2.848c0,0.355 -0.044,0.668 -0.132,0.937c-0.088,0.27 -0.208,0.495 -0.36,0.677c-0.153,0.181 -0.333,0.319 -0.541,0.412c-0.207,0.092 -0.431,0.139 -0.672,0.139l-1.413,0l0,-4.266l1.265,0c0.27,0 0.519,0.042 0.746,0.124c0.227,0.083 0.423,0.21 0.586,0.382c0.164,0.171 0.291,0.389 0.383,0.654c0.092,0.264 0.138,0.578 0.138,0.941Zm-0.739,0c0,-0.248 -0.028,-0.461 -0.083,-0.639c-0.056,-0.177 -0.133,-0.323 -0.232,-0.437c-0.099,-0.114 -0.217,-0.198 -0.355,-0.253c-0.139,-0.054 -0.292,-0.082 -0.459,-0.082l-0.518,0l0,2.886l0.621,0c0.147,0 0.283,-0.032 0.409,-0.094c0.125,-0.063 0.233,-0.156 0.325,-0.28c0.092,-0.124 0.163,-0.278 0.215,-0.462c0.051,-0.184 0.077,-0.397 0.077,-0.639Z"></path>
<path d="M5.939,5.013l0,-1.829l-1.523,0l0,1.829l-0.732,0l0,-4.266l0.732,0l0,1.699l1.523,0l0,-1.699l0.733,0l0,4.266l-0.733,0Z"></path>
<path d="M8.933,1.437l0,3.576l-0.732,0l0,-3.576l-1.13,0l0,-0.69l2.994,0l0,0.69l-1.132,0Z"></path>
</svg>
</span>
</span>`);
document.getElementById("dht-userscript-trigger").addEventListener("click", start);
}
}, 200);

View File

@@ -1,312 +0,0 @@
var DISCORD = (function(){
var getMessageOuterElement = function(){
return DOM.queryReactClass("messagesWrapper");
};
var getMessageScrollerElement = function(){
return getMessageOuterElement().querySelector("[class*='scroller-']");
};
var getMessageElements = function() {
return getMessageOuterElement().querySelectorAll("[class*='message-']");
};
var getReactProps = function(ele) {
var keys = Object.keys(ele || {});
var key = keys.find(key => key.startsWith("__reactInternalInstance"));
if (key){
return ele[key].memoizedProps;
}
key = keys.find(key => key.startsWith("__reactProps$"));
return key ? ele[key] : null;
};
var getMessageElementProps = function(ele) {
const props = getReactProps(ele);
if (props.children && props.children.length >= 4) {
const childProps = props.children[3].props;
if ("message" in childProps && "channel" in childProps) {
return childProps;
}
}
return null;
};
var hasMoreMessages = function() {
return document.querySelector("#messagesNavigationDescription + [class^=container]") === null;
};
var getMessages = function() {
try {
const messages = [];
for (const ele of getMessageElements()) {
const props = getMessageElementProps(ele);
if (props != null) {
messages.push(props.message);
}
}
return messages;
} catch (e) {
console.error(e);
return [];
}
};
return {
/**
* Calls the provided function with a list of messages whenever the currently loaded messages change,
* or with `false` if there are no more messages.
*/
setupMessageCallback: function(callback) {
let skipsLeft = 0;
let waitForCleanup = false;
let hasReachedStart = false;
const previousMessages = new Set();
const intervalId = window.setInterval(() => {
if (skipsLeft > 0) {
--skipsLeft;
return;
}
const view = getMessageOuterElement();
if (!view) {
skipsLeft = 2;
return;
}
const anyMessage = DOM.queryReactClass("message", getMessageOuterElement());
const messageCount = anyMessage ? anyMessage.parentElement.children.length : 0;
if (messageCount > 300) {
if (waitForCleanup) {
return;
}
skipsLeft = 3;
waitForCleanup = true;
window.setTimeout(() => {
const view = getMessageScrollerElement();
view.scrollTop = view.scrollHeight / 2;
}, 1);
}
else {
waitForCleanup = false;
}
const messages = getMessages();
let hasChanged = false;
for (const message of messages) {
if (!previousMessages.has(message.id)) {
hasChanged = true;
break;
}
}
if (!hasChanged) {
if (!hasReachedStart && !hasMoreMessages()) {
hasReachedStart = true;
callback(false);
}
return;
}
previousMessages.clear();
for (const message of messages) {
previousMessages.add(message.id);
}
hasReachedStart = false;
callback(messages);
}, 200);
window.DHT_ON_UNLOAD.push(() => window.clearInterval(intervalId));
},
/*
* Returns internal React state object of an element.
*/
getReactProps: function(ele){
return getReactProps(ele);
},
/*
* Returns an object containing the selected server name, selected channel name and ID, and the object type.
* For types DM and GROUP, the server and channel names are identical.
* For SERVER type, the channel has to be in view, otherwise Discord unloads it.
*/
getSelectedChannel: function() {
try {
let obj;
for (const ele of getMessageElements()) {
const props = getMessageElementProps(ele);
if (props != null) {
obj = props.channel;
break;
}
}
if (!obj) {
return null;
}
var dms = DOM.queryReactClass("privateChannels");
if (dms){
let name;
for (const ele of dms.querySelectorAll("[class*='channel-'] [class*='selected-'] [class^='name-'] *, [class*='channel-'][class*='selected-'] [class^='name-'] *")) {
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
if (node) {
name = node.nodeValue;
break;
}
}
if (!name) {
return null;
}
let type;
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
switch (obj.type) {
case 1: type = "DM"; break;
case 3: type = "GROUP"; break;
default: return null;
}
return {
"server": name,
"channel": name,
"id": obj.id,
"type": type,
"extra": {}
};
}
else if (obj.guild_id) {
return {
"server": document.querySelector("nav header > h1").innerText,
"channel": obj.name,
"id": obj.id,
"type": "SERVER",
"extra": {
"position": obj.position,
"topic": obj.topic,
"nsfw": obj.nsfw
}
};
}
else {
return null;
}
} catch(e) {
console.error(e);
return null;
}
},
/*
* Returns an array containing currently loaded messages.
*/
getMessages: function(){
return getMessages();
},
/*
* Returns true if the message view is visible.
*/
isInMessageView: () => !!getMessageOuterElement(),
/*
* Returns true if there are more messages available or if they're still loading.
*/
hasMoreMessages: function(){
return hasMoreMessages();
},
/*
* Forces the message view to load older messages by scrolling all the way up.
*/
loadOlderMessages: function(){
let view = getMessageScrollerElement();
if (view.scrollTop > 0){
view.scrollTop = 0;
}
},
/*
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
*/
selectNextTextChannel: function(){
var dms = DOM.queryReactClass("privateChannels");
if (dms){
var currentChannel = DOM.queryReactClass("selected", dms);
var 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{
var 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";
var 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";
var isValidChannelClass = cls => cls.includes("wrapper-") && !cls.includes("clickable-");
var isValidChannelType = ele => !!ele.querySelector('path[d="' + channelIconNormal + '"]') || !!ele.querySelector('path[d="' + channelIconSpecial + '"]');
var isValidChannel = ele => ele.childElementCount > 0 && isValidChannelClass(ele.children[0].className) && isValidChannelType(ele);
var channelListEle = document.querySelector("div[class*='sidebar'] > nav[class*='container'] > div[class*='scroller']");
if (!channelListEle){
return false;
}
var allChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), isValidChannel);
var nextChannel = null;
for(var 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;
}
}
};
})();

View File

@@ -1,85 +0,0 @@
var DOM = (function(){
var createElement = (tag, parent, id, html) => {
var ele = document.createElement(tag);
ele.id = id || "";
ele.innerHTML = html || "";
parent.appendChild(ele);
return ele;
};
return {
/*
* Returns a child element by its ID. Parent defaults to the entire document.
*/
id: (id, parent) => (parent || document).getElementById(id),
/*
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
*/
queryReactClass: (cls, parent) => (parent || document).querySelector(`[class*="${cls}-"]`),
/*
* Creates an element, adds it to the DOM, and returns it.
*/
createElement: (tag, parent, id, html) => createElement(tag, parent, id, html),
/*
* Removes an element from the DOM.
*/
removeElement: (ele) => ele.parentNode.removeChild(ele),
/*
* Creates a new style element with the specified CSS and returns it.
*/
createStyle: (styles) => createElement("style", document.head, "", styles),
/*
* Convenience setTimeout function to save space after minification.
*/
setTimer: (callback, timeout) => window.setTimeout(callback, timeout),
/*
* Convenience addEventListener function to save space after minification.
*/
listen: (ele, event, callback) => ele.addEventListener(event, callback),
/*
* Utility function to save an object into a cookie.
*/
saveToCookie: (name, obj, expiresInSeconds) => {
var 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.
*/
loadFromCookie: (name) => {
var value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)"+name+"\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
return value.length ? JSON.parse(decodeURIComponent(value)) : null;
},
/*
* Triggers a UTF-8 text file download.
*/
downloadTextFile: (fileName, fileContents) => {
var blob = new Blob([fileContents], { "type": "octet/stream" });
if ("msSaveBlob" in window.navigator){
return window.navigator.msSaveBlob(blob, fileName);
}
var url = window.URL.createObjectURL(blob);
var ele = createElement("a", document.body);
ele.href = url;
ele.download = fileName;
ele.style.display = "none";
ele.click();
document.body.removeChild(ele);
window.URL.revokeObjectURL(url);
}
};
})();

View File

@@ -1,277 +0,0 @@
var GUI = (function(){
var controller;
var settings;
var updateButtonState = () => {
if (STATE.isTracking()){
controller.ui.btnUpload.disabled = true;
controller.ui.btnSettings.disabled = true;
controller.ui.btnReset.disabled = true;
}
else{
controller.ui.btnUpload.disabled = false;
controller.ui.btnSettings.disabled = false;
controller.ui.btnDownload.disabled = controller.ui.btnReset.disabled = !STATE.hasSavedData();
}
};
var stateChangedEvent = (type, detail) => {
if (controller){
var force = type === "gui" && detail === "controller";
if (type === "data" || force){
updateButtonState();
}
if (type === "tracking" || force){
updateButtonState();
controller.ui.btnToggleTracking.innerHTML = STATE.isTracking() ? "Pause Tracking" : "Start Tracking";
}
if (type === "data" || force){
var messageCount = 0;
var channelCount = 0;
if (STATE.hasSavedData()){
messageCount = STATE.getSavefile().countMessages();
channelCount = STATE.getSavefile().countChannels();
}
controller.ui.textStatus.innerHTML = [
messageCount, " message", (messageCount === 1 ? "" : "s"),
" from ",
channelCount, " channel", (channelCount === 1 ? "" : "s")
].join("");
}
}
if (settings){
var force = type === "gui" && detail === "settings";
if (force){
settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll;
settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true;
settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true;
}
if (type === "setting" || force){
var autoscrollRev = !SETTINGS.autoscroll;
// discord polyfills Object.values
Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollRev);
Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollRev);
}
}
};
var registeredEvent = false;
var setupStateChanged = function(detail){
if (!registeredEvent){
STATE.onStateChanged(stateChangedEvent);
SETTINGS.onSettingsChanged(stateChangedEvent);
registeredEvent = true;
}
stateChangedEvent("gui", detail);
};
var root = {
showController: function(){
controller = {};
// styles
controller.styles = DOM.createStyle(`
#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-close { margin: 8px 8px 8px 0 !important; float: right; }
#dht-ctrl p { display: inline-block; margin: 14px 12px; }
#dht-ctrl input { display: none; }`);
// main
var btn = (id, title) => "<button id='dht-ctrl-"+id+"'>"+title+"</button>";
controller.ele = DOM.createElement("div", document.body, "dht-ctrl", `
${btn("upload", "Upload &amp; Combine")}
${btn("settings", "Settings")}
${btn("track", "")}
${btn("download", "Download")}
${btn("reset", "Reset")}
<p id='dht-ctrl-status'></p>
<input id='dht-ctrl-upload-input' type='file' multiple>
${btn("close", "X")}`);
// elements
controller.ui = {
btnUpload: DOM.id("dht-ctrl-upload"),
btnSettings: DOM.id("dht-ctrl-settings"),
btnToggleTracking: DOM.id("dht-ctrl-track"),
btnDownload: DOM.id("dht-ctrl-download"),
btnReset: DOM.id("dht-ctrl-reset"),
btnClose: DOM.id("dht-ctrl-close"),
textStatus: DOM.id("dht-ctrl-status"),
inputUpload: DOM.id("dht-ctrl-upload-input")
};
// events
DOM.listen(controller.ui.btnUpload, "click", () => {
controller.ui.inputUpload.click();
});
DOM.listen(controller.ui.btnSettings, "click", () => {
root.showSettings();
});
DOM.listen(controller.ui.btnToggleTracking, "click", () => {
STATE.setIsTracking(!STATE.isTracking());
});
DOM.listen(controller.ui.btnDownload, "click", () => {
STATE.downloadSavefile();
});
DOM.listen(controller.ui.btnReset, "click", () => {
STATE.resetState();
});
DOM.listen(controller.ui.btnClose, "click", () => {
root.hideController();
window.DHT_ON_UNLOAD.forEach(f => f());
window.DHT_LOADED = false;
});
DOM.listen(controller.ui.inputUpload, "change", () => {
Array.prototype.forEach.call(controller.ui.inputUpload.files, file => {
var reader = new FileReader();
reader.onload = function(){
var obj = {};
try{
obj = JSON.parse(reader.result);
}catch(e){
alert("Could not parse '"+file.name+"', see console for details.");
console.error(e);
return;
}
if (SAVEFILE.isValid(obj)){
STATE.uploadSavefile(file.name, new SAVEFILE(obj));
}
else{
alert("File '"+file.name+"' has an invalid format.");
}
};
reader.readAsText(file, "UTF-8");
});
controller.ui.inputUpload.value = null;
});
setupStateChanged("controller");
},
hideController: function(){
if (controller){
DOM.removeElement(controller.ele);
DOM.removeElement(controller.styles);
controller = null;
}
},
showSettings: function(){
settings = {};
// styles
settings.styles = DOM.createStyle(`
#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; }
#dht-cfg sub { color: #666; font-size: 13px; }`);
// overlay
settings.overlay = DOM.createElement("div", document.body, "dht-cfg-overlay");
DOM.listen(settings.overlay, "click", () => {
root.hideSettings();
});
// main
var radio = (type, id, label) => "<label><input id='dht-cfg-"+type+"-"+id+"' name='dht-"+type+"' type='radio'> "+label+"</label><br>";
settings.ele = DOM.createElement("div", document.body, "dht-cfg", `
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
<br>
<label>After reaching the first message in channel...</label><br>
${radio("afm", "nothing", "Do Nothing")}
${radio("afm", "pause", "Pause Tracking")}
${radio("afm", "switch", "Switch to Next Channel")}
<br>
<label>After reaching a previously saved message...</label><br>
${radio("asm", "nothing", "Do Nothing")}
${radio("asm", "pause", "Pause Tracking")}
${radio("asm", "switch", "Switch to Next Channel")}
<p id='dht-cfg-note'>
It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.<br><br>
<sub>{{{version:full}}}</sub>
</p>`);
// elements
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");
// events
settings.ui.cbAutoscroll.addEventListener("change", () => {
SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked;
});
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
DOM.listen(settings.ui.optsAfterFirstMsg[key], "click", () => {
SETTINGS.afterFirstMsg = key;
});
});
Object.keys(settings.ui.optsAfterSavedMsg).forEach(key => {
DOM.listen(settings.ui.optsAfterSavedMsg[key], "click", () => {
SETTINGS.afterSavedMsg = key;
});
});
setupStateChanged("settings");
},
hideSettings: function(){
if (settings){
DOM.removeElement(settings.overlay);
DOM.removeElement(settings.ele);
DOM.removeElement(settings.styles);
settings = null;
}
}
};
return root;
})();

View File

@@ -1,349 +0,0 @@
/*
* SAVEFILE STRUCTURE
* ==================
*
* {
* meta: {
* users: {
* <discord user id>: {
* name: <user name>,
* avatar: <user icon>,
* tag: <user discriminator> // only present if not a bot
* }, ...
* },
*
* // the user index is an array of discord user ids,
* // these indexes are used in the message objects to save space
* userindex: [
* <discord user id>, ...
* ],
*
* servers: [
* {
* name: <server name>,
* type: <"SERVER"|"GROUP"|DM">
* }, ...
* ],
*
* channels: {
* <discord channel id>: {
* server: <server index in the meta.servers array>,
* name: <channel name>,
* position: <order in channel list>, // only present if server type == SERVER
* topic: <channel topic>, // only present if server type == SERVER
* nsfw: <channel NSFW status> // only present if server type == SERVER
* }, ...
* }
* },
*
* data: {
* <discord channel id>: {
* <discord message id>: {
* u: <user index of the sender>,
* t: <message timestamp>,
* m: <message content>, // only present if not empty
* f: <message flags>, // only present if edited in which case it equals 1, deprecated (use 'te' instead)
* te: <edit timestamp>, // only present if edited
* e: [ // omit for no embeds
* {
* url: <embed url>,
* type: <embed type>,
* t: <rich embed title>, // only present if type == rich, and if not empty
* d: <rich embed description> // only present if type == rich, and if the embed has a simple description text
* }, ...
* ],
* a: [ // omit for no attachments
* {
* url: <attachment url>
* }, ...
* ],
* r: <reply message id>, // only present if referencing another message (reply)
* re: [ // omit for no reactions
* {
* c: <react count>
* n: <emoji name>,
* id: <emoji id>, // only present for custom emoji
* an: <emoji is animated>, // only present for custom animated emoji
* }, ...
* ]
* }, ...
* }, ...
* }
* }
*
*
* TEMPORARY OBJECT STRUCTURE
* ==========================
*
* {
* userlookup: {
* <discord user id>: <user index in the meta.userindex array>
* },
* channelkeys: Set<channel id>,
* messagekeys: Set<message id>,
* freshmsgs: Set<message id> // only messages which were newly added to the savefile in the current session
* }
*/
class SAVEFILE{
constructor(parsedObj){
var me = this;
if (!SAVEFILE.isValid(parsedObj)){
parsedObj = {
meta: {},
data: {}
};
}
me.meta = parsedObj.meta;
me.data = parsedObj.data;
me.meta.users = me.meta.users || {};
me.meta.userindex = me.meta.userindex || [];
me.meta.servers = me.meta.servers || [];
me.meta.channels = me.meta.channels || {};
me.tmp = {
userlookup: {},
channelkeys: new Set(),
messagekeys: new Set(),
freshmsgs: new Set()
};
}
static isValid(parsedObj){
return parsedObj && typeof parsedObj.meta === "object" && typeof parsedObj.data === "object";
}
findOrRegisterUser(userId, userName, userDiscriminator, userAvatar){
var wasPresent = userId in this.meta.users;
var userObj = wasPresent ? this.meta.users[userId] : {};
userObj.name = userName;
if (userDiscriminator){
userObj.tag = userDiscriminator;
}
if (userAvatar){
userObj.avatar = userAvatar;
}
if (!wasPresent){
this.meta.users[userId] = userObj;
this.meta.userindex.push(userId);
return this.tmp.userlookup[userId] = this.meta.userindex.length-1;
}
else if (!(userId in this.tmp.userlookup)){
return this.tmp.userlookup[userId] = this.meta.userindex.findIndex(id => id == userId);
}
else{
return this.tmp.userlookup[userId];
}
}
findOrRegisterServer(serverName, serverType){
var index = this.meta.servers.findIndex(server => server.name === serverName && server.type === serverType);
if (index === -1){
this.meta.servers.push({
"name": serverName,
"type": serverType
});
return this.meta.servers.length-1;
}
else{
return index;
}
}
tryRegisterChannel(serverIndex, channelId, channelName, extraInfo){
if (!this.meta.servers[serverIndex]){
return undefined;
}
var wasPresent = channelId in this.meta.channels;
var channelObj = wasPresent ? this.meta.channels[channelId] : { "server": serverIndex };
channelObj.name = channelName;
if (extraInfo.position){
channelObj.position = extraInfo.position;
}
if (extraInfo.topic){
channelObj.topic = extraInfo.topic;
}
if (extraInfo.nsfw){
channelObj.nsfw = extraInfo.nsfw;
}
if (wasPresent){
return false;
}
else{
this.meta.channels[channelId] = channelObj;
this.tmp.channelkeys.add(channelId);
return true;
}
}
addMessage(channelId, messageId, messageObject){
var container = this.data[channelId] || (this.data[channelId] = {});
var wasPresent = messageId in container;
container[messageId] = messageObject;
this.tmp.messagekeys.add(messageId);
return !wasPresent;
}
convertToMessageObject(discordMessage){
var author = discordMessage.author;
var obj = {
u: this.findOrRegisterUser(author.id, author.username, author.bot ? null : author.discriminator, author.avatar),
t: discordMessage.timestamp.toDate().getTime()
};
if (discordMessage.content.length > 0){
obj.m = discordMessage.content;
}
if (discordMessage.editedTimestamp !== null){
obj.te = discordMessage.editedTimestamp.toDate().getTime();
}
if (discordMessage.embeds.length > 0){
obj.e = discordMessage.embeds.map(embed => {
let conv = {
url: embed.url,
type: embed.type
};
if (embed.type === "rich"){
if (Array.isArray(embed.title) && embed.title.length === 1 && typeof embed.title[0] === "string"){
conv.t = embed.title[0];
if (Array.isArray(embed.description) && embed.description.length === 1 && typeof embed.description[0] === "string"){
conv.d = embed.description[0];
}
}
}
return conv;
});
}
if (discordMessage.attachments.length > 0){
obj.a = discordMessage.attachments.map(attachment => ({
url: attachment.url
}));
}
if (discordMessage.messageReference !== null){
obj.r = discordMessage.messageReference.message_id;
}
if (discordMessage.reactions.length > 0) {
obj.re = discordMessage.reactions.map(reaction => {
let conv = {
c: reaction.count,
n: reaction.emoji.name
};
if (reaction.emoji.id !== null) {
conv.id = reaction.emoji.id;
}
if (reaction.emoji.animated) {
conv.an = true;
}
return conv;
});
}
return obj;
}
isMessageFresh(id){
return this.tmp.freshmsgs.has(id);
}
addMessagesFromDiscord(channelId, discordMessageArray){
var hasNewMessages = false;
for(var discordMessage of discordMessageArray){
var type = discordMessage.type;
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
if ((type === 0 || type === 19) && discordMessage.state === "SENT" && this.addMessage(channelId, discordMessage.id, this.convertToMessageObject(discordMessage))){
this.tmp.freshmsgs.add(discordMessage.id);
hasNewMessages = true;
}
}
return hasNewMessages;
}
countChannels(){
return this.tmp.channelkeys.size;
}
countMessages(){
return this.tmp.messagekeys.size;
}
combineWith(obj){
var userMap = {};
var shownError = false;
for(var userId in obj.meta.users){
var oldUser = obj.meta.users[userId];
userMap[obj.meta.userindex.findIndex(id => id == userId)] = this.findOrRegisterUser(userId, oldUser.name, oldUser.tag, oldUser.avatar);
}
for(var channelId in obj.meta.channels){
var oldServer = obj.meta.servers[obj.meta.channels[channelId].server];
var oldChannel = obj.meta.channels[channelId];
this.tryRegisterChannel(this.findOrRegisterServer(oldServer.name, oldServer.type), channelId, oldChannel.name, oldChannel /* filtered later */);
}
for(var channelId in obj.data){
var oldChannel = obj.data[channelId];
for(var messageId in oldChannel){
var oldMessage = oldChannel[messageId];
var oldUser = oldMessage.u;
if (oldUser in userMap){
oldMessage.u = userMap[oldUser];
this.addMessage(channelId, messageId, oldMessage);
}
else{
if (!shownError){
shownError = true;
alert("The uploaded archive appears to be corrupted, some messages will be skipped. See console for details.");
console.error("User list:", obj.meta.users);
console.error("User index:", obj.meta.userindex);
console.error("Generated mapping:", userMap);
console.error("Missing user for the following messages:");
}
console.error(oldMessage);
}
}
}
}
toJson(){
return JSON.stringify({
"meta": this.meta,
"data": this.data
});
}
}

View File

@@ -1,64 +0,0 @@
var CONSTANTS = {
AUTOSCROLL_ACTION_NOTHING: "optNothing",
AUTOSCROLL_ACTION_PAUSE: "optPause",
AUTOSCROLL_ACTION_SWITCH: "optSwitch"
};
var IS_FIRST_RUN = false;
var SETTINGS = (function(){
var root = {};
var settingsChangedEvents = [];
var saveSettings = function(){
DOM.saveToCookie("DHT_SETTINGS", root, 60*60*24*365*5);
};
var triggerSettingsChanged = function(changeType, changeDetail){
for(var callback of settingsChangedEvents){
callback(changeType, changeDetail);
}
saveSettings();
};
var defineTriggeringProperty = function(obj, property, value){
var name = "_"+property;
Object.defineProperty(obj, property, {
get: (() => obj[name]),
set: (value => {
obj[name] = value;
triggerSettingsChanged("setting", property);
})
});
obj[name] = value;
};
var loaded = DOM.loadFromCookie("DHT_SETTINGS");
if (!loaded){
loaded = {
"_autoscroll": true,
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE
};
IS_FIRST_RUN = true;
}
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
root.onSettingsChanged = function(callback){
settingsChangedEvents.push(callback);
};
if (IS_FIRST_RUN){
saveSettings();
}
return root;
})();

View File

@@ -1,119 +0,0 @@
var STATE = (function(){
var stateChangedEvents = [];
var triggerStateChanged = function(changeType, changeDetail){
for(var callback of stateChangedEvents){
callback(changeType, changeDetail);
}
};
/*
* Internal class constructor.
*/
class CLS{
constructor(){
this.resetState();
};
/*
* Resets the state to default values.
*/
resetState(){
this._savefile = null;
this._isTracking = false;
this._lastFileName = null;
triggerStateChanged("data", "reset");
}
/*
* Returns the savefile object, creates a new one if needed.
*/
getSavefile(){
if (!this._savefile){
this._savefile = new SAVEFILE();
}
return this._savefile;
}
/*
* Returns true if the database file contains any data.
*/
hasSavedData(){
return this._savefile != null;
}
/*
* Returns true if currently tracking message.
*/
isTracking(){
return this._isTracking;
}
/*
* Sets the tracking state.
*/
setIsTracking(state){
this._isTracking = state;
triggerStateChanged("tracking", state);
}
/*
* Combines current savefile with the provided one.
*/
uploadSavefile(fileName, fileObject){
this._lastFileName = fileName;
this.getSavefile().combineWith(fileObject);
triggerStateChanged("data", "upload");
}
/*
* Triggers a savefile download, if available.
*/
downloadSavefile(){
if (this.hasSavedData()){
DOM.downloadTextFile(this._lastFileName || "dht.txt", this._savefile.toJson());
}
}
/*
* Registers a Discord server and channel.
*/
addDiscordChannel(serverName, serverType, channelId, channelName, extraInfo){
var serverIndex = this.getSavefile().findOrRegisterServer(serverName, serverType);
if (this.getSavefile().tryRegisterChannel(serverIndex, channelId, channelName, extraInfo) === true){
triggerStateChanged("data", "channel");
}
}
/*
* Adds all messages from the array to the specified channel. Returns true if the savefile was updated.
*/
addDiscordMessages(channelId, discordMessageArray){
if (this.getSavefile().addMessagesFromDiscord(channelId, discordMessageArray)){
triggerStateChanged("data", "messages");
return true;
}
else{
return false;
}
}
/*
* Returns true if the message was added during this session.
*/
isMessageFresh(id){
return this.getSavefile().isMessageFresh(id);
}
/*
* Adds a listener that is called whenever the state changes. The callback is a function that takes subject (generic type) and detail (specific type or data).
*/
onStateChanged(callback){
stateChangedEvents.push(callback);
}
}
return new CLS();
})();

View File

@@ -1,136 +0,0 @@
const url = window.location.href;
if (!url.includes("discord.com/") && !url.includes("discordapp.com/") && !confirm("Could not detect Discord in the URL, do you want to run the script anyway?")){
return;
}
if (window.DHT_LOADED){
alert("Discord History Tracker is already loaded.");
return;
}
window.DHT_LOADED = true;
window.DHT_ON_UNLOAD = [];
// Execution
let ignoreMessageCallback = new Set();
let frozenMessageLoadingTimer = null;
let stopTrackingDelayed = function(callback){
ignoreMessageCallback.add("stopping");
DOM.setTimer(() => {
STATE.setIsTracking(false);
ignoreMessageCallback.delete("stopping");
if (callback){
callback();
}
}, 200); // give the user visual feedback after clicking the button before switching off
};
DISCORD.setupMessageCallback(messages => {
if (STATE.isTracking() && ignoreMessageCallback.size === 0){
let info = DISCORD.getSelectedChannel();
if (!info){
stopTrackingDelayed();
return;
}
STATE.addDiscordChannel(info.server, info.type, info.id, info.channel, info.extra);
if (messages !== false && !messages.length){
DISCORD.loadOlderMessages();
return;
}
let hasUpdatedFile = messages !== false && STATE.addDiscordMessages(info.id, messages);
if (SETTINGS.autoscroll){
let action = null;
if (messages === false) {
action = SETTINGS.afterFirstMsg;
}
else if (!hasUpdatedFile && !STATE.isMessageFresh(messages[0].id)){
action = SETTINGS.afterSavedMsg;
}
if (action === null){
if (hasUpdatedFile){
DISCORD.loadOlderMessages();
window.clearTimeout(frozenMessageLoadingTimer);
frozenMessageLoadingTimer = null;
}
else{
frozenMessageLoadingTimer = window.setTimeout(DISCORD.loadOlderMessages, 2500);
}
}
else{
ignoreMessageCallback.add("stalling");
DOM.setTimer(() => {
ignoreMessageCallback.delete("stalling");
let updatedInfo = DISCORD.getSelectedChannel();
if (updatedInfo && updatedInfo.id === info.id){
let lastMessages = DISCORD.getMessages(); // sometimes needed to catch the last few messages before switching
if (lastMessages != null){
STATE.addDiscordMessages(info.id, lastMessages);
}
}
if ((action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel()) || action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE){
STATE.setIsTracking(false);
}
}, 250);
}
}
}
});
STATE.onStateChanged((type, enabled) => {
if (type === "tracking" && enabled){
let info = DISCORD.getSelectedChannel();
if (info){
let messages = DISCORD.getMessages();
if (messages != null){
STATE.addDiscordChannel(info.server, info.type, info.id, info.channel, info.extra);
STATE.addDiscordMessages(info.id, messages);
}
else{
stopTrackingDelayed(() => alert("Cannot see any messages."));
return;
}
}
else{
stopTrackingDelayed(() => alert("The selected channel is not visible in the channel list."));
return;
}
if (SETTINGS.autoscroll && DISCORD.isInMessageView()){
if (DISCORD.hasMoreMessages()){
DISCORD.loadOlderMessages();
}
else{
let action = SETTINGS.afterFirstMsg;
if ((action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel()) || action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE){
stopTrackingDelayed();
}
}
}
}
});
GUI.showController();
if (IS_FIRST_RUN){
GUI.showSettings();
}

View File

@@ -1,85 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Discord Offline History</title>
<script type="text/javascript">
/*{js}*/
</script>
<style type="text/css">
/*{css}*/
</style>
</head>
<body>
<div id="menu">
<input id="uploaded-file" type="file" style="display:none">
<button id="btn-upload-file" class="hide-embedded">Load File</button>
<div class="splitter hide-embedded"></div>
<button id="btn-settings">Settings</button>
<div> <!-- needed to stop the select from messing up -->
<select id="opt-messages-per-page">
<option value="50">50 messages per page&nbsp;</option>
<option value="100">100 messages per page&nbsp;</option>
<option value="250">250 messages per page&nbsp;</option>
<option value="500">500 messages per page&nbsp;</option>
<option value="1000">1000 messages per page&nbsp;</option>
<option value="0">All messages&nbsp;</option>
</select>
</div>
<div class="nav">
<button id="nav-first" data-nav="first" class="icon">&laquo;</button>
<button id="nav-prev" data-nav="prev" class="icon">&lsaquo;</button>
<button id="nav-pick" data-nav="pick">Page <span id="nav-page-current">1</span>/<span id="nav-page-total">?</span></button>
<button id="nav-next" data-nav="next" class="icon">&rsaquo;</button>
<button id="nav-last" data-nav="last" class="icon">&raquo;</button>
</div>
<div class="splitter"></div>
<div> <!-- needed to stop the select from messing up -->
<select id="opt-messages-filter">
<option value="">No filter&nbsp;</option>
<option value="user">Filter messages by user&nbsp;</option>
<option value="contents">Filter messages by contents&nbsp;</option>
<option value="withimages">Only messages with images&nbsp;</option>
<option value="withdownloads">Only messages with downloads&nbsp;</option>
<option value="edited">Only edited messages&nbsp;</option>
</select>
</div>
<div id="opt-filter-list">
<select id="opt-filter-user" data-filter-type="user">
<option value="">Select user...</option>
</select>
<input id="opt-filter-contents" type="text" data-filter-type="contents" placeholder="Messages containing...">
<input type="hidden" data-filter-type="withimages" value="1">
<input type="hidden" data-filter-type="withdownloads" value="1">
<input type="hidden" data-filter-type="edited" value="1">
</div>
<div id="opt-save-filtered">
<div class="splitter"></div>
<button id="btn-save-filtered">Save Filtered Messages</button>
</div>
<div class="separator"></div>
<button id="btn-about">About</button>
</div>
<div id="app">
<div id="channels"></div>
<div id="messages"></div>
</div>
<div id="modal">
<div id="overlay"></div>
<div id="dialog"></div>
</div>
</body>
</html>

View File

@@ -1,246 +0,0 @@
var DISCORD = (function(){
var 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: /&lt;@&(\d+?)&gt;/g,
mentionUser: /&lt;@!?(\d+?)&gt;/g,
mentionChannel: /&lt;#(\d+?)&gt;/g,
customEmojiStatic: /&lt;:([^:]+):(\d+?)&gt;/g,
customEmojiAnimated: /&lt;a:([^:]+):(\d+?)&gt;/g
};
var isImageAttachment = function(attachment){
var dot = attachment.url.lastIndexOf(".");
var ext = dot === -1 ? "" : attachment.url.substring(dot).toLowerCase();
return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg";
};
var getHumanReadableTime = function(timestamp){
var date = new Date(timestamp);
return date.toLocaleDateString() + ", " + date.toLocaleTimeString();
};
var templateChannelServer;
var templateChannelPrivate;
var templateMessageNoAvatar;
var templateMessageWithAvatar;
var templateUserAvatar;
var templateEmbedImage;
var templateEmbedRich;
var templateEmbedRichNoDescription;
var templateEmbedRichUnsupported;
var templateEmbedDownload;
var processMessageContents = function(contents){
var processed = DOM.escapeHTML(contents.replace(REGEX.formatUrlNoEmbed, "$1"));
if (STATE.settings.enableFormatting){
var escapeHtmlMatch = (full, match) => "&#"+match.charCodeAt(0)+";";
processed = processed
.replace(REGEX.specialEscapedBacktick, "&#96;")
.replace(REGEX.formatCodeBlock, (full, ignore, match) => "<code class='block'>"+match.replace(REGEX.specialUnescaped, escapeHtmlMatch)+"</code>")
.replace(REGEX.formatCodeInline, (full, ignore, match) => "<code class='inline'>"+match.replace(REGEX.specialUnescaped, escapeHtmlMatch)+"</code>")
.replace(REGEX.specialEscapedSingle, escapeHtmlMatch)
.replace(REGEX.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch))
.replace(REGEX.formatBold, "<b>$1</b>")
.replace(REGEX.formatItalic, (full, pre, match) => pre === '\\' ? full : (pre || "")+"<i>"+match+"</i>")
.replace(REGEX.formatUnderline, "<u>$1</u>")
.replace(REGEX.formatStrike, "<s>$1</s>");
}
var animatedEmojiExtension = STATE.settings.enableAnimatedEmoji ? "gif" : "png";
processed = processed
.replace(REGEX.formatUrl, "<a href='$1' target='_blank' rel='noreferrer'>$1</a>")
.replace(REGEX.mentionChannel, (full, match) => "<span class='link mention-chat'>#"+STATE.getChannelName(match)+"</span>")
.replace(REGEX.mentionUser, (full, match) => "<span class='link mention-user' title='#"+(STATE.getUserTag(match) || "????")+"'>@"+STATE.getUserName(match)+"</span>")
.replace(REGEX.customEmojiStatic, "<img src='https://cdn.discordapp.com/emojis/$2.png' alt=':$1:' title=':$1:' class='emoji'>")
.replace(REGEX.customEmojiAnimated, "<img src='https://cdn.discordapp.com/emojis/$2."+animatedEmojiExtension+"' alt=':$1:' title=':$1:' class='emoji'>");
return "<p>"+processed+"</p>";
};
return {
setup: function(){
templateChannelServer = new TEMPLATE([
"<div data-channel='{id}'>",
"<div class='info' title='{topic}'><strong class='name'>#{name}</strong>{nsfw}<span class='tag'>{msgcount}</span></div>",
"<span class='server'>{server.name} ({server.type})</span>",
"</div>"
].join(""));
templateChannelPrivate = new TEMPLATE([
"<div data-channel='{id}'>",
"<div class='info'><strong class='name'>{name}</strong><span class='tag'>{msgcount}</span></div>",
"<span class='server'>({server.type})</span>",
"</div>"
].join(""));
templateMessageNoAvatar = new TEMPLATE([
"<div>",
"<div class='reply-message'>{reply}</div>",
"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
"<div class='message'>{contents}{embeds}{attachments}</div>",
"<div class='reactions'>{reactions}</div>",
"</div>"
].join(""));
templateMessageWithAvatar = new TEMPLATE([
"<div>",
"<div class='reply-message reply-message-with-avatar'>{reply}</div>",
"<div class='avatar-wrapper'>",
"<div class='avatar'>{avatar}</div>",
"<div>",
"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
"<div class='message'>{contents}{embeds}{attachments}</div>",
"<div class='reactions'>{reactions}</div>",
"</div>",
"</div>",
"</div>"
].join(""));
templateUserAvatar = new TEMPLATE([
"<img src='https://cdn.discordapp.com/avatars/{id}/{path}.webp?size=128'>"
].join(""));
templateEmbedImage = new TEMPLATE([
"<a href='{url}' class='embed thumbnail'><img src='{url}' alt='(image attachment not found)'></a><br>"
].join(""));
templateEmbedRich = new TEMPLATE([
"<div class='embed download'><a href='{url}' class='title'>{t}</a><p class='desc'>{d}</p></div>"
].join(""));
templateEmbedRichNoDescription = new TEMPLATE([
"<div class='embed download'><a href='{url}' class='title'>{t}</a></div>"
].join(""));
templateEmbedRichUnsupported = new TEMPLATE([
"<div class='embed download'><p>(Formatted embeds are currently not supported)</p></div>"
].join(""));
templateEmbedDownload = new TEMPLATE([
"<a href='{url}' class='embed download'>Download {filename}</a>"
].join(""));
templateReaction = new TEMPLATE([
"<span class='reaction-wrapper'><span class='reaction-emoji'>{n}</span><span class='count'>{c}</span></span>"
].join(""));
templateReactionCustom = new TEMPLATE([
"<span class='reaction-wrapper'><img src='https://cdn.discordapp.com/emojis/{id}.{ext}' alt=':{n}:' title=':{n}:' class='reaction-emoji-custom'><span class='count'>{c}</span></span>"
].join(""));
},
isImageAttachment: isImageAttachment,
getChannelHTML: function(channel){
return (channel.server.type === "SERVER" ? templateChannelServer : templateChannelPrivate).apply(channel, (property, value) => {
if (property === "server.type"){
switch(value){
case "SERVER": return "server";
case "GROUP": return "group";
case "DM": return "user";
}
}
else if (property === "nsfw"){
return value ? "<span class='tag'>NSFW</span>" : "";
}
});
},
getMessageHTML: function(message){
return (STATE.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 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 => {
switch(embed.type){
case "image":
return STATE.settings.enableImagePreviews ? templateEmbedImage.apply(embed) : "";
case "rich":
return (embed.t ? (embed.d ? templateEmbedRich : templateEmbedRichNoDescription) : templateEmbedRichUnsupported).apply(embed);
}
}).join("");
}
else if (property === "attachments"){
if (!value){
return "";
}
return value.map(attachment => {
if (isImageAttachment(attachment) && STATE.settings.enableImagePreviews){
return templateEmbedImage.apply(attachment);
}
else{
var sliced = attachment.url.split("/");
return templateEmbedDownload.apply({
"url": attachment.url,
"filename": sliced[sliced.length-1]
});
}
}).join("");
}
else if (property === "edit"){
return value ? "<span class='info edited'>Edited" + (value > 1 ? " " + getHumanReadableTime(value) : "") + "</span>" : "";
}
else if (property === "jump"){
return STATE.hasActiveFilter ? "<span class='info jump' data-jump='" + value + "'>Jump to message</span>" : "";
}
else if (property === "reply"){
if (value === null) {
return "";
}
var user = "<span class='reply-username' title='#" + (value.user.tag ? value.user.tag : "????") + "'>" + value.user.name + "</span>";
var avatar = STATE.settings.enableUserAvatars && value.avatar ? "<span class='reply-avatar'>" + templateUserAvatar.apply(value.avatar) + "</span>" : "";
var contents = value.contents ? "<span class='reply-contents'>" + processMessageContents(value.contents) + "</span>" : "";
return "<span class='jump' data-jump='" + value.id + "'>Jump to reply</span><span class='user'>" + avatar + user + "</span>" + contents;
}
else if (property === "reactions"){
if (value === null){
return "";
}
return value.map(reaction => {
if ("id" in reaction){
reaction.ext = reaction.an && STATE.settings.enableAnimatedEmoji ? "gif" : "png";
return templateReactionCustom.apply(reaction);
}
else {
return templateReaction.apply(reaction);
}
}).join("");
}
});
}
};
})();

View File

@@ -1,77 +0,0 @@
var DOM = (function(){
var createElement = (tag, parent) => {
var ele = document.createElement(tag);
parent.appendChild(ele);
return ele;
};
var entityMap = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': '&quot;',
"'": '&#39;'
};
var entityRegex = /[&<>"']/g;
return {
/*
* Returns a child element by its ID. Parent defaults to the entire document.
*/
id: (id, parent) => (parent || document).getElementById(id),
/*
* Returns an array of all child elements containing the specified class. Parent defaults to the entire document.
*/
cls: (cls, parent) => 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.
*/
tag: (tag, parent) => Array.prototype.slice.call((parent || document).getElementsByTagName(tag)),
/*
* Returns the first child element containing the specified class. Parent defaults to the entire document.
*/
fcls: (cls, parent) => (parent || document).getElementsByClassName(cls)[0],
/*
* Creates an element, adds it to the DOM, and returns it.
*/
createElement: (tag, parent) => createElement(tag, parent),
/*
* Removes an element from the DOM.
*/
removeElement: (ele) => ele.parentNode.removeChild(ele),
/*
* Converts characters to their HTML entity form.
*/
escapeHTML: (html) => String(html).replace(entityRegex, s => entityMap[s]),
/*
* Triggers a UTF-8 text file download.
*/
downloadTextFile: (fileName, fileContents) => {
var blob = new Blob([fileContents], { "type": "octet/stream" });
if ("msSaveBlob" in window.navigator){
return window.navigator.msSaveBlob(blob, fileName);
}
var url = window.URL.createObjectURL(blob);
var ele = createElement("a", document.body);
ele.href = url;
ele.download = fileName;
ele.style.display = "none";
ele.click();
document.body.removeChild(ele);
window.URL.revokeObjectURL(url);
}
};
})();

View File

@@ -1,58 +0,0 @@
var EMBED = (function(){
var enabled = false;
var html;
var generated;
var downloadTextFile = function(fileName, fileContents){
var blob = new Blob([fileContents], { "type": "octet/stream" });
if ("msSaveBlob" in window.navigator){
return window.navigator.msSaveBlob(blob, fileName);
}
var url = window.URL.createObjectURL(blob);
var ele = DOM.createElement("a", document.body);
ele.href = url;
ele.download = fileName;
ele.style.display = "none";
ele.click();
document.body.removeChild(ele);
window.URL.revokeObjectURL(url);
};
var utoa = function(str){
return window.btoa(unescape(encodeURIComponent(str)));
};
var atou = function(str){
return decodeURIComponent(escape(window.atob(str)));
};
return {
setup: function(){
enabled = true;
html = "<!DOCTYPE html>\n" + document.documentElement.outerHTML;
DOM.id("btn-upload-file").insertAdjacentHTML("afterend", `<button id="btn-embed-file" disabled>Embed File</button>`);
DOM.id("btn-embed-file").addEventListener("click", () => downloadTextFile("embed.html", generated));
},
onFileRead: function(json){
if (!enabled){
return;
}
DOM.id("btn-embed-file").disabled = false;
generated = html.replace("</title>", `</title>\n<script type="text/javascript">window.DHT_EMBEDDED = "${utoa(json)}";<\/script>`).replace(`<${document.body.tagName.toLowerCase()}>`, `<body class="embedded">`);
},
getEmbeddedJSON: function(){
var embed = window.DHT_EMBEDDED;
return embed ? atou(embed) : null;
}
};
})();

View File

@@ -1,80 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
var embedded = EMBED.getEmbeddedJSON();
if (location.search === "?embed" && !embedded){
EMBED.setup();
}
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();
});
var loadJSON = function(json, errParse, errInvalid){
var obj;
try{
obj = JSON.parse(json);
EMBED.onFileRead(json);
}catch(e){
console.error(e);
alert(errParse);
return;
}
if (SAVEFILE.isValid(obj)){
STATE.uploadFile(new SAVEFILE(obj));
}
else{
alert(errInvalid);
}
};
if (embedded){
loadJSON(embedded, "Could not parse embedded file, see console for details.", "Embedded file has an invalid format.");
}
else{
GUI.onFileUploaded(files => {
if (files.length === 1){
var file = files[0];
var reader = new FileReader();
STATE.setUploadedFileName(file.name);
reader.onload = () => loadJSON(reader.result, "Could not parse '"+file.name+"', see console for details.", "File '"+file.name+"' has an invalid format.");
reader.readAsText(file, "UTF-8");
}
else{
alert("Please, select only one file.");
}
return true;
});
}
});

View File

@@ -1,295 +0,0 @@
var GUI = (function(){
var eventOnFileUploaded;
var eventOnOptMessagesPerPageChanged;
var eventOnOptMessageFilterChanged;
var eventOnNavButtonClicked;
var getActiveFilter = function(){
var active = DOM.fcls("active", DOM.id("opt-filter-list"));
return active && active.value !== "" ? {
"type": active.getAttribute("data-filter-type"),
"value": active.value
} : null;
};
var triggerFilterChanged = function(){
var activeFilter = getActiveFilter();
DOM.id("opt-save-filtered").classList.toggle("active", activeFilter != null);
eventOnOptMessageFilterChanged && eventOnOptMessageFilterChanged(activeFilter);
};
var showModal = function(width, html){
var 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
// -------------
var showSettingsModal = function(){
showModal(560, `
<label><input id='dht-cfg-imgpreviews' type='checkbox'> Image Previews</label><br>
<label><input id='dht-cfg-formatting' type='checkbox'> Message Formatting</label><br>
<label><input id='dht-cfg-useravatars' type='checkbox'> User Avatars</label><br>
<label><input id='dht-cfg-animemoji' type='checkbox'> Animated Emoji</label><br>`);
var setupCheckBox = function(id, settingName){
var ele = DOM.id(id);
ele.checked = STATE.settings[settingName];
ele.addEventListener("change", () => STATE.settings[settingName] = ele.checked);
};
setupCheckBox("dht-cfg-imgpreviews", "enableImagePreviews");
setupCheckBox("dht-cfg-formatting", "enableFormatting");
setupCheckBox("dht-cfg-useravatars", "enableUserAvatars");
setupCheckBox("dht-cfg-animemoji", "enableAnimatedEmoji");
};
var showInfoModal = function(){
var linkGH = "https://github.com/chylex/Discord-History-Tracker";
showModal(560, `
<p>Discord History Tracker is developed by <a href='https://chylex.com'>chylex</a> as an <a href='${linkGH}/blob/master/LICENSE.md'>open source</a> project.</p>
<sub>{{{version:full}}}</sub>
<p>Please, report any issues and suggestions to the <a href='${linkGH}/issues'>tracker</a>. If you want to support the development, please spread the word and consider <a href='https://www.patreon.com/chylex'>becoming a patron</a> or <a href='https://ko-fi.com/chylex'>buying me a coffee</a>. Any support is appreciated!</p>
<p><a href='${linkGH}/issues'>Issue Tracker</a> &nbsp;&mdash;&nbsp; <a href='${linkGH}'>GitHub Repository</a> &nbsp;&mdash;&nbsp; <a href='https://twitter.com/chylexmc'>Developer's Twitter</a></p>`);
};
return {
// ---------
// GUI setup
// ---------
/*
* Hooks all event listeners into the DOM.
*/
setup: function(){
var inputUploadedFile = DOM.id("uploaded-file");
var inputMessageFilter = DOM.id("opt-messages-filter");
var containerFilterList = DOM.id("opt-filter-list");
var resetActiveFilter = function(){
inputMessageFilter.value = "";
inputMessageFilter.dispatchEvent(new Event("change"));
DOM.id("opt-filter-contents").value = "";
DOM.id("opt-save-filtered").classList.remove("active");
};
DOM.id("btn-upload-file").addEventListener("click", () => {
inputUploadedFile.click();
});
inputUploadedFile.addEventListener("change", () => {
if (eventOnFileUploaded && eventOnFileUploaded(inputUploadedFile.files)){
inputUploadedFile.value = null;
resetActiveFilter();
}
});
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", e => triggerFilterChanged());
});
DOM.id("opt-messages-per-page").addEventListener("change", () => {
eventOnOptMessagesPerPageChanged && eventOnOptMessagesPerPageChanged();
});
DOM.id("btn-save-filtered").addEventListener("click", () => {
if (confirm("Filtering only removes messages, all users and servers will remain in the new archive. Continue?")){
STATE.saveFilteredMessages();
}
});
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 => {
var jump = e.target.getAttribute("data-jump");
if (jump){
resetActiveFilter();
var 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 a file is uploaded. The callback takes a single argument, which is the file object array, and should return true to reset the input.
*/
onFileUploaded: function(callback){
eventOnFileUploaded = callback;
},
/*
* Sets a callback for when the user changes the messages per page option. The callback is not passed any arguments.
*/
onOptionMessagesPerPageChanged: function(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: <filter type>, value: <filter value> }.
*/
onOptMessageFilterChanged: function(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: function(callback){
eventOnNavButtonClicked = callback;
},
// ----------------------
// Options and navigation
// ----------------------
/*
* Returns the selected amount of messages per page.
*/
getOptionMessagesPerPage: function(){
return parseInt(DOM.id("opt-messages-per-page").value, 10);
},
/*
* Updates the navigation text and buttons.
*/
updateNavigation: function(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: function(channels, selected, callback){
var 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", e => {
var currentChannel = DOM.fcls("active", eleChannels);
if (currentChannel){
currentChannel.classList.remove("active");
}
ele.classList.add("active");
callback(ele.getAttribute("data-channel"));
});
});
if (selected){
var activeChannel = eleChannels.querySelector("[data-channel='"+selected+"']");
activeChannel && activeChannel.classList.add("active");
}
}
},
// ------------
// Message list
// ------------
/*
* Updates the message list.
*/
updateMessageList: function(messages){
DOM.id("messages").innerHTML = messages ? messages.map(message => DISCORD.getMessageHTML(message)).join("") : "";
},
/*
* Updates the user filter list.
*/
updateUserList: function(users){
var eleSelect = DOM.id("opt-filter-user");
while(eleSelect.length > 1){
eleSelect.remove(1);
}
var options = [];
for(var key of Object.keys(users)){
var 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));
},
/*
* Scrolls the message div to the top.
*/
scrollMessagesToTop: function(){
DOM.id("messages").scrollTop = 0;
}
};
})();

View File

@@ -1,41 +0,0 @@
var 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;
}
}
};

View File

@@ -1,83 +0,0 @@
class SAVEFILE{
constructor(parsedObj){
var me = this;
me.meta = parsedObj.meta;
me.data = parsedObj.data;
me.meta.users = me.meta.users || {};
me.meta.userindex = me.meta.userindex || [];
me.meta.servers = me.meta.servers || [];
me.meta.channels = me.meta.channels || {};
};
static isValid(parsedObj){
return parsedObj && typeof parsedObj.meta === "object" && typeof parsedObj.data === "object";
};
getServer(index){
return this.meta.servers[index] || { "name": "&lt;unknown&gt;", "type": "ERROR" };
}
getChannels(){
return this.meta.channels;
}
getChannelById(channel){
return this.meta.channels[channel] || { "id": channel, "name": channel };
}
getUsers(){
return this.meta.users;
}
getUser(index){
return this.meta.users[this.meta.userindex[index]] || { "name": "&lt;unknown&gt;" };
}
getUserId(index){
return this.meta.userindex[index];
}
getUserById(user){
return this.meta.users[user] || { "name": user };
}
getUserIndex(user){
return this.meta.userindex.indexOf(user);
}
getMessages(channel){
return this.data[channel] || {};
}
filterToJson(filterFunction){
var newMeta = JSON.parse(JSON.stringify(this.meta));
var newData = {};
for(let channel of Object.keys(this.getChannels())){
var messages = this.getMessages(channel);
var retained = {};
for(let key of Object.keys(messages)){
var message = messages[key];
if (filterFunction(message)){
retained[key] = message;
}
}
if (Object.keys(retained).length > 0){
newData[channel] = retained;
}
else{
delete newMeta.channels[channel];
}
}
return JSON.stringify({
"meta": newMeta,
"data": newData
});
}
}

View File

@@ -1,358 +0,0 @@
var STATE = (function(){
var ROOT = {};
// ---------------
// State variables
// ---------------
var FILE;
var MSGS;
var uploadedFileName;
var filterFunction;
var selectedChannel;
var currentPage;
var messagesPerPage;
// ----------------------------------
// Channel and message refresh events
// ----------------------------------
var eventOnChannelsRefreshed;
var eventOnMessagesRefreshed;
var eventOnUsersRefreshed;
var triggerChannelsRefreshed = function(selectedChannel){
eventOnChannelsRefreshed && eventOnChannelsRefreshed(ROOT.getChannelList(), selectedChannel);
};
var triggerMessagesRefreshed = function(){
eventOnMessagesRefreshed && eventOnMessagesRefreshed(ROOT.getMessageList());
};
var triggerUsersRefreshed = function(){
eventOnUsersRefreshed && eventOnUsersRefreshed(ROOT.getUserList());
};
ROOT.onChannelsRefreshed = function(callback){
eventOnChannelsRefreshed = callback;
};
ROOT.onMessagesRefreshed = function(callback){
eventOnMessagesRefreshed = callback;
};
ROOT.onUsersRefreshed = function(callback){
eventOnUsersRefreshed = callback;
};
// ------------------------------------
// File upload and basic data retrieval
// ------------------------------------
ROOT.uploadFile = function(file){
FILE = file;
MSGS = null;
selectedChannel = null;
currentPage = 1;
triggerUsersRefreshed();
triggerChannelsRefreshed();
triggerMessagesRefreshed();
};
ROOT.setUploadedFileName = function(name){
uploadedFileName = name;
};
ROOT.getChannelName = function(channel){
return FILE.getChannelById(channel).name;
};
ROOT.getUserName = function(user){
return FILE.getUserById(user).name;
};
ROOT.getUserTag = function(user){
return FILE.getUserById(user).tag;
};
// --------------------------
// Channel list and selection
// --------------------------
var getFilteredMessageKeys = function(channel){
var messages = FILE.getMessages(channel);
var keys = Object.keys(messages);
if (filterFunction){
keys = keys.filter(key => filterFunction(messages[key]));
}
return keys;
};
ROOT.getChannelList = function(){
if (!FILE){
return [];
}
var channels = FILE.getChannels();
return Object.keys(channels).map(key => ({
"id": key,
"name": channels[key].name,
"server": FILE.getServer(channels[key].server),
"msgcount": getFilteredMessageKeys(key).length,
"topic": channels[key].topic || "",
"nsfw": channels[key].nsfw || false,
"position": channels[key].position || -1
})).sort((ac, bc) => {
var as = ac.server;
var 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 });
});
};
ROOT.selectChannel = function(channel){
currentPage = 1;
selectedChannel = channel;
MSGS = getFilteredMessageKeys(channel).sort(PROCESSOR.SORTER.oldestToNewest);
triggerMessagesRefreshed();
};
ROOT.getSelectedChannel = function(){
return selectedChannel;
};
// ------------
// Message list
// ------------
ROOT.getMessageList = function(){
if (!MSGS){
return [];
}
var messages = FILE.getMessages(selectedChannel);
var startIndex = messagesPerPage*(ROOT.getCurrentPage()-1);
return MSGS.slice(startIndex, !messagesPerPage ? undefined : startIndex+messagesPerPage).map(key => {
var message = messages[key];
var user = FILE.getUser(message.u);
var avatar = user.avatar ? { id: FILE.getUserId(message.u), path: user.avatar } : null;
var reply = ("r" in message && message.r in messages) ? messages[message.r] : null;
var replyUser = reply ? FILE.getUser(reply.u) : null;
var replyAvatar = replyUser && replyUser.avatar ? { id: FILE.getUserId(reply.u), path: replyUser.avatar } : null;
var replyObj = reply ? {
"id": message.r,
"user": replyUser,
"avatar": replyAvatar,
"contents": reply.m
} : null;
return {
"user": user,
"avatar": avatar,
"timestamp": message.t,
"contents": ("m" in message) ? message.m : null,
"embeds": message.e,
"attachments": message.a,
"edit": ("te" in message) ? message.te : (message.f & 1) === 1,
"jump": key,
"reply": replyObj,
"reactions": ("re" in message) ? message.re : null
};
});
};
ROOT.navigateToMessage = function(id){
if (!MSGS){
return 0;
}
var index = MSGS.indexOf(id);
if (index == -1){
return 0;
}
currentPage = Math.max(1, Math.min(ROOT.getPageCount(), 1 + Math.floor(index / messagesPerPage)));
triggerMessagesRefreshed();
return index % messagesPerPage;
};
// ----------
// Filtering
// ----------
ROOT.hasActiveFilter = false;
ROOT.setActiveFilter = function(filter){
switch(filter ? filter.type : ""){
case "user":
filterFunction = PROCESSOR.FILTER.byUser(FILE.getUserIndex(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;
}
ROOT.hasActiveFilter = filterFunction != null;
triggerChannelsRefreshed(selectedChannel);
if (selectedChannel){
ROOT.selectChannel(selectedChannel); // resets current page and updates messages
}
};
ROOT.saveFilteredMessages = function(){
var saveFileName = "dht-filtered.txt";
if (uploadedFileName){
if (uploadedFileName.includes("filtered")){
saveFileName = uploadedFileName;
}
else{
saveFileName = uploadedFileName.replace(".", "-filtered.");
}
}
DOM.downloadTextFile(saveFileName, FILE.filterToJson(filterFunction));
};
// -----
// Users
// -----
ROOT.getUserList = function(){
return FILE ? FILE.getUsers() : [];
};
// ----------
// Pagination
// ----------
ROOT.setMessagesPerPage = function(amount){
messagesPerPage = amount;
triggerMessagesRefreshed();
};
ROOT.updateCurrentPage = function(action){
switch(action){
case "first": currentPage = 1; break;
case "prev": currentPage = Math.max(1, currentPage-1); break;
case "next": currentPage = Math.min(ROOT.getPageCount(), currentPage+1); break;
case "last": currentPage = ROOT.getPageCount(); break;
case "pick":
var page = parseInt(prompt("Select page:", currentPage), 10);
if (!page && page !== 0){
return;
}
currentPage = Math.max(1, Math.min(ROOT.getPageCount(), page));
break;
}
triggerMessagesRefreshed();
};
ROOT.getCurrentPage = function(){
var total = ROOT.getPageCount();
if (currentPage > total && total > 0){
currentPage = total;
}
return currentPage || 1;
};
ROOT.getPageCount = function(){
return !MSGS ? 0 : (!messagesPerPage ? 1 : Math.ceil(MSGS.length/messagesPerPage));
};
// --------
// Settings
// --------
ROOT.settings = {};
var getStorageItem = (property) => {
try{
return localStorage.getItem(property);
}catch(e){
console.error(e);
return null;
}
};
var setStorageItem = (property, value) => {
try{
localStorage.setItem(property, value);
}catch(e){
console.error(e);
}
};
var defineSettingProperty = (property, defaultValue, storageToValue) => {
var name = "_"+property;
Object.defineProperty(ROOT.settings, property, {
get: (() => ROOT.settings[name]),
set: (value => {
ROOT.settings[name] = value;
triggerMessagesRefreshed();
setStorageItem(property, value);
})
});
var stored = getStorageItem(property);
if (stored !== null){
stored = storageToValue(stored);
}
ROOT.settings[name] = stored === null ? defaultValue : stored;
};
var fromBooleanString = (value) => {
if (value === "true") return true;
if (value === "false") return false;
return null;
};
defineSettingProperty("enableImagePreviews", true, fromBooleanString);
defineSettingProperty("enableFormatting", true, fromBooleanString);
defineSettingProperty("enableUserAvatars", true, fromBooleanString);
defineSettingProperty("enableAnimatedEmoji", true, fromBooleanString);
// End
return ROOT;
})();

View File

@@ -1,20 +0,0 @@
var TEMPLATE_REGEX = /{([^{}]+?)}/g;
class TEMPLATE{
constructor(contents){
this.contents = contents;
};
apply(obj, processor){
return this.contents.replace(TEMPLATE_REGEX, (full, match) => {
var value = match.split(".").reduce((o, property) => o[property], obj);
if (processor){
var updated = processor(match, value);
return typeof updated === "undefined" ? DOM.escapeHTML(value) : updated;
}
return DOM.escapeHTML(value);
});
}
}

View File

@@ -1,42 +0,0 @@
#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;
}

View File

@@ -1,24 +0,0 @@
body {
font-family: Whitney, "Helvetica Neue", Helvetica, Verdana, "Lucida Grande", sans-serif;
line-height: 1;
margin: 0;
padding: 0;
overflow: hidden;
}
body.embedded .hide-embedded {
display: none;
}
#menu {
width: 100%;
height: 48px;
display: flex;
flex-direction: row;
}
#app {
height: calc(100vh - 48px);
display: flex;
flex-direction: row;
}

View File

@@ -1,82 +0,0 @@
#menu {
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;
}
#opt-save-filtered {
display: flex;
}
#opt-save-filtered:not(.active) {
display: none;
}

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