diff --git a/package.json b/package.json index 5a95e36c..20e7a9ec 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,8 @@ "react-dom": "^18.3.1", "svelte": "^4.2.19", "web-vitals": "^4.2.3", - "ynab": "^1.19.0" + "ynab": "^1.19.0", + "zod": "^3.24.2" }, "resolutions": { "underscore": "1.12.1", diff --git a/packages/main/src/backend/commonTypes.ts b/packages/main/src/backend/commonTypes.ts index 78def43e..439612b5 100644 --- a/packages/main/src/backend/commonTypes.ts +++ b/packages/main/src/backend/commonTypes.ts @@ -11,6 +11,7 @@ export type { Spreadsheet, } from './export/outputVendors/googleSheets/googleSheetsInternalAPI'; export interface Config { + version: number; outputVendors: { [OutputVendorName.GOOGLE_SHEETS]?: GoogleSheetsConfig; [OutputVendorName.YNAB]?: YnabConfig; diff --git a/packages/main/src/backend/configManager/configManager.ts b/packages/main/src/backend/configManager/configManager.ts index 4e618d5f..71dd5f98 100644 --- a/packages/main/src/backend/configManager/configManager.ts +++ b/packages/main/src/backend/configManager/configManager.ts @@ -2,6 +2,7 @@ import { configFilePath } from '@/app-globals'; import { type Config } from '@/backend/commonTypes'; import { decrypt, encrypt } from '@/backend/configManager/encryption/crypto'; import { existsSync, promises as fs } from 'fs'; +import { migrateConfig } from './configMigration/configMigrator'; import configExample from './defaultConfig'; import logger from '/@/logging/logger'; @@ -19,7 +20,7 @@ export async function getConfig(configPath: string = configFilePath): Promise any> = {}; + +export function migrateConfig(config: unknown): Config { + let currentConfig = config; + // original config does not have version key and must be handled separately + if (isOriginalConfig(config)) { + currentConfig = migrateOriginalToV1(config); + } + let currentVersion = getConfigVersion(currentConfig); + + while (migrations[currentVersion]) { + currentConfig = migrations[currentVersion](currentConfig); + currentVersion = getConfigVersion(currentConfig); + } + + return latestConfigSchema.parse(currentConfig) as Config; +} + +function getConfigVersion(config: unknown): keyof typeof migrations { + const versionSchema = z.object({ version: z.number().int().positive() }); + return versionSchema.parse(config).version; +} diff --git a/packages/main/src/backend/configManager/configMigration/versions/original.ts b/packages/main/src/backend/configManager/configMigration/versions/original.ts new file mode 100644 index 00000000..88e6991e --- /dev/null +++ b/packages/main/src/backend/configManager/configMigration/versions/original.ts @@ -0,0 +1,94 @@ +import { z } from 'zod'; + +export const outputVendorNameSchema = z.enum(['ynab', 'googleSheets', 'json', 'csv']); + +export const companyTypeSchema = z.enum([ + 'hapoalim', + 'hapoalimBeOnline', + 'beinleumi', + 'union', + 'amex', + 'isracard', + 'visaCal', + 'max', + 'leumiCard', + 'otsarHahayal', + 'discount', + 'mercantile', + 'mizrahi', + 'leumi', + 'massad', + 'yahav', + 'behatsdaa', + 'beyahadBishvilha', + 'oneZero', + 'pagi', +]); + +export const googleSheetsConfigSchema = z.object({ + active: z.boolean(), + options: z.object({ + credentials: z.any(), + spreadsheetId: z.string(), + }), +}); + +export const ynabConfigSchema = z.object({ + active: z.boolean(), + options: z.object({ + accessToken: z.string(), + accountNumbersToYnabAccountIds: z.record(z.string(), z.string()), + budgetId: z.string(), + maxPayeeNameLength: z.number().optional(), + }), +}); + +export const jsonConfigSchema = z.object({ + active: z.boolean(), + options: z.object({ + filePath: z.string(), + }), +}); + +export const csvConfigSchema = z.object({ + active: z.boolean(), + options: z.object({ + filePath: z.string(), + }), +}); + +export const outputVendorsSchema = z.object({ + [outputVendorNameSchema.Values.googleSheets]: googleSheetsConfigSchema.optional(), + [outputVendorNameSchema.Values.ynab]: ynabConfigSchema.optional(), + [outputVendorNameSchema.Values.json]: jsonConfigSchema.optional(), + [outputVendorNameSchema.Values.csv]: csvConfigSchema.optional(), +}); + +export const accountToScrapeConfigSchema = z.object({ + id: z.string(), + key: companyTypeSchema, + name: z.string(), + loginFields: z.any(), + active: z.boolean().optional(), +}); + +export const scrapingSchema = z.object({ + numDaysBack: z.number(), + showBrowser: z.boolean(), + accountsToScrape: z.array(accountToScrapeConfigSchema), + chromiumPath: z.string().optional(), + maxConcurrency: z.number().optional(), + timeout: z.number(), + periodicScrapingIntervalHours: z.number().optional(), +}); + +export const originalConfigSchema = z.object({ + outputVendors: outputVendorsSchema, + scraping: scrapingSchema, + useReactUI: z.boolean().optional(), +}); + +export function isOriginalConfig(obj: unknown): obj is z.infer { + const parseResult = originalConfigSchema.strict().safeParse(obj); + return parseResult.success; +} diff --git a/packages/main/src/backend/configManager/configMigration/versions/v1.ts b/packages/main/src/backend/configManager/configMigration/versions/v1.ts new file mode 100644 index 00000000..6b3a635b --- /dev/null +++ b/packages/main/src/backend/configManager/configMigration/versions/v1.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { originalConfigSchema } from './original'; + +export const v1ConfigSchema = originalConfigSchema.extend({ version: z.literal(1) }); + +export function migrateOriginalToV1(v1Config: z.infer): z.infer { + return { + ...v1Config, + version: 1, + }; +} diff --git a/packages/main/src/backend/configManager/defaultConfig.ts b/packages/main/src/backend/configManager/defaultConfig.ts index df8f7034..11c2b416 100644 --- a/packages/main/src/backend/configManager/defaultConfig.ts +++ b/packages/main/src/backend/configManager/defaultConfig.ts @@ -1,6 +1,7 @@ import { type Config } from '../commonTypes'; const DEFAULT_CONFIG: Config = { + version: 1, scraping: { numDaysBack: 40, showBrowser: false, diff --git a/packages/preload/src/commonTypes.ts b/packages/preload/src/commonTypes.ts index bac6c7c2..d04265f2 100644 --- a/packages/preload/src/commonTypes.ts +++ b/packages/preload/src/commonTypes.ts @@ -15,6 +15,7 @@ export enum OutputVendorName { } export interface Config { + version: number; outputVendors: { [OutputVendorName.GOOGLE_SHEETS]?: GoogleSheetsConfig; [OutputVendorName.YNAB]?: YnabConfig; diff --git a/packages/renderer/src/store/Store.test.tsx b/packages/renderer/src/store/Store.test.tsx index 1ae4f397..9fddb1ce 100644 --- a/packages/renderer/src/store/Store.test.tsx +++ b/packages/renderer/src/store/Store.test.tsx @@ -99,6 +99,7 @@ describe('Store', () => { }); export const dummyConfig: Config = { + version: 1, scraping: { numDaysBack: 40, showBrowser: false, diff --git a/packages/renderer/src/types.tsx b/packages/renderer/src/types.tsx index f24fb543..1bd944b9 100644 --- a/packages/renderer/src/types.tsx +++ b/packages/renderer/src/types.tsx @@ -17,6 +17,7 @@ export enum OutputVendorName { } export interface Config { + version: number; outputVendors: { [OutputVendorName.GOOGLE_SHEETS]?: GoogleSheetsConfig; [OutputVendorName.YNAB]?: YnabConfig; diff --git a/yarn.lock b/yarn.lock index c7251dd0..eb2093fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6493,3 +6493,8 @@ zod@3.23.8: version "3.23.8" resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== + +zod@^3.24.2: + version "3.24.2" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.2.tgz#8efa74126287c675e92f46871cfc8d15c34372b3" + integrity sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==