mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-08-18 04:31:41 +02:00
Compare commits
14 Commits
v39
...
wip-viewer
Author | SHA1 | Date | |
---|---|---|---|
b660af4be0
|
|||
3d9d6a454a
|
|||
ee39780928
|
|||
7b58f973a0
|
|||
93fe018343
|
|||
4f5e27f651
|
|||
cbf81ec95a
|
|||
8a80cb8c20
|
|||
865deb356a
|
|||
069ab97196
|
|||
caab038eaa
|
|||
fb837374fc
|
|||
65d935cca1
|
|||
6e64c86d7a
|
@@ -1,4 +1,3 @@
|
|||||||
using System;
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
@@ -13,7 +12,7 @@ sealed class App : Application {
|
|||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted() {
|
public override void OnFrameworkInitializationCompleted() {
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
||||||
desktop.MainWindow = new MainWindow(new Arguments(desktop.Args ?? Array.Empty<string>()));
|
desktop.MainWindow = new MainWindow(Program.Arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
@@ -5,26 +5,33 @@ namespace DHT.Desktop;
|
|||||||
|
|
||||||
sealed class Arguments {
|
sealed class Arguments {
|
||||||
private static readonly Log Log = Log.ForType<Arguments>();
|
private static readonly Log Log = Log.ForType<Arguments>();
|
||||||
|
|
||||||
|
private const int FirstArgument = 1;
|
||||||
|
|
||||||
public static Arguments Empty => new(Array.Empty<string>());
|
public static Arguments Empty => new(Array.Empty<string>());
|
||||||
|
|
||||||
|
public bool Console { get; }
|
||||||
public string? DatabaseFile { get; }
|
public string? DatabaseFile { get; }
|
||||||
public ushort? ServerPort { get; }
|
public ushort? ServerPort { get; }
|
||||||
public string? ServerToken { get; }
|
public string? ServerToken { get; }
|
||||||
|
|
||||||
public Arguments(string[] args) {
|
public Arguments(string[] args) {
|
||||||
for (int i = 0; i < args.Length; i++) {
|
for (int i = FirstArgument; i < args.Length; i++) {
|
||||||
string key = args[i];
|
string key = args[i];
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "-debug":
|
case "-debug":
|
||||||
Log.IsDebugEnabled = true;
|
Log.IsDebugEnabled = true;
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
case "-console":
|
||||||
|
Console = true;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
string value;
|
string value;
|
||||||
|
|
||||||
if (i == 0 && !key.StartsWith('-')) {
|
if (i == FirstArgument && !key.StartsWith('-')) {
|
||||||
value = key;
|
value = key;
|
||||||
key = "-db";
|
key = "-db";
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using Avalonia.Threading;
|
||||||
using DHT.Desktop.Dialogs.File;
|
using DHT.Desktop.Dialogs.File;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
@@ -41,11 +43,16 @@ static class DatabaseGui {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, Func<Task<bool>> checkCanUpgradeDatabase) {
|
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
|
||||||
|
var prevSynchronizationContext = SynchronizationContext.Current;
|
||||||
|
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
||||||
|
var taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
|
||||||
|
SynchronizationContext.SetSynchronizationContext(prevSynchronizationContext);
|
||||||
|
|
||||||
IDatabaseFile? file = null;
|
IDatabaseFile? file = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
file = await SqliteDatabaseFile.OpenOrCreate(path, checkCanUpgradeDatabase);
|
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks, taskScheduler);
|
||||||
} catch (InvalidDatabaseVersionException ex) {
|
} catch (InvalidDatabaseVersionException ex) {
|
||||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
|
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
|
||||||
} catch (DatabaseTooNewException ex) {
|
} catch (DatabaseTooNewException ex) {
|
||||||
|
@@ -9,6 +9,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
|
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
|
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
|
||||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox"
|
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox"
|
||||||
mc:Ignorable="d" d:DesignWidth="500"
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog"
|
x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog"
|
||||||
|
x:DataType="namespace:CheckBoxDialogModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="500" SizeToContent="Height" CanResize="False"
|
Width="500" SizeToContent="Height" CanResize="False"
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
|
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
|
||||||
mc:Ignorable="d" d:DesignWidth="500"
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
|
x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
|
||||||
|
x:DataType="namespace:MessageDialogModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="500" SizeToContent="Height" CanResize="False"
|
Width="500" SizeToContent="Height" CanResize="False"
|
||||||
|
@@ -4,4 +4,5 @@ namespace DHT.Desktop.Dialogs.Progress;
|
|||||||
|
|
||||||
interface IProgressCallback {
|
interface IProgressCallback {
|
||||||
Task Update(string message, int finishedItems, int totalItems);
|
Task Update(string message, int finishedItems, int totalItems);
|
||||||
|
Task Hide();
|
||||||
}
|
}
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
|
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
|
||||||
mc:Ignorable="d" d:DesignWidth="500"
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
|
x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
|
||||||
|
x:DataType="namespace:ProgressDialogModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Opened="OnOpened"
|
Opened="OnOpened"
|
||||||
@@ -31,12 +32,18 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</Window.Styles>
|
</Window.Styles>
|
||||||
|
|
||||||
<StackPanel Margin="20">
|
<ItemsRepeater ItemsSource="{Binding Items}" Margin="0 10">
|
||||||
<DockPanel>
|
<ItemsRepeater.ItemTemplate>
|
||||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
|
<DataTemplate>
|
||||||
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
|
<StackPanel Margin="20 10" IsHitTestVisible="{Binding IsVisible}" Opacity="{Binding Opacity}">
|
||||||
</DockPanel>
|
<DockPanel>
|
||||||
<ProgressBar Value="{Binding Progress}" />
|
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
|
||||||
</StackPanel>
|
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
|
||||||
|
</DockPanel>
|
||||||
|
<ProgressBar Value="{Binding Progress}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsRepeater.ItemTemplate>
|
||||||
|
</ItemsRepeater>
|
||||||
|
|
||||||
</Window>
|
</Window>
|
||||||
|
@@ -8,6 +8,7 @@ namespace DHT.Desktop.Dialogs.Progress;
|
|||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class ProgressDialog : Window {
|
public sealed partial class ProgressDialog : Window {
|
||||||
private bool isFinished = false;
|
private bool isFinished = false;
|
||||||
|
private Task progressTask = Task.CompletedTask;
|
||||||
|
|
||||||
public ProgressDialog() {
|
public ProgressDialog() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -15,7 +16,8 @@ public sealed partial class ProgressDialog : Window {
|
|||||||
|
|
||||||
public void OnOpened(object? sender, EventArgs e) {
|
public void OnOpened(object? sender, EventArgs e) {
|
||||||
if (DataContext is ProgressDialogModel model) {
|
if (DataContext is ProgressDialogModel model) {
|
||||||
Task.Run(model.StartTask).ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
progressTask = Task.Run(model.StartTask);
|
||||||
|
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,4 +29,9 @@ public sealed partial class ProgressDialog : Window {
|
|||||||
isFinished = true;
|
isFinished = true;
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task ShowProgressDialog(Window owner) {
|
||||||
|
await ShowDialog(owner);
|
||||||
|
await progressTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
@@ -9,57 +11,43 @@ namespace DHT.Desktop.Dialogs.Progress;
|
|||||||
sealed class ProgressDialogModel : BaseModel {
|
sealed class ProgressDialogModel : BaseModel {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
private string message = "";
|
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
|
||||||
|
|
||||||
public string Message {
|
|
||||||
get => message;
|
|
||||||
private set => Change(ref message, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string items = "";
|
|
||||||
|
|
||||||
public string Items {
|
|
||||||
get => items;
|
|
||||||
private set => Change(ref items, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int progress = 0;
|
|
||||||
|
|
||||||
public int Progress {
|
|
||||||
get => progress;
|
|
||||||
private set => Change(ref progress, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly TaskRunner? task;
|
private readonly TaskRunner? task;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public ProgressDialogModel() {}
|
public ProgressDialogModel() {}
|
||||||
|
|
||||||
public ProgressDialogModel(TaskRunner task) {
|
public ProgressDialogModel(TaskRunner task, int progressItems = 1) {
|
||||||
|
this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
|
||||||
this.task = task;
|
this.task = task;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task StartTask() {
|
internal async Task StartTask() {
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
await task(new Callback(this));
|
await task(Items.Select(static item => new Callback(item)).ToArray());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public delegate Task TaskRunner(IProgressCallback callback);
|
public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
|
||||||
|
|
||||||
private sealed class Callback : IProgressCallback {
|
private sealed class Callback : IProgressCallback {
|
||||||
private readonly ProgressDialogModel model;
|
private readonly ProgressItem item;
|
||||||
|
|
||||||
public Callback(ProgressDialogModel model) {
|
public Callback(ProgressItem item) {
|
||||||
this.model = model;
|
this.item = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task IProgressCallback.Update(string message, int finishedItems, int totalItems) {
|
public async Task Update(string message, int finishedItems, int totalItems) {
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => {
|
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||||
model.Message = message;
|
item.Message = message;
|
||||||
model.Items = finishedItems.Format() + " / " + totalItems.Format();
|
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
|
||||||
model.Progress = 100 * finishedItems / totalItems;
|
item.Progress = totalItems == 0 ? 0 : 100 * finishedItems / totalItems;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task Hide() {
|
||||||
|
return Update(string.Empty, 0, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
41
app/Desktop/Dialogs/Progress/ProgressItem.cs
Normal file
41
app/Desktop/Dialogs/Progress/ProgressItem.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
|
|
||||||
|
sealed class ProgressItem : BaseModel {
|
||||||
|
private bool isVisible = false;
|
||||||
|
|
||||||
|
public bool IsVisible {
|
||||||
|
get => isVisible;
|
||||||
|
private set {
|
||||||
|
Change(ref isVisible, value);
|
||||||
|
OnPropertyChanged(nameof(Opacity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Opacity => IsVisible ? 1.0 : 0.0;
|
||||||
|
|
||||||
|
private string message = "";
|
||||||
|
|
||||||
|
public string Message {
|
||||||
|
get => message;
|
||||||
|
set {
|
||||||
|
Change(ref message, value);
|
||||||
|
IsVisible = !string.IsNullOrEmpty(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string items = "";
|
||||||
|
|
||||||
|
public string Items {
|
||||||
|
get => items;
|
||||||
|
set => Change(ref items, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int progress = 0;
|
||||||
|
|
||||||
|
public int Progress {
|
||||||
|
get => progress;
|
||||||
|
set => Change(ref progress, value);
|
||||||
|
}
|
||||||
|
}
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox"
|
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox"
|
||||||
mc:Ignorable="d" d:DesignWidth="500"
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
|
x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
|
||||||
|
x:DataType="namespace:TextBoxDialogModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="500" SizeToContent="Height" CanResize="False"
|
Width="500" SizeToContent="Height" CanResize="False"
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using static System.Environment.SpecialFolder;
|
using static System.Environment.SpecialFolder;
|
||||||
@@ -47,12 +47,12 @@ static class DiscordAppSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool AreDevToolsEnabled(Dictionary<string, object?> json) {
|
private static bool AreDevToolsEnabled(JsonObject json) {
|
||||||
return json.TryGetValue(JsonKeyDevTools, out var value) && value is JsonElement { ValueKind: JsonValueKind.True };
|
return json.TryGetPropertyValue(JsonKeyDevTools, out var node) && node?.GetValueKind() == JsonValueKind.True;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
||||||
Dictionary<string, object?> json;
|
JsonObject json;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
json = await ReadSettingsJson();
|
json = await ReadSettingsJson();
|
||||||
@@ -109,13 +109,13 @@ static class DiscordAppSettings {
|
|||||||
return SettingsJsonResult.Success;
|
return SettingsJsonResult.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Dictionary<string, object?>> ReadSettingsJson() {
|
private static async Task<JsonObject> ReadSettingsJson() {
|
||||||
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
return await JsonSerializer.DeserializeAsync<Dictionary<string, object?>?>(stream) ?? throw new JsonException();
|
return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WriteSettingsJson(Dictionary<string, object?> json) {
|
private static async Task WriteSettingsJson(JsonObject json) {
|
||||||
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
|
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
|
||||||
await JsonSerializer.SerializeAsync(stream, json, new JsonSerializerOptions { WriteIndented = true });
|
await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
8
app/Desktop/Discord/DiscordAppSettingsJsonContext.cs
Normal file
8
app/Desktop/Discord/DiscordAppSettingsJsonContext.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Discord;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
|
||||||
|
[JsonSerializable(typeof(JsonObject))]
|
||||||
|
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext {}
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
||||||
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295"
|
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295"
|
||||||
x:Class="DHT.Desktop.Main.AboutWindow"
|
x:Class="DHT.Desktop.Main.AboutWindow"
|
||||||
|
x:DataType="main:AboutWindowModel"
|
||||||
Title="About Discord History Tracker"
|
Title="About Discord History Tracker"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="480" Height="295" CanResize="False"
|
Width="480" Height="295" CanResize="False"
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel">
|
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel"
|
||||||
|
x:DataType="controls:AttachmentFilterPanelModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:AttachmentFilterPanelModel />
|
<controls:AttachmentFilterPanelModel />
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel">
|
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel"
|
||||||
|
x:DataType="controls:MessageFilterPanelModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:MessageFilterPanelModel />
|
<controls:MessageFilterPanelModel />
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel">
|
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel"
|
||||||
|
x:DataType="controls:ServerConfigurationPanelModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:ServerConfigurationPanelModel />
|
<controls:ServerConfigurationPanelModel />
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="DHT.Desktop.Main.Controls.StatusBar">
|
x:Class="DHT.Desktop.Main.Controls.StatusBar"
|
||||||
|
x:DataType="controls:StatusBarModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:StatusBarModel />
|
<controls:StatusBarModel />
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.MainWindow"
|
x:Class="DHT.Desktop.Main.MainWindow"
|
||||||
|
x:DataType="main:MainWindowModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="800" Height="500"
|
Width="800" Height="500"
|
||||||
|
@@ -5,7 +5,8 @@
|
|||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.AdvancedPage">
|
x:Class="DHT.Desktop.Main.Pages.AdvancedPage"
|
||||||
|
x:DataType="pages:AdvancedPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:AdvancedPageModel />
|
<pages:AdvancedPageModel />
|
||||||
|
@@ -5,7 +5,8 @@
|
|||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.AttachmentsPage">
|
x:Class="DHT.Desktop.Main.Pages.AttachmentsPage"
|
||||||
|
x:DataType="pages:AttachmentsPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:AttachmentsPageModel />
|
<pages:AttachmentsPageModel />
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
<TextBlock Text="{Binding DownloadMessage}" Margin="10 0 0 0" VerticalAlignment="Center" DockPanel.Dock="Left" />
|
<TextBlock Text="{Binding DownloadMessage}" Margin="10 0 0 0" VerticalAlignment="Center" DockPanel.Dock="Left" />
|
||||||
<ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" />
|
<ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !DataContext.IsDownloading, RelativeSource={RelativeSource AncestorType=UserControl}}" />
|
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !IsDownloading, RelativeSource={RelativeSource AncestorType=pages:AttachmentsPageModel}}" />
|
||||||
<StackPanel Orientation="Vertical" Spacing="12">
|
<StackPanel Orientation="Vertical" Spacing="12">
|
||||||
<Expander Header="Download Status" IsExpanded="True">
|
<Expander Header="Download Status" IsExpanded="True">
|
||||||
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.DatabasePage">
|
x:Class="DHT.Desktop.Main.Pages.DatabasePage"
|
||||||
|
x:DataType="pages:DatabasePageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:DatabasePageModel />
|
<pages:DatabasePageModel />
|
||||||
|
@@ -17,6 +17,7 @@ using DHT.Desktop.Dialogs.TextBox;
|
|||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Import;
|
using DHT.Server.Database.Import;
|
||||||
|
using DHT.Server.Database.Sqlite;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
@@ -77,31 +78,18 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ProgressDialog progressDialog = new ProgressDialog();
|
ProgressDialog progressDialog = new ProgressDialog();
|
||||||
progressDialog.DataContext = new ProgressDialogModel(async callback => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callback)) {
|
progressDialog.DataContext = new ProgressDialogModel(async callbacks => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callbacks[0])) {
|
||||||
Title = "Database Merge"
|
Title = "Database Merge"
|
||||||
};
|
};
|
||||||
|
|
||||||
await progressDialog.ShowDialog(window);
|
await progressDialog.ShowProgressDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
int total = paths.Length;
|
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
|
||||||
|
|
||||||
DialogResult.YesNo? upgradeResult = null;
|
|
||||||
|
|
||||||
async Task<bool> CheckCanUpgradeDatabase() {
|
|
||||||
upgradeResult ??= total > 1
|
|
||||||
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
|
||||||
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog);
|
|
||||||
|
|
||||||
return DialogResult.YesNo.Yes == upgradeResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
||||||
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
||||||
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
|
||||||
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, CheckCanUpgradeDatabase);
|
|
||||||
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
|
|
||||||
|
|
||||||
if (db == null) {
|
if (db == null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -116,6 +104,41 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||||
|
private readonly ProgressDialog dialog;
|
||||||
|
private readonly int total;
|
||||||
|
private bool? decision;
|
||||||
|
|
||||||
|
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
|
||||||
|
this.total = total;
|
||||||
|
this.dialog = dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanUpgrade() {
|
||||||
|
return decision ??= (total > 1
|
||||||
|
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
||||||
|
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||||
|
return doUpgrade(new NullReporter());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||||
|
public Task NextVersion() {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task MainWork(string message, int finishedItems, int totalItems) {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SubWork(string message, int finishedItems, int totalItems) {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async void ImportLegacyArchive() {
|
public async void ImportLegacyArchive() {
|
||||||
var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||||
Title = "Open Legacy DHT Archive",
|
Title = "Open Legacy DHT Archive",
|
||||||
@@ -128,11 +151,11 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ProgressDialog progressDialog = new ProgressDialog();
|
ProgressDialog progressDialog = new ProgressDialog();
|
||||||
progressDialog.DataContext = new ProgressDialogModel(async callback => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callback)) {
|
progressDialog.DataContext = new ProgressDialogModel(async callbacks => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callbacks[0])) {
|
||||||
Title = "Legacy Archive Import"
|
Title = "Legacy Archive Import"
|
||||||
};
|
};
|
||||||
|
|
||||||
await progressDialog.ShowDialog(window);
|
await progressDialog.ShowProgressDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.DebugPage">
|
x:Class="DHT.Desktop.Main.Pages.DebugPage"
|
||||||
|
x:DataType="pages:DebugPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:DebugPageModel />
|
<pages:DebugPageModel />
|
||||||
|
@@ -45,12 +45,12 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ProgressDialog progressDialog = new ProgressDialog {
|
ProgressDialog progressDialog = new ProgressDialog {
|
||||||
DataContext = new ProgressDialogModel(async callback => await GenerateRandomData(channels, users, messages, callback)) {
|
DataContext = new ProgressDialogModel(async callbacks => await GenerateRandomData(channels, users, messages, callbacks[0])) {
|
||||||
Title = "Generating Random Data"
|
Title = "Generating Random Data"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await progressDialog.ShowDialog(window);
|
await progressDialog.ShowProgressDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int BatchSize = 500;
|
private const int BatchSize = 500;
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.TrackingPage">
|
x:Class="DHT.Desktop.Main.Pages.TrackingPage"
|
||||||
|
x:DataType="pages:TrackingPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:TrackingPageModel />
|
<pages:TrackingPageModel />
|
||||||
|
@@ -5,7 +5,8 @@
|
|||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.ViewerPage">
|
x:Class="DHT.Desktop.Main.Pages.ViewerPage"
|
||||||
|
x:DataType="pages:ViewerPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:ViewerPageModel />
|
<pages:ViewerPageModel />
|
||||||
|
@@ -35,7 +35,7 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
|
|||||||
set => Change(ref hasFilters, value);
|
set => Change(ref hasFilters, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private MessageFilterPanelModel FilterModel { get; }
|
public MessageFilterPanelModel FilterModel { get; }
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly IDatabaseFile db;
|
private readonly IDatabaseFile db;
|
||||||
@@ -65,6 +65,8 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
|
|||||||
string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
|
string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
|
||||||
string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
|
string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
|
||||||
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
|
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
|
||||||
|
|
||||||
|
viewerTemplate = strategy.ProcessViewerTemplate(viewerTemplate);
|
||||||
|
|
||||||
int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
|
int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
|
||||||
int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length;
|
int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length;
|
||||||
|
@@ -5,7 +5,8 @@
|
|||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Screens.MainContentScreen">
|
x:Class="DHT.Desktop.Main.Screens.MainContentScreen"
|
||||||
|
x:DataType="screens:MainContentScreenModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<screens:MainContentScreenModel />
|
<screens:MainContentScreenModel />
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen">
|
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen"
|
||||||
|
x:DataType="screens:WelcomeScreenModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<screens:WelcomeScreenModel />
|
<screens:WelcomeScreenModel />
|
||||||
|
@@ -1,10 +1,13 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
|
using DHT.Server.Database.Sqlite;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens;
|
namespace DHT.Desktop.Main.Screens;
|
||||||
@@ -39,14 +42,71 @@ sealed class WelcomeScreenModel : BaseModel, IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbFilePath = path;
|
dbFilePath = path;
|
||||||
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase);
|
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||||
|
|
||||||
OnPropertyChanged(nameof(Db));
|
OnPropertyChanged(nameof(Db));
|
||||||
OnPropertyChanged(nameof(HasDatabase));
|
OnPropertyChanged(nameof(HasDatabase));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> CheckCanUpgradeDatabase() {
|
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||||
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
private readonly Window window;
|
||||||
|
|
||||||
|
public SchemaUpgradeCallbacks(Window window) {
|
||||||
|
this.window = window;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanUpgrade() {
|
||||||
|
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||||
|
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
|
||||||
|
var reporter = new ProgressReporter(versionSteps, callbacks);
|
||||||
|
await reporter.NextVersion();
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(800));
|
||||||
|
await doUpgrade(reporter);
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(600));
|
||||||
|
}
|
||||||
|
|
||||||
|
await new ProgressDialog {
|
||||||
|
DataContext = new ProgressDialogModel(StartUpgrade, progressItems: 3) {
|
||||||
|
Title = "Upgrading Database"
|
||||||
|
}
|
||||||
|
}.ShowProgressDialog(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||||
|
private readonly IReadOnlyList<IProgressCallback> callbacks;
|
||||||
|
|
||||||
|
private readonly int versionSteps;
|
||||||
|
private int versionProgress = 0;
|
||||||
|
|
||||||
|
public ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) {
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
this.versionSteps = versionSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NextVersion() {
|
||||||
|
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
|
||||||
|
await HideChildren(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MainWork(string message, int finishedItems, int totalItems) {
|
||||||
|
await callbacks[1].Update(message, finishedItems, totalItems);
|
||||||
|
await HideChildren(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SubWork(string message, int finishedItems, int totalItems) {
|
||||||
|
await callbacks[2].Update(message, finishedItems, totalItems);
|
||||||
|
await HideChildren(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HideChildren(int parentIndex) {
|
||||||
|
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
|
||||||
|
await callbacks[i].Hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CloseDatabase() {
|
public void CloseDatabase() {
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
using System.Globalization;
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
using DHT.Utils.Resources;
|
using DHT.Utils.Resources;
|
||||||
|
|
||||||
namespace DHT.Desktop;
|
namespace DHT.Desktop;
|
||||||
@@ -9,6 +11,7 @@ static class Program {
|
|||||||
public static string Version { get; }
|
public static string Version { get; }
|
||||||
public static CultureInfo Culture { get; }
|
public static CultureInfo Culture { get; }
|
||||||
public static ResourceLoader Resources { get; }
|
public static ResourceLoader Resources { get; }
|
||||||
|
public static Arguments Arguments { get; }
|
||||||
|
|
||||||
static Program() {
|
static Program() {
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
@@ -25,10 +28,21 @@ static class Program {
|
|||||||
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
||||||
|
|
||||||
Resources = new ResourceLoader(assembly);
|
Resources = new ResourceLoader(assembly);
|
||||||
|
Arguments = new Arguments(Environment.GetCommandLineArgs());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Main(string[] args) {
|
public static void Main(string[] args) {
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
if (Arguments.Console && OperatingSystem.IsWindows()) {
|
||||||
|
WindowsConsole.AllocConsole();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
} finally {
|
||||||
|
if (Arguments.Console && OperatingSystem.IsWindows()) {
|
||||||
|
WindowsConsole.FreeConsole();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AppBuilder BuildAvaloniaApp() {
|
private static AppBuilder BuildAvaloniaApp() {
|
||||||
|
@@ -19,9 +19,21 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||||
<PublishTrimmed>true</PublishTrimmed>
|
<PublishTrimmed>true</PublishTrimmed>
|
||||||
<TrimMode>partial</TrimMode>
|
<TrimMode>partial</TrimMode>
|
||||||
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
|
||||||
|
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
|
||||||
|
<EventSourceSupport>false</EventSourceSupport>
|
||||||
|
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
|
||||||
|
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
|
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||||
|
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
window.DHT_EMBEDDED = "/*[ARCHIVE]*/";
|
window.DHT_EMBEDDED = "/*[ARCHIVE]*/";
|
||||||
|
window.DHT_SERVER_URL = "/*[SERVER_URL]*/";
|
||||||
|
window.DHT_SERVER_TOKEN = "/*[SERVER_TOKEN]*/";
|
||||||
/*[JS]*/
|
/*[JS]*/
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
|
@@ -182,15 +182,32 @@ const STATE = (function() {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMessageList = function() {
|
const getMessageList = async function(abortSignal) {
|
||||||
if (!loadedMessages) {
|
if (!loadedMessages) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = getMessages(selectedChannel);
|
const messages = getMessages(selectedChannel);
|
||||||
const startIndex = messagesPerPage * (root.getCurrentPage() - 1);
|
const startIndex = messagesPerPage * (root.getCurrentPage() - 1);
|
||||||
|
const slicedMessages = loadedMessages.slice(startIndex, !messagesPerPage ? undefined : startIndex + messagesPerPage);
|
||||||
|
|
||||||
return loadedMessages.slice(startIndex, !messagesPerPage ? undefined : startIndex + messagesPerPage).map(key => {
|
let messageTexts = null;
|
||||||
|
|
||||||
|
if (window.DHT_SERVER_URL !== null) {
|
||||||
|
const messageIds = new Set(slicedMessages);
|
||||||
|
|
||||||
|
for (const key of slicedMessages) {
|
||||||
|
const message = messages[key];
|
||||||
|
|
||||||
|
if ("r" in message) {
|
||||||
|
messageIds.add(message.r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messageTexts = await getMessageTextsFromServer(messageIds, abortSignal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return slicedMessages.map(key => {
|
||||||
/**
|
/**
|
||||||
* @type {{}}
|
* @type {{}}
|
||||||
* @property {Number} u
|
* @property {Number} u
|
||||||
@@ -216,6 +233,9 @@ const STATE = (function() {
|
|||||||
if ("m" in message) {
|
if ("m" in message) {
|
||||||
obj["contents"] = message.m;
|
obj["contents"] = message.m;
|
||||||
}
|
}
|
||||||
|
else if (messageTexts && key in messageTexts) {
|
||||||
|
obj["contents"] = messageTexts[key];
|
||||||
|
}
|
||||||
|
|
||||||
if ("e" in message) {
|
if ("e" in message) {
|
||||||
obj["embeds"] = message.e.map(embed => JSON.parse(embed));
|
obj["embeds"] = message.e.map(embed => JSON.parse(embed));
|
||||||
@@ -230,15 +250,16 @@ const STATE = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ("r" in message) {
|
if ("r" in message) {
|
||||||
const replyMessage = getMessageById(message.r);
|
const replyId = message.r;
|
||||||
|
const replyMessage = getMessageById(replyId);
|
||||||
const replyUser = replyMessage ? getUser(replyMessage.u) : null;
|
const replyUser = replyMessage ? getUser(replyMessage.u) : null;
|
||||||
const replyAvatar = replyUser && replyUser.avatar ? { id: getUserId(replyMessage.u), path: replyUser.avatar } : null;
|
const replyAvatar = replyUser && replyUser.avatar ? { id: getUserId(replyMessage.u), path: replyUser.avatar } : null;
|
||||||
|
|
||||||
obj["reply"] = replyMessage ? {
|
obj["reply"] = replyMessage ? {
|
||||||
"id": message.r,
|
"id": replyId,
|
||||||
"user": replyUser,
|
"user": replyUser,
|
||||||
"avatar": replyAvatar,
|
"avatar": replyAvatar,
|
||||||
"contents": replyMessage.m
|
"contents": messageTexts != null && replyId in messageTexts ? messageTexts[replyId] : replyMessage.m,
|
||||||
} : null;
|
} : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,9 +271,35 @@ const STATE = (function() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getMessageTextsFromServer = async function(messageIds, abortSignal) {
|
||||||
|
let idParams = "";
|
||||||
|
|
||||||
|
for (const messageId of messageIds) {
|
||||||
|
idParams += "id=" + encodeURIComponent(messageId) + "&";
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(DHT_SERVER_URL + "/get-messages?" + idParams + "token=" + encodeURIComponent(DHT_SERVER_TOKEN), {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "omit",
|
||||||
|
redirect: "error",
|
||||||
|
signal: abortSignal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new Error("Server returned status " + response.status + " " + response.statusText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let eventOnUsersRefreshed;
|
let eventOnUsersRefreshed;
|
||||||
let eventOnChannelsRefreshed;
|
let eventOnChannelsRefreshed;
|
||||||
let eventOnMessagesRefreshed;
|
let eventOnMessagesRefreshed;
|
||||||
|
let messageLoaderAborter = null;
|
||||||
|
|
||||||
const triggerUsersRefreshed = function() {
|
const triggerUsersRefreshed = function() {
|
||||||
eventOnUsersRefreshed && eventOnUsersRefreshed(getUserList());
|
eventOnUsersRefreshed && eventOnUsersRefreshed(getUserList());
|
||||||
@@ -263,7 +310,22 @@ const STATE = (function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const triggerMessagesRefreshed = function() {
|
const triggerMessagesRefreshed = function() {
|
||||||
eventOnMessagesRefreshed && eventOnMessagesRefreshed(getMessageList());
|
if (!eventOnMessagesRefreshed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageLoaderAborter != null) {
|
||||||
|
messageLoaderAborter.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const aborter = new AbortController();
|
||||||
|
messageLoaderAborter = aborter;
|
||||||
|
|
||||||
|
getMessageList(aborter.signal).then(eventOnMessagesRefreshed).finally(() => {
|
||||||
|
if (messageLoaderAborter === aborter) {
|
||||||
|
messageLoaderAborter = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFilteredMessageKeys = function(channel) {
|
const getFilteredMessageKeys = function(channel) {
|
||||||
|
@@ -44,7 +44,7 @@ public sealed class DummyDatabaseFile : IDatabaseFile {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Message> GetMessages(MessageFilter? filter = null) {
|
public List<Message> GetMessages(MessageFilter? filter = null, bool includeText = true) {
|
||||||
return new();
|
return new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
3
app/Server/Database/Export/Snowflake.cs
Normal file
3
app/Server/Database/Export/Snowflake.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace DHT.Server.Database.Export;
|
||||||
|
|
||||||
|
readonly record struct Snowflake(ulong Id);
|
23
app/Server/Database/Export/SnowflakeJsonSerializer.cs
Normal file
23
app/Server/Database/Export/SnowflakeJsonSerializer.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DHT.Server.Database.Export;
|
||||||
|
|
||||||
|
sealed class SnowflakeJsonSerializer : JsonConverter<Snowflake> {
|
||||||
|
public override Snowflake Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
|
||||||
|
return new Snowflake(ulong.Parse(reader.GetString()!));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
|
||||||
|
writer.WriteStringValue(value.Id.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Snowflake ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
|
||||||
|
return new Snowflake(ulong.Parse(reader.GetString()!));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void WriteAsPropertyName(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
|
||||||
|
writer.WritePropertyName(value.Id.ToString());
|
||||||
|
}
|
||||||
|
}
|
@@ -3,5 +3,7 @@ using DHT.Server.Data;
|
|||||||
namespace DHT.Server.Database.Export.Strategy;
|
namespace DHT.Server.Database.Export.Strategy;
|
||||||
|
|
||||||
public interface IViewerExportStrategy {
|
public interface IViewerExportStrategy {
|
||||||
|
bool IncludeMessageText { get; }
|
||||||
|
string ProcessViewerTemplate(string template);
|
||||||
string GetAttachmentUrl(Attachment attachment);
|
string GetAttachmentUrl(Attachment attachment);
|
||||||
}
|
}
|
||||||
|
@@ -12,6 +12,13 @@ public sealed class LiveViewerExportStrategy : IViewerExportStrategy {
|
|||||||
this.safeToken = WebUtility.UrlEncode(token);
|
this.safeToken = WebUtility.UrlEncode(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IncludeMessageText => false;
|
||||||
|
|
||||||
|
public string ProcessViewerTemplate(string template) {
|
||||||
|
return template.Replace("/*[SERVER_URL]*/", "http://127.0.0.1:" + safePort)
|
||||||
|
.Replace("/*[SERVER_TOKEN]*/", WebUtility.UrlEncode(safeToken));
|
||||||
|
}
|
||||||
|
|
||||||
public string GetAttachmentUrl(Attachment attachment) {
|
public string GetAttachmentUrl(Attachment attachment) {
|
||||||
return "http://127.0.0.1:" + safePort + "/get-attachment/" + WebUtility.UrlEncode(attachment.NormalizedUrl) + "?token=" + safeToken;
|
return "http://127.0.0.1:" + safePort + "/get-attachment/" + WebUtility.UrlEncode(attachment.NormalizedUrl) + "?token=" + safeToken;
|
||||||
}
|
}
|
||||||
|
@@ -7,6 +7,13 @@ public sealed class StandaloneViewerExportStrategy : IViewerExportStrategy {
|
|||||||
|
|
||||||
private StandaloneViewerExportStrategy() {}
|
private StandaloneViewerExportStrategy() {}
|
||||||
|
|
||||||
|
public bool IncludeMessageText => true;
|
||||||
|
|
||||||
|
public string ProcessViewerTemplate(string template) {
|
||||||
|
return template.Replace("\"/*[SERVER_URL]*/\"", "null")
|
||||||
|
.Replace("\"/*[SERVER_TOKEN]*/\"", "null");
|
||||||
|
}
|
||||||
|
|
||||||
public string GetAttachmentUrl(Attachment attachment) {
|
public string GetAttachmentUrl(Attachment attachment) {
|
||||||
// The normalized URL will not load files from Discord CDN once the time limit is enforced.
|
// The normalized URL will not load files from Discord CDN once the time limit is enforced.
|
||||||
|
|
||||||
|
93
app/Server/Database/Export/ViewerJson.cs
Normal file
93
app/Server/Database/Export/ViewerJson.cs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DHT.Server.Database.Export;
|
||||||
|
|
||||||
|
sealed class ViewerJson {
|
||||||
|
public required JsonMeta Meta { get; init; }
|
||||||
|
public required Dictionary<Snowflake, Dictionary<Snowflake, JsonMessage>> Data { get; init; }
|
||||||
|
|
||||||
|
public sealed class JsonMeta {
|
||||||
|
public required Dictionary<Snowflake, JsonUser> Users { get; init; }
|
||||||
|
public required List<Snowflake> Userindex { get; init; }
|
||||||
|
public required List<JsonServer> Servers { get; init; }
|
||||||
|
public required Dictionary<Snowflake, JsonChannel> Channels { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class JsonUser {
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Avatar { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Tag { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class JsonServer {
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string Type { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class JsonChannel {
|
||||||
|
public required int Server { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Parent { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public int? Position { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Topic { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public bool? Nsfw { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class JsonMessage {
|
||||||
|
public required int U { get; init; }
|
||||||
|
public required long T { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? M { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public long? Te { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? R { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public JsonMessageAttachment[]? A { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string[]? E { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public JsonMessageReaction[]? Re { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class JsonMessageAttachment {
|
||||||
|
public required string Url { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public int? Width { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public int? Height { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class JsonMessageReaction {
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Id { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? N { get; init; }
|
||||||
|
|
||||||
|
public required bool A { get; init; }
|
||||||
|
public required int C { get; init; }
|
||||||
|
}
|
||||||
|
}
|
11
app/Server/Database/Export/ViewerJsonContext.cs
Normal file
11
app/Server/Database/Export/ViewerJsonContext.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DHT.Server.Database.Export;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(
|
||||||
|
Converters = new [] { typeof(SnowflakeJsonSerializer) },
|
||||||
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
|
GenerationMode = JsonSourceGenerationMode.Default
|
||||||
|
)]
|
||||||
|
[JsonSerializable(typeof(ViewerJson))]
|
||||||
|
sealed partial class ViewerJsonContext : JsonSerializerContext {}
|
@@ -21,7 +21,7 @@ public static class ViewerJsonExport {
|
|||||||
var includedChannelIds = new HashSet<ulong>();
|
var includedChannelIds = new HashSet<ulong>();
|
||||||
var includedServerIds = new HashSet<ulong>();
|
var includedServerIds = new HashSet<ulong>();
|
||||||
|
|
||||||
var includedMessages = db.GetMessages(filter);
|
var includedMessages = db.GetMessages(filter, strategy.IncludeMessageText);
|
||||||
var includedChannels = new List<Channel>();
|
var includedChannels = new List<Channel>();
|
||||||
|
|
||||||
foreach (var message in includedMessages) {
|
foreach (var message in includedMessages) {
|
||||||
@@ -42,26 +42,28 @@ public static class ViewerJsonExport {
|
|||||||
|
|
||||||
perf.Step("Collect database data");
|
perf.Step("Collect database data");
|
||||||
|
|
||||||
var value = new {
|
var value = new ViewerJson {
|
||||||
meta = new { users, userindex, servers, channels },
|
Meta = new ViewerJson.JsonMeta {
|
||||||
data = GenerateMessageList(includedMessages, userIndices, strategy),
|
Users = users,
|
||||||
|
Userindex = userindex,
|
||||||
|
Servers = servers,
|
||||||
|
Channels = channels
|
||||||
|
},
|
||||||
|
Data = GenerateMessageList(includedMessages, userIndices, strategy)
|
||||||
};
|
};
|
||||||
|
|
||||||
perf.Step("Generate value object");
|
perf.Step("Generate value object");
|
||||||
|
|
||||||
var opts = new JsonSerializerOptions();
|
await JsonSerializer.SerializeAsync(stream, value, ViewerJsonContext.Default.ViewerJson);
|
||||||
opts.Converters.Add(new ViewerJsonSnowflakeSerializer());
|
|
||||||
|
|
||||||
await JsonSerializer.SerializeAsync(stream, value, opts);
|
|
||||||
|
|
||||||
perf.Step("Serialize to JSON");
|
perf.Step("Serialize to JSON");
|
||||||
perf.End();
|
perf.End();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<string, object> GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<string> userindex, out Dictionary<ulong, object> userIndices) {
|
private static Dictionary<Snowflake, ViewerJson.JsonUser> GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<Snowflake> userindex, out Dictionary<ulong, int> userIndices) {
|
||||||
var users = new Dictionary<string, object>();
|
var users = new Dictionary<Snowflake, ViewerJson.JsonUser>();
|
||||||
userindex = new List<string>();
|
userindex = new List<Snowflake>();
|
||||||
userIndices = new Dictionary<ulong, object>();
|
userIndices = new Dictionary<ulong, int>();
|
||||||
|
|
||||||
foreach (var user in db.GetAllUsers()) {
|
foreach (var user in db.GetAllUsers()) {
|
||||||
var id = user.Id;
|
var id = user.Id;
|
||||||
@@ -69,30 +71,23 @@ public static class ViewerJsonExport {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var obj = new Dictionary<string, object> {
|
var idSnowflake = new Snowflake(id);
|
||||||
["name"] = user.Name
|
|
||||||
};
|
|
||||||
|
|
||||||
if (user.AvatarUrl != null) {
|
|
||||||
obj["avatar"] = user.AvatarUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.Discriminator != null) {
|
|
||||||
obj["tag"] = user.Discriminator;
|
|
||||||
}
|
|
||||||
|
|
||||||
var idStr = id.ToString();
|
|
||||||
userIndices[id] = users.Count;
|
userIndices[id] = users.Count;
|
||||||
userindex.Add(idStr);
|
userindex.Add(idSnowflake);
|
||||||
users[idStr] = obj;
|
|
||||||
|
users[idSnowflake] = new ViewerJson.JsonUser {
|
||||||
|
Name = user.Name,
|
||||||
|
Avatar = user.AvatarUrl,
|
||||||
|
Tag = user.Discriminator
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return users;
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<object> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, object> serverIndices) {
|
private static List<ViewerJson.JsonServer> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, int> serverIndices) {
|
||||||
var servers = new List<object>();
|
var servers = new List<ViewerJson.JsonServer>();
|
||||||
serverIndices = new Dictionary<ulong, object>();
|
serverIndices = new Dictionary<ulong, int>();
|
||||||
|
|
||||||
foreach (var server in db.GetAllServers()) {
|
foreach (var server in db.GetAllServers()) {
|
||||||
var id = server.Id;
|
var id = server.Id;
|
||||||
@@ -101,113 +96,78 @@ public static class ViewerJsonExport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverIndices[id] = servers.Count;
|
serverIndices[id] = servers.Count;
|
||||||
servers.Add(new Dictionary<string, object> {
|
|
||||||
["name"] = server.Name,
|
servers.Add(new ViewerJson.JsonServer {
|
||||||
["type"] = ServerTypes.ToJsonViewerString(server.Type),
|
Name = server.Name,
|
||||||
|
Type = ServerTypes.ToJsonViewerString(server.Type)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return servers;
|
return servers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<string, object> GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, object> serverIndices) {
|
private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, int> serverIndices) {
|
||||||
var channels = new Dictionary<string, object>();
|
var channels = new Dictionary<Snowflake, ViewerJson.JsonChannel>();
|
||||||
|
|
||||||
foreach (var channel in includedChannels) {
|
foreach (var channel in includedChannels) {
|
||||||
var obj = new Dictionary<string, object> {
|
var channelIdSnowflake = new Snowflake(channel.Id);
|
||||||
["server"] = serverIndices[channel.Server],
|
|
||||||
["name"] = channel.Name,
|
channels[channelIdSnowflake] = new ViewerJson.JsonChannel {
|
||||||
|
Server = serverIndices[channel.Server],
|
||||||
|
Name = channel.Name,
|
||||||
|
Parent = channel.ParentId?.ToString(),
|
||||||
|
Position = channel.Position,
|
||||||
|
Topic = channel.Topic,
|
||||||
|
Nsfw = channel.Nsfw
|
||||||
};
|
};
|
||||||
|
|
||||||
if (channel.ParentId != null) {
|
|
||||||
obj["parent"] = channel.ParentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channel.Position != null) {
|
|
||||||
obj["position"] = channel.Position;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channel.Topic != null) {
|
|
||||||
obj["topic"] = channel.Topic;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channel.Nsfw != null) {
|
|
||||||
obj["nsfw"] = channel.Nsfw;
|
|
||||||
}
|
|
||||||
|
|
||||||
channels[channel.Id.ToString()] = obj;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<string, Dictionary<string, object>> GenerateMessageList( List<Message> includedMessages, Dictionary<ulong, object> userIndices, IViewerExportStrategy strategy) {
|
private static Dictionary<Snowflake, Dictionary<Snowflake, ViewerJson.JsonMessage>> GenerateMessageList(List<Message> includedMessages, Dictionary<ulong, int> userIndices, IViewerExportStrategy strategy) {
|
||||||
var data = new Dictionary<string, Dictionary<string, object>>();
|
var data = new Dictionary<Snowflake, Dictionary<Snowflake, ViewerJson.JsonMessage>>();
|
||||||
|
|
||||||
foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) {
|
foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) {
|
||||||
var channel = grouping.Key.ToString();
|
var channelIdSnowflake = new Snowflake(grouping.Key);
|
||||||
var channelData = new Dictionary<string, object>();
|
var channelData = new Dictionary<Snowflake, ViewerJson.JsonMessage>();
|
||||||
|
|
||||||
foreach (var message in grouping) {
|
foreach (var message in grouping) {
|
||||||
var obj = new Dictionary<string, object> {
|
var messageIdSnowflake = new Snowflake(message.Id);
|
||||||
["u"] = userIndices[message.Sender],
|
|
||||||
["t"] = message.Timestamp,
|
channelData[messageIdSnowflake] = new ViewerJson.JsonMessage {
|
||||||
};
|
U = userIndices[message.Sender],
|
||||||
|
T = message.Timestamp,
|
||||||
if (!string.IsNullOrEmpty(message.Text)) {
|
M = string.IsNullOrEmpty(message.Text) ? null : message.Text,
|
||||||
obj["m"] = message.Text;
|
Te = message.EditTimestamp,
|
||||||
}
|
R = message.RepliedToId?.ToString(),
|
||||||
|
|
||||||
if (message.EditTimestamp != null) {
|
A = message.Attachments.IsEmpty ? null : message.Attachments.Select(attachment => {
|
||||||
obj["te"] = message.EditTimestamp;
|
var a = new ViewerJson.JsonMessageAttachment {
|
||||||
}
|
Url = strategy.GetAttachmentUrl(attachment),
|
||||||
|
Name = Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl
|
||||||
if (message.RepliedToId != null) {
|
|
||||||
obj["r"] = message.RepliedToId.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!message.Attachments.IsEmpty) {
|
|
||||||
obj["a"] = message.Attachments.Select(attachment => {
|
|
||||||
var a = new Dictionary<string, object> {
|
|
||||||
{ "url", strategy.GetAttachmentUrl(attachment) },
|
|
||||||
{ "name", Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (attachment is { Width: not null, Height: not null }) {
|
if (attachment is { Width: not null, Height: not null }) {
|
||||||
a["width"] = attachment.Width;
|
a.Width = attachment.Width;
|
||||||
a["height"] = attachment.Height;
|
a.Height = attachment.Height;
|
||||||
}
|
}
|
||||||
|
|
||||||
return a;
|
return a;
|
||||||
}).ToArray();
|
}).ToArray(),
|
||||||
}
|
|
||||||
|
E = message.Embeds.IsEmpty ? null : message.Embeds.Select(static embed => embed.Json).ToArray(),
|
||||||
if (!message.Embeds.IsEmpty) {
|
|
||||||
obj["e"] = message.Embeds.Select(static embed => embed.Json).ToArray();
|
Re = message.Reactions.IsEmpty ? null : message.Reactions.Select(static reaction => new ViewerJson.JsonMessageReaction {
|
||||||
}
|
Id = reaction.EmojiId?.ToString(),
|
||||||
|
N = reaction.EmojiName,
|
||||||
if (!message.Reactions.IsEmpty) {
|
A = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated),
|
||||||
obj["re"] = message.Reactions.Select(static reaction => {
|
C = reaction.Count
|
||||||
var r = new Dictionary<string, object>();
|
}).ToArray()
|
||||||
|
};
|
||||||
if (reaction.EmojiId != null) {
|
|
||||||
r["id"] = reaction.EmojiId.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reaction.EmojiName != null) {
|
|
||||||
r["n"] = reaction.EmojiName;
|
|
||||||
}
|
|
||||||
|
|
||||||
r["a"] = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated);
|
|
||||||
r["c"] = reaction.Count;
|
|
||||||
return r;
|
|
||||||
}).ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
channelData[message.Id.ToString()] = obj;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data[channel] = channelData;
|
data[channelIdSnowflake] = channelData;
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
@@ -1,15 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Export;
|
|
||||||
|
|
||||||
sealed class ViewerJsonSnowflakeSerializer : JsonConverter<ulong> {
|
|
||||||
public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
|
|
||||||
return ulong.Parse(reader.GetString()!);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Write(Utf8JsonWriter writer, ulong value, JsonSerializerOptions options) {
|
|
||||||
writer.WriteStringValue(value.ToString());
|
|
||||||
}
|
|
||||||
}
|
|
@@ -23,7 +23,7 @@ public interface IDatabaseFile : IDisposable {
|
|||||||
|
|
||||||
void AddMessages(Message[] messages);
|
void AddMessages(Message[] messages);
|
||||||
int CountMessages(MessageFilter? filter = null);
|
int CountMessages(MessageFilter? filter = null);
|
||||||
List<Message> GetMessages(MessageFilter? filter = null);
|
List<Message> GetMessages(MessageFilter? filter = null, bool includeText = true);
|
||||||
HashSet<ulong> GetMessageIds(MessageFilter? filter = null);
|
HashSet<ulong> GetMessageIds(MessageFilter? filter = null);
|
||||||
void RemoveMessages(MessageFilter filter, FilterRemovalMode mode);
|
void RemoveMessages(MessageFilter filter, FilterRemovalMode mode);
|
||||||
|
|
||||||
|
23
app/Server/Database/Import/DiscordEmbedLegacyJson.cs
Normal file
23
app/Server/Database/Import/DiscordEmbedLegacyJson.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DHT.Server.Database.Import;
|
||||||
|
|
||||||
|
sealed class DiscordEmbedLegacyJson {
|
||||||
|
public required string Url { get; init; }
|
||||||
|
public required string Type { get; init; }
|
||||||
|
|
||||||
|
public bool DhtLegacy { get; } = true;
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Title { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Description { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public ImageJson? Image { get; init; }
|
||||||
|
|
||||||
|
public sealed class ImageJson {
|
||||||
|
public required string Url { get; init; }
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,7 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DHT.Server.Database.Import;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, GenerationMode = JsonSourceGenerationMode.Default)]
|
||||||
|
[JsonSerializable(typeof(DiscordEmbedLegacyJson))]
|
||||||
|
sealed partial class DiscordEmbedLegacyJsonContext : JsonSerializerContext {}
|
@@ -21,7 +21,7 @@ public static class LegacyArchiveImport {
|
|||||||
|
|
||||||
public static async Task<bool> Read(Stream stream, IDatabaseFile db, FakeSnowflake fakeSnowflake, Func<Data.Server[], Task<Dictionary<Data.Server, ulong>?>> askForServerIds) {
|
public static async Task<bool> Read(Stream stream, IDatabaseFile db, FakeSnowflake fakeSnowflake, Func<Data.Server[], Task<Dictionary<Data.Server, ulong>?>> askForServerIds) {
|
||||||
var perf = Log.Start();
|
var perf = Log.Start();
|
||||||
var root = await JsonSerializer.DeserializeAsync<JsonElement>(stream);
|
var root = await JsonSerializer.DeserializeAsync(stream, JsonElementContext.Default.JsonElement);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var meta = root.RequireObject("meta");
|
var meta = root.RequireObject("meta");
|
||||||
@@ -212,30 +212,17 @@ public static class LegacyArchiveImport {
|
|||||||
return embedsArray.Where(static embedObj => embedObj.HasKey("url")).Select(embedObj => {
|
return embedsArray.Where(static embedObj => embedObj.HasKey("url")).Select(embedObj => {
|
||||||
string url = embedObj.RequireString("url", path);
|
string url = embedObj.RequireString("url", path);
|
||||||
string type = embedObj.RequireString("type", path);
|
string type = embedObj.RequireString("type", path);
|
||||||
|
|
||||||
var embedJson = new Dictionary<string, object> {
|
var embed = new DiscordEmbedLegacyJson {
|
||||||
{ "url", url },
|
Url = url,
|
||||||
{ "type", type },
|
Type = type,
|
||||||
{ "dht_legacy", true },
|
Title = type == "rich" && embedObj.HasKey("t") ? embedObj.RequireString("t", path) : null,
|
||||||
|
Description = type == "rich" && embedObj.HasKey("d") ? embedObj.RequireString("d", path) : null,
|
||||||
|
Image = type == "image" ? new DiscordEmbedLegacyJson.ImageJson { Url = url } : null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type == "image") {
|
|
||||||
embedJson["image"] = new Dictionary<string, string> {
|
|
||||||
{ "url", url }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else if (type == "rich") {
|
|
||||||
if (embedObj.HasKey("t")) {
|
|
||||||
embedJson["title"] = embedObj.RequireString("t", path);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (embedObj.HasKey("d")) {
|
|
||||||
embedJson["description"] = embedObj.RequireString("d", path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Embed {
|
return new Embed {
|
||||||
Json = JsonSerializer.Serialize(embedJson)
|
Json = JsonSerializer.Serialize(embed, DiscordEmbedLegacyJsonContext.Default.DiscordEmbedLegacyJson)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
15
app/Server/Database/Sqlite/ISchemaUpgradeCallbacks.cs
Normal file
15
app/Server/Database/Sqlite/ISchemaUpgradeCallbacks.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace DHT.Server.Database.Sqlite;
|
||||||
|
|
||||||
|
public interface ISchemaUpgradeCallbacks {
|
||||||
|
Task<bool> CanUpgrade();
|
||||||
|
Task Start(int versionSteps, Func<IProgressReporter, Task> doUpgrade);
|
||||||
|
|
||||||
|
public interface IProgressReporter {
|
||||||
|
Task NextVersion();
|
||||||
|
Task MainWork(string message, int finishedItems, int totalItems);
|
||||||
|
Task SubWork(string message, int finishedItems, int totalItems);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,4 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Database.Exceptions;
|
using DHT.Server.Database.Exceptions;
|
||||||
@@ -20,12 +19,8 @@ sealed class Schema {
|
|||||||
this.conn = conn;
|
this.conn = conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Execute(string sql) {
|
public async Task<bool> Setup(ISchemaUpgradeCallbacks callbacks) {
|
||||||
conn.Command(sql).ExecuteNonQuery();
|
conn.Execute(@"CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)");
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> Setup(Func<Task<bool>> checkCanUpgradeSchemas) {
|
|
||||||
Execute(@"CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)");
|
|
||||||
|
|
||||||
var dbVersionStr = conn.SelectScalar("SELECT value FROM metadata WHERE key = 'version'");
|
var dbVersionStr = conn.SelectScalar("SELECT value FROM metadata WHERE key = 'version'");
|
||||||
if (dbVersionStr == null) {
|
if (dbVersionStr == null) {
|
||||||
@@ -38,131 +33,133 @@ sealed class Schema {
|
|||||||
throw new DatabaseTooNewException(dbVersion);
|
throw new DatabaseTooNewException(dbVersion);
|
||||||
}
|
}
|
||||||
else if (dbVersion < Version) {
|
else if (dbVersion < Version) {
|
||||||
var proceed = await checkCanUpgradeSchemas();
|
var proceed = await callbacks.CanUpgrade();
|
||||||
if (!proceed) {
|
if (!proceed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
UpgradeSchemas(dbVersion);
|
await callbacks.Start(Version - dbVersion, async reporter => await UpgradeSchemas(dbVersion, reporter));
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeSchemas() {
|
private void InitializeSchemas() {
|
||||||
Execute("""
|
conn.Execute("""
|
||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id INTEGER PRIMARY KEY NOT NULL,
|
id INTEGER PRIMARY KEY NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
discriminator TEXT
|
discriminator TEXT
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
Execute("""
|
conn.Execute("""
|
||||||
CREATE TABLE servers (
|
CREATE TABLE servers (
|
||||||
id INTEGER PRIMARY KEY NOT NULL,
|
id INTEGER PRIMARY KEY NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
type TEXT NOT NULL
|
type TEXT NOT NULL
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
Execute("""
|
conn.Execute("""
|
||||||
CREATE TABLE channels (
|
CREATE TABLE channels (
|
||||||
id INTEGER PRIMARY KEY NOT NULL,
|
id INTEGER PRIMARY KEY NOT NULL,
|
||||||
server INTEGER NOT NULL,
|
server INTEGER NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
parent_id INTEGER,
|
parent_id INTEGER,
|
||||||
position INTEGER,
|
position INTEGER,
|
||||||
topic TEXT,
|
topic TEXT,
|
||||||
nsfw INTEGER
|
nsfw INTEGER
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
Execute("""
|
conn.Execute("""
|
||||||
CREATE TABLE messages (
|
CREATE TABLE messages (
|
||||||
message_id INTEGER PRIMARY KEY NOT NULL,
|
message_id INTEGER PRIMARY KEY NOT NULL,
|
||||||
sender_id INTEGER NOT NULL,
|
sender_id INTEGER NOT NULL,
|
||||||
channel_id INTEGER NOT NULL,
|
channel_id INTEGER NOT NULL,
|
||||||
text TEXT NOT NULL,
|
text TEXT NOT NULL,
|
||||||
timestamp INTEGER NOT NULL
|
timestamp INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
Execute("""
|
conn.Execute("""
|
||||||
CREATE TABLE attachments (
|
CREATE TABLE attachments (
|
||||||
message_id INTEGER NOT NULL,
|
message_id INTEGER NOT NULL,
|
||||||
attachment_id INTEGER NOT NULL PRIMARY KEY NOT NULL,
|
attachment_id INTEGER NOT NULL PRIMARY KEY NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
type TEXT,
|
type TEXT,
|
||||||
normalized_url TEXT NOT NULL,
|
normalized_url TEXT NOT NULL,
|
||||||
download_url TEXT,
|
download_url TEXT,
|
||||||
size INTEGER NOT NULL,
|
size INTEGER NOT NULL,
|
||||||
width INTEGER,
|
width INTEGER,
|
||||||
height INTEGER
|
height INTEGER
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
Execute("""
|
conn.Execute("""
|
||||||
CREATE TABLE embeds (
|
CREATE TABLE embeds (
|
||||||
message_id INTEGER NOT NULL,
|
message_id INTEGER NOT NULL,
|
||||||
json TEXT NOT NULL
|
json TEXT NOT NULL
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
Execute("""
|
conn.Execute("""
|
||||||
CREATE TABLE downloads (
|
CREATE TABLE downloads (
|
||||||
normalized_url TEXT NOT NULL PRIMARY KEY,
|
normalized_url TEXT NOT NULL PRIMARY KEY,
|
||||||
download_url TEXT,
|
download_url TEXT,
|
||||||
status INTEGER NOT NULL,
|
status INTEGER NOT NULL,
|
||||||
size INTEGER NOT NULL,
|
size INTEGER NOT NULL,
|
||||||
blob BLOB
|
blob BLOB
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
Execute("""
|
conn.Execute("""
|
||||||
CREATE TABLE reactions (
|
CREATE TABLE reactions (
|
||||||
message_id INTEGER NOT NULL,
|
message_id INTEGER NOT NULL,
|
||||||
emoji_id INTEGER,
|
emoji_id INTEGER,
|
||||||
emoji_name TEXT,
|
emoji_name TEXT,
|
||||||
emoji_flags INTEGER NOT NULL,
|
emoji_flags INTEGER NOT NULL,
|
||||||
count INTEGER NOT NULL
|
count INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
CreateMessageEditTimestampTable();
|
CreateMessageEditTimestampTable();
|
||||||
CreateMessageRepliedToTable();
|
CreateMessageRepliedToTable();
|
||||||
|
|
||||||
Execute("CREATE INDEX attachments_message_ix ON attachments(message_id)");
|
conn.Execute("CREATE INDEX attachments_message_ix ON attachments(message_id)");
|
||||||
Execute("CREATE INDEX embeds_message_ix ON embeds(message_id)");
|
conn.Execute("CREATE INDEX embeds_message_ix ON embeds(message_id)");
|
||||||
Execute("CREATE INDEX reactions_message_ix ON reactions(message_id)");
|
conn.Execute("CREATE INDEX reactions_message_ix ON reactions(message_id)");
|
||||||
|
|
||||||
Execute("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")");
|
conn.Execute("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CreateMessageEditTimestampTable() {
|
private void CreateMessageEditTimestampTable() {
|
||||||
Execute("""
|
conn.Execute("""
|
||||||
CREATE TABLE edit_timestamps (
|
CREATE TABLE edit_timestamps (
|
||||||
message_id INTEGER PRIMARY KEY NOT NULL,
|
message_id INTEGER PRIMARY KEY NOT NULL,
|
||||||
edit_timestamp INTEGER NOT NULL
|
edit_timestamp INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CreateMessageRepliedToTable() {
|
private void CreateMessageRepliedToTable() {
|
||||||
Execute("""
|
conn.Execute("""
|
||||||
CREATE TABLE replied_to (
|
CREATE TABLE replied_to (
|
||||||
message_id INTEGER PRIMARY KEY NOT NULL,
|
message_id INTEGER PRIMARY KEY NOT NULL,
|
||||||
replied_to_id INTEGER NOT NULL
|
replied_to_id INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void NormalizeAttachmentUrls() {
|
private async Task NormalizeAttachmentUrls(ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
||||||
|
await reporter.SubWork("Preparing attachments...", 0, 0);
|
||||||
|
|
||||||
var normalizedUrls = new Dictionary<long, string>();
|
var normalizedUrls = new Dictionary<long, string>();
|
||||||
|
|
||||||
using (var selectCmd = conn.Command("SELECT attachment_id, url FROM attachments")) {
|
await using (var selectCmd = conn.Command("SELECT attachment_id, url FROM attachments")) {
|
||||||
using var reader = selectCmd.ExecuteReader();
|
await using var reader = await selectCmd.ExecuteReaderAsync();
|
||||||
|
|
||||||
while (reader.Read()) {
|
while (reader.Read()) {
|
||||||
var attachmentId = reader.GetInt64(0);
|
var attachmentId = reader.GetInt64(0);
|
||||||
@@ -171,28 +168,39 @@ sealed class Schema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
using var tx = conn.BeginTransaction();
|
await using var tx = conn.BeginTransaction();
|
||||||
|
|
||||||
using (var updateCmd = conn.Command("UPDATE attachments SET download_url = url, url = :normalized_url WHERE attachment_id = :attachment_id")) {
|
int totalUrls = normalizedUrls.Count;
|
||||||
|
int processedUrls = -1;
|
||||||
|
|
||||||
|
await using (var updateCmd = conn.Command("UPDATE attachments SET download_url = url, url = :normalized_url WHERE attachment_id = :attachment_id")) {
|
||||||
updateCmd.Parameters.Add(":attachment_id", SqliteType.Integer);
|
updateCmd.Parameters.Add(":attachment_id", SqliteType.Integer);
|
||||||
updateCmd.Parameters.Add(":normalized_url", SqliteType.Text);
|
updateCmd.Parameters.Add(":normalized_url", SqliteType.Text);
|
||||||
|
|
||||||
foreach (var (attachmentId, normalizedUrl) in normalizedUrls) {
|
foreach (var (attachmentId, normalizedUrl) in normalizedUrls) {
|
||||||
|
if (++processedUrls % 1000 == 0) {
|
||||||
|
await reporter.SubWork("Updating URLs...", processedUrls, totalUrls);
|
||||||
|
}
|
||||||
|
|
||||||
updateCmd.Set(":attachment_id", attachmentId);
|
updateCmd.Set(":attachment_id", attachmentId);
|
||||||
updateCmd.Set(":normalized_url", normalizedUrl);
|
updateCmd.Set(":normalized_url", normalizedUrl);
|
||||||
updateCmd.ExecuteNonQuery();
|
updateCmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.Commit();
|
await reporter.SubWork("Updating URLs...", totalUrls, totalUrls);
|
||||||
|
|
||||||
|
await tx.CommitAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void NormalizeDownloadUrls() {
|
private async Task NormalizeDownloadUrls(ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
||||||
|
await reporter.SubWork("Preparing downloads...", 0, 0);
|
||||||
|
|
||||||
var normalizedUrlsToOriginalUrls = new Dictionary<string, string>();
|
var normalizedUrlsToOriginalUrls = new Dictionary<string, string>();
|
||||||
var duplicateUrlsToDelete = new HashSet<string>();
|
var duplicateUrlsToDelete = new HashSet<string>();
|
||||||
|
|
||||||
using (var selectCmd = conn.Command("SELECT url FROM downloads ORDER BY CASE WHEN status = 200 THEN 0 ELSE 1 END")) {
|
await using (var selectCmd = conn.Command("SELECT url FROM downloads ORDER BY CASE WHEN status = 200 THEN 0 ELSE 1 END")) {
|
||||||
using var reader = selectCmd.ExecuteReader();
|
await using var reader = await selectCmd.ExecuteReaderAsync();
|
||||||
|
|
||||||
while (reader.Read()) {
|
while (reader.Read()) {
|
||||||
var originalUrl = reader.GetString(0);
|
var originalUrl = reader.GetString(0);
|
||||||
@@ -204,96 +212,144 @@ sealed class Schema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
using var tx = conn.BeginTransaction();
|
conn.Execute("PRAGMA cache_size = -20000");
|
||||||
|
|
||||||
|
SqliteTransaction tx;
|
||||||
|
|
||||||
using (var deleteCmd = conn.Delete("downloads", ("url", SqliteType.Text))) {
|
await using (tx = conn.BeginTransaction()) {
|
||||||
foreach (var duplicateUrl in duplicateUrlsToDelete) {
|
await reporter.SubWork("Deleting duplicates...", 0, 0);
|
||||||
deleteCmd.Set(":url", duplicateUrl);
|
|
||||||
deleteCmd.ExecuteNonQuery();
|
await using (var deleteCmd = conn.Delete("downloads", ("url", SqliteType.Text))) {
|
||||||
|
foreach (var duplicateUrl in duplicateUrlsToDelete) {
|
||||||
|
deleteCmd.Set(":url", duplicateUrl);
|
||||||
|
deleteCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
using (var updateCmd = conn.Command("UPDATE downloads SET download_url = :download_url, url = :normalized_url WHERE url = :download_url")) {
|
await tx.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalUrls = normalizedUrlsToOriginalUrls.Count;
|
||||||
|
int processedUrls = -1;
|
||||||
|
|
||||||
|
tx = conn.BeginTransaction();
|
||||||
|
|
||||||
|
await using (var updateCmd = conn.Command("UPDATE downloads SET download_url = :download_url, url = :normalized_url WHERE url = :download_url")) {
|
||||||
updateCmd.Parameters.Add(":normalized_url", SqliteType.Text);
|
updateCmd.Parameters.Add(":normalized_url", SqliteType.Text);
|
||||||
updateCmd.Parameters.Add(":download_url", SqliteType.Text);
|
updateCmd.Parameters.Add(":download_url", SqliteType.Text);
|
||||||
|
|
||||||
foreach (var (normalizedUrl, downloadUrl) in normalizedUrlsToOriginalUrls) {
|
foreach (var (normalizedUrl, downloadUrl) in normalizedUrlsToOriginalUrls) {
|
||||||
|
if (++processedUrls % 100 == 0) {
|
||||||
|
await reporter.SubWork("Updating URLs...", processedUrls, totalUrls);
|
||||||
|
|
||||||
|
// Not proper way of dealing with transactions, but it avoids a long commit at the end.
|
||||||
|
// Schema upgrades are already non-atomic anyways, so this doesn't make it worse.
|
||||||
|
await tx.CommitAsync();
|
||||||
|
await tx.DisposeAsync();
|
||||||
|
|
||||||
|
tx = conn.BeginTransaction();
|
||||||
|
updateCmd.Transaction = tx;
|
||||||
|
}
|
||||||
|
|
||||||
updateCmd.Set(":normalized_url", normalizedUrl);
|
updateCmd.Set(":normalized_url", normalizedUrl);
|
||||||
updateCmd.Set(":download_url", downloadUrl);
|
updateCmd.Set(":download_url", downloadUrl);
|
||||||
updateCmd.ExecuteNonQuery();
|
updateCmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.Commit();
|
await reporter.SubWork("Updating URLs...", totalUrls, totalUrls);
|
||||||
|
|
||||||
|
await tx.CommitAsync();
|
||||||
|
await tx.DisposeAsync();
|
||||||
|
|
||||||
|
conn.Execute("PRAGMA cache_size = -2000");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpgradeSchemas(int dbVersion) {
|
private async Task UpgradeSchemas(int dbVersion, ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
||||||
var perf = Log.Start("from version " + dbVersion);
|
var perf = Log.Start("from version " + dbVersion);
|
||||||
|
|
||||||
Execute("UPDATE metadata SET value = " + Version + " WHERE key = 'version'");
|
conn.Execute("UPDATE metadata SET value = " + Version + " WHERE key = 'version'");
|
||||||
|
|
||||||
if (dbVersion <= 1) {
|
if (dbVersion <= 1) {
|
||||||
Execute("ALTER TABLE channels ADD parent_id INTEGER");
|
await reporter.MainWork("Applying schema changes...", 0, 1);
|
||||||
|
conn.Execute("ALTER TABLE channels ADD parent_id INTEGER");
|
||||||
|
|
||||||
perf.Step("Upgrade to version 2");
|
perf.Step("Upgrade to version 2");
|
||||||
|
await reporter.NextVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dbVersion <= 2) {
|
if (dbVersion <= 2) {
|
||||||
|
await reporter.MainWork("Applying schema changes...", 0, 1);
|
||||||
|
|
||||||
CreateMessageEditTimestampTable();
|
CreateMessageEditTimestampTable();
|
||||||
CreateMessageRepliedToTable();
|
CreateMessageRepliedToTable();
|
||||||
|
|
||||||
Execute("""
|
conn.Execute("""
|
||||||
INSERT INTO edit_timestamps (message_id, edit_timestamp)
|
INSERT INTO edit_timestamps (message_id, edit_timestamp)
|
||||||
SELECT message_id, edit_timestamp
|
SELECT message_id, edit_timestamp
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE edit_timestamp IS NOT NULL
|
WHERE edit_timestamp IS NOT NULL
|
||||||
""");
|
""");
|
||||||
|
|
||||||
Execute("""
|
conn.Execute("""
|
||||||
INSERT INTO replied_to (message_id, replied_to_id)
|
INSERT INTO replied_to (message_id, replied_to_id)
|
||||||
SELECT message_id, replied_to_id
|
SELECT message_id, replied_to_id
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE replied_to_id IS NOT NULL
|
WHERE replied_to_id IS NOT NULL
|
||||||
""");
|
""");
|
||||||
|
|
||||||
Execute("ALTER TABLE messages DROP COLUMN replied_to_id");
|
conn.Execute("ALTER TABLE messages DROP COLUMN replied_to_id");
|
||||||
Execute("ALTER TABLE messages DROP COLUMN edit_timestamp");
|
conn.Execute("ALTER TABLE messages DROP COLUMN edit_timestamp");
|
||||||
|
|
||||||
perf.Step("Upgrade to version 3");
|
perf.Step("Upgrade to version 3");
|
||||||
|
|
||||||
Execute("VACUUM");
|
await reporter.MainWork("Vacuuming the database...", 1, 1);
|
||||||
|
conn.Execute("VACUUM");
|
||||||
perf.Step("Vacuum");
|
perf.Step("Vacuum");
|
||||||
|
|
||||||
|
await reporter.NextVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dbVersion <= 3) {
|
if (dbVersion <= 3) {
|
||||||
Execute("""
|
conn.Execute("""
|
||||||
CREATE TABLE downloads (
|
CREATE TABLE downloads (
|
||||||
url TEXT NOT NULL PRIMARY KEY,
|
url TEXT NOT NULL PRIMARY KEY,
|
||||||
status INTEGER NOT NULL,
|
status INTEGER NOT NULL,
|
||||||
size INTEGER NOT NULL,
|
size INTEGER NOT NULL,
|
||||||
blob BLOB
|
blob BLOB
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
perf.Step("Upgrade to version 4");
|
perf.Step("Upgrade to version 4");
|
||||||
|
await reporter.NextVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dbVersion <= 4) {
|
if (dbVersion <= 4) {
|
||||||
Execute("ALTER TABLE attachments ADD width INTEGER");
|
await reporter.MainWork("Applying schema changes...", 0, 1);
|
||||||
Execute("ALTER TABLE attachments ADD height INTEGER");
|
conn.Execute("ALTER TABLE attachments ADD width INTEGER");
|
||||||
|
conn.Execute("ALTER TABLE attachments ADD height INTEGER");
|
||||||
|
|
||||||
perf.Step("Upgrade to version 5");
|
perf.Step("Upgrade to version 5");
|
||||||
|
await reporter.NextVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dbVersion <= 5) {
|
if (dbVersion <= 5) {
|
||||||
Execute("ALTER TABLE attachments ADD download_url TEXT");
|
await reporter.MainWork("Applying schema changes...", 0, 3);
|
||||||
Execute("ALTER TABLE downloads ADD download_url TEXT");
|
conn.Execute("ALTER TABLE attachments ADD download_url TEXT");
|
||||||
|
conn.Execute("ALTER TABLE downloads ADD download_url TEXT");
|
||||||
|
|
||||||
NormalizeAttachmentUrls();
|
await reporter.MainWork("Updating attachments...", 1, 3);
|
||||||
NormalizeDownloadUrls();
|
await NormalizeAttachmentUrls(reporter);
|
||||||
|
|
||||||
Execute("ALTER TABLE attachments RENAME COLUMN url TO normalized_url");
|
await reporter.MainWork("Updating downloads...", 2, 3);
|
||||||
Execute("ALTER TABLE downloads RENAME COLUMN url TO normalized_url");
|
await NormalizeDownloadUrls(reporter);
|
||||||
|
|
||||||
|
await reporter.MainWork("Applying schema changes...", 3, 3);
|
||||||
|
conn.Execute("ALTER TABLE attachments RENAME COLUMN url TO normalized_url");
|
||||||
|
conn.Execute("ALTER TABLE downloads RENAME COLUMN url TO normalized_url");
|
||||||
|
|
||||||
perf.Step("Upgrade to version 6");
|
perf.Step("Upgrade to version 6");
|
||||||
|
await reporter.NextVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
perf.End();
|
perf.End();
|
||||||
|
@@ -18,7 +18,7 @@ namespace DHT.Server.Database.Sqlite;
|
|||||||
public sealed class SqliteDatabaseFile : IDatabaseFile {
|
public sealed class SqliteDatabaseFile : IDatabaseFile {
|
||||||
private const int DefaultPoolSize = 5;
|
private const int DefaultPoolSize = 5;
|
||||||
|
|
||||||
public static async Task<SqliteDatabaseFile?> OpenOrCreate(string path, Func<Task<bool>> checkCanUpgradeSchemas) {
|
public static async Task<SqliteDatabaseFile?> OpenOrCreate(string path, ISchemaUpgradeCallbacks schemaUpgradeCallbacks, TaskScheduler computeTaskResultScheduler) {
|
||||||
var connectionString = new SqliteConnectionStringBuilder {
|
var connectionString = new SqliteConnectionStringBuilder {
|
||||||
DataSource = path,
|
DataSource = path,
|
||||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||||
@@ -27,12 +27,16 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
|
|||||||
var pool = new SqliteConnectionPool(connectionString, DefaultPoolSize);
|
var pool = new SqliteConnectionPool(connectionString, DefaultPoolSize);
|
||||||
bool wasOpened;
|
bool wasOpened;
|
||||||
|
|
||||||
using (var conn = pool.Take()) {
|
try {
|
||||||
wasOpened = await new Schema(conn).Setup(checkCanUpgradeSchemas);
|
using var conn = pool.Take();
|
||||||
|
wasOpened = await new Schema(conn).Setup(schemaUpgradeCallbacks);
|
||||||
|
} catch (Exception) {
|
||||||
|
pool.Dispose();
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasOpened) {
|
if (wasOpened) {
|
||||||
return new SqliteDatabaseFile(path, pool);
|
return new SqliteDatabaseFile(path, pool, computeTaskResultScheduler);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
pool.Dispose();
|
pool.Dispose();
|
||||||
@@ -49,13 +53,13 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
|
|||||||
private readonly AsyncValueComputer<long>.Single totalAttachmentsComputer;
|
private readonly AsyncValueComputer<long>.Single totalAttachmentsComputer;
|
||||||
private readonly AsyncValueComputer<long>.Single totalDownloadsComputer;
|
private readonly AsyncValueComputer<long>.Single totalDownloadsComputer;
|
||||||
|
|
||||||
private SqliteDatabaseFile(string path, SqliteConnectionPool pool) {
|
private SqliteDatabaseFile(string path, SqliteConnectionPool pool, TaskScheduler computeTaskResultScheduler) {
|
||||||
this.log = Log.ForType(typeof(SqliteDatabaseFile), System.IO.Path.GetFileName(path));
|
this.log = Log.ForType(typeof(SqliteDatabaseFile), System.IO.Path.GetFileName(path));
|
||||||
this.pool = pool;
|
this.pool = pool;
|
||||||
|
|
||||||
this.totalMessagesComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateMessageStatistics).WithOutdatedResults().BuildWithComputer(ComputeMessageStatistics);
|
this.totalMessagesComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateMessageStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeMessageStatistics);
|
||||||
this.totalAttachmentsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateAttachmentStatistics).WithOutdatedResults().BuildWithComputer(ComputeAttachmentStatistics);
|
this.totalAttachmentsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateAttachmentStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeAttachmentStatistics);
|
||||||
this.totalDownloadsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateDownloadStatistics).WithOutdatedResults().BuildWithComputer(ComputeDownloadStatistics);
|
this.totalDownloadsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateDownloadStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeDownloadStatistics);
|
||||||
|
|
||||||
this.Path = path;
|
this.Path = path;
|
||||||
this.Statistics = new DatabaseStatistics();
|
this.Statistics = new DatabaseStatistics();
|
||||||
@@ -356,7 +360,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
|
|||||||
return reader.Read() ? reader.GetInt32(0) : 0;
|
return reader.Read() ? reader.GetInt32(0) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Message> GetMessages(MessageFilter? filter = null) {
|
public List<Message> GetMessages(MessageFilter? filter = null, bool includeText = true) {
|
||||||
var perf = log.Start();
|
var perf = log.Start();
|
||||||
var list = new List<Message>();
|
var list = new List<Message>();
|
||||||
|
|
||||||
@@ -366,7 +370,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
|
|||||||
|
|
||||||
using var conn = pool.Take();
|
using var conn = pool.Take();
|
||||||
using var cmd = conn.Command($"""
|
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
|
SELECT m.message_id, m.sender_id, m.channel_id, {(includeText ? "m.text" : "NULL")}, m.timestamp, et.edit_timestamp, rt.replied_to_id
|
||||||
FROM messages m
|
FROM messages m
|
||||||
LEFT JOIN edit_timestamps et ON m.message_id = et.message_id
|
LEFT JOIN edit_timestamps et ON m.message_id = et.message_id
|
||||||
LEFT JOIN replied_to rt ON m.message_id = rt.message_id
|
LEFT JOIN replied_to rt ON m.message_id = rt.message_id
|
||||||
@@ -381,7 +385,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
|
|||||||
Id = id,
|
Id = id,
|
||||||
Sender = reader.GetUint64(1),
|
Sender = reader.GetUint64(1),
|
||||||
Channel = reader.GetUint64(2),
|
Channel = reader.GetUint64(2),
|
||||||
Text = reader.GetString(3),
|
Text = includeText ? reader.GetString(3) : string.Empty,
|
||||||
Timestamp = reader.GetInt64(4),
|
Timestamp = reader.GetInt64(4),
|
||||||
EditTimestamp = reader.IsDBNull(5) ? null : reader.GetInt64(5),
|
EditTimestamp = reader.IsDBNull(5) ? null : reader.GetInt64(5),
|
||||||
RepliedToId = reader.IsDBNull(6) ? null : reader.GetUint64(6),
|
RepliedToId = reader.IsDBNull(6) ? null : reader.GetUint64(6),
|
||||||
|
@@ -5,14 +5,19 @@ using Microsoft.Data.Sqlite;
|
|||||||
namespace DHT.Server.Database.Sqlite.Utils;
|
namespace DHT.Server.Database.Sqlite.Utils;
|
||||||
|
|
||||||
static class SqliteExtensions {
|
static class SqliteExtensions {
|
||||||
|
public static SqliteTransaction BeginTransaction(this ISqliteConnection conn) {
|
||||||
|
return conn.InnerConnection.BeginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
public static SqliteCommand Command(this ISqliteConnection conn, string sql) {
|
public static SqliteCommand Command(this ISqliteConnection conn, string sql) {
|
||||||
var cmd = conn.InnerConnection.CreateCommand();
|
var cmd = conn.InnerConnection.CreateCommand();
|
||||||
cmd.CommandText = sql;
|
cmd.CommandText = sql;
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SqliteTransaction BeginTransaction(this ISqliteConnection conn) {
|
public static void Execute(this ISqliteConnection conn, string sql) {
|
||||||
return conn.InnerConnection.BeginTransaction();
|
using var cmd = conn.Command(sql);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static object? SelectScalar(this ISqliteConnection conn, string sql) {
|
public static object? SelectScalar(this ISqliteConnection conn, string sql) {
|
||||||
|
@@ -3,12 +3,9 @@ using System.Net;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Service;
|
|
||||||
using DHT.Utils.Http;
|
using DHT.Utils.Http;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Http.Extensions;
|
|
||||||
using Microsoft.Extensions.Primitives;
|
|
||||||
|
|
||||||
namespace DHT.Server.Endpoints;
|
namespace DHT.Server.Endpoints;
|
||||||
|
|
||||||
@@ -16,25 +13,14 @@ abstract class BaseEndpoint {
|
|||||||
private static readonly Log Log = Log.ForType<BaseEndpoint>();
|
private static readonly Log Log = Log.ForType<BaseEndpoint>();
|
||||||
|
|
||||||
protected IDatabaseFile Db { get; }
|
protected IDatabaseFile Db { get; }
|
||||||
protected ServerParameters Parameters { get; }
|
|
||||||
|
|
||||||
protected BaseEndpoint(IDatabaseFile db, ServerParameters parameters) {
|
protected BaseEndpoint(IDatabaseFile db) {
|
||||||
this.Db = db;
|
this.Db = db;
|
||||||
this.Parameters = parameters;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Handle(HttpContext ctx, StringValues token) {
|
public async Task Handle(HttpContext ctx) {
|
||||||
var request = ctx.Request;
|
|
||||||
var response = ctx.Response;
|
var response = ctx.Response;
|
||||||
|
|
||||||
Log.Info("Request: " + request.GetDisplayUrl() + " (" + request.ContentLength + " B)");
|
|
||||||
|
|
||||||
if (token.Count != 1 || token[0] != Parameters.Token) {
|
|
||||||
Log.Error("Token: " + (token.Count == 1 ? token[0] : "<missing>"));
|
|
||||||
response.StatusCode = (int) HttpStatusCode.Forbidden;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response.StatusCode = (int) HttpStatusCode.OK;
|
response.StatusCode = (int) HttpStatusCode.OK;
|
||||||
var output = await Respond(ctx);
|
var output = await Respond(ctx);
|
||||||
@@ -49,17 +35,13 @@ abstract class BaseEndpoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task HandleGet(HttpContext ctx) {
|
|
||||||
await Handle(ctx, ctx.Request.Query["token"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task HandlePost(HttpContext ctx) {
|
|
||||||
await Handle(ctx, ctx.Request.Headers["X-DHT-Token"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract Task<IHttpOutput> Respond(HttpContext ctx);
|
protected abstract Task<IHttpOutput> Respond(HttpContext ctx);
|
||||||
|
|
||||||
protected static async Task<JsonElement> ReadJson(HttpContext ctx) {
|
protected static async Task<JsonElement> ReadJson(HttpContext ctx) {
|
||||||
return await ctx.Request.ReadFromJsonAsync<JsonElement?>() ?? throw new HttpException(HttpStatusCode.UnsupportedMediaType, "This endpoint only accepts JSON.");
|
try {
|
||||||
|
return await ctx.Request.ReadFromJsonAsync(JsonElementContext.Default.JsonElement);
|
||||||
|
} catch (JsonException) {
|
||||||
|
throw new HttpException(HttpStatusCode.UnsupportedMediaType, "This endpoint only accepts JSON.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,14 +2,13 @@ using System.Net;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Service;
|
|
||||||
using DHT.Utils.Http;
|
using DHT.Utils.Http;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
namespace DHT.Server.Endpoints;
|
namespace DHT.Server.Endpoints;
|
||||||
|
|
||||||
sealed class GetAttachmentEndpoint : BaseEndpoint {
|
sealed class GetAttachmentEndpoint : BaseEndpoint {
|
||||||
public GetAttachmentEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
|
public GetAttachmentEndpoint(IDatabaseFile db) : base(db) {}
|
||||||
|
|
||||||
protected override Task<IHttpOutput> Respond(HttpContext ctx) {
|
protected override Task<IHttpOutput> Respond(HttpContext ctx) {
|
||||||
string attachmentUrl = WebUtility.UrlDecode((string) ctx.Request.RouteValues["url"]!);
|
string attachmentUrl = WebUtility.UrlDecode((string) ctx.Request.RouteValues["url"]!);
|
||||||
|
34
app/Server/Endpoints/GetMessagesEndpoint.cs
Normal file
34
app/Server/Endpoints/GetMessagesEndpoint.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DHT.Server.Data.Filters;
|
||||||
|
using DHT.Server.Database;
|
||||||
|
using DHT.Utils.Http;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using GetMessagesJsonContext = DHT.Server.Endpoints.Responses.GetMessagesJsonContext;
|
||||||
|
|
||||||
|
namespace DHT.Server.Endpoints;
|
||||||
|
|
||||||
|
sealed class GetMessagesEndpoint : BaseEndpoint {
|
||||||
|
public GetMessagesEndpoint(IDatabaseFile db) : base(db) {}
|
||||||
|
|
||||||
|
protected override Task<IHttpOutput> Respond(HttpContext ctx) {
|
||||||
|
HashSet<ulong> messageIdSet;
|
||||||
|
try {
|
||||||
|
var messageIds = ctx.Request.Query["id"];
|
||||||
|
messageIdSet = messageIds.Select(ulong.Parse!).ToHashSet();
|
||||||
|
} catch (Exception) {
|
||||||
|
throw new HttpException(HttpStatusCode.BadRequest, "Invalid message ids.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var messageFilter = new MessageFilter {
|
||||||
|
MessageIds = messageIdSet
|
||||||
|
};
|
||||||
|
|
||||||
|
var messages = Db.GetMessages(messageFilter).ToDictionary(static message => message.Id, static message => message.Text);
|
||||||
|
var response = new HttpOutput.Json<Dictionary<ulong, string>>(messages, GetMessagesJsonContext.Default.DictionaryUInt64String);
|
||||||
|
return Task.FromResult<IHttpOutput>(response);
|
||||||
|
}
|
||||||
|
}
|
@@ -13,12 +13,16 @@ namespace DHT.Server.Endpoints;
|
|||||||
sealed class GetTrackingScriptEndpoint : BaseEndpoint {
|
sealed class GetTrackingScriptEndpoint : BaseEndpoint {
|
||||||
private static ResourceLoader Resources { get; } = new (Assembly.GetExecutingAssembly());
|
private static ResourceLoader Resources { get; } = new (Assembly.GetExecutingAssembly());
|
||||||
|
|
||||||
public GetTrackingScriptEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
|
private readonly ServerParameters serverParameters;
|
||||||
|
|
||||||
|
public GetTrackingScriptEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db) {
|
||||||
|
serverParameters = parameters;
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
|
protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
|
||||||
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
|
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
|
||||||
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + Parameters.Port + ";")
|
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + serverParameters.Port + ";")
|
||||||
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(Parameters.Token))
|
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(serverParameters.Token))
|
||||||
.Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
|
.Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
|
||||||
.Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css"))
|
.Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css"))
|
||||||
.Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css"))
|
.Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css"))
|
||||||
|
8
app/Server/Endpoints/Responses/GetMessagesJsonContext.cs
Normal file
8
app/Server/Endpoints/Responses/GetMessagesJsonContext.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DHT.Server.Endpoints.Responses;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, GenerationMode = JsonSourceGenerationMode.Default)]
|
||||||
|
[JsonSerializable(typeof(Dictionary<ulong, string>))]
|
||||||
|
sealed partial class GetMessagesJsonContext : JsonSerializerContext {}
|
@@ -3,14 +3,13 @@ using System.Text.Json;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Service;
|
|
||||||
using DHT.Utils.Http;
|
using DHT.Utils.Http;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
namespace DHT.Server.Endpoints;
|
namespace DHT.Server.Endpoints;
|
||||||
|
|
||||||
sealed class TrackChannelEndpoint : BaseEndpoint {
|
sealed class TrackChannelEndpoint : BaseEndpoint {
|
||||||
public TrackChannelEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
|
public TrackChannelEndpoint(IDatabaseFile db) : base(db) {}
|
||||||
|
|
||||||
protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
|
protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
|
||||||
var root = await ReadJson(ctx);
|
var root = await ReadJson(ctx);
|
||||||
|
@@ -9,7 +9,6 @@ using DHT.Server.Data;
|
|||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Download;
|
using DHT.Server.Download;
|
||||||
using DHT.Server.Service;
|
|
||||||
using DHT.Utils.Collections;
|
using DHT.Utils.Collections;
|
||||||
using DHT.Utils.Http;
|
using DHT.Utils.Http;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -17,7 +16,10 @@ using Microsoft.AspNetCore.Http;
|
|||||||
namespace DHT.Server.Endpoints;
|
namespace DHT.Server.Endpoints;
|
||||||
|
|
||||||
sealed class TrackMessagesEndpoint : BaseEndpoint {
|
sealed class TrackMessagesEndpoint : BaseEndpoint {
|
||||||
public TrackMessagesEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
|
private const string HasNewMessages = "1";
|
||||||
|
private const string NoNewMessages = "0";
|
||||||
|
|
||||||
|
public TrackMessagesEndpoint(IDatabaseFile db) : base(db) {}
|
||||||
|
|
||||||
protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
|
protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
|
||||||
var root = await ReadJson(ctx);
|
var root = await ReadJson(ctx);
|
||||||
@@ -41,7 +43,7 @@ sealed class TrackMessagesEndpoint : BaseEndpoint {
|
|||||||
|
|
||||||
Db.AddMessages(messages);
|
Db.AddMessages(messages);
|
||||||
|
|
||||||
return new HttpOutput.Json(anyNewMessages ? 1 : 0);
|
return new HttpOutput.Text(anyNewMessages ? HasNewMessages : NoNewMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Message ReadMessage(JsonElement json, string path) => new() {
|
private static Message ReadMessage(JsonElement json, string path) => new() {
|
||||||
|
@@ -3,14 +3,13 @@ using System.Text.Json;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Service;
|
|
||||||
using DHT.Utils.Http;
|
using DHT.Utils.Http;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
namespace DHT.Server.Endpoints;
|
namespace DHT.Server.Endpoints;
|
||||||
|
|
||||||
sealed class TrackUsersEndpoint : BaseEndpoint {
|
sealed class TrackUsersEndpoint : BaseEndpoint {
|
||||||
public TrackUsersEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
|
public TrackUsersEndpoint(IDatabaseFile db) : base(db) {}
|
||||||
|
|
||||||
protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
|
protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
|
||||||
var root = await ReadJson(ctx);
|
var root = await ReadJson(ctx);
|
||||||
|
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
|
||||||
|
namespace DHT.Server.Service.Middlewares;
|
||||||
|
|
||||||
|
sealed class ServerAuthorizationMiddleware {
|
||||||
|
private static readonly Log Log = Log.ForType<ServerAuthorizationMiddleware>();
|
||||||
|
|
||||||
|
private readonly RequestDelegate next;
|
||||||
|
private readonly ServerParameters serverParameters;
|
||||||
|
|
||||||
|
public ServerAuthorizationMiddleware(RequestDelegate next, ServerParameters serverParameters) {
|
||||||
|
this.next = next;
|
||||||
|
this.serverParameters = serverParameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context) {
|
||||||
|
var request = context.Request;
|
||||||
|
|
||||||
|
bool success = HttpMethods.IsGet(request.Method)
|
||||||
|
? CheckToken(request.Query["token"])
|
||||||
|
: CheckToken(request.Headers["X-DHT-Token"]);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await next(context);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
context.Response.StatusCode = (int) HttpStatusCode.Forbidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CheckToken(StringValues token) {
|
||||||
|
if (token.Count == 1 && token[0] == serverParameters.Token) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Log.Error("Invalid token: " + (token.Count == 1 ? token[0] : "<missing>"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
29
app/Server/Service/Middlewares/ServerLoggingMiddleware.cs
Normal file
29
app/Server/Service/Middlewares/ServerLoggingMiddleware.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Http.Extensions;
|
||||||
|
|
||||||
|
namespace DHT.Server.Service.Middlewares;
|
||||||
|
|
||||||
|
sealed class ServerLoggingMiddleware {
|
||||||
|
private static readonly Log Log = Log.ForType<ServerLoggingMiddleware>();
|
||||||
|
|
||||||
|
private readonly RequestDelegate next;
|
||||||
|
|
||||||
|
public ServerLoggingMiddleware(RequestDelegate next) {
|
||||||
|
this.next = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context) {
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
await next(context);
|
||||||
|
stopwatch.Stop();
|
||||||
|
|
||||||
|
var request = context.Request;
|
||||||
|
var requestLength = request.ContentLength ?? 0L;
|
||||||
|
var responseStatus = context.Response.StatusCode;
|
||||||
|
var elapsedMs = stopwatch.ElapsedMilliseconds;
|
||||||
|
Log.Debug("Request to " + request.GetEncodedPathAndQuery() + " (" + requestLength + " B) returned " + responseStatus + ", took " + elapsedMs + " ms");
|
||||||
|
}
|
||||||
|
}
|
@@ -4,7 +4,6 @@ using System.Diagnostics.CodeAnalysis;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using Microsoft.AspNetCore;
|
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -75,11 +74,11 @@ public static class ServerLauncher {
|
|||||||
options.ListenLocalhost(port, static listenOptions => listenOptions.Protocols = HttpProtocols.Http1);
|
options.ListenLocalhost(port, static listenOptions => listenOptions.Protocols = HttpProtocols.Http1);
|
||||||
}
|
}
|
||||||
|
|
||||||
Server = WebHost.CreateDefaultBuilder()
|
Server = new WebHostBuilder()
|
||||||
.ConfigureServices(AddServices)
|
.ConfigureServices(AddServices)
|
||||||
.UseKestrel(SetKestrelOptions)
|
.UseKestrel(SetKestrelOptions)
|
||||||
.UseStartup<Startup>()
|
.UseStartup<Startup>()
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
Server.Start();
|
Server.Start();
|
||||||
|
|
||||||
|
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Endpoints;
|
using DHT.Server.Endpoints;
|
||||||
|
using DHT.Server.Service.Middlewares;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http.Json;
|
using Microsoft.AspNetCore.Http.Json;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -15,6 +16,7 @@ sealed class Startup {
|
|||||||
"https://ptb.discord.com",
|
"https://ptb.discord.com",
|
||||||
"https://canary.discord.com",
|
"https://canary.discord.com",
|
||||||
"https://discordapp.com",
|
"https://discordapp.com",
|
||||||
|
"null" // For file:// protocol in the Viewer
|
||||||
};
|
};
|
||||||
|
|
||||||
public void ConfigureServices(IServiceCollection services) {
|
public void ConfigureServices(IServiceCollection services) {
|
||||||
@@ -27,27 +29,24 @@ sealed class Startup {
|
|||||||
builder.WithOrigins(AllowedOrigins).AllowCredentials().AllowAnyMethod().AllowAnyHeader().WithExposedHeaders("X-DHT");
|
builder.WithOrigins(AllowedOrigins).AllowCredentials().AllowAnyMethod().AllowAnyHeader().WithExposedHeaders("X-DHT");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
services.AddRoutingCore();
|
||||||
}
|
}
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
||||||
public void Configure(IApplicationBuilder app, IHostApplicationLifetime lifetime, IDatabaseFile db, ServerParameters parameters) {
|
public void Configure(IApplicationBuilder app, IHostApplicationLifetime lifetime, IDatabaseFile db, ServerParameters parameters) {
|
||||||
app.UseRouting();
|
app.UseMiddleware<ServerLoggingMiddleware>();
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
|
app.UseMiddleware<ServerAuthorizationMiddleware>();
|
||||||
|
app.UseRouting();
|
||||||
|
|
||||||
app.UseEndpoints(endpoints => {
|
app.UseEndpoints(endpoints => {
|
||||||
GetTrackingScriptEndpoint getTrackingScript = new (db, parameters);
|
endpoints.MapGet("/get-tracking-script", new GetTrackingScriptEndpoint(db, parameters).Handle);
|
||||||
endpoints.MapGet("/get-tracking-script", context => getTrackingScript.HandleGet(context));
|
endpoints.MapGet("/get-messages", new GetMessagesEndpoint(db).Handle);
|
||||||
|
endpoints.MapGet("/get-attachment/{url}", new GetAttachmentEndpoint(db).Handle);
|
||||||
TrackChannelEndpoint trackChannel = new (db, parameters);
|
endpoints.MapPost("/track-channel", new TrackChannelEndpoint(db).Handle);
|
||||||
endpoints.MapPost("/track-channel", context => trackChannel.HandlePost(context));
|
endpoints.MapPost("/track-users", new TrackUsersEndpoint(db).Handle);
|
||||||
|
endpoints.MapPost("/track-messages", new TrackMessagesEndpoint(db).Handle);
|
||||||
TrackUsersEndpoint trackUsers = new (db, parameters);
|
|
||||||
endpoints.MapPost("/track-users", context => trackUsers.HandlePost(context));
|
|
||||||
|
|
||||||
TrackMessagesEndpoint trackMessages = new (db, parameters);
|
|
||||||
endpoints.MapPost("/track-messages", context => trackMessages.HandlePost(context));
|
|
||||||
|
|
||||||
GetAttachmentEndpoint getAttachment = new (db, parameters);
|
|
||||||
endpoints.MapGet("/get-attachment/{url}", context => getAttachment.HandleGet(context));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Serialization.Metadata;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
@@ -12,15 +14,29 @@ public static class HttpOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class Json : IHttpOutput {
|
public sealed class Text : IHttpOutput {
|
||||||
private readonly object? obj;
|
private readonly string text;
|
||||||
|
|
||||||
public Json(object? obj) {
|
public Text(string text) {
|
||||||
this.obj = obj;
|
this.text = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task WriteTo(HttpResponse response) {
|
public Task WriteTo(HttpResponse response) {
|
||||||
return response.WriteAsJsonAsync(obj);
|
return response.WriteAsync(text, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Json<TValue> : IHttpOutput {
|
||||||
|
private readonly TValue value;
|
||||||
|
private readonly JsonTypeInfo<TValue> typeInfo;
|
||||||
|
|
||||||
|
public Json(TValue value, JsonTypeInfo<TValue> typeInfo) {
|
||||||
|
this.value = value;
|
||||||
|
this.typeInfo = typeInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task WriteTo(HttpResponse response) {
|
||||||
|
return response.WriteAsJsonAsync(value, typeInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
8
app/Utils/Http/JsonElementContext.cs
Normal file
8
app/Utils/Http/JsonElementContext.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DHT.Utils.Http;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, GenerationMode = JsonSourceGenerationMode.Default)]
|
||||||
|
[JsonSerializable(typeof(JsonElement))]
|
||||||
|
public sealed partial class JsonElementContext : JsonSerializerContext {}
|
13
app/Utils/Logging/WindowsConsole.cs
Normal file
13
app/Utils/Logging/WindowsConsole.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
|
||||||
|
namespace DHT.Utils.Logging;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public static partial class WindowsConsole {
|
||||||
|
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||||
|
public static partial void AllocConsole();
|
||||||
|
|
||||||
|
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||||
|
public static partial void FreeConsole();
|
||||||
|
}
|
@@ -6,6 +6,10 @@
|
|||||||
<PackageId>DiscordHistoryTrackerUtils</PackageId>
|
<PackageId>DiscordHistoryTrackerUtils</PackageId>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
@@ -8,5 +8,5 @@ using DHT.Utils;
|
|||||||
namespace DHT.Utils;
|
namespace DHT.Utils;
|
||||||
|
|
||||||
static class Version {
|
static class Version {
|
||||||
public const string Tag = "39.0.0.0";
|
public const string Tag = "39.1.0.0";
|
||||||
}
|
}
|
||||||
|
@@ -4,11 +4,11 @@ set list=win-x64 linux-x64 osx-x64
|
|||||||
rmdir /S /Q bin
|
rmdir /S /Q bin
|
||||||
|
|
||||||
(for %%a in (%list%) do (
|
(for %%a in (%list%) do (
|
||||||
dotnet publish Desktop -c Release -r %%a -o ./bin/%%a -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishReadyToRun=false --self-contained true
|
dotnet publish Desktop -c Release -r %%a -o ./bin/%%a --self-contained true
|
||||||
powershell "Compress-Archive -Path ./bin/%%a/* -DestinationPath ./bin/%%a.zip -CompressionLevel Optimal"
|
powershell "Compress-Archive -Path ./bin/%%a/* -DestinationPath ./bin/%%a.zip -CompressionLevel Optimal"
|
||||||
))
|
))
|
||||||
|
|
||||||
dotnet publish Desktop -c Release -o ./bin/portable -p:PublishTrimmed=false --self-contained false
|
dotnet publish Desktop -c Release -o ./bin/portable -p:PublishSingleFile=false -p:PublishTrimmed=false --self-contained false
|
||||||
powershell "Compress-Archive -Path ./bin/portable/* -DestinationPath ./bin/portable.zip -CompressionLevel Optimal"
|
powershell "Compress-Archive -Path ./bin/portable/* -DestinationPath ./bin/portable.zip -CompressionLevel Optimal"
|
||||||
|
|
||||||
echo Done
|
echo Done
|
||||||
|
@@ -17,9 +17,9 @@ rm -rf "./bin"
|
|||||||
configurations=(win-x64 linux-x64 osx-x64)
|
configurations=(win-x64 linux-x64 osx-x64)
|
||||||
|
|
||||||
for cfg in ${configurations[@]}; do
|
for cfg in ${configurations[@]}; do
|
||||||
dotnet publish Desktop -c Release -r "$cfg" -o "./bin/$cfg" -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishReadyToRun=false --self-contained true
|
dotnet publish Desktop -c Release -r "$cfg" -o "./bin/$cfg" --self-contained true
|
||||||
makezip "$cfg"
|
makezip "$cfg"
|
||||||
done
|
done
|
||||||
|
|
||||||
dotnet publish Desktop -c Release -o "./bin/portable" -p:PublishTrimmed=false --self-contained false
|
dotnet publish Desktop -c Release -o "./bin/portable" -p:PublishSingleFile=false -p:PublishTrimmed=false --self-contained false
|
||||||
makezip "portable"
|
makezip "portable"
|
||||||
|
Reference in New Issue
Block a user