Add rename option for file collisions during archive (#3)

* Add rename option for file collisions during archive

- Extend SimpleArchiverPromptModal to support optional third button
- Add generateUniqueArchivePath helper for timestamp-based naming
- Update archiveFile to show Replace/Rename/Cancel for collisions
- Generate unique filenames using YYYYMMDD-HHMMSS format
- Handle edge cases: files without extensions, multiple renames per second
- Preserve both existing and new files when rename is chosen
- Maintain backward compatibility with two-button modal usage

Fixes #18

* Use originalName in error message for consistency

- Move originalName declaration outside try-catch for proper scope
- Ensure both success and error messages reference original filename
- Prevents confusion if file.name is mutated during error handling
This commit is contained in:
Gareth Evans 2025-10-16 00:13:59 -07:00 committed by GitHub
parent e97ec7458a
commit 06e1f02256
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

104
main.ts
View file

@ -179,6 +179,42 @@ export default class SimpleArchiver extends Plugin {
return file instanceof TFolder;
}
private generateUniqueArchivePath(
file: TAbstractFile,
destinationPath: string
): string {
const fileName = file.name;
const lastDotIndex = fileName.lastIndexOf(".");
const baseName =
lastDotIndex > 0 ? fileName.substring(0, lastDotIndex) : fileName;
const extension =
lastDotIndex > 0 ? fileName.substring(lastDotIndex) : "";
// Generate timestamp-based suffix
const now = new Date();
const timestamp = now
.toISOString()
.replace(/[-:]/g, "")
.replace(/\..+/, "")
.replace("T", "-")
.substring(0, 15); // YYYYMMDD-HHMMSS
let counter = 0;
let uniquePath = normalizePath(
`${destinationPath}/${baseName}-${timestamp}${extension}`
);
// Handle edge case: multiple renames in same second
while (this.app.vault.getAbstractFileByPath(uniquePath) != null) {
counter++;
uniquePath = normalizePath(
`${destinationPath}/${baseName}-${timestamp}-${counter}${extension}`
);
}
return uniquePath;
}
private async archiveFile(file: TAbstractFile): Promise<ArchiveResult> {
if (this.isFileArchived(file)) {
return { success: false, message: "Item is already archived" };
@ -197,10 +233,10 @@ export default class SimpleArchiver extends Plugin {
return new Promise<ArchiveResult>((resolve) => {
const prompt = new SimpleArchiverPromptModal(
this.app,
isFolder ? "Merge folders?" : "Replace archived item?",
isFolder ? "Merge folders?" : "File exists in archive",
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?`,
: `An item called "${file.name}" already exists in the destination folder in the archive. Replace, rename, or cancel?`,
isFolder ? "Merge" : "Replace",
"Cancel",
async () => {
@ -228,7 +264,44 @@ export default class SimpleArchiver extends Plugin {
success: false,
message: "Archive operation cancelled",
});
}
},
// Only add rename option for files, not folders
isFolder ? undefined : "Rename",
isFolder
? undefined
: async () => {
const destinationPath = normalizePath(
`${this.settings.archiveFolder}/${file.parent?.path}`
);
const destinationFolder =
this.app.vault.getFolderByPath(destinationPath);
if (destinationFolder == null) {
await this.app.vault.createFolder(destinationPath);
}
const uniquePath = this.generateUniqueArchivePath(
file,
destinationPath
);
const originalName = file.name;
try {
await this.app.fileManager.renameFile(file, uniquePath);
const newName = uniquePath.split("/").pop() || file.name;
resolve({
success: true,
message: `${originalName} archived as ${newName}`,
});
} catch (error) {
resolve({
success: false,
message: `Unable to archive ${originalName}: ${error}`,
});
}
}
);
prompt.open();
});
@ -506,7 +579,9 @@ class SimpleArchiverPromptModal extends Modal {
yesButtonText: string,
noButtonText: string,
callback: () => Promise<void>,
cancelCallback: () => Promise<void>
cancelCallback: () => Promise<void>,
middleButtonText?: string,
middleCallback?: () => Promise<void>
) {
super(app);
@ -514,7 +589,7 @@ class SimpleArchiverPromptModal extends Modal {
this.setContent(message);
new Setting(this.contentEl)
const setting = new Setting(this.contentEl)
.addButton((btn) =>
btn
.setButtonText(yesButtonText)
@ -523,13 +598,24 @@ class SimpleArchiverPromptModal extends Modal {
callback();
this.close();
})
)
.addButton((btn) =>
btn.setButtonText(noButtonText).onClick(() => {
cancelCallback();
);
// Add optional middle button if provided
if (middleButtonText && middleCallback) {
setting.addButton((btn) =>
btn.setButtonText(middleButtonText).onClick(() => {
middleCallback();
this.close();
})
);
}
setting.addButton((btn) =>
btn.setButtonText(noButtonText).onClick(() => {
cancelCallback();
this.close();
})
);
}
}