@morroc/open-ledger 0.14.1
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/LICENSE +202 -0
- package/README.md +191 -0
- package/datasets/defaults/cn.json +1 -0
- package/datasets/defaults/jp.json +1 -0
- package/datasets/defaults/th.json +1 -0
- package/datasets/defaults/us.json +1 -0
- package/datasets/institutions/cn.json +47 -0
- package/datasets/institutions/jp.json +59 -0
- package/datasets/institutions/th.json +99 -0
- package/datasets/institutions/us.json +70 -0
- package/dist/accounts/accounts.js +99 -0
- package/dist/accounts/balances.js +109 -0
- package/dist/accounts/matching.js +72 -0
- package/dist/accounts/resolve.js +118 -0
- package/dist/cli/commands/accounts.js +477 -0
- package/dist/cli/commands/config.js +175 -0
- package/dist/cli/commands/datasets.js +56 -0
- package/dist/cli/commands/doctor.js +153 -0
- package/dist/cli/commands/files.js +72 -0
- package/dist/cli/commands/ingest-commit.js +279 -0
- package/dist/cli/commands/ingest.js +203 -0
- package/dist/cli/commands/merchants.js +140 -0
- package/dist/cli/commands/notes.js +71 -0
- package/dist/cli/commands/open.js +54 -0
- package/dist/cli/commands/questions.js +119 -0
- package/dist/cli/commands/report.js +50 -0
- package/dist/cli/commands/setup.js +61 -0
- package/dist/cli/commands/status.js +187 -0
- package/dist/cli/commands/transactions.js +420 -0
- package/dist/cli/commands/vault.js +65 -0
- package/dist/cli/currency.js +28 -0
- package/dist/cli/db.js +5 -0
- package/dist/cli/format.js +71 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/output.js +304 -0
- package/dist/cli/program.js +140 -0
- package/dist/config.js +104 -0
- package/dist/context.js +30 -0
- package/dist/datasets/defaults.js +29 -0
- package/dist/datasets/index.js +33 -0
- package/dist/datasets/institutions.js +31 -0
- package/dist/datasets/loader.js +57 -0
- package/dist/db/connection.js +39 -0
- package/dist/db/encryption.js +42 -0
- package/dist/db/migrations/0001_baseline.js +121 -0
- package/dist/db/migrations/index.js +3 -0
- package/dist/db/queries/accounts.js +108 -0
- package/dist/db/queries/balances.js +61 -0
- package/dist/db/queries/files.js +72 -0
- package/dist/db/queries/merchants.js +163 -0
- package/dist/db/queries/notes.js +28 -0
- package/dist/db/queries/questions.js +117 -0
- package/dist/db/queries/transactions-dedup.js +80 -0
- package/dist/db/queries/transactions.js +319 -0
- package/dist/db/queries/vault.js +48 -0
- package/dist/db/schema.js +105 -0
- package/dist/extract/extract.js +89 -0
- package/dist/extract/ocr.js +164 -0
- package/dist/extract/pdf.js +127 -0
- package/dist/extract/presets/index.js +18 -0
- package/dist/extract/presets/lighton-ocr.js +12 -0
- package/dist/extract/presets/typhoon-ocr.js +23 -0
- package/dist/extract/route.js +40 -0
- package/dist/extract/source.js +84 -0
- package/dist/ingest/commit.js +313 -0
- package/dist/ingest/dedup.js +35 -0
- package/dist/ingest/prepare.js +241 -0
- package/dist/ingest/vault.js +56 -0
- package/dist/lib/date.js +6 -0
- package/dist/lib/ids.js +25 -0
- package/dist/lib/json.js +12 -0
- package/dist/lib/masked.js +49 -0
- package/dist/lib/money.js +35 -0
- package/dist/lib/patch.js +29 -0
- package/dist/lib/result.js +15 -0
- package/dist/lib/validate.js +154 -0
- package/dist/privacy/redactor.js +140 -0
- package/dist/setup/hosts.js +32 -0
- package/dist/setup/install.js +60 -0
- package/package.json +60 -0
- package/skills/SKILL.md +18 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { accountExists } from "./accounts.js";
|
|
2
|
+
import { upsertMerchant } from "./merchants.js";
|
|
3
|
+
import { buildPatch } from "../../lib/patch.js";
|
|
4
|
+
import { newGroupId, newTransactionId } from "../../lib/ids.js";
|
|
5
|
+
import { ISO_DATE_RE } from "../../lib/date.js";
|
|
6
|
+
/** Amount must already be an integer in minor units — this layer never sees decimals. */
|
|
7
|
+
export function validateTransaction(input) {
|
|
8
|
+
if (!ISO_DATE_RE.test(input.date ?? "")) {
|
|
9
|
+
return { ok: false, reason: "Transaction date must be an ISO date (YYYY-MM-DD)." };
|
|
10
|
+
}
|
|
11
|
+
if (!input.description || !input.description.trim()) {
|
|
12
|
+
return { ok: false, reason: "Transaction description must not be empty." };
|
|
13
|
+
}
|
|
14
|
+
if (!Number.isInteger(input.amount) || input.amount <= 0) {
|
|
15
|
+
return { ok: false, reason: "Transaction amount must be a positive integer in minor units." };
|
|
16
|
+
}
|
|
17
|
+
if (!input.debit_account_id || !input.debit_account_id.trim()) {
|
|
18
|
+
return { ok: false, reason: "Transaction debit_account_id must not be empty." };
|
|
19
|
+
}
|
|
20
|
+
if (!input.credit_account_id || !input.credit_account_id.trim()) {
|
|
21
|
+
return { ok: false, reason: "Transaction credit_account_id must not be empty." };
|
|
22
|
+
}
|
|
23
|
+
if (input.debit_account_id === input.credit_account_id) {
|
|
24
|
+
return { ok: false, reason: "Transaction debit and credit accounts must differ." };
|
|
25
|
+
}
|
|
26
|
+
return { ok: true };
|
|
27
|
+
}
|
|
28
|
+
const INSERT_COLUMNS = "id, group_id, date, description, merchant_id, raw_descriptor, source_file_id, source_page, debit_account_id, credit_account_id, amount, currency, code, user_ref";
|
|
29
|
+
function insertParams(id, merchantId, input) {
|
|
30
|
+
return [
|
|
31
|
+
id,
|
|
32
|
+
input.group_id ?? null,
|
|
33
|
+
input.date,
|
|
34
|
+
input.description,
|
|
35
|
+
merchantId,
|
|
36
|
+
input.raw_descriptor ?? null,
|
|
37
|
+
input.source_file_id ?? null,
|
|
38
|
+
input.source_page ?? null,
|
|
39
|
+
input.debit_account_id,
|
|
40
|
+
input.credit_account_id,
|
|
41
|
+
input.amount,
|
|
42
|
+
input.currency,
|
|
43
|
+
input.code ?? null,
|
|
44
|
+
input.user_ref ?? null,
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Validates, then `INSERT ... ON CONFLICT(id) DO NOTHING` — re-inserting the
|
|
49
|
+
* same derived id is a no-op. `duplicate` is true when the row already existed.
|
|
50
|
+
*/
|
|
51
|
+
export function insertTransaction(db, input) {
|
|
52
|
+
const check = validateTransaction(input);
|
|
53
|
+
if (!check.ok)
|
|
54
|
+
throw new Error(check.reason);
|
|
55
|
+
const id = input.id ?? newTransactionId();
|
|
56
|
+
let merchantId = input.merchant_id ?? null;
|
|
57
|
+
if (!merchantId && input.merchant) {
|
|
58
|
+
merchantId = upsertMerchant(db, input.merchant).id;
|
|
59
|
+
}
|
|
60
|
+
const result = db
|
|
61
|
+
.prepare(`INSERT INTO transactions (${INSERT_COLUMNS})
|
|
62
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
63
|
+
ON CONFLICT(id) DO NOTHING`)
|
|
64
|
+
.run(...insertParams(id, merchantId, input));
|
|
65
|
+
return { id, duplicate: result.changes === 0 };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Insert several transactions sharing one group_id, atomically (any leg's
|
|
69
|
+
* failure rolls back all). group_id: `opts.group_id`, else the first input's, else a fresh `tg:`.
|
|
70
|
+
*/
|
|
71
|
+
export function insertLinkedTransactions(db, inputs, opts = {}) {
|
|
72
|
+
if (inputs.length === 0) {
|
|
73
|
+
throw new Error("insertLinkedTransactions requires at least one transaction.");
|
|
74
|
+
}
|
|
75
|
+
const groupId = opts.group_id ?? inputs.find((i) => i.group_id)?.group_id ?? newGroupId();
|
|
76
|
+
let results = [];
|
|
77
|
+
const tx = db.transaction(() => {
|
|
78
|
+
results = inputs.map((input) => insertTransaction(db, { ...input, group_id: groupId }));
|
|
79
|
+
});
|
|
80
|
+
tx();
|
|
81
|
+
return { results, group_id: groupId };
|
|
82
|
+
}
|
|
83
|
+
// Shared by listing and counts so a `--query` filter (reads joined names) matches in both.
|
|
84
|
+
const LIST_FROM = `FROM transactions t
|
|
85
|
+
LEFT JOIN accounts da ON da.id = t.debit_account_id
|
|
86
|
+
LEFT JOIN accounts ca ON ca.id = t.credit_account_id
|
|
87
|
+
LEFT JOIN merchants m ON m.id = t.merchant_id`;
|
|
88
|
+
const ROW_SELECT = `SELECT t.id, t.group_id, t.date, t.description, t.merchant_id,
|
|
89
|
+
t.raw_descriptor, t.source_file_id, t.source_page,
|
|
90
|
+
t.debit_account_id, t.credit_account_id, t.amount, t.currency,
|
|
91
|
+
t.code, t.user_ref, t.void_of, t.has_question, t.created_at,
|
|
92
|
+
da.name AS debit_account_name,
|
|
93
|
+
ca.name AS credit_account_name,
|
|
94
|
+
m.canonical_name AS merchant_name
|
|
95
|
+
${LIST_FROM}`;
|
|
96
|
+
/**
|
|
97
|
+
* Loads one transaction; when grouped, `group` carries every member (self
|
|
98
|
+
* included) ordered by id. Null if the id doesn't exist.
|
|
99
|
+
*/
|
|
100
|
+
export function findTransactionById(db, id) {
|
|
101
|
+
const row = db.prepare(`${ROW_SELECT} WHERE t.id = ?`).get(id);
|
|
102
|
+
if (!row)
|
|
103
|
+
return null;
|
|
104
|
+
if (!row.group_id)
|
|
105
|
+
return row;
|
|
106
|
+
const group = db
|
|
107
|
+
.prepare(`${ROW_SELECT} WHERE t.group_id = ? ORDER BY t.id`)
|
|
108
|
+
.all(row.group_id);
|
|
109
|
+
return { ...row, group };
|
|
110
|
+
}
|
|
111
|
+
// Shared by listTransactions/countTransactions so a filtered count matches the list.
|
|
112
|
+
// `params` align positionally with the `?` placeholders in `whereSql`.
|
|
113
|
+
function buildListWhere(opts) {
|
|
114
|
+
const conditions = [];
|
|
115
|
+
const params = [];
|
|
116
|
+
if (opts.account) {
|
|
117
|
+
conditions.push("(t.debit_account_id = ? OR t.credit_account_id = ?)");
|
|
118
|
+
params.push(opts.account, opts.account);
|
|
119
|
+
}
|
|
120
|
+
if (opts.from) {
|
|
121
|
+
conditions.push("t.date >= ?");
|
|
122
|
+
params.push(opts.from);
|
|
123
|
+
}
|
|
124
|
+
if (opts.to) {
|
|
125
|
+
conditions.push("t.date <= ?");
|
|
126
|
+
params.push(opts.to);
|
|
127
|
+
}
|
|
128
|
+
if (opts.query) {
|
|
129
|
+
conditions.push("(t.description LIKE ? OR t.raw_descriptor LIKE ? OR m.canonical_name LIKE ? OR da.name LIKE ? OR ca.name LIKE ?)");
|
|
130
|
+
const like = `%${opts.query}%`;
|
|
131
|
+
params.push(like, like, like, like, like);
|
|
132
|
+
}
|
|
133
|
+
if (opts.amount !== undefined) {
|
|
134
|
+
conditions.push("t.amount = ?");
|
|
135
|
+
params.push(opts.amount);
|
|
136
|
+
}
|
|
137
|
+
const whereSql = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
138
|
+
return { whereSql, params };
|
|
139
|
+
}
|
|
140
|
+
const DEFAULT_LIST_LIMIT = 50;
|
|
141
|
+
const MAX_LIST_LIMIT = 500;
|
|
142
|
+
/** Shared with the CLI summary so the reported cap matches the rows returned. */
|
|
143
|
+
export function clampListLimit(limit) {
|
|
144
|
+
return Math.min(Math.max(limit ?? DEFAULT_LIST_LIMIT, 1), MAX_LIST_LIMIT);
|
|
145
|
+
}
|
|
146
|
+
export function listTransactions(db, opts = {}) {
|
|
147
|
+
const { whereSql, params } = buildListWhere(opts);
|
|
148
|
+
const limit = clampListLimit(opts.limit);
|
|
149
|
+
const rows = db
|
|
150
|
+
.prepare(`${ROW_SELECT} ${whereSql} ORDER BY t.date DESC, t.id DESC LIMIT ?`)
|
|
151
|
+
.all(...params, limit);
|
|
152
|
+
return opts.group ? clusterByGroup(rows) : rows;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Fold rows into per-group clusters, preserving the incoming (date DESC, id
|
|
156
|
+
* DESC) order for cluster first-appearance. Rows with a null group_id each
|
|
157
|
+
* become their own standalone cluster.
|
|
158
|
+
*/
|
|
159
|
+
function clusterByGroup(rows) {
|
|
160
|
+
const clusters = [];
|
|
161
|
+
const byGroup = new Map();
|
|
162
|
+
for (const row of rows) {
|
|
163
|
+
if (row.group_id == null) {
|
|
164
|
+
clusters.push({ group_id: null, transactions: [row] });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
let cluster = byGroup.get(row.group_id);
|
|
168
|
+
if (!cluster) {
|
|
169
|
+
cluster = { group_id: row.group_id, transactions: [] };
|
|
170
|
+
byGroup.set(row.group_id, cluster);
|
|
171
|
+
clusters.push(cluster);
|
|
172
|
+
}
|
|
173
|
+
cluster.transactions.push(row);
|
|
174
|
+
}
|
|
175
|
+
return clusters;
|
|
176
|
+
}
|
|
177
|
+
export function deleteTransaction(db, id) {
|
|
178
|
+
return db.prepare(`DELETE FROM transactions WHERE id = ?`).run(id).changes > 0;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Re-points every matching transaction's `:from` side (debit OR credit) to
|
|
182
|
+
* `:to`. Rows whose other side already equals `:to` are skipped (would
|
|
183
|
+
* violate the debit<>credit CHECK) and counted in `skipped_self_transaction`.
|
|
184
|
+
*/
|
|
185
|
+
export function bulkRecategorize(db, filter, set) {
|
|
186
|
+
const from = filter.accountId;
|
|
187
|
+
const to = set.accountId;
|
|
188
|
+
if (!from)
|
|
189
|
+
throw new Error("bulkRecategorize: filter.accountId is required.");
|
|
190
|
+
if (!to)
|
|
191
|
+
throw new Error("bulkRecategorize: set.accountId is required.");
|
|
192
|
+
if (from === to) {
|
|
193
|
+
throw new Error("bulkRecategorize: set.accountId equals filter.accountId (no-op).");
|
|
194
|
+
}
|
|
195
|
+
if (!accountExists(db, to)) {
|
|
196
|
+
throw new Error(`bulkRecategorize: target account "${to}" does not exist.`);
|
|
197
|
+
}
|
|
198
|
+
const whereSql = "(t.debit_account_id = ? OR t.credit_account_id = ?)";
|
|
199
|
+
const params = [from, from];
|
|
200
|
+
let affected = 0;
|
|
201
|
+
let skipped = 0;
|
|
202
|
+
let sample = [];
|
|
203
|
+
const tx = db.transaction(() => {
|
|
204
|
+
const rows = db
|
|
205
|
+
.prepare(`SELECT t.id, t.debit_account_id, t.credit_account_id FROM transactions t WHERE ${whereSql}`)
|
|
206
|
+
.all(...params);
|
|
207
|
+
const toUpdate = [];
|
|
208
|
+
for (const r of rows) {
|
|
209
|
+
const other = r.debit_account_id === from ? r.credit_account_id : r.debit_account_id;
|
|
210
|
+
if (other === to) {
|
|
211
|
+
skipped++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
toUpdate.push(r.id);
|
|
215
|
+
}
|
|
216
|
+
if (toUpdate.length === 0)
|
|
217
|
+
return;
|
|
218
|
+
sample = toUpdate.slice(0, 10);
|
|
219
|
+
const placeholders = toUpdate.map(() => "?").join(",");
|
|
220
|
+
affected = db
|
|
221
|
+
.prepare(`UPDATE transactions
|
|
222
|
+
SET debit_account_id = CASE WHEN debit_account_id = ? THEN ? ELSE debit_account_id END,
|
|
223
|
+
credit_account_id = CASE WHEN credit_account_id = ? THEN ? ELSE credit_account_id END
|
|
224
|
+
WHERE id IN (${placeholders})`)
|
|
225
|
+
.run(from, to, from, to, ...toUpdate).changes;
|
|
226
|
+
});
|
|
227
|
+
tx();
|
|
228
|
+
return { affected, skipped_self_transaction: skipped, sample_transaction_ids: sample };
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The re-point step of `mergeAccounts` (src/accounts/accounts.ts). Rows that
|
|
232
|
+
* would become a degenerate self-transaction (one side `fromId`, the other
|
|
233
|
+
* already `toId`) are deleted FIRST — the debit<>credit CHECK forbids that state
|
|
234
|
+
* even transiently — then the remainder is re-pointed. Does not touch the
|
|
235
|
+
* accounts table.
|
|
236
|
+
*/
|
|
237
|
+
export function repointTransactions(db, fromId, toId) {
|
|
238
|
+
if (fromId === toId)
|
|
239
|
+
throw new Error("Cannot re-point transactions to the same account.");
|
|
240
|
+
let moved = 0;
|
|
241
|
+
let deletedSelfTransactions = 0;
|
|
242
|
+
const tx = db.transaction(() => {
|
|
243
|
+
deletedSelfTransactions = db
|
|
244
|
+
.prepare(`DELETE FROM transactions
|
|
245
|
+
WHERE (debit_account_id = ? AND credit_account_id = ?)
|
|
246
|
+
OR (credit_account_id = ? AND debit_account_id = ?)`)
|
|
247
|
+
.run(fromId, toId, fromId, toId).changes;
|
|
248
|
+
const d = db
|
|
249
|
+
.prepare(`UPDATE transactions SET debit_account_id = ? WHERE debit_account_id = ?`)
|
|
250
|
+
.run(toId, fromId).changes;
|
|
251
|
+
const c = db
|
|
252
|
+
.prepare(`UPDATE transactions SET credit_account_id = ? WHERE credit_account_id = ?`)
|
|
253
|
+
.run(toId, fromId).changes;
|
|
254
|
+
moved = d + c;
|
|
255
|
+
});
|
|
256
|
+
tx();
|
|
257
|
+
return { moved, deletedSelfTransactions };
|
|
258
|
+
}
|
|
259
|
+
/** Same filters as listTransactions, no limit; no opts counts every row (the case `status` uses). */
|
|
260
|
+
export function countTransactions(db, opts = {}) {
|
|
261
|
+
const { whereSql, params } = buildListWhere(opts);
|
|
262
|
+
return db.prepare(`SELECT COUNT(*) AS n ${LIST_FROM} ${whereSql}`).get(...params).n;
|
|
263
|
+
}
|
|
264
|
+
export function countTransactionsBySourceFile(db, fileId) {
|
|
265
|
+
return db.prepare(`SELECT COUNT(*) AS n FROM transactions WHERE source_file_id = ?`).get(fileId).n;
|
|
266
|
+
}
|
|
267
|
+
/** The "account still in use" guard before deleting an account. */
|
|
268
|
+
export function accountHasTransactions(db, accountId) {
|
|
269
|
+
return !!db
|
|
270
|
+
.prepare(`SELECT 1 FROM transactions WHERE debit_account_id = ? OR credit_account_id = ? LIMIT 1`)
|
|
271
|
+
.get(accountId, accountId);
|
|
272
|
+
}
|
|
273
|
+
const TRANSACTION_META_PATCH = {
|
|
274
|
+
date: {},
|
|
275
|
+
description: {},
|
|
276
|
+
merchant_id: {},
|
|
277
|
+
source_page: {},
|
|
278
|
+
};
|
|
279
|
+
/**
|
|
280
|
+
* Amount, currency, and the account columns are intentionally not accepted
|
|
281
|
+
* here: moving accounts is `bulkRecategorize`'s job, and amount/currency edits
|
|
282
|
+
* must go through delete + re-record to keep minor-unit invariants intact.
|
|
283
|
+
*/
|
|
284
|
+
export function updateTransactionMeta(db, id, fields) {
|
|
285
|
+
const { sets, params } = buildPatch(TRANSACTION_META_PATCH, {}, fields);
|
|
286
|
+
if (sets.length === 0)
|
|
287
|
+
return 0;
|
|
288
|
+
params.push(id);
|
|
289
|
+
return db.prepare(`UPDATE transactions SET ${sets.join(", ")} WHERE id = ?`).run(...params).changes;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Voids `fromId` into surviving `toId` (sets `void_of=toId`); never deletes,
|
|
293
|
+
* so re-ingesting the mirror's source file can't resurrect it. Requires
|
|
294
|
+
* matching amount/currency/both accounts but not date (statement vs. posting
|
|
295
|
+
* dates legitimately differ). Re-voiding an already-void row is a no-op.
|
|
296
|
+
*/
|
|
297
|
+
export function voidTransactionAsMirror(db, fromId, toId) {
|
|
298
|
+
if (fromId === toId)
|
|
299
|
+
throw new Error("Cannot merge a transaction into itself.");
|
|
300
|
+
const select = db.prepare(`SELECT amount, currency, debit_account_id, credit_account_id, void_of FROM transactions WHERE id = ?`);
|
|
301
|
+
const from = select.get(fromId);
|
|
302
|
+
if (!from)
|
|
303
|
+
throw new Error(`transaction "${fromId}" not found`);
|
|
304
|
+
const to = select.get(toId);
|
|
305
|
+
if (!to)
|
|
306
|
+
throw new Error(`transaction "${toId}" not found`);
|
|
307
|
+
if (from.void_of !== null)
|
|
308
|
+
return { alreadyVoid: true };
|
|
309
|
+
if (to.void_of !== null)
|
|
310
|
+
throw new Error(`cannot merge into voided transaction "${toId}"`);
|
|
311
|
+
if (from.amount !== to.amount ||
|
|
312
|
+
from.currency !== to.currency ||
|
|
313
|
+
from.debit_account_id !== to.debit_account_id ||
|
|
314
|
+
from.credit_account_id !== to.credit_account_id) {
|
|
315
|
+
throw new Error(`transactions "${fromId}" and "${toId}" are not mirrors (amount, currency, and both accounts must match)`);
|
|
316
|
+
}
|
|
317
|
+
db.prepare(`UPDATE transactions SET void_of = ? WHERE id = ?`).run(toId, fromId);
|
|
318
|
+
return { alreadyVoid: false };
|
|
319
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { decryptSecret, encryptSecret } from "../encryption.js";
|
|
3
|
+
// Most-used first: most likely to open the next statement too.
|
|
4
|
+
const BY_USE = `ORDER BY use_count DESC, last_used_at DESC NULLS LAST, created_at ASC`;
|
|
5
|
+
/** A vault browser, not a decrypt path — `listPasswordSecrets` is that. */
|
|
6
|
+
export function listPasswords(db) {
|
|
7
|
+
return db
|
|
8
|
+
.prepare(`SELECT id, pattern, use_count, last_used_at FROM file_passwords ${BY_USE}`)
|
|
9
|
+
.all();
|
|
10
|
+
}
|
|
11
|
+
/** Every stored password, decrypted, most-used first. */
|
|
12
|
+
export function listPasswordSecrets(db, dbKey) {
|
|
13
|
+
const rows = db
|
|
14
|
+
.prepare(`SELECT id, pattern, password_encrypted FROM file_passwords ${BY_USE}`)
|
|
15
|
+
.all();
|
|
16
|
+
return rows.map((row) => ({
|
|
17
|
+
id: row.id,
|
|
18
|
+
pattern: row.pattern,
|
|
19
|
+
password: decryptSecret(row.password_encrypted, dbKey),
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
/** Replaces on conflict, so a bank's rotated password overwrites the stale one. */
|
|
23
|
+
export function upsertPassword(db, pattern, password, dbKey) {
|
|
24
|
+
const encrypted = encryptSecret(password, dbKey);
|
|
25
|
+
const existing = db
|
|
26
|
+
.prepare(`SELECT id FROM file_passwords WHERE pattern = ?`)
|
|
27
|
+
.get(pattern);
|
|
28
|
+
if (existing) {
|
|
29
|
+
db.prepare(`UPDATE file_passwords
|
|
30
|
+
SET password_encrypted = ?, use_count = 0, last_used_at = NULL
|
|
31
|
+
WHERE id = ?`).run(encrypted, existing.id);
|
|
32
|
+
return existing.id;
|
|
33
|
+
}
|
|
34
|
+
const id = `fp:${randomUUID()}`;
|
|
35
|
+
db.prepare(`INSERT INTO file_passwords (id, pattern, password_encrypted) VALUES (?, ?, ?)`).run(id, pattern, encrypted);
|
|
36
|
+
return id;
|
|
37
|
+
}
|
|
38
|
+
export function recordPasswordUse(db, id) {
|
|
39
|
+
db.prepare(`UPDATE file_passwords
|
|
40
|
+
SET use_count = use_count + 1, last_used_at = datetime('now')
|
|
41
|
+
WHERE id = ?`).run(id);
|
|
42
|
+
}
|
|
43
|
+
export function deletePassword(db, patternOrId) {
|
|
44
|
+
const result = db
|
|
45
|
+
.prepare(`DELETE FROM file_passwords WHERE id = ? OR pattern = ?`)
|
|
46
|
+
.run(patternOrId, patternOrId);
|
|
47
|
+
return result.changes > 0;
|
|
48
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { copyFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { MIGRATIONS } from "./migrations/index.js";
|
|
4
|
+
/** Never drops tables or overwrites the file. */
|
|
5
|
+
export function migrate(db, dbPath) {
|
|
6
|
+
applyMigrations(db, MIGRATIONS, dbPath);
|
|
7
|
+
}
|
|
8
|
+
/** Migrations are a parameter, not a direct import, so tests can drive a synthetic manifest. */
|
|
9
|
+
export function applyMigrations(db, migrations, dbPath) {
|
|
10
|
+
const current = currentVersion(db);
|
|
11
|
+
if (current > migrations.length) {
|
|
12
|
+
throw new Error(`Database schema version ${current} is newer than this build supports ` +
|
|
13
|
+
`(${migrations.length}). Upgrade OpenLedger to open this database.`);
|
|
14
|
+
}
|
|
15
|
+
if (current === migrations.length)
|
|
16
|
+
return;
|
|
17
|
+
// Every migration is CREATE TABLE IF NOT EXISTS, so a foreign database would otherwise be half-adopted in silence.
|
|
18
|
+
if (current === 0 && hasUserTables(db)) {
|
|
19
|
+
const at = dbPath ? ` at ${dbPath}` : "";
|
|
20
|
+
throw new Error(`This database${at} is not an OpenLedger database. Your data has not been ` +
|
|
21
|
+
`touched. Back up the file, then remove it to start fresh.`);
|
|
22
|
+
}
|
|
23
|
+
// A version-0 database that reaches here is empty; only an upgrade has anything worth copying.
|
|
24
|
+
if (dbPath && current >= 1)
|
|
25
|
+
backupDatabase(db, dbPath);
|
|
26
|
+
db.exec(`
|
|
27
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
28
|
+
version INTEGER PRIMARY KEY,
|
|
29
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
30
|
+
);
|
|
31
|
+
`);
|
|
32
|
+
for (let i = current; i < migrations.length; i++) {
|
|
33
|
+
const version = i + 1;
|
|
34
|
+
const migration = migrations[i];
|
|
35
|
+
const apply = db.transaction(() => {
|
|
36
|
+
migration.up(db);
|
|
37
|
+
db.prepare(`INSERT INTO schema_migrations (version) VALUES (?)`).run(version);
|
|
38
|
+
});
|
|
39
|
+
apply();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function currentVersion(db) {
|
|
43
|
+
const table = db
|
|
44
|
+
.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations' LIMIT 1`)
|
|
45
|
+
.get();
|
|
46
|
+
if (!table)
|
|
47
|
+
return 0;
|
|
48
|
+
const row = db
|
|
49
|
+
.prepare(`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations`)
|
|
50
|
+
.get();
|
|
51
|
+
return row.version;
|
|
52
|
+
}
|
|
53
|
+
/** Which of `tables` this database does not have — the schema half of `doctor`. */
|
|
54
|
+
export function listMissingTables(db, tables) {
|
|
55
|
+
const placeholders = tables.map(() => "?").join(",");
|
|
56
|
+
const rows = db
|
|
57
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`)
|
|
58
|
+
.all(...tables);
|
|
59
|
+
const present = new Set(rows.map((r) => r.name));
|
|
60
|
+
return tables.filter((t) => !present.has(t));
|
|
61
|
+
}
|
|
62
|
+
/** True if the database holds any table other than the migration ledger itself. */
|
|
63
|
+
function hasUserTables(db) {
|
|
64
|
+
const row = db
|
|
65
|
+
.prepare(`SELECT 1 FROM sqlite_master
|
|
66
|
+
WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> 'schema_migrations'
|
|
67
|
+
LIMIT 1`)
|
|
68
|
+
.get();
|
|
69
|
+
return !!row;
|
|
70
|
+
}
|
|
71
|
+
// Checkpoints the WAL first so the copy is complete; a non-WAL database
|
|
72
|
+
// tolerates the checkpoint failing.
|
|
73
|
+
function backupDatabase(db, dbPath) {
|
|
74
|
+
try {
|
|
75
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Rollback-journal databases have no WAL to checkpoint; the copy still holds.
|
|
79
|
+
}
|
|
80
|
+
copyFileSync(dbPath, `${dbPath}.${backupStamp()}.bak`);
|
|
81
|
+
pruneBackups(dbPath);
|
|
82
|
+
}
|
|
83
|
+
function backupStamp() {
|
|
84
|
+
const d = new Date();
|
|
85
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
86
|
+
return (`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
|
|
87
|
+
`-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`);
|
|
88
|
+
}
|
|
89
|
+
/** Keeps the five newest `<dbPath>.<stamp>.bak` copies; older ones are pruned. */
|
|
90
|
+
function pruneBackups(dbPath) {
|
|
91
|
+
const dir = dirname(dbPath);
|
|
92
|
+
const prefix = `${basename(dbPath)}.`;
|
|
93
|
+
// The fixed-width stamp makes lexicographic order chronological.
|
|
94
|
+
const backups = readdirSync(dir)
|
|
95
|
+
.filter((name) => name.startsWith(prefix) && name.endsWith(".bak"))
|
|
96
|
+
.sort();
|
|
97
|
+
for (const name of backups.slice(0, Math.max(0, backups.length - 5))) {
|
|
98
|
+
try {
|
|
99
|
+
unlinkSync(join(dir, name));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// A backup already gone is fine; pruning is best-effort.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { isServerFailure, ocrPages, } from "./ocr.js";
|
|
2
|
+
import { probePdfPages, renderPdfPages, } from "./pdf.js";
|
|
3
|
+
import { readerFor, verdictOf } from "./route.js";
|
|
4
|
+
/** The neutral spec for the agent route, which hands rasters to an unknown vision model. */
|
|
5
|
+
export const PAGE_RENDER = { dpi: 200, maxLongestDimPx: 1800 };
|
|
6
|
+
const SERVER_FAILURE = {
|
|
7
|
+
unreachable: "ocr_unreachable",
|
|
8
|
+
rejected: "ocr_rejected",
|
|
9
|
+
};
|
|
10
|
+
// Images have no text layer; mupdf would resample a bare image as a fake 96-dpi PDF.
|
|
11
|
+
async function probe(input) {
|
|
12
|
+
if (input.kind === "image")
|
|
13
|
+
return { ok: true, value: [] };
|
|
14
|
+
return probePdfPages(input.bytes);
|
|
15
|
+
}
|
|
16
|
+
async function pageImages(input, spec) {
|
|
17
|
+
if (input.kind === "image") {
|
|
18
|
+
return {
|
|
19
|
+
ok: true,
|
|
20
|
+
value: {
|
|
21
|
+
source: "original",
|
|
22
|
+
pages: [{ page: 1, mime: input.mime, bytes: input.bytes, path: input.path }],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const rendered = await renderPdfPages(input.bytes, spec);
|
|
27
|
+
if (!rendered.ok)
|
|
28
|
+
return rendered;
|
|
29
|
+
return { ok: true, value: { source: "raster", dpi: spec.dpi, pages: rendered.value } };
|
|
30
|
+
}
|
|
31
|
+
function placeholder(page) {
|
|
32
|
+
return `[page ${page}: OCR failed]`;
|
|
33
|
+
}
|
|
34
|
+
/** A failed page is a hole in the document, not a failed run — the caller reports `failedPages` and exits PARTIAL. */
|
|
35
|
+
function ocrExtraction(outcomes, settings, textLayer) {
|
|
36
|
+
for (const outcome of outcomes) {
|
|
37
|
+
if (outcome.ok || !isServerFailure(outcome.reason))
|
|
38
|
+
continue;
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
reason: SERVER_FAILURE[outcome.reason],
|
|
42
|
+
message: `page ${outcome.page}: ${outcome.message}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
ok: true,
|
|
47
|
+
value: {
|
|
48
|
+
kind: "text",
|
|
49
|
+
source: "ocr",
|
|
50
|
+
textLayer,
|
|
51
|
+
model: settings.model,
|
|
52
|
+
pages: outcomes.map((outcome) => ({
|
|
53
|
+
page: outcome.page,
|
|
54
|
+
text: outcome.ok ? outcome.text : placeholder(outcome.page),
|
|
55
|
+
})),
|
|
56
|
+
failedPages: outcomes.filter((outcome) => !outcome.ok).map((outcome) => outcome.page),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export async function extractFile(input, options) {
|
|
61
|
+
const probed = await probe(input);
|
|
62
|
+
if (!probed.ok)
|
|
63
|
+
return { ok: false, reason: "pdf_unreadable", message: probed.error };
|
|
64
|
+
// Each override cancels exactly one routing decision.
|
|
65
|
+
const overrides = options.overrides ?? {};
|
|
66
|
+
const ocr = overrides.noOcr ? null : options.ocr;
|
|
67
|
+
const textLayer = overrides.rescan ? "none" : verdictOf(probed.value);
|
|
68
|
+
const reader = readerFor(textLayer, ocr ? "ready" : "unset");
|
|
69
|
+
if (reader === "text-layer") {
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
value: {
|
|
73
|
+
kind: "text",
|
|
74
|
+
source: "text-layer",
|
|
75
|
+
textLayer,
|
|
76
|
+
pages: probed.value.map(({ page, text }) => ({ page, text })),
|
|
77
|
+
failedPages: [],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// Reader chosen, so the resolution is too: the endpoint's preset when it reads, the neutral spec otherwise.
|
|
82
|
+
const images = await pageImages(input, ocr ? ocr.render : PAGE_RENDER);
|
|
83
|
+
if (!images.ok)
|
|
84
|
+
return { ok: false, reason: "pdf_unreadable", message: images.error };
|
|
85
|
+
// The READER table's "unset" column: no endpoint means reader "agent", images pass through as-is.
|
|
86
|
+
if (!ocr)
|
|
87
|
+
return { ok: true, value: { kind: "images", textLayer, ...images.value } };
|
|
88
|
+
return ocrExtraction(await ocrPages(images.value.pages, ocr), ocr, textLayer);
|
|
89
|
+
}
|