1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2025-09-15 19:32:09 +02:00

10 Commits

68 changed files with 652 additions and 899 deletions

View File

@@ -1,29 +1,30 @@
using System; using System;
using System.Collections.Generic;
using DHT.Utils.Logging; using DHT.Utils.Logging;
namespace DHT.Desktop; namespace DHT.Desktop;
sealed class Arguments { sealed class Arguments {
private static readonly Log Log = Log.ForType<Arguments>(); private static readonly Log Log = Log.ForType<Arguments>();
private const int FirstArgument = 1; private const int FirstArgument = 1;
public static Arguments Empty => new(Array.Empty<string>()); public static Arguments Empty => new (Array.Empty<string>());
public bool Console { get; } public bool Console { get; }
public string? DatabaseFile { get; } public string? DatabaseFile { get; }
public ushort? ServerPort { get; } public ushort? ServerPort { get; }
public string? ServerToken { get; } public string? ServerToken { get; }
public Arguments(string[] args) { public Arguments(IReadOnlyList<string> args) {
for (int i = FirstArgument; i < args.Length; i++) { for (int i = FirstArgument; i < args.Count; i++) {
string key = args[i]; string key = args[i];
switch (key) { switch (key) {
case "-debug": case "-debug":
Log.IsDebugEnabled = true; Log.IsDebugEnabled = true;
continue; continue;
case "-console": case "-console":
Console = true; Console = true;
continue; continue;
@@ -35,7 +36,7 @@ sealed class Arguments {
value = key; value = key;
key = "-db"; key = "-db";
} }
else if (i >= args.Length - 1) { else if (i >= args.Count - 1) {
Log.Warn("Missing value for command line argument: " + key); Log.Warn("Missing value for command line argument: " + key);
continue; continue;
} }

View File

@@ -19,13 +19,13 @@ sealed class BytesValueConverter : IValueConverter {
} }
} }
private static readonly Unit[] Units = { private static readonly Unit[] Units = [
new ("B", decimalPlaces: 0), new Unit("B", decimalPlaces: 0),
new ("kB", decimalPlaces: 0), new Unit("kB", decimalPlaces: 0),
new ("MB", decimalPlaces: 1), new Unit("MB", decimalPlaces: 1),
new ("GB", decimalPlaces: 1), new Unit("GB", decimalPlaces: 1),
new ("TB", decimalPlaces: 1) new Unit("TB", decimalPlaces: 1)
}; ];
private const int Scale = 1000; private const int Scale = 1000;

View File

@@ -1,11 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Threading;
using DHT.Desktop.Dialogs.File; using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Server.Database; using DHT.Server.Database;
@@ -45,15 +43,10 @@ static class DatabaseGui {
} }
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) { public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
var prevSynchronizationContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
var taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(prevSynchronizationContext);
IDatabaseFile? file = null; IDatabaseFile? file = null;
try { try {
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks, taskScheduler); file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks);
} catch (InvalidDatabaseVersionException ex) { } catch (InvalidDatabaseVersionException ex) {
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ")."); await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
} catch (DatabaseTooNewException ex) { } catch (DatabaseTooNewException ex) {

View File

@@ -23,6 +23,7 @@
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" /> <PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.6" /> <PackageReference Include="Avalonia.ReactiveUI" Version="11.0.6" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -38,7 +38,7 @@
<ItemsRepeater ItemsSource="{Binding Items}"> <ItemsRepeater ItemsSource="{Binding Items}">
<ItemsRepeater.ItemTemplate> <ItemsRepeater.ItemTemplate>
<DataTemplate> <DataTemplate>
<CheckBox IsChecked="{Binding Checked}"> <CheckBox IsChecked="{Binding IsChecked}">
<Label> <Label>
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" /> <TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
</Label> </Label>

View File

@@ -2,11 +2,11 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using DHT.Utils.Models; using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.CheckBox; namespace DHT.Desktop.Dialogs.CheckBox;
class CheckBoxDialogModel : BaseModel { class CheckBoxDialogModel : ObservableObject {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>(); private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
@@ -29,8 +29,8 @@ class CheckBoxDialogModel : BaseModel {
private bool pauseCheckEvents = false; private bool pauseCheckEvents = false;
public bool AreAllSelected => Items.All(static item => item.Checked); public bool AreAllSelected => Items.All(static item => item.IsChecked);
public bool AreNoneSelected => Items.All(static item => !item.Checked); public bool AreNoneSelected => Items.All(static item => !item.IsChecked);
public void SelectAll() => SetAllChecked(true); public void SelectAll() => SetAllChecked(true);
public void SelectNone() => SetAllChecked(false); public void SelectNone() => SetAllChecked(false);
@@ -39,7 +39,7 @@ class CheckBoxDialogModel : BaseModel {
pauseCheckEvents = true; pauseCheckEvents = true;
foreach (var item in Items) { foreach (var item in Items) {
item.Checked = isChecked; item.IsChecked = isChecked;
} }
pauseCheckEvents = false; pauseCheckEvents = false;
@@ -52,16 +52,16 @@ class CheckBoxDialogModel : BaseModel {
} }
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) { private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.Checked)) { if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) {
UpdateBulkButtons(); UpdateBulkButtons();
} }
} }
} }
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel { sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
public new IReadOnlyList<CheckBoxItem<T>> Items { get; } private new IReadOnlyList<CheckBoxItem<T>> Items { get; }
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked); public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.IsChecked);
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) { public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
this.Items = new List<CheckBoxItem<T>>(items); this.Items = new List<CheckBoxItem<T>>(items);

View File

@@ -1,17 +1,13 @@
using DHT.Utils.Models; using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.CheckBox; namespace DHT.Desktop.Dialogs.CheckBox;
class CheckBoxItem : BaseModel { partial class CheckBoxItem : ObservableObject {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
public object? Item { get; init; } = null; public object? Item { get; init; } = null;
[ObservableProperty]
private bool isChecked = false; private bool isChecked = false;
public bool Checked {
get => isChecked;
set => Change(ref isChecked, value);
}
} }
sealed class CheckBoxItem<T> : CheckBoxItem { sealed class CheckBoxItem<T> : CheckBoxItem {

View File

@@ -4,11 +4,10 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Threading; using Avalonia.Threading;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.Progress; namespace DHT.Desktop.Dialogs.Progress;
sealed class ProgressDialogModel : BaseModel { sealed class ProgressDialogModel {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>(); public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();

View File

@@ -1,18 +1,12 @@
using DHT.Utils.Models; using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.Progress; namespace DHT.Desktop.Dialogs.Progress;
sealed class ProgressItem : BaseModel { sealed partial class ProgressItem : ObservableObject {
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(Opacity))]
private bool isVisible = false; private bool isVisible = false;
public bool IsVisible {
get => isVisible;
private set {
Change(ref isVisible, value);
OnPropertyChanged(nameof(Opacity));
}
}
public double Opacity => IsVisible ? 1.0 : 0.0; public double Opacity => IsVisible ? 1.0 : 0.0;
private string message = ""; private string message = "";
@@ -20,29 +14,17 @@ sealed class ProgressItem : BaseModel {
public string Message { public string Message {
get => message; get => message;
set { set {
Change(ref message, value); SetProperty(ref message, value);
IsVisible = !string.IsNullOrEmpty(value); IsVisible = !string.IsNullOrEmpty(value);
} }
} }
[ObservableProperty]
private string items = ""; private string items = "";
public string Items { [ObservableProperty]
get => items;
set => Change(ref items, value);
}
private int progress = 0; private int progress = 0;
public int Progress { [ObservableProperty]
get => progress;
set => Change(ref progress, value);
}
private bool isIndeterminate; private bool isIndeterminate;
public bool IsIndeterminate {
get => isIndeterminate;
set => Change(ref isIndeterminate, value);
}
} }

View File

@@ -2,11 +2,11 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using DHT.Utils.Models; using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.TextBox; namespace DHT.Desktop.Dialogs.TextBox;
class TextBoxDialogModel : BaseModel { class TextBoxDialogModel : ObservableObject {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
public string Description { get; init; } = ""; public string Description { get; init; } = "";
@@ -36,7 +36,7 @@ class TextBoxDialogModel : BaseModel {
} }
sealed class TextBoxDialogModel<T> : TextBoxDialogModel { sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
public new IReadOnlyList<TextBoxItem<T>> Items { get; } private new IReadOnlyList<TextBoxItem<T>> Items { get; }
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid); public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);

View File

@@ -1,11 +1,11 @@
using System; using System;
using System.Collections; using System.Collections;
using System.ComponentModel; using System.ComponentModel;
using DHT.Utils.Models; using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.TextBox; namespace DHT.Desktop.Dialogs.TextBox;
class TextBoxItem : BaseModel, INotifyDataErrorInfo { class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
public object? Item { get; init; } = null; public object? Item { get; init; } = null;
@@ -17,7 +17,7 @@ class TextBoxItem : BaseModel, INotifyDataErrorInfo {
public string Value { public string Value {
get => this.value; get => this.value;
set { set {
Change(ref this.value, value); SetProperty(ref this.value, value);
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value))); ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
} }
} }

View File

@@ -39,7 +39,7 @@ static class DiscordAppSettings {
public static async Task<bool?> AreDevToolsEnabled() { public static async Task<bool?> AreDevToolsEnabled() {
try { try {
var settingsJson = await ReadSettingsJson().ConfigureAwait(false); var settingsJson = await ReadSettingsJson();
return AreDevToolsEnabled(settingsJson); return AreDevToolsEnabled(settingsJson);
} catch (Exception e) { } catch (Exception e) {
Log.Error("Cannot read settings file."); Log.Error("Cannot read settings file.");

View File

@@ -5,4 +5,4 @@ namespace DHT.Desktop.Discord;
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)] [JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
[JsonSerializable(typeof(JsonObject))] [JsonSerializable(typeof(JsonObject))]
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext {} sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext;

View File

@@ -1,17 +1,18 @@
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.Tasks; using System.Threading.Tasks;
using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Server; using DHT.Server;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Utils.Models;
using DHT.Utils.Tasks; using DHT.Utils.Tasks;
namespace DHT.Desktop.Main.Controls; namespace DHT.Desktop.Main.Controls;
sealed class AttachmentFilterPanelModel : BaseModel, IDisposable { sealed partial class AttachmentFilterPanelModel : ObservableObject, 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 = [
@@ -28,25 +29,15 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
public string FilterStatisticsText { get; private set; } = ""; public string FilterStatisticsText { get; private set; } = "";
[ObservableProperty]
private bool limitSize = false; private bool limitSize = false;
[ObservableProperty]
private ulong maximumSize = 0L; private ulong maximumSize = 0L;
[ObservableProperty]
private Unit maximumSizeUnit = AllUnits[0]; private Unit maximumSizeUnit = AllUnits[0];
public bool LimitSize {
get => limitSize;
set => Change(ref limitSize, value);
}
public ulong MaximumSize {
get => maximumSize;
set => Change(ref maximumSize, value);
}
public Unit MaximumSizeUnit {
get => maximumSizeUnit;
set => Change(ref maximumSizeUnit, value);
}
public IEnumerable<Unit> Units => AllUnits; public IEnumerable<Unit> Units => AllUnits;
private readonly State state; private readonly State state;
@@ -54,6 +45,8 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
private readonly RestartableTask<long> matchingAttachmentCountTask; private readonly RestartableTask<long> matchingAttachmentCountTask;
private long? matchingAttachmentCount; private long? matchingAttachmentCount;
private readonly IDisposable attachmentCountSubscription;
private long? totalAttachmentCount; private long? totalAttachmentCount;
[Obsolete("Designer")] [Obsolete("Designer")]
@@ -64,15 +57,15 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
this.verb = verb; this.verb = verb;
this.matchingAttachmentCountTask = new RestartableTask<long>(SetAttachmentCounts, TaskScheduler.FromCurrentSynchronizationContext()); this.matchingAttachmentCountTask = new RestartableTask<long>(SetAttachmentCounts, TaskScheduler.FromCurrentSynchronizationContext());
this.attachmentCountSubscription = state.Db.Attachments.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnAttachmentCountChanged);
UpdateFilterStatistics(); UpdateFilterStatistics();
PropertyChanged += OnPropertyChanged; PropertyChanged += OnPropertyChanged;
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
} }
public void Dispose() { public void Dispose() {
state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged; attachmentCountSubscription.Dispose();
} }
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
@@ -80,13 +73,12 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
UpdateFilterStatistics(); UpdateFilterStatistics();
} }
} }
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) { private void OnAttachmentCountChanged(long newAttachmentCount) {
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) { totalAttachmentCount = newAttachmentCount;
totalAttachmentCount = state.Db.Statistics.TotalAttachments; UpdateFilterStatistics();
UpdateFilterStatistics();
}
} }
private void UpdateFilterStatistics() { private void UpdateFilterStatistics() {
var filter = CreateFilter(); var filter = CreateFilter();
@@ -98,7 +90,7 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
else { else {
matchingAttachmentCount = null; matchingAttachmentCount = null;
UpdateFilterStatisticsText(); UpdateFilterStatisticsText();
matchingAttachmentCountTask.Restart(cancellationToken => state.Db.Downloads.CountAttachments(filter, cancellationToken)); matchingAttachmentCountTask.Restart(cancellationToken => state.Db.Attachments.Count(filter, cancellationToken));
} }
} }

View File

@@ -2,9 +2,12 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using System.Reactive.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.CheckBox; using DHT.Desktop.Dialogs.CheckBox;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
@@ -12,13 +15,11 @@ using DHT.Desktop.Dialogs.Progress;
using DHT.Server; using DHT.Server;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Utils.Models;
using DHT.Utils.Tasks; using DHT.Utils.Tasks;
namespace DHT.Desktop.Main.Controls; namespace DHT.Desktop.Main.Controls;
sealed class MessageFilterPanelModel : BaseModel, IDisposable { sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
private static readonly HashSet<string> FilterProperties = [ private static readonly HashSet<string> FilterProperties = [
nameof(FilterByDate), nameof(FilterByDate),
nameof(StartDate), nameof(StartDate),
@@ -35,70 +36,48 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser; public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
[ObservableProperty]
private bool filterByDate = false; private bool filterByDate = false;
[ObservableProperty]
private DateTime? startDate = null; private DateTime? startDate = null;
[ObservableProperty]
private DateTime? endDate = null; private DateTime? endDate = null;
[ObservableProperty]
private bool filterByChannel = false; private bool filterByChannel = false;
[ObservableProperty]
private HashSet<ulong>? includedChannels = null; private HashSet<ulong>? includedChannels = null;
[ObservableProperty]
private bool filterByUser = false; private bool filterByUser = false;
[ObservableProperty]
private HashSet<ulong>? includedUsers = null; private HashSet<ulong>? includedUsers = null;
public bool FilterByDate { [ObservableProperty]
get => filterByDate;
set => Change(ref filterByDate, value);
}
public DateTime? StartDate {
get => startDate;
set => Change(ref startDate, value);
}
public DateTime? EndDate {
get => endDate;
set => Change(ref endDate, value);
}
public bool FilterByChannel {
get => filterByChannel;
set => Change(ref filterByChannel, value);
}
public HashSet<ulong>? IncludedChannels {
get => includedChannels;
set => Change(ref includedChannels, value);
}
public bool FilterByUser {
get => filterByUser;
set => Change(ref filterByUser, value);
}
public HashSet<ulong>? IncludedUsers {
get => includedUsers;
set => Change(ref includedUsers, value);
}
private string channelFilterLabel = ""; private string channelFilterLabel = "";
public string ChannelFilterLabel { [ObservableProperty]
get => channelFilterLabel;
set => Change(ref channelFilterLabel, value);
}
private string userFilterLabel = ""; private string userFilterLabel = "";
public string UserFilterLabel {
get => userFilterLabel;
set => Change(ref userFilterLabel, value);
}
private readonly Window window; private readonly Window window;
private readonly State state; private readonly State state;
private readonly string verb; private readonly string verb;
private readonly RestartableTask<long> exportedMessageCountTask; private readonly RestartableTask<long> exportedMessageCountTask;
private long? exportedMessageCount; private long? exportedMessageCount;
private readonly IDisposable messageCountSubscription;
private long? totalMessageCount; private long? totalMessageCount;
private readonly IDisposable channelCountSubscription;
private long? totalChannelCount;
private readonly IDisposable userCountSubscription;
private long? totalUserCount;
[Obsolete("Designer")] [Obsolete("Designer")]
public MessageFilterPanelModel() : this(null!, State.Dummy) {} public MessageFilterPanelModel() : this(null!, State.Dummy) {}
@@ -109,18 +88,24 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
this.verb = verb; this.verb = verb;
this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext()); this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext());
this.messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnMessageCountChanged);
this.channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnChannelCountChanged);
this.userCountSubscription = state.Db.Users.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnUserCountChanged);
UpdateFilterStatistics(); UpdateFilterStatistics();
UpdateChannelFilterLabel(); UpdateChannelFilterLabel();
UpdateUserFilterLabel(); UpdateUserFilterLabel();
PropertyChanged += OnPropertyChanged; PropertyChanged += OnPropertyChanged;
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
} }
public void Dispose() { public void Dispose() {
exportedMessageCountTask.Cancel(); exportedMessageCountTask.Cancel();
state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
messageCountSubscription.Dispose();
channelCountSubscription.Dispose();
userCountSubscription.Dispose();
} }
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
@@ -137,29 +122,41 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
} }
} }
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) { private void OnMessageCountChanged(long newMessageCount) {
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) { totalMessageCount = newMessageCount;
totalMessageCount = state.Db.Statistics.TotalMessages; UpdateFilterStatistics();
UpdateFilterStatistics(); }
}
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) { private void OnChannelCountChanged(long newChannelCount) {
UpdateChannelFilterLabel(); totalChannelCount = newChannelCount;
} UpdateChannelFilterLabel();
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) { }
UpdateUserFilterLabel();
} private void OnUserCountChanged(long newUserCount) {
totalUserCount = newUserCount;
UpdateUserFilterLabel();
} }
private void UpdateChannelFilterLabel() { private void UpdateChannelFilterLabel() {
long total = state.Db.Statistics.TotalChannels; if (totalChannelCount.HasValue) {
long included = FilterByChannel && IncludedChannels != null ? IncludedChannels.Count : total; long total = totalChannelCount.Value;
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + "."; long included = FilterByChannel && IncludedChannels != null ? IncludedChannels.Count : total;
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
}
else {
ChannelFilterLabel = "Loading...";
}
} }
private void UpdateUserFilterLabel() { private void UpdateUserFilterLabel() {
long total = state.Db.Statistics.TotalUsers; if (totalUserCount.HasValue) {
long included = FilterByUser && IncludedUsers != null ? IncludedUsers.Count : total; long total = totalUserCount.Value;
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + "."; long included = FilterByUser && IncludedUsers != null ? IncludedUsers.Count : total;
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
}
else {
UserFilterLabel = "Loading...";
}
} }
private void UpdateFilterStatistics() { private void UpdateFilterStatistics() {
@@ -224,13 +221,13 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
items.Add(new CheckBoxItem<ulong>(channelId) { items.Add(new CheckBoxItem<ulong>(channelId) {
Title = title, Title = title,
Checked = IncludedChannels == null || IncludedChannels.Contains(channelId) IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId)
}); });
} }
return items; return items;
} }
const string Title = "Included Channels"; const string Title = "Included Channels";
List<CheckBoxItem<ulong>> items; List<CheckBoxItem<ulong>> items;
@@ -257,7 +254,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) { checkBoxItems.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) IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
}); });
} }
@@ -265,7 +262,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
} }
const string Title = "Included Users"; const string Title = "Included Users";
List<CheckBoxItem<ulong>> items; List<CheckBoxItem<ulong>> items;
try { try {
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems); items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);

View File

@@ -2,47 +2,31 @@ using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading; using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Server; using DHT.Desktop.Server;
using DHT.Server; using DHT.Server;
using DHT.Server.Service; using DHT.Server.Service;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls; namespace DHT.Desktop.Main.Controls;
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable { sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposable {
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>(); private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
private string inputPort; private string inputPort;
public string InputPort { [ObservableProperty]
get => inputPort; [NotifyPropertyChangedFor(nameof(HasMadeChanges))]
set {
Change(ref inputPort, value);
OnPropertyChanged(nameof(HasMadeChanges));
}
}
private string inputToken; private string inputToken;
public string InputToken {
get => inputToken;
set {
Change(ref inputToken, value);
OnPropertyChanged(nameof(HasMadeChanges));
}
}
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken; public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
[ObservableProperty(Setter = Access.Private)]
private bool isToggleServerButtonEnabled = true; private bool isToggleServerButtonEnabled = true;
public bool IsToggleServerButtonEnabled {
get => isToggleServerButtonEnabled;
set => Change(ref isToggleServerButtonEnabled, value);
}
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server"; public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
private readonly Window window; private readonly Window window;

View File

@@ -45,17 +45,17 @@
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Servers</TextBlock> <TextBlock Classes="label">Servers</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" /> <TextBlock Classes="value" Text="{Binding ServerCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Channels</TextBlock> <TextBlock Classes="label">Channels</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" /> <TextBlock Classes="value" Text="{Binding ChannelCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Messages</TextBlock> <TextBlock Classes="label">Messages</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" /> <TextBlock Classes="value" Text="{Binding MessageCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>

View File

@@ -1,15 +1,25 @@
using System; using System;
using System.Reactive.Linq;
using Avalonia.ReactiveUI;
using Avalonia.Threading; using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Server; using DHT.Server;
using DHT.Server.Database;
using DHT.Server.Service; using DHT.Server.Service;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls; namespace DHT.Desktop.Main.Controls;
sealed class StatusBarModel : BaseModel, IDisposable { sealed partial class StatusBarModel : ObservableObject, IDisposable {
public DatabaseStatistics DatabaseStatistics { get; } [ObservableProperty(Setter = Access.Private)]
private long? serverCount;
[ObservableProperty(Setter = Access.Private)]
private long? channelCount;
[ObservableProperty(Setter = Access.Private)]
private long? messageCount;
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
private ServerManager.Status serverStatus; private ServerManager.Status serverStatus;
public string ServerStatusText => serverStatus switch { public string ServerStatusText => serverStatus switch {
@@ -21,26 +31,33 @@ sealed class StatusBarModel : BaseModel, IDisposable {
}; };
private readonly State state; private readonly State state;
private readonly IDisposable serverCountSubscription;
private readonly IDisposable channelCountSubscription;
private readonly IDisposable messageCountSubscription;
[Obsolete("Designer")] [Obsolete("Designer")]
public StatusBarModel() : this(State.Dummy) {} public StatusBarModel() : this(State.Dummy) {}
public StatusBarModel(State state) { public StatusBarModel(State state) {
this.state = state; this.state = state;
this.DatabaseStatistics = state.Db.Statistics;
serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount);
channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount);
messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount);
state.Server.StatusChanged += OnServerStatusChanged; state.Server.StatusChanged += OnServerStatusChanged;
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped; serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
} }
public void Dispose() { public void Dispose() {
serverCountSubscription.Dispose();
channelCountSubscription.Dispose();
messageCountSubscription.Dispose();
state.Server.StatusChanged -= OnServerStatusChanged; state.Server.StatusChanged -= OnServerStatusChanged;
} }
private void OnServerStatusChanged(object? sender, ServerManager.Status e) { private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
Dispatcher.UIThread.InvokeAsync(() => { Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e);
serverStatus = e;
OnPropertyChanged(nameof(ServerStatusText));
});
} }
} }

View File

@@ -8,7 +8,7 @@
x:DataType="main:MainWindowModel" x:DataType="main:MainWindowModel"
Title="{Binding Title}" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="800" Height="500" Width="820" Height="520"
MinWidth="520" MinHeight="300" MinWidth="520" MinHeight="300"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Closing="OnClosing"> Closing="OnClosing">

View File

@@ -3,24 +3,26 @@ using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Screens; using DHT.Desktop.Main.Screens;
using DHT.Desktop.Server; using DHT.Desktop.Server;
using DHT.Server; using DHT.Server;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using DHT.Utils.Models;
namespace DHT.Desktop.Main; namespace DHT.Desktop.Main;
sealed class MainWindowModel : BaseModel, IAsyncDisposable { sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
private const string DefaultTitle = "Discord History Tracker"; private const string DefaultTitle = "Discord History Tracker";
private static readonly Log Log = Log.ForType<MainWindowModel>(); private static readonly Log Log = Log.ForType<MainWindowModel>();
public string Title { get; private set; } = DefaultTitle; [ObservableProperty(Setter = Access.Private)]
private string title = DefaultTitle;
public UserControl CurrentScreen { get; private set; } [ObservableProperty(Setter = Access.Private)]
private UserControl currentScreen;
private readonly WelcomeScreen welcomeScreen; private readonly WelcomeScreen welcomeScreen;
private readonly WelcomeScreenModel welcomeScreenModel; private readonly WelcomeScreenModel welcomeScreenModel;
@@ -41,7 +43,7 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected; welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel }; welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
CurrentScreen = welcomeScreen; currentScreen = welcomeScreen;
var dbFile = args.DatabaseFile; var dbFile = args.DatabaseFile;
if (!string.IsNullOrWhiteSpace(dbFile)) { if (!string.IsNullOrWhiteSpace(dbFile)) {
@@ -93,9 +95,6 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle; Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel }; CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(CurrentScreen));
window.Focus(); window.Focus();
} }
@@ -112,9 +111,6 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
CurrentScreen = welcomeScreen; CurrentScreen = welcomeScreen;
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected; welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(CurrentScreen));
} }
private async Task DisposeState() { private async Task DisposeState() {

View File

@@ -5,11 +5,10 @@ using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress; using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Server; using DHT.Server;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
sealed class AdvancedPageModel : BaseModel, IDisposable { sealed class AdvancedPageModel : IDisposable {
public ServerConfigurationPanelModel ServerConfigurationModel { get; } public ServerConfigurationPanelModel ServerConfigurationModel { get; }
private readonly Window window; private readonly Window window;

View File

@@ -1,23 +1,22 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.Collections.ObjectModel;
using System.Reactive.Linq; using System.Reactive.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.ReactiveUI; using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Server; 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.Utils.Logging; using DHT.Utils.Logging;
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 partial class AttachmentsPageModel : ObservableObject, IDisposable {
private static readonly Log Log = Log.ForType<AttachmentsPageModel>(); private static readonly Log Log = Log.ForType<AttachmentsPageModel>();
private static readonly DownloadItemFilter EnqueuedItemFilter = new () { private static readonly DownloadItemFilter EnqueuedItemFilter = new () {
@@ -27,28 +26,24 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
} }
}; };
[ObservableProperty(Setter = Access.Private)]
private bool isToggleDownloadButtonEnabled = true; private bool isToggleDownloadButtonEnabled = true;
public bool IsToggleDownloadButtonEnabled {
get => isToggleDownloadButtonEnabled;
set => Change(ref isToggleDownloadButtonEnabled, value);
}
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading"; public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
private bool isRetryingFailedDownloads = false; private bool isRetryingFailedDownloads = false;
public bool IsRetryingFailedDownloads { [ObservableProperty(Setter = Access.Private)]
get => isRetryingFailedDownloads; [NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
set { private bool hasFailedDownloads;
isRetryingFailedDownloads = value;
OnPropertyChanged(nameof(IsRetryFailedOnDownloadsButtonEnabled));
}
}
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads; public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && hasFailedDownloads;
[ObservableProperty(Setter = Access.Private)]
private string downloadMessage = "";
public string DownloadMessage { get; set; } = "";
public double DownloadProgress => totalItemsToDownloadCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / totalItemsToDownloadCount.Value; public double DownloadProgress => totalItemsToDownloadCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / totalItemsToDownloadCount.Value;
public AttachmentFilterPanelModel FilterModel { get; } public AttachmentFilterPanelModel FilterModel { get; }
@@ -58,20 +53,17 @@ 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 ObservableCollection<StatisticsRow> StatisticsRows { get; }
statisticsEnqueued,
statisticsDownloaded,
statisticsFailed,
statisticsSkipped
];
public bool IsDownloading => state.Downloader.IsDownloading; public bool IsDownloading => state.Downloader.IsDownloading;
public bool HasFailedDownloads => statisticsFailed.Items > 0;
private readonly State state; private readonly State state;
private readonly ThrottledTask<int> enqueueDownloadItemsTask; private readonly ThrottledTask<int> enqueueDownloadItemsTask;
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask; private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
private readonly IDisposable attachmentCountSubscription;
private readonly IDisposable downloadCountSubscription;
private IDisposable? finishedItemsSubscription; private IDisposable? finishedItemsSubscription;
private int doneItemsCount; private int doneItemsCount;
private int totalEnqueuedItemCount; private int totalEnqueuedItemCount;
@@ -83,36 +75,47 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
this.state = state; this.state = state;
FilterModel = new AttachmentFilterPanelModel(state); FilterModel = new AttachmentFilterPanelModel(state);
StatisticsRows = [
statisticsEnqueued,
statisticsDownloaded,
statisticsFailed,
statisticsSkipped
];
enqueueDownloadItemsTask = new ThrottledTask<int>(OnItemsEnqueued, TaskScheduler.FromCurrentSynchronizationContext()); enqueueDownloadItemsTask = new ThrottledTask<int>(OnItemsEnqueued, TaskScheduler.FromCurrentSynchronizationContext());
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext()); downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
RecomputeDownloadStatistics();
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged; attachmentCountSubscription = state.Db.Attachments.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnAttachmentCountChanged);
downloadCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged);
RecomputeDownloadStatistics();
} }
public void Dispose() { public void Dispose() {
state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged; attachmentCountSubscription.Dispose();
downloadCountSubscription.Dispose();
finishedItemsSubscription?.Dispose();
enqueueDownloadItemsTask.Dispose(); enqueueDownloadItemsTask.Dispose();
downloadStatisticsTask.Dispose(); downloadStatisticsTask.Dispose();
finishedItemsSubscription?.Dispose();
FilterModel.Dispose(); FilterModel.Dispose();
} }
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) { private void OnAttachmentCountChanged(long newAttachmentCount) {
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) { if (IsDownloading) {
if (IsDownloading) { EnqueueDownloadItemsLater();
EnqueueDownloadItemsLater();
}
else {
RecomputeDownloadStatistics();
}
} }
else if (e.PropertyName == nameof(DatabaseStatistics.TotalDownloads)) { else {
RecomputeDownloadStatistics(); RecomputeDownloadStatistics();
} }
} }
private void OnDownloadCountChanged(long newDownloadCount) {
RecomputeDownloadStatistics();
}
private async Task EnqueueDownloadItems() { private async Task EnqueueDownloadItems() {
OnItemsEnqueued(await state.Db.Downloads.EnqueueDownloadItems(CreateAttachmentFilter())); OnItemsEnqueued(await state.Db.Downloads.EnqueueDownloadItems(CreateAttachmentFilter()));
} }
@@ -174,7 +177,6 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
private void OnItemsFinished(int finishedItemCount) { private void OnItemsFinished(int finishedItemCount) {
doneItemsCount += finishedItemCount; doneItemsCount += finishedItemCount;
UpdateDownloadMessage(); UpdateDownloadMessage();
RecomputeDownloadStatistics();
} }
public async Task OnClickRetryFailedDownloads() { public async Task OnClickRetryFailedDownloads() {
@@ -206,8 +208,6 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
} }
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) { private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
var hadFailedDownloads = HasFailedDownloads;
statisticsEnqueued.Items = statusStatistics.EnqueuedCount; statisticsEnqueued.Items = statusStatistics.EnqueuedCount;
statisticsEnqueued.Size = statusStatistics.EnqueuedSize; statisticsEnqueued.Size = statusStatistics.EnqueuedSize;
@@ -220,12 +220,7 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
statisticsSkipped.Items = statusStatistics.SkippedCount; statisticsSkipped.Items = statusStatistics.SkippedCount;
statisticsSkipped.Size = statusStatistics.SkippedSize; statisticsSkipped.Size = statusStatistics.SkippedSize;
OnPropertyChanged(nameof(StatisticsRows)); hasFailedDownloads = statusStatistics.FailedCount > 0;
if (hadFailedDownloads != HasFailedDownloads) {
OnPropertyChanged(nameof(HasFailedDownloads));
OnPropertyChanged(nameof(IsRetryFailedOnDownloadsButtonEnabled));
}
UpdateDownloadMessage(); UpdateDownloadMessage();
} }
@@ -233,17 +228,17 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
private void UpdateDownloadMessage() { private void UpdateDownloadMessage() {
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (totalItemsToDownloadCount?.Format() ?? "?") : ""; DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (totalItemsToDownloadCount?.Format() ?? "?") : "";
OnPropertyChanged(nameof(DownloadMessage));
OnPropertyChanged(nameof(DownloadProgress)); OnPropertyChanged(nameof(DownloadProgress));
} }
public sealed class StatisticsRow { [ObservableObject]
public string State { get; } public sealed partial class StatisticsRow(string state) {
public int Items { get; set; } public string State { get; } = state;
public ulong? Size { get; set; }
public StatisticsRow(string state) { [ObservableProperty]
State = state; private int items;
}
[ObservableProperty]
private ulong? size;
} }
} }

View File

@@ -20,11 +20,10 @@ 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.Utils;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
sealed class DatabasePageModel : BaseModel { sealed class DatabasePageModel {
private static readonly Log Log = Log.ForType<DatabasePageModel>(); private static readonly Log Log = Log.ForType<DatabasePageModel>();
public IDatabaseFile Db { get; } public IDatabaseFile Db { get; }
@@ -81,7 +80,7 @@ sealed class DatabasePageModel : BaseModel {
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) { private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length); var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => { await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks); IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
@@ -93,7 +92,7 @@ sealed class DatabasePageModel : BaseModel {
await target.AddFrom(db); await target.AddFrom(db);
return true; return true;
} finally { } finally {
db.Dispose(); await db.DisposeAsync();
} }
}); });
} }
@@ -102,7 +101,7 @@ sealed class DatabasePageModel : BaseModel {
private readonly ProgressDialog dialog; private readonly ProgressDialog dialog;
private readonly int total; private readonly int total;
private bool? decision; private bool? decision;
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) { public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
this.total = total; this.total = total;
this.dialog = dialog; this.dialog = dialog;
@@ -150,7 +149,7 @@ sealed class DatabasePageModel : BaseModel {
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => { await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => { return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
SynchronizationContext? prevSyncContext = SynchronizationContext.Current; SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext()); SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
@@ -165,7 +164,7 @@ sealed class DatabasePageModel : BaseModel {
static bool IsValidSnowflake(string value) { static bool IsValidSnowflake(string value) {
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _); return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
} }
var items = new List<TextBoxItem<DHT.Server.Data.Server>>(); var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) { foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
@@ -194,7 +193,7 @@ sealed class DatabasePageModel : BaseModel {
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) { private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
int total = paths.Length; int total = paths.Length;
var oldStatistics = await target.SnapshotStatistics(); var oldStatistics = await DatabaseStatistics.Take(target);
int successful = 0; int successful = 0;
int finished = 0; int finished = 0;
@@ -225,15 +224,26 @@ sealed class DatabasePageModel : BaseModel {
return; return;
} }
var newStatistics = await target.SnapshotStatistics(); var newStatistics = await DatabaseStatistics.Take(target);
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName)); await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
} }
private static string GetImportDialogMessage(DatabaseStatisticsSnapshot oldStatistics, DatabaseStatisticsSnapshot newStatistics, int successfulItems, int totalItems, string itemName) { private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers; public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels; return new DatabaseStatistics(
long newUsers = newStatistics.TotalUsers - oldStatistics.TotalUsers; await db.Servers.Count(),
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages; await db.Channels.Count(),
await db.Users.Count(),
await db.Messages.Count()
);
}
}
private static string GetImportDialogMessage(DatabaseStatistics oldStatistics, DatabaseStatistics newStatistics, int successfulItems, int totalItems, string itemName) {
long newServers = newStatistics.ServerCount - oldStatistics.ServerCount;
long newChannels = newStatistics.ChannelCount - oldStatistics.ChannelCount;
long newUsers = newStatistics.UserCount - oldStatistics.UserCount;
long newMessages = newStatistics.MessageCount - oldStatistics.MessageCount;
StringBuilder message = new StringBuilder(); StringBuilder message = new StringBuilder();
message.Append("Processed "); message.Append("Processed ");

View File

@@ -9,10 +9,9 @@ using DHT.Desktop.Dialogs.Progress;
using DHT.Server; using DHT.Server;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Service; using DHT.Server.Service;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages { namespace DHT.Desktop.Main.Pages {
sealed class DebugPageModel : BaseModel { sealed class DebugPageModel {
public string GenerateChannels { get; set; } = "0"; public string GenerateChannels { get; set; } = "0";
public string GenerateUsers { get; set; } = "0"; public string GenerateUsers { get; set; } = "0";
public string GenerateMessages { get; set; } = "0"; public string GenerateMessages { get; set; } = "0";
@@ -129,7 +128,7 @@ namespace DHT.Desktop.Main.Pages {
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())]; return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
} }
private static readonly string[] RandomWords = { private static readonly string[] RandomWords = [
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado", "apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli", "banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
"cabbage", "cacao", "cantaloupe", "caper", "carambola", "carrot", "cauliflower", "celery", "chard", "cherry", "chokeberry", "citron", "clementine", "coconut", "corn", "crabapple", "cranberry", "cucumber", "currant", "cabbage", "cacao", "cantaloupe", "caper", "carambola", "carrot", "cauliflower", "celery", "chard", "cherry", "chokeberry", "citron", "clementine", "coconut", "corn", "crabapple", "cranberry", "cucumber", "currant",
@@ -152,8 +151,8 @@ namespace DHT.Desktop.Main.Pages {
"vanilla", "vanilla",
"watercress", "watermelon", "watercress", "watermelon",
"yam", "yam",
"zucchini", "zucchini"
}; ];
private static string RandomText(Random rand, int maxWords) { private static string RandomText(Random rand, int maxWords) {
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3)); int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
@@ -162,10 +161,8 @@ namespace DHT.Desktop.Main.Pages {
} }
} }
#else #else
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages { namespace DHT.Desktop.Main.Pages {
sealed class DebugPageModel : BaseModel { sealed class DebugPageModel {
public string GenerateChannels { get; set; } = "0"; public string GenerateChannels { get; set; } = "0";
public string GenerateUsers { get; set; } = "0"; public string GenerateUsers { get; set; } = "0";
public string GenerateMessages { get; set; } = "0"; public string GenerateMessages { get; set; } = "0";

View File

@@ -3,33 +3,25 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia.Controls; using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Discord; using DHT.Desktop.Discord;
using DHT.Desktop.Server; using DHT.Desktop.Server;
using DHT.Utils.Models;
using static DHT.Desktop.Program; using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
sealed class TrackingPageModel : BaseModel { sealed partial class TrackingPageModel : ObservableObject {
[ObservableProperty(Setter = Access.Private)]
private bool isCopyTrackingScriptButtonEnabled = true; private bool isCopyTrackingScriptButtonEnabled = true;
public bool IsCopyTrackingScriptButtonEnabled { [ObservableProperty(Setter = Access.Private)]
get => isCopyTrackingScriptButtonEnabled; [NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
set => Change(ref isCopyTrackingScriptButtonEnabled, value); private bool? areDevToolsEnabled = null;
}
private bool? areDevToolsEnabled; [ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
private bool? AreDevToolsEnabled { private bool isToggleAppDevToolsButtonEnabled = false;
get => areDevToolsEnabled;
set {
Change(ref areDevToolsEnabled, value);
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
}
}
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = false;
public string ToggleAppDevToolsButtonText { public string ToggleAppDevToolsButtonText {
get { get {
@@ -86,17 +78,15 @@ sealed class TrackingPageModel : BaseModel {
} }
private async Task InitializeDevToolsToggle() { private async Task InitializeDevToolsToggle() {
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled(); bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
if (devToolsEnabled.HasValue) { if (devToolsEnabled.HasValue) {
IsToggleAppDevToolsButtonEnabled = true;
AreDevToolsEnabled = devToolsEnabled.Value; AreDevToolsEnabled = devToolsEnabled.Value;
IsToggleAppDevToolsButtonEnabled = true;
} }
else { else {
IsToggleAppDevToolsButtonEnabled = false; IsToggleAppDevToolsButtonEnabled = false;
} }
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
} }
public async Task OnClickToggleAppDevTools() { public async Task OnClickToggleAppDevTools() {

View File

@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.File; using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
@@ -18,12 +19,11 @@ using DHT.Server;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
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 static DHT.Desktop.Program; using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
sealed class ViewerPageModel : BaseModel, IDisposable { sealed partial class ViewerPageModel : ObservableObject, IDisposable {
public static readonly ConcurrentBag<string> TemporaryFiles = []; public static readonly ConcurrentBag<string> TemporaryFiles = [];
private static readonly FilePickerFileType[] ViewerFileTypes = [ private static readonly FilePickerFileType[] ViewerFileTypes = [
@@ -33,13 +33,9 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
public bool DatabaseToolFilterModeKeep { get; set; } = true; public bool DatabaseToolFilterModeKeep { get; set; } = true;
public bool DatabaseToolFilterModeRemove { get; set; } = false; public bool DatabaseToolFilterModeRemove { get; set; } = false;
[ObservableProperty]
private bool hasFilters = false; private bool hasFilters = false;
public bool HasFilters {
get => hasFilters;
set => Change(ref hasFilters, value);
}
public MessageFilterPanelModel FilterModel { get; } public MessageFilterPanelModel FilterModel { get; }
private readonly Window window; private readonly Window window;
@@ -69,10 +65,13 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
var fullPath = await PrepareTemporaryViewerFile(); var fullPath = await PrepareTemporaryViewerFile();
var strategy = new LiveViewerExportStrategy(ServerConfiguration.Port, ServerConfiguration.Token); var strategy = new LiveViewerExportStrategy(ServerConfiguration.Port, ServerConfiguration.Token);
await WriteViewerFile(fullPath, strategy); await ProgressDialog.ShowIndeterminate(window, "Open Viewer", "Creating viewer...", _ => Task.Run(() => WriteViewerFile(fullPath, strategy)));
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
Process.Start(new ProcessStartInfo(fullPath) {
UseShellExecute = true
});
} catch (Exception e) { } catch (Exception e) {
await Dialog.ShowOk(window, "Open Viewer", "Could not save viewer: " + e.Message); await Dialog.ShowOk(window, "Open Viewer", "Could not create or save viewer: " + e.Message);
} }
} }
@@ -110,9 +109,9 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
} }
try { try {
await WriteViewerFile(path, StandaloneViewerExportStrategy.Instance); await ProgressDialog.ShowIndeterminate(window, "Save Viewer", "Creating viewer...", _ => Task.Run(() => WriteViewerFile(path, StandaloneViewerExportStrategy.Instance)));
} catch (Exception e) { } catch (Exception e) {
await Dialog.ShowOk(window, "Save Viewer", "Could not save viewer: " + e.Message); await Dialog.ShowOk(window, "Save Viewer", "Could not create or save viewer: " + e.Message);
} }
} }

View File

@@ -3,25 +3,21 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress; using DHT.Desktop.Dialogs.Progress;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Database.Sqlite.Utils; using DHT.Server.Database.Sqlite.Utils;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Screens; namespace DHT.Desktop.Main.Screens;
sealed class WelcomeScreenModel : BaseModel { sealed partial class WelcomeScreenModel : ObservableObject {
public string Version => Program.Version; public string Version => Program.Version;
[ObservableProperty(Setter = Access.Private)]
private bool isOpenOrCreateDatabaseButtonEnabled = true; private bool isOpenOrCreateDatabaseButtonEnabled = true;
public bool IsOpenOrCreateDatabaseButtonEnabled {
get => isOpenOrCreateDatabaseButtonEnabled;
set => Change(ref isOpenOrCreateDatabaseButtonEnabled, value);
}
public event EventHandler<IDatabaseFile>? DatabaseSelected; public event EventHandler<IDatabaseFile>? DatabaseSelected;
private readonly Window window; private readonly Window window;

6
app/NuGet.Config Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="chylex's repository" value="https://nuget.chylex.com/feed/index.json" />
</packageSources>
</configuration>

View File

@@ -1,46 +0,0 @@
using DHT.Utils.Models;
namespace DHT.Server.Database;
/// <summary>
/// A live view of database statistics.
/// Some of the totals are computed asynchronously and may not reflect the most recent version of the database, or may not be available at all until computed for the first time.
/// </summary>
public sealed class DatabaseStatistics : BaseModel {
private long totalServers;
private long totalChannels;
private long totalUsers;
private long? totalMessages;
private long? totalAttachments;
private long? totalDownloads;
public long TotalServers {
get => totalServers;
internal set => Change(ref totalServers, value);
}
public long TotalChannels {
get => totalChannels;
internal set => Change(ref totalChannels, value);
}
public long TotalUsers {
get => totalUsers;
internal set => Change(ref totalUsers, value);
}
public long? TotalMessages {
get => totalMessages;
internal set => Change(ref totalMessages, value);
}
public long? TotalAttachments {
get => totalAttachments;
internal set => Change(ref totalAttachments, value);
}
public long? TotalDownloads {
get => totalDownloads;
internal set => Change(ref totalDownloads, value);
}
}

View File

@@ -1,11 +0,0 @@
namespace DHT.Server.Database;
/// <summary>
/// A complete snapshot of database statistics at a particular point in time.
/// </summary>
public readonly struct DatabaseStatisticsSnapshot {
public long TotalServers { get; internal init; }
public long TotalChannels { get; internal init; }
public long TotalUsers { get; internal init; }
public long TotalMessages { get; internal init; }
}

View File

@@ -9,23 +9,21 @@ sealed class DummyDatabaseFile : IDatabaseFile {
public static DummyDatabaseFile Instance { get; } = new (); public static DummyDatabaseFile Instance { get; } = new ();
public string Path => ""; public string Path => "";
public DatabaseStatistics Statistics { get; } = new ();
public IUserRepository Users { get; } = new IUserRepository.Dummy(); public IUserRepository Users { get; } = new IUserRepository.Dummy();
public IServerRepository Servers { get; } = new IServerRepository.Dummy(); public IServerRepository Servers { get; } = new IServerRepository.Dummy();
public IChannelRepository Channels { get; } = new IChannelRepository.Dummy(); public IChannelRepository Channels { get; } = new IChannelRepository.Dummy();
public IMessageRepository Messages { get; } = new IMessageRepository.Dummy(); public IMessageRepository Messages { get; } = new IMessageRepository.Dummy();
public IAttachmentRepository Attachments { get; } = new IAttachmentRepository.Dummy();
public IDownloadRepository Downloads { get; } = new IDownloadRepository.Dummy(); public IDownloadRepository Downloads { get; } = new IDownloadRepository.Dummy();
private DummyDatabaseFile() {} private DummyDatabaseFile() {}
public Task<DatabaseStatisticsSnapshot> SnapshotStatistics() {
return Task.FromResult(new DatabaseStatisticsSnapshot());
}
public Task Vacuum() { public Task Vacuum() {
return Task.CompletedTask; return Task.CompletedTask;
} }
public void Dispose() {} public ValueTask DisposeAsync() {
return ValueTask.CompletedTask;
}
} }

View File

@@ -3,9 +3,9 @@ using System.Text.Json.Serialization;
namespace DHT.Server.Database.Export; namespace DHT.Server.Database.Export;
[JsonSourceGenerationOptions( [JsonSourceGenerationOptions(
Converters = new [] { typeof(SnowflakeJsonSerializer) }, Converters = [typeof(SnowflakeJsonSerializer)],
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
GenerationMode = JsonSourceGenerationMode.Default GenerationMode = JsonSourceGenerationMode.Default
)] )]
[JsonSerializable(typeof(ViewerJson))] [JsonSerializable(typeof(ViewerJson))]
sealed partial class ViewerJsonContext : JsonSerializerContext {} sealed partial class ViewerJsonContext : JsonSerializerContext;

View File

@@ -4,15 +4,14 @@ using DHT.Server.Database.Repositories;
namespace DHT.Server.Database; namespace DHT.Server.Database;
public interface IDatabaseFile : IDisposable { public interface IDatabaseFile : IAsyncDisposable {
string Path { get; } string Path { get; }
DatabaseStatistics Statistics { get; }
Task<DatabaseStatisticsSnapshot> SnapshotStatistics();
IUserRepository Users { get; } IUserRepository Users { get; }
IServerRepository Servers { get; } IServerRepository Servers { get; }
IChannelRepository Channels { get; } IChannelRepository Channels { get; }
IMessageRepository Messages { get; } IMessageRepository Messages { get; }
IAttachmentRepository Attachments { get; }
IDownloadRepository Downloads { get; } IDownloadRepository Downloads { get; }
Task Vacuum(); Task Vacuum();

View File

@@ -4,4 +4,4 @@ namespace DHT.Server.Database.Import;
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, GenerationMode = JsonSourceGenerationMode.Default)] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, GenerationMode = JsonSourceGenerationMode.Default)]
[JsonSerializable(typeof(DiscordEmbedLegacyJson))] [JsonSerializable(typeof(DiscordEmbedLegacyJson))]
sealed partial class DiscordEmbedLegacyJsonContext : JsonSerializerContext {} sealed partial class DiscordEmbedLegacyJsonContext : JsonSerializerContext;

View File

@@ -0,0 +1,21 @@
using System;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks;
using DHT.Server.Data.Filters;
namespace DHT.Server.Database.Repositories;
public interface IAttachmentRepository {
IObservable<long> TotalCount { get; }
Task<long> Count(AttachmentFilter? filter = null, CancellationToken cancellationToken = default);
internal sealed class Dummy : IAttachmentRepository {
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
public Task<long> Count(AttachmentFilter? filter = null, CancellationToken cancellationToken = default) {
return Task.FromResult(0L);
}
}
}

View File

@@ -1,20 +1,33 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Data; using DHT.Server.Data;
namespace DHT.Server.Database.Repositories; namespace DHT.Server.Database.Repositories;
public interface IChannelRepository { public interface IChannelRepository {
IObservable<long> TotalCount { get; }
Task Add(IReadOnlyList<Channel> channels); Task Add(IReadOnlyList<Channel> channels);
Task<long> Count(CancellationToken cancellationToken = default);
IAsyncEnumerable<Channel> Get(); IAsyncEnumerable<Channel> Get();
internal sealed class Dummy : IChannelRepository { internal sealed class Dummy : IChannelRepository {
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
public Task Add(IReadOnlyList<Channel> channels) { public Task Add(IReadOnlyList<Channel> channels) {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<long> Count(CancellationToken cancellationToken) {
return Task.FromResult(0L);
}
public IAsyncEnumerable<Channel> Get() { public IAsyncEnumerable<Channel> Get() {
return AsyncEnumerable.Empty<Channel>(); return AsyncEnumerable.Empty<Channel>();
} }

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reactive.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Data; using DHT.Server.Data;
@@ -10,7 +12,7 @@ using DHT.Server.Download;
namespace DHT.Server.Database.Repositories; namespace DHT.Server.Database.Repositories;
public interface IDownloadRepository { public interface IDownloadRepository {
Task<long> CountAttachments(AttachmentFilter? filter = null, CancellationToken cancellationToken = default); IObservable<long> TotalCount { get; }
Task AddDownload(Data.Download download); Task AddDownload(Data.Download download);
@@ -29,9 +31,7 @@ public interface IDownloadRepository {
Task RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode); Task RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode);
internal sealed class Dummy : IDownloadRepository { internal sealed class Dummy : IDownloadRepository {
public Task<long> CountAttachments(AttachmentFilter? filter, CancellationToken cancellationToken) { public IObservable<long> TotalCount { get; } = Observable.Return(0L);
return Task.FromResult(0L);
}
public Task AddDownload(Data.Download download) { public Task AddDownload(Data.Download download) {
return Task.CompletedTask; return Task.CompletedTask;

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reactive.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Data; using DHT.Server.Data;
@@ -8,6 +10,8 @@ using DHT.Server.Data.Filters;
namespace DHT.Server.Database.Repositories; namespace DHT.Server.Database.Repositories;
public interface IMessageRepository { public interface IMessageRepository {
IObservable<long> TotalCount { get; }
Task Add(IReadOnlyList<Message> messages); Task Add(IReadOnlyList<Message> messages);
Task<long> Count(MessageFilter? filter = null, CancellationToken cancellationToken = default); Task<long> Count(MessageFilter? filter = null, CancellationToken cancellationToken = default);
@@ -19,6 +23,8 @@ public interface IMessageRepository {
Task Remove(MessageFilter filter, FilterRemovalMode mode); Task Remove(MessageFilter filter, FilterRemovalMode mode);
internal sealed class Dummy : IMessageRepository { internal sealed class Dummy : IMessageRepository {
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
public Task Add(IReadOnlyList<Message> messages) { public Task Add(IReadOnlyList<Message> messages) {
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@@ -1,19 +1,32 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace DHT.Server.Database.Repositories; namespace DHT.Server.Database.Repositories;
public interface IServerRepository { public interface IServerRepository {
IObservable<long> TotalCount { get; }
Task Add(IReadOnlyList<Data.Server> servers); Task Add(IReadOnlyList<Data.Server> servers);
Task<long> Count(CancellationToken cancellationToken = default);
IAsyncEnumerable<Data.Server> Get(); IAsyncEnumerable<Data.Server> Get();
internal sealed class Dummy : IServerRepository { internal sealed class Dummy : IServerRepository {
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
public Task Add(IReadOnlyList<Data.Server> servers) { public Task Add(IReadOnlyList<Data.Server> servers) {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<long> Count(CancellationToken cancellationToken) {
return Task.FromResult(0L);
}
public IAsyncEnumerable<Data.Server> Get() { public IAsyncEnumerable<Data.Server> Get() {
return AsyncEnumerable.Empty<Data.Server>(); return AsyncEnumerable.Empty<Data.Server>();
} }

View File

@@ -1,20 +1,33 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Data; using DHT.Server.Data;
namespace DHT.Server.Database.Repositories; namespace DHT.Server.Database.Repositories;
public interface IUserRepository { public interface IUserRepository {
IObservable<long> TotalCount { get; }
Task Add(IReadOnlyList<User> users); Task Add(IReadOnlyList<User> users);
Task<long> Count(CancellationToken cancellationToken = default);
IAsyncEnumerable<User> Get(); IAsyncEnumerable<User> Get();
internal sealed class Dummy : IUserRepository { internal sealed class Dummy : IUserRepository {
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
public Task Add(IReadOnlyList<User> users) { public Task Add(IReadOnlyList<User> users) {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<long> Count(CancellationToken cancellationToken) {
return Task.FromResult(0L);
}
public IAsyncEnumerable<User> Get() { public IAsyncEnumerable<User> Get() {
return AsyncEnumerable.Empty<User>(); return AsyncEnumerable.Empty<User>();
} }

View File

@@ -0,0 +1,26 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using DHT.Utils.Tasks;
namespace DHT.Server.Database.Sqlite.Repositories;
abstract class BaseSqliteRepository : IDisposable {
private readonly ObservableThrottledTask<long> totalCountTask = new (TaskScheduler.Default);
public IObservable<long> TotalCount => totalCountTask;
protected BaseSqliteRepository() {
UpdateTotalCount();
}
public void Dispose() {
totalCountTask.Dispose();
}
protected void UpdateTotalCount() {
totalCountTask.Post(Count);
}
public abstract Task<long> Count(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,28 @@
using System.Threading;
using System.Threading.Tasks;
using DHT.Server.Data.Filters;
using DHT.Server.Database.Repositories;
using DHT.Server.Database.Sqlite.Utils;
namespace DHT.Server.Database.Sqlite.Repositories;
sealed class SqliteAttachmentRepository : BaseSqliteRepository, IAttachmentRepository {
private readonly SqliteConnectionPool pool;
public SqliteAttachmentRepository(SqliteConnectionPool pool) {
this.pool = pool;
}
internal new void UpdateTotalCount() {
base.UpdateTotalCount();
}
public override Task<long> Count(CancellationToken cancellationToken) {
return Count(filter: null, cancellationToken);
}
public async Task<long> Count(AttachmentFilter? filter, CancellationToken cancellationToken) {
await using var conn = await pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(DISTINCT normalized_url) FROM attachments a" + filter.GenerateWhereClause("a"), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
}
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Database.Repositories; using DHT.Server.Database.Repositories;
@@ -7,26 +8,15 @@ using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Repositories; namespace DHT.Server.Database.Sqlite.Repositories;
sealed class SqliteChannelRepository : IChannelRepository { sealed class SqliteChannelRepository : BaseSqliteRepository, IChannelRepository {
private readonly SqliteConnectionPool pool; private readonly SqliteConnectionPool pool;
private readonly DatabaseStatistics statistics;
public SqliteChannelRepository(SqliteConnectionPool pool, DatabaseStatistics statistics) { public SqliteChannelRepository(SqliteConnectionPool pool) {
this.pool = pool; this.pool = pool;
this.statistics = statistics;
}
internal async Task Initialize() {
using var conn = pool.Take();
await UpdateChannelStatistics(conn);
}
private async Task UpdateChannelStatistics(ISqliteConnection conn) {
statistics.TotalChannels = await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM channels", static reader => reader?.GetInt64(0) ?? 0L);
} }
public async Task Add(IReadOnlyList<Channel> channels) { public async Task Add(IReadOnlyList<Channel> channels) {
using var conn = pool.Take(); await using var conn = await pool.Take();
await using (var tx = await conn.BeginTransactionAsync()) { await using (var tx = await conn.BeginTransactionAsync()) {
await using var cmd = conn.Upsert("channels", [ await using var cmd = conn.Upsert("channels", [
@@ -53,11 +43,16 @@ sealed class SqliteChannelRepository : IChannelRepository {
await tx.CommitAsync(); await tx.CommitAsync();
} }
await UpdateChannelStatistics(conn); UpdateTotalCount();
}
public override async Task<long> Count(CancellationToken cancellationToken) {
await using var conn = await pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM channels", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
} }
public async IAsyncEnumerable<Channel> Get() { public async IAsyncEnumerable<Channel> Get() {
using var conn = pool.Take(); await using var conn = await pool.Take();
await using var cmd = conn.Command("SELECT id, server, name, parent_id, position, topic, nsfw FROM channels"); await using var cmd = conn.Command("SELECT id, server, name, parent_id, position, topic, nsfw FROM channels");
await using var reader = await cmd.ExecuteReaderAsync(); await using var reader = await cmd.ExecuteReaderAsync();

View File

@@ -9,27 +9,19 @@ using DHT.Server.Data.Filters;
using DHT.Server.Database.Repositories; using DHT.Server.Database.Repositories;
using DHT.Server.Database.Sqlite.Utils; using DHT.Server.Database.Sqlite.Utils;
using DHT.Server.Download; using DHT.Server.Download;
using DHT.Utils.Tasks;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Repositories; namespace DHT.Server.Database.Sqlite.Repositories;
sealed class SqliteDownloadRepository : IDownloadRepository { sealed class SqliteDownloadRepository : BaseSqliteRepository, IDownloadRepository {
private readonly SqliteConnectionPool pool; private readonly SqliteConnectionPool pool;
private readonly AsyncValueComputer<long>.Single totalDownloadsComputer;
public SqliteDownloadRepository(SqliteConnectionPool pool, AsyncValueComputer<long>.Single totalDownloadsComputer) { public SqliteDownloadRepository(SqliteConnectionPool pool) {
this.pool = pool; 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) { public async Task AddDownload(Data.Download download) {
using (var conn = pool.Take()) { await using (var conn = await pool.Take()) {
await using var cmd = conn.Upsert("downloads", [ await using var cmd = conn.Upsert("downloads", [
("normalized_url", SqliteType.Text), ("normalized_url", SqliteType.Text),
("download_url", SqliteType.Text), ("download_url", SqliteType.Text),
@@ -46,7 +38,12 @@ sealed class SqliteDownloadRepository : IDownloadRepository {
await cmd.ExecuteNonQueryAsync(); await cmd.ExecuteNonQueryAsync();
} }
totalDownloadsComputer.Recompute(); UpdateTotalCount();
}
public override async Task<long> Count(CancellationToken cancellationToken) {
await using var conn = await pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM downloads", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
} }
public async Task<DownloadStatusStatistics> GetStatistics(CancellationToken cancellationToken) { public async Task<DownloadStatusStatistics> GetStatistics(CancellationToken cancellationToken) {
@@ -81,7 +78,7 @@ sealed class SqliteDownloadRepository : IDownloadRepository {
FROM downloads FROM downloads
""" """
); );
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued); cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading); cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success); cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
@@ -100,14 +97,14 @@ sealed class SqliteDownloadRepository : IDownloadRepository {
var result = new DownloadStatusStatistics(); var result = new DownloadStatusStatistics();
using var conn = pool.Take(); await using var conn = await pool.Take();
await LoadUndownloadedStatistics(conn, result, cancellationToken); await LoadUndownloadedStatistics(conn, result, cancellationToken);
await LoadSuccessStatistics(conn, result, cancellationToken); await LoadSuccessStatistics(conn, result, cancellationToken);
return result; return result;
} }
public async IAsyncEnumerable<Data.Download> GetWithoutData() { public async IAsyncEnumerable<Data.Download> GetWithoutData() {
using var conn = pool.Take(); await using var conn = await pool.Take();
await using var cmd = conn.Command("SELECT normalized_url, download_url, status, size FROM downloads"); await using var cmd = conn.Command("SELECT normalized_url, download_url, status, size FROM downloads");
await using var reader = await cmd.ExecuteReaderAsync(); await using var reader = await cmd.ExecuteReaderAsync();
@@ -123,7 +120,7 @@ sealed class SqliteDownloadRepository : IDownloadRepository {
} }
public async Task<Data.Download> HydrateWithData(Data.Download download) { public async Task<Data.Download> HydrateWithData(Data.Download download) {
using var conn = pool.Take(); await using var conn = await pool.Take();
await using var cmd = conn.Command("SELECT blob FROM downloads WHERE normalized_url = :url"); await using var cmd = conn.Command("SELECT blob FROM downloads WHERE normalized_url = :url");
cmd.AddAndSet(":url", SqliteType.Text, download.NormalizedUrl); cmd.AddAndSet(":url", SqliteType.Text, download.NormalizedUrl);
@@ -139,7 +136,7 @@ sealed class SqliteDownloadRepository : IDownloadRepository {
} }
public async Task<DownloadedAttachment?> GetDownloadedAttachment(string normalizedUrl) { public async Task<DownloadedAttachment?> GetDownloadedAttachment(string normalizedUrl) {
using var conn = pool.Take(); await using var conn = await pool.Take();
await using var cmd = conn.Command( await using var cmd = conn.Command(
""" """
@@ -165,7 +162,7 @@ sealed class SqliteDownloadRepository : IDownloadRepository {
} }
public async Task<int> EnqueueDownloadItems(AttachmentFilter? filter, CancellationToken cancellationToken) { public async Task<int> EnqueueDownloadItems(AttachmentFilter? filter, CancellationToken cancellationToken) {
using var conn = pool.Take(); await using var conn = await pool.Take();
await using var cmd = conn.Command( await using var cmd = conn.Command(
$""" $"""
@@ -184,7 +181,7 @@ sealed class SqliteDownloadRepository : IDownloadRepository {
public async IAsyncEnumerable<DownloadItem> PullEnqueuedDownloadItems(int count, [EnumeratorCancellation] CancellationToken cancellationToken) { public async IAsyncEnumerable<DownloadItem> PullEnqueuedDownloadItems(int count, [EnumeratorCancellation] CancellationToken cancellationToken) {
var found = new List<DownloadItem>(); var found = new List<DownloadItem>();
using var conn = pool.Take(); await using var conn = await pool.Take();
await using (var cmd = conn.Command("SELECT normalized_url, download_url, size FROM downloads WHERE status = :enqueued LIMIT :limit")) { 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(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
@@ -218,7 +215,7 @@ sealed class SqliteDownloadRepository : IDownloadRepository {
} }
public async Task RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) { public async Task RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {
using (var conn = pool.Take()) { await using (var conn = await pool.Take()) {
await conn.ExecuteAsync( await conn.ExecuteAsync(
$""" $"""
-- noinspection SqlWithoutWhere -- noinspection SqlWithoutWhere
@@ -228,6 +225,6 @@ sealed class SqliteDownloadRepository : IDownloadRepository {
); );
} }
totalDownloadsComputer.Recompute(); UpdateTotalCount();
} }
} }

View File

@@ -7,20 +7,17 @@ using DHT.Server.Data;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database.Repositories; using DHT.Server.Database.Repositories;
using DHT.Server.Database.Sqlite.Utils; using DHT.Server.Database.Sqlite.Utils;
using DHT.Utils.Tasks;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Repositories; namespace DHT.Server.Database.Sqlite.Repositories;
sealed class SqliteMessageRepository : IMessageRepository { sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository {
private readonly SqliteConnectionPool pool; private readonly SqliteConnectionPool pool;
private readonly AsyncValueComputer<long>.Single totalMessagesComputer; private readonly SqliteAttachmentRepository attachments;
private readonly AsyncValueComputer<long>.Single totalAttachmentsComputer;
public SqliteMessageRepository(SqliteConnectionPool pool, AsyncValueComputer<long>.Single totalMessagesComputer, AsyncValueComputer<long>.Single totalAttachmentsComputer) { public SqliteMessageRepository(SqliteConnectionPool pool, SqliteAttachmentRepository attachments) {
this.pool = pool; this.pool = pool;
this.totalMessagesComputer = totalMessagesComputer; this.attachments = attachments;
this.totalAttachmentsComputer = totalAttachmentsComputer;
} }
public async Task Add(IReadOnlyList<Message> messages) { public async Task Add(IReadOnlyList<Message> messages) {
@@ -39,7 +36,7 @@ sealed class SqliteMessageRepository : IMessageRepository {
bool addedAttachments = false; bool addedAttachments = false;
using (var conn = pool.Take()) { await using (var conn = await pool.Take()) {
await using var tx = await conn.BeginTransactionAsync(); await using var tx = await conn.BeginTransactionAsync();
await using var messageCmd = conn.Upsert("messages", [ await using var messageCmd = conn.Upsert("messages", [
@@ -161,15 +158,19 @@ sealed class SqliteMessageRepository : IMessageRepository {
await tx.CommitAsync(); await tx.CommitAsync();
} }
totalMessagesComputer.Recompute(); UpdateTotalCount();
if (addedAttachments) { if (addedAttachments) {
totalAttachmentsComputer.Recompute(); attachments.UpdateTotalCount();
} }
} }
public override Task<long> Count(CancellationToken cancellationToken) {
return Count(filter: null, cancellationToken);
}
public async Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) { public async Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) {
using var conn = pool.Take(); await using var conn = await pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM messages" + filter.GenerateWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken); return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM messages" + filter.GenerateWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
} }
@@ -204,7 +205,7 @@ sealed class SqliteMessageRepository : IMessageRepository {
} }
public async IAsyncEnumerable<Message> Get(MessageFilter? filter) { public async IAsyncEnumerable<Message> Get(MessageFilter? filter) {
using var conn = pool.Take(); await using var conn = await pool.Take();
const string AttachmentSql = const string AttachmentSql =
""" """
@@ -280,7 +281,7 @@ sealed class SqliteMessageRepository : IMessageRepository {
} }
public async IAsyncEnumerable<ulong> GetIds(MessageFilter? filter) { public async IAsyncEnumerable<ulong> GetIds(MessageFilter? filter) {
using var conn = pool.Take(); await using var conn = await pool.Take();
await using var cmd = conn.Command("SELECT message_id FROM messages" + filter.GenerateWhereClause()); await using var cmd = conn.Command("SELECT message_id FROM messages" + filter.GenerateWhereClause());
await using var reader = await cmd.ExecuteReaderAsync(); await using var reader = await cmd.ExecuteReaderAsync();
@@ -291,7 +292,7 @@ sealed class SqliteMessageRepository : IMessageRepository {
} }
public async Task Remove(MessageFilter filter, FilterRemovalMode mode) { public async Task Remove(MessageFilter filter, FilterRemovalMode mode) {
using (var conn = pool.Take()) { await using (var conn = await pool.Take()) {
await conn.ExecuteAsync( await conn.ExecuteAsync(
$""" $"""
-- noinspection SqlWithoutWhere -- noinspection SqlWithoutWhere
@@ -301,6 +302,6 @@ sealed class SqliteMessageRepository : IMessageRepository {
); );
} }
totalMessagesComputer.Recompute(); UpdateTotalCount();
} }
} }

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Database.Repositories; using DHT.Server.Database.Repositories;
@@ -7,26 +8,15 @@ using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Repositories; namespace DHT.Server.Database.Sqlite.Repositories;
sealed class SqliteServerRepository : IServerRepository { sealed class SqliteServerRepository : BaseSqliteRepository, IServerRepository {
private readonly SqliteConnectionPool pool; private readonly SqliteConnectionPool pool;
private readonly DatabaseStatistics statistics;
public SqliteServerRepository(SqliteConnectionPool pool, DatabaseStatistics statistics) { public SqliteServerRepository(SqliteConnectionPool pool) {
this.pool = pool; this.pool = pool;
this.statistics = statistics;
}
internal async Task Initialize() {
using var conn = pool.Take();
await UpdateServerStatistics(conn);
}
private async Task UpdateServerStatistics(ISqliteConnection conn) {
statistics.TotalServers = await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM servers", static reader => reader?.GetInt64(0) ?? 0L);
} }
public async Task Add(IReadOnlyList<Data.Server> servers) { public async Task Add(IReadOnlyList<Data.Server> servers) {
using var conn = pool.Take(); await using var conn = await pool.Take();
await using (var tx = await conn.BeginTransactionAsync()) { await using (var tx = await conn.BeginTransactionAsync()) {
await using var cmd = conn.Upsert("servers", [ await using var cmd = conn.Upsert("servers", [
@@ -45,11 +35,16 @@ sealed class SqliteServerRepository : IServerRepository {
await tx.CommitAsync(); await tx.CommitAsync();
} }
await UpdateServerStatistics(conn); UpdateTotalCount();
}
public override async Task<long> Count(CancellationToken cancellationToken) {
await using var conn = await pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM servers", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
} }
public async IAsyncEnumerable<Data.Server> Get() { public async IAsyncEnumerable<Data.Server> Get() {
using var conn = pool.Take(); await using var conn = await pool.Take();
await using var cmd = conn.Command("SELECT id, name, type FROM servers"); await using var cmd = conn.Command("SELECT id, name, type FROM servers");
await using var reader = await cmd.ExecuteReaderAsync(); await using var reader = await cmd.ExecuteReaderAsync();

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Database.Repositories; using DHT.Server.Database.Repositories;
@@ -7,28 +8,17 @@ using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Repositories; namespace DHT.Server.Database.Sqlite.Repositories;
sealed class SqliteUserRepository : IUserRepository { sealed class SqliteUserRepository : BaseSqliteRepository, IUserRepository {
private readonly SqliteConnectionPool pool; private readonly SqliteConnectionPool pool;
private readonly DatabaseStatistics statistics;
public SqliteUserRepository(SqliteConnectionPool pool, DatabaseStatistics statistics) { public SqliteUserRepository(SqliteConnectionPool pool) {
this.pool = pool; 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) { public async Task Add(IReadOnlyList<User> users) {
using var conn = pool.Take(); await using (var conn = await pool.Take()) {
await using var tx = await conn.BeginTransactionAsync();
await using (var tx = await conn.BeginTransactionAsync()) {
await using var cmd = conn.Upsert("users", [ await using var cmd = conn.Upsert("users", [
("id", SqliteType.Integer), ("id", SqliteType.Integer),
("name", SqliteType.Text), ("name", SqliteType.Text),
@@ -46,12 +36,17 @@ sealed class SqliteUserRepository : IUserRepository {
await tx.CommitAsync(); await tx.CommitAsync();
} }
await UpdateUserStatistics(conn); UpdateTotalCount();
}
public override async Task<long> Count(CancellationToken cancellationToken) {
await using var conn = await pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM users", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
} }
public async IAsyncEnumerable<User> Get() { public async IAsyncEnumerable<User> Get() {
using var conn = pool.Take(); await using var conn = await pool.Take();
await using var cmd = conn.Command("SELECT id, name, avatar_url, discriminator FROM users"); await using var cmd = conn.Command("SELECT id, name, avatar_url, discriminator FROM users");
await using var reader = await cmd.ExecuteReaderAsync(); await using var reader = await cmd.ExecuteReaderAsync();

View File

@@ -3,7 +3,6 @@ using System.Threading.Tasks;
using DHT.Server.Database.Repositories; using DHT.Server.Database.Repositories;
using DHT.Server.Database.Sqlite.Repositories; using DHT.Server.Database.Sqlite.Repositories;
using DHT.Server.Database.Sqlite.Utils; using DHT.Server.Database.Sqlite.Utils;
using DHT.Utils.Tasks;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite; namespace DHT.Server.Database.Sqlite;
@@ -11,41 +10,39 @@ namespace DHT.Server.Database.Sqlite;
public sealed class SqliteDatabaseFile : IDatabaseFile { public sealed class SqliteDatabaseFile : IDatabaseFile {
private const int DefaultPoolSize = 5; private const int DefaultPoolSize = 5;
public static async Task<SqliteDatabaseFile?> OpenOrCreate(string path, ISchemaUpgradeCallbacks schemaUpgradeCallbacks, TaskScheduler computeTaskResultScheduler) { public static async Task<SqliteDatabaseFile?> OpenOrCreate(string path, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
var connectionString = new SqliteConnectionStringBuilder { var connectionString = new SqliteConnectionStringBuilder {
DataSource = path, DataSource = path,
Mode = SqliteOpenMode.ReadWriteCreate, Mode = SqliteOpenMode.ReadWriteCreate,
}; };
var pool = new SqliteConnectionPool(connectionString, DefaultPoolSize); var pool = await SqliteConnectionPool.Create(connectionString, DefaultPoolSize);
bool wasOpened; bool wasOpened;
try { try {
using var conn = pool.Take(); await using var conn = await pool.Take();
wasOpened = await new Schema(conn).Setup(schemaUpgradeCallbacks); wasOpened = await new Schema(conn).Setup(schemaUpgradeCallbacks);
} catch (Exception) { } catch (Exception) {
pool.Dispose(); await pool.DisposeAsync();
throw; throw;
} }
if (wasOpened) { if (wasOpened) {
var db = new SqliteDatabaseFile(path, pool, computeTaskResultScheduler); return new SqliteDatabaseFile(path, pool);
await db.Initialize();
return db;
} }
else { else {
pool.Dispose(); await pool.DisposeAsync();
return null; return null;
} }
} }
public string Path { get; } public string Path { get; }
public DatabaseStatistics Statistics { get; }
public IUserRepository Users => users; public IUserRepository Users => users;
public IServerRepository Servers => servers; public IServerRepository Servers => servers;
public IChannelRepository Channels => channels; public IChannelRepository Channels => channels;
public IMessageRepository Messages => messages; public IMessageRepository Messages => messages;
public IAttachmentRepository Attachments => attachments;
public IDownloadRepository Downloads => downloads; public IDownloadRepository Downloads => downloads;
private readonly SqliteConnectionPool pool; private readonly SqliteConnectionPool pool;
@@ -54,84 +51,32 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
private readonly SqliteServerRepository servers; private readonly SqliteServerRepository servers;
private readonly SqliteChannelRepository channels; private readonly SqliteChannelRepository channels;
private readonly SqliteMessageRepository messages; private readonly SqliteMessageRepository messages;
private readonly SqliteAttachmentRepository attachments;
private readonly SqliteDownloadRepository downloads; private readonly SqliteDownloadRepository downloads;
private readonly AsyncValueComputer<long>.Single totalMessagesComputer; private SqliteDatabaseFile(string path, SqliteConnectionPool pool) {
private readonly AsyncValueComputer<long>.Single totalAttachmentsComputer; this.Path = path;
private readonly AsyncValueComputer<long>.Single totalDownloadsComputer;
private SqliteDatabaseFile(string path, SqliteConnectionPool pool, TaskScheduler computeTaskResultScheduler) {
this.pool = pool; this.pool = pool;
this.totalMessagesComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateMessageStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeMessageStatistics); users = new SqliteUserRepository(pool);
this.totalAttachmentsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateAttachmentStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeAttachmentStatistics); servers = new SqliteServerRepository(pool);
this.totalDownloadsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateDownloadStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeDownloadStatistics); channels = new SqliteChannelRepository(pool);
messages = new SqliteMessageRepository(pool, attachments = new SqliteAttachmentRepository(pool));
this.Path = path; downloads = new SqliteDownloadRepository(pool);
this.Statistics = new DatabaseStatistics();
this.users = new SqliteUserRepository(pool, Statistics);
this.servers = new SqliteServerRepository(pool, Statistics);
this.channels = new SqliteChannelRepository(pool, Statistics);
this.messages = new SqliteMessageRepository(pool, totalMessagesComputer, totalAttachmentsComputer);
this.downloads = new SqliteDownloadRepository(pool, totalDownloadsComputer);
totalMessagesComputer.Recompute();
totalAttachmentsComputer.Recompute();
totalDownloadsComputer.Recompute();
} }
private async Task Initialize() { public async ValueTask DisposeAsync() {
await users.Initialize(); users.Dispose();
await servers.Initialize(); servers.Dispose();
await channels.Initialize(); channels.Dispose();
} messages.Dispose();
attachments.Dispose();
public void Dispose() { downloads.Dispose();
totalMessagesComputer.Cancel(); await pool.DisposeAsync();
totalAttachmentsComputer.Cancel();
totalDownloadsComputer.Cancel();
pool.Dispose();
}
public async Task<DatabaseStatisticsSnapshot> SnapshotStatistics() {
return new DatabaseStatisticsSnapshot {
TotalServers = Statistics.TotalServers,
TotalChannels = Statistics.TotalChannels,
TotalUsers = Statistics.TotalUsers,
TotalMessages = await ComputeMessageStatistics(),
};
} }
public async Task Vacuum() { public async Task Vacuum() {
using var conn = pool.Take(); await using var conn = await pool.Take();
await conn.ExecuteAsync("VACUUM"); await conn.ExecuteAsync("VACUUM");
} }
private async Task<long> ComputeMessageStatistics() {
using var conn = pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM messages", static reader => reader?.GetInt64(0) ?? 0L);
}
private void UpdateMessageStatistics(long totalMessages) {
Statistics.TotalMessages = totalMessages;
}
private async Task<long> ComputeAttachmentStatistics() {
using var conn = pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(DISTINCT normalized_url) FROM attachments", static reader => reader?.GetInt64(0) ?? 0L);
}
private void UpdateAttachmentStatistics(long totalAttachments) {
Statistics.TotalAttachments = totalAttachments;
}
private async Task<long> ComputeDownloadStatistics() {
using var conn = pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM downloads", static reader => reader?.GetInt64(0) ?? 0L);
}
private void UpdateDownloadStatistics(long totalDownloads) {
Statistics.TotalDownloads = totalDownloads;
}
} }

View File

@@ -3,6 +3,6 @@ using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Utils; namespace DHT.Server.Database.Sqlite.Utils;
interface ISqliteConnection : IDisposable { interface ISqliteConnection : IAsyncDisposable {
SqliteConnection InnerConnection { get; } SqliteConnection InnerConnection { get; }
} }

View File

@@ -1,100 +1,77 @@
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using DHT.Utils.Logging; using System.Threading.Tasks;
using DHT.Utils.Collections;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Utils; namespace DHT.Server.Database.Sqlite.Utils;
sealed class SqliteConnectionPool : IDisposable { sealed class SqliteConnectionPool : IAsyncDisposable {
public static async Task<SqliteConnectionPool> Create(SqliteConnectionStringBuilder connectionStringBuilder, int poolSize) {
var pool = new SqliteConnectionPool(poolSize);
await pool.InitializePooledConnections(connectionStringBuilder);
return pool;
}
private static string GetConnectionString(SqliteConnectionStringBuilder connectionStringBuilder) { private static string GetConnectionString(SqliteConnectionStringBuilder connectionStringBuilder) {
connectionStringBuilder.Pooling = false; connectionStringBuilder.Pooling = false;
return connectionStringBuilder.ToString(); return connectionStringBuilder.ToString();
} }
private readonly object monitor = new (); private readonly int poolSize;
private readonly Random rand = new (); private readonly List<PooledConnection> all;
private volatile bool isDisposed; private readonly ConcurrentPool<PooledConnection> free;
private readonly BlockingCollection<PooledConnection> free = new (new ConcurrentStack<PooledConnection>()); private readonly CancellationTokenSource disposalTokenSource = new ();
private readonly List<PooledConnection> used; private readonly CancellationToken disposalToken;
public SqliteConnectionPool(SqliteConnectionStringBuilder connectionStringBuilder, int poolSize) { private SqliteConnectionPool(int poolSize) {
this.poolSize = poolSize;
this.all = new List<PooledConnection>(poolSize);
this.free = new ConcurrentPool<PooledConnection>(poolSize);
this.disposalToken = disposalTokenSource.Token;
}
private async Task InitializePooledConnections(SqliteConnectionStringBuilder connectionStringBuilder) {
var connectionString = GetConnectionString(connectionStringBuilder); var connectionString = GetConnectionString(connectionStringBuilder);
for (int i = 0; i < poolSize; i++) { for (int i = 0; i < poolSize; i++) {
var conn = new SqliteConnection(connectionString); var conn = new SqliteConnection(connectionString);
conn.Open(); conn.Open();
var pooledConn = new PooledConnection(this, conn); var pooledConnection = new PooledConnection(this, conn);
using (var cmd = pooledConn.Command("PRAGMA journal_mode=WAL")) { await using (var cmd = pooledConnection.Command("PRAGMA journal_mode=WAL")) {
cmd.ExecuteNonQuery(); await cmd.ExecuteNonQueryAsync(disposalToken);
} }
free.Add(pooledConn); all.Add(pooledConnection);
} await free.Push(pooledConnection, disposalToken);
used = new List<PooledConnection>(poolSize);
}
private void ThrowIfDisposed() {
ObjectDisposedException.ThrowIf(isDisposed, nameof(SqliteConnectionPool));
}
public ISqliteConnection Take() {
while (true) {
ThrowIfDisposed();
lock (monitor) {
if (free.TryTake(out var conn)) {
used.Add(conn);
return conn;
}
else {
Log.ForType<SqliteConnectionPool>().Warn("Thread " + Environment.CurrentManagedThreadId + " is starving for connections.");
}
}
Thread.Sleep(TimeSpan.FromMilliseconds(rand.Next(100, 200)));
} }
} }
private void Return(PooledConnection conn) { public async Task<ISqliteConnection> Take() {
ThrowIfDisposed(); return await free.Pop(disposalToken);
lock (monitor) {
if (used.Remove(conn)) {
free.Add(conn);
}
}
} }
public void Dispose() { private async Task Return(PooledConnection conn) {
if (isDisposed) { await free.Push(conn, disposalToken);
}
public async ValueTask DisposeAsync() {
if (disposalToken.IsCancellationRequested) {
return; return;
} }
isDisposed = true; await disposalTokenSource.CancelAsync();
lock (monitor) { foreach (var conn in all) {
while (free.TryTake(out var conn)) { await conn.InnerConnection.CloseAsync();
Close(conn.InnerConnection); await conn.InnerConnection.DisposeAsync();
}
foreach (var conn in used) {
Close(conn.InnerConnection);
}
free.Dispose();
used.Clear();
} }
}
disposalTokenSource.Dispose();
private static void Close(SqliteConnection conn) {
conn.Close();
conn.Dispose();
} }
private sealed class PooledConnection : ISqliteConnection { private sealed class PooledConnection : ISqliteConnection {
@@ -107,8 +84,8 @@ sealed class SqliteConnectionPool : IDisposable {
this.InnerConnection = conn; this.InnerConnection = conn;
} }
void IDisposable.Dispose() { public async ValueTask DisposeAsync() {
pool.Return(this); await pool.Return(this);
} }
} }
} }

View File

@@ -58,7 +58,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, [column]);
return cmd; return cmd;
} }

View File

@@ -5,7 +5,7 @@ namespace DHT.Server.Database.Sqlite.Utils;
sealed class SqliteWhereGenerator { sealed class SqliteWhereGenerator {
private readonly string? tableAlias; private readonly string? tableAlias;
private readonly bool invert; private readonly bool invert;
private readonly List<string> conditions = new (); private readonly List<string> conditions = [];
public SqliteWhereGenerator(string? tableAlias, bool invert) { public SqliteWhereGenerator(string? tableAlias, bool invert) {
this.tableAlias = tableAlias; this.tableAlias = tableAlias;

View File

@@ -4,7 +4,7 @@ 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[] {
"cdn.discordapp.com", "cdn.discordapp.com",
"cdn.discord.com", "cdn.discord.com",
}.ToFrozenSet(); }.ToFrozenSet();

View File

@@ -29,7 +29,7 @@ sealed class DownloaderTask : IAsyncDisposable {
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private readonly IDatabaseFile db; private readonly IDatabaseFile db;
private readonly Subject<DownloadItem> finishedItemPublisher = new (); private readonly ISubject<DownloadItem> finishedItemPublisher = Subject.Synchronize(new Subject<DownloadItem>());
private readonly Task queueWriterTask; private readonly Task queueWriterTask;
private readonly Task[] downloadTasks; private readonly Task[] downloadTasks;

View File

@@ -13,7 +13,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.Linq.Async" Version="6.0.1" />
<PackageReference Include="System.Reactive" Version="6.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -11,12 +11,12 @@ using Microsoft.Extensions.Hosting;
namespace DHT.Server.Service; namespace DHT.Server.Service;
sealed class Startup { sealed class Startup {
private static readonly string[] AllowedOrigins = { private static readonly string[] AllowedOrigins = [
"https://discord.com", "https://discord.com",
"https://ptb.discord.com", "https://ptb.discord.com",
"https://canary.discord.com", "https://canary.discord.com",
"https://discordapp.com", "https://discordapp.com"
}; ];
public void ConfigureServices(IServiceCollection services) { public void ConfigureServices(IServiceCollection services) {
services.Configure<JsonOptions>(static options => { services.Configure<JsonOptions>(static options => {

View File

@@ -22,6 +22,6 @@ public sealed class State : IAsyncDisposable {
public async ValueTask DisposeAsync() { public async ValueTask DisposeAsync() {
await Downloader.Stop(); await Downloader.Stop();
await Server.Stop(); await Server.Stop();
Db.Dispose(); await Db.DisposeAsync();
} }
} }

View File

@@ -0,0 +1,45 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace DHT.Utils.Collections;
public sealed class ConcurrentPool<T> {
private readonly SemaphoreSlim mutexSemaphore;
private readonly SemaphoreSlim availableItemSemaphore;
private readonly Stack<T> items;
public ConcurrentPool(int size) {
mutexSemaphore = new SemaphoreSlim(1);
availableItemSemaphore = new SemaphoreSlim(0, size);
items = new Stack<T>();
}
public async Task Push(T item, CancellationToken cancellationToken) {
await PushItem(item, cancellationToken);
availableItemSemaphore.Release();
}
public async Task<T> Pop(CancellationToken cancellationToken) {
await availableItemSemaphore.WaitAsync(cancellationToken);
return await PopItem(cancellationToken);
}
private async Task PushItem(T item, CancellationToken cancellationToken) {
await mutexSemaphore.WaitAsync(cancellationToken);
try {
items.Push(item);
} finally {
mutexSemaphore.Release();
}
}
private async Task<T> PopItem(CancellationToken cancellationToken) {
await mutexSemaphore.WaitAsync(cancellationToken);
try {
return items.Pop();
} finally {
mutexSemaphore.Release();
}
}
}

View File

@@ -1,19 +0,0 @@
using System.Collections.Generic;
namespace DHT.Utils.Collections;
public sealed class MultiDictionary<TKey, TValue> where TKey : notnull {
private readonly Dictionary<TKey, List<TValue>> dict = new();
public void Add(TKey key, TValue value) {
if (!dict.TryGetValue(key, out var list)) {
dict[key] = list = new List<TValue>();
}
list.Add(value);
}
public List<TValue>? GetListOrNull(TKey key) {
return dict.TryGetValue(key, out var list) ? list : null;
}
}

View File

@@ -13,42 +13,20 @@ public static class HttpOutput {
} }
} }
public sealed class Text : IHttpOutput { public sealed class Text(string text) : IHttpOutput {
private readonly string text;
public Text(string text) {
this.text = text;
}
public Task WriteTo(HttpResponse response) { public Task WriteTo(HttpResponse response) {
return response.WriteAsync(text, Encoding.UTF8); return response.WriteAsync(text, Encoding.UTF8);
} }
} }
public sealed class File : IHttpOutput { public sealed class File(string? contentType, byte[] bytes) : IHttpOutput {
private readonly string? contentType;
private readonly byte[] bytes;
public File(string? contentType, byte[] bytes) {
this.contentType = contentType;
this.bytes = bytes;
}
public async Task WriteTo(HttpResponse response) { public async Task WriteTo(HttpResponse response) {
response.ContentType = contentType ?? string.Empty; response.ContentType = contentType ?? string.Empty;
await response.Body.WriteAsync(bytes); await response.Body.WriteAsync(bytes);
} }
} }
public sealed class Redirect : IHttpOutput { public sealed class Redirect(string url, bool permanent) : IHttpOutput {
private readonly string url;
private readonly bool permanent;
public Redirect(string url, bool permanent) {
this.url = url;
this.permanent = permanent;
}
public Task WriteTo(HttpResponse response) { public Task WriteTo(HttpResponse response) {
response.Redirect(url, permanent); response.Redirect(url, permanent);
return Task.CompletedTask; return Task.CompletedTask;

View File

@@ -5,4 +5,4 @@ namespace DHT.Utils.Http;
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, GenerationMode = JsonSourceGenerationMode.Default)] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, GenerationMode = JsonSourceGenerationMode.Default)]
[JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(JsonElement))]
public sealed partial class JsonElementContext : JsonSerializerContext {} public sealed partial class JsonElementContext : JsonSerializerContext;

View File

@@ -1,22 +0,0 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using JetBrains.Annotations;
namespace DHT.Utils.Models;
public abstract class BaseModel : INotifyPropertyChanged {
public event PropertyChangedEventHandler? PropertyChanged;
[NotifyPropertyChangedInvocator]
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) {
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected void Change<T>(ref T field, T newValue, [CallerMemberName] string? propertyName = null) {
if (!EqualityComparer<T>.Default.Equals(field, newValue)) {
field = newValue;
OnPropertyChanged(propertyName);
}
}
}

View File

@@ -6,13 +6,7 @@ using System.Threading.Tasks;
namespace DHT.Utils.Resources; namespace DHT.Utils.Resources;
public sealed class ResourceLoader { public sealed class ResourceLoader(Assembly assembly) {
private readonly Assembly assembly;
public ResourceLoader(Assembly assembly) {
this.assembly = assembly;
}
private Stream GetEmbeddedStream(string filename) { private Stream GetEmbeddedStream(string filename) {
Stream? stream = null; Stream? stream = null;
foreach (var embeddedName in assembly.GetManifestResourceNames()) { foreach (var embeddedName in assembly.GetManifestResourceNames()) {
@@ -35,7 +29,7 @@ public sealed class ResourceLoader {
} }
public async Task<string> ReadJoinedAsync(string path, char separator) { public async Task<string> ReadJoinedAsync(string path, char separator) {
StringBuilder joined = new(); StringBuilder joined = new ();
foreach (var embeddedName in assembly.GetManifestResourceNames()) { foreach (var embeddedName in assembly.GetManifestResourceNames()) {
if (embeddedName.Replace('\\', '/').StartsWith(path)) { if (embeddedName.Replace('\\', '/').StartsWith(path)) {

View File

@@ -1,130 +0,0 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
namespace DHT.Utils.Tasks;
public sealed class AsyncValueComputer<TValue> {
private readonly Action<TValue> resultProcessor;
private readonly TaskScheduler resultTaskScheduler;
private readonly bool processOutdatedResults;
private readonly object stateLock = new ();
private SoftHardCancellationToken? currentCancellationTokenSource;
private bool wasHardCancelled = false;
private Func<Task<TValue>>? currentComputeFunction;
private bool hasComputeFunctionChanged = false;
private AsyncValueComputer(Action<TValue> resultProcessor, TaskScheduler resultTaskScheduler, bool processOutdatedResults) {
this.resultProcessor = resultProcessor;
this.resultTaskScheduler = resultTaskScheduler;
this.processOutdatedResults = processOutdatedResults;
}
public void Cancel() {
lock (stateLock) {
wasHardCancelled = true;
currentCancellationTokenSource?.RequestHardCancellation();
}
}
public void Compute(Func<Task<TValue>> func) {
lock (stateLock) {
wasHardCancelled = false;
if (currentComputeFunction != null) {
currentComputeFunction = func;
hasComputeFunctionChanged = true;
currentCancellationTokenSource?.RequestSoftCancellation();
}
else {
EnqueueComputation(func);
}
}
}
[SuppressMessage("ReSharper", "MethodSupportsCancellation")]
private void EnqueueComputation(Func<Task<TValue>> func) {
var cancellationTokenSource = new SoftHardCancellationToken();
currentCancellationTokenSource?.RequestSoftCancellation();
currentCancellationTokenSource = cancellationTokenSource;
currentComputeFunction = func;
hasComputeFunctionChanged = false;
var task = Task.Run(func);
task.ContinueWith(t => {
if (!cancellationTokenSource.IsCancelled(processOutdatedResults)) {
resultProcessor(t.Result);
}
}, CancellationToken.None, TaskContinuationOptions.NotOnFaulted, resultTaskScheduler);
task.ContinueWith(_ => {
lock (stateLock) {
cancellationTokenSource.Dispose();
if (currentCancellationTokenSource == cancellationTokenSource) {
currentCancellationTokenSource = null;
}
if (hasComputeFunctionChanged && !wasHardCancelled) {
EnqueueComputation(currentComputeFunction);
}
else {
currentComputeFunction = null;
hasComputeFunctionChanged = false;
}
}
});
}
public sealed class Single {
private readonly AsyncValueComputer<TValue> baseComputer;
private readonly Func<Task<TValue>> resultComputer;
internal Single(AsyncValueComputer<TValue> baseComputer, Func<Task<TValue>> resultComputer) {
this.baseComputer = baseComputer;
this.resultComputer = resultComputer;
}
public void Recompute() {
baseComputer.Compute(resultComputer);
}
public void Cancel() {
baseComputer.Cancel();
}
}
public static Builder WithResultProcessor(Action<TValue> resultProcessor, TaskScheduler? scheduler = null) {
return new Builder(resultProcessor, scheduler ?? TaskScheduler.FromCurrentSynchronizationContext());
}
public sealed class Builder {
private readonly Action<TValue> resultProcessor;
private readonly TaskScheduler resultTaskScheduler;
private bool processOutdatedResults;
internal Builder(Action<TValue> resultProcessor, TaskScheduler resultTaskScheduler) {
this.resultProcessor = resultProcessor;
this.resultTaskScheduler = resultTaskScheduler;
}
public Builder WithOutdatedResults() {
this.processOutdatedResults = true;
return this;
}
public AsyncValueComputer<TValue> Build() {
return new AsyncValueComputer<TValue>(resultProcessor, resultTaskScheduler, processOutdatedResults);
}
public Single BuildWithComputer(Func<Task<TValue>> resultComputer) {
return new Single(Build(), resultComputer);
}
}
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Reactive.Subjects;
using System.Threading;
using System.Threading.Tasks;
namespace DHT.Utils.Tasks;
public sealed class ObservableThrottledTask<T> : IObservable<T>, IDisposable {
private readonly ReplaySubject<T> subject;
private readonly ThrottledTask<T> task;
public ObservableThrottledTask(TaskScheduler resultScheduler) {
this.subject = new ReplaySubject<T>(bufferSize: 1);
this.task = new ThrottledTask<T>(subject.OnNext, resultScheduler);
}
public void Post(Func<CancellationToken, Task<T>> resultComputer) {
task.Post(resultComputer);
}
public IDisposable Subscribe(IObserver<T> observer) {
return subject.Subscribe(observer);
}
public void Dispose() {
task.Dispose();
subject.OnCompleted();
subject.Dispose();
}
}

View File

@@ -1,39 +0,0 @@
using System;
using System.Threading;
namespace DHT.Utils.Tasks;
/// <summary>
/// Manages a pair of cancellation tokens that follow these rules:
/// <list type="number">
/// <item><description>If the soft token is cancelled, the hard token remains uncancelled.</description></item>
/// <item><description>If the hard token is cancelled, the soft token is also cancelled.</description></item>
/// </list>
/// </summary>
sealed class SoftHardCancellationToken : IDisposable {
private readonly CancellationTokenSource soft;
private readonly CancellationTokenSource hard;
public SoftHardCancellationToken() {
this.soft = new CancellationTokenSource();
this.hard = new CancellationTokenSource();
}
public bool IsCancelled(bool onlyHardCancellation) {
return (onlyHardCancellation ? hard : soft).IsCancellationRequested;
}
public void RequestSoftCancellation() {
soft.Cancel();
}
public void RequestHardCancellation() {
soft.Cancel();
hard.Cancel();
}
public void Dispose() {
soft.Dispose();
hard.Dispose();
}
}

View File

@@ -16,6 +16,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="JetBrains.Annotations" Version="2023.2.0" /> <PackageReference Include="JetBrains.Annotations" Version="2023.2.0" />
<PackageReference Include="System.Reactive" Version="6.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -8,5 +8,5 @@ using DHT.Utils;
namespace DHT.Utils; namespace DHT.Utils;
static class Version { static class Version {
public const string Tag = "39.1.0.0"; public const string Tag = "40.0.0.0";
} }