mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 03:31:24 +08:00
feat(migration): implement data migration service and update database architecture
This commit introduces a new data migration service with various IPC channels for migration tasks, including checking if migration is needed, starting the migration, and tracking progress. Additionally, the database architecture section has been added to the documentation, detailing the use of SQLite with Drizzle ORM, migration standards, and JSON field handling. Legacy migration files for ElectronStore and Redux have been removed as they are now deprecated.
This commit is contained in:
parent
4e3f8a8f76
commit
973f26f9dd
16
CLAUDE.md
16
CLAUDE.md
@ -92,6 +92,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- **Multi-language Support**: i18n with dynamic loading
|
||||
- **Theme System**: Light/dark themes with custom CSS variables
|
||||
|
||||
### Database Architecture
|
||||
|
||||
- **Database**: SQLite (`cherrystudio.sqlite`) + libsql driver
|
||||
- **ORM**: Drizzle ORM with comprehensive migration system
|
||||
- **Schemas**: Located in `src/main/data/db/schemas/` directory
|
||||
|
||||
#### Database Standards
|
||||
|
||||
- **Table Naming**: Use singular form with snake_case (e.g., `topic`, `message`, `app_state`)
|
||||
- **Schema Exports**: Export using `xxxTable` pattern (e.g., `topicTable`, `appStateTable`)
|
||||
- **Field Definition**: Drizzle auto-infers field names, no need to add default field names
|
||||
- **JSON Fields**: For JSON support, add `{ mode: 'json' }`, refer to `preference.ts` table definition
|
||||
- **JSON Serialization**: For JSON fields, no need to manually serialize/deserialize when reading/writing to database, Drizzle handles this automatically
|
||||
- **Timestamps**: Use existing `crudTimestamps` utility
|
||||
- **Migrations**: Generate via `yarn run migrations:generate`
|
||||
|
||||
## Logging Standards
|
||||
|
||||
### Usage
|
||||
|
||||
@ -102,7 +102,8 @@ export default defineConfig({
|
||||
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'),
|
||||
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
|
||||
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html')
|
||||
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html'),
|
||||
dataMigrate: resolve(__dirname, 'src/renderer/dataMigrate.html')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -184,6 +184,15 @@ export enum IpcChannel {
|
||||
Backup_DeleteS3File = 'backup:deleteS3File',
|
||||
Backup_CheckS3Connection = 'backup:checkS3Connection',
|
||||
|
||||
// data migration
|
||||
DataMigrate_CheckNeeded = 'data-migrate:check-needed',
|
||||
DataMigrate_StartMigration = 'data-migrate:start-migration',
|
||||
DataMigrate_GetProgress = 'data-migrate:get-progress',
|
||||
DataMigrate_Cancel = 'data-migrate:cancel',
|
||||
DataMigrate_RequireBackup = 'data-migrate:require-backup',
|
||||
DataMigrate_BackupCompleted = 'data-migrate:backup-completed',
|
||||
DataMigrate_ShowBackupDialog = 'data-migrate:show-backup-dialog',
|
||||
|
||||
// zip
|
||||
Zip_Compress = 'zip:compress',
|
||||
Zip_Decompress = 'zip:decompress',
|
||||
@ -197,6 +206,7 @@ export enum IpcChannel {
|
||||
|
||||
// events
|
||||
BackupProgress = 'backup-progress',
|
||||
DataMigrateProgress = 'data-migrate-progress',
|
||||
ThemeUpdated = 'theme:updated',
|
||||
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
|
||||
RestoreProgress = 'restore-progress',
|
||||
|
||||
28
src/main/data/db/schemas/appState.ts
Normal file
28
src/main/data/db/schemas/appState.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { crudTimestamps } from './columnHelpers'
|
||||
|
||||
export const appStateTable = sqliteTable('app_state', {
|
||||
key: text().primaryKey(),
|
||||
value: text({ mode: 'json' }).notNull(), // JSON field, drizzle handles serialization automatically
|
||||
description: text(), // Optional description field
|
||||
...crudTimestamps
|
||||
})
|
||||
|
||||
export type AppStateTable = typeof appStateTable
|
||||
export type AppStateInsert = typeof appStateTable.$inferInsert
|
||||
export type AppStateSelect = typeof appStateTable.$inferSelect
|
||||
|
||||
// State key constants
|
||||
export const APP_STATE_KEYS = {
|
||||
DATA_REFACTOR_MIGRATION_STATUS: 'data_refactor_migration_status',
|
||||
// Future state keys can be added here
|
||||
// FIRST_RUN_COMPLETED: 'first_run_completed',
|
||||
// USER_ONBOARDING_COMPLETED: 'user_onboarding_completed',
|
||||
} as const
|
||||
|
||||
// Data refactor migration status interface
|
||||
export interface DataRefactorMigrationStatus {
|
||||
completed: boolean
|
||||
completedAt?: number
|
||||
version?: string
|
||||
}
|
||||
490
src/main/data/migrate/MigrateService.ts
Normal file
490
src/main/data/migrate/MigrateService.ts
Normal file
@ -0,0 +1,490 @@
|
||||
import dbService from '@data/db/DbService'
|
||||
import { APP_STATE_KEYS, appStateTable, DataRefactorMigrationStatus } from '@data/db/schemas/appState'
|
||||
import { loggerService } from '@logger'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { app as electronApp } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import { join } from 'path'
|
||||
|
||||
import icon from '../../../../build/icon.png?asset'
|
||||
import BackupManager from '../../services/BackupManager'
|
||||
import { PreferencesMigrator } from './PreferencesMigrator'
|
||||
|
||||
const logger = loggerService.withContext('MigrateService')
|
||||
|
||||
export interface MigrationProgress {
|
||||
stage: string
|
||||
progress: number
|
||||
total: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface MigrationResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
migratedCount: number
|
||||
}
|
||||
|
||||
export class MigrateService {
|
||||
private static instance: MigrateService | 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
|
||||
}
|
||||
|
||||
public static getInstance(): MigrateService {
|
||||
if (!MigrateService.instance) {
|
||||
MigrateService.instance = new MigrateService()
|
||||
}
|
||||
return MigrateService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if migration is needed
|
||||
*/
|
||||
async checkMigrationNeeded(): Promise<boolean> {
|
||||
try {
|
||||
logger.info('Checking if migration is needed')
|
||||
|
||||
// 1. Check migration completion status
|
||||
const isMigrated = await this.isMigrationCompleted()
|
||||
if (isMigrated) {
|
||||
logger.info('Migration already completed')
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. Check if there's old data that needs migration
|
||||
const hasOldData = await this.hasOldFormatData()
|
||||
|
||||
logger.info('Migration check result', {
|
||||
isMigrated,
|
||||
hasOldData
|
||||
})
|
||||
|
||||
return hasOldData
|
||||
} catch (error) {
|
||||
logger.error('Failed to check migration status', error as Error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if old format data exists
|
||||
*/
|
||||
private async hasOldFormatData(): Promise<boolean> {
|
||||
const hasReduxData = await this.checkReduxPersistData()
|
||||
const hasElectronStoreData = await this.checkElectronStoreData()
|
||||
|
||||
logger.debug('Old format data check', {
|
||||
hasReduxData,
|
||||
hasElectronStoreData
|
||||
})
|
||||
|
||||
return hasReduxData || hasElectronStoreData
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Redux persist data exists
|
||||
*/
|
||||
private async checkReduxPersistData(): Promise<boolean> {
|
||||
try {
|
||||
// In Electron, localStorage data is stored in userData/Local Storage/leveldb
|
||||
// We'll check for the existence of these files as a proxy for Redux persist data
|
||||
const userDataPath = app.getPath('userData')
|
||||
const localStoragePath = join(userDataPath, 'Local Storage', 'leveldb')
|
||||
|
||||
const exists = await fs.pathExists(localStoragePath)
|
||||
logger.debug('Redux persist data check', { localStoragePath, exists })
|
||||
|
||||
return exists
|
||||
} catch (error) {
|
||||
logger.warn('Failed to check Redux persist data', error as Error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ElectronStore data exists
|
||||
*/
|
||||
private async checkElectronStoreData(): Promise<boolean> {
|
||||
try {
|
||||
// ElectronStore typically stores data in config files
|
||||
const userDataPath = app.getPath('userData')
|
||||
const configPath = join(userDataPath, 'config.json')
|
||||
|
||||
const exists = await fs.pathExists(configPath)
|
||||
logger.debug('ElectronStore data check', { configPath, exists })
|
||||
|
||||
return exists
|
||||
} catch (error) {
|
||||
logger.warn('Failed to check ElectronStore data', 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, APP_STATE_KEYS.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: APP_STATE_KEYS.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
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
...(process.platform === 'linux' ? { icon } : {})
|
||||
})
|
||||
|
||||
// Load the migration window
|
||||
if (app.isPackaged) {
|
||||
this.migrateWindow.loadFile(join(__dirname, '../renderer/dataMigrate.html'))
|
||||
} else {
|
||||
this.migrateWindow.loadURL('http://localhost:5173/dataMigrate.html')
|
||||
}
|
||||
|
||||
this.migrateWindow.once('ready-to-show', () => {
|
||||
this.migrateWindow?.show()
|
||||
if (!app.isPackaged) {
|
||||
this.migrateWindow?.webContents.openDevTools()
|
||||
}
|
||||
})
|
||||
|
||||
this.migrateWindow.on('closed', () => {
|
||||
this.migrateWindow = null
|
||||
})
|
||||
|
||||
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 process completed successfully')
|
||||
|
||||
// Close migration window after a delay
|
||||
setTimeout(() => {
|
||||
this.closeMigrateWindow()
|
||||
}, 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)}`
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const migrateService = MigrateService.getInstance()
|
||||
345
src/main/data/migrate/PreferencesMigrator.ts
Normal file
345
src/main/data/migrate/PreferencesMigrator.ts
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,152 +0,0 @@
|
||||
/**
|
||||
* Auto-generated ElectronStore to Preferences migration
|
||||
* Generated at: 2025-08-09T07:20:05.910Z
|
||||
*
|
||||
* === AUTO-GENERATED CONTENT START ===
|
||||
*/
|
||||
|
||||
import dbService from '@data/db/DbService'
|
||||
import { loggerService } from '@logger'
|
||||
import { configManager } from '@main/services/ConfigManager'
|
||||
|
||||
import type { MigrationResult } from './index'
|
||||
import { TypeConverter } from './utils/typeConverters'
|
||||
|
||||
const logger = loggerService.withContext('ElectronStoreMigrator')
|
||||
|
||||
// 键映射表
|
||||
const KEY_MAPPINGS = [
|
||||
{
|
||||
originalKey: 'Language',
|
||||
targetKey: 'app.language',
|
||||
sourceCategory: 'Language',
|
||||
type: 'unknown',
|
||||
defaultValue: null
|
||||
},
|
||||
{
|
||||
originalKey: 'SelectionAssistantFollowToolbar',
|
||||
targetKey: 'feature.selection.follow_toolbar',
|
||||
sourceCategory: 'SelectionAssistantFollowToolbar',
|
||||
type: 'unknown',
|
||||
defaultValue: null
|
||||
},
|
||||
{
|
||||
originalKey: 'SelectionAssistantRemeberWinSize',
|
||||
targetKey: 'feature.selection.remember_win_size',
|
||||
sourceCategory: 'SelectionAssistantRemeberWinSize',
|
||||
type: 'unknown',
|
||||
defaultValue: null
|
||||
},
|
||||
{
|
||||
originalKey: 'ZoomFactor',
|
||||
targetKey: 'app.zoom_factor',
|
||||
sourceCategory: 'ZoomFactor',
|
||||
type: 'unknown',
|
||||
defaultValue: null
|
||||
}
|
||||
] as const
|
||||
|
||||
export class ElectronStoreMigrator {
|
||||
private typeConverter: TypeConverter
|
||||
|
||||
constructor() {
|
||||
this.typeConverter = new TypeConverter()
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行ElectronStore到preferences的迁移
|
||||
*/
|
||||
async migrate(): Promise<MigrationResult> {
|
||||
logger.info('开始ElectronStore迁移', { totalItems: KEY_MAPPINGS.length })
|
||||
|
||||
const result: MigrationResult = {
|
||||
success: true,
|
||||
migratedCount: 0,
|
||||
errors: [],
|
||||
source: 'electronStore'
|
||||
}
|
||||
|
||||
for (const mapping of KEY_MAPPINGS) {
|
||||
try {
|
||||
await this.migrateItem(mapping)
|
||||
result.migratedCount++
|
||||
} catch (error) {
|
||||
logger.error('迁移单项失败', { mapping, error })
|
||||
result.errors.push({
|
||||
key: mapping.originalKey,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
result.success = false
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('ElectronStore迁移完成', result)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移单个配置项
|
||||
*/
|
||||
private async migrateItem(mapping: (typeof KEY_MAPPINGS)[0]): Promise<void> {
|
||||
const { originalKey, targetKey, type, defaultValue } = mapping
|
||||
|
||||
// 从ElectronStore读取原始值
|
||||
const originalValue = configManager.get(originalKey)
|
||||
|
||||
if (originalValue === undefined || originalValue === null) {
|
||||
// 如果原始值不存在,使用默认值
|
||||
if (defaultValue !== null && defaultValue !== undefined) {
|
||||
const convertedValue = this.typeConverter.convert(defaultValue, type)
|
||||
await dbService.setPreference('default', targetKey, convertedValue)
|
||||
logger.debug('使用默认值迁移', { originalKey, targetKey, defaultValue: convertedValue })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 类型转换
|
||||
const convertedValue = this.typeConverter.convert(originalValue, type)
|
||||
|
||||
// 写入preferences表
|
||||
await dbService.setPreference('default', targetKey, convertedValue)
|
||||
|
||||
logger.debug('成功迁移配置项', {
|
||||
originalKey,
|
||||
targetKey,
|
||||
originalValue,
|
||||
convertedValue
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证迁移结果
|
||||
*/
|
||||
async validateMigration(): Promise<boolean> {
|
||||
logger.info('开始验证ElectronStore迁移结果')
|
||||
|
||||
for (const mapping of KEY_MAPPINGS) {
|
||||
const { targetKey } = mapping
|
||||
|
||||
try {
|
||||
const value = await dbService.getPreference('default', targetKey)
|
||||
if (value === null) {
|
||||
logger.error('验证失败:配置项不存在', { targetKey })
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('验证失败:读取配置项错误', { targetKey, error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('ElectronStore迁移验证成功')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// === AUTO-GENERATED CONTENT END ===
|
||||
|
||||
/**
|
||||
* 迁移统计:
|
||||
* - ElectronStore配置项: 4
|
||||
* - 包含的原始键: Language, SelectionAssistantFollowToolbar, SelectionAssistantRemeberWinSize, ZoomFactor
|
||||
*/
|
||||
@ -1,16 +1,18 @@
|
||||
/**
|
||||
* Auto-generated migration index
|
||||
* Generated at: 2025-08-09T07:20:05.909Z
|
||||
*
|
||||
*
|
||||
* This file is automatically generated from classification.json
|
||||
* To update this file, modify classification.json and run:
|
||||
* node .claude/data-classify/scripts/generate-migration.js
|
||||
*
|
||||
*
|
||||
* === AUTO-GENERATED CONTENT START ===
|
||||
*/
|
||||
|
||||
import { ElectronStoreMigrator } from './electronStoreToPreferences'
|
||||
import { ReduxMigrator } from './reduxToPreferences'
|
||||
// LEGACY MIGRATION SYSTEM - COMMENTED OUT
|
||||
// These files have been replaced by PreferencesMigrator.ts
|
||||
// import { ElectronStoreMigrator } from './electronStoreToPreferences'
|
||||
// import { ReduxMigrator } from './reduxToPreferences'
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
const logger = loggerService.withContext('MigrationManager')
|
||||
@ -34,12 +36,14 @@ export interface MigrationSummary {
|
||||
}
|
||||
|
||||
export class MigrationManager {
|
||||
private electronStoreMigrator: ElectronStoreMigrator
|
||||
private reduxMigrator: ReduxMigrator
|
||||
// LEGACY MIGRATION SYSTEM - COMMENTED OUT
|
||||
// private electronStoreMigrator: ElectronStoreMigrator
|
||||
// private reduxMigrator: ReduxMigrator
|
||||
|
||||
constructor() {
|
||||
this.electronStoreMigrator = new ElectronStoreMigrator()
|
||||
this.reduxMigrator = new ReduxMigrator()
|
||||
// this.electronStoreMigrator = new ElectronStoreMigrator()
|
||||
// this.reduxMigrator = new ReduxMigrator()
|
||||
logger.warn('MigrationManager is deprecated. Use PreferencesMigrator instead.')
|
||||
}
|
||||
|
||||
/**
|
||||
@ -47,34 +51,18 @@ export class MigrationManager {
|
||||
* @returns 迁移摘要
|
||||
*/
|
||||
async migrateAllPreferences(): Promise<MigrationSummary> {
|
||||
logger.info('开始完整preferences迁移')
|
||||
|
||||
try {
|
||||
// 并行执行两个迁移器
|
||||
const [electronStoreResult, reduxResult] = await Promise.all([
|
||||
this.electronStoreMigrator.migrate(),
|
||||
this.reduxMigrator.migrate()
|
||||
])
|
||||
logger.warn('MigrationManager.migrateAllPreferences is deprecated. Use PreferencesMigrator instead.')
|
||||
|
||||
const summary: MigrationSummary = {
|
||||
totalItems: 158,
|
||||
successCount: electronStoreResult.migratedCount + reduxResult.migratedCount,
|
||||
errorCount: electronStoreResult.errors.length + reduxResult.errors.length,
|
||||
electronStore: electronStoreResult,
|
||||
redux: reduxResult
|
||||
}
|
||||
|
||||
if (summary.errorCount > 0) {
|
||||
logger.warn('迁移完成但有错误', { summary })
|
||||
} else {
|
||||
logger.info('迁移完全成功', { summary })
|
||||
}
|
||||
|
||||
return summary
|
||||
} catch (error) {
|
||||
logger.error('迁移过程中发生致命错误', error)
|
||||
throw error
|
||||
// Return a placeholder summary since the actual migration is handled by PreferencesMigrator
|
||||
const summary: MigrationSummary = {
|
||||
totalItems: 0,
|
||||
successCount: 0,
|
||||
errorCount: 0,
|
||||
electronStore: { success: false, migratedCount: 0, errors: [], source: 'electronStore' },
|
||||
redux: { success: false, migratedCount: 0, errors: [], source: 'redux' }
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
@ -82,43 +70,9 @@ export class MigrationManager {
|
||||
* @param summary 迁移摘要
|
||||
* @returns 是否验证成功
|
||||
*/
|
||||
async validateMigration(summary: MigrationSummary): Promise<boolean> {
|
||||
logger.info('开始验证迁移结果')
|
||||
|
||||
// 基本验证:检查成功率
|
||||
const successRate = summary.successCount / summary.totalItems
|
||||
if (successRate < 0.95) { // 要求95%以上成功率
|
||||
logger.error('迁移成功率过低', { successRate, summary })
|
||||
return false
|
||||
}
|
||||
|
||||
// 验证关键配置项是否存在
|
||||
const criticalKeys = [
|
||||
'app.theme.mode',
|
||||
'app.language',
|
||||
'app.user.id',
|
||||
'feature.quick_assistant.enabled',
|
||||
'chat.message.font_size'
|
||||
]
|
||||
|
||||
try {
|
||||
const dbServiceModule = await import('@main/db/DbService')
|
||||
const dbService = dbServiceModule.default
|
||||
|
||||
for (const key of criticalKeys) {
|
||||
const result = await dbService.getPreference('default', key)
|
||||
if (result === null) {
|
||||
logger.error('关键配置项迁移失败', { key })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('迁移验证成功')
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('验证过程中发生错误', error)
|
||||
return false
|
||||
}
|
||||
async validateMigration(_summary: MigrationSummary): Promise<boolean> {
|
||||
logger.warn('MigrationManager.validateMigration is deprecated. Use PreferencesMigrator validation instead.')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,4 +83,4 @@ export class MigrationManager {
|
||||
* - 总迁移项: 158
|
||||
* - ElectronStore项: 4
|
||||
* - Redux项: 154
|
||||
*/
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,7 @@ import '@main/config'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import dbService from '@main/db/DbService'
|
||||
import dbService from '@data/db/DbService'
|
||||
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
||||
import { app } from 'electron'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
@ -27,6 +27,7 @@ import selectionService, { initSelectionService } from './services/SelectionServ
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { migrateService } from './data/migrate/MigrateService'
|
||||
import process from 'node:process'
|
||||
|
||||
const logger = loggerService.withContext('MainEntry')
|
||||
@ -118,6 +119,20 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.dock?.hide()
|
||||
}
|
||||
|
||||
// Check if data migration is needed
|
||||
try {
|
||||
const needsMigration = await migrateService.checkMigrationNeeded()
|
||||
if (needsMigration) {
|
||||
logger.info('Migration needed, starting migration process')
|
||||
await migrateService.runMigration()
|
||||
logger.info('Migration completed, proceeding with normal startup')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Migration process failed', error as Error)
|
||||
// Continue with normal startup even if migration fails
|
||||
// The user can retry migration later or use backup recovery
|
||||
}
|
||||
|
||||
const mainWindow = windowService.createMainWindow()
|
||||
new TrayService()
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import { migrateService } from './data/migrate/MigrateService'
|
||||
import appService from './services/AppService'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
@ -696,4 +697,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
(_, spanId: string, modelName: string, context: string, msg: any) =>
|
||||
addStreamMessage(spanId, modelName, context, msg)
|
||||
)
|
||||
|
||||
// Data migration handlers
|
||||
ipcMain.handle(IpcChannel.DataMigrate_CheckNeeded, () => migrateService.checkMigrationNeeded())
|
||||
ipcMain.handle(IpcChannel.DataMigrate_StartMigration, () => migrateService.runMigration())
|
||||
ipcMain.handle(IpcChannel.DataMigrate_GetProgress, () => migrateService.getCurrentProgress())
|
||||
ipcMain.handle(IpcChannel.DataMigrate_Cancel, () => migrateService.cancelMigration())
|
||||
ipcMain.handle(IpcChannel.DataMigrate_BackupCompleted, () => {
|
||||
migrateService.notifyBackupCompleted()
|
||||
return true
|
||||
})
|
||||
ipcMain.handle(IpcChannel.DataMigrate_ShowBackupDialog, () => {
|
||||
// Show the backup dialog/interface
|
||||
// This could integrate with existing backup UI or create a new backup interface
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
12
src/renderer/dataMigrate.html
Normal file
12
src/renderer/dataMigrate.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Cherry Studio - Data Migration</title>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline';" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</head>
|
||||
<body id="root">
|
||||
<script type="module" src="/src/windows/dataMigrate/entryPoint.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
276
src/renderer/src/windows/dataMigrate/MigrateApp.tsx
Normal file
276
src/renderer/src/windows/dataMigrate/MigrateApp.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import { CheckCircleOutlined, ExclamationCircleOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Alert, Button, Card, Progress, Space, Typography } from 'antd'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
`
|
||||
|
||||
const MigrationCard = styled(Card)`
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.ant-card-head {
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
`
|
||||
|
||||
const LogoContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
`
|
||||
|
||||
const StageIndicator = styled.div<{ stage: string }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.stage-icon {
|
||||
font-size: 20px;
|
||||
color: ${(props) => {
|
||||
switch (props.stage) {
|
||||
case 'completed':
|
||||
return '#52c41a'
|
||||
case 'error':
|
||||
return '#ff4d4f'
|
||||
default:
|
||||
return '#1890ff'
|
||||
}
|
||||
}};
|
||||
}
|
||||
`
|
||||
|
||||
const ProgressContainer = styled.div`
|
||||
margin: 24px 0;
|
||||
`
|
||||
|
||||
const MessageContainer = styled.div`
|
||||
text-align: center;
|
||||
margin: 16px 0;
|
||||
min-height: 24px;
|
||||
`
|
||||
|
||||
interface MigrationProgress {
|
||||
stage: string
|
||||
progress: number
|
||||
total: number
|
||||
message: string
|
||||
}
|
||||
|
||||
const MigrateApp: React.FC = () => {
|
||||
const [progress, setProgress] = useState<MigrationProgress>({
|
||||
stage: 'idle',
|
||||
progress: 0,
|
||||
total: 100,
|
||||
message: 'Initializing migration...'
|
||||
})
|
||||
const [showBackupRequired, setShowBackupRequired] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for progress updates
|
||||
const handleProgress = (_: any, progressData: MigrationProgress) => {
|
||||
setProgress(progressData)
|
||||
}
|
||||
|
||||
// Listen for backup requirement
|
||||
const handleBackupRequired = () => {
|
||||
setShowBackupRequired(true)
|
||||
}
|
||||
|
||||
window.electron.ipcRenderer.on(IpcChannel.DataMigrateProgress, handleProgress)
|
||||
window.electron.ipcRenderer.on(IpcChannel.DataMigrate_RequireBackup, handleBackupRequired)
|
||||
|
||||
// Request initial progress
|
||||
window.electron.ipcRenderer
|
||||
.invoke(IpcChannel.DataMigrate_GetProgress)
|
||||
.then((initialProgress: MigrationProgress) => {
|
||||
if (initialProgress) {
|
||||
setProgress(initialProgress)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeAllListeners(IpcChannel.DataMigrateProgress)
|
||||
window.electron.ipcRenderer.removeAllListeners(IpcChannel.DataMigrate_RequireBackup)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCancel = () => {
|
||||
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_Cancel)
|
||||
}
|
||||
|
||||
const handleShowBackupDialog = () => {
|
||||
// Open the main window backup dialog
|
||||
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_ShowBackupDialog)
|
||||
}
|
||||
|
||||
const handleBackupCompleted = () => {
|
||||
setShowBackupRequired(false)
|
||||
// Notify the main process that backup is completed
|
||||
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_BackupCompleted)
|
||||
}
|
||||
|
||||
const getStageTitle = () => {
|
||||
switch (progress.stage) {
|
||||
case 'backup':
|
||||
return 'Creating Backup'
|
||||
case 'migration':
|
||||
return 'Migrating Data'
|
||||
case 'completed':
|
||||
return 'Migration Completed'
|
||||
case 'error':
|
||||
return 'Migration Failed'
|
||||
case 'cancelled':
|
||||
return 'Migration Cancelled'
|
||||
default:
|
||||
return 'Preparing Migration'
|
||||
}
|
||||
}
|
||||
|
||||
const getStageIcon = () => {
|
||||
switch (progress.stage) {
|
||||
case 'completed':
|
||||
return <CheckCircleOutlined className="stage-icon" />
|
||||
case 'error':
|
||||
case 'cancelled':
|
||||
return <ExclamationCircleOutlined className="stage-icon" />
|
||||
default:
|
||||
return <LoadingOutlined className="stage-icon" />
|
||||
}
|
||||
}
|
||||
|
||||
const getProgressColor = () => {
|
||||
switch (progress.stage) {
|
||||
case 'completed':
|
||||
return '#52c41a'
|
||||
case 'error':
|
||||
case 'cancelled':
|
||||
return '#ff4d4f'
|
||||
default:
|
||||
return '#1890ff'
|
||||
}
|
||||
}
|
||||
|
||||
const showCancelButton = () => {
|
||||
return progress.stage !== 'completed' && progress.stage !== 'error' && progress.stage !== 'cancelled'
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<MigrationCard
|
||||
title={
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
Cherry Studio Data Migration
|
||||
</Title>
|
||||
</div>
|
||||
}
|
||||
bordered={false}>
|
||||
<LogoContainer>
|
||||
<img
|
||||
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHZpZXdCb3g9IjAgMCA2NCA2NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMzIiIGZpbGw9IiM2NjdlZWEiLz4KPHN2ZyB4PSIxNiIgeT0iMTYiIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJ3aGl0ZSI+CjxwYXRoIGQ9Im0xMiAyIDMgN2g3bC01LjUgNEwxOSAyMGwtNy02LTcgNiAyLjUtN0w2IDloN2wzLTd6Ii8+Cjwvc3ZnPgo8L3N2Zz4K"
|
||||
alt="Cherry Studio"
|
||||
/>
|
||||
</LogoContainer>
|
||||
|
||||
<StageIndicator stage={progress.stage}>
|
||||
{getStageIcon()}
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{getStageTitle()}
|
||||
</Title>
|
||||
</StageIndicator>
|
||||
|
||||
<ProgressContainer>
|
||||
<Progress
|
||||
percent={progress.progress}
|
||||
strokeColor={getProgressColor()}
|
||||
trailColor="#f0f0f0"
|
||||
size="default"
|
||||
showInfo={true}
|
||||
/>
|
||||
</ProgressContainer>
|
||||
|
||||
<MessageContainer>
|
||||
<Text type="secondary">{progress.message}</Text>
|
||||
</MessageContainer>
|
||||
|
||||
{progress.stage === 'error' && (
|
||||
<Alert
|
||||
message="Migration Error"
|
||||
description="The migration process encountered an error. You can try again or restore from a backup using an older version."
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{progress.stage === 'completed' && (
|
||||
<Alert
|
||||
message="Migration Successful"
|
||||
description="Your data has been successfully migrated to the new format. Cherry Studio will now start with your updated data."
|
||||
type="success"
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBackupRequired && (
|
||||
<Alert
|
||||
message="Backup Required"
|
||||
description="A backup is required before migration can proceed. Please create a backup of your data to ensure safety."
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
action={
|
||||
<Space>
|
||||
<Button size="small" onClick={handleShowBackupDialog}>
|
||||
Create Backup
|
||||
</Button>
|
||||
<Button size="small" type="link" onClick={handleBackupCompleted}>
|
||||
I've Already Created a Backup
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCancelButton() && (
|
||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
<Space>
|
||||
<Button onClick={handleCancel} disabled={progress.stage === 'backup'}>
|
||||
Cancel Migration
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</MigrationCard>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default MigrateApp
|
||||
9
src/renderer/src/windows/dataMigrate/entryPoint.tsx
Normal file
9
src/renderer/src/windows/dataMigrate/entryPoint.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import '../../assets/styles/index.scss'
|
||||
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
import MigrateApp from './MigrateApp'
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
|
||||
|
||||
root.render(<MigrateApp />)
|
||||
Loading…
Reference in New Issue
Block a user