plasalid 0.8.2 → 0.9.0
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/README.md +4 -0
- package/dist/ai/personas.js +29 -6
- package/dist/ai/prompt-sections.d.ts +10 -0
- package/dist/ai/prompt-sections.js +29 -0
- package/dist/ai/system-prompt.js +10 -6
- package/dist/ai/tools/clarify.js +35 -0
- package/dist/ai/tools/common.js +3 -2
- package/dist/ai/tools/index.js +6 -3
- package/dist/ai/tools/ingest.js +47 -35
- package/dist/ai/tools/mutate.d.ts +2 -0
- package/dist/ai/tools/mutate.js +81 -0
- package/dist/cli/commands/accounts.d.ts +1 -4
- package/dist/cli/commands/accounts.js +12 -101
- package/dist/cli/commands/files.d.ts +7 -0
- package/dist/cli/commands/files.js +24 -0
- package/dist/cli/commands/rules.d.ts +4 -12
- package/dist/cli/commands/rules.js +33 -67
- package/dist/cli/commands/scan.js +14 -12
- 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/helper.d.ts +9 -1
- package/dist/cli/helper.js +17 -2
- package/dist/cli/index.js +37 -32
- package/dist/cli/ink/FilesBrowser.d.ts +7 -0
- package/dist/cli/ink/FilesBrowser.js +103 -0
- package/dist/cli/ink/ListBrowser.d.ts +16 -1
- package/dist/cli/ink/ListBrowser.js +36 -49
- package/dist/cli/ink/PromptFrame.js +1 -1
- package/dist/cli/ink/RulesBrowser.d.ts +7 -0
- package/dist/cli/ink/RulesBrowser.js +67 -0
- package/dist/cli/ink/ScanDashboard.js +90 -68
- package/dist/cli/ink/hooks/useFooterText.js +14 -22
- package/dist/cli/ink/keys.d.ts +2 -0
- package/dist/cli/ink/keys.js +19 -0
- package/dist/db/queries/files.d.ts +29 -0
- package/dist/db/queries/files.js +34 -0
- package/dist/db/queries/questions.d.ts +17 -0
- package/dist/db/queries/questions.js +47 -9
- package/dist/db/queries/rules.d.ts +31 -0
- package/dist/db/queries/rules.js +55 -0
- package/dist/db/queries/transactions.d.ts +34 -0
- package/dist/db/queries/transactions.js +86 -0
- package/dist/db/schema.js +17 -0
- package/dist/scanner/clarifier-memory.d.ts +15 -3
- package/dist/scanner/clarifier-memory.js +38 -17
- package/dist/scanner/clarifier.d.ts +2 -1
- package/dist/scanner/clarifier.js +40 -26
- package/dist/scanner/commit-pipeline.d.ts +56 -0
- package/dist/scanner/commit-pipeline.js +204 -0
- package/dist/scanner/committer.d.ts +56 -0
- package/dist/scanner/committer.js +204 -0
- package/dist/scanner/parse.js +27 -7
- package/dist/scanner/recurrence-pipeline.d.ts +28 -0
- package/dist/scanner/recurrence-pipeline.js +126 -0
- package/dist/scanner/recurrence.d.ts +28 -0
- package/dist/scanner/recurrence.js +155 -0
- package/dist/scanner/rule-keys.d.ts +13 -0
- package/dist/scanner/rule-keys.js +28 -0
- package/dist/scanner/rules.d.ts +13 -0
- package/dist/scanner/rules.js +28 -0
- 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
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open the scanned-files browser. The user-facing surface for dropping a
|
|
3
|
+
* file's data — same `d`-confirm-`y/n` loop as the rules browser, but
|
|
4
|
+
* typed for scanned_files rows so the layout shows path / status /
|
|
5
|
+
* provider / model / scanned_at.
|
|
6
|
+
*/
|
|
7
|
+
export declare function showFiles(): Promise<void>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { getDb } from "../../db/connection.js";
|
|
3
|
+
import { listScannedFiles } from "../../db/queries/files.js";
|
|
4
|
+
/**
|
|
5
|
+
* Open the scanned-files browser. The user-facing surface for dropping a
|
|
6
|
+
* file's data — same `d`-confirm-`y/n` loop as the rules browser, but
|
|
7
|
+
* typed for scanned_files rows so the layout shows path / status /
|
|
8
|
+
* provider / model / scanned_at.
|
|
9
|
+
*/
|
|
10
|
+
export async function showFiles() {
|
|
11
|
+
const db = getDb();
|
|
12
|
+
const files = listScannedFiles(db);
|
|
13
|
+
if (files.length === 0) {
|
|
14
|
+
console.log("No scanned files yet.\n\n" +
|
|
15
|
+
chalk.dim("Drop PDFs into ~/.plasalid/data/ and run `plasalid scan`."));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const [{ runBrowser }, { FilesBrowser }, { createElement }] = await Promise.all([
|
|
19
|
+
import("../ink/runBrowser.js"),
|
|
20
|
+
import("../ink/FilesBrowser.js"),
|
|
21
|
+
import("react"),
|
|
22
|
+
]);
|
|
23
|
+
await runBrowser(createElement(FilesBrowser, { files, db }));
|
|
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>;
|
|
@@ -1,79 +1,45 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { getDb } from "../../db/connection.js";
|
|
3
3
|
import { getMemories, deleteMemory } from "../../ai/memory.js";
|
|
4
|
+
import { listRules, deleteRule } from "../../db/queries/rules.js";
|
|
4
5
|
import { listMerchants, clearMerchantDefaultAccount, } from "../../db/queries/merchants.js";
|
|
5
|
-
function collectRules(db) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
export function collectRules(db) {
|
|
7
|
+
return [...collectStructuredRules(db), ...collectMemories(db), ...collectMerchantDefaults(db)];
|
|
8
|
+
}
|
|
9
|
+
function collectStructuredRules(db) {
|
|
10
|
+
return listRules(db).map((r) => ({
|
|
11
|
+
displayId: `rule:${r.id}`,
|
|
12
|
+
text: `[${r.kind}] ${r.key} → ${r.target}`,
|
|
13
|
+
forget: (db) => { deleteRule(db, r.id); },
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
function collectMemories(db) {
|
|
17
|
+
return getMemories(db).map((m) => ({
|
|
18
|
+
displayId: `mem:${m.id}`,
|
|
19
|
+
text: m.content,
|
|
20
|
+
forget: (db) => { deleteMemory(db, m.id); },
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
function collectMerchantDefaults(db) {
|
|
16
24
|
const merchants = listMerchants(db, { withDefaultOnly: true });
|
|
17
|
-
merchants.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
clearMerchantDefaultAccount(db, m.id);
|
|
23
|
-
},
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
return out;
|
|
25
|
+
return merchants.map((m, i) => ({
|
|
26
|
+
displayId: `mch:${i + 1}`,
|
|
27
|
+
text: `${m.canonical_name} → ${m.default_account_id}`,
|
|
28
|
+
forget: (db) => { clearMerchantDefaultAccount(db, m.id); },
|
|
29
|
+
}));
|
|
27
30
|
}
|
|
28
|
-
export function
|
|
31
|
+
export async function showRules() {
|
|
32
|
+
const db = getDb();
|
|
29
33
|
const rules = collectRules(db);
|
|
30
34
|
if (rules.length === 0) {
|
|
31
|
-
|
|
35
|
+
console.log("No rules yet.\n\n" +
|
|
32
36
|
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
37
|
return;
|
|
74
38
|
}
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
39
|
+
const [{ runBrowser }, { RulesBrowser }, { createElement }] = await Promise.all([
|
|
40
|
+
import("../ink/runBrowser.js"),
|
|
41
|
+
import("../ink/RulesBrowser.js"),
|
|
42
|
+
import("react"),
|
|
43
|
+
]);
|
|
44
|
+
await runBrowser(createElement(RulesBrowser, { rules, db }));
|
|
79
45
|
}
|
|
@@ -4,6 +4,7 @@ import { runScan } from "../../scanner/engine.js";
|
|
|
4
4
|
import { getActiveModel } from "../../config.js";
|
|
5
5
|
import { getProvider } from "../../ai/providers/index.js";
|
|
6
6
|
import { AbortedError } from "../../ai/errors.js";
|
|
7
|
+
import { countQuestions } from "../../db/queries/questions.js";
|
|
7
8
|
/** Show the cursor — always safe; mirrors the TTY mount-time hide. */
|
|
8
9
|
function restoreTerminal() {
|
|
9
10
|
if (process.stdout.isTTY)
|
|
@@ -69,10 +70,12 @@ async function buildTtyHooks(signal) {
|
|
|
69
70
|
let inkInstance = null;
|
|
70
71
|
let unsubscribeProgress = null;
|
|
71
72
|
const chunkLookup = new Map();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Surface cancellation through Ink's controller, not raw stdout — writing
|
|
75
|
+
* to stdout while Ink is rendering corrupts its frame tracking and leaves
|
|
76
|
+
* a phantom copy of the header in scrollback. once:true so the listener
|
|
77
|
+
* self-removes without leaking past the scan run.
|
|
78
|
+
*/
|
|
76
79
|
const onAbortEvt = () => {
|
|
77
80
|
controller.publish({ type: "phase-set", phase: "cancelling" });
|
|
78
81
|
};
|
|
@@ -86,11 +89,6 @@ async function buildTtyHooks(signal) {
|
|
|
86
89
|
}
|
|
87
90
|
console.log(chalk.dim(`Decrypted ${s.decrypted.length}, skipped ${s.skipped.length}, failed ${s.failed.length}.`));
|
|
88
91
|
},
|
|
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
92
|
beforeParse: (s) => {
|
|
95
93
|
for (const c of s.chunks)
|
|
96
94
|
chunkLookup.set(c.chunkId, {
|
|
@@ -271,9 +269,13 @@ function renderSummary(state) {
|
|
|
271
269
|
const r = state.clarifySummary;
|
|
272
270
|
if (r && r.total > 0) {
|
|
273
271
|
console.log(`Clarified ${r.clarified}/${r.total} questions.`);
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
272
|
+
}
|
|
273
|
+
// Show the total active backlog (excluding deferred rows) across the whole
|
|
274
|
+
// ledger, not just this scan — so the user sees questions accumulated from
|
|
275
|
+
// prior runs alongside this scan's leftovers.
|
|
276
|
+
const totalOpen = countQuestions(getDb());
|
|
277
|
+
if (totalOpen > 0) {
|
|
278
|
+
console.log(chalk.yellow(`${totalOpen} question(s) need your input — run ${chalk.cyan("plasalid clarify")} when ready.`));
|
|
277
279
|
}
|
|
278
280
|
if (state.errors.length > 0) {
|
|
279
281
|
console.log(chalk.yellow(`${state.errors.length} phase error(s):`));
|
|
@@ -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/helper.d.ts
CHANGED
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
* in the middle. Returns `s` unchanged when already short enough.
|
|
4
4
|
*/
|
|
5
5
|
export declare function truncateMiddle(s: string, max: number): string;
|
|
6
|
+
/**
|
|
7
|
+
* Count visible terminal cells. Each code point counts as 1, except Unicode
|
|
8
|
+
* combining marks (\p{M}) which stack on the preceding base char and count
|
|
9
|
+
* as 0. Does not handle East-Asian wide characters; revisit if CJK filenames
|
|
10
|
+
* appear in real ledgers.
|
|
11
|
+
*/
|
|
12
|
+
export declare function displayWidth(s: string): number;
|
|
6
13
|
/**
|
|
7
14
|
* Right-pad to a fixed visible width. Assumes `s` has no ANSI codes — callers
|
|
8
15
|
* working with colored strings should compose color around the padded value,
|
|
9
|
-
* not inside it.
|
|
16
|
+
* not inside it. Pads by display width so Thai (and other scripts with
|
|
17
|
+
* combining marks) stay aligned with surrounding columns.
|
|
10
18
|
*/
|
|
11
19
|
export declare function padRight(s: string, width: number): string;
|
package/dist/cli/helper.js
CHANGED
|
@@ -14,11 +14,26 @@ export function truncateMiddle(s, max) {
|
|
|
14
14
|
const tail = Math.floor(keep / 2);
|
|
15
15
|
return `${s.slice(0, head)}…${s.slice(s.length - tail)}`;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Count visible terminal cells. Each code point counts as 1, except Unicode
|
|
19
|
+
* combining marks (\p{M}) which stack on the preceding base char and count
|
|
20
|
+
* as 0. Does not handle East-Asian wide characters; revisit if CJK filenames
|
|
21
|
+
* appear in real ledgers.
|
|
22
|
+
*/
|
|
23
|
+
export function displayWidth(s) {
|
|
24
|
+
let w = 0;
|
|
25
|
+
for (const c of s)
|
|
26
|
+
if (!/^\p{M}$/u.test(c))
|
|
27
|
+
w++;
|
|
28
|
+
return w;
|
|
29
|
+
}
|
|
17
30
|
/**
|
|
18
31
|
* Right-pad to a fixed visible width. Assumes `s` has no ANSI codes — callers
|
|
19
32
|
* working with colored strings should compose color around the padded value,
|
|
20
|
-
* not inside it.
|
|
33
|
+
* not inside it. Pads by display width so Thai (and other scripts with
|
|
34
|
+
* combining marks) stay aligned with surrounding columns.
|
|
21
35
|
*/
|
|
22
36
|
export function padRight(s, width) {
|
|
23
|
-
|
|
37
|
+
const visual = displayWidth(s);
|
|
38
|
+
return visual >= width ? s : s + " ".repeat(width - visual);
|
|
24
39
|
}
|