diff --git a/packages/loot-core/src/server/budgetfiles/backups.ts b/packages/loot-core/src/server/budgetfiles/backups.ts index 241f5ea035..441f146ead 100644 --- a/packages/loot-core/src/server/budgetfiles/backups.ts +++ b/packages/loot-core/src/server/budgetfiles/backups.ts @@ -1,6 +1,7 @@ +import type { Database } from '@jlongster/sql.js'; // @ts-strict-ignore +import AdmZip from 'adm-zip'; import * as dateFns from 'date-fns'; -import { v4 as uuidv4 } from 'uuid'; import * as connection from '../../platform/server/connection'; import * as fs from '../../platform/server/fs'; @@ -26,7 +27,7 @@ async function getBackups(id: string): Promise { let paths = []; if (await fs.exists(backupDir)) { paths = await fs.listDir(backupDir); - paths = paths.filter(file => file.match(/\.sqlite$/)); + paths = paths.filter(file => file.match(/\.zip$/)); } const backups = await Promise.all( @@ -112,20 +113,42 @@ export async function makeBackup(id: string) { await fs.removeFile(fs.join(fs.getBudgetDir(id), LATEST_BACKUP_FILENAME)); } - const backupId = `${uuidv4()}.sqlite`; + const backupId = `${dateFns.format(new Date(), 'yyyy-MM-dd_HH-mm-ss')}.zip`; const backupPath = fs.join(budgetDir, 'backups', backupId); if (!(await fs.exists(fs.join(budgetDir, 'backups')))) { await fs.mkdir(fs.join(budgetDir, 'backups')); } - await fs.copyFile(fs.join(budgetDir, 'db.sqlite'), backupPath); + // Copy db to a temp path so we can clean CRDT messages before zipping + const tempDbPath = fs.join( + budgetDir, + 'backups', + `db.${Date.now()}.sqlite.tmp`, + ); - // Remove all the messages from the backup - const db = await sqlite.openDatabase(backupPath); - sqlite.runQuery(db, 'DELETE FROM messages_crdt'); - sqlite.runQuery(db, 'DELETE FROM messages_clock'); - sqlite.closeDatabase(db); + await fs.copyFile(fs.join(budgetDir, 'db.sqlite'), tempDbPath); + + let db: Database | undefined; + + try { + // Remove all the messages from the backup + db = await sqlite.openDatabase(tempDbPath); + sqlite.runQuery(db, 'DELETE FROM messages_crdt'); + sqlite.runQuery(db, 'DELETE FROM messages_clock'); + // Zip up the cleaned db and metadata into a single backup file + const zip = new AdmZip(); + zip.addLocalFile(tempDbPath, '', 'db.sqlite'); + zip.addLocalFile(fs.join(budgetDir, 'metadata.json')); + zip.writeZip(backupPath); + } finally { + if (db) { + sqlite.closeDatabase(db); + } + if (await fs.exists(tempDbPath)) { + await fs.removeFile(tempDbPath); + } + } const toRemove = await updateBackups(await getBackups(id)); for (const id of toRemove) { @@ -201,10 +224,9 @@ export async function loadBackup(id: string, backupId: string) { prefs.unloadPrefs(); - await fs.copyFile( - fs.join(budgetDir, 'backups', backupId), - fs.join(budgetDir, 'db.sqlite'), - ); + const zip = new AdmZip(fs.join(budgetDir, 'backups', backupId)); + zip.extractEntryTo('db.sqlite', budgetDir, false, true); + zip.extractEntryTo('metadata.json', budgetDir, false, true); } } diff --git a/upcoming-release-notes/7069.md b/upcoming-release-notes/7069.md new file mode 100644 index 0000000000..3fdb25fbc2 --- /dev/null +++ b/upcoming-release-notes/7069.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [MikesGlitch] +--- + +Converted Electron backups to zip with metadata.json for easy importing