mirror of
				https://github.com/chylex/Discord-History-Tracker.git
				synced 2025-10-31 11:17:15 +01:00 
			
		
		
		
	Compare commits
	
		
			7 Commits
		
	
	
		
			v42.1
			...
			e1eae393c3
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e1eae393c3 | |||
| fbed74529d | |||
| 8924aa7c06 | |||
| 9738880ba7 | |||
| 1700f99bf7 | |||
| 84acf5f5d5 | |||
| ae56433836 | 
| @@ -15,14 +15,14 @@ | ||||
|   </PropertyGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Avalonia" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia.Desktop" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " /> | ||||
|     <PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia.ReactiveUI" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia" Version="11.0.11" /> | ||||
|     <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.11" /> | ||||
|     <PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.11" /> | ||||
|     <PackageReference Include="Avalonia.Desktop" Version="11.0.11" /> | ||||
|     <PackageReference Include="Avalonia.Diagnostics" Version="11.0.11" Condition=" '$(Configuration)' == 'Debug' " /> | ||||
|     <PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.11" /> | ||||
|     <PackageReference Include="Avalonia.ReactiveUI" Version="11.0.11" /> | ||||
|     <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.11" /> | ||||
|     <PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.ComponentModel; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.ReactiveUI; | ||||
| @@ -8,13 +9,17 @@ using CommunityToolkit.Mvvm.ComponentModel; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Server; | ||||
| using DHT.Server.Data.Filters; | ||||
| using DHT.Server.Data.Settings; | ||||
| using DHT.Utils.Logging; | ||||
| using DHT.Utils.Tasks; | ||||
|  | ||||
| namespace DHT.Desktop.Main.Controls; | ||||
|  | ||||
| sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposable { | ||||
| sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisposable { | ||||
| 	private static readonly Log Log = Log.ForType<DownloadItemFilterPanelModel>(); | ||||
| 	 | ||||
| 	public sealed record Unit(string Name, uint Scale); | ||||
|  | ||||
| 	 | ||||
| 	private static readonly Unit[] AllUnits = [ | ||||
| 		new Unit("B", 1), | ||||
| 		new Unit("kB", 1024), | ||||
| @@ -33,7 +38,7 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposabl | ||||
| 	private bool limitSize = false; | ||||
| 	 | ||||
| 	[ObservableProperty] | ||||
| 	private ulong maximumSize = 0L; | ||||
| 	private ulong maximumSize = 0UL; | ||||
| 	 | ||||
| 	[ObservableProperty] | ||||
| 	private Unit maximumSizeUnit = AllUnits[0]; | ||||
| @@ -43,6 +48,9 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposabl | ||||
| 	private readonly State state; | ||||
| 	private readonly string verb; | ||||
|  | ||||
| 	private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask; | ||||
| 	private bool isLoadingFilterSettings; | ||||
|  | ||||
| 	private readonly RestartableTask<long> downloadItemCountTask; | ||||
| 	private long? matchingItemCount; | ||||
| 	 | ||||
| @@ -56,6 +64,8 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposabl | ||||
| 		this.state = state; | ||||
| 		this.verb = verb; | ||||
|  | ||||
| 		this.saveFilterSettingsTask = new DelayedThrottledTask<FilterSettings>(Log, TimeSpan.FromSeconds(5), SaveFilterSettings); | ||||
|  | ||||
| 		this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext()); | ||||
| 		this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged); | ||||
|  | ||||
| @@ -64,13 +74,51 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposabl | ||||
| 		PropertyChanged += OnPropertyChanged; | ||||
| 	} | ||||
|  | ||||
| 	public void Dispose() { | ||||
| 	public async Task Initialize() { | ||||
| 		isLoadingFilterSettings = true; | ||||
| 		 | ||||
| 		LimitSize = await state.Db.Settings.Get(SettingsKey.DownloadsLimitSize, LimitSize); | ||||
| 		MaximumSize = await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSize, MaximumSize); | ||||
|  | ||||
| 		if (await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSizeUnit, null) is {} unitName && AllUnits.FirstOrDefault(unit => unit.Name == unitName) is {} unitValue) { | ||||
| 			MaximumSizeUnit = unitValue; | ||||
| 		} | ||||
| 		 | ||||
| 		isLoadingFilterSettings = false; | ||||
| 	} | ||||
|  | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| 		saveFilterSettingsTask.Dispose(); | ||||
| 		 | ||||
| 		downloadItemCountTask.Cancel(); | ||||
| 		downloadItemCountSubscription.Dispose(); | ||||
| 		 | ||||
| 		await SaveFilterSettings(new FilterSettings(this)); | ||||
| 	} | ||||
| 	 | ||||
| 	private sealed record FilterSettings(bool LimitSize, ulong MaximumSize, Unit MaximumSizeUnit) { | ||||
| 		public FilterSettings(DownloadItemFilterPanelModel model) : this(model.LimitSize, model.MaximumSize, model.MaximumSizeUnit) {} | ||||
| 	} | ||||
| 	 | ||||
| 	private async Task SaveFilterSettings(FilterSettings settings) { | ||||
| 		try { | ||||
| 			await state.Db.Settings.Set(async setter => { | ||||
| 				await setter.Set(SettingsKey.DownloadsLimitSize, settings.LimitSize); | ||||
| 				await setter.Set(SettingsKey.DownloadsMaximumSize, settings.MaximumSize); | ||||
| 				await setter.Set(SettingsKey.DownloadsMaximumSizeUnit, settings.MaximumSizeUnit.Name); | ||||
| 			}); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error("Could not save download filter settings"); | ||||
| 			Log.Error(e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { | ||||
| 		if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) { | ||||
| 			if (!isLoadingFilterSettings) { | ||||
| 				saveFilterSettingsTask.Post(new FilterSettings(this)); | ||||
| 			} | ||||
| 			 | ||||
| 			UpdateFilterStatistics(); | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -95,6 +95,8 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable { | ||||
| 		mainContentScreenModel = new MainContentScreenModel(window, state); | ||||
| 		mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed; | ||||
| 		 | ||||
| 		await mainContentScreenModel.Initialize(); | ||||
| 		 | ||||
| 		Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle; | ||||
| 		CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel }; | ||||
|  | ||||
| @@ -104,7 +106,7 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable { | ||||
| 	private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) { | ||||
| 		if (mainContentScreenModel != null) { | ||||
| 			mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed; | ||||
| 			mainContentScreenModel.Dispose(); | ||||
| 			await mainContentScreenModel.DisposeAsync(); | ||||
| 			mainContentScreenModel = null; | ||||
| 		} | ||||
|  | ||||
| @@ -124,7 +126,10 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable { | ||||
| 	} | ||||
|  | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| 		mainContentScreenModel?.Dispose(); | ||||
| 		if (mainContentScreenModel != null) { | ||||
| 			await mainContentScreenModel.DisposeAsync(); | ||||
| 		} | ||||
| 		 | ||||
| 		await DisposeState(); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -31,8 +31,14 @@ | ||||
|     </UserControl.Styles> | ||||
|  | ||||
|     <StackPanel Orientation="Vertical" Spacing="20"> | ||||
|         <Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" /> | ||||
|         <StackPanel Orientation="Horizontal" Spacing="10"> | ||||
|             <Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" /> | ||||
|             <Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button> | ||||
|         </StackPanel> | ||||
|         <controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" /> | ||||
|         <TextBlock TextWrapping="Wrap"> | ||||
|             Downloading state and filter settings are remembered per-database. | ||||
|         </TextBlock> | ||||
|         <StackPanel Orientation="Vertical" Spacing="12"> | ||||
|             <Expander Header="Download Status" IsExpanded="True"> | ||||
|                 <DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True"> | ||||
| @@ -43,9 +49,6 @@ | ||||
|                     </DataGrid.Columns> | ||||
|                 </DataGrid> | ||||
|             </Expander> | ||||
|             <StackPanel Orientation="Horizontal" Spacing="10"> | ||||
|                 <Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button> | ||||
|             </StackPanel> | ||||
|         </StackPanel> | ||||
|     </StackPanel> | ||||
| </UserControl> | ||||
|   | ||||
| @@ -9,13 +9,14 @@ using DHT.Desktop.Main.Controls; | ||||
| using DHT.Server; | ||||
| using DHT.Server.Data.Aggregations; | ||||
| using DHT.Server.Data.Filters; | ||||
| using DHT.Server.Data.Settings; | ||||
| using DHT.Server.Download; | ||||
| using DHT.Utils.Logging; | ||||
| using DHT.Utils.Tasks; | ||||
|  | ||||
| namespace DHT.Desktop.Main.Pages; | ||||
|  | ||||
| sealed partial class DownloadsPageModel : ObservableObject, IDisposable { | ||||
| sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable { | ||||
| 	private static readonly Log Log = Log.ForType<DownloadsPageModel>(); | ||||
|  | ||||
| 	[ObservableProperty(Setter = Access.Private)] | ||||
| @@ -73,14 +74,22 @@ sealed partial class DownloadsPageModel : ObservableObject, IDisposable { | ||||
|  | ||||
| 		RecomputeDownloadStatistics(); | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task Initialize() { | ||||
| 		await FilterModel.Initialize(); | ||||
| 		 | ||||
| 		if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, false)) { | ||||
| 			await StartDownload(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public void Dispose() { | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| 		finishedItemsSubscription?.Dispose(); | ||||
| 		 | ||||
| 		downloadItemCountSubscription.Dispose(); | ||||
| 		downloadStatisticsTask.Dispose(); | ||||
|  | ||||
| 		FilterModel.Dispose(); | ||||
| 		await FilterModel.DisposeAsync(); | ||||
| 	} | ||||
|  | ||||
| 	private void OnDownloadCountChanged(long newDownloadCount) { | ||||
| @@ -91,26 +100,41 @@ sealed partial class DownloadsPageModel : ObservableObject, IDisposable { | ||||
| 		IsToggleDownloadButtonEnabled = false; | ||||
|  | ||||
| 		if (IsDownloading) { | ||||
| 			await state.Downloader.Stop(); | ||||
| 			await state.Db.Downloads.MoveDownloadingItemsBackToQueue(); | ||||
|  | ||||
| 			finishedItemsSubscription?.Dispose(); | ||||
| 			finishedItemsSubscription = null; | ||||
| 			 | ||||
| 			currentDownloadFilter = null; | ||||
| 			await StopDownload(); | ||||
| 		} | ||||
| 		else { | ||||
| 			await state.Db.Downloads.MoveDownloadingItemsBackToQueue(); | ||||
| 			 | ||||
| 			var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter()); | ||||
| 			finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished); | ||||
| 			await StartDownload(); | ||||
| 		} | ||||
|  | ||||
| 		await state.Db.Settings.Set(SettingsKey.DownloadsAutoStart, IsDownloading); | ||||
| 		IsToggleDownloadButtonEnabled = true; | ||||
| 	} | ||||
|  | ||||
| 	private async Task StartDownload() { | ||||
| 		await state.Db.Downloads.MoveDownloadingItemsBackToQueue(); | ||||
| 		 | ||||
| 		var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter()); | ||||
| 		finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished); | ||||
| 		 | ||||
| 		OnDownloadStateChanged(); | ||||
| 	} | ||||
|  | ||||
| 	private async Task StopDownload() { | ||||
| 		await state.Downloader.Stop(); | ||||
| 		await state.Db.Downloads.MoveDownloadingItemsBackToQueue(); | ||||
|  | ||||
| 		finishedItemsSubscription?.Dispose(); | ||||
| 		finishedItemsSubscription = null; | ||||
| 			 | ||||
| 		currentDownloadFilter = null; | ||||
| 		OnDownloadStateChanged(); | ||||
| 	} | ||||
|  | ||||
| 	private void OnDownloadStateChanged() { | ||||
| 		RecomputeDownloadStatistics(); | ||||
|  | ||||
| 		OnPropertyChanged(nameof(ToggleDownloadButtonText)); | ||||
| 		OnPropertyChanged(nameof(IsDownloading)); | ||||
| 		IsToggleDownloadButtonEnabled = true; | ||||
| 	} | ||||
|  | ||||
| 	private void OnItemFinished(DownloadItem item) { | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| using System; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.Controls; | ||||
| using DHT.Desktop.Main.Controls; | ||||
| using DHT.Desktop.Main.Pages; | ||||
| @@ -6,7 +7,7 @@ using DHT.Server; | ||||
|  | ||||
| namespace DHT.Desktop.Main.Screens; | ||||
|  | ||||
| sealed class MainContentScreenModel : IDisposable { | ||||
| sealed class MainContentScreenModel : IAsyncDisposable { | ||||
| 	public DatabasePage DatabasePage { get; } | ||||
| 	private DatabasePageModel DatabasePageModel { get; } | ||||
|  | ||||
| @@ -70,9 +71,13 @@ sealed class MainContentScreenModel : IDisposable { | ||||
|  | ||||
| 		StatusBarModel = new StatusBarModel(state); | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task Initialize() { | ||||
| 		await DownloadsPageModel.Initialize(); | ||||
| 	} | ||||
|  | ||||
| 	public void Dispose() { | ||||
| 		DownloadsPageModel.Dispose(); | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| 		await DownloadsPageModel.DisposeAsync(); | ||||
| 		ViewerPageModel.Dispose(); | ||||
| 		AdvancedPageModel.Dispose(); | ||||
| 		StatusBarModel.Dispose(); | ||||
|   | ||||
| @@ -7,6 +7,7 @@ using CommunityToolkit.Mvvm.ComponentModel; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Desktop.Dialogs.Message; | ||||
| using DHT.Desktop.Dialogs.Progress; | ||||
| using DHT.Server.Data.Settings; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Server.Database.Sqlite.Schema; | ||||
|  | ||||
| @@ -46,10 +47,18 @@ sealed partial class WelcomeScreenModel : ObservableObject { | ||||
| 	public async Task OpenOrCreateDatabaseFromPath(string path) { | ||||
| 		dbFilePath = path; | ||||
| 		 | ||||
| 		bool isNew = !File.Exists(path); | ||||
| 		 | ||||
| 		var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window)); | ||||
| 		if (db != null) { | ||||
| 			DatabaseSelected?.Invoke(this, db); | ||||
| 		if (db == null) { | ||||
| 			return; | ||||
| 		} | ||||
| 		 | ||||
| 		if (isNew && await Dialog.ShowYesNo(window, "Automatic Downloads", "Do you want to automatically download files hosted on Discord? You can change this later in the Downloads tab.") == DialogResult.YesNo.Yes) { | ||||
| 			await db.Settings.Set(SettingsKey.DownloadsAutoStart, true); | ||||
| 		} | ||||
| 		 | ||||
| 		DatabaseSelected?.Invoke(this, db); | ||||
| 	} | ||||
|  | ||||
| 	private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks { | ||||
|   | ||||
							
								
								
									
										60
									
								
								app/Server/Data/Settings/SettingsKey.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								app/Server/Data/Settings/SettingsKey.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
|  | ||||
| namespace DHT.Server.Data.Settings; | ||||
|  | ||||
| public static class SettingsKey { | ||||
| 	public static Bool DownloadsAutoStart { get; } = new ("downloads_auto_start"); | ||||
| 	public static Bool DownloadsLimitSize { get; } = new ("downloads_limit_size"); | ||||
| 	public static UnsignedLong DownloadsMaximumSize { get; } = new ("downloads_maximum_size"); | ||||
| 	public static String DownloadsMaximumSizeUnit { get; } = new ("downloads_maximum_size_unit"); | ||||
|  | ||||
| 	public sealed class String(string key) : SettingsKey<string>(key) { | ||||
| 		internal override bool FromString(string value, out string result) { | ||||
| 			result = value; | ||||
| 			return true; | ||||
| 		} | ||||
|  | ||||
| 		internal override string ToString(string value) { | ||||
| 			return value; | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public sealed class Bool(string key) : SettingsKey<bool>(key) { | ||||
| 		internal override bool FromString(string value, out bool result) { | ||||
| 			switch (value) { | ||||
| 				case "1": | ||||
| 					result = true; | ||||
| 					return true; | ||||
|  | ||||
| 				case "0": | ||||
| 					result = false; | ||||
| 					return true; | ||||
|  | ||||
| 				default: | ||||
| 					result = false; | ||||
| 					return false; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		internal override string ToString(bool value) { | ||||
| 			return value ? "1" : "0"; | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public sealed class UnsignedLong(string key) : SettingsKey<ulong>(key) { | ||||
| 		internal override bool FromString(string value, out ulong result) { | ||||
| 			return ulong.TryParse(value, out result); | ||||
| 		} | ||||
|  | ||||
| 		internal override string ToString(ulong value) { | ||||
| 			return value.ToString(); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| public abstract class SettingsKey<T>(string key) { | ||||
| 	internal string Key => key; | ||||
|  | ||||
| 	internal abstract bool FromString(string value, [NotNullWhen(true)] out T result); | ||||
| 	internal abstract string ToString(T value); | ||||
| } | ||||
| @@ -10,6 +10,7 @@ sealed class DummyDatabaseFile : IDatabaseFile { | ||||
|  | ||||
| 	public string Path => ""; | ||||
| 	 | ||||
| 	public ISettingsRepository Settings { get; } = new ISettingsRepository.Dummy(); | ||||
| 	public IUserRepository Users { get; } = new IUserRepository.Dummy(); | ||||
| 	public IServerRepository Servers { get; } = new IServerRepository.Dummy(); | ||||
| 	public IChannelRepository Channels { get; } = new IChannelRepository.Dummy(); | ||||
|   | ||||
| @@ -7,6 +7,7 @@ namespace DHT.Server.Database; | ||||
| public interface IDatabaseFile : IAsyncDisposable { | ||||
| 	string Path { get; } | ||||
|  | ||||
| 	ISettingsRepository Settings { get; } | ||||
| 	IUserRepository Users { get; } | ||||
| 	IServerRepository Servers { get; } | ||||
| 	IChannelRepository Channels { get; } | ||||
|   | ||||
							
								
								
									
										31
									
								
								app/Server/Database/Repositories/ISettingsRepository.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								app/Server/Database/Repositories/ISettingsRepository.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| using System; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data.Settings; | ||||
|  | ||||
| namespace DHT.Server.Database.Repositories; | ||||
|  | ||||
| public interface ISettingsRepository { | ||||
| 	Task Set<T>(SettingsKey<T> key, T value); | ||||
| 	 | ||||
| 	Task Set(Func<ISetter, Task> setter); | ||||
| 	 | ||||
| 	Task<T?> Get<T>(SettingsKey<T> key, T? defaultValue); | ||||
| 	 | ||||
| 	interface ISetter { | ||||
| 		Task Set<T>(SettingsKey<T> key, T value); | ||||
| 	} | ||||
| 	 | ||||
| 	internal sealed class Dummy : ISettingsRepository { | ||||
| 		public Task Set<T>(SettingsKey<T> key, T value) { | ||||
| 			return Task.CompletedTask; | ||||
| 		} | ||||
|  | ||||
| 		public Task Set(Func<ISetter, Task> setter) { | ||||
| 			return Task.CompletedTask; | ||||
| 		} | ||||
|  | ||||
| 		public Task<T?> Get<T>(SettingsKey<T> key, T? defaultValue) { | ||||
| 			return Task.FromResult(defaultValue); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -20,7 +20,7 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep | ||||
|  | ||||
| 	internal sealed class NewDownloadCollector : IAsyncDisposable { | ||||
| 		private readonly SqliteDownloadRepository repository; | ||||
| 		private bool hasAdded = false; | ||||
| 		private bool hasChanged = false; | ||||
|  | ||||
| 		private readonly SqliteCommand metadataCmd; | ||||
|  | ||||
| @@ -31,7 +31,16 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep | ||||
| 				""" | ||||
| 				INSERT INTO download_metadata (normalized_url, download_url, status, type, size) | ||||
| 				VALUES (:normalized_url, :download_url, :status, :type, :size) | ||||
| 				ON CONFLICT DO NOTHING | ||||
| 				ON CONFLICT (normalized_url) | ||||
| 				DO UPDATE SET | ||||
| 					download_url = excluded.download_url, | ||||
| 					type = IFNULL(excluded.type, type), | ||||
| 					size = IFNULL(excluded.size, size) | ||||
| 				WHERE status != :success | ||||
| 				  AND (download_url != excluded.download_url | ||||
| 				    OR (excluded.type IS NOT NULL AND type IS NOT excluded.type) | ||||
| 				    OR (excluded.size IS NOT NULL AND size IS NOT excluded.size) | ||||
| 				  ) | ||||
| 				""" | ||||
| 			); | ||||
| 			metadataCmd.Add(":normalized_url", SqliteType.Text); | ||||
| @@ -39,6 +48,7 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep | ||||
| 			metadataCmd.Add(":status", SqliteType.Integer); | ||||
| 			metadataCmd.Add(":type", SqliteType.Text); | ||||
| 			metadataCmd.Add(":size", SqliteType.Integer); | ||||
| 			metadataCmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success); | ||||
| 		} | ||||
|  | ||||
| 		public async Task Add(Data.Download download) { | ||||
| @@ -47,11 +57,11 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep | ||||
| 			metadataCmd.Set(":status", (int) download.Status); | ||||
| 			metadataCmd.Set(":type", download.Type); | ||||
| 			metadataCmd.Set(":size", download.Size); | ||||
| 			hasAdded |= await metadataCmd.ExecuteNonQueryAsync() > 0; | ||||
| 			hasChanged |= await metadataCmd.ExecuteNonQueryAsync() > 0; | ||||
| 		} | ||||
|  | ||||
| 		public void OnCommitted() { | ||||
| 			if (hasAdded) { | ||||
| 			if (hasChanged) { | ||||
| 				repository.UpdateTotalCount(); | ||||
| 			} | ||||
| 		} | ||||
| @@ -90,7 +100,8 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep | ||||
| 					""" | ||||
| 					INSERT INTO download_blobs (normalized_url, blob) | ||||
| 					VALUES (:normalized_url, ZEROBLOB(:blob_length)) | ||||
| 					ON CONFLICT (normalized_url) DO UPDATE SET blob = excluded.blob | ||||
| 					ON CONFLICT (normalized_url) | ||||
| 					DO UPDATE SET blob = excluded.blob | ||||
| 					RETURNING rowid | ||||
| 					""" | ||||
| 				); | ||||
|   | ||||
| @@ -14,16 +14,8 @@ using Microsoft.Data.Sqlite; | ||||
|  | ||||
| namespace DHT.Server.Database.Sqlite.Repositories; | ||||
|  | ||||
| sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository { | ||||
| sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRepository downloads) : BaseSqliteRepository(Log), IMessageRepository { | ||||
| 	private static readonly Log Log = Log.ForType<SqliteMessageRepository>(); | ||||
| 	 | ||||
| 	private readonly SqliteConnectionPool pool; | ||||
| 	private readonly SqliteDownloadRepository downloads; | ||||
|  | ||||
| 	public SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRepository downloads) : base(Log) { | ||||
| 		this.pool = pool; | ||||
| 		this.downloads = downloads; | ||||
| 	} | ||||
|  | ||||
| 	public async Task Add(IReadOnlyList<Message> messages) { | ||||
| 		if (messages.Count == 0) { | ||||
| @@ -50,25 +42,7 @@ sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository | ||||
| 				("timestamp", SqliteType.Integer) | ||||
| 			]); | ||||
|  | ||||
| 			await using var deleteEditTimestampCmd = DeleteByMessageId(conn, "edit_timestamps"); | ||||
| 			await using var deleteRepliedToCmd = DeleteByMessageId(conn, "replied_to"); | ||||
|  | ||||
| 			await using var deleteAttachmentsCmd = DeleteByMessageId(conn, "attachments"); | ||||
| 			await using var deleteEmbedsCmd = DeleteByMessageId(conn, "embeds"); | ||||
| 			await using var deleteReactionsCmd = DeleteByMessageId(conn, "reactions"); | ||||
|  | ||||
| 			await using var editTimestampCmd = conn.Insert("edit_timestamps", [ | ||||
| 				("message_id", SqliteType.Integer), | ||||
| 				("edit_timestamp", SqliteType.Integer) | ||||
| 			]); | ||||
|  | ||||
| 			await using var repliedToCmd = conn.Insert("replied_to", [ | ||||
| 				("message_id", SqliteType.Integer), | ||||
| 				("replied_to_id", SqliteType.Integer) | ||||
| 			]); | ||||
|  | ||||
| 			await using var attachmentCmd = conn.Insert("attachments", [ | ||||
| 				("message_id", SqliteType.Integer), | ||||
| 			await using var attachmentCmd = conn.Upsert("attachments", [ | ||||
| 				("attachment_id", SqliteType.Integer), | ||||
| 				("name", SqliteType.Text), | ||||
| 				("type", SqliteType.Text), | ||||
| @@ -79,19 +53,41 @@ sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository | ||||
| 				("height", SqliteType.Integer) | ||||
| 			]); | ||||
|  | ||||
| 			await using var embedCmd = conn.Insert("embeds", [ | ||||
| 			await using var deleteMessageEditTimestampCmd = DeleteByMessageId(conn, "message_edit_timestamps"); | ||||
| 			await using var deleteMessageRepliedToCmd = DeleteByMessageId(conn, "message_replied_to"); | ||||
|  | ||||
| 			await using var deleteMessageAttachmentsCmd = DeleteByMessageId(conn, "message_attachments"); | ||||
| 			await using var deleteMessageEmbedsCmd = DeleteByMessageId(conn, "message_embeds"); | ||||
| 			await using var deleteMessageReactionsCmd = DeleteByMessageId(conn, "message_reactions"); | ||||
|  | ||||
| 			await using var messageEditTimestampCmd = conn.Insert("message_edit_timestamps", [ | ||||
| 				("message_id", SqliteType.Integer), | ||||
| 				("edit_timestamp", SqliteType.Integer) | ||||
| 			]); | ||||
|  | ||||
| 			await using var messageRepliedToCmd = conn.Insert("message_replied_to", [ | ||||
| 				("message_id", SqliteType.Integer), | ||||
| 				("replied_to_id", SqliteType.Integer) | ||||
| 			]); | ||||
|  | ||||
| 			await using var messageAttachmentCmd = conn.Insert("message_attachments", [ | ||||
| 				("message_id", SqliteType.Integer), | ||||
| 				("attachment_id", SqliteType.Integer) | ||||
| 			]); | ||||
|  | ||||
| 			await using var messageEmbedCmd = conn.Insert("message_embeds", [ | ||||
| 				("message_id", SqliteType.Integer), | ||||
| 				("json", SqliteType.Text) | ||||
| 			]); | ||||
|  | ||||
| 			await using var reactionCmd = conn.Insert("reactions", [ | ||||
| 			await using var messageReactionCmd = conn.Insert("message_reactions", [ | ||||
| 				("message_id", SqliteType.Integer), | ||||
| 				("emoji_id", SqliteType.Integer), | ||||
| 				("emoji_name", SqliteType.Text), | ||||
| 				("emoji_flags", SqliteType.Integer), | ||||
| 				("count", SqliteType.Integer) | ||||
| 			]); | ||||
| 			 | ||||
|  | ||||
| 			await using var downloadCollector = new SqliteDownloadRepository.NewDownloadCollector(downloads, conn); | ||||
|  | ||||
| 			foreach (var message in messages) { | ||||
| @@ -104,29 +100,30 @@ sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository | ||||
| 				messageCmd.Set(":timestamp", message.Timestamp); | ||||
| 				await messageCmd.ExecuteNonQueryAsync(); | ||||
|  | ||||
| 				await ExecuteDeleteByMessageId(deleteEditTimestampCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteRepliedToCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteMessageEditTimestampCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteMessageRepliedToCmd, messageId); | ||||
|  | ||||
| 				await ExecuteDeleteByMessageId(deleteAttachmentsCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteEmbedsCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteReactionsCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteMessageAttachmentsCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteMessageEmbedsCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteMessageReactionsCmd, messageId); | ||||
|  | ||||
| 				if (message.EditTimestamp is {} timestamp) { | ||||
| 					editTimestampCmd.Set(":message_id", messageId); | ||||
| 					editTimestampCmd.Set(":edit_timestamp", timestamp); | ||||
| 					await editTimestampCmd.ExecuteNonQueryAsync(); | ||||
| 					messageEditTimestampCmd.Set(":message_id", messageId); | ||||
| 					messageEditTimestampCmd.Set(":edit_timestamp", timestamp); | ||||
| 					await messageEditTimestampCmd.ExecuteNonQueryAsync(); | ||||
| 				} | ||||
|  | ||||
| 				if (message.RepliedToId is {} repliedToId) { | ||||
| 					repliedToCmd.Set(":message_id", messageId); | ||||
| 					repliedToCmd.Set(":replied_to_id", repliedToId); | ||||
| 					await repliedToCmd.ExecuteNonQueryAsync(); | ||||
| 					messageRepliedToCmd.Set(":message_id", messageId); | ||||
| 					messageRepliedToCmd.Set(":replied_to_id", repliedToId); | ||||
| 					await messageRepliedToCmd.ExecuteNonQueryAsync(); | ||||
| 				} | ||||
|  | ||||
| 				if (!message.Attachments.IsEmpty) { | ||||
| 					foreach (var attachment in message.Attachments) { | ||||
| 						attachmentCmd.Set(":message_id", messageId); | ||||
| 						attachmentCmd.Set(":attachment_id", attachment.Id); | ||||
| 						object attachmentId = attachment.Id; | ||||
|  | ||||
| 						attachmentCmd.Set(":attachment_id", attachmentId); | ||||
| 						attachmentCmd.Set(":name", attachment.Name); | ||||
| 						attachmentCmd.Set(":type", attachment.Type); | ||||
| 						attachmentCmd.Set(":normalized_url", attachment.NormalizedUrl); | ||||
| @@ -135,16 +132,20 @@ sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository | ||||
| 						attachmentCmd.Set(":width", attachment.Width); | ||||
| 						attachmentCmd.Set(":height", attachment.Height); | ||||
| 						await attachmentCmd.ExecuteNonQueryAsync(); | ||||
| 						 | ||||
|  | ||||
| 						messageAttachmentCmd.Set(":message_id", messageId); | ||||
| 						messageAttachmentCmd.Set(":attachment_id", attachmentId); | ||||
| 						await messageAttachmentCmd.ExecuteNonQueryAsync(); | ||||
|  | ||||
| 						await downloadCollector.Add(DownloadLinkExtractor.FromAttachment(attachment)); | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				if (!message.Embeds.IsEmpty) { | ||||
| 					foreach (var embed in message.Embeds) { | ||||
| 						embedCmd.Set(":message_id", messageId); | ||||
| 						embedCmd.Set(":json", embed.Json); | ||||
| 						await embedCmd.ExecuteNonQueryAsync(); | ||||
| 						messageEmbedCmd.Set(":message_id", messageId); | ||||
| 						messageEmbedCmd.Set(":json", embed.Json); | ||||
| 						await messageEmbedCmd.ExecuteNonQueryAsync(); | ||||
|  | ||||
| 						if (DownloadLinkExtractor.TryFromEmbedJson(embed.Json) is {} download) { | ||||
| 							await downloadCollector.Add(download); | ||||
| @@ -154,12 +155,12 @@ sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository | ||||
|  | ||||
| 				if (!message.Reactions.IsEmpty) { | ||||
| 					foreach (var reaction in message.Reactions) { | ||||
| 						reactionCmd.Set(":message_id", messageId); | ||||
| 						reactionCmd.Set(":emoji_id", reaction.EmojiId); | ||||
| 						reactionCmd.Set(":emoji_name", reaction.EmojiName); | ||||
| 						reactionCmd.Set(":emoji_flags", (int) reaction.EmojiFlags); | ||||
| 						reactionCmd.Set(":count", reaction.Count); | ||||
| 						await reactionCmd.ExecuteNonQueryAsync(); | ||||
| 						messageReactionCmd.Set(":message_id", messageId); | ||||
| 						messageReactionCmd.Set(":emoji_id", reaction.EmojiId); | ||||
| 						messageReactionCmd.Set(":emoji_name", reaction.EmojiName); | ||||
| 						messageReactionCmd.Set(":emoji_flags", (int) reaction.EmojiFlags); | ||||
| 						messageReactionCmd.Set(":count", reaction.Count); | ||||
| 						await messageReactionCmd.ExecuteNonQueryAsync(); | ||||
|  | ||||
| 						if (reaction.EmojiId is {} emojiId) { | ||||
| 							await downloadCollector.Add(DownloadLinkExtractor.FromEmoji(emojiId, reaction.EmojiFlags)); | ||||
| @@ -178,7 +179,7 @@ sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository | ||||
| 	public override Task<long> Count(CancellationToken cancellationToken) { | ||||
| 		return Count(filter: null, cancellationToken); | ||||
| 	} | ||||
| 	 | ||||
|  | ||||
| 	public async Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) { | ||||
| 		await using var conn = await pool.Take(); | ||||
| 		return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM messages" + filter.GenerateConditions().BuildWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken); | ||||
| @@ -221,7 +222,8 @@ sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository | ||||
| 			""" | ||||
| 			SELECT attachment_id, name, type, normalized_url, download_url, size, width, height | ||||
| 			FROM attachments | ||||
| 			WHERE message_id = :message_id | ||||
| 			JOIN message_attachments USING (attachment_id) | ||||
| 			WHERE message_attachments.message_id = :message_id | ||||
| 			"""; | ||||
|  | ||||
| 		await using var attachmentCmd = new MessageToManyCommand<Attachment>(conn, AttachmentSql, static reader => new Attachment { | ||||
| @@ -238,7 +240,7 @@ sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository | ||||
| 		const string EmbedSql = | ||||
| 			""" | ||||
| 			SELECT json | ||||
| 			FROM embeds | ||||
| 			FROM message_embeds | ||||
| 			WHERE message_id = :message_id | ||||
| 			"""; | ||||
|  | ||||
| @@ -249,7 +251,7 @@ sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository | ||||
| 		const string ReactionSql = | ||||
| 			""" | ||||
| 			SELECT emoji_id, emoji_name, emoji_flags, count | ||||
| 			FROM reactions | ||||
| 			FROM message_reactions | ||||
| 			WHERE message_id = :message_id | ||||
| 			"""; | ||||
|  | ||||
| @@ -262,10 +264,10 @@ sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository | ||||
|  | ||||
| 		await using var messageCmd = conn.Command( | ||||
| 			$""" | ||||
| 			 SELECT m.message_id, m.sender_id, m.channel_id, m.text, m.timestamp, et.edit_timestamp, rt.replied_to_id | ||||
| 			 SELECT m.message_id, m.sender_id, m.channel_id, m.text, m.timestamp, met.edit_timestamp, mrt.replied_to_id | ||||
| 			 FROM messages m | ||||
| 			 LEFT JOIN edit_timestamps et ON m.message_id = et.message_id | ||||
| 			 LEFT JOIN replied_to rt ON m.message_id = rt.message_id | ||||
| 			 LEFT JOIN message_edit_timestamps met ON m.message_id = met.message_id | ||||
| 			 LEFT JOIN message_replied_to mrt ON m.message_id = mrt.message_id | ||||
| 			 {filter.GenerateConditions("m").BuildWhereClause()} | ||||
| 			 """ | ||||
| 		); | ||||
| @@ -292,7 +294,7 @@ sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository | ||||
|  | ||||
| 	public async IAsyncEnumerable<ulong> GetIds(MessageFilter? filter) { | ||||
| 		await using var conn = await pool.Take(); | ||||
| 		 | ||||
|  | ||||
| 		await using var cmd = conn.Command("SELECT message_id FROM messages" + filter.GenerateConditions().BuildWhereClause()); | ||||
| 		await using var reader = await cmd.ExecuteReaderAsync(); | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,58 @@ | ||||
| using System; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data.Settings; | ||||
| using DHT.Server.Database.Repositories; | ||||
| using DHT.Server.Database.Sqlite.Utils; | ||||
| using Microsoft.Data.Sqlite; | ||||
|  | ||||
| namespace DHT.Server.Database.Sqlite.Repositories; | ||||
|  | ||||
| sealed class SqliteSettingsRepository(SqliteConnectionPool pool) : ISettingsRepository { | ||||
| 	public Task Set<T>(SettingsKey<T> key, T value) { | ||||
| 		return Set(setter => setter.Set(key, value)); | ||||
| 	} | ||||
|  | ||||
| 	public async Task Set(Func<ISettingsRepository.ISetter, Task> setter) { | ||||
| 		await using var conn = await pool.Take(); | ||||
| 		await conn.BeginTransactionAsync(); | ||||
| 		 | ||||
| 		await using var cmd = conn.Command( | ||||
| 			""" | ||||
| 			INSERT INTO metadata (key, value) | ||||
| 			VALUES (:key, :value) | ||||
| 			ON CONFLICT (key) | ||||
| 			DO UPDATE SET value = excluded.value | ||||
| 			""" | ||||
| 		); | ||||
|  | ||||
| 		cmd.Add(":key", SqliteType.Text); | ||||
| 		cmd.Add(":value", SqliteType.Text); | ||||
| 		 | ||||
| 		await setter(new Setter(cmd)); | ||||
|  | ||||
| 		await cmd.ExecuteNonQueryAsync(); | ||||
| 		await conn.CommitTransactionAsync(); | ||||
| 	} | ||||
| 	 | ||||
| 	private sealed class Setter(SqliteCommand cmd) : ISettingsRepository.ISetter { | ||||
| 		public async Task Set<T>(SettingsKey<T> key, T value) { | ||||
| 			cmd.Set(":key", key.Key); | ||||
| 			cmd.Set(":value", key.ToString(value)); | ||||
| 			await cmd.ExecuteNonQueryAsync(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async Task<T?> Get<T>(SettingsKey<T> key, T? defaultValue) { | ||||
| 		string? value; | ||||
| 		 | ||||
| 		await using (var conn = await pool.Take()) { | ||||
| 			await using var cmd = conn.Command("SELECT value FROM metadata WHERE key = :key"); | ||||
| 			cmd.AddAndSet(":key", SqliteType.Text, key.Key); | ||||
|  | ||||
| 			await using var reader = await cmd.ExecuteReaderAsync(); | ||||
| 			value = await reader.ReadAsync() ? reader.GetString(0) : null; | ||||
| 		} | ||||
|  | ||||
| 		return value != null && key.FromString(value, out var convertedValue) ? convertedValue : defaultValue; | ||||
| 	} | ||||
| } | ||||
| @@ -8,7 +8,10 @@ sealed class SqliteSchemaUpgradeTo3 : ISchemaUpgrade { | ||||
| 		await reporter.MainWork("Applying schema changes...", 0, 1); | ||||
|  | ||||
| 		await SqliteSchema.CreateMessageEditTimestampTable(conn); | ||||
| 		await conn.ExecuteAsync("ALTER TABLE message_edit_timestamps RENAME TO edit_timestamps"); | ||||
| 		 | ||||
| 		await SqliteSchema.CreateMessageRepliedToTable(conn); | ||||
| 		await conn.ExecuteAsync("ALTER TABLE message_replied_to RENAME TO replied_to"); | ||||
|  | ||||
| 		await conn.ExecuteAsync(""" | ||||
| 		                        INSERT INTO edit_timestamps (message_id, edit_timestamp) | ||||
|   | ||||
							
								
								
									
										23
									
								
								app/Server/Database/Sqlite/Schema/SqliteSchemaUpgradeTo9.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/Server/Database/Sqlite/Schema/SqliteSchemaUpgradeTo9.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Database.Sqlite.Utils; | ||||
|  | ||||
| namespace DHT.Server.Database.Sqlite.Schema; | ||||
|  | ||||
| sealed class SqliteSchemaUpgradeTo9 : ISchemaUpgrade { | ||||
| 	async Task ISchemaUpgrade.Run(ISqliteConnection conn, ISchemaUpgradeCallbacks.IProgressReporter reporter) { | ||||
| 		await reporter.MainWork("Applying schema changes...", 0, 3); | ||||
| 		await SqliteSchema.CreateMessageAttachmentsTable(conn); | ||||
| 		 | ||||
| 		await reporter.MainWork("Migrating message attachments...", 1, 3); | ||||
| 		await conn.ExecuteAsync("INSERT INTO message_attachments (message_id, attachment_id) SELECT message_id, attachment_id FROM attachments"); | ||||
| 		 | ||||
| 		await reporter.MainWork("Applying schema changes...", 2, 3); | ||||
| 		await conn.ExecuteAsync("DROP INDEX attachments_message_ix"); | ||||
| 		await conn.ExecuteAsync("ALTER TABLE attachments DROP COLUMN message_id"); | ||||
| 		 | ||||
| 		await conn.ExecuteAsync("ALTER TABLE embeds RENAME TO message_embeds"); | ||||
| 		await conn.ExecuteAsync("ALTER TABLE edit_timestamps RENAME TO message_edit_timestamps"); | ||||
| 		await conn.ExecuteAsync("ALTER TABLE replied_to RENAME TO message_replied_to"); | ||||
| 		await conn.ExecuteAsync("ALTER TABLE reactions RENAME TO message_reactions"); | ||||
| 	} | ||||
| } | ||||
| @@ -39,6 +39,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile { | ||||
|  | ||||
| 	public string Path { get; } | ||||
| 	 | ||||
| 	public ISettingsRepository Settings => settings; | ||||
| 	public IUserRepository Users => users; | ||||
| 	public IServerRepository Servers => servers; | ||||
| 	public IChannelRepository Channels => channels; | ||||
| @@ -47,6 +48,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile { | ||||
| 	 | ||||
| 	private readonly SqliteConnectionPool pool; | ||||
| 	 | ||||
| 	private readonly SqliteSettingsRepository settings; | ||||
| 	private readonly SqliteUserRepository users; | ||||
| 	private readonly SqliteServerRepository servers; | ||||
| 	private readonly SqliteChannelRepository channels; | ||||
| @@ -58,6 +60,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile { | ||||
| 		this.pool = pool; | ||||
|  | ||||
| 		downloads = new SqliteDownloadRepository(pool); | ||||
| 		settings = new SqliteSettingsRepository(pool); | ||||
| 		users = new SqliteUserRepository(pool, downloads); | ||||
| 		servers = new SqliteServerRepository(pool); | ||||
| 		channels = new SqliteChannelRepository(pool); | ||||
|   | ||||
| @@ -8,7 +8,7 @@ using DHT.Utils.Logging; | ||||
| namespace DHT.Server.Database.Sqlite; | ||||
|  | ||||
| sealed class SqliteSchema { | ||||
| 	internal const int Version = 8; | ||||
| 	internal const int Version = 9; | ||||
|  | ||||
| 	private static readonly Log Log = Log.ForType<SqliteSchema>(); | ||||
|  | ||||
| @@ -86,7 +86,6 @@ sealed class SqliteSchema { | ||||
|  | ||||
| 		await conn.ExecuteAsync(""" | ||||
| 		                        CREATE TABLE attachments ( | ||||
| 		                        	message_id     INTEGER NOT NULL, | ||||
| 		                        	attachment_id  INTEGER NOT NULL PRIMARY KEY NOT NULL, | ||||
| 		                        	name           TEXT NOT NULL, | ||||
| 		                        	type           TEXT, | ||||
| @@ -99,14 +98,14 @@ sealed class SqliteSchema { | ||||
| 		                        """); | ||||
|  | ||||
| 		await conn.ExecuteAsync(""" | ||||
| 		                        CREATE TABLE embeds ( | ||||
| 		                        CREATE TABLE message_embeds ( | ||||
| 		                        	message_id INTEGER NOT NULL, | ||||
| 		                        	json       TEXT NOT NULL | ||||
| 		                        ) | ||||
| 		                        """); | ||||
|  | ||||
| 		await conn.ExecuteAsync(""" | ||||
| 		                        CREATE TABLE reactions ( | ||||
| 		                        CREATE TABLE message_reactions ( | ||||
| 		                        	message_id  INTEGER NOT NULL, | ||||
| 		                        	emoji_id    INTEGER, | ||||
| 		                        	emoji_name  TEXT, | ||||
| @@ -118,17 +117,17 @@ sealed class SqliteSchema { | ||||
| 		await CreateMessageEditTimestampTable(conn); | ||||
| 		await CreateMessageRepliedToTable(conn); | ||||
| 		await CreateDownloadTables(conn); | ||||
| 		await CreateMessageAttachmentsTable(conn); | ||||
|  | ||||
| 		await conn.ExecuteAsync("CREATE INDEX attachments_message_ix ON attachments(message_id)"); | ||||
| 		await conn.ExecuteAsync("CREATE INDEX embeds_message_ix ON embeds(message_id)"); | ||||
| 		await conn.ExecuteAsync("CREATE INDEX reactions_message_ix ON reactions(message_id)"); | ||||
| 		await conn.ExecuteAsync("CREATE INDEX embeds_message_ix ON message_embeds(message_id)"); | ||||
| 		await conn.ExecuteAsync("CREATE INDEX reactions_message_ix ON message_reactions(message_id)"); | ||||
|  | ||||
| 		await conn.ExecuteAsync("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")"); | ||||
| 	} | ||||
|  | ||||
| 	internal static async Task CreateMessageEditTimestampTable(ISqliteConnection conn) { | ||||
| 		await conn.ExecuteAsync(""" | ||||
| 		                        CREATE TABLE edit_timestamps ( | ||||
| 		                        CREATE TABLE message_edit_timestamps ( | ||||
| 		                        	message_id     INTEGER PRIMARY KEY NOT NULL, | ||||
| 		                        	edit_timestamp INTEGER NOT NULL | ||||
| 		                        ) | ||||
| @@ -137,7 +136,7 @@ sealed class SqliteSchema { | ||||
|  | ||||
| 	internal static async Task CreateMessageRepliedToTable(ISqliteConnection conn) { | ||||
| 		await conn.ExecuteAsync(""" | ||||
| 		                        CREATE TABLE replied_to ( | ||||
| 		                        CREATE TABLE message_replied_to ( | ||||
| 		                        	message_id    INTEGER PRIMARY KEY NOT NULL, | ||||
| 		                        	replied_to_id INTEGER NOT NULL | ||||
| 		                        ) | ||||
| @@ -163,6 +162,18 @@ sealed class SqliteSchema { | ||||
| 		                        ) | ||||
| 		                        """); | ||||
| 	} | ||||
| 	 | ||||
| 	internal static async Task CreateMessageAttachmentsTable(ISqliteConnection conn) { | ||||
| 		await conn.ExecuteAsync(""" | ||||
| 		                        CREATE TABLE message_attachments ( | ||||
| 		                        	message_id    INTEGER NOT NULL, | ||||
| 		                        	attachment_id INTEGER NOT NULL, | ||||
| 		                        	PRIMARY KEY (message_id, attachment_id), | ||||
| 		                            FOREIGN KEY (message_id) REFERENCES messages (message_id) ON UPDATE CASCADE ON DELETE CASCADE, | ||||
| 		                            FOREIGN KEY (attachment_id) REFERENCES attachments (attachment_id) ON UPDATE CASCADE ON DELETE CASCADE | ||||
| 		                        ) | ||||
| 		                        """); | ||||
| 	} | ||||
|  | ||||
| 	private async Task UpgradeSchemas(int dbVersion, ISchemaUpgradeCallbacks.IProgressReporter reporter) { | ||||
| 		var upgrades = new Dictionary<int, ISchemaUpgrade> { | ||||
| @@ -173,13 +184,14 @@ sealed class SqliteSchema { | ||||
| 			{ 5, new SqliteSchemaUpgradeTo6() }, | ||||
| 			{ 6, new SqliteSchemaUpgradeTo7() }, | ||||
| 			{ 7, new SqliteSchemaUpgradeTo8() }, | ||||
| 			{ 8, new SqliteSchemaUpgradeTo9() }, | ||||
| 		}; | ||||
|  | ||||
| 		var perf = Log.Start("from version " + dbVersion); | ||||
|  | ||||
| 		for (int fromVersion = dbVersion; fromVersion < Version; fromVersion++) { | ||||
| 			var toVersion = fromVersion + 1; | ||||
| 			 | ||||
|  | ||||
| 			if (upgrades.TryGetValue(fromVersion, out var upgrade)) { | ||||
| 				await upgrade.Run(conn, reporter); | ||||
| 			} | ||||
|   | ||||
| @@ -11,7 +11,7 @@ | ||||
|   </ItemGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.7" /> | ||||
|     <PackageReference Include="System.Linq.Async" Version="6.0.1" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
|   | ||||
							
								
								
									
										61
									
								
								app/Utils/Tasks/DelayedThrottledTask.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								app/Utils/Tasks/DelayedThrottledTask.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| 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() { | ||||
| 		var 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() { | ||||
| 		taskChannel.Writer.Complete(); | ||||
| 		cancellationTokenSource.Cancel(); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								app/empty.dht
									
									
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/empty.dht
									
									
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							
		Reference in New Issue
	
	Block a user