(feature): paint first sync incrementally and bind rag to the discrete gpu

This commit is contained in:
Maksym Sadovnychyy 2026-09-20 17:02:37 +02:00
parent 863d9d949d
commit 92c997513b
40 changed files with 1261 additions and 148 deletions

View File

@ -6,6 +6,29 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic
## [Unreleased] ## [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 ## [0.3.2] - 2026-09-19
Expired folder mail goes to Trash; Trash can be kept forever or purged after a number of days. Expired folder mail goes to Trash; Trash can be kept forever or purged after a number of days.

View File

@ -30,6 +30,11 @@
<display_length compare="ge">768</display_length> <display_length compare="ge">768</display_length>
</requires> </requires>
<releases> <releases>
<release version="0.3.3" date="2026-09-20">
<description>
<p>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.</p>
</description>
</release>
<release version="0.3.2" date="2026-09-19"> <release version="0.3.2" date="2026-09-19">
<description> <description>
<p>Retention moves expired folder mail to Trash. Trash can be kept forever or purged after a number of days. Delete sends messages to Trash.</p> <p>Retention moves expired folder mail to Trash. Trash can be kept forever or purged after a number of days. Delete sends messages to Trash.</p>

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Version>0.3.2</Version> <Version>0.3.3</Version>
<Product>Postclient</Product> <Product>Postclient</Product>
<AssemblyTitle>Postclient</AssemblyTitle> <AssemblyTitle>Postclient</AssemblyTitle>
<Authors>Maksym Sadovnychyy</Authors> <Authors>Maksym Sadovnychyy</Authors>

View File

@ -1,10 +1,26 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using MaksIT.PostClient.Shared; using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client; namespace MaksIT.PostClient.Client;
public readonly record struct GpuAdapter(
int DeviceId,
string Name,
uint VendorId,
long DedicatedBytes,
bool Software);
public static class GpuProbe { 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 => public static bool SupportsBoost =>
OperatingSystem.IsWindows(); OperatingSystem.IsWindows();
@ -16,4 +32,139 @@ public static class GpuProbe {
return false; return false;
return kind is SemanticDevice.Gpu or SemanticDevice.Auto; return kind is SemanticDevice.Gpu or SemanticDevice.Auto;
} }
public static IReadOnlyList<GpuAdapter> 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<GpuAdapter> PreferForDirectMl(IEnumerable<GpuAdapter> 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<GpuAdapter> 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<Native.EnumAdapters1>(factory, 12);
var adapters = new List<GpuAdapter>();
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<Native.GetDesc1>(adapter, 10);
var descPtr = Marshal.AllocHGlobal(Marshal.SizeOf<Native.AdapterDesc1>());
try {
hr = getDesc(adapter, descPtr);
if (hr < 0)
continue;
var desc = Marshal.PtrToStructure<Native.AdapterDesc1>(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<Native.Release>(adapter, 2)(adapter);
}
}
return adapters;
}
finally {
Native.Vtbl<Native.Release>(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<T>(IntPtr com, int index) where T : Delegate {
var table = Marshal.ReadIntPtr(com);
return Marshal.GetDelegateForFunctionPointer<T>(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;
}
}
} }

View File

@ -13,6 +13,8 @@ public interface ISemanticSearchService : IDisposable {
event Action? Changed; event Action? Changed;
event Action<Exception>? Faulted;
void Start(); void Start();
void NotifySettingsChanged(); void NotifySettingsChanged();

View File

@ -16,6 +16,8 @@ public sealed class ImapMailSession : IMailSession {
private ImapClient? _imap; private ImapClient? _imap;
private MailboxAccount? _account; private MailboxAccount? _account;
private MailAuthMaterial? _material; private MailAuthMaterial? _material;
private readonly Dictionary<string, int> _listOffset = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, HashSet<uint>> _listedUids = new(StringComparer.OrdinalIgnoreCase);
private const MessageSummaryItems FlagItems = private const MessageSummaryItems FlagItems =
MessageSummaryItems.UniqueId | MessageSummaryItems.Flags; MessageSummaryItems.UniqueId | MessageSummaryItems.Flags;
@ -107,15 +109,14 @@ public sealed class ImapMailSession : IMailSession {
string folder, string folder,
IReadOnlySet<uint>? knownIds = null, IReadOnlySet<uint>? knownIds = null,
CancellationToken cancellationToken = default, CancellationToken cancellationToken = default,
int itemBudget = 0) { int itemBudget = 0) =>
_ = itemBudget; _io.RunAsync(() => ListMessagesCoreAsync(folder, knownIds, cancellationToken, itemBudget), cancellationToken);
return _io.RunAsync(() => ListMessagesCoreAsync(folder, knownIds, cancellationToken), cancellationToken);
}
private async Task<Result<MailFolderSync>> ListMessagesCoreAsync( private async Task<Result<MailFolderSync>> ListMessagesCoreAsync(
string folder, string folder,
IReadOnlySet<uint>? knownIds, IReadOnlySet<uint>? knownIds,
CancellationToken cancellationToken) { CancellationToken cancellationToken,
int itemBudget) {
var client = RequireImap(); var client = RequireImap();
if (client is null) if (client is null)
return Result<MailFolderSync>.UnprocessableEntity(null, "Not connected."); return Result<MailFolderSync>.UnprocessableEntity(null, "Not connected.");
@ -130,6 +131,9 @@ public sealed class ImapMailSession : IMailSession {
var extraHeaders = ImapFetchPolicy.ExtraHeaders(AccountProvider()); var extraHeaders = ImapFetchPolicy.ExtraHeaders(AccountProvider());
var known = knownIds ?? (IReadOnlySet<uint>)new HashSet<uint>(); var known = knownIds ?? (IReadOnlySet<uint>)new HashSet<uint>();
var chunk = itemBudget > 0;
if (!chunk)
ClearListCursor(folder);
if (known.Count > 0 && known.Count >= imapFolder.Count / 2) { if (known.Count > 0 && known.Count >= imapFolder.Count / 2) {
var flagRows = await FetchSummariesAsync( var flagRows = await FetchSummariesAsync(
imapFolder, imapFolder,
@ -141,21 +145,31 @@ public sealed class ImapMailSession : IMailSession {
var flags = FlagsOf(flagRows); var flags = FlagsOf(flagRows);
var unknown = flagRows var unknown = flagRows
.Where(s => s.UniqueId.IsValid && !known.Contains(s.UniqueId.Id)) .Where(s => s.UniqueId.IsValid && !known.Contains(s.UniqueId.Id))
.OrderByDescending(s => s.UniqueId.Id)
.Select(s => s.UniqueId) .Select(s => s.UniqueId)
.ToList(); .ToList();
if (unknown.Count == 0) if (unknown.Count == 0)
return Result<MailFolderSync>.Ok(new MailFolderSync { Flags = flags, Present = present }); return Result<MailFolderSync>.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); .ConfigureAwait(false);
return Result<MailFolderSync>.Ok(new MailFolderSync { return Result<MailFolderSync>.Ok(new MailFolderSync {
Headers = HeadersOf(folder, added), Headers = HeadersOf(folder, added),
Flags = flags, Flags = flags,
Present = present Present = incomplete ? null : present,
Incomplete = incomplete
}); });
} }
} }
if (!chunk) {
var summaries = await FetchSummariesAsync( var summaries = await FetchSummariesAsync(
imapFolder, imapFolder,
ListItems, ListItems,
@ -170,11 +184,57 @@ public sealed class ImapMailSession : IMailSession {
Present = ids Present = ids
}); });
} }
int offset;
HashSet<uint> 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<MailFolderSync>.Ok(new MailFolderSync { Present = listed.ToList() });
}
var chunkRows = await FetchRangeAsync(
imapFolder,
range.Start,
range.End,
ListItems,
extraHeaders,
cancellationToken).ConfigureAwait(false);
foreach (var id in IdsOf(chunkRows))
listed.Add(id);
lock (_gate) {
if (range.Incomplete)
_listOffset[folder] = range.NextOffset;
else
ClearListCursor(folder);
}
return Result<MailFolderSync>.Ok(new MailFolderSync {
Headers = HeadersOf(folder, chunkRows),
Present = range.Incomplete ? null : listed.ToList(),
Incomplete = range.Incomplete
});
}
catch (Exception ex) { catch (Exception ex) {
return Result<MailFolderSync>.UnprocessableEntity(null, ex.Message); return Result<MailFolderSync>.UnprocessableEntity(null, ex.Message);
} }
} }
private void ClearListCursor(string folder) {
lock (_gate) {
_listOffset.Remove(folder);
_listedUids.Remove(folder);
}
}
public Task<Result<MailMessageBody>> GetMessageAsync( public Task<Result<MailMessageBody>> GetMessageAsync(
string folder, string folder,
uint id, uint id,

View File

@ -200,11 +200,18 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore {
IReadOnlyList<uint> ids, IReadOnlyList<uint> ids,
MailFlagUpdate update, MailFlagUpdate update,
CancellationToken cancellationToken = default) { CancellationToken cancellationToken = default) {
_ = folder; if (update.Deleted is not true)
_ = ids;
_ = update;
_ = cancellationToken;
return Task.FromResult(Result.Ok()); 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<Result> CreateFolderAsync( public Task<Result> CreateFolderAsync(

View File

@ -1,6 +1,8 @@
using System.Reflection;
using System.Diagnostics.CodeAnalysis;
using Microsoft.ML.Tokenizers;
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors; using Microsoft.ML.OnnxRuntime.Tensors;
using Microsoft.ML.Tokenizers;
using MaksIT.PostClient.Shared; using MaksIT.PostClient.Shared;
@ -22,17 +24,14 @@ public sealed class OnnxGemmaEmbedder : ITextEmbedder {
ArgumentException.ThrowIfNullOrWhiteSpace(tokenizerPath); ArgumentException.ThrowIfNullOrWhiteSpace(tokenizerPath);
using var stream = File.OpenRead(tokenizerPath); using var stream = File.OpenRead(tokenizerPath);
_tokenizer = LlamaTokenizer.Create(stream, addBeginOfSentence: true, addEndOfSentence: true); _tokenizer = LlamaTokenizer.Create(stream, addBeginOfSentence: true, addEndOfSentence: true);
var options = new SessionOptions();
UsesGpu = false; UsesGpu = false;
if (useGpu) if (useGpu && TryCreateGpuSession(onnxPath, out var gpu)) {
UsesGpu = TryDirectMl(options); _session = gpu;
try { UsesGpu = true;
options.AppendExecutionProvider_CPU();
} }
catch { else {
_session = CreateCpuSession(onnxPath);
} }
_session = new InferenceSession(onnxPath, options);
_inputIds = PickInput(_session, "input_ids", "input"); _inputIds = PickInput(_session, "input_ids", "input");
_attentionMask = PickInput(_session, "attention_mask", "mask"); _attentionMask = PickInput(_session, "attention_mask", "mask");
_tokenTypeIds = FindInput(_session, "token_type_ids"); _tokenTypeIds = FindInput(_session, "token_type_ids");
@ -84,19 +83,59 @@ public sealed class OnnxGemmaEmbedder : ITextEmbedder {
public void Dispose() => public void Dispose() =>
_session.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)]); var method = typeof(SessionOptions).GetMethod("AppendExecutionProvider_DML", [typeof(int)]);
if (method is null) if (method is null)
return false; return false;
Exception? last = null;
foreach (var adapter in GpuProbe.DirectMlAdapters()) {
var options = DmlSessionOptions();
try { try {
method.Invoke(options, [0]); method.Invoke(options, [adapter.DeviceId]);
TryAppendCpu(options);
session = new InferenceSession(onnxPath, options);
AppLog.Write($"Meaning index DirectML: {adapter.Name} (device {adapter.DeviceId})");
return true; return true;
} }
catch { 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; 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 {
options.AppendExecutionProvider_CPU();
}
catch {
}
}
private static Exception Unwrap(Exception exception) =>
exception is TargetInvocationException { InnerException: { } inner } ? inner : exception;
private static string PickInput(InferenceSession session, params string[] names) { private static string PickInput(InferenceSession session, params string[] names) {
foreach (var name in names) { foreach (var name in names) {
foreach (var key in session.InputMetadata.Keys) { foreach (var key in session.InputMetadata.Keys) {

View File

@ -16,6 +16,8 @@ public sealed class Pop3MailSession : IMailSession {
private Pop3Client? _pop; private Pop3Client? _pop;
private MailboxAccount? _account; private MailboxAccount? _account;
private MailAuthMaterial? _material; private MailAuthMaterial? _material;
private int _listOffset;
private readonly HashSet<uint> _listedIds = [];
public Pop3MailSession(IMailAuthService auth) { public Pop3MailSession(IMailAuthService auth) {
_auth = auth; _auth = auth;
@ -84,26 +86,31 @@ public sealed class Pop3MailSession : IMailSession {
string folder, string folder,
IReadOnlySet<uint>? knownIds = null, IReadOnlySet<uint>? knownIds = null,
CancellationToken cancellationToken = default, CancellationToken cancellationToken = default,
int itemBudget = 0) { int itemBudget = 0) =>
_ = itemBudget; _io.RunAsync(() => ListMessagesCoreAsync(folder, knownIds, cancellationToken, itemBudget), cancellationToken);
return _io.RunAsync(() => ListMessagesCoreAsync(folder, knownIds, cancellationToken), cancellationToken);
}
private async Task<Result<MailFolderSync>> ListMessagesCoreAsync( private async Task<Result<MailFolderSync>> ListMessagesCoreAsync(
string folder, string folder,
IReadOnlySet<uint>? knownIds, IReadOnlySet<uint>? knownIds,
CancellationToken cancellationToken) { CancellationToken cancellationToken,
int itemBudget) {
var client = RequirePop(); var client = RequirePop();
if (client is null) if (client is null)
return Result<MailFolderSync>.UnprocessableEntity(null, "Not connected."); return Result<MailFolderSync>.UnprocessableEntity(null, "Not connected.");
_ = folder; _ = folder;
_ = knownIds; var known = knownIds ?? (IReadOnlySet<uint>)new HashSet<uint>();
var chunk = itemBudget > 0;
if (!chunk) {
_listOffset = 0;
_listedIds.Clear();
}
try { try {
var count = client.Count; var count = client.Count;
if (count == 0) if (count == 0)
return Result<MailFolderSync>.Ok(new MailFolderSync { Present = [] }); return Result<MailFolderSync>.Ok(new MailFolderSync { Present = [] });
if (!chunk) {
var indexes = Enumerable.Range(0, count).ToList(); var indexes = Enumerable.Range(0, count).ToList();
var headers = await client.GetMessageHeadersAsync(indexes, cancellationToken).ConfigureAwait(false); var headers = await client.GetMessageHeadersAsync(indexes, cancellationToken).ConfigureAwait(false);
var rows = indexes var rows = indexes
@ -116,6 +123,42 @@ public sealed class Pop3MailSession : IMailSession {
Present = rows.Select(h => h.Id).ToList() 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<MailFolderSync>.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<MailMessageHeader>();
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<MailFolderSync>.Ok(new MailFolderSync {
Headers = added,
Present = present
});
}
return Result<MailFolderSync>.Ok(new MailFolderSync {
Headers = added,
Incomplete = true
});
}
catch (Exception ex) { catch (Exception ex) {
return Result<MailFolderSync>.UnprocessableEntity(null, ex.Message); return Result<MailFolderSync>.UnprocessableEntity(null, ex.Message);
} }

View File

@ -14,6 +14,7 @@ public sealed class SemanticSearchService : ISemanticSearchService {
private CancellationTokenSource? _step; private CancellationTokenSource? _step;
private ITextEmbedder? _embedder; private ITextEmbedder? _embedder;
private bool _gpu; private bool _gpu;
private bool _gpuFailed;
private string _status = ""; private string _status = "";
private bool _ready; private bool _ready;
@ -46,6 +47,8 @@ public sealed class SemanticSearchService : ISemanticSearchService {
public event Action? Changed; public event Action? Changed;
public event Action<Exception>? Faulted;
public void NotifySettingsChanged() { public void NotifySettingsChanged() {
_step?.Cancel(); _step?.Cancel();
try { try {
@ -128,7 +131,8 @@ public sealed class SemanticSearchService : ISemanticSearchService {
break; break;
} }
catch (Exception ex) { 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); var wantGpu = GpuProbe.UseGpu(settings.Device);
if (!EnsureEmbedder(wantGpu, out var error)) { if (!EnsureEmbedder(wantGpu, out var error)) {
SetStatus(error, ready: false); AppLog.Write(error);
SetStatus("", ready: false);
await DelayAsync(TimeSpan.FromSeconds(30), token).ConfigureAwait(false); await DelayAsync(TimeSpan.FromSeconds(30), token).ConfigureAwait(false);
return; return;
} }
@ -175,15 +180,23 @@ public sealed class SemanticSearchService : ISemanticSearchService {
settings = _files.Current.Semantic ?? settings; settings = _files.Current.Semantic ?? settings;
if (!settings.Enabled) if (!settings.Enabled)
return; 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) { if (pending.Count == 0) {
SetStatus("", ready: true); SetStatus("", ready: true);
await WaitAsync(token).ConfigureAwait(false); await WaitAsync(token).ConfigureAwait(false);
return; return;
} }
var left = _archive.EmbeddingPendingCount(EmbeddingModelSpec.Id); var left = _archive.EmbeddingPendingCount(EmbeddingModelSpec.Id, ready);
SetStatus(MailIndexProgress.MeaningLeft(left), ready: true); var done = _archive.EmbeddingCount(EmbeddingModelSpec.Id, ready);
SetStatus(MailIndexProgress.MeaningLine(done, done + left), ready: true);
foreach (var item in pending) { foreach (var item in pending) {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
ITextEmbedder embedder; ITextEmbedder embedder;
@ -195,7 +208,9 @@ public sealed class SemanticSearchService : ISemanticSearchService {
vector = embedder.Embed(text); vector = embedder.Embed(text);
} }
catch (Exception ex) { 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); await DelayAsync(TimeSpan.FromSeconds(15), token).ConfigureAwait(false);
return; return;
} }
@ -207,6 +222,8 @@ public sealed class SemanticSearchService : ISemanticSearchService {
private bool EnsureEmbedder(bool wantGpu, out string error) { private bool EnsureEmbedder(bool wantGpu, out string error) {
error = ""; error = "";
if (wantGpu && _gpuFailed)
wantGpu = false;
lock (_gate) { lock (_gate) {
if (_embedder is not null && _gpu == wantGpu) { if (_embedder is not null && _gpu == wantGpu) {
_ready = true; _ready = true;
@ -220,16 +237,10 @@ public sealed class SemanticSearchService : ISemanticSearchService {
} }
try { try {
var created = new OnnxGemmaEmbedder( var created = CreateEmbedder(wantGpu);
EmbeddingModelSpec.OnnxPath(), if (wantGpu && !created.UsesGpu) {
EmbeddingModelSpec.TokenizerPath(),
wantGpu);
if (wantGpu && !created.UsesGpu && SemanticDevice.IsGpu(_files.Current.Semantic?.Device)) {
created.Dispose(); created.Dispose();
created = new OnnxGemmaEmbedder( created = CreateEmbedder(false);
EmbeddingModelSpec.OnnxPath(),
EmbeddingModelSpec.TokenizerPath(),
useGpu: false);
} }
lock (_gate) { lock (_gate) {
@ -240,11 +251,50 @@ public sealed class SemanticSearchService : ISemanticSearchService {
return true; return true;
} }
catch (Exception ex) { catch (Exception ex) when (wantGpu) {
error = ex.Message; _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; 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<string> ReadyMailboxIds() {
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var box in _files.Current.Mailboxes) {
if (box.InitialSyncCompleted)
ids.Add(box.Id);
}
return ids;
}
private void CloseEmbedder() { private void CloseEmbedder() {
lock (_gate) { lock (_gate) {

View File

@ -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));
}
}

View File

@ -2,13 +2,13 @@ namespace MaksIT.PostClient.Shared;
/// <summary> /// <summary>
/// Placeholder product folder until the public sign is chosen. /// Windows: <c>%LocalAppData%\MaksIT\Postclient</c> for config and data.
/// Windows: <c>%AppData%\Postclient</c> / <c>%LocalAppData%\Postclient</c>. /// Linux: XDG under a MaksIT parent. macOS: Application Support under MaksIT.
/// Linux: XDG. macOS: Application Support. Not a MAKS-IT subfolder.
/// </summary> /// </summary>
public static class AppPaths { public static class AppPaths {
public const string ProductName = "Postclient"; public const string ProductName = "Postclient";
public const string ProductId = "postclient"; public const string ProductId = "postclient";
public const string BrandFolder = "MaksIT";
public const string ConfigEnv = "POSTCLIENT_CONFIG"; public const string ConfigEnv = "POSTCLIENT_CONFIG";
public const string DataEnv = "POSTCLIENT_DATA_DIR"; public const string DataEnv = "POSTCLIENT_DATA_DIR";
@ -22,20 +22,21 @@ public static class AppPaths {
return portable; return portable;
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ProductName); return WindowsProductDirectory();
if (OperatingSystem.IsMacOS()) { if (OperatingSystem.IsMacOS()) {
return Path.Combine( return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library", "Library",
"Application Support", "Application Support",
BrandFolder,
ProductName); ProductName);
} }
var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME");
if (string.IsNullOrWhiteSpace(xdg)) if (string.IsNullOrWhiteSpace(xdg))
xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config");
return Path.Combine(xdg, ProductId); return Path.Combine(xdg, BrandFolder, ProductId);
} }
public static string DataDirectory() { public static string DataDirectory() {
@ -48,7 +49,7 @@ public static class AppPaths {
return Path.Combine(portable, "data"); return Path.Combine(portable, "data");
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ProductName); return WindowsProductDirectory();
if (OperatingSystem.IsMacOS()) if (OperatingSystem.IsMacOS())
return ConfigDirectory(); return ConfigDirectory();
@ -56,7 +57,7 @@ public static class AppPaths {
var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
if (string.IsNullOrWhiteSpace(xdg)) if (string.IsNullOrWhiteSpace(xdg))
xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share"); 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() => public static string SettingsFile() =>
@ -127,6 +128,7 @@ public static class AppPaths {
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library", "Library",
"Logs", "Logs",
BrandFolder,
ProductName); ProductName);
} }
@ -134,6 +136,7 @@ public static class AppPaths {
} }
public static void EnsureDirectories() { public static void EnsureDirectories() {
MigrateLegacyLayout();
Directory.CreateDirectory(ConfigDirectory()); Directory.CreateDirectory(ConfigDirectory());
Directory.CreateDirectory(DataDirectory()); Directory.CreateDirectory(DataDirectory());
Directory.CreateDirectory(ObjectsDirectory()); Directory.CreateDirectory(ObjectsDirectory());
@ -164,4 +167,55 @@ public static class AppPaths {
return null; 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);
}
} }

View File

@ -94,6 +94,20 @@ public sealed class MailArchiveCatalog : IDisposable {
return 0; 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( public void UpdateFlags(
string mailboxId, string mailboxId,
string folder, string folder,
@ -114,14 +128,56 @@ public sealed class MailArchiveCatalog : IDisposable {
return 0; return 0;
}); });
public IReadOnlyList<EmbeddingWorkItem> PendingEmbeddings(string modelId, int limit) => public IReadOnlyList<EmbeddingWorkItem> PendingEmbeddings(
OffUi(() => Merge(store => store.PendingEmbeddings(modelId, limit))); string modelId,
int limit,
IReadOnlyCollection<string>? mailboxIds = null) =>
OffUi(() => {
if (mailboxIds is { Count: 0 })
return (IReadOnlyList<EmbeddingWorkItem>)[];
var rows = new List<EmbeddingWorkItem>();
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) => return rows;
OffUi(() => Sum(store => store.EmbeddingCount(modelId))); });
public int EmbeddingPendingCount(string modelId) => public int EmbeddingCount(string modelId, IReadOnlyCollection<string>? mailboxIds = null) =>
OffUi(() => Sum(store => store.EmbeddingPendingCount(modelId))); 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<string>? 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) => public void UpsertEmbedding(string mailboxId, long messageId, string modelId, float[] vector) =>
OffUi(() => { OffUi(() => {

View File

@ -8,6 +8,8 @@ public sealed class MailArchiveStore : IDisposable {
private readonly SqliteConnection _db; private readonly SqliteConnection _db;
private readonly Lock _gate = new(); private readonly Lock _gate = new();
public bool KeywordIndexEnabled { get; set; } = true;
public MailArchiveStore(string? path = null) { public MailArchiveStore(string? path = null) {
var file = string.IsNullOrWhiteSpace(path) ? AppPaths.ArchiveDatabase() : path; var file = string.IsNullOrWhiteSpace(path) ? AppPaths.ArchiveDatabase() : path;
var dir = Path.GetDirectoryName(file); var dir = Path.GetDirectoryName(file);
@ -186,6 +188,7 @@ public sealed class MailArchiveStore : IDisposable {
} }
public int RebuildKeywordIndex() { public int RebuildKeywordIndex() {
KeywordIndexEnabled = true;
lock (_gate) { lock (_gate) {
using var tx = _db.BeginTransaction(); using var tx = _db.BeginTransaction();
using (var del = _db.CreateCommand()) { using (var del = _db.CreateCommand()) {
@ -757,9 +760,18 @@ public sealed class MailArchiveStore : IDisposable {
} }
public IReadOnlyList<uint> UidsOlderThan(string mailboxId, string folder, DateTimeOffset cutoff) { public IReadOnlyList<uint> UidsOlderThan(string mailboxId, string folder, DateTimeOffset cutoff) {
var trash = MailRetention.IsTrash(folder);
lock (_gate) { lock (_gate) {
using var cmd = _db.CreateCommand(); using var cmd = _db.CreateCommand();
cmd.CommandText = """ 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 SELECT uid FROM messages
WHERE mailbox_id = $m AND folder = $f AND date_utc <> '' AND date_utc < $cut WHERE mailbox_id = $m AND folder = $f AND date_utc <> '' AND date_utc < $cut
ORDER BY date_utc; ORDER BY date_utc;
@ -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<string> DistinctMailboxIds() { public IReadOnlyList<string> DistinctMailboxIds() {
lock (_gate) { lock (_gate) {
using var cmd = _db.CreateCommand(); using var cmd = _db.CreateCommand();
@ -860,8 +881,8 @@ public sealed class MailArchiveStore : IDisposable {
cmd.CommandText = $""" cmd.CommandText = $"""
INSERT INTO messages ( INSERT INTO messages (
mailbox_id, folder, uid, message_id, subject, from_addr, date_utc, envelope_kind, envelope_badge, 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) 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) 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 ON CONFLICT(mailbox_id, folder, uid) DO UPDATE SET
message_id = excluded.message_id, message_id = excluded.message_id,
subject = excluded.subject, subject = excluded.subject,
@ -873,8 +894,15 @@ public sealed class MailArchiveStore : IDisposable {
in_reply_to = excluded.in_reply_to, in_reply_to = excluded.in_reply_to,
{flagAssign} {flagAssign}
has_attachments = excluded.has_attachments, 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("$m", mailboxId);
cmd.Parameters.AddWithValue("$f", header.Folder); cmd.Parameters.AddWithValue("$f", header.Folder);
cmd.Parameters.AddWithValue("$u", header.Uid); 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("$flag", header.IsFlagged ? 1 : 0);
cmd.Parameters.AddWithValue("$att", header.HasAttachments ? 1 : 0); cmd.Parameters.AddWithValue("$att", header.HasAttachments ? 1 : 0);
cmd.Parameters.AddWithValue("$pri", header.Priority); cmd.Parameters.AddWithValue("$pri", header.Priority);
cmd.Parameters.AddWithValue("$trashed", trashed);
cmd.Parameters.AddWithValue("$trash", trash ? 1 : 0);
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
using var idCmd = _db.CreateCommand(); using var idCmd = _db.CreateCommand();
idCmd.Transaction = tx; idCmd.Transaction = tx;
@ -908,6 +938,8 @@ public sealed class MailArchiveStore : IDisposable {
MailArchiveHeader header, MailArchiveHeader header,
string? bodyText, string? bodyText,
string? attachmentText) { string? attachmentText) {
if (!KeywordIndexEnabled)
return;
using (var del = _db.CreateCommand()) { using (var del = _db.CreateCommand()) {
del.Transaction = tx; del.Transaction = tx;
del.CommandText = "DELETE FROM messages_fts WHERE rowid = $id;"; del.CommandText = "DELETE FROM messages_fts WHERE rowid = $id;";
@ -1163,6 +1195,7 @@ public sealed class MailArchiveStore : IDisposable {
eml_path TEXT NOT NULL DEFAULT '', eml_path TEXT NOT NULL DEFAULT '',
body_text TEXT NOT NULL DEFAULT '', body_text TEXT NOT NULL DEFAULT '',
attachment_text TEXT NOT NULL DEFAULT '', attachment_text TEXT NOT NULL DEFAULT '',
trashed_utc TEXT NOT NULL DEFAULT '',
UNIQUE(mailbox_id, folder, uid) UNIQUE(mailbox_id, folder, uid)
); );
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
@ -1183,6 +1216,22 @@ public sealed class MailArchiveStore : IDisposable {
); );
"""; """;
cmd.ExecuteNonQuery(); 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();
} }
} }

View File

@ -16,6 +16,11 @@ public static class MailIndexProgress {
return left <= 0 ? copy.IndexingBusy : string.Format(copy.IndexingLeft, left); 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) => public static string MeaningLeft(int left) =>
string.Format(UiLocale.Copy.IndexingMeaningLeft, Math.Max(0, left)); string.Format(UiLocale.Copy.IndexingMeaningLeft, Math.Max(0, left));

View File

@ -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);
}
}

View File

@ -41,6 +41,8 @@ public sealed class MailboxAccount {
public string AuthKind { get; set; } = MailAuthKind.Password; public string AuthKind { get; set; } = MailAuthKind.Password;
public bool InitialSyncCompleted { get; set; }
[JsonIgnore] [JsonIgnore]
public bool TracksCertifiedReceipts => public bool TracksCertifiedReceipts =>
MailCertifiedKind.TracksReceipts(CertifiedKind); MailCertifiedKind.TracksReceipts(CertifiedKind);

View File

@ -96,6 +96,8 @@ public sealed partial class UiCopy {
public string Help { get; init; } = ""; public string Help { get; init; } = "";
public string Logs { get; init; } = "";
public string CreateStore { get; init; } = ""; public string CreateStore { get; init; } = "";
public string AttachStore { get; init; } = ""; public string AttachStore { get; init; } = "";
@ -118,6 +120,8 @@ public sealed partial class UiCopy {
public string RetentionDays { get; init; } = ""; public string RetentionDays { get; init; } = "";
public string RetentionDaysBadge { get; init; } = "";
public string RetentionAction { get; init; } = ""; public string RetentionAction { get; init; } = "";
public string RetentionToTrash { get; init; } = ""; public string RetentionToTrash { get; init; } = "";
@ -402,6 +406,8 @@ public sealed partial class UiCopy {
public string IndexingLeft { get; init; } = ""; public string IndexingLeft { get; init; } = "";
public string IndexingMeaning { get; init; } = "";
public string IndexingMeaningLeft { get; init; } = ""; public string IndexingMeaningLeft { get; init; } = "";
public string IndexingMeaningReady { get; init; } = ""; public string IndexingMeaningReady { get; init; } = "";
@ -440,6 +446,8 @@ public sealed partial class UiCopy {
public string EmptyFolder { get; init; } = ""; public string EmptyFolder { get; init; } = "";
public string DeleteAllItems { get; init; } = "";
public string MarkFolderRead { get; init; } = ""; public string MarkFolderRead { get; init; } = "";
public string MarkFolderUnread { get; init; } = ""; public string MarkFolderUnread { get; init; } = "";
@ -528,6 +536,41 @@ public sealed partial class UiCopy {
public string CertifiedRem { get; init; } = ""; 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) => public static UiCopy For(string? language) =>
UiLanguage.Normalize(language) switch { UiLanguage.Normalize(language) switch {
UiLanguage.It => Italian(), UiLanguage.It => Italian(),
@ -571,6 +614,7 @@ public sealed partial class UiCopy {
Message = "_Message", Message = "_Message",
Settings = "_Settings", Settings = "_Settings",
Help = "_Help", Help = "_Help",
Logs = "_Logs",
Features = "_Features…", 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.", 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", Feature = "Feature",
@ -624,6 +668,7 @@ public sealed partial class UiCopy {
Retention = "_Retention…", 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.", 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", RetentionDays = "Days",
RetentionDaysBadge = "{0}d",
RetentionAction = "When expired", RetentionAction = "When expired",
RetentionToTrash = "Move to Trash", RetentionToTrash = "Move to Trash",
RetentionPurge = "Delete permanently", RetentionPurge = "Delete permanently",
@ -766,6 +811,7 @@ public sealed partial class UiCopy {
IndexingWithFolder = "Indexing {0} · {1} / {2}", IndexingWithFolder = "Indexing {0} · {1} / {2}",
IndexingBusy = "Indexing…", IndexingBusy = "Indexing…",
IndexingLeft = "Indexing {0} left", IndexingLeft = "Indexing {0} left",
IndexingMeaning = "Indexing meaning {0} / {1}",
IndexingMeaningLeft = "Indexing meaning {0} left", IndexingMeaningLeft = "Indexing meaning {0} left",
IndexingMeaningReady = "Meaning index ready ({0})", IndexingMeaningReady = "Meaning index ready ({0})",
IndexingMeaningReadyCount = "Meaning index ready ({0}, {1})", IndexingMeaningReadyCount = "Meaning index ready ({0}, {1})",
@ -785,6 +831,7 @@ public sealed partial class UiCopy {
DeletingFolder = "Deleting folder…", DeletingFolder = "Deleting folder…",
NewFolder = "New folder…", NewFolder = "New folder…",
EmptyFolder = "Empty folder", EmptyFolder = "Empty folder",
DeleteAllItems = "Delete all items",
MarkFolderRead = "Mark all as read", MarkFolderRead = "Mark all as read",
MarkFolderUnread = "Mark all as unread", MarkFolderUnread = "Mark all as unread",
DeleteFolder = "Delete folder", DeleteFolder = "Delete folder",
@ -838,6 +885,7 @@ public sealed partial class UiCopy {
Message = "_Messaggio", Message = "_Messaggio",
Settings = "_Impostazioni", Settings = "_Impostazioni",
Help = "_Aiuto", Help = "_Aiuto",
Logs = "_Registro",
Features = "_Funzionalità…", 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.", 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", Feature = "Funzione",
@ -891,6 +939,7 @@ public sealed partial class UiCopy {
Retention = "_Conservazione…", 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.", 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", RetentionDays = "Giorni",
RetentionDaysBadge = "{0}g",
RetentionAction = "Alla scadenza", RetentionAction = "Alla scadenza",
RetentionToTrash = "Sposta nel Cestino", RetentionToTrash = "Sposta nel Cestino",
RetentionPurge = "Elimina in modo permanente", RetentionPurge = "Elimina in modo permanente",
@ -1033,6 +1082,7 @@ public sealed partial class UiCopy {
IndexingWithFolder = "Indicizzazione {0} · {1} / {2}", IndexingWithFolder = "Indicizzazione {0} · {1} / {2}",
IndexingBusy = "Indicizzazione…", IndexingBusy = "Indicizzazione…",
IndexingLeft = "Indicizzazione {0} rimanenti", IndexingLeft = "Indicizzazione {0} rimanenti",
IndexingMeaning = "Indicizzazione significato {0} / {1}",
IndexingMeaningLeft = "Indicizzazione significato {0} rimanenti", IndexingMeaningLeft = "Indicizzazione significato {0} rimanenti",
IndexingMeaningReady = "Indice significato pronto ({0})", IndexingMeaningReady = "Indice significato pronto ({0})",
IndexingMeaningReadyCount = "Indice significato pronto ({0}, {1})", IndexingMeaningReadyCount = "Indice significato pronto ({0}, {1})",
@ -1052,6 +1102,7 @@ public sealed partial class UiCopy {
DeletingFolder = "Eliminazione cartella…", DeletingFolder = "Eliminazione cartella…",
NewFolder = "Nuova cartella…", NewFolder = "Nuova cartella…",
EmptyFolder = "Svuota cartella", EmptyFolder = "Svuota cartella",
DeleteAllItems = "Elimina tutti gli elementi",
MarkFolderRead = "Segna tutte come lette", MarkFolderRead = "Segna tutte come lette",
MarkFolderUnread = "Segna tutte come non lette", MarkFolderUnread = "Segna tutte come non lette",
DeleteFolder = "Elimina cartella", DeleteFolder = "Elimina cartella",

View File

@ -9,6 +9,7 @@ public sealed partial class UiCopy {
Message = "_Nachricht", Message = "_Nachricht",
Settings = "_Einstellungen", Settings = "_Einstellungen",
Help = "_Hilfe", Help = "_Hilfe",
Logs = "_Protokoll",
Features = "_Funktionen…", 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.", 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", Feature = "Funktion",
@ -62,6 +63,7 @@ public sealed partial class UiCopy {
Retention = "_Aufbewahrung…", 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.", 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", RetentionDays = "Tage",
RetentionDaysBadge = "{0}T",
RetentionAction = "Nach Ablauf", RetentionAction = "Nach Ablauf",
RetentionToTrash = "In den Papierkorb", RetentionToTrash = "In den Papierkorb",
RetentionPurge = "Endgültig löschen", RetentionPurge = "Endgültig löschen",
@ -204,6 +206,7 @@ public sealed partial class UiCopy {
IndexingWithFolder = "Indizierung {0} · {1} / {2}", IndexingWithFolder = "Indizierung {0} · {1} / {2}",
IndexingBusy = "Indizierung…", IndexingBusy = "Indizierung…",
IndexingLeft = "Indizierung {0} übrig", IndexingLeft = "Indizierung {0} übrig",
IndexingMeaning = "Bedeutungsindex {0} / {1}",
IndexingMeaningLeft = "Bedeutungsindex {0} übrig", IndexingMeaningLeft = "Bedeutungsindex {0} übrig",
IndexingMeaningReady = "Bedeutungsindex bereit ({0})", IndexingMeaningReady = "Bedeutungsindex bereit ({0})",
IndexingMeaningReadyCount = "Bedeutungsindex bereit ({0}, {1})", IndexingMeaningReadyCount = "Bedeutungsindex bereit ({0}, {1})",
@ -223,6 +226,7 @@ public sealed partial class UiCopy {
DeletingFolder = "Ordner wird gelöscht…", DeletingFolder = "Ordner wird gelöscht…",
NewFolder = "Neuer Ordner…", NewFolder = "Neuer Ordner…",
EmptyFolder = "Ordner leeren", EmptyFolder = "Ordner leeren",
DeleteAllItems = "Alle Elemente löschen",
MarkFolderRead = "Alle als gelesen markieren", MarkFolderRead = "Alle als gelesen markieren",
MarkFolderUnread = "Alle als ungelesen markieren", MarkFolderUnread = "Alle als ungelesen markieren",
DeleteFolder = "Ordner löschen", DeleteFolder = "Ordner löschen",

View File

@ -9,6 +9,7 @@ public sealed partial class UiCopy {
Message = "_Mensaje", Message = "_Mensaje",
Settings = "Ajus_tes", Settings = "Ajus_tes",
Help = "A_yuda", Help = "A_yuda",
Logs = "_Registro",
Features = "_Funciones…", 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.", 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", Feature = "Función",
@ -62,6 +63,7 @@ public sealed partial class UiCopy {
Retention = "_Retención…", 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.", 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", RetentionDays = "Días",
RetentionDaysBadge = "{0}d",
RetentionAction = "Al caducar", RetentionAction = "Al caducar",
RetentionToTrash = "Mover a la Papelera", RetentionToTrash = "Mover a la Papelera",
RetentionPurge = "Eliminar de forma permanente", RetentionPurge = "Eliminar de forma permanente",
@ -204,6 +206,7 @@ public sealed partial class UiCopy {
IndexingWithFolder = "Indexación {0} · {1} / {2}", IndexingWithFolder = "Indexación {0} · {1} / {2}",
IndexingBusy = "Indexación…", IndexingBusy = "Indexación…",
IndexingLeft = "Indexación {0} restantes", IndexingLeft = "Indexación {0} restantes",
IndexingMeaning = "Indexación de significado {0} / {1}",
IndexingMeaningLeft = "Indexación de significado {0} restantes", IndexingMeaningLeft = "Indexación de significado {0} restantes",
IndexingMeaningReady = "Índice de significado listo ({0})", IndexingMeaningReady = "Índice de significado listo ({0})",
IndexingMeaningReadyCount = "Índice de significado listo ({0}, {1})", IndexingMeaningReadyCount = "Índice de significado listo ({0}, {1})",
@ -223,6 +226,7 @@ public sealed partial class UiCopy {
DeletingFolder = "Eliminando carpeta…", DeletingFolder = "Eliminando carpeta…",
NewFolder = "Nueva carpeta…", NewFolder = "Nueva carpeta…",
EmptyFolder = "Vaciar carpeta", EmptyFolder = "Vaciar carpeta",
DeleteAllItems = "Eliminar todos los elementos",
MarkFolderRead = "Marcar todo como leído", MarkFolderRead = "Marcar todo como leído",
MarkFolderUnread = "Marcar todo como no leído", MarkFolderUnread = "Marcar todo como no leído",
DeleteFolder = "Eliminar carpeta", DeleteFolder = "Eliminar carpeta",

View File

@ -9,6 +9,7 @@ public sealed partial class UiCopy {
Message = "_Message", Message = "_Message",
Settings = "_Paramètres", Settings = "_Paramètres",
Help = "_Aide", Help = "_Aide",
Logs = "_Journal",
Features = "_Fonctionnalités…", 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.", 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", Feature = "Fonction",
@ -62,6 +63,7 @@ public sealed partial class UiCopy {
Retention = "_Rétention…", 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.", 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", RetentionDays = "Jours",
RetentionDaysBadge = "{0}j",
RetentionAction = "À l’échéance", RetentionAction = "À l’échéance",
RetentionToTrash = "Déplacer vers la Corbeille", RetentionToTrash = "Déplacer vers la Corbeille",
RetentionPurge = "Supprimer définitivement", RetentionPurge = "Supprimer définitivement",
@ -204,6 +206,7 @@ public sealed partial class UiCopy {
IndexingWithFolder = "Indexation {0} · {1} / {2}", IndexingWithFolder = "Indexation {0} · {1} / {2}",
IndexingBusy = "Indexation…", IndexingBusy = "Indexation…",
IndexingLeft = "Indexation {0} restants", IndexingLeft = "Indexation {0} restants",
IndexingMeaning = "Indexation du sens {0} / {1}",
IndexingMeaningLeft = "Indexation du sens {0} restants", IndexingMeaningLeft = "Indexation du sens {0} restants",
IndexingMeaningReady = "Index de sens prêt ({0})", IndexingMeaningReady = "Index de sens prêt ({0})",
IndexingMeaningReadyCount = "Index de sens prêt ({0}, {1})", IndexingMeaningReadyCount = "Index de sens prêt ({0}, {1})",
@ -223,6 +226,7 @@ public sealed partial class UiCopy {
DeletingFolder = "Suppression du dossier…", DeletingFolder = "Suppression du dossier…",
NewFolder = "Nouveau dossier…", NewFolder = "Nouveau dossier…",
EmptyFolder = "Vider le dossier", EmptyFolder = "Vider le dossier",
DeleteAllItems = "Supprimer tous les éléments",
MarkFolderRead = "Tout marquer comme lu", MarkFolderRead = "Tout marquer comme lu",
MarkFolderUnread = "Tout marquer comme non lu", MarkFolderUnread = "Tout marquer comme non lu",
DeleteFolder = "Supprimer le dossier", DeleteFolder = "Supprimer le dossier",

View File

@ -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] [Fact]
public void ClearEmbeddings_LeavesKeywordAndPending() { public void ClearEmbeddings_LeavesKeywordAndPending() {
var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db");

View File

@ -161,8 +161,15 @@ public class EnvelopeUnwrapperTests {
public void Inspect_SavedPostclientObjects_IfPresent() { public void Inspect_SavedPostclientObjects_IfPresent() {
var dir = Path.Combine( var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MaksIT",
"Postclient", "Postclient",
"objects"); "objects");
if (!Directory.Exists(dir)) {
dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Postclient",
"objects");
}
if (!Directory.Exists(dir)) if (!Directory.Exists(dir))
return; return;
var files = Directory.GetFiles(dir, "*.eml"); var files = Directory.GetFiles(dir, "*.eml");

View File

@ -1,3 +1,4 @@
using MaksIT.PostClient.Client;
using MaksIT.PostClient.Shared; 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 { public class SemanticSearchSettingsTests {
[Fact] [Fact]
public void Normalize_KeepsEnabledAndAuto() { public void Normalize_KeepsEnabledAndAuto() {

View File

@ -81,10 +81,11 @@ public class ErrorReportTests {
public class AppPathsTests { public class AppPathsTests {
[Fact] [Fact]
public void ProductFolderIsPlaceholderBrandNotMaksIt() { public void ProductFolderUsesMaksItBrand() {
Assert.Equal("Postclient", AppPaths.ProductName); Assert.Equal("Postclient", AppPaths.ProductName);
Assert.Equal("postclient", AppPaths.ProductId); 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("webview", AppPaths.WebViewDirectory(), StringComparison.OrdinalIgnoreCase);
Assert.EndsWith("models", AppPaths.ModelsDirectory(), 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("Indexing meaning 12 left", MailIndexProgress.MeaningLeft(12));
Assert.Equal("Meaning index ready (CPU)", MailIndexProgress.MeaningReady("CPU", 0)); Assert.Equal("Meaning index ready (CPU)", MailIndexProgress.MeaningReady("CPU", 0));
Assert.Equal("Meaning index ready (GPU, 40)", MailIndexProgress.MeaningReady("GPU", 40)); 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);
} }
} }

View File

@ -515,7 +515,7 @@ public class LocalStoreTests {
files.Save(files.Current); files.Save(files.Current);
using var purgeHost = new MailWorkerHost(catalog, files); using var purgeHost = new MailWorkerHost(catalog, files);
purgeHost.Retention(new WorkerRequest { Op = "retention" }); purgeHost.Retention(new WorkerRequest { Op = "retention" });
Assert.Empty(catalog.Search("box", MailRetention.TrashFolder, "IMU")); Assert.Single(catalog.Search("box", MailRetention.TrashFolder, "IMU"));
} }
finally { finally {
Directory.Delete(root, recursive: true); Directory.Delete(root, recursive: true);

View File

@ -16,8 +16,12 @@
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Status}" Classes="hint" TextTrimming="CharacterEllipsis" /> <TextBlock Text="{Binding Status}" Classes="hint" TextTrimming="CharacterEllipsis" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="16"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="16">
<TextBlock Text="{Binding SemanticLine}" Classes="hint" /> <TextBlock Text="{Binding SemanticLine}"
<TextBlock Text="{Binding IndexLine}" Classes="hint" /> Classes="hint"
IsVisible="{Binding ShowSemanticLine}" />
<TextBlock Text="{Binding IndexLine}"
Classes="hint"
IsVisible="{Binding ShowIndexLine}" />
<TextBlock Text="{Binding FolderStats}" Classes="hint" /> <TextBlock Text="{Binding FolderStats}" Classes="hint" />
</StackPanel> </StackPanel>
</Grid> </Grid>
@ -130,6 +134,7 @@
</MenuItem> </MenuItem>
<MenuItem Header="{Binding Copy.Help}"> <MenuItem Header="{Binding Copy.Help}">
<MenuItem Header="{Binding Copy.CheckUpdates}" Command="{Binding CheckForUpdatesCommand}" /> <MenuItem Header="{Binding Copy.CheckUpdates}" Command="{Binding CheckForUpdatesCommand}" />
<MenuItem Header="{Binding Copy.Logs}" Command="{Binding ShowLogsCommand}" />
<MenuItem Header="{Binding Copy.About}" Command="{Binding ShowAboutCommand}" /> <MenuItem Header="{Binding Copy.About}" Command="{Binding ShowAboutCommand}" />
</MenuItem> </MenuItem>
</Menu> </Menu>
@ -171,7 +176,7 @@
<MenuItem Header="{Binding Copy.NewFolder}" <MenuItem Header="{Binding Copy.NewFolder}"
Command="{Binding CreateFolderCommand}" Command="{Binding CreateFolderCommand}"
IsVisible="{Binding ShowFolderContext}" /> IsVisible="{Binding ShowFolderContext}" />
<MenuItem Header="{Binding Copy.EmptyFolder}" <MenuItem Header="{Binding EmptyFolderLabel}"
Command="{Binding EmptyFolderCommand}" Command="{Binding EmptyFolderCommand}"
IsVisible="{Binding ShowFolderContext}" /> IsVisible="{Binding ShowFolderContext}" />
<MenuItem Header="{Binding Copy.MarkFolderRead}" <MenuItem Header="{Binding Copy.MarkFolderRead}"
@ -194,7 +199,7 @@
</TreeView.ContextFlyout> </TreeView.ContextFlyout>
<TreeView.ItemTemplate> <TreeView.ItemTemplate>
<TreeDataTemplate ItemsSource="{Binding Children}"> <TreeDataTemplate ItemsSource="{Binding Children}">
<Grid ColumnDefinitions="20,*,Auto" Margin="0,1" HorizontalAlignment="Stretch"> <Grid ColumnDefinitions="20,*,Auto,Auto" Margin="0,1" HorizontalAlignment="Stretch">
<TextBlock Text="{Binding Glyph}" <TextBlock Text="{Binding Glyph}"
FontSize="12" FontSize="12"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
@ -215,6 +220,12 @@
Margin="8,0,4,0" Margin="8,0,4,0"
VerticalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding HasUnread}" /> IsVisible="{Binding HasUnread}" />
<TextBlock Grid.Column="3"
Text="{Binding RetentionLabel}"
Classes="hint"
Margin="0,0,4,0"
VerticalAlignment="Center"
IsVisible="{Binding HasRetention}" />
</Grid> </Grid>
</TreeDataTemplate> </TreeDataTemplate>
</TreeView.ItemTemplate> </TreeView.ItemTemplate>
@ -472,8 +483,7 @@
Classes.selected="{Binding IsFatturaKind}" Classes.selected="{Binding IsFatturaKind}"
Command="{Binding SetReadingKindCommand}" Command="{Binding SetReadingKindCommand}"
CommandParameter="fattura" CommandParameter="fattura"
IsVisible="{Binding UseFatturaPa}" IsVisible="{Binding UseFatturaPa}" />
IsEnabled="{Binding ShowFatturaPaTools}" />
</StackPanel> </StackPanel>
<ItemsControl ItemsSource="{Binding ReadingFiles}" IsVisible="{Binding HasReadingFiles}"> <ItemsControl ItemsSource="{Binding ReadingFiles}" IsVisible="{Binding HasReadingFiles}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
@ -524,7 +534,9 @@
Text="{Binding ReadingSource, Mode=OneWay}" Text="{Binding ReadingSource, Mode=OneWay}"
IsReadOnly="True" IsReadOnly="True"
AcceptsReturn="True" AcceptsReturn="True"
TextWrapping="Wrap" /> TextWrapping="NoWrap"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
<TextBox Classes="reading-text" <TextBox Classes="reading-text"
IsVisible="{Binding ShowFatturaBody}" IsVisible="{Binding ShowFatturaBody}"
Text="{Binding ReadingFattura, Mode=OneWay}" Text="{Binding ReadingFattura, Mode=OneWay}"

View File

@ -79,6 +79,7 @@ public partial class MainWindow : Window {
viewModel.FolderTreeSelectionRestoreRequested += RestoreFolderTreeSelection; viewModel.FolderTreeSelectionRestoreRequested += RestoreFolderTreeSelection;
viewModel.RulesRequested += OnRulesRequested; viewModel.RulesRequested += OnRulesRequested;
viewModel.AboutRequested += OnAboutRequested; viewModel.AboutRequested += OnAboutRequested;
viewModel.LogsRequested += OnLogsRequested;
viewModel.AccountSaved += OnAccountSaved; viewModel.AccountSaved += OnAccountSaved;
viewModel.ExportArchiveRequested += OnExportArchive; viewModel.ExportArchiveRequested += OnExportArchive;
viewModel.ExportFascicoloRequested += OnExportFascicolo; viewModel.ExportFascicoloRequested += OnExportFascicolo;
@ -688,6 +689,8 @@ public partial class MainWindow : Window {
private async void OnRetentionRequested() { private async void OnRetentionRequested() {
if (_files is null || DataContext is not MainViewModel vm) if (_files is null || DataContext is not MainViewModel vm)
return; return;
foreach (var box in vm.Mailboxes)
await vm.EnsureFoldersForRulesAsync(box.Id);
var window = new RetentionWindow { var window = new RetentionWindow {
DataContext = new RetentionViewModel( DataContext = new RetentionViewModel(
_files, _files,
@ -697,6 +700,7 @@ public partial class MainWindow : Window {
WindowStartupLocation = WindowStartupLocation.CenterOwner WindowStartupLocation = WindowStartupLocation.CenterOwner
}; };
await window.ShowDialog(this); await window.ShowDialog(this);
vm.RefreshRetentionBadges();
} }
private async void OnIdentityHubSignIn(string kind) { private async void OnIdentityHubSignIn(string kind) {
@ -776,6 +780,9 @@ public partial class MainWindow : Window {
private void OnAboutRequested() => private void OnAboutRequested() =>
_ = AboutWindow.ShowAsync(this); _ = AboutWindow.ShowAsync(this);
private void OnLogsRequested() =>
_ = LogWindow.ShowAsync(this);
private async Task<string?> PickFolder(string title, string? suggested = null) { private async Task<string?> PickFolder(string title, string? suggested = null) {
IStorageFolder? start = null; IStorageFolder? start = null;
if (!string.IsNullOrWhiteSpace(suggested)) { if (!string.IsNullOrWhiteSpace(suggested)) {

View File

@ -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<string> 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<string>()
.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;
}
}
}

View File

@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input;
using MaksIT.Results; using MaksIT.Results;
using MaksIT.PostClient.Client; using MaksIT.PostClient.Client;
using MaksIT.PostClient.Shared; using MaksIT.PostClient.Shared;
using MaksIT.PostClient.UI;
namespace MaksIT.PostClient.UI.ViewModels; namespace MaksIT.PostClient.UI.ViewModels;
@ -36,6 +37,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private readonly HashSet<string> _catalogDone = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _catalogDone = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _catalogFolders = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _catalogFolders = new(StringComparer.OrdinalIgnoreCase);
private const int IndexCatalogChunk = 2_000; private const int IndexCatalogChunk = 2_000;
private const int IndexUiChunk = 250;
private int _indexBodyTotal;
private bool _uiReady; private bool _uiReady;
private MailMessageBody? _reading; private MailMessageBody? _reading;
private MessageRowViewModel? _pendingWindow; private MessageRowViewModel? _pendingWindow;
@ -210,9 +213,11 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private string practiceLabel = ""; private string practiceLabel = "";
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowIndexLine))]
private string indexLine = ""; private string indexLine = "";
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowSemanticLine))]
private string semanticLine = ""; private string semanticLine = "";
[ObservableProperty] [ObservableProperty]
@ -382,6 +387,25 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
public event Action? AboutRequested; 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<PromptRequest, Task<string?>>? PromptRequested; public event Func<PromptRequest, Task<string?>>? PromptRequested;
public event Action? ExportArchiveRequested; public event Action? ExportArchiveRequested;
@ -458,8 +482,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
editorGoogleClientSecret = StoredGoogleClientSecret(); editorGoogleClientSecret = StoredGoogleClientSecret();
editorMicrosoftClientId = files.Current.MicrosoftClientId; editorMicrosoftClientId = files.Current.MicrosoftClientId;
ReloadMailboxes(); ReloadMailboxes();
MarkExistingSyncComplete();
semanticLine = semantic.StatusLine; semanticLine = semantic.StatusLine;
semantic.Changed += OnSemanticChanged; semantic.Changed += OnSemanticChanged;
semantic.Faulted += OnSemanticFaulted;
} }
public void StartBackgroundWork() { public void StartBackgroundWork() {
@ -484,6 +510,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
_readingLoad = null; _readingLoad = null;
_work?.Cancel(); _work?.Cancel();
_semantic.Changed -= OnSemanticChanged; _semantic.Changed -= OnSemanticChanged;
_semantic.Faulted -= OnSemanticFaulted;
_semantic.Dispose(); _semantic.Dispose();
List<IMailSession> sessions; List<IMailSession> sessions;
lock (_connections) { lock (_connections) {
@ -498,6 +525,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
_worker.Dispose(); _worker.Dispose();
} }
private void OnSemanticFaulted(Exception exception) =>
Dispatcher.UIThread.Post(() => ErrorDialog.Report(exception));
private void OnSemanticChanged() => private void OnSemanticChanged() =>
Dispatcher.UIThread.Post(() => { Dispatcher.UIThread.Post(() => {
var line = _semantic.StatusLine; var line = _semantic.StatusLine;
@ -550,6 +580,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private void ShowAbout() => private void ShowAbout() =>
AboutRequested?.Invoke(); AboutRequested?.Invoke();
[RelayCommand]
private void ShowLogs() =>
LogsRequested?.Invoke();
[RelayCommand(CanExecute = nameof(CanCheckForUpdates))] [RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
private async Task CheckForUpdatesAsync() { private async Task CheckForUpdatesAsync() {
_updateBusy = true; _updateBusy = true;
@ -1384,6 +1418,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
&& (SelectedFolderNode is null || !SelectedFolderNodes.Contains(SelectedFolderNode))) && (SelectedFolderNode is null || !SelectedFolderNodes.Contains(SelectedFolderNode)))
SelectedFolderNode = SelectedFolderNodes[0]; SelectedFolderNode = SelectedFolderNodes[0];
NotifyFolderCommands(); NotifyFolderCommands();
OnPropertyChanged(nameof(EmptyFolderLabel));
} }
public IReadOnlyList<uint> DragMessageIds(MessageRowViewModel source) { public IReadOnlyList<uint> DragMessageIds(MessageRowViewModel source) {
@ -1619,6 +1654,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
var rows = TargetRows(); var rows = TargetRows();
if (rows.Count == 0) if (rows.Count == 0)
return; return;
if (VisibleMessages.Count > 0
&& rows.Count >= VisibleMessages.Count
&& rows.Count >= Messages.Count)
rows = VisibleMessages.ToList();
var box = SelectedMailbox; var box = SelectedMailbox;
var folder = SelectedFolder; var folder = SelectedFolder;
if (box is null || folder is null) if (box is null || folder is null)
@ -1667,14 +1706,14 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private async Task EmptyFolderAsync() { private async Task EmptyFolderAsync() {
var session = ActiveSession; var session = ActiveSession;
var box = SelectedMailbox; 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) if (session is not { IsConnected: true, SupportsFolders: true } || box is null || folder is null)
return; return;
var trash = await EnsureTrashFolderAsync(box, CancellationToken.None); var trash = await EnsureTrashFolderAsync(box, CancellationToken.None);
var purge = string.IsNullOrWhiteSpace(trash) var purge = string.IsNullOrWhiteSpace(trash)
|| trash.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase); || trash.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase);
var confirm = string.Format(purge ? Copy.EmptyTrashConfirm : Copy.EmptyFolderConfirm, folder.Name); 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; return;
await RunAsync(Copy.EmptyingFolder, async token => { await RunAsync(Copy.EmptyingFolder, async token => {
var result = await session.EmptyFolderAsync( var result = await session.EmptyFolderAsync(
@ -1822,6 +1861,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
uids is null uids is null
? _archive.RemoveFolder(mailboxId, folder) ? _archive.RemoveFolder(mailboxId, folder)
: _archive.RemoveUids(mailboxId, folder, uids)); : _archive.RemoveUids(mailboxId, folder, uids));
if (IsInitialSyncComplete(mailboxId))
_semantic.NotifySettingsChanged(); _semantic.NotifySettingsChanged();
} }
@ -1957,7 +1997,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private bool CanOrganize() => private bool CanOrganize() =>
!IsBusy && HasMessageSelection() && SelectedMailbox is not null !IsBusy && HasMessageSelection() && SelectedMailbox is not null
&& (ActiveSession is { SupportsFolders: true } || SelectedMailbox.IsLocalStore); && (ActiveSession is { IsConnected: true, SupportsFolders: true } || SelectedMailbox.IsLocalStore);
private bool CanManageFolders() => private bool CanManageFolders() =>
CanSessionFolders() && SelectedMailbox is not null; CanSessionFolders() && SelectedMailbox is not null;
@ -1965,10 +2005,14 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private bool CanCreateFolder() => private bool CanCreateFolder() =>
CanManageFolders(); CanManageFolders();
private bool CanEmptyFolder() => private bool CanEmptyFolder() {
CanManageFolders() var folder = TargetFolderNodes().FirstOrDefault()?.Folder ?? SelectedFolder;
&& SelectedFolder is not null if (folder is null || MailFolderRole.IsNamespace(folder.Name, folder.FullName))
&& !MailFolderRole.IsNamespace(SelectedFolder.Name, SelectedFolder.FullName); return false;
if (SelectedMailbox is { IsLocalStore: true } && !IsBusy)
return ActiveSession is { IsConnected: true, SupportsFolders: true };
return CanManageFolders();
}
private bool CanDeleteCustomFolder() => private bool CanDeleteCustomFolder() =>
!IsBusy && TargetCustomFolderRoots().Count > 0; !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)); var box = Mailboxes.FirstOrDefault(m => m.Id.Equals(mailboxId, StringComparison.OrdinalIgnoreCase));
foreach (var folder in FoldersFor(box)) foreach (var folder in FoldersFor(box))
Add(folder.Name, folder.FullName); 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; return rows;
} }
@ -3086,7 +3125,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
if (session is PstMailSession pst) if (session is PstMailSession pst)
pst.StorePathChanged += path => PersistPstStorePath(box, path); pst.StorePathChanged += path => PersistPstStorePath(box, path);
await LoadFoldersAsync(box, session, token).ConfigureAwait(false); await LoadFoldersAsync(box, session, token).ConfigureAwait(false);
await UiAsync(() => EnsureIndexing(box.Id)).ConfigureAwait(false); await UiAsync(() => {
EnsureIndexing(box.Id);
NotifyFolderCommands();
}).ConfigureAwait(false);
return true; return true;
} }
finally { finally {
@ -3117,6 +3159,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
} }
private void ApplyFolderSync(string mailboxId, string folder, MailFolderSync sync) { private void ApplyFolderSync(string mailboxId, string folder, MailFolderSync sync) {
var ready = IsInitialSyncComplete(mailboxId);
_archive.SetKeywordIndexEnabled(mailboxId, ready);
if (sync.Flags.Count > 0) if (sync.Flags.Count > 0)
_archive.UpdateFlags(mailboxId, folder, sync.Flags.Select(f => (f.Id, f.IsSeen, f.IsFlagged))); _archive.UpdateFlags(mailboxId, folder, sync.Flags.Select(f => (f.Id, f.IsSeen, f.IsFlagged)));
_archive.UpsertHeaders(mailboxId, sync.Headers.Select(MailArchiveMap.FromHeader)); _archive.UpsertHeaders(mailboxId, sync.Headers.Select(MailArchiveMap.FromHeader));
@ -3127,6 +3171,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
DropArchived(mailboxId, folder, gone); DropArchived(mailboxId, folder, gone);
} }
if (ready)
_semantic.NotifySettingsChanged(); _semantic.NotifySettingsChanged();
} }
@ -3143,8 +3188,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
PauseIndexing(); PauseIndexing();
try { try {
var painted = false;
while (true) {
var known = await Task.Run(() => _archive.Uids(box.Id, folder), token).ConfigureAwait(false); var known = await Task.Run(() => _archive.Uids(box.Id, folder), token).ConfigureAwait(false);
var result = await session.ListMessagesAsync(folder, known, token).ConfigureAwait(false); var result = await session.ListMessagesAsync(folder, known, token, IndexUiChunk).ConfigureAwait(false);
if (!result.IsSuccess) { if (!result.IsSuccess) {
await UiAsync(() => await UiAsync(() =>
Status = string.Join(" ", result.Messages) + " Showing local archive. " + ArchiveFiles.Hint()) Status = string.Join(" ", result.Messages) + " Showing local archive. " + ArchiveFiles.Hint())
@ -3164,13 +3211,26 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
} }
await ApplyIncomingRulesAsync(folder, fresh, token).ConfigureAwait(false); await ApplyIncomingRulesAsync(folder, fresh, token).ConfigureAwait(false);
await ShowArchiveAsync(box.Id, folder, 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;
}
await UiAsync(() => MarkFolderCataloged(box, folder, session)).ConfigureAwait(false);
if (TracksCertifiedReceipts(box) && MailFolderRole.Kind(null, folder) == "sent") { if (TracksCertifiedReceipts(box) && MailFolderRole.Kind(null, folder) == "sent") {
await IngestReceiptFoldersAsync(token).ConfigureAwait(false); await IngestReceiptFoldersAsync(token).ConfigureAwait(false);
await UiAsync(OverlaySentMarks).ConfigureAwait(false); await UiAsync(OverlaySentMarks).ConfigureAwait(false);
} }
await UiAsync(() => Status = ArchiveFiles.Hint()).ConfigureAwait(false); await UiAsync(() => Status = ArchiveFiles.Hint()).ConfigureAwait(false);
if (resumeBodies) if (resumeBodies && IsInitialSyncComplete(box.Id))
await UiAsync(() => EnsureBodyBackfill(box.Id)).ConfigureAwait(false); await UiAsync(() => EnsureBodyBackfill(box.Id)).ConfigureAwait(false);
} }
finally { finally {
@ -3623,6 +3683,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
path, path,
MailArchiveMap.BodyText(body, UnwrapEnvelope), MailArchiveMap.BodyText(body, UnwrapEnvelope),
MailArchiveMap.AttachmentIndex(body, UnwrapEnvelope)); MailArchiveMap.AttachmentIndex(body, UnwrapEnvelope));
if (IsInitialSyncComplete(mailboxId))
_semantic.NotifySettingsChanged(); _semantic.NotifySettingsChanged();
} }
catch { catch {
@ -3738,7 +3799,6 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
if (snapshot.Count == 0) if (snapshot.Count == 0)
break; break;
var ids = snapshot.Select(s => s.Id).ToList();
var sessions = snapshot.ToDictionary( var sessions = snapshot.ToDictionary(
s => s.Id, s => s.Id,
s => s.Session, s => s.Session,
@ -3759,7 +3819,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
pendingFolders = true; pendingFolders = true;
cataloged = true; cataloged = true;
await WaitIfIndexingPausedAsync(token).ConfigureAwait(false); 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); .ConfigureAwait(false);
var known = _archive.Uids(box.Id, folder.FullName); var known = _archive.Uids(box.Id, folder.FullName);
var listed = await box.Session var listed = await box.Session
@ -3773,26 +3836,37 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
await UiAsync(() => RefreshIndexedFolderCounts(box.Id, folder.FullName, counts)) await UiAsync(() => RefreshIndexedFolderCounts(box.Id, folder.FullName, counts))
.ConfigureAwait(false); .ConfigureAwait(false);
if (!sync.Incomplete) { if (!sync.Incomplete) {
await UiAsync(() => _catalogFolders.Add(CatalogKey(box.Id, folder.FullName))) await UiAsync(() => {
.ConfigureAwait(false); _catalogFolders.Add(CatalogKey(box.Id, folder.FullName));
TryCompleteInitialSync(MailboxById(box.Id));
}).ConfigureAwait(false);
} }
await Task.Delay(15, token).ConfigureAwait(false); await Task.Delay(15, token).ConfigureAwait(false);
} }
if (!pendingFolders) if (!pendingFolders && box.Folders.All(f => box.CatalogedFolders.Contains(f.FullName)))
await UiAsync(() => _catalogDone.Add(box.Id)).ConfigureAwait(false); await UiAsync(() => TryCompleteInitialSync(MailboxById(box.Id))).ConfigureAwait(false);
} }
await WaitIfIndexingPausedAsync(token).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 (pending.Count == 0) {
if (!cataloged) if (!cataloged)
break; break;
await SetIndexLineAsync("", token).ConfigureAwait(false);
continue; 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); .ConfigureAwait(false);
var fetched = 0; var fetched = 0;
foreach (var item in pending) { foreach (var item in pending) {
@ -3834,6 +3908,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
} }
IndexLine = ""; IndexLine = "";
_indexBodyTotal = 0;
if (_indexMailboxes.Any(id => SessionFor(MailboxById(id)) is { IsConnected: true })) if (_indexMailboxes.Any(id => SessionFor(MailboxById(id)) is { IsConnected: true }))
StartIndexing(); StartIndexing();
}); });
@ -3938,6 +4013,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
DetachPstCommand.NotifyCanExecuteChanged(); DetachPstCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(ShowFolderContext)); OnPropertyChanged(nameof(ShowFolderContext));
OnPropertyChanged(nameof(ShowDetachPst)); OnPropertyChanged(nameof(ShowDetachPst));
OnPropertyChanged(nameof(EmptyFolderLabel));
} }
partial void OnHasReadingFilesChanged(bool value) => partial void OnHasReadingFilesChanged(bool value) =>
@ -4180,8 +4256,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
Action<MessageRowViewModel>? apply, Action<MessageRowViewModel>? apply,
IReadOnlyList<MessageRowViewModel>? rows = null) { IReadOnlyList<MessageRowViewModel>? rows = null) {
var session = ActiveSession; 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; return false;
}
var targets = rows ?? TargetRows(); var targets = rows ?? TargetRows();
if (targets.Count == 0) if (targets.Count == 0)
return false; return false;
@ -4289,12 +4367,35 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private void NotifyFolderCounts() { private void NotifyFolderCounts() {
foreach (var account in FolderTree) { foreach (var account in FolderTree) {
ApplyRetentionBadge(account);
foreach (var child in account.Children) foreach (var child in account.Children)
child.NotifyCounts(); child.NotifyCounts();
account.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() { private void RefreshFolderStats() {
if (SelectedFolder is null) { if (SelectedFolder is null) {
FolderStats = ""; FolderStats = "";
@ -4358,6 +4459,57 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private static string CatalogKey(string mailboxId, string folder) => private static string CatalogKey(string mailboxId, string folder) =>
mailboxId + "\n" + 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) { private async Task UiAsync(Action work) {
if (Dispatcher.UIThread.CheckAccess()) if (Dispatcher.UIThread.CheckAccess())
work(); work();

View File

@ -152,6 +152,14 @@ public sealed partial class FolderNodeViewModel : ObservableObject {
public bool HasUnread => public bool HasUnread =>
Unread > 0; 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 MailboxQuota? Quota { get; set; }
public bool ShowQuota => public bool ShowQuota =>
@ -172,6 +180,8 @@ public sealed partial class FolderNodeViewModel : ObservableObject {
OnPropertyChanged(nameof(UnreadLabel)); OnPropertyChanged(nameof(UnreadLabel));
OnPropertyChanged(nameof(HasUnread)); OnPropertyChanged(nameof(HasUnread));
OnPropertyChanged(nameof(Weight)); OnPropertyChanged(nameof(Weight));
OnPropertyChanged(nameof(RetentionLabel));
OnPropertyChanged(nameof(HasRetention));
} }
public void NotifyQuota() { public void NotifyQuota() {

View File

@ -16,7 +16,7 @@ public partial class AboutWindow : Window {
public static Task ShowAsync(Window owner) { public static Task ShowAsync(Window owner) {
var copy = UiLocale.Copy; var copy = UiLocale.Copy;
var window = new AboutWindow { var window = new AboutWindow {
Title = copy.About.Replace("_", "", StringComparison.Ordinal), Title = copy.AboutTitle,
DataContext = new AboutViewModel() DataContext = new AboutViewModel()
}; };
return window.ShowDialog(owner); return window.ShowDialog(owner);

View File

@ -3,7 +3,7 @@
xmlns:vm="using:MaksIT.PostClient.UI.ViewModels" xmlns:vm="using:MaksIT.PostClient.UI.ViewModels"
x:Class="MaksIT.PostClient.UI.Windows.AccountSettingsWindow" x:Class="MaksIT.PostClient.UI.Windows.AccountSettingsWindow"
x:DataType="vm:MainViewModel" x:DataType="vm:MainViewModel"
Title="{Binding Copy.AccountSettings}" Title="{Binding Copy.AccountSettingsTitle}"
Icon="/Assets/icon.ico" Icon="/Assets/icon.ico"
Width="560" Width="560"
Height="740" Height="740"

View File

@ -3,7 +3,7 @@
xmlns:vm="using:MaksIT.PostClient.UI.ViewModels" xmlns:vm="using:MaksIT.PostClient.UI.ViewModels"
x:Class="MaksIT.PostClient.UI.Windows.FeatureSettingsWindow" x:Class="MaksIT.PostClient.UI.Windows.FeatureSettingsWindow"
x:DataType="vm:FeatureMatrixViewModel" x:DataType="vm:FeatureMatrixViewModel"
Title="{Binding Copy.Features}" Title="{Binding Copy.FeaturesTitle}"
Icon="/Assets/icon.ico" Icon="/Assets/icon.ico"
Width="940" Width="940"
Height="560" Height="560"

View File

@ -0,0 +1,46 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MaksIT.PostClient.UI.ViewModels"
x:Class="MaksIT.PostClient.UI.Windows.LogWindow"
x:DataType="vm:LogViewModel"
Width="860"
Height="560"
MinWidth="560"
MinHeight="360"
CanResize="True"
WindowStartupLocation="CenterOwner"
Icon="/Assets/icon.ico"
Title="{Binding Copy.LogsTitle}">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Bottom"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8"
Margin="0,12,0,0">
<TextBlock Text="{Binding CopyStatus}"
Classes="hint"
VerticalAlignment="Center"
Margin="0,0,8,0" />
<Button Content="{Binding Copy.CopyDetails}"
Classes="ghost"
Click="OnCopyClick" />
<Button Content="{Binding Copy.Close}"
Classes="accent"
Padding="16,8"
Click="OnCloseClick"
IsDefault="True"
IsCancel="True" />
</StackPanel>
<ComboBox DockPanel.Dock="Top"
ItemsSource="{Binding Files}"
SelectedItem="{Binding SelectedFile}"
Margin="0,0,0,12" />
<TextBox Text="{Binding Report}"
Classes="error-dump"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="NoWrap"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
</DockPanel>
</Window>

View File

@ -0,0 +1,39 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Input.Platform;
using MaksIT.PostClient.UI.ViewModels;
namespace MaksIT.PostClient.UI.Windows;
public partial class LogWindow : Window {
public LogWindow() {
InitializeComponent();
}
public static Task ShowAsync(Window owner) {
var window = new LogWindow {
DataContext = new LogViewModel(),
WindowStartupLocation = WindowStartupLocation.CenterOwner
};
return window.ShowDialog(owner);
}
private async void OnCopyClick(object? sender, RoutedEventArgs e) {
if (DataContext is not LogViewModel vm)
return;
var clipboard = Clipboard;
if (clipboard is null)
return;
try {
await clipboard.SetTextAsync(vm.Report);
vm.MarkCopied();
}
catch {
}
}
private void OnCloseClick(object? sender, RoutedEventArgs e) =>
Close();
}

View File

@ -74,8 +74,7 @@
Classes.selected="{Binding IsFatturaKind}" Classes.selected="{Binding IsFatturaKind}"
Command="{Binding SetReadingKindCommand}" Command="{Binding SetReadingKindCommand}"
CommandParameter="fattura" CommandParameter="fattura"
IsVisible="{Binding UseFatturaPa}" IsVisible="{Binding UseFatturaPa}" />
IsEnabled="{Binding ShowFatturaPaTools}" />
</StackPanel> </StackPanel>
<ItemsControl ItemsSource="{Binding ReadingFiles}" IsVisible="{Binding HasReadingFiles}"> <ItemsControl ItemsSource="{Binding ReadingFiles}" IsVisible="{Binding HasReadingFiles}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
@ -126,7 +125,9 @@
Text="{Binding ReadingSource, Mode=OneWay}" Text="{Binding ReadingSource, Mode=OneWay}"
IsReadOnly="True" IsReadOnly="True"
AcceptsReturn="True" AcceptsReturn="True"
TextWrapping="Wrap" /> TextWrapping="NoWrap"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
<TextBox Classes="reading-text" <TextBox Classes="reading-text"
IsVisible="{Binding ShowFatturaBody}" IsVisible="{Binding ShowFatturaBody}"
Text="{Binding ReadingFattura, Mode=OneWay}" Text="{Binding ReadingFattura, Mode=OneWay}"

View File

@ -4,7 +4,7 @@
x:Class="MaksIT.PostClient.UI.Windows.RetentionWindow" x:Class="MaksIT.PostClient.UI.Windows.RetentionWindow"
x:Name="Root" x:Name="Root"
x:DataType="vm:RetentionViewModel" x:DataType="vm:RetentionViewModel"
Title="{Binding Copy.Retention}" Title="{Binding Copy.RetentionTitle}"
Icon="/Assets/icon.ico" Icon="/Assets/icon.ico"
Width="720" Width="720"
Height="560" Height="560"

View File

@ -4,7 +4,7 @@
x:Class="MaksIT.PostClient.UI.Windows.RulesWindow" x:Class="MaksIT.PostClient.UI.Windows.RulesWindow"
x:Name="Root" x:Name="Root"
x:DataType="vm:RulesViewModel" x:DataType="vm:RulesViewModel"
Title="{Binding Copy.Rules}" Title="{Binding Copy.RulesTitle}"
Icon="/Assets/icon.ico" Icon="/Assets/icon.ico"
Width="860" Width="860"
Height="620" Height="620"

View File

@ -3,7 +3,7 @@
xmlns:vm="using:MaksIT.PostClient.UI.ViewModels" xmlns:vm="using:MaksIT.PostClient.UI.ViewModels"
x:Class="MaksIT.PostClient.UI.Windows.SemanticSearchWindow" x:Class="MaksIT.PostClient.UI.Windows.SemanticSearchWindow"
x:DataType="vm:SemanticSearchViewModel" x:DataType="vm:SemanticSearchViewModel"
Title="{Binding Copy.SemanticSearch}" Title="{Binding Copy.SemanticSearchTitle}"
Icon="/Assets/icon.ico" Icon="/Assets/icon.ico"
Width="580" Width="580"
SizeToContent="Height" SizeToContent="Height"