Files
CherryHQ-cherry-studio/v2-refactor-temp/tools/data-classify/scripts/lib/classificationUtils.js
Phantom 2e71e93b6c feat(migration): add Dexie settings unified data source and auto-generation (#13269)
### What this PR does

Before this PR:
The v2 migration system only supports ElectronStore and Redux as data
sources for preference migration. Dexie IndexedDB `settings` table (a
simple KV store used by the renderer for translate settings, pinned
models, etc.) has no migration path.

After this PR:
- Adds `DexieSettingsReader` that pre-loads the Dexie `settings` table
into memory for synchronous KV access, consistent with
`ElectronStoreReader` and `ReduxStateReader`
- Extends `MigrationContext` with a `dexieSettings` data source (makes
`createMigrationContext` async to pre-load the table)
- `PreferencesMigrator` now reads from `'dexie-settings'` source for
both simple and complex mappings
- `DEXIE_SETTINGS_MAPPINGS` is auto-generated into
`PreferencesMappings.ts` from `classification.json`'s new
`dexieSettings` section
- All generator scripts (`generate-migration.js`,
`generate-preferences.js`, `classificationUtils.js`) support
`dexieSettings` as a data source with priority-based deduplication

### Why we need it and why it was done in this way

The following tradeoffs were made:
- `DEXIE_SETTINGS_MAPPINGS` is integrated into `PreferencesMappings.ts`
(not a separate file) to keep all preference mapping exports in one
auto-generated file and simplify imports.
- `dexieSettings` priority is set to 2 (same as `localStorage`, below
`redux` at 3). If the same `targetKey` exists in both `redux` and
`dexieSettings`, redux wins — this is intentional since redux data is
typically more up-to-date.
- Dynamic keys (`image://`, `mcp:provider:*`) are explicitly excluded
from this path. They will be handled by dedicated migrators in future
PRs.

The following alternatives were considered:
- Generating a separate `DexieSettingsMappings.ts` file — rejected in
favor of consolidating all mappings into one file for simpler imports
and unified statistics.

Links to places where the discussion took place:
https://github.com/CherryHQ/cherry-studio/issues/10162

### Breaking changes

None.

### Special notes for your reviewer

- The `classification.json` `dexieSettings.settings` array is currently
empty (skeleton only). Actual key classifications will be added in
follow-up PRs.
- The architecture supports dynamic keys via
`DexieSettingsReader.keys()` prefix filtering, but dynamic key migrators
are out of scope for this PR.
- Source identifier in `PreferencesMigrator` uses `'dexie-settings'`
(with hyphen) to distinguish from the Dexie export reader
(`dexieExport`) used by `ChatMigrator`.

### Checklist

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [ ] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [ ] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others

### Release note

```release-note
NONE
```

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:20:34 +08:00

326 lines
9.5 KiB
JavaScript

/**
* Shared utilities for data classification tools
*
* This module provides common functions used across multiple scripts:
* - Loading and saving classification data
* - Extracting preferences data with deduplication
* - Traversing nested classification structures
*/
const fs = require('fs')
const path = require('path')
/**
* Default data directory path
*/
const DATA_DIR = path.resolve(__dirname, '../../data')
/**
* Load classification.json file
* @param {string} [dataDir] - Optional custom data directory
* @returns {Object} Parsed classification data
*/
function loadClassification(dataDir = DATA_DIR) {
const classificationFile = path.join(dataDir, 'classification.json')
if (!fs.existsSync(classificationFile)) {
throw new Error(`Classification file not found: ${classificationFile}`)
}
const content = fs.readFileSync(classificationFile, 'utf8')
return JSON.parse(content)
}
/**
* Save classification data to file with backup
* @param {Object} classification - Classification data to save
* @param {string} [dataDir] - Optional custom data directory
*/
function saveClassification(classification, dataDir = DATA_DIR) {
const classificationFile = path.join(dataDir, 'classification.json')
// Create backup if file exists
if (fs.existsSync(classificationFile)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const backupPath = path.join(dataDir, `classification.backup.${timestamp}.json`)
fs.copyFileSync(classificationFile, backupPath)
console.log(`Backup created: ${backupPath}`)
}
fs.writeFileSync(classificationFile, JSON.stringify(classification, null, 2), 'utf8')
console.log(`Classification saved: ${classificationFile}`)
}
/**
* Load inventory.json file
* @param {string} [dataDir] - Optional custom data directory
* @returns {Object} Parsed inventory data
*/
function loadInventory(dataDir = DATA_DIR) {
const inventoryFile = path.join(dataDir, 'inventory.json')
if (!fs.existsSync(inventoryFile)) {
throw new Error(`Inventory file not found: ${inventoryFile}`)
}
const content = fs.readFileSync(inventoryFile, 'utf8')
return JSON.parse(content)
}
/**
* Source priority for deduplication
* Higher number = higher priority
*/
const SOURCE_PRIORITY = {
redux: 4,
dexieSettings: 3,
localStorage: 2,
electronStore: 1
}
/**
* Extract preferences data from classification with deduplication
* Supports nested structures with children
*
* @param {Object} classification - Classification data object
* @returns {Object} Object with categorized preferences data:
* - all: all deduplicated items
* - electronStore: items from electronStore
* - redux: items from redux
* - localStorage: items from localStorage
*/
function extractPreferencesData(classification) {
const allPreferencesData = []
const sources = ['electronStore', 'redux', 'localStorage', 'dexieSettings']
// Recursive function to extract items including children
const extractItems = (items, source, category, parentKey = '') => {
if (!Array.isArray(items)) return
items.forEach((item) => {
// Handle items with children
if (item.children && Array.isArray(item.children)) {
extractItems(item.children, source, category, `${parentKey}${item.originalKey}.`)
return
}
// Handle regular items
if (item.category === 'preferences' && item.status === 'classified' && item.targetKey) {
allPreferencesData.push({
...item,
source,
sourceCategory: category,
originalKey: parentKey + item.originalKey,
fullPath: `${source}/${category}/${parentKey}${item.originalKey}`
})
}
})
}
// Extract from all sources
sources.forEach((source) => {
if (classification.classifications[source]) {
Object.keys(classification.classifications[source]).forEach((category) => {
const items = classification.classifications[source][category]
extractItems(items, source, category)
})
}
})
// Handle duplicate targetKeys with priority-based selection
const targetKeyGroups = {}
allPreferencesData.forEach((item) => {
if (!targetKeyGroups[item.targetKey]) {
targetKeyGroups[item.targetKey] = []
}
targetKeyGroups[item.targetKey].push(item)
})
// Deduplicate using source priority
const deduplicatedData = []
Object.keys(targetKeyGroups).forEach((targetKey) => {
const items = targetKeyGroups[targetKey]
if (items.length > 1) {
// Sort by priority and select highest
items.sort((a, b) => SOURCE_PRIORITY[b.source] - SOURCE_PRIORITY[a.source])
}
deduplicatedData.push(items[0])
})
// Group by source
const result = {
electronStore: [],
redux: [],
localStorage: [],
dexieSettings: [],
all: deduplicatedData
}
deduplicatedData.forEach((item) => {
if (result[item.source]) {
result[item.source].push(item)
}
})
return result
}
/**
* Traverse nested classification structure
* Calls callback for each item (including nested children)
*
* @param {Object} classifications - The classifications object
* @param {Function} callback - Function to call for each item: (item, source, category, fullKey) => void
*/
function traverseClassifications(classifications, callback) {
const traverse = (items, source, category, parentKey = '') => {
if (!Array.isArray(items)) return
for (const item of items) {
const fullKey = parentKey ? `${parentKey}.${item.originalKey}` : item.originalKey
// Call callback for this item
callback(item, source, category, fullKey)
// Recurse into children
if (item.children && Array.isArray(item.children)) {
traverse(item.children, source, category, fullKey)
}
}
}
// Iterate through all sources and categories
for (const [source, sourceData] of Object.entries(classifications)) {
if (typeof sourceData === 'object' && sourceData !== null) {
for (const [category, items] of Object.entries(sourceData)) {
if (Array.isArray(items)) {
traverse(items, source, category, '')
}
}
}
}
}
/**
* Get all flat keys from nested classification structure
* Used for comparison and validation
*
* @param {Object} classifications - The classifications object
* @returns {string[]} Array of full keys in format "source.category.field" or "source.category.parent.child"
*/
function getAllClassificationKeys(classifications) {
const keys = []
traverseClassifications(classifications, (item, source, category, fullKey) => {
// Build the complete key path
if (source === 'redux') {
keys.push(`${source}.${category}.${fullKey}`)
} else {
keys.push(`${source}.${fullKey}`)
}
})
return keys
}
/**
* Calculate statistics from nested classification structure
*
* @param {Object} classifications - The classifications object
* @returns {Object} Statistics object with counts by status and category
*/
function calculateStats(classifications) {
const stats = {
byStatus: {
pending: 0,
classified: 0,
'classified-deleted': 0
},
byCategory: {},
total: 0
}
traverseClassifications(classifications, (item) => {
stats.total++
// Count by status
if (item.status) {
stats.byStatus[item.status] = (stats.byStatus[item.status] || 0) + 1
}
// Count by category (only for classified items)
if (item.status === 'classified' && item.category) {
stats.byCategory[item.category] = (stats.byCategory[item.category] || 0) + 1
}
})
return stats
}
/**
* Normalize type string to standard format
*
* @param {string} type - Type string to normalize
* @returns {string} Normalized type string
*/
function normalizeType(type) {
if (!type || type === 'unknown') {
return 'unknown'
}
const cleanType = String(type).toLowerCase().trim()
// Handle union types - extract primary type
if (cleanType.includes('|')) {
const types = cleanType.split('|').map((t) => t.trim())
const nonNullTypes = types.filter((t) => t !== 'null' && t !== 'undefined')
if (nonNullTypes.length > 0) {
return normalizeType(nonNullTypes[0])
}
return normalizeType(types[0])
}
// Map to standard types
if (cleanType === 'string' || cleanType.includes('string')) return 'string'
if (cleanType === 'number' || cleanType.includes('number')) return 'number'
if (cleanType === 'boolean' || cleanType.includes('boolean')) return 'boolean'
if (cleanType === 'array' || cleanType.includes('[]')) return 'array'
if (cleanType === 'object' || cleanType.includes('object') || cleanType.includes('record')) return 'object'
if (cleanType === 'null') return 'null'
if (cleanType === 'undefined') return 'undefined'
if (cleanType.includes('entitytable') || cleanType === 'table') return 'table'
return 'unknown'
}
/**
* Infer type from a value
*
* @param {*} value - Value to infer type from
* @returns {string} Inferred type string
*/
function inferTypeFromValue(value) {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'number') return 'number'
if (typeof value === 'string') return 'string'
if (Array.isArray(value)) return 'array'
if (typeof value === 'object') return 'object'
return 'unknown'
}
module.exports = {
DATA_DIR,
loadClassification,
saveClassification,
loadInventory,
extractPreferencesData,
traverseClassifications,
getAllClassificationKeys,
calculateStats,
normalizeType,
inferTypeFromValue,
SOURCE_PRIORITY
}