mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-09-15 19:32:09 +02:00
Compare commits
1 Commits
e0f359c15b
...
wip-viewer
Author | SHA1 | Date | |
---|---|---|---|
b660af4be0
|
@@ -11,7 +11,6 @@ using DHT.Desktop.Dialogs.Message;
|
|||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Exceptions;
|
using DHT.Server.Database.Exceptions;
|
||||||
using DHT.Server.Database.Sqlite;
|
using DHT.Server.Database.Sqlite;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop.Common;
|
namespace DHT.Desktop.Common;
|
||||||
@@ -21,9 +20,9 @@ static class DatabaseGui {
|
|||||||
|
|
||||||
private const string DatabaseFileInitialName = "archive.dht";
|
private const string DatabaseFileInitialName = "archive.dht";
|
||||||
|
|
||||||
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = [
|
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = new List<FilePickerFileType> {
|
||||||
FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"])
|
FileDialogs.CreateFilter("Discord History Tracker Database", new [] { "dht" })
|
||||||
];
|
};
|
||||||
|
|
||||||
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
|
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
|
||||||
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||||
|
@@ -21,7 +21,6 @@
|
|||||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.6" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.0.6" />
|
||||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " />
|
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
|
||||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.6" />
|
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
@@ -4,6 +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 UpdateIndeterminate(string message);
|
|
||||||
Task Hide();
|
Task Hide();
|
||||||
}
|
}
|
||||||
|
@@ -40,7 +40,7 @@
|
|||||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
|
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
|
||||||
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
<ProgressBar IsIndeterminate="{Binding IsIndeterminate}" Value="{Binding Progress}" />
|
<ProgressBar Value="{Binding Progress}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsRepeater.ItemTemplate>
|
</ItemsRepeater.ItemTemplate>
|
||||||
|
@@ -7,58 +7,6 @@ namespace DHT.Desktop.Dialogs.Progress;
|
|||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class ProgressDialog : Window {
|
public sealed partial class ProgressDialog : Window {
|
||||||
internal static async Task Show(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task> action) {
|
|
||||||
var taskCompletionSource = new TaskCompletionSource();
|
|
||||||
var dialog = new ProgressDialog();
|
|
||||||
|
|
||||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
|
||||||
try {
|
|
||||||
await action(dialog, callbacks[0]);
|
|
||||||
taskCompletionSource.SetResult();
|
|
||||||
} catch (Exception e) {
|
|
||||||
taskCompletionSource.SetException(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await dialog.ShowProgressDialog(owner);
|
|
||||||
await taskCompletionSource.Task;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static async Task ShowIndeterminate(Window owner, string title, string message, Func<ProgressDialog, Task> action) {
|
|
||||||
var taskCompletionSource = new TaskCompletionSource();
|
|
||||||
var dialog = new ProgressDialog();
|
|
||||||
|
|
||||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
|
||||||
await callbacks[0].UpdateIndeterminate(message);
|
|
||||||
try {
|
|
||||||
await action(dialog);
|
|
||||||
taskCompletionSource.SetResult();
|
|
||||||
} catch (Exception e) {
|
|
||||||
taskCompletionSource.SetException(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await dialog.ShowProgressDialog(owner);
|
|
||||||
await taskCompletionSource.Task;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static async Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
|
|
||||||
var taskCompletionSource = new TaskCompletionSource<T>();
|
|
||||||
var dialog = new ProgressDialog();
|
|
||||||
|
|
||||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
|
||||||
await callbacks[0].UpdateIndeterminate(message);
|
|
||||||
try {
|
|
||||||
taskCompletionSource.SetResult(await action(dialog));
|
|
||||||
} catch (Exception e) {
|
|
||||||
taskCompletionSource.SetException(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await dialog.ShowProgressDialog(owner);
|
|
||||||
return await taskCompletionSource.Task;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool isFinished = false;
|
private bool isFinished = false;
|
||||||
private Task progressTask = Task.CompletedTask;
|
private Task progressTask = Task.CompletedTask;
|
||||||
|
|
||||||
|
@@ -18,10 +18,9 @@ sealed class ProgressDialogModel : BaseModel {
|
|||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public ProgressDialogModel() {}
|
public ProgressDialogModel() {}
|
||||||
|
|
||||||
public ProgressDialogModel(string title, TaskRunner task, int progressItems = 1) {
|
public ProgressDialogModel(TaskRunner task, int progressItems = 1) {
|
||||||
this.Title = title;
|
|
||||||
this.task = task;
|
|
||||||
this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
|
this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
|
||||||
|
this.task = task;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task StartTask() {
|
internal async Task StartTask() {
|
||||||
@@ -44,16 +43,6 @@ sealed class ProgressDialogModel : BaseModel {
|
|||||||
item.Message = message;
|
item.Message = message;
|
||||||
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
|
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
|
||||||
item.Progress = totalItems == 0 ? 0 : 100 * finishedItems / totalItems;
|
item.Progress = totalItems == 0 ? 0 : 100 * finishedItems / totalItems;
|
||||||
item.IsIndeterminate = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateIndeterminate(string message) {
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => {
|
|
||||||
item.Message = message;
|
|
||||||
item.Items = string.Empty;
|
|
||||||
item.Progress = 0;
|
|
||||||
item.IsIndeterminate = true;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Progress;
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
|
|
||||||
sealed class ProgressItem : BaseModel {
|
sealed class ProgressItem : BaseModel {
|
||||||
private bool isVisible = false;
|
private bool isVisible = false;
|
||||||
@@ -38,11 +38,4 @@ sealed class ProgressItem : BaseModel {
|
|||||||
get => progress;
|
get => progress;
|
||||||
set => Change(ref progress, value);
|
set => Change(ref progress, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool isIndeterminate;
|
|
||||||
|
|
||||||
public bool IsIndeterminate {
|
|
||||||
get => isIndeterminate;
|
|
||||||
set => Change(ref isIndeterminate, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -3,7 +3,7 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.TextBox;
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class TextBoxDialog : Window {
|
public sealed partial class TextBoxDialog : Window {
|
||||||
|
@@ -4,7 +4,7 @@ using System.ComponentModel;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.TextBox;
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
|
|
||||||
class TextBoxDialogModel : BaseModel {
|
class TextBoxDialogModel : BaseModel {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
|
@@ -3,7 +3,7 @@ using System.Collections;
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.TextBox;
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
|
|
||||||
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
|
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
|
@@ -39,8 +39,7 @@ static class DiscordAppSettings {
|
|||||||
|
|
||||||
public static async Task<bool?> AreDevToolsEnabled() {
|
public static async Task<bool?> AreDevToolsEnabled() {
|
||||||
try {
|
try {
|
||||||
var settingsJson = await ReadSettingsJson().ConfigureAwait(false);
|
return AreDevToolsEnabled(await ReadSettingsJson());
|
||||||
return AreDevToolsEnabled(settingsJson);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.Error("Cannot read settings file.");
|
Log.Error("Cannot read settings file.");
|
||||||
Log.Error(e);
|
Log.Error(e);
|
||||||
|
@@ -1,9 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Server;
|
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
@@ -14,17 +12,17 @@ namespace DHT.Desktop.Main.Controls;
|
|||||||
sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
|
sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
|
||||||
public sealed record Unit(string Name, uint Scale);
|
public sealed record Unit(string Name, uint Scale);
|
||||||
|
|
||||||
private static readonly Unit[] AllUnits = [
|
private static readonly Unit[] AllUnits = {
|
||||||
new Unit("B", 1),
|
new ("B", 1),
|
||||||
new Unit("kB", 1024),
|
new ("kB", 1024),
|
||||||
new Unit("MB", 1024 * 1024)
|
new ("MB", 1024 * 1024)
|
||||||
];
|
};
|
||||||
|
|
||||||
private static readonly HashSet<string> FilterProperties = [
|
private static readonly HashSet<string> FilterProperties = new () {
|
||||||
nameof(LimitSize),
|
nameof(LimitSize),
|
||||||
nameof(MaximumSize),
|
nameof(MaximumSize),
|
||||||
nameof(MaximumSizeUnit)
|
nameof(MaximumSizeUnit)
|
||||||
];
|
};
|
||||||
|
|
||||||
public string FilterStatisticsText { get; private set; } = "";
|
public string FilterStatisticsText { get; private set; } = "";
|
||||||
|
|
||||||
@@ -49,30 +47,30 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
|
|||||||
|
|
||||||
public IEnumerable<Unit> Units => AllUnits;
|
public IEnumerable<Unit> Units => AllUnits;
|
||||||
|
|
||||||
private readonly State state;
|
private readonly IDatabaseFile db;
|
||||||
private readonly string verb;
|
private readonly string verb;
|
||||||
|
|
||||||
private readonly RestartableTask<long> matchingAttachmentCountTask;
|
private readonly AsyncValueComputer<long> matchingAttachmentCountComputer;
|
||||||
private long? matchingAttachmentCount;
|
private long? matchingAttachmentCount;
|
||||||
private long? totalAttachmentCount;
|
private long? totalAttachmentCount;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public AttachmentFilterPanelModel() : this(State.Dummy) {}
|
public AttachmentFilterPanelModel() : this(DummyDatabaseFile.Instance) {}
|
||||||
|
|
||||||
public AttachmentFilterPanelModel(State state, string verb = "Matches") {
|
public AttachmentFilterPanelModel(IDatabaseFile db, string verb = "Matches") {
|
||||||
this.state = state;
|
this.db = db;
|
||||||
this.verb = verb;
|
this.verb = verb;
|
||||||
|
|
||||||
this.matchingAttachmentCountTask = new RestartableTask<long>(SetAttachmentCounts, TaskScheduler.FromCurrentSynchronizationContext());
|
this.matchingAttachmentCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetAttachmentCounts).Build();
|
||||||
|
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
|
|
||||||
PropertyChanged += OnPropertyChanged;
|
PropertyChanged += OnPropertyChanged;
|
||||||
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
@@ -83,7 +81,7 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
|
|||||||
|
|
||||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
||||||
totalAttachmentCount = state.Db.Statistics.TotalAttachments;
|
totalAttachmentCount = db.Statistics.TotalAttachments;
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,14 +89,14 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
|
|||||||
private void UpdateFilterStatistics() {
|
private void UpdateFilterStatistics() {
|
||||||
var filter = CreateFilter();
|
var filter = CreateFilter();
|
||||||
if (filter.IsEmpty) {
|
if (filter.IsEmpty) {
|
||||||
matchingAttachmentCountTask.Cancel();
|
matchingAttachmentCountComputer.Cancel();
|
||||||
matchingAttachmentCount = totalAttachmentCount;
|
matchingAttachmentCount = totalAttachmentCount;
|
||||||
UpdateFilterStatisticsText();
|
UpdateFilterStatisticsText();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
matchingAttachmentCount = null;
|
matchingAttachmentCount = null;
|
||||||
UpdateFilterStatisticsText();
|
UpdateFilterStatisticsText();
|
||||||
matchingAttachmentCountTask.Restart(cancellationToken => state.Db.Downloads.CountAttachments(filter, cancellationToken));
|
matchingAttachmentCountComputer.Compute(() => db.CountAttachments(filter));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +114,7 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public AttachmentFilter CreateFilter() {
|
public AttachmentFilter CreateFilter() {
|
||||||
AttachmentFilter filter = new ();
|
AttachmentFilter filter = new();
|
||||||
|
|
||||||
if (LimitSize) {
|
if (LimitSize) {
|
||||||
try {
|
try {
|
||||||
|
@@ -8,8 +8,6 @@ using Avalonia.Controls;
|
|||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Desktop.Dialogs.CheckBox;
|
using DHT.Desktop.Dialogs.CheckBox;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Dialogs.Progress;
|
|
||||||
using DHT.Server;
|
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
@@ -19,7 +17,7 @@ using DHT.Utils.Tasks;
|
|||||||
namespace DHT.Desktop.Main.Controls;
|
namespace DHT.Desktop.Main.Controls;
|
||||||
|
|
||||||
sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
||||||
private static readonly HashSet<string> FilterProperties = [
|
private static readonly HashSet<string> FilterProperties = new () {
|
||||||
nameof(FilterByDate),
|
nameof(FilterByDate),
|
||||||
nameof(StartDate),
|
nameof(StartDate),
|
||||||
nameof(EndDate),
|
nameof(EndDate),
|
||||||
@@ -27,7 +25,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
nameof(IncludedChannels),
|
nameof(IncludedChannels),
|
||||||
nameof(FilterByUser),
|
nameof(FilterByUser),
|
||||||
nameof(IncludedUsers)
|
nameof(IncludedUsers)
|
||||||
];
|
};
|
||||||
|
|
||||||
public string FilterStatisticsText { get; private set; } = "";
|
public string FilterStatisticsText { get; private set; } = "";
|
||||||
|
|
||||||
@@ -63,8 +61,8 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
set => Change(ref filterByChannel, value);
|
set => Change(ref filterByChannel, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public HashSet<ulong>? IncludedChannels {
|
public HashSet<ulong> IncludedChannels {
|
||||||
get => includedChannels;
|
get => includedChannels ?? db.GetAllChannels().Select(static channel => channel.Id).ToHashSet();
|
||||||
set => Change(ref includedChannels, value);
|
set => Change(ref includedChannels, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,8 +71,8 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
set => Change(ref filterByUser, value);
|
set => Change(ref filterByUser, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public HashSet<ulong>? IncludedUsers {
|
public HashSet<ulong> IncludedUsers {
|
||||||
get => includedUsers;
|
get => includedUsers ?? db.GetAllUsers().Select(static user => user.Id).ToHashSet();
|
||||||
set => Change(ref includedUsers, value);
|
set => Change(ref includedUsers, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,34 +91,33 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly State state;
|
private readonly IDatabaseFile db;
|
||||||
private readonly string verb;
|
private readonly string verb;
|
||||||
|
|
||||||
private readonly RestartableTask<long> exportedMessageCountTask;
|
private readonly AsyncValueComputer<long> exportedMessageCountComputer;
|
||||||
private long? exportedMessageCount;
|
private long? exportedMessageCount;
|
||||||
private long? totalMessageCount;
|
private long? totalMessageCount;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public MessageFilterPanelModel() : this(null!, State.Dummy) {}
|
public MessageFilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||||
|
|
||||||
public MessageFilterPanelModel(Window window, State state, string verb = "Matches") {
|
public MessageFilterPanelModel(Window window, IDatabaseFile db, string verb = "Matches") {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.state = state;
|
this.db = db;
|
||||||
this.verb = verb;
|
this.verb = verb;
|
||||||
|
|
||||||
this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext());
|
this.exportedMessageCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetExportedMessageCount).Build();
|
||||||
|
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
UpdateChannelFilterLabel();
|
UpdateChannelFilterLabel();
|
||||||
UpdateUserFilterLabel();
|
UpdateUserFilterLabel();
|
||||||
|
|
||||||
PropertyChanged += OnPropertyChanged;
|
PropertyChanged += OnPropertyChanged;
|
||||||
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
exportedMessageCountTask.Cancel();
|
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||||
state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
@@ -139,7 +136,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
|
|
||||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
|
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
|
||||||
totalMessageCount = state.Db.Statistics.TotalMessages;
|
totalMessageCount = db.Statistics.TotalMessages;
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
}
|
}
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
|
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
|
||||||
@@ -150,29 +147,17 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateChannelFilterLabel() {
|
|
||||||
long total = state.Db.Statistics.TotalChannels;
|
|
||||||
long included = FilterByChannel && IncludedChannels != null ? IncludedChannels.Count : total;
|
|
||||||
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateUserFilterLabel() {
|
|
||||||
long total = state.Db.Statistics.TotalUsers;
|
|
||||||
long included = FilterByUser && IncludedUsers != null ? IncludedUsers.Count : total;
|
|
||||||
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateFilterStatistics() {
|
private void UpdateFilterStatistics() {
|
||||||
var filter = CreateFilter();
|
var filter = CreateFilter();
|
||||||
if (filter.IsEmpty) {
|
if (filter.IsEmpty) {
|
||||||
exportedMessageCountTask.Cancel();
|
exportedMessageCountComputer.Cancel();
|
||||||
exportedMessageCount = totalMessageCount;
|
exportedMessageCount = totalMessageCount;
|
||||||
UpdateFilterStatisticsText();
|
UpdateFilterStatisticsText();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
exportedMessageCount = null;
|
exportedMessageCount = null;
|
||||||
UpdateFilterStatisticsText();
|
UpdateFilterStatisticsText();
|
||||||
exportedMessageCountTask.Restart(cancellationToken => state.Db.Messages.Count(filter, cancellationToken));
|
exportedMessageCountComputer.Compute(() => db.CountMessages(filter));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,98 +174,103 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OpenChannelFilterDialog() {
|
public async void OpenChannelFilterDialog() {
|
||||||
async Task<List<CheckBoxItem<ulong>>> PrepareChannelItems(ProgressDialog dialog) {
|
var servers = db.GetAllServers().ToDictionary(static server => server.Id);
|
||||||
var items = new List<CheckBoxItem<ulong>>();
|
var items = new List<CheckBoxItem<ulong>>();
|
||||||
var servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id);
|
var included = IncludedChannels;
|
||||||
|
|
||||||
await foreach (var channel in state.Db.Channels.Get()) {
|
foreach (var channel in db.GetAllChannels()) {
|
||||||
var channelId = channel.Id;
|
var channelId = channel.Id;
|
||||||
var channelName = channel.Name;
|
var channelName = channel.Name;
|
||||||
|
|
||||||
string title;
|
string title;
|
||||||
if (servers.TryGetValue(channel.Server, out var server)) {
|
if (servers.TryGetValue(channel.Server, out var server)) {
|
||||||
var titleBuilder = new StringBuilder();
|
var titleBuilder = new StringBuilder();
|
||||||
var serverType = server.Type;
|
var serverType = server.Type;
|
||||||
|
|
||||||
titleBuilder.Append('[')
|
titleBuilder.Append('[')
|
||||||
.Append(ServerTypes.ToString(serverType))
|
.Append(ServerTypes.ToString(serverType))
|
||||||
.Append("] ");
|
.Append("] ");
|
||||||
|
|
||||||
if (serverType == ServerType.DirectMessage) {
|
if (serverType == ServerType.DirectMessage) {
|
||||||
titleBuilder.Append(channelName);
|
titleBuilder.Append(channelName);
|
||||||
}
|
|
||||||
else {
|
|
||||||
titleBuilder.Append(server.Name)
|
|
||||||
.Append(" - ")
|
|
||||||
.Append(channelName);
|
|
||||||
}
|
|
||||||
|
|
||||||
title = titleBuilder.ToString();
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
title = channelName;
|
titleBuilder.Append(server.Name)
|
||||||
|
.Append(" - ")
|
||||||
|
.Append(channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
items.Add(new CheckBoxItem<ulong>(channelId) {
|
title = titleBuilder.ToString();
|
||||||
Title = title,
|
}
|
||||||
Checked = IncludedChannels == null || IncludedChannels.Contains(channelId)
|
else {
|
||||||
});
|
title = channelName;
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
items.Add(new CheckBoxItem<ulong>(channelId) {
|
||||||
}
|
Title = title,
|
||||||
|
Checked = included.Contains(channelId)
|
||||||
const string Title = "Included Channels";
|
});
|
||||||
|
|
||||||
List<CheckBoxItem<ulong>> items;
|
|
||||||
try {
|
|
||||||
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading channels...", PrepareChannelItems);
|
|
||||||
} catch (Exception e) {
|
|
||||||
await Dialog.ShowOk(window, Title, "Error loading channels: " + e.Message);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await OpenIdFilterDialog(Title, items);
|
var result = await OpenIdFilterDialog(window, "Included Channels", items);
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
IncludedChannels = result;
|
IncludedChannels = result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OpenUserFilterDialog() {
|
public async void OpenUserFilterDialog() {
|
||||||
async Task<List<CheckBoxItem<ulong>>> PrepareUserItems(ProgressDialog dialog) {
|
var items = new List<CheckBoxItem<ulong>>();
|
||||||
var checkBoxItems = new List<CheckBoxItem<ulong>>();
|
var included = IncludedUsers;
|
||||||
|
|
||||||
await foreach (var user in state.Db.Users.Get()) {
|
foreach (var user in db.GetAllUsers()) {
|
||||||
var name = user.Name;
|
var name = user.Name;
|
||||||
var discriminator = user.Discriminator;
|
var discriminator = user.Discriminator;
|
||||||
|
|
||||||
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
|
items.Add(new CheckBoxItem<ulong>(user.Id) {
|
||||||
Title = discriminator == null ? name : name + " #" + discriminator,
|
Title = discriminator == null ? name : name + " #" + discriminator,
|
||||||
Checked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
|
Checked = included.Contains(user.Id)
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return checkBoxItems;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const string Title = "Included Users";
|
var result = await OpenIdFilterDialog(window, "Included Users", items);
|
||||||
|
|
||||||
List<CheckBoxItem<ulong>> items;
|
|
||||||
try {
|
|
||||||
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
|
|
||||||
} catch (Exception e) {
|
|
||||||
await Dialog.ShowOk(window, Title, "Error loading users: " + e.Message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await OpenIdFilterDialog(Title, items);
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
IncludedUsers = result;
|
IncludedUsers = result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<HashSet<ulong>?> OpenIdFilterDialog(string title, List<CheckBoxItem<ulong>> items) {
|
private void UpdateChannelFilterLabel() {
|
||||||
|
long total = db.Statistics.TotalChannels;
|
||||||
|
long included = FilterByChannel ? IncludedChannels.Count : total;
|
||||||
|
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateUserFilterLabel() {
|
||||||
|
long total = db.Statistics.TotalUsers;
|
||||||
|
long included = FilterByUser ? IncludedUsers.Count : total;
|
||||||
|
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageFilter CreateFilter() {
|
||||||
|
MessageFilter filter = new();
|
||||||
|
|
||||||
|
if (FilterByDate) {
|
||||||
|
filter.StartDate = StartDate;
|
||||||
|
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FilterByChannel) {
|
||||||
|
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FilterByUser) {
|
||||||
|
filter.UserIds = new HashSet<ulong>(IncludedUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HashSet<ulong>?> OpenIdFilterDialog(Window window, string title, List<CheckBoxItem<ulong>> items) {
|
||||||
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
|
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
|
||||||
|
|
||||||
var model = new CheckBoxDialogModel<ulong>(items) {
|
var model = new CheckBoxDialogModel<ulong>(items) {
|
||||||
@@ -292,23 +282,4 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
|
|
||||||
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
|
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public MessageFilter CreateFilter() {
|
|
||||||
MessageFilter filter = new ();
|
|
||||||
|
|
||||||
if (FilterByDate) {
|
|
||||||
filter.StartDate = StartDate;
|
|
||||||
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (FilterByChannel && IncludedChannels != null) {
|
|
||||||
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (FilterByUser && IncludedUsers != null) {
|
|
||||||
filter.UserIds = new HashSet<ulong>(IncludedUsers);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filter;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,19 +1,14 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Threading;
|
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Desktop.Server;
|
||||||
using DHT.Server;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Service;
|
using DHT.Server.Service;
|
||||||
using DHT.Utils.Logging;
|
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls;
|
namespace DHT.Desktop.Main.Controls;
|
||||||
|
|
||||||
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
|
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
|
||||||
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
|
|
||||||
|
|
||||||
private string inputPort;
|
private string inputPort;
|
||||||
|
|
||||||
public string InputPort {
|
public string InputPort {
|
||||||
@@ -34,7 +29,7 @@ sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
|
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
|
||||||
|
|
||||||
private bool isToggleServerButtonEnabled = true;
|
private bool isToggleServerButtonEnabled = true;
|
||||||
|
|
||||||
@@ -43,69 +38,59 @@ sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
|
|||||||
set => Change(ref isToggleServerButtonEnabled, value);
|
set => Change(ref isToggleServerButtonEnabled, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
|
public string ToggleServerButtonText => serverManager.IsRunning ? "Stop Server" : "Start Server";
|
||||||
|
|
||||||
|
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly ServerManager server;
|
private readonly ServerManager serverManager;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public ServerConfigurationPanelModel() : this(null!, State.Dummy) {}
|
public ServerConfigurationPanelModel() : this(null!, new ServerManager(DummyDatabaseFile.Instance)) {}
|
||||||
|
|
||||||
public ServerConfigurationPanelModel(Window window, State state) {
|
public ServerConfigurationPanelModel(Window window, ServerManager serverManager) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.server = state.Server;
|
this.serverManager = serverManager;
|
||||||
this.inputPort = ServerConfiguration.Port.ToString();
|
this.inputPort = ServerManager.Port.ToString();
|
||||||
this.inputToken = ServerConfiguration.Token;
|
this.inputToken = ServerManager.Token;
|
||||||
|
}
|
||||||
server.StatusChanged += OnServerStatusChanged;
|
|
||||||
|
public void Initialize() {
|
||||||
|
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
server.StatusChanged -= OnServerStatusChanged;
|
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
|
||||||
Dispatcher.UIThread.InvokeAsync(UpdateServerStatus);
|
ServerStatusChanged?.Invoke(this, serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateServerStatus() {
|
|
||||||
OnPropertyChanged(nameof(ToggleServerButtonText));
|
OnPropertyChanged(nameof(ToggleServerButtonText));
|
||||||
}
|
|
||||||
|
|
||||||
private async Task StartServer() {
|
|
||||||
IsToggleServerButtonEnabled = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.Error(e);
|
|
||||||
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateServerStatus();
|
|
||||||
IsToggleServerButtonEnabled = true;
|
IsToggleServerButtonEnabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task StopServer() {
|
private void BeforeServerStart() {
|
||||||
IsToggleServerButtonEnabled = false;
|
IsToggleServerButtonEnabled = false;
|
||||||
|
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
|
||||||
try {
|
|
||||||
await server.Stop();
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.Error(e);
|
|
||||||
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateServerStatus();
|
|
||||||
IsToggleServerButtonEnabled = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OnClickToggleServerButton() {
|
private void StartServer() {
|
||||||
if (server.IsRunning) {
|
BeforeServerStart();
|
||||||
await StopServer();
|
serverManager.Launch();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopServer() {
|
||||||
|
IsToggleServerButtonEnabled = false;
|
||||||
|
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
|
||||||
|
serverManager.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClickToggleServerButton() {
|
||||||
|
if (serverManager.IsRunning) {
|
||||||
|
StopServer();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await StartServer();
|
StartServer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,22 +98,19 @@ sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
|
|||||||
InputToken = ServerUtils.GenerateRandomToken(20);
|
InputToken = ServerUtils.GenerateRandomToken(20);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OnClickApplyChanges() {
|
public async void OnClickApplyChanges() {
|
||||||
if (!ushort.TryParse(InputPort, out ushort port)) {
|
if (!ushort.TryParse(InputPort, out ushort port)) {
|
||||||
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
|
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ServerConfiguration.Port = port;
|
BeforeServerStart();
|
||||||
ServerConfiguration.Token = inputToken;
|
serverManager.Relaunch(port, InputToken);
|
||||||
|
|
||||||
OnPropertyChanged(nameof(HasMadeChanges));
|
OnPropertyChanged(nameof(HasMadeChanges));
|
||||||
|
|
||||||
await StartServer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnClickCancelChanges() {
|
public void OnClickCancelChanges() {
|
||||||
InputPort = ServerConfiguration.Port.ToString();
|
InputPort = ServerManager.Port.ToString();
|
||||||
InputToken = ServerConfiguration.Token;
|
InputToken = ServerManager.Token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -40,22 +40,22 @@
|
|||||||
<StackPanel Orientation="Horizontal" Margin="6 3">
|
<StackPanel Orientation="Horizontal" Margin="6 3">
|
||||||
<StackPanel Orientation="Vertical" Width="65">
|
<StackPanel Orientation="Vertical" Width="65">
|
||||||
<TextBlock Classes="label">Status</TextBlock>
|
<TextBlock Classes="label">Status</TextBlock>
|
||||||
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding ServerStatusText}" />
|
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding StatusText}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Rectangle />
|
<Rectangle />
|
||||||
<StackPanel Orientation="Vertical">
|
<StackPanel Orientation="Vertical">
|
||||||
<TextBlock Classes="label">Servers</TextBlock>
|
<TextBlock Classes="label">Servers</TextBlock>
|
||||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Converter={StaticResource NumberValueConverter}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Rectangle />
|
<Rectangle />
|
||||||
<StackPanel Orientation="Vertical">
|
<StackPanel Orientation="Vertical">
|
||||||
<TextBlock Classes="label">Channels</TextBlock>
|
<TextBlock Classes="label">Channels</TextBlock>
|
||||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Converter={StaticResource NumberValueConverter}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Rectangle />
|
<Rectangle />
|
||||||
<StackPanel Orientation="Vertical">
|
<StackPanel Orientation="Vertical">
|
||||||
<TextBlock Classes="label">Messages</TextBlock>
|
<TextBlock Classes="label">Messages</TextBlock>
|
||||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Converter={StaticResource NumberValueConverter}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
@@ -1,46 +1,45 @@
|
|||||||
using System;
|
using System;
|
||||||
using Avalonia.Threading;
|
|
||||||
using DHT.Server;
|
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Service;
|
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls;
|
namespace DHT.Desktop.Main.Controls;
|
||||||
|
|
||||||
sealed class StatusBarModel : BaseModel, IDisposable {
|
sealed class StatusBarModel : BaseModel {
|
||||||
public DatabaseStatistics DatabaseStatistics { get; }
|
public DatabaseStatistics DatabaseStatistics { get; }
|
||||||
|
|
||||||
private ServerManager.Status serverStatus;
|
private Status status = Status.Stopped;
|
||||||
|
|
||||||
public string ServerStatusText => serverStatus switch {
|
public Status CurrentStatus {
|
||||||
ServerManager.Status.Starting => "STARTING",
|
get => status;
|
||||||
ServerManager.Status.Started => "READY",
|
set {
|
||||||
ServerManager.Status.Stopping => "STOPPING",
|
status = value;
|
||||||
ServerManager.Status.Stopped => "STOPPED",
|
OnPropertyChanged(nameof(StatusText));
|
||||||
_ => ""
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
private readonly State state;
|
public string StatusText {
|
||||||
|
get {
|
||||||
|
return CurrentStatus switch {
|
||||||
|
Status.Starting => "STARTING",
|
||||||
|
Status.Ready => "READY",
|
||||||
|
Status.Stopping => "STOPPING",
|
||||||
|
Status.Stopped => "STOPPED",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public StatusBarModel() : this(State.Dummy) {}
|
public StatusBarModel() : this(new DatabaseStatistics()) {}
|
||||||
|
|
||||||
public StatusBarModel(State state) {
|
public StatusBarModel(DatabaseStatistics databaseStatistics) {
|
||||||
this.state = state;
|
this.DatabaseStatistics = databaseStatistics;
|
||||||
this.DatabaseStatistics = state.Db.Statistics;
|
|
||||||
|
|
||||||
state.Server.StatusChanged += OnServerStatusChanged;
|
|
||||||
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public enum Status {
|
||||||
state.Server.StatusChanged -= OnServerStatusChanged;
|
Starting,
|
||||||
}
|
Ready,
|
||||||
|
Stopping,
|
||||||
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
Stopped
|
||||||
Dispatcher.UIThread.InvokeAsync(() => {
|
|
||||||
serverStatus = e;
|
|
||||||
OnPropertyChanged(nameof(ServerStatusText));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -11,7 +11,7 @@
|
|||||||
Width="800" Height="500"
|
Width="800" Height="500"
|
||||||
MinWidth="520" MinHeight="300"
|
MinWidth="520" MinHeight="300"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
Closing="OnClosing">
|
Closed="OnClosed">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<main:MainWindowModel />
|
<main:MainWindowModel />
|
||||||
|
@@ -1,18 +1,14 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Main.Pages;
|
using DHT.Desktop.Main.Pages;
|
||||||
using DHT.Utils.Logging;
|
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main;
|
namespace DHT.Desktop.Main;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class MainWindow : Window {
|
public sealed partial class MainWindow : Window {
|
||||||
private static readonly Log Log = Log.ForType<MainWindow>();
|
|
||||||
|
|
||||||
[UsedImplicitly]
|
[UsedImplicitly]
|
||||||
public MainWindow() {
|
public MainWindow() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -24,24 +20,9 @@ public sealed partial class MainWindow : Window {
|
|||||||
DataContext = new MainWindowModel(this, args);
|
DataContext = new MainWindowModel(this, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OnClosing(object? sender, WindowClosingEventArgs e) {
|
public void OnClosed(object? sender, EventArgs e) {
|
||||||
e.Cancel = true;
|
if (DataContext is IDisposable disposable) {
|
||||||
Closing -= OnClosing;
|
disposable.Dispose();
|
||||||
|
|
||||||
try {
|
|
||||||
await Dispose();
|
|
||||||
} finally {
|
|
||||||
Close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task Dispose() {
|
|
||||||
if (DataContext is MainWindowModel model) {
|
|
||||||
try {
|
|
||||||
await model.DisposeAsync();
|
|
||||||
} catch (Exception ex) {
|
|
||||||
Log.Error("Caught exception while disposing window: " + ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
|
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -6,18 +7,14 @@ using Avalonia.Controls;
|
|||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Main.Screens;
|
using DHT.Desktop.Main.Screens;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Desktop.Server;
|
||||||
using DHT.Server;
|
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Utils.Logging;
|
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main;
|
namespace DHT.Desktop.Main;
|
||||||
|
|
||||||
sealed class MainWindowModel : BaseModel, IAsyncDisposable {
|
sealed class MainWindowModel : BaseModel, IDisposable {
|
||||||
private const string DefaultTitle = "Discord History Tracker";
|
private const string DefaultTitle = "Discord History Tracker";
|
||||||
|
|
||||||
private static readonly Log Log = Log.ForType<MainWindowModel>();
|
|
||||||
|
|
||||||
public string Title { get; private set; } = DefaultTitle;
|
public string Title { get; private set; } = DefaultTitle;
|
||||||
|
|
||||||
public UserControl CurrentScreen { get; private set; }
|
public UserControl CurrentScreen { get; private set; }
|
||||||
@@ -25,11 +22,12 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
|
|||||||
private readonly WelcomeScreen welcomeScreen;
|
private readonly WelcomeScreen welcomeScreen;
|
||||||
private readonly WelcomeScreenModel welcomeScreenModel;
|
private readonly WelcomeScreenModel welcomeScreenModel;
|
||||||
|
|
||||||
|
private MainContentScreen? mainContentScreen;
|
||||||
private MainContentScreenModel? mainContentScreenModel;
|
private MainContentScreenModel? mainContentScreenModel;
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
|
||||||
private State? state;
|
private IDatabaseFile? db;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
||||||
@@ -38,11 +36,11 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
|
|||||||
this.window = window;
|
this.window = window;
|
||||||
|
|
||||||
welcomeScreenModel = new WelcomeScreenModel(window);
|
welcomeScreenModel = new WelcomeScreenModel(window);
|
||||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
|
||||||
|
|
||||||
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
||||||
CurrentScreen = welcomeScreen;
|
CurrentScreen = welcomeScreen;
|
||||||
|
|
||||||
|
welcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
|
||||||
|
|
||||||
var dbFile = args.DatabaseFile;
|
var dbFile = args.DatabaseFile;
|
||||||
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
||||||
async void OnWindowOpened(object? o, EventArgs eventArgs) {
|
async void OnWindowOpened(object? o, EventArgs eventArgs) {
|
||||||
@@ -65,67 +63,54 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.ServerPort != null) {
|
if (args.ServerPort != null) {
|
||||||
ServerConfiguration.Port = args.ServerPort.Value;
|
ServerManager.Port = args.ServerPort.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.ServerToken != null) {
|
if (args.ServerToken != null) {
|
||||||
ServerConfiguration.Token = args.ServerToken;
|
ServerManager.Token = args.ServerToken;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnDatabaseSelected(object? sender, IDatabaseFile db) {
|
private async void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
welcomeScreenModel.DatabaseSelected -= OnDatabaseSelected;
|
if (e.PropertyName == nameof(welcomeScreenModel.Db)) {
|
||||||
|
if (mainContentScreenModel != null) {
|
||||||
await DisposeState();
|
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
||||||
|
mainContentScreenModel.Dispose();
|
||||||
state = new State(db);
|
}
|
||||||
|
|
||||||
try {
|
db?.Dispose();
|
||||||
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
db = welcomeScreenModel.Db;
|
||||||
} catch (Exception ex) {
|
|
||||||
Log.Error(ex);
|
|
||||||
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
mainContentScreenModel = new MainContentScreenModel(window, state);
|
if (db == null) {
|
||||||
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
Title = DefaultTitle;
|
||||||
|
mainContentScreenModel = null;
|
||||||
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
|
mainContentScreen = null;
|
||||||
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
CurrentScreen = welcomeScreen;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle;
|
||||||
|
mainContentScreenModel = new MainContentScreenModel(window, db);
|
||||||
|
await mainContentScreenModel.Initialize();
|
||||||
|
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
||||||
|
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
||||||
|
CurrentScreen = mainContentScreen;
|
||||||
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(Title));
|
OnPropertyChanged(nameof(CurrentScreen));
|
||||||
OnPropertyChanged(nameof(CurrentScreen));
|
OnPropertyChanged(nameof(Title));
|
||||||
|
|
||||||
window.Focus();
|
window.Focus();
|
||||||
}
|
|
||||||
|
|
||||||
private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
|
||||||
if (mainContentScreenModel != null) {
|
|
||||||
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
|
||||||
mainContentScreenModel.Dispose();
|
|
||||||
mainContentScreenModel = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await DisposeState();
|
|
||||||
|
|
||||||
Title = DefaultTitle;
|
|
||||||
CurrentScreen = welcomeScreen;
|
|
||||||
|
|
||||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(Title));
|
|
||||||
OnPropertyChanged(nameof(CurrentScreen));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DisposeState() {
|
|
||||||
if (state != null) {
|
|
||||||
await state.DisposeAsync();
|
|
||||||
state = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() {
|
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
||||||
|
welcomeScreenModel.CloseDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
welcomeScreenModel.Dispose();
|
||||||
mainContentScreenModel?.Dispose();
|
mainContentScreenModel?.Dispose();
|
||||||
await DisposeState();
|
db?.Dispose();
|
||||||
|
db = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,37 +1,39 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Dialogs.Progress;
|
|
||||||
using DHT.Desktop.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
using DHT.Server;
|
using DHT.Desktop.Server;
|
||||||
|
using DHT.Server.Database;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
sealed class AdvancedPageModel : BaseModel, IDisposable {
|
sealed class AdvancedPageModel : BaseModel, IDisposable {
|
||||||
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly State state;
|
private readonly IDatabaseFile db;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public AdvancedPageModel() : this(null!, State.Dummy) {}
|
public AdvancedPageModel() : this(null!, DummyDatabaseFile.Instance, new ServerManager(DummyDatabaseFile.Instance)) {}
|
||||||
|
|
||||||
public AdvancedPageModel(Window window, State state) {
|
public AdvancedPageModel(Window window, IDatabaseFile db, ServerManager serverManager) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.state = state;
|
this.db = db;
|
||||||
|
|
||||||
ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
|
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Initialize() {
|
||||||
|
ServerConfigurationModel.Initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
ServerConfigurationModel.Dispose();
|
ServerConfigurationModel.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task VacuumDatabase() {
|
public async void VacuumDatabase() {
|
||||||
const string Title = "Vacuum Database";
|
db.Vacuum();
|
||||||
await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => state.Db.Vacuum());
|
await Dialog.ShowOk(window, "Vacuum Database", "Done.");
|
||||||
await Dialog.ShowOk(window, Title, "Done.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -33,7 +33,7 @@
|
|||||||
<StackPanel Orientation="Vertical" Spacing="20">
|
<StackPanel Orientation="Vertical" Spacing="20">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" DockPanel.Dock="Left" />
|
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" DockPanel.Dock="Left" />
|
||||||
<TextBlock Text="{Binding DownloadMessage}" MinWidth="100" Margin="10 0 0 0" VerticalAlignment="Center" TextAlignment="Right" 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 !IsDownloading, RelativeSource={RelativeSource AncestorType=pages:AttachmentsPageModel}}" />
|
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !IsDownloading, RelativeSource={RelativeSource AncestorType=pages:AttachmentsPageModel}}" />
|
||||||
@@ -42,13 +42,13 @@
|
|||||||
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
|
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
|
||||||
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||||
<DataGridTextColumn Header="Size" Binding="{Binding Size, Mode=OneWay, Converter={StaticResource BytesValueConverter}}" Width="*" CellStyleClasses="right" />
|
<DataGridTextColumn Header="Size" Binding="{Binding Size, Converter={StaticResource BytesValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
</Expander>
|
</Expander>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button>
|
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding HasFailedDownloads}">Retry Failed Downloads</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
@@ -1,55 +1,38 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Reactive.Linq;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using Avalonia.Threading;
|
||||||
using Avalonia.ReactiveUI;
|
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Desktop.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
using DHT.Server;
|
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Data.Aggregations;
|
using DHT.Server.Data.Aggregations;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Server.Download;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
using DHT.Utils.Tasks;
|
using DHT.Utils.Tasks;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
sealed class AttachmentsPageModel : BaseModel, IDisposable {
|
sealed class AttachmentsPageModel : BaseModel, IDisposable {
|
||||||
private static readonly Log Log = Log.ForType<AttachmentsPageModel>();
|
private static readonly DownloadItemFilter EnqueuedItemFilter = new() {
|
||||||
|
|
||||||
private static readonly DownloadItemFilter EnqueuedItemFilter = new () {
|
|
||||||
IncludeStatuses = new HashSet<DownloadStatus> {
|
IncludeStatuses = new HashSet<DownloadStatus> {
|
||||||
DownloadStatus.Enqueued,
|
DownloadStatus.Enqueued
|
||||||
DownloadStatus.Downloading
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private bool isToggleDownloadButtonEnabled = true;
|
private bool isThreadDownloadButtonEnabled = true;
|
||||||
|
|
||||||
|
public string ToggleDownloadButtonText => downloadThread == null ? "Start Downloading" : "Stop Downloading";
|
||||||
|
|
||||||
public bool IsToggleDownloadButtonEnabled {
|
public bool IsToggleDownloadButtonEnabled {
|
||||||
get => isToggleDownloadButtonEnabled;
|
get => isThreadDownloadButtonEnabled;
|
||||||
set => Change(ref isToggleDownloadButtonEnabled, value);
|
set => Change(ref isThreadDownloadButtonEnabled, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
|
|
||||||
|
|
||||||
private bool isRetryingFailedDownloads = false;
|
|
||||||
|
|
||||||
public bool IsRetryingFailedDownloads {
|
|
||||||
get => isRetryingFailedDownloads;
|
|
||||||
set {
|
|
||||||
isRetryingFailedDownloads = value;
|
|
||||||
OnPropertyChanged(nameof(IsRetryFailedOnDownloadsButtonEnabled));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads;
|
|
||||||
|
|
||||||
public string DownloadMessage { get; set; } = "";
|
public string DownloadMessage { get; set; } = "";
|
||||||
public double DownloadProgress => totalItemsToDownloadCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / totalItemsToDownloadCount.Value;
|
public double DownloadProgress => allItemsCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / allItemsCount.Value;
|
||||||
|
|
||||||
public AttachmentFilterPanelModel FilterModel { get; }
|
public AttachmentFilterPanelModel FilterModel { get; }
|
||||||
|
|
||||||
@@ -58,151 +41,66 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
|
|||||||
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
||||||
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
||||||
|
|
||||||
public List<StatisticsRow> StatisticsRows => [
|
public List<StatisticsRow> StatisticsRows {
|
||||||
statisticsEnqueued,
|
get {
|
||||||
statisticsDownloaded,
|
return new List<StatisticsRow> {
|
||||||
statisticsFailed,
|
statisticsEnqueued,
|
||||||
statisticsSkipped
|
statisticsDownloaded,
|
||||||
];
|
statisticsFailed,
|
||||||
|
statisticsSkipped
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsDownloading => state.Downloader.IsDownloading;
|
public bool IsDownloading => downloadThread != null;
|
||||||
public bool HasFailedDownloads => statisticsFailed.Items > 0;
|
public bool HasFailedDownloads => statisticsFailed.Items > 0;
|
||||||
|
|
||||||
private readonly State state;
|
private readonly IDatabaseFile db;
|
||||||
private readonly ThrottledTask<int> enqueueDownloadItemsTask;
|
private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer;
|
||||||
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
|
private BackgroundDownloadThread? downloadThread;
|
||||||
|
|
||||||
private IDisposable? finishedItemsSubscription;
|
|
||||||
private int doneItemsCount;
|
private int doneItemsCount;
|
||||||
private int totalEnqueuedItemCount;
|
private int? allItemsCount;
|
||||||
private int? totalItemsToDownloadCount;
|
|
||||||
|
|
||||||
public AttachmentsPageModel() : this(State.Dummy) {}
|
public AttachmentsPageModel() : this(DummyDatabaseFile.Instance) {}
|
||||||
|
|
||||||
public AttachmentsPageModel(State state) {
|
public AttachmentsPageModel(IDatabaseFile db) {
|
||||||
this.state = state;
|
this.db = db;
|
||||||
|
this.FilterModel = new AttachmentFilterPanelModel(db);
|
||||||
|
|
||||||
FilterModel = new AttachmentFilterPanelModel(state);
|
this.downloadStatisticsComputer = AsyncValueComputer<DownloadStatusStatistics>.WithResultProcessor(UpdateStatistics).WithOutdatedResults().BuildWithComputer(db.GetDownloadStatusStatistics);
|
||||||
|
this.downloadStatisticsComputer.Recompute();
|
||||||
|
|
||||||
enqueueDownloadItemsTask = new ThrottledTask<int>(OnItemsEnqueued, TaskScheduler.FromCurrentSynchronizationContext());
|
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||||
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
|
|
||||||
RecomputeDownloadStatistics();
|
|
||||||
|
|
||||||
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||||
enqueueDownloadItemsTask.Dispose();
|
|
||||||
downloadStatisticsTask.Dispose();
|
|
||||||
finishedItemsSubscription?.Dispose();
|
|
||||||
FilterModel.Dispose();
|
FilterModel.Dispose();
|
||||||
|
DisposeDownloadThread();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
||||||
if (IsDownloading) {
|
if (IsDownloading) {
|
||||||
EnqueueDownloadItemsLater();
|
EnqueueDownloadItems();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
RecomputeDownloadStatistics();
|
downloadStatisticsComputer.Recompute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalDownloads)) {
|
else if (e.PropertyName == nameof(DatabaseStatistics.TotalDownloads)) {
|
||||||
RecomputeDownloadStatistics();
|
downloadStatisticsComputer.Recompute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EnqueueDownloadItems() {
|
private void EnqueueDownloadItems() {
|
||||||
OnItemsEnqueued(await state.Db.Downloads.EnqueueDownloadItems(CreateAttachmentFilter()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnqueueDownloadItemsLater() {
|
|
||||||
var filter = CreateAttachmentFilter();
|
|
||||||
enqueueDownloadItemsTask.Post(cancellationToken => state.Db.Downloads.EnqueueDownloadItems(filter, cancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnItemsEnqueued(int itemCount) {
|
|
||||||
totalEnqueuedItemCount += itemCount;
|
|
||||||
totalItemsToDownloadCount = totalEnqueuedItemCount;
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
RecomputeDownloadStatistics();
|
|
||||||
}
|
|
||||||
|
|
||||||
private AttachmentFilter CreateAttachmentFilter() {
|
|
||||||
var filter = FilterModel.CreateFilter();
|
var filter = FilterModel.CreateFilter();
|
||||||
filter.DownloadItemRule = AttachmentFilter.DownloadItemRules.OnlyNotPresent;
|
filter.DownloadItemRule = AttachmentFilter.DownloadItemRules.OnlyNotPresent;
|
||||||
return filter;
|
db.EnqueueDownloadItems(filter);
|
||||||
}
|
|
||||||
|
|
||||||
public async Task OnClickToggleDownload() {
|
downloadStatisticsComputer.Recompute();
|
||||||
IsToggleDownloadButtonEnabled = false;
|
|
||||||
|
|
||||||
if (IsDownloading) {
|
|
||||||
await state.Downloader.Stop();
|
|
||||||
|
|
||||||
finishedItemsSubscription?.Dispose();
|
|
||||||
finishedItemsSubscription = null;
|
|
||||||
|
|
||||||
RecomputeDownloadStatistics();
|
|
||||||
|
|
||||||
await state.Db.Downloads.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
|
|
||||||
|
|
||||||
doneItemsCount = 0;
|
|
||||||
totalEnqueuedItemCount = 0;
|
|
||||||
totalItemsToDownloadCount = null;
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
var finishedItems = await state.Downloader.Start();
|
|
||||||
|
|
||||||
finishedItemsSubscription = finishedItems.Select(static _ => true)
|
|
||||||
.Buffer(TimeSpan.FromMilliseconds(100))
|
|
||||||
.Select(static items => items.Count)
|
|
||||||
.Where(static items => items > 0)
|
|
||||||
.ObserveOn(AvaloniaScheduler.Instance)
|
|
||||||
.Subscribe(OnItemsFinished);
|
|
||||||
|
|
||||||
await EnqueueDownloadItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(ToggleDownloadButtonText));
|
|
||||||
OnPropertyChanged(nameof(IsDownloading));
|
|
||||||
IsToggleDownloadButtonEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnItemsFinished(int finishedItemCount) {
|
|
||||||
doneItemsCount += finishedItemCount;
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
RecomputeDownloadStatistics();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task OnClickRetryFailedDownloads() {
|
|
||||||
IsRetryingFailedDownloads = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
var allExceptFailedFilter = new DownloadItemFilter {
|
|
||||||
IncludeStatuses = new HashSet<DownloadStatus> {
|
|
||||||
DownloadStatus.Enqueued,
|
|
||||||
DownloadStatus.Downloading,
|
|
||||||
DownloadStatus.Success
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await state.Db.Downloads.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching);
|
|
||||||
|
|
||||||
if (IsDownloading) {
|
|
||||||
await EnqueueDownloadItems();
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.Error(e);
|
|
||||||
} finally {
|
|
||||||
IsRetryingFailedDownloads = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RecomputeDownloadStatistics() {
|
|
||||||
downloadStatisticsTask.Post(state.Db.Downloads.GetStatistics);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
||||||
@@ -224,19 +122,77 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
|
|||||||
|
|
||||||
if (hadFailedDownloads != HasFailedDownloads) {
|
if (hadFailedDownloads != HasFailedDownloads) {
|
||||||
OnPropertyChanged(nameof(HasFailedDownloads));
|
OnPropertyChanged(nameof(HasFailedDownloads));
|
||||||
OnPropertyChanged(nameof(IsRetryFailedOnDownloadsButtonEnabled));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allItemsCount = doneItemsCount + statisticsEnqueued.Items;
|
||||||
UpdateDownloadMessage();
|
UpdateDownloadMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateDownloadMessage() {
|
private void UpdateDownloadMessage() {
|
||||||
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (totalItemsToDownloadCount?.Format() ?? "?") : "";
|
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (allItemsCount?.Format() ?? "?") : "";
|
||||||
|
|
||||||
OnPropertyChanged(nameof(DownloadMessage));
|
OnPropertyChanged(nameof(DownloadMessage));
|
||||||
OnPropertyChanged(nameof(DownloadProgress));
|
OnPropertyChanged(nameof(DownloadProgress));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DownloadThreadOnOnItemFinished(object? sender, DownloadItem e) {
|
||||||
|
Interlocked.Increment(ref doneItemsCount);
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Invoke(UpdateDownloadMessage);
|
||||||
|
downloadStatisticsComputer.Recompute();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DownloadThreadOnOnServerStopped(object? sender, EventArgs e) {
|
||||||
|
downloadStatisticsComputer.Recompute();
|
||||||
|
IsToggleDownloadButtonEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClickToggleDownload() {
|
||||||
|
if (downloadThread == null) {
|
||||||
|
EnqueueDownloadItems();
|
||||||
|
downloadThread = new BackgroundDownloadThread(db);
|
||||||
|
downloadThread.OnItemFinished += DownloadThreadOnOnItemFinished;
|
||||||
|
downloadThread.OnServerStopped += DownloadThreadOnOnServerStopped;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
IsToggleDownloadButtonEnabled = false;
|
||||||
|
DisposeDownloadThread();
|
||||||
|
|
||||||
|
db.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
|
||||||
|
|
||||||
|
doneItemsCount = 0;
|
||||||
|
allItemsCount = null;
|
||||||
|
UpdateDownloadMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(ToggleDownloadButtonText));
|
||||||
|
OnPropertyChanged(nameof(IsDownloading));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClickRetryFailedDownloads() {
|
||||||
|
var allExceptFailedFilter = new DownloadItemFilter {
|
||||||
|
IncludeStatuses = new HashSet<DownloadStatus> {
|
||||||
|
DownloadStatus.Enqueued,
|
||||||
|
DownloadStatus.Success
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
db.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching);
|
||||||
|
|
||||||
|
if (IsDownloading) {
|
||||||
|
EnqueueDownloadItems();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisposeDownloadThread() {
|
||||||
|
if (downloadThread != null) {
|
||||||
|
downloadThread.OnItemFinished -= DownloadThreadOnOnItemFinished;
|
||||||
|
downloadThread.StopThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadThread = null;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class StatisticsRow {
|
public sealed class StatisticsRow {
|
||||||
public string State { get; }
|
public string State { get; }
|
||||||
public int Items { get; set; }
|
public int Items { get; set; }
|
||||||
|
@@ -14,15 +14,14 @@ using DHT.Desktop.Dialogs.File;
|
|||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Dialogs.Progress;
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Desktop.Dialogs.TextBox;
|
using DHT.Desktop.Dialogs.TextBox;
|
||||||
using DHT.Server;
|
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Import;
|
using DHT.Server.Database.Import;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
using DHT.Server.Database.Sqlite;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
sealed class DatabasePageModel : BaseModel {
|
sealed class DatabasePageModel : BaseModel {
|
||||||
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
||||||
@@ -34,14 +33,14 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public DatabasePageModel() : this(null!, State.Dummy) {}
|
public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||||
|
|
||||||
public DatabasePageModel(Window window, State state) {
|
public DatabasePageModel(Window window, IDatabaseFile db) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.Db = state.Db;
|
this.Db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OpenDatabaseFolder() {
|
public async void OpenDatabaseFolder() {
|
||||||
string file = Db.Path;
|
string file = Db.Path;
|
||||||
string? folder = Path.GetDirectoryName(file);
|
string? folder = Path.GetDirectoryName(file);
|
||||||
|
|
||||||
@@ -72,11 +71,18 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MergeWithDatabase() {
|
public async void MergeWithDatabase() {
|
||||||
var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
|
var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
|
||||||
if (paths.Length > 0) {
|
if (paths.Length == 0) {
|
||||||
await ProgressDialog.Show(window, "Database Merge", async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ProgressDialog progressDialog = new ProgressDialog();
|
||||||
|
progressDialog.DataContext = new ProgressDialogModel(async callbacks => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callbacks[0])) {
|
||||||
|
Title = "Database Merge"
|
||||||
|
};
|
||||||
|
|
||||||
|
await progressDialog.ShowProgressDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
@@ -90,7 +96,7 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await target.AddFrom(db);
|
target.AddFrom(db);
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
db.Dispose();
|
db.Dispose();
|
||||||
@@ -133,16 +139,23 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task 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",
|
||||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
|
||||||
AllowMultiple = true
|
AllowMultiple = true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (paths.Length > 0) {
|
if (paths.Length == 0) {
|
||||||
await ProgressDialog.Show(window, "Legacy Archive Import", async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ProgressDialog progressDialog = new ProgressDialog();
|
||||||
|
progressDialog.DataContext = new ProgressDialogModel(async callbacks => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callbacks[0])) {
|
||||||
|
Title = "Legacy Archive Import"
|
||||||
|
};
|
||||||
|
|
||||||
|
await progressDialog.ShowProgressDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
@@ -194,7 +207,7 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
|
|
||||||
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
|
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
|
||||||
int total = paths.Length;
|
int total = paths.Length;
|
||||||
var oldStatistics = await target.SnapshotStatistics();
|
var oldStatistics = target.SnapshotStatistics();
|
||||||
|
|
||||||
int successful = 0;
|
int successful = 0;
|
||||||
int finished = 0;
|
int finished = 0;
|
||||||
@@ -225,8 +238,7 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var newStatistics = await target.SnapshotStatistics();
|
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, target.SnapshotStatistics(), successful, total, itemName));
|
||||||
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetImportDialogMessage(DatabaseStatisticsSnapshot oldStatistics, DatabaseStatisticsSnapshot newStatistics, int successfulItems, int totalItems, string itemName) {
|
private static string GetImportDialogMessage(DatabaseStatisticsSnapshot oldStatistics, DatabaseStatisticsSnapshot newStatistics, int successfulItems, int totalItems, string itemName) {
|
||||||
|
@@ -6,8 +6,8 @@ using System.Threading.Tasks;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Dialogs.Progress;
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Server;
|
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Service;
|
using DHT.Server.Service;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
@@ -18,14 +18,14 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
public string GenerateMessages { get; set; } = "0";
|
public string GenerateMessages { get; set; } = "0";
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly State state;
|
private readonly IDatabaseFile db;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public DebugPageModel() : this(null!, State.Dummy) {}
|
public DebugPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||||
|
|
||||||
public DebugPageModel(Window window, State state) {
|
public DebugPageModel(Window window, IDatabaseFile db) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.state = state;
|
this.db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OnClickAddRandomDataToDatabase() {
|
public async void OnClickAddRandomDataToDatabase() {
|
||||||
@@ -44,7 +44,13 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
|
ProgressDialog progressDialog = new ProgressDialog {
|
||||||
|
DataContext = new ProgressDialogModel(async callbacks => await GenerateRandomData(channels, users, messages, callbacks[0])) {
|
||||||
|
Title = "Generating Random Data"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await progressDialog.ShowProgressDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int BatchSize = 500;
|
private const int BatchSize = 500;
|
||||||
@@ -77,9 +83,12 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
Discriminator = rand.Next(0, 9999).ToString(),
|
Discriminator = rand.Next(0, 9999).ToString(),
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
await state.Db.Users.Add(users);
|
db.AddServer(server);
|
||||||
await state.Db.Servers.Add([server]);
|
db.AddUsers(users);
|
||||||
await state.Db.Channels.Add(channels);
|
|
||||||
|
foreach (var channel in channels) {
|
||||||
|
db.AddChannel(channel);
|
||||||
|
}
|
||||||
|
|
||||||
var now = DateTimeOffset.Now;
|
var now = DateTimeOffset.Now;
|
||||||
int batchIndex = 0;
|
int batchIndex = 0;
|
||||||
@@ -102,13 +111,13 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
Timestamp = timeMillis,
|
Timestamp = timeMillis,
|
||||||
EditTimestamp = editMillis,
|
EditTimestamp = editMillis,
|
||||||
RepliedToId = null,
|
RepliedToId = null,
|
||||||
Attachments = ImmutableList<Attachment>.Empty,
|
Attachments = ImmutableArray<Attachment>.Empty,
|
||||||
Embeds = ImmutableList<Embed>.Empty,
|
Embeds = ImmutableArray<Embed>.Empty,
|
||||||
Reactions = ImmutableList<Reaction>.Empty,
|
Reactions = ImmutableArray<Reaction>.Empty,
|
||||||
};
|
};
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
await state.Db.Messages.Add(messages);
|
db.AddMessages(messages);
|
||||||
|
|
||||||
messageCount -= BatchSize;
|
messageCount -= BatchSize;
|
||||||
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
||||||
|
@@ -16,7 +16,7 @@
|
|||||||
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I.
|
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I.
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
|
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
|
||||||
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick" IsEnabled="{Binding IsCopyTrackingScriptButtonEnabled}">Copy Tracking Script</Button>
|
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
|
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
|
||||||
By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the Ctrl+Shift+I shortcut is enabled.
|
By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the Ctrl+Shift+I shortcut is enabled.
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
@@ -12,16 +11,9 @@ using static DHT.Desktop.Program;
|
|||||||
namespace DHT.Desktop.Main.Pages;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
sealed class TrackingPageModel : BaseModel {
|
sealed class TrackingPageModel : BaseModel {
|
||||||
private bool isCopyTrackingScriptButtonEnabled = true;
|
private bool areDevToolsEnabled;
|
||||||
|
|
||||||
public bool IsCopyTrackingScriptButtonEnabled {
|
private bool AreDevToolsEnabled {
|
||||||
get => isCopyTrackingScriptButtonEnabled;
|
|
||||||
set => Change(ref isCopyTrackingScriptButtonEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool? areDevToolsEnabled;
|
|
||||||
|
|
||||||
private bool? AreDevToolsEnabled {
|
|
||||||
get => areDevToolsEnabled;
|
get => areDevToolsEnabled;
|
||||||
set {
|
set {
|
||||||
Change(ref areDevToolsEnabled, value);
|
Change(ref areDevToolsEnabled, value);
|
||||||
@@ -29,19 +21,15 @@ sealed class TrackingPageModel : BaseModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = false;
|
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true;
|
||||||
|
|
||||||
public string ToggleAppDevToolsButtonText {
|
public string ToggleAppDevToolsButtonText {
|
||||||
get {
|
get {
|
||||||
if (!AreDevToolsEnabled.HasValue) {
|
|
||||||
return "Loading...";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!IsToggleAppDevToolsButtonEnabled) {
|
if (!IsToggleAppDevToolsButtonEnabled) {
|
||||||
return "Unavailable";
|
return "Unavailable";
|
||||||
}
|
}
|
||||||
|
|
||||||
return AreDevToolsEnabled.Value ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,22 +40,21 @@ sealed class TrackingPageModel : BaseModel {
|
|||||||
|
|
||||||
public TrackingPageModel(Window window) {
|
public TrackingPageModel(Window window) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
|
|
||||||
Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> OnClickCopyTrackingScript() {
|
public async Task Initialize() {
|
||||||
IsCopyTrackingScriptButtonEnabled = false;
|
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
|
||||||
|
if (devToolsEnabled.HasValue) {
|
||||||
try {
|
AreDevToolsEnabled = devToolsEnabled.Value;
|
||||||
return await CopyTrackingScript();
|
}
|
||||||
} finally {
|
else {
|
||||||
IsCopyTrackingScriptButtonEnabled = true;
|
IsToggleAppDevToolsButtonEnabled = false;
|
||||||
|
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> CopyTrackingScript() {
|
public async Task<bool> OnClickCopyTrackingScript() {
|
||||||
string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
|
string url = $"http://127.0.0.1:{ServerManager.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerManager.Token)}";
|
||||||
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
|
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
|
||||||
|
|
||||||
var clipboard = window.Clipboard;
|
var clipboard = window.Clipboard;
|
||||||
@@ -85,28 +72,10 @@ sealed class TrackingPageModel : BaseModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task InitializeDevToolsToggle() {
|
public async void OnClickToggleAppDevTools() {
|
||||||
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
|
|
||||||
|
|
||||||
if (devToolsEnabled.HasValue) {
|
|
||||||
IsToggleAppDevToolsButtonEnabled = true;
|
|
||||||
AreDevToolsEnabled = devToolsEnabled.Value;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
IsToggleAppDevToolsButtonEnabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task OnClickToggleAppDevTools() {
|
|
||||||
const string DialogTitle = "Discord App Settings File";
|
const string DialogTitle = "Discord App Settings File";
|
||||||
|
|
||||||
if (!AreDevToolsEnabled.HasValue) {
|
bool oldState = AreDevToolsEnabled;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool oldState = AreDevToolsEnabled.Value;
|
|
||||||
bool newState = !oldState;
|
bool newState = !oldState;
|
||||||
|
|
||||||
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
|
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
|
||||||
|
@@ -11,11 +11,10 @@ using Avalonia.Platform.Storage;
|
|||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Desktop.Dialogs.File;
|
using DHT.Desktop.Dialogs.File;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Dialogs.Progress;
|
|
||||||
using DHT.Desktop.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Desktop.Server;
|
||||||
using DHT.Server;
|
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Export;
|
using DHT.Server.Database.Export;
|
||||||
using DHT.Server.Database.Export.Strategy;
|
using DHT.Server.Database.Export.Strategy;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
@@ -24,11 +23,7 @@ using static DHT.Desktop.Program;
|
|||||||
namespace DHT.Desktop.Main.Pages;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
sealed class ViewerPageModel : BaseModel, IDisposable {
|
sealed class ViewerPageModel : BaseModel, IDisposable {
|
||||||
public static readonly ConcurrentBag<string> TemporaryFiles = [];
|
public static readonly ConcurrentBag<string> TemporaryFiles = new ();
|
||||||
|
|
||||||
private static readonly FilePickerFileType[] ViewerFileTypes = [
|
|
||||||
FileDialogs.CreateFilter("Discord History Viewer", ["html"])
|
|
||||||
];
|
|
||||||
|
|
||||||
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
||||||
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
||||||
@@ -43,16 +38,16 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
|
|||||||
public MessageFilterPanelModel FilterModel { get; }
|
public MessageFilterPanelModel FilterModel { get; }
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly State state;
|
private readonly IDatabaseFile db;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public ViewerPageModel() : this(null!, State.Dummy) {}
|
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||||
|
|
||||||
public ViewerPageModel(Window window, State state) {
|
public ViewerPageModel(Window window, IDatabaseFile db) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.state = state;
|
this.db = db;
|
||||||
|
|
||||||
FilterModel = new MessageFilterPanelModel(window, state, "Will export");
|
FilterModel = new MessageFilterPanelModel(window, db, "Will export");
|
||||||
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,64 +59,14 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
|
|||||||
HasFilters = FilterModel.HasAnyFilters;
|
HasFilters = FilterModel.HasAnyFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OnClickOpenViewer() {
|
|
||||||
try {
|
|
||||||
var fullPath = await PrepareTemporaryViewerFile();
|
|
||||||
var strategy = new LiveViewerExportStrategy(ServerConfiguration.Port, ServerConfiguration.Token);
|
|
||||||
|
|
||||||
await WriteViewerFile(fullPath, strategy);
|
|
||||||
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
|
|
||||||
} catch (Exception e) {
|
|
||||||
await Dialog.ShowOk(window, "Open Viewer", "Could not save viewer: " + e.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string> PrepareTemporaryViewerFile() {
|
|
||||||
return await Task.Run(() => {
|
|
||||||
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
|
|
||||||
string filenameBase = Path.GetFileNameWithoutExtension(state.Db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
|
|
||||||
string fullPath = Path.Combine(rootPath, filenameBase + ".html");
|
|
||||||
|
|
||||||
int counter = 0;
|
|
||||||
|
|
||||||
while (File.Exists(fullPath)) {
|
|
||||||
++counter;
|
|
||||||
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
|
|
||||||
}
|
|
||||||
|
|
||||||
TemporaryFiles.Add(fullPath);
|
|
||||||
|
|
||||||
Directory.CreateDirectory(rootPath);
|
|
||||||
|
|
||||||
return fullPath;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void OnClickSaveViewer() {
|
|
||||||
string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
|
||||||
Title = "Save Viewer",
|
|
||||||
FileTypeChoices = ViewerFileTypes,
|
|
||||||
SuggestedFileName = Path.GetFileNameWithoutExtension(state.Db.Path) + ".html",
|
|
||||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(state.Db.Path)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (path == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await WriteViewerFile(path, StandaloneViewerExportStrategy.Instance);
|
|
||||||
} catch (Exception e) {
|
|
||||||
await Dialog.ShowOk(window, "Save Viewer", "Could not save viewer: " + e.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task WriteViewerFile(string path, IViewerExportStrategy strategy) {
|
private async Task WriteViewerFile(string path, IViewerExportStrategy strategy) {
|
||||||
const string ArchiveTag = "/*[ARCHIVE]*/";
|
const string ArchiveTag = "/*[ARCHIVE]*/";
|
||||||
|
|
||||||
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;
|
||||||
@@ -129,7 +74,7 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
|
|||||||
string jsonTempFile = path + ".tmp";
|
string jsonTempFile = path + ".tmp";
|
||||||
|
|
||||||
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
|
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
|
||||||
await ViewerJsonExport.Generate(jsonStream, strategy, state.Db, FilterModel.CreateFilter());
|
await ViewerJsonExport.Generate(jsonStream, strategy, db, FilterModel.CreateFilter());
|
||||||
|
|
||||||
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
|
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
|
||||||
jsonStream.Position = 0;
|
jsonStream.Position = 0;
|
||||||
@@ -153,23 +98,54 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
|
|||||||
File.Delete(jsonTempFile);
|
File.Delete(jsonTempFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OnClickApplyFiltersToDatabase() {
|
public async void OnClickOpenViewer() {
|
||||||
|
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
|
||||||
|
string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
|
||||||
|
string fullPath = Path.Combine(rootPath, filenameBase + ".html");
|
||||||
|
int counter = 0;
|
||||||
|
|
||||||
|
while (File.Exists(fullPath)) {
|
||||||
|
++counter;
|
||||||
|
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
|
||||||
|
}
|
||||||
|
|
||||||
|
TemporaryFiles.Add(fullPath);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(rootPath);
|
||||||
|
await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerManager.Port, ServerManager.Token));
|
||||||
|
|
||||||
|
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly FilePickerFileType[] ViewerFileTypes = {
|
||||||
|
FileDialogs.CreateFilter("Discord History Viewer", new string[] { "html" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
public async void OnClickSaveViewer() {
|
||||||
|
string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
||||||
|
Title = "Save Viewer",
|
||||||
|
FileTypeChoices = ViewerFileTypes,
|
||||||
|
SuggestedFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
|
||||||
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(db.Path)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (path != null) {
|
||||||
|
await WriteViewerFile(path, StandaloneViewerExportStrategy.Instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void OnClickApplyFiltersToDatabase() {
|
||||||
var filter = FilterModel.CreateFilter();
|
var filter = FilterModel.CreateFilter();
|
||||||
var messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
|
|
||||||
|
|
||||||
if (DatabaseToolFilterModeKeep) {
|
if (DatabaseToolFilterModeKeep) {
|
||||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", messageCount.Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
|
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||||
await ApplyFilterToDatabase(filter, FilterRemovalMode.KeepMatching);
|
db.RemoveMessages(filter, FilterRemovalMode.KeepMatching);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (DatabaseToolFilterModeRemove) {
|
else if (DatabaseToolFilterModeRemove) {
|
||||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", messageCount.Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
|
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||||
await ApplyFilterToDatabase(filter, FilterRemovalMode.RemoveMatching);
|
db.RemoveMessages(filter, FilterRemovalMode.RemoveMatching);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) {
|
|
||||||
await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Removing messages...", _ => state.Db.Messages.Remove(filter, removalMode));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,12 +1,19 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
using DHT.Desktop.Main.Pages;
|
using DHT.Desktop.Main.Pages;
|
||||||
using DHT.Server;
|
using DHT.Desktop.Server;
|
||||||
|
using DHT.Server.Database;
|
||||||
|
using DHT.Server.Service;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens;
|
namespace DHT.Desktop.Main.Screens;
|
||||||
|
|
||||||
sealed class MainContentScreenModel : IDisposable {
|
sealed class MainContentScreenModel : IDisposable {
|
||||||
|
private static readonly Log Log = Log.ForType<MainContentScreenModel>();
|
||||||
|
|
||||||
public DatabasePage DatabasePage { get; }
|
public DatabasePage DatabasePage { get; }
|
||||||
private DatabasePageModel DatabasePageModel { get; }
|
private DatabasePageModel DatabasePageModel { get; }
|
||||||
|
|
||||||
@@ -28,7 +35,7 @@ sealed class MainContentScreenModel : IDisposable {
|
|||||||
public bool HasDebugPage => true;
|
public bool HasDebugPage => true;
|
||||||
private DebugPageModel DebugPageModel { get; }
|
private DebugPageModel DebugPageModel { get; }
|
||||||
#else
|
#else
|
||||||
public bool HasDebugPage => false;
|
public bool HasDebugPage => false;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public StatusBarModel StatusBarModel { get; }
|
public StatusBarModel StatusBarModel { get; }
|
||||||
@@ -42,39 +49,71 @@ sealed class MainContentScreenModel : IDisposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
private readonly Window window;
|
||||||
public MainContentScreenModel() : this(null!, State.Dummy) {}
|
private readonly ServerManager serverManager;
|
||||||
|
|
||||||
public MainContentScreenModel(Window window, State state) {
|
[Obsolete("Designer")]
|
||||||
DatabasePageModel = new DatabasePageModel(window, state);
|
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||||
|
|
||||||
|
public MainContentScreenModel(Window window, IDatabaseFile db) {
|
||||||
|
this.window = window;
|
||||||
|
this.serverManager = new ServerManager(db);
|
||||||
|
|
||||||
|
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
|
||||||
|
|
||||||
|
DatabasePageModel = new DatabasePageModel(window, db);
|
||||||
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
|
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
|
||||||
|
|
||||||
TrackingPageModel = new TrackingPageModel(window);
|
TrackingPageModel = new TrackingPageModel(window);
|
||||||
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
||||||
|
|
||||||
AttachmentsPageModel = new AttachmentsPageModel(state);
|
AttachmentsPageModel = new AttachmentsPageModel(db);
|
||||||
AttachmentsPage = new AttachmentsPage { DataContext = AttachmentsPageModel };
|
AttachmentsPage = new AttachmentsPage { DataContext = AttachmentsPageModel };
|
||||||
|
|
||||||
ViewerPageModel = new ViewerPageModel(window, state);
|
ViewerPageModel = new ViewerPageModel(window, db);
|
||||||
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
||||||
|
|
||||||
AdvancedPageModel = new AdvancedPageModel(window, state);
|
AdvancedPageModel = new AdvancedPageModel(window, db, serverManager);
|
||||||
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
DebugPageModel = new DebugPageModel(window, state);
|
DebugPageModel = new DebugPageModel(window, db);
|
||||||
DebugPage = new DebugPage { DataContext = DebugPageModel };
|
DebugPage = new DebugPage { DataContext = DebugPageModel };
|
||||||
#else
|
#else
|
||||||
DebugPage = null;
|
DebugPage = null;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
StatusBarModel = new StatusBarModel(state);
|
StatusBarModel = new StatusBarModel(db.Statistics);
|
||||||
|
|
||||||
|
AdvancedPageModel.ServerConfigurationModel.ServerStatusChanged += OnServerStatusChanged;
|
||||||
|
DatabaseClosed += OnDatabaseClosed;
|
||||||
|
|
||||||
|
StatusBarModel.CurrentStatus = serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Initialize() {
|
||||||
|
await TrackingPageModel.Initialize();
|
||||||
|
AdvancedPageModel.Initialize();
|
||||||
|
serverManager.Launch();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
|
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
|
||||||
AttachmentsPageModel.Dispose();
|
AttachmentsPageModel.Dispose();
|
||||||
ViewerPageModel.Dispose();
|
ViewerPageModel.Dispose();
|
||||||
AdvancedPageModel.Dispose();
|
serverManager.Dispose();
|
||||||
StatusBarModel.Dispose();
|
}
|
||||||
|
|
||||||
|
private void OnServerStatusChanged(object? sender, StatusBarModel.Status e) {
|
||||||
|
StatusBarModel.CurrentStatus = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDatabaseClosed(object? sender, EventArgs e) {
|
||||||
|
serverManager.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
|
||||||
|
Log.Error(ex);
|
||||||
|
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -32,7 +32,7 @@
|
|||||||
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" />
|
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" />
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||||
<Button Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
|
<Button Command="{Binding OpenOrCreateDatabase}">Open or Create Database</Button>
|
||||||
<Button Command="{Binding ShowAboutDialog}">About</Button>
|
<Button Command="{Binding ShowAboutDialog}">About</Button>
|
||||||
<Button Command="{Binding Exit}">Exit</Button>
|
<Button Command="{Binding Exit}">Exit</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
@@ -7,23 +7,17 @@ using DHT.Desktop.Common;
|
|||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Dialogs.Progress;
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
using DHT.Server.Database.Sqlite;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens;
|
namespace DHT.Desktop.Main.Screens;
|
||||||
|
|
||||||
sealed class WelcomeScreenModel : BaseModel {
|
sealed class WelcomeScreenModel : BaseModel, IDisposable {
|
||||||
public string Version => Program.Version;
|
public string Version => Program.Version;
|
||||||
|
|
||||||
private bool isOpenOrCreateDatabaseButtonEnabled = true;
|
public IDatabaseFile? Db { get; private set; }
|
||||||
|
public bool HasDatabase => Db != null;
|
||||||
public bool IsOpenOrCreateDatabaseButtonEnabled {
|
|
||||||
get => isOpenOrCreateDatabaseButtonEnabled;
|
|
||||||
set => Change(ref isOpenOrCreateDatabaseButtonEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public event EventHandler<IDatabaseFile>? DatabaseSelected;
|
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
|
||||||
private string? dbFilePath;
|
private string? dbFilePath;
|
||||||
@@ -35,25 +29,23 @@ sealed class WelcomeScreenModel : BaseModel {
|
|||||||
this.window = window;
|
this.window = window;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OpenOrCreateDatabase() {
|
public async void OpenOrCreateDatabase() {
|
||||||
IsOpenOrCreateDatabaseButtonEnabled = false;
|
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
||||||
try {
|
if (path != null) {
|
||||||
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
await OpenOrCreateDatabaseFromPath(path);
|
||||||
if (path != null) {
|
|
||||||
await OpenOrCreateDatabaseFromPath(path);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
IsOpenOrCreateDatabaseButtonEnabled = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
||||||
dbFilePath = path;
|
if (Db != null) {
|
||||||
|
Db = null;
|
||||||
var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
|
||||||
if (db != null) {
|
|
||||||
DatabaseSelected?.Invoke(this, db);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dbFilePath = path;
|
||||||
|
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(Db));
|
||||||
|
OnPropertyChanged(nameof(HasDatabase));
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||||
@@ -75,8 +67,12 @@ sealed class WelcomeScreenModel : BaseModel {
|
|||||||
await doUpgrade(reporter);
|
await doUpgrade(reporter);
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(600));
|
await Task.Delay(TimeSpan.FromMilliseconds(600));
|
||||||
}
|
}
|
||||||
|
|
||||||
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
|
await new ProgressDialog {
|
||||||
|
DataContext = new ProgressDialogModel(StartUpgrade, progressItems: 3) {
|
||||||
|
Title = "Upgrading Database"
|
||||||
|
}
|
||||||
|
}.ShowProgressDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||||
@@ -113,11 +109,22 @@ sealed class WelcomeScreenModel : BaseModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ShowAboutDialog() {
|
public void CloseDatabase() {
|
||||||
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window);
|
Dispose();
|
||||||
|
OnPropertyChanged(nameof(Db));
|
||||||
|
OnPropertyChanged(nameof(HasDatabase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void ShowAboutDialog() {
|
||||||
|
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Exit() {
|
public void Exit() {
|
||||||
window.Close();
|
window.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
Db?.Dispose();
|
||||||
|
Db = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,8 +0,0 @@
|
|||||||
using DHT.Server.Service;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Server;
|
|
||||||
|
|
||||||
static class ServerConfiguration {
|
|
||||||
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
|
|
||||||
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
|
||||||
}
|
|
50
app/Desktop/Server/ServerManager.cs
Normal file
50
app/Desktop/Server/ServerManager.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using DHT.Server.Database;
|
||||||
|
using DHT.Server.Service;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Server;
|
||||||
|
|
||||||
|
sealed class ServerManager : IDisposable {
|
||||||
|
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
|
||||||
|
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
||||||
|
|
||||||
|
private static ServerManager? instance;
|
||||||
|
|
||||||
|
public bool IsRunning => ServerLauncher.IsRunning;
|
||||||
|
|
||||||
|
private readonly IDatabaseFile db;
|
||||||
|
|
||||||
|
public ServerManager(IDatabaseFile db) {
|
||||||
|
if (db != DummyDatabaseFile.Instance) {
|
||||||
|
if (instance != null) {
|
||||||
|
throw new InvalidOperationException("Only one instance of ServerManager can exist at the same time!");
|
||||||
|
}
|
||||||
|
|
||||||
|
instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Launch() {
|
||||||
|
ServerLauncher.Relaunch(Port, Token, db);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Relaunch(ushort port, string token) {
|
||||||
|
Port = port;
|
||||||
|
Token = token;
|
||||||
|
Launch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop() {
|
||||||
|
ServerLauncher.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
Stop();
|
||||||
|
|
||||||
|
if (instance == this) {
|
||||||
|
instance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<LangVersion>12</LangVersion>
|
<LangVersion>11</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
@@ -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) {
|
||||||
|
@@ -8,6 +8,5 @@ namespace DHT.Server.Data;
|
|||||||
public enum DownloadStatus {
|
public enum DownloadStatus {
|
||||||
Enqueued = 0,
|
Enqueued = 0,
|
||||||
GenericError = 1,
|
GenericError = 1,
|
||||||
Downloading = 2,
|
|
||||||
Success = HttpStatusCode.OK
|
Success = HttpStatusCode.OK
|
||||||
}
|
}
|
||||||
|
@@ -10,7 +10,7 @@ public readonly struct Message {
|
|||||||
public long Timestamp { get; init; }
|
public long Timestamp { get; init; }
|
||||||
public long? EditTimestamp { get; init; }
|
public long? EditTimestamp { get; init; }
|
||||||
public ulong? RepliedToId { get; init; }
|
public ulong? RepliedToId { get; init; }
|
||||||
public ImmutableList<Attachment> Attachments { get; init; }
|
public ImmutableArray<Attachment> Attachments { get; init; }
|
||||||
public ImmutableList<Embed> Embeds { get; init; }
|
public ImmutableArray<Embed> Embeds { get; init; }
|
||||||
public ImmutableList<Reaction> Reactions { get; init; }
|
public ImmutableArray<Reaction> Reactions { get; init; }
|
||||||
}
|
}
|
||||||
|
@@ -1,32 +1,29 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
|
|
||||||
namespace DHT.Server.Database;
|
namespace DHT.Server.Database;
|
||||||
|
|
||||||
public static class DatabaseExtensions {
|
public static class DatabaseExtensions {
|
||||||
public static async Task AddFrom(this IDatabaseFile target, IDatabaseFile source) {
|
public static void AddFrom(this IDatabaseFile target, IDatabaseFile source) {
|
||||||
await target.Users.Add(await source.Users.Get().ToListAsync());
|
target.AddServers(source.GetAllServers());
|
||||||
await target.Servers.Add(await source.Servers.Get().ToListAsync());
|
target.AddChannels(source.GetAllChannels());
|
||||||
await target.Channels.Add(await source.Channels.Get().ToListAsync());
|
target.AddUsers(source.GetAllUsers().ToArray());
|
||||||
|
target.AddMessages(source.GetMessages().ToArray());
|
||||||
|
|
||||||
const int MessageBatchSize = 100;
|
foreach (var download in source.GetDownloadsWithoutData()) {
|
||||||
List<Message> batchedMessages = new (MessageBatchSize);
|
target.AddDownload(download.Status == DownloadStatus.Success ? source.GetDownloadWithData(download) : download);
|
||||||
|
|
||||||
await foreach (var message in source.Messages.Get()) {
|
|
||||||
batchedMessages.Add(message);
|
|
||||||
|
|
||||||
if (batchedMessages.Count >= MessageBatchSize) {
|
|
||||||
await target.Messages.Add(batchedMessages);
|
|
||||||
batchedMessages.Clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
await target.Messages.Add(batchedMessages);
|
|
||||||
|
|
||||||
await foreach (var download in source.Downloads.GetWithoutData()) {
|
internal static void AddServers(this IDatabaseFile target, IEnumerable<Data.Server> servers) {
|
||||||
await target.Downloads.AddDownload(download.Status == DownloadStatus.Success ? await source.Downloads.HydrateWithData(download) : download);
|
foreach (var server in servers) {
|
||||||
|
target.AddServer(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void AddChannels(this IDatabaseFile target, IEnumerable<Channel> channels) {
|
||||||
|
foreach (var channel in channels) {
|
||||||
|
target.AddChannel(channel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,31 +1,90 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Threading.Tasks;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database.Repositories;
|
using DHT.Server.Data.Aggregations;
|
||||||
|
using DHT.Server.Data.Filters;
|
||||||
|
using DHT.Server.Download;
|
||||||
|
|
||||||
namespace DHT.Server.Database;
|
namespace DHT.Server.Database;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "ArrangeObjectCreationWhenTypeNotEvident")]
|
[SuppressMessage("ReSharper", "ArrangeObjectCreationWhenTypeNotEvident")]
|
||||||
sealed class DummyDatabaseFile : IDatabaseFile {
|
public sealed class DummyDatabaseFile : IDatabaseFile {
|
||||||
public static DummyDatabaseFile Instance { get; } = new ();
|
public static DummyDatabaseFile Instance { get; } = new();
|
||||||
|
|
||||||
public string Path => "";
|
public string Path => "";
|
||||||
public DatabaseStatistics Statistics { get; } = new ();
|
public DatabaseStatistics Statistics { get; } = new();
|
||||||
|
|
||||||
public IUserRepository Users { get; } = new IUserRepository.Dummy();
|
|
||||||
public IServerRepository Servers { get; } = new IServerRepository.Dummy();
|
|
||||||
public IChannelRepository Channels { get; } = new IChannelRepository.Dummy();
|
|
||||||
public IMessageRepository Messages { get; } = new IMessageRepository.Dummy();
|
|
||||||
public IDownloadRepository Downloads { get; } = new IDownloadRepository.Dummy();
|
|
||||||
|
|
||||||
private DummyDatabaseFile() {}
|
private DummyDatabaseFile() {}
|
||||||
|
|
||||||
public Task<DatabaseStatisticsSnapshot> SnapshotStatistics() {
|
public DatabaseStatisticsSnapshot SnapshotStatistics() {
|
||||||
return Task.FromResult(new DatabaseStatisticsSnapshot());
|
return new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Vacuum() {
|
public void AddServer(Data.Server server) {}
|
||||||
return Task.CompletedTask;
|
|
||||||
|
public List<Data.Server> GetAllServers() {
|
||||||
|
return new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void AddChannel(Channel channel) {}
|
||||||
|
|
||||||
|
public List<Channel> GetAllChannels() {
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddUsers(User[] users) {}
|
||||||
|
|
||||||
|
public List<User> GetAllUsers() {
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddMessages(Message[] messages) {}
|
||||||
|
|
||||||
|
public int CountMessages(MessageFilter? filter = null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Message> GetMessages(MessageFilter? filter = null, bool includeText = true) {
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public HashSet<ulong> GetMessageIds(MessageFilter? filter = null) {
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveMessages(MessageFilter filter, FilterRemovalMode mode) {}
|
||||||
|
|
||||||
|
public int CountAttachments(AttachmentFilter? filter = null) {
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Data.Download> GetDownloadsWithoutData() {
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Data.Download GetDownloadWithData(Data.Download download) {
|
||||||
|
return download;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DownloadedAttachment? GetDownloadedAttachment(string url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddDownload(Data.Download download) {}
|
||||||
|
|
||||||
|
public void EnqueueDownloadItems(AttachmentFilter? filter = null) {}
|
||||||
|
|
||||||
|
public List<DownloadItem> GetEnqueuedDownloadItems(int count) {
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {}
|
||||||
|
|
||||||
|
public DownloadStatusStatistics GetDownloadStatusStatistics() {
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Vacuum() {}
|
||||||
|
|
||||||
public void Dispose() {}
|
public void Dispose() {}
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,3 @@
|
|||||||
namespace DHT.Server.Database.Export;
|
namespace DHT.Server.Database.Export;
|
||||||
|
|
||||||
readonly record struct Snowflake(ulong Id);
|
readonly record struct Snowflake(ulong Id);
|
||||||
|
@@ -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.
|
||||||
|
|
||||||
|
@@ -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 = await db.Messages.Get(filter).ToListAsync();
|
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) {
|
||||||
@@ -29,23 +29,23 @@ public static class ViewerJsonExport {
|
|||||||
includedChannelIds.Add(message.Channel);
|
includedChannelIds.Add(message.Channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
await foreach (var channel in db.Channels.Get()) {
|
foreach (var channel in db.GetAllChannels()) {
|
||||||
if (includedChannelIds.Contains(channel.Id)) {
|
if (includedChannelIds.Contains(channel.Id)) {
|
||||||
includedChannels.Add(channel);
|
includedChannels.Add(channel);
|
||||||
includedServerIds.Add(channel.Server);
|
includedServerIds.Add(channel.Server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var (users, userIndex, userIndices) = await GenerateUserList(db, includedUserIds);
|
var users = GenerateUserList(db, includedUserIds, out var userindex, out var userIndices);
|
||||||
var (servers, serverIndices) = await GenerateServerList(db, includedServerIds);
|
var servers = GenerateServerList(db, includedServerIds, out var serverindex);
|
||||||
var channels = GenerateChannelList(includedChannels, serverIndices);
|
var channels = GenerateChannelList(includedChannels, serverindex);
|
||||||
|
|
||||||
perf.Step("Collect database data");
|
perf.Step("Collect database data");
|
||||||
|
|
||||||
var value = new ViewerJson {
|
var value = new ViewerJson {
|
||||||
Meta = new ViewerJson.JsonMeta {
|
Meta = new ViewerJson.JsonMeta {
|
||||||
Users = users,
|
Users = users,
|
||||||
Userindex = userIndex,
|
Userindex = userindex,
|
||||||
Servers = servers,
|
Servers = servers,
|
||||||
Channels = channels
|
Channels = channels
|
||||||
},
|
},
|
||||||
@@ -60,12 +60,12 @@ public static class ViewerJsonExport {
|
|||||||
perf.End();
|
perf.End();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<(Dictionary<Snowflake, ViewerJson.JsonUser> Users, List<Snowflake> UserIndex, Dictionary<ulong, int> UserIndices)> GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds) {
|
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<Snowflake, ViewerJson.JsonUser>();
|
var users = new Dictionary<Snowflake, ViewerJson.JsonUser>();
|
||||||
var userIndex = new List<Snowflake>();
|
userindex = new List<Snowflake>();
|
||||||
var userIndices = new Dictionary<ulong, int>();
|
userIndices = new Dictionary<ulong, int>();
|
||||||
|
|
||||||
await foreach (var user in db.Users.Get()) {
|
foreach (var user in db.GetAllUsers()) {
|
||||||
var id = user.Id;
|
var id = user.Id;
|
||||||
if (!userIds.Contains(id)) {
|
if (!userIds.Contains(id)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -73,7 +73,7 @@ public static class ViewerJsonExport {
|
|||||||
|
|
||||||
var idSnowflake = new Snowflake(id);
|
var idSnowflake = new Snowflake(id);
|
||||||
userIndices[id] = users.Count;
|
userIndices[id] = users.Count;
|
||||||
userIndex.Add(idSnowflake);
|
userindex.Add(idSnowflake);
|
||||||
|
|
||||||
users[idSnowflake] = new ViewerJson.JsonUser {
|
users[idSnowflake] = new ViewerJson.JsonUser {
|
||||||
Name = user.Name,
|
Name = user.Name,
|
||||||
@@ -82,14 +82,14 @@ public static class ViewerJsonExport {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return (users, userIndex, userIndices);
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<(List<ViewerJson.JsonServer> Servers, Dictionary<ulong, int> ServerIndices)> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds) {
|
private static List<ViewerJson.JsonServer> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, int> serverIndices) {
|
||||||
var servers = new List<ViewerJson.JsonServer>();
|
var servers = new List<ViewerJson.JsonServer>();
|
||||||
var serverIndices = new Dictionary<ulong, int>();
|
serverIndices = new Dictionary<ulong, int>();
|
||||||
|
|
||||||
await foreach (var server in db.Servers.Get()) {
|
foreach (var server in db.GetAllServers()) {
|
||||||
var id = server.Id;
|
var id = server.Id;
|
||||||
if (!serverIds.Contains(id)) {
|
if (!serverIds.Contains(id)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -103,7 +103,7 @@ public static class ViewerJsonExport {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (servers, serverIndices);
|
return servers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, int> serverIndices) {
|
private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, int> serverIndices) {
|
||||||
|
@@ -1,19 +1,43 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Collections.Generic;
|
||||||
using DHT.Server.Database.Repositories;
|
using DHT.Server.Data;
|
||||||
|
using DHT.Server.Data.Aggregations;
|
||||||
|
using DHT.Server.Data.Filters;
|
||||||
|
using DHT.Server.Download;
|
||||||
|
|
||||||
namespace DHT.Server.Database;
|
namespace DHT.Server.Database;
|
||||||
|
|
||||||
public interface IDatabaseFile : IDisposable {
|
public interface IDatabaseFile : IDisposable {
|
||||||
string Path { get; }
|
string Path { get; }
|
||||||
DatabaseStatistics Statistics { get; }
|
DatabaseStatistics Statistics { get; }
|
||||||
Task<DatabaseStatisticsSnapshot> SnapshotStatistics();
|
DatabaseStatisticsSnapshot SnapshotStatistics();
|
||||||
|
|
||||||
IUserRepository Users { get; }
|
void AddServer(Data.Server server);
|
||||||
IServerRepository Servers { get; }
|
List<Data.Server> GetAllServers();
|
||||||
IChannelRepository Channels { get; }
|
|
||||||
IMessageRepository Messages { get; }
|
|
||||||
IDownloadRepository Downloads { get; }
|
|
||||||
|
|
||||||
Task Vacuum();
|
void AddChannel(Channel channel);
|
||||||
|
List<Channel> GetAllChannels();
|
||||||
|
|
||||||
|
void AddUsers(User[] users);
|
||||||
|
List<User> GetAllUsers();
|
||||||
|
|
||||||
|
void AddMessages(Message[] messages);
|
||||||
|
int CountMessages(MessageFilter? filter = null);
|
||||||
|
List<Message> GetMessages(MessageFilter? filter = null, bool includeText = true);
|
||||||
|
HashSet<ulong> GetMessageIds(MessageFilter? filter = null);
|
||||||
|
void RemoveMessages(MessageFilter filter, FilterRemovalMode mode);
|
||||||
|
|
||||||
|
int CountAttachments(AttachmentFilter? filter = null);
|
||||||
|
|
||||||
|
void AddDownload(Data.Download download);
|
||||||
|
List<Data.Download> GetDownloadsWithoutData();
|
||||||
|
Data.Download GetDownloadWithData(Data.Download download);
|
||||||
|
DownloadedAttachment? GetDownloadedAttachment(string url);
|
||||||
|
|
||||||
|
void EnqueueDownloadItems(AttachmentFilter? filter = null);
|
||||||
|
List<DownloadItem> GetEnqueuedDownloadItems(int count);
|
||||||
|
void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode);
|
||||||
|
DownloadStatusStatistics GetDownloadStatusStatistics();
|
||||||
|
|
||||||
|
void Vacuum();
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Import;
|
namespace DHT.Server.Database.Import;
|
||||||
|
|
||||||
sealed class DiscordEmbedLegacyJson {
|
sealed class DiscordEmbedLegacyJson {
|
||||||
public required string Url { get; init; }
|
public required string Url { get; init; }
|
||||||
|
@@ -33,8 +33,10 @@ public static class LegacyArchiveImport {
|
|||||||
var servers = ReadServerList(meta, fakeSnowflake);
|
var servers = ReadServerList(meta, fakeSnowflake);
|
||||||
|
|
||||||
var newServersOnly = new HashSet<Data.Server>(servers);
|
var newServersOnly = new HashSet<Data.Server>(servers);
|
||||||
var oldServersById = await db.Servers.Get().ToDictionaryAsync(static server => server.Id, static server => server);
|
var oldServersById = db.GetAllServers().ToDictionary(static server => server.Id, static server => server);
|
||||||
var oldChannelsById = await db.Channels.Get().ToDictionaryAsync(static channel => channel.Id, static channel => channel);
|
|
||||||
|
var oldChannels = db.GetAllChannels();
|
||||||
|
var oldChannelsById = oldChannels.ToDictionary(static channel => channel.Id, static channel => channel);
|
||||||
|
|
||||||
foreach (var (channelId, serverIndex) in ReadChannelToServerIndexMapping(meta, servers)) {
|
foreach (var (channelId, serverIndex) in ReadChannelToServerIndexMapping(meta, servers)) {
|
||||||
if (oldChannelsById.TryGetValue(channelId, out var oldChannel) && oldServersById.TryGetValue(oldChannel.Server, out var oldServer) && newServersOnly.Remove(servers[serverIndex])) {
|
if (oldChannelsById.TryGetValue(channelId, out var oldChannel) && oldServersById.TryGetValue(oldChannel.Server, out var oldServer) && newServersOnly.Remove(servers[serverIndex])) {
|
||||||
@@ -64,17 +66,17 @@ public static class LegacyArchiveImport {
|
|||||||
|
|
||||||
perf.Step("Read channel list");
|
perf.Step("Read channel list");
|
||||||
|
|
||||||
var oldMessageIds = await db.Messages.GetIds().ToHashSetAsync();
|
var oldMessageIds = db.GetMessageIds();
|
||||||
var newMessages = channels.SelectMany(channel => ReadMessages(data, channel, users, fakeSnowflake))
|
var newMessages = channels.SelectMany(channel => ReadMessages(data, channel, users, fakeSnowflake))
|
||||||
.Where(message => !oldMessageIds.Contains(message.Id))
|
.Where(message => !oldMessageIds.Contains(message.Id))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
perf.Step("Read messages");
|
perf.Step("Read messages");
|
||||||
|
|
||||||
await db.Users.Add(users);
|
db.AddUsers(users);
|
||||||
await db.Servers.Add(servers);
|
db.AddServers(servers);
|
||||||
await db.Channels.Add(channels);
|
db.AddChannels(channels);
|
||||||
await db.Messages.Add(newMessages);
|
db.AddMessages(newMessages);
|
||||||
|
|
||||||
perf.Step("Import into database");
|
perf.Step("Import into database");
|
||||||
} catch (HttpException e) {
|
} catch (HttpException e) {
|
||||||
@@ -177,9 +179,9 @@ public static class LegacyArchiveImport {
|
|||||||
Timestamp = messageObj.RequireLong("t", path),
|
Timestamp = messageObj.RequireLong("t", path),
|
||||||
EditTimestamp = messageObj.HasKey("te") ? messageObj.RequireLong("te", path) : null,
|
EditTimestamp = messageObj.HasKey("te") ? messageObj.RequireLong("te", path) : null,
|
||||||
RepliedToId = messageObj.HasKey("r") ? messageObj.RequireSnowflake("r", path) : null,
|
RepliedToId = messageObj.HasKey("r") ? messageObj.RequireSnowflake("r", path) : null,
|
||||||
Attachments = messageObj.HasKey("a") ? ReadMessageAttachments(messageObj.RequireArray("a", path), fakeSnowflake, path + ".a[]").ToImmutableList() : ImmutableList<Attachment>.Empty,
|
Attachments = messageObj.HasKey("a") ? ReadMessageAttachments(messageObj.RequireArray("a", path), fakeSnowflake, path + ".a[]").ToImmutableArray() : ImmutableArray<Attachment>.Empty,
|
||||||
Embeds = messageObj.HasKey("e") ? ReadMessageEmbeds(messageObj.RequireArray("e", path), path + ".e[]").ToImmutableList() : ImmutableList<Embed>.Empty,
|
Embeds = messageObj.HasKey("e") ? ReadMessageEmbeds(messageObj.RequireArray("e", path), path + ".e[]").ToImmutableArray() : ImmutableArray<Embed>.Empty,
|
||||||
Reactions = messageObj.HasKey("re") ? ReadMessageReactions(messageObj.RequireArray("re", path), path + ".re[]").ToImmutableList() : ImmutableList<Reaction>.Empty,
|
Reactions = messageObj.HasKey("re") ? ReadMessageReactions(messageObj.RequireArray("re", path), path + ".re[]").ToImmutableArray() : ImmutableArray<Reaction>.Empty,
|
||||||
};
|
};
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
}
|
}
|
||||||
|
@@ -1,22 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Data;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Repositories;
|
|
||||||
|
|
||||||
public interface IChannelRepository {
|
|
||||||
Task Add(IReadOnlyList<Channel> channels);
|
|
||||||
|
|
||||||
IAsyncEnumerable<Channel> Get();
|
|
||||||
|
|
||||||
internal sealed class Dummy : IChannelRepository {
|
|
||||||
public Task Add(IReadOnlyList<Channel> channels) {
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAsyncEnumerable<Channel> Get() {
|
|
||||||
return AsyncEnumerable.Empty<Channel>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,68 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Data;
|
|
||||||
using DHT.Server.Data.Aggregations;
|
|
||||||
using DHT.Server.Data.Filters;
|
|
||||||
using DHT.Server.Download;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Repositories;
|
|
||||||
|
|
||||||
public interface IDownloadRepository {
|
|
||||||
Task<long> CountAttachments(AttachmentFilter? filter = null, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task AddDownload(Data.Download download);
|
|
||||||
|
|
||||||
Task<DownloadStatusStatistics> GetStatistics(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
IAsyncEnumerable<Data.Download> GetWithoutData();
|
|
||||||
|
|
||||||
Task<Data.Download> HydrateWithData(Data.Download download);
|
|
||||||
|
|
||||||
Task<DownloadedAttachment?> GetDownloadedAttachment(string normalizedUrl);
|
|
||||||
|
|
||||||
Task<int> EnqueueDownloadItems(AttachmentFilter? filter = null, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
IAsyncEnumerable<DownloadItem> PullEnqueuedDownloadItems(int count, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode);
|
|
||||||
|
|
||||||
internal sealed class Dummy : IDownloadRepository {
|
|
||||||
public Task<long> CountAttachments(AttachmentFilter? filter, CancellationToken cancellationToken) {
|
|
||||||
return Task.FromResult(0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddDownload(Data.Download download) {
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<DownloadStatusStatistics> GetStatistics(CancellationToken cancellationToken) {
|
|
||||||
return Task.FromResult(new DownloadStatusStatistics());
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAsyncEnumerable<Data.Download> GetWithoutData() {
|
|
||||||
return AsyncEnumerable.Empty<Data.Download>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<Data.Download> HydrateWithData(Data.Download download) {
|
|
||||||
return Task.FromResult(download);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<DownloadedAttachment?> GetDownloadedAttachment(string normalizedUrl) {
|
|
||||||
return Task.FromResult<DownloadedAttachment?>(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<int> EnqueueDownloadItems(AttachmentFilter? filter, CancellationToken cancellationToken) {
|
|
||||||
return Task.FromResult(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAsyncEnumerable<DownloadItem> PullEnqueuedDownloadItems(int count, CancellationToken cancellationToken) {
|
|
||||||
return AsyncEnumerable.Empty<DownloadItem>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,42 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Data;
|
|
||||||
using DHT.Server.Data.Filters;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Repositories;
|
|
||||||
|
|
||||||
public interface IMessageRepository {
|
|
||||||
Task Add(IReadOnlyList<Message> messages);
|
|
||||||
|
|
||||||
Task<long> Count(MessageFilter? filter = null, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
IAsyncEnumerable<Message> Get(MessageFilter? filter = null);
|
|
||||||
|
|
||||||
IAsyncEnumerable<ulong> GetIds(MessageFilter? filter = null);
|
|
||||||
|
|
||||||
Task Remove(MessageFilter filter, FilterRemovalMode mode);
|
|
||||||
|
|
||||||
internal sealed class Dummy : IMessageRepository {
|
|
||||||
public Task Add(IReadOnlyList<Message> messages) {
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) {
|
|
||||||
return Task.FromResult(0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAsyncEnumerable<Message> Get(MessageFilter? filter) {
|
|
||||||
return AsyncEnumerable.Empty<Message>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAsyncEnumerable<ulong> GetIds(MessageFilter? filter) {
|
|
||||||
return AsyncEnumerable.Empty<ulong>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task Remove(MessageFilter filter, FilterRemovalMode mode) {
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,21 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Repositories;
|
|
||||||
|
|
||||||
public interface IServerRepository {
|
|
||||||
Task Add(IReadOnlyList<Data.Server> servers);
|
|
||||||
|
|
||||||
IAsyncEnumerable<Data.Server> Get();
|
|
||||||
|
|
||||||
internal sealed class Dummy : IServerRepository {
|
|
||||||
public Task Add(IReadOnlyList<Data.Server> servers) {
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAsyncEnumerable<Data.Server> Get() {
|
|
||||||
return AsyncEnumerable.Empty<Data.Server>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,22 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Data;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Repositories;
|
|
||||||
|
|
||||||
public interface IUserRepository {
|
|
||||||
Task Add(IReadOnlyList<User> users);
|
|
||||||
|
|
||||||
IAsyncEnumerable<User> Get();
|
|
||||||
|
|
||||||
internal sealed class Dummy : IUserRepository {
|
|
||||||
public Task Add(IReadOnlyList<User> users) {
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAsyncEnumerable<User> Get() {
|
|
||||||
return AsyncEnumerable.Empty<User>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite.Utils;
|
namespace DHT.Server.Database.Sqlite;
|
||||||
|
|
||||||
public interface ISchemaUpgradeCallbacks {
|
public interface ISchemaUpgradeCallbacks {
|
||||||
Task<bool> CanUpgrade();
|
Task<bool> CanUpgrade();
|
@@ -1,77 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Data;
|
|
||||||
using DHT.Server.Database.Repositories;
|
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite.Repositories;
|
|
||||||
|
|
||||||
sealed class SqliteChannelRepository : IChannelRepository {
|
|
||||||
private readonly SqliteConnectionPool pool;
|
|
||||||
private readonly DatabaseStatistics statistics;
|
|
||||||
|
|
||||||
public SqliteChannelRepository(SqliteConnectionPool pool, DatabaseStatistics statistics) {
|
|
||||||
this.pool = pool;
|
|
||||||
this.statistics = statistics;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task Initialize() {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
await UpdateChannelStatistics(conn);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpdateChannelStatistics(ISqliteConnection conn) {
|
|
||||||
statistics.TotalChannels = await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM channels", static reader => reader?.GetInt64(0) ?? 0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Add(IReadOnlyList<Channel> channels) {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
|
|
||||||
await using (var tx = await conn.BeginTransactionAsync()) {
|
|
||||||
await using var cmd = conn.Upsert("channels", [
|
|
||||||
("id", SqliteType.Integer),
|
|
||||||
("server", SqliteType.Integer),
|
|
||||||
("name", SqliteType.Text),
|
|
||||||
("parent_id", SqliteType.Integer),
|
|
||||||
("position", SqliteType.Integer),
|
|
||||||
("topic", SqliteType.Text),
|
|
||||||
("nsfw", SqliteType.Integer)
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach (var channel in channels) {
|
|
||||||
cmd.Set(":id", channel.Id);
|
|
||||||
cmd.Set(":server", channel.Server);
|
|
||||||
cmd.Set(":name", channel.Name);
|
|
||||||
cmd.Set(":parent_id", channel.ParentId);
|
|
||||||
cmd.Set(":position", channel.Position);
|
|
||||||
cmd.Set(":topic", channel.Topic);
|
|
||||||
cmd.Set(":nsfw", channel.Nsfw);
|
|
||||||
await cmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.CommitAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
await UpdateChannelStatistics(conn);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<Channel> Get() {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT id, server, name, parent_id, position, topic, nsfw FROM channels");
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
|
||||||
|
|
||||||
while (reader.Read()) {
|
|
||||||
yield return new Channel {
|
|
||||||
Id = reader.GetUint64(0),
|
|
||||||
Server = reader.GetUint64(1),
|
|
||||||
Name = reader.GetString(2),
|
|
||||||
ParentId = reader.IsDBNull(3) ? null : reader.GetUint64(3),
|
|
||||||
Position = reader.IsDBNull(4) ? null : reader.GetInt32(4),
|
|
||||||
Topic = reader.IsDBNull(5) ? null : reader.GetString(5),
|
|
||||||
Nsfw = reader.IsDBNull(6) ? null : reader.GetBoolean(6),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,233 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Data;
|
|
||||||
using DHT.Server.Data.Aggregations;
|
|
||||||
using DHT.Server.Data.Filters;
|
|
||||||
using DHT.Server.Database.Repositories;
|
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
|
||||||
using DHT.Server.Download;
|
|
||||||
using DHT.Utils.Tasks;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite.Repositories;
|
|
||||||
|
|
||||||
sealed class SqliteDownloadRepository : IDownloadRepository {
|
|
||||||
private readonly SqliteConnectionPool pool;
|
|
||||||
private readonly AsyncValueComputer<long>.Single totalDownloadsComputer;
|
|
||||||
|
|
||||||
public SqliteDownloadRepository(SqliteConnectionPool pool, AsyncValueComputer<long>.Single totalDownloadsComputer) {
|
|
||||||
this.pool = pool;
|
|
||||||
this.totalDownloadsComputer = totalDownloadsComputer;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<long> CountAttachments(AttachmentFilter? filter, CancellationToken cancellationToken) {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
return await conn.ExecuteReaderAsync("SELECT COUNT(DISTINCT normalized_url) FROM attachments a" + filter.GenerateWhereClause("a"), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task AddDownload(Data.Download download) {
|
|
||||||
using (var conn = pool.Take()) {
|
|
||||||
await using var cmd = conn.Upsert("downloads", [
|
|
||||||
("normalized_url", SqliteType.Text),
|
|
||||||
("download_url", SqliteType.Text),
|
|
||||||
("status", SqliteType.Integer),
|
|
||||||
("size", SqliteType.Integer),
|
|
||||||
("blob", SqliteType.Blob)
|
|
||||||
]);
|
|
||||||
|
|
||||||
cmd.Set(":normalized_url", download.NormalizedUrl);
|
|
||||||
cmd.Set(":download_url", download.DownloadUrl);
|
|
||||||
cmd.Set(":status", (int) download.Status);
|
|
||||||
cmd.Set(":size", download.Size);
|
|
||||||
cmd.Set(":blob", download.Data);
|
|
||||||
await cmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
totalDownloadsComputer.Recompute();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<DownloadStatusStatistics> GetStatistics(CancellationToken cancellationToken) {
|
|
||||||
static async Task LoadUndownloadedStatistics(ISqliteConnection conn, DownloadStatusStatistics result, CancellationToken cancellationToken) {
|
|
||||||
await using var cmd = conn.Command(
|
|
||||||
"""
|
|
||||||
SELECT IFNULL(COUNT(size), 0), IFNULL(SUM(size), 0)
|
|
||||||
FROM (SELECT MAX(a.size) size
|
|
||||||
FROM attachments a
|
|
||||||
WHERE a.normalized_url NOT IN (SELECT d.normalized_url FROM downloads d)
|
|
||||||
GROUP BY a.normalized_url)
|
|
||||||
""");
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (reader.Read()) {
|
|
||||||
result.SkippedCount = reader.GetInt32(0);
|
|
||||||
result.SkippedSize = reader.GetUint64(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task LoadSuccessStatistics(ISqliteConnection conn, DownloadStatusStatistics result, CancellationToken cancellationToken) {
|
|
||||||
await using var cmd = conn.Command(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
IFNULL(SUM(CASE WHEN status IN (:enqueued, :downloading) THEN 1 ELSE 0 END), 0),
|
|
||||||
IFNULL(SUM(CASE WHEN status IN (:enqueued, :downloading) THEN size ELSE 0 END), 0),
|
|
||||||
IFNULL(SUM(CASE WHEN status = :success THEN 1 ELSE 0 END), 0),
|
|
||||||
IFNULL(SUM(CASE WHEN status = :success THEN size ELSE 0 END), 0),
|
|
||||||
IFNULL(SUM(CASE WHEN status NOT IN (:enqueued, :downloading) AND status != :success THEN 1 ELSE 0 END), 0),
|
|
||||||
IFNULL(SUM(CASE WHEN status NOT IN (:enqueued, :downloading) AND status != :success THEN size ELSE 0 END), 0)
|
|
||||||
FROM downloads
|
|
||||||
"""
|
|
||||||
);
|
|
||||||
|
|
||||||
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
|
|
||||||
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
|
|
||||||
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (reader.Read()) {
|
|
||||||
result.EnqueuedCount = reader.GetInt32(0);
|
|
||||||
result.EnqueuedSize = reader.GetUint64(1);
|
|
||||||
result.SuccessfulCount = reader.GetInt32(2);
|
|
||||||
result.SuccessfulSize = reader.GetUint64(3);
|
|
||||||
result.FailedCount = reader.GetInt32(4);
|
|
||||||
result.FailedSize = reader.GetUint64(5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = new DownloadStatusStatistics();
|
|
||||||
|
|
||||||
using var conn = pool.Take();
|
|
||||||
await LoadUndownloadedStatistics(conn, result, cancellationToken);
|
|
||||||
await LoadSuccessStatistics(conn, result, cancellationToken);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<Data.Download> GetWithoutData() {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT normalized_url, download_url, status, size FROM downloads");
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
|
||||||
|
|
||||||
while (reader.Read()) {
|
|
||||||
string normalizedUrl = reader.GetString(0);
|
|
||||||
string downloadUrl = reader.GetString(1);
|
|
||||||
var status = (DownloadStatus) reader.GetInt32(2);
|
|
||||||
ulong size = reader.GetUint64(3);
|
|
||||||
|
|
||||||
yield return new Data.Download(normalizedUrl, downloadUrl, status, size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Data.Download> HydrateWithData(Data.Download download) {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT blob FROM downloads WHERE normalized_url = :url");
|
|
||||||
cmd.AddAndSet(":url", SqliteType.Text, download.NormalizedUrl);
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
|
||||||
|
|
||||||
if (reader.Read() && !reader.IsDBNull(0)) {
|
|
||||||
return download.WithData((byte[]) reader["blob"]);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return download;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<DownloadedAttachment?> GetDownloadedAttachment(string normalizedUrl) {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
|
|
||||||
await using var cmd = conn.Command(
|
|
||||||
"""
|
|
||||||
SELECT a.type, d.blob FROM downloads d
|
|
||||||
LEFT JOIN attachments a ON d.normalized_url = a.normalized_url
|
|
||||||
WHERE d.normalized_url = :normalized_url AND d.status = :success AND d.blob IS NOT NULL
|
|
||||||
"""
|
|
||||||
);
|
|
||||||
|
|
||||||
cmd.AddAndSet(":normalized_url", SqliteType.Text, normalizedUrl);
|
|
||||||
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
|
||||||
|
|
||||||
if (!reader.Read()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new DownloadedAttachment {
|
|
||||||
Type = reader.IsDBNull(0) ? null : reader.GetString(0),
|
|
||||||
Data = (byte[]) reader["blob"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> EnqueueDownloadItems(AttachmentFilter? filter, CancellationToken cancellationToken) {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
|
|
||||||
await using var cmd = conn.Command(
|
|
||||||
$"""
|
|
||||||
INSERT INTO downloads (normalized_url, download_url, status, size)
|
|
||||||
SELECT a.normalized_url, a.download_url, :enqueued, MAX(a.size)
|
|
||||||
FROM attachments a
|
|
||||||
{filter.GenerateWhereClause("a")}
|
|
||||||
GROUP BY a.normalized_url
|
|
||||||
"""
|
|
||||||
);
|
|
||||||
|
|
||||||
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
|
|
||||||
return await cmd.ExecuteNonQueryAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<DownloadItem> PullEnqueuedDownloadItems(int count, [EnumeratorCancellation] CancellationToken cancellationToken) {
|
|
||||||
var found = new List<DownloadItem>();
|
|
||||||
|
|
||||||
using var conn = pool.Take();
|
|
||||||
|
|
||||||
await using (var cmd = conn.Command("SELECT normalized_url, download_url, size FROM downloads WHERE status = :enqueued LIMIT :limit")) {
|
|
||||||
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
|
|
||||||
cmd.AddAndSet(":limit", SqliteType.Integer, Math.Max(0, count));
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
|
||||||
|
|
||||||
while (reader.Read()) {
|
|
||||||
found.Add(new DownloadItem {
|
|
||||||
NormalizedUrl = reader.GetString(0),
|
|
||||||
DownloadUrl = reader.GetString(1),
|
|
||||||
Size = reader.GetUint64(2),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (found.Count != 0) {
|
|
||||||
await using var cmd = conn.Command("UPDATE downloads SET status = :downloading WHERE normalized_url = :normalized_url AND status = :enqueued");
|
|
||||||
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
|
|
||||||
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
|
|
||||||
cmd.Add(":normalized_url", SqliteType.Text);
|
|
||||||
|
|
||||||
foreach (var item in found) {
|
|
||||||
cmd.Set(":normalized_url", item.NormalizedUrl);
|
|
||||||
|
|
||||||
if (await cmd.ExecuteNonQueryAsync(cancellationToken) == 1) {
|
|
||||||
yield return item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {
|
|
||||||
using (var conn = pool.Take()) {
|
|
||||||
await conn.ExecuteAsync(
|
|
||||||
$"""
|
|
||||||
-- noinspection SqlWithoutWhere
|
|
||||||
DELETE FROM downloads
|
|
||||||
{filter.GenerateWhereClause(invert: mode == FilterRemovalMode.KeepMatching)}
|
|
||||||
"""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
totalDownloadsComputer.Recompute();
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,306 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Collections.Immutable;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Data;
|
|
||||||
using DHT.Server.Data.Filters;
|
|
||||||
using DHT.Server.Database.Repositories;
|
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
|
||||||
using DHT.Utils.Tasks;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite.Repositories;
|
|
||||||
|
|
||||||
sealed class SqliteMessageRepository : IMessageRepository {
|
|
||||||
private readonly SqliteConnectionPool pool;
|
|
||||||
private readonly AsyncValueComputer<long>.Single totalMessagesComputer;
|
|
||||||
private readonly AsyncValueComputer<long>.Single totalAttachmentsComputer;
|
|
||||||
|
|
||||||
public SqliteMessageRepository(SqliteConnectionPool pool, AsyncValueComputer<long>.Single totalMessagesComputer, AsyncValueComputer<long>.Single totalAttachmentsComputer) {
|
|
||||||
this.pool = pool;
|
|
||||||
this.totalMessagesComputer = totalMessagesComputer;
|
|
||||||
this.totalAttachmentsComputer = totalAttachmentsComputer;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Add(IReadOnlyList<Message> messages) {
|
|
||||||
if (messages.Count == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
static SqliteCommand DeleteByMessageId(ISqliteConnection conn, string tableName) {
|
|
||||||
return conn.Delete(tableName, ("message_id", SqliteType.Integer));
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task ExecuteDeleteByMessageId(SqliteCommand cmd, object id) {
|
|
||||||
cmd.Set(":message_id", id);
|
|
||||||
await cmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool addedAttachments = false;
|
|
||||||
|
|
||||||
using (var conn = pool.Take()) {
|
|
||||||
await using var tx = await conn.BeginTransactionAsync();
|
|
||||||
|
|
||||||
await using var messageCmd = conn.Upsert("messages", [
|
|
||||||
("message_id", SqliteType.Integer),
|
|
||||||
("sender_id", SqliteType.Integer),
|
|
||||||
("channel_id", SqliteType.Integer),
|
|
||||||
("text", SqliteType.Text),
|
|
||||||
("timestamp", SqliteType.Integer)
|
|
||||||
]);
|
|
||||||
|
|
||||||
await using var deleteEditTimestampCmd = DeleteByMessageId(conn, "edit_timestamps");
|
|
||||||
await using var deleteRepliedToCmd = DeleteByMessageId(conn, "replied_to");
|
|
||||||
|
|
||||||
await using var deleteAttachmentsCmd = DeleteByMessageId(conn, "attachments");
|
|
||||||
await using var deleteEmbedsCmd = DeleteByMessageId(conn, "embeds");
|
|
||||||
await using var deleteReactionsCmd = DeleteByMessageId(conn, "reactions");
|
|
||||||
|
|
||||||
await using var editTimestampCmd = conn.Insert("edit_timestamps", [
|
|
||||||
("message_id", SqliteType.Integer),
|
|
||||||
("edit_timestamp", SqliteType.Integer)
|
|
||||||
]);
|
|
||||||
|
|
||||||
await using var repliedToCmd = conn.Insert("replied_to", [
|
|
||||||
("message_id", SqliteType.Integer),
|
|
||||||
("replied_to_id", SqliteType.Integer)
|
|
||||||
]);
|
|
||||||
|
|
||||||
await using var attachmentCmd = conn.Insert("attachments", [
|
|
||||||
("message_id", SqliteType.Integer),
|
|
||||||
("attachment_id", SqliteType.Integer),
|
|
||||||
("name", SqliteType.Text),
|
|
||||||
("type", SqliteType.Text),
|
|
||||||
("normalized_url", SqliteType.Text),
|
|
||||||
("download_url", SqliteType.Text),
|
|
||||||
("size", SqliteType.Integer),
|
|
||||||
("width", SqliteType.Integer),
|
|
||||||
("height", SqliteType.Integer)
|
|
||||||
]);
|
|
||||||
|
|
||||||
await using var embedCmd = conn.Insert("embeds", [
|
|
||||||
("message_id", SqliteType.Integer),
|
|
||||||
("json", SqliteType.Text)
|
|
||||||
]);
|
|
||||||
|
|
||||||
await using var reactionCmd = conn.Insert("reactions", [
|
|
||||||
("message_id", SqliteType.Integer),
|
|
||||||
("emoji_id", SqliteType.Integer),
|
|
||||||
("emoji_name", SqliteType.Text),
|
|
||||||
("emoji_flags", SqliteType.Integer),
|
|
||||||
("count", SqliteType.Integer)
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach (var message in messages) {
|
|
||||||
object messageId = message.Id;
|
|
||||||
|
|
||||||
messageCmd.Set(":message_id", messageId);
|
|
||||||
messageCmd.Set(":sender_id", message.Sender);
|
|
||||||
messageCmd.Set(":channel_id", message.Channel);
|
|
||||||
messageCmd.Set(":text", message.Text);
|
|
||||||
messageCmd.Set(":timestamp", message.Timestamp);
|
|
||||||
await messageCmd.ExecuteNonQueryAsync();
|
|
||||||
|
|
||||||
await ExecuteDeleteByMessageId(deleteEditTimestampCmd, messageId);
|
|
||||||
await ExecuteDeleteByMessageId(deleteRepliedToCmd, messageId);
|
|
||||||
|
|
||||||
await ExecuteDeleteByMessageId(deleteAttachmentsCmd, messageId);
|
|
||||||
await ExecuteDeleteByMessageId(deleteEmbedsCmd, messageId);
|
|
||||||
await ExecuteDeleteByMessageId(deleteReactionsCmd, messageId);
|
|
||||||
|
|
||||||
if (message.EditTimestamp is {} timestamp) {
|
|
||||||
editTimestampCmd.Set(":message_id", messageId);
|
|
||||||
editTimestampCmd.Set(":edit_timestamp", timestamp);
|
|
||||||
await editTimestampCmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.RepliedToId is {} repliedToId) {
|
|
||||||
repliedToCmd.Set(":message_id", messageId);
|
|
||||||
repliedToCmd.Set(":replied_to_id", repliedToId);
|
|
||||||
await repliedToCmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!message.Attachments.IsEmpty) {
|
|
||||||
addedAttachments = true;
|
|
||||||
|
|
||||||
foreach (var attachment in message.Attachments) {
|
|
||||||
attachmentCmd.Set(":message_id", messageId);
|
|
||||||
attachmentCmd.Set(":attachment_id", attachment.Id);
|
|
||||||
attachmentCmd.Set(":name", attachment.Name);
|
|
||||||
attachmentCmd.Set(":type", attachment.Type);
|
|
||||||
attachmentCmd.Set(":normalized_url", attachment.NormalizedUrl);
|
|
||||||
attachmentCmd.Set(":download_url", attachment.DownloadUrl);
|
|
||||||
attachmentCmd.Set(":size", attachment.Size);
|
|
||||||
attachmentCmd.Set(":width", attachment.Width);
|
|
||||||
attachmentCmd.Set(":height", attachment.Height);
|
|
||||||
await attachmentCmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!message.Embeds.IsEmpty) {
|
|
||||||
foreach (var embed in message.Embeds) {
|
|
||||||
embedCmd.Set(":message_id", messageId);
|
|
||||||
embedCmd.Set(":json", embed.Json);
|
|
||||||
await embedCmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!message.Reactions.IsEmpty) {
|
|
||||||
foreach (var reaction in message.Reactions) {
|
|
||||||
reactionCmd.Set(":message_id", messageId);
|
|
||||||
reactionCmd.Set(":emoji_id", reaction.EmojiId);
|
|
||||||
reactionCmd.Set(":emoji_name", reaction.EmojiName);
|
|
||||||
reactionCmd.Set(":emoji_flags", (int) reaction.EmojiFlags);
|
|
||||||
reactionCmd.Set(":count", reaction.Count);
|
|
||||||
await reactionCmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.CommitAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
totalMessagesComputer.Recompute();
|
|
||||||
|
|
||||||
if (addedAttachments) {
|
|
||||||
totalAttachmentsComputer.Recompute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM messages" + filter.GenerateWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class MesageToManyCommand<T> : IAsyncDisposable {
|
|
||||||
private readonly SqliteCommand cmd;
|
|
||||||
private readonly Func<SqliteDataReader, T> readItem;
|
|
||||||
|
|
||||||
public MesageToManyCommand(ISqliteConnection conn, string sql, Func<SqliteDataReader, T> readItem) {
|
|
||||||
this.cmd = conn.Command(sql);
|
|
||||||
this.cmd.Add(":message_id", SqliteType.Integer);
|
|
||||||
|
|
||||||
this.readItem = readItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ImmutableList<T>> GetItems(ulong messageId) {
|
|
||||||
cmd.Set(":message_id", messageId);
|
|
||||||
|
|
||||||
var items = ImmutableList<T>.Empty;
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
|
||||||
|
|
||||||
while (await reader.ReadAsync()) {
|
|
||||||
items = items.Add(readItem(reader));
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() {
|
|
||||||
await cmd.DisposeAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<Message> Get(MessageFilter? filter) {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
|
|
||||||
const string AttachmentSql =
|
|
||||||
"""
|
|
||||||
SELECT attachment_id, name, type, normalized_url, download_url, size, width, height
|
|
||||||
FROM attachments
|
|
||||||
WHERE message_id = :message_id
|
|
||||||
""";
|
|
||||||
|
|
||||||
await using var attachmentCmd = new MesageToManyCommand<Attachment>(conn, AttachmentSql, static reader => new Attachment {
|
|
||||||
Id = reader.GetUint64(0),
|
|
||||||
Name = reader.GetString(1),
|
|
||||||
Type = reader.IsDBNull(2) ? null : reader.GetString(2),
|
|
||||||
NormalizedUrl = reader.GetString(3),
|
|
||||||
DownloadUrl = reader.GetString(4),
|
|
||||||
Size = reader.GetUint64(5),
|
|
||||||
Width = reader.IsDBNull(6) ? null : reader.GetInt32(6),
|
|
||||||
Height = reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
|
||||||
});
|
|
||||||
|
|
||||||
const string EmbedSql =
|
|
||||||
"""
|
|
||||||
SELECT json
|
|
||||||
FROM embeds
|
|
||||||
WHERE message_id = :message_id
|
|
||||||
""";
|
|
||||||
|
|
||||||
await using var embedCmd = new MesageToManyCommand<Embed>(conn, EmbedSql, static reader => new Embed {
|
|
||||||
Json = reader.GetString(0)
|
|
||||||
});
|
|
||||||
|
|
||||||
const string ReactionSql =
|
|
||||||
"""
|
|
||||||
SELECT emoji_id, emoji_name, emoji_flags, count
|
|
||||||
FROM reactions
|
|
||||||
WHERE message_id = :message_id
|
|
||||||
""";
|
|
||||||
|
|
||||||
await using var reactionsCmd = new MesageToManyCommand<Reaction>(conn, ReactionSql, static reader => new Reaction {
|
|
||||||
EmojiId = reader.IsDBNull(0) ? null : reader.GetUint64(0),
|
|
||||||
EmojiName = reader.IsDBNull(1) ? null : reader.GetString(1),
|
|
||||||
EmojiFlags = (EmojiFlags) reader.GetInt16(2),
|
|
||||||
Count = reader.GetInt32(3),
|
|
||||||
});
|
|
||||||
|
|
||||||
await using var messageCmd = conn.Command(
|
|
||||||
$"""
|
|
||||||
SELECT m.message_id, m.sender_id, m.channel_id, m.text, m.timestamp, et.edit_timestamp, rt.replied_to_id
|
|
||||||
FROM messages m
|
|
||||||
LEFT JOIN edit_timestamps et ON m.message_id = et.message_id
|
|
||||||
LEFT JOIN replied_to rt ON m.message_id = rt.message_id
|
|
||||||
{filter.GenerateWhereClause("m")}
|
|
||||||
"""
|
|
||||||
);
|
|
||||||
|
|
||||||
await using var reader = await messageCmd.ExecuteReaderAsync();
|
|
||||||
|
|
||||||
while (await reader.ReadAsync()) {
|
|
||||||
ulong messageId = reader.GetUint64(0);
|
|
||||||
|
|
||||||
yield return new Message {
|
|
||||||
Id = messageId,
|
|
||||||
Sender = reader.GetUint64(1),
|
|
||||||
Channel = reader.GetUint64(2),
|
|
||||||
Text = reader.GetString(3),
|
|
||||||
Timestamp = reader.GetInt64(4),
|
|
||||||
EditTimestamp = reader.IsDBNull(5) ? null : reader.GetInt64(5),
|
|
||||||
RepliedToId = reader.IsDBNull(6) ? null : reader.GetUint64(6),
|
|
||||||
Attachments = await attachmentCmd.GetItems(messageId),
|
|
||||||
Embeds = await embedCmd.GetItems(messageId),
|
|
||||||
Reactions = await reactionsCmd.GetItems(messageId)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<ulong> GetIds(MessageFilter? filter) {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT message_id FROM messages" + filter.GenerateWhereClause());
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
|
||||||
|
|
||||||
while (await reader.ReadAsync()) {
|
|
||||||
yield return reader.GetUint64(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Remove(MessageFilter filter, FilterRemovalMode mode) {
|
|
||||||
using (var conn = pool.Take()) {
|
|
||||||
await conn.ExecuteAsync(
|
|
||||||
$"""
|
|
||||||
-- noinspection SqlWithoutWhere
|
|
||||||
DELETE FROM messages
|
|
||||||
{filter.GenerateWhereClause(invert: mode == FilterRemovalMode.KeepMatching)}
|
|
||||||
"""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
totalMessagesComputer.Recompute();
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,65 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Data;
|
|
||||||
using DHT.Server.Database.Repositories;
|
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite.Repositories;
|
|
||||||
|
|
||||||
sealed class SqliteServerRepository : IServerRepository {
|
|
||||||
private readonly SqliteConnectionPool pool;
|
|
||||||
private readonly DatabaseStatistics statistics;
|
|
||||||
|
|
||||||
public SqliteServerRepository(SqliteConnectionPool pool, DatabaseStatistics statistics) {
|
|
||||||
this.pool = pool;
|
|
||||||
this.statistics = statistics;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task Initialize() {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
await UpdateServerStatistics(conn);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpdateServerStatistics(ISqliteConnection conn) {
|
|
||||||
statistics.TotalServers = await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM servers", static reader => reader?.GetInt64(0) ?? 0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Add(IReadOnlyList<Data.Server> servers) {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
|
|
||||||
await using (var tx = await conn.BeginTransactionAsync()) {
|
|
||||||
await using var cmd = conn.Upsert("servers", [
|
|
||||||
("id", SqliteType.Integer),
|
|
||||||
("name", SqliteType.Text),
|
|
||||||
("type", SqliteType.Text)
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach (var server in servers) {
|
|
||||||
cmd.Set(":id", server.Id);
|
|
||||||
cmd.Set(":name", server.Name);
|
|
||||||
cmd.Set(":type", ServerTypes.ToString(server.Type));
|
|
||||||
await cmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.CommitAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
await UpdateServerStatistics(conn);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<Data.Server> Get() {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT id, name, type FROM servers");
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
|
||||||
|
|
||||||
while (reader.Read()) {
|
|
||||||
yield return new Data.Server {
|
|
||||||
Id = reader.GetUint64(0),
|
|
||||||
Name = reader.GetString(1),
|
|
||||||
Type = ServerTypes.FromString(reader.GetString(2)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,68 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Data;
|
|
||||||
using DHT.Server.Database.Repositories;
|
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite.Repositories;
|
|
||||||
|
|
||||||
sealed class SqliteUserRepository : IUserRepository {
|
|
||||||
private readonly SqliteConnectionPool pool;
|
|
||||||
private readonly DatabaseStatistics statistics;
|
|
||||||
|
|
||||||
public SqliteUserRepository(SqliteConnectionPool pool, DatabaseStatistics statistics) {
|
|
||||||
this.pool = pool;
|
|
||||||
this.statistics = statistics;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task Initialize() {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
await UpdateUserStatistics(conn);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpdateUserStatistics(ISqliteConnection conn) {
|
|
||||||
statistics.TotalUsers = await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM users", static reader => reader?.GetInt64(0) ?? 0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Add(IReadOnlyList<User> users) {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
|
|
||||||
await using (var tx = await conn.BeginTransactionAsync()) {
|
|
||||||
await using var cmd = conn.Upsert("users", [
|
|
||||||
("id", SqliteType.Integer),
|
|
||||||
("name", SqliteType.Text),
|
|
||||||
("avatar_url", SqliteType.Text),
|
|
||||||
("discriminator", SqliteType.Text)
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach (var user in users) {
|
|
||||||
cmd.Set(":id", user.Id);
|
|
||||||
cmd.Set(":name", user.Name);
|
|
||||||
cmd.Set(":avatar_url", user.AvatarUrl);
|
|
||||||
cmd.Set(":discriminator", user.Discriminator);
|
|
||||||
await cmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.CommitAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
await UpdateUserStatistics(conn);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<User> Get() {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT id, name, avatar_url, discriminator FROM users");
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
|
||||||
|
|
||||||
while (reader.Read()) {
|
|
||||||
yield return new User {
|
|
||||||
Id = reader.GetUint64(0),
|
|
||||||
Name = reader.GetString(1),
|
|
||||||
AvatarUrl = reader.IsDBNull(2) ? null : reader.GetString(2),
|
|
||||||
Discriminator = reader.IsDBNull(3) ? null : reader.GetString(3),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,5 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Data.Common;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Database.Exceptions;
|
using DHT.Server.Database.Exceptions;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
@@ -21,14 +20,14 @@ sealed class Schema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> Setup(ISchemaUpgradeCallbacks callbacks) {
|
public async Task<bool> Setup(ISchemaUpgradeCallbacks callbacks) {
|
||||||
await conn.ExecuteAsync("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)");
|
conn.Execute(@"CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)");
|
||||||
|
|
||||||
var dbVersionStr = await conn.ExecuteReaderAsync("SELECT value FROM metadata WHERE key = 'version'", static reader => reader?.GetString(0));
|
var dbVersionStr = conn.SelectScalar("SELECT value FROM metadata WHERE key = 'version'");
|
||||||
if (dbVersionStr == null) {
|
if (dbVersionStr == null) {
|
||||||
await InitializeSchemas();
|
InitializeSchemas();
|
||||||
}
|
}
|
||||||
else if (!int.TryParse(dbVersionStr, out int dbVersion) || dbVersion < 1) {
|
else if (!int.TryParse(dbVersionStr.ToString(), out int dbVersion) || dbVersion < 1) {
|
||||||
throw new InvalidDatabaseVersionException(dbVersionStr);
|
throw new InvalidDatabaseVersionException(dbVersionStr.ToString() ?? "<null>");
|
||||||
}
|
}
|
||||||
else if (dbVersion > Version) {
|
else if (dbVersion > Version) {
|
||||||
throw new DatabaseTooNewException(dbVersion);
|
throw new DatabaseTooNewException(dbVersion);
|
||||||
@@ -45,123 +44,123 @@ sealed class Schema {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task InitializeSchemas() {
|
private void InitializeSchemas() {
|
||||||
await conn.ExecuteAsync("""
|
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
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
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
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
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
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
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
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
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
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
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
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
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
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
|
conn.Execute("""
|
||||||
|
CREATE TABLE reactions (
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
emoji_id INTEGER,
|
||||||
|
emoji_name TEXT,
|
||||||
|
emoji_flags INTEGER NOT NULL,
|
||||||
|
count INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
CreateMessageEditTimestampTable();
|
||||||
CREATE TABLE reactions (
|
CreateMessageRepliedToTable();
|
||||||
message_id INTEGER NOT NULL,
|
|
||||||
emoji_id INTEGER,
|
|
||||||
emoji_name TEXT,
|
|
||||||
emoji_flags INTEGER NOT NULL,
|
|
||||||
count INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
|
|
||||||
await CreateMessageEditTimestampTable();
|
conn.Execute("CREATE INDEX attachments_message_ix ON attachments(message_id)");
|
||||||
await CreateMessageRepliedToTable();
|
conn.Execute("CREATE INDEX embeds_message_ix ON embeds(message_id)");
|
||||||
|
conn.Execute("CREATE INDEX reactions_message_ix ON reactions(message_id)");
|
||||||
|
|
||||||
await conn.ExecuteAsync("CREATE INDEX attachments_message_ix ON attachments(message_id)");
|
conn.Execute("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")");
|
||||||
await conn.ExecuteAsync("CREATE INDEX embeds_message_ix ON embeds(message_id)");
|
|
||||||
await conn.ExecuteAsync("CREATE INDEX reactions_message_ix ON reactions(message_id)");
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CreateMessageEditTimestampTable() {
|
private void CreateMessageEditTimestampTable() {
|
||||||
await conn.ExecuteAsync("""
|
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 async Task CreateMessageRepliedToTable() {
|
private void CreateMessageRepliedToTable() {
|
||||||
await conn.ExecuteAsync("""
|
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 async Task NormalizeAttachmentUrls(ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
private async Task NormalizeAttachmentUrls(ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
||||||
await reporter.SubWork("Preparing attachments...", 0, 0);
|
await reporter.SubWork("Preparing attachments...", 0, 0);
|
||||||
|
|
||||||
var normalizedUrls = new Dictionary<long, string>();
|
var normalizedUrls = new Dictionary<long, string>();
|
||||||
|
|
||||||
await using (var selectCmd = conn.Command("SELECT attachment_id, url FROM attachments")) {
|
await using (var selectCmd = conn.Command("SELECT attachment_id, url FROM attachments")) {
|
||||||
await using var reader = await selectCmd.ExecuteReaderAsync();
|
await using var reader = await selectCmd.ExecuteReaderAsync();
|
||||||
|
|
||||||
while (reader.Read()) {
|
while (reader.Read()) {
|
||||||
var attachmentId = reader.GetInt64(0);
|
var attachmentId = reader.GetInt64(0);
|
||||||
var originalUrl = reader.GetString(1);
|
var originalUrl = reader.GetString(1);
|
||||||
@@ -169,15 +168,15 @@ sealed class Schema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var tx = await conn.BeginTransactionAsync();
|
await using var tx = conn.BeginTransaction();
|
||||||
|
|
||||||
int totalUrls = normalizedUrls.Count;
|
int totalUrls = normalizedUrls.Count;
|
||||||
int processedUrls = -1;
|
int processedUrls = -1;
|
||||||
|
|
||||||
await using (var updateCmd = conn.Command("UPDATE attachments SET download_url = url, url = :normalized_url WHERE attachment_id = :attachment_id")) {
|
await using (var updateCmd = conn.Command("UPDATE attachments SET download_url = url, url = :normalized_url WHERE attachment_id = :attachment_id")) {
|
||||||
updateCmd.Add(":attachment_id", SqliteType.Integer);
|
updateCmd.Parameters.Add(":attachment_id", SqliteType.Integer);
|
||||||
updateCmd.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) {
|
if (++processedUrls % 1000 == 0) {
|
||||||
await reporter.SubWork("Updating URLs...", processedUrls, totalUrls);
|
await reporter.SubWork("Updating URLs...", processedUrls, totalUrls);
|
||||||
@@ -188,15 +187,15 @@ sealed class Schema {
|
|||||||
updateCmd.ExecuteNonQuery();
|
updateCmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await reporter.SubWork("Updating URLs...", totalUrls, totalUrls);
|
await reporter.SubWork("Updating URLs...", totalUrls, totalUrls);
|
||||||
|
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task NormalizeDownloadUrls(ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
private async Task NormalizeDownloadUrls(ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
||||||
await reporter.SubWork("Preparing downloads...", 0, 0);
|
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>();
|
||||||
|
|
||||||
@@ -213,11 +212,11 @@ sealed class Schema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await conn.ExecuteAsync("PRAGMA cache_size = -20000");
|
conn.Execute("PRAGMA cache_size = -20000");
|
||||||
|
|
||||||
DbTransaction tx;
|
SqliteTransaction tx;
|
||||||
|
|
||||||
await using (tx = await conn.BeginTransactionAsync()) {
|
await using (tx = conn.BeginTransaction()) {
|
||||||
await reporter.SubWork("Deleting duplicates...", 0, 0);
|
await reporter.SubWork("Deleting duplicates...", 0, 0);
|
||||||
|
|
||||||
await using (var deleteCmd = conn.Delete("downloads", ("url", SqliteType.Text))) {
|
await using (var deleteCmd = conn.Delete("downloads", ("url", SqliteType.Text))) {
|
||||||
@@ -226,30 +225,30 @@ sealed class Schema {
|
|||||||
deleteCmd.ExecuteNonQuery();
|
deleteCmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
int totalUrls = normalizedUrlsToOriginalUrls.Count;
|
int totalUrls = normalizedUrlsToOriginalUrls.Count;
|
||||||
int processedUrls = -1;
|
int processedUrls = -1;
|
||||||
|
|
||||||
tx = await conn.BeginTransactionAsync();
|
tx = conn.BeginTransaction();
|
||||||
|
|
||||||
await using (var updateCmd = conn.Command("UPDATE downloads SET download_url = :download_url, url = :normalized_url WHERE url = :download_url")) {
|
await using (var updateCmd = conn.Command("UPDATE downloads SET download_url = :download_url, url = :normalized_url WHERE url = :download_url")) {
|
||||||
updateCmd.Add(":normalized_url", SqliteType.Text);
|
updateCmd.Parameters.Add(":normalized_url", SqliteType.Text);
|
||||||
updateCmd.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) {
|
if (++processedUrls % 100 == 0) {
|
||||||
await reporter.SubWork("Updating URLs...", processedUrls, totalUrls);
|
await reporter.SubWork("Updating URLs...", processedUrls, totalUrls);
|
||||||
|
|
||||||
// Not proper way of dealing with transactions, but it avoids a long commit at the end.
|
// 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.
|
// Schema upgrades are already non-atomic anyways, so this doesn't make it worse.
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
await tx.DisposeAsync();
|
await tx.DisposeAsync();
|
||||||
|
|
||||||
tx = await conn.BeginTransactionAsync();
|
tx = conn.BeginTransaction();
|
||||||
updateCmd.Transaction = (SqliteTransaction) tx;
|
updateCmd.Transaction = tx;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCmd.Set(":normalized_url", normalizedUrl);
|
updateCmd.Set(":normalized_url", normalizedUrl);
|
||||||
@@ -257,98 +256,98 @@ sealed class Schema {
|
|||||||
updateCmd.ExecuteNonQuery();
|
updateCmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await reporter.SubWork("Updating URLs...", totalUrls, totalUrls);
|
await reporter.SubWork("Updating URLs...", totalUrls, totalUrls);
|
||||||
|
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
await tx.DisposeAsync();
|
await tx.DisposeAsync();
|
||||||
|
|
||||||
await conn.ExecuteAsync("PRAGMA cache_size = -2000");
|
conn.Execute("PRAGMA cache_size = -2000");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task UpgradeSchemas(int dbVersion, ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
private async Task UpgradeSchemas(int dbVersion, ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
||||||
var perf = Log.Start("from version " + dbVersion);
|
var perf = Log.Start("from version " + dbVersion);
|
||||||
|
|
||||||
await conn.ExecuteAsync("UPDATE metadata SET value = " + Version + " WHERE key = 'version'");
|
conn.Execute("UPDATE metadata SET value = " + Version + " WHERE key = 'version'");
|
||||||
|
|
||||||
if (dbVersion <= 1) {
|
if (dbVersion <= 1) {
|
||||||
await reporter.MainWork("Applying schema changes...", 0, 1);
|
await reporter.MainWork("Applying schema changes...", 0, 1);
|
||||||
await conn.ExecuteAsync("ALTER TABLE channels ADD parent_id INTEGER");
|
conn.Execute("ALTER TABLE channels ADD parent_id INTEGER");
|
||||||
|
|
||||||
perf.Step("Upgrade to version 2");
|
perf.Step("Upgrade to version 2");
|
||||||
await reporter.NextVersion();
|
await reporter.NextVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dbVersion <= 2) {
|
if (dbVersion <= 2) {
|
||||||
await reporter.MainWork("Applying schema changes...", 0, 1);
|
await reporter.MainWork("Applying schema changes...", 0, 1);
|
||||||
|
|
||||||
|
CreateMessageEditTimestampTable();
|
||||||
|
CreateMessageRepliedToTable();
|
||||||
|
|
||||||
await CreateMessageEditTimestampTable();
|
conn.Execute("""
|
||||||
await CreateMessageRepliedToTable();
|
INSERT INTO edit_timestamps (message_id, edit_timestamp)
|
||||||
|
SELECT message_id, edit_timestamp
|
||||||
|
FROM messages
|
||||||
|
WHERE edit_timestamp IS NOT NULL
|
||||||
|
""");
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
conn.Execute("""
|
||||||
INSERT INTO edit_timestamps (message_id, edit_timestamp)
|
INSERT INTO replied_to (message_id, replied_to_id)
|
||||||
SELECT message_id, edit_timestamp
|
SELECT message_id, replied_to_id
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE edit_timestamp IS NOT NULL
|
WHERE replied_to_id IS NOT NULL
|
||||||
""");
|
""");
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
conn.Execute("ALTER TABLE messages DROP COLUMN replied_to_id");
|
||||||
INSERT INTO replied_to (message_id, replied_to_id)
|
conn.Execute("ALTER TABLE messages DROP COLUMN edit_timestamp");
|
||||||
SELECT message_id, replied_to_id
|
|
||||||
FROM messages
|
|
||||||
WHERE replied_to_id IS NOT NULL
|
|
||||||
""");
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("ALTER TABLE messages DROP COLUMN replied_to_id");
|
|
||||||
await conn.ExecuteAsync("ALTER TABLE messages DROP COLUMN edit_timestamp");
|
|
||||||
|
|
||||||
perf.Step("Upgrade to version 3");
|
perf.Step("Upgrade to version 3");
|
||||||
|
|
||||||
await reporter.MainWork("Vacuuming the database...", 1, 1);
|
await reporter.MainWork("Vacuuming the database...", 1, 1);
|
||||||
await conn.ExecuteAsync("VACUUM");
|
conn.Execute("VACUUM");
|
||||||
perf.Step("Vacuum");
|
perf.Step("Vacuum");
|
||||||
|
|
||||||
await reporter.NextVersion();
|
await reporter.NextVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dbVersion <= 3) {
|
if (dbVersion <= 3) {
|
||||||
await conn.ExecuteAsync("""
|
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();
|
await reporter.NextVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dbVersion <= 4) {
|
if (dbVersion <= 4) {
|
||||||
await reporter.MainWork("Applying schema changes...", 0, 1);
|
await reporter.MainWork("Applying schema changes...", 0, 1);
|
||||||
await conn.ExecuteAsync("ALTER TABLE attachments ADD width INTEGER");
|
conn.Execute("ALTER TABLE attachments ADD width INTEGER");
|
||||||
await conn.ExecuteAsync("ALTER TABLE attachments ADD height INTEGER");
|
conn.Execute("ALTER TABLE attachments ADD height INTEGER");
|
||||||
|
|
||||||
perf.Step("Upgrade to version 5");
|
perf.Step("Upgrade to version 5");
|
||||||
await reporter.NextVersion();
|
await reporter.NextVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dbVersion <= 5) {
|
if (dbVersion <= 5) {
|
||||||
await reporter.MainWork("Applying schema changes...", 0, 3);
|
await reporter.MainWork("Applying schema changes...", 0, 3);
|
||||||
await conn.ExecuteAsync("ALTER TABLE attachments ADD download_url TEXT");
|
conn.Execute("ALTER TABLE attachments ADD download_url TEXT");
|
||||||
await conn.ExecuteAsync("ALTER TABLE downloads ADD download_url TEXT");
|
conn.Execute("ALTER TABLE downloads ADD download_url TEXT");
|
||||||
|
|
||||||
await reporter.MainWork("Updating attachments...", 1, 3);
|
await reporter.MainWork("Updating attachments...", 1, 3);
|
||||||
await NormalizeAttachmentUrls(reporter);
|
await NormalizeAttachmentUrls(reporter);
|
||||||
|
|
||||||
await reporter.MainWork("Updating downloads...", 2, 3);
|
await reporter.MainWork("Updating downloads...", 2, 3);
|
||||||
await NormalizeDownloadUrls(reporter);
|
await NormalizeDownloadUrls(reporter);
|
||||||
|
|
||||||
await reporter.MainWork("Applying schema changes...", 3, 3);
|
await reporter.MainWork("Applying schema changes...", 3, 3);
|
||||||
await conn.ExecuteAsync("ALTER TABLE attachments RENAME COLUMN url TO normalized_url");
|
conn.Execute("ALTER TABLE attachments RENAME COLUMN url TO normalized_url");
|
||||||
await conn.ExecuteAsync("ALTER TABLE downloads 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();
|
await reporter.NextVersion();
|
||||||
}
|
}
|
||||||
|
@@ -1,8 +1,15 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Database.Repositories;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database.Sqlite.Repositories;
|
using DHT.Server.Data.Aggregations;
|
||||||
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
|
using DHT.Server.Download;
|
||||||
|
using DHT.Utils.Collections;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
using DHT.Utils.Tasks;
|
using DHT.Utils.Tasks;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
@@ -29,9 +36,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (wasOpened) {
|
if (wasOpened) {
|
||||||
var db = new SqliteDatabaseFile(path, pool, computeTaskResultScheduler);
|
return new SqliteDatabaseFile(path, pool, computeTaskResultScheduler);
|
||||||
await db.Initialize();
|
|
||||||
return db;
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
pool.Dispose();
|
pool.Dispose();
|
||||||
@@ -41,26 +46,15 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
|
|||||||
|
|
||||||
public string Path { get; }
|
public string Path { get; }
|
||||||
public DatabaseStatistics Statistics { get; }
|
public DatabaseStatistics Statistics { get; }
|
||||||
|
|
||||||
public IUserRepository Users => users;
|
private readonly Log log;
|
||||||
public IServerRepository Servers => servers;
|
|
||||||
public IChannelRepository Channels => channels;
|
|
||||||
public IMessageRepository Messages => messages;
|
|
||||||
public IDownloadRepository Downloads => downloads;
|
|
||||||
|
|
||||||
private readonly SqliteConnectionPool pool;
|
private readonly SqliteConnectionPool pool;
|
||||||
|
|
||||||
private readonly SqliteUserRepository users;
|
|
||||||
private readonly SqliteServerRepository servers;
|
|
||||||
private readonly SqliteChannelRepository channels;
|
|
||||||
private readonly SqliteMessageRepository messages;
|
|
||||||
private readonly SqliteDownloadRepository downloads;
|
|
||||||
|
|
||||||
private readonly AsyncValueComputer<long>.Single totalMessagesComputer;
|
private readonly AsyncValueComputer<long>.Single totalMessagesComputer;
|
||||||
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, TaskScheduler computeTaskResultScheduler) {
|
private SqliteDatabaseFile(string path, SqliteConnectionPool pool, TaskScheduler computeTaskResultScheduler) {
|
||||||
|
this.log = Log.ForType(typeof(SqliteDatabaseFile), System.IO.Path.GetFileName(path));
|
||||||
this.pool = pool;
|
this.pool = pool;
|
||||||
|
|
||||||
this.totalMessagesComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateMessageStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeMessageStatistics);
|
this.totalMessagesComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateMessageStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeMessageStatistics);
|
||||||
@@ -70,65 +64,651 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
|
|||||||
this.Path = path;
|
this.Path = path;
|
||||||
this.Statistics = new DatabaseStatistics();
|
this.Statistics = new DatabaseStatistics();
|
||||||
|
|
||||||
this.users = new SqliteUserRepository(pool, Statistics);
|
using (var conn = pool.Take()) {
|
||||||
this.servers = new SqliteServerRepository(pool, Statistics);
|
UpdateServerStatistics(conn);
|
||||||
this.channels = new SqliteChannelRepository(pool, Statistics);
|
UpdateChannelStatistics(conn);
|
||||||
this.messages = new SqliteMessageRepository(pool, totalMessagesComputer, totalAttachmentsComputer);
|
UpdateUserStatistics(conn);
|
||||||
this.downloads = new SqliteDownloadRepository(pool, totalDownloadsComputer);
|
}
|
||||||
|
|
||||||
totalMessagesComputer.Recompute();
|
totalMessagesComputer.Recompute();
|
||||||
totalAttachmentsComputer.Recompute();
|
totalAttachmentsComputer.Recompute();
|
||||||
totalDownloadsComputer.Recompute();
|
totalDownloadsComputer.Recompute();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Initialize() {
|
|
||||||
await users.Initialize();
|
|
||||||
await servers.Initialize();
|
|
||||||
await channels.Initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
totalMessagesComputer.Cancel();
|
|
||||||
totalAttachmentsComputer.Cancel();
|
|
||||||
totalDownloadsComputer.Cancel();
|
|
||||||
pool.Dispose();
|
pool.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DatabaseStatisticsSnapshot> SnapshotStatistics() {
|
public DatabaseStatisticsSnapshot SnapshotStatistics() {
|
||||||
return new DatabaseStatisticsSnapshot {
|
return new DatabaseStatisticsSnapshot {
|
||||||
TotalServers = Statistics.TotalServers,
|
TotalServers = Statistics.TotalServers,
|
||||||
TotalChannels = Statistics.TotalChannels,
|
TotalChannels = Statistics.TotalChannels,
|
||||||
TotalUsers = Statistics.TotalUsers,
|
TotalUsers = Statistics.TotalUsers,
|
||||||
TotalMessages = await ComputeMessageStatistics(),
|
TotalMessages = ComputeMessageStatistics(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Vacuum() {
|
public void AddServer(Data.Server server) {
|
||||||
using var conn = pool.Take();
|
using var conn = pool.Take();
|
||||||
await conn.ExecuteAsync("VACUUM");
|
using var cmd = conn.Upsert("servers", new[] {
|
||||||
|
("id", SqliteType.Integer),
|
||||||
|
("name", SqliteType.Text),
|
||||||
|
("type", SqliteType.Text),
|
||||||
|
});
|
||||||
|
|
||||||
|
cmd.Set(":id", server.Id);
|
||||||
|
cmd.Set(":name", server.Name);
|
||||||
|
cmd.Set(":type", ServerTypes.ToString(server.Type));
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
UpdateServerStatistics(conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<long> ComputeMessageStatistics() {
|
public List<Data.Server> GetAllServers() {
|
||||||
|
var perf = log.Start();
|
||||||
|
var list = new List<Data.Server>();
|
||||||
|
|
||||||
using var conn = pool.Take();
|
using var conn = pool.Take();
|
||||||
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM messages", static reader => reader?.GetInt64(0) ?? 0L);
|
using var cmd = conn.Command("SELECT id, name, type FROM servers");
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
while (reader.Read()) {
|
||||||
|
list.Add(new Data.Server {
|
||||||
|
Id = reader.GetUint64(0),
|
||||||
|
Name = reader.GetString(1),
|
||||||
|
Type = ServerTypes.FromString(reader.GetString(2)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
perf.End();
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddChannel(Channel channel) {
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Upsert("channels", new[] {
|
||||||
|
("id", SqliteType.Integer),
|
||||||
|
("server", SqliteType.Integer),
|
||||||
|
("name", SqliteType.Text),
|
||||||
|
("parent_id", SqliteType.Integer),
|
||||||
|
("position", SqliteType.Integer),
|
||||||
|
("topic", SqliteType.Text),
|
||||||
|
("nsfw", SqliteType.Integer),
|
||||||
|
});
|
||||||
|
|
||||||
|
cmd.Set(":id", channel.Id);
|
||||||
|
cmd.Set(":server", channel.Server);
|
||||||
|
cmd.Set(":name", channel.Name);
|
||||||
|
cmd.Set(":parent_id", channel.ParentId);
|
||||||
|
cmd.Set(":position", channel.Position);
|
||||||
|
cmd.Set(":topic", channel.Topic);
|
||||||
|
cmd.Set(":nsfw", channel.Nsfw);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
UpdateChannelStatistics(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Channel> GetAllChannels() {
|
||||||
|
var list = new List<Channel>();
|
||||||
|
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command("SELECT id, server, name, parent_id, position, topic, nsfw FROM channels");
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
while (reader.Read()) {
|
||||||
|
list.Add(new Channel {
|
||||||
|
Id = reader.GetUint64(0),
|
||||||
|
Server = reader.GetUint64(1),
|
||||||
|
Name = reader.GetString(2),
|
||||||
|
ParentId = reader.IsDBNull(3) ? null : reader.GetUint64(3),
|
||||||
|
Position = reader.IsDBNull(4) ? null : reader.GetInt32(4),
|
||||||
|
Topic = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||||
|
Nsfw = reader.IsDBNull(6) ? null : reader.GetBoolean(6),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddUsers(User[] users) {
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var tx = conn.BeginTransaction();
|
||||||
|
using var cmd = conn.Upsert("users", new[] {
|
||||||
|
("id", SqliteType.Integer),
|
||||||
|
("name", SqliteType.Text),
|
||||||
|
("avatar_url", SqliteType.Text),
|
||||||
|
("discriminator", SqliteType.Text),
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (var user in users) {
|
||||||
|
cmd.Set(":id", user.Id);
|
||||||
|
cmd.Set(":name", user.Name);
|
||||||
|
cmd.Set(":avatar_url", user.AvatarUrl);
|
||||||
|
cmd.Set(":discriminator", user.Discriminator);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit();
|
||||||
|
UpdateUserStatistics(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<User> GetAllUsers() {
|
||||||
|
var perf = log.Start();
|
||||||
|
var list = new List<User>();
|
||||||
|
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command("SELECT id, name, avatar_url, discriminator FROM users");
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
while (reader.Read()) {
|
||||||
|
list.Add(new User {
|
||||||
|
Id = reader.GetUint64(0),
|
||||||
|
Name = reader.GetString(1),
|
||||||
|
AvatarUrl = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||||
|
Discriminator = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
perf.End();
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddMessages(Message[] messages) {
|
||||||
|
static SqliteCommand DeleteByMessageId(ISqliteConnection conn, string tableName) {
|
||||||
|
return conn.Delete(tableName, ("message_id", SqliteType.Integer));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ExecuteDeleteByMessageId(SqliteCommand cmd, object id) {
|
||||||
|
cmd.Set(":message_id", id);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool addedAttachments = false;
|
||||||
|
|
||||||
|
using (var conn = pool.Take()) {
|
||||||
|
using var tx = conn.BeginTransaction();
|
||||||
|
|
||||||
|
using var messageCmd = conn.Upsert("messages", new[] {
|
||||||
|
("message_id", SqliteType.Integer),
|
||||||
|
("sender_id", SqliteType.Integer),
|
||||||
|
("channel_id", SqliteType.Integer),
|
||||||
|
("text", SqliteType.Text),
|
||||||
|
("timestamp", SqliteType.Integer),
|
||||||
|
});
|
||||||
|
|
||||||
|
using var deleteEditTimestampCmd = DeleteByMessageId(conn, "edit_timestamps");
|
||||||
|
using var deleteRepliedToCmd = DeleteByMessageId(conn, "replied_to");
|
||||||
|
|
||||||
|
using var deleteAttachmentsCmd = DeleteByMessageId(conn, "attachments");
|
||||||
|
using var deleteEmbedsCmd = DeleteByMessageId(conn, "embeds");
|
||||||
|
using var deleteReactionsCmd = DeleteByMessageId(conn, "reactions");
|
||||||
|
|
||||||
|
using var editTimestampCmd = conn.Insert("edit_timestamps", new [] {
|
||||||
|
("message_id", SqliteType.Integer),
|
||||||
|
("edit_timestamp", SqliteType.Integer),
|
||||||
|
});
|
||||||
|
|
||||||
|
using var repliedToCmd = conn.Insert("replied_to", new [] {
|
||||||
|
("message_id", SqliteType.Integer),
|
||||||
|
("replied_to_id", SqliteType.Integer),
|
||||||
|
});
|
||||||
|
|
||||||
|
using var attachmentCmd = conn.Insert("attachments", new[] {
|
||||||
|
("message_id", SqliteType.Integer),
|
||||||
|
("attachment_id", SqliteType.Integer),
|
||||||
|
("name", SqliteType.Text),
|
||||||
|
("type", SqliteType.Text),
|
||||||
|
("normalized_url", SqliteType.Text),
|
||||||
|
("download_url", SqliteType.Text),
|
||||||
|
("size", SqliteType.Integer),
|
||||||
|
("width", SqliteType.Integer),
|
||||||
|
("height", SqliteType.Integer),
|
||||||
|
});
|
||||||
|
|
||||||
|
using var embedCmd = conn.Insert("embeds", new[] {
|
||||||
|
("message_id", SqliteType.Integer),
|
||||||
|
("json", SqliteType.Text),
|
||||||
|
});
|
||||||
|
|
||||||
|
using var reactionCmd = conn.Insert("reactions", new[] {
|
||||||
|
("message_id", SqliteType.Integer),
|
||||||
|
("emoji_id", SqliteType.Integer),
|
||||||
|
("emoji_name", SqliteType.Text),
|
||||||
|
("emoji_flags", SqliteType.Integer),
|
||||||
|
("count", SqliteType.Integer),
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (var message in messages) {
|
||||||
|
object messageId = message.Id;
|
||||||
|
|
||||||
|
messageCmd.Set(":message_id", messageId);
|
||||||
|
messageCmd.Set(":sender_id", message.Sender);
|
||||||
|
messageCmd.Set(":channel_id", message.Channel);
|
||||||
|
messageCmd.Set(":text", message.Text);
|
||||||
|
messageCmd.Set(":timestamp", message.Timestamp);
|
||||||
|
messageCmd.ExecuteNonQuery();
|
||||||
|
|
||||||
|
ExecuteDeleteByMessageId(deleteEditTimestampCmd, messageId);
|
||||||
|
ExecuteDeleteByMessageId(deleteRepliedToCmd, messageId);
|
||||||
|
|
||||||
|
ExecuteDeleteByMessageId(deleteAttachmentsCmd, messageId);
|
||||||
|
ExecuteDeleteByMessageId(deleteEmbedsCmd, messageId);
|
||||||
|
ExecuteDeleteByMessageId(deleteReactionsCmd, messageId);
|
||||||
|
|
||||||
|
if (message.EditTimestamp is {} timestamp) {
|
||||||
|
editTimestampCmd.Set(":message_id", messageId);
|
||||||
|
editTimestampCmd.Set(":edit_timestamp", timestamp);
|
||||||
|
editTimestampCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.RepliedToId is {} repliedToId) {
|
||||||
|
repliedToCmd.Set(":message_id", messageId);
|
||||||
|
repliedToCmd.Set(":replied_to_id", repliedToId);
|
||||||
|
repliedToCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.Attachments.IsEmpty) {
|
||||||
|
addedAttachments = true;
|
||||||
|
|
||||||
|
foreach (var attachment in message.Attachments) {
|
||||||
|
attachmentCmd.Set(":message_id", messageId);
|
||||||
|
attachmentCmd.Set(":attachment_id", attachment.Id);
|
||||||
|
attachmentCmd.Set(":name", attachment.Name);
|
||||||
|
attachmentCmd.Set(":type", attachment.Type);
|
||||||
|
attachmentCmd.Set(":normalized_url", attachment.NormalizedUrl);
|
||||||
|
attachmentCmd.Set(":download_url", attachment.DownloadUrl);
|
||||||
|
attachmentCmd.Set(":size", attachment.Size);
|
||||||
|
attachmentCmd.Set(":width", attachment.Width);
|
||||||
|
attachmentCmd.Set(":height", attachment.Height);
|
||||||
|
attachmentCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.Embeds.IsEmpty) {
|
||||||
|
foreach (var embed in message.Embeds) {
|
||||||
|
embedCmd.Set(":message_id", messageId);
|
||||||
|
embedCmd.Set(":json", embed.Json);
|
||||||
|
embedCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.Reactions.IsEmpty) {
|
||||||
|
foreach (var reaction in message.Reactions) {
|
||||||
|
reactionCmd.Set(":message_id", messageId);
|
||||||
|
reactionCmd.Set(":emoji_id", reaction.EmojiId);
|
||||||
|
reactionCmd.Set(":emoji_name", reaction.EmojiName);
|
||||||
|
reactionCmd.Set(":emoji_flags", (int) reaction.EmojiFlags);
|
||||||
|
reactionCmd.Set(":count", reaction.Count);
|
||||||
|
reactionCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
totalMessagesComputer.Recompute();
|
||||||
|
|
||||||
|
if (addedAttachments) {
|
||||||
|
totalAttachmentsComputer.Recompute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CountMessages(MessageFilter? filter = null) {
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command("SELECT COUNT(*) FROM messages" + filter.GenerateWhereClause());
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
return reader.Read() ? reader.GetInt32(0) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Message> GetMessages(MessageFilter? filter = null, bool includeText = true) {
|
||||||
|
var perf = log.Start();
|
||||||
|
var list = new List<Message>();
|
||||||
|
|
||||||
|
var attachments = GetAllAttachments();
|
||||||
|
var embeds = GetAllEmbeds();
|
||||||
|
var reactions = GetAllReactions();
|
||||||
|
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command($"""
|
||||||
|
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
|
||||||
|
LEFT JOIN edit_timestamps et ON m.message_id = et.message_id
|
||||||
|
LEFT JOIN replied_to rt ON m.message_id = rt.message_id
|
||||||
|
{filter.GenerateWhereClause("m")}
|
||||||
|
""");
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
while (reader.Read()) {
|
||||||
|
ulong id = reader.GetUint64(0);
|
||||||
|
|
||||||
|
list.Add(new Message {
|
||||||
|
Id = id,
|
||||||
|
Sender = reader.GetUint64(1),
|
||||||
|
Channel = reader.GetUint64(2),
|
||||||
|
Text = includeText ? reader.GetString(3) : string.Empty,
|
||||||
|
Timestamp = reader.GetInt64(4),
|
||||||
|
EditTimestamp = reader.IsDBNull(5) ? null : reader.GetInt64(5),
|
||||||
|
RepliedToId = reader.IsDBNull(6) ? null : reader.GetUint64(6),
|
||||||
|
Attachments = attachments.GetListOrNull(id)?.ToImmutableArray() ?? ImmutableArray<Attachment>.Empty,
|
||||||
|
Embeds = embeds.GetListOrNull(id)?.ToImmutableArray() ?? ImmutableArray<Embed>.Empty,
|
||||||
|
Reactions = reactions.GetListOrNull(id)?.ToImmutableArray() ?? ImmutableArray<Reaction>.Empty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
perf.End();
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HashSet<ulong> GetMessageIds(MessageFilter? filter = null) {
|
||||||
|
var perf = log.Start();
|
||||||
|
var ids = new HashSet<ulong>();
|
||||||
|
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command("SELECT message_id FROM messages" + filter.GenerateWhereClause());
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
while (reader.Read()) {
|
||||||
|
ids.Add(reader.GetUint64(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
perf.End();
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveMessages(MessageFilter filter, FilterRemovalMode mode) {
|
||||||
|
var perf = log.Start();
|
||||||
|
|
||||||
|
DeleteFromTable("messages", filter.GenerateWhereClause(invert: mode == FilterRemovalMode.KeepMatching));
|
||||||
|
totalMessagesComputer.Recompute();
|
||||||
|
|
||||||
|
perf.End();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CountAttachments(AttachmentFilter? filter = null) {
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command("SELECT COUNT(DISTINCT normalized_url) FROM attachments a" + filter.GenerateWhereClause("a"));
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
return reader.Read() ? reader.GetInt32(0) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddDownload(Data.Download download) {
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Upsert("downloads", new[] {
|
||||||
|
("normalized_url", SqliteType.Text),
|
||||||
|
("download_url", SqliteType.Text),
|
||||||
|
("status", SqliteType.Integer),
|
||||||
|
("size", SqliteType.Integer),
|
||||||
|
("blob", SqliteType.Blob),
|
||||||
|
});
|
||||||
|
|
||||||
|
cmd.Set(":normalized_url", download.NormalizedUrl);
|
||||||
|
cmd.Set(":download_url", download.DownloadUrl);
|
||||||
|
cmd.Set(":status", (int) download.Status);
|
||||||
|
cmd.Set(":size", download.Size);
|
||||||
|
cmd.Set(":blob", download.Data);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
|
||||||
|
totalDownloadsComputer.Recompute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Data.Download> GetDownloadsWithoutData() {
|
||||||
|
var list = new List<Data.Download>();
|
||||||
|
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command("SELECT normalized_url, download_url, status, size FROM downloads");
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
while (reader.Read()) {
|
||||||
|
string normalizedUrl = reader.GetString(0);
|
||||||
|
string downloadUrl = reader.GetString(1);
|
||||||
|
var status = (DownloadStatus) reader.GetInt32(2);
|
||||||
|
ulong size = reader.GetUint64(3);
|
||||||
|
|
||||||
|
list.Add(new Data.Download(normalizedUrl, downloadUrl, status, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Data.Download GetDownloadWithData(Data.Download download) {
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command("SELECT blob FROM downloads WHERE normalized_url = :url");
|
||||||
|
cmd.AddAndSet(":url", SqliteType.Text, download.NormalizedUrl);
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
if (reader.Read() && !reader.IsDBNull(0)) {
|
||||||
|
return download.WithData((byte[]) reader["blob"]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return download;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DownloadedAttachment? GetDownloadedAttachment(string normalizedUrl) {
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command("""
|
||||||
|
SELECT a.type, d.blob FROM downloads d
|
||||||
|
LEFT JOIN attachments a ON d.normalized_url = a.normalized_url
|
||||||
|
WHERE d.normalized_url = :normalized_url AND d.status = :success AND d.blob IS NOT NULL
|
||||||
|
""");
|
||||||
|
|
||||||
|
cmd.AddAndSet(":normalized_url", SqliteType.Text, normalizedUrl);
|
||||||
|
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
if (!reader.Read()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DownloadedAttachment {
|
||||||
|
Type = reader.IsDBNull(0) ? null : reader.GetString(0),
|
||||||
|
Data = (byte[]) reader["blob"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnqueueDownloadItems(AttachmentFilter? filter = null) {
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command($"""
|
||||||
|
INSERT INTO downloads (normalized_url, download_url, status, size)
|
||||||
|
SELECT a.normalized_url, a.download_url, :enqueued, MAX(a.size)
|
||||||
|
FROM attachments a
|
||||||
|
{filter.GenerateWhereClause("a")}
|
||||||
|
GROUP BY a.normalized_url
|
||||||
|
""");
|
||||||
|
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DownloadItem> GetEnqueuedDownloadItems(int count) {
|
||||||
|
var list = new List<DownloadItem>();
|
||||||
|
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command("SELECT normalized_url, download_url, size FROM downloads WHERE status = :enqueued LIMIT :limit");
|
||||||
|
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
|
||||||
|
cmd.AddAndSet(":limit", SqliteType.Integer, Math.Max(0, count));
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
while (reader.Read()) {
|
||||||
|
list.Add(new DownloadItem {
|
||||||
|
NormalizedUrl = reader.GetString(0),
|
||||||
|
DownloadUrl = reader.GetString(1),
|
||||||
|
Size = reader.GetUint64(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {
|
||||||
|
DeleteFromTable("downloads", filter.GenerateWhereClause(invert: mode == FilterRemovalMode.KeepMatching));
|
||||||
|
totalDownloadsComputer.Recompute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public DownloadStatusStatistics GetDownloadStatusStatistics() {
|
||||||
|
static void LoadUndownloadedStatistics(ISqliteConnection conn, DownloadStatusStatistics result) {
|
||||||
|
using var cmd = conn.Command("SELECT IFNULL(COUNT(size), 0), IFNULL(SUM(size), 0) FROM (SELECT MAX(a.size) size FROM attachments a WHERE a.normalized_url NOT IN (SELECT d.normalized_url FROM downloads d) GROUP BY a.normalized_url)");
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
if (reader.Read()) {
|
||||||
|
result.SkippedCount = reader.GetInt32(0);
|
||||||
|
result.SkippedSize = reader.GetUint64(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void LoadSuccessStatistics(ISqliteConnection conn, DownloadStatusStatistics result) {
|
||||||
|
using var cmd = conn.Command("""
|
||||||
|
SELECT
|
||||||
|
IFNULL(SUM(CASE WHEN status = :enqueued THEN 1 ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status = :enqueued THEN size ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status = :success THEN 1 ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status = :success THEN size ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status != :enqueued AND status != :success THEN 1 ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status != :enqueued AND status != :success THEN size ELSE 0 END), 0)
|
||||||
|
FROM downloads
|
||||||
|
""");
|
||||||
|
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
|
||||||
|
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
if (reader.Read()) {
|
||||||
|
result.EnqueuedCount = reader.GetInt32(0);
|
||||||
|
result.EnqueuedSize = reader.GetUint64(1);
|
||||||
|
result.SuccessfulCount = reader.GetInt32(2);
|
||||||
|
result.SuccessfulSize = reader.GetUint64(3);
|
||||||
|
result.FailedCount = reader.GetInt32(4);
|
||||||
|
result.FailedSize = reader.GetUint64(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new DownloadStatusStatistics();
|
||||||
|
|
||||||
|
using var conn = pool.Take();
|
||||||
|
LoadUndownloadedStatistics(conn, result);
|
||||||
|
LoadSuccessStatistics(conn, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MultiDictionary<ulong, Attachment> GetAllAttachments() {
|
||||||
|
var dict = new MultiDictionary<ulong, Attachment>();
|
||||||
|
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command("SELECT message_id, attachment_id, name, type, normalized_url, download_url, size, width, height FROM attachments");
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
while (reader.Read()) {
|
||||||
|
ulong messageId = reader.GetUint64(0);
|
||||||
|
|
||||||
|
dict.Add(messageId, new Attachment {
|
||||||
|
Id = reader.GetUint64(1),
|
||||||
|
Name = reader.GetString(2),
|
||||||
|
Type = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||||
|
NormalizedUrl = reader.GetString(4),
|
||||||
|
DownloadUrl = reader.GetString(5),
|
||||||
|
Size = reader.GetUint64(6),
|
||||||
|
Width = reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
||||||
|
Height = reader.IsDBNull(8) ? null : reader.GetInt32(8),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MultiDictionary<ulong, Embed> GetAllEmbeds() {
|
||||||
|
var dict = new MultiDictionary<ulong, Embed>();
|
||||||
|
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command("SELECT message_id, json FROM embeds");
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
while (reader.Read()) {
|
||||||
|
ulong messageId = reader.GetUint64(0);
|
||||||
|
|
||||||
|
dict.Add(messageId, new Embed {
|
||||||
|
Json = reader.GetString(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MultiDictionary<ulong, Reaction> GetAllReactions() {
|
||||||
|
var dict = new MultiDictionary<ulong, Reaction>();
|
||||||
|
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command("SELECT message_id, emoji_id, emoji_name, emoji_flags, count FROM reactions");
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
while (reader.Read()) {
|
||||||
|
ulong messageId = reader.GetUint64(0);
|
||||||
|
|
||||||
|
dict.Add(messageId, new Reaction {
|
||||||
|
EmojiId = reader.IsDBNull(1) ? null : reader.GetUint64(1),
|
||||||
|
EmojiName = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||||
|
EmojiFlags = (EmojiFlags) reader.GetInt16(3),
|
||||||
|
Count = reader.GetInt32(4),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteFromTable(string table, string whereClause) {
|
||||||
|
// Rider is being stupid...
|
||||||
|
StringBuilder build = new StringBuilder()
|
||||||
|
.Append("DELETE ")
|
||||||
|
.Append("FROM ")
|
||||||
|
.Append(table)
|
||||||
|
.Append(whereClause);
|
||||||
|
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command(build.ToString());
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Vacuum() {
|
||||||
|
using var conn = pool.Take();
|
||||||
|
using var cmd = conn.Command("VACUUM");
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateServerStatistics(ISqliteConnection conn) {
|
||||||
|
Statistics.TotalServers = conn.SelectScalar("SELECT COUNT(*) FROM servers") as long? ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateChannelStatistics(ISqliteConnection conn) {
|
||||||
|
Statistics.TotalChannels = conn.SelectScalar("SELECT COUNT(*) FROM channels") as long? ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateUserStatistics(ISqliteConnection conn) {
|
||||||
|
Statistics.TotalUsers = conn.SelectScalar("SELECT COUNT(*) FROM users") as long? ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long ComputeMessageStatistics() {
|
||||||
|
using var conn = pool.Take();
|
||||||
|
return conn.SelectScalar("SELECT COUNT(*) FROM messages") as long? ?? 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateMessageStatistics(long totalMessages) {
|
private void UpdateMessageStatistics(long totalMessages) {
|
||||||
Statistics.TotalMessages = totalMessages;
|
Statistics.TotalMessages = totalMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<long> ComputeAttachmentStatistics() {
|
private long ComputeAttachmentStatistics() {
|
||||||
using var conn = pool.Take();
|
using var conn = pool.Take();
|
||||||
return await conn.ExecuteReaderAsync("SELECT COUNT(DISTINCT normalized_url) FROM attachments", static reader => reader?.GetInt64(0) ?? 0L);
|
return conn.SelectScalar("SELECT COUNT(DISTINCT normalized_url) FROM attachments") as long? ?? 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateAttachmentStatistics(long totalAttachments) {
|
private void UpdateAttachmentStatistics(long totalAttachments) {
|
||||||
Statistics.TotalAttachments = totalAttachments;
|
Statistics.TotalAttachments = totalAttachments;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<long> ComputeDownloadStatistics() {
|
private long ComputeDownloadStatistics() {
|
||||||
using var conn = pool.Take();
|
using var conn = pool.Take();
|
||||||
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM downloads", static reader => reader?.GetInt64(0) ?? 0L);
|
return conn.SelectScalar("SELECT COUNT(*) FROM downloads") as long? ?? 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateDownloadStatistics(long totalDownloads) {
|
private void UpdateDownloadStatistics(long totalDownloads) {
|
||||||
|
@@ -8,13 +8,9 @@ using DHT.Server.Database.Sqlite.Utils;
|
|||||||
namespace DHT.Server.Database.Sqlite;
|
namespace DHT.Server.Database.Sqlite;
|
||||||
|
|
||||||
static class SqliteFilters {
|
static class SqliteFilters {
|
||||||
private static string WhereAll(bool invert) {
|
|
||||||
return invert ? "WHERE FALSE" : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GenerateWhereClause(this MessageFilter? filter, string? tableAlias = null, bool invert = false) {
|
public static string GenerateWhereClause(this MessageFilter? filter, string? tableAlias = null, bool invert = false) {
|
||||||
if (filter == null || filter.IsEmpty) {
|
if (filter == null) {
|
||||||
return WhereAll(invert);
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
var where = new SqliteWhereGenerator(tableAlias, invert);
|
var where = new SqliteWhereGenerator(tableAlias, invert);
|
||||||
@@ -43,8 +39,8 @@ static class SqliteFilters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static string GenerateWhereClause(this AttachmentFilter? filter, string? tableAlias = null, bool invert = false) {
|
public static string GenerateWhereClause(this AttachmentFilter? filter, string? tableAlias = null, bool invert = false) {
|
||||||
if (filter == null || filter.IsEmpty) {
|
if (filter == null) {
|
||||||
return WhereAll(invert);
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
var where = new SqliteWhereGenerator(tableAlias, invert);
|
var where = new SqliteWhereGenerator(tableAlias, invert);
|
||||||
@@ -64,8 +60,8 @@ static class SqliteFilters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static string GenerateWhereClause(this DownloadItemFilter? filter, string? tableAlias = null, bool invert = false) {
|
public static string GenerateWhereClause(this DownloadItemFilter? filter, string? tableAlias = null, bool invert = false) {
|
||||||
if (filter == null || filter.IsEmpty) {
|
if (filter == null) {
|
||||||
return WhereAll(invert);
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
var where = new SqliteWhereGenerator(tableAlias, invert);
|
var where = new SqliteWhereGenerator(tableAlias, invert);
|
||||||
|
@@ -1,16 +1,12 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Data.Common;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using JetBrains.Annotations;
|
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite.Utils;
|
namespace DHT.Server.Database.Sqlite.Utils;
|
||||||
|
|
||||||
static class SqliteExtensions {
|
static class SqliteExtensions {
|
||||||
public static ValueTask<DbTransaction> BeginTransactionAsync(this ISqliteConnection conn) {
|
public static SqliteTransaction BeginTransaction(this ISqliteConnection conn) {
|
||||||
return conn.InnerConnection.BeginTransactionAsync();
|
return conn.InnerConnection.BeginTransaction();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SqliteCommand Command(this ISqliteConnection conn, string sql) {
|
public static SqliteCommand Command(this ISqliteConnection conn, string sql) {
|
||||||
@@ -19,16 +15,14 @@ static class SqliteExtensions {
|
|||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<int> ExecuteAsync(this ISqliteConnection conn, [LanguageInjection("sql")] string sql, CancellationToken cancellationToken = default) {
|
public static void Execute(this ISqliteConnection conn, string sql) {
|
||||||
await using var cmd = conn.Command(sql);
|
using var cmd = conn.Command(sql);
|
||||||
return await cmd.ExecuteNonQueryAsync(cancellationToken);
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<T> ExecuteReaderAsync<T>(this ISqliteConnection conn, string sql, Func<SqliteDataReader?, T> readFunction, CancellationToken cancellationToken = default) {
|
|
||||||
await using var cmd = conn.Command(sql);
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
|
||||||
|
|
||||||
return reader.Read() ? readFunction(reader) : readFunction(null);
|
public static object? SelectScalar(this ISqliteConnection conn, string sql) {
|
||||||
|
using var cmd = conn.Command(sql);
|
||||||
|
return cmd.ExecuteScalar();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SqliteCommand Insert(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type)[] columns) {
|
public static SqliteCommand Insert(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type)[] columns) {
|
||||||
@@ -58,7 +52,7 @@ static class SqliteExtensions {
|
|||||||
|
|
||||||
public static SqliteCommand Delete(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type) column) {
|
public static SqliteCommand Delete(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type) column) {
|
||||||
var cmd = conn.Command("DELETE FROM " + tableName + " WHERE " + column.Name + " = :" + column.Name);
|
var cmd = conn.Command("DELETE FROM " + tableName + " WHERE " + column.Name + " = :" + column.Name);
|
||||||
CreateParameters(cmd, new[] { column });
|
CreateParameters(cmd, new [] { column });
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,10 +62,6 @@ static class SqliteExtensions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Add(this SqliteCommand cmd, string key, SqliteType type) {
|
|
||||||
cmd.Parameters.Add(key, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void AddAndSet(this SqliteCommand cmd, string key, SqliteType type, object? value) {
|
public static void AddAndSet(this SqliteCommand cmd, string key, SqliteType type, object? value) {
|
||||||
cmd.Parameters.Add(key, type).Value = value ?? DBNull.Value;
|
cmd.Parameters.Add(key, type).Value = value ?? DBNull.Value;
|
||||||
}
|
}
|
||||||
|
130
app/Server/Download/BackgroundDownloadThread.cs
Normal file
130
app/Server/Download/BackgroundDownloadThread.cs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using DHT.Server.Database;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
|
namespace DHT.Server.Download;
|
||||||
|
|
||||||
|
public sealed class BackgroundDownloadThread : BaseModel {
|
||||||
|
private static readonly Log Log = Log.ForType<BackgroundDownloadThread>();
|
||||||
|
|
||||||
|
public event EventHandler<DownloadItem>? OnItemFinished {
|
||||||
|
add => parameters.OnItemFinished += value;
|
||||||
|
remove => parameters.OnItemFinished -= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? OnServerStopped {
|
||||||
|
add => parameters.OnServerStopped += value;
|
||||||
|
remove => parameters.OnServerStopped -= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly CancellationTokenSource cancellationTokenSource;
|
||||||
|
private readonly ThreadInstance.Parameters parameters;
|
||||||
|
|
||||||
|
public BackgroundDownloadThread(IDatabaseFile db) {
|
||||||
|
this.cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
this.parameters = new ThreadInstance.Parameters(db, cancellationTokenSource);
|
||||||
|
|
||||||
|
var thread = new Thread(new ThreadInstance().Work) {
|
||||||
|
Name = "DHT download thread"
|
||||||
|
};
|
||||||
|
|
||||||
|
thread.Start(parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StopThread() {
|
||||||
|
try {
|
||||||
|
cancellationTokenSource.Cancel();
|
||||||
|
} catch (ObjectDisposedException) {
|
||||||
|
Log.Warn("Attempted to stop background download thread after the cancellation token has been disposed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThreadInstance {
|
||||||
|
private const int QueueSize = 32;
|
||||||
|
|
||||||
|
public sealed class Parameters {
|
||||||
|
public event EventHandler<DownloadItem>? OnItemFinished;
|
||||||
|
public event EventHandler? OnServerStopped;
|
||||||
|
|
||||||
|
public IDatabaseFile Db { get; }
|
||||||
|
public CancellationTokenSource CancellationTokenSource { get; }
|
||||||
|
|
||||||
|
public Parameters(IDatabaseFile db, CancellationTokenSource cancellationTokenSource) {
|
||||||
|
Db = db;
|
||||||
|
CancellationTokenSource = cancellationTokenSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FireOnItemFinished(DownloadItem item) {
|
||||||
|
OnItemFinished?.Invoke(null, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FireOnServerStopped() {
|
||||||
|
OnServerStopped?.Invoke(null, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly HttpClient client = new ();
|
||||||
|
|
||||||
|
public ThreadInstance() {
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void Work(object? obj) {
|
||||||
|
var parameters = (Parameters) obj!;
|
||||||
|
|
||||||
|
var cancellationTokenSource = parameters.CancellationTokenSource;
|
||||||
|
var cancellationToken = cancellationTokenSource.Token;
|
||||||
|
|
||||||
|
var db = parameters.Db;
|
||||||
|
var queue = new ConcurrentQueue<DownloadItem>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!cancellationToken.IsCancellationRequested) {
|
||||||
|
FillQueue(db, queue, cancellationToken);
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested && queue.TryDequeue(out var item)) {
|
||||||
|
var downloadUrl = item.DownloadUrl;
|
||||||
|
Log.Debug("Downloading " + downloadUrl + "...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.AddDownload(Data.Download.NewSuccess(item, await client.GetByteArrayAsync(downloadUrl, cancellationToken)));
|
||||||
|
} catch (HttpRequestException e) {
|
||||||
|
db.AddDownload(Data.Download.NewFailure(item, e.StatusCode, item.Size));
|
||||||
|
Log.Error(e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
db.AddDownload(Data.Download.NewFailure(item, null, item.Size));
|
||||||
|
Log.Error(e);
|
||||||
|
} finally {
|
||||||
|
parameters.FireOnItemFinished(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (OperationCanceledException) {
|
||||||
|
//
|
||||||
|
} catch (ObjectDisposedException) {
|
||||||
|
//
|
||||||
|
} finally {
|
||||||
|
cancellationTokenSource.Dispose();
|
||||||
|
parameters.FireOnServerStopped();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FillQueue(IDatabaseFile db, ConcurrentQueue<DownloadItem> queue, CancellationToken cancellationToken) {
|
||||||
|
while (!cancellationToken.IsCancellationRequested && queue.IsEmpty) {
|
||||||
|
var newItems = db.GetEnqueuedDownloadItems(QueueSize);
|
||||||
|
if (newItems.Count == 0) {
|
||||||
|
Thread.Sleep(TimeSpan.FromMilliseconds(50));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
foreach (var item in newItems) {
|
||||||
|
queue.Enqueue(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Frozen;
|
using System.Collections.Frozen;
|
||||||
|
|
||||||
namespace DHT.Server.Download;
|
namespace DHT.Server.Download;
|
||||||
|
|
||||||
static class DiscordCdn {
|
static class DiscordCdn {
|
||||||
private static FrozenSet<string> CdnHosts { get; } = new [] {
|
private static FrozenSet<string> CdnHosts { get; } = new [] {
|
||||||
|
@@ -1,40 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Database;
|
|
||||||
|
|
||||||
namespace DHT.Server.Download;
|
|
||||||
|
|
||||||
public sealed class Downloader {
|
|
||||||
private DownloaderTask? current;
|
|
||||||
public bool IsDownloading => current != null;
|
|
||||||
|
|
||||||
private readonly IDatabaseFile db;
|
|
||||||
private readonly SemaphoreSlim semaphore = new (1, 1);
|
|
||||||
|
|
||||||
internal Downloader(IDatabaseFile db) {
|
|
||||||
this.db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IObservable<DownloadItem>> Start() {
|
|
||||||
await semaphore.WaitAsync();
|
|
||||||
try {
|
|
||||||
current ??= new DownloaderTask(db);
|
|
||||||
return current.FinishedItems;
|
|
||||||
} finally {
|
|
||||||
semaphore.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Stop() {
|
|
||||||
await semaphore.WaitAsync();
|
|
||||||
try {
|
|
||||||
if (current != null) {
|
|
||||||
await current.DisposeAsync();
|
|
||||||
current = null;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
semaphore.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,110 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Reactive.Subjects;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Channels;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Utils.Logging;
|
|
||||||
using DHT.Utils.Tasks;
|
|
||||||
|
|
||||||
namespace DHT.Server.Download;
|
|
||||||
|
|
||||||
sealed class DownloaderTask : IAsyncDisposable {
|
|
||||||
private static readonly Log Log = Log.ForType<DownloaderTask>();
|
|
||||||
|
|
||||||
private const int DownloadTasks = 4;
|
|
||||||
private const int QueueSize = 25;
|
|
||||||
private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
||||||
|
|
||||||
private readonly Channel<DownloadItem> downloadQueue = Channel.CreateBounded<DownloadItem>(new BoundedChannelOptions(QueueSize) {
|
|
||||||
SingleReader = false,
|
|
||||||
SingleWriter = true,
|
|
||||||
AllowSynchronousContinuations = false,
|
|
||||||
FullMode = BoundedChannelFullMode.Wait
|
|
||||||
});
|
|
||||||
|
|
||||||
private readonly CancellationTokenSource cancellationTokenSource = new ();
|
|
||||||
private readonly CancellationToken cancellationToken;
|
|
||||||
|
|
||||||
private readonly IDatabaseFile db;
|
|
||||||
private readonly Subject<DownloadItem> finishedItemPublisher = new ();
|
|
||||||
|
|
||||||
private readonly Task queueWriterTask;
|
|
||||||
private readonly Task[] downloadTasks;
|
|
||||||
|
|
||||||
public IObservable<DownloadItem> FinishedItems => finishedItemPublisher;
|
|
||||||
|
|
||||||
internal DownloaderTask(IDatabaseFile db) {
|
|
||||||
this.db = db;
|
|
||||||
this.cancellationToken = cancellationTokenSource.Token;
|
|
||||||
this.queueWriterTask = Task.Run(RunQueueWriterTask);
|
|
||||||
this.downloadTasks = Enumerable.Range(1, DownloadTasks).Select(taskIndex => Task.Run(() => RunDownloadTask(taskIndex))).ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunQueueWriterTask() {
|
|
||||||
while (await downloadQueue.Writer.WaitToWriteAsync(cancellationToken)) {
|
|
||||||
var newItems = await db.Downloads.PullEnqueuedDownloadItems(QueueSize, cancellationToken).ToListAsync(cancellationToken);
|
|
||||||
if (newItems.Count == 0) {
|
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var newItem in newItems) {
|
|
||||||
await downloadQueue.Writer.WriteAsync(newItem, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunDownloadTask(int taskIndex) {
|
|
||||||
var log = Log.ForType<DownloaderTask>("Task " + taskIndex);
|
|
||||||
|
|
||||||
var client = new HttpClient();
|
|
||||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
|
|
||||||
client.Timeout = TimeSpan.FromSeconds(30);
|
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested) {
|
|
||||||
var item = await downloadQueue.Reader.ReadAsync(cancellationToken);
|
|
||||||
log.Debug("Downloading " + item.DownloadUrl + "...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
var downloadedBytes = await client.GetByteArrayAsync(item.DownloadUrl, cancellationToken);
|
|
||||||
await db.Downloads.AddDownload(Data.Download.NewSuccess(item, downloadedBytes));
|
|
||||||
} catch (OperationCanceledException) {
|
|
||||||
// Ignore.
|
|
||||||
} catch (HttpRequestException e) {
|
|
||||||
await db.Downloads.AddDownload(Data.Download.NewFailure(item, e.StatusCode, item.Size));
|
|
||||||
log.Error(e);
|
|
||||||
} catch (Exception e) {
|
|
||||||
await db.Downloads.AddDownload(Data.Download.NewFailure(item, null, item.Size));
|
|
||||||
log.Error(e);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
finishedItemPublisher.OnNext(item);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.Error("Caught exception in event handler: " + e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() {
|
|
||||||
try {
|
|
||||||
await cancellationTokenSource.CancelAsync();
|
|
||||||
} catch (Exception) {
|
|
||||||
Log.Warn("Attempted to stop background download twice.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadQueue.Writer.Complete();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await queueWriterTask.WaitIgnoringCancellation();
|
|
||||||
await Task.WhenAll(downloadTasks).WaitIgnoringCancellation();
|
|
||||||
} finally {
|
|
||||||
cancellationTokenSource.Dispose();
|
|
||||||
finishedItemPublisher.OnCompleted();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -10,15 +10,15 @@ namespace DHT.Server.Endpoints;
|
|||||||
sealed class GetAttachmentEndpoint : BaseEndpoint {
|
sealed class GetAttachmentEndpoint : BaseEndpoint {
|
||||||
public GetAttachmentEndpoint(IDatabaseFile db) : base(db) {}
|
public GetAttachmentEndpoint(IDatabaseFile db) : base(db) {}
|
||||||
|
|
||||||
protected override async 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"]!);
|
||||||
DownloadedAttachment? maybeDownloadedAttachment = await Db.Downloads.GetDownloadedAttachment(attachmentUrl);
|
DownloadedAttachment? maybeDownloadedAttachment = Db.GetDownloadedAttachment(attachmentUrl);
|
||||||
|
|
||||||
if (maybeDownloadedAttachment is {} downloadedAttachment) {
|
if (maybeDownloadedAttachment is {} downloadedAttachment) {
|
||||||
return new HttpOutput.File(downloadedAttachment.Type, downloadedAttachment.Data);
|
return Task.FromResult<IHttpOutput>(new HttpOutput.File(downloadedAttachment.Type, downloadedAttachment.Data));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return new HttpOutput.Redirect(attachmentUrl, permanent: false);
|
return Task.FromResult<IHttpOutput>(new HttpOutput.Redirect(attachmentUrl, permanent: false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
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 {}
|
@@ -16,19 +16,19 @@ sealed class TrackChannelEndpoint : BaseEndpoint {
|
|||||||
var server = ReadServer(root.RequireObject("server"), "server");
|
var server = ReadServer(root.RequireObject("server"), "server");
|
||||||
var channel = ReadChannel(root.RequireObject("channel"), "channel", server.Id);
|
var channel = ReadChannel(root.RequireObject("channel"), "channel", server.Id);
|
||||||
|
|
||||||
await Db.Servers.Add([server]);
|
Db.AddServer(server);
|
||||||
await Db.Channels.Add([channel]);
|
Db.AddChannel(channel);
|
||||||
|
|
||||||
return HttpOutput.None;
|
return HttpOutput.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Data.Server ReadServer(JsonElement json, string path) => new () {
|
private static Data.Server ReadServer(JsonElement json, string path) => new() {
|
||||||
Id = json.RequireSnowflake("id", path),
|
Id = json.RequireSnowflake("id", path),
|
||||||
Name = json.RequireString("name", path),
|
Name = json.RequireString("name", path),
|
||||||
Type = ServerTypes.FromString(json.RequireString("type", path)) ?? throw new HttpException(HttpStatusCode.BadRequest, "Server type must be either 'SERVER', 'GROUP', or 'DM'.")
|
Type = ServerTypes.FromString(json.RequireString("type", path)) ?? throw new HttpException(HttpStatusCode.BadRequest, "Server type must be either 'SERVER', 'GROUP', or 'DM'.")
|
||||||
};
|
};
|
||||||
|
|
||||||
private static Channel ReadChannel(JsonElement json, string path, ulong serverId) => new () {
|
private static Channel ReadChannel(JsonElement json, string path, ulong serverId) => new() {
|
||||||
Id = json.RequireSnowflake("id", path),
|
Id = json.RequireSnowflake("id", path),
|
||||||
Server = serverId,
|
Server = serverId,
|
||||||
Name = json.RequireString("name", path),
|
Name = json.RequireString("name", path),
|
||||||
|
@@ -18,7 +18,7 @@ namespace DHT.Server.Endpoints;
|
|||||||
sealed class TrackMessagesEndpoint : BaseEndpoint {
|
sealed class TrackMessagesEndpoint : BaseEndpoint {
|
||||||
private const string HasNewMessages = "1";
|
private const string HasNewMessages = "1";
|
||||||
private const string NoNewMessages = "0";
|
private const string NoNewMessages = "0";
|
||||||
|
|
||||||
public TrackMessagesEndpoint(IDatabaseFile db) : base(db) {}
|
public TrackMessagesEndpoint(IDatabaseFile db) : base(db) {}
|
||||||
|
|
||||||
protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
|
protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
|
||||||
@@ -39,14 +39,14 @@ sealed class TrackMessagesEndpoint : BaseEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var addedMessageFilter = new MessageFilter { MessageIds = addedMessageIds };
|
var addedMessageFilter = new MessageFilter { MessageIds = addedMessageIds };
|
||||||
bool anyNewMessages = await Db.Messages.Count(addedMessageFilter) < addedMessageIds.Count;
|
bool anyNewMessages = Db.CountMessages(addedMessageFilter) < messages.Length;
|
||||||
|
|
||||||
await Db.Messages.Add(messages);
|
Db.AddMessages(messages);
|
||||||
|
|
||||||
return new HttpOutput.Text(anyNewMessages ? HasNewMessages : NoNewMessages);
|
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() {
|
||||||
Id = json.RequireSnowflake("id", path),
|
Id = json.RequireSnowflake("id", path),
|
||||||
Sender = json.RequireSnowflake("sender", path),
|
Sender = json.RequireSnowflake("sender", path),
|
||||||
Channel = json.RequireSnowflake("channel", path),
|
Channel = json.RequireSnowflake("channel", path),
|
||||||
@@ -54,9 +54,9 @@ sealed class TrackMessagesEndpoint : BaseEndpoint {
|
|||||||
Timestamp = json.RequireLong("timestamp", path),
|
Timestamp = json.RequireLong("timestamp", path),
|
||||||
EditTimestamp = json.HasKey("editTimestamp") ? json.RequireLong("editTimestamp", path) : null,
|
EditTimestamp = json.HasKey("editTimestamp") ? json.RequireLong("editTimestamp", path) : null,
|
||||||
RepliedToId = json.HasKey("repliedToId") ? json.RequireSnowflake("repliedToId", path) : null,
|
RepliedToId = json.HasKey("repliedToId") ? json.RequireSnowflake("repliedToId", path) : null,
|
||||||
Attachments = json.HasKey("attachments") ? ReadAttachments(json.RequireArray("attachments", path + ".attachments"), path + ".attachments[]").ToImmutableList() : ImmutableList<Attachment>.Empty,
|
Attachments = json.HasKey("attachments") ? ReadAttachments(json.RequireArray("attachments", path + ".attachments"), path + ".attachments[]").ToImmutableArray() : ImmutableArray<Attachment>.Empty,
|
||||||
Embeds = json.HasKey("embeds") ? ReadEmbeds(json.RequireArray("embeds", path + ".embeds"), path + ".embeds[]").ToImmutableList() : ImmutableList<Embed>.Empty,
|
Embeds = json.HasKey("embeds") ? ReadEmbeds(json.RequireArray("embeds", path + ".embeds"), path + ".embeds[]").ToImmutableArray() : ImmutableArray<Embed>.Empty,
|
||||||
Reactions = json.HasKey("reactions") ? ReadReactions(json.RequireArray("reactions", path + ".reactions"), path + ".reactions[]").ToImmutableList() : ImmutableList<Reaction>.Empty,
|
Reactions = json.HasKey("reactions") ? ReadReactions(json.RequireArray("reactions", path + ".reactions"), path + ".reactions[]").ToImmutableArray() : ImmutableArray<Reaction>.Empty,
|
||||||
};
|
};
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "ConvertToLambdaExpression")]
|
[SuppressMessage("ReSharper", "ConvertToLambdaExpression")]
|
||||||
|
@@ -25,12 +25,12 @@ sealed class TrackUsersEndpoint : BaseEndpoint {
|
|||||||
users[i++] = ReadUser(user, "user");
|
users[i++] = ReadUser(user, "user");
|
||||||
}
|
}
|
||||||
|
|
||||||
await Db.Users.Add(users);
|
Db.AddUsers(users);
|
||||||
|
|
||||||
return HttpOutput.None;
|
return HttpOutput.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static User ReadUser(JsonElement json, string path) => new () {
|
private static User ReadUser(JsonElement json, string path) => new() {
|
||||||
Id = json.RequireSnowflake("id", path),
|
Id = json.RequireSnowflake("id", path),
|
||||||
Name = json.RequireString("name", path),
|
Name = json.RequireString("name", path),
|
||||||
AvatarUrl = json.HasKey("avatar") ? json.RequireString("avatar", path) : null,
|
AvatarUrl = json.HasKey("avatar") ? json.RequireString("avatar", path) : null,
|
||||||
|
@@ -12,8 +12,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
|
||||||
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
|
|
||||||
<PackageReference Include="System.Reactive" Version="6.0.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
@@ -4,7 +4,7 @@ using DHT.Utils.Logging;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Http.Extensions;
|
using Microsoft.AspNetCore.Http.Extensions;
|
||||||
|
|
||||||
namespace DHT.Server.Service.Middlewares;
|
namespace DHT.Server.Service.Middlewares;
|
||||||
|
|
||||||
sealed class ServerLoggingMiddleware {
|
sealed class ServerLoggingMiddleware {
|
||||||
private static readonly Log Log = Log.ForType<ServerLoggingMiddleware>();
|
private static readonly Log Log = Log.ForType<ServerLoggingMiddleware>();
|
||||||
|
126
app/Server/Service/ServerLauncher.cs
Normal file
126
app/Server/Service/ServerLauncher.cs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Threading;
|
||||||
|
using DHT.Server.Database;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace DHT.Server.Service;
|
||||||
|
|
||||||
|
public static class ServerLauncher {
|
||||||
|
private static readonly Log Log = Log.ForType(typeof(ServerLauncher));
|
||||||
|
|
||||||
|
private static IWebHost? Server { get; set; } = null;
|
||||||
|
|
||||||
|
public static bool IsRunning { get; private set; }
|
||||||
|
public static event EventHandler? ServerStatusChanged;
|
||||||
|
public static event EventHandler<Exception>? ServerManagementExceptionCaught;
|
||||||
|
|
||||||
|
private static Thread? ManagementThread { get; set; } = null;
|
||||||
|
private static readonly Mutex ManagementThreadLock = new();
|
||||||
|
private static readonly BlockingCollection<IMessage> Messages = new(new ConcurrentQueue<IMessage>());
|
||||||
|
|
||||||
|
private static void EnqueueMessage(IMessage message) {
|
||||||
|
ManagementThreadLock.WaitOne();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (ManagementThread == null) {
|
||||||
|
ManagementThread = new Thread(RunManagementThread) {
|
||||||
|
Name = "DHT server management thread",
|
||||||
|
IsBackground = true
|
||||||
|
};
|
||||||
|
ManagementThread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
Messages.Add(message);
|
||||||
|
} finally {
|
||||||
|
ManagementThreadLock.ReleaseMutex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "FunctionNeverReturns")]
|
||||||
|
private static void RunManagementThread() {
|
||||||
|
foreach (IMessage message in Messages.GetConsumingEnumerable()) {
|
||||||
|
try {
|
||||||
|
switch (message) {
|
||||||
|
case IMessage.StartServer start:
|
||||||
|
StopServerFromManagementThread();
|
||||||
|
StartServerFromManagementThread(start.Port, start.Token, start.Db);
|
||||||
|
break;
|
||||||
|
case IMessage.StopServer:
|
||||||
|
StopServerFromManagementThread();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
ServerManagementExceptionCaught?.Invoke(null, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void StartServerFromManagementThread(ushort port, string token, IDatabaseFile db) {
|
||||||
|
Log.Info("Starting server on port " + port + "...");
|
||||||
|
|
||||||
|
void AddServices(IServiceCollection services) {
|
||||||
|
services.AddSingleton(typeof(IDatabaseFile), db);
|
||||||
|
services.AddSingleton(typeof(ServerParameters), new ServerParameters(port, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetKestrelOptions(KestrelServerOptions options) {
|
||||||
|
options.Limits.MaxRequestBodySize = null;
|
||||||
|
options.Limits.MinResponseDataRate = null;
|
||||||
|
options.ListenLocalhost(port, static listenOptions => listenOptions.Protocols = HttpProtocols.Http1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Server = new WebHostBuilder()
|
||||||
|
.ConfigureServices(AddServices)
|
||||||
|
.UseKestrel(SetKestrelOptions)
|
||||||
|
.UseStartup<Startup>()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
Server.Start();
|
||||||
|
|
||||||
|
Log.Info("Server started");
|
||||||
|
IsRunning = true;
|
||||||
|
ServerStatusChanged?.Invoke(null, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void StopServerFromManagementThread() {
|
||||||
|
if (Server != null) {
|
||||||
|
Log.Info("Stopping server...");
|
||||||
|
Server.StopAsync().Wait();
|
||||||
|
Server.Dispose();
|
||||||
|
Server = null;
|
||||||
|
|
||||||
|
Log.Info("Server stopped");
|
||||||
|
IsRunning = false;
|
||||||
|
ServerStatusChanged?.Invoke(null, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Relaunch(ushort port, string token, IDatabaseFile db) {
|
||||||
|
EnqueueMessage(new IMessage.StartServer(port, token, db));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Stop() {
|
||||||
|
EnqueueMessage(new IMessage.StopServer());
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface IMessage {
|
||||||
|
public sealed class StartServer : IMessage {
|
||||||
|
public ushort Port { get; }
|
||||||
|
public string Token { get; }
|
||||||
|
public IDatabaseFile Db { get; }
|
||||||
|
|
||||||
|
public StartServer(ushort port, string token, IDatabaseFile db) {
|
||||||
|
this.Port = port;
|
||||||
|
this.Token = token;
|
||||||
|
this.Db = db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StopServer : IMessage {}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,107 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Utils.Logging;
|
|
||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace DHT.Server.Service;
|
|
||||||
|
|
||||||
public sealed class ServerManager {
|
|
||||||
private static readonly Log Log = Log.ForType(typeof(ServerManager));
|
|
||||||
|
|
||||||
private IWebHost? server;
|
|
||||||
public bool IsRunning => server != null;
|
|
||||||
|
|
||||||
public event EventHandler<Status>? StatusChanged;
|
|
||||||
|
|
||||||
public enum Status {
|
|
||||||
Starting,
|
|
||||||
Started,
|
|
||||||
Stopping,
|
|
||||||
Stopped
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly IDatabaseFile db;
|
|
||||||
private readonly SemaphoreSlim semaphore = new (1, 1);
|
|
||||||
|
|
||||||
internal ServerManager(IDatabaseFile db) {
|
|
||||||
this.db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Start(ushort port, string token) {
|
|
||||||
await semaphore.WaitAsync();
|
|
||||||
try {
|
|
||||||
await StartInternal(port, token);
|
|
||||||
} finally {
|
|
||||||
semaphore.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Stop() {
|
|
||||||
await semaphore.WaitAsync();
|
|
||||||
try {
|
|
||||||
await StopInternal();
|
|
||||||
} finally {
|
|
||||||
semaphore.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task StartInternal(ushort port, string token) {
|
|
||||||
await StopInternal();
|
|
||||||
|
|
||||||
StatusChanged?.Invoke(this, Status.Starting);
|
|
||||||
|
|
||||||
void AddServices(IServiceCollection services) {
|
|
||||||
services.AddSingleton(typeof(IDatabaseFile), db);
|
|
||||||
services.AddSingleton(typeof(ServerParameters), new ServerParameters(port, token));
|
|
||||||
}
|
|
||||||
|
|
||||||
void SetKestrelOptions(KestrelServerOptions options) {
|
|
||||||
options.Limits.MaxRequestBodySize = null;
|
|
||||||
options.Limits.MinResponseDataRate = null;
|
|
||||||
options.ListenLocalhost(port, static listenOptions => listenOptions.Protocols = HttpProtocols.Http1);
|
|
||||||
}
|
|
||||||
|
|
||||||
var newServer = new WebHostBuilder()
|
|
||||||
.ConfigureServices(AddServices)
|
|
||||||
.UseKestrel(SetKestrelOptions)
|
|
||||||
.UseStartup<Startup>()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
Log.Info("Starting server on port " + port + "...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await newServer.StartAsync();
|
|
||||||
} catch (Exception) {
|
|
||||||
Log.Error("Server could not start");
|
|
||||||
StatusChanged?.Invoke(this, Status.Stopped);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Info("Server started");
|
|
||||||
|
|
||||||
server = newServer;
|
|
||||||
|
|
||||||
StatusChanged?.Invoke(this, Status.Started);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task StopInternal() {
|
|
||||||
if (server == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusChanged?.Invoke(this, Status.Stopping);
|
|
||||||
|
|
||||||
Log.Info("Stopping server...");
|
|
||||||
await server.StopAsync();
|
|
||||||
Log.Info("Server stopped");
|
|
||||||
|
|
||||||
server.Dispose();
|
|
||||||
server = null;
|
|
||||||
|
|
||||||
StatusChanged?.Invoke(this, Status.Stopped);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -16,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) {
|
||||||
@@ -41,6 +42,7 @@ sealed class Startup {
|
|||||||
|
|
||||||
app.UseEndpoints(endpoints => {
|
app.UseEndpoints(endpoints => {
|
||||||
endpoints.MapGet("/get-tracking-script", new GetTrackingScriptEndpoint(db, parameters).Handle);
|
endpoints.MapGet("/get-tracking-script", new GetTrackingScriptEndpoint(db, parameters).Handle);
|
||||||
|
endpoints.MapGet("/get-messages", new GetMessagesEndpoint(db).Handle);
|
||||||
endpoints.MapGet("/get-attachment/{url}", new GetAttachmentEndpoint(db).Handle);
|
endpoints.MapGet("/get-attachment/{url}", new GetAttachmentEndpoint(db).Handle);
|
||||||
endpoints.MapPost("/track-channel", new TrackChannelEndpoint(db).Handle);
|
endpoints.MapPost("/track-channel", new TrackChannelEndpoint(db).Handle);
|
||||||
endpoints.MapPost("/track-users", new TrackUsersEndpoint(db).Handle);
|
endpoints.MapPost("/track-users", new TrackUsersEndpoint(db).Handle);
|
||||||
|
@@ -1,27 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Server.Download;
|
|
||||||
using DHT.Server.Service;
|
|
||||||
|
|
||||||
namespace DHT.Server;
|
|
||||||
|
|
||||||
public sealed class State : IAsyncDisposable {
|
|
||||||
public static State Dummy { get; } = new (DummyDatabaseFile.Instance);
|
|
||||||
|
|
||||||
public IDatabaseFile Db { get; }
|
|
||||||
public Downloader Downloader { get; }
|
|
||||||
public ServerManager Server { get; }
|
|
||||||
|
|
||||||
public State(IDatabaseFile db) {
|
|
||||||
Db = db;
|
|
||||||
Downloader = new Downloader(db);
|
|
||||||
Server = new ServerManager(db);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() {
|
|
||||||
await Downloader.Stop();
|
|
||||||
await Server.Stop();
|
|
||||||
Db.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,4 +1,5 @@
|
|||||||
using System.Text;
|
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;
|
||||||
|
|
||||||
@@ -25,6 +26,20 @@ public static class HttpOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class File : IHttpOutput {
|
public sealed class File : IHttpOutput {
|
||||||
private readonly string? contentType;
|
private readonly string? contentType;
|
||||||
private readonly byte[] bytes;
|
private readonly byte[] bytes;
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Runtime.Versioning;
|
using System.Runtime.Versioning;
|
||||||
|
|
||||||
namespace DHT.Utils.Logging;
|
namespace DHT.Utils.Logging;
|
||||||
|
|
||||||
[SupportedOSPlatform("windows")]
|
[SupportedOSPlatform("windows")]
|
||||||
public static partial class WindowsConsole {
|
public static partial class WindowsConsole {
|
||||||
|
@@ -15,7 +15,7 @@ public sealed class AsyncValueComputer<TValue> {
|
|||||||
private SoftHardCancellationToken? currentCancellationTokenSource;
|
private SoftHardCancellationToken? currentCancellationTokenSource;
|
||||||
private bool wasHardCancelled = false;
|
private bool wasHardCancelled = false;
|
||||||
|
|
||||||
private Func<Task<TValue>>? currentComputeFunction;
|
private Func<TValue>? currentComputeFunction;
|
||||||
private bool hasComputeFunctionChanged = false;
|
private bool hasComputeFunctionChanged = false;
|
||||||
|
|
||||||
private AsyncValueComputer(Action<TValue> resultProcessor, TaskScheduler resultTaskScheduler, bool processOutdatedResults) {
|
private AsyncValueComputer(Action<TValue> resultProcessor, TaskScheduler resultTaskScheduler, bool processOutdatedResults) {
|
||||||
@@ -31,7 +31,7 @@ public sealed class AsyncValueComputer<TValue> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Compute(Func<Task<TValue>> func) {
|
public void Compute(Func<TValue> func) {
|
||||||
lock (stateLock) {
|
lock (stateLock) {
|
||||||
wasHardCancelled = false;
|
wasHardCancelled = false;
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ public sealed class AsyncValueComputer<TValue> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MethodSupportsCancellation")]
|
[SuppressMessage("ReSharper", "MethodSupportsCancellation")]
|
||||||
private void EnqueueComputation(Func<Task<TValue>> func) {
|
private void EnqueueComputation(Func<TValue> func) {
|
||||||
var cancellationTokenSource = new SoftHardCancellationToken();
|
var cancellationTokenSource = new SoftHardCancellationToken();
|
||||||
|
|
||||||
currentCancellationTokenSource?.RequestSoftCancellation();
|
currentCancellationTokenSource?.RequestSoftCancellation();
|
||||||
@@ -84,9 +84,9 @@ public sealed class AsyncValueComputer<TValue> {
|
|||||||
|
|
||||||
public sealed class Single {
|
public sealed class Single {
|
||||||
private readonly AsyncValueComputer<TValue> baseComputer;
|
private readonly AsyncValueComputer<TValue> baseComputer;
|
||||||
private readonly Func<Task<TValue>> resultComputer;
|
private readonly Func<TValue> resultComputer;
|
||||||
|
|
||||||
internal Single(AsyncValueComputer<TValue> baseComputer, Func<Task<TValue>> resultComputer) {
|
internal Single(AsyncValueComputer<TValue> baseComputer, Func<TValue> resultComputer) {
|
||||||
this.baseComputer = baseComputer;
|
this.baseComputer = baseComputer;
|
||||||
this.resultComputer = resultComputer;
|
this.resultComputer = resultComputer;
|
||||||
}
|
}
|
||||||
@@ -94,10 +94,6 @@ public sealed class AsyncValueComputer<TValue> {
|
|||||||
public void Recompute() {
|
public void Recompute() {
|
||||||
baseComputer.Compute(resultComputer);
|
baseComputer.Compute(resultComputer);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Cancel() {
|
|
||||||
baseComputer.Cancel();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Builder WithResultProcessor(Action<TValue> resultProcessor, TaskScheduler? scheduler = null) {
|
public static Builder WithResultProcessor(Action<TValue> resultProcessor, TaskScheduler? scheduler = null) {
|
||||||
@@ -123,7 +119,7 @@ public sealed class AsyncValueComputer<TValue> {
|
|||||||
return new AsyncValueComputer<TValue>(resultProcessor, resultTaskScheduler, processOutdatedResults);
|
return new AsyncValueComputer<TValue>(resultProcessor, resultTaskScheduler, processOutdatedResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Single BuildWithComputer(Func<Task<TValue>> resultComputer) {
|
public Single BuildWithComputer(Func<TValue> resultComputer) {
|
||||||
return new Single(Build(), resultComputer);
|
return new Single(Build(), resultComputer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,45 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace DHT.Utils.Tasks;
|
|
||||||
|
|
||||||
public sealed class RestartableTask<T>(Action<T> resultProcessor, TaskScheduler resultScheduler) {
|
|
||||||
private readonly object monitor = new ();
|
|
||||||
|
|
||||||
private CancellationTokenSource? cancellationTokenSource;
|
|
||||||
|
|
||||||
public void Restart(Func<CancellationToken, Task<T>> resultComputer) {
|
|
||||||
lock (monitor) {
|
|
||||||
Cancel();
|
|
||||||
|
|
||||||
cancellationTokenSource = new CancellationTokenSource();
|
|
||||||
|
|
||||||
var taskCancellationTokenSource = cancellationTokenSource;
|
|
||||||
var taskCancellationToken = taskCancellationTokenSource.Token;
|
|
||||||
|
|
||||||
Task.Run(() => resultComputer(taskCancellationToken), taskCancellationToken)
|
|
||||||
.ContinueWith(task => resultProcessor(task.Result), taskCancellationToken, TaskContinuationOptions.OnlyOnRanToCompletion, resultScheduler)
|
|
||||||
.ContinueWith(_ => OnTaskFinished(taskCancellationTokenSource), CancellationToken.None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Cancel() {
|
|
||||||
lock (monitor) {
|
|
||||||
if (cancellationTokenSource != null) {
|
|
||||||
cancellationTokenSource.Cancel();
|
|
||||||
cancellationTokenSource = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnTaskFinished(CancellationTokenSource taskCancellationTokenSource) {
|
|
||||||
lock (monitor) {
|
|
||||||
taskCancellationTokenSource.Dispose();
|
|
||||||
|
|
||||||
if (cancellationTokenSource == taskCancellationTokenSource) {
|
|
||||||
cancellationTokenSource = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,12 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace DHT.Utils.Tasks;
|
|
||||||
|
|
||||||
public static class TaskExtensions {
|
|
||||||
public static async Task WaitIgnoringCancellation(this Task task) {
|
|
||||||
try {
|
|
||||||
await task;
|
|
||||||
} catch (OperationCanceledException) {}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,84 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Channels;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace DHT.Utils.Tasks;
|
|
||||||
|
|
||||||
public abstract class ThrottledTaskBase<T> : IDisposable {
|
|
||||||
private readonly Channel<Func<CancellationToken, T>> taskChannel = Channel.CreateBounded<Func<CancellationToken, T>>(new BoundedChannelOptions(capacity: 1) {
|
|
||||||
SingleReader = true,
|
|
||||||
SingleWriter = false,
|
|
||||||
AllowSynchronousContinuations = false,
|
|
||||||
FullMode = BoundedChannelFullMode.DropOldest
|
|
||||||
});
|
|
||||||
|
|
||||||
private readonly CancellationTokenSource cancellationTokenSource = new ();
|
|
||||||
|
|
||||||
internal ThrottledTaskBase() {}
|
|
||||||
|
|
||||||
protected async Task ReaderTask() {
|
|
||||||
var cancellationToken = cancellationTokenSource.Token;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await foreach (var item in taskChannel.Reader.ReadAllAsync(cancellationToken)) {
|
|
||||||
try {
|
|
||||||
await Run(item, cancellationToken);
|
|
||||||
} catch (OperationCanceledException) {
|
|
||||||
throw;
|
|
||||||
} catch (Exception) {
|
|
||||||
// Ignore.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (OperationCanceledException) {
|
|
||||||
// Ignore.
|
|
||||||
} finally {
|
|
||||||
cancellationTokenSource.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract Task Run(Func<CancellationToken, T> func, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
public void Post(Func<CancellationToken, T> resultComputer) {
|
|
||||||
taskChannel.Writer.TryWrite(resultComputer);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
taskChannel.Writer.Complete();
|
|
||||||
cancellationTokenSource.Cancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ThrottledTask : ThrottledTaskBase<Task> {
|
|
||||||
private readonly Action resultProcessor;
|
|
||||||
private readonly TaskScheduler resultScheduler;
|
|
||||||
|
|
||||||
public ThrottledTask(Action resultProcessor, TaskScheduler resultScheduler) {
|
|
||||||
this.resultProcessor = resultProcessor;
|
|
||||||
this.resultScheduler = resultScheduler;
|
|
||||||
|
|
||||||
Task.Run(ReaderTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task Run(Func<CancellationToken, Task> func, CancellationToken cancellationToken) {
|
|
||||||
await func(cancellationToken);
|
|
||||||
await Task.Factory.StartNew(resultProcessor, cancellationToken, TaskCreationOptions.None, resultScheduler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ThrottledTask<T> : ThrottledTaskBase<Task<T>> {
|
|
||||||
private readonly Action<T> resultProcessor;
|
|
||||||
private readonly TaskScheduler resultScheduler;
|
|
||||||
|
|
||||||
public ThrottledTask(Action<T> resultProcessor, TaskScheduler resultScheduler) {
|
|
||||||
this.resultProcessor = resultProcessor;
|
|
||||||
this.resultScheduler = resultScheduler;
|
|
||||||
|
|
||||||
Task.Run(ReaderTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task Run(Func<CancellationToken, Task<T>> func, CancellationToken cancellationToken) {
|
|
||||||
T result = await func(cancellationToken);
|
|
||||||
await Task.Factory.StartNew(() => resultProcessor(result), cancellationToken, TaskCreationOptions.None, resultScheduler);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -5,7 +5,7 @@ using DHT.Utils;
|
|||||||
[assembly: AssemblyFileVersion(Version.Tag)]
|
[assembly: AssemblyFileVersion(Version.Tag)]
|
||||||
[assembly: AssemblyInformationalVersion(Version.Tag)]
|
[assembly: AssemblyInformationalVersion(Version.Tag)]
|
||||||
|
|
||||||
namespace DHT.Utils;
|
namespace DHT.Utils;
|
||||||
|
|
||||||
static class Version {
|
static class Version {
|
||||||
public const string Tag = "39.1.0.0";
|
public const string Tag = "39.1.0.0";
|
||||||
|
Reference in New Issue
Block a user