mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2024-11-25 14:42:44 +01:00
Compare commits
No commits in common. "5ca7cf09e8320609251545bf8911664000f34f7d" and "1a6346677e8ba0631c519c0c13bc6a0095be5c39" have entirely different histories.
5ca7cf09e8
...
1a6346677e
@ -35,8 +35,6 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<StackPanel>
|
|
||||||
<TextBlock Text="{Binding FilterStatisticsText}" />
|
|
||||||
<WrapPanel>
|
<WrapPanel>
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<CheckBox IsChecked="{Binding FilterByDate}">Filter by Date</CheckBox>
|
<CheckBox IsChecked="{Binding FilterByDate}">Filter by Date</CheckBox>
|
||||||
@ -58,6 +56,5 @@
|
|||||||
<TextBlock Text="{Binding UserFilterLabel}" />
|
<TextBlock Text="{Binding UserFilterLabel}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</WrapPanel>
|
</WrapPanel>
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
@ -12,7 +12,6 @@ using DHT.Server.Data;
|
|||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
using DHT.Utils.Tasks;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls {
|
||||||
sealed class FilterPanelModel : BaseModel, IDisposable {
|
sealed class FilterPanelModel : BaseModel, IDisposable {
|
||||||
@ -26,8 +25,6 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
nameof(IncludedUsers)
|
nameof(IncludedUsers)
|
||||||
};
|
};
|
||||||
|
|
||||||
public string FilterStatisticsText { get; private set; } = "";
|
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? FilterPropertyChanged;
|
public event PropertyChangedEventHandler? FilterPropertyChanged;
|
||||||
|
|
||||||
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
||||||
@ -92,10 +89,6 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly IDatabaseFile db;
|
private readonly IDatabaseFile db;
|
||||||
|
|
||||||
private readonly AsyncValueComputer<long> exportedMessageCountComputer;
|
|
||||||
private long? exportedMessageCount;
|
|
||||||
private long? totalMessageCount;
|
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public FilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
|
public FilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||||
|
|
||||||
@ -103,9 +96,6 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
this.window = window;
|
this.window = window;
|
||||||
this.db = db;
|
this.db = db;
|
||||||
|
|
||||||
this.exportedMessageCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetExportedMessageCount).Build();
|
|
||||||
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
UpdateChannelFilterLabel();
|
UpdateChannelFilterLabel();
|
||||||
UpdateUserFilterLabel();
|
UpdateUserFilterLabel();
|
||||||
|
|
||||||
@ -119,7 +109,6 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
|
|
||||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||||
UpdateFilterStatistics();
|
|
||||||
FilterPropertyChanged?.Invoke(sender, e);
|
FilterPropertyChanged?.Invoke(sender, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,11 +121,7 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
|
if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
|
||||||
totalMessageCount = db.Statistics.TotalMessages;
|
|
||||||
UpdateFilterStatistics();
|
|
||||||
}
|
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
|
|
||||||
UpdateChannelFilterLabel();
|
UpdateChannelFilterLabel();
|
||||||
}
|
}
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) {
|
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) {
|
||||||
@ -144,32 +129,6 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateFilterStatistics() {
|
|
||||||
var filter = CreateFilter();
|
|
||||||
if (filter.IsEmpty) {
|
|
||||||
exportedMessageCount = totalMessageCount;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
exportedMessageCount = null;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
exportedMessageCountComputer.Compute(_ => db.CountMessages(filter));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetExportedMessageCount(long exportedMessageCount) {
|
|
||||||
this.exportedMessageCount = exportedMessageCount;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateFilterStatisticsText() {
|
|
||||||
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
|
|
||||||
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
|
|
||||||
|
|
||||||
FilterStatisticsText = "Will export " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or > 0 ? "s." : ".");
|
|
||||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void OpenChannelFilterDialog() {
|
public async void OpenChannelFilterDialog() {
|
||||||
var servers = db.GetAllServers().ToDictionary(static server => server.Id);
|
var servers = db.GetAllServers().ToDictionary(static server => server.Id);
|
||||||
var items = new List<CheckBoxItem<ulong>>();
|
var items = new List<CheckBoxItem<ulong>>();
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using DHT.Desktop.Main.Pages;
|
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main {
|
namespace DHT.Desktop.Main {
|
||||||
@ -32,14 +30,6 @@ namespace DHT.Desktop.Main {
|
|||||||
if (DataContext is IDisposable disposable) {
|
if (DataContext is IDisposable disposable) {
|
||||||
disposable.Dispose();
|
disposable.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
|
|
||||||
try {
|
|
||||||
File.Delete(temporaryFile);
|
|
||||||
} catch (Exception) {
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,9 @@
|
|||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.ViewerPage">
|
x:Class="DHT.Desktop.Main.Pages.ViewerPage"
|
||||||
|
AttachedToVisualTree="OnAttachedToVisualTree"
|
||||||
|
DetachedFromVisualTree="OnDetachedFromVisualTree">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:ViewerPageModel />
|
<pages:ViewerPageModel />
|
||||||
@ -22,7 +24,8 @@
|
|||||||
<Button Command="{Binding OnClickOpenViewer}" Margin="0 0 5 0">Open Viewer</Button>
|
<Button Command="{Binding OnClickOpenViewer}" Margin="0 0 5 0">Open Viewer</Button>
|
||||||
<Button Command="{Binding OnClickSaveViewer}" Margin="5 0 0 0">Save Viewer</Button>
|
<Button Command="{Binding OnClickSaveViewer}" Margin="5 0 0 0">Save Viewer</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<controls:FilterPanel DataContext="{Binding FilterModel}" Margin="0 20 0 0" />
|
<TextBlock Text="{Binding ExportedMessageText}" Margin="0 20 0 0" />
|
||||||
|
<controls:FilterPanel DataContext="{Binding FilterModel}" />
|
||||||
<Expander Header="Database Tools">
|
<Expander Header="Database Tools">
|
||||||
<StackPanel Orientation="Vertical" Spacing="10">
|
<StackPanel Orientation="Vertical" Spacing="10">
|
||||||
<StackPanel Orientation="Vertical" Spacing="4">
|
<StackPanel Orientation="Vertical" Spacing="4">
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
@ -12,5 +13,17 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
private void InitializeComponent() {
|
private void InitializeComponent() {
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) {
|
||||||
|
if (DataContext is ViewerPageModel model) {
|
||||||
|
model.SetPageVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) {
|
||||||
|
if (DataContext is ViewerPageModel model) {
|
||||||
|
model.SetPageVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
@ -19,7 +17,7 @@ using static DHT.Desktop.Program;
|
|||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages {
|
||||||
sealed class ViewerPageModel : BaseModel, IDisposable {
|
sealed class ViewerPageModel : BaseModel, IDisposable {
|
||||||
public static readonly ConcurrentBag<string> TemporaryFiles = new ();
|
public string ExportedMessageText { get; private set; } = "";
|
||||||
|
|
||||||
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
||||||
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
||||||
@ -36,6 +34,8 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly IDatabaseFile db;
|
private readonly IDatabaseFile db;
|
||||||
|
|
||||||
|
private bool isPageVisible = false;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||||
|
|
||||||
@ -45,51 +45,49 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
|
|
||||||
FilterModel = new FilterPanelModel(window, db);
|
FilterModel = new FilterPanelModel(window, db);
|
||||||
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
||||||
|
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
|
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||||
FilterModel.Dispose();
|
FilterModel.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetPageVisible(bool isPageVisible) {
|
||||||
|
this.isPageVisible = isPageVisible;
|
||||||
|
if (isPageVisible) {
|
||||||
|
UpdateStatistics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
|
UpdateStatistics();
|
||||||
HasFilters = FilterModel.HasAnyFilters;
|
HasFilters = FilterModel.HasAnyFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task WriteViewerFile(string path) {
|
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
const string ArchiveTag = "/*[ARCHIVE]*/";
|
if (isPageVisible && e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
|
||||||
|
UpdateStatistics();
|
||||||
string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
|
|
||||||
string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
|
|
||||||
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
|
|
||||||
|
|
||||||
int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
|
|
||||||
int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length;
|
|
||||||
|
|
||||||
string jsonTempFile = path + ".tmp";
|
|
||||||
|
|
||||||
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
|
|
||||||
await ViewerJsonExport.Generate(jsonStream, db, FilterModel.CreateFilter());
|
|
||||||
|
|
||||||
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
|
|
||||||
jsonStream.Position = 0;
|
|
||||||
|
|
||||||
await using (var outputStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
|
|
||||||
await using (var outputWriter = new StreamWriter(outputStream, Encoding.UTF8)) {
|
|
||||||
await outputWriter.WriteAsync(viewerTemplate[..viewerArchiveTagStart]);
|
|
||||||
|
|
||||||
using (var jsonReader = new StreamReader(jsonStream, Encoding.UTF8)) {
|
|
||||||
int readBytes;
|
|
||||||
while ((readBytes = await jsonReader.ReadAsync(jsonBuffer, 0, jsonBuffer.Length)) > 0) {
|
|
||||||
string jsonChunk = new string(jsonBuffer, 0, readBytes);
|
|
||||||
await outputWriter.WriteAsync(HttpUtility.JavaScriptStringEncode(jsonChunk));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await outputWriter.WriteAsync(viewerTemplate[viewerArchiveTagEnd..]);
|
private void UpdateStatistics() {
|
||||||
}
|
var filter = FilterModel.CreateFilter();
|
||||||
|
var allMessagesCount = db.Statistics.TotalMessages?.Format() ?? "?";
|
||||||
|
var filteredMessagesCount = filter.IsEmpty ? allMessagesCount : db.CountMessages(filter).Format();
|
||||||
|
|
||||||
|
ExportedMessageText = "Will export " + filteredMessagesCount + " out of " + allMessagesCount + " message(s).";
|
||||||
|
OnPropertyChanged(nameof(ExportedMessageText));
|
||||||
}
|
}
|
||||||
|
|
||||||
File.Delete(jsonTempFile);
|
private async Task<string> GenerateViewerContents() {
|
||||||
|
string json = ViewerJsonExport.Generate(db, FilterModel.CreateFilter());
|
||||||
|
|
||||||
|
string index = await Resources.ReadTextAsync("Viewer/index.html");
|
||||||
|
string viewer = index.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
|
||||||
|
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'))
|
||||||
|
.Replace("/*[ARCHIVE]*/", HttpUtility.JavaScriptStringEncode(json));
|
||||||
|
return viewer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OnClickOpenViewer() {
|
public async void OnClickOpenViewer() {
|
||||||
@ -103,10 +101,8 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
|
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
|
||||||
}
|
}
|
||||||
|
|
||||||
TemporaryFiles.Add(fullPath);
|
|
||||||
|
|
||||||
Directory.CreateDirectory(rootPath);
|
Directory.CreateDirectory(rootPath);
|
||||||
await WriteViewerFile(fullPath);
|
await File.WriteAllTextAsync(fullPath, await GenerateViewerContents());
|
||||||
|
|
||||||
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
|
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
|
||||||
}
|
}
|
||||||
@ -114,7 +110,7 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
public async void OnClickSaveViewer() {
|
public async void OnClickSaveViewer() {
|
||||||
var dialog = new SaveFileDialog {
|
var dialog = new SaveFileDialog {
|
||||||
Title = "Save Viewer",
|
Title = "Save Viewer",
|
||||||
InitialFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
|
InitialFileName = "archive.html",
|
||||||
Directory = Path.GetDirectoryName(db.Path),
|
Directory = Path.GetDirectoryName(db.Path),
|
||||||
Filters = new List<FileDialogFilter> {
|
Filters = new List<FileDialogFilter> {
|
||||||
new() {
|
new() {
|
||||||
@ -126,7 +122,7 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
|
|
||||||
string? path = await dialog;
|
string? path = await dialog;
|
||||||
if (!string.IsNullOrEmpty(path)) {
|
if (!string.IsNullOrEmpty(path)) {
|
||||||
await WriteViewerFile(path);
|
await File.WriteAllTextAsync(path, await GenerateViewerContents());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
@ -11,7 +9,7 @@ namespace DHT.Server.Database.Export {
|
|||||||
public static class ViewerJsonExport {
|
public static class ViewerJsonExport {
|
||||||
private static readonly Log Log = Log.ForType(typeof(ViewerJsonExport));
|
private static readonly Log Log = Log.ForType(typeof(ViewerJsonExport));
|
||||||
|
|
||||||
public static async Task Generate(Stream stream, IDatabaseFile db, MessageFilter? filter = null) {
|
public static string Generate(IDatabaseFile db, MessageFilter? filter = null) {
|
||||||
var perf = Log.Start();
|
var perf = Log.Start();
|
||||||
|
|
||||||
var includedUserIds = new HashSet<ulong>();
|
var includedUserIds = new HashSet<ulong>();
|
||||||
@ -39,20 +37,17 @@ namespace DHT.Server.Database.Export {
|
|||||||
|
|
||||||
perf.Step("Collect database data");
|
perf.Step("Collect database data");
|
||||||
|
|
||||||
var value = new {
|
|
||||||
meta = new { users, userindex, servers, channels },
|
|
||||||
data = GenerateMessageList(includedMessages, userIndices)
|
|
||||||
};
|
|
||||||
|
|
||||||
perf.Step("Generate value object");
|
|
||||||
|
|
||||||
var opts = new JsonSerializerOptions();
|
var opts = new JsonSerializerOptions();
|
||||||
opts.Converters.Add(new ViewerJsonSnowflakeSerializer());
|
opts.Converters.Add(new ViewerJsonSnowflakeSerializer());
|
||||||
|
|
||||||
await JsonSerializer.SerializeAsync(stream, value, opts);
|
var json = JsonSerializer.Serialize(new {
|
||||||
|
meta = new { users, userindex, servers, channels },
|
||||||
|
data = GenerateMessageList(includedMessages, userIndices)
|
||||||
|
}, opts);
|
||||||
|
|
||||||
perf.Step("Serialize to JSON");
|
perf.Step("Serialize to JSON");
|
||||||
perf.End();
|
perf.End();
|
||||||
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<string> userindex, out Dictionary<ulong, object> userIndices) {
|
private static object GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<string> userindex, out Dictionary<ulong, object> userIndices) {
|
||||||
@ -164,8 +159,8 @@ namespace DHT.Server.Database.Export {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!message.Attachments.IsEmpty) {
|
if (!message.Attachments.IsEmpty) {
|
||||||
obj["a"] = message.Attachments.Select(static attachment => new Dictionary<string, object> {
|
obj["a"] = message.Attachments.Select(static attachment => new {
|
||||||
{ "url", attachment.Url }
|
url = attachment.Url
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,14 +2,12 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
using DHT.Utils.Collections;
|
using DHT.Utils.Collections;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using DHT.Utils.Tasks;
|
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite {
|
namespace DHT.Server.Database.Sqlite {
|
||||||
@ -38,12 +36,12 @@ namespace DHT.Server.Database.Sqlite {
|
|||||||
|
|
||||||
private readonly Log log;
|
private readonly Log log;
|
||||||
private readonly SqliteConnectionPool pool;
|
private readonly SqliteConnectionPool pool;
|
||||||
private readonly AsyncValueComputer<long>.Single totalMessagesComputer;
|
private readonly SqliteMessageStatisticsThread messageStatisticsThread;
|
||||||
|
|
||||||
private SqliteDatabaseFile(string path, SqliteConnectionPool pool) {
|
private SqliteDatabaseFile(string path, SqliteConnectionPool pool) {
|
||||||
this.log = Log.ForType(typeof(SqliteDatabaseFile), System.IO.Path.GetFileName(path));
|
this.log = Log.ForType(typeof(SqliteDatabaseFile), System.IO.Path.GetFileName(path));
|
||||||
this.pool = pool;
|
this.pool = pool;
|
||||||
this.totalMessagesComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateMessageStatistics).WithOutdatedResults().BuildWithComputer(ComputeMessageStatistics);
|
this.messageStatisticsThread = new SqliteMessageStatisticsThread(pool, UpdateMessageStatistics);
|
||||||
|
|
||||||
this.Path = path;
|
this.Path = path;
|
||||||
this.Statistics = new DatabaseStatistics();
|
this.Statistics = new DatabaseStatistics();
|
||||||
@ -54,10 +52,11 @@ namespace DHT.Server.Database.Sqlite {
|
|||||||
UpdateUserStatistics(conn);
|
UpdateUserStatistics(conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
totalMessagesComputer.Recompute();
|
messageStatisticsThread.RequestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
|
messageStatisticsThread.Dispose();
|
||||||
pool.Dispose();
|
pool.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,7 +193,7 @@ namespace DHT.Server.Database.Sqlite {
|
|||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
using (var conn = pool.Take()) {
|
using var conn = pool.Take();
|
||||||
using var tx = conn.BeginTransaction();
|
using var tx = conn.BeginTransaction();
|
||||||
|
|
||||||
using var messageCmd = conn.Upsert("messages", new[] {
|
using var messageCmd = conn.Upsert("messages", new[] {
|
||||||
@ -306,9 +305,7 @@ namespace DHT.Server.Database.Sqlite {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tx.Commit();
|
tx.Commit();
|
||||||
}
|
messageStatisticsThread.RequestUpdate();
|
||||||
|
|
||||||
totalMessagesComputer.Recompute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public int CountMessages(MessageFilter? filter = null) {
|
public int CountMessages(MessageFilter? filter = null) {
|
||||||
@ -370,12 +367,11 @@ LEFT JOIN replied_to rt ON m.message_id = rt.message_id" + filter.GenerateWhereC
|
|||||||
.Append("FROM messages")
|
.Append("FROM messages")
|
||||||
.Append(whereClause);
|
.Append(whereClause);
|
||||||
|
|
||||||
using (var conn = pool.Take()) {
|
using var conn = pool.Take();
|
||||||
using var cmd = conn.Command(build.ToString());
|
using var cmd = conn.Command(build.ToString());
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
|
||||||
|
|
||||||
totalMessagesComputer.Recompute();
|
UpdateMessageStatistics(conn);
|
||||||
perf.End();
|
perf.End();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -458,13 +454,8 @@ LEFT JOIN replied_to rt ON m.message_id = rt.message_id" + filter.GenerateWhereC
|
|||||||
Statistics.TotalUsers = conn.SelectScalar("SELECT COUNT(*) FROM users") as long? ?? 0;
|
Statistics.TotalUsers = conn.SelectScalar("SELECT COUNT(*) FROM users") as long? ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private long ComputeMessageStatistics(CancellationToken token) {
|
private void UpdateMessageStatistics(ISqliteConnection conn) {
|
||||||
using var conn = pool.Take();
|
Statistics.TotalMessages = conn.SelectScalar("SELECT COUNT(*) FROM messages") as long? ?? 0L;
|
||||||
return conn.SelectScalar("SELECT COUNT(*) FROM messages") as long? ?? 0L;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateMessageStatistics(long totalMessages) {
|
|
||||||
Statistics.TotalMessages = totalMessages;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
54
app/Server/Database/Sqlite/SqliteMessageStatisticsThread.cs
Normal file
54
app/Server/Database/Sqlite/SqliteMessageStatisticsThread.cs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
|
|
||||||
|
namespace DHT.Server.Database.Sqlite {
|
||||||
|
sealed class SqliteMessageStatisticsThread : IDisposable {
|
||||||
|
private readonly SqliteConnectionPool pool;
|
||||||
|
private readonly Action<ISqliteConnection> action;
|
||||||
|
|
||||||
|
private readonly CancellationTokenSource cancellationTokenSource = new();
|
||||||
|
private readonly CancellationToken cancellationToken;
|
||||||
|
|
||||||
|
private readonly AutoResetEvent requestEvent = new (false);
|
||||||
|
|
||||||
|
public SqliteMessageStatisticsThread(SqliteConnectionPool pool, Action<ISqliteConnection> action) {
|
||||||
|
this.pool = pool;
|
||||||
|
this.action = action;
|
||||||
|
|
||||||
|
this.cancellationToken = cancellationTokenSource.Token;
|
||||||
|
|
||||||
|
var thread = new Thread(RunThread) {
|
||||||
|
Name = "DHT message statistics thread",
|
||||||
|
IsBackground = true
|
||||||
|
};
|
||||||
|
thread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
try {
|
||||||
|
cancellationTokenSource.Cancel();
|
||||||
|
} catch (ObjectDisposedException) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestUpdate() {
|
||||||
|
try {
|
||||||
|
requestEvent.Set();
|
||||||
|
} catch (ObjectDisposedException) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RunThread() {
|
||||||
|
try {
|
||||||
|
while (!cancellationToken.IsCancellationRequested) {
|
||||||
|
if (requestEvent.WaitOne(TimeSpan.FromMilliseconds(100))) {
|
||||||
|
using var conn = pool.Take();
|
||||||
|
action(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cancellationTokenSource.Dispose();
|
||||||
|
requestEvent.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,115 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace DHT.Utils.Tasks {
|
|
||||||
public sealed class AsyncValueComputer<TValue> {
|
|
||||||
private readonly Action<TValue> resultProcessor;
|
|
||||||
private readonly TaskScheduler resultTaskScheduler;
|
|
||||||
private readonly bool processOutdatedResults;
|
|
||||||
|
|
||||||
private readonly object stateLock = new ();
|
|
||||||
|
|
||||||
private CancellationTokenSource? currentCancellationTokenSource;
|
|
||||||
private Func<CancellationToken, TValue>? currentComputeFunction;
|
|
||||||
private bool hasComputeFunctionChanged = false;
|
|
||||||
|
|
||||||
private AsyncValueComputer(Action<TValue> resultProcessor, TaskScheduler resultTaskScheduler, bool processOutdatedResults) {
|
|
||||||
this.resultProcessor = resultProcessor;
|
|
||||||
this.resultTaskScheduler = resultTaskScheduler;
|
|
||||||
this.processOutdatedResults = processOutdatedResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Compute(Func<CancellationToken, TValue> func) {
|
|
||||||
lock (stateLock) {
|
|
||||||
if (currentComputeFunction != null) {
|
|
||||||
currentComputeFunction = func;
|
|
||||||
hasComputeFunctionChanged = true;
|
|
||||||
currentCancellationTokenSource?.Cancel();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
EnqueueComputation(func);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MethodSupportsCancellation")]
|
|
||||||
private void EnqueueComputation(Func<CancellationToken, TValue> func) {
|
|
||||||
var cancellationTokenSource = new CancellationTokenSource();
|
|
||||||
var cancellationToken = cancellationTokenSource.Token;
|
|
||||||
|
|
||||||
currentCancellationTokenSource?.Cancel();
|
|
||||||
currentCancellationTokenSource = cancellationTokenSource;
|
|
||||||
currentComputeFunction = func;
|
|
||||||
hasComputeFunctionChanged = false;
|
|
||||||
|
|
||||||
var task = Task.Run(() => func(cancellationToken));
|
|
||||||
|
|
||||||
task.ContinueWith(t => {
|
|
||||||
if (processOutdatedResults || !cancellationToken.IsCancellationRequested) {
|
|
||||||
resultProcessor(t.Result);
|
|
||||||
}
|
|
||||||
}, CancellationToken.None, TaskContinuationOptions.NotOnFaulted, resultTaskScheduler);
|
|
||||||
|
|
||||||
task.ContinueWith(_ => {
|
|
||||||
lock (stateLock) {
|
|
||||||
cancellationTokenSource.Dispose();
|
|
||||||
|
|
||||||
if (currentCancellationTokenSource == cancellationTokenSource) {
|
|
||||||
currentCancellationTokenSource = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasComputeFunctionChanged) {
|
|
||||||
EnqueueComputation(currentComputeFunction);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
currentComputeFunction = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class Single {
|
|
||||||
private readonly AsyncValueComputer<TValue> baseComputer;
|
|
||||||
private readonly Func<CancellationToken, TValue> resultComputer;
|
|
||||||
|
|
||||||
internal Single(AsyncValueComputer<TValue> baseComputer, Func<CancellationToken, TValue> resultComputer) {
|
|
||||||
this.baseComputer = baseComputer;
|
|
||||||
this.resultComputer = resultComputer;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Recompute() {
|
|
||||||
baseComputer.Compute(resultComputer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Builder WithResultProcessor(Action<TValue> resultProcessor, TaskScheduler? scheduler = null) {
|
|
||||||
return new Builder(resultProcessor, scheduler ?? TaskScheduler.FromCurrentSynchronizationContext());
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class Builder {
|
|
||||||
private readonly Action<TValue> resultProcessor;
|
|
||||||
private readonly TaskScheduler resultTaskScheduler;
|
|
||||||
private bool processOutdatedResults;
|
|
||||||
|
|
||||||
internal Builder(Action<TValue> resultProcessor, TaskScheduler resultTaskScheduler) {
|
|
||||||
this.resultProcessor = resultProcessor;
|
|
||||||
this.resultTaskScheduler = resultTaskScheduler;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder WithOutdatedResults() {
|
|
||||||
this.processOutdatedResults = true;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AsyncValueComputer<TValue> Build() {
|
|
||||||
return new AsyncValueComputer<TValue>(resultProcessor, resultTaskScheduler, processOutdatedResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Single BuildWithComputer(Func<CancellationToken, TValue> resultComputer) {
|
|
||||||
return new Single(Build(), resultComputer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -53,11 +53,11 @@ define('LATEST_VERSION', $version === false ? '_' : $version);
|
|||||||
<svg class="icon">
|
<svg class="icon">
|
||||||
<use href="#icon-globe" />
|
<use href="#icon-globe" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="platform">Other</span>
|
<span class="platform">Portable</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p>To launch the three OS-specific versions, extract the <strong>DiscordHistoryTracker</strong> executable, and double-click it.</p>
|
<p>To launch the three OS-specific versions, extract the <strong>DiscordHistoryTracker</strong> executable, and double-click it.</p>
|
||||||
<p>To launch the <strong>Other</strong> version, which works on other operating systems including 32-bit versions, you must install <a href="https://dotnet.microsoft.com/download/dotnet/5.0" rel="nofollow noopener">ASP.NET Core Runtime 5</a>. Then extract the downloaded archive into a folder, open the folder in a terminal, and type: <code>dotnet DiscordHistoryTracker.dll</code></p>
|
<p>To launch the <strong>Portable</strong> version, which works on other operating systems including 32-bit versions, you must install <a href="https://dotnet.microsoft.com/download/dotnet/5.0/runtime" rel="nofollow noopener">.NET 5</a>. Then extract the downloaded archive into a folder, open the folder in a terminal, and type: <code>dotnet DiscordHistoryTracker.dll</code></p>
|
||||||
|
|
||||||
<h3>How to Track Messages</h3>
|
<h3>How to Track Messages</h3>
|
||||||
<p>The app saves messages into a database file stored on your computer. When you open the app, you are given the option to create a new database file, or open an existing one.</p>
|
<p>The app saves messages into a database file stored on your computer. When you open the app, you are given the option to create a new database file, or open an existing one.</p>
|
||||||
|
Loading…
Reference in New Issue
Block a user