diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..9329105 --- /dev/null +++ b/.npmignore @@ -0,0 +1,4 @@ +src +rollup.config.js +tsconfig.json +import-sorter.json \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..583b8ed --- /dev/null +++ b/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + coveragePathIgnorePatterns: ["/node_modules/", "/build/"], + moduleFileExtensions: ["js", "jsx", "ts", "tsx"], + moduleNameMapper: { + "^@/(.*)$": "/src/$1", + }, + setupFiles: [], + testMatch: ["**/*.test.ts", "**/*.test.tsx", "**/*.test.js"], + testURL: "http://localhost/", + transform: { + "^.+\\.tsx?$": "ts-jest", + }, +}; diff --git a/package-lock.json b/package-lock.json index d29987a..5861007 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "pta", + "name": "pta-js", "version": "0.1.0", "lockfileVersion": 1, "requires": true, @@ -790,6 +790,16 @@ "@types/istanbul-lib-report": "*" } }, + "@types/jest": { + "version": "27.0.2", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.0.2.tgz", + "integrity": "sha512-4dRxkS/AFX0c5XW6IPMNOydLn2tEhNhJV7DnYK+0bjoJZ+QTmfucBlihX7aoEsh/ocYtkLC73UbnBXBXIxsULA==", + "dev": true, + "requires": { + "jest-diff": "^27.0.0", + "pretty-format": "^27.0.0" + } + }, "@types/node": { "version": "16.11.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz", @@ -802,6 +812,15 @@ "integrity": "sha512-Fo79ojj3vdEZOHg3wR9ksAMRz4P3S5fDB5e/YWZiFnyFQI1WY2Vftu9XoXVVtJfxB7Bpce/QTqWSSntkz2Znrw==", "dev": true }, + "@types/split2": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/split2/-/split2-3.2.1.tgz", + "integrity": "sha512-7uz3yU+LooBq4yNOzlZD9PU9/1Eu0rTD1MjQ6apOVEoHsPrMUrFw7W8XrvWtesm2vK67SBK9AyJcOXtMpl9bgQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -1047,6 +1066,15 @@ "picocolors": "^1.0.0" } }, + "bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "requires": { + "fast-json-stable-stringify": "2.x" + } + }, "bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -2359,6 +2387,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", + "dev": true + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -2386,6 +2420,12 @@ "semver": "^6.0.0" } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -2836,6 +2876,11 @@ "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", "dev": true }, + "split2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz", + "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==" + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -2994,6 +3039,33 @@ "punycode": "^2.1.1" } }, + "ts-jest": { + "version": "27.0.7", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.0.7.tgz", + "integrity": "sha512-O41shibMqzdafpuP+CkrOL7ykbmLh+FqQrXEmV9CydQ5JBk0Sj0uAEF5TNNe94fZWKm3yYvWa/IbyV4Yg1zK2Q==", + "dev": true, + "requires": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^27.0.0", + "json5": "2.x", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "7.x", + "yargs-parser": "20.x" + }, + "dependencies": { + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, "tslib": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", diff --git a/package.json b/package.json index a004031..cb6edb0 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "build/index.js", "scripts": { "build": "rimraf ./build && rollup -c rollup.config.js", - "watch": "rollup -w -c rollup.config.js" + "watch": "rollup -w -c rollup.config.js", + "test": "jest" }, "repository": { "type": "git", @@ -19,12 +20,18 @@ "homepage": "https://github.com/kajyr/pta-js#readme", "devDependencies": { "@rollup/plugin-commonjs": "^21.0.1", + "@types/jest": "^27.0.2", "@types/node": "^16.11.6", + "@types/split2": "^3.2.1", "jest": "^27.3.1", "rimraf": "^3.0.2", "rollup": "^2.59.0", "rollup-plugin-typescript2": "^0.30.0", + "ts-jest": "^27.0.7", "tslib": "^2.3.1", "typescript": "^4.4.4" + }, + "dependencies": { + "split2": "^4.1.0" } } diff --git a/src/__mocks__/prova.journal b/src/__mocks__/prova.journal new file mode 100644 index 0000000..82f7086 --- /dev/null +++ b/src/__mocks__/prova.journal @@ -0,0 +1,20 @@ +~ Monthly + Expenses:Droga 15 EUR + Assets:Banca + +2021-01-01 * Opening Balance + Assets:Banca 1000 EUR + Equity:Opening Balances + +2021-06-02 Nulla + Assets:Banca 20 EUR + Expenses:Droga -20 EUR + +2021-11-02 Nulla + Assets:Banca 40 EUR + Expenses:Droga -40 EUR + + +2021-11-04 Pino + 34 EUR + diff --git a/src/__mocks__/string-stream.ts b/src/__mocks__/string-stream.ts new file mode 100644 index 0000000..5e2f9a5 --- /dev/null +++ b/src/__mocks__/string-stream.ts @@ -0,0 +1,14 @@ +import { Stream } from 'stream'; + +function mockStream(str: string) { + const stream = new Stream.Readable(); + + stream._read = function () { + this.push(str); + this.push(null); + }; + + return stream; +} + +export default mockStream; diff --git a/src/index.ts b/src/index.ts index 5419ec1..846becc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import formatTransaction from './format-transaction'; +import parse from './parse'; import { Transaction } from './types'; -export { formatTransaction, Transaction }; +export { formatTransaction, Transaction, parse }; diff --git a/src/parse.test.ts b/src/parse.test.ts new file mode 100644 index 0000000..bd0d537 --- /dev/null +++ b/src/parse.test.ts @@ -0,0 +1,112 @@ +import { createReadStream } from 'fs'; + +import mockStream from './__mocks__/string-stream'; +import parse, { parseEntryLine, parseHeaderLine } from './parse'; + +describe("parseHeaderLine", () => { + it("Confirmed and description", () => { + expect(parseHeaderLine("2021-11-02 * Description")).toEqual({ + date: new Date("2021-11-02"), + confirmed: true, + description: "Description", + }); + }); + + it("Not confirmed", () => { + expect(parseHeaderLine("2021-11-02 Foo")).toEqual({ + date: new Date("2021-11-02"), + confirmed: false, + description: "Foo", + }); + }); + + it("No description", () => { + expect(parseHeaderLine("2021-11-02")).toEqual({ + date: new Date("2021-11-02"), + confirmed: false, + description: "", + }); + }); +}); + +describe("parseEntryLine", () => { + test("Line with commodity", () => { + expect(parseEntryLine("Assets:Crypto:Coinbase 1942.96 EUR")).toEqual({ + account: "Assets:Crypto:Coinbase", + amount: "1942.96", + commodity: "EUR", + }); + }); + test("Line with just account", () => { + expect(parseEntryLine("Assets:Crypto:Coinbase")).toEqual({ + account: "Assets:Crypto:Coinbase", + amount: undefined, + commodity: undefined, + }); + }); + + test("Line with just value", () => { + expect(parseEntryLine("Assets:Bank 34.00")).toEqual({ + account: "Assets:Bank", + amount: "34.00", + commodity: undefined, + }); + }); + + test("Line with conversion data", () => { + expect(parseEntryLine("Assets:Crypto -8.00 LTC @ 173.41 EUR")).toEqual( + { + account: "Assets:Crypto", + amount: "-8.00", + commodity: "LTC", + conversion: { amount: "173.41", commodity: "EUR" }, + } + ); + }); +}); + +describe("parse", () => { + test("it works with file streams", async () => { + const readStream = createReadStream(`src/__mocks__/prova.journal`); + + const p = await parse(readStream); + + expect(p.length).toBe(4); + }); + + test("it works with string streams", async () => { + const stream = mockStream(` + 2021-11-02 * Some shopping + Assets:Crypto:Coinbase -8.00 LTC @ 173.41 EUR + Assets:Crypto:Coinbase 1382.42 EUR + Expenses:Fees:Coinbase + + 2021-11-02 + Assets:Crypto:Coinbase -0.5 ETH @ 3899.56 EUR + Assets:Crypto:Coinbase 1942.96 EUR + Expenses:Fees:Coinbase + + + `); + + const p = await parse(stream); + + expect(p.length).toBe(2); + const [first] = p; + + expect(first.date).toStrictEqual(new Date("2021-11-02")); + expect(first.confirmed).toBe(true); + expect(first.description).toBe("Some shopping"); + expect(first.entries.length).toBe(3); + + expect(first.entries[0]).toEqual({ + account: "Assets:Crypto:Coinbase", + amount: "-8.00", + commodity: "LTC", + conversion: { + amount: "173.41", + commodity: "EUR", + }, + }); + }); +}); diff --git a/src/parse.ts b/src/parse.ts new file mode 100644 index 0000000..072ebab --- /dev/null +++ b/src/parse.ts @@ -0,0 +1,103 @@ +import { match } from 'assert'; +import split2 from 'split2'; +import { Transform } from 'stream'; +import { Entry, Transaction } from 'types'; + +function isDate(str: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(str); +} + +export function parseHeaderLine(str: string) { + const [date, ...other] = str.split(/\s+/); + let confirmed = other[0] === "*"; + if (confirmed) { + other.shift(); + } + const description = other.join(" "); + return { date: new Date(date), confirmed, description }; +} + +export function parseEntryLine(str: string): Entry { + const matches = str.match(/^(\S+)\s{2,}(.+)$/); + if (!matches) { + return { account: str }; + } + const account = matches[1]; + const values = matches[2]; + + if (values.indexOf("@") === -1) { + // No conversion + const [amount, commodity] = values.split(/\s+/); + return { amount, account, commodity }; + } + + const [ams, conversion] = values.split(/\s*@\s*/); + const [amount, commodity] = ams.split(/\s+/); + const [c_amount, c_commodity] = conversion.split(/\s+/); + + return { + amount, + account, + commodity, + conversion: { amount: c_amount, commodity: c_commodity }, + }; +} + +class Transformer extends Transform { + chunk: Transaction | null = null; + constructor() { + super({ + objectMode: true, + }); + } + + _transform(line: string, encoding: string, callback: Function) { + const trimmed = line.trim(); + const broken = trimmed.split(/\s+/); + if (trimmed === "") { + callback(); + return; + } + if (isDate(broken[0])) { + if (this.chunk) { + this.push(this.chunk); + } + this.chunk = { + ...parseHeaderLine(trimmed), + entries: [], + }; + } else if (this.chunk) { + this.chunk.entries.push(parseEntryLine(trimmed)); + } + callback(); + } + + _flush(callback: Function) { + if (this.chunk) { + this.push(this.chunk); + } + callback(); + } +} + +function parse(stream: NodeJS.ReadableStream): Promise { + const trxs: Transaction[] = []; + const transformer = new Transformer(); + + return new Promise((resolve, reject) => { + stream + .pipe(split2()) + .pipe(transformer) + .on("data", (data: Transaction) => { + trxs.push(data); + }) + .on("end", () => { + resolve(trxs); + }) + .on("error", () => { + reject(); + }); + }); +} + +export default parse; diff --git a/src/types.ts b/src/types.ts index 3f27098..c8ab051 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,12 @@ +export type Entry = { + account: string; + amount?: string; + commodity?: string; + conversion?: { amount: string; commodity: string }; +}; export type Transaction = { date: Date; description?: string; - entries: { account: string; amount: string }[]; + confirmed?: boolean; + entries: Entry[]; };