mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
feat(migration): add v4-to-v5 settings migration runner with backup
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
class MigrationRunner {
|
||||||
|
constructor({ dir, transform }) {
|
||||||
|
this.dir = dir;
|
||||||
|
this.transform = transform;
|
||||||
|
this.file = path.join(dir, 'settings.json');
|
||||||
|
this.backup = path.join(dir, 'settings.v4.bak.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
run() {
|
||||||
|
if (!fs.existsSync(this.file)) {
|
||||||
|
this._writeDefaults();
|
||||||
|
return 'fresh';
|
||||||
|
}
|
||||||
|
const raw = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
|
||||||
|
if (raw && raw['migration.version'] === 5) {
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const v5 = this.transform(raw);
|
||||||
|
fs.copyFileSync(this.file, this.backup);
|
||||||
|
fs.writeFileSync(this.file, JSON.stringify({ ...v5, 'migration.version': 5 }, null, 2));
|
||||||
|
return 'migrated';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[migration-runner] transform failed:', err.message);
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_writeDefaults() {
|
||||||
|
const v5 = this.transform({});
|
||||||
|
fs.writeFileSync(this.file, JSON.stringify({ ...v5, 'migration.version': 5 }, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { MigrationRunner };
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { settingsSchema } from '@/lib/validators';
|
||||||
|
|
||||||
|
export const v4SettingsSchema = z.object({
|
||||||
|
theme: z.enum(['light', 'dark', 'auto']).default('auto'),
|
||||||
|
customCss: z.string().optional().nullable(),
|
||||||
|
recentFiles: z.array(z.string()).default([]),
|
||||||
|
editorFontSize: z.number().min(10).max(28).default(14),
|
||||||
|
keyBindings: z.record(z.string(), z.string()).optional(),
|
||||||
|
snippets: z.array(z.unknown()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function migrateV4ToV5(v4: unknown): z.infer<typeof settingsSchema> {
|
||||||
|
const parsed = v4SettingsSchema.parse(v4);
|
||||||
|
const defaults = settingsSchema.parse({});
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
...parsed,
|
||||||
|
theme: parsed.theme === 'auto' ? 'system' : parsed.theme,
|
||||||
|
customCssPath: parsed.customCss ?? null,
|
||||||
|
recentFiles: parsed.recentFiles,
|
||||||
|
editorFontSize: parsed.editorFontSize,
|
||||||
|
userBindings: parsed.keyBindings ?? defaults.userBindings ?? {},
|
||||||
|
snippets: parsed.snippets,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ export const settingsSchema = z.object({
|
|||||||
lineNumbers: z.boolean().default(true),
|
lineNumbers: z.boolean().default(true),
|
||||||
wordWrap: z.boolean().default(true),
|
wordWrap: z.boolean().default(true),
|
||||||
minimap: z.boolean().default(true),
|
minimap: z.boolean().default(true),
|
||||||
theme: z.enum(['light', 'dark', 'auto']).default('auto'),
|
theme: z.enum(['light', 'dark', 'system']).default('system'),
|
||||||
accentColor: z.enum(['brand', 'blue', 'green', 'purple', 'orange']).default('brand'),
|
accentColor: z.enum(['brand', 'blue', 'green', 'purple', 'orange']).default('brand'),
|
||||||
fontFamily: z.enum(['system', 'jetbrains', 'fira']).default('system'),
|
fontFamily: z.enum(['system', 'jetbrains', 'fira']).default('system'),
|
||||||
pdfFormat: z.enum(['letter', 'a4', 'legal']).default('a4'),
|
pdfFormat: z.enum(['letter', 'a4', 'legal']).default('a4'),
|
||||||
@@ -21,6 +21,7 @@ export const settingsSchema = z.object({
|
|||||||
welcomeDismissed: z.boolean().default(false),
|
welcomeDismissed: z.boolean().default(false),
|
||||||
editorFontSize: z.number().min(10).max(28).default(14),
|
editorFontSize: z.number().min(10).max(28).default(14),
|
||||||
customCssPath: z.string().nullable().default(null),
|
customCssPath: z.string().nullable().default(null),
|
||||||
|
userBindings: z.record(z.string(), z.string()).default({}),
|
||||||
});
|
});
|
||||||
export type Settings = z.infer<typeof settingsSchema>;
|
export type Settings = z.infer<typeof settingsSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { migrateV4ToV5, v4SettingsSchema } from '@/lib/migrations/v4-to-v5';
|
||||||
|
|
||||||
|
describe('migrateV4ToV5', () => {
|
||||||
|
test('maps theme auto to system', () => {
|
||||||
|
const out = migrateV4ToV5({ theme: 'auto', editorFontSize: 14 });
|
||||||
|
expect(out.theme).toBe('system');
|
||||||
|
expect(out.editorFontSize).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops unknown keys, applies defaults', () => {
|
||||||
|
const out = migrateV4ToV5({ theme: 'light', weirdKey: 1 });
|
||||||
|
expect(out.theme).toBe('light');
|
||||||
|
expect(out.weirdKey).toBeUndefined();
|
||||||
|
expect(out.tabSize).toBe(4); // default
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on invalid v4 input', () => {
|
||||||
|
expect(() => migrateV4ToV5({ theme: 42 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is idempotent — running twice yields the same v5 output', () => {
|
||||||
|
const v4 = { theme: 'dark', editorFontSize: 16, recentFiles: ['/a.md'] };
|
||||||
|
const a = migrateV4ToV5(v4);
|
||||||
|
const b = migrateV4ToV5(v4);
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes the v4 schema for use by main', () => {
|
||||||
|
expect(v4SettingsSchema).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const { MigrationRunner } = require('../../../../src/main/updater/migration-runner');
|
||||||
|
|
||||||
|
function tmpDir() {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'mig-test-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MigrationRunner', () => {
|
||||||
|
test('no-op when migration.version is already 5', () => {
|
||||||
|
const dir = tmpDir();
|
||||||
|
fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ 'migration.version': 5 }));
|
||||||
|
const r = new MigrationRunner({ dir, transform: (v) => v });
|
||||||
|
expect(r.run()).toBe('skipped');
|
||||||
|
fs.rmSync(dir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runs migration on missing v5 marker, writes v5 file, backs up v4', () => {
|
||||||
|
const dir = tmpDir();
|
||||||
|
fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ theme: 'light' }));
|
||||||
|
const r = new MigrationRunner({ dir, transform: (v) => ({ ...v, theme: 'system', 'migration.version': 5 }) });
|
||||||
|
expect(r.run()).toBe('migrated');
|
||||||
|
expect(JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf-8'))['migration.version']).toBe(5);
|
||||||
|
expect(fs.existsSync(path.join(dir, 'settings.v4.bak.json'))).toBe(true);
|
||||||
|
fs.rmSync(dir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns fresh on missing file', () => {
|
||||||
|
const dir = tmpDir();
|
||||||
|
const r = new MigrationRunner({ dir, transform: () => ({ 'migration.version': 5 }) });
|
||||||
|
expect(r.run()).toBe('fresh');
|
||||||
|
expect(fs.existsSync(path.join(dir, 'settings.json'))).toBe(true);
|
||||||
|
fs.rmSync(dir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns failed and preserves v4 on transform throw', () => {
|
||||||
|
const dir = tmpDir();
|
||||||
|
fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ theme: 'light' }));
|
||||||
|
const r = new MigrationRunner({ dir, transform: () => { throw new Error('boom'); } });
|
||||||
|
expect(r.run()).toBe('failed');
|
||||||
|
expect(fs.readFileSync(path.join(dir, 'settings.json'), 'utf-8')).toContain('light');
|
||||||
|
fs.rmSync(dir, { recursive: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user