tallyd 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +1493 -2
- package/dist/accounts/chart.d.ts +96 -0
- package/dist/accounts/chart.js +244 -0
- package/dist/accounts/index.d.ts +3 -0
- package/dist/accounts/index.js +3 -0
- package/dist/accounts/standard.d.ts +12 -0
- package/dist/accounts/standard.js +95 -0
- package/dist/accounts/types.d.ts +20 -0
- package/dist/accounts/types.js +40 -0
- package/dist/cli/args.d.ts +45 -0
- package/dist/cli/args.js +147 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/main.d.ts +6 -0
- package/dist/cli/main.js +25 -0
- package/dist/cli/run.d.ts +34 -0
- package/dist/cli/run.js +1660 -0
- package/dist/dashboard/charts.d.ts +38 -0
- package/dist/dashboard/charts.js +173 -0
- package/dist/dashboard/index.d.ts +3 -0
- package/dist/dashboard/index.js +3 -0
- package/dist/dashboard/model.d.ts +137 -0
- package/dist/dashboard/model.js +190 -0
- package/dist/dashboard/render.d.ts +23 -0
- package/dist/dashboard/render.js +231 -0
- package/dist/dashboard/script.d.ts +28 -0
- package/dist/dashboard/script.js +704 -0
- package/dist/dashboard/styles.d.ts +14 -0
- package/dist/dashboard/styles.js +726 -0
- package/dist/demo/disposal.d.ts +80 -0
- package/dist/demo/disposal.js +215 -0
- package/dist/demo/evidence.d.ts +153 -0
- package/dist/demo/evidence.js +348 -0
- package/dist/demo/foreign.d.ts +29 -0
- package/dist/demo/foreign.js +113 -0
- package/dist/demo/foreignReport.d.ts +10 -0
- package/dist/demo/foreignReport.js +89 -0
- package/dist/demo/generator.d.ts +108 -0
- package/dist/demo/generator.js +445 -0
- package/dist/demo/group.d.ts +70 -0
- package/dist/demo/group.js +195 -0
- package/dist/demo/groupPeriods.d.ts +27 -0
- package/dist/demo/groupPeriods.js +125 -0
- package/dist/demo/groupReport.d.ts +13 -0
- package/dist/demo/groupReport.js +87 -0
- package/dist/demo/ingest.d.ts +6 -0
- package/dist/demo/ingest.js +61 -0
- package/dist/demo/main.d.ts +6 -0
- package/dist/demo/main.js +7 -0
- package/dist/demo/mainDisposal.d.ts +1 -0
- package/dist/demo/mainDisposal.js +2 -0
- package/dist/demo/mainEvidence.d.ts +8 -0
- package/dist/demo/mainEvidence.js +9 -0
- package/dist/demo/mainForeign.d.ts +6 -0
- package/dist/demo/mainForeign.js +7 -0
- package/dist/demo/mainGroup.d.ts +6 -0
- package/dist/demo/mainGroup.js +7 -0
- package/dist/demo/mainGroupPeriods.d.ts +6 -0
- package/dist/demo/mainGroupPeriods.js +7 -0
- package/dist/demo/mainIngest.d.ts +6 -0
- package/dist/demo/mainIngest.js +7 -0
- package/dist/demo/mainMidYear.d.ts +6 -0
- package/dist/demo/mainMidYear.js +7 -0
- package/dist/demo/mainReconcile.d.ts +6 -0
- package/dist/demo/mainReconcile.js +7 -0
- package/dist/demo/mainStatements.d.ts +6 -0
- package/dist/demo/mainStatements.js +7 -0
- package/dist/demo/midYear.d.ts +51 -0
- package/dist/demo/midYear.js +162 -0
- package/dist/demo/month.d.ts +13 -0
- package/dist/demo/month.js +142 -0
- package/dist/demo/random.d.ts +48 -0
- package/dist/demo/random.js +94 -0
- package/dist/demo/receivables.d.ts +36 -0
- package/dist/demo/receivables.js +104 -0
- package/dist/demo/reconcile.d.ts +20 -0
- package/dist/demo/reconcile.js +104 -0
- package/dist/demo/report.d.ts +6 -0
- package/dist/demo/report.js +26 -0
- package/dist/demo/statement.d.ts +13 -0
- package/dist/demo/statement.js +51 -0
- package/dist/demo/statements.d.ts +6 -0
- package/dist/demo/statements.js +29 -0
- package/dist/demo/supplierRun.d.ts +46 -0
- package/dist/demo/supplierRun.js +94 -0
- package/dist/demo/unrealisedEvidence.d.ts +51 -0
- package/dist/demo/unrealisedEvidence.js +183 -0
- package/dist/fx/average.d.ts +60 -0
- package/dist/fx/average.js +139 -0
- package/dist/fx/document.d.ts +60 -0
- package/dist/fx/document.js +205 -0
- package/dist/fx/exposure.d.ts +108 -0
- package/dist/fx/exposure.js +262 -0
- package/dist/fx/index.d.ts +8 -0
- package/dist/fx/index.js +8 -0
- package/dist/fx/rate.d.ts +77 -0
- package/dist/fx/rate.js +200 -0
- package/dist/fx/revaluation.d.ts +92 -0
- package/dist/fx/revaluation.js +197 -0
- package/dist/fx/settlement.d.ts +84 -0
- package/dist/fx/settlement.js +162 -0
- package/dist/fx/table.d.ts +120 -0
- package/dist/fx/table.js +271 -0
- package/dist/fx/translate.d.ts +126 -0
- package/dist/fx/translate.js +258 -0
- package/dist/group/accounts.d.ts +74 -0
- package/dist/group/accounts.js +179 -0
- package/dist/group/acquisition.d.ts +324 -0
- package/dist/group/acquisition.js +416 -0
- package/dist/group/aggregate.d.ts +143 -0
- package/dist/group/aggregate.js +288 -0
- package/dist/group/comparative.d.ts +102 -0
- package/dist/group/comparative.js +166 -0
- package/dist/group/consolidate.d.ts +196 -0
- package/dist/group/consolidate.js +635 -0
- package/dist/group/disposal.d.ts +181 -0
- package/dist/group/disposal.js +233 -0
- package/dist/group/document.d.ts +41 -0
- package/dist/group/document.js +326 -0
- package/dist/group/index.d.ts +12 -0
- package/dist/group/index.js +12 -0
- package/dist/group/intercompany.d.ts +136 -0
- package/dist/group/intercompany.js +238 -0
- package/dist/group/interest.d.ts +109 -0
- package/dist/group/interest.js +224 -0
- package/dist/group/movement.d.ts +206 -0
- package/dist/group/movement.js +432 -0
- package/dist/group/structure.d.ts +151 -0
- package/dist/group/structure.js +327 -0
- package/dist/group/timeline.d.ts +109 -0
- package/dist/group/timeline.js +169 -0
- package/dist/group/unrealised.d.ts +165 -0
- package/dist/group/unrealised.js +314 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/ledger/close.d.ts +75 -0
- package/dist/ledger/close.js +142 -0
- package/dist/ledger/date.d.ts +48 -0
- package/dist/ledger/date.js +141 -0
- package/dist/ledger/entry.d.ts +150 -0
- package/dist/ledger/entry.js +264 -0
- package/dist/ledger/index.d.ts +6 -0
- package/dist/ledger/index.js +6 -0
- package/dist/ledger/ledger.d.ts +101 -0
- package/dist/ledger/ledger.js +308 -0
- package/dist/ledger/serialise.d.ts +68 -0
- package/dist/ledger/serialise.js +204 -0
- package/dist/ledger/trialBalance.d.ts +49 -0
- package/dist/ledger/trialBalance.js +103 -0
- package/dist/money/currency.d.ts +37 -0
- package/dist/money/currency.js +67 -0
- package/dist/money/index.d.ts +3 -0
- package/dist/money/index.js +3 -0
- package/dist/money/money.d.ts +106 -0
- package/dist/money/money.js +300 -0
- package/dist/money/rounding.d.ts +41 -0
- package/dist/money/rounding.js +117 -0
- package/dist/reconcile/accuracy.d.ts +65 -0
- package/dist/reconcile/accuracy.js +162 -0
- package/dist/reconcile/assignment.d.ts +83 -0
- package/dist/reconcile/assignment.js +326 -0
- package/dist/reconcile/bankView.d.ts +65 -0
- package/dist/reconcile/bankView.js +86 -0
- package/dist/reconcile/bridge.d.ts +76 -0
- package/dist/reconcile/bridge.js +129 -0
- package/dist/reconcile/candidates.d.ts +84 -0
- package/dist/reconcile/candidates.js +174 -0
- package/dist/reconcile/decisions.d.ts +116 -0
- package/dist/reconcile/decisions.js +168 -0
- package/dist/reconcile/index.d.ts +12 -0
- package/dist/reconcile/index.js +12 -0
- package/dist/reconcile/matcher.d.ts +89 -0
- package/dist/reconcile/matcher.js +306 -0
- package/dist/reconcile/memory.d.ts +120 -0
- package/dist/reconcile/memory.js +281 -0
- package/dist/reconcile/posting.d.ts +185 -0
- package/dist/reconcile/posting.js +481 -0
- package/dist/reconcile/scoring.d.ts +98 -0
- package/dist/reconcile/scoring.js +319 -0
- package/dist/reconcile/similarity.d.ts +88 -0
- package/dist/reconcile/similarity.js +272 -0
- package/dist/reconcile/subsetSum.d.ts +61 -0
- package/dist/reconcile/subsetSum.js +136 -0
- package/dist/reports/ageing.d.ts +75 -0
- package/dist/reports/ageing.js +149 -0
- package/dist/reports/balanceSheet.d.ts +57 -0
- package/dist/reports/balanceSheet.js +107 -0
- package/dist/reports/incomeStatement.d.ts +59 -0
- package/dist/reports/incomeStatement.js +132 -0
- package/dist/reports/index.d.ts +4 -0
- package/dist/reports/index.js +4 -0
- package/dist/reports/period.d.ts +37 -0
- package/dist/reports/period.js +78 -0
- package/dist/statement/amount.d.ts +55 -0
- package/dist/statement/amount.js +211 -0
- package/dist/statement/columns.d.ts +39 -0
- package/dist/statement/columns.js +221 -0
- package/dist/statement/csv.d.ts +50 -0
- package/dist/statement/csv.js +185 -0
- package/dist/statement/dates.d.ts +52 -0
- package/dist/statement/dates.js +204 -0
- package/dist/statement/duplicates.d.ts +51 -0
- package/dist/statement/duplicates.js +101 -0
- package/dist/statement/import.d.ts +52 -0
- package/dist/statement/import.js +172 -0
- package/dist/statement/index.d.ts +20 -0
- package/dist/statement/index.js +32 -0
- package/dist/statement/line.d.ts +60 -0
- package/dist/statement/line.js +73 -0
- package/dist/statement/ofx.d.ts +47 -0
- package/dist/statement/ofx.js +199 -0
- package/package.json +72 -4
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working out which column is which.
|
|
3
|
+
*
|
|
4
|
+
* Header names are a hint, not an answer: banks ship `Transaction Date`,
|
|
5
|
+
* `Datum`, `Post Date`, `Value Date` and sometimes nothing at all. So each
|
|
6
|
+
* column is scored twice — once on its name, once on what its cells actually
|
|
7
|
+
* look like — and the content score is what saves an unlabelled export.
|
|
8
|
+
*/
|
|
9
|
+
import { looksLikeAmount } from "./amount.js";
|
|
10
|
+
import { looksLikeDate } from "./dates.js";
|
|
11
|
+
/** Header aliases, lowercased. Longer, more specific names come first. */
|
|
12
|
+
const NAME_HINTS = [
|
|
13
|
+
{
|
|
14
|
+
role: "valueDate",
|
|
15
|
+
patterns: [/^value\s*date$/, /^val\s*date$/, /^settle(ment)?\s*date$/, /^valuta/],
|
|
16
|
+
weight: 10,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
role: "date",
|
|
20
|
+
patterns: [
|
|
21
|
+
/^(transaction|posting|post|booking|entry|trans)\s*date$/,
|
|
22
|
+
/^date$/,
|
|
23
|
+
/^datum$/,
|
|
24
|
+
/^fecha$/,
|
|
25
|
+
/^date\s*of\s*transaction$/,
|
|
26
|
+
],
|
|
27
|
+
weight: 10,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
role: "debit",
|
|
31
|
+
patterns: [/^debit/, /^paid\s*out$/, /^money\s*out$/, /^withdrawal/, /^afschrijving/, /^soll$/],
|
|
32
|
+
weight: 10,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
role: "credit",
|
|
36
|
+
patterns: [/^credit/, /^paid\s*in$/, /^money\s*in$/, /^deposit/, /^bijschrijving/, /^haben$/],
|
|
37
|
+
weight: 10,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
role: "balance",
|
|
41
|
+
patterns: [/balance/, /^saldo$/, /^running\s*total$/],
|
|
42
|
+
weight: 10,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
role: "amount",
|
|
46
|
+
patterns: [/^amount$/, /^value$/, /^bedrag$/, /^betrag$/, /^importe$/, /amount/],
|
|
47
|
+
weight: 8,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
role: "description",
|
|
51
|
+
patterns: [
|
|
52
|
+
/^description$/,
|
|
53
|
+
/^narrative$/,
|
|
54
|
+
/^details?$/,
|
|
55
|
+
/^payee$/,
|
|
56
|
+
/^memo$/,
|
|
57
|
+
/^particulars$/,
|
|
58
|
+
/^omschrijving$/,
|
|
59
|
+
/^merchant$/,
|
|
60
|
+
/description|narrative|details/,
|
|
61
|
+
],
|
|
62
|
+
weight: 8,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
role: "reference",
|
|
66
|
+
patterns: [/^reference$/, /^ref$/, /^transaction\s*id$/, /^cheque\s*(no|number)$/, /ref/],
|
|
67
|
+
weight: 7,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
role: "currency",
|
|
71
|
+
patterns: [/^currency$/, /^ccy$/, /^valuta\s*code$/],
|
|
72
|
+
weight: 9,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
role: "type",
|
|
76
|
+
patterns: [/^type$/, /^transaction\s*type$/, /^tx\s*type$/, /^code$/],
|
|
77
|
+
weight: 7,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
function nameScore(header, role) {
|
|
81
|
+
const name = header.trim().toLowerCase().replace(/[_.]/g, " ").replace(/\s+/g, " ");
|
|
82
|
+
if (name === "")
|
|
83
|
+
return 0;
|
|
84
|
+
for (const hint of NAME_HINTS) {
|
|
85
|
+
if (hint.role !== role)
|
|
86
|
+
continue;
|
|
87
|
+
for (let i = 0; i < hint.patterns.length; i++) {
|
|
88
|
+
const pattern = hint.patterns[i];
|
|
89
|
+
if (pattern.test(name)) {
|
|
90
|
+
// Earlier patterns are the more exact ones.
|
|
91
|
+
return hint.weight - i * 0.1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
function fraction(values, predicate) {
|
|
98
|
+
const nonEmpty = values.filter((v) => v.trim() !== "");
|
|
99
|
+
if (nonEmpty.length === 0)
|
|
100
|
+
return 0;
|
|
101
|
+
return nonEmpty.filter(predicate).length / nonEmpty.length;
|
|
102
|
+
}
|
|
103
|
+
function contentScore(values, role) {
|
|
104
|
+
switch (role) {
|
|
105
|
+
case "date":
|
|
106
|
+
case "valueDate":
|
|
107
|
+
return fraction(values, looksLikeDate) * 8;
|
|
108
|
+
case "amount":
|
|
109
|
+
case "debit":
|
|
110
|
+
case "credit":
|
|
111
|
+
case "balance": {
|
|
112
|
+
const numeric = fraction(values, looksLikeAmount);
|
|
113
|
+
const dateish = fraction(values, looksLikeDate);
|
|
114
|
+
return Math.max(0, numeric - dateish) * 6;
|
|
115
|
+
}
|
|
116
|
+
case "description": {
|
|
117
|
+
const wordy = fraction(values, (v) => !looksLikeAmount(v) && !looksLikeDate(v) && v.trim().length >= 3);
|
|
118
|
+
return wordy * 5;
|
|
119
|
+
}
|
|
120
|
+
case "currency":
|
|
121
|
+
return fraction(values, (v) => /^[A-Za-z]{3}$/.test(v.trim())) * 6;
|
|
122
|
+
default:
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Roles at most one column may hold. */
|
|
127
|
+
const EXCLUSIVE = [
|
|
128
|
+
"date",
|
|
129
|
+
"valueDate",
|
|
130
|
+
"description",
|
|
131
|
+
"amount",
|
|
132
|
+
"debit",
|
|
133
|
+
"credit",
|
|
134
|
+
"balance",
|
|
135
|
+
"reference",
|
|
136
|
+
"currency",
|
|
137
|
+
"type",
|
|
138
|
+
];
|
|
139
|
+
/**
|
|
140
|
+
* Assign roles to columns.
|
|
141
|
+
*
|
|
142
|
+
* Greedy over the highest-scoring (column, role) pairs: each column takes one
|
|
143
|
+
* role and each role one column. Greedy is enough here because the scores are
|
|
144
|
+
* well separated in practice, and it is explainable — every assignment carries
|
|
145
|
+
* the score that won it, which matters when a user asks why their file was
|
|
146
|
+
* read the way it was.
|
|
147
|
+
*/
|
|
148
|
+
export function inferColumns(header, rows, overrides = {}) {
|
|
149
|
+
const columnCount = Math.max(header.length, ...rows.map((r) => r.length), 0);
|
|
150
|
+
const columns = [];
|
|
151
|
+
for (let i = 0; i < columnCount; i++) {
|
|
152
|
+
columns.push(rows.map((r) => r[i] ?? ""));
|
|
153
|
+
}
|
|
154
|
+
const scored = [];
|
|
155
|
+
for (let index = 0; index < columnCount; index++) {
|
|
156
|
+
const name = header[index] ?? "";
|
|
157
|
+
const values = columns[index];
|
|
158
|
+
for (const role of EXCLUSIVE) {
|
|
159
|
+
const score = nameScore(name, role) + contentScore(values, role);
|
|
160
|
+
if (score > 0)
|
|
161
|
+
scored.push({ index, role, score });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
scored.sort((a, b) => (b.score === a.score ? a.index - b.index : b.score - a.score));
|
|
165
|
+
const roleToColumn = new Map();
|
|
166
|
+
const columnToRole = new Map();
|
|
167
|
+
const scoreByColumn = new Map();
|
|
168
|
+
for (const [role, index] of Object.entries(overrides)) {
|
|
169
|
+
roleToColumn.set(role, index);
|
|
170
|
+
columnToRole.set(index, role);
|
|
171
|
+
scoreByColumn.set(index, Number.POSITIVE_INFINITY);
|
|
172
|
+
}
|
|
173
|
+
for (const candidate of scored) {
|
|
174
|
+
if (roleToColumn.has(candidate.role))
|
|
175
|
+
continue;
|
|
176
|
+
if (columnToRole.has(candidate.index))
|
|
177
|
+
continue;
|
|
178
|
+
roleToColumn.set(candidate.role, candidate.index);
|
|
179
|
+
columnToRole.set(candidate.index, candidate.role);
|
|
180
|
+
scoreByColumn.set(candidate.index, candidate.score);
|
|
181
|
+
}
|
|
182
|
+
const assignments = [];
|
|
183
|
+
for (let index = 0; index < columnCount; index++) {
|
|
184
|
+
assignments.push(Object.freeze({
|
|
185
|
+
index,
|
|
186
|
+
header: header[index] ?? "",
|
|
187
|
+
role: columnToRole.get(index) ?? "unknown",
|
|
188
|
+
score: scoreByColumn.get(index) ?? 0,
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
const at = (role) => roleToColumn.get(role) ?? null;
|
|
192
|
+
const warnings = [];
|
|
193
|
+
const amount = at("amount");
|
|
194
|
+
const debit = at("debit");
|
|
195
|
+
const credit = at("credit");
|
|
196
|
+
if (amount === null && (debit === null || credit === null)) {
|
|
197
|
+
warnings.push("No amount column found, and no debit/credit pair either — rows cannot be valued");
|
|
198
|
+
}
|
|
199
|
+
if (amount !== null && debit !== null && credit !== null) {
|
|
200
|
+
warnings.push("Found both an amount column and a debit/credit pair; the amount column wins");
|
|
201
|
+
}
|
|
202
|
+
if (at("date") === null)
|
|
203
|
+
warnings.push("No date column found");
|
|
204
|
+
if (at("description") === null) {
|
|
205
|
+
warnings.push("No description column found; matching will rely on amount and date alone");
|
|
206
|
+
}
|
|
207
|
+
return Object.freeze({
|
|
208
|
+
assignments: Object.freeze(assignments),
|
|
209
|
+
date: at("date"),
|
|
210
|
+
valueDate: at("valueDate"),
|
|
211
|
+
description: at("description"),
|
|
212
|
+
amount,
|
|
213
|
+
debit,
|
|
214
|
+
credit,
|
|
215
|
+
balance: at("balance"),
|
|
216
|
+
reference: at("reference"),
|
|
217
|
+
currency: at("currency"),
|
|
218
|
+
type: at("type"),
|
|
219
|
+
warnings: Object.freeze(warnings),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CSV reader for files exported by banks, which is a narrower and stranger
|
|
3
|
+
* problem than "parse CSV".
|
|
4
|
+
*
|
|
5
|
+
* Real exports arrive with a UTF-8 BOM, CRLF endings, a preamble of two or
|
|
6
|
+
* three junk lines before the header, semicolon delimiters from European
|
|
7
|
+
* banks, and quoted fields containing the delimiter. None of that is exotic;
|
|
8
|
+
* all of it breaks a `line.split(",")`.
|
|
9
|
+
*
|
|
10
|
+
* The tokenizer follows RFC 4180 with one deliberate relaxation: a bare quote
|
|
11
|
+
* inside an unquoted field is treated as data rather than an error, because
|
|
12
|
+
* descriptions like `JOHN'S 24" TV` appear in the wild and rejecting the whole
|
|
13
|
+
* import over one is not a service to anybody.
|
|
14
|
+
*/
|
|
15
|
+
export declare const DELIMITERS: readonly [",", ";", "\t", "|"];
|
|
16
|
+
export type Delimiter = (typeof DELIMITERS)[number];
|
|
17
|
+
export declare class CsvParseError extends Error {
|
|
18
|
+
readonly line: number;
|
|
19
|
+
constructor(message: string, line: number);
|
|
20
|
+
}
|
|
21
|
+
/** Strip a UTF-8 byte-order mark, which Excel adds and nothing else wants. */
|
|
22
|
+
export declare function stripBom(text: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Split into records. Quoted fields may span physical lines, so this cannot be
|
|
25
|
+
* done by splitting on newlines first.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseCsv(input: string, delimiter?: Delimiter): string[][];
|
|
28
|
+
/**
|
|
29
|
+
* Guess the delimiter by looking for the one that gives a consistent column
|
|
30
|
+
* count across the most lines. Counting occurrences on line one alone gets
|
|
31
|
+
* this wrong whenever a description contains a comma.
|
|
32
|
+
*/
|
|
33
|
+
export declare function sniffDelimiter(input: string): Delimiter;
|
|
34
|
+
export interface CsvTable {
|
|
35
|
+
readonly header: readonly string[];
|
|
36
|
+
readonly rows: readonly (readonly string[])[];
|
|
37
|
+
readonly delimiter: Delimiter;
|
|
38
|
+
/** How many leading junk lines were skipped before the header. */
|
|
39
|
+
readonly preambleLines: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Read a table, locating the header row rather than assuming it is first.
|
|
43
|
+
*
|
|
44
|
+
* Banks routinely prefix an export with the account name, the statement period
|
|
45
|
+
* and a blank line. The header is taken to be the first row whose width equals
|
|
46
|
+
* the modal width of the file — a preamble line is almost always narrower.
|
|
47
|
+
*/
|
|
48
|
+
export declare function readTable(input: string, options?: {
|
|
49
|
+
delimiter?: Delimiter;
|
|
50
|
+
}): CsvTable;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CSV reader for files exported by banks, which is a narrower and stranger
|
|
3
|
+
* problem than "parse CSV".
|
|
4
|
+
*
|
|
5
|
+
* Real exports arrive with a UTF-8 BOM, CRLF endings, a preamble of two or
|
|
6
|
+
* three junk lines before the header, semicolon delimiters from European
|
|
7
|
+
* banks, and quoted fields containing the delimiter. None of that is exotic;
|
|
8
|
+
* all of it breaks a `line.split(",")`.
|
|
9
|
+
*
|
|
10
|
+
* The tokenizer follows RFC 4180 with one deliberate relaxation: a bare quote
|
|
11
|
+
* inside an unquoted field is treated as data rather than an error, because
|
|
12
|
+
* descriptions like `JOHN'S 24" TV` appear in the wild and rejecting the whole
|
|
13
|
+
* import over one is not a service to anybody.
|
|
14
|
+
*/
|
|
15
|
+
export const DELIMITERS = [",", ";", "\t", "|"];
|
|
16
|
+
export class CsvParseError extends Error {
|
|
17
|
+
line;
|
|
18
|
+
constructor(message, line) {
|
|
19
|
+
super(`${message} (line ${line})`);
|
|
20
|
+
this.line = line;
|
|
21
|
+
this.name = "CsvParseError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Strip a UTF-8 byte-order mark, which Excel adds and nothing else wants. */
|
|
25
|
+
export function stripBom(text) {
|
|
26
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Split into records. Quoted fields may span physical lines, so this cannot be
|
|
30
|
+
* done by splitting on newlines first.
|
|
31
|
+
*/
|
|
32
|
+
export function parseCsv(input, delimiter = ",") {
|
|
33
|
+
const text = stripBom(input);
|
|
34
|
+
const rows = [];
|
|
35
|
+
let row = [];
|
|
36
|
+
let field = "";
|
|
37
|
+
let inQuotes = false;
|
|
38
|
+
let fieldWasQuoted = false;
|
|
39
|
+
let line = 1;
|
|
40
|
+
let sawAnyContent = false;
|
|
41
|
+
const endField = () => {
|
|
42
|
+
row.push(fieldWasQuoted ? field : field.trim());
|
|
43
|
+
field = "";
|
|
44
|
+
fieldWasQuoted = false;
|
|
45
|
+
};
|
|
46
|
+
const endRow = () => {
|
|
47
|
+
endField();
|
|
48
|
+
// Drop rows that are entirely empty — trailing newlines, blank separators.
|
|
49
|
+
if (row.some((f) => f !== ""))
|
|
50
|
+
rows.push(row);
|
|
51
|
+
row = [];
|
|
52
|
+
};
|
|
53
|
+
for (let i = 0; i < text.length; i++) {
|
|
54
|
+
const char = text[i];
|
|
55
|
+
if (inQuotes) {
|
|
56
|
+
if (char === '"') {
|
|
57
|
+
if (text[i + 1] === '"') {
|
|
58
|
+
field += '"';
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
inQuotes = false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
if (char === "\n")
|
|
67
|
+
line++;
|
|
68
|
+
field += char;
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (char === '"' && field.trim() === "") {
|
|
73
|
+
// A quote only opens a quoted field at the start of one; elsewhere it is
|
|
74
|
+
// literal data.
|
|
75
|
+
inQuotes = true;
|
|
76
|
+
fieldWasQuoted = true;
|
|
77
|
+
field = "";
|
|
78
|
+
sawAnyContent = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (char === delimiter) {
|
|
82
|
+
endField();
|
|
83
|
+
sawAnyContent = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (char === "\r") {
|
|
87
|
+
if (text[i + 1] === "\n")
|
|
88
|
+
i++;
|
|
89
|
+
line++;
|
|
90
|
+
endRow();
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (char === "\n") {
|
|
94
|
+
line++;
|
|
95
|
+
endRow();
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
field += char;
|
|
99
|
+
if (char.trim() !== "")
|
|
100
|
+
sawAnyContent = true;
|
|
101
|
+
}
|
|
102
|
+
if (inQuotes)
|
|
103
|
+
throw new CsvParseError("Unterminated quoted field", line);
|
|
104
|
+
if (field !== "" || row.length > 0)
|
|
105
|
+
endRow();
|
|
106
|
+
if (!sawAnyContent)
|
|
107
|
+
return [];
|
|
108
|
+
return rows;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Guess the delimiter by looking for the one that gives a consistent column
|
|
112
|
+
* count across the most lines. Counting occurrences on line one alone gets
|
|
113
|
+
* this wrong whenever a description contains a comma.
|
|
114
|
+
*/
|
|
115
|
+
export function sniffDelimiter(input) {
|
|
116
|
+
const sample = stripBom(input).split(/\r?\n/).filter((l) => l.trim() !== "").slice(0, 20);
|
|
117
|
+
if (sample.length === 0)
|
|
118
|
+
return ",";
|
|
119
|
+
let best = ",";
|
|
120
|
+
let bestScore = -1;
|
|
121
|
+
for (const delimiter of DELIMITERS) {
|
|
122
|
+
let rows;
|
|
123
|
+
try {
|
|
124
|
+
rows = parseCsv(sample.join("\n"), delimiter);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (rows.length === 0)
|
|
130
|
+
continue;
|
|
131
|
+
const counts = rows.map((r) => r.length);
|
|
132
|
+
const maxColumns = Math.max(...counts);
|
|
133
|
+
if (maxColumns < 2)
|
|
134
|
+
continue;
|
|
135
|
+
// Reward consistency and column count; a delimiter that never appears
|
|
136
|
+
// yields one column everywhere and scores zero.
|
|
137
|
+
const modal = mode(counts);
|
|
138
|
+
const consistent = counts.filter((c) => c === modal).length / counts.length;
|
|
139
|
+
const score = consistent * 10 + modal;
|
|
140
|
+
if (score > bestScore) {
|
|
141
|
+
bestScore = score;
|
|
142
|
+
best = delimiter;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return best;
|
|
146
|
+
}
|
|
147
|
+
function mode(values) {
|
|
148
|
+
const tally = new Map();
|
|
149
|
+
for (const v of values)
|
|
150
|
+
tally.set(v, (tally.get(v) ?? 0) + 1);
|
|
151
|
+
let best = values[0] ?? 0;
|
|
152
|
+
let bestCount = 0;
|
|
153
|
+
for (const [value, count] of tally) {
|
|
154
|
+
if (count > bestCount || (count === bestCount && value > best)) {
|
|
155
|
+
best = value;
|
|
156
|
+
bestCount = count;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return best;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Read a table, locating the header row rather than assuming it is first.
|
|
163
|
+
*
|
|
164
|
+
* Banks routinely prefix an export with the account name, the statement period
|
|
165
|
+
* and a blank line. The header is taken to be the first row whose width equals
|
|
166
|
+
* the modal width of the file — a preamble line is almost always narrower.
|
|
167
|
+
*/
|
|
168
|
+
export function readTable(input, options = {}) {
|
|
169
|
+
const delimiter = options.delimiter ?? sniffDelimiter(input);
|
|
170
|
+
const rows = parseCsv(input, delimiter);
|
|
171
|
+
if (rows.length === 0) {
|
|
172
|
+
return { header: [], rows: [], delimiter, preambleLines: 0 };
|
|
173
|
+
}
|
|
174
|
+
const widths = rows.map((r) => r.length);
|
|
175
|
+
const modal = mode(widths);
|
|
176
|
+
const headerIndex = rows.findIndex((r) => r.length === modal);
|
|
177
|
+
const header = rows[headerIndex].map((h) => h.trim());
|
|
178
|
+
const body = rows.slice(headerIndex + 1).filter((r) => r.some((f) => f.trim() !== ""));
|
|
179
|
+
return {
|
|
180
|
+
header,
|
|
181
|
+
rows: body,
|
|
182
|
+
delimiter,
|
|
183
|
+
preambleLines: headerIndex,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the date column.
|
|
3
|
+
*
|
|
4
|
+
* `03/04/2026` is the third of April in London and the fourth of March in New
|
|
5
|
+
* York, and a bank export rarely says which it means. Guessing per row is the
|
|
6
|
+
* worst option: it produces a statement where some rows are right and some are
|
|
7
|
+
* silently six weeks out.
|
|
8
|
+
*
|
|
9
|
+
* So the format is decided once for the whole column. A single value with a
|
|
10
|
+
* component above 12 settles it for every other row. Where the column is
|
|
11
|
+
* genuinely ambiguous — every day-of-month is 12 or under — the caller is told
|
|
12
|
+
* so and can insist on a format rather than accept a coin flip.
|
|
13
|
+
*/
|
|
14
|
+
import { type CalendarDate } from "../ledger/date.js";
|
|
15
|
+
export type DateFormat = "YYYY-MM-DD" | "DD/MM/YYYY" | "MM/DD/YYYY" | "DD-MM-YYYY" | "MM-DD-YYYY" | "DD.MM.YYYY" | "YYYY/MM/DD" | "DD-MMM-YYYY" | "MMM DD, YYYY";
|
|
16
|
+
export declare const DATE_FORMATS: readonly DateFormat[];
|
|
17
|
+
export declare class DateParseError extends Error {
|
|
18
|
+
readonly input: string;
|
|
19
|
+
readonly format: DateFormat | null;
|
|
20
|
+
constructor(input: string, format: DateFormat | null);
|
|
21
|
+
}
|
|
22
|
+
/** Parse one value in a known format. */
|
|
23
|
+
export declare function parseDateIn(input: string, format: DateFormat): CalendarDate;
|
|
24
|
+
export declare function canParseIn(input: string, format: DateFormat): boolean;
|
|
25
|
+
export interface DateFormatDetection {
|
|
26
|
+
readonly format: DateFormat;
|
|
27
|
+
/** Every format that parses all the samples. More than one means ambiguity. */
|
|
28
|
+
readonly candidates: readonly DateFormat[];
|
|
29
|
+
/** False when day/month order could not be settled from the data. */
|
|
30
|
+
readonly confident: boolean;
|
|
31
|
+
/** Samples no candidate format could read. */
|
|
32
|
+
readonly unparsed: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Work out which format a column of dates is in.
|
|
36
|
+
*
|
|
37
|
+
* A format survives only if it reads *every* sample. Where both DD/MM and
|
|
38
|
+
* MM/DD survive, the column contains no value with a component above 12 and
|
|
39
|
+
* the order is genuinely undecidable from the data alone.
|
|
40
|
+
*/
|
|
41
|
+
export declare function detectDateFormat(samples: readonly string[], options?: {
|
|
42
|
+
prefer?: DateFormat;
|
|
43
|
+
}): DateFormatDetection;
|
|
44
|
+
/** Parse a column, detecting the format from the column itself. */
|
|
45
|
+
export declare function parseDateColumn(values: readonly string[], options?: {
|
|
46
|
+
prefer?: DateFormat;
|
|
47
|
+
}): {
|
|
48
|
+
dates: readonly (CalendarDate | null)[];
|
|
49
|
+
detection: DateFormatDetection;
|
|
50
|
+
};
|
|
51
|
+
/** True when a string parses as a date in any supported format. */
|
|
52
|
+
export declare function looksLikeDate(input: string): boolean;
|