Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Parse .qif files into a sensible JSON format
## Getting Started
Install the module with: `npm install qif2json`

### Use in Node Js:

```javascript
var qif2json = require('qif2json');
qif2json.parse(qifData, options);
Expand All @@ -17,11 +19,36 @@ qif2json.parseFile(filePath, options, function(err, qifData){
});
```

If installed globally, the `qif2json` command can also be used with an input file and the output JSON will be pretty-printed to the console
### Use in CLI:

If installed globally, the `qif2json` command can also be used with an input file, and the output JSON will be pretty-printed to the console

### Use in browser:

Example of implementation in Vue

```
import {parse} from 'qif2json/lib/parse';

export default {
name: "UploadFile",
methods: {
async upload(e) { // method listening on @change event of input element with type "file"
const text = await e.target.files[0].text();
console.log(text.length);
console.log(text);

const json = parse(text)
console.log(json);
}
}
}
```

## Options

* `dateFormat` - The format of dates within the file. The `fetcha` module is used for parsing them into Date objects. See https://www.npmjs.com/package/fecha#formatting-tokens for available formatting tokens. The special format `"us"` will use us-format MM/DD/YYYY dates. Dates are normalised before parsing so `/`, `'` become `-` and spaces are removed. On the commandline you can specify multiple date formats comma-delimited.
* `encoding` - Package try detect encoding, but if want to override it, use this option.

## Contributing
Take care to maintain the existing coding style. Add unit tests for any new or changed functionality. Lint and test your code using `npm test`.
Expand All @@ -38,6 +65,9 @@ Take care to maintain the existing coding style. Add unit tests for any new or c
* 0.1.0 Support for Money 97 and "partial" transactions
* 0.1.1 Installs on node 0.12 and iojs 1.6
* 0.2.0 Added normalTransactions.qif example file + tests

* 0.3.0 Add description to partial transactions
* 0.3.1 Dependencies and docs updated
* 0.4.0 Added date formatting options
* 0.4.1 Support for multi accounts QIF files
## License
Licensed under the MIT license.
189 changes: 189 additions & 0 deletions lib/parse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
const fecha = require('fecha');
const debug = require('debug')('qif2json');
const { TYPES } = require('./types');

const US_DATE_FORMATS = ['MM-DD-YYYYHH:mm:ss', 'MM-DD-YYYY', 'MM-DD-YY'];
const UK_DATE_FORMATS = ['DD-MM-YYYYHH:mm:ss', 'DD-MM-YYYY', 'DD-MM-YY'];

function parseDate(dateStr, formats) {
if (formats === 'us' || dateStr.includes("'")) {
formats = US_DATE_FORMATS;
}
if (!formats) {
formats = UK_DATE_FORMATS;
}
formats = [].concat(formats);

let str = dateStr.replace(/ /g, '');
str = str.replace(/\//g, '-');
str = str.replace(/'/g, '-');
str = str.split(/\s*-\s*/g).map((s) => s.padStart(2, '0')).join('-');
debug(`input date ${dateStr} became ${str}`);

while (formats.length) {
const format = formats.shift();
const formatted = fecha.parse(str, format);
if (formatted) {
debug(`input date ${str} parses correctly with ${format}`);
return fecha.format(formatted, 'YYYY-MM-DDTHH:mm:ss');
}
}

return `<invalid date:"${dateStr}">`;
}

function appendEntity(data, type, entity, currentBankName, isMultiAccount) {
if (isMultiAccount && currentBankName && type.list_name === 'transactions') {
entity.account = currentBankName;
}
if (!isMultiAccount && type.list_name === 'accounts') {
data.type = entity.type.replace('Type:', '');
return data;
}

if (type.list_name === 'accounts'
&& Object.hasOwnProperty.call(data, 'accounts')
&& data.accounts.find((a) => a.name === entity.name)
) {
return data; // skip duplicates
}

if (Object.hasOwnProperty.call(data, type.list_name)) {
data[type.list_name].push(entity);
} else {
data[type.list_name] = [entity];
}

return data;
}

function clean(line) {
line = line.trim();
if (line.charCodeAt(0) === 239 && line.charCodeAt(1) === 187 && line.charCodeAt(2) === 191) {
line = line.substring(3);
}
return line;
}

exports.parse = function parse(qif, options) {
/* eslint no-multi-assign: "off", no-param-reassign: "off", no-cond-assign: "off",
no-continue: "off", prefer-destructuring: "off", no-case-declarations: "off" */
const lines = qif.split('\n');
let type = { }; // /^(!Type:([^$]*)|!Account)$/.exec(line.trim());
let currentBankName = '';
let isMultiAccount = false;

let data = {};

let entity = {};

options = options || {};

let division = {};
let line;

let i = 0;

while (line = lines.shift()) {
line = clean(line);
i += 1;
debug(i, line, line.charCodeAt(0), [...line], [...line].map((c) => c.charCodeAt(0)));

if (line === '^') {
if (type.list_name === 'accounts') {
currentBankName = entity.name;
}
data = appendEntity(data, type, entity, currentBankName, isMultiAccount);
entity = {};
continue;
}
switch (line[0]) {
case 'D':
if (type.list_name === 'transactions') {
entity.date = parseDate(line.substring(1), options.dateFormat);
} else {
entity.description = line.substring(1);
}
break;
case 'T':
if (type.list_name === 'transactions') {
entity.amount = parseFloat(line.substring(1).replace(',', ''));
} else {
entity.type = line.substring(1);
}
break;
case 'U':
// Looks like a legacy repeat of 'T'
break;
case 'N':
const propName = type.list_name === 'transactions' ? 'number' : 'name';
entity[propName] = line.substring(1);
break;
case 'M':
entity.memo = line.substring(1);
break;
case 'A':
entity.address = (entity.address || []).concat(line.substring(1));
break;
case 'P':
entity.payee = line.substring(1).replace(/&amp;/g, '&');
break;
case 'L':
const lArray = line.substring(1).split(':');
entity.category = lArray[0];
if (lArray[1] !== undefined) {
entity.subcategory = lArray[1];
}
break;
case 'C':
entity.clearedStatus = line.substring(1);
break;
case 'S':
const sArray = line.substring(1).split(':');
division.category = sArray[0];
if (sArray[1] !== undefined) {
division.subcategory = sArray[1];
}
break;
case 'E':
division.description = line.substring(1);
break;
case '$':
division.amount = parseFloat(line.substring(1));
if (!(entity.division instanceof Array)) {
entity.division = [];
}
entity.division.push(division);
division = {};
break;
case '!':
const typeName = line.substring(1);
type = TYPES.find(({ name }) => name === typeName);
if (typeName === 'Account') {
isMultiAccount = true;
}
if (!type && typeName.startsWith('Type:')) {
type = {
type: typeName,
list_name: 'transactions',
};
}
if (isMultiAccount === false) {
data = appendEntity(data, { list_name: 'accounts' }, { type: typeName }, currentBankName, isMultiAccount);
}

if (!type) {
throw new Error(`File does not appear to be a valid qif file: ${line}. Type ${typeName} is not supported.`);
}
break;
default:
throw new Error(`Unknown Detail Code: ${line[0]} in line ${i} with content: "${line}"`);
}
}

if (Object.keys(entity).length) {
data = appendEntity(data, type, entity, currentBankName, isMultiAccount);
}

return data;
};
17 changes: 17 additions & 0 deletions lib/parseFile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const fs = require('fs');
const { parseInput } = require('./parseInput');

/* eslint-disable no-param-reassign */
exports.parseFile = function parseFile(qifFile, options, callback) {
if (!callback) {
callback = options;
options = {};
}
fs.readFile(qifFile, (err, qifData) => {
if (err) {
return callback(err);
}
return parseInput(qifData, options, callback);
});
};
/* eslint-enable no-param-reassign */
29 changes: 29 additions & 0 deletions lib/parseInput.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const Iconv = require('iconv-lite');
const jschardet = require('jschardet');
const { parse } = require('./parse');

/* eslint-disable no-param-reassign */
exports.parseInput = function parseInput(qifData, options, callback) {
if (!callback) {
callback = options;
options = {};
}

const { encoding } = { ...jschardet.detect(qifData), ...options };
let err;

if (encoding.toUpperCase() !== 'UTF-8' && encoding.toUpperCase() !== 'ASCII') {
qifData = Iconv.decode(Buffer.from(qifData), encoding);
} else {
qifData = qifData.toString('utf8');
}

try {
qifData = parse(qifData, options);
} catch (e) {
err = e;
}

callback(err || undefined, qifData);
};
/* eslint-enable no-param-reassign */
17 changes: 17 additions & 0 deletions lib/parseStream.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const { parseInput } = require('./parseInput');

/* eslint-disable no-param-reassign */
exports.parseStream = function parseStream(stream, options, callback) {
let qifData = '';
if (!callback) {
callback = options;
options = {};
}
stream.on('data', (chunk) => {
qifData += chunk;
});
stream.on('end', () => {
parseInput(qifData, options, callback);
});
};
/* eslint-enable no-param-reassign */
Loading