mirror of
				https://github.com/chylex/Discord-History-Tracker.git
				synced 2025-10-31 02:17:15 +01:00 
			
		
		
		
	Compare commits
	
		
			8 Commits
		
	
	
		
			c3bf7d5dc3
			...
			v47.2
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 7a8819f95b | |||
| 258bae1865 | |||
| be6203ede9 | |||
| 998893b655 | |||
| 588a1ac0dc | |||
| 2d39825cf5 | |||
| a0325953fd | |||
| 5a33540015 | 
							
								
								
									
										17
									
								
								app/Desktop/Common/AvaloniaObsevableValueExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/Desktop/Common/AvaloniaObsevableValueExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Desktop.Common; | ||||
|  | ||||
| static class AvaloniaObsevableValueExtensions { | ||||
| 	public static IDisposable SubscribeLastOnUI<T>(this ObservableValue<T> observable, Action<T> action, TimeSpan delayBetweenRuns) { | ||||
| 		Task Action(T value, CancellationToken cancellationToken) { | ||||
| 			action(value); | ||||
| 			return Task.Delay(delayBetweenRuns, cancellationToken); | ||||
| 		} | ||||
| 		 | ||||
| 		return observable.SubscribeLast(Action, TaskScheduler.FromCurrentSynchronizationContext()); | ||||
| 	} | ||||
| } | ||||
| @@ -2,9 +2,7 @@ using System; | ||||
| using System.Collections.Generic; | ||||
| using System.ComponentModel; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.ReactiveUI; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Server; | ||||
| using DHT.Server.Data.Filters; | ||||
| @@ -49,7 +47,7 @@ sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable { | ||||
| 	private readonly State state; | ||||
| 	private readonly string verb; | ||||
| 	 | ||||
| 	private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask; | ||||
| 	private readonly ThrottledTask<FilterSettings> saveFilterSettingsTask; | ||||
| 	private bool isLoadingFilterSettings; | ||||
| 	 | ||||
| 	private readonly RestartableTask<long> downloadItemCountTask; | ||||
| @@ -65,10 +63,10 @@ sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable { | ||||
| 		this.state = state; | ||||
| 		this.verb = verb; | ||||
| 		 | ||||
| 		this.saveFilterSettingsTask = new DelayedThrottledTask<FilterSettings>(Log, TimeSpan.FromSeconds(5), SaveFilterSettings); | ||||
| 		this.saveFilterSettingsTask = new ThrottledTask<FilterSettings>(Log, SaveFilterSettings, TimeSpan.FromSeconds(5), TaskScheduler.Default); | ||||
| 		 | ||||
| 		this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext()); | ||||
| 		this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged); | ||||
| 		this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.SubscribeLastOnUI(OnDownloadItemCountChanged, TimeSpan.FromMilliseconds(15)); | ||||
| 		 | ||||
| 		UpdateFilterStatistics(); | ||||
| 		 | ||||
|   | ||||
| @@ -4,10 +4,8 @@ using System.Collections.Immutable; | ||||
| using System.ComponentModel; | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.Controls; | ||||
| using Avalonia.ReactiveUI; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Desktop.Dialogs.CheckBox; | ||||
| using DHT.Desktop.Dialogs.Message; | ||||
| @@ -91,9 +89,9 @@ sealed partial class MessageFilterPanelModel : IDisposable { | ||||
| 		 | ||||
| 		this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext()); | ||||
| 		 | ||||
| 		this.messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnMessageCountChanged); | ||||
| 		this.channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnChannelCountChanged); | ||||
| 		this.userCountSubscription = state.Db.Users.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnUserCountChanged); | ||||
| 		this.messageCountSubscription = state.Db.Messages.TotalCount.SubscribeLastOnUI(OnMessageCountChanged, TimeSpan.FromMilliseconds(15)); | ||||
| 		this.channelCountSubscription = state.Db.Channels.TotalCount.SubscribeLastOnUI(OnChannelCountChanged, TimeSpan.FromMilliseconds(15)); | ||||
| 		this.userCountSubscription = state.Db.Users.TotalCount.SubscribeLastOnUI(OnUserCountChanged, TimeSpan.FromMilliseconds(15)); | ||||
| 		 | ||||
| 		UpdateFilterStatistics(); | ||||
| 		UpdateChannelFilterLabel(); | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| using System; | ||||
| using System.Reactive.Linq; | ||||
| using Avalonia.ReactiveUI; | ||||
| using Avalonia.Threading; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Server; | ||||
| using DHT.Server.Service; | ||||
| using PropertyChanged.SourceGenerator; | ||||
| @@ -41,9 +40,9 @@ sealed partial class StatusBarModel : IDisposable { | ||||
| 	public StatusBarModel(State state) { | ||||
| 		this.state = state; | ||||
| 		 | ||||
| 		serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount); | ||||
| 		channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount); | ||||
| 		messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount); | ||||
| 		serverCountSubscription = state.Db.Servers.TotalCount.SubscribeLastOnUI(newServerCount => ServerCount = newServerCount, TimeSpan.FromMilliseconds(15)); | ||||
| 		channelCountSubscription = state.Db.Channels.TotalCount.SubscribeLastOnUI(newChannelCount => ChannelCount = newChannelCount, TimeSpan.FromMilliseconds(15)); | ||||
| 		messageCountSubscription = state.Db.Messages.TotalCount.SubscribeLastOnUI(newMessageCount => MessageCount = newMessageCount, TimeSpan.FromMilliseconds(15)); | ||||
| 		 | ||||
| 		state.Server.StatusChanged += OnStateServerStatusChanged; | ||||
| 		serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped; | ||||
|   | ||||
| @@ -55,11 +55,11 @@ sealed class DatabasePageModel { | ||||
| 				break; | ||||
| 			 | ||||
| 			case PlatformID.Unix: | ||||
| 				Process.Start("xdg-open", [ folder ]); | ||||
| 				Process.Start("xdg-open", [folder]); | ||||
| 				break; | ||||
| 			 | ||||
| 			case PlatformID.MacOSX: | ||||
| 				Process.Start("open", [ folder ]); | ||||
| 				Process.Start("open", [folder]); | ||||
| 				break; | ||||
| 			 | ||||
| 			default: | ||||
| @@ -80,22 +80,25 @@ sealed class DatabasePageModel { | ||||
| 		 | ||||
| 		const string Title = "Database Merge"; | ||||
| 		 | ||||
| 		ImportResult? result; | ||||
| 		var result = new TaskCompletionSource<ImportResult?>(); | ||||
| 		try { | ||||
| 			result = await ProgressDialog.Show(window, Title, async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback)); | ||||
| 			var dialog = new ProgressDialog(); | ||||
| 			dialog.DataContext = new ProgressDialogModel(Title, async callbacks => result.SetResult(await MergeWithDatabaseFromPaths(Db, paths, dialog, callbacks)), progressItems: 2); | ||||
| 			await dialog.ShowProgressDialog(window); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error("Could not merge databases.", e); | ||||
| 			await Dialog.ShowOk(window, Title, "Could not merge databases: " + e.Message); | ||||
| 			return; | ||||
| 		} | ||||
| 		 | ||||
| 		await Dialog.ShowOk(window, Title, GetImportDialogMessage(result, "database file")); | ||||
| 		await Dialog.ShowOk(window, Title, GetImportDialogMessage(result.Task.Result, "database file")); | ||||
| 	} | ||||
| 	 | ||||
| 	private static async Task<ImportResult?> MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) { | ||||
| 		var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length); | ||||
| 	private static async Task<ImportResult?> MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IReadOnlyList<IProgressCallback> callbacks) { | ||||
| 		var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, callbacks[1], paths.Length); | ||||
| 		var databaseMergeProgressCallback = new DatabaseMergeProgressCallback(callbacks[1]); | ||||
| 		 | ||||
| 		return await PerformImport(target, paths, dialog, callback, "Database Merge", async path => { | ||||
| 		return await PerformImport(target, paths, dialog, callbacks[0], "Database Merge", async path => { | ||||
| 			IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks); | ||||
| 			 | ||||
| 			if (db == null) { | ||||
| @@ -103,7 +106,7 @@ sealed class DatabasePageModel { | ||||
| 			} | ||||
| 			 | ||||
| 			try { | ||||
| 				await target.AddFrom(db); | ||||
| 				await target.Merge(db, databaseMergeProgressCallback); | ||||
| 				return true; | ||||
| 			} finally { | ||||
| 				await db.DisposeAsync(); | ||||
| @@ -111,7 +114,7 @@ sealed class DatabasePageModel { | ||||
| 		}); | ||||
| 	} | ||||
| 	 | ||||
| 	private sealed class SchemaUpgradeCallbacks(ProgressDialog dialog, int total) : ISchemaUpgradeCallbacks { | ||||
| 	private sealed class SchemaUpgradeCallbacks(ProgressDialog dialog, IProgressCallback callback, int total) : ISchemaUpgradeCallbacks { | ||||
| 		private bool? decision; | ||||
| 		 | ||||
| 		public Task<InitialDatabaseSettings?> GetInitialDatabaseSettings() { | ||||
| @@ -125,6 +128,7 @@ sealed class DatabasePageModel { | ||||
| 		} | ||||
| 		 | ||||
| 		public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) { | ||||
| 			callback.UpdateIndeterminate("Upgrading database..."); | ||||
| 			return doUpgrade(new NullReporter()); | ||||
| 		} | ||||
| 		 | ||||
| @@ -143,6 +147,20 @@ sealed class DatabasePageModel { | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private sealed class DatabaseMergeProgressCallback(IProgressCallback callback) : DatabaseMerging.IProgressCallback { | ||||
| 		public void OnImportingMetadata() { | ||||
| 			callback.UpdateIndeterminate("Importing metadata..."); | ||||
| 		} | ||||
| 		 | ||||
| 		public void OnMessagesImported(long finished, long total) { | ||||
| 			callback.Update("Importing messages...", finished, total); | ||||
| 		} | ||||
| 		 | ||||
| 		public void OnDownloadsImported(long finished, long total) { | ||||
| 			callback.Update("Importing downloaded files...", finished, total); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task ImportLegacyArchive() { | ||||
| 		string[] paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions { | ||||
| 			Title = "Open Legacy DHT Archive", | ||||
| @@ -223,7 +241,7 @@ sealed class DatabasePageModel { | ||||
| 		int finished = 0; | ||||
| 		 | ||||
| 		foreach (string path in paths) { | ||||
| 			await callback.Update(Path.GetFileName(path), finished, total); | ||||
| 			await callback.Update("File: " + Path.GetFileName(path), finished, total); | ||||
| 			++finished; | ||||
| 			 | ||||
| 			if (!File.Exists(path)) { | ||||
|   | ||||
| @@ -2,11 +2,9 @@ using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.ComponentModel; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.Controls; | ||||
| using Avalonia.Platform.Storage; | ||||
| using Avalonia.ReactiveUI; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Desktop.Dialogs.File; | ||||
| using DHT.Desktop.Dialogs.Message; | ||||
| @@ -19,6 +17,7 @@ using DHT.Server.Data.Filters; | ||||
| using DHT.Server.Data.Settings; | ||||
| using DHT.Server.Download; | ||||
| using DHT.Utils.Logging; | ||||
| using DHT.Utils.Observables; | ||||
| using DHT.Utils.Tasks; | ||||
| using PropertyChanged.SourceGenerator; | ||||
|  | ||||
| @@ -82,8 +81,8 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 			statisticsSkipped, | ||||
| 		]; | ||||
| 		 | ||||
| 		downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext()); | ||||
| 		downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged); | ||||
| 		downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TimeSpan.FromMilliseconds(100), TaskScheduler.FromCurrentSynchronizationContext()); | ||||
| 		downloadItemCountSubscription = state.Db.Downloads.TotalCount.SubscribeLastOnUI(OnDownloadCountChanged, TimeSpan.FromMilliseconds(15)); | ||||
| 		 | ||||
| 		RecomputeDownloadStatistics(); | ||||
| 	} | ||||
| @@ -145,8 +144,8 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 		 | ||||
| 		try { | ||||
| 			currentDownloadFilter = FilterModel.CreateFilter(); | ||||
| 			IObservable<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter); | ||||
| 			finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished); | ||||
| 			ObservableValue<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter); | ||||
| 			finishedItemsSubscription = finishedItems.SubscribeLastOnUI(OnItemFinished, TimeSpan.FromMilliseconds(15)); | ||||
| 		} catch (Exception) { | ||||
| 			finishedItemsSubscription?.Dispose(); | ||||
| 			finishedItemsSubscription = null; | ||||
| @@ -285,7 +284,7 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private void UpdateStatistics(DownloadStatusStatistics statusStatistics) { | ||||
| 	private Task UpdateStatistics(DownloadStatusStatistics statusStatistics) { | ||||
| 		statisticsPending.Items = statusStatistics.PendingCount; | ||||
| 		statisticsPending.Size = statusStatistics.PendingTotalSize; | ||||
| 		statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0; | ||||
| @@ -304,6 +303,8 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 		 | ||||
| 		HasSuccessfulDownloads = statusStatistics.SuccessfulCount > 0; | ||||
| 		HasFailedDownloads = statusStatistics.FailedCount > 0; | ||||
| 		 | ||||
| 		return Task.CompletedTask; | ||||
| 	} | ||||
| 	 | ||||
| 	public sealed partial class StatisticsRow(string state) { | ||||
|   | ||||
| @@ -10,7 +10,6 @@ | ||||
|      | ||||
|     <link rel="stylesheet" href="styles/main.css"> | ||||
|     <link rel="stylesheet" href="styles/menu.css"> | ||||
|     <link rel="stylesheet" href="styles/servers.css"> | ||||
|     <link rel="stylesheet" href="styles/channels.css"> | ||||
|     <link rel="stylesheet" href="styles/messages.css"> | ||||
|     <link rel="stylesheet" href="styles/modal.css"> | ||||
| @@ -76,7 +75,6 @@ | ||||
|     </div> | ||||
|      | ||||
|     <div id="app"> | ||||
|       <div id="servers"></div> | ||||
|       <div id="channels"> | ||||
|         <div class="loading"></div> | ||||
|       </div> | ||||
|   | ||||
| @@ -2,7 +2,6 @@ import discord from "./discord.mjs"; | ||||
| import gui from "./gui.mjs"; | ||||
| import state from "./state.mjs"; | ||||
| import "./polyfills.mjs"; | ||||
| import servers from "./servers.mjs"; | ||||
|  | ||||
| window.DISCORD = discord; | ||||
|  | ||||
| @@ -26,12 +25,10 @@ document.addEventListener("DOMContentLoaded", () => { | ||||
| 	 | ||||
| 	state.onUsersRefreshed(users => { | ||||
| 		gui.updateUserList(users); | ||||
| 		servers.update() | ||||
| 	}); | ||||
| 	 | ||||
| 	state.onChannelsRefreshed((channels, selected) => { | ||||
| 		gui.updateChannelList(channels, selected, state.selectChannel); | ||||
| 		servers.update() | ||||
| 	}); | ||||
| 	 | ||||
| 	state.onMessagesRefreshed(messages => { | ||||
|   | ||||
| @@ -107,23 +107,22 @@ export default (function() { | ||||
| 	const isImageUrl = function(url) { | ||||
| 		const dot = url.pathname.lastIndexOf("."); | ||||
| 		const ext = dot === -1 ? "" : url.pathname.substring(dot).toLowerCase(); | ||||
| 		return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg"; | ||||
| 		return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg" || ext === ".webp" || ext === ".avif"; | ||||
| 	}; | ||||
| 	 | ||||
| 	return { | ||||
| 		setup() { | ||||
| 			templateChannelServer = new template([ | ||||
| 				"<div class='channel ServerChannel' data-channel='{id}' server-id='{serverId}' server-name='{server.name}' server-type='{server.type}'>", | ||||
| 				"<div class='channel' data-channel='{id}'>", | ||||
| 				"<div class='info' title='{topic}'><strong class='name'>#{name}</strong>{nsfw}<span class='tag'>{msgcount}</span></div>", | ||||
| 				"<!--<span class='server'>{server.name} ({server.type})</span>-->", | ||||
| 				"<span class='server'>{server.name} ({server.type})</span>", | ||||
| 				"</div>" | ||||
| 			].join("")); | ||||
| 			 | ||||
| 			templateChannelPrivate = new template([ | ||||
| 				"<div class='channel UserChannel' data-channel='{id}' server-id='0' server-name='{server.name}' server-type='{server.type}'>", | ||||
| 				"<div class='avatar'>{icon}</div>", | ||||
| 				"<div class='channel' data-channel='{id}'>", | ||||
| 				"<div class='info'><strong class='name'>{name}</strong><span class='tag'>{msgcount}</span></div>", | ||||
| 				"<!--<span class='server'>{server.name} ({server.type})</span>-->", | ||||
| 				"<span class='server'>({server.type})</span>", | ||||
| 				"</div>" | ||||
| 			].join("")); | ||||
| 			 | ||||
| @@ -167,7 +166,7 @@ export default (function() { | ||||
| 			 | ||||
| 			// noinspection HtmlUnknownTarget | ||||
| 			templateEmbedImageWithSize = new template([ | ||||
| 				"<a href='{url}' class='embed thumbnail loading'><img src='{src}' width='{width}' alt='' onload='window.DISCORD.handleImageLoad(this)' onerror='window.DISCORD.handleImageLoadError(this)'></a><br>" | ||||
| 				"<a href='{url}' class='embed thumbnail loading'><img src='{src}' width='{width}' height='{height}' alt='' onload='window.DISCORD.handleImageLoad(this)' onerror='window.DISCORD.handleImageLoadError(this)'></a><br>" | ||||
| 			].join("")); | ||||
| 			 | ||||
| 			// noinspection HtmlUnknownTarget | ||||
|   | ||||
| @@ -1,109 +0,0 @@ | ||||
| import state from "./state.mjs"; | ||||
|  | ||||
| const servers = (() => { | ||||
|     let currentServerId = "0"; | ||||
|  | ||||
|     function getIcon(name) { | ||||
|         return name.split(" ").map(word => { | ||||
|             if (word.startsWith("[") && word.length > 1) return word[1]; | ||||
|             return word[0] || ""; | ||||
|         }).join(""); | ||||
|     } | ||||
|  | ||||
|     function update() { | ||||
|         const channels = document.querySelectorAll("#channels .channel"); | ||||
|         const serversMap = new Map(); | ||||
|  | ||||
|         // Check if there are any channels with server-id 0 (DM) | ||||
|         const hasDMChannels = Array.from(channels).some(channel => { | ||||
|             return channel.getAttribute("server-id") === "0"; | ||||
|         }); | ||||
|  | ||||
|         if (hasDMChannels) { | ||||
|             serversMap.set("0", { | ||||
|                 id: "0", | ||||
|                 name: "DM", | ||||
|                 icon: "DM", | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         channels.forEach(channel => { | ||||
|             const serverId = channel.getAttribute("server-id") || "0"; | ||||
|             const serverType = channel.getAttribute("server-type"); | ||||
|             const serverName = channel.getAttribute("server-name"); | ||||
|  | ||||
|             if (serverType === "server" && !serversMap.has(serverId)) { | ||||
|                 serversMap.set(serverId, { | ||||
|                     id: serverId, | ||||
|                     name: serverName, | ||||
|                     icon: getIcon(serverName), | ||||
|                 }); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         const serversDiv = document.getElementById("servers"); | ||||
|         serversDiv.innerHTML = ""; | ||||
|  | ||||
|         if (hasDMChannels) { | ||||
|             const dmServer = serversMap.get("0"); | ||||
|             const dmElement = document.createElement("div"); | ||||
|             dmElement.className = `Server${dmServer.id === currentServerId ? " active" : ""}`; | ||||
|             dmElement.id = "DM"; | ||||
|             dmElement.dataset.serverId = dmServer.id; | ||||
|             dmElement.innerHTML = ` | ||||
|                 <div class="icon">DM</div> | ||||
|                 <div class="name" title="Direct Messages">Direct Messages</div> | ||||
|             `; | ||||
|             dmElement.addEventListener("click", () => selectServer(dmServer.id)); | ||||
|             serversDiv.appendChild(dmElement); | ||||
|         } | ||||
|  | ||||
|         serversMap.forEach(server => { | ||||
|             if (server.id === "0") return; // Skip DM since it's already added | ||||
|  | ||||
|             const serverElement = document.createElement("div"); | ||||
|             serverElement.className = `Server${server.id === currentServerId ? " active" : ""}`; | ||||
|             serverElement.dataset.serverId = server.id; | ||||
|             serverElement.innerHTML = ` | ||||
|                 <div class="icon">${server.icon}</div> | ||||
|                 <div class="name" title="${server.name}">${server.name}</div> | ||||
|             `; | ||||
|             serverElement.addEventListener("click", () => selectServer(server.id)); | ||||
|             serversDiv.appendChild(serverElement); | ||||
|         }); | ||||
|  | ||||
|         if (!serversMap.has(currentServerId)) { | ||||
|             currentServerId = "0"; | ||||
|         } | ||||
|  | ||||
|         updateChannelVisibility(); | ||||
|     } | ||||
|  | ||||
|     function selectServer(serverId) { | ||||
|         // Remove active class from all servers | ||||
|         document.querySelectorAll("#servers .Server").forEach(server => { | ||||
|             server.classList.remove("active"); | ||||
|         }); | ||||
|  | ||||
|         // Add active class to the selected server | ||||
|         const selectedServer = document.querySelector(`#servers .Server[data-server-id="${serverId}"]`); | ||||
|         if (selectedServer) { | ||||
|             selectedServer.classList.add("active"); | ||||
|         } | ||||
|  | ||||
|         currentServerId = serverId; | ||||
|         updateChannelVisibility(); | ||||
|         state.selectChannel(null); | ||||
|     } | ||||
|  | ||||
|     function updateChannelVisibility() { | ||||
|         document.querySelectorAll("#channels .channel").forEach(channel => { | ||||
|             const channelServerId = channel.getAttribute("server-id") || "0"; | ||||
|             channel.classList.toggle("visible", channelServerId === currentServerId); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     return { update }; | ||||
| })(); | ||||
|  | ||||
| export default servers; | ||||
| @@ -2,25 +2,13 @@ import settings from "./settings.mjs"; | ||||
| import processor from "./processor.mjs"; | ||||
|  | ||||
| // noinspection FunctionWithInconsistentReturnsJS | ||||
| export default (function () { | ||||
| export default (function() { | ||||
| 	/** | ||||
| 	 * @type {{}} | ||||
| 	 * @property {{}} users | ||||
| 	 * @property {{}} servers | ||||
| 	 * @property {{}} channels | ||||
| 	 */ | ||||
|  | ||||
|  | ||||
| 	const fileUrlProcessor = function (serverToken) { | ||||
| 		if (typeof serverToken === "string") { | ||||
| 			return url => "/get-downloaded-file/" + encodeURIComponent(url) + "?token=" + encodeURIComponent(serverToken); | ||||
| 		} | ||||
| 		else { | ||||
| 			return url => url; | ||||
| 		} | ||||
| 	}(window.DHT_SERVER_TOKEN); | ||||
|  | ||||
|  | ||||
| 	let loadedFileMeta; | ||||
| 	let loadedFileData; | ||||
| 	 | ||||
| @@ -31,39 +19,19 @@ export default (function () { | ||||
| 	let currentPage; | ||||
| 	let messagesPerPage; | ||||
| 	 | ||||
| 	const getUser = function (id) { | ||||
| 	const getUser = function(id) { | ||||
| 		return loadedFileMeta.users[id] || { "name": "<unknown>" }; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getUserList = function () { | ||||
| 	const getUserList = function() { | ||||
| 		return loadedFileMeta ? loadedFileMeta.users : []; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getServer = function (id) { | ||||
| 	const getServer = function(id) { | ||||
| 		return loadedFileMeta.servers[id] || { "name": "<unknown>", "type": "unknown" }; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getUserIDByName = function (name) { | ||||
| 		for (let userId in loadedFileMeta.users) { | ||||
| 			let user = loadedFileMeta.users[userId]; | ||||
| 			if (user.name === name) { | ||||
| 				return userId; | ||||
| 			} | ||||
| 		} | ||||
| 		return 0; | ||||
| 	}; | ||||
|  | ||||
| 	const getUserByName = function (name) { | ||||
| 		for (let userId in loadedFileMeta.users) { | ||||
| 			let user = loadedFileMeta.users[userId]; | ||||
| 			if (user.name === name) { | ||||
| 				return user; | ||||
| 			} | ||||
| 		} | ||||
| 		return 0; | ||||
| 	}; | ||||
|  | ||||
| 	const generateChannelHierarchy = function () { | ||||
| 	const generateChannelHierarchy = function() { | ||||
| 		/** | ||||
| 		 * @type {Map<string, Set>} | ||||
| 		 */ | ||||
| @@ -87,7 +55,7 @@ export default (function () { | ||||
| 			return children; | ||||
| 		} | ||||
| 		 | ||||
| 		for (const [id, channel] of Object.entries(loadedFileMeta.channels)) { | ||||
| 		for (const [ id, channel ] of Object.entries(loadedFileMeta.channels)) { | ||||
| 			getChildren(channel.parent || "").add(id); | ||||
| 		} | ||||
| 		 | ||||
| @@ -120,7 +88,7 @@ export default (function () { | ||||
| 		return hierarchy; | ||||
| 	}; | ||||
| 	 | ||||
| 	const generateChannelOrder = function () { | ||||
| 	const generateChannelOrder = function() { | ||||
| 		if (!loadedFileMeta) { | ||||
| 			return {}; | ||||
| 		} | ||||
| @@ -168,7 +136,7 @@ export default (function () { | ||||
| 		return orderMap; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getChannelList = function () { | ||||
| 	const getChannelList = function() { | ||||
| 		if (!loadedFileMeta) { | ||||
| 			return []; | ||||
| 		} | ||||
| @@ -176,28 +144,23 @@ export default (function () { | ||||
| 		const channels = loadedFileMeta.channels; | ||||
| 		const channelOrder = generateChannelOrder(); | ||||
| 		 | ||||
|  | ||||
| 		return Object.keys(channels).map(key => ({ | ||||
| 			"id": key, | ||||
| 			"serverId": channels[key].server, | ||||
| 			"name": channels[key].name, | ||||
| 			"server": getServer(channels[key].server), | ||||
| 			"msgcount": getFilteredMessageKeys(key).length, | ||||
| 			"topic": channels[key].topic || "", | ||||
| 			"nsfw": channels[key].nsfw || false, | ||||
| 			"icon": getServer(channels[key].server).type === "group" | ||||
| 				? `<!--<span>${channels[key].name.split(" ").map(word => word[0]).join("")}</span>-->` //Discord default naming without a icon | ||||
| 				: `<img src='${fileUrlProcessor("https://cdn.discordapp.com/avatars/" + getUserIDByName(channels[key].name) + "/" + getUserByName(channels[key].name).avatar+".webp")}'>` | ||||
| 		})).sort((ac, bc) => { | ||||
| 			return channelOrder[ac.id] - channelOrder[bc.id]; | ||||
| 		}); | ||||
| 	}; | ||||
| 	 | ||||
| 	const getMessages = function (channel) { | ||||
| 	const getMessages = function(channel) { | ||||
| 		return loadedFileData[channel] || {}; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getMessageById = function (id) { | ||||
| 	const getMessageById = function(id) { | ||||
| 		for (const messages of Object.values(loadedFileData)) { | ||||
| 			if (id in messages) { | ||||
| 				return messages[id]; | ||||
| @@ -207,8 +170,8 @@ export default (function () { | ||||
| 		return null; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getMessageChannel = function (id) { | ||||
| 		for (const [channel, messages] of Object.entries(loadedFileData)) { | ||||
| 	const getMessageChannel = function(id) { | ||||
| 		for (const [ channel, messages ] of Object.entries(loadedFileData)) { | ||||
| 			if (id in messages) { | ||||
| 				return channel; | ||||
| 			} | ||||
| @@ -217,7 +180,7 @@ export default (function () { | ||||
| 		return null; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getMessageList = function () { | ||||
| 	const getMessageList = function() { | ||||
| 		if (!loadedMessages) { | ||||
| 			return []; | ||||
| 		} | ||||
| @@ -289,19 +252,19 @@ export default (function () { | ||||
| 	let eventOnChannelsRefreshed; | ||||
| 	let eventOnMessagesRefreshed; | ||||
| 	 | ||||
| 	const triggerUsersRefreshed = function () { | ||||
| 	const triggerUsersRefreshed = function() { | ||||
| 		eventOnUsersRefreshed && eventOnUsersRefreshed(getUserList()); | ||||
| 	}; | ||||
| 	 | ||||
| 	const triggerChannelsRefreshed = function (selectedChannel) { | ||||
| 	const triggerChannelsRefreshed = function(selectedChannel) { | ||||
| 		eventOnChannelsRefreshed && eventOnChannelsRefreshed(getChannelList(), selectedChannel); | ||||
| 	}; | ||||
| 	 | ||||
| 	const triggerMessagesRefreshed = function () { | ||||
| 	const triggerMessagesRefreshed = function() { | ||||
| 		eventOnMessagesRefreshed && eventOnMessagesRefreshed(getMessageList()); | ||||
| 	}; | ||||
| 	 | ||||
| 	const getFilteredMessageKeys = function (channel) { | ||||
| 	const getFilteredMessageKeys = function(channel) { | ||||
| 		const messages = getMessages(channel); | ||||
| 		let keys = Object.keys(messages); | ||||
| 		 | ||||
|   | ||||
| @@ -8,21 +8,15 @@ export default class { | ||||
| 	}; | ||||
| 	 | ||||
| 	apply(obj, processor) { | ||||
|  | ||||
| 		//Keys to not escape | ||||
| 		const allowHTMLKeys = new Set(["icon"]); //Example with more: Set(["icon", "description", "content"]); | ||||
| 	 | ||||
| 		return this.contents.replace(TEMPLATE_REGEX, (full, match) => { | ||||
| 			const value = match.split(".").reduce((o, property) => o[property], obj); | ||||
| 			 | ||||
| 			if (processor) { | ||||
| 				const updated = processor(match, value); | ||||
| 				return typeof updated === "undefined" ? (allowHTMLKeys.has(match) ? value : dom.escapeHTML(value)) : updated; | ||||
| 				return typeof updated === "undefined" ? dom.escapeHTML(value) : updated; | ||||
| 			} | ||||
| 			 | ||||
| 	 | ||||
| 			return allowHTMLKeys.has(match) ? value : dom.escapeHTML(value); | ||||
| 			return dom.escapeHTML(value); | ||||
| 		}); | ||||
| 	} | ||||
| 	 | ||||
| } | ||||
|   | ||||
| @@ -4,9 +4,8 @@ | ||||
|   max-width: 300px; | ||||
|   overflow-y: auto; | ||||
|   color: #eee; | ||||
|   background-color: #2B2D31; | ||||
|   background-color: #1c1e22; | ||||
|   font-size: 15px; | ||||
|   padding: 2px; | ||||
| } | ||||
|  | ||||
| #channels > div.loading { | ||||
| @@ -15,93 +14,27 @@ | ||||
|   height: 150px; | ||||
| } | ||||
|  | ||||
| #channels > div.loading { | ||||
|   background-color: rgba(0, 0, 0, 0) !important; | ||||
| } | ||||
|  | ||||
|  | ||||
| #channels > div.channel.visible { | ||||
|   display: flex !important; | ||||
| } | ||||
|  | ||||
| #channels > div.channel { | ||||
|   display: none !important; | ||||
|  | ||||
|   cursor: pointer; | ||||
|   padding: 5px 8px; | ||||
|  | ||||
|   color: #eee; | ||||
|   font-size: 15px; | ||||
|  | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   flex-wrap: nowrap; | ||||
|   align-items: flex-start; | ||||
|  | ||||
|   white-space: nowrap; | ||||
|   text-overflow: ellipsis; | ||||
|   overflow: hidden; | ||||
|  | ||||
|   border-radius: 5px; | ||||
|   padding: 10px 12px; | ||||
|   border-bottom: 1px solid #333333; | ||||
| } | ||||
|  | ||||
|  | ||||
| .ServerChannel { | ||||
|   padding: 8px 8px !important; | ||||
| #channels > div.channel:hover, #channels > div.channel.active { | ||||
|   background-color: #282b30; | ||||
| } | ||||
|  | ||||
| #channels > div.channel:hover, | ||||
| #channels > div.channel.active { | ||||
|   background-color: rgba(78, 80, 88, 0.6); | ||||
| } | ||||
|  | ||||
| #channels > div.channel.active > .info > .name { | ||||
|   color: oklab(0.999994 0.0000455678 0.0000200868); | ||||
| } | ||||
|  | ||||
|  | ||||
| #channels .info { | ||||
|   display: flex; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| .ServerChannel > .info { | ||||
|   padding: 0px !important; | ||||
| } | ||||
|  | ||||
| .UserChannel > .info { | ||||
|   align-self: center; | ||||
|   padding-left: 6px !important; | ||||
|   align-items: center; | ||||
|  | ||||
|   width: 150px !important; | ||||
| } | ||||
|  | ||||
|  | ||||
| [server-type="group"] > .info { | ||||
|   padding: 6px; | ||||
|  | ||||
|   width: 172px !important; | ||||
| } | ||||
|  | ||||
| .UserChannel > .info > .name { | ||||
|   min-height: 18px; | ||||
|   height: 16px; | ||||
|   margin-bottom: 4px; | ||||
| } | ||||
|  | ||||
| #channels .name { | ||||
|   flex-grow: 1; | ||||
|   overflow-x: hidden; | ||||
|   overflow-y: clip; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   color: oklab(0.686636 -0.00407365 -0.0149199); | ||||
|   font-weight: 500; | ||||
|   font-size: 16px; | ||||
|   /* max-width: 200px; */ | ||||
| } | ||||
|  | ||||
| .ServerChannel .name { | ||||
|   height: 18px; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| #channels .tag { | ||||
| @@ -113,25 +46,3 @@ | ||||
|   padding: 2px 5px; | ||||
|   font-size: 11px; | ||||
| } | ||||
|  | ||||
|  | ||||
| #channels > div:hover, | ||||
| #channels > div.active { | ||||
|   background-color: #404249 | ||||
| } | ||||
|  | ||||
| #channels > div > .avatar { | ||||
|   padding: 0; | ||||
|   margin: 0; | ||||
| } | ||||
|  | ||||
| #channels > div > .avatar > * { | ||||
|   width: 30px; | ||||
|  | ||||
|   min-width: 30px; | ||||
|   min-height: 30px; | ||||
|   /*Make not loaded images be 30px*/ | ||||
|  | ||||
|   border-radius: 100%; | ||||
|   font-size: 1em; | ||||
| } | ||||
| @@ -17,10 +17,6 @@ body { | ||||
|   --loading-backdrop: rgba(0, 0, 0, 0); | ||||
| } | ||||
|  | ||||
| .loading:hover { | ||||
|   background-color: rgba(0, 0, 0, 0) !important; | ||||
| } | ||||
|  | ||||
| .loading::after { | ||||
|   content: ""; | ||||
|   background: var(--loading-backdrop) | ||||
|   | ||||
| @@ -4,8 +4,8 @@ | ||||
|   align-items: stretch; | ||||
|   gap: 8px; | ||||
|   padding: 8px; | ||||
|   background-color: #313338; | ||||
|   border-bottom: 2px solid #27292D; | ||||
|   background-color: #17181c; | ||||
|   border-bottom: 1px dotted #5d626b; | ||||
| } | ||||
|  | ||||
| #menu .splitter { | ||||
| @@ -23,9 +23,7 @@ | ||||
|   cursor: default; | ||||
| } | ||||
|  | ||||
| #menu button, | ||||
| #menu select, | ||||
| #menu input[type="text"] { | ||||
| #menu button, #menu select, #menu input[type="text"] { | ||||
|   height: 31px; | ||||
|   padding: 0 10px; | ||||
|   background-color: #7289da; | ||||
| @@ -66,13 +64,11 @@ | ||||
|   padding: 0 8px; | ||||
| } | ||||
|  | ||||
| #menu .nav > button, | ||||
| #menu .nav > p { | ||||
| #menu .nav > button, #menu .nav > p { | ||||
|   margin: 0 1px; | ||||
| } | ||||
|  | ||||
| #opt-filter-list > select, | ||||
| #opt-filter-list > input { | ||||
| #opt-filter-list > select, #opt-filter-list > input { | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -5,14 +5,9 @@ | ||||
| } | ||||
|  | ||||
| #messages > div { | ||||
|   padding: 0 4px 0 24px; | ||||
|  | ||||
|   margin-bottom: 17px; | ||||
| } | ||||
|  | ||||
|  | ||||
| #messages > div:hover { | ||||
|   background-color: oklab(0.0846607 0.00000385568 0.00000169128 / 0.06); | ||||
|   margin: 0 24px; | ||||
|   padding: 4px 0 12px; | ||||
|   border-bottom: 1px solid rgba(255, 255, 255, 0.04); | ||||
| } | ||||
|  | ||||
| #messages h2 { | ||||
| @@ -28,8 +23,6 @@ | ||||
|   align-content: flex-start; | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| #messages .avatar-wrapper > div { | ||||
|   flex: 1 1 auto; | ||||
| } | ||||
| @@ -57,9 +50,14 @@ | ||||
|   font-size: 12px; | ||||
|   font-weight: 500; | ||||
|   letter-spacing: 0; | ||||
|   margin-left: 5px; | ||||
| } | ||||
|  | ||||
| #messages .info::before { | ||||
|   content: "\2022"; | ||||
|   text-align: center; | ||||
|   display: inline-block; | ||||
|   width: 14px; | ||||
| } | ||||
|  | ||||
| #messages .jump { | ||||
|   cursor: pointer; | ||||
| @@ -68,22 +66,20 @@ | ||||
| } | ||||
|  | ||||
| .message { | ||||
|   margin-top: 2px; | ||||
|   color: oklab(0.89908 -0.00192907 -0.0048306); | ||||
|   font-size: 16px; | ||||
|   margin-top: 6px; | ||||
|   color: rgba(255, 255, 255, 0.7); | ||||
|   font-size: 15px; | ||||
|   line-height: 1.1em; | ||||
|   white-space: pre-wrap; | ||||
|   word-wrap: break-word; | ||||
| } | ||||
|  | ||||
| .message .link, | ||||
| .reply-message .link { | ||||
| .message .link, .reply-message .link { | ||||
|   color: #7289da; | ||||
|   background-color: rgba(115, 139, 215, 0.1); | ||||
| } | ||||
|  | ||||
| .message a, | ||||
| .reply-message a { | ||||
| .message a, .reply-message a { | ||||
|   color: #0096cf; | ||||
|   text-decoration: none; | ||||
| } | ||||
| @@ -130,8 +126,7 @@ | ||||
|   border-radius: 3px; | ||||
| } | ||||
|  | ||||
| .message .embed:first-child, | ||||
| .message .download+.download { | ||||
| .message .embed:first-child, .message .download + .download { | ||||
|   margin-top: 0; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,81 +0,0 @@ | ||||
| #servers { | ||||
|     width: 76px; | ||||
|     background-color: #1e1f22; | ||||
|     padding: 0px; | ||||
|     overflow-y: auto; | ||||
|     overflow-x: hidden; | ||||
| } | ||||
|  | ||||
| .Server { | ||||
|     width: 54px; | ||||
|     height: 54px; | ||||
|     margin-left: 11px; | ||||
|     margin-top: 8px; | ||||
|     border-radius: 100%; | ||||
|     transition: .15s ease-out; | ||||
|     cursor: pointer; | ||||
|     background-color: #313338; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .Server:hover { | ||||
|     border-radius: 20px; | ||||
|     background-color: #5865f2; | ||||
| } | ||||
|  | ||||
| .Server.active { | ||||
|     border-radius: 20px; | ||||
|     background-color: #5865f2; | ||||
| } | ||||
|  | ||||
| .ServerImg { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
| } | ||||
|  | ||||
| svg.ServerImg { | ||||
|     width: 100%; | ||||
|     height: 70%; | ||||
|     /* margin-top: 15%; */ | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| #servers .Server .icon { | ||||
|     font-weight: bold; | ||||
|     font-size: 1.5em; | ||||
|     text-align: center; | ||||
|     word-wrap: break-word; | ||||
|     word-break: break-all; | ||||
|     white-space: normal; | ||||
|     overflow: hidden; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| #servers .Server .name { | ||||
|     display: none; | ||||
|     font-weight: bold; | ||||
|     white-space: normal; | ||||
|     word-wrap: break-word; | ||||
|     text-align: center; | ||||
| } | ||||
| #servers .Server .name { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| #servers .Server:hover .name { | ||||
|     display: block; | ||||
|     position: absolute; | ||||
|     background: rgba(0, 0, 0, 0.8); | ||||
|     color: white; | ||||
|     padding: 5px; | ||||
|     border-radius: 4px; | ||||
|     margin-left: 60px; | ||||
|     white-space: nowrap; | ||||
| } | ||||
| @@ -1,34 +0,0 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
|  | ||||
| namespace DHT.Server.Database; | ||||
|  | ||||
| public static class DatabaseExtensions { | ||||
| 	public static async Task AddFrom(this IDatabaseFile target, IDatabaseFile source) { | ||||
| 		await target.Users.Add(await source.Users.Get().ToListAsync()); | ||||
| 		await target.Servers.Add(await source.Servers.Get().ToListAsync()); | ||||
| 		await target.Channels.Add(await source.Channels.Get().ToListAsync()); | ||||
| 		 | ||||
| 		const int MessageBatchSize = 100; | ||||
| 		List<Message> batchedMessages = new (MessageBatchSize); | ||||
| 		 | ||||
| 		await foreach (Message message in source.Messages.Get()) { | ||||
| 			batchedMessages.Add(message); | ||||
| 			 | ||||
| 			if (batchedMessages.Count >= MessageBatchSize) { | ||||
| 				await target.Messages.Add(batchedMessages); | ||||
| 				batchedMessages.Clear(); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		await target.Messages.Add(batchedMessages); | ||||
| 		 | ||||
| 		await foreach (Data.Download download in source.Downloads.Get()) { | ||||
| 			if (download.Status != DownloadStatus.Success || !await source.Downloads.GetDownloadData(download.NormalizedUrl, stream => target.Downloads.AddDownload(download, stream))) { | ||||
| 				await target.Downloads.AddDownload(download, stream: null); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										77
									
								
								app/Server/Database/Import/DatabaseMerging.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								app/Server/Database/Import/DatabaseMerging.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
|  | ||||
| namespace DHT.Server.Database.Import; | ||||
|  | ||||
| public static class DatabaseMerging { | ||||
| 	public static async Task Merge(this IDatabaseFile target, IDatabaseFile source, IProgressCallback callback) { | ||||
| 		// Import downloads first, otherwise automatic downloads would try to re-download files from other imported data. | ||||
| 		await MergeDownloads(target, source, callback); | ||||
| 		 | ||||
| 		callback.OnImportingMetadata(); | ||||
| 		await target.Users.Add(await source.Users.Get().ToListAsync()); | ||||
| 		await target.Servers.Add(await source.Servers.Get().ToListAsync()); | ||||
| 		await target.Channels.Add(await source.Channels.Get().ToListAsync()); | ||||
| 		 | ||||
| 		await MergeMessages(target, source, callback); | ||||
| 	} | ||||
| 	 | ||||
| 	private static async Task MergeDownloads(IDatabaseFile target, IDatabaseFile source, IProgressCallback callback) { | ||||
| 		const int ReportBatchSize = 100; | ||||
| 		 | ||||
| 		long totalDownloads = await source.Downloads.Count(); | ||||
| 		long importedDownloads = 0; | ||||
| 		 | ||||
| 		callback.OnDownloadsImported(importedDownloads, totalDownloads); | ||||
| 		 | ||||
| 		await foreach (Data.Download download in source.Downloads.Get()) { | ||||
| 			if (download.Status != DownloadStatus.Success || !await source.Downloads.GetDownloadData(download.NormalizedUrl, stream => target.Downloads.AddDownload(download, stream))) { | ||||
| 				await target.Downloads.AddDownload(download, stream: null); | ||||
| 			} | ||||
| 			 | ||||
| 			if (++importedDownloads % ReportBatchSize == 0) { | ||||
| 				callback.OnDownloadsImported(importedDownloads, totalDownloads); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		callback.OnDownloadsImported(totalDownloads, totalDownloads); | ||||
| 	} | ||||
| 	 | ||||
| 	private static async Task MergeMessages(IDatabaseFile target, IDatabaseFile source, IProgressCallback callback) { | ||||
| 		const int MessageBatchSize = 100; | ||||
| 		const int ReportEveryBatches = 10; | ||||
| 		List<Message> batchedMessages = new (MessageBatchSize); | ||||
| 		 | ||||
| 		long totalMessages = await source.Messages.Count(); | ||||
| 		long importedMessages = 0; | ||||
| 		 | ||||
| 		callback.OnMessagesImported(importedMessages, totalMessages); | ||||
| 		 | ||||
| 		await foreach (Message message in source.Messages.Get()) { | ||||
| 			batchedMessages.Add(message); | ||||
| 			 | ||||
| 			if (batchedMessages.Count >= MessageBatchSize) { | ||||
| 				await target.Messages.Add(batchedMessages); | ||||
| 				 | ||||
| 				importedMessages += batchedMessages.Count; | ||||
| 				 | ||||
| 				if (importedMessages % (MessageBatchSize * ReportEveryBatches) == 0) { | ||||
| 					callback.OnMessagesImported(importedMessages, totalMessages); | ||||
| 				} | ||||
| 				 | ||||
| 				batchedMessages.Clear(); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		await target.Messages.Add(batchedMessages); | ||||
| 		callback.OnMessagesImported(totalMessages, totalMessages); | ||||
| 	} | ||||
| 	 | ||||
| 	public interface IProgressCallback { | ||||
| 		void OnImportingMetadata(); | ||||
| 		void OnMessagesImported(long finished, long total); | ||||
| 		void OnDownloadsImported(long finished, long total); | ||||
| 	} | ||||
| } | ||||
| @@ -1,15 +1,14 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Server.Database.Repositories; | ||||
|  | ||||
| public interface IChannelRepository { | ||||
| 	IObservable<long> TotalCount { get; } | ||||
| 	ObservableValue<long> TotalCount { get; } | ||||
| 	 | ||||
| 	Task Add(IReadOnlyList<Channel> channels); | ||||
| 	 | ||||
| @@ -20,7 +19,7 @@ public interface IChannelRepository { | ||||
| 	Task<int> RemoveUnreachable(); | ||||
| 	 | ||||
| 	internal sealed class Dummy : IChannelRepository { | ||||
| 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | ||||
| 		 | ||||
| 		public Task Add(IReadOnlyList<Channel> channels) { | ||||
| 			return Task.CompletedTask; | ||||
|   | ||||
| @@ -2,22 +2,22 @@ using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| using DHT.Server.Data.Aggregations; | ||||
| using DHT.Server.Data.Filters; | ||||
| using DHT.Server.Download; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Server.Database.Repositories; | ||||
|  | ||||
| public interface IDownloadRepository { | ||||
| 	IObservable<long> TotalCount { get; } | ||||
| 	ObservableValue<long> TotalCount { get; } | ||||
| 	 | ||||
| 	Task AddDownload(Data.Download item, Stream? stream); | ||||
| 	 | ||||
| 	Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken = default); | ||||
| 	Task<long> Count(DownloadItemFilter? filter = null, CancellationToken cancellationToken = default); | ||||
| 	 | ||||
| 	Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken = default); | ||||
| 	 | ||||
| @@ -38,13 +38,13 @@ public interface IDownloadRepository { | ||||
| 	IAsyncEnumerable<FileUrl> FindReachableFiles(CancellationToken cancellationToken = default); | ||||
| 	 | ||||
| 	internal sealed class Dummy : IDownloadRepository { | ||||
| 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | ||||
| 		 | ||||
| 		public Task AddDownload(Data.Download item, Stream? stream) { | ||||
| 			return Task.CompletedTask; | ||||
| 		} | ||||
| 		 | ||||
| 		public Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken) { | ||||
| 		public Task<long> Count(DownloadItemFilter? filter, CancellationToken cancellationToken) { | ||||
| 			return Task.FromResult(0L); | ||||
| 		} | ||||
| 		 | ||||
|   | ||||
| @@ -1,16 +1,15 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| using DHT.Server.Data.Filters; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Server.Database.Repositories; | ||||
|  | ||||
| public interface IMessageRepository { | ||||
| 	IObservable<long> TotalCount { get; } | ||||
| 	ObservableValue<long> TotalCount { get; } | ||||
| 	 | ||||
| 	Task Add(IReadOnlyList<Message> messages); | ||||
| 	 | ||||
| @@ -25,7 +24,7 @@ public interface IMessageRepository { | ||||
| 	Task<int> RemoveUnreachableAttachments(); | ||||
| 	 | ||||
| 	internal sealed class Dummy : IMessageRepository { | ||||
| 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | ||||
| 		 | ||||
| 		public Task Add(IReadOnlyList<Message> messages) { | ||||
| 			return Task.CompletedTask; | ||||
|   | ||||
| @@ -1,14 +1,13 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Server.Database.Repositories; | ||||
|  | ||||
| public interface IServerRepository { | ||||
| 	IObservable<long> TotalCount { get; } | ||||
| 	ObservableValue<long> TotalCount { get; } | ||||
| 	 | ||||
| 	Task Add(IReadOnlyList<Data.Server> servers); | ||||
| 	 | ||||
| @@ -19,7 +18,7 @@ public interface IServerRepository { | ||||
| 	Task<int> RemoveUnreachable(); | ||||
| 	 | ||||
| 	internal sealed class Dummy : IServerRepository { | ||||
| 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | ||||
| 		 | ||||
| 		public Task Add(IReadOnlyList<Data.Server> servers) { | ||||
| 			return Task.CompletedTask; | ||||
|   | ||||
| @@ -1,15 +1,14 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Server.Database.Repositories; | ||||
|  | ||||
| public interface IUserRepository { | ||||
| 	IObservable<long> TotalCount { get; } | ||||
| 	ObservableValue<long> TotalCount { get; } | ||||
| 	 | ||||
| 	Task Add(IReadOnlyList<User> users); | ||||
| 	 | ||||
| @@ -20,7 +19,7 @@ public interface IUserRepository { | ||||
| 	Task<int> RemoveUnreachable(); | ||||
| 	 | ||||
| 	internal sealed class Dummy : IUserRepository { | ||||
| 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | ||||
| 		 | ||||
| 		public Task Add(IReadOnlyList<User> users) { | ||||
| 			return Task.CompletedTask; | ||||
|   | ||||
| @@ -1,23 +1,27 @@ | ||||
| using System; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Utils.Logging; | ||||
| using DHT.Utils.Observables; | ||||
| using DHT.Utils.Tasks; | ||||
|  | ||||
| namespace DHT.Server.Database.Sqlite.Repositories; | ||||
|  | ||||
| abstract class BaseSqliteRepository : IDisposable { | ||||
| 	private readonly ObservableThrottledTask<long> totalCountTask; | ||||
| 	private readonly ThrottledTask<long> totalCountTask; | ||||
| 	 | ||||
| 	public IObservable<long> TotalCount { get; } | ||||
| 	public ObservableValue<long> TotalCount { get; } = new (0L); | ||||
| 	 | ||||
| 	protected BaseSqliteRepository(Log log) { | ||||
| 		totalCountTask = new ObservableThrottledTask<long>(log, TaskScheduler.Default); | ||||
| 		TotalCount = totalCountTask.DistinctUntilChanged(); | ||||
| 		totalCountTask = new ThrottledTask<long>(log, SetTotalCount, TimeSpan.Zero, TaskScheduler.Default); | ||||
| 		UpdateTotalCount(); | ||||
| 	} | ||||
| 	 | ||||
| 	private Task SetTotalCount(long newCount) { | ||||
| 		TotalCount.Set(newCount); | ||||
| 		return Task.CompletedTask; | ||||
| 	} | ||||
| 	 | ||||
| 	public void Dispose() { | ||||
| 		totalCountTask.Dispose(); | ||||
| 	} | ||||
|   | ||||
| @@ -306,12 +306,14 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep | ||||
| 			await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); | ||||
| 			 | ||||
| 			while (await reader.ReadAsync(cancellationToken)) { | ||||
| 				found.Add(new DownloadItem { | ||||
| 					NormalizedUrl = reader.GetString(0), | ||||
| 					DownloadUrl = reader.GetString(1), | ||||
| 					Type = reader.IsDBNull(2) ? null : reader.GetString(2), | ||||
| 					Size = reader.IsDBNull(3) ? null : reader.GetUint64(3), | ||||
| 				}); | ||||
| 				var item = new DownloadItem( | ||||
| 					NormalizedUrl: reader.GetString(0), | ||||
| 					DownloadUrl: reader.GetString(1), | ||||
| 					Type: reader.IsDBNull(2) ? null : reader.GetString(2), | ||||
| 					Size: reader.IsDBNull(3) ? null : reader.GetUint64(3) | ||||
| 				); | ||||
| 				 | ||||
| 				found.Add(item); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
|   | ||||
| @@ -4,12 +4,7 @@ using DHT.Server.Data; | ||||
|  | ||||
| namespace DHT.Server.Download; | ||||
|  | ||||
| public readonly struct DownloadItem { | ||||
| 	public string NormalizedUrl { get; init; } | ||||
| 	public string DownloadUrl { get; init; } | ||||
| 	public string? Type { get; init; } | ||||
| 	public ulong? Size { get; init; } | ||||
| 	 | ||||
| public sealed record DownloadItem(string NormalizedUrl, string DownloadUrl, string? Type, ulong? Size) { | ||||
| 	internal Data.Download ToSuccess(long size) { | ||||
| 		return new Data.Download(NormalizedUrl, DownloadUrl, DownloadStatus.Success, Type, (ulong) Math.Max(size, val2: 0)); | ||||
| 	} | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data.Filters; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Utils.Observables; | ||||
|  | ||||
| namespace DHT.Server.Download; | ||||
|  | ||||
| @@ -19,11 +19,11 @@ public sealed class Downloader { | ||||
| 		this.concurrentDownloads = concurrentDownloads; | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task<IObservable<DownloadItem>> Start(DownloadItemFilter filter) { | ||||
| 	public async Task<ObservableValue<DownloadItem>> Start(DownloadItemFilter filter) { | ||||
| 		await semaphore.WaitAsync(); | ||||
| 		try { | ||||
| 			current ??= new DownloaderTask(db, filter, concurrentDownloads); | ||||
| 			return current.FinishedItems; | ||||
| 			return current.LastFinishedItem; | ||||
| 		} finally { | ||||
| 			semaphore.Release(); | ||||
| 		} | ||||
|   | ||||
| @@ -4,13 +4,13 @@ using System.IO; | ||||
| using System.Linq; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Reactive.Subjects; | ||||
| using System.Threading; | ||||
| using System.Threading.Channels; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data.Filters; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Utils.Logging; | ||||
| using DHT.Utils.Observables; | ||||
| using DHT.Utils.Tasks; | ||||
|  | ||||
| namespace DHT.Server.Download; | ||||
| @@ -38,12 +38,11 @@ sealed class DownloaderTask : IAsyncDisposable { | ||||
| 	 | ||||
| 	private readonly IDatabaseFile db; | ||||
| 	private readonly DownloadItemFilter filter; | ||||
| 	private readonly ISubject<DownloadItem> finishedItemPublisher = Subject.Synchronize(new Subject<DownloadItem>()); | ||||
| 	 | ||||
| 	private readonly Task queueWriterTask; | ||||
| 	private readonly Task[] downloadTasks; | ||||
| 	 | ||||
| 	public IObservable<DownloadItem> FinishedItems => finishedItemPublisher; | ||||
| 	public ObservableValue<DownloadItem> LastFinishedItem { get; } = new (null); | ||||
| 	 | ||||
| 	internal DownloaderTask(IDatabaseFile db, DownloadItemFilter filter, int? concurrentDownloads) { | ||||
| 		this.db = db; | ||||
| @@ -101,7 +100,7 @@ sealed class DownloaderTask : IAsyncDisposable { | ||||
| 				log.Error("Could not download file: " + item.DownloadUrl, e); | ||||
| 			} finally { | ||||
| 				try { | ||||
| 					finishedItemPublisher.OnNext(item); | ||||
| 					LastFinishedItem.Set(item); | ||||
| 				} catch (Exception e) { | ||||
| 					log.Error("Caught exception in event handler: " + e); | ||||
| 				} | ||||
| @@ -145,7 +144,6 @@ sealed class DownloaderTask : IAsyncDisposable { | ||||
| 			await Task.WhenAll(downloadTasks).WaitIgnoringCancellation(); | ||||
| 		} finally { | ||||
| 			cancellationTokenSource.Dispose(); | ||||
| 			finishedItemPublisher.OnCompleted(); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										63
									
								
								app/Utils/Observables/LastValueObserver.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								app/Utils/Observables/LastValueObserver.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Channels; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace DHT.Utils.Observables; | ||||
|  | ||||
| sealed class LastValueObserver<T> : IDisposable { | ||||
| 	private readonly ObservableValue<T> observable; | ||||
| 	private readonly Func<T, CancellationToken, Task> action; | ||||
| 	private readonly TaskScheduler scheduler; | ||||
| 	 | ||||
| 	private readonly Channel<T> channel = Channel.CreateBounded<T>(new BoundedChannelOptions(capacity: 1) { | ||||
| 		AllowSynchronousContinuations = false, | ||||
| 		FullMode = BoundedChannelFullMode.DropOldest, | ||||
| 		SingleReader = true, | ||||
| 		SingleWriter = false, | ||||
| 	}); | ||||
| 	 | ||||
| 	private readonly CancellationTokenSource cancellationTokenSource = new (); | ||||
| 	 | ||||
| 	public LastValueObserver(ObservableValue<T> observable, Func<T, CancellationToken, Task> action, TaskScheduler scheduler) { | ||||
| 		this.observable = observable; | ||||
| 		this.action = action; | ||||
| 		this.scheduler = scheduler; | ||||
| 		 | ||||
| 		_ = ReadNextValue(); | ||||
| 	} | ||||
| 	 | ||||
| 	private async Task ReadNextValue() { | ||||
| 		CancellationToken cancellationToken = cancellationTokenSource.Token; | ||||
| 		 | ||||
| 		try { | ||||
| 			await foreach (T value in channel.Reader.ReadAllAsync(cancellationToken)) { | ||||
| 				try { | ||||
| 					await Task.Factory.StartNew(UseValue, value, CancellationToken.None, TaskCreationOptions.None, scheduler).WaitAsync(cancellationToken); | ||||
| 				} catch (Exception) { | ||||
| 					// Ignore. | ||||
| 				} | ||||
| 			} | ||||
| 		} finally { | ||||
| 			cancellationTokenSource.Dispose(); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private Task UseValue(object? value) { | ||||
| 		return action((T) value!, cancellationTokenSource.Token); | ||||
| 	} | ||||
| 	 | ||||
| 	public void Notify(T value) { | ||||
| 		channel.Writer.TryWrite(value); | ||||
| 	} | ||||
| 	 | ||||
| 	public void Dispose() { | ||||
| 		observable.Unsubscribe(this); | ||||
| 		 | ||||
| 		try { | ||||
| 			cancellationTokenSource.Cancel(); | ||||
| 		} catch (ObjectDisposedException) {} | ||||
| 		 | ||||
| 		channel.Writer.TryComplete(); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										45
									
								
								app/Utils/Observables/ObservableValue.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								app/Utils/Observables/ObservableValue.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace DHT.Utils.Observables; | ||||
|  | ||||
| public sealed class ObservableValue<T>(T? value) { | ||||
| 	private readonly List<LastValueObserver<T>> observers = []; | ||||
| 	private T? value = value; | ||||
| 	 | ||||
| 	public void Set(T value) { | ||||
| 		lock (this) { | ||||
| 			if (EqualityComparer<T>.Default.Equals(value, this.value)) { | ||||
| 				return; | ||||
| 			} | ||||
| 			 | ||||
| 			this.value = value; | ||||
| 			 | ||||
| 			foreach (var observer in observers) { | ||||
| 				observer.Notify(value); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public IDisposable SubscribeLast(Func<T, CancellationToken, Task> action, TaskScheduler scheduler) { | ||||
| 		var observer = new LastValueObserver<T>(this, action, scheduler); | ||||
| 		 | ||||
| 		lock (this) { | ||||
| 			observers.Add(observer); | ||||
| 			 | ||||
| 			if (value is not null) { | ||||
| 				observer.Notify(value); | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		return observer; | ||||
| 	} | ||||
| 	 | ||||
| 	internal void Unsubscribe(LastValueObserver<T> observer) { | ||||
| 		lock (this) { | ||||
| 			observers.Remove(observer); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -1,64 +0,0 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Channels; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Utils.Logging; | ||||
|  | ||||
| namespace DHT.Utils.Tasks; | ||||
|  | ||||
| public sealed class DelayedThrottledTask<T> : IDisposable { | ||||
| 	private readonly Channel<T> taskChannel = Channel.CreateBounded<T>(new BoundedChannelOptions(capacity: 1) { | ||||
| 		SingleReader = true, | ||||
| 		SingleWriter = false, | ||||
| 		AllowSynchronousContinuations = false, | ||||
| 		FullMode = BoundedChannelFullMode.DropOldest, | ||||
| 	}); | ||||
| 	 | ||||
| 	private readonly CancellationTokenSource cancellationTokenSource = new (); | ||||
| 	private readonly Log log; | ||||
| 	private readonly TimeSpan delay; | ||||
| 	private readonly Func<T, Task> inputProcessor; | ||||
| 	 | ||||
| 	public DelayedThrottledTask(Log log, TimeSpan delay, Func<T, Task> inputProcessor) { | ||||
| 		this.log = log; | ||||
| 		this.delay = delay; | ||||
| 		this.inputProcessor = inputProcessor; | ||||
| 		 | ||||
| 		Task.Run(ReaderTask); | ||||
| 	} | ||||
| 	 | ||||
| 	private async Task ReaderTask() { | ||||
| 		CancellationToken cancellationToken = cancellationTokenSource.Token; | ||||
| 		 | ||||
| 		try { | ||||
| 			while (await taskChannel.Reader.WaitToReadAsync(cancellationToken)) { | ||||
| 				await Task.Delay(delay, cancellationToken); | ||||
| 				 | ||||
| 				T input = await taskChannel.Reader.ReadAsync(cancellationToken); | ||||
| 				try { | ||||
| 					await inputProcessor(input); | ||||
| 				} catch (OperationCanceledException) { | ||||
| 					throw; | ||||
| 				} catch (Exception e) { | ||||
| 					log.Error("Caught exception in task: " + e); | ||||
| 				} | ||||
| 			} | ||||
| 		} catch (OperationCanceledException) { | ||||
| 			// Ignore. | ||||
| 		} finally { | ||||
| 			cancellationTokenSource.Dispose(); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public void Post(T input) { | ||||
| 		taskChannel.Writer.TryWrite(input); | ||||
| 	} | ||||
| 	 | ||||
| 	public void Dispose() { | ||||
| 		try { | ||||
| 			cancellationTokenSource.Cancel(); | ||||
| 		} catch (ObjectDisposedException) {} | ||||
| 		 | ||||
| 		taskChannel.Writer.Complete(); | ||||
| 	} | ||||
| } | ||||
| @@ -1,32 +0,0 @@ | ||||
| using System; | ||||
| using System.Reactive.Subjects; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Utils.Logging; | ||||
|  | ||||
| namespace DHT.Utils.Tasks; | ||||
|  | ||||
| public sealed class ObservableThrottledTask<T> : IObservable<T>, IDisposable { | ||||
| 	private readonly ReplaySubject<T> subject; | ||||
| 	private readonly ThrottledTask<T> task; | ||||
| 	 | ||||
| 	public ObservableThrottledTask(Log log, TaskScheduler resultScheduler) { | ||||
| 		this.subject = new ReplaySubject<T>(bufferSize: 1); | ||||
| 		this.task = new ThrottledTask<T>(log, subject.OnNext, resultScheduler); | ||||
| 	} | ||||
| 	 | ||||
| 	public void Post(Func<CancellationToken, Task<T>> resultComputer) { | ||||
| 		task.Post(resultComputer); | ||||
| 	} | ||||
| 	 | ||||
| 	public IDisposable Subscribe(IObserver<T> observer) { | ||||
| 		return subject.Subscribe(observer); | ||||
| 	} | ||||
| 	 | ||||
| 	public void Dispose() { | ||||
| 		task.Dispose(); | ||||
| 		 | ||||
| 		subject.OnCompleted(); | ||||
| 		subject.Dispose(); | ||||
| 	} | ||||
| } | ||||
| @@ -14,11 +14,13 @@ public abstract class ThrottledTaskBase<T> : IDisposable { | ||||
| 		FullMode = BoundedChannelFullMode.DropOldest, | ||||
| 	}); | ||||
| 	 | ||||
| 	private readonly CancellationTokenSource cancellationTokenSource = new (); | ||||
| 	private readonly Log log; | ||||
| 	private readonly TimeSpan delayBetweenRuns; | ||||
| 	private readonly CancellationTokenSource cancellationTokenSource = new (); | ||||
| 	 | ||||
| 	internal ThrottledTaskBase(Log log) { | ||||
| 	internal ThrottledTaskBase(Log log, TimeSpan delayBetweenRuns) { | ||||
| 		this.log = log; | ||||
| 		this.delayBetweenRuns = delayBetweenRuns; | ||||
| 	} | ||||
| 	 | ||||
| 	protected async Task ReaderTask() { | ||||
| @@ -33,9 +35,9 @@ public abstract class ThrottledTaskBase<T> : IDisposable { | ||||
| 				} catch (Exception e) { | ||||
| 					log.Error("Caught exception in task: " + e); | ||||
| 				} | ||||
| 				 | ||||
| 				await Task.Delay(delayBetweenRuns, cancellationToken); | ||||
| 			} | ||||
| 		} catch (OperationCanceledException) { | ||||
| 			// Ignore. | ||||
| 		} finally { | ||||
| 			cancellationTokenSource.Dispose(); | ||||
| 		} | ||||
| @@ -48,20 +50,20 @@ public abstract class ThrottledTaskBase<T> : IDisposable { | ||||
| 	} | ||||
| 	 | ||||
| 	public void Dispose() { | ||||
| 		taskChannel.Writer.Complete(); | ||||
| 		cancellationTokenSource.Cancel(); | ||||
| 		taskChannel.Writer.Complete(); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| public sealed class ThrottledTask : ThrottledTaskBase<Task> { | ||||
| 	private readonly Action resultProcessor; | ||||
| 	private readonly Func<Task> resultProcessor; | ||||
| 	private readonly TaskScheduler resultScheduler; | ||||
| 	 | ||||
| 	public ThrottledTask(Log log, Action resultProcessor, TaskScheduler resultScheduler) : base(log) { | ||||
| 	public ThrottledTask(Log log, Func<Task> resultProcessor, TimeSpan delayBetweenRuns, TaskScheduler resultScheduler) : base(log, delayBetweenRuns) { | ||||
| 		this.resultProcessor = resultProcessor; | ||||
| 		this.resultScheduler = resultScheduler; | ||||
| 		 | ||||
| 		Task.Run(ReaderTask); | ||||
| 		_ = ReaderTask(); | ||||
| 	} | ||||
| 	 | ||||
| 	protected override async Task Run(Func<CancellationToken, Task> func, CancellationToken cancellationToken) { | ||||
| @@ -71,18 +73,22 @@ public sealed class ThrottledTask : ThrottledTaskBase<Task> { | ||||
| } | ||||
|  | ||||
| public sealed class ThrottledTask<T> : ThrottledTaskBase<Task<T>> { | ||||
| 	private readonly Action<T> resultProcessor; | ||||
| 	private readonly Func<T, Task> resultProcessor; | ||||
| 	private readonly TaskScheduler resultScheduler; | ||||
| 	 | ||||
| 	public ThrottledTask(Log log, Action<T> resultProcessor, TaskScheduler resultScheduler) : base(log) { | ||||
| 	public ThrottledTask(Log log, Func<T, Task> resultProcessor, TimeSpan delayBetweenRuns, TaskScheduler resultScheduler) : base(log, delayBetweenRuns) { | ||||
| 		this.resultProcessor = resultProcessor; | ||||
| 		this.resultScheduler = resultScheduler; | ||||
| 		 | ||||
| 		Task.Run(ReaderTask); | ||||
| 		_ = ReaderTask(); | ||||
| 	} | ||||
| 	 | ||||
| 	protected override async Task Run(Func<CancellationToken, Task<T>> func, CancellationToken cancellationToken) { | ||||
| 		T result = await func(cancellationToken); | ||||
| 		await Task.Factory.StartNew(() => resultProcessor(result), cancellationToken, TaskCreationOptions.None, resultScheduler); | ||||
| 	} | ||||
| 	 | ||||
| 	public void Post(T result) { | ||||
| 		base.Post(_ => Task.FromResult(result)); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -16,7 +16,6 @@ | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="JetBrains.Annotations" Version="2023.2.0" /> | ||||
|     <PackageReference Include="System.Reactive" Version="6.0.0" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|   | ||||
| @@ -8,5 +8,5 @@ using DHT.Utils; | ||||
| namespace DHT.Utils; | ||||
|  | ||||
| static class Version { | ||||
| 	public const string Tag = "47.1.0.0"; | ||||
| 	public const string Tag = "47.2.0.0"; | ||||
| } | ||||
|   | ||||
| @@ -22,7 +22,6 @@ define('LATEST_VERSION', $version === false ? '_' : $version); | ||||
|       <h1>Discord History Tracker <span class="bar">|</span> <span class="notes"><a href="https://github.com/chylex/Discord-History-Tracker/releases">Release Notes</a></span></h1> | ||||
|       <p>Discord History Tracker lets you save chat history in your servers, groups, and private conversations, and view it offline.</p> | ||||
|       <img src="img/tracker.png" width="851" class="dht bordered"> | ||||
|       <p>This page explains how to use the desktop app. If you are looking for the older version of Discord History Tracker which only needs a browser or the Discord app, visit the page for the <a href="https://dht.chylex.com/browser-only">browser-only version</a>, however keep in mind that this version has <strong>significant limitations and fewer features</strong>.</p> | ||||
|        | ||||
|       <h2>How to Use</h2> | ||||
|       <p>Download the latest version of the desktop app here, or visit <a href="https://github.com/chylex/Discord-History-Tracker/releases">All Releases</a> for older versions and release notes.</p> | ||||
| @@ -71,9 +70,9 @@ define('LATEST_VERSION', $version === false ? '_' : $version); | ||||
|        | ||||
|       <h2>External Links</h2> | ||||
|       <p class="links"> | ||||
|         <a href="https://github.com/chylex/Discord-History-Tracker/issues">Issues & Suggestions</a>  —  | ||||
|         <a href="https://github.com/chylex/Discord-History-Tracker">Source Code</a>  —  | ||||
|         <a href="https://twitter.com/chylexmc">Follow Dev on Twitter</a>  —  | ||||
|         <a href="https://github.com/chylex/Discord-History-Tracker/issues">Issues & Suggestions</a>  •  | ||||
|         <a href="https://github.com/chylex/Discord-History-Tracker">Source Code</a>  •  | ||||
|         <a href="https://chylex.com">Developer's Website</a>  •  | ||||
|         <a href="https://ko-fi.com/chylex">Support via Ko-fi</a> | ||||
|       </p> | ||||
|        | ||||
|   | ||||
| @@ -165,7 +165,7 @@ code { | ||||
|  | ||||
| .downloads svg { | ||||
|   margin: 1px 4px; | ||||
|   vertical-align: -25%; | ||||
|   vertical-align: -30%; | ||||
| } | ||||
|  | ||||
| .downloads svg.icon-large { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user