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