refactor(migration): rename data migration files and update migration logic
This commit renames the data migration files for clarity, changing `dataMigrate.html` to `dataRefactorMigrate.html` and updating the corresponding service imports. It also enhances the migration logic by implementing a new `app_state` table structure and removing deprecated migration files, streamlining the overall migration process.
This commit is contained in:
@@ -0,0 +1,564 @@
|
||||
import dbService from '@data/db/DbService'
|
||||
import { appStateTable } from '@data/db/schemas/appState'
|
||||
import { loggerService } from '@logger'
|
||||
import { isDev } from '@main/constant'
|
||||
import BackupManager from '@main/services/BackupManager'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { app as electronApp } from 'electron'
|
||||
import { join } from 'path'
|
||||
|
||||
import { PreferencesMigrator } from './migrators/PreferencesMigrator'
|
||||
|
||||
const logger = loggerService.withContext('DataRefactorMigrateService')
|
||||
|
||||
const DATA_REFACTOR_MIGRATION_STATUS = 'data_refactor_migration_status'
|
||||
|
||||
// Data refactor migration status interface
|
||||
interface DataRefactorMigrationStatus {
|
||||
completed: boolean
|
||||
completedAt?: number
|
||||
version?: string
|
||||
}
|
||||
|
||||
interface MigrationProgress {
|
||||
stage: string
|
||||
progress: number
|
||||
total: number
|
||||
message: string
|
||||
}
|
||||
|
||||
interface MigrationResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
migratedCount: number
|
||||
}
|
||||
|
||||
class DataRefactorMigrateService {
|
||||
private static instance: DataRefactorMigrateService | null = null
|
||||
private migrateWindow: BrowserWindow | null = null
|
||||
private backupManager: BackupManager
|
||||
private backupCompletionResolver: ((value: boolean) => void) | null = null
|
||||
private backupTimeout: NodeJS.Timeout | null = null
|
||||
private db = dbService.getDb()
|
||||
private currentProgress: MigrationProgress = {
|
||||
stage: 'idle',
|
||||
progress: 0,
|
||||
total: 100,
|
||||
message: 'Ready to migrate'
|
||||
}
|
||||
private isMigrating: boolean = false
|
||||
|
||||
constructor() {
|
||||
this.backupManager = new BackupManager()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup manager instance for integration with existing backup system
|
||||
*/
|
||||
public getBackupManager(): BackupManager {
|
||||
return this.backupManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Register migration-specific IPC handlers
|
||||
* This creates an isolated IPC environment only for migration operations
|
||||
*/
|
||||
public registerMigrationIpcHandlers(): void {
|
||||
logger.info('Registering migration-specific IPC handlers')
|
||||
|
||||
// Only register the minimal IPC handlers needed for migration
|
||||
ipcMain.handle(IpcChannel.DataMigrate_CheckNeeded, async () => {
|
||||
try {
|
||||
return await this.isMigrated()
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: checkMigrationNeeded', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_StartMigration, async () => {
|
||||
try {
|
||||
return await this.runMigration()
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: runMigration', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_GetProgress, () => {
|
||||
try {
|
||||
return this.getCurrentProgress()
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: getCurrentProgress', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_Cancel, async () => {
|
||||
try {
|
||||
return await this.cancelMigration()
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: cancelMigration', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_BackupCompleted, () => {
|
||||
try {
|
||||
this.notifyBackupCompleted()
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: notifyBackupCompleted', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_ShowBackupDialog, () => {
|
||||
try {
|
||||
// Show the backup dialog/interface
|
||||
// This could integrate with existing backup UI or create a new backup interface
|
||||
logger.info('Backup dialog request received')
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: showBackupDialog', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Migration IPC handlers registered successfully')
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove migration-specific IPC handlers
|
||||
* Clean up when migration is complete or cancelled
|
||||
*/
|
||||
public unregisterMigrationIpcHandlers(): void {
|
||||
logger.info('Unregistering migration-specific IPC handlers')
|
||||
|
||||
try {
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_CheckNeeded)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_StartMigration)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_GetProgress)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_Cancel)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_BackupCompleted)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_ShowBackupDialog)
|
||||
|
||||
logger.info('Migration IPC handlers unregistered successfully')
|
||||
} catch (error) {
|
||||
logger.warn('Error unregistering migration IPC handlers', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): DataRefactorMigrateService {
|
||||
if (!DataRefactorMigrateService.instance) {
|
||||
DataRefactorMigrateService.instance = new DataRefactorMigrateService()
|
||||
}
|
||||
return DataRefactorMigrateService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if migration is needed
|
||||
*/
|
||||
async isMigrated(): Promise<boolean> {
|
||||
try {
|
||||
const isMigrated = await this.isMigrationCompleted()
|
||||
if (isMigrated) {
|
||||
logger.info('Data Refactor Migration already completed')
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('Failed to check migration status', error as Error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if migration is already completed
|
||||
*/
|
||||
private async isMigrationCompleted(): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(appStateTable)
|
||||
.where(eq(appStateTable.key, DATA_REFACTOR_MIGRATION_STATUS))
|
||||
.limit(1)
|
||||
|
||||
if (result.length === 0) return false
|
||||
|
||||
const status = result[0].value as DataRefactorMigrationStatus
|
||||
return status.completed === true
|
||||
} catch (error) {
|
||||
logger.warn('Failed to check migration state', error as Error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark migration as completed
|
||||
*/
|
||||
private async markMigrationCompleted(): Promise<void> {
|
||||
try {
|
||||
const migrationStatus: DataRefactorMigrationStatus = {
|
||||
completed: true,
|
||||
completedAt: Date.now(),
|
||||
version: electronApp.getVersion()
|
||||
}
|
||||
|
||||
await this.db
|
||||
.insert(appStateTable)
|
||||
.values({
|
||||
key: DATA_REFACTOR_MIGRATION_STATUS,
|
||||
value: migrationStatus, // drizzle handles JSON serialization automatically
|
||||
description: 'Data refactoring migration status from legacy format (ElectronStore + Redux persist) to SQLite',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: appStateTable.key,
|
||||
set: {
|
||||
value: migrationStatus,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Migration marked as completed in app_state table', {
|
||||
version: migrationStatus.version,
|
||||
completedAt: migrationStatus.completedAt
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to mark migration as completed', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and show migration window
|
||||
*/
|
||||
private createMigrateWindow(): BrowserWindow {
|
||||
if (this.migrateWindow && !this.migrateWindow.isDestroyed()) {
|
||||
this.migrateWindow.show()
|
||||
return this.migrateWindow
|
||||
}
|
||||
|
||||
// Register migration-specific IPC handlers before creating window
|
||||
this.registerMigrationIpcHandlers()
|
||||
|
||||
this.migrateWindow = new BrowserWindow({
|
||||
width: 600,
|
||||
height: 500,
|
||||
resizable: false,
|
||||
maximizable: false,
|
||||
minimizable: false,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
titleBarStyle: 'hidden',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
contextIsolation: true
|
||||
}
|
||||
})
|
||||
|
||||
// Load the migration window
|
||||
if (isDev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
this.migrateWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/dataRefactorMigrate.html')
|
||||
} else {
|
||||
this.migrateWindow.loadFile(join(__dirname, '../renderer/dataRefactorMigrate.html'))
|
||||
}
|
||||
|
||||
this.migrateWindow.once('ready-to-show', () => {
|
||||
this.migrateWindow?.show()
|
||||
if (!app.isPackaged) {
|
||||
this.migrateWindow?.webContents.openDevTools()
|
||||
}
|
||||
})
|
||||
|
||||
this.migrateWindow.on('closed', () => {
|
||||
this.migrateWindow = null
|
||||
// Clean up IPC handlers when window is closed
|
||||
this.unregisterMigrationIpcHandlers()
|
||||
})
|
||||
|
||||
logger.info('Migration window created')
|
||||
return this.migrateWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the complete migration process
|
||||
*/
|
||||
async runMigration(): Promise<void> {
|
||||
if (this.isMigrating) {
|
||||
logger.warn('Migration already in progress')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.isMigrating = true
|
||||
logger.info('Starting migration process')
|
||||
|
||||
// Create migration window
|
||||
const window = this.createMigrateWindow()
|
||||
|
||||
// Wait for window to be ready
|
||||
await new Promise<void>((resolve) => {
|
||||
if (window.webContents.isLoading()) {
|
||||
window.webContents.once('did-finish-load', () => resolve())
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
// Start the migration flow
|
||||
await this.executeMigrationFlow()
|
||||
} catch (error) {
|
||||
logger.error('Migration process failed', error as Error)
|
||||
throw error
|
||||
} finally {
|
||||
this.isMigrating = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the complete migration flow
|
||||
*/
|
||||
private async executeMigrationFlow(): Promise<void> {
|
||||
try {
|
||||
// Step 1: Enforce backup
|
||||
await this.updateProgress('backup', 0, 'Starting backup process...')
|
||||
const backupSuccess = await this.enforceBackup()
|
||||
|
||||
if (!backupSuccess) {
|
||||
throw new Error('Backup process failed or was cancelled by user')
|
||||
}
|
||||
|
||||
await this.updateProgress('backup', 100, 'Backup completed successfully')
|
||||
|
||||
// Step 2: Execute migration
|
||||
await this.updateProgress('migration', 0, 'Starting data migration...')
|
||||
const migrationResult = await this.executeMigration()
|
||||
|
||||
if (!migrationResult.success) {
|
||||
throw new Error(migrationResult.error || 'Migration failed')
|
||||
}
|
||||
|
||||
await this.updateProgress(
|
||||
'migration',
|
||||
100,
|
||||
`Migration completed: ${migrationResult.migratedCount} items migrated`
|
||||
)
|
||||
|
||||
// Step 3: Mark as completed
|
||||
await this.markMigrationCompleted()
|
||||
|
||||
await this.updateProgress('completed', 100, 'Migration completed! App will restart in 3 seconds...')
|
||||
|
||||
// Wait a moment to show success message, then restart the app
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
logger.info('Migration completed successfully, restarting application')
|
||||
this.restartApplication()
|
||||
resolve()
|
||||
}, 3000)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Migration flow failed', error as Error)
|
||||
await this.updateProgress(
|
||||
'error',
|
||||
0,
|
||||
`Migration failed: ${error instanceof Error ? error.message : String(error)}. Please restart the app to try again.`
|
||||
)
|
||||
|
||||
// Wait a moment to show error message, then close migration window
|
||||
// Do NOT restart on error - let user handle the situation
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
this.closeMigrateWindow()
|
||||
resolve()
|
||||
}, 8000) // Show error for longer (8 seconds) to give user time to read
|
||||
})
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce backup before migration
|
||||
*/
|
||||
private async enforceBackup(): Promise<boolean> {
|
||||
try {
|
||||
logger.info('Enforcing backup before migration')
|
||||
|
||||
await this.updateProgress('backup', 0, 'Backup is required before migration')
|
||||
|
||||
// Send backup requirement to renderer
|
||||
if (this.migrateWindow && !this.migrateWindow.isDestroyed()) {
|
||||
this.migrateWindow.webContents.send(IpcChannel.DataMigrate_RequireBackup)
|
||||
}
|
||||
|
||||
// Wait for user to complete backup
|
||||
const backupResult = await this.waitForBackupCompletion()
|
||||
|
||||
if (backupResult) {
|
||||
await this.updateProgress('backup', 100, 'Backup completed successfully')
|
||||
return true
|
||||
} else {
|
||||
await this.updateProgress('backup', 0, 'Backup is required to proceed with migration')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Backup enforcement failed', error as Error)
|
||||
await this.updateProgress('backup', 0, 'Backup process failed')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for user to complete backup
|
||||
*/
|
||||
private async waitForBackupCompletion(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
// Store resolver for later use
|
||||
this.backupCompletionResolver = resolve
|
||||
|
||||
// Set up timeout (5 minutes)
|
||||
this.backupTimeout = setTimeout(() => {
|
||||
logger.warn('Backup completion timeout')
|
||||
this.backupCompletionResolver = null
|
||||
this.backupTimeout = null
|
||||
resolve(false)
|
||||
}, 300000) // 5 minutes
|
||||
|
||||
// The actual completion will be triggered by notifyBackupCompleted() method
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify that backup has been completed (called from IPC handler)
|
||||
*/
|
||||
public notifyBackupCompleted(): void {
|
||||
if (this.backupCompletionResolver) {
|
||||
logger.info('Backup completed by user')
|
||||
|
||||
// Clear timeout if it exists
|
||||
if (this.backupTimeout) {
|
||||
clearTimeout(this.backupTimeout)
|
||||
this.backupTimeout = null
|
||||
}
|
||||
|
||||
this.backupCompletionResolver(true)
|
||||
this.backupCompletionResolver = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the actual migration
|
||||
*/
|
||||
private async executeMigration(): Promise<MigrationResult> {
|
||||
try {
|
||||
logger.info('Executing migration')
|
||||
|
||||
// Create preferences migrator
|
||||
const preferencesMigrator = new PreferencesMigrator()
|
||||
|
||||
// Execute preferences migration with progress updates
|
||||
const result = await preferencesMigrator.migrate((progress, message) => {
|
||||
this.updateProgress('migration', progress, message)
|
||||
})
|
||||
|
||||
logger.info('Migration execution completed', result)
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
migratedCount: result.migratedCount,
|
||||
error: result.errors.length > 0 ? result.errors.map((e) => e.error).join('; ') : undefined
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Migration execution failed', error as Error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
migratedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update migration progress and broadcast to window
|
||||
*/
|
||||
private async updateProgress(stage: string, progress: number, message: string): Promise<void> {
|
||||
this.currentProgress = {
|
||||
stage,
|
||||
progress,
|
||||
total: 100,
|
||||
message
|
||||
}
|
||||
|
||||
if (this.migrateWindow && !this.migrateWindow.isDestroyed()) {
|
||||
this.migrateWindow.webContents.send(IpcChannel.DataMigrateProgress, this.currentProgress)
|
||||
}
|
||||
|
||||
logger.debug('Progress updated', this.currentProgress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current migration progress
|
||||
*/
|
||||
getCurrentProgress(): MigrationProgress {
|
||||
return this.currentProgress
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel migration process
|
||||
*/
|
||||
async cancelMigration(): Promise<void> {
|
||||
if (!this.isMigrating) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Cancelling migration process')
|
||||
this.isMigrating = false
|
||||
await this.updateProgress('cancelled', 0, 'Migration cancelled by user')
|
||||
this.closeMigrateWindow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Close migration window
|
||||
*/
|
||||
private closeMigrateWindow(): void {
|
||||
if (this.migrateWindow && !this.migrateWindow.isDestroyed()) {
|
||||
this.migrateWindow.close()
|
||||
this.migrateWindow = null
|
||||
}
|
||||
|
||||
// Clean up migration-specific IPC handlers
|
||||
this.unregisterMigrationIpcHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the application after successful migration
|
||||
*/
|
||||
private restartApplication(): void {
|
||||
try {
|
||||
logger.info('Restarting application after migration completion')
|
||||
|
||||
// Clean up migration window and handlers before restart
|
||||
this.closeMigrateWindow()
|
||||
|
||||
// Restart the app using Electron's relaunch mechanism
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
} catch (error) {
|
||||
logger.error('Failed to restart application', error as Error)
|
||||
// Fallback: just close migration window and let user manually restart
|
||||
this.closeMigrateWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const dataRefactorMigrateService = DataRefactorMigrateService.getInstance()
|
||||
@@ -0,0 +1,345 @@
|
||||
import dbService from '@data/db/DbService'
|
||||
import { preferenceTable } from '@data/db/schemas/preference'
|
||||
import { loggerService } from '@logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
|
||||
import { configManager } from '../../../../services/ConfigManager'
|
||||
|
||||
const logger = loggerService.withContext('PreferencesMigrator')
|
||||
|
||||
export interface MigrationItem {
|
||||
originalKey: string
|
||||
targetKey: string
|
||||
type: string
|
||||
defaultValue: any
|
||||
source: 'electronStore' | 'redux'
|
||||
sourceCategory: string
|
||||
}
|
||||
|
||||
export interface MigrationResult {
|
||||
success: boolean
|
||||
migratedCount: number
|
||||
errors: Array<{
|
||||
key: string
|
||||
error: string
|
||||
}>
|
||||
}
|
||||
|
||||
export class PreferencesMigrator {
|
||||
private db = dbService.getDb()
|
||||
|
||||
/**
|
||||
* Execute preferences migration from all sources
|
||||
*/
|
||||
async migrate(onProgress?: (progress: number, message: string) => void): Promise<MigrationResult> {
|
||||
logger.info('Starting preferences migration')
|
||||
|
||||
const result: MigrationResult = {
|
||||
success: true,
|
||||
migratedCount: 0,
|
||||
errors: []
|
||||
}
|
||||
|
||||
try {
|
||||
// Get migration items from classification.json
|
||||
const migrationItems = await this.loadMigrationItems()
|
||||
const totalItems = migrationItems.length
|
||||
|
||||
logger.info(`Found ${totalItems} items to migrate`)
|
||||
|
||||
for (let i = 0; i < migrationItems.length; i++) {
|
||||
const item = migrationItems[i]
|
||||
|
||||
try {
|
||||
await this.migrateItem(item)
|
||||
result.migratedCount++
|
||||
|
||||
const progress = Math.floor(((i + 1) / totalItems) * 100)
|
||||
onProgress?.(progress, `Migrated: ${item.targetKey}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to migrate item', { item, error })
|
||||
result.errors.push({
|
||||
key: item.originalKey,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
result.success = false
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Preferences migration completed', {
|
||||
migratedCount: result.migratedCount,
|
||||
errorCount: result.errors.length
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Preferences migration failed', error as Error)
|
||||
result.success = false
|
||||
result.errors.push({
|
||||
key: 'global',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Load migration items from the generated preferences.ts mappings
|
||||
* For now, we'll use a simplified set based on the current generated migration code
|
||||
*/
|
||||
private async loadMigrationItems(): Promise<MigrationItem[]> {
|
||||
// This is a simplified implementation. In the full version, this would read from
|
||||
// the classification.json and apply the same deduplication logic as the generators
|
||||
|
||||
const items: MigrationItem[] = [
|
||||
// ElectronStore items (from generated migration code)
|
||||
{
|
||||
originalKey: 'Language',
|
||||
targetKey: 'app.language',
|
||||
sourceCategory: 'Language',
|
||||
type: 'unknown',
|
||||
defaultValue: null,
|
||||
source: 'electronStore'
|
||||
},
|
||||
{
|
||||
originalKey: 'SelectionAssistantFollowToolbar',
|
||||
targetKey: 'feature.selection.follow_toolbar',
|
||||
sourceCategory: 'SelectionAssistantFollowToolbar',
|
||||
type: 'unknown',
|
||||
defaultValue: null,
|
||||
source: 'electronStore'
|
||||
},
|
||||
{
|
||||
originalKey: 'SelectionAssistantRemeberWinSize',
|
||||
targetKey: 'feature.selection.remember_win_size',
|
||||
sourceCategory: 'SelectionAssistantRemeberWinSize',
|
||||
type: 'unknown',
|
||||
defaultValue: null,
|
||||
source: 'electronStore'
|
||||
},
|
||||
{
|
||||
originalKey: 'ZoomFactor',
|
||||
targetKey: 'app.zoom_factor',
|
||||
sourceCategory: 'ZoomFactor',
|
||||
type: 'unknown',
|
||||
defaultValue: null,
|
||||
source: 'electronStore'
|
||||
}
|
||||
]
|
||||
|
||||
// Add some sample Redux items (in full implementation, these would be loaded from classification.json)
|
||||
const reduxItems: MigrationItem[] = [
|
||||
{
|
||||
originalKey: 'theme',
|
||||
targetKey: 'app.theme.mode',
|
||||
sourceCategory: 'settings',
|
||||
type: 'string',
|
||||
defaultValue: 'ThemeMode.system',
|
||||
source: 'redux'
|
||||
},
|
||||
{
|
||||
originalKey: 'language',
|
||||
targetKey: 'app.language',
|
||||
sourceCategory: 'settings',
|
||||
type: 'string',
|
||||
defaultValue: 'en',
|
||||
source: 'redux'
|
||||
}
|
||||
]
|
||||
|
||||
items.push(...reduxItems)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single preference item
|
||||
*/
|
||||
private async migrateItem(item: MigrationItem): Promise<void> {
|
||||
logger.debug('Migrating preference item', { item })
|
||||
|
||||
let originalValue: any
|
||||
|
||||
// Read value from the appropriate source
|
||||
if (item.source === 'electronStore') {
|
||||
originalValue = await this.readFromElectronStore(item.originalKey)
|
||||
} else if (item.source === 'redux') {
|
||||
originalValue = await this.readFromReduxPersist(item.sourceCategory, item.originalKey)
|
||||
} else {
|
||||
throw new Error(`Unknown source: ${item.source}`)
|
||||
}
|
||||
|
||||
// Use default value if original value is not found
|
||||
let valueToMigrate = originalValue
|
||||
if (originalValue === undefined || originalValue === null) {
|
||||
valueToMigrate = item.defaultValue
|
||||
}
|
||||
|
||||
// Convert value to appropriate type
|
||||
const convertedValue = this.convertValue(valueToMigrate, item.type)
|
||||
|
||||
// Write to preferences table using Drizzle
|
||||
await this.writeToPreferences(item.targetKey, convertedValue)
|
||||
|
||||
logger.debug('Successfully migrated preference item', {
|
||||
targetKey: item.targetKey,
|
||||
originalValue,
|
||||
convertedValue
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read value from ElectronStore (via ConfigManager)
|
||||
*/
|
||||
private async readFromElectronStore(key: string): Promise<any> {
|
||||
try {
|
||||
return configManager.get(key)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read from ElectronStore', { key, error })
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read value from Redux persist data
|
||||
*/
|
||||
private async readFromReduxPersist(category: string, key: string): Promise<any> {
|
||||
try {
|
||||
// This is a simplified implementation
|
||||
// In the full version, we would need to properly parse the leveldb files
|
||||
// For now, we'll return undefined to use default values
|
||||
|
||||
logger.debug('Redux persist read not fully implemented', { category, key })
|
||||
return undefined
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read from Redux persist', { category, key, error })
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert value to the specified type
|
||||
*/
|
||||
private convertValue(value: any, targetType: string): any {
|
||||
if (value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
switch (targetType) {
|
||||
case 'boolean':
|
||||
return this.toBoolean(value)
|
||||
case 'string':
|
||||
return this.toString(value)
|
||||
case 'number':
|
||||
return this.toNumber(value)
|
||||
case 'array':
|
||||
case 'unknown[]':
|
||||
return this.toArray(value)
|
||||
case 'object':
|
||||
case 'Record<string, unknown>':
|
||||
return this.toObject(value)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Type conversion failed, using original value', { value, targetType, error })
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private toBoolean(value: any): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'string') {
|
||||
const lower = value.toLowerCase()
|
||||
return lower === 'true' || lower === '1' || lower === 'yes'
|
||||
}
|
||||
if (typeof value === 'number') return value !== 0
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
private toString(value: any): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
private toNumber(value: any): number {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseFloat(value)
|
||||
return isNaN(parsed) ? 0 : parsed
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? 1 : 0
|
||||
return 0
|
||||
}
|
||||
|
||||
private toArray(value: any): any[] {
|
||||
if (Array.isArray(value)) return value
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed : [value]
|
||||
} catch {
|
||||
return [value]
|
||||
}
|
||||
}
|
||||
return [value]
|
||||
}
|
||||
|
||||
private toObject(value: any): Record<string, any> {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : { value }
|
||||
} catch {
|
||||
return { value }
|
||||
}
|
||||
}
|
||||
return { value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write value to preferences table using direct Drizzle operations
|
||||
*/
|
||||
private async writeToPreferences(targetKey: string, value: any): Promise<void> {
|
||||
const scope = 'default'
|
||||
|
||||
try {
|
||||
// Check if preference already exists
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(preferenceTable)
|
||||
.where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, targetKey)))
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing preference
|
||||
await this.db
|
||||
.update(preferenceTable)
|
||||
.set({
|
||||
value: value, // drizzle handles JSON serialization automatically
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
.where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, targetKey)))
|
||||
} else {
|
||||
// Insert new preference
|
||||
await this.db.insert(preferenceTable).values({
|
||||
scope,
|
||||
key: targetKey,
|
||||
value: value, // drizzle handles JSON serialization automatically
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
logger.debug('Successfully wrote to preferences table', { targetKey, value })
|
||||
} catch (error) {
|
||||
logger.error('Failed to write to preferences table', { targetKey, value, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Migration helper utilities
|
||||
* Generated at: 2025-08-09T07:20:05.912Z
|
||||
*/
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
const logger = loggerService.withContext('MigrationHelpers')
|
||||
|
||||
export interface BackupInfo {
|
||||
timestamp: string
|
||||
version: string
|
||||
dataSize: number
|
||||
backupPath: string
|
||||
}
|
||||
|
||||
export class MigrationHelpers {
|
||||
/**
|
||||
* 创建数据备份
|
||||
*/
|
||||
static async createBackup(): Promise<BackupInfo> {
|
||||
logger.info('开始创建数据备份')
|
||||
|
||||
// 实现备份逻辑
|
||||
const timestamp = new Date().toISOString()
|
||||
const backupInfo: BackupInfo = {
|
||||
timestamp,
|
||||
version: process.env.npm_package_version || 'unknown',
|
||||
dataSize: 0,
|
||||
backupPath: ''
|
||||
}
|
||||
|
||||
// TODO: 实现具体的备份逻辑
|
||||
|
||||
logger.info('数据备份完成', backupInfo)
|
||||
return backupInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证备份完整性
|
||||
*/
|
||||
static async validateBackup(backupInfo: BackupInfo): Promise<boolean> {
|
||||
logger.info('验证备份完整性', backupInfo)
|
||||
|
||||
// TODO: 实现备份验证逻辑
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复备份
|
||||
*/
|
||||
static async restoreBackup(backupInfo: BackupInfo): Promise<boolean> {
|
||||
logger.info('开始恢复备份', backupInfo)
|
||||
|
||||
// TODO: 实现备份恢复逻辑
|
||||
|
||||
logger.info('备份恢复完成')
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理临时文件
|
||||
*/
|
||||
static async cleanup(): Promise<void> {
|
||||
logger.info('清理迁移临时文件')
|
||||
|
||||
// TODO: 实现清理逻辑
|
||||
|
||||
logger.info('清理完成')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Type conversion utilities for migration
|
||||
* Generated at: 2025-08-09T07:20:05.912Z
|
||||
*/
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
const logger = loggerService.withContext('TypeConverter')
|
||||
|
||||
export class TypeConverter {
|
||||
/**
|
||||
* 转换值到指定类型
|
||||
*/
|
||||
convert(value: any, targetType: string): any {
|
||||
if (value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
switch (targetType) {
|
||||
case 'boolean':
|
||||
return this.toBoolean(value)
|
||||
|
||||
case 'string':
|
||||
return this.toString(value)
|
||||
|
||||
case 'number':
|
||||
return this.toNumber(value)
|
||||
|
||||
case 'array':
|
||||
case 'unknown[]':
|
||||
return this.toArray(value)
|
||||
|
||||
case 'object':
|
||||
case 'Record<string, unknown>':
|
||||
return this.toObject(value)
|
||||
|
||||
default:
|
||||
// 未知类型,保持原样
|
||||
logger.debug('未知类型,保持原值', { targetType, value })
|
||||
return value
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('类型转换失败', { value, targetType, error })
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private toBoolean(value: any): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const lower = value.toLowerCase()
|
||||
return lower === 'true' || lower === '1' || lower === 'yes'
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0
|
||||
}
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
private toString(value: any): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
private toNumber(value: any): number {
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseFloat(value)
|
||||
if (isNaN(parsed)) {
|
||||
logger.warn('字符串无法转换为数字', { value })
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 1 : 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private toArray(value: any): any[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed : [value]
|
||||
} catch {
|
||||
return [value]
|
||||
}
|
||||
}
|
||||
return [value]
|
||||
}
|
||||
|
||||
private toObject(value: any): Record<string, any> {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
||||
? parsed
|
||||
: { value }
|
||||
} catch {
|
||||
return { value }
|
||||
}
|
||||
}
|
||||
return { value }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user