diff --git a/packages/import-ynab4/README.md b/packages/import-ynab4/README.md deleted file mode 100644 index c4f1be2ce6abf84074144f82937431a7b35bfe0d..0000000000000000000000000000000000000000 --- a/packages/import-ynab4/README.md +++ /dev/null @@ -1,4 +0,0 @@ - -``` -npx @actual/import-ynab4 <path-to-ynab4-file> -``` \ No newline at end of file diff --git a/packages/import-ynab4/index.js b/packages/import-ynab4/index.js deleted file mode 100755 index 9af8154bdf7b0938de042ec1de839f66a16b41dc..0000000000000000000000000000000000000000 --- a/packages/import-ynab4/index.js +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env ts-node -import * as fs from 'fs'; - -import { init, shutdown } from '@actual-app/api'; - -import { importBuffer } from './importer'; - -async function run() { - let filepath = process.argv[2]; - let buffer = fs.readFileSync(filepath); - - await init(); - await importBuffer(filepath, buffer); - await shutdown(); -} - -run(); diff --git a/packages/import-ynab4/package.json b/packages/import-ynab4/package.json deleted file mode 100644 index fd4c41a23b59b72648c1ba715bbec9fb5637b89b..0000000000000000000000000000000000000000 --- a/packages/import-ynab4/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@actual-app/import-ynab4", - "description": "A tool for importing YNAB4 data into Actual", - "version": "1.0.6", - "main": "index.js", - "repository": { - "type": "git", - "url": "git+https://github.com/actualbudget/actual.git", - "directory": "packages/import-ynab4" - }, - "author": "James Long", - "license": "ISC", - "bugs": { - "url": "https://github.com/actualbudget/actual/issues" - }, - "bin": "./index.js", - "homepage": "https://github.com/actualbudget/actual/tree/master/packages/import-ynab4#readme", - "dependencies": { - "@actual-app/api": "*", - "adm-zip": "^0.5.9", - "date-fns": "^2.29.3", - "slash": "3.0.0", - "ts-node": "^10.9.1", - "uuid": "^9.0.0" - }, - "devDependencies": { - "@types/uuid": "^9.0.2" - } -} diff --git a/packages/import-ynab5/README.md b/packages/import-ynab5/README.md deleted file mode 100644 index e76d305665611bf0735019a10250f9243f761248..0000000000000000000000000000000000000000 --- a/packages/import-ynab5/README.md +++ /dev/null @@ -1,86 +0,0 @@ - -This is a **beta** importer for YNAB5 (nYNAB) data. - -To run: - -``` -npx @actual-app/import-ynab5 <path-to-ynab5-file> -``` - -Read below for how to get your YNAB5 file. - -Almost everything should be working now. - -## TODO - - There might be a way to set carryover using internal categories from YNAB (Deferred Income Subcategory and Immediate Income Subcategory) - - Docs of how credit cards translate from Actual to YNAB - - Maybe something else I'm missing - - Remove ynab transfer payees not used by actual - -## How to use the importer - -To use the importer, you will first need to export your budget, then have the correct software installed, and then run the importer. - -**Note: currently this does not work under WSL in Windows. Run this directly in Windows.** - -### Exporting from YNAB - -In order to export your budget from YNAB, you will need an API key. - -If you haven't already got an API key, you'll need to: - - * Sign in to the YNAB web app - * Go to the "Account Settings" page, then to the "Developer Settings" page - * Under the "Personal Access Tokens" section, click "New Token" - * Enter your password and click "Generate" to get a new access token - -The API key is only shown once, so make sure you copy it down somewhere! More information on how to access the YNAB API can be found at https://api.youneedabudget.com/ - -Now open a terminal window / command prompt, and enter: - -```bash -curl -H "Authorization: Bearer <ACCESS_TOKEN>" https://api.youneedabudget.com/v1/budgets -``` - -This will get the list of all the budgets you have. You'll need to find the `id` of the budget you want to export and use it to perform the following API request: - -```bash -curl -H "Authorization: Bearer <ACCESS_TOKEN>" https://api.youneedabudget.com/v1/budgets/<BUDGET ID> --output budget.json -``` -### Getting the right tools installed - -For the importer to run, you will need `nodejs` installed. Details on doing that are too long for this README, but you can find details at https://nodejs.org/. - -Once you have `nodejs` installed, you'll need to get download this importer. If you're familiar with GitHub and Git then you probably have everything setup to easily clone this repository. If not, the easiest way to get this importer is to use the `Code` button and then use the `Download ZIP` file. - -Once you have downloaded the zip file, unzip it on your computer to extract the files. Then in a terminal / command prompt, navigate into the directory and type the command: - -```bash -npm i -``` - -This will install the required libraries for the importer. - -### Running the importer - -* Have _Actual_ running locally on your computer -* Open a terminal / command prompt in the unzipped directory from the previous steps -* Run the following command, substituting the `/path/to` with where ever you saved the `budget.json` file: - -```bash -npx @actual-app/import-ynab5 /path/to/budget.json -``` - -If you have checked out this code and running it locally, do `node index.js` instead of the `npx` command. - -### Refresh the cache - -Once the import is complete, it may not show all the up-to-date information correctly. In order to refresh the view: - -* Click the âš™ï¸ icon next to the budget name -* Click Advanced -> Reset budget cache -* Restart _Actual_ - - -## Contributions -If you would like to contribute, check out the [documentation for the API](https://actualbudget.github.io/docs/Developers/using-the-API), specifically about [importers](https://actualbudget.github.io/docs/Developers/using-the-API#writing-data-importers). All of the available methods can be found [here](https://actualbudget.github.io/docs/Developers/API). diff --git a/packages/import-ynab5/example.json b/packages/import-ynab5/example.json deleted file mode 100644 index 2aadc408e3f3f0c7cabba71ee73341f68eb0370c..0000000000000000000000000000000000000000 --- a/packages/import-ynab5/example.json +++ /dev/null @@ -1,174 +0,0 @@ -{ - "data": { - "budget": { - "id": "string", - "name": "string", - "last_modified_on": "2020-02-12T20:46:50.121Z", - "first_month": "string", - "last_month": "string", - "date_format": { - "format": "string" - }, - "currency_format": { - "iso_code": "string", - "example_format": "string", - "decimal_digits": 0, - "decimal_separator": "string", - "symbol_first": true, - "group_separator": "string", - "currency_symbol": "string", - "display_symbol": true - }, - "accounts": [ - { - "id": "string", - "name": "string", - "type": "checking", - "on_budget": true, - "closed": true, - "note": "string", - "balance": 0, - "cleared_balance": 0, - "uncleared_balance": 0, - "transfer_payee_id": "string", - "deleted": true - } - ], - "payees": [ - { - "id": "string", - "name": "string", - "transfer_account_id": "string", - "deleted": true - } - ], - "payee_locations": [ - { - "id": "string", - "payee_id": "string", - "latitude": "string", - "longitude": "string", - "deleted": true - } - ], - "category_groups": [ - { - "id": "string", - "name": "string", - "hidden": true, - "deleted": true - } - ], - "categories": [ - { - "id": "string", - "category_group_id": "string", - "name": "string", - "hidden": true, - "original_category_group_id": "string", - "note": "string", - "budgeted": 0, - "activity": 0, - "balance": 0, - "goal_type": "TB", - "goal_creation_month": "string", - "goal_target": 0, - "goal_target_month": "string", - "goal_percentage_complete": 0, - "deleted": true - } - ], - "months": [ - { - "month": "string", - "note": "string", - "income": 0, - "budgeted": 0, - "activity": 0, - "to_be_budgeted": 0, - "age_of_money": 0, - "deleted": true, - "categories": [ - { - "id": "string", - "category_group_id": "string", - "name": "string", - "hidden": true, - "original_category_group_id": "string", - "note": "string", - "budgeted": 0, - "activity": 0, - "balance": 0, - "goal_type": "TB", - "goal_creation_month": "string", - "goal_target": 0, - "goal_target_month": "string", - "goal_percentage_complete": 0, - "deleted": true - } - ] - } - ], - "transactions": [ - { - "id": "string", - "date": "string", - "amount": 0, - "memo": "string", - "cleared": "cleared", - "approved": true, - "flag_color": "red", - "account_id": "string", - "payee_id": "string", - "category_id": "string", - "transfer_account_id": "string", - "transfer_transaction_id": "string", - "matched_transaction_id": "string", - "import_id": "string", - "deleted": true - } - ], - "subtransactions": [ - { - "id": "string", - "transaction_id": "string", - "amount": 0, - "memo": "string", - "payee_id": "string", - "category_id": "string", - "transfer_account_id": "string", - "deleted": true - } - ], - "scheduled_transactions": [ - { - "id": "string", - "date_first": "string", - "date_next": "string", - "frequency": "never", - "amount": 0, - "memo": "string", - "flag_color": "red", - "account_id": "string", - "payee_id": "string", - "category_id": "string", - "transfer_account_id": "string", - "deleted": true - } - ], - "scheduled_subtransactions": [ - { - "id": "string", - "scheduled_transaction_id": "string", - "amount": 0, - "memo": "string", - "payee_id": "string", - "category_id": "string", - "transfer_account_id": "string", - "deleted": true - } - ] - }, - "server_knowledge": 0 - } -} diff --git a/packages/import-ynab5/index.js b/packages/import-ynab5/index.js deleted file mode 100755 index 0cb184ab47de293df27715846ce0bf8acb33ce37..0000000000000000000000000000000000000000 --- a/packages/import-ynab5/index.js +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env ts-node -import * as fs from 'fs'; - -import { init, shutdown } from '@actual-app/api'; - -import { importYNAB5 } from './importer'; - -async function run() { - let filepath = process.argv[2]; - let data = JSON.parse(fs.readFileSync(filepath, 'utf8')); - - await init(); - await importYNAB5(data); - await shutdown(); -} - -run(); diff --git a/packages/import-ynab5/package.json b/packages/import-ynab5/package.json deleted file mode 100644 index 1e20fd392736507d35c270def7760898e7cf4fcb..0000000000000000000000000000000000000000 --- a/packages/import-ynab5/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@actual-app/import-ynab5", - "description": "A tool for importing nYNAB data into Actual", - "version": "1.0.2", - "main": "index.js", - "repository": { - "type": "git", - "url": "git+https://github.com/actualbudget/actual.git", - "directory": "packages/import-ynab5" - }, - "author": "James Long", - "license": "ISC", - "bugs": { - "url": "https://github.com/actualbudget/actual/issues" - }, - "bin": "./index.js", - "homepage": "https://github.com/actualbudget/actual/tree/master/packages/import-ynab5#readme", - "dependencies": { - "@actual-app/api": "*", - "date-fns": "^2.29.3", - "ts-node": "^10.9.1", - "uuid": "^9.0.0" - }, - "devDependencies": { - "@types/uuid": "^9.0.2" - } -} diff --git a/packages/loot-core/package.json b/packages/loot-core/package.json index cdc8f71c0b9274ae79a1125885ee45b043ab1469..8219dd67e88b96e28ebf3380672809ad30c49e56 100644 --- a/packages/loot-core/package.json +++ b/packages/loot-core/package.json @@ -22,6 +22,7 @@ "@rschedule/ical-tools": "^1.2.0", "@rschedule/json-tools": "^1.2.0", "@rschedule/standard-date-adapter": "^1.2.0", + "@types/adm-zip": "^0.5.0", "absurd-sql": "0.0.53", "assert": "^2.0.0", "better-sqlite3": "^8.2.0", @@ -42,7 +43,6 @@ "devDependencies": { "@actual-app/api": "*", "@actual-app/crdt": "*", - "@actual-app/import-ynab4": "*", "@babel/core": "~7.22.5", "@babel/preset-env": "^7.22.5", "@babel/preset-typescript": "^7.22.5", @@ -69,6 +69,7 @@ "mockdate": "^3.0.5", "npm-run-all": "^4.1.3", "peggy": "3.0.2", + "slash": "3.0.0", "snapshot-diff": "^0.10.0", "source-map": "^0.7.3", "throttleit": "^1.0.0", diff --git a/packages/loot-core/src/platform/server/sqlite/index.d.ts b/packages/loot-core/src/platform/server/sqlite/index.d.ts index 97b997625a6b570b58642cbcea8161981155a30b..63a9575212a23eac36f909e8f9bcbfe302f7f84b 100644 --- a/packages/loot-core/src/platform/server/sqlite/index.d.ts +++ b/packages/loot-core/src/platform/server/sqlite/index.d.ts @@ -7,26 +7,29 @@ export function _getModule(): SqlJsStatic; export function prepare(db, sql): unknown; export function runQuery( - db: unknown, + db: Database, sql: string, params?: (string | number)[], fetchAll?: false, ): { changes: unknown }; export function runQuery<T>( - db: unknown, + db: Database, sql: string, params: (string | number)[], fetchAll: true, ): T[]; -export function execQuery(db, sql): void; +export function execQuery(db: Database, sql): void; -export function transaction(db, fn): unknown; +export function transaction(db: Database, fn: () => void): void; -export async function asyncTransaction(db, fn): unknown; +export async function asyncTransaction( + db: Database, + fn: () => Promise<void>, +): Promise<void>; export async function openDatabase(pathOrBuffer?: string | Buffer): Database; -export function closeDatabase(db): void; +export function closeDatabase(db: Database): void; -export async function exportDatabase(db): Buffer; +export async function exportDatabase(db: Database): Promise<Uint8Array>; diff --git a/packages/loot-core/src/platform/server/sqlite/index.electron.ts b/packages/loot-core/src/platform/server/sqlite/index.electron.ts index 27e0ad828969c1591471095eb5f92feb988e059a..2e928f60fdefd2128c7f7aaab7115e221ab3b788 100644 --- a/packages/loot-core/src/platform/server/sqlite/index.electron.ts +++ b/packages/loot-core/src/platform/server/sqlite/index.electron.ts @@ -1,4 +1,4 @@ -import Database from 'better-sqlite3'; +import SQL from 'better-sqlite3'; import { v4 as uuidv4 } from 'uuid'; import { removeFile, readFile } from '../fs'; @@ -18,12 +18,17 @@ export function prepare(db, sql) { return db.prepare(sql); } -export function runQuery(db, sql, params = [], fetchAll) { +export function runQuery( + db: SQL.Database, + sql: string | SQL.Statement, + params: (string | number)[] = [], + fetchAll = false, +) { if (params) { verifyParamTypes(sql, params); } - let stmt; + let stmt: SQL.Statement; try { stmt = typeof sql === 'string' ? db.prepare(sql) : sql; } catch (e) { @@ -50,11 +55,11 @@ export function runQuery(db, sql, params = [], fetchAll) { } } -export function execQuery(db, sql) { +export function execQuery(db: SQL.Database, sql: string) { db.exec(sql); } -export function transaction(db, fn) { +export function transaction(db: SQL.Database, fn: () => void) { db.transaction(fn)(); } @@ -64,7 +69,10 @@ export function transaction(db, fn) { // it. This is rarely used, and only needed for specific cases (like // batch importing a bunch of data). Don't use this. let transactionDepth = 0; -export async function asyncTransaction(db, fn) { +export async function asyncTransaction( + db: SQL.Database, + fn: () => Promise<void>, +) { // Support nested transactions by "coalescing" them into the parent // one if one is already started if (transactionDepth === 0) { @@ -86,8 +94,8 @@ export async function asyncTransaction(db, fn) { } } -export function openDatabase(pathOrBuffer) { - let db = new Database(pathOrBuffer); +export function openDatabase(pathOrBuffer: string | Buffer) { + let db = new SQL(pathOrBuffer); // Define Unicode-aware LOWER and UPPER implementation. // This is necessary because better-sqlite3 uses SQLite build without ICU support. db.function('UNICODE_LOWER', { deterministic: true }, (arg: string | null) => @@ -99,18 +107,18 @@ export function openDatabase(pathOrBuffer) { return db; } -export function closeDatabase(db) { +export function closeDatabase(db: SQL.Database) { return db.close(); } -export async function exportDatabase(db) { +export async function exportDatabase(db: SQL.Database) { // electron does not support better-sqlite serialize since v21 // save to file and read in the raw data. let name = `backup-for-export-${uuidv4()}.db`; await db.backup(name); - let data = await readFile(name); + let data = await readFile(name, 'binary'); await removeFile(name); return data; diff --git a/packages/loot-core/src/platform/server/sqlite/index.web.ts b/packages/loot-core/src/platform/server/sqlite/index.web.ts index 3f6895b07719ec160d4b37b86ba068325afdf4ef..b9b39f456ab93c08d8945b9114a616d8c04e2525 100644 --- a/packages/loot-core/src/platform/server/sqlite/index.web.ts +++ b/packages/loot-core/src/platform/server/sqlite/index.web.ts @@ -1,4 +1,4 @@ -import initSqlJS, { type SqlJsStatic } from '@jlongster/sql.js'; +import initSqlJS, { type SqlJsStatic, type Database } from '@jlongster/sql.js'; let SQL: SqlJsStatic | null = null; @@ -42,13 +42,13 @@ export function prepare(db, sql) { export function runQuery( db: unknown, sql: string, - params?: string[], + params?: (string | number)[], fetchAll?: false, ): { changes: unknown }; export function runQuery( db: unknown, sql: string, - params: string[], + params: (string | number)[], fetchAll: true, ): unknown[]; export function runQuery(db, sql, params = [], fetchAll = false) { @@ -128,7 +128,7 @@ export function transaction(db, fn) { // See the comment about this function in index.electron.js. You // shouldn't normally use this. I'd like to get rid of it. -export async function asyncTransaction(db, fn) { +export async function asyncTransaction(db: Database, fn: () => Promise<void>) { // Support nested transactions by "coalescing" them into the parent // one if one is already started if (transactionDepth === 0) { @@ -195,10 +195,10 @@ export async function openDatabase(pathOrBuffer?: string | Buffer) { return db; } -export function closeDatabase(db) { +export function closeDatabase(db: Database) { db.close(); } -export async function exportDatabase(db) { +export async function exportDatabase(db: Database) { return db.export(); } diff --git a/packages/loot-core/src/server/cloud-storage.ts b/packages/loot-core/src/server/cloud-storage.ts index c1cfd05f46f1a1ba51049e39a7da5d75665c1e13..a7a7f9c7a42c8a11e6d5f4af3d6a5d8e722f29c9 100644 --- a/packages/loot-core/src/server/cloud-storage.ts +++ b/packages/loot-core/src/server/cloud-storage.ts @@ -161,7 +161,7 @@ export async function exportBuffer() { meta.resetClock = true; let metaContent = Buffer.from(JSON.stringify(meta), 'utf8'); - zipped.addFile('db.sqlite', dbContent); + zipped.addFile('db.sqlite', Buffer.from(dbContent)); zipped.addFile('metadata.json', metaContent); }); diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index ef7bc22d5cb1bc790425e9ef34a906d766a5338c..5e7d36cc98fc45b94a767329210a113b6d32c3b8 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -131,11 +131,11 @@ function resetQueryCache() { _queryCache = new LRU({ max: 100 }); } -export function transaction(fn) { +export function transaction(fn: () => void) { return sqlite.transaction(db, fn); } -export function asyncTransaction(fn) { +export function asyncTransaction(fn: () => Promise<void>) { return sqlite.asyncTransaction(db, fn); } diff --git a/packages/loot-core/src/server/importers/actual.ts b/packages/loot-core/src/server/importers/actual.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac1fe39dba46e407b4685c72fde39a5d935901d6 --- /dev/null +++ b/packages/loot-core/src/server/importers/actual.ts @@ -0,0 +1,46 @@ +import * as fs from '../../platform/server/fs'; +import * as sqlite from '../../platform/server/sqlite'; +import * as cloudStorage from '../cloud-storage'; +import { handlers } from '../main'; +import { waitOnSpreadsheet } from '../sheet'; + +export default async function importActual(_filepath: string, buffer: Buffer) { + // Importing Actual files is a special case because we can directly + // write down the files, but because it doesn't go through the API + // layer we need to duplicate some of the workflow + await handlers['close-budget'](); + + let id; + try { + ({ id } = await cloudStorage.importBuffer( + { cloudFileId: null, groupId: null }, + buffer, + )); + } catch (e) { + if (e.type === 'FileDownloadError') { + return { error: e.reason }; + } + throw e; + } + + // We never want to load cached data from imported files, so + // delete the cache + let sqliteDb = await sqlite.openDatabase( + fs.join(fs.getBudgetDir(id), 'db.sqlite'), + ); + sqlite.execQuery( + sqliteDb, + ` + DELETE FROM kvcache; + DELETE FROM kvcache_key; + `, + ); + sqlite.closeDatabase(sqliteDb); + + // Load the budget, force everything to be computed, and try + // to upload it as a cloud file + await handlers['load-budget']({ id }); + await handlers['get-budget-bounds'](); + await waitOnSpreadsheet(); + await cloudStorage.upload().catch(err => {}); +} diff --git a/packages/loot-core/src/server/importers/index.ts b/packages/loot-core/src/server/importers/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..39cdbb86d6f4cf52139f881329ee7b86a725425d --- /dev/null +++ b/packages/loot-core/src/server/importers/index.ts @@ -0,0 +1,56 @@ +import { handlers } from '../main'; + +import importActual from './actual'; +import * as YNAB4 from './ynab4'; +import * as YNAB5 from './ynab5'; + +type ImportableBudgetType = 'ynab4' | 'ynab5' | 'actual'; + +type Importer = { + parseFile(buffer: Buffer): unknown; + getBudgetName(filepath: string, data: unknown): string | null; + doImport(data: unknown): Promise<void>; +}; + +let importers: Record<Exclude<ImportableBudgetType, 'actual'>, Importer> = { + ynab4: YNAB4, + ynab5: YNAB5, +}; + +export async function handleBudgetImport( + type: ImportableBudgetType, + filepath: string, + buffer: Buffer, +) { + if (type === 'actual') { + return importActual(filepath, buffer); + } + let importer = importers[type]; + try { + let data; + let budgetName: string; + try { + data = importer.parseFile(buffer); + budgetName = importer.getBudgetName(filepath, data); + } catch (e) { + console.error('failed to parse file', e); + } + if (!budgetName) { + return { error: 'not-' + type }; + } + + try { + await handlers['api/start-import']({ budgetName }); + } catch (e) { + console.error('failed to start import', e); + return { error: 'unknown' }; + } + await importer.doImport(data); + } catch (e) { + await handlers['api/abort-import'](); + console.error('failed to run import', e); + return { error: 'unknown' }; + } + + await handlers['api/finish-import'](); +} diff --git a/packages/loot-core/src/server/importers/ynab4-types.d.ts b/packages/loot-core/src/server/importers/ynab4-types.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..72cf81483c2a0be1d401e62e16cc1c4efc579d3a --- /dev/null +++ b/packages/loot-core/src/server/importers/ynab4-types.d.ts @@ -0,0 +1,165 @@ +/* eslint-disable import/no-unused-modules */ + +export namespace YNAB4 { + export interface YFull { + masterCategories: MasterCategory[]; + payees: Payee[]; + monthlyBudgets: MonthlyBudget[]; + fileMetaData: FileMetaData; + transactions: Transaction[]; + scheduledTransactions: ScheduledTransaction[]; + // accountMappings: []; + budgetMetaData: BudgetMetaData; + accounts: Account[]; + } + + export interface MasterCategory { + entityType: string; + expanded: boolean; + name: string; + type: string; + deleteable: boolean; + subCategories?: SubCategory[]; + entityVersion: string; + entityId: string; + sortableIndex: number; + + // speculative + isTombstone?: boolean; + } + + export interface SubCategory { + entityType: string; + name: string; + type: string; + // cachedBalance: null; + masterCategoryId: string; + entityVersion: string; + entityId: string; + sortableIndex: number; + + // speculative + isTombstone?: boolean; + } + + export interface Payee { + entityType: string; + autoFillCategoryId?: string; + autoFillAmount: number; + name: string; + renameConditions?: RenameCondition[]; + autoFillMemo?: string; + targetAccountId?: string; + // locations: null; + enabled: boolean; + entityVersion: string; + entityId: string; + + // speculative + isTombstone?: boolean; + } + + export interface RenameCondition { + entityType: string; + parentPayeeId: string; + operator: string; + operand: string; + entityVersion: string; + entityId: string; + } + + export interface MonthlyBudget { + entityType: string; + monthlySubCategoryBudgets: MonthlySubCategoryBudget[]; + month: string; + entityVersion: string; + entityId: string; + } + + export interface MonthlySubCategoryBudget { + entityType: string; + categoryId: string; + budgeted: number; + // overspendingHandling: null; + entityVersion: string; + entityId: string; + parentMonthlyBudgetId: string; + + // speculative + isTombstone?: boolean; + } + + export interface FileMetaData { + entityType: string; + budgetDataVersion: string; + currentKnowledge: string; + } + + export interface Transaction { + entityType: string; + entityId: string; + categoryId: string; + payeeId: string; + amount: number; + date: string; + accountId: string; + entityVersion: string; + cleared: string; + accepted: boolean; + isTombstone?: boolean; + memo?: string; + dateEnteredFromSchedule?: string; + // speculative: + subTransactions?: SubTransaction[]; + transferTransactionId?: string; + targetAccountId?: string; + } + + // speculative, not in the test data + export type SubTransaction = Exclude<Transaction, 'subTransactions'>; + + export interface ScheduledTransaction { + entityType: string; + entityId: string; + categoryId: string; + payeeId: string; + amount: number; + date: string; + isTombstone?: boolean; + accountId: string; + entityVersion: string; + memo: string; + twiceAMonthStartDay: number; + cleared: string; + frequency: string; + accepted: boolean; + } + + export interface BudgetMetaData { + entityType: string; + strictBudget: string; + currencyISOSymbol?: string; + entityVersion: string; + currencyLocale: string; + budgetType: string; + dateLocale: string; + entityId: string; + } + + export interface Account { + entityType: string; + // lastReconciledDate: null; + lastEnteredCheckNumber: number; + lastReconciledBalance: number; + accountType: string; + hidden: boolean; + sortableIndex: number; + onBudget: boolean; + accountName: string; + entityVersion: string; + entityId: string; + + // speculative + isTombstone?: boolean; + } +} diff --git a/packages/import-ynab4/importer.js b/packages/loot-core/src/server/importers/ynab4.ts similarity index 77% rename from packages/import-ynab4/importer.js rename to packages/loot-core/src/server/importers/ynab4.ts index d8b71b0ef1e9b15c433bdd0ddccae8710efacc16..9286464dd89d0626b11067968a6fd823848d76d0 100644 --- a/packages/import-ynab4/importer.js +++ b/packages/loot-core/src/server/importers/ynab4.ts @@ -5,59 +5,20 @@ import * as actual from '@actual-app/api/methods'; import { amountToInteger } from '@actual-app/api/utils'; import AdmZip from 'adm-zip'; -import * as d from 'date-fns'; import normalizePathSep from 'slash'; import { v4 as uuidv4 } from 'uuid'; -// Utils +import * as monthUtils from '../../shared/months'; +import { groupBy, sortByKey } from '../../shared/util'; -function sortByKey(arr, key) { - return [...arr].sort((item1, item2) => { - if (item1[key] < item2[key]) { - return -1; - } else if (item1[key] > item2[key]) { - return 1; - } - return 0; - }); -} - -function groupBy(arr, keyName) { - return arr.reduce(function (obj, item) { - var key = item[keyName]; - if (!obj.hasOwnProperty(key)) { - obj[key] = []; - } - obj[key].push(item); - return obj; - }, {}); -} - -function _parse(value) { - if (typeof value === 'string') { - // We don't want parsing to take local timezone into account, - // which parsing a string does. Pass the integers manually to - // bypass it. - - let [year, month, day] = value.split('-'); - if (day != null) { - return new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); - } else if (month != null) { - return new Date(parseInt(year), parseInt(month) - 1, 1); - } else { - return new Date(parseInt(year), 0, 1); - } - } - return value; -} - -function monthFromDate(date) { - return d.format(_parse(date), 'yyyy-MM'); -} +import { YNAB4 } from './ynab4-types'; // Importer -async function importAccounts(data, entityIdMap) { +async function importAccounts( + data: YNAB4.YFull, + entityIdMap: Map<string, string>, +) { const accounts = sortByKey(data.accounts, 'sortableIndex'); return Promise.all( @@ -74,7 +35,10 @@ async function importAccounts(data, entityIdMap) { ); } -async function importCategories(data, entityIdMap) { +async function importCategories( + data: YNAB4.YFull, + entityIdMap: Map<string, string>, +) { const masterCategories = sortByKey(data.masterCategories, 'sortableIndex'); await Promise.all( @@ -83,7 +47,7 @@ async function importCategories(data, entityIdMap) { masterCategory.type === 'OUTFLOW' && !masterCategory.isTombstone && masterCategory.subCategories && - masterCategory.subCategories.some(cat => !cat.isTombstone) > 0 + masterCategory.subCategories.some(cat => !cat.isTombstone) ) { const id = await actual.createCategoryGroup({ name: masterCategory.name, @@ -115,7 +79,10 @@ async function importCategories(data, entityIdMap) { ); } -async function importPayees(data, entityIdMap) { +async function importPayees( + data: YNAB4.YFull, + entityIdMap: Map<string, string>, +) { for (let payee of data.payees) { if (!payee.isTombstone) { let id = await actual.createPayee({ @@ -131,13 +98,18 @@ async function importPayees(data, entityIdMap) { } } -async function importTransactions(data, entityIdMap) { +async function importTransactions( + data: YNAB4.YFull, + entityIdMap: Map<string, string>, +) { const categories = await actual.getCategories(); - const incomeCategoryId = categories.find(cat => cat.name === 'Income').id; + const incomeCategoryId: string = categories.find( + cat => cat.name === 'Income', + ).id; const accounts = await actual.getAccounts(); const payees = await actual.getPayees(); - function getCategory(id) { + function getCategory(id: string) { if (id == null || id === 'Category/__Split__') { return null; } else if ( @@ -149,7 +121,7 @@ async function importTransactions(data, entityIdMap) { return entityIdMap.get(id); } - function isOffBudget(acctId) { + function isOffBudget(acctId: string) { let acct = accounts.find(acct => acct.id === acctId); if (!acct) { throw new Error('Could not find account for transaction when importing'); @@ -172,8 +144,8 @@ async function importTransactions(data, entityIdMap) { let transactionsGrouped = groupBy(data.transactions, 'accountId'); await Promise.all( - Object.keys(transactionsGrouped).map(async accountId => { - let transactions = transactionsGrouped[accountId]; + [...transactionsGrouped.keys()].map(async accountId => { + let transactions = transactionsGrouped.get(accountId); let toImport = transactions .map(transaction => { @@ -183,7 +155,7 @@ async function importTransactions(data, entityIdMap) { let id = entityIdMap.get(transaction.entityId); - function transferProperties(t) { + function transferProperties(t: YNAB4.SubTransaction) { let transferId = entityIdMap.get(t.transferTransactionId) || null; let payee = null; @@ -216,19 +188,19 @@ async function importTransactions(data, entityIdMap) { notes: transaction.memo || null, cleared: transaction.cleared === 'Cleared', ...transferProperties(transaction), - }; - newTransaction.subtransactions = - transaction.subTransactions && - transaction.subTransactions.map((t, i) => { - return { - id: entityIdMap.get(t.entityId), - amount: amountToInteger(t.amount), - category: getCategory(t.categoryId), - notes: t.memo || null, - ...transferProperties(t), - }; - }); + subtransactions: + transaction.subTransactions && + transaction.subTransactions.map((t, i) => { + return { + id: entityIdMap.get(t.entityId), + amount: amountToInteger(t.amount), + category: getCategory(t.categoryId), + notes: t.memo || null, + ...transferProperties(t), + }; + }), + }; return newTransaction; }) @@ -239,14 +211,21 @@ async function importTransactions(data, entityIdMap) { ); } -function fillInBudgets(data, categoryBudgets) { +function fillInBudgets( + data: YNAB4.YFull, + categoryBudgets: YNAB4.MonthlySubCategoryBudget[], +) { // YNAB only contains entries for categories that have been actually // budgeted. That would be fine except that we need to set the // "carryover" flag on each month when carrying debt across months. // To make sure our system has a chance to set this flag on each // category, make sure a budget exists for every category of every // month. - const budgets = [...categoryBudgets]; + const budgets: { + budgeted: number; + categoryId: string; + overspendingHandling?: string; + }[] = [...categoryBudgets]; data.masterCategories.forEach(masterCategory => { if (masterCategory.subCategories) { masterCategory.subCategories.forEach(category => { @@ -262,7 +241,10 @@ function fillInBudgets(data, categoryBudgets) { return budgets; } -async function importBudgets(data, entityIdMap) { +async function importBudgets( + data: YNAB4.YFull, + entityIdMap: Map<string, string>, +) { let budgets = sortByKey(data.monthlyBudgets, 'month'); await actual.batchBudgetUpdates(async () => { @@ -276,7 +258,7 @@ async function importBudgets(data, entityIdMap) { filled.map(async catBudget => { let amount = amountToInteger(catBudget.budgeted); let catId = entityIdMap.get(catBudget.categoryId); - let month = monthFromDate(budget.month); + let month = monthUtils.monthFromDate(budget.month); if (!catId) { return; } @@ -294,7 +276,7 @@ async function importBudgets(data, entityIdMap) { }); } -function estimateRecentness(str) { +function estimateRecentness(str: string) { // The "recentness" is the total amount of changes that this device // is aware of, which is estimated by summing up all of the version // numbers that its aware of. This works because version numbers are @@ -305,7 +287,7 @@ function estimateRecentness(str) { }, 0); } -function findLatestDevice(zipped, entries) { +function findLatestDevice(zipped: AdmZip, entries: AdmZip.IZipEntry[]): string { let devices = entries .map(entry => { const contents = zipped.readFile(entry).toString('utf8'); @@ -333,8 +315,8 @@ function findLatestDevice(zipped, entries) { return devices[devices.length - 1].deviceGUID; } -async function doImport(data) { - const entityIdMap = new Map(); +export async function doImport(data: YNAB4.YFull) { + const entityIdMap = new Map<string, string>(); console.log('Importing Accounts...'); await importAccounts(data, entityIdMap); @@ -354,7 +336,7 @@ async function doImport(data) { console.log('Setting up...'); } -function getBudgetName(filepath) { +export function getBudgetName(filepath, _data) { let unixFilepath = normalizePathSep(filepath); if (!/\.zip/.test(unixFilepath)) { @@ -373,7 +355,7 @@ function getBudgetName(filepath) { return m[1]; } -function getFile(entries, path) { +function getFile(entries: AdmZip.IZipEntry[], path: string) { let files = entries.filter(e => e.entryName === path); if (files.length === 0) { throw new Error('Could not find file: ' + path); @@ -384,19 +366,13 @@ function getFile(entries, path) { return files[0]; } -function join(...paths) { +function join(...paths: string[]): string { return paths.slice(1).reduce((full, path) => { return full + '/' + path.replace(/^\//, ''); }, paths[0].replace(/\/$/, '')); } -export async function importBuffer(filepath, buffer) { - let budgetName = getBudgetName(filepath); - - if (!budgetName) { - throw new Error('Not a YNAB4 file: ' + filepath); - } - +export function parseFile(buffer: Buffer): YNAB4.YFull { let zipped = new AdmZip(buffer); let entries = zipped.getEntries(); @@ -424,12 +400,9 @@ export async function importBuffer(filepath, buffer) { throw new Error('Error reading Budget.yfull file'); } - let data; try { - data = JSON.parse(contents); + return JSON.parse(contents); } catch (e) { - throw new Error('Error parsing Budget.yull file'); + throw new Error('Error parsing Budget.yfull file'); } - - return actual.runImport(budgetName, () => doImport(data)); } diff --git a/packages/loot-core/src/server/importers/ynab5-types.d.ts b/packages/loot-core/src/server/importers/ynab5-types.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b1a59f0251ef1e97818a2836e210cecbb849b70 --- /dev/null +++ b/packages/loot-core/src/server/importers/ynab5-types.d.ts @@ -0,0 +1,75 @@ +/* eslint-disable import/no-unused-modules */ + +export namespace YNAB5 { + export interface Budget { + budget_name: string; + accounts: Account[]; + payees: Payee[]; + category_groups: CategoryGroup[]; + categories: Category[]; + transactions: Transaction[]; + subtransactions: Subtransaction[]; + months: Month[]; + } + + interface Account { + id: string; + name: string; + on_budget: boolean; + deleted: boolean; + closed: boolean; + } + + interface Payee { + id: string; + name: string; + deleted: boolean; + } + + interface CategoryGroup { + id: string; + name: string; + deleted: boolean; + } + + interface Category { + id: string; + category_group_id: string; + name: string; + deleted: boolean; + } + + interface Transaction { + id: string; + account_id: string; + date: string; + payee_id: string; + import_id: string; + category_id: string; + transfer_account_id: string; + transfer_transaction_id: string; + memo: string; + cleared: string; + amount: number; + deleted: boolean; + } + + interface Subtransaction { + id: string; + transaction_id: string; + category_id: string; + memo: string; + amount: number; + } + + interface Month { + month: string; + categories: MonthCategory[]; + } + + interface MonthCategory { + category_group_id: string; + id: string; + budgeted: number; + } +} diff --git a/packages/import-ynab5/importer.js b/packages/loot-core/src/server/importers/ynab5.ts similarity index 79% rename from packages/import-ynab5/importer.js rename to packages/loot-core/src/server/importers/ynab5.ts index d7f79aaddb4095746f750e9dec261ec3d5d65bc5..eddee853d01abd9787110ab14d9813c36a718bbd 100644 --- a/packages/import-ynab5/importer.js +++ b/packages/loot-core/src/server/importers/ynab5.ts @@ -5,40 +5,18 @@ import * as actual from '@actual-app/api/methods'; import { v4 as uuidv4 } from 'uuid'; -function amountFromYnab(amount) { +import * as monthUtils from '../../shared/months'; +import { sortByKey, groupBy } from '../../shared/util'; + +import { YNAB5 } from './ynab5-types'; + +function amountFromYnab(amount: number) { // ynabs multiplies amount by 1000 and actual by 100 // so, this function divides by 10 return Math.round(amount / 10); } -function monthFromDate(date) { - let parts = date.split('-'); - return parts[0] + '-' + parts[1]; -} - -function sortByKey(arr, key) { - return [...arr].sort((item1, item2) => { - if (item1[key] < item2[key]) { - return -1; - } else if (item1[key] > item2[key]) { - return 1; - } - return 0; - }); -} - -function groupBy(arr, keyName) { - return arr.reduce(function (obj, item) { - var key = item[keyName]; - if (!obj.hasOwnProperty(key)) { - obj[key] = []; - } - obj[key].push(item); - return obj; - }, {}); -} - -function importAccounts(data, entityIdMap) { +function importAccounts(data: YNAB5.Budget, entityIdMap: Map<string, string>) { return Promise.all( data.accounts.map(async account => { if (!account.deleted) { @@ -53,7 +31,10 @@ function importAccounts(data, entityIdMap) { ); } -async function importCategories(data, entityIdMap) { +async function importCategories( + data: YNAB5.Budget, + entityIdMap: Map<string, string>, +) { // Hidden categories are put in its own group by YNAB, // so it's already handled. @@ -86,12 +67,13 @@ async function importCategories(data, entityIdMap) { for (let group of data.category_groups) { if (!group.deleted) { + let groupId; // Ignores internal category and credit cards if ( group.name !== 'Internal Master Category' && group.name !== 'Credit Card Payments' ) { - var groupId = await actual.createCategoryGroup({ + groupId = await actual.createCategoryGroup({ name: group.name, is_income: false, }); @@ -104,9 +86,6 @@ async function importCategories(data, entityIdMap) { for (let cat of cats.reverse()) { if (!cat.deleted) { - let newCategory = {}; - newCategory.name = cat.name; - // Handles special categories. Starting balance is a payee // in YNAB so it's handled in importTransactions switch (checkSpecialCat(cat)) { @@ -120,8 +99,10 @@ async function importCategories(data, entityIdMap) { case 'internal': // uncategorized is ignored too, handled by actual break; default: { - newCategory.group_id = groupId; - let id = await actual.createCategory(newCategory); + let id = await actual.createCategory({ + name: cat.name, + group_id: groupId, + }); entityIdMap.set(cat.id, id); break; } @@ -132,7 +113,7 @@ async function importCategories(data, entityIdMap) { } } -function importPayees(data, entityIdMap) { +function importPayees(data: YNAB5.Budget, entityIdMap: Map<string, string>) { return Promise.all( data.payees.map(async payee => { if (!payee.deleted) { @@ -145,7 +126,10 @@ function importPayees(data, entityIdMap) { ); } -async function importTransactions(data, entityIdMap) { +async function importTransactions( + data: YNAB5.Budget, + entityIdMap: Map<string, string>, +) { const payees = await actual.getPayees(); const categories = await actual.getCategories(); const incomeCatId = categories.find(cat => cat.name === 'Income').id; @@ -169,8 +153,8 @@ async function importTransactions(data, entityIdMap) { } await Promise.all( - Object.keys(transactionsGrouped).map(async accountId => { - let transactions = transactionsGrouped[accountId]; + [...transactionsGrouped.keys()].map(async accountId => { + let transactions = transactionsGrouped.get(accountId); let toImport = transactions .map(transaction => { @@ -178,18 +162,7 @@ async function importTransactions(data, entityIdMap) { return null; } - // Handle subtransactions - let subtransactions = subtransactionsGrouped[transaction.id]; - if (subtransactions) { - subtransactions = subtransactions.map(subtrans => { - return { - id: entityIdMap.get(subtrans.id), - amount: amountFromYnab(subtrans.amount), - category: entityIdMap.get(subtrans.category_id) || null, - notes: subtrans.memo, - }; - }); - } + let subtransactions = subtransactionsGrouped.get(transaction.id); // Add transaction let newTransaction = { @@ -203,7 +176,18 @@ async function importTransactions(data, entityIdMap) { imported_id: transaction.import_id || null, transfer_id: entityIdMap.get(transaction.transfer_transaction_id) || null, - subtransactions: subtransactions, + subtransactions: subtransactions + ? subtransactions.map(subtrans => { + return { + id: entityIdMap.get(subtrans.id), + amount: amountFromYnab(subtrans.amount), + category: entityIdMap.get(subtrans.category_id) || null, + notes: subtrans.memo, + }; + }) + : null, + payee: null, + imported_payee: null, }; // Handle transfer payee @@ -237,7 +221,10 @@ async function importTransactions(data, entityIdMap) { ); } -async function importBudgets(data, entityIdMap) { +async function importBudgets( + data: YNAB5.Budget, + entityIdMap: Map<string, string>, +) { // There should be info in the docs to deal with // no credit card category and how YNAB and Actual // handle differently the amount To be Budgeted @@ -257,7 +244,7 @@ async function importBudgets(data, entityIdMap) { await actual.batchBudgetUpdates(async () => { for (let budget of budgets) { - let month = monthFromDate(budget.month); + let month = monthUtils.monthFromDate(budget.month); await Promise.all( budget.categories.map(async catBudget => { @@ -281,8 +268,8 @@ async function importBudgets(data, entityIdMap) { // Utils -async function doImport(data) { - const entityIdMap = new Map(); +export async function doImport(data: YNAB5.Budget) { + const entityIdMap = new Map<string, string>(); console.log('Importing Accounts...'); await importAccounts(data, entityIdMap); @@ -302,10 +289,15 @@ async function doImport(data) { console.log('Setting up...'); } -export async function importYNAB5(data) { +export function parseFile(buffer: Buffer): YNAB5.Budget { + let data = JSON.parse(buffer.toString()); if (data.data) { data = data.data; } - return actual.runImport(data.budget.name, () => doImport(data.budget)); + return data; +} + +export function getBudgetName(_filepath: string, data: YNAB5.Budget) { + return data.budget_name; } diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index adcffb0811f40d6b0cf81ad471685cd5d2376f14..e070e1a2e8a7e5861d7edb6532b9a4ee025aae66 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -1,8 +1,6 @@ import './polyfills'; import * as injectAPI from '@actual-app/api/injected'; import * as CRDT from '@actual-app/crdt'; -import * as YNAB4 from '@actual-app/import-ynab4/importer'; -import * as YNAB5 from '@actual-app/import-ynab5/importer'; import { v4 as uuidv4 } from 'uuid'; import { createTestBudget } from '../mocks/budget'; @@ -44,6 +42,7 @@ import * as mappings from './db/mappings'; import * as encryption from './encryption'; import { APIError, TransactionError, PostError, RuleError } from './errors'; import filtersApp from './filters/app'; +import { handleBudgetImport } from './importers'; import app from './main-app'; import { mutator, runHandler } from './mutators'; import notesApp from './notes/app'; @@ -2054,80 +2053,7 @@ handlers['import-budget'] = async function ({ filepath, type }) { } let buffer = Buffer.from(await fs.readFile(filepath, 'binary')); - - switch (type) { - case 'ynab4': - try { - await YNAB4.importBuffer(filepath, buffer); - } catch (e) { - let msg = e.message.toLowerCase(); - if ( - msg.includes('not a ynab4') || - msg.includes('could not find file') - ) { - return { error: 'not-ynab4' }; - } - } - break; - case 'ynab5': - let data; - try { - data = JSON.parse(buffer.toString()); - } catch (e) { - return { error: 'parse-error' }; - } - - try { - await YNAB5.importYNAB5(data); - } catch (e) { - return { error: 'not-ynab5' }; - } - break; - case 'actual': - // We should pull out import/export into its own app so this - // can be abstracted out better. Importing Actual files is a - // special case because we can directly write down the files, - // but because it doesn't go through the API layer we need to - // duplicate some of the workflow - await handlers['close-budget'](); - - let id; - try { - ({ id } = await cloudStorage.importBuffer( - { cloudFileId: null, groupId: null }, - buffer, - )); - } catch (e) { - if (e.type === 'FileDownloadError') { - return { error: e.reason }; - } - throw e; - } - - // We never want to load cached data from imported files, so - // delete the cache - let sqliteDb = await sqlite.openDatabase( - fs.join(fs.getBudgetDir(id), 'db.sqlite'), - ); - sqlite.execQuery( - sqliteDb, - ` - DELETE FROM kvcache; - DELETE FROM kvcache_key; - `, - ); - sqlite.closeDatabase(sqliteDb); - - // Load the budget, force everything to be computed, and try - // to upload it as a cloud file - await handlers['load-budget']({ id }); - await handlers['get-budget-bounds'](); - await sheet.waitOnSpreadsheet(); - await cloudStorage.upload().catch(err => {}); - - break; - default: - } + await handleBudgetImport(type, filepath, buffer); } catch (err) { err.message = 'Error importing budget: ' + err.message; captureException(err); diff --git a/packages/loot-core/src/server/sheet.ts b/packages/loot-core/src/server/sheet.ts index 4c67f935e7538e16451f34a4a8db87085cd51686..180cbaf13d88de608eafd25de2a330fca265d64c 100644 --- a/packages/loot-core/src/server/sheet.ts +++ b/packages/loot-core/src/server/sheet.ts @@ -35,8 +35,8 @@ async function updateSpreadsheetCache(rawDb, names: string[]) { } function setCacheStatus( - mainDb: unknown, - cacheDb: unknown, + mainDb: Database, + cacheDb: Database, { clean }: { clean: boolean }, ) { if (clean) { diff --git a/packages/loot-core/src/shared/util.ts b/packages/loot-core/src/shared/util.ts index 22d5d60e245005d9c095f6ba69dd40206d918f4a..847922736a7be1938ddc2344f4b0409c9e1adc19 100644 --- a/packages/loot-core/src/shared/util.ts +++ b/packages/loot-core/src/shared/util.ts @@ -78,13 +78,13 @@ export function partitionByField(data, field) { return res; } -export function groupBy(data, field, mapper?: (v: unknown) => unknown) { - let res = new Map(); +export function groupBy<T, K extends keyof T>(data: T[], field: K) { + let res = new Map<T[K], T[]>(); for (let i = 0; i < data.length; i++) { let item = data[i]; let key = item[field]; let existing = res.get(key) || []; - res.set(key, existing.concat([mapper ? mapper(item) : data[i]])); + res.set(key, existing.concat([item])); } return res; } @@ -343,3 +343,14 @@ export function looselyParseAmount(amount) { return safeNumber(parseFloat(left + '.' + right)); } + +export function sortByKey<T>(arr: T[], key: keyof T): T[] { + return [...arr].sort((item1, item2) => { + if (item1[key] < item2[key]) { + return -1; + } else if (item1[key] > item2[key]) { + return 1; + } + return 0; + }); +} diff --git a/upcoming-release-notes/1208.md b/upcoming-release-notes/1208.md new file mode 100644 index 0000000000000000000000000000000000000000..30065b31cd7f9a37850d8bf0407221510d821571 --- /dev/null +++ b/upcoming-release-notes/1208.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [j-f1] +--- + +Move YNAB4/5 import code into loot-core diff --git a/yarn.lock b/yarn.lock index ac92d9981a1e7a6e07fa7bb6ebf09f6b8eb2c38b..777b6a9c428698f288eb6188d752393fb3b6090b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -40,36 +40,6 @@ __metadata: languageName: unknown linkType: soft -"@actual-app/import-ynab4@*, @actual-app/import-ynab4@workspace:packages/import-ynab4": - version: 0.0.0-use.local - resolution: "@actual-app/import-ynab4@workspace:packages/import-ynab4" - dependencies: - "@actual-app/api": "*" - "@types/uuid": ^9.0.2 - adm-zip: ^0.5.9 - date-fns: ^2.29.3 - slash: 3.0.0 - ts-node: ^10.9.1 - uuid: ^9.0.0 - bin: - import-ynab4: ./index.js - languageName: unknown - linkType: soft - -"@actual-app/import-ynab5@workspace:packages/import-ynab5": - version: 0.0.0-use.local - resolution: "@actual-app/import-ynab5@workspace:packages/import-ynab5" - dependencies: - "@actual-app/api": "*" - "@types/uuid": ^9.0.2 - date-fns: ^2.29.3 - ts-node: ^10.9.1 - uuid: ^9.0.0 - bin: - import-ynab5: ./index.js - languageName: unknown - linkType: soft - "@actual-app/web@workspace:packages/desktop-client": version: 0.0.0-use.local resolution: "@actual-app/web@workspace:packages/desktop-client" @@ -3987,6 +3957,15 @@ __metadata: languageName: node linkType: hard +"@types/adm-zip@npm:^0.5.0": + version: 0.5.0 + resolution: "@types/adm-zip@npm:0.5.0" + dependencies: + "@types/node": "*" + checksum: 11dd013584e47d431bdf7c115b73cd3162c1a1eca0fbb911f691c9734e904cfe4a01ac1d2d3cbf76d0a952e01fcce8a01fd6fb1c150675a29d740a7fb15325b2 + languageName: node + linkType: hard + "@types/aria-query@npm:^5.0.1": version: 5.0.1 resolution: "@types/aria-query@npm:5.0.1" @@ -12569,7 +12548,6 @@ __metadata: dependencies: "@actual-app/api": "*" "@actual-app/crdt": "*" - "@actual-app/import-ynab4": "*" "@babel/core": ~7.22.5 "@babel/preset-env": ^7.22.5 "@babel/preset-typescript": ^7.22.5 @@ -12579,6 +12557,7 @@ __metadata: "@rschedule/ical-tools": ^1.2.0 "@rschedule/json-tools": ^1.2.0 "@rschedule/standard-date-adapter": ^1.2.0 + "@types/adm-zip": ^0.5.0 "@types/better-sqlite3": ^7.6.4 "@types/jest": ^27.5.0 "@types/jlongster__sql.js": "npm:@types/sql.js@latest" @@ -12616,6 +12595,7 @@ __metadata: path-browserify: ^1.0.1 peggy: 3.0.2 process: ^0.11.10 + slash: 3.0.0 snapshot-diff: ^0.10.0 source-map: ^0.7.3 stream-browserify: ^3.0.0 @@ -17821,7 +17801,7 @@ __metadata: languageName: node linkType: hard -"ts-node@npm:^10.7.0, ts-node@npm:^10.9.1": +"ts-node@npm:^10.7.0": version: 10.9.1 resolution: "ts-node@npm:10.9.1" dependencies: