(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]
## [0.3.3] - 2026-09-20
First sync paints mail as it arrives, meaning search can use a discrete GPU, and trash, retention, and logs follow typical desktop mail apps.
### Added
- **Help → Logs** opens a copyable window over the app log folder.
### Changed
- First mailbox sync paints the message list as header batches arrive (newest first). Keyword and meaning indexes wait until the first full folder catalog finishes.
- Default user data is `%LocalAppData%\MaksIT\Postclient`. Existing `%AppData%\Postclient` / `%LocalAppData%\Postclient` trees are moved on first launch.
- Status bar index lines show scanned / total and hide when idle. GPU init failures open the copyable error window and fall back to CPU in Auto.
- Meaning search DirectML prefers a discrete GPU (NVIDIA GTX 1660 Ti and similar) instead of always DXGI adapter 0, and uses sequential DirectML session options.
- Trash retention counts from the move-to-trash time. Folder tree shows retention days next to unread when days are greater than zero.
- Window titles drop menu mnemonics and trailing ellipses.
### Fixed
- Select-all delete in Trash, folder context commands while a huge first sync is painting, and Empty on Trash labeled **Delete all items**.
- Retention list no longer keeps Gmail labels that were deleted on the server.
- FatturaPA toolbar kind button matches the View menu. Source view selection no longer wraps the full raw EML.
## [0.3.2] - 2026-09-19
Expired folder mail goes to Trash; Trash can be kept forever or purged after a number of days.

View File

@ -30,6 +30,11 @@
<display_length compare="ge">768</display_length>
</requires>
<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">
<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>

View File

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

View File

@ -1,10 +1,26 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using MaksIT.PostClient.Shared;
namespace MaksIT.PostClient.Client;
public readonly record struct GpuAdapter(
int DeviceId,
string Name,
uint VendorId,
long DedicatedBytes,
bool Software);
public static class GpuProbe {
private const uint Nvidia = 0x10DE;
private const uint Amd = 0x1002;
private const uint Intel = 0x8086;
private const uint Microsoft = 0x1414;
private const uint DxgiAdapterFlagSoftware = 2;
public static bool SupportsBoost =>
OperatingSystem.IsWindows();
@ -16,4 +32,139 @@ public static class GpuProbe {
return false;
return kind is SemanticDevice.Gpu or SemanticDevice.Auto;
}
public static IReadOnlyList<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<Exception>? Faulted;
void Start();
void NotifySettingsChanged();

View File

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

View File

@ -200,11 +200,18 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore {
IReadOnlyList<uint> ids,
MailFlagUpdate update,
CancellationToken cancellationToken = default) {
_ = folder;
_ = ids;
_ = update;
_ = cancellationToken;
return Task.FromResult(Result.Ok());
if (update.Deleted is not true)
return Task.FromResult(Result.Ok());
return _io.RunAsync(() => {
foreach (var id in ids) {
cancellationToken.ThrowIfCancellationRequested();
var path = MessagePath(folder, id);
if (File.Exists(path))
File.Delete(path);
}
return Task.FromResult(Result.Ok());
}, cancellationToken);
}
public Task<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.Tensors;
using Microsoft.ML.Tokenizers;
using MaksIT.PostClient.Shared;
@ -22,17 +24,14 @@ public sealed class OnnxGemmaEmbedder : ITextEmbedder {
ArgumentException.ThrowIfNullOrWhiteSpace(tokenizerPath);
using var stream = File.OpenRead(tokenizerPath);
_tokenizer = LlamaTokenizer.Create(stream, addBeginOfSentence: true, addEndOfSentence: true);
var options = new SessionOptions();
UsesGpu = false;
if (useGpu)
UsesGpu = TryDirectMl(options);
try {
options.AppendExecutionProvider_CPU();
if (useGpu && TryCreateGpuSession(onnxPath, out var gpu)) {
_session = gpu;
UsesGpu = true;
}
catch {
else {
_session = CreateCpuSession(onnxPath);
}
_session = new InferenceSession(onnxPath, options);
_inputIds = PickInput(_session, "input_ids", "input");
_attentionMask = PickInput(_session, "attention_mask", "mask");
_tokenTypeIds = FindInput(_session, "token_type_ids");
@ -84,19 +83,59 @@ public sealed class OnnxGemmaEmbedder : ITextEmbedder {
public void Dispose() =>
_session.Dispose();
private static bool TryDirectMl(SessionOptions options) {
private static bool TryCreateGpuSession(string onnxPath, [NotNullWhen(true)] out InferenceSession? session) {
session = null;
var method = typeof(SessionOptions).GetMethod("AppendExecutionProvider_DML", [typeof(int)]);
if (method is null)
return false;
Exception? last = null;
foreach (var adapter in GpuProbe.DirectMlAdapters()) {
var options = DmlSessionOptions();
try {
method.Invoke(options, [adapter.DeviceId]);
TryAppendCpu(options);
session = new InferenceSession(onnxPath, options);
AppLog.Write($"Meaning index DirectML: {adapter.Name} (device {adapter.DeviceId})");
return true;
}
catch (Exception ex) {
options.Dispose();
last = Unwrap(ex);
AppLog.Write($"Meaning index DirectML skipped {adapter.Name} (device {adapter.DeviceId}): {last.Message}");
}
}
if (last is not null)
throw last;
return false;
}
private static InferenceSession CreateCpuSession(string onnxPath) {
var options = new SessionOptions();
TryAppendCpu(options);
return new InferenceSession(onnxPath, options);
}
private static SessionOptions DmlSessionOptions() =>
new() {
GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL,
ExecutionMode = ExecutionMode.ORT_SEQUENTIAL,
EnableMemoryPattern = false,
EnableCpuMemArena = false
};
private static void TryAppendCpu(SessionOptions options) {
try {
method.Invoke(options, [0]);
return true;
options.AppendExecutionProvider_CPU();
}
catch {
return false;
}
}
private static Exception Unwrap(Exception exception) =>
exception is TargetInvocationException { InnerException: { } inner } ? inner : exception;
private static string PickInput(InferenceSession session, params string[] names) {
foreach (var name in names) {
foreach (var key in session.InputMetadata.Keys) {

View File

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

View File

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

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>
/// Placeholder product folder until the public sign is chosen.
/// Windows: <c>%AppData%\Postclient</c> / <c>%LocalAppData%\Postclient</c>.
/// Linux: XDG. macOS: Application Support. Not a MAKS-IT subfolder.
/// Windows: <c>%LocalAppData%\MaksIT\Postclient</c> for config and data.
/// Linux: XDG under a MaksIT parent. macOS: Application Support under MaksIT.
/// </summary>
public static class AppPaths {
public const string ProductName = "Postclient";
public const string ProductId = "postclient";
public const string BrandFolder = "MaksIT";
public const string ConfigEnv = "POSTCLIENT_CONFIG";
public const string DataEnv = "POSTCLIENT_DATA_DIR";
@ -22,20 +22,21 @@ public static class AppPaths {
return portable;
if (OperatingSystem.IsWindows())
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ProductName);
return WindowsProductDirectory();
if (OperatingSystem.IsMacOS()) {
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library",
"Application Support",
BrandFolder,
ProductName);
}
var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME");
if (string.IsNullOrWhiteSpace(xdg))
xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config");
return Path.Combine(xdg, ProductId);
return Path.Combine(xdg, BrandFolder, ProductId);
}
public static string DataDirectory() {
@ -48,7 +49,7 @@ public static class AppPaths {
return Path.Combine(portable, "data");
if (OperatingSystem.IsWindows())
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ProductName);
return WindowsProductDirectory();
if (OperatingSystem.IsMacOS())
return ConfigDirectory();
@ -56,7 +57,7 @@ public static class AppPaths {
var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
if (string.IsNullOrWhiteSpace(xdg))
xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share");
return Path.Combine(xdg, ProductId);
return Path.Combine(xdg, BrandFolder, ProductId);
}
public static string SettingsFile() =>
@ -127,6 +128,7 @@ public static class AppPaths {
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library",
"Logs",
BrandFolder,
ProductName);
}
@ -134,6 +136,7 @@ public static class AppPaths {
}
public static void EnsureDirectories() {
MigrateLegacyLayout();
Directory.CreateDirectory(ConfigDirectory());
Directory.CreateDirectory(DataDirectory());
Directory.CreateDirectory(ObjectsDirectory());
@ -164,4 +167,55 @@ public static class AppPaths {
return null;
}
}
private static string WindowsProductDirectory() =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
BrandFolder,
ProductName);
private static bool UsesDefaultUserLayout() {
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(ConfigEnv)))
return false;
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(DataEnv)))
return false;
return PortableRoot() is null;
}
private static void MigrateLegacyLayout() {
if (!UsesDefaultUserLayout())
return;
try {
if (OperatingSystem.IsWindows())
MigrateWindowsLayout();
}
catch {
}
}
private static void MigrateWindowsLayout() {
var dest = WindowsProductDirectory();
var oldLocal = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
ProductName);
var oldRoam = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
ProductName);
if (!Directory.Exists(dest) && Directory.Exists(oldLocal))
Directory.Move(oldLocal, dest);
Directory.CreateDirectory(dest);
CopyIfMissing(Path.Combine(oldRoam, "settings.json"), Path.Combine(dest, "settings.json"));
CopyIfMissing(Path.Combine(oldRoam, "secrets.bin"), Path.Combine(dest, "secrets.bin"));
CopyIfMissing(Path.Combine(oldRoam, "receipts.json"), Path.Combine(dest, "receipts.json"));
}
private static void CopyIfMissing(string source, string dest) {
if (!File.Exists(source) || File.Exists(dest))
return;
var dir = Path.GetDirectoryName(dest);
if (!string.IsNullOrWhiteSpace(dir))
Directory.CreateDirectory(dir);
File.Copy(source, dest);
}
}

View File

@ -94,6 +94,20 @@ public sealed class MailArchiveCatalog : IDisposable {
return 0;
});
public void SetKeywordIndexEnabled(string mailboxId, bool enabled) =>
OffUi(() => {
var store = TryGet(mailboxId);
if (store is not null)
store.KeywordIndexEnabled = enabled;
return 0;
});
public int MessageCount(string mailboxId) =>
OffUi(() => TryGet(mailboxId)?.MessageCount(mailboxId) ?? 0);
public int RebuildKeywordIndex(string mailboxId) =>
OffUi(() => TryGet(mailboxId)?.RebuildKeywordIndex() ?? 0);
public void UpdateFlags(
string mailboxId,
string folder,
@ -114,14 +128,56 @@ public sealed class MailArchiveCatalog : IDisposable {
return 0;
});
public IReadOnlyList<EmbeddingWorkItem> PendingEmbeddings(string modelId, int limit) =>
OffUi(() => Merge(store => store.PendingEmbeddings(modelId, limit)));
public IReadOnlyList<EmbeddingWorkItem> PendingEmbeddings(
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) =>
OffUi(() => Sum(store => store.EmbeddingCount(modelId)));
return rows;
});
public int EmbeddingPendingCount(string modelId) =>
OffUi(() => Sum(store => store.EmbeddingPendingCount(modelId)));
public int EmbeddingCount(string modelId, IReadOnlyCollection<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.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) =>
OffUi(() => {

View File

@ -8,6 +8,8 @@ public sealed class MailArchiveStore : IDisposable {
private readonly SqliteConnection _db;
private readonly Lock _gate = new();
public bool KeywordIndexEnabled { get; set; } = true;
public MailArchiveStore(string? path = null) {
var file = string.IsNullOrWhiteSpace(path) ? AppPaths.ArchiveDatabase() : path;
var dir = Path.GetDirectoryName(file);
@ -186,6 +188,7 @@ public sealed class MailArchiveStore : IDisposable {
}
public int RebuildKeywordIndex() {
KeywordIndexEnabled = true;
lock (_gate) {
using var tx = _db.BeginTransaction();
using (var del = _db.CreateCommand()) {
@ -757,13 +760,22 @@ public sealed class MailArchiveStore : IDisposable {
}
public IReadOnlyList<uint> UidsOlderThan(string mailboxId, string folder, DateTimeOffset cutoff) {
var trash = MailRetention.IsTrash(folder);
lock (_gate) {
using var cmd = _db.CreateCommand();
cmd.CommandText = """
SELECT uid FROM messages
WHERE mailbox_id = $m AND folder = $f AND date_utc <> '' AND date_utc < $cut
ORDER BY date_utc;
""";
cmd.CommandText = trash
? """
SELECT uid FROM messages
WHERE mailbox_id = $m AND folder = $f
AND COALESCE(NULLIF(trashed_utc, ''), date_utc) <> ''
AND COALESCE(NULLIF(trashed_utc, ''), date_utc) < $cut
ORDER BY COALESCE(NULLIF(trashed_utc, ''), date_utc);
"""
: """
SELECT uid FROM messages
WHERE mailbox_id = $m AND folder = $f AND date_utc <> '' AND date_utc < $cut
ORDER BY date_utc;
""";
cmd.Parameters.AddWithValue("$m", mailboxId);
cmd.Parameters.AddWithValue("$f", folder);
cmd.Parameters.AddWithValue("$cut", cutoff.ToString("O"));
@ -775,6 +787,15 @@ public sealed class MailArchiveStore : IDisposable {
}
}
public int MessageCount(string mailboxId) {
lock (_gate) {
using var cmd = _db.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM messages WHERE mailbox_id = $m;";
cmd.Parameters.AddWithValue("$m", mailboxId);
return (int)(long)(cmd.ExecuteScalar() ?? 0L);
}
}
public IReadOnlyList<string> DistinctMailboxIds() {
lock (_gate) {
using var cmd = _db.CreateCommand();
@ -860,8 +881,8 @@ public sealed class MailArchiveStore : IDisposable {
cmd.CommandText = $"""
INSERT INTO messages (
mailbox_id, folder, uid, message_id, subject, from_addr, date_utc, envelope_kind, envelope_badge,
envelope_tipo, in_reply_to, is_seen, is_flagged, has_attachments, priority)
VALUES ($m, $f, $u, $mid, $sub, $from, $date, $kind, $badge, $tipo, $reply, $seen, $flag, $att, $pri)
envelope_tipo, in_reply_to, is_seen, is_flagged, has_attachments, priority, trashed_utc)
VALUES ($m, $f, $u, $mid, $sub, $from, $date, $kind, $badge, $tipo, $reply, $seen, $flag, $att, $pri, $trashed)
ON CONFLICT(mailbox_id, folder, uid) DO UPDATE SET
message_id = excluded.message_id,
subject = excluded.subject,
@ -873,8 +894,15 @@ public sealed class MailArchiveStore : IDisposable {
in_reply_to = excluded.in_reply_to,
{flagAssign}
has_attachments = excluded.has_attachments,
priority = excluded.priority;
priority = excluded.priority,
trashed_utc = CASE
WHEN messages.trashed_utc <> '' THEN messages.trashed_utc
WHEN $trash = 1 THEN excluded.trashed_utc
ELSE messages.trashed_utc
END;
""";
var trash = MailRetention.IsTrash(header.Folder);
var trashed = trash ? DateTimeOffset.UtcNow.ToString("O") : "";
cmd.Parameters.AddWithValue("$m", mailboxId);
cmd.Parameters.AddWithValue("$f", header.Folder);
cmd.Parameters.AddWithValue("$u", header.Uid);
@ -890,6 +918,8 @@ public sealed class MailArchiveStore : IDisposable {
cmd.Parameters.AddWithValue("$flag", header.IsFlagged ? 1 : 0);
cmd.Parameters.AddWithValue("$att", header.HasAttachments ? 1 : 0);
cmd.Parameters.AddWithValue("$pri", header.Priority);
cmd.Parameters.AddWithValue("$trashed", trashed);
cmd.Parameters.AddWithValue("$trash", trash ? 1 : 0);
cmd.ExecuteNonQuery();
using var idCmd = _db.CreateCommand();
idCmd.Transaction = tx;
@ -908,6 +938,8 @@ public sealed class MailArchiveStore : IDisposable {
MailArchiveHeader header,
string? bodyText,
string? attachmentText) {
if (!KeywordIndexEnabled)
return;
using (var del = _db.CreateCommand()) {
del.Transaction = tx;
del.CommandText = "DELETE FROM messages_fts WHERE rowid = $id;";
@ -1163,6 +1195,7 @@ public sealed class MailArchiveStore : IDisposable {
eml_path TEXT NOT NULL DEFAULT '',
body_text TEXT NOT NULL DEFAULT '',
attachment_text TEXT NOT NULL DEFAULT '',
trashed_utc TEXT NOT NULL DEFAULT '',
UNIQUE(mailbox_id, folder, uid)
);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
@ -1183,6 +1216,22 @@ public sealed class MailArchiveStore : IDisposable {
);
""";
cmd.ExecuteNonQuery();
EnsureColumn("messages", "trashed_utc", "TEXT NOT NULL DEFAULT ''");
}
private void EnsureColumn(string table, string column, string definition) {
using var info = _db.CreateCommand();
info.CommandText = "PRAGMA table_info(" + table + ");";
using var reader = info.ExecuteReader();
while (reader.Read()) {
if (reader.GetString(1).Equals(column, StringComparison.OrdinalIgnoreCase))
return;
}
reader.Dispose();
using var alter = _db.CreateCommand();
alter.CommandText = "ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition + ";";
alter.ExecuteNonQuery();
}
}

View File

@ -16,6 +16,11 @@ public static class MailIndexProgress {
return left <= 0 ? copy.IndexingBusy : string.Format(copy.IndexingLeft, left);
}
public static string MeaningLine(int done, int total) {
var copy = UiLocale.Copy;
return total <= 0 ? "" : string.Format(copy.IndexingMeaning, Math.Clamp(done, 0, total), total);
}
public static string MeaningLeft(int left) =>
string.Format(UiLocale.Copy.IndexingMeaningLeft, Math.Max(0, left));

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 bool InitialSyncCompleted { get; set; }
[JsonIgnore]
public bool TracksCertifiedReceipts =>
MailCertifiedKind.TracksReceipts(CertifiedKind);

View File

@ -96,6 +96,8 @@ public sealed partial class UiCopy {
public string Help { get; init; } = "";
public string Logs { get; init; } = "";
public string CreateStore { get; init; } = "";
public string AttachStore { get; init; } = "";
@ -118,6 +120,8 @@ public sealed partial class UiCopy {
public string RetentionDays { get; init; } = "";
public string RetentionDaysBadge { get; init; } = "";
public string RetentionAction { get; init; } = "";
public string RetentionToTrash { get; init; } = "";
@ -402,6 +406,8 @@ public sealed partial class UiCopy {
public string IndexingLeft { get; init; } = "";
public string IndexingMeaning { get; init; } = "";
public string IndexingMeaningLeft { get; init; } = "";
public string IndexingMeaningReady { get; init; } = "";
@ -440,6 +446,8 @@ public sealed partial class UiCopy {
public string EmptyFolder { get; init; } = "";
public string DeleteAllItems { get; init; } = "";
public string MarkFolderRead { get; init; } = "";
public string MarkFolderUnread { get; init; } = "";
@ -528,6 +536,41 @@ public sealed partial class UiCopy {
public string CertifiedRem { get; init; } = "";
public string RulesTitle =>
Plain(Rules);
public string FeaturesTitle =>
Plain(Features);
public string RetentionTitle =>
Plain(Retention);
public string SemanticSearchTitle =>
Plain(SemanticSearch);
public string AccountSettingsTitle =>
Plain(AccountSettings);
public string AboutTitle =>
Plain(About);
public string LogsTitle =>
Plain(Logs);
public string RetentionBadge(int days) =>
days <= 0 ? "" : string.Format(RetentionDaysBadge, days);
public static string Plain(string? value) {
if (string.IsNullOrWhiteSpace(value))
return "";
var text = value.Replace("_", "", StringComparison.Ordinal).Trim();
if (text.EndsWith("…", StringComparison.Ordinal))
return text[..^1].TrimEnd();
if (text.EndsWith("...", StringComparison.Ordinal))
return text[..^3].TrimEnd();
return text;
}
public static UiCopy For(string? language) =>
UiLanguage.Normalize(language) switch {
UiLanguage.It => Italian(),
@ -571,6 +614,7 @@ public sealed partial class UiCopy {
Message = "_Message",
Settings = "_Settings",
Help = "_Help",
Logs = "_Logs",
Features = "_Features…",
FeaturesHint = "Certified-mail packs only. Ordinary mail (IMAP/POP3, search, labels, imports) stays on. Header checkboxes turn a whole country on or off. Italy is PEC and FatturaPA; Europe is eIDAS REM. France, Germany, Spain, and Switzerland add operator evidence labels — those networks are usually not IMAP.",
Feature = "Feature",
@ -624,6 +668,7 @@ public sealed partial class UiCopy {
Retention = "_Retention…",
RetentionHint = "Per folder. 0 keeps mail forever. Folder days move older messages to Trash. Trash days permanently delete, or 0 to keep Trash forever.",
RetentionDays = "Days",
RetentionDaysBadge = "{0}d",
RetentionAction = "When expired",
RetentionToTrash = "Move to Trash",
RetentionPurge = "Delete permanently",
@ -766,6 +811,7 @@ public sealed partial class UiCopy {
IndexingWithFolder = "Indexing {0} · {1} / {2}",
IndexingBusy = "Indexing…",
IndexingLeft = "Indexing {0} left",
IndexingMeaning = "Indexing meaning {0} / {1}",
IndexingMeaningLeft = "Indexing meaning {0} left",
IndexingMeaningReady = "Meaning index ready ({0})",
IndexingMeaningReadyCount = "Meaning index ready ({0}, {1})",
@ -785,6 +831,7 @@ public sealed partial class UiCopy {
DeletingFolder = "Deleting folder…",
NewFolder = "New folder…",
EmptyFolder = "Empty folder",
DeleteAllItems = "Delete all items",
MarkFolderRead = "Mark all as read",
MarkFolderUnread = "Mark all as unread",
DeleteFolder = "Delete folder",
@ -838,6 +885,7 @@ public sealed partial class UiCopy {
Message = "_Messaggio",
Settings = "_Impostazioni",
Help = "_Aiuto",
Logs = "_Registro",
Features = "_Funzionalità…",
FeaturesHint = "Solo posta certificata. La posta ordinaria (IMAP/POP3, ricerca, etichette, import) resta sempre disponibile. Le caselle in intestazione accendono o spengono un intero Paese. Italia è PEC e FatturaPA; Europa è REM eIDAS. Francia, Germania, Spagna e Svizzera aggiungono etichette di evidenza — quelle reti di solito non sono IMAP.",
Feature = "Funzione",
@ -891,6 +939,7 @@ public sealed partial class UiCopy {
Retention = "_Conservazione…",
RetentionHint = "Per cartella. 0 conserva per sempre. I giorni della cartella spostano i messaggi più vecchi nel Cestino. I giorni del Cestino eliminano in modo permanente, oppure 0 per tenere il Cestino per sempre.",
RetentionDays = "Giorni",
RetentionDaysBadge = "{0}g",
RetentionAction = "Alla scadenza",
RetentionToTrash = "Sposta nel Cestino",
RetentionPurge = "Elimina in modo permanente",
@ -1033,6 +1082,7 @@ public sealed partial class UiCopy {
IndexingWithFolder = "Indicizzazione {0} · {1} / {2}",
IndexingBusy = "Indicizzazione…",
IndexingLeft = "Indicizzazione {0} rimanenti",
IndexingMeaning = "Indicizzazione significato {0} / {1}",
IndexingMeaningLeft = "Indicizzazione significato {0} rimanenti",
IndexingMeaningReady = "Indice significato pronto ({0})",
IndexingMeaningReadyCount = "Indice significato pronto ({0}, {1})",
@ -1052,6 +1102,7 @@ public sealed partial class UiCopy {
DeletingFolder = "Eliminazione cartella…",
NewFolder = "Nuova cartella…",
EmptyFolder = "Svuota cartella",
DeleteAllItems = "Elimina tutti gli elementi",
MarkFolderRead = "Segna tutte come lette",
MarkFolderUnread = "Segna tutte come non lette",
DeleteFolder = "Elimina cartella",

View File

@ -9,6 +9,7 @@ public sealed partial class UiCopy {
Message = "_Nachricht",
Settings = "_Einstellungen",
Help = "_Hilfe",
Logs = "_Protokoll",
Features = "_Funktionen…",
FeaturesHint = "Nur Pakete für zertifizierte Post. Gewöhnliche Mail (IMAP/POP3, Suche, Kennzeichen, Import) bleibt an. Kopf-Kontrollkästchen schalten ein ganzes Land ein oder aus. Italien ist PEC und FatturaPA; Europa ist eIDAS-REM. Frankreich, Deutschland, Spanien und die Schweiz fügen Nachweisbeschriftungen hinzu — diese Netze sind in der Regel kein IMAP.",
Feature = "Funktion",
@ -62,6 +63,7 @@ public sealed partial class UiCopy {
Retention = "_Aufbewahrung…",
RetentionHint = "Pro Ordner. 0 behält dauerhaft. Ordner-Tage verschieben ältere Nachrichten in den Papierkorb. Papierkorb-Tage löschen endgültig, oder 0 behält den Papierkorb dauerhaft.",
RetentionDays = "Tage",
RetentionDaysBadge = "{0}T",
RetentionAction = "Nach Ablauf",
RetentionToTrash = "In den Papierkorb",
RetentionPurge = "Endgültig löschen",
@ -204,6 +206,7 @@ public sealed partial class UiCopy {
IndexingWithFolder = "Indizierung {0} · {1} / {2}",
IndexingBusy = "Indizierung…",
IndexingLeft = "Indizierung {0} übrig",
IndexingMeaning = "Bedeutungsindex {0} / {1}",
IndexingMeaningLeft = "Bedeutungsindex {0} übrig",
IndexingMeaningReady = "Bedeutungsindex bereit ({0})",
IndexingMeaningReadyCount = "Bedeutungsindex bereit ({0}, {1})",
@ -223,6 +226,7 @@ public sealed partial class UiCopy {
DeletingFolder = "Ordner wird gelöscht…",
NewFolder = "Neuer Ordner…",
EmptyFolder = "Ordner leeren",
DeleteAllItems = "Alle Elemente löschen",
MarkFolderRead = "Alle als gelesen markieren",
MarkFolderUnread = "Alle als ungelesen markieren",
DeleteFolder = "Ordner löschen",

View File

@ -9,6 +9,7 @@ public sealed partial class UiCopy {
Message = "_Mensaje",
Settings = "Ajus_tes",
Help = "A_yuda",
Logs = "_Registro",
Features = "_Funciones…",
FeaturesHint = "Solo paquetes de correo certificado. El correo ordinario (IMAP/POP3, búsqueda, etiquetas, importación) sigue disponible. Las casillas de cabecera encienden o apagan todo un país. Italia es PEC y FatturaPA; Europa es REM eIDAS. Francia, Alemania, España y Suiza añaden etiquetas de evidencia: esas redes no suelen ser IMAP.",
Feature = "Función",
@ -62,6 +63,7 @@ public sealed partial class UiCopy {
Retention = "_Retención…",
RetentionHint = "Por carpeta. 0 conserva para siempre. Los días de la carpeta envían los mensajes antiguos a la Papelera. Los días de la Papelera borran de forma permanente, o 0 para conservarla siempre.",
RetentionDays = "Días",
RetentionDaysBadge = "{0}d",
RetentionAction = "Al caducar",
RetentionToTrash = "Mover a la Papelera",
RetentionPurge = "Eliminar de forma permanente",
@ -204,6 +206,7 @@ public sealed partial class UiCopy {
IndexingWithFolder = "Indexación {0} · {1} / {2}",
IndexingBusy = "Indexación…",
IndexingLeft = "Indexación {0} restantes",
IndexingMeaning = "Indexación de significado {0} / {1}",
IndexingMeaningLeft = "Indexación de significado {0} restantes",
IndexingMeaningReady = "Índice de significado listo ({0})",
IndexingMeaningReadyCount = "Índice de significado listo ({0}, {1})",
@ -223,6 +226,7 @@ public sealed partial class UiCopy {
DeletingFolder = "Eliminando carpeta…",
NewFolder = "Nueva carpeta…",
EmptyFolder = "Vaciar carpeta",
DeleteAllItems = "Eliminar todos los elementos",
MarkFolderRead = "Marcar todo como leído",
MarkFolderUnread = "Marcar todo como no leído",
DeleteFolder = "Eliminar carpeta",

View File

@ -9,6 +9,7 @@ public sealed partial class UiCopy {
Message = "_Message",
Settings = "_Paramètres",
Help = "_Aide",
Logs = "_Journal",
Features = "_Fonctionnalités…",
FeaturesHint = "Packs de courrier certifié uniquement. Le courrier ordinaire (IMAP/POP3, recherche, étiquettes, imports) reste disponible. Les cases d’en-tête activent ou désactivent tout un pays. L’Italie est PEC et FatturaPA ; l’Europe est REM eIDAS. La France, l’Allemagne, l’Espagne et la Suisse ajoutent des libellés de preuve — ces réseaux ne sont en général pas de l’IMAP.",
Feature = "Fonction",
@ -62,6 +63,7 @@ public sealed partial class UiCopy {
Retention = "_Rétention…",
RetentionHint = "Par dossier. 0 conserve pour toujours. Les jours du dossier envoient les messages plus anciens vers la Corbeille. Les jours de la Corbeille suppriment définitivement, ou 0 pour la garder pour toujours.",
RetentionDays = "Jours",
RetentionDaysBadge = "{0}j",
RetentionAction = "À l’échéance",
RetentionToTrash = "Déplacer vers la Corbeille",
RetentionPurge = "Supprimer définitivement",
@ -204,6 +206,7 @@ public sealed partial class UiCopy {
IndexingWithFolder = "Indexation {0} · {1} / {2}",
IndexingBusy = "Indexation…",
IndexingLeft = "Indexation {0} restants",
IndexingMeaning = "Indexation du sens {0} / {1}",
IndexingMeaningLeft = "Indexation du sens {0} restants",
IndexingMeaningReady = "Index de sens prêt ({0})",
IndexingMeaningReadyCount = "Index de sens prêt ({0}, {1})",
@ -223,6 +226,7 @@ public sealed partial class UiCopy {
DeletingFolder = "Suppression du dossier…",
NewFolder = "Nouveau dossier…",
EmptyFolder = "Vider le dossier",
DeleteAllItems = "Supprimer tous les éléments",
MarkFolderRead = "Tout marquer comme lu",
MarkFolderUnread = "Tout marquer comme non lu",
DeleteFolder = "Supprimer le dossier",

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]
public void ClearEmbeddings_LeavesKeywordAndPending() {
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() {
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MaksIT",
"Postclient",
"objects");
if (!Directory.Exists(dir)) {
dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Postclient",
"objects");
}
if (!Directory.Exists(dir))
return;
var files = Directory.GetFiles(dir, "*.eml");

View File

@ -1,3 +1,4 @@
using MaksIT.PostClient.Client;
using MaksIT.PostClient.Shared;
@ -54,6 +55,37 @@ public class SemanticDeviceTests {
}
public class GpuProbeTests {
[Fact]
public void PreferForDirectMl_PicksNvidiaOverIntelAndSkipsWarp() {
GpuAdapter[] adapters = [
new(0, "Intel UHD Graphics", 0x8086, 128_000_000, false),
new(1, "NVIDIA GeForce GTX 1660 Ti", 0x10DE, 6_000_000_000, false),
new(2, "NVIDIA GeForce GTX 1660 Ti", 0x10DE, 6_000_000_000, false),
new(3, "Microsoft Basic Render Driver", 0x1414, 0, true)
];
var picked = GpuProbe.PreferForDirectMl(adapters);
Assert.Equal(new[] { 1, 0 }, picked.Select(static adapter => adapter.DeviceId).ToArray());
}
[Fact]
public void IsSoftwareAdapter_DetectsWarp() {
Assert.True(GpuProbe.IsSoftwareAdapter("Microsoft Basic Render Driver", 0, 0x1414));
Assert.True(GpuProbe.IsSoftwareAdapter("NVIDIA", 2, 0x10DE));
Assert.False(GpuProbe.IsSoftwareAdapter("NVIDIA GeForce GTX 1660 Ti", 0, 0x10DE));
}
[Fact]
public void DirectMlAdapters_OnWindowsSkipsSoftware() {
if (!OperatingSystem.IsWindows())
return;
var adapters = GpuProbe.DirectMlAdapters();
Assert.NotEmpty(adapters);
Assert.DoesNotContain(adapters, static adapter => adapter.Software);
}
}
public class SemanticSearchSettingsTests {
[Fact]
public void Normalize_KeepsEnabledAndAuto() {

View File

@ -81,10 +81,11 @@ public class ErrorReportTests {
public class AppPathsTests {
[Fact]
public void ProductFolderIsPlaceholderBrandNotMaksIt() {
public void ProductFolderUsesMaksItBrand() {
Assert.Equal("Postclient", AppPaths.ProductName);
Assert.Equal("postclient", AppPaths.ProductId);
Assert.DoesNotContain("MaksIT", AppPaths.ConfigDirectory(), StringComparison.OrdinalIgnoreCase);
Assert.Contains("MaksIT", AppPaths.ConfigDirectory(), StringComparison.OrdinalIgnoreCase);
Assert.Contains("Postclient", AppPaths.ConfigDirectory(), StringComparison.OrdinalIgnoreCase);
Assert.EndsWith("webview", AppPaths.WebViewDirectory(), StringComparison.OrdinalIgnoreCase);
Assert.EndsWith("models", AppPaths.ModelsDirectory(), StringComparison.OrdinalIgnoreCase);
}
@ -358,6 +359,37 @@ public class MailIndexProgressTests {
Assert.Equal("Indexing meaning 12 left", MailIndexProgress.MeaningLeft(12));
Assert.Equal("Meaning index ready (CPU)", MailIndexProgress.MeaningReady("CPU", 0));
Assert.Equal("Meaning index ready (GPU, 40)", MailIndexProgress.MeaningReady("GPU", 40));
Assert.Equal("", MailIndexProgress.MeaningLine(0, 0));
Assert.Equal("Indexing meaning 3 / 10", MailIndexProgress.MeaningLine(3, 10));
}
}
public class MailListBudgetTests {
[Fact]
public void NewestRange_TakesFromTheEnd() {
var first = MailListBudget.NewestRange(1000, 0, 200);
Assert.Equal(800, first.Start);
Assert.Equal(999, first.End);
Assert.Equal(200, first.Take);
Assert.True(first.Incomplete);
var last = MailListBudget.NewestRange(1000, 800, 200);
Assert.Equal(0, last.Start);
Assert.Equal(199, last.End);
Assert.False(last.Incomplete);
}
}
public class UiCopyPlainTests {
[Fact]
public void Plain_StripsMnemonicAndEllipsis() {
Assert.Equal("Rules", UiCopy.Plain("_Rules…"));
Assert.Equal("Indices", UiCopy.Plain("_Indices…"));
Assert.Equal("Account Settings", UiCopy.Plain("Account Settings…"));
Assert.Equal("Help", UiCopy.Plain("_Help"));
Assert.Equal("Rules", UiCopy.For("en").RulesTitle);
Assert.Equal("About Postclient", UiCopy.For("en").AboutTitle);
}
}

View File

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

View File

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

View File

@ -79,6 +79,7 @@ public partial class MainWindow : Window {
viewModel.FolderTreeSelectionRestoreRequested += RestoreFolderTreeSelection;
viewModel.RulesRequested += OnRulesRequested;
viewModel.AboutRequested += OnAboutRequested;
viewModel.LogsRequested += OnLogsRequested;
viewModel.AccountSaved += OnAccountSaved;
viewModel.ExportArchiveRequested += OnExportArchive;
viewModel.ExportFascicoloRequested += OnExportFascicolo;
@ -688,6 +689,8 @@ public partial class MainWindow : Window {
private async void OnRetentionRequested() {
if (_files is null || DataContext is not MainViewModel vm)
return;
foreach (var box in vm.Mailboxes)
await vm.EnsureFoldersForRulesAsync(box.Id);
var window = new RetentionWindow {
DataContext = new RetentionViewModel(
_files,
@ -697,6 +700,7 @@ public partial class MainWindow : Window {
WindowStartupLocation = WindowStartupLocation.CenterOwner
};
await window.ShowDialog(this);
vm.RefreshRetentionBadges();
}
private async void OnIdentityHubSignIn(string kind) {
@ -776,6 +780,9 @@ public partial class MainWindow : Window {
private void OnAboutRequested() =>
_ = AboutWindow.ShowAsync(this);
private void OnLogsRequested() =>
_ = LogWindow.ShowAsync(this);
private async Task<string?> PickFolder(string title, string? suggested = null) {
IStorageFolder? start = null;
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.PostClient.Client;
using MaksIT.PostClient.Shared;
using MaksIT.PostClient.UI;
namespace MaksIT.PostClient.UI.ViewModels;
@ -36,6 +37,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private readonly HashSet<string> _catalogDone = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _catalogFolders = new(StringComparer.OrdinalIgnoreCase);
private const int IndexCatalogChunk = 2_000;
private const int IndexUiChunk = 250;
private int _indexBodyTotal;
private bool _uiReady;
private MailMessageBody? _reading;
private MessageRowViewModel? _pendingWindow;
@ -210,9 +213,11 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private string practiceLabel = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowIndexLine))]
private string indexLine = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowSemanticLine))]
private string semanticLine = "";
[ObservableProperty]
@ -382,6 +387,25 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
public event Action? AboutRequested;
public event Action? LogsRequested;
public bool ShowIndexLine =>
!string.IsNullOrWhiteSpace(IndexLine);
public bool ShowSemanticLine =>
!string.IsNullOrWhiteSpace(SemanticLine);
public string EmptyFolderLabel {
get {
foreach (var node in TargetFolderNodes()) {
if (MailRetention.IsTrash(node.Folder?.Name, node.Folder?.FullName))
return Copy.DeleteAllItems;
}
return Copy.EmptyFolder;
}
}
public event Func<PromptRequest, Task<string?>>? PromptRequested;
public event Action? ExportArchiveRequested;
@ -458,8 +482,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
editorGoogleClientSecret = StoredGoogleClientSecret();
editorMicrosoftClientId = files.Current.MicrosoftClientId;
ReloadMailboxes();
MarkExistingSyncComplete();
semanticLine = semantic.StatusLine;
semantic.Changed += OnSemanticChanged;
semantic.Faulted += OnSemanticFaulted;
}
public void StartBackgroundWork() {
@ -484,6 +510,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
_readingLoad = null;
_work?.Cancel();
_semantic.Changed -= OnSemanticChanged;
_semantic.Faulted -= OnSemanticFaulted;
_semantic.Dispose();
List<IMailSession> sessions;
lock (_connections) {
@ -498,6 +525,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
_worker.Dispose();
}
private void OnSemanticFaulted(Exception exception) =>
Dispatcher.UIThread.Post(() => ErrorDialog.Report(exception));
private void OnSemanticChanged() =>
Dispatcher.UIThread.Post(() => {
var line = _semantic.StatusLine;
@ -550,6 +580,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private void ShowAbout() =>
AboutRequested?.Invoke();
[RelayCommand]
private void ShowLogs() =>
LogsRequested?.Invoke();
[RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
private async Task CheckForUpdatesAsync() {
_updateBusy = true;
@ -1384,6 +1418,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
&& (SelectedFolderNode is null || !SelectedFolderNodes.Contains(SelectedFolderNode)))
SelectedFolderNode = SelectedFolderNodes[0];
NotifyFolderCommands();
OnPropertyChanged(nameof(EmptyFolderLabel));
}
public IReadOnlyList<uint> DragMessageIds(MessageRowViewModel source) {
@ -1619,6 +1654,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
var rows = TargetRows();
if (rows.Count == 0)
return;
if (VisibleMessages.Count > 0
&& rows.Count >= VisibleMessages.Count
&& rows.Count >= Messages.Count)
rows = VisibleMessages.ToList();
var box = SelectedMailbox;
var folder = SelectedFolder;
if (box is null || folder is null)
@ -1667,14 +1706,14 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private async Task EmptyFolderAsync() {
var session = ActiveSession;
var box = SelectedMailbox;
var folder = SelectedFolder;
var folder = TargetFolderNodes().FirstOrDefault()?.Folder ?? SelectedFolder;
if (session is not { IsConnected: true, SupportsFolders: true } || box is null || folder is null)
return;
var trash = await EnsureTrashFolderAsync(box, CancellationToken.None);
var purge = string.IsNullOrWhiteSpace(trash)
|| trash.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase);
var confirm = string.Format(purge ? Copy.EmptyTrashConfirm : Copy.EmptyFolderConfirm, folder.Name);
if (!await ConfirmAsync(Copy.EmptyFolder, confirm))
if (!await ConfirmAsync(purge ? Copy.DeleteAllItems : Copy.EmptyFolder, confirm))
return;
await RunAsync(Copy.EmptyingFolder, async token => {
var result = await session.EmptyFolderAsync(
@ -1822,7 +1861,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
uids is null
? _archive.RemoveFolder(mailboxId, folder)
: _archive.RemoveUids(mailboxId, folder, uids));
_semantic.NotifySettingsChanged();
if (IsInitialSyncComplete(mailboxId))
_semantic.NotifySettingsChanged();
}
private static void DeleteEmlFiles(IEnumerable<string> paths) {
@ -1957,7 +1997,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private bool CanOrganize() =>
!IsBusy && HasMessageSelection() && SelectedMailbox is not null
&& (ActiveSession is { SupportsFolders: true } || SelectedMailbox.IsLocalStore);
&& (ActiveSession is { IsConnected: true, SupportsFolders: true } || SelectedMailbox.IsLocalStore);
private bool CanManageFolders() =>
CanSessionFolders() && SelectedMailbox is not null;
@ -1965,10 +2005,14 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private bool CanCreateFolder() =>
CanManageFolders();
private bool CanEmptyFolder() =>
CanManageFolders()
&& SelectedFolder is not null
&& !MailFolderRole.IsNamespace(SelectedFolder.Name, SelectedFolder.FullName);
private bool CanEmptyFolder() {
var folder = TargetFolderNodes().FirstOrDefault()?.Folder ?? SelectedFolder;
if (folder is null || MailFolderRole.IsNamespace(folder.Name, folder.FullName))
return false;
if (SelectedMailbox is { IsLocalStore: true } && !IsBusy)
return ActiveSession is { IsConnected: true, SupportsFolders: true };
return CanManageFolders();
}
private bool CanDeleteCustomFolder() =>
!IsBusy && TargetCustomFolderRoots().Count > 0;
@ -2750,11 +2794,6 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
var box = Mailboxes.FirstOrDefault(m => m.Id.Equals(mailboxId, StringComparison.OrdinalIgnoreCase));
foreach (var folder in FoldersFor(box))
Add(folder.Name, folder.FullName);
foreach (var path in _archive.ListFolders(mailboxId)) {
var slash = path.Replace('\\', '/').TrimEnd('/');
var i = slash.LastIndexOf('/');
Add(i < 0 ? slash : slash[(i + 1)..], path);
}
return rows;
}
@ -3086,7 +3125,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
if (session is PstMailSession pst)
pst.StorePathChanged += path => PersistPstStorePath(box, path);
await LoadFoldersAsync(box, session, token).ConfigureAwait(false);
await UiAsync(() => EnsureIndexing(box.Id)).ConfigureAwait(false);
await UiAsync(() => {
EnsureIndexing(box.Id);
NotifyFolderCommands();
}).ConfigureAwait(false);
return true;
}
finally {
@ -3117,6 +3159,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
}
private void ApplyFolderSync(string mailboxId, string folder, MailFolderSync sync) {
var ready = IsInitialSyncComplete(mailboxId);
_archive.SetKeywordIndexEnabled(mailboxId, ready);
if (sync.Flags.Count > 0)
_archive.UpdateFlags(mailboxId, folder, sync.Flags.Select(f => (f.Id, f.IsSeen, f.IsFlagged)));
_archive.UpsertHeaders(mailboxId, sync.Headers.Select(MailArchiveMap.FromHeader));
@ -3127,7 +3171,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
DropArchived(mailboxId, folder, gone);
}
_semantic.NotifySettingsChanged();
if (ready)
_semantic.NotifySettingsChanged();
}
private async Task LoadMessagesAsync(string folder, CancellationToken token, bool resumeBodies = false) {
@ -3143,34 +3188,49 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
PauseIndexing();
try {
var known = await Task.Run(() => _archive.Uids(box.Id, folder), token).ConfigureAwait(false);
var result = await session.ListMessagesAsync(folder, known, token).ConfigureAwait(false);
if (!result.IsSuccess) {
await UiAsync(() =>
Status = string.Join(" ", result.Messages) + " Showing local archive. " + ArchiveFiles.Hint())
.ConfigureAwait(false);
return;
var painted = false;
while (true) {
var known = await Task.Run(() => _archive.Uids(box.Id, folder), token).ConfigureAwait(false);
var result = await session.ListMessagesAsync(folder, known, token, IndexUiChunk).ConfigureAwait(false);
if (!result.IsSuccess) {
await UiAsync(() =>
Status = string.Join(" ", result.Messages) + " Showing local archive. " + ArchiveFiles.Hint())
.ConfigureAwait(false);
return;
}
token.ThrowIfCancellationRequested();
var sync = result.Value ?? new MailFolderSync();
ApplyFolderSync(box.Id, folder, sync);
var fresh = sync.Headers.Where(h => !known.Contains(h.Id)).ToList();
if (known.Count > 0) {
await UiAsync(() => {
if (SelectedMailbox?.Id == box.Id)
NoticeNewMail(fresh);
}).ConfigureAwait(false);
}
await ApplyIncomingRulesAsync(folder, fresh, token).ConfigureAwait(false);
await ShowArchiveAsync(box.Id, folder, token).ConfigureAwait(false);
if (!painted) {
painted = true;
await UiAsync(() => {
IsBusy = false;
NotifyFolderCommands();
}).ConfigureAwait(false);
}
if (!sync.Incomplete)
break;
}
token.ThrowIfCancellationRequested();
var sync = result.Value ?? new MailFolderSync();
ApplyFolderSync(box.Id, folder, sync);
var fresh = sync.Headers.Where(h => !known.Contains(h.Id)).ToList();
if (known.Count > 0) {
await UiAsync(() => {
if (SelectedMailbox?.Id == box.Id)
NoticeNewMail(fresh);
}).ConfigureAwait(false);
}
await ApplyIncomingRulesAsync(folder, fresh, token).ConfigureAwait(false);
await ShowArchiveAsync(box.Id, folder, token).ConfigureAwait(false);
await UiAsync(() => MarkFolderCataloged(box, folder, session)).ConfigureAwait(false);
if (TracksCertifiedReceipts(box) && MailFolderRole.Kind(null, folder) == "sent") {
await IngestReceiptFoldersAsync(token).ConfigureAwait(false);
await UiAsync(OverlaySentMarks).ConfigureAwait(false);
}
await UiAsync(() => Status = ArchiveFiles.Hint()).ConfigureAwait(false);
if (resumeBodies)
if (resumeBodies && IsInitialSyncComplete(box.Id))
await UiAsync(() => EnsureBodyBackfill(box.Id)).ConfigureAwait(false);
}
finally {
@ -3623,7 +3683,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
path,
MailArchiveMap.BodyText(body, UnwrapEnvelope),
MailArchiveMap.AttachmentIndex(body, UnwrapEnvelope));
_semantic.NotifySettingsChanged();
if (IsInitialSyncComplete(mailboxId))
_semantic.NotifySettingsChanged();
}
catch {
}
@ -3738,7 +3799,6 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
if (snapshot.Count == 0)
break;
var ids = snapshot.Select(s => s.Id).ToList();
var sessions = snapshot.ToDictionary(
s => s.Id,
s => s.Session,
@ -3759,7 +3819,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
pendingFolders = true;
cataloged = true;
await WaitIfIndexingPausedAsync(token).ConfigureAwait(false);
await SetIndexLineAsync(MailIndexProgress.Left(_archive.MissingBodyCount(ids)), token)
var folderDone = box.CatalogedFolders.Count;
await SetIndexLineAsync(
MailIndexProgress.Line(folderDone, Math.Max(box.Folders.Count, 1), folder.Name),
token)
.ConfigureAwait(false);
var known = _archive.Uids(box.Id, folder.FullName);
var listed = await box.Session
@ -3773,26 +3836,37 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
await UiAsync(() => RefreshIndexedFolderCounts(box.Id, folder.FullName, counts))
.ConfigureAwait(false);
if (!sync.Incomplete) {
await UiAsync(() => _catalogFolders.Add(CatalogKey(box.Id, folder.FullName)))
.ConfigureAwait(false);
await UiAsync(() => {
_catalogFolders.Add(CatalogKey(box.Id, folder.FullName));
TryCompleteInitialSync(MailboxById(box.Id));
}).ConfigureAwait(false);
}
await Task.Delay(15, token).ConfigureAwait(false);
}
if (!pendingFolders)
await UiAsync(() => _catalogDone.Add(box.Id)).ConfigureAwait(false);
if (!pendingFolders && box.Folders.All(f => box.CatalogedFolders.Contains(f.FullName)))
await UiAsync(() => TryCompleteInitialSync(MailboxById(box.Id))).ConfigureAwait(false);
}
await WaitIfIndexingPausedAsync(token).ConfigureAwait(false);
var pending = _archive.MissingBodies(ids, 16);
var readyIds = snapshot
.Where(s => IsInitialSyncComplete(s.Id))
.Select(s => s.Id)
.ToList();
var pending = readyIds.Count == 0 ? [] : _archive.MissingBodies(readyIds, 16);
if (pending.Count == 0) {
if (!cataloged)
break;
await SetIndexLineAsync("", token).ConfigureAwait(false);
continue;
}
await SetIndexLineAsync(MailIndexProgress.Left(_archive.MissingBodyCount(ids)), token)
var left = _archive.MissingBodyCount(readyIds);
if (_indexBodyTotal < left)
_indexBodyTotal = left;
var done = Math.Max(0, _indexBodyTotal - left);
await SetIndexLineAsync(MailIndexProgress.Line(done, _indexBodyTotal), token)
.ConfigureAwait(false);
var fetched = 0;
foreach (var item in pending) {
@ -3834,6 +3908,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
}
IndexLine = "";
_indexBodyTotal = 0;
if (_indexMailboxes.Any(id => SessionFor(MailboxById(id)) is { IsConnected: true }))
StartIndexing();
});
@ -3938,6 +4013,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
DetachPstCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(ShowFolderContext));
OnPropertyChanged(nameof(ShowDetachPst));
OnPropertyChanged(nameof(EmptyFolderLabel));
}
partial void OnHasReadingFilesChanged(bool value) =>
@ -4180,8 +4256,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
Action<MessageRowViewModel>? apply,
IReadOnlyList<MessageRowViewModel>? rows = null) {
var session = ActiveSession;
if (session is not { IsConnected: true, SupportsFolders: true } || SelectedFolder is null)
if (session is not { IsConnected: true, SupportsFolders: true } || SelectedFolder is null) {
Status = Copy.Connecting;
return false;
}
var targets = rows ?? TargetRows();
if (targets.Count == 0)
return false;
@ -4289,12 +4367,35 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private void NotifyFolderCounts() {
foreach (var account in FolderTree) {
ApplyRetentionBadge(account);
foreach (var child in account.Children)
child.NotifyCounts();
account.NotifyCounts();
}
}
public void RefreshRetentionBadges() =>
NotifyFolderCounts();
private void ApplyRetentionBadge(FolderNodeViewModel node) {
if (node.Mailbox is { } box && node.Folder is { } folder)
node.RetentionDays = RetentionDaysFor(box.Id, folder.FullName);
else
node.RetentionDays = 0;
foreach (var child in node.Children)
ApplyRetentionBadge(child);
}
private int RetentionDaysFor(string mailboxId, string folder) {
foreach (var row in _files.Current.Retention ?? []) {
if (row.MailboxId.Equals(mailboxId, StringComparison.OrdinalIgnoreCase)
&& row.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))
return row.Days;
}
return 0;
}
private void RefreshFolderStats() {
if (SelectedFolder is null) {
FolderStats = "";
@ -4358,6 +4459,57 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private static string CatalogKey(string mailboxId, string folder) =>
mailboxId + "\n" + folder;
private bool IsInitialSyncComplete(string mailboxId) {
var box = MailboxById(mailboxId);
return box?.InitialSyncCompleted == true || _catalogDone.Contains(mailboxId);
}
private void MarkExistingSyncComplete() {
var changed = false;
foreach (var box in _files.Current.Mailboxes) {
if (box.InitialSyncCompleted)
continue;
if (_archive.MessageCount(box.Id) <= 0)
continue;
box.InitialSyncCompleted = true;
changed = true;
}
foreach (var box in _files.Current.Mailboxes)
_archive.SetKeywordIndexEnabled(box.Id, box.InitialSyncCompleted);
if (changed)
_files.Save(_files.Current);
}
private void MarkFolderCataloged(MailboxAccount box, string folder, IMailSession? session) {
_catalogFolders.Add(CatalogKey(box.Id, folder));
if (session is not { SupportsFolders: true } || AllFoldersCataloged(box))
TryCompleteInitialSync(box);
}
private bool AllFoldersCataloged(MailboxAccount box) {
var folders = FoldersFor(box);
if (folders.Count == 0)
return false;
return folders.All(folder => _catalogFolders.Contains(CatalogKey(box.Id, folder.FullName)));
}
private void TryCompleteInitialSync(MailboxAccount? box) {
if (box is null || box.InitialSyncCompleted)
return;
if (FoldersFor(box).Count > 0 && !AllFoldersCataloged(box))
return;
box.InitialSyncCompleted = true;
_catalogDone.Add(box.Id);
var stored = _files.Current.FindMailbox(box.Id);
if (stored is not null)
stored.InitialSyncCompleted = true;
_files.Save(_files.Current);
_archive.SetKeywordIndexEnabled(box.Id, true);
_archive.RebuildKeywordIndex(box.Id);
_semantic.NotifySettingsChanged();
}
private async Task UiAsync(Action work) {
if (Dispatcher.UIThread.CheckAccess())
work();

View File

@ -152,6 +152,14 @@ public sealed partial class FolderNodeViewModel : ObservableObject {
public bool HasUnread =>
Unread > 0;
public int RetentionDays { get; set; }
public string RetentionLabel =>
UiLocale.Copy.RetentionBadge(RetentionDays);
public bool HasRetention =>
RetentionDays > 0;
public MailboxQuota? Quota { get; set; }
public bool ShowQuota =>
@ -172,6 +180,8 @@ public sealed partial class FolderNodeViewModel : ObservableObject {
OnPropertyChanged(nameof(UnreadLabel));
OnPropertyChanged(nameof(HasUnread));
OnPropertyChanged(nameof(Weight));
OnPropertyChanged(nameof(RetentionLabel));
OnPropertyChanged(nameof(HasRetention));
}
public void NotifyQuota() {

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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