diff --git a/package.json b/package.json index a84a44e..451e7dd 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@tauri-apps/plugin-sql": "~2", "@tauri-apps/plugin-store": "~2", "clusterize.js": "^1.0.0", + "csv-parse": "^5.6.0", "dayjs": "^1.11.13", "vue": "^3.3.4", "vue-router": "4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec3a4cf..f7d5184 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: clusterize.js: specifier: ^1.0.0 version: 1.0.0 + csv-parse: + specifier: ^5.6.0 + version: 5.6.0 dayjs: specifier: ^1.11.13 version: 1.11.13 @@ -606,6 +609,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csv-parse@5.6.0: + resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==} + dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} @@ -1474,6 +1480,8 @@ snapshots: csstype@3.1.3: {} + csv-parse@5.6.0: {} + dayjs@1.11.13: {} de-indent@1.0.2: {} diff --git a/src/importers/csv.ts b/src/importers/csv.ts new file mode 100644 index 0000000..5a6c1c9 --- /dev/null +++ b/src/importers/csv.ts @@ -0,0 +1,68 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { parse } from 'csv-parse/browser/esm/sync'; +import dayjs from 'dayjs'; + +import { DT_FORMAT, StatementLine, db } from '../db.ts'; + +export default function importCsv(sourceAccount: string, content: string): StatementLine[] { + const records = parse(content, { + skip_empty_lines: true, + }); + + // Validate column layout + if (records.length === 0) { + throw new Error('Empty CSV file'); + } + if (records[0][0] !== 'Date') { + throw new Error('Unexpected column 1, expected "Date"'); + } + if (records[0][1] !== 'Description') { + throw new Error('Unexpected column 1, expected "Description"'); + } + if (records[0][2] !== 'Amount') { + throw new Error('Unexpected column 1, expected "Amount"'); + } + + const statementLines: StatementLine[] = []; + + // Parse records + for (let i = 1; i < records.length; i++) { + const record = records[i]; + + const date = dayjs(record[0], 'YYYY-MM-DD').format(DT_FORMAT); + const description = record[1]; + const amount = record[2]; + + const quantity = Math.round(parseFloat(amount) * Math.pow(10, db.metadata.dps)); + if (!Number.isSafeInteger(quantity)) { throw new Error('Quantity not representable by safe integer'); } + + statementLines.push({ + id: null, + source_account: sourceAccount, + dt: date, + description: description, + quantity: quantity, + balance: null, + commodity: db.metadata.reporting_commodity, + }); + } + + return statementLines; +} diff --git a/src/pages/ImportStatementView.vue b/src/pages/ImportStatementView.vue index 9cd22c5..c7eb46b 100644 --- a/src/pages/ImportStatementView.vue +++ b/src/pages/ImportStatementView.vue @@ -26,6 +26,7 @@
@@ -85,6 +86,7 @@ import ComboBoxAccounts from '../components/ComboBoxAccounts.vue'; import { ppWithCommodity } from '../display.ts'; + import importCsv from '../importers/csv.ts'; import importOfxAutodetectVersion from '../importers/ofx.ts'; const fileInput = useTemplateRef('file'); @@ -113,7 +115,9 @@ const content = await file.text(); - if (format.value === 'ofx') { + if (format.value === 'csv') { + statementLines.value = importCsv(sourceAccount.value, content); + } else if (format.value === 'ofx') { statementLines.value = importOfxAutodetectVersion(sourceAccount.value, content); } else { throw new Error('Unexpected import format');