mirror of
https://github.com/actualbudget/actual.git
synced 2026-10-12 02:10:19 +00:00
[AI] Add api.importBudget() and api.exportBudget() (#8414)
* [AI] Add api.importBudget() and api.exportBudget()
Add first-class import/export functions to @actual-app/api so consumers
no longer have to drive the low-level worker RPC and the engine
filesystem by hand.
- api.importBudget(pathOrBytes, { type?, filename? }) imports an Actual
.zip export (or a YNAB4/YNAB5 export), loads it, and resolves with the
imported budget id. Raw bytes are passed straight to the import-budget
handler instead of the upload-file-web temp-file dance, so it works in
both the Node and browser builds and leaves no temp files behind.
- api.exportBudget() exports the currently-loaded budget and resolves
with the zip contents as a Uint8Array.
- Both reject with an Error on failure instead of resolving { error }.
- The import-budget handler now optionally accepts raw file contents
(buffer + filename) alongside the existing filepath input, and returns
the id of the loaded budget on success; existing callers are
unaffected.
- Add round-trip, file-path, and failure integration tests; update the
API reference docs.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014wq2oMcHb99mLb9qpaR8SN
* [AI] Move ImportableBudgetType re-export to models.ts
Type re-exports for API consumers live in models.ts (like Preferences),
not methods.ts — follow the existing pattern.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014wq2oMcHb99mLb9qpaR8SN
* [AI] Address review: relax error assertions, document type union
- Assert importBudget failures on the stable "Error importing budget:"
prefix instead of backend-specific error codes
- Document options.type as the 'actual' | 'ynab4' | 'ynab5' union in the
API reference instead of a generic string
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014wq2oMcHb99mLb9qpaR8SN
---------
Co-authored-by: Claude <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c64c0aa049
commit
168fe0d68b
@@ -75,6 +75,92 @@ describe('API setup and teardown', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('API budget import/export', () => {
|
||||
const importedBudgetIds = new Set<string>();
|
||||
|
||||
beforeEach(async () => {
|
||||
await api.loadBudget(budgetName);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Close any budget loaded by an import before removing its files
|
||||
await api.shutdown();
|
||||
|
||||
for (const id of importedBudgetIds) {
|
||||
await fs.rm(path.join(__dirname, '/mocks/budgets/', id), {
|
||||
force: true,
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
importedBudgetIds.clear();
|
||||
});
|
||||
|
||||
// apis: exportBudget, importBudget
|
||||
test('exportBudget returns bytes that importBudget loads back', async () => {
|
||||
const accountId = await api.createAccount({ name: 'round-trip' }, 0);
|
||||
await api.addTransactions(accountId, [
|
||||
{ date: '2023-10-01', amount: 1234, notes: 'round-trip transaction' },
|
||||
]);
|
||||
|
||||
const data = await api.exportBudget();
|
||||
expect(data).toBeInstanceOf(Uint8Array);
|
||||
expect(data.length).toBeGreaterThan(0);
|
||||
|
||||
const { id } = await api.importBudget(data);
|
||||
expect(id).toBeTruthy();
|
||||
importedBudgetIds.add(id);
|
||||
|
||||
// The imported budget is now the loaded one; verify the data
|
||||
// survived the round-trip
|
||||
const accounts = await api.getAccounts();
|
||||
expect(accounts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: accountId, name: 'round-trip' }),
|
||||
]),
|
||||
);
|
||||
|
||||
const transactions = await api.getTransactions(
|
||||
accountId,
|
||||
'2023-10-01',
|
||||
'2023-10-31',
|
||||
);
|
||||
expect(transactions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
amount: 1234,
|
||||
notes: 'round-trip transaction',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// apis: exportBudget, importBudget
|
||||
test('importBudget imports from a file path', async () => {
|
||||
const data = await api.exportBudget();
|
||||
const filepath = path.join(__dirname, '/mocks/budgets/', 'export.zip');
|
||||
await fs.writeFile(filepath, data);
|
||||
|
||||
try {
|
||||
const { id } = await api.importBudget(filepath);
|
||||
expect(id).toBeTruthy();
|
||||
importedBudgetIds.add(id);
|
||||
} finally {
|
||||
await fs.rm(filepath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// apis: importBudget
|
||||
test('importBudget rejects with an Error on failure', async () => {
|
||||
await expect(api.importBudget('/does/not/exist.zip')).rejects.toThrow(
|
||||
/^Error importing budget:/,
|
||||
);
|
||||
|
||||
await expect(api.importBudget(new Uint8Array([1, 2, 3]))).rejects.toThrow(
|
||||
/^Error importing budget:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API CRUD operations', () => {
|
||||
beforeEach(async () => {
|
||||
// load test budget
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
APIScheduleEntity,
|
||||
APITagEntity,
|
||||
} from '@actual-app/core/server/api-models';
|
||||
import type { ImportableBudgetType } from '@actual-app/core/server/importers/index';
|
||||
import type { Query } from '@actual-app/core/shared/query';
|
||||
import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers';
|
||||
import type {
|
||||
@@ -49,6 +50,61 @@ export async function getBudgets() {
|
||||
return send('api/get-budgets');
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a budget from an exported file — an Actual `.zip` export, or a
|
||||
* YNAB4/YNAB5 export. Accepts either a path to the file on the engine's
|
||||
* filesystem, or the raw file contents. Loads the imported budget and
|
||||
* returns its id.
|
||||
*/
|
||||
export async function importBudget(
|
||||
input: string | ArrayBuffer | Uint8Array,
|
||||
{
|
||||
type = 'actual',
|
||||
filename,
|
||||
}: { type?: ImportableBudgetType; filename?: string } = {},
|
||||
): Promise<{ id: string }> {
|
||||
const result =
|
||||
typeof input === 'string'
|
||||
? await send('import-budget', { filepath: input, type })
|
||||
: await send('import-budget', {
|
||||
buffer: toArrayBuffer(input),
|
||||
filename,
|
||||
type,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Error importing budget: ${result.error}`);
|
||||
}
|
||||
if (!result.id) {
|
||||
throw new Error('Error importing budget: no budget was loaded');
|
||||
}
|
||||
return { id: result.id };
|
||||
}
|
||||
|
||||
/** Export the currently-loaded budget as a zip buffer. */
|
||||
export async function exportBudget(): Promise<Uint8Array> {
|
||||
const result = await send('export-budget');
|
||||
|
||||
if ('error' in result) {
|
||||
throw new Error(`Error exporting budget: ${result.error}`);
|
||||
}
|
||||
if (!result.data) {
|
||||
throw new Error('Error exporting budget: no data was returned');
|
||||
}
|
||||
return new Uint8Array(result.data);
|
||||
}
|
||||
|
||||
function toArrayBuffer(data: ArrayBuffer | Uint8Array): ArrayBuffer {
|
||||
if (data instanceof Uint8Array) {
|
||||
// Copy into a fresh ArrayBuffer so that views into a larger (possibly
|
||||
// shared) buffer are not sent across the worker boundary as-is.
|
||||
const copy = new Uint8Array(data.byteLength);
|
||||
copy.set(data);
|
||||
return copy.buffer;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sync() {
|
||||
return send('api/sync');
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export type * from '@actual-app/core/server/api-models';
|
||||
export type { ImportableBudgetType } from '@actual-app/core/server/importers/index';
|
||||
export type { SyncedPrefs as Preferences } from '@actual-app/core/types/prefs';
|
||||
|
||||
@@ -103,6 +103,8 @@ import APIList from './APIList';
|
||||
"getBudgets",
|
||||
"loadBudget",
|
||||
"downloadBudget",
|
||||
"importBudget",
|
||||
"exportBudget",
|
||||
"batchBudgetUpdates",
|
||||
"runQuery",
|
||||
"getIDByName",
|
||||
@@ -820,6 +822,18 @@ Load a locally cached budget file.
|
||||
|
||||
Load a budget file. If the file exists locally, it will load from there. Otherwise, it will download the file from the server.
|
||||
|
||||
#### `importBudget`
|
||||
|
||||
<Method name="importBudget" args={[{ name: 'input', type: 'string | ArrayBuffer | Uint8Array' }, { name: 'options', type: "{ type?: 'actual' | 'ynab4' | 'ynab5', filename?: string }?" }]} returns="Promise<{ id: string }>" />
|
||||
|
||||
Import a budget from an exported file and load it. `input` is either a path to the file or the raw file contents. By default the file is treated as an Actual export (a `.zip` file containing `db.sqlite` and `metadata.json`); pass `type: 'ynab4'` or `type: 'ynab5'` to import a YNAB export instead. When passing raw contents, you can supply the original file name with `filename` — some import types use it to derive the budget name. Returns the id of the imported budget, which is now the loaded budget.
|
||||
|
||||
#### `exportBudget`
|
||||
|
||||
<Method name="exportBudget" args={[]} returns="Promise<Uint8Array>" />
|
||||
|
||||
Export the currently loaded budget as raw bytes in the zip format. You can save the bytes to a `.zip` file, or pass them back to `importBudget` to restore the budget later.
|
||||
|
||||
#### `batchBudgetUpdates`
|
||||
|
||||
<Method name="batchBudgetUpdates" args={[{ name: 'func', type: 'func' }]} returns="Promise<void>" />
|
||||
|
||||
@@ -467,19 +467,44 @@ async function createBudget({
|
||||
|
||||
async function importBudget({
|
||||
filepath,
|
||||
buffer,
|
||||
filename,
|
||||
type,
|
||||
}: {
|
||||
filepath: string;
|
||||
type: ImportableBudgetType;
|
||||
}): Promise<{ error?: string }> {
|
||||
/** Path to the file to import, on the engine's filesystem. */
|
||||
filepath?: string;
|
||||
/** Raw contents of the file to import; alternative to `filepath`. */
|
||||
buffer?: ArrayBuffer;
|
||||
/**
|
||||
* Original name of the imported file; only used together with `buffer`,
|
||||
* to derive the budget name for some import types.
|
||||
*/
|
||||
filename?: string;
|
||||
}): Promise<{ error?: string; id?: string }> {
|
||||
try {
|
||||
if (!(await fs.exists(filepath))) {
|
||||
throw new Error(`File not found at the provided path: ${filepath}`);
|
||||
let contents: Buffer;
|
||||
let name: string;
|
||||
if (filepath != null) {
|
||||
if (!(await fs.exists(filepath))) {
|
||||
throw new Error(`File not found at the provided path: ${filepath}`);
|
||||
}
|
||||
|
||||
contents = Buffer.from(await fs.readFile(filepath, 'binary'));
|
||||
name = filepath;
|
||||
} else if (buffer != null) {
|
||||
contents = Buffer.from(buffer);
|
||||
name = filename || 'budget-import';
|
||||
} else {
|
||||
throw new Error('Either `filepath` or `buffer` must be given');
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await fs.readFile(filepath, 'binary'));
|
||||
const results = await handleBudgetImport(type, filepath, buffer);
|
||||
return results || {};
|
||||
const results = await handleBudgetImport(type, name, contents);
|
||||
if (results && results.error) {
|
||||
return results;
|
||||
}
|
||||
// A successful import leaves the imported budget loaded
|
||||
return { id: prefs.getPrefs()?.id };
|
||||
} catch (err) {
|
||||
err.message = 'Error importing budget: ' + err.message;
|
||||
captureException(err);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Add importBudget and exportBudget functions to the API for importing and exporting budget files directly
|
||||
Reference in New Issue
Block a user