mirror of
				https://github.com/chylex/Discord-History-Tracker.git
				synced 2025-10-31 20:17:15 +01:00 
			
		
		
		
	Compare commits
	
		
			1 Commits
		
	
	
		
			v44.0
			...
			ebdabb3dd2
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ebdabb3dd2 | 
| @@ -1,9 +0,0 @@ | ||||
| using System.Diagnostics; | ||||
|  | ||||
| namespace DHT.Desktop.Common; | ||||
|  | ||||
| static class SystemUtils { | ||||
| 	public static void OpenUrl(string url) { | ||||
| 		Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); | ||||
| 	} | ||||
| } | ||||
| @@ -15,14 +15,14 @@ | ||||
|   </PropertyGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <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="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="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
|   | ||||
| @@ -3,12 +3,12 @@ | ||||
|         xmlns:d="http://schemas.microsoft.com/expression/blend/2008" | ||||
|         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" | ||||
|         xmlns:main="clr-namespace:DHT.Desktop.Main" | ||||
|         mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="360" | ||||
|         mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295" | ||||
|         x:Class="DHT.Desktop.Main.AboutWindow" | ||||
|         x:DataType="main:AboutWindowModel" | ||||
|         Title="About Discord History Tracker" | ||||
|         Icon="avares://DiscordHistoryTracker/Resources/icon.ico" | ||||
|         Width="480" Height="360" CanResize="False" | ||||
|         Width="480" Height="295" CanResize="False" | ||||
|         WindowStartupLocation="CenterOwner"> | ||||
|  | ||||
|     <Design.DataContext> | ||||
| @@ -39,11 +39,10 @@ | ||||
|  | ||||
|         <StackPanel> | ||||
|             <Button Command="{Binding ShowOfficialWebsite}">Official Website</Button> | ||||
|             <Button Command="{Binding ShowIssueTracker}">Issue Tracker</Button> | ||||
|             <Button Command="{Binding ShowSourceCode}">Source Code</Button> | ||||
|         </StackPanel> | ||||
|  | ||||
|         <Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="175,125,*" Margin="0 10 0 0"> | ||||
|         <Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="175,125,*" Margin="0 10 0 0"> | ||||
|             <TextBlock Grid.Row="0" Grid.Column="0" FontWeight="Bold">Third-Party Software</TextBlock> | ||||
|             <TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock> | ||||
|             <TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock> | ||||
| @@ -56,21 +55,13 @@ | ||||
|             <TextBlock Grid.Row="3" Grid.Column="1">MIT</TextBlock> | ||||
|             <Button Grid.Row="3" Grid.Column="2" Command="{Binding ShowLibraryAvalonia}">NuGet</Button> | ||||
|  | ||||
|             <TextBlock Grid.Row="4" Grid.Column="0">MVVM Toolkit</TextBlock> | ||||
|             <TextBlock Grid.Row="4" Grid.Column="1">MIT</TextBlock> | ||||
|             <Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibraryCommunityToolkit}">GitHub</Button> | ||||
|              | ||||
|             <TextBlock Grid.Row="5" Grid.Column="0">SQLite</TextBlock> | ||||
|             <TextBlock Grid.Row="5" Grid.Column="1">Public Domain</TextBlock> | ||||
|             <Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Official Website</Button> | ||||
|             <TextBlock Grid.Row="4" Grid.Column="0">SQLite</TextBlock> | ||||
|             <TextBlock Grid.Row="4" Grid.Column="1">Public Domain</TextBlock> | ||||
|             <Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Official Website</Button> | ||||
|  | ||||
|             <TextBlock Grid.Row="6" Grid.Column="0">Microsoft.Data.Sqlite</TextBlock> | ||||
|             <TextBlock Grid.Row="6" Grid.Column="1">Apache-2.0</TextBlock> | ||||
|             <Button Grid.Row="6" Grid.Column="2" Command="{Binding ShowLibrarySqliteAdoNet}">NuGet</Button> | ||||
|              | ||||
|             <TextBlock Grid.Row="7" Grid.Column="0">Rx.NET</TextBlock> | ||||
|             <TextBlock Grid.Row="7" Grid.Column="1">MIT</TextBlock> | ||||
|             <Button Grid.Row="7" Grid.Column="2" Command="{Binding ShowLibraryRxNet}">GitHub</Button> | ||||
|             <TextBlock Grid.Row="5" Grid.Column="0">Microsoft.Data.Sqlite</TextBlock> | ||||
|             <TextBlock Grid.Row="5" Grid.Column="1">Apache-2.0</TextBlock> | ||||
|             <Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqliteAdoNet}">NuGet</Button> | ||||
|         </Grid> | ||||
|  | ||||
|     </StackPanel> | ||||
|   | ||||
| @@ -1,41 +1,33 @@ | ||||
| using DHT.Desktop.Common; | ||||
| using System.Diagnostics; | ||||
|  | ||||
| namespace DHT.Desktop.Main; | ||||
|  | ||||
| sealed class AboutWindowModel { | ||||
| 	public void ShowOfficialWebsite() { | ||||
| 		SystemUtils.OpenUrl(Program.Website); | ||||
| 		OpenUrl("https://dht.chylex.com"); | ||||
| 	} | ||||
|  | ||||
| 	public void ShowIssueTracker() { | ||||
| 		SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues"); | ||||
| 	} | ||||
| 	 | ||||
| 	public void ShowSourceCode() { | ||||
| 		SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker"); | ||||
| 	} | ||||
|  | ||||
| 	public void ShowLibraryNetCore() { | ||||
| 		SystemUtils.OpenUrl("https://github.com/dotnet/core"); | ||||
| 		OpenUrl("https://github.com/chylex/Discord-History-Tracker"); | ||||
| 	} | ||||
|  | ||||
| 	public void ShowLibraryAvalonia() { | ||||
| 		SystemUtils.OpenUrl("https://www.nuget.org/packages/Avalonia"); | ||||
| 	} | ||||
|  | ||||
| 	public void ShowLibraryCommunityToolkit() { | ||||
| 		SystemUtils.OpenUrl("https://github.com/CommunityToolkit/dotnet"); | ||||
| 		OpenUrl("https://www.nuget.org/packages/Avalonia"); | ||||
| 	} | ||||
|  | ||||
| 	public void ShowLibrarySqlite() { | ||||
| 		SystemUtils.OpenUrl("https://www.sqlite.org"); | ||||
| 		OpenUrl("https://www.sqlite.org"); | ||||
| 	} | ||||
|  | ||||
| 	public void ShowLibrarySqliteAdoNet() { | ||||
| 		SystemUtils.OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite"); | ||||
| 		OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite"); | ||||
| 	} | ||||
|  | ||||
| 	public void ShowLibraryRxNet() { | ||||
| 		SystemUtils.OpenUrl("https://github.com/dotnet/reactive"); | ||||
| 	public void ShowLibraryNetCore() { | ||||
| 		OpenUrl("https://github.com/dotnet/core"); | ||||
| 	} | ||||
|  | ||||
| 	private static void OpenUrl(string url) { | ||||
| 		Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.ComponentModel; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.ReactiveUI; | ||||
| @@ -9,17 +8,13 @@ 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, IAsyncDisposable { | ||||
| 	private static readonly Log Log = Log.ForType<DownloadItemFilterPanelModel>(); | ||||
| 	 | ||||
| sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposable { | ||||
| 	public sealed record Unit(string Name, uint Scale); | ||||
| 	 | ||||
|  | ||||
| 	private static readonly Unit[] AllUnits = [ | ||||
| 		new Unit("B", 1), | ||||
| 		new Unit("kB", 1024), | ||||
| @@ -38,7 +33,7 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp | ||||
| 	private bool limitSize = false; | ||||
| 	 | ||||
| 	[ObservableProperty] | ||||
| 	private ulong maximumSize = 0UL; | ||||
| 	private ulong maximumSize = 0L; | ||||
| 	 | ||||
| 	[ObservableProperty] | ||||
| 	private Unit maximumSizeUnit = AllUnits[0]; | ||||
| @@ -48,9 +43,6 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp | ||||
| 	private readonly State state; | ||||
| 	private readonly string verb; | ||||
|  | ||||
| 	private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask; | ||||
| 	private bool isLoadingFilterSettings; | ||||
|  | ||||
| 	private readonly RestartableTask<long> downloadItemCountTask; | ||||
| 	private long? matchingItemCount; | ||||
| 	 | ||||
| @@ -64,8 +56,6 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp | ||||
| 		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); | ||||
|  | ||||
| @@ -74,51 +64,13 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp | ||||
| 		PropertyChanged += OnPropertyChanged; | ||||
| 	} | ||||
|  | ||||
| 	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(); | ||||
| 		 | ||||
| 	public void 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(); | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -249,8 +249,11 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable { | ||||
| 			var checkBoxItems = new List<CheckBoxItem<ulong>>(); | ||||
|  | ||||
| 			await foreach (var user in state.Db.Users.Get()) { | ||||
| 				var name = user.Name; | ||||
| 				var discriminator = user.Discriminator; | ||||
|  | ||||
| 				checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) { | ||||
| 					Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})", | ||||
| 					Title = discriminator == null ? name : name + " #" + discriminator, | ||||
| 					IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id) | ||||
| 				}); | ||||
| 			} | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| using System; | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using System.IO; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.Controls; | ||||
| using DHT.Desktop.Main.Pages; | ||||
| using DHT.Utils.Logging; | ||||
| using JetBrains.Annotations; | ||||
|  | ||||
| @@ -41,5 +43,13 @@ public sealed partial class MainWindow : Window { | ||||
| 				Log.Error("Caught exception while disposing window: " + ex); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) { | ||||
| 			try { | ||||
| 				File.Delete(temporaryFile); | ||||
| 			} catch (Exception) { | ||||
| 				// ignored | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -95,8 +95,6 @@ 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 }; | ||||
|  | ||||
| @@ -106,7 +104,7 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable { | ||||
| 	private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) { | ||||
| 		if (mainContentScreenModel != null) { | ||||
| 			mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed; | ||||
| 			await mainContentScreenModel.DisposeAsync(); | ||||
| 			mainContentScreenModel.Dispose(); | ||||
| 			mainContentScreenModel = null; | ||||
| 		} | ||||
|  | ||||
| @@ -126,10 +124,7 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable { | ||||
| 	} | ||||
|  | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| 		if (mainContentScreenModel != null) { | ||||
| 			await mainContentScreenModel.DisposeAsync(); | ||||
| 		} | ||||
| 		 | ||||
| 		mainContentScreenModel?.Dispose(); | ||||
| 		await DisposeState(); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -73,7 +73,6 @@ sealed class DebugPageModel { | ||||
| 		var users = Enumerable.Range(0, userCount).Select(_ => new User { | ||||
| 			Id = RandomId(rand), | ||||
| 			Name = RandomName("u"), | ||||
| 			DisplayName = RandomName("u"), | ||||
| 			AvatarUrl = null, | ||||
| 			Discriminator = rand.Next(0, 9999).ToString(), | ||||
| 		}).ToArray(); | ||||
|   | ||||
| @@ -31,14 +31,8 @@ | ||||
|     </UserControl.Styles> | ||||
|  | ||||
|     <StackPanel Orientation="Vertical" Spacing="20"> | ||||
|         <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> | ||||
|         <Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" /> | ||||
|         <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"> | ||||
| @@ -49,6 +43,9 @@ | ||||
|                     </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,14 +9,13 @@ 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, IAsyncDisposable { | ||||
| sealed partial class DownloadsPageModel : ObservableObject, IDisposable { | ||||
| 	private static readonly Log Log = Log.ForType<DownloadsPageModel>(); | ||||
|  | ||||
| 	[ObservableProperty(Setter = Access.Private)] | ||||
| @@ -74,22 +73,14 @@ sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable { | ||||
|  | ||||
| 		RecomputeDownloadStatistics(); | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task Initialize() { | ||||
| 		await FilterModel.Initialize(); | ||||
| 		 | ||||
| 		if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, false)) { | ||||
| 			await StartDownload(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| 	public void Dispose() { | ||||
| 		finishedItemsSubscription?.Dispose(); | ||||
| 		 | ||||
| 		downloadItemCountSubscription.Dispose(); | ||||
| 		downloadStatisticsTask.Dispose(); | ||||
|  | ||||
| 		await FilterModel.DisposeAsync(); | ||||
| 		FilterModel.Dispose(); | ||||
| 	} | ||||
|  | ||||
| 	private void OnDownloadCountChanged(long newDownloadCount) { | ||||
| @@ -100,41 +91,26 @@ sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable { | ||||
| 		IsToggleDownloadButtonEnabled = false; | ||||
|  | ||||
| 		if (IsDownloading) { | ||||
| 			await StopDownload(); | ||||
| 			await state.Downloader.Stop(); | ||||
| 			await state.Db.Downloads.MoveDownloadingItemsBackToQueue(); | ||||
|  | ||||
| 			finishedItemsSubscription?.Dispose(); | ||||
| 			finishedItemsSubscription = null; | ||||
| 			 | ||||
| 			currentDownloadFilter = null; | ||||
| 		} | ||||
| 		else { | ||||
| 			await StartDownload(); | ||||
| 			await state.Db.Downloads.MoveDownloadingItemsBackToQueue(); | ||||
| 			 | ||||
| 			var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter()); | ||||
| 			finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished); | ||||
| 		} | ||||
|  | ||||
| 		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,21 +1,30 @@ | ||||
| using System; | ||||
| using System.Collections.Concurrent; | ||||
| using System.ComponentModel; | ||||
| using System.Diagnostics; | ||||
| using System.Threading.Tasks; | ||||
| using System.Web; | ||||
| using Avalonia.Controls; | ||||
| using Avalonia.Platform.Storage; | ||||
| using CommunityToolkit.Mvvm.ComponentModel; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Desktop.Dialogs.File; | ||||
| using DHT.Desktop.Dialogs.Message; | ||||
| using DHT.Desktop.Dialogs.Progress; | ||||
| using DHT.Desktop.Main.Controls; | ||||
| using DHT.Desktop.Server; | ||||
| using DHT.Server; | ||||
| using DHT.Server.Data.Filters; | ||||
| using DHT.Server.Service.Viewer; | ||||
|  | ||||
| namespace DHT.Desktop.Main.Pages; | ||||
|  | ||||
| sealed partial class ViewerPageModel : ObservableObject, IDisposable { | ||||
| 	public static readonly ConcurrentBag<string> TemporaryFiles = []; | ||||
|  | ||||
| 	private static readonly FilePickerFileType[] ViewerFileTypes = [ | ||||
| 		FileDialogs.CreateFilter("Discord History Viewer", ["html"]) | ||||
| 	]; | ||||
|  | ||||
| 	public bool DatabaseToolFilterModeKeep { get; set; } = true; | ||||
| 	public bool DatabaseToolFilterModeRemove { get; set; } = false; | ||||
|  | ||||
| @@ -50,8 +59,10 @@ sealed partial class ViewerPageModel : ObservableObject, IDisposable { | ||||
| 		try { | ||||
| 			string serverUrl = "http://127.0.0.1:" + ServerConfiguration.Port; | ||||
| 			string serverToken = ServerConfiguration.Token; | ||||
| 			string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString(); | ||||
| 			SystemUtils.OpenUrl(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId)); | ||||
| 			 | ||||
| 			Process.Start(new ProcessStartInfo(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken)) { | ||||
| 				UseShellExecute = true | ||||
| 			}); | ||||
| 		} catch (Exception e) { | ||||
| 			await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message); | ||||
| 		} | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| using System; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.Controls; | ||||
| using DHT.Desktop.Main.Controls; | ||||
| using DHT.Desktop.Main.Pages; | ||||
| @@ -7,7 +6,7 @@ using DHT.Server; | ||||
|  | ||||
| namespace DHT.Desktop.Main.Screens; | ||||
|  | ||||
| sealed class MainContentScreenModel : IAsyncDisposable { | ||||
| sealed class MainContentScreenModel : IDisposable { | ||||
| 	public DatabasePage DatabasePage { get; } | ||||
| 	private DatabasePageModel DatabasePageModel { get; } | ||||
|  | ||||
| @@ -71,13 +70,9 @@ sealed class MainContentScreenModel : IAsyncDisposable { | ||||
|  | ||||
| 		StatusBarModel = new StatusBarModel(state); | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task Initialize() { | ||||
| 		await DownloadsPageModel.Initialize(); | ||||
| 	} | ||||
|  | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| 		await DownloadsPageModel.DisposeAsync(); | ||||
| 	public void Dispose() { | ||||
| 		DownloadsPageModel.Dispose(); | ||||
| 		ViewerPageModel.Dispose(); | ||||
| 		AdvancedPageModel.Dispose(); | ||||
| 		StatusBarModel.Dispose(); | ||||
|   | ||||
| @@ -22,21 +22,20 @@ | ||||
|             <Setter Property="HorizontalAlignment" Value="Center" /> | ||||
|             <Setter Property="VerticalAlignment" Value="Center" /> | ||||
|         </Style> | ||||
|         <Style Selector="Grid#ButtonPanel > Button"> | ||||
|             <Setter Property="HorizontalAlignment" Value="Stretch" /> | ||||
|         <Style Selector="Button"> | ||||
|             <Setter Property="Margin" Value="5 0" /> | ||||
|         </Style> | ||||
|     </UserControl.Styles> | ||||
|  | ||||
|     <Panel Name="RootPanel"> | ||||
|         <StackPanel Margin="42 30"> | ||||
|             <TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 25" HorizontalAlignment="Center" /> | ||||
|              | ||||
|             <Grid Name="ButtonPanel" RowDefinitions="Auto,12,Auto,12,Auto" ColumnDefinitions="*,12,*" Margin="12 0" HorizontalAlignment="Stretch"> | ||||
|                 <Button Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button> | ||||
|                 <Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding CheckUpdates}">Check For Updates</Button> | ||||
|                 <Button Grid.Row="4" Grid.Column="0" Command="{Binding ShowAboutDialog}">About</Button> | ||||
|                 <Button Grid.Row="4" Grid.Column="2" Command="{Binding Exit}">Exit</Button> | ||||
|             </Grid> | ||||
|         <StackPanel Margin="42"> | ||||
|             <TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" /> | ||||
|  | ||||
|             <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> | ||||
|                 <Button Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button> | ||||
|                 <Button Command="{Binding ShowAboutDialog}">About</Button> | ||||
|                 <Button Command="{Binding Exit}">Exit</Button> | ||||
|             </StackPanel> | ||||
|         </StackPanel> | ||||
|     </Panel> | ||||
| </UserControl> | ||||
|   | ||||
| @@ -1,31 +1,25 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.Controls; | ||||
| using CommunityToolkit.Mvvm.ComponentModel; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Desktop.Dialogs.Message; | ||||
| using DHT.Desktop.Dialogs.Progress; | ||||
| using DHT.Server.Data.Settings; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Server.Database.Sqlite.Schema; | ||||
| using DHT.Utils.Logging; | ||||
|  | ||||
| namespace DHT.Desktop.Main.Screens; | ||||
|  | ||||
| sealed partial class WelcomeScreenModel : ObservableObject { | ||||
| 	private static readonly Log Log = Log.ForType<WelcomeScreenModel>(); | ||||
|  | ||||
| 	public string Version => Program.Version; | ||||
|  | ||||
| 	 | ||||
| 	[ObservableProperty(Setter = Access.Private)] | ||||
| 	private bool isOpenOrCreateDatabaseButtonEnabled = true; | ||||
|  | ||||
| 	public event EventHandler<IDatabaseFile>? DatabaseSelected; | ||||
|  | ||||
| 	 | ||||
| 	public event EventHandler<IDatabaseFile>? DatabaseSelected;  | ||||
| 	 | ||||
| 	private readonly Window window; | ||||
|  | ||||
| 	private string? dbFilePath; | ||||
| @@ -51,22 +45,20 @@ 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) { | ||||
| 			return; | ||||
| 		if (db != null) { | ||||
| 			DatabaseSelected?.Invoke(this, db); | ||||
| 		} | ||||
|  | ||||
| 		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(Window window) : ISchemaUpgradeCallbacks { | ||||
| 	private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks { | ||||
| 		private readonly Window window; | ||||
| 		 | ||||
| 		public SchemaUpgradeCallbacks(Window window) { | ||||
| 			this.window = window; | ||||
| 		} | ||||
|  | ||||
| 		public async Task<bool> CanUpgrade() { | ||||
| 			return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window); | ||||
| 		} | ||||
| @@ -79,12 +71,20 @@ sealed partial class WelcomeScreenModel : ObservableObject { | ||||
| 				await doUpgrade(reporter); | ||||
| 				await Task.Delay(TimeSpan.FromMilliseconds(600)); | ||||
| 			} | ||||
|  | ||||
| 			 | ||||
| 			await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window); | ||||
| 		} | ||||
|  | ||||
| 		private sealed class ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) : ISchemaUpgradeCallbacks.IProgressReporter { | ||||
| 		private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter { | ||||
| 			private readonly IReadOnlyList<IProgressCallback> callbacks; | ||||
| 			 | ||||
| 			private readonly int versionSteps; | ||||
| 			private int versionProgress = 0; | ||||
| 			 | ||||
| 			public ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) { | ||||
| 				this.callbacks = callbacks; | ||||
| 				this.versionSteps = versionSteps; | ||||
| 			} | ||||
|  | ||||
| 			public async Task NextVersion() { | ||||
| 				await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps); | ||||
| @@ -109,53 +109,6 @@ sealed partial class WelcomeScreenModel : ObservableObject { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async Task CheckUpdates() { | ||||
| 		Version? latestVersion = await ProgressDialog.ShowIndeterminate<Version?>(window, "Check Updates", "Checking for updates...", async _ => { | ||||
| 			var client = new HttpClient(new SocketsHttpHandler { | ||||
| 				AutomaticDecompression = DecompressionMethods.None, | ||||
| 				AllowAutoRedirect = false, | ||||
| 				UseCookies = false | ||||
| 			}); | ||||
|  | ||||
| 			client.Timeout = TimeSpan.FromSeconds(30); | ||||
| 			client.MaxResponseContentBufferSize = 1024; | ||||
| 			client.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordHistoryTracker/" + Program.Version); | ||||
|  | ||||
| 			string response; | ||||
| 			try { | ||||
| 				response = await client.GetStringAsync(Program.Website + "/version"); | ||||
| 			} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) { | ||||
| 				await Dialog.ShowOk(window, "Check Updates", "Request timed out."); | ||||
| 				return null; | ||||
| 			} catch (Exception e) { | ||||
| 				Log.Error(e); | ||||
| 				await Dialog.ShowOk(window, "Check Updates", "Error checking for updates: " + e.Message); | ||||
| 				return null; | ||||
| 			} | ||||
|  | ||||
| 			if (!System.Version.TryParse(response, out var latestVersion)) { | ||||
| 				await Dialog.ShowOk(window, "Check Updates", "Server returned an invalid response."); | ||||
| 				return null; | ||||
| 			} | ||||
|  | ||||
| 			return latestVersion; | ||||
| 		}); | ||||
|  | ||||
| 		if (latestVersion == null) { | ||||
| 			return; | ||||
| 		} | ||||
| 		 | ||||
| 		if (Program.AssemblyVersion >= latestVersion) { | ||||
| 			await Dialog.ShowOk(window, "Check Updates", "You are using the latest version."); | ||||
| 			return; | ||||
| 		} | ||||
| 		 | ||||
| 		if (await Dialog.ShowYesNo(window, "Check Updates", "A newer version is available: v" + Program.VersionToString(latestVersion) + "\nVisit the official website and close the app?") == DialogResult.YesNo.Yes) { | ||||
| 			SystemUtils.OpenUrl(Program.Website); | ||||
| 			Exit(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async Task ShowAboutDialog() { | ||||
| 		await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window); | ||||
| 	} | ||||
|   | ||||
| @@ -9,18 +9,17 @@ namespace DHT.Desktop; | ||||
|  | ||||
| static class Program { | ||||
| 	public static string Version { get; } | ||||
| 	public static Version AssemblyVersion { get; } | ||||
| 	public static CultureInfo Culture { get; } | ||||
| 	public static ResourceLoader Resources { get; } | ||||
| 	public static Arguments Arguments { get; } | ||||
|  | ||||
| 	public const string Website = "https://dht.chylex.com"; | ||||
| 	 | ||||
| 	static Program() { | ||||
| 		var assembly = Assembly.GetExecutingAssembly(); | ||||
|  | ||||
| 		AssemblyVersion = assembly.GetName().Version ?? new Version(0, 0, 0, 0); | ||||
| 		Version = VersionToString(AssemblyVersion); | ||||
| 		Version = assembly.GetName().Version?.ToString() ?? ""; | ||||
| 		while (Version.EndsWith(".0")) { | ||||
| 			Version = Version[..^2]; | ||||
| 		} | ||||
|  | ||||
| 		Culture = CultureInfo.CurrentCulture; | ||||
| 		CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; | ||||
| @@ -31,16 +30,6 @@ static class Program { | ||||
| 		Resources = new ResourceLoader(assembly); | ||||
| 		Arguments = new Arguments(Environment.GetCommandLineArgs()); | ||||
| 	} | ||||
| 	 | ||||
| 	public static string VersionToString(Version version) { | ||||
| 		string versionStr = version.ToString(); | ||||
| 		 | ||||
| 		while (versionStr.EndsWith(".0")) { | ||||
| 			versionStr = versionStr[..^2]; | ||||
| 		} | ||||
| 		 | ||||
| 		return versionStr; | ||||
| 	} | ||||
|  | ||||
| 	public static void Main(string[] args) { | ||||
| 		if (Arguments.Console && OperatingSystem.IsWindows()) { | ||||
|   | ||||
| @@ -9,8 +9,6 @@ items: | ||||
|           pattern: "^[0-9]+$" | ||||
|         name: | ||||
|           type: string | ||||
|         displayName: | ||||
|           type: string | ||||
|         avatar: | ||||
|           type: string | ||||
|         discriminator: | ||||
|   | ||||
							
								
								
									
										47
									
								
								app/Resources/Tracker/bootstrap.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										47
									
								
								app/Resources/Tracker/bootstrap.js
									
									
									
									
										vendored
									
									
								
							| @@ -10,16 +10,11 @@ | ||||
| 		return; | ||||
| 	} | ||||
| 	 | ||||
| 	/*[IMPORTS]*/ | ||||
| 	 | ||||
| 	if (!DISCORD.isCompatible()) { | ||||
| 		alert("Discord History Tracker is not compatible with this version of Discord."); | ||||
| 		return; | ||||
| 	} | ||||
| 	 | ||||
| 	window.DHT_LOADED = true; | ||||
| 	window.DHT_ON_UNLOAD = []; | ||||
| 	 | ||||
| 	/*[IMPORTS]*/ | ||||
| 	 | ||||
| 	const port = 0; /*[PORT]*/ | ||||
| 	const token = "/*[TOKEN]*/"; | ||||
| 	STATE.setup(port, token); | ||||
| @@ -51,7 +46,7 @@ | ||||
| 		return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING; | ||||
| 	}; | ||||
| 	 | ||||
| 	const onTrackingContinued = function(anyNewMessages, hasMoreBefore) { | ||||
| 	const onTrackingContinued = function(anyNewMessages) { | ||||
| 		if (!STATE.isTracking()) { | ||||
| 			return; | ||||
| 		} | ||||
| @@ -68,7 +63,7 @@ | ||||
| 		if (SETTINGS.autoscroll) { | ||||
| 			let action = null; | ||||
| 			 | ||||
| 			if (!hasMoreBefore) { | ||||
| 			if (!DISCORD.hasMoreMessages()) { | ||||
| 				console.debug("[DHT] Reached first message."); | ||||
| 				action = SETTINGS.afterFirstMsg; | ||||
| 			} | ||||
| @@ -89,7 +84,7 @@ | ||||
| 	 | ||||
| 	let waitUntilSendingFinishedTimer = null; | ||||
| 	 | ||||
| 	const onMessagesUpdated = async (server, channel, messages, hasMoreBefore) => { | ||||
| 	const onMessagesUpdated = async messages => { | ||||
| 		if (!STATE.isTracking() || delayedStopRequests > 0) { | ||||
| 			return; | ||||
| 		} | ||||
| @@ -99,16 +94,24 @@ | ||||
| 			 | ||||
| 			waitUntilSendingFinishedTimer = window.setTimeout(() => { | ||||
| 				waitUntilSendingFinishedTimer = null; | ||||
| 				onMessagesUpdated(server, channel, messages, hasMoreBefore); | ||||
| 				onMessagesUpdated(messages); | ||||
| 			}, 100); | ||||
| 			 | ||||
| 			return; | ||||
| 		} | ||||
| 		 | ||||
| 		const info = DISCORD.getSelectedChannel(); | ||||
| 		 | ||||
| 		if (!info) { | ||||
| 			GUI.setStatus("Error (Unknown Channel)"); | ||||
| 			stopTrackingDelayed(); | ||||
| 			return; | ||||
| 		} | ||||
| 		 | ||||
| 		isSending = true; | ||||
| 		 | ||||
| 		try { | ||||
| 			await STATE.addDiscordChannel(server, channel); | ||||
| 			await STATE.addDiscordChannel(info.server, info.channel); | ||||
| 		} catch (e) { | ||||
| 			onError(e); | ||||
| 			return; | ||||
| @@ -117,33 +120,35 @@ | ||||
| 		try { | ||||
| 			if (!messages.length) { | ||||
| 				isSending = false; | ||||
| 				onTrackingContinued(false, hasMoreBefore); | ||||
| 				onTrackingContinued(false); | ||||
| 			} | ||||
| 			else { | ||||
| 				const anyNewMessages = await STATE.addDiscordMessages(messages); | ||||
| 				onTrackingContinued(anyNewMessages, hasMoreBefore); | ||||
| 				onTrackingContinued(anyNewMessages); | ||||
| 			} | ||||
| 		} catch (e) { | ||||
| 			onError(e); | ||||
| 		} | ||||
| 	}; | ||||
| 	 | ||||
| 	const starter = DISCORD.setupMessageCallback(onMessagesUpdated); | ||||
| 	DISCORD.setupMessageCallback(onMessagesUpdated); | ||||
| 	 | ||||
| 	STATE.onTrackingStateChanged(enabled => { | ||||
| 		if (enabled) { | ||||
| 			GUI.setStatus("Starting"); | ||||
| 			GUI.createTrackingStyles(); | ||||
| 			hasJustStarted = true; | ||||
| 			const messages = DISCORD.getMessages(); | ||||
| 			 | ||||
| 			if (!starter()) { | ||||
| 			if (messages.length === 0) { | ||||
| 				stopTrackingDelayed(() => alert("Cannot see any messages.")); | ||||
| 				hasJustStarted = false; | ||||
| 				return; | ||||
| 			} | ||||
| 			 | ||||
| 			GUI.setStatus("Starting"); | ||||
| 			hasJustStarted = true; | ||||
| 			// noinspection JSIgnoredPromiseFromCall | ||||
| 			onMessagesUpdated(messages); | ||||
| 		} | ||||
| 		else { | ||||
| 			isSending = false; | ||||
| 			GUI.deleteTrackingStyles(); | ||||
| 		} | ||||
| 	}); | ||||
| 	 | ||||
|   | ||||
| @@ -4,26 +4,11 @@ class DISCORD { | ||||
| 	 | ||||
| 	// https://discord.com/developers/docs/resources/channel#channel-object-channel-types | ||||
| 	static CHANNEL_TYPE = { | ||||
| 		GUILD_TEXT: 0, | ||||
| 		DM: 1, | ||||
| 		GROUP_DM: 3, | ||||
| 		GUILD_ANNOUNCEMENT: 5, | ||||
| 		ANNOUNCEMENT_THREAD: 10, | ||||
| 		PUBLIC_THREAD: 11, | ||||
| 		PRIVATE_THREAD: 12, | ||||
| 		 | ||||
| 		isPrivate(type) { | ||||
| 			return type === this.DM | ||||
| 				|| type === this.GROUP_DM; | ||||
| 		}, | ||||
| 		 | ||||
| 		isNavigableGuildChannel(type) { | ||||
| 			return type === this.GUILD_TEXT | ||||
| 				|| type === this.GUILD_ANNOUNCEMENT | ||||
| 				|| type === this.ANNOUNCEMENT_THREAD | ||||
| 				|| type === this.PUBLIC_THREAD | ||||
| 				|| type === this.PRIVATE_THREAD; | ||||
| 		} | ||||
| 		PRIVATE_THREAD: 12 | ||||
| 	}; | ||||
| 	 | ||||
| 	// https://discord.com/developers/docs/resources/channel#message-object-message-types | ||||
| @@ -33,74 +18,6 @@ class DISCORD { | ||||
| 		THREAD_STARTER: 21 | ||||
| 	}; | ||||
| 	 | ||||
| 	// https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags | ||||
| 	static PERMISSION = { | ||||
| 		VIEW_CHANNEL: 1n << 10n | ||||
| 	}; | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @type {Object} | ||||
| 	 * @property {function(String): ?DiscordGuild} getGuild | ||||
| 	 */ | ||||
| 	static #guildStore = WEBPACK.findModule("guildStore", WEBPACK.filterByProps("getGuild", "getGuilds", "getGuildIds")); | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @type {Object} | ||||
| 	 * @property {function(String): Boolean} isOptInEnabled | ||||
| 	 * @property {function(String): Set<String>} getOptedInChannels | ||||
| 	 */ | ||||
| 	static #guildSettings = WEBPACK.findModule("guildSettings", WEBPACK.filterByProps("isOptInEnabled", "getOptedInChannels")); | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @type {Object} | ||||
| 	 * @property {function(String): ?DiscordChannel} getChannel | ||||
| 	 * @property {function(String): Array<DiscordChannel>} getMutableGuildChannelsForGuild | ||||
| 	 * @property {function(): Array<DiscordChannel>} getSortedPrivateChannels | ||||
| 	 */ | ||||
| 	static #channelStore = WEBPACK.findModule("channelStore", WEBPACK.filterByProps("getChannel", "getMutableGuildChannelsForGuild", "getSortedPrivateChannels")); | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @type {function(BigInt, Object): Boolean} | ||||
| 	 */ | ||||
| 	static #hasPermission = WEBPACK.findFunction("can", [ "getGuildPermissions", "getChannelPermissions" ]); | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @type {function(String): MessageData} | ||||
| 	 */ | ||||
| 	static #getMessages = WEBPACK.findFunction("getMessages"); | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @type {function(String): void} | ||||
| 	 */ | ||||
| 	static #jumpToMessage = WEBPACK.findFunction("jumpToMessage"); | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @type {function(): String} | ||||
| 	 */ | ||||
| 	static #getCurrentlySelectedChannelId = WEBPACK.findFunction("getCurrentlySelectedChannelId"); | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @type {function(String): void} | ||||
| 	 */ | ||||
| 	static #selectPrivateChannel = WEBPACK.findFunction("selectPrivateChannel", [ "selectChannel" ]); | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @type {function(String, Object, String=null): void} | ||||
| 	 */ | ||||
| 	static #transitionToGuildSync = WEBPACK.findFunction("transitionToGuildSync"); | ||||
| 	 | ||||
| 	static isCompatible() { | ||||
| 		return !!this.#guildStore | ||||
| 			&& !!this.#guildSettings | ||||
| 			&& !!this.#channelStore | ||||
| 			&& !!this.#hasPermission | ||||
| 			&& !!this.#getMessages | ||||
| 			&& !!this.#jumpToMessage | ||||
| 			&& !!this.#getCurrentlySelectedChannelId | ||||
| 			&& !!this.#selectPrivateChannel | ||||
| 			&& !!this.#transitionToGuildSync; | ||||
| 	} | ||||
| 	 | ||||
| 	static getMessageOuterElement() { | ||||
| 		return DOM.queryReactClass("messagesWrapper"); | ||||
| 	} | ||||
| @@ -109,6 +26,14 @@ class DISCORD { | ||||
| 		return DOM.queryReactClass("scroller", this.getMessageOuterElement()); | ||||
| 	} | ||||
| 	 | ||||
| 	static getMessageElements() { | ||||
| 		return this.getMessageOuterElement().querySelectorAll("[class*='message_']"); | ||||
| 	} | ||||
| 	 | ||||
| 	static hasMoreMessages() { | ||||
| 		return document.querySelector("#messagesNavigationDescription + [class^=container]") === null; | ||||
| 	} | ||||
| 	 | ||||
| 	static loadOlderMessages() { | ||||
| 		const view = this.getMessageScrollerElement(); | ||||
| 		 | ||||
| @@ -117,43 +42,26 @@ class DISCORD { | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	static getMessagesFromSelectedChannel() { | ||||
| 		const channelId = this.#getCurrentlySelectedChannelId(); | ||||
| 		return channelId ? this.#getMessages(channelId) : null; | ||||
| 	} | ||||
| 	 | ||||
| 	/** | ||||
| 	 * Calls the provided function with a list of messages whenever the currently loaded messages change. | ||||
| 	 * @param callback {function(server: ?DiscordGuild, channel: DiscordChannel, messages: Array<DiscordMessage>, hasMoreBefore: boolean)} | ||||
| 	 */ | ||||
| 	static setupMessageCallback(callback) { | ||||
| 		const previousMessages = new Set(); | ||||
| 		 | ||||
| 		const onMessageElementsChanged = force => { | ||||
| 			const messages = this.getMessagesFromSelectedChannel(); | ||||
| 			if (!messages || !messages.ready || messages.loadingMore) { | ||||
| 				return false; | ||||
| 			} | ||||
| 		const onMessageElementsChanged = function() { | ||||
| 			const messages = DISCORD.getMessages(); | ||||
| 			const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages(); | ||||
| 			 | ||||
| 			const channel = this.#channelStore.getChannel(messages.channelId); | ||||
| 			if (!channel) { | ||||
| 				return false; | ||||
| 			} | ||||
| 			 | ||||
| 			const hasChanged = force || !messages.hasMoreBefore || messages.some(message => !previousMessages.has(message.id)); | ||||
| 			if (!hasChanged) { | ||||
| 				return false; | ||||
| 				return; | ||||
| 			} | ||||
| 			 | ||||
| 			previousMessages.clear(); | ||||
| 			for (const message of messages._array) { | ||||
| 			for (const message of messages) { | ||||
| 				previousMessages.add(message.id); | ||||
| 			} | ||||
| 			 | ||||
| 			const server = this.#guildStore.getGuild(channel.guild_id); | ||||
| 			 | ||||
| 			callback(server, channel, messages._array, messages.hasMoreBefore); | ||||
| 			return true; | ||||
| 			callback(messages); | ||||
| 		}; | ||||
| 		 | ||||
| 		let debounceTimer; | ||||
| @@ -166,7 +74,7 @@ class DISCORD { | ||||
| 			debounceTimer = window.setTimeout(onMessageElementsChanged, 100); | ||||
| 		}; | ||||
| 		 | ||||
| 		const observer = new MutationObserver(function() { | ||||
| 		const observer = new MutationObserver(function () { | ||||
| 			onMessageElementsChangedLater(); | ||||
| 		}); | ||||
| 		 | ||||
| @@ -204,54 +112,216 @@ class DISCORD { | ||||
| 			observedElement = null; | ||||
| 			window.clearInterval(observerTimer); | ||||
| 		}); | ||||
| 	} | ||||
| 	 | ||||
| 	/** | ||||
| 	 * Returns the message from a message element. | ||||
| 	 * @returns { null | DiscordMessage } } | ||||
| 	 */ | ||||
| 	static getMessageFromElement(ele) { | ||||
| 		const props = DOM.getReactProps(ele); | ||||
| 		 | ||||
| 		return () => onMessageElementsChanged(true); | ||||
| 		if (props && Array.isArray(props.children)) { | ||||
| 			for (const child of props.children) { | ||||
| 				if (!(child instanceof Object)) { | ||||
| 					continue; | ||||
| 				} | ||||
| 				 | ||||
| 				const childProps = child.props; | ||||
| 				if (childProps instanceof Object && "message" in childProps) { | ||||
| 					return childProps.message; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		return null; | ||||
| 	} | ||||
| 	 | ||||
| 	/** | ||||
| 	 * Returns an array containing currently loaded messages. | ||||
| 	 */ | ||||
| 	static getMessages() { | ||||
| 		try { | ||||
| 			const messages = []; | ||||
| 			 | ||||
| 			for (const ele of this.getMessageElements()) { | ||||
| 				try { | ||||
| 					const message = this.getMessageFromElement(ele); | ||||
| 					 | ||||
| 					if (message != null) { | ||||
| 						messages.push(message); | ||||
| 					} | ||||
| 				} catch (e) { | ||||
| 					console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele)); | ||||
| 				} | ||||
| 			} | ||||
| 			 | ||||
| 			return messages; | ||||
| 		} catch (e) { | ||||
| 			console.error("[DHT] Error retrieving messages.", e); | ||||
| 			return []; | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	/** | ||||
| 	 * Returns an object containing the selected server and channel information. | ||||
| 	 * For types DM and GROUP, the server and channel ids and names are identical. | ||||
| 	 * @returns { {} | null } | ||||
| 	 */ | ||||
| 	static getSelectedChannel() { | ||||
| 		try { | ||||
| 			let obj = null; | ||||
| 			 | ||||
| 			try { | ||||
| 				for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) { | ||||
| 					if (child && child.props && child.props.channel) { | ||||
| 						obj = child.props.channel; | ||||
| 						break; | ||||
| 					} | ||||
| 				} | ||||
| 			} catch (e) { | ||||
| 				console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e); | ||||
| 			} | ||||
| 			 | ||||
| 			if (!obj || typeof obj.id !== "string") { | ||||
| 				return null; | ||||
| 			} | ||||
| 			 | ||||
| 			const dms = DOM.queryReactClass("privateChannels"); | ||||
| 			 | ||||
| 			if (dms) { | ||||
| 				let name; | ||||
| 				 | ||||
| 				for (const ele of dms.querySelectorAll("[class*='channel_'] [class*='selected_'] [class^='name_'] *")) { | ||||
| 					const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE); | ||||
| 					 | ||||
| 					if (node) { | ||||
| 						name = node.nodeValue; | ||||
| 						break; | ||||
| 					} | ||||
| 				} | ||||
| 				 | ||||
| 				if (!name) { | ||||
| 					return null; | ||||
| 				} | ||||
| 				 | ||||
| 				let type; | ||||
| 				 | ||||
| 				// https://discord.com/developers/docs/resources/channel#channel-object-channel-types | ||||
| 				switch (obj.type) { | ||||
| 					case DISCORD.CHANNEL_TYPE.DM: type = "DM"; break; | ||||
| 					case DISCORD.CHANNEL_TYPE.GROUP_DM: type = "GROUP"; break; | ||||
| 					default: return null; | ||||
| 				} | ||||
| 				 | ||||
| 				const id = obj.id; | ||||
| 				const server = { id, name, type }; | ||||
| 				const channel = { id, name }; | ||||
| 				 | ||||
| 				return { server, channel }; | ||||
| 			} | ||||
| 			else if (obj.guild_id) { | ||||
| 				let guild; | ||||
| 				 | ||||
| 				for (const child of DOM.getReactProps(document.querySelector("nav header [class*='headerContent_']")).children) { | ||||
| 					if (child && child.props && child.props.guild) { | ||||
| 						guild = child.props.guild; | ||||
| 						break; | ||||
| 					} | ||||
| 				} | ||||
| 				 | ||||
| 				if (!guild || typeof guild.name !== "string" || obj.guild_id !== guild.id) { | ||||
| 					return null; | ||||
| 				} | ||||
| 				 | ||||
| 				const server = { | ||||
| 					"id": guild.id, | ||||
| 					"name": guild.name, | ||||
| 					"type": "SERVER" | ||||
| 				}; | ||||
| 				 | ||||
| 				const channel = { | ||||
| 					"id": obj.id, | ||||
| 					"name": obj.name, | ||||
| 					"extra": { | ||||
| 						"nsfw": obj.nsfw | ||||
| 					} | ||||
| 				}; | ||||
| 				 | ||||
| 				if (obj.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) { | ||||
| 					channel["extra"]["parent"] = obj.parent_id; | ||||
| 				} | ||||
| 				else { | ||||
| 					channel["extra"]["position"] = obj.position; | ||||
| 					channel["extra"]["topic"] = obj.topic; | ||||
| 				} | ||||
| 				 | ||||
| 				return { server, channel }; | ||||
| 			} | ||||
| 			else { | ||||
| 				return null; | ||||
| 			} | ||||
| 		} catch (e) { | ||||
| 			console.error("[DHT] Error retrieving selected channel.", e); | ||||
| 			return null; | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	/** | ||||
| 	 * Selects the next text channel and returns true, otherwise returns false if there are no more channels. | ||||
| 	 */ | ||||
| 	static selectNextTextChannel() { | ||||
| 		const currentChannel = this.#channelStore.getChannel(this.#getCurrentlySelectedChannelId()); | ||||
| 		if (!currentChannel) { | ||||
| 			return false; | ||||
| 		} | ||||
| 		const dms = DOM.queryReactClass("privateChannels"); | ||||
| 		 | ||||
| 		if (this.CHANNEL_TYPE.isPrivate(currentChannel.type)) { | ||||
| 			const privateChannel = this.#channelStore.getSortedPrivateChannels(); | ||||
| 			const currentIndex = privateChannel.findIndex(channel => channel.id === currentChannel.id); | ||||
| 		if (dms) { | ||||
| 			const currentChannel = DOM.queryReactClass("selected", dms); | ||||
| 			const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']"); | ||||
| 			const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling; | ||||
| 			 | ||||
| 			if (currentIndex === -1 || currentIndex === privateChannel.length - 1) { | ||||
| 			if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) { | ||||
| 				return false; | ||||
| 			} | ||||
| 			 | ||||
| 			this.#selectPrivateChannel(privateChannel[currentIndex + 1].id); | ||||
| 			const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']"); | ||||
| 			if (!nextChannelLink) { | ||||
| 				return false; | ||||
| 			} | ||||
| 			 | ||||
| 			nextChannelLink.click(); | ||||
| 			nextChannelLink.scrollIntoView(true); | ||||
| 			return true; | ||||
| 		} | ||||
| 		else { | ||||
| 			const guildId = currentChannel.guild_id; | ||||
| 			 | ||||
| 			let isChannelOptedIn; | ||||
| 			if (this.#guildSettings.isOptInEnabled(guildId)) { | ||||
| 				const optedInChannels = this.#guildSettings.getOptedInChannels(guildId); | ||||
| 				isChannelOptedIn = channel => optedInChannels.has(channel.id); | ||||
| 			} | ||||
| 			else { | ||||
| 				isChannelOptedIn = _ => true; | ||||
| 			} | ||||
| 			 | ||||
| 			const guildChannelMap = this.#channelStore.getMutableGuildChannelsForGuild(guildId); | ||||
| 			const guildChannels = Object.values(guildChannelMap) | ||||
| 				.filter(channel => this.CHANNEL_TYPE.isNavigableGuildChannel(channel.type) && isChannelOptedIn(channel) && this.#hasPermission(this.PERMISSION.VIEW_CHANNEL, channel)) | ||||
| 				.sort((a, b) => a.position - b.position); | ||||
| 			 | ||||
| 			const currentIndex = guildChannels.findIndex(channel => channel.id === currentChannel.id); | ||||
| 			 | ||||
| 			if (currentIndex === -1 || currentIndex === guildChannels.length - 1) { | ||||
| 			const channelListEle = document.getElementById("channels"); | ||||
| 			if (!channelListEle) { | ||||
| 				return false; | ||||
| 			} | ||||
| 			 | ||||
| 			this.#transitionToGuildSync(guildId, {}, guildChannels[currentIndex + 1].id); | ||||
| 			function getLinkElement(channel) { | ||||
| 				return channel.querySelector("a[href^='/channels/'][role='link']"); | ||||
| 			} | ||||
| 			 | ||||
| 			const allTextChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), ele => getLinkElement(ele) !== null); | ||||
| 			let nextChannel = null; | ||||
| 			 | ||||
| 			for (let index = 0; index < allTextChannels.length - 1; index++) { | ||||
| 				if (allTextChannels[index].className.includes("selected_")) { | ||||
| 					nextChannel = allTextChannels[index + 1]; | ||||
| 					break; | ||||
| 				} | ||||
| 			} | ||||
| 			 | ||||
| 			if (nextChannel === null) { | ||||
| 				return false; | ||||
| 			} | ||||
| 			 | ||||
| 			const nextChannelLink = getLinkElement(nextChannel); | ||||
| 			if (!nextChannelLink) { | ||||
| 				return false; | ||||
| 			} | ||||
| 			 | ||||
| 			nextChannelLink.click(); | ||||
| 			nextChannel.scrollIntoView(true); | ||||
| 			return true; | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -1,8 +1,6 @@ | ||||
| class DOM { | ||||
| 	/** | ||||
| 	 * Returns a child element by its ID. Parent defaults to the entire document. | ||||
| 	 * @param {string} id | ||||
| 	 * @param {HTMLElement?} [parent] | ||||
| 	 * @returns {HTMLElement} | ||||
| 	 */ | ||||
| 	static id(id, parent) { | ||||
| @@ -11,9 +9,6 @@ class DOM { | ||||
| 	 | ||||
| 	/** | ||||
| 	 * Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document. | ||||
| 	 * @param {string} cls | ||||
| 	 * @param {HTMLElement?} [parent] | ||||
| 	 * @returns {HTMLElement} | ||||
| 	 */ | ||||
| 	static queryReactClass(cls, parent) { | ||||
| 		return (parent || document).querySelector(`[class*="${cls}_"]`); | ||||
| @@ -60,4 +55,31 @@ class DOM { | ||||
| 		const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1"); | ||||
| 		return value.length ? JSON.parse(decodeURIComponent(value)) : null; | ||||
| 	} | ||||
| 	 | ||||
| 	/** | ||||
| 	 * Returns internal React state object of an element. | ||||
| 	 */ | ||||
| 	static getReactProps(ele) { | ||||
| 		const keys = Object.keys(ele || {}); | ||||
| 		let key = keys.find(key => key.startsWith("__reactInternalInstance")); | ||||
| 		 | ||||
| 		if (key) { | ||||
| 			// noinspection JSUnresolvedVariable | ||||
| 			return ele[key].memoizedProps; | ||||
| 		} | ||||
| 		 | ||||
| 		key = keys.find(key => key.startsWith("__reactProps$")); | ||||
| 		return key ? ele[key] : null; | ||||
| 	} | ||||
| 	 | ||||
| 	/** | ||||
| 	 * Returns internal React state object of an element, or null if the retrieval throws. | ||||
| 	 */ | ||||
| 	static tryGetReactProps(ele) { | ||||
| 		try { | ||||
| 			return this.getReactProps(ele); | ||||
| 		} catch (e) { | ||||
| 			return null; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -2,17 +2,14 @@ | ||||
| const GUI = (function() { | ||||
| 	let controller = null; | ||||
| 	let settings = null; | ||||
| 	let trackingStyles = null; | ||||
| 	 | ||||
| 	const stateChangedEvent = () => { | ||||
| 		if (settings) { | ||||
| 			settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll; | ||||
| 			settings.ui.cbHidePreviewsWhileAutoscrolling.checked = SETTINGS.hidePreviewsWhileAutoscrolling; | ||||
| 			settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true; | ||||
| 			settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true; | ||||
| 			 | ||||
| 			const autoscrollDisabled = !SETTINGS.autoscroll; | ||||
| 			settings.ui.cbHidePreviewsWhileAutoscrolling.disabled = autoscrollDisabled; | ||||
| 			Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollDisabled); | ||||
| 			Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollDisabled); | ||||
| 		} | ||||
| @@ -57,7 +54,6 @@ const GUI = (function() { | ||||
| 			 | ||||
| 			controller.ui.btnClose.addEventListener("click", () => { | ||||
| 				this.hideController(); | ||||
| 				this.deleteTrackingStyles(); | ||||
| 				window.DHT_ON_UNLOAD.forEach(f => f()); | ||||
| 				delete window.DHT_ON_UNLOAD; | ||||
| 				delete window.DHT_LOADED; | ||||
| @@ -88,7 +84,6 @@ const GUI = (function() { | ||||
| 			const radio = (type, id, label) => "<label><input id='dht-cfg-" + type + "-" + id + "' name='dht-" + type + "' type='radio'> " + label + "</label><br>"; | ||||
| 			const html = ` | ||||
| <label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br> | ||||
| <label><input id='dht-cfg-hide-previews-while-autoscrolling' type='checkbox'> Hide previews to improve browser performance</label><br> | ||||
| <br> | ||||
| <label>After reaching the first message in channel...</label><br> | ||||
| ${radio("afm", "nothing", "Continue Tracking")} | ||||
| @@ -98,7 +93,8 @@ ${radio("afm", "switch", "Switch to Next Channel")} | ||||
| <label>After reaching a previously saved message...</label><br> | ||||
| ${radio("asm", "nothing", "Continue Tracking")} | ||||
| ${radio("asm", "pause", "Pause Tracking")} | ||||
| ${radio("asm", "switch", "Switch to Next Channel")}`; | ||||
| ${radio("asm", "switch", "Switch to Next Channel")} | ||||
| <p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`; | ||||
| 			 | ||||
| 			settings = { | ||||
| 				styles: DOM.createStyle(`/*[CSS-SETTINGS]*/`), | ||||
| @@ -111,10 +107,9 @@ ${radio("asm", "switch", "Switch to Next Channel")}`; | ||||
| 			}); | ||||
| 			 | ||||
| 			settings.ui = { | ||||
| 				/** @type {HTMLInputElement} */ cbAutoscroll: DOM.id("dht-cfg-autoscroll"), | ||||
| 				/** @type {HTMLInputElement} */ cbHidePreviewsWhileAutoscrolling: DOM.id("dht-cfg-hide-previews-while-autoscrolling"), | ||||
| 				/** @type {Object.<number, HTMLInputElement>} */ optsAfterFirstMsg: {}, | ||||
| 				/** @type {Object.<number, HTMLInputElement>} */ optsAfterSavedMsg: {} | ||||
| 				cbAutoscroll: DOM.id("dht-cfg-autoscroll"), | ||||
| 				optsAfterFirstMsg: {}, | ||||
| 				optsAfterSavedMsg: {} | ||||
| 			}; | ||||
| 			 | ||||
| 			settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing"); | ||||
| @@ -129,10 +124,6 @@ ${radio("asm", "switch", "Switch to Next Channel")}`; | ||||
| 				SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked; | ||||
| 			}); | ||||
| 			 | ||||
| 			settings.ui.cbHidePreviewsWhileAutoscrolling.addEventListener("change", () => { | ||||
| 				SETTINGS.hidePreviewsWhileAutoscrolling = settings.ui.cbHidePreviewsWhileAutoscrolling.checked; | ||||
| 			}); | ||||
| 			 | ||||
| 			Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => { | ||||
| 				settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => { | ||||
| 					SETTINGS.afterFirstMsg = key; | ||||
| @@ -161,29 +152,6 @@ ${radio("asm", "switch", "Switch to Next Channel")}`; | ||||
| 			if (controller) { | ||||
| 				controller.ui.textStatus.innerText = state; | ||||
| 			} | ||||
| 		}, | ||||
| 		 | ||||
| 		createTrackingStyles() { | ||||
| 			if (trackingStyles) { | ||||
| 				return; | ||||
| 			} | ||||
| 			 | ||||
| 			let style = ""; | ||||
| 			 | ||||
| 			if (SETTINGS.autoscroll && SETTINGS.hidePreviewsWhileAutoscrolling) { | ||||
| 				style += `div[id^="message-accessories-"] { display: none; }`; | ||||
| 			} | ||||
| 			 | ||||
| 			if (style.length > 0) { | ||||
| 				trackingStyles = DOM.createStyle(style); | ||||
| 			} | ||||
| 		}, | ||||
| 		 | ||||
| 		deleteTrackingStyles() { | ||||
| 			if (trackingStyles) { | ||||
| 				DOM.removeElement(trackingStyles); | ||||
| 				trackingStyles = null; | ||||
| 			} | ||||
| 		} | ||||
| 	}; | ||||
| })(); | ||||
|   | ||||
| @@ -35,28 +35,17 @@ const SETTINGS = (function() { | ||||
| 		obj[name] = value; | ||||
| 	}; | ||||
| 	 | ||||
| 	const defaults = { | ||||
| 		"_autoscroll": true, | ||||
| 		"_hidePreviewsWhileAutoscrolling": true, | ||||
| 		"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE, | ||||
| 		"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE, | ||||
| 	}; | ||||
| 	 | ||||
| 	let loaded = DOM.loadFromCookie("DHT_SETTINGS"); | ||||
| 	let hasChanged = false; | ||||
| 	 | ||||
| 	if (!loaded) { | ||||
| 		loaded = defaults; | ||||
| 		loaded = { | ||||
| 			"_autoscroll": true, | ||||
| 			"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE, | ||||
| 			"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE | ||||
| 		}; | ||||
| 		 | ||||
| 		IS_FIRST_RUN = true; | ||||
| 	} | ||||
| 	else { | ||||
| 		for (const property in defaults) { | ||||
| 			if (!(property in loaded)) { | ||||
| 				loaded[property] = defaults[property]; | ||||
| 				hasChanged = true; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	const root = { | ||||
| 		onSettingsChanged(callback) { | ||||
| @@ -65,11 +54,10 @@ const SETTINGS = (function() { | ||||
| 	}; | ||||
| 	 | ||||
| 	defineTriggeringProperty(root, "autoscroll", loaded._autoscroll); | ||||
| 	defineTriggeringProperty(root, "hidePreviewsWhileAutoscrolling", loaded._hidePreviewsWhileAutoscrolling); | ||||
| 	defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg); | ||||
| 	defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg); | ||||
| 	 | ||||
| 	if (IS_FIRST_RUN || hasChanged) { | ||||
| 	if (IS_FIRST_RUN) { | ||||
| 		saveSettings(); | ||||
| 	} | ||||
| 	 | ||||
|   | ||||
| @@ -58,39 +58,64 @@ const STATE = (function() { | ||||
| 		} | ||||
| 	}; | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @param {DiscordChannel} channel | ||||
| 	 */ | ||||
| 	const getPrivateChannelName = function(channel) { | ||||
| 		if (channel.name === "") { | ||||
| 			return channel.rawRecipients.map(user => user.username).join(", "); | ||||
| 		} | ||||
| 		else { | ||||
| 			return channel.name; | ||||
| 		} | ||||
| 	}; | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @param {Number} type | ||||
| 	 */ | ||||
| 	const getChannelTypeName = function(type) { | ||||
| 		if (type === DISCORD.CHANNEL_TYPE.DM) { | ||||
| 			return "DM"; | ||||
| 		} | ||||
| 		else if (type === DISCORD.CHANNEL_TYPE.GROUP_DM) { | ||||
| 			return "GROUP"; | ||||
| 		} | ||||
| 		else { | ||||
| 			return "SERVER"; | ||||
| 		} | ||||
| 	}; | ||||
| 	 | ||||
| 	const trackingStateChangedListeners = []; | ||||
| 	let isTracking = false; | ||||
| 	 | ||||
| 	const addedChannels = new Set(); | ||||
| 	const addedUsers = new Set(); | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @name DiscordUser | ||||
| 	 * @property {String} id | ||||
| 	 * @property {String} username | ||||
| 	 * @property {String} discriminator | ||||
| 	 * @property {String} [avatar] | ||||
| 	 * @property {Boolean} [bot] | ||||
| 	 */ | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @name DiscordMessage | ||||
| 	 * @property {String} id | ||||
| 	 * @property {String} channel_id | ||||
| 	 * @property {DiscordUser} author | ||||
| 	 * @property {String} content | ||||
| 	 * @property {Date} timestamp | ||||
| 	 * @property {Date|null} editedTimestamp | ||||
| 	 * @property {DiscordAttachment[]} attachments | ||||
| 	 * @property {Object[]} embeds | ||||
| 	 * @property {DiscordMessageReaction[]} [reactions] | ||||
| 	 * @property {DiscordMessageReference} [messageReference] | ||||
| 	 * @property {Number} type | ||||
| 	 * @property {String} state | ||||
| 	 */ | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @name DiscordAttachment | ||||
| 	 * @property {String} id | ||||
| 	 * @property {String} filename | ||||
| 	 * @property {String} [content_type] | ||||
| 	 * @property {String} size | ||||
| 	 * @property {String} url | ||||
| 	 */ | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @name DiscordMessageReaction | ||||
| 	 * @property {DiscordEmoji} emoji | ||||
| 	 * @property {Number} count | ||||
| 	 */ | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @name DiscordMessageReference | ||||
| 	 * @property {String} [message_id] | ||||
| 	 */ | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @name DiscordEmoji | ||||
| 	 * @property {String|null} id | ||||
| 	 * @property {String|null} name | ||||
| 	 * @property {Boolean} animated | ||||
| 	 */ | ||||
| 	 | ||||
| 	return { | ||||
| 		setup(port, token) { | ||||
| 			serverPort = port; | ||||
| @@ -121,51 +146,32 @@ const STATE = (function() { | ||||
| 			} | ||||
| 		}, | ||||
| 		 | ||||
| 		/** | ||||
| 		 * @param {?DiscordGuild} serverInfo | ||||
| 		 * @param {DiscordChannel} channelInfo | ||||
| 		 */ | ||||
| 		async addDiscordChannel(serverInfo, channelInfo) { | ||||
| 			if (addedChannels.has(channelInfo.id)) { | ||||
| 				return; | ||||
| 			} | ||||
| 			 | ||||
| 			const server = { | ||||
| 				type: getChannelTypeName(channelInfo.type) | ||||
| 				id: serverInfo.id, | ||||
| 				name: serverInfo.name, | ||||
| 				type: serverInfo.type | ||||
| 			}; | ||||
| 			 | ||||
| 			const channel = { | ||||
| 				id: channelInfo.id, | ||||
| 				extra: {} | ||||
| 				name: channelInfo.name | ||||
| 			}; | ||||
| 			 | ||||
| 			if (DISCORD.CHANNEL_TYPE.isPrivate(channelInfo.type)) { | ||||
| 				server.id = channelInfo.id; | ||||
| 				server.name = channel.name = getPrivateChannelName(channelInfo); | ||||
| 			} | ||||
| 			else if (serverInfo) { | ||||
| 				server.id = serverInfo.id; | ||||
| 				server.name = serverInfo.name; | ||||
| 				channel.name = channelInfo.name; | ||||
| 			} | ||||
| 			else { | ||||
| 				return; | ||||
| 			} | ||||
| 			 | ||||
| 			if ("nsfw" in channelInfo) { | ||||
| 				channel.extra.nsfw = channelInfo.nsfw; | ||||
| 			} | ||||
| 			 | ||||
| 			if ("topic" in channelInfo) { | ||||
| 				channel.extra.topic = channelInfo.topic; | ||||
| 			} | ||||
| 			 | ||||
| 			if ("position" in channelInfo) { | ||||
| 				channel.extra.position = channelInfo.position; | ||||
| 			} | ||||
| 			 | ||||
| 			if (channelInfo.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) { | ||||
| 				channel.extra.parent = channelInfo.parent_id; | ||||
| 			if ("extra" in channelInfo) { | ||||
| 				const extra = channelInfo.extra; | ||||
| 				 | ||||
| 				if ("parent" in extra) { | ||||
| 					channel.parent = extra.parent; | ||||
| 				} | ||||
| 				 | ||||
| 				channel.position = extra.position; | ||||
| 				channel.topic = extra.topic; | ||||
| 				channel.nsfw = extra.nsfw; | ||||
| 			} | ||||
| 			 | ||||
| 			await post("/track-channel", { server, channel }); | ||||
| @@ -194,10 +200,6 @@ const STATE = (function() { | ||||
| 						name: user.username | ||||
| 					}; | ||||
| 					 | ||||
| 					if (user.globalName) { | ||||
| 						obj.displayName = user.globalName; | ||||
| 					} | ||||
| 					 | ||||
| 					if (user.avatar) { | ||||
| 						obj.avatar = user.avatar; | ||||
| 					} | ||||
| @@ -286,7 +288,7 @@ const STATE = (function() { | ||||
| 				} | ||||
| 				 | ||||
| 				if (msg.reactions.length > 0) { | ||||
| 					obj.reactions = msg.reactions.filter(reaction => reaction.count > 0).map(reaction => { | ||||
| 					obj.reactions = msg.reactions.map(reaction => { | ||||
| 						const emoji = reaction.emoji; | ||||
| 						 | ||||
| 						const mapped = { | ||||
|   | ||||
| @@ -1,82 +0,0 @@ | ||||
| /** | ||||
|  * @name DiscordGuild | ||||
|  * @property {String} id | ||||
|  * @property {String} name | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @name DiscordChannel | ||||
|  * @property {String} id | ||||
|  * @property {String} name | ||||
|  * @property {Number} type | ||||
|  * @property {String} [guild_id] | ||||
|  * @property {String} [parent_id] | ||||
|  * @property {Number} [position] | ||||
|  * @property {String} [topic] | ||||
|  * @property {Boolean} [nsfw] | ||||
|  * @property {DiscordUser[]} [rawRecipients] | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @name DiscordUser | ||||
|  * @property {String} id | ||||
|  * @property {String} username | ||||
|  * @property {String} discriminator | ||||
|  * @property {String} [globalName] | ||||
|  * @property {String} [avatar] | ||||
|  * @property {Boolean} [bot] | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @name DiscordMessage | ||||
|  * @property {String} id | ||||
|  * @property {String} channel_id | ||||
|  * @property {DiscordUser} author | ||||
|  * @property {String} content | ||||
|  * @property {Date} timestamp | ||||
|  * @property {Date|null} editedTimestamp | ||||
|  * @property {DiscordAttachment[]} attachments | ||||
|  * @property {Object[]} embeds | ||||
|  * @property {DiscordMessageReaction[]} [reactions] | ||||
|  * @property {DiscordMessageReference} [messageReference] | ||||
|  * @property {Number} type | ||||
|  * @property {String} state | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @name DiscordAttachment | ||||
|  * @property {String} id | ||||
|  * @property {String} filename | ||||
|  * @property {String} [content_type] | ||||
|  * @property {String} size | ||||
|  * @property {String} url | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @name DiscordMessageReaction | ||||
|  * @property {DiscordEmoji} emoji | ||||
|  * @property {Number} count | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @name DiscordMessageReference | ||||
|  * @property {String} [message_id] | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @name DiscordEmoji | ||||
|  * @property {String|null} id | ||||
|  * @property {String|null} name | ||||
|  * @property {Boolean} animated | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @name MessageData | ||||
|  * @type {Object} | ||||
|  * @property {String} channelId | ||||
|  * @property {Boolean} ready | ||||
|  * @property {Boolean} loadingMore | ||||
|  * @property {Boolean} hasMoreAfter | ||||
|  * @property {Boolean} hasMoreBefore | ||||
|  * @property {Array<DiscordMessage>} _array | ||||
|  */ | ||||
| @@ -1,98 +0,0 @@ | ||||
| /** | ||||
|  * Parts copied from Better Discord, licensed under Apache License 2.0. | ||||
|  *  | ||||
|  * https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/renderer/src/modules/webpackmodules.js | ||||
|  * https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/LICENSE | ||||
|  */ | ||||
| class WEBPACK { | ||||
| 	static get require() { | ||||
| 		if (this._require) { | ||||
| 			return this._require; | ||||
| 		} | ||||
| 		 | ||||
| 		/** | ||||
| 		 * @type {Object} | ||||
| 		 * @property {Object} m | ||||
| 		 * @property {Object} c | ||||
| 		 */ | ||||
| 		let hookedRequire; | ||||
| 		 | ||||
| 		const id = "dht-webpackmodules-" + new Date().getTime(); | ||||
| 		if (typeof (window["webpackChunkdiscord_app"]) !== "undefined") { | ||||
| 			window["webpackChunkdiscord_app"].push([ [ id ], {}, internalRequire => hookedRequire = internalRequire ]); | ||||
| 		} | ||||
| 		 | ||||
| 		delete hookedRequire.m[id]; | ||||
| 		delete hookedRequire.c[id]; | ||||
| 		return this._require = hookedRequire; | ||||
| 	} | ||||
| 	 | ||||
| 	static getAllModules() { | ||||
| 		return this.require.c; | ||||
| 	} | ||||
| 	 | ||||
| 	static filterByProps(...props) { | ||||
| 		return module => props.every(prop => prop in module); | ||||
| 	} | ||||
| 	 | ||||
| 	static filterByPropsWithPredicate(predicate, ...props) { | ||||
| 		return module => props.every(prop => prop in module && predicate(module[prop])); | ||||
| 	} | ||||
| 	 | ||||
| 	static findModules(filter) { | ||||
| 		const defaultExport = true; | ||||
| 		const moduleFilter = module => (typeof module === "object" || typeof module === "function") && filter(module); | ||||
| 		 | ||||
| 		const results = []; | ||||
| 		 | ||||
| 		for (const module of Object.values(this.getAllModules())) { | ||||
| 			/** | ||||
| 			 * @type {Object} | ||||
| 			 * @property [Z] | ||||
| 			 * @property [ZP] | ||||
| 			 * @property [__esModule] | ||||
| 			 * @property [default] | ||||
| 			 */ | ||||
| 			const exports = module.exports; | ||||
| 			if (!exports || exports === window || exports === document.documentElement || exports[Symbol.toStringTag] === "DOMTokenList") { | ||||
| 				continue; | ||||
| 			} | ||||
| 			 | ||||
| 			let foundModule = null; | ||||
| 			if (exports.Z && moduleFilter(exports.Z)) { | ||||
| 				foundModule = defaultExport ? exports.Z : exports; | ||||
| 			} | ||||
| 			if (exports.ZP && moduleFilter(exports.ZP)) { | ||||
| 				foundModule = defaultExport ? exports.ZP : exports; | ||||
| 			} | ||||
| 			if (exports.__esModule && exports.default && moduleFilter(exports.default)) { | ||||
| 				foundModule = defaultExport ? exports.default : exports; | ||||
| 			} | ||||
| 			if (moduleFilter(exports)) { | ||||
| 				foundModule = exports; | ||||
| 			} | ||||
| 			 | ||||
| 			if (foundModule) { | ||||
| 				results.push(foundModule); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		return results; | ||||
| 	} | ||||
| 	 | ||||
| 	static findModule(name, filter) { | ||||
| 		const modules = this.findModules(filter); | ||||
| 		if (modules.length === 1) { | ||||
| 			return modules[0]; | ||||
| 		} | ||||
| 		 | ||||
| 		console.error("[DHT] Cannot find module " + name + ", results found:", modules.length); | ||||
| 		return null; | ||||
| 	} | ||||
| 	 | ||||
| 	static findFunction(name, additionalRequiredProps) { | ||||
| 		const searchedProps = additionalRequiredProps ? [name, ...additionalRequiredProps] : [name]; | ||||
| 		const matchingModule = this.findModule("containing function " + name, this.filterByPropsWithPredicate(prop => typeof(prop) === "function", ...searchedProps)); | ||||
| 		return matchingModule == null ? null : matchingModule[name].bind(matchingModule); | ||||
| 	} | ||||
| } | ||||
| @@ -18,13 +18,11 @@ | ||||
|   height: 262px; | ||||
|   margin-left: -400px; | ||||
|   margin-top: -131px; | ||||
|   line-height: 120%; | ||||
|   padding: 8px; | ||||
|   background-color: #fff; | ||||
|   z-index: 1000002; | ||||
| } | ||||
|  | ||||
| #dht-cfg label { | ||||
|   display: inline-block; | ||||
|   margin: 1px 0; | ||||
| #dht-cfg-note { | ||||
|   margin-top: 22px; | ||||
| } | ||||
|   | ||||
| @@ -2,8 +2,6 @@ | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <meta name="referrer" content="no-referrer"> | ||||
|      | ||||
|     <title>Discord Offline History</title> | ||||
|      | ||||
|     <link rel="icon" href="favicon.ico"> | ||||
| @@ -15,9 +13,7 @@ | ||||
|     <link rel="stylesheet" href="styles/modal.css"> | ||||
|      | ||||
|     <script type="text/javascript"> | ||||
| 		const query = new URLSearchParams(location.search); | ||||
| 		window.DHT_SERVER_TOKEN = query.get("token"); | ||||
| 		window.DHT_SERVER_SESSION = query.get("session"); | ||||
| 		window.DHT_SERVER_TOKEN = new URLSearchParams(location.search).get("token"); | ||||
|     </script> | ||||
|     <script type="module" src="scripts/bootstrap.mjs"></script> | ||||
|   </head> | ||||
| @@ -25,8 +21,6 @@ | ||||
|     <div id="menu"> | ||||
|       <button id="btn-settings">Settings</button> | ||||
|        | ||||
|       <div class="splitter"></div> | ||||
|        | ||||
|       <div> <!-- needed to stop the select from messing up --> | ||||
|         <select id="opt-messages-per-page"> | ||||
|           <option value="50">50 messages per page </option> | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import discord from "./discord.mjs"; | ||||
| import gui from "./gui.mjs"; | ||||
| import state from "./state.mjs"; | ||||
| import "./polyfills.mjs"; | ||||
|  | ||||
| window.DISCORD = discord; | ||||
|  | ||||
| @@ -37,69 +36,19 @@ document.addEventListener("DOMContentLoaded", () => { | ||||
| 		gui.scrollMessagesToTop(); | ||||
| 	}); | ||||
| 	 | ||||
| 	async function fetchUrl(path, contentType) { | ||||
| 		const response = await fetch("/" + path + "?token=" + encodeURIComponent(window.DHT_SERVER_TOKEN) + "&session=" + encodeURIComponent(window.DHT_SERVER_SESSION), { | ||||
| 			method: "GET", | ||||
| 			headers: { | ||||
| 				"Content-Type": contentType, | ||||
| 			}, | ||||
| 			credentials: "omit", | ||||
| 			redirect: "error", | ||||
| 		}); | ||||
| 		 | ||||
| 		if (!response.ok) { | ||||
| 			throw "Unexpected response status: " + response.statusText; | ||||
| 		} | ||||
| 		 | ||||
| 		return response; | ||||
| 	} | ||||
| 	 | ||||
| 	async function processLines(response, callback) { | ||||
| 		let body = ""; | ||||
| 		 | ||||
| 		for await (const chunk of response.body.pipeThrough(new TextDecoderStream("utf-8"))) { | ||||
| 			body += chunk; | ||||
| 			 | ||||
| 			let startIndex = 0; | ||||
| 			 | ||||
| 			while (true) { | ||||
| 				const endIndex = body.indexOf("\n", startIndex); | ||||
| 				if (endIndex === -1) { | ||||
| 					break; | ||||
| 				} | ||||
| 				 | ||||
| 				callback(body.substring(startIndex, endIndex)); | ||||
| 				startIndex = endIndex + 1; | ||||
| 			} | ||||
| 			 | ||||
| 			body = body.substring(startIndex); | ||||
| 		} | ||||
| 		 | ||||
| 		if (body !== "") { | ||||
| 			callback(body); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	async function loadData() { | ||||
| 		try { | ||||
| 			const metadataResponse = await fetchUrl("get-viewer-metadata", "application/json"); | ||||
| 			const metadataJson = await metadataResponse.json(); | ||||
| 			 | ||||
| 			const messagesResponse = await fetchUrl("get-viewer-messages", "application/x-ndjson"); | ||||
| 			const messages = {}; | ||||
| 			 | ||||
| 			await processLines(messagesResponse, line => { | ||||
| 				const message = JSON.parse(line); | ||||
| 				const channel = message.c; | ||||
| 				 | ||||
| 				const channelMessages = messages[channel] || (messages[channel] = {}); | ||||
| 				channelMessages[message.id] = message; | ||||
| 				 | ||||
| 				delete message.id; | ||||
| 				delete message.c; | ||||
| 			const response = await fetch("/get-viewer-data?token=" + encodeURIComponent(window.DHT_SERVER_TOKEN), { | ||||
| 				method: "GET", | ||||
| 				headers: { | ||||
| 					"Content-Type": "application/json", | ||||
| 				}, | ||||
| 				credentials: "omit", | ||||
| 				redirect: "error", | ||||
| 			}); | ||||
| 			 | ||||
| 			state.uploadFile(metadataJson, messages); | ||||
| 			const json = await response.json(); | ||||
| 			state.uploadFile(json); | ||||
| 		} catch (e) { | ||||
| 			console.error(e); | ||||
| 			alert("Could not load data, see console for details."); | ||||
|   | ||||
| @@ -80,7 +80,7 @@ export default (function() { | ||||
| 		processed = processed | ||||
| 			.replace(regex.formatUrl, "<a href='$1' target='_blank' rel='noreferrer'>$1</a>") | ||||
| 			.replace(regex.mentionChannel, (full, match) => "<span class='link mention-chat'>#" + state.getChannelName(match) + "</span>") | ||||
| 			.replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='" + state.getUserName(match) + "'>@" + state.getUserDisplayName(match) + "</span>") | ||||
| 			.replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='#" + (state.getUserTag(match) || "????") + "'>@" + state.getUserName(match) + "</span>") | ||||
| 			.replace(regex.customEmojiStatic, (full, m1, m2) => getEmoji(m1, m2, "webp")) | ||||
| 			.replace(regex.customEmojiAnimated, (full, m1, m2) => getEmoji(m1, m2, animatedEmojiExtension)); | ||||
| 		 | ||||
| @@ -129,7 +129,7 @@ export default (function() { | ||||
| 			templateMessageNoAvatar = new template([ | ||||
| 				"<div>", | ||||
| 				"<div class='reply-message'>{reply}</div>", | ||||
| 				"<h2><strong class='username' title='{user.name}'>{user.displayName}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>", | ||||
| 				"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>", | ||||
| 				"<div class='message'>{contents}{embeds}{attachments}</div>", | ||||
| 				"{reactions}", | ||||
| 				"</div>" | ||||
| @@ -141,7 +141,7 @@ export default (function() { | ||||
| 				"<div class='avatar-wrapper'>", | ||||
| 				"<div class='avatar'>{avatar}</div>", | ||||
| 				"<div>", | ||||
| 				"<h2><strong class='username' title='{user.name}'>{user.displayName}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>", | ||||
| 				"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>", | ||||
| 				"<div class='message'>{contents}{embeds}{attachments}</div>", | ||||
| 				"{reactions}", | ||||
| 				"</div>", | ||||
| @@ -227,8 +227,8 @@ export default (function() { | ||||
| 				if (property === "avatar") { | ||||
| 					return value ? templateUserAvatar.apply(getAvatarUrlObject(value)) : ""; | ||||
| 				} | ||||
| 				else if (property === "user.displayName") { | ||||
| 					return value ? value : message.user.name; | ||||
| 				else if (property === "user.tag") { | ||||
| 					return value ? value : "????"; | ||||
| 				} | ||||
| 				else if (property === "timestamp") { | ||||
| 					return dom.getHumanReadableTime(value); | ||||
| @@ -246,10 +246,10 @@ export default (function() { | ||||
| 							return templateEmbedUnsupported.apply(embed); | ||||
| 						} | ||||
| 						else if ("image" in embed && embed.image.url) { | ||||
| 							return getImageEmbed(embed.url, embed.image); | ||||
| 							return getImageEmbed(fileUrlProcessor(embed.url), embed.image); | ||||
| 						} | ||||
| 						else if ("thumbnail" in embed && embed.thumbnail.url) { | ||||
| 							return getImageEmbed(embed.url, embed.thumbnail); | ||||
| 							return getImageEmbed(fileUrlProcessor(embed.url), embed.thumbnail); | ||||
| 						} | ||||
| 						else if ("title" in embed && "description" in embed) { | ||||
| 							return templateEmbedRich.apply(embed); | ||||
| @@ -292,7 +292,7 @@ export default (function() { | ||||
| 						return value === null ? "<span class='reply-contents reply-missing'>(replies to an unknown message)</span>" : ""; | ||||
| 					} | ||||
| 					 | ||||
| 					const user = "<span class='reply-username' title='" + value.user.name + "'>" + (value.user.displayName ?? value.user.name) + "</span>"; | ||||
| 					const user = "<span class='reply-username' title='#" + (value.user.tag ? value.user.tag : "????") + "'>" + value.user.name + "</span>"; | ||||
| 					const avatar = settings.enableUserAvatars && value.avatar ? "<span class='reply-avatar'>" + templateUserAvatar.apply(getAvatarUrlObject(value.avatar)) + "</span>" : ""; | ||||
| 					const contents = value.contents ? "<span class='reply-contents'>" + processMessageContents(value.contents) + "</span>" : ""; | ||||
| 					 | ||||
|   | ||||
| @@ -243,11 +243,10 @@ export default (function() { | ||||
| 			 | ||||
| 			const options = []; | ||||
| 			 | ||||
| 			for (const id of Object.keys(users)) { | ||||
| 				const user = users[id]; | ||||
| 			for (const key of Object.keys(users)) { | ||||
| 				const option = document.createElement("option"); | ||||
| 				option.value = id; | ||||
| 				option.text = user.displayName ? `${user.displayName} (${user.name})` : user.name; | ||||
| 				option.value = key; | ||||
| 				option.text = users[key].name; | ||||
| 				options.push(option); | ||||
| 			} | ||||
| 			 | ||||
|   | ||||
| @@ -1,35 +0,0 @@ | ||||
| // https://gist.github.com/MattiasBuelens/496fc1d37adb50a733edd43853f2f60e/088f061ab79b296f29225467ae9ba86ff990195d | ||||
|  | ||||
| ReadableStream.prototype.values ??= function({ preventCancel = false } = {}) { | ||||
| 	const reader = this.getReader(); | ||||
| 	return { | ||||
| 		async next() { | ||||
| 			try { | ||||
| 				const result = await reader.read(); | ||||
| 				if (result.done) { | ||||
| 					reader.releaseLock(); | ||||
| 				} | ||||
| 				return result; | ||||
| 			} catch (e) { | ||||
| 				reader.releaseLock(); | ||||
| 				throw e; | ||||
| 			} | ||||
| 		}, | ||||
| 		async return(value) { | ||||
| 			if (!preventCancel) { | ||||
| 				const cancelPromise = reader.cancel(value); | ||||
| 				reader.releaseLock(); | ||||
| 				await cancelPromise; | ||||
| 			} | ||||
| 			else { | ||||
| 				reader.releaseLock(); | ||||
| 			} | ||||
| 			return { done: true, value }; | ||||
| 		}, | ||||
| 		[Symbol.asyncIterator]() { | ||||
| 			return this; | ||||
| 		} | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
| ReadableStream.prototype[Symbol.asyncIterator] ??= ReadableStream.prototype.values; | ||||
| @@ -5,7 +5,7 @@ import discord from "./discord.mjs"; | ||||
| // ------------------------ | ||||
|  | ||||
| const filter = { | ||||
| 	byUser: ((user) => message => message.u === user), | ||||
| 	byUser: ((userindex) => message => message.u === userindex), | ||||
| 	byTime: ((timeStart, timeEnd) => message => message.t >= timeStart && message.t <= timeEnd), | ||||
| 	byContents: ((substr) => message => ("m" in message ? message.m : "").indexOf(substr) !== -1), | ||||
| 	byRegex: ((regex) => message => regex.test("m" in message ? message.m : "")), | ||||
|   | ||||
| @@ -6,7 +6,8 @@ export default (function() { | ||||
| 	/** | ||||
| 	 * @type {{}} | ||||
| 	 * @property {{}} users | ||||
| 	 * @property {{}} servers | ||||
| 	 * @property {String[]} userindex | ||||
| 	 * @property {{}[]} servers | ||||
| 	 * @property {{}} channels | ||||
| 	 */ | ||||
| 	let loadedFileMeta; | ||||
| @@ -19,16 +20,20 @@ export default (function() { | ||||
| 	let currentPage; | ||||
| 	let messagesPerPage; | ||||
| 	 | ||||
| 	const getUser = function(id) { | ||||
| 		return loadedFileMeta.users[id] || { "name": "<unknown>" }; | ||||
| 	const getUser = function(index) { | ||||
| 		return loadedFileMeta.users[loadedFileMeta.userindex[index]] || { "name": "<unknown>" }; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getUserId = function(index) { | ||||
| 		return loadedFileMeta.userindex[index]; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getUserList = function() { | ||||
| 		return loadedFileMeta ? loadedFileMeta.users : []; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getServer = function(id) { | ||||
| 		return loadedFileMeta.servers[id] || { "name": "<unknown>", "type": "unknown" }; | ||||
| 	const getServer = function(index) { | ||||
| 		return loadedFileMeta.servers[index] || { "name": "<unknown>", "type": "unknown" }; | ||||
| 	}; | ||||
| 	 | ||||
| 	const generateChannelHierarchy = function() { | ||||
| @@ -202,7 +207,7 @@ export default (function() { | ||||
| 			 */ | ||||
| 			const message = messages[key]; | ||||
| 			const user = getUser(message.u); | ||||
| 			const avatar = user.avatar ? { id: message.u, path: user.avatar } : null; | ||||
| 			const avatar = user.avatar ? { id: getUserId(message.u), path: user.avatar } : null; | ||||
| 			 | ||||
| 			const obj = { | ||||
| 				user, | ||||
| @@ -230,7 +235,7 @@ export default (function() { | ||||
| 			if ("r" in message) { | ||||
| 				const replyMessage = getMessageById(message.r); | ||||
| 				const replyUser = replyMessage ? getUser(replyMessage.u) : null; | ||||
| 				const replyAvatar = replyUser && replyUser.avatar ? { id: replyMessage.u, path: replyUser.avatar } : null; | ||||
| 				const replyAvatar = replyUser && replyUser.avatar ? { id: getUserId(replyMessage.u), path: replyUser.avatar } : null; | ||||
| 				 | ||||
| 				obj["reply"] = replyMessage ? { | ||||
| 					"id": message.r, | ||||
| @@ -288,17 +293,20 @@ export default (function() { | ||||
| 			eventOnUsersRefreshed = callback; | ||||
| 		}, | ||||
| 		 | ||||
| 		uploadFile(meta, data) { | ||||
| 		/** | ||||
| 		 * @param {{ meta, data }} file | ||||
| 		 */ | ||||
| 		uploadFile(file) { | ||||
| 			if (loadedFileMeta != null) { | ||||
| 				throw "A file is already loaded!"; | ||||
| 			} | ||||
| 			 | ||||
| 			if (typeof meta !== "object" || typeof data !== "object") { | ||||
| 			if (!file || typeof file.meta !== "object" || typeof file.data !== "object") { | ||||
| 				throw "Invalid file format!"; | ||||
| 			} | ||||
| 			 | ||||
| 			loadedFileMeta = meta; | ||||
| 			loadedFileData = data; | ||||
| 			loadedFileMeta = file.meta; | ||||
| 			loadedFileData = file.data; | ||||
| 			loadedMessages = null; | ||||
| 			 | ||||
| 			selectedChannel = null; | ||||
| @@ -316,16 +324,16 @@ export default (function() { | ||||
| 			return (channelObj && channelObj.name) || channel; | ||||
| 		}, | ||||
| 		 | ||||
| 		getUserTag(user) { | ||||
| 			const userObj = loadedFileMeta.users[user]; | ||||
| 			return (userObj && userObj.tag) || "????"; | ||||
| 		}, | ||||
| 		 | ||||
| 		getUserName(user) { | ||||
| 			const userObj = loadedFileMeta.users[user]; | ||||
| 			return (userObj && userObj.name) || user; | ||||
| 		}, | ||||
| 		 | ||||
| 		getUserDisplayName(user) { | ||||
| 			const userObj = loadedFileMeta.users[user]; | ||||
| 			return (userObj && (userObj.displayName || userObj.name)) || user; | ||||
| 		}, | ||||
| 		 | ||||
| 		selectChannel(channel) { | ||||
| 			currentPage = 1; | ||||
| 			selectedChannel = channel; | ||||
| @@ -411,7 +419,7 @@ export default (function() { | ||||
| 		setActiveFilter(filter) { | ||||
| 			switch (filter ? filter.type : "") { | ||||
| 				case "user": | ||||
| 					filterFunction = processor.FILTER.byUser(filter.value); | ||||
| 					filterFunction = processor.FILTER.byUser(loadedFileMeta.userindex.indexOf(filter.value)); | ||||
| 					break; | ||||
| 				 | ||||
| 				case "contents": | ||||
|   | ||||
| @@ -1,16 +1,15 @@ | ||||
| #menu { | ||||
|   width: 100%; | ||||
|   height: 48px; | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   align-items: stretch; | ||||
|   gap: 8px; | ||||
|   padding: 8px; | ||||
|   background-color: #17181c; | ||||
|   border-bottom: 1px dotted #5d626b; | ||||
| } | ||||
|  | ||||
| #menu .splitter { | ||||
|   flex: 0 0 1px; | ||||
|   margin: 9px 1px; | ||||
|   width: 1px; | ||||
|   margin: 9px 4px; | ||||
|   background-color: #5d626b; | ||||
| } | ||||
|  | ||||
| @@ -24,8 +23,7 @@ | ||||
| } | ||||
|  | ||||
| #menu button, #menu select, #menu input[type="text"] { | ||||
|   height: 31px; | ||||
|   padding: 0 10px; | ||||
|   margin: 8px; | ||||
|   background-color: #7289da; | ||||
|   color: #fff; | ||||
|   text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75); | ||||
| @@ -33,25 +31,28 @@ | ||||
|  | ||||
| #menu button { | ||||
|   font-size: 17px; | ||||
|   padding: 0 12px; | ||||
|   border: 0; | ||||
|   cursor: pointer; | ||||
|   white-space: nowrap; | ||||
| } | ||||
|  | ||||
| #menu select { | ||||
|   font-size: 14px; | ||||
|   padding: 6px; | ||||
|   border: 0; | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| #menu input[type="text"] { | ||||
|   font-size: 14px; | ||||
|   padding: 7px 12px; | ||||
|   border: 0; | ||||
| } | ||||
|  | ||||
| #menu .nav { | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   margin: 0 8px; | ||||
| } | ||||
|  | ||||
| #menu .nav > button { | ||||
| @@ -65,7 +66,7 @@ | ||||
| } | ||||
|  | ||||
| #menu .nav > button, #menu .nav > p { | ||||
|   margin: 0 1px; | ||||
|   margin: 8px 1px; | ||||
| } | ||||
|  | ||||
| #opt-filter-list > select, #opt-filter-list > input { | ||||
| @@ -75,7 +76,3 @@ | ||||
| #opt-filter-list > .active { | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| #btn-about { | ||||
|   margin-left: auto; | ||||
| } | ||||
|   | ||||
| @@ -1,60 +0,0 @@ | ||||
| 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); | ||||
| } | ||||
| @@ -3,7 +3,6 @@ namespace DHT.Server.Data; | ||||
| public readonly struct User { | ||||
| 	public ulong Id { get; init; } | ||||
| 	public string Name { get; init; } | ||||
| 	public string? DisplayName { get; init; } | ||||
| 	public string? AvatarUrl { get; init; } | ||||
| 	public string? Discriminator { get; init; } | ||||
| } | ||||
|   | ||||
| @@ -10,7 +10,6 @@ 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(); | ||||
|   | ||||
| @@ -1,5 +1,3 @@ | ||||
| namespace DHT.Server.Database.Export; | ||||
|  | ||||
| readonly record struct Snowflake(ulong Id) { | ||||
| 	public static implicit operator Snowflake(ulong id) => new (id); | ||||
| } | ||||
| readonly record struct Snowflake(ulong Id); | ||||
|   | ||||
| @@ -3,10 +3,14 @@ using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace DHT.Server.Database.Export; | ||||
|  | ||||
| static class ViewerJson { | ||||
| sealed class ViewerJson { | ||||
| 	public required JsonMeta Meta { get; init; } | ||||
| 	public required Dictionary<Snowflake, Dictionary<Snowflake, JsonMessage>> Data { get; init; } | ||||
|  | ||||
| 	public sealed class JsonMeta { | ||||
| 		public required Dictionary<Snowflake, JsonUser> Users { get; init; } | ||||
| 		public required Dictionary<Snowflake, JsonServer> Servers { get; init; } | ||||
| 		public required List<Snowflake> Userindex { get; init; } | ||||
| 		public required List<JsonServer> Servers { get; init; } | ||||
| 		public required Dictionary<Snowflake, JsonChannel> Channels { get; init; } | ||||
| 	} | ||||
| 	 | ||||
| @@ -14,10 +18,10 @@ static class ViewerJson { | ||||
| 		public required string Name { get; init; } | ||||
| 		 | ||||
| 		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
| 		public string? DisplayName { get; init; } | ||||
| 		public string? Avatar { get; init; } | ||||
| 		 | ||||
| 		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
| 		public string? Avatar { get; init; } | ||||
| 		public string? Tag { get; init; } | ||||
| 	} | ||||
|  | ||||
| 	public sealed class JsonServer { | ||||
| @@ -26,7 +30,7 @@ static class ViewerJson { | ||||
| 	} | ||||
|  | ||||
| 	public sealed class JsonChannel { | ||||
| 		public required Snowflake Server { get; init; } | ||||
| 		public required int Server { get; init; } | ||||
| 		public required string Name { get; init; } | ||||
| 		 | ||||
| 		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
| @@ -43,9 +47,7 @@ static class ViewerJson { | ||||
| 	} | ||||
|  | ||||
| 	public sealed class JsonMessage { | ||||
| 		public required Snowflake Id { get; init; } | ||||
| 		public required Snowflake C { get; init; } | ||||
| 		public required Snowflake U { get; init; } | ||||
| 		public required int U { get; init; } | ||||
| 		public required long T { get; init; } | ||||
|  | ||||
| 		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|   | ||||
| @@ -7,5 +7,5 @@ namespace DHT.Server.Database.Export; | ||||
| 	PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, | ||||
| 	GenerationMode = JsonSourceGenerationMode.Default | ||||
| )] | ||||
| [JsonSerializable(typeof(ViewerJson.JsonMeta))] | ||||
| sealed partial class ViewerJsonMetadataContext : JsonSerializerContext; | ||||
| [JsonSerializable(typeof(ViewerJson))] | ||||
| sealed partial class ViewerJsonContext : JsonSerializerContext; | ||||
| @@ -2,9 +2,7 @@ using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Runtime.CompilerServices; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| using DHT.Server.Data.Filters; | ||||
| @@ -15,90 +13,106 @@ namespace DHT.Server.Database.Export; | ||||
| static class ViewerJsonExport { | ||||
| 	private static readonly Log Log = Log.ForType(typeof(ViewerJsonExport)); | ||||
|  | ||||
| 	public static async Task GetMetadata(Stream stream, IDatabaseFile db, MessageFilter? filter = null, CancellationToken cancellationToken = default) { | ||||
| 	public static async Task Generate(Stream stream, IDatabaseFile db, MessageFilter? filter = null) { | ||||
| 		var perf = Log.Start(); | ||||
|  | ||||
| 		var includedChannels = new List<Channel>(); | ||||
| 		var includedUserIds = new HashSet<ulong>(); | ||||
| 		var includedChannelIds = new HashSet<ulong>(); | ||||
| 		var includedServerIds = new HashSet<ulong>(); | ||||
|  | ||||
| 		var channelIdFilter = filter?.ChannelIds; | ||||
| 		var includedMessages = await db.Messages.Get(filter).ToListAsync(); | ||||
| 		var includedChannels = new List<Channel>(); | ||||
|  | ||||
| 		await foreach (var channel in db.Channels.Get(cancellationToken)) { | ||||
| 			if (channelIdFilter == null || channelIdFilter.Contains(channel.Id)) { | ||||
| 		foreach (var message in includedMessages) { | ||||
| 			includedUserIds.Add(message.Sender); | ||||
| 			includedChannelIds.Add(message.Channel); | ||||
| 		} | ||||
|  | ||||
| 		await foreach (var channel in db.Channels.Get()) { | ||||
| 			if (includedChannelIds.Contains(channel.Id)) { | ||||
| 				includedChannels.Add(channel); | ||||
| 				includedServerIds.Add(channel.Server); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		var users = await GenerateUserList(db, cancellationToken); | ||||
| 		var servers = await GenerateServerList(db, includedServerIds, cancellationToken); | ||||
| 		var channels = GenerateChannelList(includedChannels); | ||||
|  | ||||
| 		var meta = new ViewerJson.JsonMeta { | ||||
| 			Users = users, | ||||
| 			Servers = servers, | ||||
| 			Channels = channels | ||||
| 		}; | ||||
| 		var (users, userIndex, userIndices) = await GenerateUserList(db, includedUserIds); | ||||
| 		var (servers, serverIndices) = await GenerateServerList(db, includedServerIds); | ||||
| 		var channels = GenerateChannelList(includedChannels, serverIndices); | ||||
|  | ||||
| 		perf.Step("Collect database data"); | ||||
|  | ||||
| 		await JsonSerializer.SerializeAsync(stream, meta, ViewerJsonMetadataContext.Default.JsonMeta, cancellationToken); | ||||
| 		var value = new ViewerJson { | ||||
| 			Meta = new ViewerJson.JsonMeta { | ||||
| 				Users = users, | ||||
| 				Userindex = userIndex, | ||||
| 				Servers = servers, | ||||
| 				Channels = channels | ||||
| 			}, | ||||
| 			Data = GenerateMessageList(includedMessages, userIndices) | ||||
| 		}; | ||||
|  | ||||
| 		perf.Step("Generate value object"); | ||||
|  | ||||
| 		await JsonSerializer.SerializeAsync(stream, value, ViewerJsonContext.Default.ViewerJson); | ||||
|  | ||||
| 		perf.Step("Serialize to JSON"); | ||||
| 		perf.End(); | ||||
| 	} | ||||
|  | ||||
| 	public static async Task GetMessages(Stream stream, IDatabaseFile db, MessageFilter? filter = null, CancellationToken cancellationToken = default) { | ||||
| 		var perf = Log.Start(); | ||||
|  | ||||
| 		ReadOnlyMemory<byte> newLine = "\n"u8.ToArray(); | ||||
| 		 | ||||
| 		await foreach(var message in GenerateMessageList(db, filter, cancellationToken)) { | ||||
| 			await JsonSerializer.SerializeAsync(stream, message, ViewerJsonMessageContext.Default.JsonMessage, cancellationToken); | ||||
| 			await stream.WriteAsync(newLine, cancellationToken); | ||||
| 		} | ||||
|  | ||||
| 		perf.Step("Generate and serialize messages to JSON"); | ||||
| 		perf.End(); | ||||
| 	} | ||||
|  | ||||
| 	private static async Task<Dictionary<Snowflake, ViewerJson.JsonUser>> GenerateUserList(IDatabaseFile db, CancellationToken cancellationToken) { | ||||
| 	private static async Task<(Dictionary<Snowflake, ViewerJson.JsonUser> Users, List<Snowflake> UserIndex, Dictionary<ulong, int> UserIndices)> GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds) { | ||||
| 		var users = new Dictionary<Snowflake, ViewerJson.JsonUser>(); | ||||
| 		var userIndex = new List<Snowflake>(); | ||||
| 		var userIndices = new Dictionary<ulong, int>(); | ||||
|  | ||||
| 		await foreach (var user in db.Users.Get(cancellationToken)) { | ||||
| 			users[user.Id] = new ViewerJson.JsonUser { | ||||
| 				Name = user.Name, | ||||
| 				DisplayName = user.DisplayName, | ||||
| 				Avatar = user.AvatarUrl, | ||||
| 			}; | ||||
| 		} | ||||
|  | ||||
| 		return users; | ||||
| 	} | ||||
|  | ||||
| 	private static async Task<Dictionary<Snowflake, ViewerJson.JsonServer>> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, CancellationToken cancellationToken) { | ||||
| 		var servers = new Dictionary<Snowflake, ViewerJson.JsonServer>(); | ||||
|  | ||||
| 		await foreach (var server in db.Servers.Get(cancellationToken)) { | ||||
| 			if (!serverIds.Contains(server.Id)) { | ||||
| 		await foreach (var user in db.Users.Get()) { | ||||
| 			var id = user.Id; | ||||
| 			if (!userIds.Contains(id)) { | ||||
| 				continue; | ||||
| 			} | ||||
|  | ||||
| 			servers[server.Id] = new ViewerJson.JsonServer { | ||||
| 				Name = server.Name, | ||||
| 				Type = ServerTypes.ToJsonViewerString(server.Type) | ||||
| 			var idSnowflake = new Snowflake(id); | ||||
| 			userIndices[id] = users.Count; | ||||
| 			userIndex.Add(idSnowflake); | ||||
| 			 | ||||
| 			users[idSnowflake] = new ViewerJson.JsonUser { | ||||
| 				Name = user.Name, | ||||
| 				Avatar = user.AvatarUrl, | ||||
| 				Tag = user.Discriminator | ||||
| 			}; | ||||
| 		} | ||||
|  | ||||
| 		return servers; | ||||
| 		return (users, userIndex, userIndices); | ||||
| 	} | ||||
|  | ||||
| 	private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<Channel> includedChannels) { | ||||
| 	private static async Task<(List<ViewerJson.JsonServer> Servers, Dictionary<ulong, int> ServerIndices)> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds) { | ||||
| 		var servers = new List<ViewerJson.JsonServer>(); | ||||
| 		var serverIndices = new Dictionary<ulong, int>(); | ||||
|  | ||||
| 		await foreach (var server in db.Servers.Get()) { | ||||
| 			var id = server.Id; | ||||
| 			if (!serverIds.Contains(id)) { | ||||
| 				continue; | ||||
| 			} | ||||
|  | ||||
| 			serverIndices[id] = servers.Count; | ||||
| 			 | ||||
| 			servers.Add(new ViewerJson.JsonServer { | ||||
| 				Name = server.Name, | ||||
| 				Type = ServerTypes.ToJsonViewerString(server.Type) | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		return (servers, serverIndices); | ||||
| 	} | ||||
|  | ||||
| 	private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, int> serverIndices) { | ||||
| 		var channels = new Dictionary<Snowflake, ViewerJson.JsonChannel>(); | ||||
|  | ||||
| 		foreach (var channel in includedChannels) { | ||||
| 			channels[channel.Id] = new ViewerJson.JsonChannel { | ||||
| 				Server = channel.Server, | ||||
| 			var channelIdSnowflake = new Snowflake(channel.Id); | ||||
| 			 | ||||
| 			channels[channelIdSnowflake] = new ViewerJson.JsonChannel { | ||||
| 				Server = serverIndices[channel.Server], | ||||
| 				Name = channel.Name, | ||||
| 				Parent = channel.ParentId?.ToString(), | ||||
| 				Position = channel.Position, | ||||
| @@ -110,40 +124,51 @@ static class ViewerJsonExport { | ||||
| 		return channels; | ||||
| 	} | ||||
|  | ||||
| 	private static async IAsyncEnumerable<ViewerJson.JsonMessage> GenerateMessageList(IDatabaseFile db, MessageFilter? filter, [EnumeratorCancellation] CancellationToken cancellationToken) { | ||||
| 		await foreach (var message in db.Messages.Get(filter, cancellationToken)) { | ||||
| 			yield return new ViewerJson.JsonMessage { | ||||
| 				Id = message.Id, | ||||
| 				C = message.Channel, | ||||
| 				U = message.Sender, | ||||
| 				T = message.Timestamp, | ||||
| 				M = string.IsNullOrEmpty(message.Text) ? null : message.Text, | ||||
| 				Te = message.EditTimestamp, | ||||
| 				R = message.RepliedToId?.ToString(), | ||||
| 	private static Dictionary<Snowflake, Dictionary<Snowflake, ViewerJson.JsonMessage>> GenerateMessageList(List<Message> includedMessages, Dictionary<ulong, int> userIndices) { | ||||
| 		var data = new Dictionary<Snowflake, Dictionary<Snowflake, ViewerJson.JsonMessage>>(); | ||||
|  | ||||
| 				A = message.Attachments.IsEmpty ? null : message.Attachments.Select(static attachment => { | ||||
| 					var a = new ViewerJson.JsonMessageAttachment { | ||||
| 						Url = attachment.DownloadUrl, | ||||
| 						Name = Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl | ||||
| 					}; | ||||
| 		foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) { | ||||
| 			var channelIdSnowflake = new Snowflake(grouping.Key); | ||||
| 			var channelData = new Dictionary<Snowflake, ViewerJson.JsonMessage>(); | ||||
|  | ||||
| 					if (attachment is { Width: not null, Height: not null }) { | ||||
| 						a.Width = attachment.Width; | ||||
| 						a.Height = attachment.Height; | ||||
| 					} | ||||
| 			foreach (var message in grouping) { | ||||
| 				var messageIdSnowflake = new Snowflake(message.Id); | ||||
| 				 | ||||
| 				channelData[messageIdSnowflake] = new ViewerJson.JsonMessage { | ||||
| 					U = userIndices[message.Sender], | ||||
| 					T = message.Timestamp, | ||||
| 					M = string.IsNullOrEmpty(message.Text) ? null : message.Text, | ||||
| 					Te = message.EditTimestamp, | ||||
| 					R = message.RepliedToId?.ToString(), | ||||
| 					 | ||||
| 					A = message.Attachments.IsEmpty ? null : message.Attachments.Select(static attachment => { | ||||
| 						var a = new ViewerJson.JsonMessageAttachment { | ||||
| 							Url = attachment.DownloadUrl, | ||||
| 							Name = Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl | ||||
| 						}; | ||||
|  | ||||
| 					return a; | ||||
| 				}).ToArray(), | ||||
| 						if (attachment is { Width: not null, Height: not null }) { | ||||
| 							a.Width = attachment.Width; | ||||
| 							a.Height = attachment.Height; | ||||
| 						} | ||||
|  | ||||
| 				E = message.Embeds.IsEmpty ? null : message.Embeds.Select(static embed => embed.Json).ToArray(), | ||||
| 						return a; | ||||
| 					}).ToArray(), | ||||
| 					 | ||||
| 					E = message.Embeds.IsEmpty ? null : message.Embeds.Select(static embed => embed.Json).ToArray(), | ||||
| 					 | ||||
| 					Re = message.Reactions.IsEmpty ? null : message.Reactions.Select(static reaction => new ViewerJson.JsonMessageReaction { | ||||
| 						Id = reaction.EmojiId?.ToString(), | ||||
| 						N = reaction.EmojiName, | ||||
| 						A = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated), | ||||
| 						C = reaction.Count | ||||
| 					}).ToArray() | ||||
| 				}; | ||||
| 			} | ||||
|  | ||||
| 				Re = message.Reactions.IsEmpty ? null : message.Reactions.Select(static reaction => new ViewerJson.JsonMessageReaction { | ||||
| 					Id = reaction.EmojiId?.ToString(), | ||||
| 					N = reaction.EmojiName, | ||||
| 					A = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated), | ||||
| 					C = reaction.Count | ||||
| 				}).ToArray() | ||||
| 			}; | ||||
| 			data[channelIdSnowflake] = channelData; | ||||
| 		} | ||||
|  | ||||
| 		return data; | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,11 +0,0 @@ | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace DHT.Server.Database.Export; | ||||
|  | ||||
| [JsonSourceGenerationOptions( | ||||
| 	Converters = [typeof(SnowflakeJsonSerializer)], | ||||
| 	PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, | ||||
| 	GenerationMode = JsonSourceGenerationMode.Default | ||||
| )] | ||||
| [JsonSerializable(typeof(ViewerJson.JsonMessage))] | ||||
| sealed partial class ViewerJsonMessageContext : JsonSerializerContext; | ||||
| @@ -7,7 +7,6 @@ namespace DHT.Server.Database; | ||||
| public interface IDatabaseFile : IAsyncDisposable { | ||||
| 	string Path { get; } | ||||
|  | ||||
| 	ISettingsRepository Settings { get; } | ||||
| 	IUserRepository Users { get; } | ||||
| 	IServerRepository Servers { get; } | ||||
| 	IChannelRepository Channels { get; } | ||||
|   | ||||
| @@ -15,7 +15,7 @@ public interface IChannelRepository { | ||||
| 	 | ||||
| 	Task<long> Count(CancellationToken cancellationToken = default); | ||||
| 	 | ||||
| 	IAsyncEnumerable<Channel> Get(CancellationToken cancellationToken = default); | ||||
| 	IAsyncEnumerable<Channel> Get(); | ||||
|  | ||||
| 	internal sealed class Dummy : IChannelRepository { | ||||
| 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||
| @@ -28,7 +28,7 @@ public interface IChannelRepository { | ||||
| 			return Task.FromResult(0L); | ||||
| 		} | ||||
|  | ||||
| 		public IAsyncEnumerable<Channel> Get(CancellationToken cancellationToken) { | ||||
| 		public IAsyncEnumerable<Channel> Get() { | ||||
| 			return AsyncEnumerable.Empty<Channel>(); | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -24,7 +24,7 @@ public interface IDownloadRepository { | ||||
|  | ||||
| 	Task<bool> GetDownloadData(string normalizedUrl, Func<Stream, Task> dataProcessor); | ||||
| 	 | ||||
| 	Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken = default); | ||||
| 	Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, Task> dataProcessor); | ||||
|  | ||||
| 	IAsyncEnumerable<DownloadItem> PullPendingDownloadItems(int count, DownloadItemFilter filter, CancellationToken cancellationToken = default); | ||||
| 	 | ||||
| @@ -55,7 +55,7 @@ public interface IDownloadRepository { | ||||
| 			return Task.FromResult(false); | ||||
| 		} | ||||
|  | ||||
| 		public Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken) { | ||||
| 		public Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, Task> dataProcessor) { | ||||
| 			return Task.FromResult(false); | ||||
| 		} | ||||
|  | ||||
|   | ||||
| @@ -16,7 +16,7 @@ public interface IMessageRepository { | ||||
| 	 | ||||
| 	Task<long> Count(MessageFilter? filter = null, CancellationToken cancellationToken = default); | ||||
| 	 | ||||
| 	IAsyncEnumerable<Message> Get(MessageFilter? filter = null, CancellationToken cancellationToken = default); | ||||
| 	IAsyncEnumerable<Message> Get(MessageFilter? filter = null); | ||||
| 	 | ||||
| 	IAsyncEnumerable<ulong> GetIds(MessageFilter? filter = null); | ||||
| 	 | ||||
| @@ -33,7 +33,7 @@ public interface IMessageRepository { | ||||
| 			return Task.FromResult(0L); | ||||
| 		} | ||||
|  | ||||
| 		public IAsyncEnumerable<Message> Get(MessageFilter? filter, CancellationToken cancellationToken) { | ||||
| 		public IAsyncEnumerable<Message> Get(MessageFilter? filter) { | ||||
| 			return AsyncEnumerable.Empty<Message>(); | ||||
| 		} | ||||
|  | ||||
|   | ||||
| @@ -14,7 +14,7 @@ public interface IServerRepository { | ||||
| 	 | ||||
| 	Task<long> Count(CancellationToken cancellationToken = default); | ||||
| 	 | ||||
| 	IAsyncEnumerable<Data.Server> Get(CancellationToken cancellationToken = default); | ||||
| 	IAsyncEnumerable<Data.Server> Get(); | ||||
|  | ||||
| 	internal sealed class Dummy : IServerRepository { | ||||
| 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||
| @@ -27,7 +27,7 @@ public interface IServerRepository { | ||||
| 			return Task.FromResult(0L); | ||||
| 		} | ||||
|  | ||||
| 		public IAsyncEnumerable<Data.Server> Get(CancellationToken cancellationToken) { | ||||
| 		public IAsyncEnumerable<Data.Server> Get() { | ||||
| 			return AsyncEnumerable.Empty<Data.Server>(); | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -1,31 +0,0 @@ | ||||
| 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); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -15,7 +15,7 @@ public interface IUserRepository { | ||||
| 	 | ||||
| 	Task<long> Count(CancellationToken cancellationToken = default); | ||||
| 	 | ||||
| 	IAsyncEnumerable<User> Get(CancellationToken cancellationToken = default); | ||||
| 	IAsyncEnumerable<User> Get(); | ||||
|  | ||||
| 	internal sealed class Dummy : IUserRepository { | ||||
| 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||
| @@ -28,7 +28,7 @@ public interface IUserRepository { | ||||
| 			return Task.FromResult(0L); | ||||
| 		} | ||||
|  | ||||
| 		public IAsyncEnumerable<User> Get(CancellationToken cancellationToken) { | ||||
| 		public IAsyncEnumerable<User> Get() { | ||||
| 			return AsyncEnumerable.Empty<User>(); | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Runtime.CompilerServices; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| @@ -55,13 +54,13 @@ sealed class SqliteChannelRepository : BaseSqliteRepository, IChannelRepository | ||||
| 		return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM channels", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken); | ||||
| 	} | ||||
|  | ||||
| 	public async IAsyncEnumerable<Channel> Get([EnumeratorCancellation] CancellationToken cancellationToken) { | ||||
| 	public async IAsyncEnumerable<Channel> Get() { | ||||
| 		await using var conn = await pool.Take(); | ||||
|  | ||||
| 		await using var cmd = conn.Command("SELECT id, server, name, parent_id, position, topic, nsfw FROM channels"); | ||||
| 		await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); | ||||
| 		await using var reader = await cmd.ExecuteReaderAsync(); | ||||
|  | ||||
| 		while (await reader.ReadAsync(cancellationToken)) { | ||||
| 		while (await reader.ReadAsync()) { | ||||
| 			yield return new Channel { | ||||
| 				Id = reader.GetUint64(0), | ||||
| 				Server = reader.GetUint64(1), | ||||
|   | ||||
| @@ -20,7 +20,7 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep | ||||
|  | ||||
| 	internal sealed class NewDownloadCollector : IAsyncDisposable { | ||||
| 		private readonly SqliteDownloadRepository repository; | ||||
| 		private bool hasChanged = false; | ||||
| 		private bool hasAdded = false; | ||||
|  | ||||
| 		private readonly SqliteCommand metadataCmd; | ||||
|  | ||||
| @@ -31,16 +31,7 @@ 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 (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) | ||||
| 				  ) | ||||
| 				ON CONFLICT DO NOTHING | ||||
| 				""" | ||||
| 			); | ||||
| 			metadataCmd.Add(":normalized_url", SqliteType.Text); | ||||
| @@ -48,7 +39,6 @@ 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) { | ||||
| @@ -57,11 +47,11 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep | ||||
| 			metadataCmd.Set(":status", (int) download.Status); | ||||
| 			metadataCmd.Set(":type", download.Type); | ||||
| 			metadataCmd.Set(":size", download.Size); | ||||
| 			hasChanged |= await metadataCmd.ExecuteNonQueryAsync() > 0; | ||||
| 			hasAdded |= await metadataCmd.ExecuteNonQueryAsync() > 0; | ||||
| 		} | ||||
|  | ||||
| 		public void OnCommitted() { | ||||
| 			if (hasChanged) { | ||||
| 			if (hasAdded) { | ||||
| 				repository.UpdateTotalCount(); | ||||
| 			} | ||||
| 		} | ||||
| @@ -100,8 +90,7 @@ 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 | ||||
| 					""" | ||||
| 				); | ||||
| @@ -221,7 +210,7 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep | ||||
| 		return true; | ||||
| 	} | ||||
|  | ||||
| 	public async Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken) { | ||||
| 	public async Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, Task> dataProcessor) { | ||||
| 		await using var conn = await pool.Take(); | ||||
|  | ||||
| 		await using var cmd = conn.Command( | ||||
| @@ -239,8 +228,8 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep | ||||
| 		string? type; | ||||
| 		long rowid; | ||||
| 		 | ||||
| 		await using (var reader = await cmd.ExecuteReaderAsync(cancellationToken)) { | ||||
| 			if (!await reader.ReadAsync(cancellationToken)) { | ||||
| 		await using (var reader = await cmd.ExecuteReaderAsync()) { | ||||
| 			if (!await reader.ReadAsync()) { | ||||
| 				return false; | ||||
| 			} | ||||
|  | ||||
| @@ -250,7 +239,7 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep | ||||
| 		} | ||||
| 		 | ||||
| 		await using (var blob = new SqliteBlob(conn.InnerConnection, "download_blobs", "blob", rowid, readOnly: true)) { | ||||
| 			await dataProcessor(new Data.Download(normalizedUrl, downloadUrl, DownloadStatus.Success, type, (ulong) blob.Length), blob, cancellationToken); | ||||
| 			await dataProcessor(new Data.Download(normalizedUrl, downloadUrl, DownloadStatus.Success, type, (ulong) blob.Length), blob); | ||||
| 		} | ||||
|  | ||||
| 		return true; | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Runtime.CompilerServices; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| @@ -14,8 +13,16 @@ using Microsoft.Data.Sqlite; | ||||
|  | ||||
| namespace DHT.Server.Database.Sqlite.Repositories; | ||||
|  | ||||
| sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRepository downloads) : BaseSqliteRepository(Log), IMessageRepository { | ||||
| sealed class SqliteMessageRepository : BaseSqliteRepository, 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) { | ||||
| @@ -42,7 +49,25 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe | ||||
| 				("timestamp", SqliteType.Integer) | ||||
| 			]); | ||||
|  | ||||
| 			await using var attachmentCmd = conn.Upsert("attachments", [ | ||||
| 			await using var deleteEditTimestampCmd = DeleteByMessageId(conn, "edit_timestamps"); | ||||
| 			await using var deleteRepliedToCmd = DeleteByMessageId(conn, "replied_to"); | ||||
|  | ||||
| 			await using var deleteAttachmentsCmd = DeleteByMessageId(conn, "attachments"); | ||||
| 			await using var deleteEmbedsCmd = DeleteByMessageId(conn, "embeds"); | ||||
| 			await using var deleteReactionsCmd = DeleteByMessageId(conn, "reactions"); | ||||
|  | ||||
| 			await using var editTimestampCmd = conn.Insert("edit_timestamps", [ | ||||
| 				("message_id", SqliteType.Integer), | ||||
| 				("edit_timestamp", SqliteType.Integer) | ||||
| 			]); | ||||
|  | ||||
| 			await using var repliedToCmd = conn.Insert("replied_to", [ | ||||
| 				("message_id", SqliteType.Integer), | ||||
| 				("replied_to_id", SqliteType.Integer) | ||||
| 			]); | ||||
|  | ||||
| 			await using var attachmentCmd = conn.Insert("attachments", [ | ||||
| 				("message_id", SqliteType.Integer), | ||||
| 				("attachment_id", SqliteType.Integer), | ||||
| 				("name", SqliteType.Text), | ||||
| 				("type", SqliteType.Text), | ||||
| @@ -53,41 +78,19 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe | ||||
| 				("height", SqliteType.Integer) | ||||
| 			]); | ||||
|  | ||||
| 			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", [ | ||||
| 			await using var embedCmd = conn.Insert("embeds", [ | ||||
| 				("message_id", SqliteType.Integer), | ||||
| 				("json", SqliteType.Text) | ||||
| 			]); | ||||
|  | ||||
| 			await using var messageReactionCmd = conn.Insert("message_reactions", [ | ||||
| 			await using var reactionCmd = conn.Insert("reactions", [ | ||||
| 				("message_id", SqliteType.Integer), | ||||
| 				("emoji_id", SqliteType.Integer), | ||||
| 				("emoji_name", SqliteType.Text), | ||||
| 				("emoji_flags", SqliteType.Integer), | ||||
| 				("count", SqliteType.Integer) | ||||
| 			]); | ||||
|  | ||||
| 			 | ||||
| 			await using var downloadCollector = new SqliteDownloadRepository.NewDownloadCollector(downloads, conn); | ||||
|  | ||||
| 			foreach (var message in messages) { | ||||
| @@ -100,30 +103,29 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe | ||||
| 				messageCmd.Set(":timestamp", message.Timestamp); | ||||
| 				await messageCmd.ExecuteNonQueryAsync(); | ||||
|  | ||||
| 				await ExecuteDeleteByMessageId(deleteMessageEditTimestampCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteMessageRepliedToCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteEditTimestampCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteRepliedToCmd, messageId); | ||||
|  | ||||
| 				await ExecuteDeleteByMessageId(deleteMessageAttachmentsCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteMessageEmbedsCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteMessageReactionsCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteAttachmentsCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteEmbedsCmd, messageId); | ||||
| 				await ExecuteDeleteByMessageId(deleteReactionsCmd, messageId); | ||||
|  | ||||
| 				if (message.EditTimestamp is {} timestamp) { | ||||
| 					messageEditTimestampCmd.Set(":message_id", messageId); | ||||
| 					messageEditTimestampCmd.Set(":edit_timestamp", timestamp); | ||||
| 					await messageEditTimestampCmd.ExecuteNonQueryAsync(); | ||||
| 					editTimestampCmd.Set(":message_id", messageId); | ||||
| 					editTimestampCmd.Set(":edit_timestamp", timestamp); | ||||
| 					await editTimestampCmd.ExecuteNonQueryAsync(); | ||||
| 				} | ||||
|  | ||||
| 				if (message.RepliedToId is {} repliedToId) { | ||||
| 					messageRepliedToCmd.Set(":message_id", messageId); | ||||
| 					messageRepliedToCmd.Set(":replied_to_id", repliedToId); | ||||
| 					await messageRepliedToCmd.ExecuteNonQueryAsync(); | ||||
| 					repliedToCmd.Set(":message_id", messageId); | ||||
| 					repliedToCmd.Set(":replied_to_id", repliedToId); | ||||
| 					await repliedToCmd.ExecuteNonQueryAsync(); | ||||
| 				} | ||||
|  | ||||
| 				if (!message.Attachments.IsEmpty) { | ||||
| 					foreach (var attachment in message.Attachments) { | ||||
| 						object attachmentId = attachment.Id; | ||||
|  | ||||
| 						attachmentCmd.Set(":attachment_id", attachmentId); | ||||
| 						attachmentCmd.Set(":message_id", messageId); | ||||
| 						attachmentCmd.Set(":attachment_id", attachment.Id); | ||||
| 						attachmentCmd.Set(":name", attachment.Name); | ||||
| 						attachmentCmd.Set(":type", attachment.Type); | ||||
| 						attachmentCmd.Set(":normalized_url", attachment.NormalizedUrl); | ||||
| @@ -132,20 +134,16 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe | ||||
| 						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) { | ||||
| 						messageEmbedCmd.Set(":message_id", messageId); | ||||
| 						messageEmbedCmd.Set(":json", embed.Json); | ||||
| 						await messageEmbedCmd.ExecuteNonQueryAsync(); | ||||
| 						embedCmd.Set(":message_id", messageId); | ||||
| 						embedCmd.Set(":json", embed.Json); | ||||
| 						await embedCmd.ExecuteNonQueryAsync(); | ||||
|  | ||||
| 						if (DownloadLinkExtractor.TryFromEmbedJson(embed.Json) is {} download) { | ||||
| 							await downloadCollector.Add(download); | ||||
| @@ -155,12 +153,12 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe | ||||
|  | ||||
| 				if (!message.Reactions.IsEmpty) { | ||||
| 					foreach (var reaction in message.Reactions) { | ||||
| 						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(); | ||||
| 						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(); | ||||
|  | ||||
| 						if (reaction.EmojiId is {} emojiId) { | ||||
| 							await downloadCollector.Add(DownloadLinkExtractor.FromEmoji(emojiId, reaction.EmojiFlags)); | ||||
| @@ -179,7 +177,7 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe | ||||
| 	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); | ||||
| @@ -215,15 +213,14 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async IAsyncEnumerable<Message> Get(MessageFilter? filter, [EnumeratorCancellation] CancellationToken cancellationToken) { | ||||
| 	public async IAsyncEnumerable<Message> Get(MessageFilter? filter) { | ||||
| 		await using var conn = await pool.Take(); | ||||
|  | ||||
| 		const string AttachmentSql = | ||||
| 			""" | ||||
| 			SELECT attachment_id, name, type, normalized_url, download_url, size, width, height | ||||
| 			FROM attachments | ||||
| 			JOIN message_attachments USING (attachment_id) | ||||
| 			WHERE message_attachments.message_id = :message_id | ||||
| 			WHERE message_id = :message_id | ||||
| 			"""; | ||||
|  | ||||
| 		await using var attachmentCmd = new MessageToManyCommand<Attachment>(conn, AttachmentSql, static reader => new Attachment { | ||||
| @@ -240,7 +237,7 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe | ||||
| 		const string EmbedSql = | ||||
| 			""" | ||||
| 			SELECT json | ||||
| 			FROM message_embeds | ||||
| 			FROM embeds | ||||
| 			WHERE message_id = :message_id | ||||
| 			"""; | ||||
|  | ||||
| @@ -251,7 +248,7 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe | ||||
| 		const string ReactionSql = | ||||
| 			""" | ||||
| 			SELECT emoji_id, emoji_name, emoji_flags, count | ||||
| 			FROM message_reactions | ||||
| 			FROM reactions | ||||
| 			WHERE message_id = :message_id | ||||
| 			"""; | ||||
|  | ||||
| @@ -264,17 +261,17 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe | ||||
|  | ||||
| 		await using var messageCmd = conn.Command( | ||||
| 			$""" | ||||
| 			 SELECT m.message_id, m.sender_id, m.channel_id, m.text, m.timestamp, met.edit_timestamp, mrt.replied_to_id | ||||
| 			 SELECT m.message_id, m.sender_id, m.channel_id, m.text, m.timestamp, et.edit_timestamp, rt.replied_to_id | ||||
| 			 FROM messages m | ||||
| 			 LEFT JOIN message_edit_timestamps met ON m.message_id = met.message_id | ||||
| 			 LEFT JOIN message_replied_to mrt ON m.message_id = mrt.message_id | ||||
| 			 LEFT JOIN edit_timestamps et ON m.message_id = et.message_id | ||||
| 			 LEFT JOIN replied_to rt ON m.message_id = rt.message_id | ||||
| 			 {filter.GenerateConditions("m").BuildWhereClause()} | ||||
| 			 """ | ||||
| 		); | ||||
|  | ||||
| 		await using var reader = await messageCmd.ExecuteReaderAsync(cancellationToken); | ||||
| 		await using var reader = await messageCmd.ExecuteReaderAsync(); | ||||
|  | ||||
| 		while (await reader.ReadAsync(cancellationToken)) { | ||||
| 		while (await reader.ReadAsync()) { | ||||
| 			ulong messageId = reader.GetUint64(0); | ||||
|  | ||||
| 			yield return new Message { | ||||
| @@ -294,7 +291,7 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe | ||||
|  | ||||
| 	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(); | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Runtime.CompilerServices; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| @@ -47,13 +46,13 @@ sealed class SqliteServerRepository : BaseSqliteRepository, IServerRepository { | ||||
| 		return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM servers", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken); | ||||
| 	} | ||||
|  | ||||
| 	public async IAsyncEnumerable<Data.Server> Get([EnumeratorCancellation] CancellationToken cancellationToken) { | ||||
| 	public async IAsyncEnumerable<Data.Server> Get() { | ||||
| 		await using var conn = await pool.Take(); | ||||
|  | ||||
| 		await using var cmd = conn.Command("SELECT id, name, type FROM servers"); | ||||
| 		await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); | ||||
| 		await using var reader = await cmd.ExecuteReaderAsync(); | ||||
|  | ||||
| 		while (await reader.ReadAsync(cancellationToken)) { | ||||
| 		while (await reader.ReadAsync()) { | ||||
| 			yield return new Data.Server { | ||||
| 				Id = reader.GetUint64(0), | ||||
| 				Name = reader.GetString(1), | ||||
|   | ||||
| @@ -1,58 +0,0 @@ | ||||
| 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; | ||||
| 	} | ||||
| } | ||||
| @@ -1,5 +1,4 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Runtime.CompilerServices; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| @@ -29,7 +28,6 @@ sealed class SqliteUserRepository : BaseSqliteRepository, IUserRepository { | ||||
| 			await using var cmd = conn.Upsert("users", [ | ||||
| 				("id", SqliteType.Integer), | ||||
| 				("name", SqliteType.Text), | ||||
| 				("display_name", SqliteType.Text), | ||||
| 				("avatar_url", SqliteType.Text), | ||||
| 				("discriminator", SqliteType.Text) | ||||
| 			]); | ||||
| @@ -39,7 +37,6 @@ sealed class SqliteUserRepository : BaseSqliteRepository, IUserRepository { | ||||
| 			foreach (var user in users) { | ||||
| 				cmd.Set(":id", user.Id); | ||||
| 				cmd.Set(":name", user.Name); | ||||
| 				cmd.Set(":display_name", user.DisplayName); | ||||
| 				cmd.Set(":avatar_url", user.AvatarUrl); | ||||
| 				cmd.Set(":discriminator", user.Discriminator); | ||||
| 				await cmd.ExecuteNonQueryAsync(); | ||||
| @@ -61,19 +58,18 @@ sealed class SqliteUserRepository : BaseSqliteRepository, IUserRepository { | ||||
| 		return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM users", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken); | ||||
| 	} | ||||
|  | ||||
| 	public async IAsyncEnumerable<User> Get([EnumeratorCancellation] CancellationToken cancellationToken) { | ||||
| 	public async IAsyncEnumerable<User> Get() { | ||||
| 		await using var conn = await pool.Take(); | ||||
|  | ||||
| 		await using var cmd = conn.Command("SELECT id, name, display_name, avatar_url, discriminator FROM users"); | ||||
| 		await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); | ||||
| 		await using var cmd = conn.Command("SELECT id, name, avatar_url, discriminator FROM users"); | ||||
| 		await using var reader = await cmd.ExecuteReaderAsync(); | ||||
|  | ||||
| 		while (await reader.ReadAsync(cancellationToken)) { | ||||
| 		while (await reader.ReadAsync()) { | ||||
| 			yield return new User { | ||||
| 				Id = reader.GetUint64(0), | ||||
| 				Name = reader.GetString(1), | ||||
| 				DisplayName = reader.IsDBNull(2) ? null : reader.GetString(2), | ||||
| 				AvatarUrl = reader.IsDBNull(3) ? null : reader.GetString(3), | ||||
| 				Discriminator = reader.IsDBNull(4) ? null : reader.GetString(4), | ||||
| 				AvatarUrl = reader.IsDBNull(2) ? null : reader.GetString(2), | ||||
| 				Discriminator = reader.IsDBNull(3) ? null : reader.GetString(3), | ||||
| 			}; | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -8,10 +8,7 @@ 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) | ||||
|   | ||||
| @@ -1,11 +0,0 @@ | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Database.Sqlite.Utils; | ||||
|  | ||||
| namespace DHT.Server.Database.Sqlite.Schema; | ||||
|  | ||||
| sealed class SqliteSchemaUpgradeTo8 : ISchemaUpgrade { | ||||
| 	async Task ISchemaUpgrade.Run(ISqliteConnection conn, ISchemaUpgradeCallbacks.IProgressReporter reporter) { | ||||
| 		await reporter.MainWork("Applying schema changes...", 0, 1); | ||||
| 		await conn.ExecuteAsync("ALTER TABLE users ADD display_name TEXT"); | ||||
| 	} | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| 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 a JOIN messages m USING (message_id)"); | ||||
| 		 | ||||
| 		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,7 +39,6 @@ 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; | ||||
| @@ -48,7 +47,6 @@ 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; | ||||
| @@ -60,7 +58,6 @@ 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 = 9; | ||||
| 	internal const int Version = 7; | ||||
|  | ||||
| 	private static readonly Log Log = Log.ForType<SqliteSchema>(); | ||||
|  | ||||
| @@ -48,7 +48,6 @@ sealed class SqliteSchema { | ||||
| 		                        CREATE TABLE users ( | ||||
| 		                        	id            INTEGER PRIMARY KEY NOT NULL, | ||||
| 		                        	name          TEXT NOT NULL, | ||||
| 		                        	display_name  TEXT, | ||||
| 		                        	avatar_url    TEXT, | ||||
| 		                        	discriminator TEXT | ||||
| 		                        ) | ||||
| @@ -86,6 +85,7 @@ 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, | ||||
| @@ -98,14 +98,14 @@ sealed class SqliteSchema { | ||||
| 		                        """); | ||||
|  | ||||
| 		await conn.ExecuteAsync(""" | ||||
| 		                        CREATE TABLE message_embeds ( | ||||
| 		                        CREATE TABLE embeds ( | ||||
| 		                        	message_id INTEGER NOT NULL, | ||||
| 		                        	json       TEXT NOT NULL | ||||
| 		                        ) | ||||
| 		                        """); | ||||
|  | ||||
| 		await conn.ExecuteAsync(""" | ||||
| 		                        CREATE TABLE message_reactions ( | ||||
| 		                        CREATE TABLE reactions ( | ||||
| 		                        	message_id  INTEGER NOT NULL, | ||||
| 		                        	emoji_id    INTEGER, | ||||
| 		                        	emoji_name  TEXT, | ||||
| @@ -117,17 +117,17 @@ sealed class SqliteSchema { | ||||
| 		await CreateMessageEditTimestampTable(conn); | ||||
| 		await CreateMessageRepliedToTable(conn); | ||||
| 		await CreateDownloadTables(conn); | ||||
| 		await CreateMessageAttachmentsTable(conn); | ||||
|  | ||||
| 		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("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("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")"); | ||||
| 	} | ||||
|  | ||||
| 	internal static async Task CreateMessageEditTimestampTable(ISqliteConnection conn) { | ||||
| 		await conn.ExecuteAsync(""" | ||||
| 		                        CREATE TABLE message_edit_timestamps ( | ||||
| 		                        CREATE TABLE edit_timestamps ( | ||||
| 		                        	message_id     INTEGER PRIMARY KEY NOT NULL, | ||||
| 		                        	edit_timestamp INTEGER NOT NULL | ||||
| 		                        ) | ||||
| @@ -136,7 +136,7 @@ sealed class SqliteSchema { | ||||
|  | ||||
| 	internal static async Task CreateMessageRepliedToTable(ISqliteConnection conn) { | ||||
| 		await conn.ExecuteAsync(""" | ||||
| 		                        CREATE TABLE message_replied_to ( | ||||
| 		                        CREATE TABLE replied_to ( | ||||
| 		                        	message_id    INTEGER PRIMARY KEY NOT NULL, | ||||
| 		                        	replied_to_id INTEGER NOT NULL | ||||
| 		                        ) | ||||
| @@ -162,18 +162,6 @@ 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> { | ||||
| @@ -183,15 +171,13 @@ sealed class SqliteSchema { | ||||
| 			{ 4, new SqliteSchemaUpgradeTo5() }, | ||||
| 			{ 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); | ||||
| 			} | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Net.Http; | ||||
| using System.Reactive.Subjects; | ||||
| @@ -71,15 +70,10 @@ sealed class DownloaderTask : IAsyncDisposable { | ||||
| 		var client = new HttpClient(new SocketsHttpHandler { | ||||
| 			ConnectTimeout = TimeSpan.FromSeconds(30) | ||||
| 		}); | ||||
|  | ||||
| 		 | ||||
| 		client.Timeout = Timeout.InfiniteTimeSpan; | ||||
| 		client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent); | ||||
|  | ||||
| 		string tempFileName = Path.GetTempFileName(); | ||||
| 		log.Debug("Using temporary file: " + tempFileName); | ||||
|  | ||||
| 		await using var tempFileStream = new FileStream(tempFileName, FileMode.Create, FileAccess.ReadWrite, FileShare.Read, 4096, FileOptions.DeleteOnClose); | ||||
|  | ||||
| 		while (!cancellationToken.IsCancellationRequested) { | ||||
| 			var item = await downloadQueue.Reader.ReadAsync(cancellationToken); | ||||
| 			log.Debug("Downloading " + item.DownloadUrl + "..."); | ||||
| @@ -87,7 +81,15 @@ sealed class DownloaderTask : IAsyncDisposable { | ||||
| 			try { | ||||
| 				var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, item.DownloadUrl), HttpCompletionOption.ResponseHeadersRead, cancellationToken); | ||||
| 				response.EnsureSuccessStatusCode(); | ||||
| 				await HandleResponse(response, tempFileStream, item); | ||||
|  | ||||
| 				if (response.Content.Headers.ContentLength is {} contentLength) { | ||||
| 					await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); | ||||
| 					await db.Downloads.AddDownload(item.ToSuccess(contentLength), stream); | ||||
| 				} | ||||
| 				else { | ||||
| 					await db.Downloads.AddDownload(item.ToFailure(), stream: null); | ||||
| 					log.Error("Download response has no content length: " + item.DownloadUrl); | ||||
| 				} | ||||
| 			} catch (OperationCanceledException e) when (e.CancellationToken == cancellationToken) { | ||||
| 				// Ignore. | ||||
| 			} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) { | ||||
| @@ -109,27 +111,6 @@ sealed class DownloaderTask : IAsyncDisposable { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private async Task HandleResponse(HttpResponseMessage response, FileStream tempFileStream, DownloadItem item) { | ||||
| 		if (response.Content.Headers.ContentLength is not {} contentLength) { | ||||
| 			throw new InvalidOperationException("Download response has no content length: " + item.DownloadUrl); | ||||
| 		} | ||||
|  | ||||
| 		try { | ||||
| 			if (tempFileStream.Length != 0) { | ||||
| 				throw new InvalidOperationException("Temporary file is not empty: " + tempFileStream.Name); | ||||
| 			} | ||||
|  | ||||
| 			await using (var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken)) { | ||||
| 				await responseStream.CopyToAsync(tempFileStream, cancellationToken); | ||||
| 			} | ||||
|  | ||||
| 			tempFileStream.Seek(0, SeekOrigin.Begin); | ||||
| 			await db.Downloads.AddDownload(item.ToSuccess(contentLength), tempFileStream); | ||||
| 		} finally { | ||||
| 			tempFileStream.SetLength(0); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| 		try { | ||||
| 			await cancellationTokenSource.CancelAsync(); | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| using System; | ||||
| using System.Net; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Utils.Http; | ||||
| @@ -20,9 +19,7 @@ abstract class BaseEndpoint(IDatabaseFile db) { | ||||
|  | ||||
| 		try { | ||||
| 			response.StatusCode = (int) HttpStatusCode.OK; | ||||
| 			await Respond(ctx.Request, response, ctx.RequestAborted); | ||||
| 		} catch (OperationCanceledException) { | ||||
| 			throw; | ||||
| 			await Respond(ctx.Request, response); | ||||
| 		} catch (HttpException e) { | ||||
| 			Log.Error(e); | ||||
| 			response.StatusCode = (int) e.StatusCode; | ||||
| @@ -38,7 +35,7 @@ abstract class BaseEndpoint(IDatabaseFile db) { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	protected abstract Task Respond(HttpRequest request, HttpResponse response, CancellationToken cancellationToken); | ||||
| 	protected abstract Task Respond(HttpRequest request, HttpResponse response); | ||||
|  | ||||
| 	protected static async Task<JsonElement> ReadJson(HttpRequest request) { | ||||
| 		try { | ||||
| @@ -47,13 +44,4 @@ abstract class BaseEndpoint(IDatabaseFile db) { | ||||
| 			throw new HttpException(HttpStatusCode.UnsupportedMediaType, "This endpoint only accepts JSON."); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	protected static Guid GetSessionId(HttpRequest request) { | ||||
| 		if (request.Query.TryGetValue("session", out var sessionIdValue) && sessionIdValue.Count == 1 && Guid.TryParse(sessionIdValue[0], out Guid sessionId)) { | ||||
| 			return sessionId; | ||||
| 		} | ||||
| 		else { | ||||
| 			throw new HttpException(HttpStatusCode.BadRequest, "Invalid session ID."); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,4 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Net; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Server.Download; | ||||
| @@ -11,16 +8,12 @@ using Microsoft.AspNetCore.Http; | ||||
| namespace DHT.Server.Endpoints; | ||||
|  | ||||
| sealed class GetDownloadedFileEndpoint(IDatabaseFile db) : BaseEndpoint(db) { | ||||
| 	protected override async Task Respond(HttpRequest request, HttpResponse response, CancellationToken cancellationToken) { | ||||
| 	protected override async Task Respond(HttpRequest request, HttpResponse response) { | ||||
| 		string url = WebUtility.UrlDecode((string) request.RouteValues["url"]!); | ||||
| 		string normalizedUrl = DiscordCdn.NormalizeUrl(url); | ||||
| 		 | ||||
| 		if (!await Db.Downloads.GetSuccessfulDownloadWithData(normalizedUrl, WriteDataTo(response), cancellationToken)) { | ||||
| 		if (!await Db.Downloads.GetSuccessfulDownloadWithData(normalizedUrl, (download, stream) => response.WriteStreamAsync(download.Type, download.Size, stream))) { | ||||
| 			response.Redirect(url, permanent: false); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private static Func<Data.Download, Stream, CancellationToken, Task> WriteDataTo(HttpResponse response) { | ||||
| 		return (download, stream, cancellationToken) => response.WriteStreamAsync(download.Type, download.Size, stream, cancellationToken); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| using System.Net.Mime; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using System.Web; | ||||
| using DHT.Server.Database; | ||||
| @@ -11,16 +10,16 @@ using Microsoft.AspNetCore.Http; | ||||
| namespace DHT.Server.Endpoints; | ||||
|  | ||||
| sealed class GetTrackingScriptEndpoint(IDatabaseFile db, ServerParameters parameters, ResourceLoader resources) : BaseEndpoint(db) { | ||||
| 	protected override async Task Respond(HttpRequest request, HttpResponse response, CancellationToken cancellationToken) { | ||||
| 	protected override async Task Respond(HttpRequest request, HttpResponse response) { | ||||
| 		string bootstrap = await resources.ReadTextAsync("Tracker/bootstrap.js"); | ||||
| 		string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + parameters.Port + ";") | ||||
| 		                         .Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(parameters.Token)) | ||||
| 		                         .Replace("/*[IMPORTS]*/", await resources.ReadJoinedAsync("Tracker/scripts/", '\n', [ "/webpack.js" ])) | ||||
| 		                         .Replace("/*[IMPORTS]*/", await resources.ReadJoinedAsync("Tracker/scripts/", '\n')) | ||||
| 		                         .Replace("/*[CSS-CONTROLLER]*/", await resources.ReadTextAsync("Tracker/styles/controller.css")) | ||||
| 		                         .Replace("/*[CSS-SETTINGS]*/", await resources.ReadTextAsync("Tracker/styles/settings.css")) | ||||
| 		                         .Replace("/*[DEBUGGER]*/", request.Query.ContainsKey("debug") ? "debugger;" : ""); | ||||
| 		 | ||||
| 		response.Headers.Append("X-DHT", "1"); | ||||
| 		await response.WriteTextAsync(MediaTypeNames.Text.JavaScript, script, cancellationToken); | ||||
| 		await response.WriteTextAsync(MediaTypeNames.Text.JavaScript, script); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,18 +0,0 @@ | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Server.Database.Export; | ||||
| using DHT.Server.Service.Viewer; | ||||
| using Microsoft.AspNetCore.Http; | ||||
|  | ||||
| namespace DHT.Server.Endpoints;  | ||||
|  | ||||
| sealed class GetViewerMessagesEndpoint(IDatabaseFile db, ViewerSessions viewerSessions) : BaseEndpoint(db) { | ||||
| 	protected override Task Respond(HttpRequest request, HttpResponse response, CancellationToken cancellationToken) { | ||||
| 		var sessionId = GetSessionId(request); | ||||
| 		var session = viewerSessions.Get(sessionId); | ||||
| 		 | ||||
| 		response.ContentType = "application/x-ndjson"; | ||||
| 		return ViewerJsonExport.GetMessages(response.Body, Db, session.MessageFilter, cancellationToken); | ||||
| 	} | ||||
| } | ||||
| @@ -1,19 +0,0 @@ | ||||
| using System.Net.Mime; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Server.Database.Export; | ||||
| using DHT.Server.Service.Viewer; | ||||
| using Microsoft.AspNetCore.Http; | ||||
|  | ||||
| namespace DHT.Server.Endpoints;  | ||||
|  | ||||
| sealed class GetViewerMetadataEndpoint(IDatabaseFile db, ViewerSessions viewerSessions) : BaseEndpoint(db) { | ||||
| 	protected override Task Respond(HttpRequest request, HttpResponse response, CancellationToken cancellationToken) { | ||||
| 		var sessionId = GetSessionId(request); | ||||
| 		var session = viewerSessions.Get(sessionId); | ||||
| 		 | ||||
| 		response.ContentType = MediaTypeNames.Application.Json; | ||||
| 		return ViewerJsonExport.GetMetadata(response.Body, Db, session.MessageFilter, cancellationToken); | ||||
| 	} | ||||
| } | ||||
| @@ -1,6 +1,5 @@ | ||||
| using System.Net; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| using DHT.Server.Database; | ||||
| @@ -10,7 +9,7 @@ using Microsoft.AspNetCore.Http; | ||||
| namespace DHT.Server.Endpoints; | ||||
|  | ||||
| sealed class TrackChannelEndpoint(IDatabaseFile db) : BaseEndpoint(db) { | ||||
| 	protected override async Task Respond(HttpRequest request, HttpResponse response, CancellationToken cancellationToken) { | ||||
| 	protected override async Task Respond(HttpRequest request, HttpResponse response) { | ||||
| 		var root = await ReadJson(request); | ||||
| 		var server = ReadServer(root.RequireObject("server"), "server"); | ||||
| 		var channel = ReadChannel(root.RequireObject("channel"), "channel", server.Id); | ||||
|   | ||||
| @@ -4,7 +4,6 @@ using System.Diagnostics.CodeAnalysis; | ||||
| using System.Linq; | ||||
| using System.Net; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| using DHT.Server.Data.Filters; | ||||
| @@ -20,7 +19,7 @@ sealed class TrackMessagesEndpoint(IDatabaseFile db) : BaseEndpoint(db) { | ||||
| 	private const string HasNewMessages = "1"; | ||||
| 	private const string NoNewMessages = "0"; | ||||
|  | ||||
| 	protected override async Task Respond(HttpRequest request, HttpResponse response, CancellationToken cancellationToken) { | ||||
| 	protected override async Task Respond(HttpRequest request, HttpResponse response) { | ||||
| 		var root = await ReadJson(request); | ||||
|  | ||||
| 		if (root.ValueKind != JsonValueKind.Array) { | ||||
| @@ -38,11 +37,11 @@ sealed class TrackMessagesEndpoint(IDatabaseFile db) : BaseEndpoint(db) { | ||||
| 		} | ||||
|  | ||||
| 		var addedMessageFilter = new MessageFilter { MessageIds = addedMessageIds }; | ||||
| 		bool anyNewMessages = await Db.Messages.Count(addedMessageFilter, CancellationToken.None) < addedMessageIds.Count; | ||||
| 		bool anyNewMessages = await Db.Messages.Count(addedMessageFilter) < addedMessageIds.Count; | ||||
|  | ||||
| 		await Db.Messages.Add(messages); | ||||
|  | ||||
| 		await response.WriteTextAsync(anyNewMessages ? HasNewMessages : NoNewMessages, cancellationToken); | ||||
| 		await response.WriteTextAsync(anyNewMessages ? HasNewMessages : NoNewMessages); | ||||
| 	} | ||||
|  | ||||
| 	private static Message ReadMessage(JsonElement json, string path) => new () { | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| using System.Net; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| using DHT.Server.Database; | ||||
| @@ -10,7 +9,7 @@ using Microsoft.AspNetCore.Http; | ||||
| namespace DHT.Server.Endpoints; | ||||
|  | ||||
| sealed class TrackUsersEndpoint(IDatabaseFile db) : BaseEndpoint(db) { | ||||
| 	protected override async Task Respond(HttpRequest request, HttpResponse response, CancellationToken cancellationToken) { | ||||
| 	protected override async Task Respond(HttpRequest request, HttpResponse response) { | ||||
| 		var root = await ReadJson(request); | ||||
|  | ||||
| 		if (root.ValueKind != JsonValueKind.Array) { | ||||
| @@ -30,7 +29,6 @@ sealed class TrackUsersEndpoint(IDatabaseFile db) : BaseEndpoint(db) { | ||||
| 	private static User ReadUser(JsonElement json, string path) => new () { | ||||
| 		Id = json.RequireSnowflake("id", path), | ||||
| 		Name = json.RequireString("name", path), | ||||
| 		DisplayName = json.HasKey("displayName") ? json.RequireString("displayName", path) : null, | ||||
| 		AvatarUrl = json.HasKey("avatar") ? json.RequireString("avatar", path) : null, | ||||
| 		Discriminator = json.HasKey("discriminator") ? json.RequireString("discriminator", path) : null | ||||
| 	}; | ||||
|   | ||||
| @@ -16,13 +16,13 @@ sealed class ViewerEndpoint(IDatabaseFile db, ResourceLoader resources) : BaseEn | ||||
| 	private readonly Dictionary<string, byte[]?> cache = new (); | ||||
| 	private readonly SemaphoreSlim cacheSemaphore = new (1); | ||||
|  | ||||
| 	protected override async Task Respond(HttpRequest request, HttpResponse response, CancellationToken cancellationToken) { | ||||
| 	protected override async Task Respond(HttpRequest request, HttpResponse response) { | ||||
| 		string path = (string?) request.RouteValues["path"] ?? "index.html"; | ||||
| 		string resourcePath = "Viewer/" + path; | ||||
| 		 | ||||
| 		byte[]? resourceBytes; | ||||
|  | ||||
| 		await cacheSemaphore.WaitAsync(cancellationToken); | ||||
| 		await cacheSemaphore.WaitAsync(); | ||||
| 		try { | ||||
| 			if (!cache.TryGetValue(resourcePath, out resourceBytes)) { | ||||
| 				cache[resourcePath] = resourceBytes = await resources.ReadBytesAsyncIfExists(resourcePath); | ||||
| @@ -36,7 +36,7 @@ sealed class ViewerEndpoint(IDatabaseFile db, ResourceLoader resources) : BaseEn | ||||
| 		} | ||||
| 		else { | ||||
| 			var contentType = ContentTypeProvider.TryGetContentType(path, out string? type) ? type : null; | ||||
| 			await response.WriteFileAsync(contentType, resourceBytes, cancellationToken); | ||||
| 			await response.WriteFileAsync(contentType, resourceBytes); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -11,7 +11,7 @@ | ||||
|   </ItemGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.7" /> | ||||
|     <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" /> | ||||
|     <PackageReference Include="System.Linq.Async" Version="6.0.1" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| using System; | ||||
| using System.Diagnostics; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Utils.Logging; | ||||
| @@ -7,34 +6,24 @@ using Microsoft.AspNetCore.Http.Extensions; | ||||
|  | ||||
| namespace DHT.Server.Service.Middlewares; | ||||
|  | ||||
| sealed class ServerLoggingMiddleware(RequestDelegate next) { | ||||
| sealed class ServerLoggingMiddleware { | ||||
| 	private static readonly Log Log = Log.ForType<ServerLoggingMiddleware>(); | ||||
| 	 | ||||
| 	private readonly RequestDelegate next; | ||||
| 	 | ||||
| 	public ServerLoggingMiddleware(RequestDelegate next) { | ||||
| 		this.next = next; | ||||
| 	} | ||||
|  | ||||
| 	public async Task InvokeAsync(HttpContext context) { | ||||
| 		var stopwatch = Stopwatch.StartNew(); | ||||
| 		try { | ||||
| 			await next(context); | ||||
| 		} catch (OperationCanceledException) { | ||||
| 			OnFinished(stopwatch, context); | ||||
| 			throw; | ||||
| 		} | ||||
|  | ||||
| 		OnFinished(stopwatch, context); | ||||
| 	} | ||||
|  | ||||
| 	private static void OnFinished(Stopwatch stopwatch, HttpContext context) { | ||||
| 		await next(context); | ||||
| 		stopwatch.Stop(); | ||||
|  | ||||
| 		 | ||||
| 		var request = context.Request; | ||||
| 		var requestLength = request.ContentLength ?? 0L; | ||||
| 		var responseStatus = context.Response.StatusCode; | ||||
| 		var elapsedMs = stopwatch.ElapsedMilliseconds; | ||||
|  | ||||
| 		if (context.RequestAborted.IsCancellationRequested) { | ||||
| 			Log.Debug("Request to " + request.GetEncodedPathAndQuery() + " (" + requestLength + " B) was cancelled after " + elapsedMs + " ms"); | ||||
| 		} | ||||
| 		else { | ||||
| 			var responseStatus = context.Response.StatusCode; | ||||
| 			Log.Debug("Request to " + request.GetEncodedPathAndQuery() + " (" + requestLength + " B) returned " + responseStatus + ", took " + elapsedMs + " ms"); | ||||
| 		} | ||||
| 		Log.Debug("Request to " + request.GetEncodedPathAndQuery() + " (" + requestLength + " B) returned " + responseStatus + ", took " + elapsedMs + " ms"); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -3,7 +3,6 @@ using System.Reflection; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Server.Service.Viewer; | ||||
| using DHT.Utils.Logging; | ||||
| using DHT.Utils.Resources; | ||||
| using Microsoft.AspNetCore.Hosting; | ||||
| @@ -28,12 +27,10 @@ public sealed class ServerManager { | ||||
| 	} | ||||
|  | ||||
| 	private readonly IDatabaseFile db; | ||||
| 	private readonly ViewerSessions viewerSessions; | ||||
| 	private readonly SemaphoreSlim semaphore = new (1, 1); | ||||
|  | ||||
| 	internal ServerManager(IDatabaseFile db, ViewerSessions viewerSessions) { | ||||
| 	internal ServerManager(IDatabaseFile db) { | ||||
| 		this.db = db; | ||||
| 		this.viewerSessions = viewerSessions; | ||||
| 	} | ||||
|  | ||||
| 	public async Task Start(ushort port, string token) { | ||||
| @@ -63,7 +60,6 @@ public sealed class ServerManager { | ||||
| 			services.AddSingleton(typeof(IDatabaseFile), db); | ||||
| 			services.AddSingleton(typeof(ServerParameters), new ServerParameters(port, token)); | ||||
| 			services.AddSingleton(typeof(ResourceLoader), new ResourceLoader(Assembly.GetExecutingAssembly())); | ||||
| 			services.AddSingleton(typeof(ViewerSessions), viewerSessions); | ||||
| 		} | ||||
|  | ||||
| 		void SetKestrelOptions(KestrelServerOptions options) { | ||||
|   | ||||
| @@ -3,7 +3,6 @@ using System.Text.Json.Serialization; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Server.Endpoints; | ||||
| using DHT.Server.Service.Middlewares; | ||||
| using DHT.Server.Service.Viewer; | ||||
| using DHT.Utils.Resources; | ||||
| using Microsoft.AspNetCore.Builder; | ||||
| using Microsoft.AspNetCore.Http.Json; | ||||
| @@ -35,7 +34,7 @@ sealed class Startup { | ||||
| 	} | ||||
|  | ||||
| 	[SuppressMessage("ReSharper", "UnusedMember.Global")] | ||||
| 	public void Configure(IApplicationBuilder app, IHostApplicationLifetime lifetime, IDatabaseFile db, ServerParameters parameters, ResourceLoader resources, ViewerSessions viewerSessions) { | ||||
| 	public void Configure(IApplicationBuilder app, IHostApplicationLifetime lifetime, IDatabaseFile db, ServerParameters parameters, ResourceLoader resources) { | ||||
| 		app.UseMiddleware<ServerLoggingMiddleware>(); | ||||
| 		app.UseCors(); | ||||
| 		 | ||||
| @@ -51,8 +50,7 @@ sealed class Startup { | ||||
| 		app.UseRouting(); | ||||
| 		app.UseEndpoints(endpoints => { | ||||
| 			endpoints.MapGet("/get-tracking-script", new GetTrackingScriptEndpoint(db, parameters, resources).Handle); | ||||
| 			endpoints.MapGet("/get-viewer-metadata", new GetViewerMetadataEndpoint(db, viewerSessions).Handle); | ||||
| 			endpoints.MapGet("/get-viewer-messages", new GetViewerMessagesEndpoint(db, viewerSessions).Handle); | ||||
| 			endpoints.MapGet("/get-viewer-data", new GetViewerDataEndpoint(db).Handle); | ||||
| 			endpoints.MapGet("/get-downloaded-file/{url}", new GetDownloadedFileEndpoint(db).Handle); | ||||
| 			endpoints.MapPost("/track-channel", new TrackChannelEndpoint(db).Handle); | ||||
| 			endpoints.MapPost("/track-users", new TrackUsersEndpoint(db).Handle); | ||||
|   | ||||
| @@ -1,5 +0,0 @@ | ||||
| using DHT.Server.Data.Filters; | ||||
|  | ||||
| namespace DHT.Server.Service.Viewer;  | ||||
|  | ||||
| public readonly record struct ViewerSession(MessageFilter? MessageFilter); | ||||
| @@ -1,35 +0,0 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace DHT.Server.Service.Viewer; | ||||
|  | ||||
| public sealed class ViewerSessions : IDisposable { | ||||
| 	private readonly Dictionary<Guid, ViewerSession> sessions = new (); | ||||
| 	private bool isDisposed = false; | ||||
|  | ||||
| 	public Guid Register(ViewerSession session) { | ||||
| 		Guid guid = Guid.NewGuid(); | ||||
| 		 | ||||
| 		lock (this) { | ||||
| 			ObjectDisposedException.ThrowIf(isDisposed, this); | ||||
| 			sessions[guid] = session; | ||||
| 		} | ||||
| 		 | ||||
| 		return guid; | ||||
| 	} | ||||
|  | ||||
| 	internal ViewerSession Get(Guid guid) { | ||||
| 		lock (this) { | ||||
| 			return sessions.GetValueOrDefault(guid); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public void Dispose() { | ||||
| 		lock (this) { | ||||
| 			if (!isDisposed) { | ||||
| 				isDisposed = true; | ||||
| 				sessions.Clear(); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -3,29 +3,19 @@ using System.Threading.Tasks; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Server.Download; | ||||
| using DHT.Server.Service; | ||||
| using DHT.Server.Service.Viewer; | ||||
|  | ||||
| namespace DHT.Server; | ||||
|  | ||||
| public sealed class State : IAsyncDisposable { | ||||
| public sealed class State(IDatabaseFile db, int? concurrentDownloads) : IAsyncDisposable { | ||||
| 	public static State Dummy { get; } = new (DummyDatabaseFile.Instance, null); | ||||
|  | ||||
| 	public IDatabaseFile Db { get; } | ||||
| 	public Downloader Downloader { get; } | ||||
| 	public ViewerSessions ViewerSessions { get; } | ||||
| 	public ServerManager Server { get; } | ||||
|  | ||||
| 	public State(IDatabaseFile db, int? concurrentDownloads) { | ||||
| 		Db = db; | ||||
| 		Downloader = new Downloader(db, concurrentDownloads); | ||||
| 		ViewerSessions = new ViewerSessions(); | ||||
| 		Server = new ServerManager(db, ViewerSessions); | ||||
| 	} | ||||
| 	 | ||||
| 	public IDatabaseFile Db { get; } = db; | ||||
| 	public Downloader Downloader { get; } = new (db, concurrentDownloads); | ||||
| 	public ServerManager Server { get; } = new (db); | ||||
|  | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| 		await Downloader.Stop(); | ||||
| 		await Server.Stop(); | ||||
| 		await Db.DisposeAsync(); | ||||
| 		ViewerSessions.Dispose(); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,34 +1,33 @@ | ||||
| using System.IO; | ||||
| using System.Net.Mime; | ||||
| using System.Text; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.AspNetCore.Http; | ||||
|  | ||||
| namespace DHT.Utils.Http;  | ||||
|  | ||||
| public static class HttpExtensions { | ||||
| 	public static Task WriteTextAsync(this HttpResponse response, string text, CancellationToken cancellationToken) { | ||||
| 		return WriteTextAsync(response, MediaTypeNames.Text.Plain, text, cancellationToken); | ||||
| 	public static Task WriteTextAsync(this HttpResponse response, string text) { | ||||
| 		return WriteTextAsync(response, MediaTypeNames.Text.Plain, text); | ||||
| 	} | ||||
| 	 | ||||
| 	public static async Task WriteTextAsync(this HttpResponse response, string contentType, string text, CancellationToken cancellationToken) { | ||||
| 	public static async Task WriteTextAsync(this HttpResponse response, string contentType, string text) { | ||||
| 		response.ContentType = contentType; | ||||
| 		await response.StartAsync(cancellationToken); | ||||
| 		await response.WriteAsync(text, Encoding.UTF8, cancellationToken); | ||||
| 		await response.StartAsync(); | ||||
| 		await response.WriteAsync(text, Encoding.UTF8); | ||||
| 	} | ||||
| 	 | ||||
| 	public static async Task WriteFileAsync(this HttpResponse response, string? contentType, byte[] bytes, CancellationToken cancellationToken) { | ||||
| 	public static async Task WriteFileAsync(this HttpResponse response, string? contentType, byte[] bytes) { | ||||
| 		response.ContentType = contentType ?? string.Empty; | ||||
| 		response.ContentLength = bytes.Length; | ||||
| 		await response.StartAsync(cancellationToken); | ||||
| 		await response.Body.WriteAsync(bytes, cancellationToken); | ||||
| 		await response.StartAsync(); | ||||
| 		await response.Body.WriteAsync(bytes); | ||||
| 	} | ||||
| 	 | ||||
| 	public static async Task WriteStreamAsync(this HttpResponse response, string? contentType, ulong? contentLength, Stream source, CancellationToken cancellationToken) { | ||||
| 	public static async Task WriteStreamAsync(this HttpResponse response, string? contentType, ulong? contentLength, Stream source) { | ||||
| 		response.ContentType = contentType ?? string.Empty; | ||||
| 		response.ContentLength = (long?) contentLength; | ||||
| 		await response.StartAsync(cancellationToken); | ||||
| 		await source.CopyToAsync(response.Body, cancellationToken); | ||||
| 		await response.StartAsync(); | ||||
| 		await source.CopyToAsync(response.Body); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,5 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Reflection; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
| @@ -45,26 +43,14 @@ public sealed class ResourceLoader(Assembly assembly) { | ||||
| 		return TryGetEmbeddedStream(filename) is {} stream ? await ReadBytesAsync(stream) : null; | ||||
| 	} | ||||
|  | ||||
| 	public async Task<string> ReadJoinedAsync(string path, char separator, string[] order) { | ||||
| 		List<(string, Stream)> resourceNames = []; | ||||
| 	public async Task<string> ReadJoinedAsync(string path, char separator) { | ||||
| 		StringBuilder joined = new (); | ||||
|  | ||||
| 		foreach (var embeddedName in assembly.GetManifestResourceNames()) { | ||||
| 			var embeddedNameNormalized = embeddedName.Replace('\\', '/'); | ||||
| 			if (embeddedNameNormalized.StartsWith(path)) { | ||||
| 				resourceNames.Add((embeddedNameNormalized, assembly.GetManifestResourceStream(embeddedName)!)); | ||||
| 			if (embeddedName.Replace('\\', '/').StartsWith(path)) { | ||||
| 				joined.Append(await ReadTextAsync(assembly.GetManifestResourceStream(embeddedName)!)).Append(separator); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		StringBuilder joined = new (); | ||||
| 		 | ||||
| 		int GetOrderKey(string name) { | ||||
| 			int key = Array.FindIndex(order, name.EndsWith); | ||||
| 			return key == -1 ? order.Length : key; | ||||
| 		} | ||||
| 		 | ||||
| 		foreach(var (_, stream) in resourceNames.OrderBy(item => GetOrderKey(item.Item1))) { | ||||
| 			joined.Append(await ReadTextAsync(stream)).Append(separator); | ||||
| 		} | ||||
|  | ||||
| 		return joined.ToString(0, Math.Max(0, joined.Length - 1)); | ||||
| 	} | ||||
|   | ||||
| @@ -1,61 +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() { | ||||
| 		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(); | ||||
| 	} | ||||
| } | ||||
| @@ -8,5 +8,5 @@ using DHT.Utils; | ||||
| namespace DHT.Utils; | ||||
|  | ||||
| static class Version { | ||||
| 	public const string Tag = "44.0.0.0"; | ||||
| 	public const string Tag = "41.2.0.0"; | ||||
| } | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								app/empty.dht
									
									
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/empty.dht
									
									
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							
		Reference in New Issue
	
	Block a user