1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2025-09-17 16:24:47 +02:00

3 Commits

17 changed files with 70 additions and 254 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1 +1,3 @@
github: chylex
patreon: chylex
ko_fi: chylex

View File

@@ -18,7 +18,7 @@ Folder organization:
* `app/` contains a Visual Studio solution for the desktop app
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
To start editing source code for the desktop app, install the [.NET 9 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
To start editing source code for the desktop app, install the [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
### Building
@@ -28,18 +28,18 @@ To build a `Release` version of the desktop app, follow the instructions for you
#### Release Windows (64-bit)
1. Install Debian in WSL and open a terminal in the project folder.
2. Run the `app/build.wsl.sh` script.
3. Read the [Distribution](#distribution) section below.
1. Install [Powershell 5](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) or newer (on Windows 10, the included version of Powershell should be enough)
Note: The build script expects `dotnet.exe` to be installed in `C:\Program Files\dotnet`.
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
#### Release Other Operating Systems
1. Install the `zip` package from your repository.
2. Run the `app/build.sh` script.
3. Read the [Distribution](#distribution) section below.
1. Install the `zip` package from your repository
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
#### Distribution
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires the ASP.NET Core Runtime to be installed.
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 8 to be installed.
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking the executable. Since .NET 8 fixed several issues with publishing Windows executables on Linux, I recommend using Linux to build the app for all operating systems.

View File

@@ -2,15 +2,11 @@ using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Utils.Logging;
namespace DHT.Desktop.Dialogs.Progress;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class ProgressDialog : Window {
private static readonly Log Log = Log.ForType<ProgressDialog>();
internal static async Task Show(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task> action) {
var taskCompletionSource = new TaskCompletionSource();
var dialog = new ProgressDialog();
@@ -88,11 +84,6 @@ public sealed partial class ProgressDialog : Window {
public async Task ShowProgressDialog(Window owner) {
await ShowDialog(owner);
try {
await progressTask;
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(owner, "Unexpected Error", e.Message);
}
await progressTask;
}
}

View File

@@ -13,8 +13,8 @@
</Design.DataContext>
<UserControl.Styles>
<Style Selector="WrapPanel > Button">
<Setter Property="Margin" Value="0 0 10 10" />
<Style Selector="Expander">
<Setter Property="Margin" Value="0 5 0 0" />
</Style>
<Style Selector="DataGridColumnHeader">
<Setter Property="FontWeight" Value="Medium" />
@@ -30,17 +30,16 @@
</Style>
</UserControl.Styles>
<StackPanel Orientation="Vertical">
<WrapPanel Orientation="Horizontal">
<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>
<Button Command="{Binding OnClickDeleteOrphanedDownloads}">Delete Orphaned Downloads</Button>
</WrapPanel>
<StackPanel Orientation="Vertical" Spacing="20" Margin="0 10 0 0">
<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>
<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">
<DataGrid.Columns>

View File

@@ -1,17 +1,12 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Main.Controls;
using DHT.Server;
using DHT.Server.Data;
using DHT.Server.Data.Aggregations;
using DHT.Server.Data.Filters;
using DHT.Server.Data.Settings;
@@ -53,7 +48,6 @@ sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
public bool IsDownloading => state.Downloader.IsDownloading;
private readonly Window window;
private readonly State state;
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
private readonly IDisposable downloadItemCountSubscription;
@@ -61,10 +55,9 @@ sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
private IDisposable? finishedItemsSubscription;
private DownloadItemFilter? currentDownloadFilter;
public DownloadsPageModel() : this(null!, State.Dummy) {}
public DownloadsPageModel() : this(State.Dummy) {}
public DownloadsPageModel(Window window, State state) {
this.window = window;
public DownloadsPageModel(State state) {
this.state = state;
FilterModel = new DownloadItemFilterPanelModel(state);
@@ -165,50 +158,6 @@ sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
downloadStatisticsTask.Post(cancellationToken => state.Db.Downloads.GetStatistics(currentDownloadFilter ?? new DownloadItemFilter(), cancellationToken));
}
private const string DeleteOrphanedDownloadsTitle = "Delete Orphaned Downloads";
public async Task OnClickDeleteOrphanedDownloads() {
await ProgressDialog.Show(window, DeleteOrphanedDownloadsTitle, DeleteOrphanedDownloads);
}
private async Task DeleteOrphanedDownloads(ProgressDialog dialog, IProgressCallback callback) {
await callback.UpdateIndeterminate("Searching for orphaned downloads...");
HashSet<string> reachableNormalizedUrls = [];
HashSet<string> orphanedNormalizedUrls = [];
await foreach (Download download in state.Db.Downloads.FindAllDownloadableUrls()) {
reachableNormalizedUrls.Add(download.NormalizedUrl);
}
await foreach (Download download in state.Db.Downloads.Get()) {
string normalizedUrl = download.NormalizedUrl;
if (!reachableNormalizedUrls.Contains(normalizedUrl)) {
orphanedNormalizedUrls.Add(normalizedUrl);
}
}
if (orphanedNormalizedUrls.Count == 0) {
await Dialog.ShowOk(window, DeleteOrphanedDownloadsTitle, "No orphaned downloads found.");
return;
}
if (await Dialog.ShowYesNo(window, DeleteOrphanedDownloadsTitle, orphanedNormalizedUrls.Count + " orphaned download(s) will be removed from this database. This action cannot be undone. Proceed?") != DialogResult.YesNo.Yes) {
return;
}
await callback.UpdateIndeterminate("Deleting orphaned downloads...");
await state.Db.Downloads.Remove(orphanedNormalizedUrls);
RecomputeDownloadStatistics();
if (await Dialog.ShowYesNo(window, DeleteOrphanedDownloadsTitle, "Orphaned downloads deleted. Vacuum database now to reclaim space?") != DialogResult.YesNo.Yes) {
return;
}
await callback.UpdateIndeterminate("Vacuuming database...");
await state.Db.Vacuum();
}
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
statisticsPending.Items = statusStatistics.PendingCount;
statisticsPending.Size = statusStatistics.PendingTotalSize;

View File

@@ -49,7 +49,7 @@ sealed class MainContentScreenModel : IAsyncDisposable {
TrackingPageModel = new TrackingPageModel(window);
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
DownloadsPageModel = new DownloadsPageModel(window, state);
DownloadsPageModel = new DownloadsPageModel(state);
DownloadsPage = new DownloadsPage { DataContext = DownloadsPageModel };
ViewerPageModel = new ViewerPageModel(window, state);

View File

@@ -1,8 +1,8 @@
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>13</LangVersion>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>12</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -39,7 +39,6 @@
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>
<DebugType>none</DebugType>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
<PropertyGroup>

View File

@@ -32,10 +32,6 @@ public interface IDownloadRepository {
Task<int> RetryFailed(CancellationToken cancellationToken = default);
Task Remove(ICollection<string> normalizedUrls);
IAsyncEnumerable<Data.Download> FindAllDownloadableUrls(CancellationToken cancellationToken = default);
internal sealed class Dummy : IDownloadRepository {
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
@@ -74,13 +70,5 @@ public interface IDownloadRepository {
public Task<int> RetryFailed(CancellationToken cancellationToken) {
return Task.FromResult(0);
}
public Task Remove(ICollection<string> normalizedUrls) {
return Task.CompletedTask;
}
public IAsyncEnumerable<Data.Download> FindAllDownloadableUrls(CancellationToken cancellationToken) {
return AsyncEnumerable.Empty<Data.Download>();
}
}
}

View File

@@ -321,62 +321,4 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
return await cmd.ExecuteNonQueryAsync(cancellationToken);
}
public async Task Remove(ICollection<string> normalizedUrls) {
await using (var conn = await pool.Take()) {
await conn.BeginTransactionAsync();
await using (var cmd = conn.Command("DELETE FROM download_metadata WHERE normalized_url = :normalized_url")) {
cmd.Add(":normalized_url", SqliteType.Text);
foreach (string normalizedUrl in normalizedUrls) {
cmd.Set(":normalized_url", normalizedUrl);
await cmd.ExecuteNonQueryAsync();
}
}
await conn.CommitTransactionAsync();
}
UpdateTotalCount();
}
public async IAsyncEnumerable<Data.Download> FindAllDownloadableUrls([EnumeratorCancellation] CancellationToken cancellationToken = default) {
await using var conn = await pool.Take();
await using (var cmd = conn.Command("SELECT normalized_url, download_url, type, size FROM attachments")) {
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) {
yield return DownloadLinkExtractor.FromAttachment(reader.GetString(0), reader.GetString(1), reader.IsDBNull(2) ? null : reader.GetString(2), reader.GetUint64(3));
}
}
await using (var cmd = conn.Command("SELECT json FROM message_embeds")) {
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) {
var result = await DownloadLinkExtractor.TryFromEmbedJson(reader.GetStream(0));
if (result is not null) {
yield return result;
}
}
}
await using (var cmd = conn.Command("SELECT id, avatar_url FROM users WHERE avatar_url IS NOT NULL")) {
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) {
yield return DownloadLinkExtractor.FromUserAvatar(reader.GetUint64(0), reader.GetString(1));
}
}
await using (var cmd = conn.Command("SELECT DISTINCT emoji_id, emoji_flags FROM message_reactions WHERE emoji_id IS NOT NULL")) {
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) {
yield return DownloadLinkExtractor.FromEmoji(reader.GetUint64(0), (EmojiFlags) reader.GetInt16(1));
}
}
}
}

View File

@@ -28,11 +28,7 @@ static class DownloadLinkExtractor {
}
public static Data.Download FromAttachment(Attachment attachment) {
return FromAttachment(attachment.NormalizedUrl, attachment.DownloadUrl, attachment.Type, attachment.Size);
}
public static Data.Download FromAttachment(string normalizedUrl, string downloadUrl, string? type, ulong size) {
return new Data.Download(normalizedUrl, downloadUrl, DownloadStatus.Pending, type, size);
return new Data.Download(attachment.NormalizedUrl, attachment.DownloadUrl, DownloadStatus.Pending, attachment.Type, attachment.Size);
}
public static async Task<Data.Download?> TryFromEmbedJson(Stream jsonStream) {

View File

@@ -8,5 +8,5 @@ using DHT.Utils;
namespace DHT.Utils;
static class Version {
public const string Tag = "45.0.0.0";
public const string Tag = "44.0.0.0";
}

15
app/build.bat Normal file
View File

@@ -0,0 +1,15 @@
@echo off
set list=win-x64 linux-x64 osx-x64
rmdir /S /Q bin
(for %%a in (%list%) do (
dotnet publish Desktop -c Release -r %%a -o ./bin/%%a --self-contained true
powershell "Compress-Archive -Path ./bin/%%a/* -DestinationPath ./bin/%%a.zip -CompressionLevel Optimal"
))
dotnet publish Desktop -c Release -o ./bin/portable -p:PublishSingleFile=false -p:PublishTrimmed=false --self-contained false
powershell "Compress-Archive -Path ./bin/portable/* -DestinationPath ./bin/portable.zip -CompressionLevel Optimal"
echo Done
pause

View File

@@ -1,49 +1,25 @@
#!/bin/bash
set -e
export TZ=UTC
if [ ! -f "DiscordHistoryTracker.sln" ]; then
echo "Missing DiscordHistoryTracker.sln in working directory!"
exit 1
echo "Missing DiscordHistoryTracker.sln in working directory!"
exit 1
fi
makezip() {
BIN_PATH="$(pwd)/bin"
pushd "$BIN_PATH/$1"
find . -type d -exec chmod 755 {} \;
find . -type f -exec chmod 644 {} \;
chmod -f 755 DiscordHistoryTracker || true
chmod -f 755 DiscordHistoryTracker.exe || true
find . -type f | sort | zip -9 -X "$BIN_PATH/$1.zip" -@
popd
pushd "./bin/$1"
zip -9 -r "../$1.zip" .
popd
}
rm -rf "./bin"
dedicated_runtimes=(win-x64 linux-x64)
skipped_portable_runtimes=(browser-wasm linux-mips64 linux-s390x linux-ppc64le)
configurations=(win-x64 linux-x64 osx-x64)
# Dedicated Runtimes
for cfg in "${dedicated_runtimes[@]}"; do
dotnet publish Desktop -c Release -r "$cfg" -o "./bin/$cfg" --self-contained true
makezip "$cfg"
for cfg in ${configurations[@]}; do
dotnet publish Desktop -c Release -r "$cfg" -o "./bin/$cfg" --self-contained true
makezip "$cfg"
done
# Portable
dotnet publish Desktop -c Release -o "./bin/portable" -p:PublishSingleFile=false -p:PublishTrimmed=false --self-contained false
rm "./bin/portable/DiscordHistoryTracker"
for runtime in "${skipped_portable_runtimes[@]}"; do
rm -rf "./bin/portable/runtimes/$runtime"
done
makezip "portable"

View File

@@ -1,53 +0,0 @@
#!/bin/bash
set -e
export TZ=UTC
if [ ! -f "DiscordHistoryTracker.sln" ]; then
echo "Missing DiscordHistoryTracker.sln in working directory!"
exit 1
fi
makezip() {
TMP_PATH="/tmp/dht-build"
BIN_PATH="$(pwd)/bin"
rm -rf "$TMP_PATH"
cp -r "$BIN_PATH/$1/" "$TMP_PATH"
pushd "$TMP_PATH"
find . -type d -exec chmod 755 {} \;
find . -type f -exec chmod 644 {} \;
chmod -f 755 DiscordHistoryTracker || true
chmod -f 755 DiscordHistoryTracker.exe || true
find . -type f | sort | zip -9 -X "$BIN_PATH/$1.zip" -@
popd
rm -rf "$TMP_PATH"
}
rm -rf "./bin"
dedicated_runtimes=(win-x64 linux-x64)
skipped_portable_runtimes=(browser-wasm linux-mips64 linux-s390x linux-ppc64le)
# Dedicated Runtimes
for cfg in "${dedicated_runtimes[@]}"; do
"/mnt/c/Program Files/dotnet/dotnet.exe" publish Desktop -c Release -r "$cfg" -o "./bin/$cfg" --self-contained true
makezip "$cfg"
done
# Portable
"/mnt/c/Program Files/dotnet/dotnet.exe" publish Desktop -c Release -o "./bin/portable" -p:PublishSingleFile=false -p:PublishTrimmed=false --self-contained false
rm "./bin/portable/DiscordHistoryTracker.exe"
for runtime in "${skipped_portable_runtimes[@]}"; do
rm -rf "./bin/portable/runtimes/$runtime"
done
makezip "portable"

Binary file not shown.

View File

@@ -1,6 +1,6 @@
{
"sdk": {
"version": "9.0.0",
"version": "8.0.0",
"rollForward": "latestMinor"
}
}

File diff suppressed because one or more lines are too long