From 92c997513b12fbc7594b84a6e03e7ff296da060f Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Sun, 20 Sep 2026 17:02:37 +0200 Subject: [PATCH] (feature): paint first sync incrementally and bind rag to the discrete gpu --- CHANGELOG.md | 23 ++ data/eu.postclient.desktop.metainfo.xml | 5 + src/Directory.Build.props | 2 +- src/MaksIT.PostClient.Client/GpuProbe.cs | 151 +++++++++++ .../ISemanticSearchService.cs | 2 + .../ImapMailSession.cs | 88 ++++++- .../LocalStoreSession.cs | 17 +- .../OnnxGemmaEmbedder.cs | 65 ++++- .../Pop3MailSession.cs | 73 ++++-- .../SemanticSearchService.cs | 80 ++++-- src/MaksIT.PostClient.Shared/AppLog.cs | 26 ++ src/MaksIT.PostClient.Shared/AppPaths.cs | 68 ++++- .../MailArchiveCatalog.cs | 68 ++++- .../MailArchiveStore.cs | 65 ++++- .../MailIndexProgress.cs | 5 + .../MailListBudget.cs | 18 ++ .../MailboxAccount.cs | 2 + src/MaksIT.PostClient.Shared/UiCopy.cs | 51 ++++ src/MaksIT.PostClient.Shared/UiCopy.de.cs | 4 + src/MaksIT.PostClient.Shared/UiCopy.es.cs | 4 + src/MaksIT.PostClient.Shared/UiCopy.fr.cs | 4 + src/MaksIT.PostClient.Tests/ArchiveTests.cs | 44 ++++ src/MaksIT.PostClient.Tests/EnvelopeTests.cs | 7 + .../SemanticSearchTests.cs | 32 +++ src/MaksIT.PostClient.Tests/SharedTests.cs | 36 ++- .../StoreAndHubTests.cs | 2 +- src/MaksIT.PostClient.UI/MainWindow.axaml | 26 +- src/MaksIT.PostClient.UI/MainWindow.axaml.cs | 7 + .../ViewModels/LogViewModel.cs | 78 ++++++ .../ViewModels/MainViewModel.cs | 242 ++++++++++++++---- .../ViewModels/RowViewModels.cs | 10 + .../Windows/AboutWindow.axaml.cs | 2 +- .../Windows/AccountSettingsWindow.axaml | 2 +- .../Windows/FeatureSettingsWindow.axaml | 2 +- .../Windows/LogWindow.axaml | 46 ++++ .../Windows/LogWindow.axaml.cs | 39 +++ .../Windows/MessageWindow.axaml | 7 +- .../Windows/RetentionWindow.axaml | 2 +- .../Windows/RulesWindow.axaml | 2 +- .../Windows/SemanticSearchWindow.axaml | 2 +- 40 files changed, 1261 insertions(+), 148 deletions(-) create mode 100644 src/MaksIT.PostClient.Shared/AppLog.cs create mode 100644 src/MaksIT.PostClient.Shared/MailListBudget.cs create mode 100644 src/MaksIT.PostClient.UI/ViewModels/LogViewModel.cs create mode 100644 src/MaksIT.PostClient.UI/Windows/LogWindow.axaml create mode 100644 src/MaksIT.PostClient.UI/Windows/LogWindow.axaml.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ed13d6c..23c3e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic ## [Unreleased] +## [0.3.3] - 2026-09-20 + +First sync paints mail as it arrives, meaning search can use a discrete GPU, and trash, retention, and logs follow typical desktop mail apps. + +### Added + +- **Help → Logs** opens a copyable window over the app log folder. + +### Changed + +- First mailbox sync paints the message list as header batches arrive (newest first). Keyword and meaning indexes wait until the first full folder catalog finishes. +- Default user data is `%LocalAppData%\MaksIT\Postclient`. Existing `%AppData%\Postclient` / `%LocalAppData%\Postclient` trees are moved on first launch. +- Status bar index lines show scanned / total and hide when idle. GPU init failures open the copyable error window and fall back to CPU in Auto. +- Meaning search DirectML prefers a discrete GPU (NVIDIA GTX 1660 Ti and similar) instead of always DXGI adapter 0, and uses sequential DirectML session options. +- Trash retention counts from the move-to-trash time. Folder tree shows retention days next to unread when days are greater than zero. +- Window titles drop menu mnemonics and trailing ellipses. + +### Fixed + +- Select-all delete in Trash, folder context commands while a huge first sync is painting, and Empty on Trash labeled **Delete all items**. +- Retention list no longer keeps Gmail labels that were deleted on the server. +- FatturaPA toolbar kind button matches the View menu. Source view selection no longer wraps the full raw EML. + ## [0.3.2] - 2026-09-19 Expired folder mail goes to Trash; Trash can be kept forever or purged after a number of days. diff --git a/data/eu.postclient.desktop.metainfo.xml b/data/eu.postclient.desktop.metainfo.xml index 1d4e1e5..547ab90 100644 --- a/data/eu.postclient.desktop.metainfo.xml +++ b/data/eu.postclient.desktop.metainfo.xml @@ -30,6 +30,11 @@ 768 + + +

First sync paints mail as it arrives. Meaning search DirectML prefers a discrete GPU. Trash retention, logs, and folder menus match typical desktop mail apps.

+
+

Retention moves expired folder mail to Trash. Trash can be kept forever or purged after a number of days. Delete sends messages to Trash.

diff --git a/src/Directory.Build.props b/src/Directory.Build.props index f50ee0b..fac645a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.3.2 + 0.3.3 Postclient Postclient Maksym Sadovnychyy diff --git a/src/MaksIT.PostClient.Client/GpuProbe.cs b/src/MaksIT.PostClient.Client/GpuProbe.cs index 13a4505..34a41cb 100644 --- a/src/MaksIT.PostClient.Client/GpuProbe.cs +++ b/src/MaksIT.PostClient.Client/GpuProbe.cs @@ -1,10 +1,26 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.Client; +public readonly record struct GpuAdapter( + int DeviceId, + string Name, + uint VendorId, + long DedicatedBytes, + bool Software); + + public static class GpuProbe { + private const uint Nvidia = 0x10DE; + private const uint Amd = 0x1002; + private const uint Intel = 0x8086; + private const uint Microsoft = 0x1414; + private const uint DxgiAdapterFlagSoftware = 2; + public static bool SupportsBoost => OperatingSystem.IsWindows(); @@ -16,4 +32,139 @@ public static class GpuProbe { return false; return kind is SemanticDevice.Gpu or SemanticDevice.Auto; } + + public static IReadOnlyList DirectMlAdapters() { + if (!OperatingSystem.IsWindows()) + return []; + + try { + var preferred = PreferForDirectMl(ListFromDxgi()); + if (preferred.Count > 0) + return preferred; + } + catch { + } + + return [ + new GpuAdapter(0, "DXGI 0", 0, 0, false), + new GpuAdapter(1, "DXGI 1", 0, 0, false), + new GpuAdapter(2, "DXGI 2", 0, 0, false), + new GpuAdapter(3, "DXGI 3", 0, 0, false) + ]; + } + + public static IReadOnlyList PreferForDirectMl(IEnumerable adapters) { + ArgumentNullException.ThrowIfNull(adapters); + return adapters + .Where(static adapter => !adapter.Software && !IsSoftwareAdapter(adapter.Name, 0, adapter.VendorId)) + .OrderBy(Rank) + .ThenByDescending(static adapter => adapter.DedicatedBytes) + .ThenBy(static adapter => adapter.DeviceId) + .DistinctBy(static adapter => (adapter.Name, adapter.VendorId, adapter.DedicatedBytes)) + .ToArray(); + } + + public static bool IsSoftwareAdapter(string? name, uint flags, uint vendorId) { + if ((flags & DxgiAdapterFlagSoftware) != 0) + return true; + if (vendorId == Microsoft) + return true; + if (string.IsNullOrWhiteSpace(name)) + return false; + return name.Contains("Basic Render", StringComparison.OrdinalIgnoreCase) + || name.Contains("Remote Display Adapter", StringComparison.OrdinalIgnoreCase); + } + + private static int Rank(GpuAdapter adapter) { + if (adapter.VendorId == Nvidia) + return 0; + if (adapter.VendorId == Amd) + return 1; + if (adapter.VendorId == Intel) + return 2; + return 3; + } + + [SupportedOSPlatform("windows")] + private static List ListFromDxgi() { + var iid = new Guid("770aae78-f26f-4dba-a829-253c83d1b387"); + var hr = Native.CreateDXGIFactory1(ref iid, out var factory); + if (hr < 0 || factory == IntPtr.Zero) + throw new InvalidOperationException($"DXGI factory failed ({hr:X8})."); + + try { + var enumAdapters = Native.Vtbl(factory, 12); + var adapters = new List(); + for (uint index = 0; index < 16; index++) { + hr = enumAdapters(factory, index, out var adapter); + if (hr < 0 || adapter == IntPtr.Zero) + break; + + try { + var getDesc = Native.Vtbl(adapter, 10); + var descPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); + try { + hr = getDesc(adapter, descPtr); + if (hr < 0) + continue; + var desc = Marshal.PtrToStructure(descPtr); + var software = IsSoftwareAdapter(desc.Description, desc.Flags, desc.VendorId); + adapters.Add( + new GpuAdapter( + (int)index, + string.IsNullOrWhiteSpace(desc.Description) ? "DXGI " + index : desc.Description.Trim(), + desc.VendorId, + desc.DedicatedVideoMemory.ToInt64(), + software)); + } + finally { + Marshal.FreeHGlobal(descPtr); + } + } + finally { + Native.Vtbl(adapter, 2)(adapter); + } + } + + return adapters; + } + finally { + Native.Vtbl(factory, 2)(factory); + } + } + + private static class Native { + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + public delegate int EnumAdapters1(IntPtr factory, uint index, out IntPtr adapter); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + public delegate int GetDesc1(IntPtr adapter, IntPtr desc); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + public delegate uint Release(IntPtr unknown); + + [DllImport("dxgi.dll", ExactSpelling = true)] + public static extern int CreateDXGIFactory1(ref Guid riid, out IntPtr factory); + + public static T Vtbl(IntPtr com, int index) where T : Delegate { + var table = Marshal.ReadIntPtr(com); + return Marshal.GetDelegateForFunctionPointer(Marshal.ReadIntPtr(table, index * IntPtr.Size)); + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct AdapterDesc1 { + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string Description; + public uint VendorId; + public uint DeviceId; + public uint SubSysId; + public uint Revision; + public IntPtr DedicatedVideoMemory; + public IntPtr DedicatedSystemMemory; + public IntPtr SharedSystemMemory; + public uint LuidLow; + public int LuidHigh; + public uint Flags; + } + } } diff --git a/src/MaksIT.PostClient.Client/ISemanticSearchService.cs b/src/MaksIT.PostClient.Client/ISemanticSearchService.cs index 587faea..d522fa7 100644 --- a/src/MaksIT.PostClient.Client/ISemanticSearchService.cs +++ b/src/MaksIT.PostClient.Client/ISemanticSearchService.cs @@ -13,6 +13,8 @@ public interface ISemanticSearchService : IDisposable { event Action? Changed; + event Action? Faulted; + void Start(); void NotifySettingsChanged(); diff --git a/src/MaksIT.PostClient.Client/ImapMailSession.cs b/src/MaksIT.PostClient.Client/ImapMailSession.cs index db15e1c..5752947 100644 --- a/src/MaksIT.PostClient.Client/ImapMailSession.cs +++ b/src/MaksIT.PostClient.Client/ImapMailSession.cs @@ -16,6 +16,8 @@ public sealed class ImapMailSession : IMailSession { private ImapClient? _imap; private MailboxAccount? _account; private MailAuthMaterial? _material; + private readonly Dictionary _listOffset = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _listedUids = new(StringComparer.OrdinalIgnoreCase); private const MessageSummaryItems FlagItems = MessageSummaryItems.UniqueId | MessageSummaryItems.Flags; @@ -107,15 +109,14 @@ public sealed class ImapMailSession : IMailSession { string folder, IReadOnlySet? knownIds = null, CancellationToken cancellationToken = default, - int itemBudget = 0) { - _ = itemBudget; - return _io.RunAsync(() => ListMessagesCoreAsync(folder, knownIds, cancellationToken), cancellationToken); - } + int itemBudget = 0) => + _io.RunAsync(() => ListMessagesCoreAsync(folder, knownIds, cancellationToken, itemBudget), cancellationToken); private async Task> ListMessagesCoreAsync( string folder, IReadOnlySet? knownIds, - CancellationToken cancellationToken) { + CancellationToken cancellationToken, + int itemBudget) { var client = RequireImap(); if (client is null) return Result.UnprocessableEntity(null, "Not connected."); @@ -130,6 +131,9 @@ public sealed class ImapMailSession : IMailSession { var extraHeaders = ImapFetchPolicy.ExtraHeaders(AccountProvider()); var known = knownIds ?? (IReadOnlySet)new HashSet(); + var chunk = itemBudget > 0; + if (!chunk) + ClearListCursor(folder); if (known.Count > 0 && known.Count >= imapFolder.Count / 2) { var flagRows = await FetchSummariesAsync( imapFolder, @@ -141,33 +145,82 @@ public sealed class ImapMailSession : IMailSession { var flags = FlagsOf(flagRows); var unknown = flagRows .Where(s => s.UniqueId.IsValid && !known.Contains(s.UniqueId.Id)) + .OrderByDescending(s => s.UniqueId.Id) .Select(s => s.UniqueId) .ToList(); if (unknown.Count == 0) return Result.Ok(new MailFolderSync { Flags = flags, Present = present }); - var added = await FetchByUidsAsync(imapFolder, unknown, extraHeaders, cancellationToken) + var take = unknown; + var incomplete = false; + if (chunk && unknown.Count > itemBudget) { + take = unknown.Take(itemBudget).ToList(); + incomplete = true; + } + + var added = await FetchByUidsAsync(imapFolder, take, extraHeaders, cancellationToken) .ConfigureAwait(false); return Result.Ok(new MailFolderSync { Headers = HeadersOf(folder, added), Flags = flags, - Present = present + Present = incomplete ? null : present, + Incomplete = incomplete }); } } - var summaries = await FetchSummariesAsync( + if (!chunk) { + var summaries = await FetchSummariesAsync( + imapFolder, + ListItems, + extraHeaders, + cancellationToken).ConfigureAwait(false); + var headers = HeadersOf(folder, summaries); + var ids = IdsOf(summaries); + if (ids.Count == 0 && imapFolder.Count > 0) + return Result.Ok(new MailFolderSync { Headers = headers, Present = null }); + return Result.Ok(new MailFolderSync { + Headers = headers, + Present = ids + }); + } + + int offset; + HashSet listed; + lock (_gate) { + _listOffset.TryGetValue(folder, out offset); + if (!_listedUids.TryGetValue(folder, out listed!)) { + listed = []; + _listedUids[folder] = listed; + } + } + + var range = MailListBudget.NewestRange(imapFolder.Count, offset, itemBudget); + if (range.Take <= 0) { + ClearListCursor(folder); + return Result.Ok(new MailFolderSync { Present = listed.ToList() }); + } + + var chunkRows = await FetchRangeAsync( imapFolder, + range.Start, + range.End, ListItems, extraHeaders, cancellationToken).ConfigureAwait(false); - var headers = HeadersOf(folder, summaries); - var ids = IdsOf(summaries); - if (ids.Count == 0 && imapFolder.Count > 0) - return Result.Ok(new MailFolderSync { Headers = headers, Present = null }); + foreach (var id in IdsOf(chunkRows)) + listed.Add(id); + lock (_gate) { + if (range.Incomplete) + _listOffset[folder] = range.NextOffset; + else + ClearListCursor(folder); + } + return Result.Ok(new MailFolderSync { - Headers = headers, - Present = ids + Headers = HeadersOf(folder, chunkRows), + Present = range.Incomplete ? null : listed.ToList(), + Incomplete = range.Incomplete }); } catch (Exception ex) { @@ -175,6 +228,13 @@ public sealed class ImapMailSession : IMailSession { } } + private void ClearListCursor(string folder) { + lock (_gate) { + _listOffset.Remove(folder); + _listedUids.Remove(folder); + } + } + public Task> GetMessageAsync( string folder, uint id, diff --git a/src/MaksIT.PostClient.Client/LocalStoreSession.cs b/src/MaksIT.PostClient.Client/LocalStoreSession.cs index c7a4f5a..cbc056c 100644 --- a/src/MaksIT.PostClient.Client/LocalStoreSession.cs +++ b/src/MaksIT.PostClient.Client/LocalStoreSession.cs @@ -200,11 +200,18 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore { IReadOnlyList ids, MailFlagUpdate update, CancellationToken cancellationToken = default) { - _ = folder; - _ = ids; - _ = update; - _ = cancellationToken; - return Task.FromResult(Result.Ok()); + if (update.Deleted is not true) + return Task.FromResult(Result.Ok()); + return _io.RunAsync(() => { + foreach (var id in ids) { + cancellationToken.ThrowIfCancellationRequested(); + var path = MessagePath(folder, id); + if (File.Exists(path)) + File.Delete(path); + } + + return Task.FromResult(Result.Ok()); + }, cancellationToken); } public Task CreateFolderAsync( diff --git a/src/MaksIT.PostClient.Client/OnnxGemmaEmbedder.cs b/src/MaksIT.PostClient.Client/OnnxGemmaEmbedder.cs index 9d87963..249d73f 100644 --- a/src/MaksIT.PostClient.Client/OnnxGemmaEmbedder.cs +++ b/src/MaksIT.PostClient.Client/OnnxGemmaEmbedder.cs @@ -1,6 +1,8 @@ +using System.Reflection; +using System.Diagnostics.CodeAnalysis; +using Microsoft.ML.Tokenizers; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; -using Microsoft.ML.Tokenizers; using MaksIT.PostClient.Shared; @@ -22,17 +24,14 @@ public sealed class OnnxGemmaEmbedder : ITextEmbedder { ArgumentException.ThrowIfNullOrWhiteSpace(tokenizerPath); using var stream = File.OpenRead(tokenizerPath); _tokenizer = LlamaTokenizer.Create(stream, addBeginOfSentence: true, addEndOfSentence: true); - var options = new SessionOptions(); UsesGpu = false; - if (useGpu) - UsesGpu = TryDirectMl(options); - try { - options.AppendExecutionProvider_CPU(); + if (useGpu && TryCreateGpuSession(onnxPath, out var gpu)) { + _session = gpu; + UsesGpu = true; } - catch { + else { + _session = CreateCpuSession(onnxPath); } - - _session = new InferenceSession(onnxPath, options); _inputIds = PickInput(_session, "input_ids", "input"); _attentionMask = PickInput(_session, "attention_mask", "mask"); _tokenTypeIds = FindInput(_session, "token_type_ids"); @@ -84,19 +83,59 @@ public sealed class OnnxGemmaEmbedder : ITextEmbedder { public void Dispose() => _session.Dispose(); - private static bool TryDirectMl(SessionOptions options) { + private static bool TryCreateGpuSession(string onnxPath, [NotNullWhen(true)] out InferenceSession? session) { + session = null; var method = typeof(SessionOptions).GetMethod("AppendExecutionProvider_DML", [typeof(int)]); if (method is null) return false; + + Exception? last = null; + foreach (var adapter in GpuProbe.DirectMlAdapters()) { + var options = DmlSessionOptions(); + try { + method.Invoke(options, [adapter.DeviceId]); + TryAppendCpu(options); + session = new InferenceSession(onnxPath, options); + AppLog.Write($"Meaning index DirectML: {adapter.Name} (device {adapter.DeviceId})"); + return true; + } + catch (Exception ex) { + options.Dispose(); + last = Unwrap(ex); + AppLog.Write($"Meaning index DirectML skipped {adapter.Name} (device {adapter.DeviceId}): {last.Message}"); + } + } + + if (last is not null) + throw last; + return false; + } + + private static InferenceSession CreateCpuSession(string onnxPath) { + var options = new SessionOptions(); + TryAppendCpu(options); + return new InferenceSession(onnxPath, options); + } + + private static SessionOptions DmlSessionOptions() => + new() { + GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL, + ExecutionMode = ExecutionMode.ORT_SEQUENTIAL, + EnableMemoryPattern = false, + EnableCpuMemArena = false + }; + + private static void TryAppendCpu(SessionOptions options) { try { - method.Invoke(options, [0]); - return true; + options.AppendExecutionProvider_CPU(); } catch { - return false; } } + private static Exception Unwrap(Exception exception) => + exception is TargetInvocationException { InnerException: { } inner } ? inner : exception; + private static string PickInput(InferenceSession session, params string[] names) { foreach (var name in names) { foreach (var key in session.InputMetadata.Keys) { diff --git a/src/MaksIT.PostClient.Client/Pop3MailSession.cs b/src/MaksIT.PostClient.Client/Pop3MailSession.cs index 49a6ed3..0917d5a 100644 --- a/src/MaksIT.PostClient.Client/Pop3MailSession.cs +++ b/src/MaksIT.PostClient.Client/Pop3MailSession.cs @@ -16,6 +16,8 @@ public sealed class Pop3MailSession : IMailSession { private Pop3Client? _pop; private MailboxAccount? _account; private MailAuthMaterial? _material; + private int _listOffset; + private readonly HashSet _listedIds = []; public Pop3MailSession(IMailAuthService auth) { _auth = auth; @@ -84,36 +86,77 @@ public sealed class Pop3MailSession : IMailSession { string folder, IReadOnlySet? knownIds = null, CancellationToken cancellationToken = default, - int itemBudget = 0) { - _ = itemBudget; - return _io.RunAsync(() => ListMessagesCoreAsync(folder, knownIds, cancellationToken), cancellationToken); - } + int itemBudget = 0) => + _io.RunAsync(() => ListMessagesCoreAsync(folder, knownIds, cancellationToken, itemBudget), cancellationToken); private async Task> ListMessagesCoreAsync( string folder, IReadOnlySet? knownIds, - CancellationToken cancellationToken) { + CancellationToken cancellationToken, + int itemBudget) { var client = RequirePop(); if (client is null) return Result.UnprocessableEntity(null, "Not connected."); _ = folder; - _ = knownIds; + var known = knownIds ?? (IReadOnlySet)new HashSet(); + var chunk = itemBudget > 0; + if (!chunk) { + _listOffset = 0; + _listedIds.Clear(); + } try { var count = client.Count; if (count == 0) return Result.Ok(new MailFolderSync { Present = [] }); - var indexes = Enumerable.Range(0, count).ToList(); - var headers = await client.GetMessageHeadersAsync(indexes, cancellationToken).ConfigureAwait(false); - var rows = indexes - .Select((index, i) => ToHeader(index, i < headers.Count ? headers[i] : new HeaderList())) - .OrderByDescending(h => h.Date) - .ThenByDescending(h => h.Id) - .ToList(); + if (!chunk) { + var indexes = Enumerable.Range(0, count).ToList(); + var headers = await client.GetMessageHeadersAsync(indexes, cancellationToken).ConfigureAwait(false); + var rows = indexes + .Select((index, i) => ToHeader(index, i < headers.Count ? headers[i] : new HeaderList())) + .OrderByDescending(h => h.Date) + .ThenByDescending(h => h.Id) + .ToList(); + return Result.Ok(new MailFolderSync { + Headers = rows, + Present = rows.Select(h => h.Id).ToList() + }); + } + + var range = MailListBudget.NewestRange(count, _listOffset, itemBudget); + if (range.Take <= 0) { + var present = _listedIds.ToList(); + _listOffset = 0; + _listedIds.Clear(); + return Result.Ok(new MailFolderSync { Present = present }); + } + + var slice = Enumerable.Range(range.Start, range.Take).ToList(); + var chunkHeaders = await client.GetMessageHeadersAsync(slice, cancellationToken).ConfigureAwait(false); + var added = new List(); + for (var i = 0; i < slice.Count; i++) { + var id = (uint)slice[i]; + _listedIds.Add(id); + if (known.Contains(id)) + continue; + added.Add(ToHeader(slice[i], i < chunkHeaders.Count ? chunkHeaders[i] : new HeaderList())); + } + + _listOffset = range.NextOffset; + if (!range.Incomplete) { + var present = _listedIds.ToList(); + _listOffset = 0; + _listedIds.Clear(); + return Result.Ok(new MailFolderSync { + Headers = added, + Present = present + }); + } + return Result.Ok(new MailFolderSync { - Headers = rows, - Present = rows.Select(h => h.Id).ToList() + Headers = added, + Incomplete = true }); } catch (Exception ex) { diff --git a/src/MaksIT.PostClient.Client/SemanticSearchService.cs b/src/MaksIT.PostClient.Client/SemanticSearchService.cs index c1fdfee..bebb58b 100644 --- a/src/MaksIT.PostClient.Client/SemanticSearchService.cs +++ b/src/MaksIT.PostClient.Client/SemanticSearchService.cs @@ -14,6 +14,7 @@ public sealed class SemanticSearchService : ISemanticSearchService { private CancellationTokenSource? _step; private ITextEmbedder? _embedder; private bool _gpu; + private bool _gpuFailed; private string _status = ""; private bool _ready; @@ -46,6 +47,8 @@ public sealed class SemanticSearchService : ISemanticSearchService { public event Action? Changed; + public event Action? Faulted; + public void NotifySettingsChanged() { _step?.Cancel(); try { @@ -128,7 +131,8 @@ public sealed class SemanticSearchService : ISemanticSearchService { break; } catch (Exception ex) { - SetStatus(ex.Message, ready: false); + AppLog.Write(ex); + SetStatus("", ready: false); } } } @@ -165,7 +169,8 @@ public sealed class SemanticSearchService : ISemanticSearchService { var wantGpu = GpuProbe.UseGpu(settings.Device); if (!EnsureEmbedder(wantGpu, out var error)) { - SetStatus(error, ready: false); + AppLog.Write(error); + SetStatus("", ready: false); await DelayAsync(TimeSpan.FromSeconds(30), token).ConfigureAwait(false); return; } @@ -175,15 +180,23 @@ public sealed class SemanticSearchService : ISemanticSearchService { settings = _files.Current.Semantic ?? settings; if (!settings.Enabled) return; - var pending = _archive.PendingEmbeddings(EmbeddingModelSpec.Id, 8); + var ready = ReadyMailboxIds(); + if (ready.Count == 0) { + SetStatus("", ready: true); + await WaitAsync(token).ConfigureAwait(false); + return; + } + + var pending = _archive.PendingEmbeddings(EmbeddingModelSpec.Id, 8, ready); if (pending.Count == 0) { SetStatus("", ready: true); await WaitAsync(token).ConfigureAwait(false); return; } - var left = _archive.EmbeddingPendingCount(EmbeddingModelSpec.Id); - SetStatus(MailIndexProgress.MeaningLeft(left), ready: true); + var left = _archive.EmbeddingPendingCount(EmbeddingModelSpec.Id, ready); + var done = _archive.EmbeddingCount(EmbeddingModelSpec.Id, ready); + SetStatus(MailIndexProgress.MeaningLine(done, done + left), ready: true); foreach (var item in pending) { token.ThrowIfCancellationRequested(); ITextEmbedder embedder; @@ -195,7 +208,9 @@ public sealed class SemanticSearchService : ISemanticSearchService { vector = embedder.Embed(text); } catch (Exception ex) { - SetStatus(ex.Message, ready: false); + AppLog.Write(ex); + Faulted?.Invoke(ex); + SetStatus("", ready: false); await DelayAsync(TimeSpan.FromSeconds(15), token).ConfigureAwait(false); return; } @@ -207,6 +222,8 @@ public sealed class SemanticSearchService : ISemanticSearchService { private bool EnsureEmbedder(bool wantGpu, out string error) { error = ""; + if (wantGpu && _gpuFailed) + wantGpu = false; lock (_gate) { if (_embedder is not null && _gpu == wantGpu) { _ready = true; @@ -220,16 +237,10 @@ public sealed class SemanticSearchService : ISemanticSearchService { } try { - var created = new OnnxGemmaEmbedder( - EmbeddingModelSpec.OnnxPath(), - EmbeddingModelSpec.TokenizerPath(), - wantGpu); - if (wantGpu && !created.UsesGpu && SemanticDevice.IsGpu(_files.Current.Semantic?.Device)) { + var created = CreateEmbedder(wantGpu); + if (wantGpu && !created.UsesGpu) { created.Dispose(); - created = new OnnxGemmaEmbedder( - EmbeddingModelSpec.OnnxPath(), - EmbeddingModelSpec.TokenizerPath(), - useGpu: false); + created = CreateEmbedder(false); } lock (_gate) { @@ -240,12 +251,51 @@ public sealed class SemanticSearchService : ISemanticSearchService { return true; } + catch (Exception ex) when (wantGpu) { + _gpuFailed = true; + AppLog.Write(ex); + Faulted?.Invoke(ex); + try { + var created = CreateEmbedder(false); + lock (_gate) { + _embedder = created; + _gpu = created.UsesGpu; + _ready = true; + } + + return true; + } + catch (Exception cpu) { + error = cpu.Message; + AppLog.Write(cpu); + Faulted?.Invoke(cpu); + return false; + } + } catch (Exception ex) { error = ex.Message; + AppLog.Write(ex); + Faulted?.Invoke(ex); return false; } } + private static OnnxGemmaEmbedder CreateEmbedder(bool useGpu) => + new( + EmbeddingModelSpec.OnnxPath(), + EmbeddingModelSpec.TokenizerPath(), + useGpu); + + private HashSet ReadyMailboxIds() { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var box in _files.Current.Mailboxes) { + if (box.InitialSyncCompleted) + ids.Add(box.Id); + } + + return ids; + } + private void CloseEmbedder() { lock (_gate) { _embedder?.Dispose(); diff --git a/src/MaksIT.PostClient.Shared/AppLog.cs b/src/MaksIT.PostClient.Shared/AppLog.cs new file mode 100644 index 0000000..8ea907e --- /dev/null +++ b/src/MaksIT.PostClient.Shared/AppLog.cs @@ -0,0 +1,26 @@ +namespace MaksIT.PostClient.Shared; + + +public static class AppLog { + private static readonly Lock Gate = new(); + + public static string FilePath() => + Path.Combine(AppPaths.LogsDirectory(), "app.log"); + + public static void Write(string message) { + try { + AppPaths.EnsureDirectories(); + var line = DateTimeOffset.UtcNow.ToString("u") + " " + (message ?? "").TrimEnd(); + lock (Gate) + File.AppendAllText(FilePath(), line + Environment.NewLine); + } + catch { + } + } + + public static void Write(Exception exception) { + if (exception is null) + return; + Write(ErrorReport.Format(exception)); + } +} diff --git a/src/MaksIT.PostClient.Shared/AppPaths.cs b/src/MaksIT.PostClient.Shared/AppPaths.cs index b78691c..f3bd492 100644 --- a/src/MaksIT.PostClient.Shared/AppPaths.cs +++ b/src/MaksIT.PostClient.Shared/AppPaths.cs @@ -2,13 +2,13 @@ namespace MaksIT.PostClient.Shared; /// -/// Placeholder product folder until the public sign is chosen. -/// Windows: %AppData%\Postclient / %LocalAppData%\Postclient. -/// Linux: XDG. macOS: Application Support. Not a MAKS-IT subfolder. +/// Windows: %LocalAppData%\MaksIT\Postclient for config and data. +/// Linux: XDG under a MaksIT parent. macOS: Application Support under MaksIT. /// public static class AppPaths { public const string ProductName = "Postclient"; public const string ProductId = "postclient"; + public const string BrandFolder = "MaksIT"; public const string ConfigEnv = "POSTCLIENT_CONFIG"; public const string DataEnv = "POSTCLIENT_DATA_DIR"; @@ -22,20 +22,21 @@ public static class AppPaths { return portable; if (OperatingSystem.IsWindows()) - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ProductName); + return WindowsProductDirectory(); if (OperatingSystem.IsMacOS()) { return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", + BrandFolder, ProductName); } var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); if (string.IsNullOrWhiteSpace(xdg)) xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); - return Path.Combine(xdg, ProductId); + return Path.Combine(xdg, BrandFolder, ProductId); } public static string DataDirectory() { @@ -48,7 +49,7 @@ public static class AppPaths { return Path.Combine(portable, "data"); if (OperatingSystem.IsWindows()) - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ProductName); + return WindowsProductDirectory(); if (OperatingSystem.IsMacOS()) return ConfigDirectory(); @@ -56,7 +57,7 @@ public static class AppPaths { var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); if (string.IsNullOrWhiteSpace(xdg)) xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share"); - return Path.Combine(xdg, ProductId); + return Path.Combine(xdg, BrandFolder, ProductId); } public static string SettingsFile() => @@ -127,6 +128,7 @@ public static class AppPaths { Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Logs", + BrandFolder, ProductName); } @@ -134,6 +136,7 @@ public static class AppPaths { } public static void EnsureDirectories() { + MigrateLegacyLayout(); Directory.CreateDirectory(ConfigDirectory()); Directory.CreateDirectory(DataDirectory()); Directory.CreateDirectory(ObjectsDirectory()); @@ -164,4 +167,55 @@ public static class AppPaths { return null; } } + + private static string WindowsProductDirectory() => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + BrandFolder, + ProductName); + + private static bool UsesDefaultUserLayout() { + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(ConfigEnv))) + return false; + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(DataEnv))) + return false; + return PortableRoot() is null; + } + + private static void MigrateLegacyLayout() { + if (!UsesDefaultUserLayout()) + return; + + try { + if (OperatingSystem.IsWindows()) + MigrateWindowsLayout(); + } + catch { + } + } + + private static void MigrateWindowsLayout() { + var dest = WindowsProductDirectory(); + var oldLocal = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + ProductName); + var oldRoam = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + ProductName); + if (!Directory.Exists(dest) && Directory.Exists(oldLocal)) + Directory.Move(oldLocal, dest); + Directory.CreateDirectory(dest); + CopyIfMissing(Path.Combine(oldRoam, "settings.json"), Path.Combine(dest, "settings.json")); + CopyIfMissing(Path.Combine(oldRoam, "secrets.bin"), Path.Combine(dest, "secrets.bin")); + CopyIfMissing(Path.Combine(oldRoam, "receipts.json"), Path.Combine(dest, "receipts.json")); + } + + private static void CopyIfMissing(string source, string dest) { + if (!File.Exists(source) || File.Exists(dest)) + return; + var dir = Path.GetDirectoryName(dest); + if (!string.IsNullOrWhiteSpace(dir)) + Directory.CreateDirectory(dir); + File.Copy(source, dest); + } } diff --git a/src/MaksIT.PostClient.Shared/MailArchiveCatalog.cs b/src/MaksIT.PostClient.Shared/MailArchiveCatalog.cs index a55c005..21d6990 100644 --- a/src/MaksIT.PostClient.Shared/MailArchiveCatalog.cs +++ b/src/MaksIT.PostClient.Shared/MailArchiveCatalog.cs @@ -94,6 +94,20 @@ public sealed class MailArchiveCatalog : IDisposable { return 0; }); + public void SetKeywordIndexEnabled(string mailboxId, bool enabled) => + OffUi(() => { + var store = TryGet(mailboxId); + if (store is not null) + store.KeywordIndexEnabled = enabled; + return 0; + }); + + public int MessageCount(string mailboxId) => + OffUi(() => TryGet(mailboxId)?.MessageCount(mailboxId) ?? 0); + + public int RebuildKeywordIndex(string mailboxId) => + OffUi(() => TryGet(mailboxId)?.RebuildKeywordIndex() ?? 0); + public void UpdateFlags( string mailboxId, string folder, @@ -114,14 +128,56 @@ public sealed class MailArchiveCatalog : IDisposable { return 0; }); - public IReadOnlyList PendingEmbeddings(string modelId, int limit) => - OffUi(() => Merge(store => store.PendingEmbeddings(modelId, limit))); + public IReadOnlyList PendingEmbeddings( + string modelId, + int limit, + IReadOnlyCollection? mailboxIds = null) => + OffUi(() => { + if (mailboxIds is { Count: 0 }) + return (IReadOnlyList)[]; + var rows = new List(); + foreach (var pair in _stores) { + if (mailboxIds is { Count: > 0 } + && !mailboxIds.Contains(pair.Key, StringComparer.OrdinalIgnoreCase)) + continue; + var take = Math.Max(0, limit - rows.Count); + if (take == 0) + break; + rows.AddRange(pair.Value.PendingEmbeddings(modelId, take)); + } - public int EmbeddingCount(string modelId) => - OffUi(() => Sum(store => store.EmbeddingCount(modelId))); + return rows; + }); - public int EmbeddingPendingCount(string modelId) => - OffUi(() => Sum(store => store.EmbeddingPendingCount(modelId))); + public int EmbeddingCount(string modelId, IReadOnlyCollection? mailboxIds = null) => + OffUi(() => { + var count = 0; + foreach (var pair in _stores) { + if (mailboxIds is { Count: > 0 } + && !mailboxIds.Contains(pair.Key, StringComparer.OrdinalIgnoreCase)) + continue; + if (mailboxIds is { Count: 0 }) + return 0; + count += pair.Value.EmbeddingCount(modelId); + } + + return count; + }); + + public int EmbeddingPendingCount(string modelId, IReadOnlyCollection? mailboxIds = null) => + OffUi(() => { + var count = 0; + foreach (var pair in _stores) { + if (mailboxIds is { Count: > 0 } + && !mailboxIds.Contains(pair.Key, StringComparer.OrdinalIgnoreCase)) + continue; + if (mailboxIds is { Count: 0 }) + return 0; + count += pair.Value.EmbeddingPendingCount(modelId); + } + + return count; + }); public void UpsertEmbedding(string mailboxId, long messageId, string modelId, float[] vector) => OffUi(() => { diff --git a/src/MaksIT.PostClient.Shared/MailArchiveStore.cs b/src/MaksIT.PostClient.Shared/MailArchiveStore.cs index ac86f2d..781757e 100644 --- a/src/MaksIT.PostClient.Shared/MailArchiveStore.cs +++ b/src/MaksIT.PostClient.Shared/MailArchiveStore.cs @@ -8,6 +8,8 @@ public sealed class MailArchiveStore : IDisposable { private readonly SqliteConnection _db; private readonly Lock _gate = new(); + public bool KeywordIndexEnabled { get; set; } = true; + public MailArchiveStore(string? path = null) { var file = string.IsNullOrWhiteSpace(path) ? AppPaths.ArchiveDatabase() : path; var dir = Path.GetDirectoryName(file); @@ -186,6 +188,7 @@ public sealed class MailArchiveStore : IDisposable { } public int RebuildKeywordIndex() { + KeywordIndexEnabled = true; lock (_gate) { using var tx = _db.BeginTransaction(); using (var del = _db.CreateCommand()) { @@ -757,13 +760,22 @@ public sealed class MailArchiveStore : IDisposable { } public IReadOnlyList UidsOlderThan(string mailboxId, string folder, DateTimeOffset cutoff) { + var trash = MailRetention.IsTrash(folder); lock (_gate) { using var cmd = _db.CreateCommand(); - cmd.CommandText = """ - SELECT uid FROM messages - WHERE mailbox_id = $m AND folder = $f AND date_utc <> '' AND date_utc < $cut - ORDER BY date_utc; - """; + cmd.CommandText = trash + ? """ + SELECT uid FROM messages + WHERE mailbox_id = $m AND folder = $f + AND COALESCE(NULLIF(trashed_utc, ''), date_utc) <> '' + AND COALESCE(NULLIF(trashed_utc, ''), date_utc) < $cut + ORDER BY COALESCE(NULLIF(trashed_utc, ''), date_utc); + """ + : """ + SELECT uid FROM messages + WHERE mailbox_id = $m AND folder = $f AND date_utc <> '' AND date_utc < $cut + ORDER BY date_utc; + """; cmd.Parameters.AddWithValue("$m", mailboxId); cmd.Parameters.AddWithValue("$f", folder); cmd.Parameters.AddWithValue("$cut", cutoff.ToString("O")); @@ -775,6 +787,15 @@ public sealed class MailArchiveStore : IDisposable { } } + public int MessageCount(string mailboxId) { + lock (_gate) { + using var cmd = _db.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM messages WHERE mailbox_id = $m;"; + cmd.Parameters.AddWithValue("$m", mailboxId); + return (int)(long)(cmd.ExecuteScalar() ?? 0L); + } + } + public IReadOnlyList DistinctMailboxIds() { lock (_gate) { using var cmd = _db.CreateCommand(); @@ -860,8 +881,8 @@ public sealed class MailArchiveStore : IDisposable { cmd.CommandText = $""" INSERT INTO messages ( mailbox_id, folder, uid, message_id, subject, from_addr, date_utc, envelope_kind, envelope_badge, - envelope_tipo, in_reply_to, is_seen, is_flagged, has_attachments, priority) - VALUES ($m, $f, $u, $mid, $sub, $from, $date, $kind, $badge, $tipo, $reply, $seen, $flag, $att, $pri) + envelope_tipo, in_reply_to, is_seen, is_flagged, has_attachments, priority, trashed_utc) + VALUES ($m, $f, $u, $mid, $sub, $from, $date, $kind, $badge, $tipo, $reply, $seen, $flag, $att, $pri, $trashed) ON CONFLICT(mailbox_id, folder, uid) DO UPDATE SET message_id = excluded.message_id, subject = excluded.subject, @@ -873,8 +894,15 @@ public sealed class MailArchiveStore : IDisposable { in_reply_to = excluded.in_reply_to, {flagAssign} has_attachments = excluded.has_attachments, - priority = excluded.priority; + priority = excluded.priority, + trashed_utc = CASE + WHEN messages.trashed_utc <> '' THEN messages.trashed_utc + WHEN $trash = 1 THEN excluded.trashed_utc + ELSE messages.trashed_utc + END; """; + var trash = MailRetention.IsTrash(header.Folder); + var trashed = trash ? DateTimeOffset.UtcNow.ToString("O") : ""; cmd.Parameters.AddWithValue("$m", mailboxId); cmd.Parameters.AddWithValue("$f", header.Folder); cmd.Parameters.AddWithValue("$u", header.Uid); @@ -890,6 +918,8 @@ public sealed class MailArchiveStore : IDisposable { cmd.Parameters.AddWithValue("$flag", header.IsFlagged ? 1 : 0); cmd.Parameters.AddWithValue("$att", header.HasAttachments ? 1 : 0); cmd.Parameters.AddWithValue("$pri", header.Priority); + cmd.Parameters.AddWithValue("$trashed", trashed); + cmd.Parameters.AddWithValue("$trash", trash ? 1 : 0); cmd.ExecuteNonQuery(); using var idCmd = _db.CreateCommand(); idCmd.Transaction = tx; @@ -908,6 +938,8 @@ public sealed class MailArchiveStore : IDisposable { MailArchiveHeader header, string? bodyText, string? attachmentText) { + if (!KeywordIndexEnabled) + return; using (var del = _db.CreateCommand()) { del.Transaction = tx; del.CommandText = "DELETE FROM messages_fts WHERE rowid = $id;"; @@ -1163,6 +1195,7 @@ public sealed class MailArchiveStore : IDisposable { eml_path TEXT NOT NULL DEFAULT '', body_text TEXT NOT NULL DEFAULT '', attachment_text TEXT NOT NULL DEFAULT '', + trashed_utc TEXT NOT NULL DEFAULT '', UNIQUE(mailbox_id, folder, uid) ); CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( @@ -1183,6 +1216,22 @@ public sealed class MailArchiveStore : IDisposable { ); """; cmd.ExecuteNonQuery(); + EnsureColumn("messages", "trashed_utc", "TEXT NOT NULL DEFAULT ''"); + } + + private void EnsureColumn(string table, string column, string definition) { + using var info = _db.CreateCommand(); + info.CommandText = "PRAGMA table_info(" + table + ");"; + using var reader = info.ExecuteReader(); + while (reader.Read()) { + if (reader.GetString(1).Equals(column, StringComparison.OrdinalIgnoreCase)) + return; + } + + reader.Dispose(); + using var alter = _db.CreateCommand(); + alter.CommandText = "ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition + ";"; + alter.ExecuteNonQuery(); } } diff --git a/src/MaksIT.PostClient.Shared/MailIndexProgress.cs b/src/MaksIT.PostClient.Shared/MailIndexProgress.cs index 54ad756..784d351 100644 --- a/src/MaksIT.PostClient.Shared/MailIndexProgress.cs +++ b/src/MaksIT.PostClient.Shared/MailIndexProgress.cs @@ -16,6 +16,11 @@ public static class MailIndexProgress { return left <= 0 ? copy.IndexingBusy : string.Format(copy.IndexingLeft, left); } + public static string MeaningLine(int done, int total) { + var copy = UiLocale.Copy; + return total <= 0 ? "" : string.Format(copy.IndexingMeaning, Math.Clamp(done, 0, total), total); + } + public static string MeaningLeft(int left) => string.Format(UiLocale.Copy.IndexingMeaningLeft, Math.Max(0, left)); diff --git a/src/MaksIT.PostClient.Shared/MailListBudget.cs b/src/MaksIT.PostClient.Shared/MailListBudget.cs new file mode 100644 index 0000000..e8e6d46 --- /dev/null +++ b/src/MaksIT.PostClient.Shared/MailListBudget.cs @@ -0,0 +1,18 @@ +namespace MaksIT.PostClient.Shared; + + +public static class MailListBudget { + public readonly record struct Slice(int Start, int End, int Take, int NextOffset, bool Incomplete); + + public static Slice NewestRange(int count, int offset, int budget) { + if (count <= 0 || offset >= count || budget <= 0) + return new Slice(0, -1, 0, Math.Max(0, offset), false); + + var remaining = count - offset; + var take = Math.Min(budget, remaining); + var end = count - 1 - offset; + var start = end - take + 1; + var next = offset + take; + return new Slice(start, end, take, next, next < count); + } +} diff --git a/src/MaksIT.PostClient.Shared/MailboxAccount.cs b/src/MaksIT.PostClient.Shared/MailboxAccount.cs index a86623a..8aca690 100644 --- a/src/MaksIT.PostClient.Shared/MailboxAccount.cs +++ b/src/MaksIT.PostClient.Shared/MailboxAccount.cs @@ -41,6 +41,8 @@ public sealed class MailboxAccount { public string AuthKind { get; set; } = MailAuthKind.Password; + public bool InitialSyncCompleted { get; set; } + [JsonIgnore] public bool TracksCertifiedReceipts => MailCertifiedKind.TracksReceipts(CertifiedKind); diff --git a/src/MaksIT.PostClient.Shared/UiCopy.cs b/src/MaksIT.PostClient.Shared/UiCopy.cs index b89c5e0..a3b9de8 100644 --- a/src/MaksIT.PostClient.Shared/UiCopy.cs +++ b/src/MaksIT.PostClient.Shared/UiCopy.cs @@ -96,6 +96,8 @@ public sealed partial class UiCopy { public string Help { get; init; } = ""; + public string Logs { get; init; } = ""; + public string CreateStore { get; init; } = ""; public string AttachStore { get; init; } = ""; @@ -118,6 +120,8 @@ public sealed partial class UiCopy { public string RetentionDays { get; init; } = ""; + public string RetentionDaysBadge { get; init; } = ""; + public string RetentionAction { get; init; } = ""; public string RetentionToTrash { get; init; } = ""; @@ -402,6 +406,8 @@ public sealed partial class UiCopy { public string IndexingLeft { get; init; } = ""; + public string IndexingMeaning { get; init; } = ""; + public string IndexingMeaningLeft { get; init; } = ""; public string IndexingMeaningReady { get; init; } = ""; @@ -440,6 +446,8 @@ public sealed partial class UiCopy { public string EmptyFolder { get; init; } = ""; + public string DeleteAllItems { get; init; } = ""; + public string MarkFolderRead { get; init; } = ""; public string MarkFolderUnread { get; init; } = ""; @@ -528,6 +536,41 @@ public sealed partial class UiCopy { public string CertifiedRem { get; init; } = ""; + public string RulesTitle => + Plain(Rules); + + public string FeaturesTitle => + Plain(Features); + + public string RetentionTitle => + Plain(Retention); + + public string SemanticSearchTitle => + Plain(SemanticSearch); + + public string AccountSettingsTitle => + Plain(AccountSettings); + + public string AboutTitle => + Plain(About); + + public string LogsTitle => + Plain(Logs); + + public string RetentionBadge(int days) => + days <= 0 ? "" : string.Format(RetentionDaysBadge, days); + + public static string Plain(string? value) { + if (string.IsNullOrWhiteSpace(value)) + return ""; + var text = value.Replace("_", "", StringComparison.Ordinal).Trim(); + if (text.EndsWith("…", StringComparison.Ordinal)) + return text[..^1].TrimEnd(); + if (text.EndsWith("...", StringComparison.Ordinal)) + return text[..^3].TrimEnd(); + return text; + } + public static UiCopy For(string? language) => UiLanguage.Normalize(language) switch { UiLanguage.It => Italian(), @@ -571,6 +614,7 @@ public sealed partial class UiCopy { Message = "_Message", Settings = "_Settings", Help = "_Help", + Logs = "_Logs", Features = "_Features…", FeaturesHint = "Certified-mail packs only. Ordinary mail (IMAP/POP3, search, labels, imports) stays on. Header checkboxes turn a whole country on or off. Italy is PEC and FatturaPA; Europe is eIDAS REM. France, Germany, Spain, and Switzerland add operator evidence labels — those networks are usually not IMAP.", Feature = "Feature", @@ -624,6 +668,7 @@ public sealed partial class UiCopy { Retention = "_Retention…", RetentionHint = "Per folder. 0 keeps mail forever. Folder days move older messages to Trash. Trash days permanently delete, or 0 to keep Trash forever.", RetentionDays = "Days", + RetentionDaysBadge = "{0}d", RetentionAction = "When expired", RetentionToTrash = "Move to Trash", RetentionPurge = "Delete permanently", @@ -766,6 +811,7 @@ public sealed partial class UiCopy { IndexingWithFolder = "Indexing {0} · {1} / {2}", IndexingBusy = "Indexing…", IndexingLeft = "Indexing {0} left", + IndexingMeaning = "Indexing meaning {0} / {1}", IndexingMeaningLeft = "Indexing meaning {0} left", IndexingMeaningReady = "Meaning index ready ({0})", IndexingMeaningReadyCount = "Meaning index ready ({0}, {1})", @@ -785,6 +831,7 @@ public sealed partial class UiCopy { DeletingFolder = "Deleting folder…", NewFolder = "New folder…", EmptyFolder = "Empty folder", + DeleteAllItems = "Delete all items", MarkFolderRead = "Mark all as read", MarkFolderUnread = "Mark all as unread", DeleteFolder = "Delete folder", @@ -838,6 +885,7 @@ public sealed partial class UiCopy { Message = "_Messaggio", Settings = "_Impostazioni", Help = "_Aiuto", + Logs = "_Registro", Features = "_Funzionalità…", FeaturesHint = "Solo posta certificata. La posta ordinaria (IMAP/POP3, ricerca, etichette, import) resta sempre disponibile. Le caselle in intestazione accendono o spengono un intero Paese. Italia è PEC e FatturaPA; Europa è REM eIDAS. Francia, Germania, Spagna e Svizzera aggiungono etichette di evidenza — quelle reti di solito non sono IMAP.", Feature = "Funzione", @@ -891,6 +939,7 @@ public sealed partial class UiCopy { Retention = "_Conservazione…", RetentionHint = "Per cartella. 0 conserva per sempre. I giorni della cartella spostano i messaggi più vecchi nel Cestino. I giorni del Cestino eliminano in modo permanente, oppure 0 per tenere il Cestino per sempre.", RetentionDays = "Giorni", + RetentionDaysBadge = "{0}g", RetentionAction = "Alla scadenza", RetentionToTrash = "Sposta nel Cestino", RetentionPurge = "Elimina in modo permanente", @@ -1033,6 +1082,7 @@ public sealed partial class UiCopy { IndexingWithFolder = "Indicizzazione {0} · {1} / {2}", IndexingBusy = "Indicizzazione…", IndexingLeft = "Indicizzazione {0} rimanenti", + IndexingMeaning = "Indicizzazione significato {0} / {1}", IndexingMeaningLeft = "Indicizzazione significato {0} rimanenti", IndexingMeaningReady = "Indice significato pronto ({0})", IndexingMeaningReadyCount = "Indice significato pronto ({0}, {1})", @@ -1052,6 +1102,7 @@ public sealed partial class UiCopy { DeletingFolder = "Eliminazione cartella…", NewFolder = "Nuova cartella…", EmptyFolder = "Svuota cartella", + DeleteAllItems = "Elimina tutti gli elementi", MarkFolderRead = "Segna tutte come lette", MarkFolderUnread = "Segna tutte come non lette", DeleteFolder = "Elimina cartella", diff --git a/src/MaksIT.PostClient.Shared/UiCopy.de.cs b/src/MaksIT.PostClient.Shared/UiCopy.de.cs index 1f0c197..a3971c5 100644 --- a/src/MaksIT.PostClient.Shared/UiCopy.de.cs +++ b/src/MaksIT.PostClient.Shared/UiCopy.de.cs @@ -9,6 +9,7 @@ public sealed partial class UiCopy { Message = "_Nachricht", Settings = "_Einstellungen", Help = "_Hilfe", + Logs = "_Protokoll", Features = "_Funktionen…", FeaturesHint = "Nur Pakete für zertifizierte Post. Gewöhnliche Mail (IMAP/POP3, Suche, Kennzeichen, Import) bleibt an. Kopf-Kontrollkästchen schalten ein ganzes Land ein oder aus. Italien ist PEC und FatturaPA; Europa ist eIDAS-REM. Frankreich, Deutschland, Spanien und die Schweiz fügen Nachweisbeschriftungen hinzu — diese Netze sind in der Regel kein IMAP.", Feature = "Funktion", @@ -62,6 +63,7 @@ public sealed partial class UiCopy { Retention = "_Aufbewahrung…", RetentionHint = "Pro Ordner. 0 behält dauerhaft. Ordner-Tage verschieben ältere Nachrichten in den Papierkorb. Papierkorb-Tage löschen endgültig, oder 0 behält den Papierkorb dauerhaft.", RetentionDays = "Tage", + RetentionDaysBadge = "{0}T", RetentionAction = "Nach Ablauf", RetentionToTrash = "In den Papierkorb", RetentionPurge = "Endgültig löschen", @@ -204,6 +206,7 @@ public sealed partial class UiCopy { IndexingWithFolder = "Indizierung {0} · {1} / {2}", IndexingBusy = "Indizierung…", IndexingLeft = "Indizierung {0} übrig", + IndexingMeaning = "Bedeutungsindex {0} / {1}", IndexingMeaningLeft = "Bedeutungsindex {0} übrig", IndexingMeaningReady = "Bedeutungsindex bereit ({0})", IndexingMeaningReadyCount = "Bedeutungsindex bereit ({0}, {1})", @@ -223,6 +226,7 @@ public sealed partial class UiCopy { DeletingFolder = "Ordner wird gelöscht…", NewFolder = "Neuer Ordner…", EmptyFolder = "Ordner leeren", + DeleteAllItems = "Alle Elemente löschen", MarkFolderRead = "Alle als gelesen markieren", MarkFolderUnread = "Alle als ungelesen markieren", DeleteFolder = "Ordner löschen", diff --git a/src/MaksIT.PostClient.Shared/UiCopy.es.cs b/src/MaksIT.PostClient.Shared/UiCopy.es.cs index 2ed8a25..36344c5 100644 --- a/src/MaksIT.PostClient.Shared/UiCopy.es.cs +++ b/src/MaksIT.PostClient.Shared/UiCopy.es.cs @@ -9,6 +9,7 @@ public sealed partial class UiCopy { Message = "_Mensaje", Settings = "Ajus_tes", Help = "A_yuda", + Logs = "_Registro", Features = "_Funciones…", FeaturesHint = "Solo paquetes de correo certificado. El correo ordinario (IMAP/POP3, búsqueda, etiquetas, importación) sigue disponible. Las casillas de cabecera encienden o apagan todo un país. Italia es PEC y FatturaPA; Europa es REM eIDAS. Francia, Alemania, España y Suiza añaden etiquetas de evidencia: esas redes no suelen ser IMAP.", Feature = "Función", @@ -62,6 +63,7 @@ public sealed partial class UiCopy { Retention = "_Retención…", RetentionHint = "Por carpeta. 0 conserva para siempre. Los días de la carpeta envían los mensajes antiguos a la Papelera. Los días de la Papelera borran de forma permanente, o 0 para conservarla siempre.", RetentionDays = "Días", + RetentionDaysBadge = "{0}d", RetentionAction = "Al caducar", RetentionToTrash = "Mover a la Papelera", RetentionPurge = "Eliminar de forma permanente", @@ -204,6 +206,7 @@ public sealed partial class UiCopy { IndexingWithFolder = "Indexación {0} · {1} / {2}", IndexingBusy = "Indexación…", IndexingLeft = "Indexación {0} restantes", + IndexingMeaning = "Indexación de significado {0} / {1}", IndexingMeaningLeft = "Indexación de significado {0} restantes", IndexingMeaningReady = "Índice de significado listo ({0})", IndexingMeaningReadyCount = "Índice de significado listo ({0}, {1})", @@ -223,6 +226,7 @@ public sealed partial class UiCopy { DeletingFolder = "Eliminando carpeta…", NewFolder = "Nueva carpeta…", EmptyFolder = "Vaciar carpeta", + DeleteAllItems = "Eliminar todos los elementos", MarkFolderRead = "Marcar todo como leído", MarkFolderUnread = "Marcar todo como no leído", DeleteFolder = "Eliminar carpeta", diff --git a/src/MaksIT.PostClient.Shared/UiCopy.fr.cs b/src/MaksIT.PostClient.Shared/UiCopy.fr.cs index fb91bbb..7c7efac 100644 --- a/src/MaksIT.PostClient.Shared/UiCopy.fr.cs +++ b/src/MaksIT.PostClient.Shared/UiCopy.fr.cs @@ -9,6 +9,7 @@ public sealed partial class UiCopy { Message = "_Message", Settings = "_Paramètres", Help = "_Aide", + Logs = "_Journal", Features = "_Fonctionnalités…", FeaturesHint = "Packs de courrier certifié uniquement. Le courrier ordinaire (IMAP/POP3, recherche, étiquettes, imports) reste disponible. Les cases d’en-tête activent ou désactivent tout un pays. L’Italie est PEC et FatturaPA ; l’Europe est REM eIDAS. La France, l’Allemagne, l’Espagne et la Suisse ajoutent des libellés de preuve — ces réseaux ne sont en général pas de l’IMAP.", Feature = "Fonction", @@ -62,6 +63,7 @@ public sealed partial class UiCopy { Retention = "_Rétention…", RetentionHint = "Par dossier. 0 conserve pour toujours. Les jours du dossier envoient les messages plus anciens vers la Corbeille. Les jours de la Corbeille suppriment définitivement, ou 0 pour la garder pour toujours.", RetentionDays = "Jours", + RetentionDaysBadge = "{0}j", RetentionAction = "À l’échéance", RetentionToTrash = "Déplacer vers la Corbeille", RetentionPurge = "Supprimer définitivement", @@ -204,6 +206,7 @@ public sealed partial class UiCopy { IndexingWithFolder = "Indexation {0} · {1} / {2}", IndexingBusy = "Indexation…", IndexingLeft = "Indexation {0} restants", + IndexingMeaning = "Indexation du sens {0} / {1}", IndexingMeaningLeft = "Indexation du sens {0} restants", IndexingMeaningReady = "Index de sens prêt ({0})", IndexingMeaningReadyCount = "Index de sens prêt ({0}, {1})", @@ -223,6 +226,7 @@ public sealed partial class UiCopy { DeletingFolder = "Suppression du dossier…", NewFolder = "Nouveau dossier…", EmptyFolder = "Vider le dossier", + DeleteAllItems = "Supprimer tous les éléments", MarkFolderRead = "Tout marquer comme lu", MarkFolderUnread = "Tout marquer comme non lu", DeleteFolder = "Supprimer le dossier", diff --git a/src/MaksIT.PostClient.Tests/ArchiveTests.cs b/src/MaksIT.PostClient.Tests/ArchiveTests.cs index bfc5c99..2cad9e6 100644 --- a/src/MaksIT.PostClient.Tests/ArchiveTests.cs +++ b/src/MaksIT.PostClient.Tests/ArchiveTests.cs @@ -260,6 +260,50 @@ public class MailArchiveStoreTests { } } + [Fact] + public void KeywordIndexDisabled_SkipsFtsUntilRebuild() { + var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); + try { + using var store = new MailArchiveStore(path); + store.KeywordIndexEnabled = false; + store.UpsertHeaders( + "box", + [new MailArchiveHeader { Uid = 1, Folder = "INBOX", Subject = "IMU avviso", From = "comune@pec.it" }]); + Assert.Equal(0, store.IndexStats(EmbeddingModelSpec.Id).KeywordRows); + Assert.Equal(1, store.RebuildKeywordIndex()); + Assert.Equal(1, store.IndexStats(EmbeddingModelSpec.Id).KeywordRows); + } + finally { + DeleteArchive(path); + } + } + + [Fact] + public void TrashRetention_UsesTrashedUtcNotReceivedDate() { + var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); + try { + using var store = new MailArchiveStore(path); + store.UpsertHeaders( + "box", + [ + new MailArchiveHeader { + Uid = 1, + Folder = MailRetention.TrashFolder, + Subject = "Old", + From = "a@b.c", + Date = DateTimeOffset.UtcNow.AddDays(-40) + } + ]); + var yesterday = DateTimeOffset.UtcNow.AddDays(-1); + Assert.Empty(store.UidsOlderThan("box", MailRetention.TrashFolder, yesterday)); + var tomorrow = DateTimeOffset.UtcNow.AddDays(1); + Assert.Equal([1u], store.UidsOlderThan("box", MailRetention.TrashFolder, tomorrow)); + } + finally { + DeleteArchive(path); + } + } + [Fact] public void ClearEmbeddings_LeavesKeywordAndPending() { var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); diff --git a/src/MaksIT.PostClient.Tests/EnvelopeTests.cs b/src/MaksIT.PostClient.Tests/EnvelopeTests.cs index 0408760..1ebb717 100644 --- a/src/MaksIT.PostClient.Tests/EnvelopeTests.cs +++ b/src/MaksIT.PostClient.Tests/EnvelopeTests.cs @@ -161,8 +161,15 @@ public class EnvelopeUnwrapperTests { public void Inspect_SavedPostclientObjects_IfPresent() { var dir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MaksIT", "Postclient", "objects"); + if (!Directory.Exists(dir)) { + dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Postclient", + "objects"); + } if (!Directory.Exists(dir)) return; var files = Directory.GetFiles(dir, "*.eml"); diff --git a/src/MaksIT.PostClient.Tests/SemanticSearchTests.cs b/src/MaksIT.PostClient.Tests/SemanticSearchTests.cs index 2dccc1a..fba759d 100644 --- a/src/MaksIT.PostClient.Tests/SemanticSearchTests.cs +++ b/src/MaksIT.PostClient.Tests/SemanticSearchTests.cs @@ -1,3 +1,4 @@ +using MaksIT.PostClient.Client; using MaksIT.PostClient.Shared; @@ -54,6 +55,37 @@ public class SemanticDeviceTests { } +public class GpuProbeTests { + [Fact] + public void PreferForDirectMl_PicksNvidiaOverIntelAndSkipsWarp() { + GpuAdapter[] adapters = [ + new(0, "Intel UHD Graphics", 0x8086, 128_000_000, false), + new(1, "NVIDIA GeForce GTX 1660 Ti", 0x10DE, 6_000_000_000, false), + new(2, "NVIDIA GeForce GTX 1660 Ti", 0x10DE, 6_000_000_000, false), + new(3, "Microsoft Basic Render Driver", 0x1414, 0, true) + ]; + var picked = GpuProbe.PreferForDirectMl(adapters); + Assert.Equal(new[] { 1, 0 }, picked.Select(static adapter => adapter.DeviceId).ToArray()); + } + + [Fact] + public void IsSoftwareAdapter_DetectsWarp() { + Assert.True(GpuProbe.IsSoftwareAdapter("Microsoft Basic Render Driver", 0, 0x1414)); + Assert.True(GpuProbe.IsSoftwareAdapter("NVIDIA", 2, 0x10DE)); + Assert.False(GpuProbe.IsSoftwareAdapter("NVIDIA GeForce GTX 1660 Ti", 0, 0x10DE)); + } + + [Fact] + public void DirectMlAdapters_OnWindowsSkipsSoftware() { + if (!OperatingSystem.IsWindows()) + return; + var adapters = GpuProbe.DirectMlAdapters(); + Assert.NotEmpty(adapters); + Assert.DoesNotContain(adapters, static adapter => adapter.Software); + } +} + + public class SemanticSearchSettingsTests { [Fact] public void Normalize_KeepsEnabledAndAuto() { diff --git a/src/MaksIT.PostClient.Tests/SharedTests.cs b/src/MaksIT.PostClient.Tests/SharedTests.cs index 86bfe42..bd65d7e 100644 --- a/src/MaksIT.PostClient.Tests/SharedTests.cs +++ b/src/MaksIT.PostClient.Tests/SharedTests.cs @@ -81,10 +81,11 @@ public class ErrorReportTests { public class AppPathsTests { [Fact] - public void ProductFolderIsPlaceholderBrandNotMaksIt() { + public void ProductFolderUsesMaksItBrand() { Assert.Equal("Postclient", AppPaths.ProductName); Assert.Equal("postclient", AppPaths.ProductId); - Assert.DoesNotContain("MaksIT", AppPaths.ConfigDirectory(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("MaksIT", AppPaths.ConfigDirectory(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("Postclient", AppPaths.ConfigDirectory(), StringComparison.OrdinalIgnoreCase); Assert.EndsWith("webview", AppPaths.WebViewDirectory(), StringComparison.OrdinalIgnoreCase); Assert.EndsWith("models", AppPaths.ModelsDirectory(), StringComparison.OrdinalIgnoreCase); } @@ -358,6 +359,37 @@ public class MailIndexProgressTests { Assert.Equal("Indexing meaning 12 left", MailIndexProgress.MeaningLeft(12)); Assert.Equal("Meaning index ready (CPU)", MailIndexProgress.MeaningReady("CPU", 0)); Assert.Equal("Meaning index ready (GPU, 40)", MailIndexProgress.MeaningReady("GPU", 40)); + Assert.Equal("", MailIndexProgress.MeaningLine(0, 0)); + Assert.Equal("Indexing meaning 3 / 10", MailIndexProgress.MeaningLine(3, 10)); + } +} + + +public class MailListBudgetTests { + [Fact] + public void NewestRange_TakesFromTheEnd() { + var first = MailListBudget.NewestRange(1000, 0, 200); + Assert.Equal(800, first.Start); + Assert.Equal(999, first.End); + Assert.Equal(200, first.Take); + Assert.True(first.Incomplete); + var last = MailListBudget.NewestRange(1000, 800, 200); + Assert.Equal(0, last.Start); + Assert.Equal(199, last.End); + Assert.False(last.Incomplete); + } +} + + +public class UiCopyPlainTests { + [Fact] + public void Plain_StripsMnemonicAndEllipsis() { + Assert.Equal("Rules", UiCopy.Plain("_Rules…")); + Assert.Equal("Indices", UiCopy.Plain("_Indices…")); + Assert.Equal("Account Settings", UiCopy.Plain("Account Settings…")); + Assert.Equal("Help", UiCopy.Plain("_Help")); + Assert.Equal("Rules", UiCopy.For("en").RulesTitle); + Assert.Equal("About Postclient", UiCopy.For("en").AboutTitle); } } diff --git a/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs b/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs index 84c4117..4da4001 100644 --- a/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs +++ b/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs @@ -515,7 +515,7 @@ public class LocalStoreTests { files.Save(files.Current); using var purgeHost = new MailWorkerHost(catalog, files); purgeHost.Retention(new WorkerRequest { Op = "retention" }); - Assert.Empty(catalog.Search("box", MailRetention.TrashFolder, "IMU")); + Assert.Single(catalog.Search("box", MailRetention.TrashFolder, "IMU")); } finally { Directory.Delete(root, recursive: true); diff --git a/src/MaksIT.PostClient.UI/MainWindow.axaml b/src/MaksIT.PostClient.UI/MainWindow.axaml index 3398654..239ef69 100644 --- a/src/MaksIT.PostClient.UI/MainWindow.axaml +++ b/src/MaksIT.PostClient.UI/MainWindow.axaml @@ -16,8 +16,12 @@ - - + + @@ -130,6 +134,7 @@ + @@ -171,7 +176,7 @@ - - + @@ -215,6 +220,12 @@ Margin="8,0,4,0" VerticalAlignment="Center" IsVisible="{Binding HasUnread}" /> + @@ -472,8 +483,7 @@ Classes.selected="{Binding IsFatturaKind}" Command="{Binding SetReadingKindCommand}" CommandParameter="fattura" - IsVisible="{Binding UseFatturaPa}" - IsEnabled="{Binding ShowFatturaPaTools}" /> + IsVisible="{Binding UseFatturaPa}" /> @@ -524,7 +534,9 @@ Text="{Binding ReadingSource, Mode=OneWay}" IsReadOnly="True" AcceptsReturn="True" - TextWrapping="Wrap" /> + TextWrapping="NoWrap" + ScrollViewer.HorizontalScrollBarVisibility="Auto" + ScrollViewer.VerticalScrollBarVisibility="Auto" /> _ = AboutWindow.ShowAsync(this); + private void OnLogsRequested() => + _ = LogWindow.ShowAsync(this); + private async Task PickFolder(string title, string? suggested = null) { IStorageFolder? start = null; if (!string.IsNullOrWhiteSpace(suggested)) { diff --git a/src/MaksIT.PostClient.UI/ViewModels/LogViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/LogViewModel.cs new file mode 100644 index 0000000..89585d9 --- /dev/null +++ b/src/MaksIT.PostClient.UI/ViewModels/LogViewModel.cs @@ -0,0 +1,78 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using MaksIT.PostClient.Shared; + + +namespace MaksIT.PostClient.UI.ViewModels; + + +public sealed partial class LogViewModel : ObservableObject { + public LogViewModel() { + Refresh(); + } + + public UiCopy Copy => + UiLocale.Copy; + + public IReadOnlyList Files { get; private set; } = []; + + [ObservableProperty] + private string selectedFile = ""; + + [ObservableProperty] + private string report = ""; + + [ObservableProperty] + private string copyStatus = ""; + + public void Refresh() { + try { + AppPaths.EnsureDirectories(); + var dir = AppPaths.LogsDirectory(); + Files = Directory.Exists(dir) + ? Directory.GetFiles(dir) + .OrderByDescending(File.GetLastWriteTimeUtc) + .Select(Path.GetFileName) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Cast() + .ToList() + : []; + } + catch { + Files = []; + } + + OnPropertyChanged(nameof(Files)); + if (Files.Count == 0) { + SelectedFile = ""; + Report = ""; + return; + } + + if (string.IsNullOrWhiteSpace(SelectedFile) || !Files.Contains(SelectedFile, StringComparer.OrdinalIgnoreCase)) + SelectedFile = Files[0]; + else + LoadSelected(); + } + + partial void OnSelectedFileChanged(string value) => + LoadSelected(); + + public void MarkCopied() => + CopyStatus = Copy.Copied; + + private void LoadSelected() { + CopyStatus = ""; + if (string.IsNullOrWhiteSpace(SelectedFile)) { + Report = ""; + return; + } + + try { + var path = Path.Combine(AppPaths.LogsDirectory(), SelectedFile); + Report = File.Exists(path) ? File.ReadAllText(path) : ""; + } + catch (Exception ex) { + Report = ex.Message; + } + } +} diff --git a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs index a9a5475..bf35dd8 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs @@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input; using MaksIT.Results; using MaksIT.PostClient.Client; using MaksIT.PostClient.Shared; +using MaksIT.PostClient.UI; namespace MaksIT.PostClient.UI.ViewModels; @@ -36,6 +37,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private readonly HashSet _catalogDone = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _catalogFolders = new(StringComparer.OrdinalIgnoreCase); private const int IndexCatalogChunk = 2_000; + private const int IndexUiChunk = 250; + private int _indexBodyTotal; private bool _uiReady; private MailMessageBody? _reading; private MessageRowViewModel? _pendingWindow; @@ -210,9 +213,11 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private string practiceLabel = ""; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowIndexLine))] private string indexLine = ""; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowSemanticLine))] private string semanticLine = ""; [ObservableProperty] @@ -382,6 +387,25 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { public event Action? AboutRequested; + public event Action? LogsRequested; + + public bool ShowIndexLine => + !string.IsNullOrWhiteSpace(IndexLine); + + public bool ShowSemanticLine => + !string.IsNullOrWhiteSpace(SemanticLine); + + public string EmptyFolderLabel { + get { + foreach (var node in TargetFolderNodes()) { + if (MailRetention.IsTrash(node.Folder?.Name, node.Folder?.FullName)) + return Copy.DeleteAllItems; + } + + return Copy.EmptyFolder; + } + } + public event Func>? PromptRequested; public event Action? ExportArchiveRequested; @@ -458,8 +482,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { editorGoogleClientSecret = StoredGoogleClientSecret(); editorMicrosoftClientId = files.Current.MicrosoftClientId; ReloadMailboxes(); + MarkExistingSyncComplete(); semanticLine = semantic.StatusLine; semantic.Changed += OnSemanticChanged; + semantic.Faulted += OnSemanticFaulted; } public void StartBackgroundWork() { @@ -484,6 +510,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { _readingLoad = null; _work?.Cancel(); _semantic.Changed -= OnSemanticChanged; + _semantic.Faulted -= OnSemanticFaulted; _semantic.Dispose(); List sessions; lock (_connections) { @@ -498,6 +525,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { _worker.Dispose(); } + private void OnSemanticFaulted(Exception exception) => + Dispatcher.UIThread.Post(() => ErrorDialog.Report(exception)); + private void OnSemanticChanged() => Dispatcher.UIThread.Post(() => { var line = _semantic.StatusLine; @@ -550,6 +580,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private void ShowAbout() => AboutRequested?.Invoke(); + [RelayCommand] + private void ShowLogs() => + LogsRequested?.Invoke(); + [RelayCommand(CanExecute = nameof(CanCheckForUpdates))] private async Task CheckForUpdatesAsync() { _updateBusy = true; @@ -1384,6 +1418,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { && (SelectedFolderNode is null || !SelectedFolderNodes.Contains(SelectedFolderNode))) SelectedFolderNode = SelectedFolderNodes[0]; NotifyFolderCommands(); + OnPropertyChanged(nameof(EmptyFolderLabel)); } public IReadOnlyList DragMessageIds(MessageRowViewModel source) { @@ -1619,6 +1654,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { var rows = TargetRows(); if (rows.Count == 0) return; + if (VisibleMessages.Count > 0 + && rows.Count >= VisibleMessages.Count + && rows.Count >= Messages.Count) + rows = VisibleMessages.ToList(); var box = SelectedMailbox; var folder = SelectedFolder; if (box is null || folder is null) @@ -1667,14 +1706,14 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private async Task EmptyFolderAsync() { var session = ActiveSession; var box = SelectedMailbox; - var folder = SelectedFolder; + var folder = TargetFolderNodes().FirstOrDefault()?.Folder ?? SelectedFolder; if (session is not { IsConnected: true, SupportsFolders: true } || box is null || folder is null) return; var trash = await EnsureTrashFolderAsync(box, CancellationToken.None); var purge = string.IsNullOrWhiteSpace(trash) || trash.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase); var confirm = string.Format(purge ? Copy.EmptyTrashConfirm : Copy.EmptyFolderConfirm, folder.Name); - if (!await ConfirmAsync(Copy.EmptyFolder, confirm)) + if (!await ConfirmAsync(purge ? Copy.DeleteAllItems : Copy.EmptyFolder, confirm)) return; await RunAsync(Copy.EmptyingFolder, async token => { var result = await session.EmptyFolderAsync( @@ -1822,7 +1861,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { uids is null ? _archive.RemoveFolder(mailboxId, folder) : _archive.RemoveUids(mailboxId, folder, uids)); - _semantic.NotifySettingsChanged(); + if (IsInitialSyncComplete(mailboxId)) + _semantic.NotifySettingsChanged(); } private static void DeleteEmlFiles(IEnumerable paths) { @@ -1957,7 +1997,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private bool CanOrganize() => !IsBusy && HasMessageSelection() && SelectedMailbox is not null - && (ActiveSession is { SupportsFolders: true } || SelectedMailbox.IsLocalStore); + && (ActiveSession is { IsConnected: true, SupportsFolders: true } || SelectedMailbox.IsLocalStore); private bool CanManageFolders() => CanSessionFolders() && SelectedMailbox is not null; @@ -1965,10 +2005,14 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private bool CanCreateFolder() => CanManageFolders(); - private bool CanEmptyFolder() => - CanManageFolders() - && SelectedFolder is not null - && !MailFolderRole.IsNamespace(SelectedFolder.Name, SelectedFolder.FullName); + private bool CanEmptyFolder() { + var folder = TargetFolderNodes().FirstOrDefault()?.Folder ?? SelectedFolder; + if (folder is null || MailFolderRole.IsNamespace(folder.Name, folder.FullName)) + return false; + if (SelectedMailbox is { IsLocalStore: true } && !IsBusy) + return ActiveSession is { IsConnected: true, SupportsFolders: true }; + return CanManageFolders(); + } private bool CanDeleteCustomFolder() => !IsBusy && TargetCustomFolderRoots().Count > 0; @@ -2750,11 +2794,6 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { var box = Mailboxes.FirstOrDefault(m => m.Id.Equals(mailboxId, StringComparison.OrdinalIgnoreCase)); foreach (var folder in FoldersFor(box)) Add(folder.Name, folder.FullName); - foreach (var path in _archive.ListFolders(mailboxId)) { - var slash = path.Replace('\\', '/').TrimEnd('/'); - var i = slash.LastIndexOf('/'); - Add(i < 0 ? slash : slash[(i + 1)..], path); - } return rows; } @@ -3086,7 +3125,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { if (session is PstMailSession pst) pst.StorePathChanged += path => PersistPstStorePath(box, path); await LoadFoldersAsync(box, session, token).ConfigureAwait(false); - await UiAsync(() => EnsureIndexing(box.Id)).ConfigureAwait(false); + await UiAsync(() => { + EnsureIndexing(box.Id); + NotifyFolderCommands(); + }).ConfigureAwait(false); return true; } finally { @@ -3117,6 +3159,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } private void ApplyFolderSync(string mailboxId, string folder, MailFolderSync sync) { + var ready = IsInitialSyncComplete(mailboxId); + _archive.SetKeywordIndexEnabled(mailboxId, ready); if (sync.Flags.Count > 0) _archive.UpdateFlags(mailboxId, folder, sync.Flags.Select(f => (f.Id, f.IsSeen, f.IsFlagged))); _archive.UpsertHeaders(mailboxId, sync.Headers.Select(MailArchiveMap.FromHeader)); @@ -3127,7 +3171,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { DropArchived(mailboxId, folder, gone); } - _semantic.NotifySettingsChanged(); + if (ready) + _semantic.NotifySettingsChanged(); } private async Task LoadMessagesAsync(string folder, CancellationToken token, bool resumeBodies = false) { @@ -3143,34 +3188,49 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { PauseIndexing(); try { - var known = await Task.Run(() => _archive.Uids(box.Id, folder), token).ConfigureAwait(false); - var result = await session.ListMessagesAsync(folder, known, token).ConfigureAwait(false); - if (!result.IsSuccess) { - await UiAsync(() => - Status = string.Join(" ", result.Messages) + " Showing local archive. " + ArchiveFiles.Hint()) - .ConfigureAwait(false); - return; + var painted = false; + while (true) { + var known = await Task.Run(() => _archive.Uids(box.Id, folder), token).ConfigureAwait(false); + var result = await session.ListMessagesAsync(folder, known, token, IndexUiChunk).ConfigureAwait(false); + if (!result.IsSuccess) { + await UiAsync(() => + Status = string.Join(" ", result.Messages) + " Showing local archive. " + ArchiveFiles.Hint()) + .ConfigureAwait(false); + return; + } + + token.ThrowIfCancellationRequested(); + var sync = result.Value ?? new MailFolderSync(); + ApplyFolderSync(box.Id, folder, sync); + var fresh = sync.Headers.Where(h => !known.Contains(h.Id)).ToList(); + if (known.Count > 0) { + await UiAsync(() => { + if (SelectedMailbox?.Id == box.Id) + NoticeNewMail(fresh); + }).ConfigureAwait(false); + } + await ApplyIncomingRulesAsync(folder, fresh, token).ConfigureAwait(false); + await ShowArchiveAsync(box.Id, folder, token).ConfigureAwait(false); + if (!painted) { + painted = true; + await UiAsync(() => { + IsBusy = false; + NotifyFolderCommands(); + }).ConfigureAwait(false); + } + + if (!sync.Incomplete) + break; } - token.ThrowIfCancellationRequested(); - var sync = result.Value ?? new MailFolderSync(); - ApplyFolderSync(box.Id, folder, sync); - var fresh = sync.Headers.Where(h => !known.Contains(h.Id)).ToList(); - if (known.Count > 0) { - await UiAsync(() => { - if (SelectedMailbox?.Id == box.Id) - NoticeNewMail(fresh); - }).ConfigureAwait(false); - } - await ApplyIncomingRulesAsync(folder, fresh, token).ConfigureAwait(false); - await ShowArchiveAsync(box.Id, folder, token).ConfigureAwait(false); + await UiAsync(() => MarkFolderCataloged(box, folder, session)).ConfigureAwait(false); if (TracksCertifiedReceipts(box) && MailFolderRole.Kind(null, folder) == "sent") { await IngestReceiptFoldersAsync(token).ConfigureAwait(false); await UiAsync(OverlaySentMarks).ConfigureAwait(false); } await UiAsync(() => Status = ArchiveFiles.Hint()).ConfigureAwait(false); - if (resumeBodies) + if (resumeBodies && IsInitialSyncComplete(box.Id)) await UiAsync(() => EnsureBodyBackfill(box.Id)).ConfigureAwait(false); } finally { @@ -3623,7 +3683,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { path, MailArchiveMap.BodyText(body, UnwrapEnvelope), MailArchiveMap.AttachmentIndex(body, UnwrapEnvelope)); - _semantic.NotifySettingsChanged(); + if (IsInitialSyncComplete(mailboxId)) + _semantic.NotifySettingsChanged(); } catch { } @@ -3738,7 +3799,6 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { if (snapshot.Count == 0) break; - var ids = snapshot.Select(s => s.Id).ToList(); var sessions = snapshot.ToDictionary( s => s.Id, s => s.Session, @@ -3759,7 +3819,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { pendingFolders = true; cataloged = true; await WaitIfIndexingPausedAsync(token).ConfigureAwait(false); - await SetIndexLineAsync(MailIndexProgress.Left(_archive.MissingBodyCount(ids)), token) + var folderDone = box.CatalogedFolders.Count; + await SetIndexLineAsync( + MailIndexProgress.Line(folderDone, Math.Max(box.Folders.Count, 1), folder.Name), + token) .ConfigureAwait(false); var known = _archive.Uids(box.Id, folder.FullName); var listed = await box.Session @@ -3773,26 +3836,37 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { await UiAsync(() => RefreshIndexedFolderCounts(box.Id, folder.FullName, counts)) .ConfigureAwait(false); if (!sync.Incomplete) { - await UiAsync(() => _catalogFolders.Add(CatalogKey(box.Id, folder.FullName))) - .ConfigureAwait(false); + await UiAsync(() => { + _catalogFolders.Add(CatalogKey(box.Id, folder.FullName)); + TryCompleteInitialSync(MailboxById(box.Id)); + }).ConfigureAwait(false); } await Task.Delay(15, token).ConfigureAwait(false); } - if (!pendingFolders) - await UiAsync(() => _catalogDone.Add(box.Id)).ConfigureAwait(false); + if (!pendingFolders && box.Folders.All(f => box.CatalogedFolders.Contains(f.FullName))) + await UiAsync(() => TryCompleteInitialSync(MailboxById(box.Id))).ConfigureAwait(false); } await WaitIfIndexingPausedAsync(token).ConfigureAwait(false); - var pending = _archive.MissingBodies(ids, 16); + var readyIds = snapshot + .Where(s => IsInitialSyncComplete(s.Id)) + .Select(s => s.Id) + .ToList(); + var pending = readyIds.Count == 0 ? [] : _archive.MissingBodies(readyIds, 16); if (pending.Count == 0) { if (!cataloged) break; + await SetIndexLineAsync("", token).ConfigureAwait(false); continue; } - await SetIndexLineAsync(MailIndexProgress.Left(_archive.MissingBodyCount(ids)), token) + var left = _archive.MissingBodyCount(readyIds); + if (_indexBodyTotal < left) + _indexBodyTotal = left; + var done = Math.Max(0, _indexBodyTotal - left); + await SetIndexLineAsync(MailIndexProgress.Line(done, _indexBodyTotal), token) .ConfigureAwait(false); var fetched = 0; foreach (var item in pending) { @@ -3834,6 +3908,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } IndexLine = ""; + _indexBodyTotal = 0; if (_indexMailboxes.Any(id => SessionFor(MailboxById(id)) is { IsConnected: true })) StartIndexing(); }); @@ -3938,6 +4013,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { DetachPstCommand.NotifyCanExecuteChanged(); OnPropertyChanged(nameof(ShowFolderContext)); OnPropertyChanged(nameof(ShowDetachPst)); + OnPropertyChanged(nameof(EmptyFolderLabel)); } partial void OnHasReadingFilesChanged(bool value) => @@ -4180,8 +4256,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { Action? apply, IReadOnlyList? rows = null) { var session = ActiveSession; - if (session is not { IsConnected: true, SupportsFolders: true } || SelectedFolder is null) + if (session is not { IsConnected: true, SupportsFolders: true } || SelectedFolder is null) { + Status = Copy.Connecting; return false; + } var targets = rows ?? TargetRows(); if (targets.Count == 0) return false; @@ -4289,12 +4367,35 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private void NotifyFolderCounts() { foreach (var account in FolderTree) { + ApplyRetentionBadge(account); foreach (var child in account.Children) child.NotifyCounts(); account.NotifyCounts(); } } + public void RefreshRetentionBadges() => + NotifyFolderCounts(); + + private void ApplyRetentionBadge(FolderNodeViewModel node) { + if (node.Mailbox is { } box && node.Folder is { } folder) + node.RetentionDays = RetentionDaysFor(box.Id, folder.FullName); + else + node.RetentionDays = 0; + foreach (var child in node.Children) + ApplyRetentionBadge(child); + } + + private int RetentionDaysFor(string mailboxId, string folder) { + foreach (var row in _files.Current.Retention ?? []) { + if (row.MailboxId.Equals(mailboxId, StringComparison.OrdinalIgnoreCase) + && row.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase)) + return row.Days; + } + + return 0; + } + private void RefreshFolderStats() { if (SelectedFolder is null) { FolderStats = ""; @@ -4358,6 +4459,57 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private static string CatalogKey(string mailboxId, string folder) => mailboxId + "\n" + folder; + private bool IsInitialSyncComplete(string mailboxId) { + var box = MailboxById(mailboxId); + return box?.InitialSyncCompleted == true || _catalogDone.Contains(mailboxId); + } + + private void MarkExistingSyncComplete() { + var changed = false; + foreach (var box in _files.Current.Mailboxes) { + if (box.InitialSyncCompleted) + continue; + if (_archive.MessageCount(box.Id) <= 0) + continue; + box.InitialSyncCompleted = true; + changed = true; + } + + foreach (var box in _files.Current.Mailboxes) + _archive.SetKeywordIndexEnabled(box.Id, box.InitialSyncCompleted); + if (changed) + _files.Save(_files.Current); + } + + private void MarkFolderCataloged(MailboxAccount box, string folder, IMailSession? session) { + _catalogFolders.Add(CatalogKey(box.Id, folder)); + if (session is not { SupportsFolders: true } || AllFoldersCataloged(box)) + TryCompleteInitialSync(box); + } + + private bool AllFoldersCataloged(MailboxAccount box) { + var folders = FoldersFor(box); + if (folders.Count == 0) + return false; + return folders.All(folder => _catalogFolders.Contains(CatalogKey(box.Id, folder.FullName))); + } + + private void TryCompleteInitialSync(MailboxAccount? box) { + if (box is null || box.InitialSyncCompleted) + return; + if (FoldersFor(box).Count > 0 && !AllFoldersCataloged(box)) + return; + box.InitialSyncCompleted = true; + _catalogDone.Add(box.Id); + var stored = _files.Current.FindMailbox(box.Id); + if (stored is not null) + stored.InitialSyncCompleted = true; + _files.Save(_files.Current); + _archive.SetKeywordIndexEnabled(box.Id, true); + _archive.RebuildKeywordIndex(box.Id); + _semantic.NotifySettingsChanged(); + } + private async Task UiAsync(Action work) { if (Dispatcher.UIThread.CheckAccess()) work(); diff --git a/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs b/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs index c28856c..768871f 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs @@ -152,6 +152,14 @@ public sealed partial class FolderNodeViewModel : ObservableObject { public bool HasUnread => Unread > 0; + public int RetentionDays { get; set; } + + public string RetentionLabel => + UiLocale.Copy.RetentionBadge(RetentionDays); + + public bool HasRetention => + RetentionDays > 0; + public MailboxQuota? Quota { get; set; } public bool ShowQuota => @@ -172,6 +180,8 @@ public sealed partial class FolderNodeViewModel : ObservableObject { OnPropertyChanged(nameof(UnreadLabel)); OnPropertyChanged(nameof(HasUnread)); OnPropertyChanged(nameof(Weight)); + OnPropertyChanged(nameof(RetentionLabel)); + OnPropertyChanged(nameof(HasRetention)); } public void NotifyQuota() { diff --git a/src/MaksIT.PostClient.UI/Windows/AboutWindow.axaml.cs b/src/MaksIT.PostClient.UI/Windows/AboutWindow.axaml.cs index 1202123..0a259a5 100644 --- a/src/MaksIT.PostClient.UI/Windows/AboutWindow.axaml.cs +++ b/src/MaksIT.PostClient.UI/Windows/AboutWindow.axaml.cs @@ -16,7 +16,7 @@ public partial class AboutWindow : Window { public static Task ShowAsync(Window owner) { var copy = UiLocale.Copy; var window = new AboutWindow { - Title = copy.About.Replace("_", "", StringComparison.Ordinal), + Title = copy.AboutTitle, DataContext = new AboutViewModel() }; return window.ShowDialog(owner); diff --git a/src/MaksIT.PostClient.UI/Windows/AccountSettingsWindow.axaml b/src/MaksIT.PostClient.UI/Windows/AccountSettingsWindow.axaml index 644dc0a..2dd51d6 100644 --- a/src/MaksIT.PostClient.UI/Windows/AccountSettingsWindow.axaml +++ b/src/MaksIT.PostClient.UI/Windows/AccountSettingsWindow.axaml @@ -3,7 +3,7 @@ xmlns:vm="using:MaksIT.PostClient.UI.ViewModels" x:Class="MaksIT.PostClient.UI.Windows.AccountSettingsWindow" x:DataType="vm:MainViewModel" - Title="{Binding Copy.AccountSettings}" + Title="{Binding Copy.AccountSettingsTitle}" Icon="/Assets/icon.ico" Width="560" Height="740" diff --git a/src/MaksIT.PostClient.UI/Windows/FeatureSettingsWindow.axaml b/src/MaksIT.PostClient.UI/Windows/FeatureSettingsWindow.axaml index bef1708..031ad63 100644 --- a/src/MaksIT.PostClient.UI/Windows/FeatureSettingsWindow.axaml +++ b/src/MaksIT.PostClient.UI/Windows/FeatureSettingsWindow.axaml @@ -3,7 +3,7 @@ xmlns:vm="using:MaksIT.PostClient.UI.ViewModels" x:Class="MaksIT.PostClient.UI.Windows.FeatureSettingsWindow" x:DataType="vm:FeatureMatrixViewModel" - Title="{Binding Copy.Features}" + Title="{Binding Copy.FeaturesTitle}" Icon="/Assets/icon.ico" Width="940" Height="560" diff --git a/src/MaksIT.PostClient.UI/Windows/LogWindow.axaml b/src/MaksIT.PostClient.UI/Windows/LogWindow.axaml new file mode 100644 index 0000000..53d842a --- /dev/null +++ b/src/MaksIT.PostClient.UI/Windows/LogWindow.axaml @@ -0,0 +1,46 @@ + + + + +