@bobfrankston/mailx 1.0.333 → 1.0.338
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/app.js +26 -28
- package/client/compose/compose.js +39 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +14 -3
- package/packages/mailx-imap/index.js +71 -42
- package/packages/mailx-settings/index.js +5 -1
package/client/app.js
CHANGED
|
@@ -482,27 +482,15 @@ document.getElementById("btn-factory-reset")?.addEventListener("click", async ()
|
|
|
482
482
|
});
|
|
483
483
|
async function openCompose(mode) {
|
|
484
484
|
const current = getCurrentMessage();
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const m = current?.message;
|
|
495
|
-
const stubReason = !current ? "no current message" :
|
|
496
|
-
!m?.from ? "msg.from missing" :
|
|
497
|
-
!m?.subject && m?.subject !== "" ? "msg.subject missing" :
|
|
498
|
-
(mode !== "forward" && !m?.messageId) ? "msg.messageId missing (can't thread reply)" :
|
|
499
|
-
null;
|
|
500
|
-
if (stubReason) {
|
|
501
|
-
console.warn(`[compose] ${mode} ignored — ${stubReason}; current=`, current);
|
|
502
|
-
alert(`Cannot ${mode === "forward" ? "forward" : "reply to"} this message yet — ` +
|
|
503
|
-
`it's still loading (${stubReason}). Please wait a moment and try again.`);
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
485
|
+
// Local-first: if the row is selected we already have its headers in the
|
|
486
|
+
// local DB. Populate the compose form unconditionally; the user can edit
|
|
487
|
+
// anything missing. Don't show "still loading" alerts — the message IS
|
|
488
|
+
// loaded (it's in the list), body is a separate fetch that isn't needed
|
|
489
|
+
// for Reply's headers. Missing fields become empty strings.
|
|
490
|
+
if ((mode === "reply" || mode === "replyAll" || mode === "forward") && !current) {
|
|
491
|
+
// Only true blocker: no message selected at all.
|
|
492
|
+
console.warn(`[compose] ${mode} — no message selected`);
|
|
493
|
+
return;
|
|
506
494
|
}
|
|
507
495
|
const accounts = await getAccounts();
|
|
508
496
|
const accountId = current?.accountId || accounts[0]?.id || "";
|
|
@@ -554,21 +542,31 @@ async function openCompose(mode) {
|
|
|
554
542
|
console.log(`[compose] no identity match`);
|
|
555
543
|
return undefined;
|
|
556
544
|
}
|
|
545
|
+
// Defensive: msg.from / msg.to may be missing on rows that arrived before
|
|
546
|
+
// headers finished loading. Don't push undefined into init.to — that
|
|
547
|
+
// bubbles to the compose form as literal "undefined". Empty-out gracefully.
|
|
557
548
|
if (msg && mode === "reply") {
|
|
558
|
-
init.to = [msg.from];
|
|
549
|
+
init.to = msg.from ? [msg.from] : [];
|
|
559
550
|
init.subject = `Re: ${cleanSubject}`;
|
|
560
551
|
init.bodyHtml = quoteBody(msg);
|
|
561
|
-
init.inReplyTo = msg.messageId;
|
|
562
|
-
init.references = [...(msg.references || []), msg.messageId];
|
|
552
|
+
init.inReplyTo = msg.messageId || "";
|
|
553
|
+
init.references = [...(msg.references || []), msg.messageId].filter(Boolean);
|
|
563
554
|
init.fromAddress = detectReplyFrom();
|
|
564
555
|
}
|
|
565
556
|
else if (msg && mode === "replyAll") {
|
|
566
|
-
|
|
567
|
-
|
|
557
|
+
const toList = msg.from ? [msg.from] : [];
|
|
558
|
+
if (Array.isArray(msg.to)) {
|
|
559
|
+
for (const a of msg.to) {
|
|
560
|
+
if (a?.address && a.address !== msg.from?.address)
|
|
561
|
+
toList.push(a);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
init.to = toList;
|
|
565
|
+
init.cc = Array.isArray(msg.cc) ? msg.cc : [];
|
|
568
566
|
init.subject = `Re: ${cleanSubject}`;
|
|
569
567
|
init.bodyHtml = quoteBody(msg);
|
|
570
|
-
init.inReplyTo = msg.messageId;
|
|
571
|
-
init.references = [...(msg.references || []), msg.messageId];
|
|
568
|
+
init.inReplyTo = msg.messageId || "";
|
|
569
|
+
init.references = [...(msg.references || []), msg.messageId].filter(Boolean);
|
|
572
570
|
init.fromAddress = detectReplyFrom();
|
|
573
571
|
}
|
|
574
572
|
else if (msg && mode === "forward") {
|
|
@@ -80,6 +80,45 @@ await loadEditorAssets(editorType);
|
|
|
80
80
|
const container = document.getElementById("compose-editor");
|
|
81
81
|
container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
|
|
82
82
|
const editor = await createEditor(container, editorType);
|
|
83
|
+
// Ctrl+scroll / Ctrl+= / Ctrl+- / Ctrl+0 zoom for the compose editor body.
|
|
84
|
+
// Persists per-session in localStorage so zoom survives window pop/close cycles.
|
|
85
|
+
(() => {
|
|
86
|
+
const STORAGE_KEY = "mailx.compose.zoom";
|
|
87
|
+
const MIN = 0.5, MAX = 3, STEP = 0.1;
|
|
88
|
+
let zoom = parseFloat(localStorage.getItem(STORAGE_KEY) || "1") || 1;
|
|
89
|
+
const applyZoom = () => {
|
|
90
|
+
container.style.fontSize = `${zoom}em`;
|
|
91
|
+
localStorage.setItem(STORAGE_KEY, String(zoom));
|
|
92
|
+
};
|
|
93
|
+
applyZoom();
|
|
94
|
+
container.addEventListener("wheel", (e) => {
|
|
95
|
+
if (!e.ctrlKey)
|
|
96
|
+
return;
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
const delta = e.deltaY < 0 ? STEP : -STEP;
|
|
99
|
+
zoom = Math.min(MAX, Math.max(MIN, Math.round((zoom + delta) * 10) / 10));
|
|
100
|
+
applyZoom();
|
|
101
|
+
}, { passive: false });
|
|
102
|
+
document.addEventListener("keydown", (e) => {
|
|
103
|
+
if (!(e.ctrlKey || e.metaKey))
|
|
104
|
+
return;
|
|
105
|
+
if (e.key === "=" || e.key === "+") {
|
|
106
|
+
zoom = Math.min(MAX, zoom + STEP);
|
|
107
|
+
applyZoom();
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
}
|
|
110
|
+
else if (e.key === "-") {
|
|
111
|
+
zoom = Math.max(MIN, zoom - STEP);
|
|
112
|
+
applyZoom();
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
}
|
|
115
|
+
else if (e.key === "0") {
|
|
116
|
+
zoom = 1;
|
|
117
|
+
applyZoom();
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
})();
|
|
83
122
|
// ── Populate from init data ──
|
|
84
123
|
// From field is a free-text input with a <datalist> of known accounts. The
|
|
85
124
|
// user can pick a preset or type an arbitrary "Name <addr@domain>" — no
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.338",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@bobfrankston/tcp-transport": "^0.1.4",
|
|
40
40
|
"@bobfrankston/node-tcp-transport": "^0.1.4",
|
|
41
41
|
"@bobfrankston/smtp-direct": "^0.1.4",
|
|
42
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
42
|
+
"@bobfrankston/mailx-sync": "^0.1.8"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/mailparser": "^3.4.6"
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"@bobfrankston/tcp-transport": "^0.1.4",
|
|
104
104
|
"@bobfrankston/node-tcp-transport": "^0.1.4",
|
|
105
105
|
"@bobfrankston/smtp-direct": "^0.1.4",
|
|
106
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
106
|
+
"@bobfrankston/mailx-sync": "^0.1.8"
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
}
|
|
@@ -187,7 +187,11 @@ export declare class ImapManager extends EventEmitter {
|
|
|
187
187
|
uid: number;
|
|
188
188
|
folderId: number;
|
|
189
189
|
}[]): Promise<void>;
|
|
190
|
-
/** Bulk move messages —
|
|
190
|
+
/** Bulk move messages — queues the IMAP action only. The service layer
|
|
191
|
+
* (MailxService.moveMessages) owns the local DB mutation via
|
|
192
|
+
* updateMessageFolder; this method used to ALSO deleteMessage here,
|
|
193
|
+
* which wiped the row the service just updated — the message vanished
|
|
194
|
+
* on the next reconcile and "spam folder empty" was the symptom. */
|
|
191
195
|
moveMessages(accountId: string, messages: {
|
|
192
196
|
uid: number;
|
|
193
197
|
folderId: number;
|
|
@@ -197,7 +201,8 @@ export declare class ImapManager extends EventEmitter {
|
|
|
197
201
|
private debounceSyncActions;
|
|
198
202
|
/** Move a message to Trash (delete) — local-first, queues IMAP sync */
|
|
199
203
|
trashMessage(accountId: string, folderId: number, uid: number): Promise<void>;
|
|
200
|
-
/** Move a message between folders —
|
|
204
|
+
/** Move a message between folders — queues IMAP sync only. Service
|
|
205
|
+
* layer owns the local DB update (see MailxService.moveMessage). */
|
|
201
206
|
moveMessage(accountId: string, uid: number, fromFolderId: number, toFolderId: number): Promise<void>;
|
|
202
207
|
/** Move message across accounts using iflow's moveMessageToServer */
|
|
203
208
|
moveMessageCrossAccount(fromAccountId: string, uid: number, fromFolderId: number, toAccountId: string, toFolderId: number): Promise<void>;
|
|
@@ -218,7 +223,13 @@ export declare class ImapManager extends EventEmitter {
|
|
|
218
223
|
* Tries the specific UID first, then falls back to searchByHeader so orphaned copies
|
|
219
224
|
* from earlier failed autosaves are cleaned up at the same time. */
|
|
220
225
|
deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
|
|
221
|
-
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP
|
|
226
|
+
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP.
|
|
227
|
+
* Single path: write `~/.mailx/outbox/<acct>/*.ltr` synchronously, then
|
|
228
|
+
* kick processLocalQueue. The file IS the queue — durable across crashes,
|
|
229
|
+
* visible in the filesystem, consumed by the existing outbox worker that
|
|
230
|
+
* handles both IMAP-APPEND (non-Gmail) and direct SMTP (Gmail). The old
|
|
231
|
+
* sync_actions "send" branch was removed because it duplicated the same
|
|
232
|
+
* work and risked double-send when both paths fired on the same message. */
|
|
222
233
|
queueOutgoingLocal(accountId: string, rawMessage: string): void;
|
|
223
234
|
/** Guard against concurrent processSendActions for the same account */
|
|
224
235
|
private sendingAccounts;
|
|
@@ -2052,27 +2052,18 @@ export class ImapManager extends EventEmitter {
|
|
|
2052
2052
|
// Process all queued actions in one IMAP session
|
|
2053
2053
|
this.debounceSyncActions(accountId);
|
|
2054
2054
|
}
|
|
2055
|
-
/** Bulk move messages —
|
|
2055
|
+
/** Bulk move messages — queues the IMAP action only. The service layer
|
|
2056
|
+
* (MailxService.moveMessages) owns the local DB mutation via
|
|
2057
|
+
* updateMessageFolder; this method used to ALSO deleteMessage here,
|
|
2058
|
+
* which wiped the row the service just updated — the message vanished
|
|
2059
|
+
* on the next reconcile and "spam folder empty" was the symptom. */
|
|
2056
2060
|
async moveMessages(accountId, messages, targetFolderId) {
|
|
2057
2061
|
if (messages.length === 0)
|
|
2058
2062
|
return;
|
|
2059
|
-
// Local first
|
|
2060
|
-
for (const msg of messages) {
|
|
2061
|
-
this.db.deleteMessage(accountId, msg.uid);
|
|
2062
|
-
}
|
|
2063
|
-
console.log(` Moved ${messages.length} messages locally (→ folder ${targetFolderId})`);
|
|
2064
|
-
// Queue IMAP actions
|
|
2065
2063
|
for (const msg of messages) {
|
|
2066
2064
|
this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId });
|
|
2067
2065
|
}
|
|
2068
|
-
|
|
2069
|
-
// badge updates immediately.
|
|
2070
|
-
const sourceFolderIds = new Set(messages.map(m => m.folderId));
|
|
2071
|
-
for (const fid of sourceFolderIds)
|
|
2072
|
-
this.db.recalcFolderCounts(fid);
|
|
2073
|
-
this.db.recalcFolderCounts(targetFolderId);
|
|
2074
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
2075
|
-
// Process all queued actions in one IMAP session
|
|
2066
|
+
console.log(` [move] ${accountId}: queued IMAP MOVE for ${messages.length} message(s) → folder ${targetFolderId}`);
|
|
2076
2067
|
this.debounceSyncActions(accountId);
|
|
2077
2068
|
}
|
|
2078
2069
|
/** Debounced sync actions — batches rapid local changes into one IMAP operation */
|
|
@@ -2092,25 +2083,25 @@ export class ImapManager extends EventEmitter {
|
|
|
2092
2083
|
// Local first — remove from DB immediately
|
|
2093
2084
|
this.db.deleteMessage(accountId, uid);
|
|
2094
2085
|
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
2095
|
-
|
|
2096
|
-
//
|
|
2086
|
+
// Queue IMAP action + log the resolution so "I deleted a message and
|
|
2087
|
+
// now it's in neither trash nor deleted" is diagnosable from the log.
|
|
2097
2088
|
if (trash && trash.id !== folderId) {
|
|
2089
|
+
const trashFolder = this.db.getFolders(accountId).find(f => f.id === trash.id);
|
|
2098
2090
|
this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId: trash.id });
|
|
2091
|
+
console.log(` [trash] ${accountId} UID ${uid}: queued MOVE to "${trashFolder?.path || trash.path}" (id=${trash.id}, specialUse=trash)`);
|
|
2099
2092
|
}
|
|
2100
2093
|
else {
|
|
2101
2094
|
this.db.queueSyncAction(accountId, "delete", uid, folderId);
|
|
2095
|
+
console.log(` [trash] ${accountId} UID ${uid}: queued EXPUNGE in folder ${folderId} (already in trash or no trash configured)`);
|
|
2102
2096
|
}
|
|
2103
2097
|
// Debounced sync — batches multiple deletes into one IMAP session
|
|
2104
2098
|
this.debounceSyncActions(accountId);
|
|
2105
2099
|
}
|
|
2106
|
-
/** Move a message between folders —
|
|
2100
|
+
/** Move a message between folders — queues IMAP sync only. Service
|
|
2101
|
+
* layer owns the local DB update (see MailxService.moveMessage). */
|
|
2107
2102
|
async moveMessage(accountId, uid, fromFolderId, toFolderId) {
|
|
2108
|
-
// Local first
|
|
2109
|
-
this.db.deleteMessage(accountId, uid);
|
|
2110
|
-
console.log(` Moved UID ${uid} locally (folder ${fromFolderId} → ${toFolderId})`);
|
|
2111
|
-
// Queue IMAP action
|
|
2112
2103
|
this.db.queueSyncAction(accountId, "move", uid, fromFolderId, { targetFolderId: toFolderId });
|
|
2113
|
-
|
|
2104
|
+
console.log(` [move] ${accountId}: queued IMAP MOVE UID ${uid} folder ${fromFolderId} → ${toFolderId}`);
|
|
2114
2105
|
this.debounceSyncActions(accountId);
|
|
2115
2106
|
}
|
|
2116
2107
|
/** Move message across accounts using iflow's moveMessageToServer */
|
|
@@ -2221,13 +2212,26 @@ export class ImapManager extends EventEmitter {
|
|
|
2221
2212
|
break;
|
|
2222
2213
|
case "move": {
|
|
2223
2214
|
const target = folders.find(f => f.id === action.targetFolderId);
|
|
2224
|
-
if (target) {
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
}
|
|
2215
|
+
if (!target) {
|
|
2216
|
+
// Target folder gone — treat as permanent failure so the
|
|
2217
|
+
// action doesn't loop forever. User must re-delete manually.
|
|
2218
|
+
console.error(` [sync] Move target folder ${action.targetFolderId} missing — dropping action UID ${action.uid}`);
|
|
2219
|
+
throw new Error(`move target folder ${action.targetFolderId} not found`);
|
|
2230
2220
|
}
|
|
2221
|
+
const msg = await client.fetchMessageByUid(folder.path, action.uid, { source: false });
|
|
2222
|
+
if (!msg) {
|
|
2223
|
+
// Message no longer in source folder. Two real cases:
|
|
2224
|
+
// (a) another client already moved/deleted it — nothing to do,
|
|
2225
|
+
// just mark the action done.
|
|
2226
|
+
// (b) the server is lying (transient SELECT miss) — the retry
|
|
2227
|
+
// will pick it up. We can't tell these apart from one fetch,
|
|
2228
|
+
// so log loud and treat as (a) after the first failure; the
|
|
2229
|
+
// attempts counter handles (b) via the failSyncAction path.
|
|
2230
|
+
console.log(` [sync] Move UID ${action.uid} in ${folder.path}: message gone (attempt ${action.attempts + 1}); dropping action`);
|
|
2231
|
+
break;
|
|
2232
|
+
}
|
|
2233
|
+
await client.moveMessage(msg, folder.path, target.path);
|
|
2234
|
+
console.log(` [sync] Moved UID ${action.uid}: ${folder.path} → ${target.path}`);
|
|
2231
2235
|
break;
|
|
2232
2236
|
}
|
|
2233
2237
|
case "flags":
|
|
@@ -2409,13 +2413,23 @@ export class ImapManager extends EventEmitter {
|
|
|
2409
2413
|
catch { /* ignore */ }
|
|
2410
2414
|
}
|
|
2411
2415
|
}
|
|
2412
|
-
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP
|
|
2416
|
+
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP.
|
|
2417
|
+
* Single path: write `~/.mailx/outbox/<acct>/*.ltr` synchronously, then
|
|
2418
|
+
* kick processLocalQueue. The file IS the queue — durable across crashes,
|
|
2419
|
+
* visible in the filesystem, consumed by the existing outbox worker that
|
|
2420
|
+
* handles both IMAP-APPEND (non-Gmail) and direct SMTP (Gmail). The old
|
|
2421
|
+
* sync_actions "send" branch was removed because it duplicated the same
|
|
2422
|
+
* work and risked double-send when both paths fired on the same message. */
|
|
2413
2423
|
queueOutgoingLocal(accountId, rawMessage) {
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2424
|
+
const outboxDir = path.join(getConfigDir(), "outbox", accountId);
|
|
2425
|
+
fs.mkdirSync(outboxDir, { recursive: true });
|
|
2426
|
+
const now = new Date();
|
|
2427
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
2428
|
+
const filename = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}.ltr`;
|
|
2429
|
+
const filePath = path.join(outboxDir, filename);
|
|
2430
|
+
fs.writeFileSync(filePath, rawMessage);
|
|
2431
|
+
console.log(` [outbox] Queued ${filePath}`);
|
|
2432
|
+
this.processLocalQueue(accountId).catch((e) => console.error(` [outbox] processLocalQueue error: ${e?.message || e}`));
|
|
2419
2433
|
}
|
|
2420
2434
|
/** Guard against concurrent processSendActions for the same account */
|
|
2421
2435
|
sendingAccounts = new Set();
|
|
@@ -2985,10 +2999,17 @@ export class ImapManager extends EventEmitter {
|
|
|
2985
2999
|
// config.jsonc is per-machine / local-only — never polled.
|
|
2986
3000
|
const cloudFiles = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc"];
|
|
2987
3001
|
const CLOUD_POLL_MS = 3 * 60 * 1000;
|
|
3002
|
+
// Normalize before comparing: strip BOM, CRLF→LF, trailing whitespace.
|
|
3003
|
+
// Without this, cloud round-trips that re-wrap newlines or add a
|
|
3004
|
+
// trailing newline trigger a local overwrite every poll, which fires
|
|
3005
|
+
// fs.watch, which shows the spurious "accounts.jsonc changed" banner.
|
|
3006
|
+
const normalize = (s) => s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
|
|
2988
3007
|
const pollCloud = async () => {
|
|
2989
3008
|
let cloudRead;
|
|
3009
|
+
let parseJsonc;
|
|
2990
3010
|
try {
|
|
2991
3011
|
({ cloudRead } = await import("@bobfrankston/mailx-settings"));
|
|
3012
|
+
({ parseJsonc } = await import("jsonc-parser").then(m => ({ parseJsonc: m.parse })));
|
|
2992
3013
|
}
|
|
2993
3014
|
catch {
|
|
2994
3015
|
return; /* cloud module unavailable */
|
|
@@ -3004,17 +3025,25 @@ export class ImapManager extends EventEmitter {
|
|
|
3004
3025
|
localContent = fs.readFileSync(localPath, "utf-8");
|
|
3005
3026
|
}
|
|
3006
3027
|
catch { /* missing */ }
|
|
3007
|
-
if (localContent
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3028
|
+
if (localContent !== null) {
|
|
3029
|
+
if (normalize(localContent) === normalize(cloudContent))
|
|
3030
|
+
continue;
|
|
3031
|
+
// Semantic check: parse both as JSONC and compare structures.
|
|
3032
|
+
// Catches reorderings that normalize() doesn't (e.g. JSON with
|
|
3033
|
+
// same keys in different order after a cloud-side re-serialize).
|
|
3034
|
+
try {
|
|
3035
|
+
const a = parseJsonc(localContent);
|
|
3036
|
+
const b = parseJsonc(cloudContent);
|
|
3037
|
+
if (a !== undefined && b !== undefined &&
|
|
3038
|
+
JSON.stringify(a) === JSON.stringify(b))
|
|
3039
|
+
continue;
|
|
3040
|
+
}
|
|
3041
|
+
catch { /* fall through to write */ }
|
|
3042
|
+
}
|
|
3012
3043
|
fs.writeFileSync(localPath, cloudContent);
|
|
3013
3044
|
console.log(` [cloud-poll] ${filename} updated from cloud copy`);
|
|
3014
3045
|
}
|
|
3015
3046
|
catch (e) {
|
|
3016
|
-
// Drive unreachable, auth expired, file missing in cloud —
|
|
3017
|
-
// silent retry on next tick; no user-visible fallout.
|
|
3018
3047
|
console.log(` [cloud-poll] ${filename} check skipped: ${e?.message || e}`);
|
|
3019
3048
|
}
|
|
3020
3049
|
}
|
|
@@ -453,7 +453,11 @@ export function loadAccounts() {
|
|
|
453
453
|
localContent = fs.readFileSync(localPath, "utf-8");
|
|
454
454
|
}
|
|
455
455
|
catch { /* missing */ }
|
|
456
|
-
|
|
456
|
+
// Normalize before comparing — GDrive-mounted copies often
|
|
457
|
+
// differ in BOM / line endings / trailing newline without any
|
|
458
|
+
// semantic change, and that triggered the spurious banner.
|
|
459
|
+
const norm = (s) => s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
|
|
460
|
+
if (norm(sharedContent) !== norm(localContent)) {
|
|
457
461
|
fs.mkdirSync(LOCAL_DIR, { recursive: true });
|
|
458
462
|
fs.writeFileSync(localPath, sharedContent);
|
|
459
463
|
}
|