From 3808c9e136e4c9e4a3eac74eef1cd44d9d84b470 Mon Sep 17 00:00:00 2001 From: Gareth Evans <63365672+ggfevans@users.noreply.github.com> Date: Wed, 15 Oct 2025 22:21:11 -0700 Subject: [PATCH 1/2] Fix leading zero preservation in unarchive operation (#1) Resolves issue where unarchiving files from folders with leading zeros (e.g., 00-inbox) incorrectly stripped the leading zero, causing files to be restored to wrong locations (e.g., 0-inbox). Root cause: Unarchive operation was not normalizing paths before passing them to Obsidian's file management APIs, unlike the archive operation which consistently normalized all paths. Changes: - Added normalizePath() to folder creation in moveFileOutOfArchive() - Added normalizePath() to file rename in moveFileOutOfArchive() This ensures consistent path handling between archive and unarchive operations, preventing leading zeros from being stripped. Fixes #15 Tested scenarios: - Single leading zero (00-inbox, 01-projects) - Multiple leading zeros (000-archive) - Single zero folders (0-temp) - Deeply nested numeric folders - Regular folders (regression test) --- main.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main.ts b/main.ts index 3fe3fba..5ae15a0 100644 --- a/main.ts +++ b/main.ts @@ -268,12 +268,12 @@ export default class SimpleArchiver extends Plugin { this.app.vault.getFolderByPath(originalParentPath); if (originalFolder == null) { - await this.app.vault.createFolder(originalParentPath); + await this.app.vault.createFolder(normalizePath(originalParentPath)); } } try { - await this.app.fileManager.renameFile(file, originalPath); + await this.app.fileManager.renameFile(file, normalizePath(originalPath)); return { success: true, message: `${file.name} unarchived successfully`, From e97ec7458a7ce828f4b16ac833cef8250eb5fb58 Mon Sep 17 00:00:00 2001 From: Gareth Evans <63365672+ggfevans@users.noreply.github.com> Date: Wed, 15 Oct 2025 22:22:04 -0700 Subject: [PATCH 2/2] Implement folder merge functionality for archive operations (#2) - Add smart merge when archiving folders with existing destination - Compare file contents before replacing (byte-level comparison) - Skip identical files, replace only when content differs - Track merge statistics (added, replaced, skipped, failed) - Handle errors gracefully without stopping entire merge - Only delete source folder when completely empty Fixes #5 --- main.ts | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 157 insertions(+), 10 deletions(-) diff --git a/main.ts b/main.ts index 5ae15a0..de362bd 100644 --- a/main.ts +++ b/main.ts @@ -9,6 +9,8 @@ import { PluginSettingTab, Setting, TAbstractFile, + TFile, + TFolder, } from "obsidian"; interface SimpleArchiverSettings { @@ -20,6 +22,14 @@ interface ArchiveResult { message: string; } +interface MergeResult { + filesAdded: number; + filesReplaced: number; + filesSkipped: number; + foldersCreated: number; + failedFiles: string[]; +} + const DEFAULT_SETTINGS: SimpleArchiverSettings = { archiveFolder: "Archive", }; @@ -98,7 +108,7 @@ export default class SimpleArchiver extends Plugin { if (result.success) { new Notice(result.message); } else { - new Error(result.message); + new Notice(result.message); } }); }); @@ -137,7 +147,7 @@ export default class SimpleArchiver extends Plugin { if (result.success) { new Notice(result.message); } else { - new Error(result.message); + new Notice(result.message); } }); }); @@ -165,6 +175,10 @@ export default class SimpleArchiver extends Plugin { return file.path.startsWith(this.settings.archiveFolder); } + private isFolder(file: TAbstractFile): file is TFolder { + return file instanceof TFolder; + } + private async archiveFile(file: TAbstractFile): Promise { if (this.isFileArchived(file)) { return { success: false, message: "Item is already archived" }; @@ -178,19 +192,36 @@ export default class SimpleArchiver extends Plugin { this.app.vault.getAbstractFileByPath(destinationFilePath); if (existingItem != null) { - // Same item exists in archive, prompt to replace + const isFolder = this.isFolder(file); + return new Promise((resolve) => { const prompt = new SimpleArchiverPromptModal( this.app, - "Replace archived item?", - `An item called "${file.name}" already exists in the destination folder in the archive. Would you like to replace it?`, - "Replace", + isFolder ? "Merge folders?" : "Replace archived item?", + isFolder + ? `A folder called "${file.name}" already exists in the archive. Merge the contents?` + : `An item called "${file.name}" already exists in the destination folder in the archive. Would you like to replace it?`, + isFolder ? "Merge" : "Replace", "Cancel", async () => { - await this.app.fileManager.trashFile(existingItem); - const response = await this.moveFileToArchive(file); - - resolve(response); + try { + if (isFolder) { + const response = await this.mergeFolderIntoArchive( + file as TFolder, + destinationFilePath + ); + resolve(response); + } else { + await this.app.fileManager.trashFile(existingItem); + const response = await this.moveFileToArchive(file); + resolve(response); + } + } catch (error) { + resolve({ + success: false, + message: `Archive operation failed: ${error}`, + }); + } }, async () => { resolve({ @@ -252,6 +283,122 @@ export default class SimpleArchiver extends Plugin { } } + private async mergeFolderIntoArchive( + sourceFolder: TFolder, + destinationFolderPath: string + ): Promise { + const stats: MergeResult = { + filesAdded: 0, + filesReplaced: 0, + filesSkipped: 0, + foldersCreated: 0, + failedFiles: [], + }; + + try { + await this.recursiveMerge(sourceFolder, destinationFolderPath, stats); + + // Only delete the source folder if it's empty + if (sourceFolder.children.length === 0) { + await this.app.vault.delete(sourceFolder); + } + + const totalFiles = stats.filesAdded + stats.filesReplaced; + let message = `Merged ${sourceFolder.name}: ${totalFiles} file${totalFiles !== 1 ? 's' : ''}`; + + const details: string[] = []; + if (stats.filesReplaced > 0) { + details.push(`${stats.filesReplaced} replaced`); + } + if (stats.filesSkipped > 0) { + details.push(`${stats.filesSkipped} skipped`); + } + if (stats.failedFiles.length > 0) { + details.push(`${stats.failedFiles.length} failed`); + } + + if (details.length > 0) { + message += ` (${details.join(', ')})`; + } + + if (stats.failedFiles.length > 0) { + message += `. Failed: ${stats.failedFiles.join(', ')}`; + } + + if (sourceFolder.children.length > 0) { + message += `. Source folder not deleted (contains remaining files)`; + } + + return { + success: stats.failedFiles.length === 0, + message: message, + }; + } catch (error) { + return { + success: false, + message: `Unable to merge ${sourceFolder.name}: ${error}`, + }; + } + } + + private async recursiveMerge( + sourceFolder: TFolder, + destinationBasePath: string, + stats: MergeResult + ): Promise { + // Create a copy of children array to avoid modification during iteration + const children = [...sourceFolder.children]; + + for (const child of children) { + if (this.isFolder(child)) { + const childDestPath = normalizePath( + `${destinationBasePath}/${child.name}` + ); + + const existingFolder = this.app.vault.getFolderByPath(childDestPath); + if (existingFolder == null) { + await this.app.vault.createFolder(childDestPath); + stats.foldersCreated++; + } + + await this.recursiveMerge(child, childDestPath, stats); + } else { + const childDestPath = normalizePath( + `${destinationBasePath}/${child.name}` + ); + + const existingFile = this.app.vault.getAbstractFileByPath(childDestPath); + + try { + if (existingFile != null) { + // Compare file contents before replacing + const sourceContent = await this.app.vault.read(child as TFile); + const existingContent = await this.app.vault.read(existingFile as TFile); + + if (sourceContent === existingContent) { + // Files are identical - just delete source, keep existing + await this.app.vault.delete(child); + stats.filesSkipped++; + } else { + // Files differ - replace existing with source + await this.app.fileManager.trashFile(existingFile); + await this.app.fileManager.renameFile(child, childDestPath); + stats.filesReplaced++; + } + } else { + // No existing file - just move the source + await this.app.fileManager.renameFile(child, childDestPath); + stats.filesAdded++; + } + } catch (error) { + // Track failed files but continue processing others + stats.failedFiles.push(child.name); + console.error(`Failed to process ${child.name}:`, error); + } + } + } + } + private async moveFileOutOfArchive( file: TAbstractFile ): Promise {