mirror of
https://github.com/MAKS-IT-COM/maksit-postclient.git
synced 2026-09-30 03:18:09 +02:00
(feature): paint first sync incrementally and bind rag to the discrete gpu
This commit is contained in:
parent
863d9d949d
commit
92c997513b
23
CHANGELOG.md
23
CHANGELOG.md
@ -6,6 +6,29 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.3] - 2026-09-20
|
||||
|
||||
First sync paints mail as it arrives, meaning search can use a discrete GPU, and trash, retention, and logs follow typical desktop mail apps.
|
||||
|
||||
### Added
|
||||
|
||||
- **Help → Logs** opens a copyable window over the app log folder.
|
||||
|
||||
### Changed
|
||||
|
||||
- First mailbox sync paints the message list as header batches arrive (newest first). Keyword and meaning indexes wait until the first full folder catalog finishes.
|
||||
- Default user data is `%LocalAppData%\MaksIT\Postclient`. Existing `%AppData%\Postclient` / `%LocalAppData%\Postclient` trees are moved on first launch.
|
||||
- Status bar index lines show scanned / total and hide when idle. GPU init failures open the copyable error window and fall back to CPU in Auto.
|
||||
- Meaning search DirectML prefers a discrete GPU (NVIDIA GTX 1660 Ti and similar) instead of always DXGI adapter 0, and uses sequential DirectML session options.
|
||||
- Trash retention counts from the move-to-trash time. Folder tree shows retention days next to unread when days are greater than zero.
|
||||
- Window titles drop menu mnemonics and trailing ellipses.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Select-all delete in Trash, folder context commands while a huge first sync is painting, and Empty on Trash labeled **Delete all items**.
|
||||
- Retention list no longer keeps Gmail labels that were deleted on the server.
|
||||
- FatturaPA toolbar kind button matches the View menu. Source view selection no longer wraps the full raw EML.
|
||||
|
||||
## [0.3.2] - 2026-09-19
|
||||
|
||||
Expired folder mail goes to Trash; Trash can be kept forever or purged after a number of days.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,8 @@ public interface ISemanticSearchService : IDisposable {
|
||||
|
||||
event Action? Changed;
|
||||
|
||||
event Action<Exception>? Faulted;
|
||||
|
||||
void Start();
|
||||
|
||||
void NotifySettingsChanged();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
26
src/MaksIT.PostClient.Shared/AppLog.cs
Normal file
26
src/MaksIT.PostClient.Shared/AppLog.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
18
src/MaksIT.PostClient.Shared/MailListBudget.cs
Normal file
18
src/MaksIT.PostClient.Shared/MailListBudget.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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)) {
|
||||
|
||||
78
src/MaksIT.PostClient.UI/ViewModels/LogViewModel.cs
Normal file
78
src/MaksIT.PostClient.UI/ViewModels/LogViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
46
src/MaksIT.PostClient.UI/Windows/LogWindow.axaml
Normal file
46
src/MaksIT.PostClient.UI/Windows/LogWindow.axaml
Normal 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>
|
||||
39
src/MaksIT.PostClient.UI/Windows/LogWindow.axaml.cs
Normal file
39
src/MaksIT.PostClient.UI/Windows/LogWindow.axaml.cs
Normal 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();
|
||||
}
|
||||
@ -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}"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user