@bobfrankston/mailx 1.0.47 → 1.0.50
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 +73 -5
- package/client/index.html +5 -0
- package/client/lib/api-client.js +3 -0
- package/client/styles/components.css +14 -1
- package/client/styles/layout.css +4 -1
- package/package.json +3 -3
- package/packages/mailx-imap/index.js +9 -6
- package/packages/mailx-server/index.js +7 -7
- package/packages/mailx-settings/index.d.ts +5 -0
- package/packages/mailx-settings/index.js +26 -0
package/client/app.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { initFolderTree, refreshFolderTree } from "./components/folder-tree.js";
|
|
6
6
|
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder } from "./components/message-list.js";
|
|
7
7
|
import { showMessage, getCurrentMessage } from "./components/message-viewer.js";
|
|
8
|
-
import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessage, undeleteMessage, restartServer, getSyncPending } from "./lib/api-client.js";
|
|
8
|
+
import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessage, undeleteMessage, restartServer, rebuildServer, getSyncPending } from "./lib/api-client.js";
|
|
9
9
|
// ── New message badge (favicon + title) ──
|
|
10
10
|
let baseTitle = "mailx";
|
|
11
11
|
let lastSeenCount = 0;
|
|
@@ -90,6 +90,29 @@ function setTitle(title) {
|
|
|
90
90
|
baseTitle = title;
|
|
91
91
|
document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;
|
|
92
92
|
}
|
|
93
|
+
// ── Alert banner ──
|
|
94
|
+
const alertBanner = document.getElementById("alert-banner");
|
|
95
|
+
const alertText = document.getElementById("alert-text");
|
|
96
|
+
const alertDismiss = document.getElementById("alert-dismiss");
|
|
97
|
+
const dismissedAlerts = new Set();
|
|
98
|
+
function showAlert(message, key) {
|
|
99
|
+
if (key && dismissedAlerts.has(key))
|
|
100
|
+
return;
|
|
101
|
+
if (alertBanner && alertText) {
|
|
102
|
+
alertText.textContent = message;
|
|
103
|
+
alertBanner.hidden = false;
|
|
104
|
+
alertBanner.dataset.key = key || "";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function hideAlert() {
|
|
108
|
+
if (alertBanner) {
|
|
109
|
+
const key = alertBanner.dataset.key;
|
|
110
|
+
if (key)
|
|
111
|
+
dismissedAlerts.add(key);
|
|
112
|
+
alertBanner.hidden = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
alertDismiss?.addEventListener("click", hideAlert);
|
|
93
116
|
// ── Wire up components ──
|
|
94
117
|
const folderTree = document.getElementById("folder-tree");
|
|
95
118
|
let currentFolderSpecialUse = "";
|
|
@@ -175,7 +198,21 @@ document.getElementById("btn-sync")?.addEventListener("click", async () => {
|
|
|
175
198
|
btn.classList.remove("syncing");
|
|
176
199
|
}
|
|
177
200
|
});
|
|
178
|
-
|
|
201
|
+
// Restart menu dropdown
|
|
202
|
+
const restartBtn = document.getElementById("btn-restart");
|
|
203
|
+
const restartDropdown = document.getElementById("restart-dropdown");
|
|
204
|
+
restartBtn?.addEventListener("click", () => {
|
|
205
|
+
if (restartDropdown)
|
|
206
|
+
restartDropdown.hidden = !restartDropdown.hidden;
|
|
207
|
+
});
|
|
208
|
+
document.addEventListener("click", (e) => {
|
|
209
|
+
if (restartDropdown && !restartDropdown.hidden && !e.target.closest("#restart-menu")) {
|
|
210
|
+
restartDropdown.hidden = true;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
document.getElementById("btn-restart-quick")?.addEventListener("click", async () => {
|
|
214
|
+
if (restartDropdown)
|
|
215
|
+
restartDropdown.hidden = true;
|
|
179
216
|
const statusSync = document.getElementById("status-sync");
|
|
180
217
|
if (statusSync)
|
|
181
218
|
statusSync.textContent = "Restarting...";
|
|
@@ -183,7 +220,19 @@ document.getElementById("btn-restart")?.addEventListener("click", async () => {
|
|
|
183
220
|
await restartServer();
|
|
184
221
|
}
|
|
185
222
|
catch { /* server is shutting down */ }
|
|
186
|
-
|
|
223
|
+
});
|
|
224
|
+
document.getElementById("btn-rebuild")?.addEventListener("click", async () => {
|
|
225
|
+
if (restartDropdown)
|
|
226
|
+
restartDropdown.hidden = true;
|
|
227
|
+
if (!confirm("Rebuild local cache?\n\nThis wipes the local database and message store, then re-downloads everything.\nAccounts and settings are preserved.\n\nThis is safe and usually takes just a few minutes."))
|
|
228
|
+
return;
|
|
229
|
+
const statusSync = document.getElementById("status-sync");
|
|
230
|
+
if (statusSync)
|
|
231
|
+
statusSync.textContent = "Rebuilding...";
|
|
232
|
+
try {
|
|
233
|
+
await rebuildServer();
|
|
234
|
+
}
|
|
235
|
+
catch { /* server is shutting down */ }
|
|
187
236
|
});
|
|
188
237
|
async function openCompose(mode) {
|
|
189
238
|
const current = getCurrentMessage();
|
|
@@ -496,6 +545,7 @@ onWsEvent((event) => {
|
|
|
496
545
|
case "error":
|
|
497
546
|
if (statusSync)
|
|
498
547
|
statusSync.textContent = `Error: ${event.message}`;
|
|
548
|
+
showAlert(event.message, "ws-error");
|
|
499
549
|
break;
|
|
500
550
|
}
|
|
501
551
|
});
|
|
@@ -539,6 +589,7 @@ const viewBtn = document.getElementById("btn-view");
|
|
|
539
589
|
const viewDropdown = document.getElementById("view-dropdown");
|
|
540
590
|
const optTwoLine = document.getElementById("opt-two-line");
|
|
541
591
|
const optPreview = document.getElementById("opt-preview");
|
|
592
|
+
const optSnippet = document.getElementById("opt-snippet");
|
|
542
593
|
const optFlagged = document.getElementById("opt-flagged");
|
|
543
594
|
// Toggle dropdown
|
|
544
595
|
viewBtn?.addEventListener("click", (e) => {
|
|
@@ -553,17 +604,22 @@ document.addEventListener("click", () => {
|
|
|
553
604
|
// Restore saved view settings
|
|
554
605
|
const savedTwoLine = localStorage.getItem("mailx-two-line") === "true";
|
|
555
606
|
const savedPreview = localStorage.getItem("mailx-preview") !== "false"; // default true
|
|
607
|
+
const savedSnippet = localStorage.getItem("mailx-snippet") !== "false"; // default true
|
|
556
608
|
const savedFlagged = localStorage.getItem("mailx-flagged") === "true";
|
|
557
609
|
if (optTwoLine)
|
|
558
610
|
optTwoLine.checked = savedTwoLine;
|
|
559
611
|
if (optPreview)
|
|
560
612
|
optPreview.checked = savedPreview;
|
|
613
|
+
if (optSnippet)
|
|
614
|
+
optSnippet.checked = savedSnippet;
|
|
561
615
|
if (optFlagged)
|
|
562
616
|
optFlagged.checked = savedFlagged;
|
|
563
617
|
if (savedTwoLine)
|
|
564
618
|
document.getElementById("message-list")?.classList.add("two-line");
|
|
565
619
|
if (!savedPreview)
|
|
566
620
|
document.querySelector(".main-area")?.classList.add("no-preview");
|
|
621
|
+
if (!savedSnippet)
|
|
622
|
+
document.getElementById("message-list")?.classList.add("no-snippets");
|
|
567
623
|
if (savedFlagged)
|
|
568
624
|
document.getElementById("ml-body")?.classList.add("flagged-only");
|
|
569
625
|
// Two-line toggle
|
|
@@ -588,6 +644,17 @@ optPreview?.addEventListener("change", () => {
|
|
|
588
644
|
}
|
|
589
645
|
localStorage.setItem("mailx-preview", String(optPreview.checked));
|
|
590
646
|
});
|
|
647
|
+
// Preview snippet toggle
|
|
648
|
+
optSnippet?.addEventListener("change", () => {
|
|
649
|
+
const list = document.getElementById("message-list");
|
|
650
|
+
if (optSnippet.checked) {
|
|
651
|
+
list?.classList.remove("no-snippets");
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
list?.classList.add("no-snippets");
|
|
655
|
+
}
|
|
656
|
+
localStorage.setItem("mailx-snippet", String(optSnippet.checked));
|
|
657
|
+
});
|
|
591
658
|
// Flagged-only filter
|
|
592
659
|
optFlagged?.addEventListener("change", () => {
|
|
593
660
|
const body = document.getElementById("ml-body");
|
|
@@ -602,9 +669,10 @@ optFlagged?.addEventListener("change", () => {
|
|
|
602
669
|
const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
603
670
|
fetch("/api/version").then(r => r.json()).then(d => {
|
|
604
671
|
const el = document.getElementById("app-version");
|
|
605
|
-
const
|
|
672
|
+
const storage = d.storage || { provider: "local", mode: "local" };
|
|
673
|
+
const storageLabel = storage.provider === "local" ? "" : ` · ${storage.provider}${storage.mode === "api" ? " (API)" : ""}`;
|
|
606
674
|
if (el)
|
|
607
|
-
el.textContent = `mailx
|
|
675
|
+
el.textContent = `mailx v${d.version}${storageLabel}${isApp ? "" : " · browser"}`;
|
|
608
676
|
}).catch(async () => {
|
|
609
677
|
// Server not running — try to start it if we're in the app
|
|
610
678
|
const startupStatus = document.getElementById("startup-status");
|
package/client/index.html
CHANGED
|
@@ -48,6 +48,11 @@
|
|
|
48
48
|
</div>
|
|
49
49
|
</header>
|
|
50
50
|
|
|
51
|
+
<div class="alert-banner" id="alert-banner" hidden>
|
|
52
|
+
<span id="alert-text"></span>
|
|
53
|
+
<button class="alert-dismiss" id="alert-dismiss" title="Dismiss">×</button>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
51
56
|
<div class="folder-panel">
|
|
52
57
|
<div class="ft-filter">
|
|
53
58
|
<input type="text" id="ft-filter-input" placeholder="Find folder..." autocomplete="off">
|
package/client/lib/api-client.js
CHANGED
|
@@ -154,6 +154,9 @@ export function restartServer() {
|
|
|
154
154
|
return mailxapi.restart?.();
|
|
155
155
|
return api("/restart", { method: "POST" }).catch(() => { });
|
|
156
156
|
}
|
|
157
|
+
export function rebuildServer() {
|
|
158
|
+
return api("/rebuild", { method: "POST" }).catch(() => { });
|
|
159
|
+
}
|
|
157
160
|
// ── Folder management ──
|
|
158
161
|
export function markFolderRead(accountId, folderId) {
|
|
159
162
|
if (hasIPC)
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
/* mailx component styles */
|
|
2
2
|
|
|
3
|
+
/* ── Alert Banner ── */
|
|
4
|
+
.alert-banner {
|
|
5
|
+
display: flex; align-items: center; gap: var(--gap-sm);
|
|
6
|
+
padding: var(--gap-xs) var(--gap-md);
|
|
7
|
+
background: oklch(0.45 0.15 25); color: #fff;
|
|
8
|
+
font-size: var(--font-size-sm); font-weight: 500;
|
|
9
|
+
grid-area: alert;
|
|
10
|
+
}
|
|
11
|
+
.alert-banner[hidden] { display: none; }
|
|
12
|
+
.alert-banner #alert-text { flex: 1; }
|
|
13
|
+
.alert-dismiss { background: none; border: none; color: #fff; font-size: 1.2em; cursor: pointer; padding: 0 var(--gap-xs); opacity: 0.7; }
|
|
14
|
+
.alert-dismiss:hover { opacity: 1; }
|
|
15
|
+
|
|
3
16
|
/* ── Context Menu ── */
|
|
4
17
|
|
|
5
18
|
.ctx-menu {
|
|
@@ -65,7 +78,7 @@
|
|
|
65
78
|
.tb-icon { font-size: 1.1em; }
|
|
66
79
|
.tb-btn.syncing .tb-icon { animation: spin 1s linear infinite; }
|
|
67
80
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
68
|
-
.app-version { font-size: var(--font-size-sm); color: var(--color-text
|
|
81
|
+
.app-version { font-size: var(--font-size-sm); color: var(--color-text); opacity: 0.7; }
|
|
69
82
|
|
|
70
83
|
.tb-menu { position: relative; display: inline-block; }
|
|
71
84
|
.tb-menu-dropdown {
|
package/client/styles/layout.css
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
body {
|
|
10
10
|
display: grid;
|
|
11
11
|
grid-template-columns: var(--folder-width) 1fr;
|
|
12
|
-
grid-template-rows: var(--toolbar-height) 1fr var(--statusbar-height);
|
|
12
|
+
grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
|
|
13
13
|
grid-template-areas:
|
|
14
14
|
"toolbar toolbar"
|
|
15
|
+
"alert alert"
|
|
15
16
|
"folders main"
|
|
16
17
|
"status status";
|
|
17
18
|
height: 100vh;
|
|
@@ -59,8 +60,10 @@ body {
|
|
|
59
60
|
@media (max-width: 768px) {
|
|
60
61
|
body {
|
|
61
62
|
grid-template-columns: 1fr;
|
|
63
|
+
grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
|
|
62
64
|
grid-template-areas:
|
|
63
65
|
"toolbar"
|
|
66
|
+
"alert"
|
|
64
67
|
"main"
|
|
65
68
|
"status";
|
|
66
69
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.50",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"postinstall": "node launcher/builder/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow": "^1.0.
|
|
23
|
+
"@bobfrankston/iflow": "^1.0.30",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.6",
|
|
25
|
-
"@bobfrankston/oauthsupport": "^1.0.
|
|
25
|
+
"@bobfrankston/oauthsupport": "^1.0.13",
|
|
26
26
|
"@bobfrankston/rust-builder": "^0.1.2",
|
|
27
27
|
"mailparser": "^3.7.2",
|
|
28
28
|
"quill": "^2.0.3",
|
|
@@ -1018,15 +1018,18 @@ export class ImapManager extends EventEmitter {
|
|
|
1018
1018
|
const account = settings.accounts.find(a => a.id === accountId);
|
|
1019
1019
|
if (!account || account.imap.auth !== "oauth2")
|
|
1020
1020
|
return null;
|
|
1021
|
-
// Find credentials.json
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1021
|
+
// Find iflow-credentials.json from the iflow package
|
|
1022
|
+
const credentialsCandidates = [
|
|
1023
|
+
path.resolve(import.meta.dirname, "..", "..", "node_modules", "@bobfrankston", "iflow", "iflow-credentials.json"),
|
|
1024
|
+
path.resolve(import.meta.dirname, "..", "..", "..", "node_modules", "@bobfrankston", "iflow", "iflow-credentials.json"),
|
|
1025
|
+
];
|
|
1026
|
+
const credentialsPath = credentialsCandidates.find(p => fs.existsSync(p));
|
|
1027
|
+
if (!credentialsPath) {
|
|
1028
|
+
console.error(" [contacts] iflow-credentials.json not found");
|
|
1026
1029
|
return null;
|
|
1027
1030
|
}
|
|
1028
1031
|
const accountDir = account.imap.user.replace(/[@.]/g, "_");
|
|
1029
|
-
const tokenDir = path.join(
|
|
1032
|
+
const tokenDir = path.join(getConfigDir(), "tokens", accountDir);
|
|
1030
1033
|
const token = await authenticateOAuth(credentialsPath, {
|
|
1031
1034
|
scope: "https://www.googleapis.com/auth/contacts.readonly",
|
|
1032
1035
|
tokenDirectory: tokenDir,
|
|
@@ -9,7 +9,7 @@ import * as fs from "node:fs";
|
|
|
9
9
|
import { MailxDB } from "@bobfrankston/mailx-store";
|
|
10
10
|
import { ImapManager } from "@bobfrankston/mailx-imap";
|
|
11
11
|
import { createApiRouter } from "@bobfrankston/mailx-api";
|
|
12
|
-
import { loadSettings, getConfigDir, getStorePath,
|
|
12
|
+
import { loadSettings, getConfigDir, getStorePath, getStorageInfo, initLocalConfig } from "@bobfrankston/mailx-settings";
|
|
13
13
|
import { ports } from "@bobfrankston/miscinfo";
|
|
14
14
|
import { createServer } from "node:http";
|
|
15
15
|
const PORT = ports.mailx;
|
|
@@ -44,7 +44,6 @@ console.error = (...args) => {
|
|
|
44
44
|
// Read version from root package.json (the published version)
|
|
45
45
|
const rootPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "..", "package.json"), "utf-8"));
|
|
46
46
|
const SERVER_VERSION = rootPkg.version;
|
|
47
|
-
const CLIENT_VERSION = rootPkg.version;
|
|
48
47
|
// ── Initialize ──
|
|
49
48
|
initLocalConfig();
|
|
50
49
|
const settings = loadSettings();
|
|
@@ -82,10 +81,8 @@ app.use("/node_modules", express.static(path.join(rootDir, "node_modules"), { et
|
|
|
82
81
|
const apiRouter = createApiRouter(db, imapManager);
|
|
83
82
|
app.use("/api", apiRouter);
|
|
84
83
|
app.get("/api/version", (req, res) => {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
const drive = sharedDir === localDir ? "local" : sharedDir;
|
|
88
|
-
res.json({ server: SERVER_VERSION, client: CLIENT_VERSION, theme: settings.ui?.theme || "system", drive });
|
|
84
|
+
const storage = getStorageInfo();
|
|
85
|
+
res.json({ version: SERVER_VERSION, theme: settings.ui?.theme || "system", storage });
|
|
89
86
|
});
|
|
90
87
|
app.get("/status", (req, res) => {
|
|
91
88
|
const accounts = db.getAccounts();
|
|
@@ -111,7 +108,7 @@ h1{font-size:1.2rem}h2{font-size:1rem;margin-top:1.5rem}.ok{color:#a6e3a1}.warn{
|
|
|
111
108
|
a{color:#89b4fa}</style></head>
|
|
112
109
|
<body>
|
|
113
110
|
<h1>mailx status</h1>
|
|
114
|
-
<p>
|
|
111
|
+
<p>mailx v${SERVER_VERSION}</p>
|
|
115
112
|
<p>Uptime: ${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m | Memory: ${Math.round(mem.rss / 1048576)} MB</p>
|
|
116
113
|
<p>Pending sync: <span class="${pendingSync > 0 ? "warn" : "ok"}">${pendingSync}</span></p>
|
|
117
114
|
<h2>Accounts</h2>
|
|
@@ -199,6 +196,9 @@ imapManager.on("syncProgress", (accountId, phase, progress) => {
|
|
|
199
196
|
imapManager.on("folderCountsChanged", (accountId, counts) => {
|
|
200
197
|
broadcast({ type: "folderCountsChanged", accountId, counts });
|
|
201
198
|
});
|
|
199
|
+
imapManager.on("syncError", (accountId, error) => {
|
|
200
|
+
broadcast({ type: "error", message: `${accountId}: ${error}` });
|
|
201
|
+
});
|
|
202
202
|
// ── Startup ──
|
|
203
203
|
async function start() {
|
|
204
204
|
console.log("mailx server starting...");
|
|
@@ -24,6 +24,11 @@ export declare function cloudRead(filename: string): Promise<string | null>;
|
|
|
24
24
|
export declare function cloudWrite(filename: string, content: string): Promise<boolean>;
|
|
25
25
|
/** Whether cloud API fallback is active */
|
|
26
26
|
export declare function isCloudMode(): boolean;
|
|
27
|
+
/** Get storage provider info for display (e.g. "OneDrive", "Google Drive", "local") */
|
|
28
|
+
export declare function getStorageInfo(): {
|
|
29
|
+
provider: string;
|
|
30
|
+
mode: "mount" | "api" | "local";
|
|
31
|
+
};
|
|
27
32
|
declare const DEFAULT_PREFERENCES: {
|
|
28
33
|
ui: {
|
|
29
34
|
theme: "system" | "dark" | "light";
|
|
@@ -155,6 +155,32 @@ export async function cloudWrite(filename, content) {
|
|
|
155
155
|
export function isCloudMode() {
|
|
156
156
|
return pendingCloudConfig !== null;
|
|
157
157
|
}
|
|
158
|
+
/** Get storage provider info for display (e.g. "OneDrive", "Google Drive", "local") */
|
|
159
|
+
export function getStorageInfo() {
|
|
160
|
+
const config = readLocalConfig();
|
|
161
|
+
if (config.sharedDir) {
|
|
162
|
+
const entries = Array.isArray(config.sharedDir) ? config.sharedDir : [config.sharedDir];
|
|
163
|
+
for (const entry of entries) {
|
|
164
|
+
const resolved = resolveSharedEntry(entry);
|
|
165
|
+
if (resolved && resolved !== LOCAL_DIR) {
|
|
166
|
+
// Mounted cloud drive
|
|
167
|
+
const name = typeof entry === "string" ? "cloud" :
|
|
168
|
+
entry.provider === "onedrive" ? "OneDrive" :
|
|
169
|
+
entry.provider === "gdrive" ? "Google Drive" :
|
|
170
|
+
entry.provider === "dropbox" ? "Dropbox" : entry.provider;
|
|
171
|
+
return { provider: name, mode: "mount" };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Not mounted but using API fallback
|
|
175
|
+
if (pendingCloudConfig) {
|
|
176
|
+
const name = pendingCloudConfig.provider === "onedrive" ? "OneDrive" :
|
|
177
|
+
pendingCloudConfig.provider === "gdrive" ? "Google Drive" :
|
|
178
|
+
pendingCloudConfig.provider === "dropbox" ? "Dropbox" : pendingCloudConfig.provider;
|
|
179
|
+
return { provider: name, mode: "api" };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { provider: "local", mode: "local" };
|
|
183
|
+
}
|
|
158
184
|
// ── File helpers ──
|
|
159
185
|
/** Read JSON or JSONC file. If exact path not found, tries .json/.jsonc variant. */
|
|
160
186
|
function readJsonc(filePath) {
|