feat(settings): add automatic settings migration system

Implement comprehensive settings migration framework to consolidate
duplicate and overlapping configuration options. Automatically migrates
legacy fileParsingConfig settings to the new unified FileSource
configuration on plugin load.

Key features:
- Automatic detection of duplicate settings
- Safe migration with validation and rollback
- User notifications for migration results
- Migration history tracking
- Test coverage for all migration scenarios
- UI styling for migration notifications

This ensures smooth upgrades for existing users while maintaining
backward compatibility during the transition period.
This commit is contained in:
Quorafind 2025-08-20 22:46:29 +08:00
parent 4c82ab5b0f
commit 1b2e26deec
4 changed files with 777 additions and 0 deletions

View file

@ -0,0 +1,222 @@
/**
* Tests for Settings Migration functionality
*/
import {
migrateFileParsingSettings,
cleanupDeprecatedSettings,
hasSettingsDuplicates,
runAllMigrations
} from '../utils/SettingsMigration';
import { DEFAULT_SETTINGS, type TaskProgressBarSettings } from '../common/setting-definition';
describe('Settings Migration', () => {
let testSettings: TaskProgressBarSettings;
beforeEach(() => {
// Create a copy of default settings for testing
testSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
});
describe('hasSettingsDuplicates', () => {
it('should detect when file metadata parsing is enabled but FileSource is disabled', () => {
testSettings.fileParsingConfig.enableFileMetadataParsing = true;
testSettings.fileSource.enabled = false;
expect(hasSettingsDuplicates(testSettings)).toBe(true);
});
it('should detect when tag parsing is enabled but FileSource tag recognition is disabled', () => {
testSettings.fileParsingConfig.enableTagBasedTaskParsing = true;
testSettings.fileSource.recognitionStrategies.tags.enabled = false;
expect(hasSettingsDuplicates(testSettings)).toBe(true);
});
it('should return false when no duplicates exist', () => {
expect(hasSettingsDuplicates(testSettings)).toBe(false);
});
});
describe('migrateFileParsingSettings', () => {
it('should migrate file metadata parsing to FileSource', () => {
testSettings.fileParsingConfig.enableFileMetadataParsing = true;
testSettings.fileSource.enabled = false;
const result = migrateFileParsingSettings(testSettings);
expect(result.migrated).toBe(true);
expect(testSettings.fileSource.enabled).toBe(true);
expect(result.details).toContain('Migrated: Enable file metadata parsing → Enable FileSource');
});
it('should migrate tag-based parsing settings', () => {
testSettings.fileParsingConfig.enableTagBasedTaskParsing = true;
testSettings.fileParsingConfig.tagsToParseAsTasks = ['#custom-tag', '#work'];
testSettings.fileSource.recognitionStrategies.tags.enabled = false;
const result = migrateFileParsingSettings(testSettings);
expect(result.migrated).toBe(true);
expect(testSettings.fileSource.recognitionStrategies.tags.enabled).toBe(true);
expect(testSettings.fileSource.recognitionStrategies.tags.taskTags).toContain('#custom-tag');
expect(testSettings.fileSource.recognitionStrategies.tags.taskTags).toContain('#work');
});
it('should migrate metadata fields for task recognition', () => {
testSettings.fileParsingConfig.metadataFieldsToParseAsTasks = ['custom-field', 'project-task'];
testSettings.fileSource.recognitionStrategies.metadata.enabled = false;
const result = migrateFileParsingSettings(testSettings);
expect(result.migrated).toBe(true);
expect(testSettings.fileSource.recognitionStrategies.metadata.enabled).toBe(true);
expect(testSettings.fileSource.recognitionStrategies.metadata.taskFields).toContain('custom-field');
expect(testSettings.fileSource.recognitionStrategies.metadata.taskFields).toContain('project-task');
});
it('should migrate worker processing settings', () => {
testSettings.fileParsingConfig.enableWorkerProcessing = true;
const result = migrateFileParsingSettings(testSettings);
expect(result.migrated).toBe(true);
expect(testSettings.fileSource.performance.enableWorkerProcessing).toBe(true);
expect(result.details).toContain('Migrated: Worker processing setting');
});
it('should migrate task content source settings', () => {
testSettings.fileParsingConfig.taskContentFromMetadata = 'custom-title';
const result = migrateFileParsingSettings(testSettings);
expect(testSettings.fileSource.fileTaskProperties.contentSource).toBe('custom');
expect(testSettings.fileSource.fileTaskProperties.customContentField).toBe('custom-title');
expect(result.details).toContain('Migrated: Task content source');
});
it('should handle title content source correctly', () => {
testSettings.fileParsingConfig.taskContentFromMetadata = 'title';
const result = migrateFileParsingSettings(testSettings);
expect(testSettings.fileSource.fileTaskProperties.contentSource).toBe('title');
expect(testSettings.fileSource.fileTaskProperties.customContentField).toBeUndefined();
});
});
describe('cleanupDeprecatedSettings', () => {
it('should not cleanup if FileSource is disabled', () => {
testSettings.fileParsingConfig.enableFileMetadataParsing = true;
testSettings.fileSource.enabled = false;
const result = cleanupDeprecatedSettings(testSettings);
expect(result.migrated).toBe(false);
expect(result.warnings).toContain('Skipping cleanup: FileSource not enabled');
expect(testSettings.fileParsingConfig.enableFileMetadataParsing).toBe(true);
});
it('should cleanup deprecated settings when FileSource is enabled', () => {
testSettings.fileParsingConfig.enableFileMetadataParsing = true;
testSettings.fileParsingConfig.enableTagBasedTaskParsing = true;
testSettings.fileParsingConfig.metadataFieldsToParseAsTasks = ['old-field'];
testSettings.fileParsingConfig.tagsToParseAsTasks = ['#old-tag'];
testSettings.fileSource.enabled = true;
const result = cleanupDeprecatedSettings(testSettings);
expect(result.migrated).toBe(true);
expect(testSettings.fileParsingConfig.enableFileMetadataParsing).toBe(false);
expect(testSettings.fileParsingConfig.enableTagBasedTaskParsing).toBe(false);
expect(testSettings.fileParsingConfig.metadataFieldsToParseAsTasks).toEqual([]);
expect(testSettings.fileParsingConfig.tagsToParseAsTasks).toEqual([]);
expect(result.details).toContain('Disabled deprecated: Enable file metadata parsing');
expect(result.details).toContain('Disabled deprecated: Tag-based task parsing');
expect(result.details).toContain('Cleared deprecated: Metadata fields array');
expect(result.details).toContain('Cleared deprecated: Task tags array');
});
});
describe('runAllMigrations', () => {
it('should run complete migration workflow', () => {
// Set up scenario with duplicates
testSettings.fileParsingConfig.enableFileMetadataParsing = true;
testSettings.fileParsingConfig.enableTagBasedTaskParsing = true;
testSettings.fileParsingConfig.metadataFieldsToParseAsTasks = ['task-field'];
testSettings.fileParsingConfig.tagsToParseAsTasks = ['#task-tag'];
testSettings.fileSource.enabled = false;
const result = runAllMigrations(testSettings);
expect(result.migrated).toBe(true);
// Check migration happened
expect(testSettings.fileSource.enabled).toBe(true);
expect(testSettings.fileSource.recognitionStrategies.tags.enabled).toBe(true);
expect(testSettings.fileSource.recognitionStrategies.metadata.enabled).toBe(true);
// Check cleanup happened
expect(testSettings.fileParsingConfig.enableFileMetadataParsing).toBe(false);
expect(testSettings.fileParsingConfig.enableTagBasedTaskParsing).toBe(false);
expect(testSettings.fileParsingConfig.metadataFieldsToParseAsTasks).toEqual([]);
expect(testSettings.fileParsingConfig.tagsToParseAsTasks).toEqual([]);
// Check we have migration details
expect(result.details.length).toBeGreaterThan(0);
});
it('should not run cleanup if migration fails', () => {
// No migration needed
const result = runAllMigrations(testSettings);
expect(result.migrated).toBe(false);
expect(result.details.length).toBeLessThanOrEqual(1); // Only project config detail
});
});
describe('edge cases', () => {
it('should handle missing fileSource gracefully', () => {
// @ts-ignore - Simulating corrupted settings
delete testSettings.fileSource;
testSettings.fileParsingConfig.enableFileMetadataParsing = true;
const result = migrateFileParsingSettings(testSettings);
expect(result.migrated).toBe(true);
expect(testSettings.fileSource).toBeDefined();
expect(testSettings.fileSource.enabled).toBe(true);
expect(result.details).toContain('Created default FileSource configuration');
});
it('should merge tags without duplicates', () => {
testSettings.fileParsingConfig.enableTagBasedTaskParsing = true;
testSettings.fileParsingConfig.tagsToParseAsTasks = ['#task', '#work'];
testSettings.fileSource.recognitionStrategies.tags.taskTags = ['#task', '#existing'];
const result = migrateFileParsingSettings(testSettings);
const finalTags = testSettings.fileSource.recognitionStrategies.tags.taskTags;
expect(finalTags).toContain('#task');
expect(finalTags).toContain('#work');
expect(finalTags).toContain('#existing');
// Should not have duplicates
expect(finalTags.filter(tag => tag === '#task')).toHaveLength(1);
});
it('should merge metadata fields without duplicates', () => {
testSettings.fileParsingConfig.metadataFieldsToParseAsTasks = ['dueDate', 'custom'];
testSettings.fileSource.recognitionStrategies.metadata.taskFields = ['dueDate', 'existing'];
const result = migrateFileParsingSettings(testSettings);
const finalFields = testSettings.fileSource.recognitionStrategies.metadata.taskFields;
expect(finalFields).toContain('dueDate');
expect(finalFields).toContain('custom');
expect(finalFields).toContain('existing');
// Should not have duplicates
expect(finalFields.filter(field => field === 'dueDate')).toHaveLength(1);
});
});
});

View file

@ -0,0 +1,90 @@
/* Settings Migration Styling */
.setting-item-info.mod-warning {
border: 1px solid var(--background-modifier-error);
border-radius: 6px;
padding: 16px;
margin: 12px 0;
}
.setting-item-info.mod-warning .setting-item-name {
font-weight: 600;
color: var(--text-error);
margin-bottom: 8px;
}
.setting-item-info.mod-warning .setting-item-description {
color: var(--text-muted);
margin-bottom: 12px;
}
.setting-item-info.mod-warning .setting-item-description p {
margin: 4px 0;
}
.setting-item-info.mod-warning .setting-item-control {
display: flex;
gap: 8px;
align-items: center;
}
.setting-item-info.mod-warning button.mod-cta {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
font-weight: 500;
}
.setting-item-info.mod-warning button.mod-cta:hover {
background-color: var(--interactive-accent-hover);
}
.setting-item-info.mod-warning button.mod-muted {
background-color: transparent;
color: var(--text-muted);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
}
.setting-item-info.mod-warning button.mod-muted:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
/* Migration Success Indicator */
.setting-item-info.mod-success {
background-color: var(--color-green-bg);
border: 1px solid var(--color-green);
border-radius: 6px;
padding: 16px;
margin: 12px 0;
}
.setting-item-info.mod-success .setting-item-name {
font-weight: 600;
color: var(--color-green);
margin-bottom: 8px;
}
/* FileSource Integration Notice */
.file-source-notice {
background-color: var(--background-modifier-border);
border-radius: 6px;
padding: 12px;
margin: 8px 0;
}
.file-source-notice .notice-title {
font-weight: 600;
margin-bottom: 4px;
}
.file-source-notice .notice-description {
color: var(--text-muted);
font-size: 0.9em;
}

View file

@ -0,0 +1,298 @@
/**
* Settings Migration Utility
*
* Handles migration of duplicate and overlapping settings to consolidate
* configuration and eliminate confusion for users.
*/
import type { TaskProgressBarSettings } from "../common/setting-definition";
import type { FileSourceConfiguration } from "../types/file-source";
export interface MigrationResult {
migrated: boolean;
details: string[];
warnings: string[];
}
/**
* Migrate duplicate settings to unified FileSource configuration
*/
export function migrateFileParsingSettings(settings: TaskProgressBarSettings): MigrationResult {
const result: MigrationResult = {
migrated: false,
details: [],
warnings: []
};
// Ensure fileSource exists
if (!settings.fileSource) {
settings.fileSource = createDefaultFileSourceConfig();
result.details.push("Created default FileSource configuration");
}
// Migration 1: fileParsingConfig.enableFileMetadataParsing → fileSource.enabled
if (settings.fileParsingConfig?.enableFileMetadataParsing === true && !settings.fileSource.enabled) {
settings.fileSource.enabled = true;
result.migrated = true;
result.details.push("Migrated: Enable file metadata parsing → Enable FileSource");
}
// Migration 2: Tag-based parsing
if (settings.fileParsingConfig?.enableTagBasedTaskParsing === true) {
if (!settings.fileSource.recognitionStrategies.tags.enabled) {
settings.fileSource.recognitionStrategies.tags.enabled = true;
result.migrated = true;
result.details.push("Migrated: Tag-based task parsing → FileSource tag recognition");
}
// Migrate tag patterns
if (settings.fileParsingConfig.tagsToParseAsTasks?.length > 0) {
settings.fileSource.recognitionStrategies.tags.taskTags = [
...new Set([
...settings.fileSource.recognitionStrategies.tags.taskTags,
...settings.fileParsingConfig.tagsToParseAsTasks
])
];
result.details.push("Migrated: Tag patterns for task recognition");
}
}
// Migration 3: Metadata fields
if (settings.fileParsingConfig?.metadataFieldsToParseAsTasks?.length > 0) {
if (!settings.fileSource.recognitionStrategies.metadata.enabled) {
settings.fileSource.recognitionStrategies.metadata.enabled = true;
result.migrated = true;
result.details.push("Migrated: Metadata parsing → FileSource metadata recognition");
}
settings.fileSource.recognitionStrategies.metadata.taskFields = [
...new Set([
...settings.fileSource.recognitionStrategies.metadata.taskFields,
...settings.fileParsingConfig.metadataFieldsToParseAsTasks
])
];
result.details.push("Migrated: Metadata fields for task recognition");
}
// Migration 4: Worker processing settings
if (settings.fileParsingConfig?.enableWorkerProcessing === true) {
settings.fileSource.performance.enableWorkerProcessing = true;
result.migrated = true;
result.details.push("Migrated: Worker processing setting");
}
// Migration 5: Default task status
if (settings.fileParsingConfig?.defaultTaskStatus) {
settings.fileSource.fileTaskProperties.defaultStatus = settings.fileParsingConfig.defaultTaskStatus;
result.details.push("Migrated: Default task status");
}
// Migration 6: Task content source
if (settings.fileParsingConfig?.taskContentFromMetadata) {
if (settings.fileParsingConfig.taskContentFromMetadata === "title") {
settings.fileSource.fileTaskProperties.contentSource = "title";
} else {
settings.fileSource.fileTaskProperties.contentSource = "custom";
settings.fileSource.fileTaskProperties.customContentField = settings.fileParsingConfig.taskContentFromMetadata;
}
result.details.push("Migrated: Task content source");
}
// Check for conflicts and warn user
if (settings.fileParsingConfig?.enableFileMetadataParsing === true && settings.fileSource.enabled === false) {
result.warnings.push("Conflict detected: File metadata parsing enabled but FileSource disabled");
}
return result;
}
/**
* Clean up deprecated settings after successful migration
*/
export function cleanupDeprecatedSettings(settings: TaskProgressBarSettings): MigrationResult {
const result: MigrationResult = {
migrated: false,
details: [],
warnings: []
};
// Only clean up if FileSource is enabled (migration was successful)
if (!settings.fileSource?.enabled) {
result.warnings.push("Skipping cleanup: FileSource not enabled");
return result;
}
// Reset deprecated fileParsingConfig flags that are now handled by FileSource
if (settings.fileParsingConfig?.enableFileMetadataParsing === true) {
settings.fileParsingConfig.enableFileMetadataParsing = false;
result.migrated = true;
result.details.push("Disabled deprecated: Enable file metadata parsing");
}
if (settings.fileParsingConfig?.enableTagBasedTaskParsing === true) {
settings.fileParsingConfig.enableTagBasedTaskParsing = false;
result.migrated = true;
result.details.push("Disabled deprecated: Tag-based task parsing");
}
// Clear migrated arrays to avoid confusion
if (settings.fileParsingConfig?.metadataFieldsToParseAsTasks?.length > 0) {
settings.fileParsingConfig.metadataFieldsToParseAsTasks = [];
result.details.push("Cleared deprecated: Metadata fields array");
}
if (settings.fileParsingConfig?.tagsToParseAsTasks?.length > 0) {
settings.fileParsingConfig.tagsToParseAsTasks = [];
result.details.push("Cleared deprecated: Task tags array");
}
return result;
}
/**
* Migrate duplicate project settings
*/
export function migrateProjectSettings(settings: TaskProgressBarSettings): MigrationResult {
const result: MigrationResult = {
migrated: false,
details: [],
warnings: []
};
// Check for duplicate project detection methods
if (settings.projectConfig?.enableEnhancedProject && settings.fileSource?.enabled) {
result.warnings.push("Both enhanced project features and FileSource are enabled - consider consolidating");
}
// Note: fileSourceConfig was removed - if any code was using it,
// it should now use fileSource instead
result.details.push("Project configuration uses projectConfig for enhanced features");
return result;
}
/**
* Run all migrations
*/
export function runAllMigrations(settings: TaskProgressBarSettings): MigrationResult {
const results: MigrationResult[] = [];
// Run individual migrations
results.push(migrateFileParsingSettings(settings));
results.push(migrateProjectSettings(settings));
// Only cleanup if migrations were successful
const hasSuccessfulMigrations = results.some(r => r.migrated);
if (hasSuccessfulMigrations) {
results.push(cleanupDeprecatedSettings(settings));
}
// Combine results
return {
migrated: results.some(r => r.migrated),
details: results.flatMap(r => r.details),
warnings: results.flatMap(r => r.warnings)
};
}
/**
* Create default FileSource configuration
*/
function createDefaultFileSourceConfig(): FileSourceConfiguration {
return {
enabled: false,
recognitionStrategies: {
metadata: {
enabled: false,
taskFields: ["dueDate", "status", "priority", "assigned"],
requireAllFields: false
},
tags: {
enabled: false,
taskTags: ["#task", "#actionable", "#todo"],
matchMode: "exact"
},
templates: {
enabled: false,
templatePaths: ["Templates/Task Template.md"],
checkTemplateMetadata: true
},
paths: {
enabled: false,
taskPaths: ["Projects/", "Tasks/"],
matchMode: "prefix"
}
},
fileTaskProperties: {
contentSource: "filename",
stripExtension: true,
defaultStatus: " ",
defaultPriority: undefined
},
relationships: {
enableChildRelationships: true,
enableMetadataInheritance: true,
inheritanceFields: ["project", "priority", "context"]
},
performance: {
enableWorkerProcessing: true,
enableCaching: true,
cacheTTL: 300000
},
advanced: {
excludePatterns: ["**/.obsidian/**", "**/node_modules/**"],
maxFileSize: 1048576
},
statusMapping: {
enabled: true,
metadataToSymbol: {
'completed': 'x',
'done': 'x',
'in-progress': '/',
'planned': '?',
'cancelled': '-',
'not-started': ' '
},
symbolToMetadata: {
'x': 'completed',
'/': 'in-progress',
'?': 'planned',
'-': 'cancelled',
' ': 'not-started'
},
autoDetect: true,
caseSensitive: false
}
};
}
/**
* Check if settings have duplicates that need migration
*/
export function hasSettingsDuplicates(settings: TaskProgressBarSettings): boolean {
// Check for the main duplicate: file metadata parsing enabled in both systems
const fileParsingEnabled = settings.fileParsingConfig?.enableFileMetadataParsing === true;
const fileSourceEnabled = settings.fileSource?.enabled === true;
if (fileParsingEnabled && !fileSourceEnabled) {
return true;
}
// Check for tag parsing duplicates
const tagParsingEnabled = settings.fileParsingConfig?.enableTagBasedTaskParsing === true;
const tagRecognitionEnabled = settings.fileSource?.recognitionStrategies?.tags?.enabled === true;
if (tagParsingEnabled && !tagRecognitionEnabled) {
return true;
}
// Check for worker processing duplicates
const workerParsingEnabled = settings.fileParsingConfig?.enableWorkerProcessing === true;
const workerSourceEnabled = settings.fileSource?.performance?.enableWorkerProcessing === true;
if (workerParsingEnabled !== workerSourceEnabled) {
return true;
}
return false;
}

View file

@ -0,0 +1,167 @@
/**
* Settings Migration Manager
*
* Integrates with the main plugin to handle automatic migration of duplicate
* settings when the plugin loads or settings are updated.
*/
import { Notice } from "obsidian";
import type TaskProgressBarPlugin from "../index";
import {
runAllMigrations,
hasSettingsDuplicates,
type MigrationResult
} from "./SettingsMigration";
import { t } from "../translations/helper";
export class SettingsMigrationManager {
private plugin: TaskProgressBarPlugin;
private migrationCompleted = false;
constructor(plugin: TaskProgressBarPlugin) {
this.plugin = plugin;
}
/**
* Check and run migrations when plugin loads
*/
async onPluginLoad(): Promise<void> {
try {
if (hasSettingsDuplicates(this.plugin.settings)) {
await this.runMigrationWithUserConsent();
}
} catch (error) {
console.error("Settings migration failed:", error);
new Notice(t("Settings migration failed. Please check console for details."));
}
}
/**
* Check for duplicates before saving settings
*/
async onBeforeSave(): Promise<boolean> {
// If migration was already completed this session, don't run again
if (this.migrationCompleted) {
return true;
}
// Check if we need to migrate
if (hasSettingsDuplicates(this.plugin.settings)) {
const result = await this.runSilentMigration();
return result.migrated;
}
return true;
}
/**
* Run migration with user notification
*/
private async runMigrationWithUserConsent(): Promise<void> {
const result = runAllMigrations(this.plugin.settings);
if (result.migrated) {
this.migrationCompleted = true;
await this.plugin.saveSettings();
// Show user-friendly notice
new Notice(
t("Task Genius: Settings have been automatically migrated to remove duplicates. ") +
t("FileSource is now the unified system for file-based task recognition."),
10000
);
// Log details for advanced users
console.log("Task Genius Settings Migration:", {
details: result.details,
warnings: result.warnings
});
}
// Show warnings if any
if (result.warnings.length > 0) {
console.warn("Task Genius Migration Warnings:", result.warnings);
}
}
/**
* Run migration silently without user notification
*/
private async runSilentMigration(): Promise<MigrationResult> {
const result = runAllMigrations(this.plugin.settings);
if (result.migrated) {
this.migrationCompleted = true;
// Settings will be saved by the calling function
console.log("Task Genius: Silent settings migration completed", result.details);
}
return result;
}
/**
* Force migration (for manual execution)
*/
async forceMigration(): Promise<MigrationResult> {
const result = runAllMigrations(this.plugin.settings);
if (result.migrated) {
this.migrationCompleted = true;
await this.plugin.saveSettings();
new Notice(
t("Settings migration completed: ") + result.details.length + t(" changes applied"),
5000
);
} else {
new Notice(t("No settings migration needed"), 3000);
}
return result;
}
/**
* Check if user has conflicting settings
*/
hasConflicts(): boolean {
return hasSettingsDuplicates(this.plugin.settings);
}
/**
* Get migration status for display in settings
*/
getMigrationStatus(): {
needsMigration: boolean;
completedThisSession: boolean;
conflictCount: number;
} {
const needsMigration = hasSettingsDuplicates(this.plugin.settings);
// Count conflicts
let conflictCount = 0;
const settings = this.plugin.settings;
if (settings.fileParsingConfig?.enableFileMetadataParsing && !settings.fileSource?.enabled) {
conflictCount++;
}
if (settings.fileParsingConfig?.enableTagBasedTaskParsing && !settings.fileSource?.recognitionStrategies?.tags?.enabled) {
conflictCount++;
}
if (settings.fileParsingConfig?.enableWorkerProcessing !== settings.fileSource?.performance?.enableWorkerProcessing) {
conflictCount++;
}
return {
needsMigration,
completedThisSession: this.migrationCompleted,
conflictCount
};
}
/**
* Reset migration state (for testing)
*/
resetMigrationState(): void {
this.migrationCompleted = false;
}
}