plasalid 0.8.2 → 0.8.3
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/dist/cli/commands/accounts.d.ts +1 -4
- package/dist/cli/commands/accounts.js +12 -101
- package/dist/cli/commands/rules.d.ts +4 -12
- package/dist/cli/commands/rules.js +10 -47
- package/dist/cli/commands/scan.js +6 -9
- package/dist/cli/commands/status.js +5 -3
- package/dist/cli/commands/transactions.d.ts +0 -2
- package/dist/cli/commands/transactions.js +10 -63
- package/dist/cli/format.js +22 -32
- package/dist/cli/index.js +27 -34
- package/dist/cli/ink/ListBrowser.d.ts +7 -0
- package/dist/cli/ink/ListBrowser.js +34 -47
- package/dist/cli/ink/RulesBrowser.d.ts +7 -0
- package/dist/cli/ink/RulesBrowser.js +67 -0
- package/dist/cli/ink/ScanDashboard.js +1 -4
- package/dist/cli/ink/keys.d.ts +2 -0
- package/dist/cli/ink/keys.js +19 -0
- package/dist/scanner/parse.js +6 -4
- package/dist/scanner/worker.js +4 -2
- package/package.json +1 -1
|
@@ -1,113 +1,24 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { getDb } from "../../db/connection.js";
|
|
3
3
|
import { getAccountBalances } from "../../db/queries/account-balance.js";
|
|
4
|
-
|
|
5
|
-
import { formatSignedAmount } from "../../currency.js";
|
|
6
|
-
const TYPE_TAG = {
|
|
7
|
-
asset: "asset",
|
|
8
|
-
liability: "liab",
|
|
9
|
-
income: "income",
|
|
10
|
-
expense: "expense",
|
|
11
|
-
equity: "equity",
|
|
12
|
-
};
|
|
13
|
-
const TYPE_TAG_WIDTH = 8;
|
|
14
|
-
const TYPE_RANK = {
|
|
15
|
-
asset: 0,
|
|
16
|
-
liability: 1,
|
|
17
|
-
income: 2,
|
|
18
|
-
expense: 3,
|
|
19
|
-
equity: 4,
|
|
20
|
-
};
|
|
21
|
-
export async function showAccounts(opts = {}) {
|
|
4
|
+
export async function showAccounts() {
|
|
22
5
|
const db = getDb();
|
|
23
6
|
const accounts = getAccountBalances(db);
|
|
24
7
|
if (accounts.length === 0) {
|
|
25
8
|
console.log(chalk.yellow("No accounts yet. Drop your bank/credit card statements into ~/.plasalid/data/ and run `plasalid scan`."));
|
|
26
9
|
return;
|
|
27
10
|
}
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
]);
|
|
36
|
-
const recentTransactionsByAccount = new Map();
|
|
37
|
-
for (const a of accounts) {
|
|
38
|
-
const rows = listPostings(db, { account_id: a.id, limit: 10 });
|
|
39
|
-
if (rows.length > 0)
|
|
40
|
-
recentTransactionsByAccount.set(a.id, rows);
|
|
41
|
-
}
|
|
42
|
-
await runBrowser(createElement(AccountsBrowser, { accounts, recentTransactionsByAccount }));
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
printAccountsPlain(accounts);
|
|
46
|
-
}
|
|
47
|
-
function printAccountsPlain(raw) {
|
|
48
|
-
const byId = new Map(raw.map((a) => [a.id, a]));
|
|
49
|
-
const depthCache = new Map();
|
|
50
|
-
const depthOf = (id) => {
|
|
51
|
-
if (depthCache.has(id))
|
|
52
|
-
return depthCache.get(id);
|
|
53
|
-
const node = byId.get(id);
|
|
54
|
-
if (!node || !node.parent_id) {
|
|
55
|
-
depthCache.set(id, 0);
|
|
56
|
-
return 0;
|
|
57
|
-
}
|
|
58
|
-
const d = depthOf(node.parent_id) + 1;
|
|
59
|
-
depthCache.set(id, d);
|
|
60
|
-
return d;
|
|
61
|
-
};
|
|
62
|
-
const accounts = [...raw].sort((a, b) => {
|
|
63
|
-
const t = TYPE_RANK[a.type] - TYPE_RANK[b.type];
|
|
64
|
-
if (t !== 0)
|
|
65
|
-
return t;
|
|
66
|
-
return a.id.localeCompare(b.id);
|
|
67
|
-
});
|
|
68
|
-
const balanceWidth = Math.max(...accounts.map((a) => formatSignedAmount(a.balance).length));
|
|
69
|
-
const nameWidth = Math.max(...accounts.map((a) => a.name.length + depthOf(a.id) * 2));
|
|
11
|
+
const [{ runBrowser }, { AccountsBrowser }, { createElement }, { listPostings },] = await Promise.all([
|
|
12
|
+
import("../ink/runBrowser.js"),
|
|
13
|
+
import("../ink/AccountsBrowser.js"),
|
|
14
|
+
import("react"),
|
|
15
|
+
import("../../db/queries/transactions.js"),
|
|
16
|
+
]);
|
|
17
|
+
const recentTransactionsByAccount = new Map();
|
|
70
18
|
for (const a of accounts) {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const name = chalk.bold(displayName) + " ".repeat(nameWidth - displayName.length);
|
|
75
|
-
const rawBalance = formatSignedAmount(a.balance);
|
|
76
|
-
const coloredBalance = a.balance < 0 ? chalk.red(rawBalance) : rawBalance;
|
|
77
|
-
const paddedBalance = " ".repeat(balanceWidth - visibleLength(coloredBalance)) + coloredBalance;
|
|
78
|
-
const meta = compactMeta(a);
|
|
79
|
-
const metaStr = meta.length ? ` ${chalk.dim(meta.join(" · "))}` : "";
|
|
80
|
-
console.log(` ${tag} ${name} ${paddedBalance}${metaStr}`);
|
|
19
|
+
const rows = listPostings(db, { account_id: a.id, limit: 10 });
|
|
20
|
+
if (rows.length > 0)
|
|
21
|
+
recentTransactionsByAccount.set(a.id, rows);
|
|
81
22
|
}
|
|
82
|
-
|
|
83
|
-
for (const a of accounts) {
|
|
84
|
-
if (a.type === "asset")
|
|
85
|
-
assets += a.balance;
|
|
86
|
-
else if (a.type === "liability")
|
|
87
|
-
liabilities += a.balance;
|
|
88
|
-
}
|
|
89
|
-
const netWorth = assets - liabilities;
|
|
90
|
-
console.log("");
|
|
91
|
-
console.log(" " +
|
|
92
|
-
chalk.dim(`Assets ${formatSignedAmount(assets)}`) +
|
|
93
|
-
chalk.dim(" · ") +
|
|
94
|
-
chalk.dim(`Liabilities ${formatSignedAmount(liabilities)}`) +
|
|
95
|
-
chalk.dim(" · ") +
|
|
96
|
-
chalk.bold(`Net worth ${formatSignedAmount(netWorth)}`));
|
|
97
|
-
}
|
|
98
|
-
function compactMeta(a) {
|
|
99
|
-
const meta = [];
|
|
100
|
-
if (a.bank_name)
|
|
101
|
-
meta.push(a.bank_name);
|
|
102
|
-
if (a.due_day)
|
|
103
|
-
meta.push(`due ${a.due_day}`);
|
|
104
|
-
if (a.statement_day)
|
|
105
|
-
meta.push(`stmt ${a.statement_day}`);
|
|
106
|
-
if (a.points_balance)
|
|
107
|
-
meta.push(`${a.points_balance.toLocaleString()} pts`);
|
|
108
|
-
if (a.currency && a.currency !== "THB")
|
|
109
|
-
meta.push(a.currency);
|
|
110
|
-
if (meta.length === 0 && a.subtype)
|
|
111
|
-
meta.push(a.subtype);
|
|
112
|
-
return meta;
|
|
23
|
+
await runBrowser(createElement(AccountsBrowser, { accounts, recentTransactionsByAccount }));
|
|
113
24
|
}
|
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
import type Database from "libsql";
|
|
2
|
-
export interface
|
|
2
|
+
export interface RuleEntry {
|
|
3
3
|
displayId: string;
|
|
4
4
|
text: string;
|
|
5
|
+
forget(db: Database.Database): void;
|
|
5
6
|
}
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
matched: ForgetMatch[];
|
|
9
|
-
} | {
|
|
10
|
-
ok: false;
|
|
11
|
-
error: string;
|
|
12
|
-
};
|
|
13
|
-
export declare function renderRules(db: Database.Database): string;
|
|
14
|
-
export declare function forgetRules(db: Database.Database, pattern: string): ForgetOutcome;
|
|
15
|
-
export declare function showRules(): void;
|
|
16
|
-
export declare function forgetRule(pattern: string): void;
|
|
7
|
+
export declare function collectRules(db: Database.Database): RuleEntry[];
|
|
8
|
+
export declare function showRules(): Promise<void>;
|
|
@@ -2,7 +2,7 @@ import chalk from "chalk";
|
|
|
2
2
|
import { getDb } from "../../db/connection.js";
|
|
3
3
|
import { getMemories, deleteMemory } from "../../ai/memory.js";
|
|
4
4
|
import { listMerchants, clearMerchantDefaultAccount, } from "../../db/queries/merchants.js";
|
|
5
|
-
function collectRules(db) {
|
|
5
|
+
export function collectRules(db) {
|
|
6
6
|
const out = [];
|
|
7
7
|
for (const m of getMemories(db)) {
|
|
8
8
|
out.push({
|
|
@@ -25,55 +25,18 @@ function collectRules(db) {
|
|
|
25
25
|
});
|
|
26
26
|
return out;
|
|
27
27
|
}
|
|
28
|
-
export function
|
|
28
|
+
export async function showRules() {
|
|
29
|
+
const db = getDb();
|
|
29
30
|
const rules = collectRules(db);
|
|
30
31
|
if (rules.length === 0) {
|
|
31
|
-
|
|
32
|
+
console.log("No rules yet.\n\n" +
|
|
32
33
|
chalk.dim("Rules accumulate as you clarify questions. Run `plasalid clarify` after a scan."));
|
|
33
|
-
}
|
|
34
|
-
const width = Math.max(...rules.map((r) => r.displayId.length));
|
|
35
|
-
const lines = [chalk.bold(`Rules (${rules.length}):`)];
|
|
36
|
-
for (const r of rules) {
|
|
37
|
-
lines.push(` ${chalk.cyan(r.displayId.padEnd(width))} ${r.text}`);
|
|
38
|
-
}
|
|
39
|
-
lines.push("");
|
|
40
|
-
lines.push(chalk.dim("To remove: plasalid forget <regex>"));
|
|
41
|
-
return lines.join("\n");
|
|
42
|
-
}
|
|
43
|
-
export function forgetRules(db, pattern) {
|
|
44
|
-
let re;
|
|
45
|
-
try {
|
|
46
|
-
re = new RegExp(`^${pattern}$`);
|
|
47
|
-
}
|
|
48
|
-
catch (err) {
|
|
49
|
-
return { ok: false, error: `Invalid regex /${pattern}/: ${err instanceof Error ? err.message : String(err)}` };
|
|
50
|
-
}
|
|
51
|
-
const snapshot = collectRules(db);
|
|
52
|
-
const hits = snapshot.filter((r) => re.test(r.displayId));
|
|
53
|
-
if (!hits.length) {
|
|
54
|
-
return {
|
|
55
|
-
ok: false,
|
|
56
|
-
error: `No rule matches /${pattern}/. Run \`plasalid rules\` to list ids.`,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
const matched = hits.map((r) => {
|
|
60
|
-
r.forget(db);
|
|
61
|
-
return { displayId: r.displayId, text: r.text };
|
|
62
|
-
});
|
|
63
|
-
return { ok: true, matched };
|
|
64
|
-
}
|
|
65
|
-
export function showRules() {
|
|
66
|
-
console.log(renderRules(getDb()));
|
|
67
|
-
}
|
|
68
|
-
export function forgetRule(pattern) {
|
|
69
|
-
const outcome = forgetRules(getDb(), pattern);
|
|
70
|
-
if (!outcome.ok) {
|
|
71
|
-
console.error(chalk.red(outcome.error));
|
|
72
|
-
process.exitCode = 1;
|
|
73
34
|
return;
|
|
74
35
|
}
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
36
|
+
const [{ runBrowser }, { RulesBrowser }, { createElement }] = await Promise.all([
|
|
37
|
+
import("../ink/runBrowser.js"),
|
|
38
|
+
import("../ink/RulesBrowser.js"),
|
|
39
|
+
import("react"),
|
|
40
|
+
]);
|
|
41
|
+
await runBrowser(createElement(RulesBrowser, { rules, db }));
|
|
79
42
|
}
|
|
@@ -69,10 +69,12 @@ async function buildTtyHooks(signal) {
|
|
|
69
69
|
let inkInstance = null;
|
|
70
70
|
let unsubscribeProgress = null;
|
|
71
71
|
const chunkLookup = new Map();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Surface cancellation through Ink's controller, not raw stdout — writing
|
|
74
|
+
* to stdout while Ink is rendering corrupts its frame tracking and leaves
|
|
75
|
+
* a phantom copy of the header in scrollback. once:true so the listener
|
|
76
|
+
* self-removes without leaking past the scan run.
|
|
77
|
+
*/
|
|
76
78
|
const onAbortEvt = () => {
|
|
77
79
|
controller.publish({ type: "phase-set", phase: "cancelling" });
|
|
78
80
|
};
|
|
@@ -86,11 +88,6 @@ async function buildTtyHooks(signal) {
|
|
|
86
88
|
}
|
|
87
89
|
console.log(chalk.dim(`Decrypted ${s.decrypted.length}, skipped ${s.skipped.length}, failed ${s.failed.length}.`));
|
|
88
90
|
},
|
|
89
|
-
afterChunk: (s) => {
|
|
90
|
-
if (s.chunks.length === 0)
|
|
91
|
-
return;
|
|
92
|
-
console.log(chalk.dim(`Chunked into ${s.chunks.length} page(s). Mounting dashboard…`));
|
|
93
|
-
},
|
|
94
91
|
beforeParse: (s) => {
|
|
95
92
|
for (const c of s.chunks)
|
|
96
93
|
chunkLookup.set(c.chunkId, {
|
|
@@ -8,10 +8,13 @@ import { countQuestions } from "../../db/queries/questions.js";
|
|
|
8
8
|
import { countMemories } from "../../ai/memory.js";
|
|
9
9
|
import { config, getActiveModel } from "../../config.js";
|
|
10
10
|
import { formatAmount } from "../../currency.js";
|
|
11
|
-
import { visibleLength } from "../format.js";
|
|
11
|
+
import { banner, visibleLength } from "../format.js";
|
|
12
12
|
const LABEL_WIDTH = 18;
|
|
13
13
|
export function showStatus() {
|
|
14
14
|
const db = getDb();
|
|
15
|
+
console.log("");
|
|
16
|
+
console.log(banner());
|
|
17
|
+
console.log("");
|
|
15
18
|
printSection("Financial", financialRows(db));
|
|
16
19
|
console.log("");
|
|
17
20
|
printSection("System", systemRows(db));
|
|
@@ -83,8 +86,7 @@ function modelRows() {
|
|
|
83
86
|
}
|
|
84
87
|
function printSection(title, rows, opts) {
|
|
85
88
|
const align = opts?.align ?? "right";
|
|
86
|
-
console.log(chalk.bold(title));
|
|
87
|
-
console.log(chalk.dim("─".repeat(title.length)));
|
|
89
|
+
console.log(chalk.bold.yellow(title));
|
|
88
90
|
const valueWidth = Math.max(0, ...rows.map((r) => visibleLength(r.value)));
|
|
89
91
|
for (const row of rows) {
|
|
90
92
|
const label = row.label.padEnd(LABEL_WIDTH);
|
|
@@ -4,7 +4,5 @@ export interface ShowTransactionsOptions {
|
|
|
4
4
|
to?: string;
|
|
5
5
|
query?: string;
|
|
6
6
|
limit?: number;
|
|
7
|
-
/** Force the plain-print path even when stdout is a TTY. */
|
|
8
|
-
noInteractive?: boolean;
|
|
9
7
|
}
|
|
10
8
|
export declare function showTransactions(opts: ShowTransactionsOptions): Promise<void>;
|
|
@@ -1,39 +1,27 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { getDb } from "../../db/connection.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import { formatAmount } from "../../currency.js";
|
|
6
|
-
import { truncateMiddle } from "../helper.js";
|
|
7
|
-
const ACCOUNT_CAP = 32;
|
|
8
|
-
const MEMO_CAP = 40;
|
|
9
|
-
const INTERACTIVE_LIMIT = 1000;
|
|
10
|
-
const RECURRING_MARKER = "[R]";
|
|
3
|
+
import { listPostings } from "../../db/queries/transactions.js";
|
|
4
|
+
const DEFAULT_LIMIT = 1000;
|
|
11
5
|
export async function showTransactions(opts) {
|
|
12
6
|
const db = getDb();
|
|
13
|
-
const interactive = !opts.noInteractive && Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
|
|
14
|
-
const requestedLimit = opts.limit ?? (interactive ? INTERACTIVE_LIMIT : 100);
|
|
15
7
|
const postings = listPostings(db, {
|
|
16
8
|
account_id: opts.account,
|
|
17
9
|
from: opts.from,
|
|
18
10
|
to: opts.to,
|
|
19
11
|
q: opts.query,
|
|
20
|
-
limit:
|
|
12
|
+
limit: opts.limit ?? DEFAULT_LIMIT,
|
|
21
13
|
});
|
|
22
14
|
if (postings.length === 0) {
|
|
23
15
|
console.log(chalk.yellow("No postings match those filters."));
|
|
24
16
|
return;
|
|
25
17
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
await runBrowser(createElement(TransactionsBrowser, { postings, filterSummary }));
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
printTransactionsPlain(postings);
|
|
18
|
+
const filterSummary = buildFilterSummary(opts);
|
|
19
|
+
const [{ runBrowser }, { TransactionsBrowser }, { createElement }] = await Promise.all([
|
|
20
|
+
import("../ink/runBrowser.js"),
|
|
21
|
+
import("../ink/TransactionsBrowser.js"),
|
|
22
|
+
import("react"),
|
|
23
|
+
]);
|
|
24
|
+
await runBrowser(createElement(TransactionsBrowser, { postings, filterSummary }));
|
|
37
25
|
}
|
|
38
26
|
function buildFilterSummary(opts) {
|
|
39
27
|
const parts = [];
|
|
@@ -47,44 +35,3 @@ function buildFilterSummary(opts) {
|
|
|
47
35
|
parts.push(`query="${opts.query}"`);
|
|
48
36
|
return parts.join(" · ");
|
|
49
37
|
}
|
|
50
|
-
function printTransactionsPlain(postings) {
|
|
51
|
-
const truncatedAccount = new Map();
|
|
52
|
-
const truncatedMemo = new Map();
|
|
53
|
-
for (const p of postings) {
|
|
54
|
-
const acct = p.account_name ?? p.account_id;
|
|
55
|
-
truncatedAccount.set(p.id, truncateMiddle(acct, ACCOUNT_CAP));
|
|
56
|
-
if (p.memo)
|
|
57
|
-
truncatedMemo.set(p.id, truncateMiddle(p.memo, MEMO_CAP));
|
|
58
|
-
}
|
|
59
|
-
const accountWidth = Math.max(...postings.map((p) => truncatedAccount.get(p.id).length));
|
|
60
|
-
const amountWidth = Math.max(...postings.map((p) => {
|
|
61
|
-
const side = p.debit > 0 ? "DR" : "CR";
|
|
62
|
-
const amt = p.debit > 0 ? p.debit : p.credit;
|
|
63
|
-
return `${side} ${formatAmount(amt, p.currency)}`.length;
|
|
64
|
-
}));
|
|
65
|
-
const cols = process.stdout.columns || 100;
|
|
66
|
-
const descMax = Math.max(20, cols - 14);
|
|
67
|
-
const groups = groupByTransaction(postings);
|
|
68
|
-
for (const g of groups) {
|
|
69
|
-
const desc = truncateMiddle(g.description, descMax);
|
|
70
|
-
const merchant = g.merchant ? chalk.green(` · ${g.merchant}`) : "";
|
|
71
|
-
const recurring = g.recurrence_id ? chalk.dim(` ${RECURRING_MARKER}`) : "";
|
|
72
|
-
console.log(`${chalk.dim(g.date)} ${chalk.bold(desc)}${merchant}${recurring}`);
|
|
73
|
-
for (const p of g.postings) {
|
|
74
|
-
const acct = truncatedAccount.get(p.id);
|
|
75
|
-
const acctPadded = acct + " ".repeat(accountWidth - acct.length);
|
|
76
|
-
const side = p.debit > 0 ? "DR" : "CR";
|
|
77
|
-
const amt = p.debit > 0 ? p.debit : p.credit;
|
|
78
|
-
const rawAmount = `${side} ${formatAmount(amt, p.currency)}`;
|
|
79
|
-
const colored = p.debit > 0 ? chalk.cyan(rawAmount) : chalk.magenta(rawAmount);
|
|
80
|
-
const amountPadded = " ".repeat(amountWidth - visibleLength(colored)) + colored;
|
|
81
|
-
const memo = truncatedMemo.get(p.id);
|
|
82
|
-
const memoStr = memo ? ` ${chalk.dim(memo)}` : "";
|
|
83
|
-
console.log(` ${acctPadded} ${amountPadded}${memoStr}`);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
if (groups.length > 1) {
|
|
87
|
-
console.log("");
|
|
88
|
-
console.log(chalk.dim(` ${groups.length} transactions · ${postings.length} postings`));
|
|
89
|
-
}
|
|
90
|
-
}
|
package/dist/cli/format.js
CHANGED
|
@@ -58,43 +58,33 @@ export function banner() {
|
|
|
58
58
|
chalk.bold("Plasalid") +
|
|
59
59
|
chalk.dim(" · The Harness Layer for Personal Finance"));
|
|
60
60
|
}
|
|
61
|
-
function stripAnsi(str) {
|
|
62
|
-
return str.replace(ANSI_RE, "");
|
|
63
|
-
}
|
|
64
|
-
function box(label, lines) {
|
|
65
|
-
const cols = process.stdout.columns || 100;
|
|
66
|
-
const inner = cols - 4;
|
|
67
|
-
const top = `┌─── ${label} ${"─".repeat(Math.max(0, inner - label.length - 5))}┐`;
|
|
68
|
-
const bot = `└${"─".repeat(inner + 2)}┘`;
|
|
69
|
-
const pad = `│${" ".repeat(inner + 2)}│`;
|
|
70
|
-
const body = lines.map((l) => {
|
|
71
|
-
const vis = stripAnsi(l).length;
|
|
72
|
-
return `│ ${l}${" ".repeat(Math.max(0, inner - vis))}│`;
|
|
73
|
-
});
|
|
74
|
-
return [top, pad, ...body, pad, bot].join("\n");
|
|
75
|
-
}
|
|
76
61
|
const DISCLAIMER = "Plasalid is an assistant, not a financial advisor. It only summarizes financial statements — verify amounts against your statements before relying on them.";
|
|
62
|
+
function section(label, lines) {
|
|
63
|
+
return [chalk.bold.yellow(label), ...lines.map((l) => ` ${l}`)].join("\n");
|
|
64
|
+
}
|
|
77
65
|
export function helpScreen(commands) {
|
|
78
|
-
const
|
|
66
|
+
const options = [
|
|
67
|
+
{ name: "--version", desc: "Show the version and exit" },
|
|
68
|
+
{ name: "--help", desc: "Show this help screen" },
|
|
69
|
+
];
|
|
70
|
+
const nameWidth = Math.max(...commands.map((c) => c.name.length), ...options.map((o) => o.name.length));
|
|
71
|
+
const row = (name, desc) => `${chalk.cyan(name.padEnd(nameWidth))} ${chalk.dim(desc)}`;
|
|
72
|
+
const usageLines = [
|
|
73
|
+
`${chalk.cyan("plasalid")} ${chalk.dim("<command> [OPTIONS]")}`,
|
|
74
|
+
row("plasalid", "Start the chat session"),
|
|
75
|
+
];
|
|
76
|
+
return [
|
|
77
|
+
"",
|
|
79
78
|
banner(),
|
|
80
79
|
"",
|
|
81
|
-
|
|
82
|
-
"plasalid <command> [OPTIONS]",
|
|
83
|
-
"plasalid Start the chat session",
|
|
84
|
-
]),
|
|
80
|
+
section("Usage", usageLines),
|
|
85
81
|
"",
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
`${chalk.white("--version".padEnd(nameWidth))} ${chalk.dim("Show the version and exit")}`,
|
|
93
|
-
`${chalk.white("--help".padEnd(nameWidth))} ${chalk.dim("Show this help screen")}`,
|
|
94
|
-
]));
|
|
95
|
-
sections.push("");
|
|
96
|
-
sections.push(chalk.dim(DISCLAIMER));
|
|
97
|
-
return sections.join("\n");
|
|
82
|
+
section("Commands", commands.map((c) => row(c.name, c.desc))),
|
|
83
|
+
"",
|
|
84
|
+
section("Options", options.map((o) => row(o.name, o.desc))),
|
|
85
|
+
"",
|
|
86
|
+
chalk.dim(DISCLAIMER),
|
|
87
|
+
].join("\n");
|
|
98
88
|
}
|
|
99
89
|
export function formatResponse(text) {
|
|
100
90
|
return text
|
package/dist/cli/index.js
CHANGED
|
@@ -13,13 +13,7 @@ function ensureConfigured() {
|
|
|
13
13
|
process.exit(1);
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
.name("plasalid")
|
|
18
|
-
.description("The Harness Layer for Personal Finance")
|
|
19
|
-
.version(version)
|
|
20
|
-
.addHelpCommand(false)
|
|
21
|
-
.showHelpAfterError(`Run ${chalk.cyan("plasalid --help")} for the list of commands.`)
|
|
22
|
-
.action(async () => {
|
|
16
|
+
async function runChatOrSetup() {
|
|
23
17
|
if (!isConfigured()) {
|
|
24
18
|
console.log("Plasalid is not configured yet. Running setup...\n");
|
|
25
19
|
const { runSetup } = await import("./setup.js");
|
|
@@ -28,7 +22,18 @@ program
|
|
|
28
22
|
}
|
|
29
23
|
const { startChat } = await import("./chat.js");
|
|
30
24
|
await startChat();
|
|
31
|
-
}
|
|
25
|
+
}
|
|
26
|
+
program
|
|
27
|
+
.name("plasalid")
|
|
28
|
+
.description("The Harness Layer for Personal Finance")
|
|
29
|
+
.version(version)
|
|
30
|
+
.addHelpCommand(false)
|
|
31
|
+
.showHelpAfterError(`Run ${chalk.cyan("plasalid --help")} for the list of commands.`)
|
|
32
|
+
.action(runChatOrSetup);
|
|
33
|
+
program
|
|
34
|
+
.command("chat")
|
|
35
|
+
.description("Open the chat TUI (the default action when running `plasalid`)")
|
|
36
|
+
.action(runChatOrSetup);
|
|
32
37
|
program
|
|
33
38
|
.command("setup")
|
|
34
39
|
.description("Configure Plasalid (API key, encryption, data directory)")
|
|
@@ -46,12 +51,11 @@ program
|
|
|
46
51
|
});
|
|
47
52
|
program
|
|
48
53
|
.command("accounts")
|
|
49
|
-
.description("Browse the chart of accounts with balances
|
|
50
|
-
.
|
|
51
|
-
.action(async (opts) => {
|
|
54
|
+
.description("Browse the chart of accounts with balances")
|
|
55
|
+
.action(async () => {
|
|
52
56
|
ensureConfigured();
|
|
53
57
|
const { showAccounts } = await import("./commands/accounts.js");
|
|
54
|
-
await showAccounts(
|
|
58
|
+
await showAccounts();
|
|
55
59
|
});
|
|
56
60
|
program
|
|
57
61
|
.command("status")
|
|
@@ -63,13 +67,12 @@ program
|
|
|
63
67
|
});
|
|
64
68
|
program
|
|
65
69
|
.command("transactions")
|
|
66
|
-
.description("Browse transactions
|
|
70
|
+
.description("Browse transactions")
|
|
67
71
|
.option("-a, --account <id>", "Filter by account id")
|
|
68
72
|
.option("--from <date>", "From date YYYY-MM-DD")
|
|
69
73
|
.option("--to <date>", "To date YYYY-MM-DD")
|
|
70
74
|
.option("-q, --query <text>", "Free-text search on description / memo")
|
|
71
|
-
.option("-n, --limit <number>", "Max results (default 1000
|
|
72
|
-
.option("--no-interactive", "Force plain-print output instead of the Ink browser")
|
|
75
|
+
.option("-n, --limit <number>", "Max results (default 1000)")
|
|
73
76
|
.action(async (opts) => {
|
|
74
77
|
ensureConfigured();
|
|
75
78
|
const { showTransactions } = await import("./commands/transactions.js");
|
|
@@ -79,8 +82,6 @@ program
|
|
|
79
82
|
to: opts.to,
|
|
80
83
|
query: opts.query,
|
|
81
84
|
limit: opts.limit != null ? Number(opts.limit) : undefined,
|
|
82
|
-
// commander inverts --no-foo to `opts.foo = false`
|
|
83
|
-
noInteractive: opts.interactive === false,
|
|
84
85
|
});
|
|
85
86
|
});
|
|
86
87
|
program
|
|
@@ -120,19 +121,11 @@ program
|
|
|
120
121
|
});
|
|
121
122
|
program
|
|
122
123
|
.command("rules")
|
|
123
|
-
.description("
|
|
124
|
+
.description("Browse the rules the system has learned (press d to delete)")
|
|
124
125
|
.action(async () => {
|
|
125
126
|
ensureConfigured();
|
|
126
127
|
const { showRules } = await import("./commands/rules.js");
|
|
127
|
-
showRules();
|
|
128
|
-
});
|
|
129
|
-
program
|
|
130
|
-
.command("forget <regex>")
|
|
131
|
-
.description("Delete every learned rule whose id matches <regex> (anchored). Run `plasalid rules` to list ids.")
|
|
132
|
-
.action(async (regex) => {
|
|
133
|
-
ensureConfigured();
|
|
134
|
-
const { forgetRule } = await import("./commands/rules.js");
|
|
135
|
-
forgetRule(regex);
|
|
128
|
+
await showRules();
|
|
136
129
|
});
|
|
137
130
|
program
|
|
138
131
|
.command("clarify")
|
|
@@ -144,6 +137,10 @@ program
|
|
|
144
137
|
});
|
|
145
138
|
program.configureHelp({
|
|
146
139
|
formatHelp: () => helpScreen([
|
|
140
|
+
{
|
|
141
|
+
name: "chat",
|
|
142
|
+
desc: "Open the chat TUI (default when running `plasalid`)",
|
|
143
|
+
},
|
|
147
144
|
{
|
|
148
145
|
name: "setup",
|
|
149
146
|
desc: "Configure Plasalid (API key, encryption, data dir)",
|
|
@@ -152,11 +149,11 @@ program.configureHelp({
|
|
|
152
149
|
name: "data",
|
|
153
150
|
desc: "Open the data folder in your OS file explorer (alias: open)",
|
|
154
151
|
},
|
|
155
|
-
{ name: "accounts", desc: "Browse the chart of accounts
|
|
152
|
+
{ name: "accounts", desc: "Browse the chart of accounts with balances" },
|
|
156
153
|
{ name: "status", desc: "Show financial and system status (net worth, recurring, questions)" },
|
|
157
154
|
{
|
|
158
155
|
name: "transactions",
|
|
159
|
-
desc: "Browse transactions (
|
|
156
|
+
desc: "Browse transactions (with optional filters)",
|
|
160
157
|
},
|
|
161
158
|
{
|
|
162
159
|
name: "record",
|
|
@@ -168,11 +165,7 @@ program.configureHelp({
|
|
|
168
165
|
},
|
|
169
166
|
{
|
|
170
167
|
name: "rules",
|
|
171
|
-
desc: "
|
|
172
|
-
},
|
|
173
|
-
{
|
|
174
|
-
name: "forget",
|
|
175
|
-
desc: "Delete learned rules whose ids match <regex> (anchored)",
|
|
168
|
+
desc: "Browse the rules the system has learned (press d to delete)",
|
|
176
169
|
},
|
|
177
170
|
{
|
|
178
171
|
name: "clarify",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
+
import { type Key } from "ink";
|
|
2
3
|
export interface ListBrowserAdapter<T> {
|
|
3
4
|
title: string;
|
|
4
5
|
filterSummary?: string;
|
|
@@ -22,6 +23,12 @@ export interface ListBrowserAdapter<T> {
|
|
|
22
23
|
summary?: ReactNode;
|
|
23
24
|
/** Optional override for the "no results" empty state. */
|
|
24
25
|
emptyMessage?: string;
|
|
26
|
+
/** Adapter-owned key handler. Runs after search-mode and before built-in
|
|
27
|
+
* navigation; return true to mark the key consumed so default handling
|
|
28
|
+
* (q/arrows/g/G/return/etc.) is skipped. */
|
|
29
|
+
onKey?: (input: string, key: Key, ctx: {
|
|
30
|
+
cursorItem: T | null;
|
|
31
|
+
}) => boolean;
|
|
25
32
|
}
|
|
26
33
|
/**
|
|
27
34
|
* Alternate-screen list browser shell. The type-specific behavior lives in the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { memo, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
4
|
+
import { keyOf } from "./keys.js";
|
|
4
5
|
const HEADER_LINES = 2; // title + rule
|
|
5
6
|
const FOOTER_LINES = 2; // rule + hint
|
|
6
7
|
const SUMMARY_LINES = 1; // optional aggregate footer
|
|
@@ -73,66 +74,52 @@ export function ListBrowser({ adapter }) {
|
|
|
73
74
|
});
|
|
74
75
|
}, [cursor, effectiveViewportSize, filtered.length]);
|
|
75
76
|
useInput((input, key) => {
|
|
77
|
+
const k = keyOf(input, key);
|
|
78
|
+
const last = Math.max(0, filtered.length - 1);
|
|
76
79
|
if (searchMode) {
|
|
77
|
-
|
|
78
|
-
setSearchMode(false)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
const SEARCH_KEYS = {
|
|
81
|
+
return: () => setSearchMode(false),
|
|
82
|
+
escape: () => setSearchMode(false),
|
|
83
|
+
backspace: () => setSearch(prev => prev.slice(0, -1)),
|
|
84
|
+
delete: () => setSearch(prev => prev.slice(0, -1)),
|
|
85
|
+
};
|
|
86
|
+
const handler = SEARCH_KEYS[k];
|
|
87
|
+
if (handler) {
|
|
88
|
+
handler();
|
|
83
89
|
return;
|
|
84
90
|
}
|
|
85
91
|
if (input && !key.ctrl && !key.meta)
|
|
86
92
|
setSearch(prev => prev + input);
|
|
87
93
|
return;
|
|
88
94
|
}
|
|
89
|
-
if (input
|
|
90
|
-
exit();
|
|
95
|
+
if (adapter.onKey?.(input, key, { cursorItem: filtered[cursor] ?? null }))
|
|
91
96
|
return;
|
|
92
|
-
}
|
|
93
|
-
if (input === "/") {
|
|
94
|
-
setSearchMode(true);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
const last = Math.max(0, filtered.length - 1);
|
|
98
97
|
const move = (delta) => {
|
|
99
98
|
setExpandedId(null);
|
|
100
99
|
setCursor(c => Math.max(0, Math.min(last, c + delta)));
|
|
101
100
|
};
|
|
102
|
-
|
|
103
|
-
move(-1);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
if (key.downArrow || input === "j") {
|
|
107
|
-
move(1);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
if (key.pageUp) {
|
|
111
|
-
move(-viewportSize);
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
if (key.pageDown) {
|
|
115
|
-
move(viewportSize);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
if (input === "g") {
|
|
119
|
-
setExpandedId(null);
|
|
120
|
-
setCursor(0);
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (input === "G") {
|
|
124
|
-
setExpandedId(null);
|
|
125
|
-
setCursor(last);
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (key.return) {
|
|
101
|
+
const toggleExpand = () => {
|
|
129
102
|
const item = filtered[cursor];
|
|
130
|
-
if (item)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
103
|
+
if (!item)
|
|
104
|
+
return;
|
|
105
|
+
const id = adapter.getId(item);
|
|
106
|
+
setExpandedId(prev => prev === id ? null : id);
|
|
107
|
+
};
|
|
108
|
+
const NAV_KEYS = {
|
|
109
|
+
q: exit,
|
|
110
|
+
escape: exit,
|
|
111
|
+
"/": () => setSearchMode(true),
|
|
112
|
+
k: () => move(-1),
|
|
113
|
+
upArrow: () => move(-1),
|
|
114
|
+
j: () => move(1),
|
|
115
|
+
downArrow: () => move(1),
|
|
116
|
+
pageUp: () => move(-viewportSize),
|
|
117
|
+
pageDown: () => move(viewportSize),
|
|
118
|
+
g: () => { setExpandedId(null); setCursor(0); },
|
|
119
|
+
G: () => { setExpandedId(null); setCursor(last); },
|
|
120
|
+
return: toggleExpand,
|
|
121
|
+
};
|
|
122
|
+
NAV_KEYS[k]?.();
|
|
136
123
|
});
|
|
137
124
|
const ruleWidth = Math.min(cols, 120);
|
|
138
125
|
const visibleEnd = Math.min(filtered.length, scrollOffset + effectiveViewportSize);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import type { RuleEntry } from "../commands/rules.js";
|
|
3
|
+
export interface RulesBrowserProps {
|
|
4
|
+
rules: RuleEntry[];
|
|
5
|
+
db: Database.Database;
|
|
6
|
+
}
|
|
7
|
+
export declare function RulesBrowser({ rules: initialRules, db }: RulesBrowserProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { Text } from "ink";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { padRight, truncateMiddle } from "../helper.js";
|
|
6
|
+
import { ListBrowser } from "./ListBrowser.js";
|
|
7
|
+
import { keyOf } from "./keys.js";
|
|
8
|
+
const MIN_TEXT_WIDTH = 16;
|
|
9
|
+
export function RulesBrowser({ rules: initialRules, db }) {
|
|
10
|
+
const [rules, setRules] = useState(initialRules);
|
|
11
|
+
const [confirmId, setConfirmId] = useState(null);
|
|
12
|
+
const idWidth = useMemo(() => (rules.length === 0 ? 0 : Math.max(...rules.map((r) => r.displayId.length))), [rules]);
|
|
13
|
+
const adapter = useMemo(() => {
|
|
14
|
+
const commitDelete = () => {
|
|
15
|
+
const target = rules.find((r) => r.displayId === confirmId);
|
|
16
|
+
if (target) {
|
|
17
|
+
target.forget(db);
|
|
18
|
+
setRules((prev) => prev.filter((r) => r.displayId !== confirmId));
|
|
19
|
+
}
|
|
20
|
+
setConfirmId(null);
|
|
21
|
+
};
|
|
22
|
+
const cancelConfirm = () => setConfirmId(null);
|
|
23
|
+
const CONFIRM_KEYS = {
|
|
24
|
+
y: commitDelete,
|
|
25
|
+
n: cancelConfirm,
|
|
26
|
+
escape: cancelConfirm,
|
|
27
|
+
};
|
|
28
|
+
const BROWSE_KEYS = {
|
|
29
|
+
d: (cursorItem) => {
|
|
30
|
+
if (!cursorItem)
|
|
31
|
+
return false;
|
|
32
|
+
setConfirmId(cursorItem.displayId);
|
|
33
|
+
return true;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
title: "Rules",
|
|
38
|
+
items: rules,
|
|
39
|
+
getId: (r) => r.displayId,
|
|
40
|
+
renderRow: (r, ctx) => renderRuleRow(r, ctx.isCursor, ctx.cols, idWidth),
|
|
41
|
+
matches: (r, needle) => r.displayId.toLowerCase().includes(needle) ||
|
|
42
|
+
r.text.toLowerCase().includes(needle),
|
|
43
|
+
emptyMessage: "No rules yet. Rules accumulate as you clarify questions. Run `plasalid clarify` after a scan.",
|
|
44
|
+
summary: confirmId ? (_jsx(Text, { color: "yellow", children: `Delete ${confirmId}? (y/n)` })) : undefined,
|
|
45
|
+
onKey: (input, key, { cursorItem }) => {
|
|
46
|
+
const k = keyOf(input, key).toLowerCase();
|
|
47
|
+
if (confirmId !== null) {
|
|
48
|
+
CONFIRM_KEYS[k]?.();
|
|
49
|
+
return true; // confirm mode swallows everything else
|
|
50
|
+
}
|
|
51
|
+
return BROWSE_KEYS[k]?.(cursorItem) ?? false;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}, [rules, idWidth, confirmId, db]);
|
|
55
|
+
return _jsx(ListBrowser, { adapter: adapter });
|
|
56
|
+
}
|
|
57
|
+
function renderRuleRow(r, isCursor, cols, idWidth) {
|
|
58
|
+
const marker = isCursor ? "▸" : " ";
|
|
59
|
+
const idPadded = padRight(r.displayId, idWidth);
|
|
60
|
+
const id = isCursor ? chalk.cyan.bold(idPadded) : chalk.cyan(idPadded);
|
|
61
|
+
// Layout: "M idPadded text" → marker(1) + space + idPadded + 2 + text
|
|
62
|
+
const fixedWidth = 1 + 1 + idWidth + 2;
|
|
63
|
+
const textBudget = Math.max(MIN_TEXT_WIDTH, cols - fixedWidth - 2);
|
|
64
|
+
const textRaw = truncateMiddle(r.text, textBudget);
|
|
65
|
+
const text = isCursor ? chalk.bold(textRaw) : textRaw;
|
|
66
|
+
return `${marker} ${id} ${text}`;
|
|
67
|
+
}
|
|
@@ -37,7 +37,7 @@ export function ScanDashboard(props) {
|
|
|
37
37
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { phase: phase }), _jsx(AttachmentLine, { info: props.attachment }), _jsx(Box, { marginTop: 1, children: _jsx(ColumnHeader, {}) }), _jsx(Divider, { width: ruleWidth }), Array.from(rows.entries()).map(([fileId, group]) => (_jsx(FileGroupView, { group: group }, fileId))), _jsx(Divider, { width: ruleWidth }), phase !== "done" && _jsx(Footnote, {})] }));
|
|
38
38
|
}
|
|
39
39
|
function Footnote() {
|
|
40
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "output accuracy depends on the model's
|
|
40
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "output accuracy depends on the model's vision capability." }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "by the way, user can run" }), _jsx(Text, { color: "cyan", children: "clarify" }), _jsx(Text, { dimColor: true, children: ", " }), _jsx(Text, { color: "cyan", children: "record" }), _jsx(Text, { dimColor: true, children: ", and " }), _jsx(Text, { color: "cyan", children: "chat" }), _jsx(Text, { dimColor: true, children: " to correct the data later." })] })] }));
|
|
41
41
|
}
|
|
42
42
|
function AttachmentLine({ info }) {
|
|
43
43
|
const detail = info.format === "pdf" ? "pdf (native)" : "png (rasterized)";
|
|
@@ -81,9 +81,6 @@ function phaseStateOf(label, current) {
|
|
|
81
81
|
return "pending";
|
|
82
82
|
}
|
|
83
83
|
function Header({ phase }) {
|
|
84
|
-
// Cancellation collapses the parse/clarify segments — neither is still
|
|
85
|
-
// running once the user hits Ctrl+C, and showing them as "pending" would
|
|
86
|
-
// be misleading. The single "cancelling…" label communicates the wind-down.
|
|
87
84
|
if (phase === "cancelling") {
|
|
88
85
|
return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Scanner" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "green", children: "\u2713 decrypt" }), _jsx(Text, { dimColor: true, children: " -> " }), _jsx(Text, { color: "green", children: "\u2713 chunk" }), _jsx(Text, { dimColor: true, children: " -> " }), _jsxs(Text, { color: "red", children: [_jsx(Spinner, { type: "dots" }), " cancelling\u2026"] })] }));
|
|
89
86
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const SPECIAL = [
|
|
2
|
+
[(k) => k.escape, "escape"],
|
|
3
|
+
[(k) => k.return, "return"],
|
|
4
|
+
[(k) => k.backspace, "backspace"],
|
|
5
|
+
[(k) => k.delete, "delete"],
|
|
6
|
+
[(k) => k.upArrow, "upArrow"],
|
|
7
|
+
[(k) => k.downArrow, "downArrow"],
|
|
8
|
+
[(k) => k.leftArrow, "leftArrow"],
|
|
9
|
+
[(k) => k.rightArrow, "rightArrow"],
|
|
10
|
+
[(k) => k.pageUp, "pageUp"],
|
|
11
|
+
[(k) => k.pageDown, "pageDown"],
|
|
12
|
+
[(k) => k.tab, "tab"],
|
|
13
|
+
];
|
|
14
|
+
export function keyOf(input, key) {
|
|
15
|
+
for (const [pred, name] of SPECIAL)
|
|
16
|
+
if (pred(key))
|
|
17
|
+
return name;
|
|
18
|
+
return input;
|
|
19
|
+
}
|
package/dist/scanner/parse.js
CHANGED
|
@@ -43,10 +43,12 @@ export async function parsePhase(db, state, hooks) {
|
|
|
43
43
|
error: errorMessage(r.error),
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Only flip files to "scanned" for groups that actually completed. On abort
|
|
48
|
+
* the pool leaves later groups unclaimed (their settled slot is undefined);
|
|
49
|
+
* those rows stay `pending` so a future re-scan can pick them up. Partial
|
|
50
|
+
* transactions already committed during the run stay (scanner is DB-direct).
|
|
51
|
+
*/
|
|
50
52
|
for (let i = 0; i < fileGroups.length; i++) {
|
|
51
53
|
if (!settled[i])
|
|
52
54
|
continue;
|
package/dist/scanner/worker.js
CHANGED
|
@@ -43,8 +43,10 @@ export async function runScanWorker(deps, hooks) {
|
|
|
43
43
|
}));
|
|
44
44
|
hooks.onWorkerEnd?.(workerId, deps.chunk, outcome.ok);
|
|
45
45
|
if (!outcome.ok) {
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* A worker whose in-flight call was cancelled by Ctrl+C is not a real
|
|
48
|
+
* failure — don't pollute the questions table with chunk_failed rows.
|
|
49
|
+
*/
|
|
48
50
|
if (deps.signal.aborted)
|
|
49
51
|
return;
|
|
50
52
|
recordChunkFailure(deps, outcome.error);
|