mirror of
				https://github.com/chylex/Discord-History-Tracker.git
				synced 2025-10-29 22:24:22 +01:00 
			
		
		
		
	Compare commits
	
		
			10 Commits
		
	
	
		
			08dec7deb3
			...
			v47.2
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 7a8819f95b | |||
| 258bae1865 | |||
| be6203ede9 | |||
| 998893b655 | |||
| 588a1ac0dc | |||
| 2d39825cf5 | |||
| a0325953fd | |||
| 5a33540015 | |||
| 16509be56c | |||
| 791171a79b | 
| @@ -55,6 +55,42 @@ | ||||
|             <Setter Property="HorizontalAlignment" Value="Stretch" /> | ||||
|         </Style> | ||||
|  | ||||
|         <Style Selector="TreeViewItem:not(:empty) /template/ Panel#PART_ExpandCollapseChevronContainer"> | ||||
|             <Setter Property="Cursor" Value="Hand" /> | ||||
|             <Setter Property="Margin" Value="0 0 5 0" /> | ||||
|         </Style> | ||||
|         <Style Selector="TreeViewItem:empty /template/ Panel#PART_ExpandCollapseChevronContainer"> | ||||
|             <Setter Property="Margin" Value="10 0" /> | ||||
|         </Style> | ||||
|         <Style Selector="TreeViewItem[Level=0]:empty /template/ Panel#PART_ExpandCollapseChevronContainer"> | ||||
|             <Setter Property="Margin" Value="0" /> | ||||
|             <Setter Property="Width" Value="0" /> | ||||
|         </Style> | ||||
|         <Style Selector="TreeViewItem /template/ ToggleButton#PART_ExpandCollapseChevron"> | ||||
|             <Setter Property="Width" Value="18" /> | ||||
|             <Setter Property="Height" Value="32" /> | ||||
|         </Style> | ||||
|         <Style Selector="TreeViewItem /template/ ToggleButton#PART_ExpandCollapseChevron > Border"> | ||||
|             <Setter Property="Padding" Value="2 10 3 10" /> | ||||
|         </Style> | ||||
|  | ||||
|         <Style Selector="TreeView.noSelection"> | ||||
|             <Style Selector="^ TreeViewItem /template/ Border#PART_LayoutRoot"> | ||||
|                 <Setter Property="Background" Value="{DynamicResource TreeViewItemBackground}" /> | ||||
|                 <Setter Property="BorderBrush" Value="{DynamicResource TreeViewItemBorderBrush}" /> | ||||
|             </Style> | ||||
|             <Style Selector="^ TreeViewItem /template/ Border#PART_LayoutRoot > ContentPresenter#PART_HeaderPresenter"> | ||||
|                 <Setter Property="Foreground" Value="{DynamicResource TreeViewItemForeground}" /> | ||||
|             </Style> | ||||
|             <Style Selector="^ TreeViewItem:disabled /template/ Border#PART_LayoutRoot"> | ||||
|                 <Setter Property="Background" Value="{DynamicResource TreeViewItemBackgroundDisabled}" /> | ||||
|                 <Setter Property="BorderBrush" Value="{DynamicResource TreeViewItemBorderBrushDisabled}" /> | ||||
|             </Style> | ||||
|             <Style Selector="^ TreeViewItem:disabled /template/ Border#PART_LayoutRoot > ContentPresenter#PART_HeaderPresenter"> | ||||
|                 <Setter Property="Foreground" Value="{DynamicResource TreeViewItemForegroundDisabled}" /> | ||||
|             </Style> | ||||
|         </Style> | ||||
|  | ||||
|         <Style Selector="Panel.buttons"> | ||||
|             <Setter Property="Margin" Value="0 20 0 0" /> | ||||
|         </Style> | ||||
|   | ||||
							
								
								
									
										17
									
								
								app/Desktop/Common/AvaloniaObsevableValueExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/Desktop/Common/AvaloniaObsevableValueExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Desktop.Common; | ||||
|  | ||||
| static class AvaloniaObsevableValueExtensions { | ||||
| 	public static IDisposable SubscribeLastOnUI<T>(this ObservableValue<T> observable, Action<T> action, TimeSpan delayBetweenRuns) { | ||||
| 		Task Action(T value, CancellationToken cancellationToken) { | ||||
| 			action(value); | ||||
| 			return Task.Delay(delayBetweenRuns, cancellationToken); | ||||
| 		} | ||||
| 		 | ||||
| 		return observable.SubscribeLast(Action, TaskScheduler.FromCurrentSynchronizationContext()); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										25
									
								
								app/Desktop/Common/AvaloniaReflection.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/Desktop/Common/AvaloniaReflection.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| using System; | ||||
| using System.Collections; | ||||
| using System.Reflection; | ||||
| using Avalonia.Interactivity; | ||||
|  | ||||
| namespace DHT.Desktop.Common; | ||||
|  | ||||
| static class AvaloniaReflection { | ||||
| 	private static FieldInfo InteractiveEventHandlersField { get; } = typeof(Interactive).GetField("_eventHandlers", BindingFlags.Instance | BindingFlags.NonPublic)!; | ||||
| 	 | ||||
| 	public static void Check() { | ||||
| 		if (InteractiveEventHandlersField == null) { | ||||
| 			throw new InvalidOperationException("Missing field: " + nameof(InteractiveEventHandlersField)); | ||||
| 		} | ||||
| 		 | ||||
| 		if (InteractiveEventHandlersField.FieldType.ToString() != "System.Collections.Generic.Dictionary`2[Avalonia.Interactivity.RoutedEvent,System.Collections.Generic.List`1[Avalonia.Interactivity.Interactive+EventSubscription]]") { | ||||
| 			throw new InvalidOperationException("Invalid field type: " + nameof(InteractiveEventHandlersField) + " = " + InteractiveEventHandlersField.FieldType); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public static IList? GetEventHandler(Interactive target, RoutedEvent routedEvent) { | ||||
| 		IDictionary? eventHandlers = (IDictionary?) InteractiveEventHandlersField.GetValue(target); | ||||
| 		return (IList?) eventHandlers?[routedEvent]; | ||||
| 	} | ||||
| } | ||||
| @@ -8,28 +8,22 @@ | ||||
|         x:DataType="namespace:CheckBoxDialogModel" | ||||
|         Title="{Binding Title}" | ||||
|         Icon="avares://DiscordHistoryTracker/Resources/icon.ico" | ||||
|         Width="500" SizeToContent="Height" CanResize="False" | ||||
|         MinWidth="425" MinHeight="200" | ||||
|         Width="500" Height="395" CanResize="True" | ||||
|         WindowStartupLocation="CenterOwner"> | ||||
|  | ||||
|     <Window.DataContext> | ||||
|         <namespace:CheckBoxDialogModel /> | ||||
|     </Window.DataContext> | ||||
|  | ||||
|     <StackPanel Margin="20"> | ||||
|         <ScrollViewer MaxHeight="400"> | ||||
|             <ItemsRepeater ItemsSource="{Binding Items}"> | ||||
|                 <ItemsRepeater.ItemTemplate> | ||||
|                     <DataTemplate> | ||||
|                         <CheckBox IsChecked="{Binding IsChecked}"> | ||||
|                             <Label> | ||||
|                                 <TextBlock Text="{Binding Title}" TextWrapping="Wrap" /> | ||||
|                             </Label> | ||||
|                         </CheckBox> | ||||
|                     </DataTemplate> | ||||
|                 </ItemsRepeater.ItemTemplate> | ||||
|             </ItemsRepeater> | ||||
|         </ScrollViewer> | ||||
|         <Panel Classes="buttons"> | ||||
|     <Window.Styles> | ||||
|         <Style Selector="TreeViewItem"> | ||||
|             <Setter Property="IsExpanded" Value="True" /> | ||||
|         </Style> | ||||
|     </Window.Styles> | ||||
|  | ||||
|     <DockPanel Margin="20 17 20 20"> | ||||
|         <Panel Classes="buttons" DockPanel.Dock="Bottom"> | ||||
|             <WrapPanel> | ||||
|                 <Button Command="{Binding SelectAll}" IsEnabled="{Binding !AreAllSelected}">Select All</Button> | ||||
|                 <Button Command="{Binding SelectNone}" IsEnabled="{Binding !AreNoneSelected}">Select None</Button> | ||||
| @@ -39,6 +33,19 @@ | ||||
|                 <Button Click="ClickCancel">Cancel</Button> | ||||
|             </WrapPanel> | ||||
|         </Panel> | ||||
|     </StackPanel> | ||||
|         <ScrollViewer DockPanel.Dock="Top"> | ||||
|             <TreeView Name="TreeView" Classes="noSelection" ItemsSource="{Binding RootItems}" ContainerPrepared="TreeViewOnContainerPrepared"> | ||||
|                 <TreeView.ItemTemplate> | ||||
|                     <TreeDataTemplate ItemsSource="{Binding Children}"> | ||||
|                         <CheckBox IsChecked="{Binding IsChecked}"> | ||||
|                             <Label> | ||||
|                                 <TextBlock Text="{Binding Title}" TextWrapping="Wrap" /> | ||||
|                             </Label> | ||||
|                         </CheckBox> | ||||
|                     </TreeDataTemplate> | ||||
|                 </TreeView.ItemTemplate> | ||||
|             </TreeView> | ||||
|         </ScrollViewer> | ||||
|     </DockPanel> | ||||
|  | ||||
| </Window> | ||||
|   | ||||
| @@ -1,6 +1,9 @@ | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using Avalonia.Controls; | ||||
| using Avalonia.Controls.Primitives; | ||||
| using Avalonia.Input; | ||||
| using Avalonia.Interactivity; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Desktop.Dialogs.Message; | ||||
|  | ||||
| namespace DHT.Desktop.Dialogs.CheckBox; | ||||
| @@ -11,6 +14,36 @@ public sealed partial class CheckBoxDialog : Window { | ||||
| 		InitializeComponent(); | ||||
| 	} | ||||
| 	 | ||||
| 	private void TreeViewOnContainerPrepared(object? sender, ContainerPreparedEventArgs e) { | ||||
| 		foreach (object? item in TreeView.Items) { | ||||
| 			if (item != null && TreeView.ContainerFromItem(item) is TreeViewItem treeViewItem) { | ||||
| 				treeViewItem.TemplateApplied += TreeViewItemOnTemplateApplied; | ||||
| 				treeViewItem.GotFocus += TreeViewItemOnGotFocus; | ||||
| 				treeViewItem.KeyDown += TreeViewItemOnKeyDown; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private void TreeViewItemOnTemplateApplied(object? sender, TemplateAppliedEventArgs e) { | ||||
| 		if (sender is TreeViewItem { HeaderPresenter: Interactive headerPresenter } ) { | ||||
| 			// Removes support for double-clicking to expand. | ||||
| 			AvaloniaReflection.GetEventHandler(headerPresenter, DoubleTappedEvent)?.Clear(); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private void TreeViewItemOnGotFocus(object? sender, GotFocusEventArgs e) { | ||||
| 		if (e.NavigationMethod == NavigationMethod.Tab && sender is TreeViewItem treeViewItem && TreeView.SelectedItem == null) { | ||||
| 			TreeView.SelectedItem = TreeView.ItemFromContainer(treeViewItem); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private void TreeViewItemOnKeyDown(object? sender, KeyEventArgs e) { | ||||
| 		if (e.Key == Key.Space && TreeView.SelectedItem is ICheckBoxItem item) { | ||||
| 			item.IsChecked = item.IsChecked == false; | ||||
| 			e.Handled = true; | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public void ClickOk(object? sender, RoutedEventArgs e) { | ||||
| 		Close(DialogResult.OkCancel.Ok); | ||||
| 	} | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.ComponentModel; | ||||
| using System.Linq; | ||||
| using PropertyChanged.SourceGenerator; | ||||
| @@ -8,64 +9,65 @@ namespace DHT.Desktop.Dialogs.CheckBox; | ||||
| partial class CheckBoxDialogModel { | ||||
| 	public string Title { get; init; } = ""; | ||||
| 	 | ||||
| 	private IReadOnlyList<CheckBoxItem> items = []; | ||||
| 	private ImmutableArray<ICheckBoxItem> rootItems = []; | ||||
| 	 | ||||
| 	public IReadOnlyList<CheckBoxItem> Items { | ||||
| 		get => items; | ||||
| 	public ImmutableArray<ICheckBoxItem> RootItems { | ||||
| 		get => rootItems; | ||||
| 		 | ||||
| 		protected set { | ||||
| 			foreach (CheckBoxItem item in items) { | ||||
| 			foreach (ICheckBoxItem item in ICheckBoxItem.GetAllRecursively(rootItems)) { | ||||
| 				item.PropertyChanged -= OnItemPropertyChanged; | ||||
| 			} | ||||
| 			 | ||||
| 			items = value; | ||||
| 			rootItems = value; | ||||
| 			 | ||||
| 			foreach (CheckBoxItem item in items) { | ||||
| 			foreach (ICheckBoxItem item in ICheckBoxItem.GetAllRecursively(rootItems)) { | ||||
| 				item.PropertyChanged += OnItemPropertyChanged; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private bool pauseCheckEvents = false; | ||||
| 	protected IEnumerable<ICheckBoxItem> AllItems => ICheckBoxItem.GetAllRecursively(RootItems); | ||||
| 	 | ||||
| 	[DependsOn(nameof(Items))] | ||||
| 	public bool AreAllSelected => Items.All(static item => item.IsChecked); | ||||
| 	[DependsOn(nameof(RootItems))] | ||||
| 	public bool AreAllSelected => RootItems.All(static item => item.IsChecked == true); | ||||
| 	 | ||||
| 	[DependsOn(nameof(Items))] | ||||
| 	public bool AreNoneSelected => Items.All(static item => !item.IsChecked); | ||||
| 	[DependsOn(nameof(RootItems))] | ||||
| 	public bool AreNoneSelected => RootItems.All(static item => item.IsChecked == false); | ||||
| 	 | ||||
| 	private bool pauseUpdatingBulkButtons = false; | ||||
| 	 | ||||
| 	public void SelectAll() => SetAllChecked(true); | ||||
| 	public void SelectNone() => SetAllChecked(false); | ||||
| 	 | ||||
| 	private void SetAllChecked(bool isChecked) { | ||||
| 		pauseCheckEvents = true; | ||||
| 		pauseUpdatingBulkButtons = true; | ||||
| 		 | ||||
| 		foreach (CheckBoxItem item in Items) { | ||||
| 		foreach (ICheckBoxItem item in RootItems) { | ||||
| 			item.IsChecked = isChecked; | ||||
| 		} | ||||
| 		 | ||||
| 		pauseCheckEvents = false; | ||||
| 		pauseUpdatingBulkButtons = false; | ||||
| 		UpdateBulkButtons(); | ||||
| 	} | ||||
| 	 | ||||
| 	private void UpdateBulkButtons() { | ||||
| 		OnPropertyChanged(new PropertyChangedEventArgs(nameof(Items))); | ||||
| 	} | ||||
| 	 | ||||
| 	private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) { | ||||
| 		if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) { | ||||
| 		if (e.PropertyName == nameof(ICheckBoxItem.IsChecked) && !pauseUpdatingBulkButtons) { | ||||
| 			UpdateBulkButtons(); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private void UpdateBulkButtons() { | ||||
| 		OnPropertyChanged(new PropertyChangedEventArgs(nameof(RootItems))); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel { | ||||
| 	private new IReadOnlyList<CheckBoxItem<T>> Items { get; } | ||||
| 	public IEnumerable<T> SelectedValues => AllItems.OfType<ICheckBoxItem.Leaf<T>>() | ||||
| 	                                                .Where(static item => item.IsChecked == true) | ||||
| 	                                                .Select(static item => item.Value); | ||||
| 	 | ||||
| 	public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.IsChecked); | ||||
| 	 | ||||
| 	public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) { | ||||
| 		this.Items = new List<CheckBoxItem<T>>(items); | ||||
| 		base.Items = Items; | ||||
| 	public CheckBoxDialogModel(ImmutableArray<ICheckBoxItem> items) { | ||||
| 		this.RootItems = items; | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,20 +1,110 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.ComponentModel; | ||||
| using System.Linq; | ||||
| using PropertyChanged.SourceGenerator; | ||||
|  | ||||
| namespace DHT.Desktop.Dialogs.CheckBox; | ||||
|  | ||||
| partial class CheckBoxItem { | ||||
| 	public string Title { get; init; } = ""; | ||||
| 	public object? Item { get; init; } = null; | ||||
| partial interface ICheckBoxItem : INotifyPropertyChanged { | ||||
| 	public string Title { get; } | ||||
| 	public bool? IsChecked { get; set; } | ||||
| 	 | ||||
| 	[Notify] | ||||
| 	private bool isChecked = false; | ||||
| } | ||||
|  | ||||
| sealed class CheckBoxItem<T> : CheckBoxItem { | ||||
| 	public new T Item { get; } | ||||
| 	public ImmutableArray<ICheckBoxItem> Children { get; } | ||||
| 	 | ||||
| 	public CheckBoxItem(T item) { | ||||
| 		this.Item = item; | ||||
| 		base.Item = item; | ||||
| 	void NotifyIsCheckedChanged(); | ||||
| 	 | ||||
| 	public static IEnumerable<ICheckBoxItem> GetAllRecursively(IEnumerable<ICheckBoxItem> items) { | ||||
| 		Stack<ICheckBoxItem> stack = new Stack<ICheckBoxItem>(items); | ||||
| 		 | ||||
| 		while (stack.TryPop(out var item)) { | ||||
| 			yield return item; | ||||
| 			 | ||||
| 			foreach (ICheckBoxItem child in item.Children) { | ||||
| 				stack.Push(child); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	sealed class NonLeaf : ICheckBoxItem { | ||||
| 		public string Title { get; } | ||||
| 		 | ||||
| 		public bool? IsChecked { | ||||
| 			get { | ||||
| 				if (Children.Count(static child => child.IsChecked == true) == Children.Length) { | ||||
| 					return true; | ||||
| 				} | ||||
| 				else if (Children.Count(static child => child.IsChecked == false) == Children.Length) { | ||||
| 					return false; | ||||
| 				} | ||||
| 				else { | ||||
| 					return null; | ||||
| 				} | ||||
| 			} | ||||
| 			 | ||||
| 			set { | ||||
| 				foreach (ICheckBoxItem child in Children) { | ||||
| 					if (child is Leaf leaf) { | ||||
| 						leaf.SetCheckedFromParent(value); | ||||
| 					} | ||||
| 					else { | ||||
| 						child.IsChecked = value; | ||||
| 					} | ||||
| 				} | ||||
| 				 | ||||
| 				NotifyIsCheckedChanged(); | ||||
| 				parent?.NotifyIsCheckedChanged(); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		public ImmutableArray<ICheckBoxItem> Children { get; } | ||||
| 		 | ||||
| 		public event PropertyChangedEventHandler? PropertyChanged; | ||||
| 		 | ||||
| 		private readonly ICheckBoxItem? parent; | ||||
| 		 | ||||
| 		public NonLeaf(string title, ICheckBoxItem? parent, Func<ICheckBoxItem, ImmutableArray<ICheckBoxItem>> children) { | ||||
| 			this.parent = parent; | ||||
| 			this.Title = title; | ||||
| 			this.Children = children(this); | ||||
| 		} | ||||
| 		 | ||||
| 		public void NotifyIsCheckedChanged() { | ||||
| 			PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsChecked))); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	partial class Leaf(string title, ICheckBoxItem? parent, bool isChecked) : ICheckBoxItem { | ||||
| 		public string Title { get; } = title; | ||||
| 		 | ||||
| 		public ImmutableArray<ICheckBoxItem> Children => ImmutableArray<ICheckBoxItem>.Empty; | ||||
| 		 | ||||
| 		public readonly ICheckBoxItem? parent = parent; | ||||
| 		 | ||||
| 		[Notify] | ||||
| 		private bool? isChecked = isChecked; | ||||
| 		 | ||||
| 		private bool notifyParent = true; | ||||
| 		 | ||||
| 		public void SetCheckedFromParent(bool? isChecked) { | ||||
| 			notifyParent = false; | ||||
| 			IsChecked = isChecked; | ||||
| 			notifyParent = true; | ||||
| 		} | ||||
| 		 | ||||
| 		private void OnIsCheckedChanged() { | ||||
| 			if (notifyParent) { | ||||
| 				parent?.NotifyIsCheckedChanged(); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		void ICheckBoxItem.NotifyIsCheckedChanged() { | ||||
| 			OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsChecked))); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	sealed class Leaf<T>(string title, ICheckBoxItem? parent, T value, bool isChecked) : Leaf(title, parent, isChecked) { | ||||
| 		public T Value => value; | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										46
									
								
								app/Desktop/Dialogs/CheckBox/CheckBoxItemList.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								app/Desktop/Dialogs/CheckBox/CheckBoxItemList.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
|  | ||||
| namespace DHT.Desktop.Dialogs.CheckBox; | ||||
|  | ||||
| sealed class CheckBoxItemList<TKey, TValue> where TKey : notnull { | ||||
| 	private readonly List<INode> rootNodes = []; | ||||
| 	private readonly Dictionary<TKey, List<INode>> parentNodes = []; | ||||
| 	 | ||||
| 	public void AddParent(TKey key, string title) { | ||||
| 		if (!parentNodes.ContainsKey(key)) { | ||||
| 			List<INode> children = []; | ||||
| 			rootNodes.Add(new INode.NonLeaf(title, children)); | ||||
| 			parentNodes[key] = children; | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public void Add(TValue value, string title, bool isChecked = false) { | ||||
| 		rootNodes.Add(new INode.Leaf(title, value, isChecked)); | ||||
| 	} | ||||
| 	 | ||||
| 	public void Add(TKey key, TValue value, string title, bool isChecked = false) { | ||||
| 		parentNodes.GetValueOrDefault(key, rootNodes).Add(new INode.Leaf(title, value, isChecked)); | ||||
| 	} | ||||
| 	 | ||||
| 	public ImmutableArray<ICheckBoxItem> ToCheckBoxItems() { | ||||
| 		return [..rootNodes.Select(static node => node.ToCheckBoxItem(null))]; | ||||
| 	} | ||||
| 	 | ||||
| 	private interface INode { | ||||
| 		ICheckBoxItem ToCheckBoxItem(ICheckBoxItem? parent); | ||||
| 		 | ||||
| 		sealed record NonLeaf(string Title, List<INode> Children) : INode { | ||||
| 			public ICheckBoxItem ToCheckBoxItem(ICheckBoxItem? parent) { | ||||
| 				return new ICheckBoxItem.NonLeaf(Title, parent, self => [..Children.Select(child => child.ToCheckBoxItem(self))]); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		sealed record Leaf(string Title, TValue Value, bool IsChecked) : INode { | ||||
| 			public ICheckBoxItem ToCheckBoxItem(ICheckBoxItem? parent) { | ||||
| 				return new ICheckBoxItem.Leaf<TValue>(Title, parent, Value, IsChecked); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -2,9 +2,7 @@ using System; | ||||
| using System.Collections.Generic; | ||||
| using System.ComponentModel; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.ReactiveUI; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Server; | ||||
| using DHT.Server.Data.Filters; | ||||
| @@ -49,7 +47,7 @@ sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable { | ||||
| 	private readonly State state; | ||||
| 	private readonly string verb; | ||||
| 	 | ||||
| 	private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask; | ||||
| 	private readonly ThrottledTask<FilterSettings> saveFilterSettingsTask; | ||||
| 	private bool isLoadingFilterSettings; | ||||
| 	 | ||||
| 	private readonly RestartableTask<long> downloadItemCountTask; | ||||
| @@ -65,10 +63,10 @@ sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable { | ||||
| 		this.state = state; | ||||
| 		this.verb = verb; | ||||
| 		 | ||||
| 		this.saveFilterSettingsTask = new DelayedThrottledTask<FilterSettings>(Log, TimeSpan.FromSeconds(5), SaveFilterSettings); | ||||
| 		this.saveFilterSettingsTask = new ThrottledTask<FilterSettings>(Log, SaveFilterSettings, TimeSpan.FromSeconds(5), TaskScheduler.Default); | ||||
| 		 | ||||
| 		this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext()); | ||||
| 		this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged); | ||||
| 		this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.SubscribeLastOnUI(OnDownloadItemCountChanged, TimeSpan.FromMilliseconds(15)); | ||||
| 		 | ||||
| 		UpdateFilterStatistics(); | ||||
| 		 | ||||
|   | ||||
| @@ -1,12 +1,11 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.ComponentModel; | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.Controls; | ||||
| using Avalonia.ReactiveUI; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Desktop.Dialogs.CheckBox; | ||||
| using DHT.Desktop.Dialogs.Message; | ||||
| @@ -90,9 +89,9 @@ sealed partial class MessageFilterPanelModel : 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); | ||||
| 		this.messageCountSubscription = state.Db.Messages.TotalCount.SubscribeLastOnUI(OnMessageCountChanged, TimeSpan.FromMilliseconds(15)); | ||||
| 		this.channelCountSubscription = state.Db.Channels.TotalCount.SubscribeLastOnUI(OnChannelCountChanged, TimeSpan.FromMilliseconds(15)); | ||||
| 		this.userCountSubscription = state.Db.Users.TotalCount.SubscribeLastOnUI(OnUserCountChanged, TimeSpan.FromMilliseconds(15)); | ||||
| 		 | ||||
| 		UpdateFilterStatistics(); | ||||
| 		UpdateChannelFilterLabel(); | ||||
| @@ -185,51 +184,64 @@ sealed partial class MessageFilterPanelModel : IDisposable { | ||||
| 		FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 1 ? "." : "s."); | ||||
| 	} | ||||
| 	 | ||||
| 	[SuppressMessage("ReSharper", "NotAccessedPositionalProperty.Local")] | ||||
| 	[SuppressMessage("ReSharper", "MemberCanBePrivate.Local")] | ||||
| 	private readonly record struct ChannelFilterKey(byte Type, ulong? ServerId, string Title) : IComparable<ChannelFilterKey> { | ||||
| 		public static ChannelFilterKey DirectMessages { get; } = new (Type: 1, ServerId: null, Title: "Direct Messages"); | ||||
| 		public static ChannelFilterKey GroupMessages { get; } = new (Type: 2, ServerId: null, Title: "Group Messages"); | ||||
| 		public static ChannelFilterKey Unknown { get; } = new (Type: 4, ServerId: null, Title: "Unknown"); | ||||
| 		 | ||||
| 		public static ChannelFilterKey For(DHT.Server.Data.Server server) { | ||||
| 			return server.Type switch { | ||||
| 				ServerType.Server        => new ChannelFilterKey(Type: 3, server.Id, "Server - " + server.Name), | ||||
| 				ServerType.Group         => GroupMessages, | ||||
| 				ServerType.DirectMessage => DirectMessages, | ||||
| 				_                        => Unknown, | ||||
| 			}; | ||||
| 		} | ||||
| 		 | ||||
| 		public bool Equals(ChannelFilterKey other) { | ||||
| 			return Type == other.Type && ServerId == other.ServerId; | ||||
| 		} | ||||
| 		 | ||||
| 		public override int GetHashCode() { | ||||
| 			return HashCode.Combine(Type, ServerId); | ||||
| 		} | ||||
| 		 | ||||
| 		public int CompareTo(ChannelFilterKey other) { | ||||
| 			int result = Type.CompareTo(other.Type); | ||||
| 			if (result != 0) { | ||||
| 				return result; | ||||
| 			} | ||||
| 			else { | ||||
| 				return Title.CompareTo(other.Title); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task OpenChannelFilterDialog() { | ||||
| 		async Task<List<CheckBoxItem<ulong>>> PrepareChannelItems(ProgressDialog dialog) { | ||||
| 			var items = new List<CheckBoxItem<ulong>>(); | ||||
| 		async Task<ImmutableArray<ICheckBoxItem>> PrepareChannelItems(ProgressDialog dialog) { | ||||
| 			CheckBoxItemList<ChannelFilterKey, ulong> items = new CheckBoxItemList<ChannelFilterKey, ulong>(); | ||||
| 			Dictionary<ulong, DHT.Server.Data.Server> servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id); | ||||
| 			 | ||||
| 			await foreach (Channel channel in state.Db.Channels.Get()) { | ||||
| 				ulong channelId = channel.Id; | ||||
| 				string channelName = channel.Name; | ||||
| 				 | ||||
| 				string title; | ||||
| 				if (servers.TryGetValue(channel.Server, out var server)) { | ||||
| 					var titleBuilder = new StringBuilder(); | ||||
| 					ServerType? serverType = server.Type; | ||||
| 					 | ||||
| 					titleBuilder.Append('[') | ||||
| 					            .Append(ServerTypes.ToString(serverType)) | ||||
| 					            .Append("] "); | ||||
| 					 | ||||
| 					if (serverType == ServerType.DirectMessage) { | ||||
| 						titleBuilder.Append(channelName); | ||||
| 					} | ||||
| 					else { | ||||
| 						titleBuilder.Append(server.Name) | ||||
| 						            .Append(" - ") | ||||
| 						            .Append(channelName); | ||||
| 					} | ||||
| 					 | ||||
| 					title = titleBuilder.ToString(); | ||||
| 				} | ||||
| 				else { | ||||
| 					title = channelName; | ||||
| 				} | ||||
| 				 | ||||
| 				items.Add(new CheckBoxItem<ulong>(channelId) { | ||||
| 					Title = title, | ||||
| 					IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId), | ||||
| 				}); | ||||
| 			foreach (ChannelFilterKey channelFilterKey in servers.Values.Select(ChannelFilterKey.For).Order()) { | ||||
| 				items.AddParent(channelFilterKey, channelFilterKey.Title); | ||||
| 			} | ||||
| 			 | ||||
| 			return items; | ||||
| 			await foreach (Channel channel in state.Db.Channels.Get().OrderBy(static channel => channel.Position ?? int.MinValue).ThenBy(static channel => channel.Name)) { | ||||
| 				ChannelFilterKey key = servers.TryGetValue(channel.Server, out var server) | ||||
| 					                       ? ChannelFilterKey.For(server) | ||||
| 					                       : ChannelFilterKey.Unknown; | ||||
| 				 | ||||
| 				items.Add(key, channel.Id, channel.Name, isChecked: IncludedChannels == null || IncludedChannels.Contains(channel.Id)); | ||||
| 			} | ||||
| 			 | ||||
| 			return items.ToCheckBoxItems(); | ||||
| 		} | ||||
| 		 | ||||
| 		const string Title = "Included Channels"; | ||||
| 		 | ||||
| 		List<CheckBoxItem<ulong>> items; | ||||
| 		ImmutableArray<ICheckBoxItem> items; | ||||
| 		try { | ||||
| 			items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading channels...", PrepareChannelItems); | ||||
| 		} catch (Exception e) { | ||||
| @@ -244,22 +256,27 @@ sealed partial class MessageFilterPanelModel : IDisposable { | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task OpenUserFilterDialog() { | ||||
| 		async Task<List<CheckBoxItem<ulong>>> PrepareUserItems(ProgressDialog dialog) { | ||||
| 			var checkBoxItems = new List<CheckBoxItem<ulong>>(); | ||||
| 		async Task<ImmutableArray<ICheckBoxItem>> PrepareUserItems(ProgressDialog dialog) { | ||||
| 			CheckBoxItemList<ulong, ulong> items = new CheckBoxItemList<ulong, ulong>(); | ||||
| 			 | ||||
| 			await foreach (User user in state.Db.Users.Get()) { | ||||
| 				checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) { | ||||
| 					Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})", | ||||
| 					IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id), | ||||
| 				}); | ||||
| 			static string GetDisplayName(User user) { | ||||
| 				return user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})"; | ||||
| 			} | ||||
| 			 | ||||
| 			return checkBoxItems; | ||||
| 			await foreach ((ulong id, string name) in state.Db.Users.Get().Select(static user => (user.Id, GetDisplayName(user))).OrderBy(static pair => pair.Item2)) { | ||||
| 				items.Add( | ||||
| 					value: id, | ||||
| 					title: name, | ||||
| 					isChecked: IncludedUsers == null || IncludedUsers.Contains(id) | ||||
| 				); | ||||
| 			} | ||||
| 			 | ||||
| 			return items.ToCheckBoxItems(); | ||||
| 		} | ||||
| 		 | ||||
| 		const string Title = "Included Users"; | ||||
| 		 | ||||
| 		List<CheckBoxItem<ulong>> items; | ||||
| 		ImmutableArray<ICheckBoxItem> items; | ||||
| 		try { | ||||
| 			items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems); | ||||
| 		} catch (Exception e) { | ||||
| @@ -273,9 +290,7 @@ sealed partial class MessageFilterPanelModel : IDisposable { | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private async Task<HashSet<ulong>?> OpenIdFilterDialog(string title, List<CheckBoxItem<ulong>> items) { | ||||
| 		items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title)); | ||||
| 		 | ||||
| 	private async Task<HashSet<ulong>?> OpenIdFilterDialog(string title, ImmutableArray<ICheckBoxItem> items) { | ||||
| 		var model = new CheckBoxDialogModel<ulong>(items) { | ||||
| 			Title = title, | ||||
| 		}; | ||||
| @@ -283,7 +298,7 @@ sealed partial class MessageFilterPanelModel : IDisposable { | ||||
| 		var dialog = new CheckBoxDialog { DataContext = model }; | ||||
| 		var result = await dialog.ShowDialog<DialogResult.OkCancel>(window); | ||||
| 		 | ||||
| 		return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null; | ||||
| 		return result == DialogResult.OkCancel.Ok ? model.SelectedValues.ToHashSet() : null; | ||||
| 	} | ||||
| 	 | ||||
| 	public MessageFilter CreateFilter() { | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| using System; | ||||
| using System.Reactive.Linq; | ||||
| using Avalonia.ReactiveUI; | ||||
| using Avalonia.Threading; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Server; | ||||
| using DHT.Server.Service; | ||||
| using PropertyChanged.SourceGenerator; | ||||
| @@ -41,9 +40,9 @@ sealed partial class StatusBarModel : IDisposable { | ||||
| 	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); | ||||
| 		serverCountSubscription = state.Db.Servers.TotalCount.SubscribeLastOnUI(newServerCount => ServerCount = newServerCount, TimeSpan.FromMilliseconds(15)); | ||||
| 		channelCountSubscription = state.Db.Channels.TotalCount.SubscribeLastOnUI(newChannelCount => ChannelCount = newChannelCount, TimeSpan.FromMilliseconds(15)); | ||||
| 		messageCountSubscription = state.Db.Messages.TotalCount.SubscribeLastOnUI(newMessageCount => MessageCount = newMessageCount, TimeSpan.FromMilliseconds(15)); | ||||
| 		 | ||||
| 		state.Server.StatusChanged += OnStateServerStatusChanged; | ||||
| 		serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped; | ||||
|   | ||||
| @@ -55,11 +55,11 @@ sealed class DatabasePageModel { | ||||
| 				break; | ||||
| 			 | ||||
| 			case PlatformID.Unix: | ||||
| 				Process.Start("xdg-open", [ folder ]); | ||||
| 				Process.Start("xdg-open", [folder]); | ||||
| 				break; | ||||
| 			 | ||||
| 			case PlatformID.MacOSX: | ||||
| 				Process.Start("open", [ folder ]); | ||||
| 				Process.Start("open", [folder]); | ||||
| 				break; | ||||
| 			 | ||||
| 			default: | ||||
| @@ -80,22 +80,25 @@ sealed class DatabasePageModel { | ||||
| 		 | ||||
| 		const string Title = "Database Merge"; | ||||
| 		 | ||||
| 		ImportResult? result; | ||||
| 		var result = new TaskCompletionSource<ImportResult?>(); | ||||
| 		try { | ||||
| 			result = await ProgressDialog.Show(window, Title, async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback)); | ||||
| 			var dialog = new ProgressDialog(); | ||||
| 			dialog.DataContext = new ProgressDialogModel(Title, async callbacks => result.SetResult(await MergeWithDatabaseFromPaths(Db, paths, dialog, callbacks)), progressItems: 2); | ||||
| 			await dialog.ShowProgressDialog(window); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error("Could not merge databases.", e); | ||||
| 			await Dialog.ShowOk(window, Title, "Could not merge databases: " + e.Message); | ||||
| 			return; | ||||
| 		} | ||||
| 		 | ||||
| 		await Dialog.ShowOk(window, Title, GetImportDialogMessage(result, "database file")); | ||||
| 		await Dialog.ShowOk(window, Title, GetImportDialogMessage(result.Task.Result, "database file")); | ||||
| 	} | ||||
| 	 | ||||
| 	private static async Task<ImportResult?> MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) { | ||||
| 		var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length); | ||||
| 	private static async Task<ImportResult?> MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IReadOnlyList<IProgressCallback> callbacks) { | ||||
| 		var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, callbacks[1], paths.Length); | ||||
| 		var databaseMergeProgressCallback = new DatabaseMergeProgressCallback(callbacks[1]); | ||||
| 		 | ||||
| 		return await PerformImport(target, paths, dialog, callback, "Database Merge", async path => { | ||||
| 		return await PerformImport(target, paths, dialog, callbacks[0], "Database Merge", async path => { | ||||
| 			IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks); | ||||
| 			 | ||||
| 			if (db == null) { | ||||
| @@ -103,7 +106,7 @@ sealed class DatabasePageModel { | ||||
| 			} | ||||
| 			 | ||||
| 			try { | ||||
| 				await target.AddFrom(db); | ||||
| 				await target.Merge(db, databaseMergeProgressCallback); | ||||
| 				return true; | ||||
| 			} finally { | ||||
| 				await db.DisposeAsync(); | ||||
| @@ -111,7 +114,7 @@ sealed class DatabasePageModel { | ||||
| 		}); | ||||
| 	} | ||||
| 	 | ||||
| 	private sealed class SchemaUpgradeCallbacks(ProgressDialog dialog, int total) : ISchemaUpgradeCallbacks { | ||||
| 	private sealed class SchemaUpgradeCallbacks(ProgressDialog dialog, IProgressCallback callback, int total) : ISchemaUpgradeCallbacks { | ||||
| 		private bool? decision; | ||||
| 		 | ||||
| 		public Task<InitialDatabaseSettings?> GetInitialDatabaseSettings() { | ||||
| @@ -125,6 +128,7 @@ sealed class DatabasePageModel { | ||||
| 		} | ||||
| 		 | ||||
| 		public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) { | ||||
| 			callback.UpdateIndeterminate("Upgrading database..."); | ||||
| 			return doUpgrade(new NullReporter()); | ||||
| 		} | ||||
| 		 | ||||
| @@ -143,6 +147,20 @@ sealed class DatabasePageModel { | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private sealed class DatabaseMergeProgressCallback(IProgressCallback callback) : DatabaseMerging.IProgressCallback { | ||||
| 		public void OnImportingMetadata() { | ||||
| 			callback.UpdateIndeterminate("Importing metadata..."); | ||||
| 		} | ||||
| 		 | ||||
| 		public void OnMessagesImported(long finished, long total) { | ||||
| 			callback.Update("Importing messages...", finished, total); | ||||
| 		} | ||||
| 		 | ||||
| 		public void OnDownloadsImported(long finished, long total) { | ||||
| 			callback.Update("Importing downloaded files...", finished, total); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task ImportLegacyArchive() { | ||||
| 		string[] paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions { | ||||
| 			Title = "Open Legacy DHT Archive", | ||||
| @@ -223,7 +241,7 @@ sealed class DatabasePageModel { | ||||
| 		int finished = 0; | ||||
| 		 | ||||
| 		foreach (string path in paths) { | ||||
| 			await callback.Update(Path.GetFileName(path), finished, total); | ||||
| 			await callback.Update("File: " + Path.GetFileName(path), finished, total); | ||||
| 			++finished; | ||||
| 			 | ||||
| 			if (!File.Exists(path)) { | ||||
|   | ||||
| @@ -2,11 +2,9 @@ using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.ComponentModel; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.Controls; | ||||
| using Avalonia.Platform.Storage; | ||||
| using Avalonia.ReactiveUI; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Desktop.Dialogs.File; | ||||
| using DHT.Desktop.Dialogs.Message; | ||||
| @@ -19,6 +17,7 @@ using DHT.Server.Data.Filters; | ||||
| using DHT.Server.Data.Settings; | ||||
| using DHT.Server.Download; | ||||
| using DHT.Utils.Logging; | ||||
| using DHT.Utils.Observables; | ||||
| using DHT.Utils.Tasks; | ||||
| using PropertyChanged.SourceGenerator; | ||||
|  | ||||
| @@ -82,8 +81,8 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 			statisticsSkipped, | ||||
| 		]; | ||||
| 		 | ||||
| 		downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext()); | ||||
| 		downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged); | ||||
| 		downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TimeSpan.FromMilliseconds(100), TaskScheduler.FromCurrentSynchronizationContext()); | ||||
| 		downloadItemCountSubscription = state.Db.Downloads.TotalCount.SubscribeLastOnUI(OnDownloadCountChanged, TimeSpan.FromMilliseconds(15)); | ||||
| 		 | ||||
| 		RecomputeDownloadStatistics(); | ||||
| 	} | ||||
| @@ -145,8 +144,8 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 		 | ||||
| 		try { | ||||
| 			currentDownloadFilter = FilterModel.CreateFilter(); | ||||
| 			IObservable<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter); | ||||
| 			finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished); | ||||
| 			ObservableValue<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter); | ||||
| 			finishedItemsSubscription = finishedItems.SubscribeLastOnUI(OnItemFinished, TimeSpan.FromMilliseconds(15)); | ||||
| 		} catch (Exception) { | ||||
| 			finishedItemsSubscription?.Dispose(); | ||||
| 			finishedItemsSubscription = null; | ||||
| @@ -285,7 +284,7 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private void UpdateStatistics(DownloadStatusStatistics statusStatistics) { | ||||
| 	private Task UpdateStatistics(DownloadStatusStatistics statusStatistics) { | ||||
| 		statisticsPending.Items = statusStatistics.PendingCount; | ||||
| 		statisticsPending.Size = statusStatistics.PendingTotalSize; | ||||
| 		statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0; | ||||
| @@ -304,6 +303,8 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 		 | ||||
| 		HasSuccessfulDownloads = statusStatistics.SuccessfulCount > 0; | ||||
| 		HasFailedDownloads = statusStatistics.FailedCount > 0; | ||||
| 		 | ||||
| 		return Task.CompletedTask; | ||||
| 	} | ||||
| 	 | ||||
| 	public sealed partial class StatisticsRow(string state) { | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| using System.Globalization; | ||||
| using System.Reflection; | ||||
| using Avalonia; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Utils.Logging; | ||||
| using DHT.Utils.Resources; | ||||
|  | ||||
| @@ -57,6 +58,8 @@ static class Program { | ||||
| 	} | ||||
| 	 | ||||
| 	private static AppBuilder BuildAvaloniaApp() { | ||||
| 		AvaloniaReflection.Check(); | ||||
| 		 | ||||
| 		return AppBuilder.Configure<App>() | ||||
| 		                 .UsePlatformDetect() | ||||
| 		                 .WithInterFont() | ||||
|   | ||||
| @@ -107,7 +107,7 @@ export default (function() { | ||||
| 	const isImageUrl = function(url) { | ||||
| 		const dot = url.pathname.lastIndexOf("."); | ||||
| 		const ext = dot === -1 ? "" : url.pathname.substring(dot).toLowerCase(); | ||||
| 		return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg"; | ||||
| 		return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg" || ext === ".webp" || ext === ".avif"; | ||||
| 	}; | ||||
| 	 | ||||
| 	return { | ||||
|   | ||||
| @@ -1,34 +0,0 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
|  | ||||
| namespace DHT.Server.Database; | ||||
|  | ||||
| public static class DatabaseExtensions { | ||||
| 	public static async Task AddFrom(this IDatabaseFile target, IDatabaseFile source) { | ||||
| 		await target.Users.Add(await source.Users.Get().ToListAsync()); | ||||
| 		await target.Servers.Add(await source.Servers.Get().ToListAsync()); | ||||
| 		await target.Channels.Add(await source.Channels.Get().ToListAsync()); | ||||
| 		 | ||||
| 		const int MessageBatchSize = 100; | ||||
| 		List<Message> batchedMessages = new (MessageBatchSize); | ||||
| 		 | ||||
| 		await foreach (Message message in source.Messages.Get()) { | ||||
| 			batchedMessages.Add(message); | ||||
| 			 | ||||
| 			if (batchedMessages.Count >= MessageBatchSize) { | ||||
| 				await target.Messages.Add(batchedMessages); | ||||
| 				batchedMessages.Clear(); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		await target.Messages.Add(batchedMessages); | ||||
| 		 | ||||
| 		await foreach (Data.Download download in source.Downloads.Get()) { | ||||
| 			if (download.Status != DownloadStatus.Success || !await source.Downloads.GetDownloadData(download.NormalizedUrl, stream => target.Downloads.AddDownload(download, stream))) { | ||||
| 				await target.Downloads.AddDownload(download, stream: null); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										77
									
								
								app/Server/Database/Import/DatabaseMerging.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								app/Server/Database/Import/DatabaseMerging.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
|  | ||||
| namespace DHT.Server.Database.Import; | ||||
|  | ||||
| public static class DatabaseMerging { | ||||
| 	public static async Task Merge(this IDatabaseFile target, IDatabaseFile source, IProgressCallback callback) { | ||||
| 		// Import downloads first, otherwise automatic downloads would try to re-download files from other imported data. | ||||
| 		await MergeDownloads(target, source, callback); | ||||
| 		 | ||||
| 		callback.OnImportingMetadata(); | ||||
| 		await target.Users.Add(await source.Users.Get().ToListAsync()); | ||||
| 		await target.Servers.Add(await source.Servers.Get().ToListAsync()); | ||||
| 		await target.Channels.Add(await source.Channels.Get().ToListAsync()); | ||||
| 		 | ||||
| 		await MergeMessages(target, source, callback); | ||||
| 	} | ||||
| 	 | ||||
| 	private static async Task MergeDownloads(IDatabaseFile target, IDatabaseFile source, IProgressCallback callback) { | ||||
| 		const int ReportBatchSize = 100; | ||||
| 		 | ||||
| 		long totalDownloads = await source.Downloads.Count(); | ||||
| 		long importedDownloads = 0; | ||||
| 		 | ||||
| 		callback.OnDownloadsImported(importedDownloads, totalDownloads); | ||||
| 		 | ||||
| 		await foreach (Data.Download download in source.Downloads.Get()) { | ||||
| 			if (download.Status != DownloadStatus.Success || !await source.Downloads.GetDownloadData(download.NormalizedUrl, stream => target.Downloads.AddDownload(download, stream))) { | ||||
| 				await target.Downloads.AddDownload(download, stream: null); | ||||
| 			} | ||||
| 			 | ||||
| 			if (++importedDownloads % ReportBatchSize == 0) { | ||||
| 				callback.OnDownloadsImported(importedDownloads, totalDownloads); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		callback.OnDownloadsImported(totalDownloads, totalDownloads); | ||||
| 	} | ||||
| 	 | ||||
| 	private static async Task MergeMessages(IDatabaseFile target, IDatabaseFile source, IProgressCallback callback) { | ||||
| 		const int MessageBatchSize = 100; | ||||
| 		const int ReportEveryBatches = 10; | ||||
| 		List<Message> batchedMessages = new (MessageBatchSize); | ||||
| 		 | ||||
| 		long totalMessages = await source.Messages.Count(); | ||||
| 		long importedMessages = 0; | ||||
| 		 | ||||
| 		callback.OnMessagesImported(importedMessages, totalMessages); | ||||
| 		 | ||||
| 		await foreach (Message message in source.Messages.Get()) { | ||||
| 			batchedMessages.Add(message); | ||||
| 			 | ||||
| 			if (batchedMessages.Count >= MessageBatchSize) { | ||||
| 				await target.Messages.Add(batchedMessages); | ||||
| 				 | ||||
| 				importedMessages += batchedMessages.Count; | ||||
| 				 | ||||
| 				if (importedMessages % (MessageBatchSize * ReportEveryBatches) == 0) { | ||||
| 					callback.OnMessagesImported(importedMessages, totalMessages); | ||||
| 				} | ||||
| 				 | ||||
| 				batchedMessages.Clear(); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		await target.Messages.Add(batchedMessages); | ||||
| 		callback.OnMessagesImported(totalMessages, totalMessages); | ||||
| 	} | ||||
| 	 | ||||
| 	public interface IProgressCallback { | ||||
| 		void OnImportingMetadata(); | ||||
| 		void OnMessagesImported(long finished, long total); | ||||
| 		void OnDownloadsImported(long finished, long total); | ||||
| 	} | ||||
| } | ||||
| @@ -1,15 +1,14 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Server.Database.Repositories; | ||||
|  | ||||
| public interface IChannelRepository { | ||||
| 	IObservable<long> TotalCount { get; } | ||||
| 	ObservableValue<long> TotalCount { get; } | ||||
| 	 | ||||
| 	Task Add(IReadOnlyList<Channel> channels); | ||||
| 	 | ||||
| @@ -20,7 +19,7 @@ public interface IChannelRepository { | ||||
| 	Task<int> RemoveUnreachable(); | ||||
| 	 | ||||
| 	internal sealed class Dummy : IChannelRepository { | ||||
| 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | ||||
| 		 | ||||
| 		public Task Add(IReadOnlyList<Channel> channels) { | ||||
| 			return Task.CompletedTask; | ||||
|   | ||||
| @@ -2,22 +2,22 @@ using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| using DHT.Server.Data.Aggregations; | ||||
| using DHT.Server.Data.Filters; | ||||
| using DHT.Server.Download; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Server.Database.Repositories; | ||||
|  | ||||
| public interface IDownloadRepository { | ||||
| 	IObservable<long> TotalCount { get; } | ||||
| 	ObservableValue<long> TotalCount { get; } | ||||
| 	 | ||||
| 	Task AddDownload(Data.Download item, Stream? stream); | ||||
| 	 | ||||
| 	Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken = default); | ||||
| 	Task<long> Count(DownloadItemFilter? filter = null, CancellationToken cancellationToken = default); | ||||
| 	 | ||||
| 	Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken = default); | ||||
| 	 | ||||
| @@ -38,13 +38,13 @@ public interface IDownloadRepository { | ||||
| 	IAsyncEnumerable<FileUrl> FindReachableFiles(CancellationToken cancellationToken = default); | ||||
| 	 | ||||
| 	internal sealed class Dummy : IDownloadRepository { | ||||
| 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | ||||
| 		 | ||||
| 		public Task AddDownload(Data.Download item, Stream? stream) { | ||||
| 			return Task.CompletedTask; | ||||
| 		} | ||||
| 		 | ||||
| 		public Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken) { | ||||
| 		public Task<long> Count(DownloadItemFilter? filter, CancellationToken cancellationToken) { | ||||
| 			return Task.FromResult(0L); | ||||
| 		} | ||||
| 		 | ||||
|   | ||||
| @@ -1,16 +1,15 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| using DHT.Server.Data.Filters; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Server.Database.Repositories; | ||||
|  | ||||
| public interface IMessageRepository { | ||||
| 	IObservable<long> TotalCount { get; } | ||||
| 	ObservableValue<long> TotalCount { get; } | ||||
| 	 | ||||
| 	Task Add(IReadOnlyList<Message> messages); | ||||
| 	 | ||||
| @@ -25,7 +24,7 @@ public interface IMessageRepository { | ||||
| 	Task<int> RemoveUnreachableAttachments(); | ||||
| 	 | ||||
| 	internal sealed class Dummy : IMessageRepository { | ||||
| 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | ||||
| 		 | ||||
| 		public Task Add(IReadOnlyList<Message> messages) { | ||||
| 			return Task.CompletedTask; | ||||
|   | ||||
| @@ -1,14 +1,13 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Server.Database.Repositories; | ||||
|  | ||||
| public interface IServerRepository { | ||||
| 	IObservable<long> TotalCount { get; } | ||||
| 	ObservableValue<long> TotalCount { get; } | ||||
| 	 | ||||
| 	Task Add(IReadOnlyList<Data.Server> servers); | ||||
| 	 | ||||
| @@ -19,7 +18,7 @@ public interface IServerRepository { | ||||
| 	Task<int> RemoveUnreachable(); | ||||
| 	 | ||||
| 	internal sealed class Dummy : IServerRepository { | ||||
| 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | ||||
| 		 | ||||
| 		public Task Add(IReadOnlyList<Data.Server> servers) { | ||||
| 			return Task.CompletedTask; | ||||
|   | ||||
| @@ -1,15 +1,14 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Server.Database.Repositories; | ||||
|  | ||||
| public interface IUserRepository { | ||||
| 	IObservable<long> TotalCount { get; } | ||||
| 	ObservableValue<long> TotalCount { get; } | ||||
| 	 | ||||
| 	Task Add(IReadOnlyList<User> users); | ||||
| 	 | ||||
| @@ -20,7 +19,7 @@ public interface IUserRepository { | ||||
| 	Task<int> RemoveUnreachable(); | ||||
| 	 | ||||
| 	internal sealed class Dummy : IUserRepository { | ||||
| 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | ||||
| 		 | ||||
| 		public Task Add(IReadOnlyList<User> users) { | ||||
| 			return Task.CompletedTask; | ||||
|   | ||||
| @@ -1,23 +1,27 @@ | ||||
| using System; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Utils.Logging; | ||||
| using DHT.Utils.Observables; | ||||
| using DHT.Utils.Tasks; | ||||
|  | ||||
| namespace DHT.Server.Database.Sqlite.Repositories; | ||||
|  | ||||
| abstract class BaseSqliteRepository : IDisposable { | ||||
| 	private readonly ObservableThrottledTask<long> totalCountTask; | ||||
| 	private readonly ThrottledTask<long> totalCountTask; | ||||
| 	 | ||||
| 	public IObservable<long> TotalCount { get; } | ||||
| 	public ObservableValue<long> TotalCount { get; } = new (0L); | ||||
| 	 | ||||
| 	protected BaseSqliteRepository(Log log) { | ||||
| 		totalCountTask = new ObservableThrottledTask<long>(log, TaskScheduler.Default); | ||||
| 		TotalCount = totalCountTask.DistinctUntilChanged(); | ||||
| 		totalCountTask = new ThrottledTask<long>(log, SetTotalCount, TimeSpan.Zero, TaskScheduler.Default); | ||||
| 		UpdateTotalCount(); | ||||
| 	} | ||||
| 	 | ||||
| 	private Task SetTotalCount(long newCount) { | ||||
| 		TotalCount.Set(newCount); | ||||
| 		return Task.CompletedTask; | ||||
| 	} | ||||
| 	 | ||||
| 	public void Dispose() { | ||||
| 		totalCountTask.Dispose(); | ||||
| 	} | ||||
|   | ||||
| @@ -306,12 +306,14 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep | ||||
| 			await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); | ||||
| 			 | ||||
| 			while (await reader.ReadAsync(cancellationToken)) { | ||||
| 				found.Add(new DownloadItem { | ||||
| 					NormalizedUrl = reader.GetString(0), | ||||
| 					DownloadUrl = reader.GetString(1), | ||||
| 					Type = reader.IsDBNull(2) ? null : reader.GetString(2), | ||||
| 					Size = reader.IsDBNull(3) ? null : reader.GetUint64(3), | ||||
| 				}); | ||||
| 				var item = new DownloadItem( | ||||
| 					NormalizedUrl: reader.GetString(0), | ||||
| 					DownloadUrl: reader.GetString(1), | ||||
| 					Type: reader.IsDBNull(2) ? null : reader.GetString(2), | ||||
| 					Size: reader.IsDBNull(3) ? null : reader.GetUint64(3) | ||||
| 				); | ||||
| 				 | ||||
| 				found.Add(item); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
|   | ||||
| @@ -4,12 +4,7 @@ using DHT.Server.Data; | ||||
|  | ||||
| namespace DHT.Server.Download; | ||||
|  | ||||
| public readonly struct DownloadItem { | ||||
| 	public string NormalizedUrl { get; init; } | ||||
| 	public string DownloadUrl { get; init; } | ||||
| 	public string? Type { get; init; } | ||||
| 	public ulong? Size { get; init; } | ||||
| 	 | ||||
| public sealed record DownloadItem(string NormalizedUrl, string DownloadUrl, string? Type, ulong? Size) { | ||||
| 	internal Data.Download ToSuccess(long size) { | ||||
| 		return new Data.Download(NormalizedUrl, DownloadUrl, DownloadStatus.Success, Type, (ulong) Math.Max(size, val2: 0)); | ||||
| 	} | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data.Filters; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Server.Download; | ||||
|  | ||||
| @@ -19,11 +19,11 @@ public sealed class Downloader { | ||||
| 		this.concurrentDownloads = concurrentDownloads; | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task<IObservable<DownloadItem>> Start(DownloadItemFilter filter) { | ||||
| 	public async Task<ObservableValue<DownloadItem>> Start(DownloadItemFilter filter) { | ||||
| 		await semaphore.WaitAsync(); | ||||
| 		try { | ||||
| 			current ??= new DownloaderTask(db, filter, concurrentDownloads); | ||||
| 			return current.FinishedItems; | ||||
| 			return current.LastFinishedItem; | ||||
| 		} finally { | ||||
| 			semaphore.Release(); | ||||
| 		} | ||||
|   | ||||
| @@ -4,13 +4,13 @@ using System.IO; | ||||
| using System.Linq; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Reactive.Subjects; | ||||
| using System.Threading; | ||||
| using System.Threading.Channels; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data.Filters; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Utils.Logging; | ||||
| using DHT.Utils.Observables; | ||||
| using DHT.Utils.Tasks; | ||||
|  | ||||
| namespace DHT.Server.Download; | ||||
| @@ -38,12 +38,11 @@ sealed class DownloaderTask : IAsyncDisposable { | ||||
| 	 | ||||
| 	private readonly IDatabaseFile db; | ||||
| 	private readonly DownloadItemFilter filter; | ||||
| 	private readonly ISubject<DownloadItem> finishedItemPublisher = Subject.Synchronize(new Subject<DownloadItem>()); | ||||
| 	 | ||||
| 	private readonly Task queueWriterTask; | ||||
| 	private readonly Task[] downloadTasks; | ||||
| 	 | ||||
| 	public IObservable<DownloadItem> FinishedItems => finishedItemPublisher; | ||||
| 	public ObservableValue<DownloadItem> LastFinishedItem { get; } = new (null); | ||||
| 	 | ||||
| 	internal DownloaderTask(IDatabaseFile db, DownloadItemFilter filter, int? concurrentDownloads) { | ||||
| 		this.db = db; | ||||
| @@ -101,7 +100,7 @@ sealed class DownloaderTask : IAsyncDisposable { | ||||
| 				log.Error("Could not download file: " + item.DownloadUrl, e); | ||||
| 			} finally { | ||||
| 				try { | ||||
| 					finishedItemPublisher.OnNext(item); | ||||
| 					LastFinishedItem.Set(item); | ||||
| 				} catch (Exception e) { | ||||
| 					log.Error("Caught exception in event handler: " + e); | ||||
| 				} | ||||
| @@ -145,7 +144,6 @@ sealed class DownloaderTask : IAsyncDisposable { | ||||
| 			await Task.WhenAll(downloadTasks).WaitIgnoringCancellation(); | ||||
| 		} finally { | ||||
| 			cancellationTokenSource.Dispose(); | ||||
| 			finishedItemPublisher.OnCompleted(); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										63
									
								
								app/Utils/Observables/LastValueObserver.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								app/Utils/Observables/LastValueObserver.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Channels; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace DHT.Utils.Observables; | ||||
|  | ||||
| sealed class LastValueObserver<T> : IDisposable { | ||||
| 	private readonly ObservableValue<T> observable; | ||||
| 	private readonly Func<T, CancellationToken, Task> action; | ||||
| 	private readonly TaskScheduler scheduler; | ||||
| 	 | ||||
| 	private readonly Channel<T> channel = Channel.CreateBounded<T>(new BoundedChannelOptions(capacity: 1) { | ||||
| 		AllowSynchronousContinuations = false, | ||||
| 		FullMode = BoundedChannelFullMode.DropOldest, | ||||
| 		SingleReader = true, | ||||
| 		SingleWriter = false, | ||||
| 	}); | ||||
| 	 | ||||
| 	private readonly CancellationTokenSource cancellationTokenSource = new (); | ||||
| 	 | ||||
| 	public LastValueObserver(ObservableValue<T> observable, Func<T, CancellationToken, Task> action, TaskScheduler scheduler) { | ||||
| 		this.observable = observable; | ||||
| 		this.action = action; | ||||
| 		this.scheduler = scheduler; | ||||
| 		 | ||||
| 		_ = ReadNextValue(); | ||||
| 	} | ||||
| 	 | ||||
| 	private async Task ReadNextValue() { | ||||
| 		CancellationToken cancellationToken = cancellationTokenSource.Token; | ||||
| 		 | ||||
| 		try { | ||||
| 			await foreach (T value in channel.Reader.ReadAllAsync(cancellationToken)) { | ||||
| 				try { | ||||
| 					await Task.Factory.StartNew(UseValue, value, CancellationToken.None, TaskCreationOptions.None, scheduler).WaitAsync(cancellationToken); | ||||
| 				} catch (Exception) { | ||||
| 					// Ignore. | ||||
| 				} | ||||
| 			} | ||||
| 		} finally { | ||||
| 			cancellationTokenSource.Dispose(); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private Task UseValue(object? value) { | ||||
| 		return action((T) value!, cancellationTokenSource.Token); | ||||
| 	} | ||||
| 	 | ||||
| 	public void Notify(T value) { | ||||
| 		channel.Writer.TryWrite(value); | ||||
| 	} | ||||
| 	 | ||||
| 	public void Dispose() { | ||||
| 		observable.Unsubscribe(this); | ||||
| 		 | ||||
| 		try { | ||||
| 			cancellationTokenSource.Cancel(); | ||||
| 		} catch (ObjectDisposedException) {} | ||||
| 		 | ||||
| 		channel.Writer.TryComplete(); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										45
									
								
								app/Utils/Observables/ObservableValue.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								app/Utils/Observables/ObservableValue.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace DHT.Utils.Observables; | ||||
|  | ||||
| public sealed class ObservableValue<T>(T? value) { | ||||
| 	private readonly List<LastValueObserver<T>> observers = []; | ||||
| 	private T? value = value; | ||||
| 	 | ||||
| 	public void Set(T value) { | ||||
| 		lock (this) { | ||||
| 			if (EqualityComparer<T>.Default.Equals(value, this.value)) { | ||||
| 				return; | ||||
| 			} | ||||
| 			 | ||||
| 			this.value = value; | ||||
| 			 | ||||
| 			foreach (var observer in observers) { | ||||
| 				observer.Notify(value); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public IDisposable SubscribeLast(Func<T, CancellationToken, Task> action, TaskScheduler scheduler) { | ||||
| 		var observer = new LastValueObserver<T>(this, action, scheduler); | ||||
| 		 | ||||
| 		lock (this) { | ||||
| 			observers.Add(observer); | ||||
| 			 | ||||
| 			if (value is not null) { | ||||
| 				observer.Notify(value); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		return observer; | ||||
| 	} | ||||
| 	 | ||||
| 	internal void Unsubscribe(LastValueObserver<T> observer) { | ||||
| 		lock (this) { | ||||
| 			observers.Remove(observer); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -1,64 +0,0 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Channels; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Utils.Logging; | ||||
|  | ||||
| namespace DHT.Utils.Tasks; | ||||
|  | ||||
| public sealed class DelayedThrottledTask<T> : IDisposable { | ||||
| 	private readonly Channel<T> taskChannel = Channel.CreateBounded<T>(new BoundedChannelOptions(capacity: 1) { | ||||
| 		SingleReader = true, | ||||
| 		SingleWriter = false, | ||||
| 		AllowSynchronousContinuations = false, | ||||
| 		FullMode = BoundedChannelFullMode.DropOldest, | ||||
| 	}); | ||||
| 	 | ||||
| 	private readonly CancellationTokenSource cancellationTokenSource = new (); | ||||
| 	private readonly Log log; | ||||
| 	private readonly TimeSpan delay; | ||||
| 	private readonly Func<T, Task> inputProcessor; | ||||
| 	 | ||||
| 	public DelayedThrottledTask(Log log, TimeSpan delay, Func<T, Task> inputProcessor) { | ||||
| 		this.log = log; | ||||
| 		this.delay = delay; | ||||
| 		this.inputProcessor = inputProcessor; | ||||
| 		 | ||||
| 		Task.Run(ReaderTask); | ||||
| 	} | ||||
| 	 | ||||
| 	private async Task ReaderTask() { | ||||
| 		CancellationToken cancellationToken = cancellationTokenSource.Token; | ||||
| 		 | ||||
| 		try { | ||||
| 			while (await taskChannel.Reader.WaitToReadAsync(cancellationToken)) { | ||||
| 				await Task.Delay(delay, cancellationToken); | ||||
| 				 | ||||
| 				T input = await taskChannel.Reader.ReadAsync(cancellationToken); | ||||
| 				try { | ||||
| 					await inputProcessor(input); | ||||
| 				} catch (OperationCanceledException) { | ||||
| 					throw; | ||||
| 				} catch (Exception e) { | ||||
| 					log.Error("Caught exception in task: " + e); | ||||
| 				} | ||||
| 			} | ||||
| 		} catch (OperationCanceledException) { | ||||
| 			// Ignore. | ||||
| 		} finally { | ||||
| 			cancellationTokenSource.Dispose(); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public void Post(T input) { | ||||
| 		taskChannel.Writer.TryWrite(input); | ||||
| 	} | ||||
| 	 | ||||
| 	public void Dispose() { | ||||
| 		try { | ||||
| 			cancellationTokenSource.Cancel(); | ||||
| 		} catch (ObjectDisposedException) {} | ||||
| 		 | ||||
| 		taskChannel.Writer.Complete(); | ||||
| 	} | ||||
| } | ||||
| @@ -1,32 +0,0 @@ | ||||
| using System; | ||||
| using System.Reactive.Subjects; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Utils.Logging; | ||||
|  | ||||
| namespace DHT.Utils.Tasks; | ||||
|  | ||||
| public sealed class ObservableThrottledTask<T> : IObservable<T>, IDisposable { | ||||
| 	private readonly ReplaySubject<T> subject; | ||||
| 	private readonly ThrottledTask<T> task; | ||||
| 	 | ||||
| 	public ObservableThrottledTask(Log log, TaskScheduler resultScheduler) { | ||||
| 		this.subject = new ReplaySubject<T>(bufferSize: 1); | ||||
| 		this.task = new ThrottledTask<T>(log, 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(); | ||||
| 	} | ||||
| } | ||||
| @@ -14,11 +14,13 @@ public abstract class ThrottledTaskBase<T> : IDisposable { | ||||
| 		FullMode = BoundedChannelFullMode.DropOldest, | ||||
| 	}); | ||||
| 	 | ||||
| 	private readonly CancellationTokenSource cancellationTokenSource = new (); | ||||
| 	private readonly Log log; | ||||
| 	private readonly TimeSpan delayBetweenRuns; | ||||
| 	private readonly CancellationTokenSource cancellationTokenSource = new (); | ||||
| 	 | ||||
| 	internal ThrottledTaskBase(Log log) { | ||||
| 	internal ThrottledTaskBase(Log log, TimeSpan delayBetweenRuns) { | ||||
| 		this.log = log; | ||||
| 		this.delayBetweenRuns = delayBetweenRuns; | ||||
| 	} | ||||
| 	 | ||||
| 	protected async Task ReaderTask() { | ||||
| @@ -33,9 +35,9 @@ public abstract class ThrottledTaskBase<T> : IDisposable { | ||||
| 				} catch (Exception e) { | ||||
| 					log.Error("Caught exception in task: " + e); | ||||
| 				} | ||||
| 				 | ||||
| 				await Task.Delay(delayBetweenRuns, cancellationToken); | ||||
| 			} | ||||
| 		} catch (OperationCanceledException) { | ||||
| 			// Ignore. | ||||
| 		} finally { | ||||
| 			cancellationTokenSource.Dispose(); | ||||
| 		} | ||||
| @@ -48,20 +50,20 @@ public abstract class ThrottledTaskBase<T> : IDisposable { | ||||
| 	} | ||||
| 	 | ||||
| 	public void Dispose() { | ||||
| 		taskChannel.Writer.Complete(); | ||||
| 		cancellationTokenSource.Cancel(); | ||||
| 		taskChannel.Writer.Complete(); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| public sealed class ThrottledTask : ThrottledTaskBase<Task> { | ||||
| 	private readonly Action resultProcessor; | ||||
| 	private readonly Func<Task> resultProcessor; | ||||
| 	private readonly TaskScheduler resultScheduler; | ||||
| 	 | ||||
| 	public ThrottledTask(Log log, Action resultProcessor, TaskScheduler resultScheduler) : base(log) { | ||||
| 	public ThrottledTask(Log log, Func<Task> resultProcessor, TimeSpan delayBetweenRuns, TaskScheduler resultScheduler) : base(log, delayBetweenRuns) { | ||||
| 		this.resultProcessor = resultProcessor; | ||||
| 		this.resultScheduler = resultScheduler; | ||||
| 		 | ||||
| 		Task.Run(ReaderTask); | ||||
| 		_ = ReaderTask(); | ||||
| 	} | ||||
| 	 | ||||
| 	protected override async Task Run(Func<CancellationToken, Task> func, CancellationToken cancellationToken) { | ||||
| @@ -71,18 +73,22 @@ public sealed class ThrottledTask : ThrottledTaskBase<Task> { | ||||
| } | ||||
|  | ||||
| public sealed class ThrottledTask<T> : ThrottledTaskBase<Task<T>> { | ||||
| 	private readonly Action<T> resultProcessor; | ||||
| 	private readonly Func<T, Task> resultProcessor; | ||||
| 	private readonly TaskScheduler resultScheduler; | ||||
| 	 | ||||
| 	public ThrottledTask(Log log, Action<T> resultProcessor, TaskScheduler resultScheduler) : base(log) { | ||||
| 	public ThrottledTask(Log log, Func<T, Task> resultProcessor, TimeSpan delayBetweenRuns, TaskScheduler resultScheduler) : base(log, delayBetweenRuns) { | ||||
| 		this.resultProcessor = resultProcessor; | ||||
| 		this.resultScheduler = resultScheduler; | ||||
| 		 | ||||
| 		Task.Run(ReaderTask); | ||||
| 		_ = ReaderTask(); | ||||
| 	} | ||||
| 	 | ||||
| 	protected override async Task Run(Func<CancellationToken, Task<T>> func, CancellationToken cancellationToken) { | ||||
| 		T result = await func(cancellationToken); | ||||
| 		await Task.Factory.StartNew(() => resultProcessor(result), cancellationToken, TaskCreationOptions.None, resultScheduler); | ||||
| 	} | ||||
| 	 | ||||
| 	public void Post(T result) { | ||||
| 		base.Post(_ => Task.FromResult(result)); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -16,7 +16,6 @@ | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="JetBrains.Annotations" Version="2023.2.0" /> | ||||
|     <PackageReference Include="System.Reactive" Version="6.0.0" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|   | ||||
| @@ -8,5 +8,5 @@ using DHT.Utils; | ||||
| namespace DHT.Utils; | ||||
|  | ||||
| static class Version { | ||||
| 	public const string Tag = "47.1.0.0"; | ||||
| 	public const string Tag = "47.2.0.0"; | ||||
| } | ||||
|   | ||||
| @@ -22,7 +22,6 @@ define('LATEST_VERSION', $version === false ? '_' : $version); | ||||
|       <h1>Discord History Tracker <span class="bar">|</span> <span class="notes"><a href="https://github.com/chylex/Discord-History-Tracker/releases">Release Notes</a></span></h1> | ||||
|       <p>Discord History Tracker lets you save chat history in your servers, groups, and private conversations, and view it offline.</p> | ||||
|       <img src="img/tracker.png" width="851" class="dht bordered"> | ||||
|       <p>This page explains how to use the desktop app. If you are looking for the older version of Discord History Tracker which only needs a browser or the Discord app, visit the page for the <a href="https://dht.chylex.com/browser-only">browser-only version</a>, however keep in mind that this version has <strong>significant limitations and fewer features</strong>.</p> | ||||
|        | ||||
|       <h2>How to Use</h2> | ||||
|       <p>Download the latest version of the desktop app here, or visit <a href="https://github.com/chylex/Discord-History-Tracker/releases">All Releases</a> for older versions and release notes.</p> | ||||
| @@ -71,9 +70,9 @@ define('LATEST_VERSION', $version === false ? '_' : $version); | ||||
|        | ||||
|       <h2>External Links</h2> | ||||
|       <p class="links"> | ||||
|         <a href="https://github.com/chylex/Discord-History-Tracker/issues">Issues & Suggestions</a>  —  | ||||
|         <a href="https://github.com/chylex/Discord-History-Tracker">Source Code</a>  —  | ||||
|         <a href="https://twitter.com/chylexmc">Follow Dev on Twitter</a>  —  | ||||
|         <a href="https://github.com/chylex/Discord-History-Tracker/issues">Issues & Suggestions</a>  •  | ||||
|         <a href="https://github.com/chylex/Discord-History-Tracker">Source Code</a>  •  | ||||
|         <a href="https://chylex.com">Developer's Website</a>  •  | ||||
|         <a href="https://ko-fi.com/chylex">Support via Ko-fi</a> | ||||
|       </p> | ||||
|        | ||||
|   | ||||
| @@ -165,7 +165,7 @@ code { | ||||
|  | ||||
| .downloads svg { | ||||
|   margin: 1px 4px; | ||||
|   vertical-align: -25%; | ||||
|   vertical-align: -30%; | ||||
| } | ||||
|  | ||||
| .downloads svg.icon-large { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user