feat: add customizable storage directory with auto-migration

- Change default storage dir from .ultimate-todoist-sync to ultimate-todoist-sync (visible in file manager)
- Add storageDirectory and lastStorageDirectory to settings
- Implement migrateToNewDirectory() for automatic data migration when directory changes
- Smart merge: keep newer files during migration (compare mtime)
- Fix listFiles() to use adapter.list() instead of vault.getFiles()
- Add Storage Directory setting in Backup & Recovery section
- Update BackupOperation and TodoistToObsidian to use new dynamic paths
This commit is contained in:
HeroBlackInk 2026-02-21 18:11:02 +08:00
parent d1ac1bcb40
commit 0ea2074296
5 changed files with 202 additions and 99 deletions

View file

@ -22,7 +22,7 @@ export class BackupOperation {
}
getBackupFolder(): string {
return this.plugin.storagePathManager?.getBackupsFilesPath() || StoragePathManager.BACKUPS_FILES_FILE;
return this.plugin.storagePathManager?.getBackupsFilesPath() || 'ultimate-todoist-sync/backups/files';
}
async backupFile(filePath: string): Promise<string | null> {

View file

@ -47,6 +47,8 @@ export interface UltimateTodoistSyncSettings {
taskId?: string;
}>;
maxBackupsPerFile: number;
storageDirectory: string;
lastStorageDirectory: string | null;
}
export const DEFAULT_SETTINGS: UltimateTodoistSyncSettings = {
@ -75,6 +77,8 @@ export const DEFAULT_SETTINGS: UltimateTodoistSyncSettings = {
logRetentionDays: 365,
todayLogs: [],
maxBackupsPerFile: 100,
storageDirectory: 'ultimate-todoist-sync',
lastStorageDirectory: null,
}
export class UltimateTodoistSyncSettingTab extends PluginSettingTab {
@ -464,6 +468,46 @@ export class UltimateTodoistSyncSettingTab extends PluginSettingTab {
// ============================================
containerEl.createEl('h3', { text: 'Backup & Recovery' });
new Setting(containerEl)
.setName('Storage Directory')
.setDesc('Directory for plugin data storage. Changing this will migrate existing data.')
.addText(text => text
.setPlaceholder('ultimate-todoist-sync')
.setValue(this.plugin.settings.storageDirectory)
.onChange(async (value) => {
const newDir = value.trim();
const currentDir = this.plugin.settings.storageDirectory;
if (!newDir) {
new Notice('Directory name cannot be empty');
return;
}
if (newDir === currentDir) return;
const confirmed = confirm(
`Change storage directory from "${currentDir}" to "${newDir}"?\n\n` +
'Existing data will be migrated to the new location.'
);
if (!confirmed) return;
if (this.plugin.storagePathManager) {
const success = await this.plugin.storagePathManager.migrateToNewDirectory(newDir);
if (success) {
this.plugin.settings.storageDirectory = newDir;
await this.plugin.saveSettings();
new Notice('Storage directory changed. Please reload the plugin.');
} else {
new Notice('Migration failed. Check console for details.');
}
} else {
this.plugin.settings.storageDirectory = newDir;
await this.plugin.saveSettings();
new Notice('Storage directory updated. Please reload the plugin.');
}
})
);
new Setting(containerEl)
.setName('Backup Todoist Data')
.setDesc('Backup all Todoist data to vault.')

View file

@ -113,30 +113,16 @@ export class SettingsBackup {
return null;
}
const fileList = await adapter.list(backupDir);
const jsonFiles = fileList.files.filter(f =>
f.startsWith('settings-') && f.endsWith('.json')
);
const files = this.app.vault.getFiles()
.filter(f => f.path.startsWith(backupDir + '/') && f.name.startsWith('settings-') && f.name.endsWith('.json'))
.sort((a, b) => b.stat.mtime - a.stat.mtime);
if (jsonFiles.length === 0) {
if (files.length === 0) {
console.log('[SettingsBackup] No backup files found');
return null;
}
// 获取每个文件的修改时间并排序
const filesWithTime = await Promise.all(
jsonFiles.map(async (fileName) => {
const fullPath = `${backupDir}/${fileName}`;
const stat = await adapter.stat(fullPath);
return {
path: fullPath,
mtime: stat?.mtime || 0
};
})
);
filesWithTime.sort((a, b) => b.mtime - a.mtime);
return filesWithTime[0].path;
return files[0].path;
} catch (error) {
console.error('[SettingsBackup] Failed to get latest backup:', error);
return null;
@ -154,26 +140,12 @@ export class SettingsBackup {
return [];
}
const fileList = await adapter.list(backupDir);
const jsonFiles = fileList.files.filter(f =>
f.startsWith('settings-') && f.endsWith('.json')
);
const files = this.app.vault.getFiles()
.filter(f => f.path.startsWith(backupDir + '/') && f.name.startsWith('settings-') && f.name.endsWith('.json'))
.sort((a, b) => b.stat.mtime - a.stat.mtime);
// 获取每个文件的修改时间并排序
const filesWithTime = await Promise.all(
jsonFiles.map(async (fileName) => {
const fullPath = `${backupDir}/${fileName}`;
const stat = await adapter.stat(fullPath);
return {
path: fullPath,
mtime: stat?.mtime || 0
};
})
);
filesWithTime.sort((a, b) => b.mtime - a.mtime);
console.log('[SettingsBackup] Found backup files:', filesWithTime.map(f => f.path));
return filesWithTime.map(f => f.path);
console.log('[SettingsBackup] Found backup files:', files.map(f => f.path));
return files.map(f => f.path);
} catch (error) {
console.error('[SettingsBackup] Failed to get backup list:', error);
return [];
@ -220,36 +192,17 @@ export class SettingsBackup {
private async cleanOldBackups(): Promise<void> {
try {
const backupDir = this.getBackupDir();
const adapter = this.app.vault.adapter;
const files = this.app.vault.getFiles()
.filter(f => f.path.startsWith(backupDir + '/') && f.name.startsWith('settings-'))
.sort((a, b) => b.stat.mtime - a.stat.mtime);
const dirExists = await adapter.exists(backupDir);
if (!dirExists) return;
const fileList = await adapter.list(backupDir);
const jsonFiles = fileList.files.filter(f =>
f.startsWith('settings-') && f.endsWith('.json')
);
if (jsonFiles.length <= this.maxBackups) return;
// 获取每个文件的修改时间并排序
const filesWithTime = await Promise.all(
jsonFiles.map(async (fileName) => {
const fullPath = `${backupDir}/${fileName}`;
const stat = await adapter.stat(fullPath);
return {
path: fullPath,
mtime: stat?.mtime || 0
};
})
);
filesWithTime.sort((a, b) => b.mtime - a.mtime);
const filesToDelete = filesWithTime.slice(this.maxBackups);
for (const file of filesToDelete) {
await adapter.remove(file.path);
console.log('[SettingsBackup] Deleted old backup:', file.path);
if (files.length > this.maxBackups) {
const filesToDelete = files.slice(this.maxBackups);
const adapter = this.app.vault.adapter;
for (const file of filesToDelete) {
await adapter.remove(file.path);
console.log('[SettingsBackup] Deleted old backup:', file.path);
}
}
} catch (error) {
console.error('[SettingsBackup] Failed to clean old backups:', error);

View file

@ -4,14 +4,12 @@ import UltimateTodoistSyncForObsidian from '../main';
export class StoragePathManager {
private app: App;
private plugin: UltimateTodoistSyncForObsidian;
private basePath = '.ultimate-todoist-sync';
private appendLock = false;
static readonly SETTINGS_FILE = '.obsidian/plugins/ultimate-todoist-sync-for-obsidian/data.json';
static readonly SETTINGS_TEMP_FILE = '.obsidian/plugins/ultimate-todoist-sync-for-obsidian/data.json.tmp';
static readonly BACKUPS_TODOIST_FILE = '.ultimate-todoist-sync/backups/todoist';
static readonly BACKUPS_FILES_FILE = '.ultimate-todoist-sync/backups/files';
static readonly BACKUPS_SETTINGS_FILE = '.ultimate-todoist-sync/backups/settings';
static readonly DEFAULT_BASE_PATH = 'ultimate-todoist-sync';
static readonly LEGACY_BASE_PATH = '.ultimate-todoist-sync';
constructor(app: App, plugin: UltimateTodoistSyncForObsidian) {
this.app = app;
@ -19,27 +17,27 @@ export class StoragePathManager {
}
getBasePath(): string {
return this.basePath;
return this.plugin.settings?.storageDirectory || StoragePathManager.DEFAULT_BASE_PATH;
}
getLogsBasePath(): string {
return `${this.basePath}/logs`;
return `${this.getBasePath()}/logs`;
}
getBackupsBasePath(): string {
return `${this.basePath}/backups`;
return `${this.getBasePath()}/backups`;
}
getBackupsFilesPath(): string {
return StoragePathManager.BACKUPS_FILES_FILE;
return `${this.getBasePath()}/backups/files`;
}
getBackupsTodoistPath(): string {
return StoragePathManager.BACKUPS_TODOIST_FILE;
return `${this.getBasePath()}/backups/todoist`;
}
getBackupsSettingsPath(): string {
return StoragePathManager.BACKUPS_SETTINGS_FILE;
return `${this.getBasePath()}/backups/settings`;
}
async getLogsPath(): Promise<string> {
@ -100,12 +98,13 @@ export class StoragePathManager {
}
async ensureAllDirs(): Promise<boolean> {
const basePath = this.getBasePath();
const dirs = [
this.basePath,
basePath,
this.getLogsBasePath(),
StoragePathManager.BACKUPS_TODOIST_FILE,
StoragePathManager.BACKUPS_FILES_FILE,
StoragePathManager.BACKUPS_SETTINGS_FILE
`${basePath}/backups/todoist`,
`${basePath}/backups/files`,
`${basePath}/backups/settings`
];
for (const dir of dirs) {
@ -131,6 +130,105 @@ export class StoragePathManager {
return true;
}
async migrateToNewDirectory(newDir: string): Promise<boolean> {
const oldDir = this.plugin.settings?.lastStorageDirectory
|| StoragePathManager.LEGACY_BASE_PATH;
if (oldDir === newDir) {
console.log('[StoragePathManager] Directory unchanged, no migration needed');
return true;
}
console.log(`[StoragePathManager] Starting migration from "${oldDir}" to "${newDir}"`);
const adapter = this.app.vault.adapter;
try {
const oldExists = await adapter.exists(oldDir);
if (!oldExists) {
console.log('[StoragePathManager] Old directory does not exist, no migration needed');
this.plugin.settings.lastStorageDirectory = newDir;
return true;
}
const newExists = await adapter.exists(newDir);
if (!newExists) {
await adapter.mkdir(newDir);
}
await this.migrateDirectoryContents(oldDir, newDir);
console.log('[StoragePathManager] Migration completed successfully');
this.plugin.settings.lastStorageDirectory = newDir;
return true;
} catch (error) {
console.error('[StoragePathManager] Migration failed:', error);
return false;
}
}
private async migrateDirectoryContents(srcDir: string, destDir: string): Promise<void> {
const adapter = this.app.vault.adapter;
try {
const result = await adapter.list(srcDir);
for (const folder of result.folders) {
const newFolderPath = folder.replace(srcDir, destDir);
const folderExists = await adapter.exists(newFolderPath);
if (!folderExists) {
await adapter.mkdir(newFolderPath);
}
await this.migrateDirectoryContents(folder, newFolderPath);
}
for (const file of result.files) {
const newFilePath = file.replace(srcDir, destDir);
const content = await adapter.read(file);
const destExists = await adapter.exists(newFilePath);
if (destExists) {
const destContent = await adapter.read(newFilePath);
const srcMtime = await this.getFileMtime(file);
const destMtime = await this.getFileMtime(newFilePath);
if (srcMtime > destMtime) {
await adapter.write(newFilePath, content);
console.log(`[StoragePathManager] Updated file: ${newFilePath}`);
} else {
console.log(`[StoragePathManager] Kept newer file: ${newFilePath}`);
}
} else {
await adapter.write(newFilePath, content);
console.log(`[StoragePathManager] Migrated file: ${newFilePath}`);
}
await adapter.remove(file);
}
const remaining = await adapter.list(srcDir);
if (remaining.folders.length === 0 && remaining.files.length === 0) {
await adapter.rmdir(srcDir, true);
console.log(`[StoragePathManager] Removed empty directory: ${srcDir}`);
}
} catch (error) {
console.error('[StoragePathManager] Error during migration:', error);
throw error;
}
}
private async getFileMtime(path: string): Promise<number> {
try {
const file = this.app.vault.getAbstractFileByPath(path);
if (file && 'stat' in file) {
return file.stat.mtime;
}
return 0;
} catch {
return 0;
}
}
async readJsonFile<T>(path: string): Promise<T | null> {
try {
const adapter = this.app.vault.adapter;
@ -226,22 +324,31 @@ export class StoragePathManager {
if (!exists) {
return [];
}
const files: string[] = [];
const allFiles = this.app.vault.getFiles();
const prefix = dirPath + '/';
for (const file of allFiles) {
if (file.path.startsWith(prefix)) {
files.push(file.path);
}
}
return files;
const result = await adapter.list(dirPath);
return result.files;
} catch (error) {
console.error(`[StoragePathManager] Failed to list files in ${dirPath}:`, error);
return [];
}
}
async listFolders(dirPath: string): Promise<string[]> {
try {
const adapter = this.app.vault.adapter;
const exists = await adapter.exists(dirPath);
if (!exists) {
return [];
}
const result = await adapter.list(dirPath);
return result.folders;
} catch (error) {
console.error(`[StoragePathManager] Failed to list folders in ${dirPath}:`, error);
return [];
}
}
async deleteFile(path: string): Promise<boolean> {
try {
const adapter = this.app.vault.adapter;
@ -301,13 +408,12 @@ export class StoragePathManager {
return;
}
const fileObjects = files.map(path => {
const file = this.app.vault.getAbstractFileByPath(path) as import('obsidian').TFile | null;
return {
const fileObjects = await Promise.all(
files.map(async path => ({
path,
mtime: file?.stat.mtime || 0
};
});
mtime: await this.getFileMtime(path)
}))
);
fileObjects.sort((a, b) => b.mtime - a.mtime);

View file

@ -123,7 +123,7 @@ export class TodoistToObsidianSync {
const now: Date = new Date();
const timeString = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
const backupFolder = this.plugin.storagePathManager?.getBackupsTodoistPath() || StoragePathManager.BACKUPS_TODOIST_FILE;
const backupFolder = this.plugin.storagePathManager?.getBackupsTodoistPath() || 'ultimate-todoist-sync/backups/todoist';
const tempFileName = `todoist-data-backup-${timeString}.tmp`;
const fileName = `todoist-data-backup-${timeString}.json`;
const tempPath = `${backupFolder}/${tempFileName}`;