1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2024-10-19 05:42:50 +02:00

Compare commits

..

No commits in common. "89161e14b1454fe49506a2197f766c921658fa67" and "e0f359c15b70d8d6e5fd213f52fd41a7fb9c2ae4" have entirely different histories.

49 changed files with 713 additions and 485 deletions

View File

@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message;
using DHT.Server.Database;
@ -43,10 +45,15 @@ static class DatabaseGui {
}
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;
try {
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks);
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks, taskScheduler);
} catch (InvalidDatabaseVersionException ex) {
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
} catch (DatabaseTooNewException ex) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,10 +20,11 @@ using DHT.Server.Database;
using DHT.Server.Database.Import;
using DHT.Server.Database.Sqlite.Utils;
using DHT.Utils.Logging;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages;
sealed class DatabasePageModel {
sealed class DatabasePageModel : BaseModel {
private static readonly Log Log = Log.ForType<DatabasePageModel>();
public IDatabaseFile Db { get; }
@ -193,7 +194,7 @@ sealed class DatabasePageModel {
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;
var oldStatistics = await DatabaseStatistics.Take(target);
var oldStatistics = await target.SnapshotStatistics();
int successful = 0;
int finished = 0;
@ -224,26 +225,15 @@ sealed class DatabasePageModel {
return;
}
var newStatistics = await DatabaseStatistics.Take(target);
var newStatistics = await target.SnapshotStatistics();
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
}
private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
return new DatabaseStatistics(
await db.Servers.Count(),
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;
private static string GetImportDialogMessage(DatabaseStatisticsSnapshot oldStatistics, DatabaseStatisticsSnapshot newStatistics, int successfulItems, int totalItems, string itemName) {
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
long newUsers = newStatistics.TotalUsers - oldStatistics.TotalUsers;
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages;
StringBuilder message = new StringBuilder();
message.Append("Processed ");

View File

@ -9,9 +9,10 @@ using DHT.Desktop.Dialogs.Progress;
using DHT.Server;
using DHT.Server.Data;
using DHT.Server.Service;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages {
sealed class DebugPageModel {
sealed class DebugPageModel : BaseModel {
public string GenerateChannels { get; set; } = "0";
public string GenerateUsers { get; set; } = "0";
public string GenerateMessages { get; set; } = "0";
@ -161,8 +162,10 @@ namespace DHT.Desktop.Main.Pages {
}
}
#else
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages {
sealed class DebugPageModel {
sealed class DebugPageModel : BaseModel {
public string GenerateChannels { get; set; } = "0";
public string GenerateUsers { get; set; } = "0";
public string GenerateMessages { get; set; } = "0";

View File

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

View File

@ -8,7 +8,6 @@ using System.Threading.Tasks;
using System.Web;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message;
@ -19,11 +18,12 @@ using DHT.Server;
using DHT.Server.Data.Filters;
using DHT.Server.Database.Export;
using DHT.Server.Database.Export.Strategy;
using DHT.Utils.Models;
using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages;
sealed partial class ViewerPageModel : ObservableObject, IDisposable {
sealed class ViewerPageModel : BaseModel, IDisposable {
public static readonly ConcurrentBag<string> TemporaryFiles = [];
private static readonly FilePickerFileType[] ViewerFileTypes = [
@ -33,9 +33,13 @@ sealed partial class ViewerPageModel : ObservableObject, IDisposable {
public bool DatabaseToolFilterModeKeep { get; set; } = true;
public bool DatabaseToolFilterModeRemove { get; set; } = false;
[ObservableProperty]
private bool hasFilters = false;
public bool HasFilters {
get => hasFilters;
set => Change(ref hasFilters, value);
}
public MessageFilterPanelModel FilterModel { get; }
private readonly Window window;

View File

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

View File

@ -1,6 +0,0 @@
<?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

@ -0,0 +1,46 @@
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

@ -0,0 +1,11 @@
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,16 +9,20 @@ sealed class DummyDatabaseFile : IDatabaseFile {
public static DummyDatabaseFile Instance { get; } = new ();
public string Path => "";
public DatabaseStatistics Statistics { get; } = new ();
public IUserRepository Users { get; } = new IUserRepository.Dummy();
public IServerRepository Servers { get; } = new IServerRepository.Dummy();
public IChannelRepository Channels { get; } = new IChannelRepository.Dummy();
public IMessageRepository Messages { get; } = new IMessageRepository.Dummy();
public IAttachmentRepository Attachments { get; } = new IAttachmentRepository.Dummy();
public IDownloadRepository Downloads { get; } = new IDownloadRepository.Dummy();
private DummyDatabaseFile() {}
public Task<DatabaseStatisticsSnapshot> SnapshotStatistics() {
return Task.FromResult(new DatabaseStatisticsSnapshot());
}
public Task Vacuum() {
return Task.CompletedTask;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,26 +0,0 @@
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

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

View File

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

View File

@ -9,15 +9,23 @@ using DHT.Server.Data.Filters;
using DHT.Server.Database.Repositories;
using DHT.Server.Database.Sqlite.Utils;
using DHT.Server.Download;
using DHT.Utils.Tasks;
using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Repositories;
sealed class SqliteDownloadRepository : BaseSqliteRepository, IDownloadRepository {
sealed class SqliteDownloadRepository : IDownloadRepository {
private readonly SqliteConnectionPool pool;
private readonly AsyncValueComputer<long>.Single totalDownloadsComputer;
public SqliteDownloadRepository(SqliteConnectionPool pool) {
public SqliteDownloadRepository(SqliteConnectionPool pool, AsyncValueComputer<long>.Single totalDownloadsComputer) {
this.pool = pool;
this.totalDownloadsComputer = totalDownloadsComputer;
}
public async Task<long> CountAttachments(AttachmentFilter? filter, CancellationToken cancellationToken) {
using var conn = pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(DISTINCT normalized_url) FROM attachments a" + filter.GenerateWhereClause("a"), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
}
public async Task AddDownload(Data.Download download) {
@ -38,12 +46,7 @@ sealed class SqliteDownloadRepository : BaseSqliteRepository, IDownloadRepositor
await cmd.ExecuteNonQueryAsync();
}
UpdateTotalCount();
}
public override async Task<long> Count(CancellationToken cancellationToken) {
using var conn = pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM downloads", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
totalDownloadsComputer.Recompute();
}
public async Task<DownloadStatusStatistics> GetStatistics(CancellationToken cancellationToken) {
@ -225,6 +228,6 @@ sealed class SqliteDownloadRepository : BaseSqliteRepository, IDownloadRepositor
);
}
UpdateTotalCount();
totalDownloadsComputer.Recompute();
}
}

View File

@ -7,17 +7,20 @@ using DHT.Server.Data;
using DHT.Server.Data.Filters;
using DHT.Server.Database.Repositories;
using DHT.Server.Database.Sqlite.Utils;
using DHT.Utils.Tasks;
using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Repositories;
sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository {
sealed class SqliteMessageRepository : IMessageRepository {
private readonly SqliteConnectionPool pool;
private readonly SqliteAttachmentRepository attachments;
private readonly AsyncValueComputer<long>.Single totalMessagesComputer;
private readonly AsyncValueComputer<long>.Single totalAttachmentsComputer;
public SqliteMessageRepository(SqliteConnectionPool pool, SqliteAttachmentRepository attachments) {
public SqliteMessageRepository(SqliteConnectionPool pool, AsyncValueComputer<long>.Single totalMessagesComputer, AsyncValueComputer<long>.Single totalAttachmentsComputer) {
this.pool = pool;
this.attachments = attachments;
this.totalMessagesComputer = totalMessagesComputer;
this.totalAttachmentsComputer = totalAttachmentsComputer;
}
public async Task Add(IReadOnlyList<Message> messages) {
@ -158,17 +161,13 @@ sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository
await tx.CommitAsync();
}
UpdateTotalCount();
totalMessagesComputer.Recompute();
if (addedAttachments) {
attachments.UpdateTotalCount();
totalAttachmentsComputer.Recompute();
}
}
public override Task<long> Count(CancellationToken cancellationToken) {
return Count(filter: null, cancellationToken);
}
public async Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) {
using var conn = pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM messages" + filter.GenerateWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
@ -302,6 +301,6 @@ sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository
);
}
UpdateTotalCount();
totalMessagesComputer.Recompute();
}
}

View File

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

View File

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

View File

@ -3,6 +3,7 @@ using System.Threading.Tasks;
using DHT.Server.Database.Repositories;
using DHT.Server.Database.Sqlite.Repositories;
using DHT.Server.Database.Sqlite.Utils;
using DHT.Utils.Tasks;
using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite;
@ -10,7 +11,7 @@ namespace DHT.Server.Database.Sqlite;
public sealed class SqliteDatabaseFile : IDatabaseFile {
private const int DefaultPoolSize = 5;
public static async Task<SqliteDatabaseFile?> OpenOrCreate(string path, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
public static async Task<SqliteDatabaseFile?> OpenOrCreate(string path, ISchemaUpgradeCallbacks schemaUpgradeCallbacks, TaskScheduler computeTaskResultScheduler) {
var connectionString = new SqliteConnectionStringBuilder {
DataSource = path,
Mode = SqliteOpenMode.ReadWriteCreate,
@ -28,7 +29,9 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
}
if (wasOpened) {
return new SqliteDatabaseFile(path, pool);
var db = new SqliteDatabaseFile(path, pool, computeTaskResultScheduler);
await db.Initialize();
return db;
}
else {
pool.Dispose();
@ -37,12 +40,12 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
}
public string Path { get; }
public DatabaseStatistics Statistics { get; }
public IUserRepository Users => users;
public IServerRepository Servers => servers;
public IChannelRepository Channels => channels;
public IMessageRepository Messages => messages;
public IAttachmentRepository Attachments => attachments;
public IDownloadRepository Downloads => downloads;
private readonly SqliteConnectionPool pool;
@ -51,32 +54,84 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
private readonly SqliteServerRepository servers;
private readonly SqliteChannelRepository channels;
private readonly SqliteMessageRepository messages;
private readonly SqliteAttachmentRepository attachments;
private readonly SqliteDownloadRepository downloads;
private SqliteDatabaseFile(string path, SqliteConnectionPool pool) {
this.Path = path;
private readonly AsyncValueComputer<long>.Single totalMessagesComputer;
private readonly AsyncValueComputer<long>.Single totalAttachmentsComputer;
private readonly AsyncValueComputer<long>.Single totalDownloadsComputer;
private SqliteDatabaseFile(string path, SqliteConnectionPool pool, TaskScheduler computeTaskResultScheduler) {
this.pool = pool;
users = new SqliteUserRepository(pool);
servers = new SqliteServerRepository(pool);
channels = new SqliteChannelRepository(pool);
messages = new SqliteMessageRepository(pool, attachments = new SqliteAttachmentRepository(pool));
downloads = new SqliteDownloadRepository(pool);
this.totalMessagesComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateMessageStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeMessageStatistics);
this.totalAttachmentsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateAttachmentStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeAttachmentStatistics);
this.totalDownloadsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateDownloadStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeDownloadStatistics);
this.Path = path;
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() {
await users.Initialize();
await servers.Initialize();
await channels.Initialize();
}
public void Dispose() {
users.Dispose();
servers.Dispose();
channels.Dispose();
messages.Dispose();
attachments.Dispose();
downloads.Dispose();
totalMessagesComputer.Cancel();
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() {
using var conn = pool.Take();
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

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

View File

@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
<PackageReference Include="System.Reactive" Version="6.0.0" />
</ItemGroup>
<ItemGroup>

View File

@ -0,0 +1,22 @@
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

@ -0,0 +1,130 @@
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

@ -1,31 +0,0 @@
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

@ -0,0 +1,39 @@
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,7 +16,6 @@
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" Version="2023.2.0" />
<PackageReference Include="System.Reactive" Version="6.0.0" />
</ItemGroup>
<ItemGroup>