feat(migration): add Redux data handling to migration process

This commit introduces new IPC channels for sending and retrieving Redux persist data during the migration process. It enhances the DataRefactorMigrateService with methods to cache and manage Redux data, ensuring a smoother migration experience. Additionally, the MigrateApp component is updated to extract Redux data from localStorage and send it to the main process, improving the overall migration flow and user experience.
This commit is contained in:
fullex 2025-08-10 20:58:43 +08:00
parent 39257f64b1
commit c217a0bf02
9 changed files with 1011 additions and 300 deletions

View File

@ -197,6 +197,8 @@ export enum IpcChannel {
DataMigrate_RetryMigration = 'data-migrate:retry-migration',
DataMigrate_RestartApp = 'data-migrate:restart-app',
DataMigrate_CloseWindow = 'data-migrate:close-window',
DataMigrate_SendReduxData = 'data-migrate:send-redux-data',
DataMigrate_GetReduxData = 'data-migrate:get-redux-data',
// zip
Zip_Compress = 'zip:compress',

View File

@ -1,6 +1,6 @@
/**
* Auto-generated preferences configuration
* Generated at: 2025-08-09T07:25:38.982Z
* Generated at: 2025-08-10T12:46:51.544Z
*
* This file is automatically generated from classification.json
* To update this file, modify classification.json and run:
@ -27,7 +27,7 @@ export interface PreferencesType {
// redux/settings/testPlan
'app.dist.test_plan.enabled': boolean
// electronStore/Language/Language
'app.language': unknown | null
'app.language': string | null
// redux/settings/launchOnBoot
'app.launch_on_boot': boolean
// redux/settings/notification.assistant
@ -65,7 +65,7 @@ export interface PreferencesType {
// redux/settings/userName
'app.user.name': string
// electronStore/ZoomFactor/ZoomFactor
'app.zoom_factor': unknown | null
'app.zoom_factor': number
// redux/settings/codeCollapsible
'chat.code.collapsible': boolean
// redux/settings/codeEditor.autocompletion
@ -292,26 +292,22 @@ export interface PreferencesType {
'feature.selection.action_items': unknown[]
// redux/selectionStore/actionWindowOpacity
'feature.selection.action_window_opacity': number
// redux/selectionStore/isAutoClose
'feature.selection.auto_close': boolean
// redux/selectionStore/isAutoPin
'feature.selection.auto_pin': boolean
// redux/selectionStore/isCompact
'feature.selection.compact': boolean
// redux/selectionStore/selectionEnabled
'feature.selection.enabled': boolean
// redux/selectionStore/filterList
'feature.selection.filter_list': unknown[]
// redux/selectionStore/filterMode
'feature.selection.filter_mode': string
// electronStore/SelectionAssistantFollowToolbar/SelectionAssistantFollowToolbar
'feature.selection.follow_toolbar': unknown | null
// redux/selectionStore/isAutoClose
'feature.selection.is_auto_close': boolean
// redux/selectionStore/isAutoPin
'feature.selection.is_auto_pin': boolean
// redux/selectionStore/isCompact
'feature.selection.is_compact': boolean
// redux/selectionStore/isFollowToolbar
'feature.selection.is_follow_toolbar': boolean
'feature.selection.follow_toolbar': boolean
// redux/selectionStore/isRemeberWinSize
'feature.selection.is_remeber_win_size': boolean
// electronStore/SelectionAssistantRemeberWinSize/SelectionAssistantRemeberWinSize
'feature.selection.remember_win_size': unknown | null
'feature.selection.remember_win_size': boolean
// redux/selectionStore/triggerMode
'feature.selection.trigger_mode': string
// redux/settings/enableTopicNaming
@ -362,7 +358,7 @@ export const defaultPreferences: PreferencesType = {
'app.tray.on_launch': false,
'app.user.id': 'uuid()',
'app.user.name': '',
'app.zoom_factor': null,
'app.zoom_factor': 1,
'chat.code.collapsible': false,
'chat.code.editor.autocompletion': true,
'chat.code.editor.fold_gutter': false,
@ -476,16 +472,14 @@ export const defaultPreferences: PreferencesType = {
'feature.quick_assistant.read_clipboard_at_startup': true,
'feature.selection.action_items': [],
'feature.selection.action_window_opacity': 100,
'feature.selection.auto_close': false,
'feature.selection.auto_pin': false,
'feature.selection.compact': false,
'feature.selection.enabled': false,
'feature.selection.filter_list': [],
'feature.selection.filter_mode': 'default',
'feature.selection.follow_toolbar': null,
'feature.selection.is_auto_close': false,
'feature.selection.is_auto_pin': false,
'feature.selection.is_compact': false,
'feature.selection.is_follow_toolbar': true,
'feature.selection.is_remeber_win_size': false,
'feature.selection.remember_win_size': null,
'feature.selection.follow_toolbar': true,
'feature.selection.remember_win_size': false,
'feature.selection.trigger_mode': 'selected',
'topic.naming.enabled': true,
'topic.naming.prompt': '',
@ -503,8 +497,8 @@ export const defaultPreferences: PreferencesType = {
/**
* :
* - 总配置项: 158
* - electronStore项: 4
* - 总配置项: 156
* - electronStore项: 2
* - redux项: 154
* - localStorage项: 0
*/

View File

@ -57,6 +57,7 @@ class DataRefactorMigrateService {
message: 'Ready to start data migration'
}
private isMigrating: boolean = false
private reduxData: any = null // Cache for Redux persist data
constructor() {
this.backupManager = new BackupManager()
@ -69,6 +70,24 @@ class DataRefactorMigrateService {
return this.backupManager
}
/**
* Get cached Redux persist data for migration
*/
public getReduxData(): any {
return this.reduxData
}
/**
* Set Redux persist data from renderer process
*/
public setReduxData(data: any): void {
this.reduxData = data
logger.info('Redux data cached for migration', {
dataKeys: data ? Object.keys(data) : [],
hasData: !!data
})
}
/**
* Register migration-specific IPC handlers
* This creates an isolated IPC environment only for migration operations
@ -226,6 +245,25 @@ class DataRefactorMigrateService {
}
})
ipcMain.handle(IpcChannel.DataMigrate_SendReduxData, (_event, data) => {
try {
this.setReduxData(data)
return { success: true }
} catch (error) {
logger.error('IPC handler error: sendReduxData', error as Error)
throw error
}
})
ipcMain.handle(IpcChannel.DataMigrate_GetReduxData, () => {
try {
return this.getReduxData()
} catch (error) {
logger.error('IPC handler error: getReduxData', error as Error)
throw error
}
})
logger.info('Migration IPC handlers registered successfully')
}
@ -248,6 +286,8 @@ class DataRefactorMigrateService {
ipcMain.removeAllListeners(IpcChannel.DataMigrate_RetryMigration)
ipcMain.removeAllListeners(IpcChannel.DataMigrate_RestartApp)
ipcMain.removeAllListeners(IpcChannel.DataMigrate_CloseWindow)
ipcMain.removeAllListeners(IpcChannel.DataMigrate_SendReduxData)
ipcMain.removeAllListeners(IpcChannel.DataMigrate_GetReduxData)
logger.info('Migration IPC handlers unregistered successfully')
} catch (error) {
@ -643,8 +683,8 @@ class DataRefactorMigrateService {
try {
logger.info('Executing migration')
// Create preferences migrator
const preferencesMigrator = new PreferencesMigrator()
// Create preferences migrator with reference to this service for Redux data access
const preferencesMigrator = new PreferencesMigrator(this)
// Execute preferences migration with progress updates
const result = await preferencesMigrator.migrate((progress, message) => {

View File

@ -0,0 +1,673 @@
/**
* Auto-generated preference mappings from classification.json
* Generated at: 2025-08-10T12:47:40.247Z
*
* This file contains pure mapping relationships without default values.
* Default values are managed in packages/shared/data/preferences.ts
*
* === AUTO-GENERATED CONTENT START ===
*/
/**
* ElectronStore映射关系 -
*
* ElectronStore没有嵌套originalKey直接对应configManager.get(key)
*/
export const ELECTRON_STORE_MAPPINGS = [
{
originalKey: 'Language',
targetKey: 'app.language'
},
{
originalKey: 'ZoomFactor',
targetKey: 'app.zoom_factor'
}
] as const
/**
* Redux Store映射关系 - category分组
*
* Redux Store可能有children结构originalKey可能包含嵌套路径:
* - : "theme" -> reduxData.settings.theme
* - : "codeEditor.enabled" -> reduxData.settings.codeEditor.enabled
* - : "exportMenuOptions.docx" -> reduxData.settings.exportMenuOptions.docx
*/
export const REDUX_STORE_MAPPINGS = {
settings: [
{
originalKey: 'autoCheckUpdate',
targetKey: 'app.dist.auto_update.enabled'
},
{
originalKey: 'clickTrayToShowQuickAssistant',
targetKey: 'feature.quick_assistant.click_tray_to_show'
},
{
originalKey: 'disableHardwareAcceleration',
targetKey: 'app.disable_hardware_acceleration'
},
{
originalKey: 'enableDataCollection',
targetKey: 'app.privacy.data_collection.enabled'
},
{
originalKey: 'enableDeveloperMode',
targetKey: 'app.developer_mode.enabled'
},
{
originalKey: 'enableQuickAssistant',
targetKey: 'feature.quick_assistant.enabled'
},
{
originalKey: 'launchToTray',
targetKey: 'app.tray.on_launch'
},
{
originalKey: 'testChannel',
targetKey: 'app.dist.test_plan.channel'
},
{
originalKey: 'testPlan',
targetKey: 'app.dist.test_plan.enabled'
},
{
originalKey: 'theme',
targetKey: 'app.theme.mode'
},
{
originalKey: 'tray',
targetKey: 'app.tray.enabled'
},
{
originalKey: 'trayOnClose',
targetKey: 'app.tray.on_close'
},
{
originalKey: 'sendMessageShortcut',
targetKey: 'chat.input.send_message_shortcut'
},
{
originalKey: 'proxyMode',
targetKey: 'app.proxy.mode'
},
{
originalKey: 'proxyUrl',
targetKey: 'app.proxy.url'
},
{
originalKey: 'proxyBypassRules',
targetKey: 'app.proxy.bypass_rules'
},
{
originalKey: 'userName',
targetKey: 'app.user.name'
},
{
originalKey: 'userId',
targetKey: 'app.user.id'
},
{
originalKey: 'showPrompt',
targetKey: 'chat.message.show_prompt'
},
{
originalKey: 'showTokens',
targetKey: 'chat.message.show_tokens'
},
{
originalKey: 'showMessageDivider',
targetKey: 'chat.message.show_divider'
},
{
originalKey: 'messageFont',
targetKey: 'chat.message.font'
},
{
originalKey: 'showInputEstimatedTokens',
targetKey: 'chat.input.show_estimated_tokens'
},
{
originalKey: 'launchOnBoot',
targetKey: 'app.launch_on_boot'
},
{
originalKey: 'userTheme',
targetKey: 'app.theme.user_defined'
},
{
originalKey: 'windowStyle',
targetKey: 'app.theme.window_style'
},
{
originalKey: 'fontSize',
targetKey: 'chat.message.font_size'
},
{
originalKey: 'topicPosition',
targetKey: 'topic.position'
},
{
originalKey: 'showTopicTime',
targetKey: 'topic.show_time'
},
{
originalKey: 'pinTopicsToTop',
targetKey: 'topic.pin_to_top'
},
{
originalKey: 'assistantIconType',
targetKey: 'ui.assistant_icon_type'
},
{
originalKey: 'pasteLongTextAsFile',
targetKey: 'chat.input.paste_long_text_as_file'
},
{
originalKey: 'pasteLongTextThreshold',
targetKey: 'chat.input.paste_long_text_threshold'
},
{
originalKey: 'clickAssistantToShowTopic',
targetKey: 'ui.click_assistant_to_show_topic'
},
{
originalKey: 'codeExecution.enabled',
targetKey: 'chat.code.execution.enabled'
},
{
originalKey: 'codeExecution.timeoutMinutes',
targetKey: 'chat.code.execution.timeout_minutes'
},
{
originalKey: 'codeEditor.enabled',
targetKey: 'chat.code.editor.highlight_active_line'
},
{
originalKey: 'codeEditor.themeLight',
targetKey: 'chat.code.editor.theme_light'
},
{
originalKey: 'codeEditor.themeDark',
targetKey: 'chat.code.editor.theme_dark'
},
{
originalKey: 'codeEditor.foldGutter',
targetKey: 'chat.code.editor.fold_gutter'
},
{
originalKey: 'codeEditor.autocompletion',
targetKey: 'chat.code.editor.autocompletion'
},
{
originalKey: 'codeEditor.keymap',
targetKey: 'chat.code.editor.keymap'
},
{
originalKey: 'codePreview.themeLight',
targetKey: 'chat.code.preview.theme_light'
},
{
originalKey: 'codePreview.themeDark',
targetKey: 'chat.code.preview.theme_dark'
},
{
originalKey: 'codeViewer.themeLight',
targetKey: 'chat.code.viewer.theme_light'
},
{
originalKey: 'codeViewer.themeDark',
targetKey: 'chat.code.viewer.theme_dark'
},
{
originalKey: 'codeShowLineNumbers',
targetKey: 'chat.code.show_line_numbers'
},
{
originalKey: 'codeCollapsible',
targetKey: 'chat.code.collapsible'
},
{
originalKey: 'codeWrappable',
targetKey: 'chat.code.wrappable'
},
{
originalKey: 'codeImageTools',
targetKey: 'chat.code.image_tools'
},
{
originalKey: 'mathEngine',
targetKey: 'chat.message.math_engine'
},
{
originalKey: 'messageStyle',
targetKey: 'chat.message.style'
},
{
originalKey: 'foldDisplayMode',
targetKey: 'chat.message.multi_model.fold_display_mode'
},
{
originalKey: 'gridColumns',
targetKey: 'chat.message.multi_model.grid_columns'
},
{
originalKey: 'gridPopoverTrigger',
targetKey: 'chat.message.multi_model.grid_popover_trigger'
},
{
originalKey: 'messageNavigation',
targetKey: 'chat.message.navigation_mode'
},
{
originalKey: 'skipBackupFile',
targetKey: 'data.backup.general.skip_backup_file'
},
{
originalKey: 'webdavHost',
targetKey: 'data.backup.webdav.host'
},
{
originalKey: 'webdavUser',
targetKey: 'data.backup.webdav.user'
},
{
originalKey: 'webdavPass',
targetKey: 'data.backup.webdav.pass'
},
{
originalKey: 'webdavPath',
targetKey: 'data.backup.webdav.path'
},
{
originalKey: 'webdavAutoSync',
targetKey: 'data.backup.webdav.auto_sync'
},
{
originalKey: 'webdavSyncInterval',
targetKey: 'data.backup.webdav.sync_interval'
},
{
originalKey: 'webdavMaxBackups',
targetKey: 'data.backup.webdav.max_backups'
},
{
originalKey: 'webdavSkipBackupFile',
targetKey: 'data.backup.webdav.skip_backup_file'
},
{
originalKey: 'webdavDisableStream',
targetKey: 'data.backup.webdav.disable_stream'
},
{
originalKey: 'autoTranslateWithSpace',
targetKey: 'chat.input.translate.auto_translate_with_space'
},
{
originalKey: 'showTranslateConfirm',
targetKey: 'chat.input.translate.show_confirm'
},
{
originalKey: 'enableTopicNaming',
targetKey: 'topic.naming.enabled'
},
{
originalKey: 'customCss',
targetKey: 'ui.custom_css'
},
{
originalKey: 'topicNamingPrompt',
targetKey: 'topic.naming.prompt'
},
{
originalKey: 'narrowMode',
targetKey: 'chat.narrow_mode'
},
{
originalKey: 'multiModelMessageStyle',
targetKey: 'chat.message.multi_model.style'
},
{
originalKey: 'readClipboardAtStartup',
targetKey: 'feature.quick_assistant.read_clipboard_at_startup'
},
{
originalKey: 'notionDatabaseID',
targetKey: 'data.integration.notion.database_id'
},
{
originalKey: 'notionApiKey',
targetKey: 'data.integration.notion.api_key'
},
{
originalKey: 'notionPageNameKey',
targetKey: 'data.integration.notion.page_name_key'
},
{
originalKey: 'markdownExportPath',
targetKey: 'data.export.markdown.path'
},
{
originalKey: 'forceDollarMathInMarkdown',
targetKey: 'data.export.markdown.force_dollar_math'
},
{
originalKey: 'useTopicNamingForMessageTitle',
targetKey: 'data.export.markdown.use_topic_naming_for_message_title'
},
{
originalKey: 'showModelNameInMarkdown',
targetKey: 'data.export.markdown.show_model_name'
},
{
originalKey: 'showModelProviderInMarkdown',
targetKey: 'data.export.markdown.show_model_provider'
},
{
originalKey: 'thoughtAutoCollapse',
targetKey: 'chat.message.thought.auto_collapse'
},
{
originalKey: 'notionExportReasoning',
targetKey: 'data.integration.notion.export_reasoning'
},
{
originalKey: 'excludeCitationsInExport',
targetKey: 'data.export.markdown.exclude_citations'
},
{
originalKey: 'standardizeCitationsInExport',
targetKey: 'data.export.markdown.standardize_citations'
},
{
originalKey: 'yuqueToken',
targetKey: 'data.integration.yuque.token'
},
{
originalKey: 'yuqueUrl',
targetKey: 'data.integration.yuque.url'
},
{
originalKey: 'yuqueRepoId',
targetKey: 'data.integration.yuque.repo_id'
},
{
originalKey: 'joplinToken',
targetKey: 'data.integration.joplin.token'
},
{
originalKey: 'joplinUrl',
targetKey: 'data.integration.joplin.url'
},
{
originalKey: 'joplinExportReasoning',
targetKey: 'data.integration.joplin.export_reasoning'
},
{
originalKey: 'defaultObsidianVault',
targetKey: 'data.integration.obsidian.default_vault'
},
{
originalKey: 'siyuanApiUrl',
targetKey: 'data.integration.siyuan.api_url'
},
{
originalKey: 'siyuanToken',
targetKey: 'data.integration.siyuan.token'
},
{
originalKey: 'siyuanBoxId',
targetKey: 'data.integration.siyuan.box_id'
},
{
originalKey: 'siyuanRootPath',
targetKey: 'data.integration.siyuan.root_path'
},
{
originalKey: 'maxKeepAliveMinapps',
targetKey: 'feature.minapp.max_keep_alive'
},
{
originalKey: 'showOpenedMinappsInSidebar',
targetKey: 'feature.minapp.show_opened_in_sidebar'
},
{
originalKey: 'minappsOpenLinkExternal',
targetKey: 'feature.minapp.open_link_external'
},
{
originalKey: 'enableSpellCheck',
targetKey: 'app.spell_check.enabled'
},
{
originalKey: 'spellCheckLanguages',
targetKey: 'app.spell_check.languages'
},
{
originalKey: 'enableQuickPanelTriggers',
targetKey: 'chat.input.quick_panel.triggers_enabled'
},
{
originalKey: 'enableBackspaceDeleteModel',
targetKey: 'chat.input.backspace_delete_model'
},
{
originalKey: 'exportMenuOptions.image',
targetKey: 'data.export.menus.image'
},
{
originalKey: 'exportMenuOptions.markdown',
targetKey: 'data.export.menus.markdown'
},
{
originalKey: 'exportMenuOptions.markdown_reason',
targetKey: 'data.export.menus.markdown_reason'
},
{
originalKey: 'exportMenuOptions.notion',
targetKey: 'data.export.menus.notion'
},
{
originalKey: 'exportMenuOptions.yuque',
targetKey: 'data.export.menus.yuque'
},
{
originalKey: 'exportMenuOptions.joplin',
targetKey: 'data.export.menus.joplin'
},
{
originalKey: 'exportMenuOptions.obsidian',
targetKey: 'data.export.menus.obsidian'
},
{
originalKey: 'exportMenuOptions.siyuan',
targetKey: 'data.export.menus.siyuan'
},
{
originalKey: 'exportMenuOptions.docx',
targetKey: 'data.export.menus.docx'
},
{
originalKey: 'exportMenuOptions.plain_text',
targetKey: 'data.export.menus.plain_text'
},
{
originalKey: 'notification.assistant',
targetKey: 'app.notification.assistant.enabled'
},
{
originalKey: 'notification.backup',
targetKey: 'app.notification.backup.enabled'
},
{
originalKey: 'notification.knowledge',
targetKey: 'app.notification.knowledge.enabled'
},
{
originalKey: 'localBackupDir',
targetKey: 'data.backup.local.dir'
},
{
originalKey: 'localBackupAutoSync',
targetKey: 'data.backup.local.auto_sync'
},
{
originalKey: 'localBackupSyncInterval',
targetKey: 'data.backup.local.sync_interval'
},
{
originalKey: 'localBackupMaxBackups',
targetKey: 'data.backup.local.max_backups'
},
{
originalKey: 'localBackupSkipBackupFile',
targetKey: 'data.backup.local.skip_backup_file'
},
{
originalKey: 's3.endpoint',
targetKey: 'data.backup.s3.endpoint'
},
{
originalKey: 's3.region',
targetKey: 'data.backup.s3.region'
},
{
originalKey: 's3.bucket',
targetKey: 'data.backup.s3.bucket'
},
{
originalKey: 's3.accessKeyId',
targetKey: 'data.backup.s3.access_key_id'
},
{
originalKey: 's3.secretAccessKey',
targetKey: 'data.backup.s3.secret_access_key'
},
{
originalKey: 's3.root',
targetKey: 'data.backup.s3.root'
},
{
originalKey: 's3.autoSync',
targetKey: 'data.backup.s3.auto_sync'
},
{
originalKey: 's3.syncInterval',
targetKey: 'data.backup.s3.sync_interval'
},
{
originalKey: 's3.maxBackups',
targetKey: 'data.backup.s3.max_backups'
},
{
originalKey: 's3.skipBackupFile',
targetKey: 'data.backup.s3.skip_backup_file'
},
{
originalKey: 'navbarPosition',
targetKey: 'ui.navbar.position'
},
{
originalKey: 'apiServer.enabled',
targetKey: 'feature.csaas.enabled'
},
{
originalKey: 'apiServer.host',
targetKey: 'feature.csaas.host'
},
{
originalKey: 'apiServer.port',
targetKey: 'feature.csaas.port'
},
{
originalKey: 'apiServer.apiKey',
targetKey: 'feature.csaas.api_key'
}
],
selectionStore: [
{
originalKey: 'selectionEnabled',
targetKey: 'feature.selection.enabled'
},
{
originalKey: 'filterList',
targetKey: 'feature.selection.filter_list'
},
{
originalKey: 'filterMode',
targetKey: 'feature.selection.filter_mode'
},
{
originalKey: 'isFollowToolbar',
targetKey: 'feature.selection.follow_toolbar'
},
{
originalKey: 'isRemeberWinSize',
targetKey: 'feature.selection.remember_win_size'
},
{
originalKey: 'triggerMode',
targetKey: 'feature.selection.trigger_mode'
},
{
originalKey: 'isCompact',
targetKey: 'feature.selection.compact'
},
{
originalKey: 'isAutoClose',
targetKey: 'feature.selection.auto_close'
},
{
originalKey: 'isAutoPin',
targetKey: 'feature.selection.auto_pin'
},
{
originalKey: 'actionWindowOpacity',
targetKey: 'feature.selection.action_window_opacity'
},
{
originalKey: 'actionItems',
targetKey: 'feature.selection.action_items'
}
],
nutstore: [
{
originalKey: 'nutstoreToken',
targetKey: 'data.backup.nutstore.token'
},
{
originalKey: 'nutstorePath',
targetKey: 'data.backup.nutstore.path'
},
{
originalKey: 'nutstoreAutoSync',
targetKey: 'data.backup.nutstore.auto_sync'
},
{
originalKey: 'nutstoreSyncInterval',
targetKey: 'data.backup.nutstore.sync_interval'
},
{
originalKey: 'nutstoreSyncState',
targetKey: 'data.backup.nutstore.sync_state'
},
{
originalKey: 'nutstoreSkipBackupFile',
targetKey: 'data.backup.nutstore.skip_backup_file'
}
]
} as const
// === AUTO-GENERATED CONTENT END ===
/**
* :
* - ElectronStore项: 2
* - Redux Store项: 154
* - Redux分类: settings, selectionStore, nutstore
* - 总配置项: 156
*
* 使:
* 1. ElectronStore读取: configManager.get(mapping.originalKey)
* 2. Redux读取: 需要解析嵌套路径 reduxData[category][originalKey路径]
* 3. 默认值: 从defaultPreferences.default[mapping.targetKey]
*/

View File

@ -1,9 +1,11 @@
import dbService from '@data/db/DbService'
import { preferenceTable } from '@data/db/schemas/preference'
import { loggerService } from '@logger'
import { defaultPreferences } from '@shared/data/preferences'
import { and, eq } from 'drizzle-orm'
import { configManager } from '../../../../services/ConfigManager'
import { ELECTRON_STORE_MAPPINGS, REDUX_STORE_MAPPINGS } from './PreferencesMappings'
const logger = loggerService.withContext('PreferencesMigrator')
@ -13,7 +15,7 @@ export interface MigrationItem {
type: string
defaultValue: any
source: 'electronStore' | 'redux'
sourceCategory: string
sourceCategory?: string // Optional for electronStore
}
export interface MigrationResult {
@ -27,6 +29,11 @@ export interface MigrationResult {
export class PreferencesMigrator {
private db = dbService.getDb()
private migrateService: any // Reference to DataRefactorMigrateService
constructor(migrateService?: any) {
this.migrateService = migrateService
}
/**
* Execute preferences migration from all sources
@ -43,6 +50,7 @@ export class PreferencesMigrator {
try {
// Get migration items from classification.json
const migrationItems = await this.loadMigrationItems()
const totalItems = migrationItems.length
logger.info(`Found ${totalItems} items to migrate`)
@ -83,70 +91,45 @@ export class PreferencesMigrator {
}
/**
* Load migration items from the generated preferences.ts mappings
* For now, we'll use a simplified set based on the current generated migration code
* Load migration items from generated mapping relationships
* This uses the auto-generated PreferencesMappings.ts file
*/
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
logger.info('Loading migration items from generated mappings')
const items: MigrationItem[] = []
const items: MigrationItem[] = [
// ElectronStore items (from generated migration code)
{
originalKey: 'Language',
targetKey: 'app.language',
sourceCategory: 'Language',
type: 'unknown',
defaultValue: null,
// Process ElectronStore mappings - no sourceCategory needed
ELECTRON_STORE_MAPPINGS.forEach((mapping) => {
const defaultValue = defaultPreferences.default[mapping.targetKey] ?? null
items.push({
originalKey: mapping.originalKey,
targetKey: mapping.targetKey,
type: 'unknown', // Type will be inferred from defaultValue during conversion
defaultValue,
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'
}
]
// Process Redux mappings
Object.entries(REDUX_STORE_MAPPINGS).forEach(([category, mappings]) => {
mappings.forEach((mapping) => {
const defaultValue = defaultPreferences.default[mapping.targetKey] ?? null
items.push({
originalKey: mapping.originalKey, // May contain nested paths like "codeEditor.enabled"
targetKey: mapping.targetKey,
sourceCategory: category,
type: 'unknown', // Type will be inferred from defaultValue during conversion
defaultValue,
source: 'redux'
})
})
})
items.push(...reduxItems)
logger.info('Successfully loaded migration items from generated mappings', {
totalItems: items.length,
electronStoreItems: items.filter((i) => i.source === 'electronStore').length,
reduxItems: items.filter((i) => i.source === 'redux').length
})
return items
}
@ -163,28 +146,80 @@ export class PreferencesMigrator {
if (item.source === 'electronStore') {
originalValue = await this.readFromElectronStore(item.originalKey)
} else if (item.source === 'redux') {
if (!item.sourceCategory) {
throw new Error(`Redux source requires sourceCategory for item: ${item.originalKey}`)
}
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
// IMPORTANT: Only migrate if we actually found data, or if we want to set defaults
// Skip migration if no original data found and no meaningful default
let valueToMigrate = originalValue
let shouldSkipMigration = false
if (originalValue === undefined || originalValue === null) {
valueToMigrate = item.defaultValue
// Check if we have a meaningful default value (not null)
if (item.defaultValue !== null && item.defaultValue !== undefined) {
valueToMigrate = item.defaultValue
logger.info('Using default value for migration', {
targetKey: item.targetKey,
defaultValue: item.defaultValue,
source: item.source,
originalKey: item.originalKey
})
} else {
// Skip migration if no data found and no meaningful default
shouldSkipMigration = true
logger.info('Skipping migration - no data found and no meaningful default', {
targetKey: item.targetKey,
originalValue,
defaultValue: item.defaultValue,
source: item.source,
originalKey: item.originalKey
})
}
} else {
// Found original data, log the successful data retrieval
logger.info('Found original data for migration', {
targetKey: item.targetKey,
source: item.source,
originalKey: item.originalKey,
valueType: typeof originalValue,
valuePreview: JSON.stringify(originalValue).substring(0, 100)
})
}
if (shouldSkipMigration) {
return
}
// Convert value to appropriate type
const convertedValue = this.convertValue(valueToMigrate, item.type)
// Write to preferences table using Drizzle
await this.writeToPreferences(item.targetKey, convertedValue)
try {
await this.writeToPreferences(item.targetKey, convertedValue)
logger.debug('Successfully migrated preference item', {
targetKey: item.targetKey,
originalValue,
convertedValue
})
logger.info('Successfully migrated preference item', {
targetKey: item.targetKey,
source: item.source,
originalKey: item.originalKey,
originalValue,
convertedValue,
migrationSuccessful: true
})
} catch (writeError) {
logger.error('Failed to write preference to database', {
targetKey: item.targetKey,
source: item.source,
originalKey: item.originalKey,
convertedValue,
writeError
})
throw writeError
}
}
/**
@ -200,18 +235,104 @@ export class PreferencesMigrator {
}
/**
* Read value from Redux persist data
* Read value from Redux persist data with support for nested paths
*/
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
// Get cached Redux data from migrate service
const reduxData = this.migrateService?.getReduxData()
logger.debug('Redux persist read not fully implemented', { category, key })
return undefined
if (!reduxData) {
logger.warn('No Redux persist data available in cache', { category, key })
return undefined
}
logger.debug('Reading from cached Redux persist data', {
category,
key,
availableCategories: Object.keys(reduxData),
isNestedKey: key.includes('.')
})
// Get the category data from Redux persist cache
const categoryData = reduxData[category]
if (!categoryData) {
logger.debug('Category not found in Redux persist data', {
category,
availableCategories: Object.keys(reduxData)
})
return undefined
}
// Redux persist usually stores data as JSON strings
let parsedCategoryData
try {
parsedCategoryData = typeof categoryData === 'string' ? JSON.parse(categoryData) : categoryData
} catch (parseError) {
logger.warn('Failed to parse Redux persist category data', {
category,
categoryData: typeof categoryData,
parseError
})
return undefined
}
// Handle nested paths (e.g., "codeEditor.enabled")
let value
if (key.includes('.')) {
// Parse nested path
const keyPath = key.split('.')
let current = parsedCategoryData
logger.debug('Parsing nested key path', {
category,
key,
keyPath,
rootDataKeys: current ? Object.keys(current) : []
})
for (const pathSegment of keyPath) {
if (current && typeof current === 'object' && !Array.isArray(current)) {
current = current[pathSegment]
logger.debug('Navigated to path segment', {
pathSegment,
foundValue: current !== undefined,
valueType: typeof current
})
} else {
logger.debug('Failed to navigate nested path - invalid structure', {
pathSegment,
currentType: typeof current,
isArray: Array.isArray(current)
})
return undefined
}
}
value = current
} else {
// Direct field access (e.g., "theme")
value = parsedCategoryData[key]
}
if (value !== undefined) {
logger.debug('Successfully read from Redux persist cache', {
category,
key,
value,
valueType: typeof value,
isNested: key.includes('.')
})
} else {
logger.debug('Key not found in Redux persist data', {
category,
key,
availableKeys: parsedCategoryData ? Object.keys(parsedCategoryData) : []
})
}
return value
} catch (error) {
logger.warn('Failed to read from Redux persist', { category, key, error })
logger.warn('Failed to read from Redux persist cache', { category, key, error })
return undefined
}
}

View File

@ -1,72 +0,0 @@
/**
* 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('清理完成')
}
}

View File

@ -1,125 +0,0 @@
/**
* 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 }
}
}

View File

@ -1,10 +1,13 @@
import { AppLogo } from '@renderer/config/env'
import { loggerService } from '@renderer/services/LoggerService'
import { IpcChannel } from '@shared/IpcChannel'
import { Button, Progress, Space, Steps } from 'antd'
import { AlertTriangle, CheckCircle, Database, Loader2, Rocket } from 'lucide-react'
import React, { useEffect, useMemo, useState } from 'react'
import styled from 'styled-components'
const logger = loggerService.withContext('MigrateApp')
type MigrationStage =
| 'introduction' // Introduction phase - user can cancel
| 'backup_required' // Backup required - show backup requirement
@ -78,12 +81,68 @@ const MigrateApp: React.FC = () => {
return 'process'
}, [progress.stage])
/**
* Extract Redux persist data from localStorage and send to main process
*/
const extractAndSendReduxData = async () => {
try {
logger.info('Extracting Redux persist data for migration...')
// Get the Redux persist key (this should match the key used in store configuration)
const persistKey = 'persist:cherry-studio'
// Read the persisted data from localStorage
const persistedDataString = localStorage.getItem(persistKey)
if (!persistedDataString) {
logger.warn('No Redux persist data found in localStorage', { persistKey })
// Send empty data to indicate no Redux data available
await window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_SendReduxData, null)
return
}
// Parse the persisted data
const persistedData = JSON.parse(persistedDataString)
logger.info('Found Redux persist data:', {
keys: Object.keys(persistedData),
hasData: !!persistedData,
dataSize: persistedDataString.length,
persistKey
})
// Send the Redux data to main process for migration
const result = await window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_SendReduxData, persistedData)
if (result?.success) {
logger.info('Successfully sent Redux data to main process for migration')
} else {
logger.warn('Failed to send Redux data to main process', { result })
}
} catch (error) {
logger.error('Error extracting Redux persist data', error as Error)
// Send null to indicate extraction failed
await window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_SendReduxData, null)
throw error
}
}
const handleProceedToBackup = () => {
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_ProceedToBackup)
}
const handleStartMigration = () => {
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_StartMigration)
const handleStartMigration = async () => {
try {
// First, extract Redux persist data and send to main process
await extractAndSendReduxData()
// Then start the migration process
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_StartMigration)
} catch (error) {
logger.error('Failed to extract Redux data for migration', error as Error)
// Still proceed with migration even if Redux data extraction fails
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_StartMigration)
}
}
const handleRestartApp = () => {
@ -256,6 +315,22 @@ const MigrateApp: React.FC = () => {
<br />
使
</InfoDescription>
{/* Debug button to test Redux data extraction */}
<div style={{ marginTop: '24px', textAlign: 'center' }}>
<Button
size="small"
type="dashed"
onClick={async () => {
try {
await extractAndSendReduxData()
alert('Redux数据提取成功请查看应用日志。')
} catch (error) {
alert('Redux数据提取失败' + (error as Error).message)
}
}}>
Redux数据提取
</Button>
</div>
</InfoCard>
)}

View File

@ -1,10 +1,13 @@
import '@ant-design/v5-patch-for-react-19'
import '@renderer/assets/styles/index.scss'
import { loggerService } from '@logger'
import { createRoot } from 'react-dom/client'
import MigrateApp from './MigrateApp'
loggerService.initWindowSource('MigrateApp')
const root = createRoot(document.getElementById('root') as HTMLElement)
root.render(<MigrateApp />)