@bobfrankston/mailx 1.0.434 → 1.0.436
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/android.html +21 -1
- package/client/app.js +57 -9
- package/client/components/message-list.js +73 -5
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +3 -0
- package/package.json +1 -1
- package/packages/mailx-service/index.d.ts +16 -2
- package/packages/mailx-service/index.js +81 -7
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-store/db.d.ts +44 -4
- package/packages/mailx-store/db.js +92 -6
- package/tdview.cmd +2 -1
- package/unwedge.cmd +1 -1
package/client/android.html
CHANGED
|
@@ -72,20 +72,35 @@
|
|
|
72
72
|
<label class="tb-menu-item" title="Stack From + Subject on two lines per row, date to the right — denser on narrow windows"><input type="checkbox" id="opt-two-line"> Two-line view</label>
|
|
73
73
|
<label class="tb-menu-item" title="Show the reading pane below/beside the message list"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
|
|
74
74
|
<label class="tb-menu-item" title="Show a short body-text preview (first ~80 chars) beneath each row's subject"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
|
|
75
|
+
<label class="tb-menu-item" title="Collapse reply chains to one row"><input type="checkbox" id="opt-threaded"> Group by thread</label>
|
|
76
|
+
<label class="tb-menu-item" title="Filter the list to rows in the selected message's thread"><input type="checkbox" id="opt-thread-filter"> Only this conversation</label>
|
|
75
77
|
<label class="tb-menu-item" title="Show only flagged (★) messages in the list"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
|
|
76
78
|
<label class="tb-menu-item" title="Show unread/total counts next to each folder in the tree"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
|
|
79
|
+
<label class="tb-menu-item" title="Show the right-side calendar/tasks sidebar"><input type="checkbox" id="opt-calendar-sidebar"> Calendar sidebar</label>
|
|
77
80
|
</div>
|
|
78
81
|
</div>
|
|
79
82
|
<div class="tb-menu" id="settings-menu">
|
|
80
83
|
<button class="tb-btn" id="btn-settings">Settings</button>
|
|
81
84
|
<div class="tb-menu-dropdown" id="settings-dropdown" hidden>
|
|
85
|
+
<span class="tb-menu-label">Theme</span>
|
|
86
|
+
<label class="tb-menu-item"><input type="radio" name="opt-theme" value="system" id="opt-theme-system"> System</label>
|
|
87
|
+
<label class="tb-menu-item"><input type="radio" name="opt-theme" value="light" id="opt-theme-light"> Light</label>
|
|
88
|
+
<label class="tb-menu-item"><input type="radio" name="opt-theme" value="dark" id="opt-theme-dark"> Dark</label>
|
|
89
|
+
<hr class="tb-menu-sep">
|
|
82
90
|
<span class="tb-menu-label">Editor</span>
|
|
83
91
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
|
|
84
92
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
|
|
85
93
|
<hr class="tb-menu-sep">
|
|
86
|
-
<label class="tb-menu-item" title="Ghost-text completions while composing
|
|
94
|
+
<label class="tb-menu-item" title="Ghost-text completions while composing"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
|
|
95
|
+
<label class="tb-menu-item" title="Right-click in message body → Translate"><input type="checkbox" id="opt-ai-translate"> AI translate (off by default)</label>
|
|
96
|
+
<label class="tb-menu-item" title="Right-click in compose editor → Proofread"><input type="checkbox" id="opt-ai-proofread"> AI proofread (off by default)</label>
|
|
97
|
+
<hr class="tb-menu-sep">
|
|
98
|
+
<button class="tb-menu-item" id="btn-edit-jsonc" title="Edit accounts.jsonc / allowlist.jsonc / contacts.jsonc">Edit config files...</button>
|
|
99
|
+
<button class="tb-menu-item" id="btn-about" title="Show version and build info">About mailx...</button>
|
|
87
100
|
</div>
|
|
88
101
|
</div>
|
|
102
|
+
<button class="tb-btn" id="btn-tb-delete" title="Delete selected (Del)">🗑</button>
|
|
103
|
+
<button class="tb-btn" id="btn-tb-spam" title="Mark as spam — move to Junk">⚠</button>
|
|
89
104
|
<span id="app-version" class="app-version">mailx</span>
|
|
90
105
|
</div>
|
|
91
106
|
<div class="toolbar-right">
|
|
@@ -143,7 +158,9 @@
|
|
|
143
158
|
<option value="current">This folder</option>
|
|
144
159
|
</select>
|
|
145
160
|
<input type="search" id="search-input" placeholder="Search..." autocomplete="off" title="Search messages">
|
|
161
|
+
<label class="search-server-check" title="Also search the IMAP server"><input type="checkbox" id="search-server-too"> Server</label>
|
|
146
162
|
</search>
|
|
163
|
+
<div class="ml-folder-title" id="ml-folder-title"></div>
|
|
147
164
|
<div class="ml-header">
|
|
148
165
|
<span class="ml-col ml-col-avatar"></span>
|
|
149
166
|
<span class="ml-col ml-col-flag"></span>
|
|
@@ -192,8 +209,11 @@
|
|
|
192
209
|
<footer class="status-bar" id="status-bar">
|
|
193
210
|
<span id="status-accounts"></span>
|
|
194
211
|
<span id="status-sync">Initializing...</span>
|
|
212
|
+
<span id="status-offline" class="status-offline" hidden title="No network — local actions queue for later">⚡ offline</span>
|
|
213
|
+
<span id="status-diag" class="status-diag" hidden title=""></span>
|
|
195
214
|
<span id="status-pending"></span>
|
|
196
215
|
<span id="status-queue"></span>
|
|
216
|
+
<span class="app-version" id="status-version">mailx</span>
|
|
197
217
|
</footer>
|
|
198
218
|
|
|
199
219
|
<div id="startup-overlay" class="startup-overlay">
|
package/client/app.js
CHANGED
|
@@ -437,8 +437,13 @@ document.addEventListener("pointerdown", (e) => {
|
|
|
437
437
|
if (!panel || !panel.classList.contains("open"))
|
|
438
438
|
return;
|
|
439
439
|
const target = e.target;
|
|
440
|
-
// Ignore clicks inside the panel itself and on
|
|
441
|
-
|
|
440
|
+
// Ignore clicks inside the panel itself and on either toggle button.
|
|
441
|
+
// Without `#btn-folder-toggle` in this list, clicking the folder icon
|
|
442
|
+
// while the panel is open closed it here (capture phase) then the click
|
|
443
|
+
// handler reopened it — net effect: panel stuck open, "doesn't toggle".
|
|
444
|
+
if (target.closest(".folder-panel")
|
|
445
|
+
|| target.closest("#btn-menu")
|
|
446
|
+
|| target.closest("#btn-folder-toggle"))
|
|
442
447
|
return;
|
|
443
448
|
// Only auto-dismiss when we're in overlay mode (small or medium screens).
|
|
444
449
|
// On wide screens the panel is a permanent column and the "open" class
|
|
@@ -2214,7 +2219,7 @@ document.getElementById("btn-open-log")?.addEventListener("click", async () => {
|
|
|
2214
2219
|
}
|
|
2215
2220
|
});
|
|
2216
2221
|
async function openJsoncEditor(initialFile) {
|
|
2217
|
-
const { readJsoncFile, writeJsoncFile, readConfigHelp } = await import("./lib/api-client.js");
|
|
2222
|
+
const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc } = await import("./lib/api-client.js");
|
|
2218
2223
|
const backdrop = document.createElement("div");
|
|
2219
2224
|
backdrop.className = "mailx-modal-backdrop";
|
|
2220
2225
|
const panel = document.createElement("div");
|
|
@@ -2248,6 +2253,7 @@ async function openJsoncEditor(initialFile) {
|
|
|
2248
2253
|
</div>
|
|
2249
2254
|
<div class="mailx-modal-error" id="jsonc-error" hidden></div>
|
|
2250
2255
|
<div class="mailx-modal-buttons">
|
|
2256
|
+
<button type="button" class="mailx-modal-btn" data-action="format" title="Reformat indentation while preserving comments and trailing commas">Format</button>
|
|
2251
2257
|
<span class="mailx-modal-spacer"></span>
|
|
2252
2258
|
<button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
|
|
2253
2259
|
<button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">Save</button>
|
|
@@ -2306,17 +2312,34 @@ async function openJsoncEditor(initialFile) {
|
|
|
2306
2312
|
renderGutter();
|
|
2307
2313
|
};
|
|
2308
2314
|
const showValidation = (err) => {
|
|
2309
|
-
|
|
2315
|
+
// CRITICAL: do NOT move the cursor here. Validation fires every 600ms
|
|
2316
|
+
// while the user types; auto-selecting the error position yanked the
|
|
2317
|
+
// cursor mid-edit and made fixing the error impossible (the user
|
|
2318
|
+
// reported this as a fatal bug — the very mechanism preventing a save
|
|
2319
|
+
// was preventing the fix). Location is shown via the gutter highlight
|
|
2320
|
+
// + the "Line N, col M" message, and the user can click "Jump" to
|
|
2321
|
+
// explicitly navigate.
|
|
2322
|
+
errorEl.innerHTML = "";
|
|
2323
|
+
const text = document.createElement("span");
|
|
2324
|
+
text.textContent = `Line ${err.line}, col ${err.col}: ${err.message} `;
|
|
2325
|
+
const jumpBtn = document.createElement("button");
|
|
2326
|
+
jumpBtn.type = "button";
|
|
2327
|
+
jumpBtn.className = "mailx-modal-btn mailx-modal-btn-link";
|
|
2328
|
+
jumpBtn.textContent = "Jump to error";
|
|
2329
|
+
jumpBtn.addEventListener("click", () => {
|
|
2330
|
+
textarea.focus();
|
|
2331
|
+
try {
|
|
2332
|
+
textarea.setSelectionRange(err.pos, err.pos + 1);
|
|
2333
|
+
}
|
|
2334
|
+
catch { /* */ }
|
|
2335
|
+
});
|
|
2336
|
+
errorEl.appendChild(text);
|
|
2337
|
+
errorEl.appendChild(jumpBtn);
|
|
2310
2338
|
errorEl.hidden = false;
|
|
2311
2339
|
textarea.classList.add("mailx-modal-input-error");
|
|
2312
2340
|
saveBtn.disabled = true;
|
|
2313
2341
|
errorLine = err.line;
|
|
2314
2342
|
renderGutter();
|
|
2315
|
-
// Select the problem character so the browser draws a visible marker
|
|
2316
|
-
try {
|
|
2317
|
-
textarea.setSelectionRange(err.pos, err.pos + 1);
|
|
2318
|
-
}
|
|
2319
|
-
catch { /* out-of-range → ignore */ }
|
|
2320
2343
|
};
|
|
2321
2344
|
let validateTimer;
|
|
2322
2345
|
const scheduleValidate = () => {
|
|
@@ -2372,6 +2395,31 @@ async function openJsoncEditor(initialFile) {
|
|
|
2372
2395
|
close();
|
|
2373
2396
|
return;
|
|
2374
2397
|
}
|
|
2398
|
+
if (action === "format") {
|
|
2399
|
+
// Reformat via the service-side jsonc-parser format() — the
|
|
2400
|
+
// edits are whitespace-only, so `//` and `/* */` comments
|
|
2401
|
+
// survive intact (which JSON.stringify(parse(...)) does not).
|
|
2402
|
+
btn.disabled = true;
|
|
2403
|
+
const orig = btn.textContent;
|
|
2404
|
+
btn.textContent = "Formatting…";
|
|
2405
|
+
try {
|
|
2406
|
+
const r = await formatJsonc(textarea.value);
|
|
2407
|
+
if (r?.content !== undefined) {
|
|
2408
|
+
textarea.value = r.content;
|
|
2409
|
+
renderGutter();
|
|
2410
|
+
scheduleValidate();
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
catch (e) {
|
|
2414
|
+
errorEl.textContent = `Format failed: ${e.message}`;
|
|
2415
|
+
errorEl.hidden = false;
|
|
2416
|
+
}
|
|
2417
|
+
finally {
|
|
2418
|
+
btn.disabled = false;
|
|
2419
|
+
btn.textContent = orig || "Format";
|
|
2420
|
+
}
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2375
2423
|
if (action === "save") {
|
|
2376
2424
|
// Final sync-check; refuse to save if it doesn't parse
|
|
2377
2425
|
const err = validateJsonc(textarea.value);
|
|
@@ -701,6 +701,57 @@ function appendMessages(body, accountId, items) {
|
|
|
701
701
|
lastClickedRow = row;
|
|
702
702
|
updateBulkBar();
|
|
703
703
|
});
|
|
704
|
+
// Right-click (or long-press) on the avatar → bulk-selection menu.
|
|
705
|
+
// Putting it on the avatar is contextually right: the avatar is the
|
|
706
|
+
// "select" affordance, so its menu owns operations on the selection
|
|
707
|
+
// set. "Select all visible" is the load-bearing item — there's no
|
|
708
|
+
// Ctrl-A equivalent on touch and the scope-after-search use case
|
|
709
|
+
// demands it.
|
|
710
|
+
avatar.addEventListener("contextmenu", async (e) => {
|
|
711
|
+
e.preventDefault();
|
|
712
|
+
e.stopPropagation();
|
|
713
|
+
const { showContextMenu } = await import("./context-menu.js");
|
|
714
|
+
const body = document.getElementById("ml-body");
|
|
715
|
+
const visibleRows = body
|
|
716
|
+
? Array.from(body.querySelectorAll(".ml-row:not(.filter-hidden)"))
|
|
717
|
+
: [];
|
|
718
|
+
const selectedCount = body
|
|
719
|
+
? body.querySelectorAll(".ml-row.selected").length
|
|
720
|
+
: 0;
|
|
721
|
+
showContextMenu(e.clientX, e.clientY, [
|
|
722
|
+
{
|
|
723
|
+
label: `Select all (${visibleRows.length})`,
|
|
724
|
+
action: () => {
|
|
725
|
+
if (!body)
|
|
726
|
+
return;
|
|
727
|
+
body.classList.add("multi-select-on");
|
|
728
|
+
for (const r of visibleRows)
|
|
729
|
+
r.classList.add("selected");
|
|
730
|
+
lastClickedRow = visibleRows[visibleRows.length - 1] || null;
|
|
731
|
+
updateBulkBar();
|
|
732
|
+
},
|
|
733
|
+
disabled: visibleRows.length === 0,
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
label: `Clear selection${selectedCount ? ` (${selectedCount})` : ""}`,
|
|
737
|
+
action: () => exitMultiSelect(),
|
|
738
|
+
disabled: selectedCount === 0,
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
label: "Invert selection",
|
|
742
|
+
action: () => {
|
|
743
|
+
if (!body)
|
|
744
|
+
return;
|
|
745
|
+
body.classList.add("multi-select-on");
|
|
746
|
+
for (const r of visibleRows)
|
|
747
|
+
r.classList.toggle("selected");
|
|
748
|
+
lastClickedRow = visibleRows[visibleRows.length - 1] || null;
|
|
749
|
+
updateBulkBar();
|
|
750
|
+
},
|
|
751
|
+
disabled: visibleRows.length === 0,
|
|
752
|
+
},
|
|
753
|
+
]);
|
|
754
|
+
});
|
|
704
755
|
const flag = document.createElement("span");
|
|
705
756
|
flag.className = "ml-flag";
|
|
706
757
|
flag.textContent = msg.flags.includes("\\Flagged") ? "\u2605" : "\u2606";
|
|
@@ -923,12 +974,29 @@ function appendMessages(body, accountId, items) {
|
|
|
923
974
|
// ── Right-click context menu ──
|
|
924
975
|
row.addEventListener("contextmenu", (e) => {
|
|
925
976
|
e.preventDefault();
|
|
926
|
-
//
|
|
977
|
+
// Selection-on-context-click semantics:
|
|
978
|
+
// - If the right-clicked row is already selected → keep the
|
|
979
|
+
// existing selection (single or multi). The menu acts on
|
|
980
|
+
// whatever is selected.
|
|
981
|
+
// - If it's NOT selected and we're in multi-select mode →
|
|
982
|
+
// ADD this row to the selection (don't clear). Long-press
|
|
983
|
+
// to open a context menu used to wipe the multi-select set,
|
|
984
|
+
// which was the user-reported "annoying" behavior.
|
|
985
|
+
// - If it's NOT selected and we're NOT in multi-select →
|
|
986
|
+
// single-select this row (replace prior selection).
|
|
987
|
+
const body = row.parentElement;
|
|
988
|
+
const inMulti = !!body?.classList.contains("multi-select-on");
|
|
927
989
|
if (!row.classList.contains("selected")) {
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
990
|
+
if (inMulti) {
|
|
991
|
+
row.classList.add("selected");
|
|
992
|
+
lastClickedRow = row;
|
|
993
|
+
}
|
|
994
|
+
else {
|
|
995
|
+
clearSelection();
|
|
996
|
+
row.classList.add("selected");
|
|
997
|
+
lastClickedRow = row;
|
|
998
|
+
focusMessage(msgAccountId, msg);
|
|
999
|
+
}
|
|
932
1000
|
}
|
|
933
1001
|
const isSeen = msg.flags.includes("\\Seen");
|
|
934
1002
|
const isFlagged = msg.flags.includes("\\Flagged");
|
package/client/lib/api-client.js
CHANGED
|
@@ -339,6 +339,9 @@ export function readJsoncFile(name) {
|
|
|
339
339
|
export function writeJsoncFile(name, content) {
|
|
340
340
|
return ipc().writeJsoncFile?.(name, content);
|
|
341
341
|
}
|
|
342
|
+
export function formatJsonc(content) {
|
|
343
|
+
return ipc().formatJsonc?.(content);
|
|
344
|
+
}
|
|
342
345
|
export function readConfigHelp(name) {
|
|
343
346
|
return ipc().readConfigHelp?.(name) ?? Promise.resolve({ content: "" });
|
|
344
347
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -131,6 +131,9 @@
|
|
|
131
131
|
writeJsoncFile: function(name, content) {
|
|
132
132
|
return callNode("writeJsoncFile", { name: name, content: content });
|
|
133
133
|
},
|
|
134
|
+
formatJsonc: function(content) {
|
|
135
|
+
return callNode("formatJsonc", { content: content });
|
|
136
|
+
},
|
|
134
137
|
readConfigHelp: function(name) {
|
|
135
138
|
return callNode("readConfigHelp", { name: name });
|
|
136
139
|
},
|
package/package.json
CHANGED
|
@@ -11,10 +11,23 @@ export declare class MailxService {
|
|
|
11
11
|
private imapManager;
|
|
12
12
|
private _accountsCache;
|
|
13
13
|
constructor(db: MailxDB, imapManager: ImapManager);
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
private _contactsFlushTimer;
|
|
15
|
+
private _contactsFlushInFlight;
|
|
16
|
+
private readonly CONTACTS_FLUSH_DEBOUNCE_MS;
|
|
17
|
+
/** Schedule a debounced flush of the local contacts state to GDrive.
|
|
18
|
+
* Multiple changes within the debounce window collapse to one write. */
|
|
19
|
+
markContactsDirty(): void;
|
|
20
|
+
/** Write current DB contacts state to GDrive contacts.jsonc. Called via
|
|
21
|
+
* the debounced timer; also exposed for force-flush on shutdown or
|
|
22
|
+
* after a manual seed. Idempotent — safe to call multiple times. */
|
|
23
|
+
flushContactsConfig(): Promise<void>;
|
|
24
|
+
/** Read contacts.jsonc from cloud + apply (preferred + denylist + discovered)
|
|
25
|
+
* into the DB. On first run with no file, seed from message corpus and
|
|
26
|
+
* write a fresh contacts.jsonc to GDrive — that auto-bootstrap is what
|
|
27
|
+
* makes a new device useful immediately on a shared GDrive setup. */
|
|
16
28
|
loadContactsConfig(): Promise<{
|
|
17
29
|
preferred: number;
|
|
30
|
+
discovered: number;
|
|
18
31
|
purged: number;
|
|
19
32
|
conflicts: string[];
|
|
20
33
|
} | null>;
|
|
@@ -239,6 +252,7 @@ export declare class MailxService {
|
|
|
239
252
|
* Names are whitelisted so the UI can't read arbitrary files.
|
|
240
253
|
* `config.jsonc` is the local per-machine config (not cloud-synced). */
|
|
241
254
|
readJsoncFile(name: string): Promise<string | null>;
|
|
255
|
+
formatJsonc(content: string): Promise<string>;
|
|
242
256
|
/** Return the help section for a named config file, extracted from docs/config-help.md.
|
|
243
257
|
* Matches a level-2 heading whose text equals the filename. Returns markdown. */
|
|
244
258
|
readConfigHelp(name: string): Promise<string>;
|
|
@@ -116,21 +116,76 @@ export class MailxService {
|
|
|
116
116
|
this.loadContactsConfig().catch(e => console.error(` [contacts] reload failed: ${e?.message || e}`));
|
|
117
117
|
}
|
|
118
118
|
});
|
|
119
|
+
// Wire DB → cloud flush. Debounced to absorb bursts (a sync run can
|
|
120
|
+
// call recordSentAddress hundreds of times). 30s flush window is
|
|
121
|
+
// long enough that the steady state is one cloud write per sync,
|
|
122
|
+
// short enough that quitting after a single send still flushes.
|
|
123
|
+
this.db.setOnContactsChanged(() => this.markContactsDirty());
|
|
119
124
|
// Initial load of contacts.jsonc — fire-and-forget; missing file is fine.
|
|
120
125
|
this.loadContactsConfig().catch(() => { });
|
|
121
126
|
}
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
_contactsFlushTimer = null;
|
|
128
|
+
_contactsFlushInFlight = false;
|
|
129
|
+
CONTACTS_FLUSH_DEBOUNCE_MS = 30_000;
|
|
130
|
+
/** Schedule a debounced flush of the local contacts state to GDrive.
|
|
131
|
+
* Multiple changes within the debounce window collapse to one write. */
|
|
132
|
+
markContactsDirty() {
|
|
133
|
+
if (this._contactsFlushTimer)
|
|
134
|
+
clearTimeout(this._contactsFlushTimer);
|
|
135
|
+
this._contactsFlushTimer = setTimeout(() => {
|
|
136
|
+
this._contactsFlushTimer = null;
|
|
137
|
+
this.flushContactsConfig().catch(e => console.error(` [contacts] flush failed: ${e?.message || e}`));
|
|
138
|
+
}, this.CONTACTS_FLUSH_DEBOUNCE_MS);
|
|
139
|
+
}
|
|
140
|
+
/** Write current DB contacts state to GDrive contacts.jsonc. Called via
|
|
141
|
+
* the debounced timer; also exposed for force-flush on shutdown or
|
|
142
|
+
* after a manual seed. Idempotent — safe to call multiple times. */
|
|
143
|
+
async flushContactsConfig() {
|
|
144
|
+
if (this._contactsFlushInFlight)
|
|
145
|
+
return;
|
|
146
|
+
this._contactsFlushInFlight = true;
|
|
147
|
+
try {
|
|
148
|
+
const cfg = this.db.exportContactsConfig();
|
|
149
|
+
const { cloudWrite } = await import("@bobfrankston/mailx-settings");
|
|
150
|
+
await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2));
|
|
151
|
+
console.log(` [contacts] flushed to cloud: ${cfg.preferred.length} preferred + ${cfg.discovered.length} discovered + ${cfg.denylist.length} denylisted`);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
this._contactsFlushInFlight = false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Read contacts.jsonc from cloud + apply (preferred + denylist + discovered)
|
|
158
|
+
* into the DB. On first run with no file, seed from message corpus and
|
|
159
|
+
* write a fresh contacts.jsonc to GDrive — that auto-bootstrap is what
|
|
160
|
+
* makes a new device useful immediately on a shared GDrive setup. */
|
|
124
161
|
async loadContactsConfig() {
|
|
125
162
|
let raw = null;
|
|
163
|
+
let cloudAvailable = false;
|
|
126
164
|
try {
|
|
127
165
|
const { cloudRead } = await import("@bobfrankston/mailx-settings");
|
|
128
166
|
raw = await cloudRead("contacts.jsonc");
|
|
167
|
+
cloudAvailable = true;
|
|
129
168
|
}
|
|
130
|
-
catch { /* cloud unavailable
|
|
169
|
+
catch { /* cloud unavailable */ }
|
|
131
170
|
if (!raw) {
|
|
132
|
-
// No file yet.
|
|
133
|
-
|
|
171
|
+
// No file (yet). Reset in-memory denylist and seed discovered
|
|
172
|
+
// from the local message corpus so autocomplete works immediately.
|
|
173
|
+
this.db.applyContactsConfig({ preferred: [], denylist: [], discovered: [] });
|
|
174
|
+
try {
|
|
175
|
+
this.db.seedContactsFromMessages();
|
|
176
|
+
}
|
|
177
|
+
catch { /* corpus may be empty */ }
|
|
178
|
+
// Auto-bootstrap GDrive copy if cloud is reachable. The file gets
|
|
179
|
+
// a header comment so a user opening it on Drive sees what it is.
|
|
180
|
+
if (cloudAvailable) {
|
|
181
|
+
try {
|
|
182
|
+
await this.flushContactsConfig();
|
|
183
|
+
console.log(" [contacts] auto-seeded contacts.jsonc on GDrive from local corpus");
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
console.error(` [contacts] auto-seed flush failed: ${e?.message || e}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
134
189
|
return null;
|
|
135
190
|
}
|
|
136
191
|
const { parse: parseJsonc } = await import("jsonc-parser");
|
|
@@ -138,10 +193,18 @@ export class MailxService {
|
|
|
138
193
|
const cfg = parseJsonc(raw, errors, { allowTrailingComma: true });
|
|
139
194
|
if (errors.length) {
|
|
140
195
|
console.error(` [contacts] contacts.jsonc has parse errors — applying empty config: ${errors.map((e) => e.error).join(", ")}`);
|
|
141
|
-
this.db.applyContactsConfig({ preferred: [], denylist: [] });
|
|
196
|
+
this.db.applyContactsConfig({ preferred: [], denylist: [], discovered: [] });
|
|
142
197
|
return null;
|
|
143
198
|
}
|
|
144
|
-
|
|
199
|
+
const result = this.db.applyContactsConfig(cfg || {});
|
|
200
|
+
// Run local seeder in case this device has corpus addresses the cloud
|
|
201
|
+
// copy doesn't know about yet. The seeder will fire notifyContactsChanged
|
|
202
|
+
// if it adds anything, which schedules a flush back to GDrive.
|
|
203
|
+
try {
|
|
204
|
+
this.db.seedContactsFromMessages();
|
|
205
|
+
}
|
|
206
|
+
catch { /* corpus may be empty */ }
|
|
207
|
+
return result;
|
|
145
208
|
}
|
|
146
209
|
/** Append an entry to contacts.jsonc#preferred[] and write back to cloud,
|
|
147
210
|
* then re-apply. Mutates the file in place — preserves existing entries
|
|
@@ -1699,6 +1762,17 @@ export class MailxService {
|
|
|
1699
1762
|
const { cloudRead } = await import("@bobfrankston/mailx-settings");
|
|
1700
1763
|
return cloudRead(name);
|
|
1701
1764
|
}
|
|
1765
|
+
// Reformat JSONC preserving comments — applyEdits returns whitespace-only edits.
|
|
1766
|
+
async formatJsonc(content) {
|
|
1767
|
+
const { format, applyEdits } = await import("jsonc-parser");
|
|
1768
|
+
const edits = format(content, undefined, {
|
|
1769
|
+
tabSize: 2,
|
|
1770
|
+
insertSpaces: true,
|
|
1771
|
+
eol: "\n",
|
|
1772
|
+
insertFinalNewline: true,
|
|
1773
|
+
});
|
|
1774
|
+
return applyEdits(content, edits);
|
|
1775
|
+
}
|
|
1702
1776
|
/** Return the help section for a named config file, extracted from docs/config-help.md.
|
|
1703
1777
|
* Matches a level-2 heading whose text equals the filename. Returns markdown. */
|
|
1704
1778
|
async readConfigHelp(name) {
|
|
@@ -169,6 +169,8 @@ async function dispatchAction(svc, action, p) {
|
|
|
169
169
|
case "writeJsoncFile":
|
|
170
170
|
await svc.writeJsoncFile(p.name, p.content);
|
|
171
171
|
return { ok: true };
|
|
172
|
+
case "formatJsonc":
|
|
173
|
+
return { content: await svc.formatJsonc(p.content) };
|
|
172
174
|
case "readConfigHelp":
|
|
173
175
|
return { content: await svc.readConfigHelp(p.name) };
|
|
174
176
|
case "unsubscribeOneClick":
|
|
@@ -201,6 +201,14 @@ export declare class MailxDB {
|
|
|
201
201
|
private _denylist;
|
|
202
202
|
isAddressDenylisted(emailLower: string): boolean;
|
|
203
203
|
setContactsDenylist(emails: string[]): void;
|
|
204
|
+
/** Callback fired when local-DB contacts mutations happen (sends adding
|
|
205
|
+
* to discovered, corpus seeder finding new addresses). The service
|
|
206
|
+
* registers a debounced cloud flush here so the GDrive copy stays in
|
|
207
|
+
* sync. NOT fired from applyContactsConfig — that's the inbound path
|
|
208
|
+
* and would create a write loop. */
|
|
209
|
+
private _onContactsChanged?;
|
|
210
|
+
setOnContactsChanged(cb: () => void): void;
|
|
211
|
+
private notifyContactsChanged;
|
|
204
212
|
/** Seed `discovered`-tier contacts from every address that appears in
|
|
205
213
|
* any cached message — From / To / Cc / Bcc across all folders. One row
|
|
206
214
|
* per email; first non-empty name observed wins. Sent-folder rows skip
|
|
@@ -213,10 +221,15 @@ export declare class MailxDB {
|
|
|
213
221
|
* source. Recency-weighted use_count differentiates within the tier. */
|
|
214
222
|
seedContactsFromMessages(): number;
|
|
215
223
|
/** Apply the contents of contacts.jsonc — replaces all preferred-tier rows
|
|
216
|
-
* with the entries in `preferred[]`,
|
|
217
|
-
*
|
|
218
|
-
* rows are *not* auto-purged on
|
|
219
|
-
*
|
|
224
|
+
* with the entries in `preferred[]`, merges `discovered[]` into the local
|
|
225
|
+
* cache, sets the in-memory denylist, and purges any discovered rows
|
|
226
|
+
* whose email is now denylisted. Preferred rows are *not* auto-purged on
|
|
227
|
+
* denylist hit — if the user explicitly added them they win that
|
|
228
|
+
* conflict; we just log a warning.
|
|
229
|
+
*
|
|
230
|
+
* Discovered rows from the file are MERGED with whatever the local
|
|
231
|
+
* message-corpus seeder has produced. Each device contributes its
|
|
232
|
+
* observed addresses; over time GDrive accumulates the union. */
|
|
220
233
|
applyContactsConfig(cfg: {
|
|
221
234
|
preferred?: {
|
|
222
235
|
name?: string;
|
|
@@ -226,11 +239,38 @@ export declare class MailxDB {
|
|
|
226
239
|
org?: string;
|
|
227
240
|
}[];
|
|
228
241
|
denylist?: string[];
|
|
242
|
+
discovered?: {
|
|
243
|
+
name?: string;
|
|
244
|
+
email: string;
|
|
245
|
+
useCount?: number;
|
|
246
|
+
lastUsed?: number;
|
|
247
|
+
}[];
|
|
229
248
|
}): {
|
|
230
249
|
preferred: number;
|
|
250
|
+
discovered: number;
|
|
231
251
|
purged: number;
|
|
232
252
|
conflicts: string[];
|
|
233
253
|
};
|
|
254
|
+
/** Build the contacts.jsonc shape from current DB state — for round-trip
|
|
255
|
+
* to GDrive. Preferred-tier rows come from anything not in the reserved
|
|
256
|
+
* system sources; discovered comes from `source='discovered'` rows;
|
|
257
|
+
* denylist comes from the in-memory set (set by applyContactsConfig).
|
|
258
|
+
* Caller is responsible for actually writing the cloud copy. */
|
|
259
|
+
exportContactsConfig(): {
|
|
260
|
+
preferred: {
|
|
261
|
+
name: string;
|
|
262
|
+
email: string;
|
|
263
|
+
source: string;
|
|
264
|
+
organization?: string;
|
|
265
|
+
}[];
|
|
266
|
+
denylist: string[];
|
|
267
|
+
discovered: {
|
|
268
|
+
name: string;
|
|
269
|
+
email: string;
|
|
270
|
+
useCount: number;
|
|
271
|
+
lastUsed: number;
|
|
272
|
+
}[];
|
|
273
|
+
};
|
|
234
274
|
/** Search contacts by name or email prefix.
|
|
235
275
|
*
|
|
236
276
|
* Source-tier bonus is what makes the curated address book win against
|
|
@@ -1157,6 +1157,7 @@ export class MailxDB {
|
|
|
1157
1157
|
else {
|
|
1158
1158
|
this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, 1, ?)").run(name || "", email, now, now);
|
|
1159
1159
|
}
|
|
1160
|
+
this.notifyContactsChanged();
|
|
1160
1161
|
}
|
|
1161
1162
|
/** True if `email` (lowercased) appears in the active denylist. Cached
|
|
1162
1163
|
* in-memory; refreshed on contacts.jsonc reload via setContactsDenylist. */
|
|
@@ -1167,6 +1168,21 @@ export class MailxDB {
|
|
|
1167
1168
|
setContactsDenylist(emails) {
|
|
1168
1169
|
this._denylist = new Set(emails.map(e => (e || "").trim().toLowerCase()).filter(Boolean));
|
|
1169
1170
|
}
|
|
1171
|
+
/** Callback fired when local-DB contacts mutations happen (sends adding
|
|
1172
|
+
* to discovered, corpus seeder finding new addresses). The service
|
|
1173
|
+
* registers a debounced cloud flush here so the GDrive copy stays in
|
|
1174
|
+
* sync. NOT fired from applyContactsConfig — that's the inbound path
|
|
1175
|
+
* and would create a write loop. */
|
|
1176
|
+
_onContactsChanged;
|
|
1177
|
+
setOnContactsChanged(cb) {
|
|
1178
|
+
this._onContactsChanged = cb;
|
|
1179
|
+
}
|
|
1180
|
+
notifyContactsChanged() {
|
|
1181
|
+
try {
|
|
1182
|
+
this._onContactsChanged?.();
|
|
1183
|
+
}
|
|
1184
|
+
catch { /* ignore */ }
|
|
1185
|
+
}
|
|
1170
1186
|
/** Seed `discovered`-tier contacts from every address that appears in
|
|
1171
1187
|
* any cached message — From / To / Cc / Bcc across all folders. One row
|
|
1172
1188
|
* per email; first non-empty name observed wins. Sent-folder rows skip
|
|
@@ -1275,17 +1291,24 @@ export class MailxDB {
|
|
|
1275
1291
|
}
|
|
1276
1292
|
if (added > 0 || bumped > 0) {
|
|
1277
1293
|
console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (discovered)`);
|
|
1294
|
+
this.notifyContactsChanged();
|
|
1278
1295
|
}
|
|
1279
1296
|
return added;
|
|
1280
1297
|
}
|
|
1281
1298
|
/** Apply the contents of contacts.jsonc — replaces all preferred-tier rows
|
|
1282
|
-
* with the entries in `preferred[]`,
|
|
1283
|
-
*
|
|
1284
|
-
* rows are *not* auto-purged on
|
|
1285
|
-
*
|
|
1299
|
+
* with the entries in `preferred[]`, merges `discovered[]` into the local
|
|
1300
|
+
* cache, sets the in-memory denylist, and purges any discovered rows
|
|
1301
|
+
* whose email is now denylisted. Preferred rows are *not* auto-purged on
|
|
1302
|
+
* denylist hit — if the user explicitly added them they win that
|
|
1303
|
+
* conflict; we just log a warning.
|
|
1304
|
+
*
|
|
1305
|
+
* Discovered rows from the file are MERGED with whatever the local
|
|
1306
|
+
* message-corpus seeder has produced. Each device contributes its
|
|
1307
|
+
* observed addresses; over time GDrive accumulates the union. */
|
|
1286
1308
|
applyContactsConfig(cfg) {
|
|
1287
1309
|
const preferred = Array.isArray(cfg.preferred) ? cfg.preferred : [];
|
|
1288
1310
|
const denylist = Array.isArray(cfg.denylist) ? cfg.denylist : [];
|
|
1311
|
+
const discovered = Array.isArray(cfg.discovered) ? cfg.discovered : [];
|
|
1289
1312
|
this.setContactsDenylist(denylist);
|
|
1290
1313
|
// Wipe and rewrite preferred-tier rows owned by contacts.jsonc.
|
|
1291
1314
|
// The address-book UI's legacy `upsertContact` still writes
|
|
@@ -1319,6 +1342,39 @@ export class MailxDB {
|
|
|
1319
1342
|
}
|
|
1320
1343
|
catch { /* dup row, skip */ }
|
|
1321
1344
|
}
|
|
1345
|
+
// Merge discovered[] from cloud into local cache. For each entry:
|
|
1346
|
+
// existing row wins on use_count (max), name fills if empty, lastUsed
|
|
1347
|
+
// is max. Missing rows are inserted. Denylisted entries skipped.
|
|
1348
|
+
const insDiscovered = this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, ?, ?)");
|
|
1349
|
+
const updDiscovered = this.db.prepare(`UPDATE contacts SET use_count = max(use_count, ?),
|
|
1350
|
+
last_used = max(last_used, ?),
|
|
1351
|
+
name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END,
|
|
1352
|
+
updated_at = ?
|
|
1353
|
+
WHERE id = ?`);
|
|
1354
|
+
let discoveredAdded = 0;
|
|
1355
|
+
for (const entry of discovered) {
|
|
1356
|
+
if (!entry)
|
|
1357
|
+
continue;
|
|
1358
|
+
const email = (entry.email || "").trim();
|
|
1359
|
+
if (!email || !VALID.test(email))
|
|
1360
|
+
continue;
|
|
1361
|
+
const lower = email.toLowerCase();
|
|
1362
|
+
if (denySet.has(lower))
|
|
1363
|
+
continue;
|
|
1364
|
+
if (isJunkContact(lower, entry.name || ""))
|
|
1365
|
+
continue;
|
|
1366
|
+
const name = (entry.name || "").trim();
|
|
1367
|
+
const useCount = Math.max(0, entry.useCount || 0);
|
|
1368
|
+
const lastUsed = Math.max(0, entry.lastUsed || 0);
|
|
1369
|
+
const existing = this.db.prepare("SELECT id FROM contacts WHERE source = 'discovered' AND lower(email) = ?").get(lower);
|
|
1370
|
+
if (!existing) {
|
|
1371
|
+
insDiscovered.run(name, email, lastUsed, useCount, now);
|
|
1372
|
+
discoveredAdded++;
|
|
1373
|
+
}
|
|
1374
|
+
else {
|
|
1375
|
+
updDiscovered.run(useCount, lastUsed, name, name, now, existing.id);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1322
1378
|
// Purge discovered rows for any denylisted email.
|
|
1323
1379
|
const purge = this.db.prepare("DELETE FROM contacts WHERE source = 'discovered' AND lower(email) = ?");
|
|
1324
1380
|
let purged = 0;
|
|
@@ -1329,8 +1385,38 @@ export class MailxDB {
|
|
|
1329
1385
|
if (conflicts.length > 0) {
|
|
1330
1386
|
console.warn(` [contacts] config: ${conflicts.length} preferred entries also appear in denylist — denylist wins, entries skipped: ${conflicts.join(", ")}`);
|
|
1331
1387
|
}
|
|
1332
|
-
console.log(` [contacts] config applied: ${inserted} preferred row(s), ${denySet.size} denylisted, ${purged} discovered row(s) purged`);
|
|
1333
|
-
return { preferred: inserted, purged, conflicts };
|
|
1388
|
+
console.log(` [contacts] config applied: ${inserted} preferred + ${discoveredAdded} discovered row(s), ${denySet.size} denylisted, ${purged} discovered row(s) purged`);
|
|
1389
|
+
return { preferred: inserted, discovered: discoveredAdded, purged, conflicts };
|
|
1390
|
+
}
|
|
1391
|
+
/** Build the contacts.jsonc shape from current DB state — for round-trip
|
|
1392
|
+
* to GDrive. Preferred-tier rows come from anything not in the reserved
|
|
1393
|
+
* system sources; discovered comes from `source='discovered'` rows;
|
|
1394
|
+
* denylist comes from the in-memory set (set by applyContactsConfig).
|
|
1395
|
+
* Caller is responsible for actually writing the cloud copy. */
|
|
1396
|
+
exportContactsConfig() {
|
|
1397
|
+
const preferredRows = this.db.prepare(`SELECT name, email, source, organization
|
|
1398
|
+
FROM contacts
|
|
1399
|
+
WHERE source NOT IN ('google', 'discovered', 'manual')
|
|
1400
|
+
ORDER BY source, lower(email), lower(name)`).all();
|
|
1401
|
+
const discoveredRows = this.db.prepare(`SELECT name, email, use_count, last_used
|
|
1402
|
+
FROM contacts
|
|
1403
|
+
WHERE source = 'discovered'
|
|
1404
|
+
ORDER BY use_count DESC, last_used DESC, lower(email)`).all();
|
|
1405
|
+
return {
|
|
1406
|
+
preferred: preferredRows.map(r => {
|
|
1407
|
+
const out = { name: r.name || "", email: r.email, source: r.source };
|
|
1408
|
+
if (r.organization)
|
|
1409
|
+
out.organization = r.organization;
|
|
1410
|
+
return out;
|
|
1411
|
+
}),
|
|
1412
|
+
denylist: Array.from(this._denylist),
|
|
1413
|
+
discovered: discoveredRows.map(r => ({
|
|
1414
|
+
name: r.name || "",
|
|
1415
|
+
email: r.email,
|
|
1416
|
+
useCount: r.use_count,
|
|
1417
|
+
lastUsed: r.last_used,
|
|
1418
|
+
})),
|
|
1419
|
+
};
|
|
1334
1420
|
}
|
|
1335
1421
|
/** Search contacts by name or email prefix.
|
|
1336
1422
|
*
|
package/tdview.cmd
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
call mdview
|
|
1
|
+
echo call mdview %~dp0todo.md -pos 100,100,1 -size 900,1400
|
|
2
|
+
call mdview %~dp0todo.md -pos 100,100,1 -size 900,1400
|
package/unwedge.cmd
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
rmdir C:\Users\Bob\.claude\session-env\
|
|
1
|
+
rmdir C:\Users\Bob\.claude\session-env\6787e337-1af0-423c-ae58-8d981702aebb /s /q
|