From 2c4efba24f5b55d8de532c5479bddd4e2183831e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 12 Mar 2026 21:38:38 +0100 Subject: [PATCH 1/8] fix: improve template file collision behavior --- docs/docs/Choices/TemplateChoice.md | 17 +- ...Template_CreateMOCNoteWithLinkDashboard.md | 2 +- src/constants.file-exists.test.ts | 55 +++ src/constants.ts | 49 +++ .../TemplateChoiceEngine.collision.test.ts | 411 ++++++++++++++++++ src/engine/TemplateChoiceEngine.ts | 37 +- src/engine/TemplateEngine.ts | 100 +++-- .../templateEngine-increment-canvas.test.ts | 232 ++++++---- .../ChoiceBuilder/templateChoiceBuilder.ts | 66 ++- src/types/choices/ITemplateChoice.ts | 4 +- src/types/choices/TemplateChoice.ts | 4 +- 11 files changed, 815 insertions(+), 162 deletions(-) create mode 100644 src/constants.file-exists.test.ts create mode 100644 src/engine/TemplateChoiceEngine.collision.test.ts diff --git a/docs/docs/Choices/TemplateChoice.md b/docs/docs/Choices/TemplateChoice.md index dc0cc746..026d6732 100644 --- a/docs/docs/Choices/TemplateChoice.md +++ b/docs/docs/Choices/TemplateChoice.md @@ -31,20 +31,21 @@ When either enabled mode is selected, you can choose where the link is placed: - **New line** - Places the link on a new line below the cursor -**Increment file name**. If a file with that name already exists, increment the file name with a number. So if a file called `untitled` already exists, the new file will be called `untitled1`. +**If the target file already exists**. Choose what QuickAdd should do when the target file already exists. Turn on **Use selected behavior automatically** to apply the selected mode without prompting, or turn it off to choose each time. **Open**. Will open the file you've created. By default, it opens in the active pane. If you enable **New tab**, it'll open in a new tab in the direction you specified. ## File Already Exists Behavior -When a file with the target name already exists, QuickAdd will prompt you with several options: +When a file with the target name already exists, QuickAdd can either prompt you or apply the selected behavior automatically: -- **Append to the bottom of the file**: Adds the template content to the end of the existing file -- **Append to the top of the file**: Adds the template content to the beginning of the existing file -- **Overwrite the file**: Replaces the entire file content with the template -- **Increment the file name**: Creates a new file with a number suffix (e.g., `note1.md`) -- **Nothing**: Opens the existing file without modification +- **Append to bottom**: Adds the template content to the end of the existing file +- **Append to top**: Adds the template content to the beginning of the existing file +- **Overwrite file**: Replaces the entire file content with the template +- **Increment trailing number**: Creates a new file by incrementing trailing digits while preserving zero padding when present (for example, `note009.md` becomes `note010.md`) +- **Append duplicate suffix**: Creates a new file by preserving the full base name and adding ` (1)`, ` (2)`, and so on (for example, `note.md` becomes `note (1).md`) +- **Do nothing**: Opens the existing file without modification -**Note**: When you select "Nothing", the existing file will automatically open, making it easy to quickly access files that already exist without needing to enable the "Open" setting. +**Note**: When you select "Do nothing", the existing file will automatically open, making it easy to quickly access files that already exist without needing to enable the "Open" setting. ![image](https://user-images.githubusercontent.com/29108628/121773888-3f680980-cb7f-11eb-919b-97d56ef9268e.png) diff --git a/docs/docs/Examples/Template_CreateMOCNoteWithLinkDashboard.md b/docs/docs/Examples/Template_CreateMOCNoteWithLinkDashboard.md index b044ffde..9745f361 100644 --- a/docs/docs/Examples/Template_CreateMOCNoteWithLinkDashboard.md +++ b/docs/docs/Examples/Template_CreateMOCNoteWithLinkDashboard.md @@ -85,7 +85,7 @@ outgoing links for this note. - **File Name Format**: `{{VALUE:moc_title}}` - **Create in folder**: your MOC folder, for example `MOCs` - **Open**: enabled -- **File already exists behavior**: `Increment the file name` +- **If the target file already exists**: `Increment trailing number` 4. Run the Template choice and enter a title such as `Alpha Project`. diff --git a/src/constants.file-exists.test.ts b/src/constants.file-exists.test.ts new file mode 100644 index 00000000..4354ff01 --- /dev/null +++ b/src/constants.file-exists.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + fileExistsAppendToBottom, + fileExistsAppendToTop, + fileExistsDoNothing, + fileExistsDuplicateSuffix, + fileExistsIncrement, + fileExistsOverwriteFile, + getFileExistsAutomationDescription, + getFileExistsBehaviorModeDescription, + getFileExistsSettingDescription, +} from "./constants"; + +describe("file exists helper copy", () => { + it("returns concrete mode descriptions", () => { + expect(getFileExistsBehaviorModeDescription(fileExistsAppendToBottom)).toBe( + "Adds the template content to the end of the existing file.", + ); + expect(getFileExistsBehaviorModeDescription(fileExistsAppendToTop)).toBe( + "Adds the template content to the beginning of the existing file.", + ); + expect(getFileExistsBehaviorModeDescription(fileExistsOverwriteFile)).toBe( + "Replaces the existing file content with the template.", + ); + expect(getFileExistsBehaviorModeDescription(fileExistsIncrement)).toBe( + "Changes trailing digits only. Example: Note009.md -> Note010.md.", + ); + expect( + getFileExistsBehaviorModeDescription(fileExistsDuplicateSuffix), + ).toBe( + "Keeps the original name and adds a duplicate marker. Example: tt0780504.md -> tt0780504 (1).md.", + ); + expect(getFileExistsBehaviorModeDescription(fileExistsDoNothing)).toBe( + "Leaves the file unchanged and opens the existing file.", + ); + }); + + it("returns distinct copy for automatic and prompted behavior", () => { + expect(getFileExistsAutomationDescription(true)).toBe( + "QuickAdd applies the selected behavior without asking.", + ); + expect(getFileExistsAutomationDescription(false)).toBe( + "QuickAdd prompts you each time the target file already exists.", + ); + }); + + it("combines automatic behavior and selected mode into one setting description", () => { + expect(getFileExistsSettingDescription(false, fileExistsDuplicateSuffix)).toBe( + "QuickAdd prompts you each time the target file already exists.", + ); + expect(getFileExistsSettingDescription(true, fileExistsDuplicateSuffix)).toBe( + "QuickAdd applies the selected behavior without asking. Keeps the original name and adds a duplicate marker. Example: tt0780504.md -> tt0780504 (1).md.", + ); + }); +}); diff --git a/src/constants.ts b/src/constants.ts index 0196110b..069950d8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -191,14 +191,63 @@ export const fileExistsAppendToBottom = "Append to the bottom of the file" as const; export const fileExistsAppendToTop = "Append to the top of the file" as const; export const fileExistsOverwriteFile = "Overwrite the file" as const; +export const fileExistsDuplicateSuffix = "Append duplicate suffix" as const; export const fileExistsDoNothing = "Nothing" as const; export const fileExistsChoices = [ fileExistsAppendToBottom, fileExistsAppendToTop, fileExistsOverwriteFile, fileExistsIncrement, + fileExistsDuplicateSuffix, fileExistsDoNothing, ] as const; +export type FileExistsMode = (typeof fileExistsChoices)[number]; +export const fileExistsModeLabels: Record = { + [fileExistsAppendToBottom]: "Append to bottom", + [fileExistsAppendToTop]: "Append to top", + [fileExistsOverwriteFile]: "Overwrite file", + [fileExistsIncrement]: "Increment trailing number", + [fileExistsDuplicateSuffix]: "Append duplicate suffix", + [fileExistsDoNothing]: "Do nothing", +}; +export const fileExistsModeDescriptions: Record = { + [fileExistsAppendToBottom]: + "Adds the template content to the end of the existing file.", + [fileExistsAppendToTop]: + "Adds the template content to the beginning of the existing file.", + [fileExistsOverwriteFile]: + "Replaces the existing file content with the template.", + [fileExistsIncrement]: + "Changes trailing digits only. Example: Note009.md -> Note010.md.", + [fileExistsDuplicateSuffix]: + "Keeps the original name and adds a duplicate marker. Example: tt0780504.md -> tt0780504 (1).md.", + [fileExistsDoNothing]: + "Leaves the file unchanged and opens the existing file.", +}; +export function getFileExistsBehaviorModeDescription( + mode: FileExistsMode, +): string { + return fileExistsModeDescriptions[mode]; +} +export function getFileExistsAutomationDescription( + setAutomatically: boolean, +): string { + return setAutomatically + ? "QuickAdd applies the selected behavior without asking." + : "QuickAdd prompts you each time the target file already exists."; +} +export function getFileExistsSettingDescription( + setAutomatically: boolean, + mode: FileExistsMode, +): string { + if (!setAutomatically) { + return getFileExistsAutomationDescription(false); + } + + return `${getFileExistsAutomationDescription(true)} ${getFileExistsBehaviorModeDescription( + mode, + )}`; +} // == MISC == // export const WIKI_LINK_REGEX = new RegExp(/\[\[([^\]]*)\]\]/); diff --git a/src/engine/TemplateChoiceEngine.collision.test.ts b/src/engine/TemplateChoiceEngine.collision.test.ts new file mode 100644 index 00000000..d8cb3308 --- /dev/null +++ b/src/engine/TemplateChoiceEngine.collision.test.ts @@ -0,0 +1,411 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../quickAddSettingsTab", () => { + const defaultSettings = { + choices: [], + inputPrompt: "single-line", + devMode: false, + templateFolderPath: "", + useSelectionAsCaptureValue: true, + announceUpdates: "major", + version: "0.0.0", + globalVariables: {}, + onePageInputEnabled: false, + disableOnlineFeatures: true, + enableRibbonIcon: false, + showCaptureNotification: true, + showInputCancellationNotification: true, + enableTemplatePropertyTypes: false, + ai: { + defaultModel: "Ask me", + defaultSystemPrompt: "", + promptTemplatesFolderPath: "", + showAssistant: true, + providers: [], + }, + migrations: { + migrateToMacroIDFromEmbeddedMacro: true, + useQuickAddTemplateFolder: false, + incrementFileNameSettingMoveToDefaultBehavior: false, + mutualExclusionInsertAfterAndWriteToBottomOfFile: false, + setVersionAfterUpdateModalRelease: false, + addDefaultAIProviders: false, + removeMacroIndirection: false, + migrateFileOpeningSettings: false, + backfillFileOpeningDefaults: false, + }, + }; + + return { + DEFAULT_SETTINGS: defaultSettings, + QuickAddSettingsTab: class {}, + }; +}); + +const { formatFileNameMock, formatFileContentMock } = vi.hoisted(() => { + const formatName = vi.fn<(format: string, prompt: string) => Promise>(); + const formatContent = vi + .fn<(...args: unknown[]) => Promise>() + .mockResolvedValue(""); + + return { + formatFileNameMock: formatName, + formatFileContentMock: formatContent, + }; +}); + +vi.mock("../formatters/completeFormatter", () => { + class CompleteFormatterMock { + constructor() {} + setLinkToCurrentFileBehavior() {} + setTitle() {} + async formatFileName(format: string, prompt: string) { + return formatFileNameMock(format, prompt); + } + async formatFileContent(...args: unknown[]) { + return await formatFileContentMock(...args); + } + getAndClearTemplatePropertyVars() { + return new Map(); + } + } + + return { + CompleteFormatter: CompleteFormatterMock, + formatFileNameMock, + formatFileContentMock, + }; +}); + +vi.mock("../utilityObsidian", () => ({ + getTemplater: vi.fn(() => ({})), + overwriteTemplaterOnce: vi.fn(), + getAllFolderPathsInVault: vi.fn(async () => []), + insertFileLinkToActiveView: vi.fn(), + openExistingFileTab: vi.fn(() => null), + openFile: vi.fn(), +})); + +vi.mock("../gui/GenericSuggester/genericSuggester", () => ({ + default: { + Suggest: vi.fn(), + }, +})); + +vi.mock("../main", () => ({ + default: class QuickAddMock {}, +})); + +vi.mock("obsidian-dataview", () => ({ + getAPI: vi.fn(), +})); + +import { TFile, type App } from "obsidian"; +import GenericSuggester from "../gui/GenericSuggester/genericSuggester"; +import type { IChoiceExecutor } from "../IChoiceExecutor"; +import { settingsStore } from "../settingsStore"; +import type ITemplateChoice from "../types/choices/ITemplateChoice"; +import { TemplateChoiceEngine } from "./TemplateChoiceEngine"; +import { + fileExistsAppendToBottom, + fileExistsDoNothing, + fileExistsDuplicateSuffix, + fileExistsIncrement, + fileExistsModeLabels, +} from "../constants"; + +const defaultSettingsState = structuredClone(settingsStore.getState()); + +const createTemplateChoice = (): ITemplateChoice => ({ + name: "Test Template Choice", + id: "choice-id", + type: "Template", + command: false, + templatePath: "Templates/Test.md", + folder: { + enabled: false, + folders: [], + chooseWhenCreatingNote: false, + createInSameFolderAsActiveFile: false, + chooseFromSubfolders: false, + }, + fileNameFormat: { enabled: false, format: "{{VALUE}}" }, + appendLink: false, + openFile: false, + fileOpening: { + location: "tab", + direction: "vertical", + mode: "source", + focus: false, + }, + fileExistsMode: fileExistsAppendToBottom, + setFileExistsBehavior: false, +}); + +const createExistingFile = (path: string) => { + const file = new TFile(); + file.path = path; + file.name = path.split("/").pop() ?? path; + file.extension = "md"; + file.basename = file.name.replace(/\.md$/, ""); + return file; +}; + +const createEngine = () => { + const app = { + workspace: { + getActiveFile: vi.fn(() => null), + }, + fileManager: { + getNewFileParent: vi.fn(() => ({ path: "" })), + }, + vault: { + getRoot: vi.fn(() => ({ path: "" })), + adapter: { + exists: vi.fn(async () => false), + }, + getAbstractFileByPath: vi.fn(), + getFiles: vi.fn(() => []), + createFolder: vi.fn(), + create: vi.fn(), + modify: vi.fn(), + }, + } as unknown as App; + + const choiceExecutor: IChoiceExecutor = { + execute: vi.fn(), + variables: new Map(), + signalAbort: vi.fn(), + consumeAbortSignal: vi.fn(), + }; + + const engine = new TemplateChoiceEngine( + app, + { settings: settingsStore.getState() } as any, + createTemplateChoice(), + choiceExecutor, + ); + + formatFileNameMock.mockResolvedValue("Test Template"); + formatFileContentMock.mockResolvedValue(""); + + return { app, engine }; +}; + +describe("TemplateChoiceEngine collision behavior", () => { + beforeEach(() => { + settingsStore.setState(structuredClone(defaultSettingsState)); + formatFileNameMock.mockReset(); + formatFileContentMock.mockReset(); + vi.mocked(GenericSuggester.Suggest).mockReset(); + }); + + it("prompts before applying increment mode when auto behavior is off", async () => { + const { app, engine } = createEngine(); + const existingFile = createExistingFile("Test Template.md"); + + engine.choice.fileExistsMode = fileExistsIncrement; + engine.choice.setFileExistsBehavior = false; + + (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); + (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( + existingFile, + ); + vi.mocked(GenericSuggester.Suggest).mockResolvedValue(fileExistsDoNothing); + + const incrementSpy = vi.spyOn( + engine as unknown as { + incrementFileName: (filePath: string) => Promise; + }, + "incrementFileName", + ); + + await engine.run(); + + expect(GenericSuggester.Suggest).toHaveBeenCalledWith( + app, + expect.arrayContaining([ + fileExistsModeLabels[fileExistsIncrement], + fileExistsModeLabels[fileExistsDuplicateSuffix], + ]), + expect.any(Array), + "If the target file already exists", + ); + expect(incrementSpy).not.toHaveBeenCalled(); + expect(app.vault.adapter.exists).toHaveBeenCalledWith("Test Template.md"); + }); + + it("creates an incremented file from the original target after prompting", async () => { + const { app, engine } = createEngine(); + const existingFile = createExistingFile("Test Template.md"); + const createdFile = createExistingFile("Test Template1.md"); + + engine.choice.fileExistsMode = fileExistsIncrement; + engine.choice.setFileExistsBehavior = false; + + (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); + (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( + existingFile, + ); + vi.mocked(GenericSuggester.Suggest).mockResolvedValue(fileExistsIncrement); + + const incrementSpy = vi + .spyOn( + engine as unknown as { + incrementFileName: (filePath: string) => Promise; + }, + "incrementFileName", + ) + .mockResolvedValue("Test Template1.md"); + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + await engine.run(); + + expect(incrementSpy).toHaveBeenCalledWith("Test Template.md"); + expect(createSpy).toHaveBeenCalledWith( + "Test Template1.md", + engine.choice.templatePath, + ); + }); + + it("creates a duplicate-suffix file from the original target after prompting", async () => { + const { app, engine } = createEngine(); + const existingFile = createExistingFile("Test Template.md"); + const createdFile = createExistingFile("Test Template (1).md"); + + engine.choice.fileExistsMode = fileExistsIncrement; + engine.choice.setFileExistsBehavior = false; + + (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); + (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( + existingFile, + ); + vi.mocked(GenericSuggester.Suggest).mockResolvedValue( + fileExistsDuplicateSuffix, + ); + + const duplicateSpy = vi + .spyOn( + engine as unknown as { + appendDuplicateSuffix: (filePath: string) => Promise; + }, + "appendDuplicateSuffix", + ) + .mockResolvedValue("Test Template (1).md"); + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + await engine.run(); + + expect(duplicateSpy).toHaveBeenCalledWith("Test Template.md"); + expect(createSpy).toHaveBeenCalledWith( + "Test Template (1).md", + engine.choice.templatePath, + ); + }); + + it("increments automatically when auto behavior is on", async () => { + const { app, engine } = createEngine(); + const existingFile = createExistingFile("Test Template.md"); + const createdFile = createExistingFile("Test Template1.md"); + + engine.choice.fileExistsMode = fileExistsIncrement; + engine.choice.setFileExistsBehavior = true; + + (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); + (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( + existingFile, + ); + + const incrementSpy = vi + .spyOn( + engine as unknown as { + incrementFileName: (filePath: string) => Promise; + }, + "incrementFileName", + ) + .mockResolvedValue("Test Template1.md"); + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + await engine.run(); + + expect(GenericSuggester.Suggest).not.toHaveBeenCalled(); + expect(incrementSpy).toHaveBeenCalledWith("Test Template.md"); + expect(createSpy).toHaveBeenCalledWith( + "Test Template1.md", + engine.choice.templatePath, + ); + }); + + it("applies duplicate suffix automatically when auto behavior is on", async () => { + const { app, engine } = createEngine(); + const existingFile = createExistingFile("Test Template.md"); + const createdFile = createExistingFile("Test Template (1).md"); + + engine.choice.fileExistsMode = fileExistsDuplicateSuffix; + engine.choice.setFileExistsBehavior = true; + + (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); + (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( + existingFile, + ); + + const duplicateSpy = vi + .spyOn( + engine as unknown as { + appendDuplicateSuffix: (filePath: string) => Promise; + }, + "appendDuplicateSuffix", + ) + .mockResolvedValue("Test Template (1).md"); + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + await engine.run(); + + expect(GenericSuggester.Suggest).not.toHaveBeenCalled(); + expect(duplicateSpy).toHaveBeenCalledWith("Test Template.md"); + expect(createSpy).toHaveBeenCalledWith( + "Test Template (1).md", + engine.choice.templatePath, + ); + }); +}); diff --git a/src/engine/TemplateChoiceEngine.ts b/src/engine/TemplateChoiceEngine.ts index 06333556..7c7ddb8b 100644 --- a/src/engine/TemplateChoiceEngine.ts +++ b/src/engine/TemplateChoiceEngine.ts @@ -7,7 +7,9 @@ import { fileExistsAppendToTop, fileExistsChoices, fileExistsDoNothing, + fileExistsDuplicateSuffix, fileExistsIncrement, + fileExistsModeLabels, fileExistsOverwriteFile, VALUE_SYNTAX, } from "../constants"; @@ -90,19 +92,16 @@ export class TemplateChoiceEngine extends TemplateEngine { strippedPrefix, ); - let filePath = this.normalizeTemplateFilePath( + const targetFilePath = this.normalizeTemplateFilePath( treatAsVaultRelativePath ? "" : folderPath, fileName, this.choice.templatePath, ); - if (this.choice.fileExistsMode === fileExistsIncrement) - filePath = await this.incrementFileName(filePath); - let createdFile: TFile | null; let shouldAutoOpen = false; - if (await this.app.vault.adapter.exists(filePath)) { - const file = this.findExistingFile(filePath); + if (await this.app.vault.adapter.exists(targetFilePath)) { + const file = this.findExistingFile(targetFilePath); if ( !(file instanceof TFile) || (file.extension !== "md" && @@ -110,20 +109,20 @@ export class TemplateChoiceEngine extends TemplateEngine { file.extension !== "base") ) { log.logError( - `'${filePath}' already exists but could not be resolved as a markdown, canvas, or base file.`, + `'${targetFilePath}' already exists but could not be resolved as a markdown, canvas, or base file.`, ); return; } - let userChoice: (typeof fileExistsChoices)[number] = - this.choice.fileExistsMode; + let userChoice = this.choice.fileExistsMode; if (!this.choice.setFileExistsBehavior) { try { userChoice = await GenericSuggester.Suggest( this.app, + fileExistsChoices.map((choice) => fileExistsModeLabels[choice]), [...fileExistsChoices], - [...fileExistsChoices], + "If the target file already exists", ); } catch (error) { if (isCancellationError(error)) { @@ -160,24 +159,36 @@ export class TemplateChoiceEngine extends TemplateEngine { log.logMessage(`Opening existing file: ${file.path}`); break; case fileExistsIncrement: { - const incrementFileName = await this.incrementFileName(filePath); + const incrementFileName = await this.resolveCollisionFilePath( + targetFilePath, + userChoice, + ); createdFile = await this.createFileWithTemplate( incrementFileName, this.choice.templatePath, ); break; } + case fileExistsDuplicateSuffix: { + const duplicateSuffixFileName = + await this.resolveCollisionFilePath(targetFilePath, userChoice); + createdFile = await this.createFileWithTemplate( + duplicateSuffixFileName, + this.choice.templatePath, + ); + break; + } default: log.logWarning("File not written to."); return; } } else { createdFile = await this.createFileWithTemplate( - filePath, + targetFilePath, this.choice.templatePath, ); if (!createdFile) { - log.logWarning(`Could not create file '${filePath}'.`); + log.logWarning(`Could not create file '${targetFilePath}'.`); return; } } diff --git a/src/engine/TemplateEngine.ts b/src/engine/TemplateEngine.ts index 0726d071..899c8be2 100644 --- a/src/engine/TemplateEngine.ts +++ b/src/engine/TemplateEngine.ts @@ -16,6 +16,7 @@ import { CANVAS_FILE_EXTENSION_REGEX, MARKDOWN_FILE_EXTENSION_REGEX, } from "../constants"; +import type { FileExistsMode } from "../constants"; import { reportError } from "../utils/errorUtils"; import { basenameWithoutMdOrCanvas } from "../utils/pathUtils"; import { @@ -466,43 +467,80 @@ export abstract class TemplateEngine extends QuickAddEngine { return `${actualFolderPath}${formattedFileName}${extension}`; } - protected async incrementFileName(fileName: string) { + protected async resolveCollisionFilePath( + fileName: string, + mode: FileExistsMode, + ): Promise { + switch (mode) { + case "Increment the file name": + return await this.incrementFileName(fileName); + case "Append duplicate suffix": + return await this.appendDuplicateSuffix(fileName); + default: + return fileName; + } + } + + protected async incrementFileName(fileName: string): Promise { const fileExists = await this.app.vault.adapter.exists(fileName); - let newFileName = fileName; + if (!fileExists) { + return fileName; + } - // Determine the extension from the filename and construct a matching regex - let extension = ".md"; - if (CANVAS_FILE_EXTENSION_REGEX.test(fileName)) { - extension = ".canvas"; - } else if (BASE_FILE_EXTENSION_REGEX.test(fileName)) { - extension = ".base"; + const { basename, extension } = this.splitCollisionFileName(fileName); + const match = basename.match(/^(.*?)(\d+)$/); + const nextBasename = match + ? `${match[1]}${String(parseInt(match[2], 10) + 1).padStart( + match[2].length, + "0", + )}` + : `${basename}1`; + const nextFileName = `${nextBasename}${extension}`; + + if (await this.app.vault.adapter.exists(nextFileName)) { + return await this.incrementFileName(nextFileName); } - const extPattern = extension.replace(/\./g, "\\."); - const numberWithExtRegex = new RegExp(`(\\d*)${extPattern}$`); - const exec = numberWithExtRegex.exec(fileName); - const numStr = exec?.[1]; - - if (fileExists && numStr !== undefined) { - if (numStr.length > 0) { - const number = parseInt(numStr, 10); - if (Number.isNaN(number)) { - throw new Error("detected numbers but couldn't get them."); - } - newFileName = newFileName.replace(numberWithExtRegex, `${number + 1}${extension}`); - } else { - // No digits previously; insert 1 before extension - newFileName = newFileName.replace(new RegExp(`${extPattern}$`), `1${extension}`); - } - } else if (fileExists) { - // No match; simply append 1 before the extension - newFileName = newFileName.replace(new RegExp(`${extPattern}$`), `1${extension}`); + + return nextFileName; + } + + protected async appendDuplicateSuffix(fileName: string): Promise { + const fileExists = await this.app.vault.adapter.exists(fileName); + if (!fileExists) { + return fileName; + } + + const { basename, extension } = this.splitCollisionFileName(fileName); + const match = basename.match(/^(.*) \((\d+)\)$/); + const nextBasename = match + ? `${match[1]} (${parseInt(match[2], 10) + 1})` + : `${basename} (1)`; + const nextFileName = `${nextBasename}${extension}`; + + if (await this.app.vault.adapter.exists(nextFileName)) { + return await this.appendDuplicateSuffix(nextFileName); } - const newFileExists = await this.app.vault.adapter.exists(newFileName); - if (newFileExists) - newFileName = await this.incrementFileName(newFileName); + return nextFileName; + } - return newFileName; + private splitCollisionFileName(fileName: string) { + if (CANVAS_FILE_EXTENSION_REGEX.test(fileName)) { + return { + basename: fileName.replace(CANVAS_FILE_EXTENSION_REGEX, ""), + extension: ".canvas", + }; + } + if (BASE_FILE_EXTENSION_REGEX.test(fileName)) { + return { + basename: fileName.replace(BASE_FILE_EXTENSION_REGEX, ""), + extension: ".base", + }; + } + return { + basename: fileName.replace(MARKDOWN_FILE_EXTENSION_REGEX, ""), + extension: ".md", + }; } protected async createFileWithTemplate( diff --git a/src/engine/templateEngine-increment-canvas.test.ts b/src/engine/templateEngine-increment-canvas.test.ts index 655de852..6b7e8792 100644 --- a/src/engine/templateEngine-increment-canvas.test.ts +++ b/src/engine/templateEngine-increment-canvas.test.ts @@ -1,96 +1,152 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { TemplateEngine } from './TemplateEngine'; -import type { App } from 'obsidian'; -import type QuickAdd from '../main'; -import type { IChoiceExecutor } from '../IChoiceExecutor'; - -// Minimal mocks -vi.mock('../formatters/completeFormatter', () => ({ - CompleteFormatter: vi.fn().mockImplementation(() => ({ - setTitle: vi.fn(), - formatFileContent: vi.fn(async (c: string) => c), - formatFileName: vi.fn(async (n: string) => n), - })), +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TemplateEngine } from "./TemplateEngine"; +import type { App } from "obsidian"; +import type QuickAdd from "../main"; +import type { IChoiceExecutor } from "../IChoiceExecutor"; + +vi.mock("../formatters/completeFormatter", () => ({ + CompleteFormatter: vi.fn().mockImplementation(() => ({ + setTitle: vi.fn(), + formatFileContent: vi.fn(async (content: string) => content), + formatFileName: vi.fn(async (name: string) => name), + })), })); -vi.mock('../utilityObsidian', () => ({ - getTemplater: vi.fn(() => null), - overwriteTemplaterOnce: vi.fn().mockResolvedValue(undefined), +vi.mock("../utilityObsidian", () => ({ + getTemplater: vi.fn(() => null), + overwriteTemplaterOnce: vi.fn().mockResolvedValue(undefined), })); class TestTemplateEngine extends TemplateEngine { - constructor(app: App, plugin: QuickAdd, choiceExecutor: IChoiceExecutor) { - super(app, plugin, choiceExecutor); - } - public async run(): Promise {} - public async testIncrement(fileName: string) { - return await this.incrementFileName(fileName); - } + constructor(app: App, plugin: QuickAdd, choiceExecutor: IChoiceExecutor) { + super(app, plugin, choiceExecutor); + } + + public async run(): Promise {} + + public async testIncrement(fileName: string) { + return await this.incrementFileName(fileName); + } + + public async testDuplicateSuffix(fileName: string) { + return await this.appendDuplicateSuffix(fileName); + } } -describe('TemplateEngine - incrementFileName for .canvas', () => { - let engine: TestTemplateEngine; - let mockApp: App; - let mockPlugin: QuickAdd; - let mockChoiceExecutor: IChoiceExecutor; - - beforeEach(() => { - vi.clearAllMocks(); - - mockApp = { - vault: { - adapter: { - exists: vi.fn(), - }, - create: vi.fn(), - }, - } as any; - - mockPlugin = {} as any; - mockChoiceExecutor = {} as any; - engine = new TestTemplateEngine(mockApp, mockPlugin, mockChoiceExecutor); - }); - - it('appends 1 before .canvas when no number exists', async () => { - (mockApp.vault.adapter.exists as any) - .mockResolvedValueOnce(true) // Note.canvas exists - .mockResolvedValueOnce(false); // Note1.canvas does not exist - const out = await engine.testIncrement('Note.canvas'); - expect(out).toBe('Note1.canvas'); - }); - - it('increments trailing number for .canvas', async () => { - (mockApp.vault.adapter.exists as any) - .mockResolvedValueOnce(true) // Note1.canvas exists - .mockResolvedValueOnce(false); // Note2.canvas does not exist - const out = await engine.testIncrement('Note1.canvas'); - expect(out).toBe('Note2.canvas'); - }); - - it('recurses until available name for .canvas', async () => { - (mockApp.vault.adapter.exists as any).mockImplementation(async (p: string) => { - if (p === 'Note.canvas') return true; - if (p === 'Note1.canvas') return true; - if (p === 'Note2.canvas') return false; - return false; - }); - const out = await engine.testIncrement('Note.canvas'); - expect(out).toBe('Note2.canvas'); - }); - - it('works similarly for .md', async () => { - (mockApp.vault.adapter.exists as any) - .mockResolvedValueOnce(true) // Doc.md exists - .mockResolvedValueOnce(false); - const out = await engine.testIncrement('Doc.md'); - expect(out).toBe('Doc1.md'); - }); - - it('works similarly for .base', async () => { - (mockApp.vault.adapter.exists as any) - .mockResolvedValueOnce(true) // Board.base exists - .mockResolvedValueOnce(false); - const out = await engine.testIncrement('Board.base'); - expect(out).toBe('Board1.base'); - }); +describe("TemplateEngine collision file naming", () => { + let engine: TestTemplateEngine; + let mockApp: App; + + beforeEach(() => { + vi.clearAllMocks(); + + mockApp = { + vault: { + adapter: { + exists: vi.fn(), + }, + create: vi.fn(), + }, + } as unknown as App; + + engine = new TestTemplateEngine(mockApp, {} as QuickAdd, {} as IChoiceExecutor); + }); + + it("appends 1 before .md when no number exists", async () => { + (mockApp.vault.adapter.exists as ReturnType) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect(engine.testIncrement("Note.md")).resolves.toBe("Note1.md"); + }); + + it("preserves zero padding for markdown files", async () => { + (mockApp.vault.adapter.exists as ReturnType) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect(engine.testIncrement("Note009.md")).resolves.toBe( + "Note010.md", + ); + }); + + it("preserves zero padding for identifier-like markdown files", async () => { + (mockApp.vault.adapter.exists as ReturnType) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect(engine.testIncrement("tt0780504.md")).resolves.toBe( + "tt0780505.md", + ); + }); + + it("preserves zero padding for .canvas files", async () => { + (mockApp.vault.adapter.exists as ReturnType) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect(engine.testIncrement("tt009.canvas")).resolves.toBe( + "tt010.canvas", + ); + }); + + it("preserves zero padding for .base files", async () => { + (mockApp.vault.adapter.exists as ReturnType) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect(engine.testIncrement("tt009.base")).resolves.toBe( + "tt010.base", + ); + }); + + it("recurses incrementing until an available file name is found", async () => { + (mockApp.vault.adapter.exists as ReturnType).mockImplementation( + async (path: string) => { + return path === "Note.md" || path === "Note1.md"; + }, + ); + + await expect(engine.testIncrement("Note.md")).resolves.toBe("Note2.md"); + }); + + it("appends a duplicate suffix to markdown files", async () => { + (mockApp.vault.adapter.exists as ReturnType) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect(engine.testDuplicateSuffix("Note.md")).resolves.toBe( + "Note (1).md", + ); + }); + + it("increments an existing duplicate suffix", async () => { + (mockApp.vault.adapter.exists as ReturnType) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect(engine.testDuplicateSuffix("Note (1).md")).resolves.toBe( + "Note (2).md", + ); + }); + + it("preserves trailing digits when adding a duplicate suffix", async () => { + (mockApp.vault.adapter.exists as ReturnType) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect(engine.testDuplicateSuffix("Note1.md")).resolves.toBe( + "Note1 (1).md", + ); + }); + + it("adds a duplicate suffix for identifier-like markdown files", async () => { + (mockApp.vault.adapter.exists as ReturnType) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect(engine.testDuplicateSuffix("tt0780504.md")).resolves.toBe( + "tt0780504 (1).md", + ); + }); }); diff --git a/src/gui/ChoiceBuilder/templateChoiceBuilder.ts b/src/gui/ChoiceBuilder/templateChoiceBuilder.ts index 437a06fe..8f9b393c 100644 --- a/src/gui/ChoiceBuilder/templateChoiceBuilder.ts +++ b/src/gui/ChoiceBuilder/templateChoiceBuilder.ts @@ -10,7 +10,10 @@ import { fileExistsAppendToBottom, fileExistsAppendToTop, fileExistsDoNothing, + fileExistsDuplicateSuffix, fileExistsIncrement, + getFileExistsSettingDescription, + fileExistsModeLabels, fileExistsOverwriteFile, } from "src/constants"; import { FileNameDisplayFormatter } from "../../formatters/fileNameDisplayFormatter"; @@ -420,35 +423,64 @@ export class TemplateChoiceBuilder extends ChoiceBuilder { } private addFileAlreadyExistsSetting(): void { - const fileAlreadyExistsSetting: Setting = new Setting(this.contentEl); - fileAlreadyExistsSetting - .setName("Set default behavior if file already exists") + if (!this.choice.fileExistsMode) + this.choice.fileExistsMode = fileExistsDoNothing; + + const setting = new Setting(this.contentEl) + .setName("If the target file already exists") .setDesc( - "Set default behavior rather then prompting user on what to do if a file already exists.", + getFileExistsSettingDescription( + this.choice.setFileExistsBehavior, + this.choice.fileExistsMode, + ), ) .addToggle((toggle) => { toggle.setValue(this.choice.setFileExistsBehavior); toggle.onChange((value) => { this.choice.setFileExistsBehavior = value; + this.reload(); }); - }) - .addDropdown((dropdown) => { - dropdown.selectEl.style.marginLeft = "10px"; - - if (!this.choice.fileExistsMode) - this.choice.fileExistsMode = fileExistsDoNothing; + }); + if (this.choice.setFileExistsBehavior) { + setting.addDropdown((dropdown) => { + dropdown.selectEl.style.marginLeft = "10px"; dropdown - .addOption(fileExistsAppendToBottom, fileExistsAppendToBottom) - .addOption(fileExistsAppendToTop, fileExistsAppendToTop) - .addOption(fileExistsIncrement, fileExistsIncrement) - .addOption(fileExistsOverwriteFile, fileExistsOverwriteFile) - .addOption(fileExistsDoNothing, fileExistsDoNothing) + .addOption( + fileExistsAppendToBottom, + fileExistsModeLabels[fileExistsAppendToBottom], + ) + .addOption( + fileExistsAppendToTop, + fileExistsModeLabels[fileExistsAppendToTop], + ) + .addOption( + fileExistsOverwriteFile, + fileExistsModeLabels[fileExistsOverwriteFile], + ) + .addOption( + fileExistsIncrement, + fileExistsModeLabels[fileExistsIncrement], + ) + .addOption( + fileExistsDuplicateSuffix, + fileExistsModeLabels[fileExistsDuplicateSuffix], + ) + .addOption( + fileExistsDoNothing, + fileExistsModeLabels[fileExistsDoNothing], + ) .setValue(this.choice.fileExistsMode) .onChange( - (value: (typeof fileExistsChoices)[number]) => - (this.choice.fileExistsMode = value), + (value: (typeof fileExistsChoices)[number]) => { + this.choice.fileExistsMode = value; + setting.descEl.textContent = getFileExistsSettingDescription( + this.choice.setFileExistsBehavior, + value, + ); + }, ); }); + } } } diff --git a/src/types/choices/ITemplateChoice.ts b/src/types/choices/ITemplateChoice.ts index 569198a6..b4e67e66 100644 --- a/src/types/choices/ITemplateChoice.ts +++ b/src/types/choices/ITemplateChoice.ts @@ -1,5 +1,5 @@ import type IChoice from "./IChoice"; -import type { fileExistsChoices } from "src/constants"; +import type { FileExistsMode } from "src/constants"; import type { AppendLinkOptions } from "../linkPlacement"; import type { OpenLocation, FileViewMode2 } from "../fileOpening"; @@ -26,6 +26,6 @@ export default interface ITemplateChoice extends IChoice { mode: FileViewMode2; focus: boolean; }; - fileExistsMode: (typeof fileExistsChoices)[number]; + fileExistsMode: FileExistsMode; setFileExistsBehavior: boolean; } diff --git a/src/types/choices/TemplateChoice.ts b/src/types/choices/TemplateChoice.ts index 44c7fe75..3e19e501 100644 --- a/src/types/choices/TemplateChoice.ts +++ b/src/types/choices/TemplateChoice.ts @@ -1,6 +1,6 @@ import type ITemplateChoice from "./ITemplateChoice"; import { Choice } from "./Choice"; -import type { fileExistsChoices } from "src/constants"; +import type { FileExistsMode } from "src/constants"; import type { OpenLocation, FileViewMode2 } from "../fileOpening"; import type { AppendLinkOptions } from "../linkPlacement"; import { normalizeFileOpening } from "../../utils/fileOpeningDefaults"; @@ -23,7 +23,7 @@ export class TemplateChoice extends Choice implements ITemplateChoice { focus: boolean; }; templatePath: string; - fileExistsMode: (typeof fileExistsChoices)[number]; + fileExistsMode: FileExistsMode; setFileExistsBehavior: boolean; constructor(name: string) { From e1760a2abbb21feb3fa78315877c4009910939e2 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 12 Mar 2026 22:08:37 +0100 Subject: [PATCH 2/8] fix: clarify template file collision settings --- src/constants.file-exists.test.ts | 14 +- src/constants.ts | 16 +- .../ChoiceBuilder/templateChoiceBuilder.ts | 142 ++++++++++++------ 3 files changed, 97 insertions(+), 75 deletions(-) diff --git a/src/constants.file-exists.test.ts b/src/constants.file-exists.test.ts index 4354ff01..bc9547eb 100644 --- a/src/constants.file-exists.test.ts +++ b/src/constants.file-exists.test.ts @@ -8,7 +8,6 @@ import { fileExistsOverwriteFile, getFileExistsAutomationDescription, getFileExistsBehaviorModeDescription, - getFileExistsSettingDescription, } from "./constants"; describe("file exists helper copy", () => { @@ -23,12 +22,12 @@ describe("file exists helper copy", () => { "Replaces the existing file content with the template.", ); expect(getFileExistsBehaviorModeDescription(fileExistsIncrement)).toBe( - "Changes trailing digits only. Example: Note009.md -> Note010.md.", + "Changes trailing digits only. Example: Draft009.md -> Draft010.md.", ); expect( getFileExistsBehaviorModeDescription(fileExistsDuplicateSuffix), ).toBe( - "Keeps the original name and adds a duplicate marker. Example: tt0780504.md -> tt0780504 (1).md.", + "Keeps the original name and adds a duplicate marker. Example: Project Plan.md -> Project Plan (1).md.", ); expect(getFileExistsBehaviorModeDescription(fileExistsDoNothing)).toBe( "Leaves the file unchanged and opens the existing file.", @@ -43,13 +42,4 @@ describe("file exists helper copy", () => { "QuickAdd prompts you each time the target file already exists.", ); }); - - it("combines automatic behavior and selected mode into one setting description", () => { - expect(getFileExistsSettingDescription(false, fileExistsDuplicateSuffix)).toBe( - "QuickAdd prompts you each time the target file already exists.", - ); - expect(getFileExistsSettingDescription(true, fileExistsDuplicateSuffix)).toBe( - "QuickAdd applies the selected behavior without asking. Keeps the original name and adds a duplicate marker. Example: tt0780504.md -> tt0780504 (1).md.", - ); - }); }); diff --git a/src/constants.ts b/src/constants.ts index 069950d8..b555e75b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -218,9 +218,9 @@ export const fileExistsModeDescriptions: Record = { [fileExistsOverwriteFile]: "Replaces the existing file content with the template.", [fileExistsIncrement]: - "Changes trailing digits only. Example: Note009.md -> Note010.md.", + "Changes trailing digits only. Example: Draft009.md -> Draft010.md.", [fileExistsDuplicateSuffix]: - "Keeps the original name and adds a duplicate marker. Example: tt0780504.md -> tt0780504 (1).md.", + "Keeps the original name and adds a duplicate marker. Example: Project Plan.md -> Project Plan (1).md.", [fileExistsDoNothing]: "Leaves the file unchanged and opens the existing file.", }; @@ -236,18 +236,6 @@ export function getFileExistsAutomationDescription( ? "QuickAdd applies the selected behavior without asking." : "QuickAdd prompts you each time the target file already exists."; } -export function getFileExistsSettingDescription( - setAutomatically: boolean, - mode: FileExistsMode, -): string { - if (!setAutomatically) { - return getFileExistsAutomationDescription(false); - } - - return `${getFileExistsAutomationDescription(true)} ${getFileExistsBehaviorModeDescription( - mode, - )}`; -} // == MISC == // export const WIKI_LINK_REGEX = new RegExp(/\[\[([^\]]*)\]\]/); diff --git a/src/gui/ChoiceBuilder/templateChoiceBuilder.ts b/src/gui/ChoiceBuilder/templateChoiceBuilder.ts index 8f9b393c..396cf600 100644 --- a/src/gui/ChoiceBuilder/templateChoiceBuilder.ts +++ b/src/gui/ChoiceBuilder/templateChoiceBuilder.ts @@ -12,7 +12,7 @@ import { fileExistsDoNothing, fileExistsDuplicateSuffix, fileExistsIncrement, - getFileExistsSettingDescription, + getFileExistsBehaviorModeDescription, fileExistsModeLabels, fileExistsOverwriteFile, } from "src/constants"; @@ -73,6 +73,29 @@ export class TemplateChoiceBuilder extends ChoiceBuilder { this.addOnePageOverrideSetting(this.choice); } + private getExistingFileBehaviorCategory(): "prompt" | "update" | "create" | "keep" { + if (!this.choice.setFileExistsBehavior) { + return "prompt"; + } + + if ( + this.choice.fileExistsMode === fileExistsAppendToBottom || + this.choice.fileExistsMode === fileExistsAppendToTop || + this.choice.fileExistsMode === fileExistsOverwriteFile + ) { + return "update"; + } + + if ( + this.choice.fileExistsMode === fileExistsIncrement || + this.choice.fileExistsMode === fileExistsDuplicateSuffix + ) { + return "create"; + } + + return "keep"; + } + private addTemplatePathSetting(): void { new Setting(this.contentEl) .setName("Template Path") @@ -426,61 +449,82 @@ export class TemplateChoiceBuilder extends ChoiceBuilder { if (!this.choice.fileExistsMode) this.choice.fileExistsMode = fileExistsDoNothing; - const setting = new Setting(this.contentEl) + const behaviorCategory = this.getExistingFileBehaviorCategory(); + + new Setting(this.contentEl) .setName("If the target file already exists") .setDesc( - getFileExistsSettingDescription( - this.choice.setFileExistsBehavior, - this.choice.fileExistsMode, - ), + "Choose whether QuickAdd should ask what to do, update the existing file, create another file, or keep the existing file.", ) - .addToggle((toggle) => { - toggle.setValue(this.choice.setFileExistsBehavior); - toggle.onChange((value) => { - this.choice.setFileExistsBehavior = value; + .addDropdown((dropdown) => { + dropdown.addOption("prompt", "Ask every time"); + dropdown.addOption("update", "Update existing file"); + dropdown.addOption("create", "Create another file"); + dropdown.addOption("keep", "Keep existing file"); + dropdown.setValue(behaviorCategory); + dropdown.onChange((value) => { + switch (value) { + case "prompt": + this.choice.setFileExistsBehavior = false; + break; + case "update": + this.choice.setFileExistsBehavior = true; + if ( + this.choice.fileExistsMode !== fileExistsAppendToBottom && + this.choice.fileExistsMode !== fileExistsAppendToTop && + this.choice.fileExistsMode !== fileExistsOverwriteFile + ) { + this.choice.fileExistsMode = fileExistsAppendToBottom; + } + break; + case "create": + this.choice.setFileExistsBehavior = true; + if ( + this.choice.fileExistsMode !== fileExistsIncrement && + this.choice.fileExistsMode !== fileExistsDuplicateSuffix + ) { + this.choice.fileExistsMode = fileExistsDuplicateSuffix; + } + break; + case "keep": + this.choice.setFileExistsBehavior = true; + this.choice.fileExistsMode = fileExistsDoNothing; + break; + } this.reload(); }); }); - if (this.choice.setFileExistsBehavior) { - setting.addDropdown((dropdown) => { - dropdown.selectEl.style.marginLeft = "10px"; - dropdown - .addOption( - fileExistsAppendToBottom, - fileExistsModeLabels[fileExistsAppendToBottom], - ) - .addOption( - fileExistsAppendToTop, - fileExistsModeLabels[fileExistsAppendToTop], - ) - .addOption( - fileExistsOverwriteFile, - fileExistsModeLabels[fileExistsOverwriteFile], - ) - .addOption( - fileExistsIncrement, - fileExistsModeLabels[fileExistsIncrement], - ) - .addOption( - fileExistsDuplicateSuffix, - fileExistsModeLabels[fileExistsDuplicateSuffix], - ) - .addOption( - fileExistsDoNothing, - fileExistsModeLabels[fileExistsDoNothing], - ) - .setValue(this.choice.fileExistsMode) - .onChange( - (value: (typeof fileExistsChoices)[number]) => { - this.choice.fileExistsMode = value; - setting.descEl.textContent = getFileExistsSettingDescription( - this.choice.setFileExistsBehavior, - value, - ); - }, - ); - }); + if (behaviorCategory === "prompt") { + return; + } + + if (behaviorCategory === "keep") { + return; } + + const isUpdateBehavior = behaviorCategory === "update"; + const selectedModes = isUpdateBehavior + ? [ + fileExistsAppendToBottom, + fileExistsAppendToTop, + fileExistsOverwriteFile, + ] + : [fileExistsIncrement, fileExistsDuplicateSuffix]; + + new Setting(this.contentEl) + .setName(isUpdateBehavior ? "Update action" : "New file naming") + .setDesc(getFileExistsBehaviorModeDescription(this.choice.fileExistsMode)) + .addDropdown((dropdown) => { + selectedModes.forEach((mode) => { + dropdown.addOption(mode, fileExistsModeLabels[mode]); + }); + dropdown.setValue(this.choice.fileExistsMode).onChange( + (value: (typeof fileExistsChoices)[number]) => { + this.choice.fileExistsMode = value; + this.reload(); + }, + ); + }); } } From d58c9be289c1fc6c2f3fc758493117d7af0615b0 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 13 Mar 2026 07:38:52 +0100 Subject: [PATCH 3/8] fix: allow folder collisions for new template files --- .../TemplateChoiceEngine.collision.test.ts | 54 ++++++++++++++++++- src/engine/TemplateChoiceEngine.ts | 43 ++++++++------- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/src/engine/TemplateChoiceEngine.collision.test.ts b/src/engine/TemplateChoiceEngine.collision.test.ts index d8cb3308..d6e4e126 100644 --- a/src/engine/TemplateChoiceEngine.collision.test.ts +++ b/src/engine/TemplateChoiceEngine.collision.test.ts @@ -100,7 +100,7 @@ vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn(), })); -import { TFile, type App } from "obsidian"; +import { TFile, TFolder, type App } from "obsidian"; import GenericSuggester from "../gui/GenericSuggester/genericSuggester"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import { settingsStore } from "../settingsStore"; @@ -151,6 +151,13 @@ const createExistingFile = (path: string) => { return file; }; +const createExistingFolder = (path: string) => { + const folder = new TFolder(); + folder.path = path; + folder.name = path.split("/").pop() ?? path; + return folder; +}; + const createEngine = () => { const app = { workspace: { @@ -323,6 +330,51 @@ describe("TemplateChoiceEngine collision behavior", () => { ); }); + it("allows create-another-file modes when the collision target resolves to a folder", async () => { + const { app, engine } = createEngine(); + const existingFolder = createExistingFolder("Test Template.md"); + const createdFile = createExistingFile("Test Template (1).md"); + + engine.choice.fileExistsMode = fileExistsIncrement; + engine.choice.setFileExistsBehavior = false; + + (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); + (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( + existingFolder, + ); + vi.mocked(GenericSuggester.Suggest).mockResolvedValue( + fileExistsDuplicateSuffix, + ); + + const duplicateSpy = vi + .spyOn( + engine as unknown as { + appendDuplicateSuffix: (filePath: string) => Promise; + }, + "appendDuplicateSuffix", + ) + .mockResolvedValue("Test Template (1).md"); + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + await engine.run(); + + expect(duplicateSpy).toHaveBeenCalledWith("Test Template.md"); + expect(createSpy).toHaveBeenCalledWith( + "Test Template (1).md", + engine.choice.templatePath, + ); + }); + it("increments automatically when auto behavior is on", async () => { const { app, engine } = createEngine(); const existingFile = createExistingFile("Test Template.md"); diff --git a/src/engine/TemplateChoiceEngine.ts b/src/engine/TemplateChoiceEngine.ts index 7c7ddb8b..68817b2d 100644 --- a/src/engine/TemplateChoiceEngine.ts +++ b/src/engine/TemplateChoiceEngine.ts @@ -101,19 +101,6 @@ export class TemplateChoiceEngine extends TemplateEngine { let createdFile: TFile | null; let shouldAutoOpen = false; if (await this.app.vault.adapter.exists(targetFilePath)) { - const file = this.findExistingFile(targetFilePath); - if ( - !(file instanceof TFile) || - (file.extension !== "md" && - file.extension !== "canvas" && - file.extension !== "base") - ) { - log.logError( - `'${targetFilePath}' already exists but could not be resolved as a markdown, canvas, or base file.`, - ); - return; - } - let userChoice = this.choice.fileExistsMode; if (!this.choice.setFileExistsBehavior) { @@ -132,31 +119,51 @@ export class TemplateChoiceEngine extends TemplateEngine { } } + const requiresExistingFile = + userChoice !== fileExistsIncrement && + userChoice !== fileExistsDuplicateSuffix; + const existingFile = requiresExistingFile + ? this.findExistingFile(targetFilePath) + : null; + + if ( + requiresExistingFile && + (!(existingFile instanceof TFile) || + (existingFile.extension !== "md" && + existingFile.extension !== "canvas" && + existingFile.extension !== "base")) + ) { + log.logError( + `'${targetFilePath}' already exists but could not be resolved as a markdown, canvas, or base file.`, + ); + return; + } + switch (userChoice) { case fileExistsAppendToTop: createdFile = await this.appendToFileWithTemplate( - file, + existingFile!, this.choice.templatePath, "top", ); break; case fileExistsAppendToBottom: createdFile = await this.appendToFileWithTemplate( - file, + existingFile!, this.choice.templatePath, "bottom", ); break; case fileExistsOverwriteFile: createdFile = await this.overwriteFileWithTemplate( - file, + existingFile!, this.choice.templatePath, ); break; case fileExistsDoNothing: - createdFile = file; + createdFile = existingFile!; shouldAutoOpen = true; // Auto-open existing file when user chooses "Nothing" - log.logMessage(`Opening existing file: ${file.path}`); + log.logMessage(`Opening existing file: ${existingFile!.path}`); break; case fileExistsIncrement: { const incrementFileName = await this.resolveCollisionFilePath( From 56506cb5a0ad1736d39aec8827aefb0bf3aeca09 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 13 Mar 2026 07:45:28 +0100 Subject: [PATCH 4/8] docs: align template collision docs with current UI --- docs/docs/Choices/TemplateChoice.md | 49 ++++++++++++++++--- ...Template_CreateMOCNoteWithLinkDashboard.md | 3 +- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/docs/docs/Choices/TemplateChoice.md b/docs/docs/Choices/TemplateChoice.md index 026d6732..0026c048 100644 --- a/docs/docs/Choices/TemplateChoice.md +++ b/docs/docs/Choices/TemplateChoice.md @@ -31,21 +31,54 @@ When either enabled mode is selected, you can choose where the link is placed: - **New line** - Places the link on a new line below the cursor -**If the target file already exists**. Choose what QuickAdd should do when the target file already exists. Turn on **Use selected behavior automatically** to apply the selected mode without prompting, or turn it off to choose each time. +**If the target file already exists**. Choose whether QuickAdd should ask what to do, update the existing file, create another file, or keep the existing file. **Open**. Will open the file you've created. By default, it opens in the active pane. If you enable **New tab**, it'll open in a new tab in the direction you specified. ## File Already Exists Behavior -When a file with the target name already exists, QuickAdd can either prompt you or apply the selected behavior automatically: +When a file with the target name already exists, the setting works in two steps: + +- **If the target file already exists**: + choose one of these high-level behaviors: + **Ask every time**, **Update existing file**, **Create another file**, or + **Keep existing file** +- **Update action**: + shown only when you choose **Update existing file** +- **New file naming**: + shown only when you choose **Create another file** + +### Ask Every Time + +QuickAdd prompts you to choose one of these actions each time the target path +already exists: + +- **Append to bottom** +- **Append to top** +- **Overwrite file** +- **Increment trailing number** +- **Append duplicate suffix** +- **Do nothing** + +### Update Existing File + +These options modify the existing markdown, canvas, or base file: - **Append to bottom**: Adds the template content to the end of the existing file - **Append to top**: Adds the template content to the beginning of the existing file -- **Overwrite file**: Replaces the entire file content with the template -- **Increment trailing number**: Creates a new file by incrementing trailing digits while preserving zero padding when present (for example, `note009.md` becomes `note010.md`) -- **Append duplicate suffix**: Creates a new file by preserving the full base name and adding ` (1)`, ` (2)`, and so on (for example, `note.md` becomes `note (1).md`) -- **Do nothing**: Opens the existing file without modification +- **Overwrite file**: Replaces the existing file content with the template + +### Create Another File + +These options keep the existing file untouched and create a new file instead: + +- **Increment trailing number**: Changes trailing digits only while preserving zero padding when present. For example, `note009.md` becomes `note010.md`. +- **Append duplicate suffix**: Keeps the full base name and adds ` (1)`, ` (2)`, and so on. For example, `note.md` becomes `note (1).md`. + +### Keep Existing File -**Note**: When you select "Do nothing", the existing file will automatically open, making it easy to quickly access files that already exist without needing to enable the "Open" setting. +Selecting **Keep existing file** applies the same result as choosing +**Do nothing** from the prompt: -![image](https://user-images.githubusercontent.com/29108628/121773888-3f680980-cb7f-11eb-919b-97d56ef9268e.png) +- **Do nothing**: Leaves the existing file unchanged and opens it + automatically. This does not require the separate **Open** setting. diff --git a/docs/docs/Examples/Template_CreateMOCNoteWithLinkDashboard.md b/docs/docs/Examples/Template_CreateMOCNoteWithLinkDashboard.md index 9745f361..c5ee9eac 100644 --- a/docs/docs/Examples/Template_CreateMOCNoteWithLinkDashboard.md +++ b/docs/docs/Examples/Template_CreateMOCNoteWithLinkDashboard.md @@ -85,7 +85,8 @@ outgoing links for this note. - **File Name Format**: `{{VALUE:moc_title}}` - **Create in folder**: your MOC folder, for example `MOCs` - **Open**: enabled -- **If the target file already exists**: `Increment trailing number` +- **If the target file already exists**: `Create another file` +- **New file naming**: `Increment trailing number` 4. Run the Template choice and enter a title such as `Alpha Project`. From 12c666b688af0ac4a19edbc1826b160d72197dbc Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 13 Mar 2026 22:03:03 +0100 Subject: [PATCH 5/8] refactor(template): centralize file collision policy --- src/constants.file-exists.test.ts | 45 --- src/constants.ts | 52 ---- .../TemplateChoiceEngine.collision.test.ts | 135 +++------ .../TemplateChoiceEngine.notice.test.ts | 10 +- src/engine/TemplateChoiceEngine.ts | 187 +++++++------ src/engine/TemplateEngine.ts | 77 ------ .../templateEngine-increment-canvas.test.ts | 152 ----------- .../ChoiceBuilder/templateChoiceBuilder.ts | 119 +++----- ...leNameSettingMoveToDefaultBehavior.test.ts | 101 +++++++ ...entFileNameSettingMoveToDefaultBehavior.ts | 66 ++++- .../runOnePagePreflight.selection.test.ts | 4 +- src/template/fileExistsPolicy.test.ts | 215 +++++++++++++++ src/template/fileExistsPolicy.ts | 258 ++++++++++++++++++ src/types/choices/ITemplateChoice.ts | 5 +- src/types/choices/TemplateChoice.ts | 8 +- 15 files changed, 810 insertions(+), 624 deletions(-) delete mode 100644 src/constants.file-exists.test.ts delete mode 100644 src/engine/templateEngine-increment-canvas.test.ts create mode 100644 src/migrations/incrementFileNameSettingMoveToDefaultBehavior.test.ts create mode 100644 src/template/fileExistsPolicy.test.ts create mode 100644 src/template/fileExistsPolicy.ts diff --git a/src/constants.file-exists.test.ts b/src/constants.file-exists.test.ts deleted file mode 100644 index bc9547eb..00000000 --- a/src/constants.file-exists.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - fileExistsAppendToBottom, - fileExistsAppendToTop, - fileExistsDoNothing, - fileExistsDuplicateSuffix, - fileExistsIncrement, - fileExistsOverwriteFile, - getFileExistsAutomationDescription, - getFileExistsBehaviorModeDescription, -} from "./constants"; - -describe("file exists helper copy", () => { - it("returns concrete mode descriptions", () => { - expect(getFileExistsBehaviorModeDescription(fileExistsAppendToBottom)).toBe( - "Adds the template content to the end of the existing file.", - ); - expect(getFileExistsBehaviorModeDescription(fileExistsAppendToTop)).toBe( - "Adds the template content to the beginning of the existing file.", - ); - expect(getFileExistsBehaviorModeDescription(fileExistsOverwriteFile)).toBe( - "Replaces the existing file content with the template.", - ); - expect(getFileExistsBehaviorModeDescription(fileExistsIncrement)).toBe( - "Changes trailing digits only. Example: Draft009.md -> Draft010.md.", - ); - expect( - getFileExistsBehaviorModeDescription(fileExistsDuplicateSuffix), - ).toBe( - "Keeps the original name and adds a duplicate marker. Example: Project Plan.md -> Project Plan (1).md.", - ); - expect(getFileExistsBehaviorModeDescription(fileExistsDoNothing)).toBe( - "Leaves the file unchanged and opens the existing file.", - ); - }); - - it("returns distinct copy for automatic and prompted behavior", () => { - expect(getFileExistsAutomationDescription(true)).toBe( - "QuickAdd applies the selected behavior without asking.", - ); - expect(getFileExistsAutomationDescription(false)).toBe( - "QuickAdd prompts you each time the target file already exists.", - ); - }); -}); diff --git a/src/constants.ts b/src/constants.ts index b555e75b..ce9e31c8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -185,57 +185,5 @@ export const TIME_FORMAT_SYNTAX_SUGGEST_REGEX = new RegExp( export const QA_INTERNAL_CAPTURE_TARGET_FILE_PATH = "__qa.captureTargetFilePath"; -// == File Exists (Template Choice) == // -export const fileExistsIncrement = "Increment the file name" as const; -export const fileExistsAppendToBottom = - "Append to the bottom of the file" as const; -export const fileExistsAppendToTop = "Append to the top of the file" as const; -export const fileExistsOverwriteFile = "Overwrite the file" as const; -export const fileExistsDuplicateSuffix = "Append duplicate suffix" as const; -export const fileExistsDoNothing = "Nothing" as const; -export const fileExistsChoices = [ - fileExistsAppendToBottom, - fileExistsAppendToTop, - fileExistsOverwriteFile, - fileExistsIncrement, - fileExistsDuplicateSuffix, - fileExistsDoNothing, -] as const; -export type FileExistsMode = (typeof fileExistsChoices)[number]; -export const fileExistsModeLabels: Record = { - [fileExistsAppendToBottom]: "Append to bottom", - [fileExistsAppendToTop]: "Append to top", - [fileExistsOverwriteFile]: "Overwrite file", - [fileExistsIncrement]: "Increment trailing number", - [fileExistsDuplicateSuffix]: "Append duplicate suffix", - [fileExistsDoNothing]: "Do nothing", -}; -export const fileExistsModeDescriptions: Record = { - [fileExistsAppendToBottom]: - "Adds the template content to the end of the existing file.", - [fileExistsAppendToTop]: - "Adds the template content to the beginning of the existing file.", - [fileExistsOverwriteFile]: - "Replaces the existing file content with the template.", - [fileExistsIncrement]: - "Changes trailing digits only. Example: Draft009.md -> Draft010.md.", - [fileExistsDuplicateSuffix]: - "Keeps the original name and adds a duplicate marker. Example: Project Plan.md -> Project Plan (1).md.", - [fileExistsDoNothing]: - "Leaves the file unchanged and opens the existing file.", -}; -export function getFileExistsBehaviorModeDescription( - mode: FileExistsMode, -): string { - return fileExistsModeDescriptions[mode]; -} -export function getFileExistsAutomationDescription( - setAutomatically: boolean, -): string { - return setAutomatically - ? "QuickAdd applies the selected behavior without asking." - : "QuickAdd prompts you each time the target file already exists."; -} - // == MISC == // export const WIKI_LINK_REGEX = new RegExp(/\[\[([^\]]*)\]\]/); diff --git a/src/engine/TemplateChoiceEngine.collision.test.ts b/src/engine/TemplateChoiceEngine.collision.test.ts index d6e4e126..858030d9 100644 --- a/src/engine/TemplateChoiceEngine.collision.test.ts +++ b/src/engine/TemplateChoiceEngine.collision.test.ts @@ -104,15 +104,9 @@ import { TFile, TFolder, type App } from "obsidian"; import GenericSuggester from "../gui/GenericSuggester/genericSuggester"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import { settingsStore } from "../settingsStore"; +import { getPromptModes } from "../template/fileExistsPolicy"; import type ITemplateChoice from "../types/choices/ITemplateChoice"; import { TemplateChoiceEngine } from "./TemplateChoiceEngine"; -import { - fileExistsAppendToBottom, - fileExistsDoNothing, - fileExistsDuplicateSuffix, - fileExistsIncrement, - fileExistsModeLabels, -} from "../constants"; const defaultSettingsState = structuredClone(settingsStore.getState()); @@ -138,8 +132,7 @@ const createTemplateChoice = (): ITemplateChoice => ({ mode: "source", focus: false, }, - fileExistsMode: fileExistsAppendToBottom, - setFileExistsBehavior: false, + fileExistsBehavior: { kind: "prompt" }, }); const createExistingFile = (path: string) => { @@ -211,20 +204,21 @@ describe("TemplateChoiceEngine collision behavior", () => { const { app, engine } = createEngine(); const existingFile = createExistingFile("Test Template.md"); - engine.choice.fileExistsMode = fileExistsIncrement; - engine.choice.setFileExistsBehavior = false; + engine.choice.fileExistsBehavior = { kind: "prompt" }; (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( existingFile, ); - vi.mocked(GenericSuggester.Suggest).mockResolvedValue(fileExistsDoNothing); - - const incrementSpy = vi.spyOn( + vi.mocked(GenericSuggester.Suggest).mockResolvedValue("doNothing"); + const createSpy = vi.spyOn( engine as unknown as { - incrementFileName: (filePath: string) => Promise; + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; }, - "incrementFileName", + "createFileWithTemplate", ); await engine.run(); @@ -232,38 +226,27 @@ describe("TemplateChoiceEngine collision behavior", () => { expect(GenericSuggester.Suggest).toHaveBeenCalledWith( app, expect.arrayContaining([ - fileExistsModeLabels[fileExistsIncrement], - fileExistsModeLabels[fileExistsDuplicateSuffix], + getPromptModes().find((mode) => mode.id === "increment")?.label, + getPromptModes().find((mode) => mode.id === "duplicateSuffix")?.label, ]), - expect.any(Array), + expect.arrayContaining(["appendBottom", "increment", "duplicateSuffix"]), "If the target file already exists", ); - expect(incrementSpy).not.toHaveBeenCalled(); + expect(createSpy).not.toHaveBeenCalled(); expect(app.vault.adapter.exists).toHaveBeenCalledWith("Test Template.md"); }); it("creates an incremented file from the original target after prompting", async () => { const { app, engine } = createEngine(); - const existingFile = createExistingFile("Test Template.md"); const createdFile = createExistingFile("Test Template1.md"); - engine.choice.fileExistsMode = fileExistsIncrement; - engine.choice.setFileExistsBehavior = false; + engine.choice.fileExistsBehavior = { kind: "prompt" }; - (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); - (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( - existingFile, + (app.vault.adapter.exists as ReturnType).mockImplementation( + async (path: string) => path === "Test Template.md", ); - vi.mocked(GenericSuggester.Suggest).mockResolvedValue(fileExistsIncrement); + vi.mocked(GenericSuggester.Suggest).mockResolvedValue("increment"); - const incrementSpy = vi - .spyOn( - engine as unknown as { - incrementFileName: (filePath: string) => Promise; - }, - "incrementFileName", - ) - .mockResolvedValue("Test Template1.md"); const createSpy = vi .spyOn( engine as unknown as { @@ -278,7 +261,6 @@ describe("TemplateChoiceEngine collision behavior", () => { await engine.run(); - expect(incrementSpy).toHaveBeenCalledWith("Test Template.md"); expect(createSpy).toHaveBeenCalledWith( "Test Template1.md", engine.choice.templatePath, @@ -287,28 +269,15 @@ describe("TemplateChoiceEngine collision behavior", () => { it("creates a duplicate-suffix file from the original target after prompting", async () => { const { app, engine } = createEngine(); - const existingFile = createExistingFile("Test Template.md"); const createdFile = createExistingFile("Test Template (1).md"); - engine.choice.fileExistsMode = fileExistsIncrement; - engine.choice.setFileExistsBehavior = false; + engine.choice.fileExistsBehavior = { kind: "prompt" }; - (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); - (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( - existingFile, - ); - vi.mocked(GenericSuggester.Suggest).mockResolvedValue( - fileExistsDuplicateSuffix, + (app.vault.adapter.exists as ReturnType).mockImplementation( + async (path: string) => path === "Test Template.md", ); + vi.mocked(GenericSuggester.Suggest).mockResolvedValue("duplicateSuffix"); - const duplicateSpy = vi - .spyOn( - engine as unknown as { - appendDuplicateSuffix: (filePath: string) => Promise; - }, - "appendDuplicateSuffix", - ) - .mockResolvedValue("Test Template (1).md"); const createSpy = vi .spyOn( engine as unknown as { @@ -323,7 +292,6 @@ describe("TemplateChoiceEngine collision behavior", () => { await engine.run(); - expect(duplicateSpy).toHaveBeenCalledWith("Test Template.md"); expect(createSpy).toHaveBeenCalledWith( "Test Template (1).md", engine.choice.templatePath, @@ -335,25 +303,16 @@ describe("TemplateChoiceEngine collision behavior", () => { const existingFolder = createExistingFolder("Test Template.md"); const createdFile = createExistingFile("Test Template (1).md"); - engine.choice.fileExistsMode = fileExistsIncrement; - engine.choice.setFileExistsBehavior = false; + engine.choice.fileExistsBehavior = { kind: "prompt" }; - (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); + (app.vault.adapter.exists as ReturnType).mockImplementation( + async (path: string) => path === "Test Template.md", + ); (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( existingFolder, ); - vi.mocked(GenericSuggester.Suggest).mockResolvedValue( - fileExistsDuplicateSuffix, - ); + vi.mocked(GenericSuggester.Suggest).mockResolvedValue("duplicateSuffix"); - const duplicateSpy = vi - .spyOn( - engine as unknown as { - appendDuplicateSuffix: (filePath: string) => Promise; - }, - "appendDuplicateSuffix", - ) - .mockResolvedValue("Test Template (1).md"); const createSpy = vi .spyOn( engine as unknown as { @@ -368,7 +327,6 @@ describe("TemplateChoiceEngine collision behavior", () => { await engine.run(); - expect(duplicateSpy).toHaveBeenCalledWith("Test Template.md"); expect(createSpy).toHaveBeenCalledWith( "Test Template (1).md", engine.choice.templatePath, @@ -377,25 +335,14 @@ describe("TemplateChoiceEngine collision behavior", () => { it("increments automatically when auto behavior is on", async () => { const { app, engine } = createEngine(); - const existingFile = createExistingFile("Test Template.md"); const createdFile = createExistingFile("Test Template1.md"); - engine.choice.fileExistsMode = fileExistsIncrement; - engine.choice.setFileExistsBehavior = true; + engine.choice.fileExistsBehavior = { kind: "apply", mode: "increment" }; - (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); - (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( - existingFile, + (app.vault.adapter.exists as ReturnType).mockImplementation( + async (path: string) => path === "Test Template.md", ); - const incrementSpy = vi - .spyOn( - engine as unknown as { - incrementFileName: (filePath: string) => Promise; - }, - "incrementFileName", - ) - .mockResolvedValue("Test Template1.md"); const createSpy = vi .spyOn( engine as unknown as { @@ -411,7 +358,6 @@ describe("TemplateChoiceEngine collision behavior", () => { await engine.run(); expect(GenericSuggester.Suggest).not.toHaveBeenCalled(); - expect(incrementSpy).toHaveBeenCalledWith("Test Template.md"); expect(createSpy).toHaveBeenCalledWith( "Test Template1.md", engine.choice.templatePath, @@ -420,25 +366,17 @@ describe("TemplateChoiceEngine collision behavior", () => { it("applies duplicate suffix automatically when auto behavior is on", async () => { const { app, engine } = createEngine(); - const existingFile = createExistingFile("Test Template.md"); const createdFile = createExistingFile("Test Template (1).md"); - engine.choice.fileExistsMode = fileExistsDuplicateSuffix; - engine.choice.setFileExistsBehavior = true; + engine.choice.fileExistsBehavior = { + kind: "apply", + mode: "duplicateSuffix", + }; - (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); - (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( - existingFile, + (app.vault.adapter.exists as ReturnType).mockImplementation( + async (path: string) => path === "Test Template.md", ); - const duplicateSpy = vi - .spyOn( - engine as unknown as { - appendDuplicateSuffix: (filePath: string) => Promise; - }, - "appendDuplicateSuffix", - ) - .mockResolvedValue("Test Template (1).md"); const createSpy = vi .spyOn( engine as unknown as { @@ -454,7 +392,6 @@ describe("TemplateChoiceEngine collision behavior", () => { await engine.run(); expect(GenericSuggester.Suggest).not.toHaveBeenCalled(); - expect(duplicateSpy).toHaveBeenCalledWith("Test Template.md"); expect(createSpy).toHaveBeenCalledWith( "Test Template (1).md", engine.choice.templatePath, diff --git a/src/engine/TemplateChoiceEngine.notice.test.ts b/src/engine/TemplateChoiceEngine.notice.test.ts index 67b3a9f9..26e69bf3 100644 --- a/src/engine/TemplateChoiceEngine.notice.test.ts +++ b/src/engine/TemplateChoiceEngine.notice.test.ts @@ -107,7 +107,6 @@ import type { IChoiceExecutor } from "../IChoiceExecutor"; import type ITemplateChoice from "../types/choices/ITemplateChoice"; import { MacroAbortError } from "../errors/MacroAbortError"; import { settingsStore } from "../settingsStore"; -import { fileExistsAppendToBottom, fileExistsOverwriteFile } from "../constants"; const defaultSettingsState = structuredClone(settingsStore.getState()); @@ -139,8 +138,7 @@ const createTemplateChoice = (): ITemplateChoice => ({ mode: "source", focus: false, }, - fileExistsMode: fileExistsAppendToBottom, - setFileExistsBehavior: false, + fileExistsBehavior: { kind: "prompt" }, }); const createEngine = ( @@ -299,8 +297,7 @@ describe("TemplateChoiceEngine file casing resolution", () => { existingFile.extension = "md"; existingFile.basename = "Bug report"; - engine.choice.fileExistsMode = fileExistsOverwriteFile; - engine.choice.setFileExistsBehavior = true; + engine.choice.fileExistsBehavior = { kind: "apply", mode: "overwrite" }; formatFileNameMock.mockResolvedValueOnce("Bug Report"); (app.vault.adapter.exists as ReturnType).mockResolvedValue( @@ -346,8 +343,7 @@ describe("TemplateChoiceEngine file casing resolution", () => { existingFile.basename = "Board"; engine.choice.templatePath = "Templates/Board.base"; - engine.choice.fileExistsMode = fileExistsOverwriteFile; - engine.choice.setFileExistsBehavior = true; + engine.choice.fileExistsBehavior = { kind: "apply", mode: "overwrite" }; formatFileNameMock.mockResolvedValueOnce("Board"); (app.vault.adapter.exists as ReturnType).mockResolvedValue( diff --git a/src/engine/TemplateChoiceEngine.ts b/src/engine/TemplateChoiceEngine.ts index 68817b2d..722ec8e7 100644 --- a/src/engine/TemplateChoiceEngine.ts +++ b/src/engine/TemplateChoiceEngine.ts @@ -2,21 +2,17 @@ import type { App } from "obsidian"; import { TFile } from "obsidian"; import { TFolder } from "obsidian"; import invariant from "src/utils/invariant"; -import { - fileExistsAppendToBottom, - fileExistsAppendToTop, - fileExistsChoices, - fileExistsDoNothing, - fileExistsDuplicateSuffix, - fileExistsIncrement, - fileExistsModeLabels, - fileExistsOverwriteFile, - VALUE_SYNTAX, -} from "../constants"; +import { VALUE_SYNTAX } from "../constants"; import GenericSuggester from "../gui/GenericSuggester/genericSuggester"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import { log } from "../logger/logManager"; import type QuickAdd from "../main"; +import { + getFileExistsMode, + getPromptModes, + resolveCreateNewCollisionFilePath, + type FileExistsModeId, +} from "../template/fileExistsPolicy"; import type ITemplateChoice from "../types/choices/ITemplateChoice"; import { normalizeAppendLinkOptions } from "../types/linkPlacement"; import { @@ -101,33 +97,14 @@ export class TemplateChoiceEngine extends TemplateEngine { let createdFile: TFile | null; let shouldAutoOpen = false; if (await this.app.vault.adapter.exists(targetFilePath)) { - let userChoice = this.choice.fileExistsMode; - - if (!this.choice.setFileExistsBehavior) { - try { - userChoice = await GenericSuggester.Suggest( - this.app, - fileExistsChoices.map((choice) => fileExistsModeLabels[choice]), - [...fileExistsChoices], - "If the target file already exists", - ); - } catch (error) { - if (isCancellationError(error)) { - throw new MacroAbortError("Input cancelled by user"); - } - throw error; - } - } - - const requiresExistingFile = - userChoice !== fileExistsIncrement && - userChoice !== fileExistsDuplicateSuffix; - const existingFile = requiresExistingFile + const modeId = await this.getSelectedFileExistsMode(); + const mode = getFileExistsMode(modeId); + const existingFile = mode.requiresExistingFile ? this.findExistingFile(targetFilePath) : null; if ( - requiresExistingFile && + mode.requiresExistingFile && (!(existingFile instanceof TFile) || (existingFile.extension !== "md" && existingFile.extension !== "canvas" && @@ -139,56 +116,11 @@ export class TemplateChoiceEngine extends TemplateEngine { return; } - switch (userChoice) { - case fileExistsAppendToTop: - createdFile = await this.appendToFileWithTemplate( - existingFile!, - this.choice.templatePath, - "top", - ); - break; - case fileExistsAppendToBottom: - createdFile = await this.appendToFileWithTemplate( - existingFile!, - this.choice.templatePath, - "bottom", - ); - break; - case fileExistsOverwriteFile: - createdFile = await this.overwriteFileWithTemplate( - existingFile!, - this.choice.templatePath, - ); - break; - case fileExistsDoNothing: - createdFile = existingFile!; - shouldAutoOpen = true; // Auto-open existing file when user chooses "Nothing" - log.logMessage(`Opening existing file: ${existingFile!.path}`); - break; - case fileExistsIncrement: { - const incrementFileName = await this.resolveCollisionFilePath( - targetFilePath, - userChoice, - ); - createdFile = await this.createFileWithTemplate( - incrementFileName, - this.choice.templatePath, - ); - break; - } - case fileExistsDuplicateSuffix: { - const duplicateSuffixFileName = - await this.resolveCollisionFilePath(targetFilePath, userChoice); - createdFile = await this.createFileWithTemplate( - duplicateSuffixFileName, - this.choice.templatePath, - ); - break; - } - default: - log.logWarning("File not written to."); - return; - } + ({ createdFile, shouldAutoOpen } = await this.applyFileExistsMode( + modeId, + targetFilePath, + existingFile, + )); } else { createdFile = await this.createFileWithTemplate( targetFilePath, @@ -234,6 +166,93 @@ export class TemplateChoiceEngine extends TemplateEngine { } } + private async getSelectedFileExistsMode(): Promise { + if (this.choice.fileExistsBehavior.kind === "apply") { + return this.choice.fileExistsBehavior.mode; + } + + const promptModes = getPromptModes(); + + try { + return await GenericSuggester.Suggest( + this.app, + promptModes.map((mode) => mode.label), + promptModes.map((mode) => mode.id), + "If the target file already exists", + ); + } catch (error) { + if (isCancellationError(error)) { + throw new MacroAbortError("Input cancelled by user"); + } + throw error; + } + } + + private async applyFileExistsMode( + modeId: FileExistsModeId, + targetFilePath: string, + existingFile: TFile | null, + ): Promise<{ createdFile: TFile | null; shouldAutoOpen: boolean }> { + const mode = getFileExistsMode(modeId); + + switch (mode.resolutionKind) { + case "modifyExisting": + return { + createdFile: await this.applyExistingFileUpdate( + mode.id, + existingFile!, + ), + shouldAutoOpen: false, + }; + case "createNew": { + const nextFilePath = await resolveCreateNewCollisionFilePath( + targetFilePath, + mode.id, + async (path) => await this.app.vault.adapter.exists(path), + ); + + return { + createdFile: await this.createFileWithTemplate( + nextFilePath, + this.choice.templatePath, + ), + shouldAutoOpen: false, + }; + } + case "reuseExisting": + log.logMessage(`Opening existing file: ${existingFile!.path}`); + return { + createdFile: existingFile, + shouldAutoOpen: true, + }; + } + } + + private async applyExistingFileUpdate( + modeId: "appendTop" | "appendBottom" | "overwrite", + existingFile: TFile, + ): Promise { + switch (modeId) { + case "appendTop": + return await this.appendToFileWithTemplate( + existingFile, + this.choice.templatePath, + "top", + ); + case "appendBottom": + return await this.appendToFileWithTemplate( + existingFile, + this.choice.templatePath, + "bottom", + ); + case "overwrite": + return await this.overwriteFileWithTemplate( + existingFile, + this.choice.templatePath, + ); + } + } + /** * Resolve an existing file by path with a case-insensitive fallback. * diff --git a/src/engine/TemplateEngine.ts b/src/engine/TemplateEngine.ts index 899c8be2..accc2d4e 100644 --- a/src/engine/TemplateEngine.ts +++ b/src/engine/TemplateEngine.ts @@ -16,7 +16,6 @@ import { CANVAS_FILE_EXTENSION_REGEX, MARKDOWN_FILE_EXTENSION_REGEX, } from "../constants"; -import type { FileExistsMode } from "../constants"; import { reportError } from "../utils/errorUtils"; import { basenameWithoutMdOrCanvas } from "../utils/pathUtils"; import { @@ -467,82 +466,6 @@ export abstract class TemplateEngine extends QuickAddEngine { return `${actualFolderPath}${formattedFileName}${extension}`; } - protected async resolveCollisionFilePath( - fileName: string, - mode: FileExistsMode, - ): Promise { - switch (mode) { - case "Increment the file name": - return await this.incrementFileName(fileName); - case "Append duplicate suffix": - return await this.appendDuplicateSuffix(fileName); - default: - return fileName; - } - } - - protected async incrementFileName(fileName: string): Promise { - const fileExists = await this.app.vault.adapter.exists(fileName); - if (!fileExists) { - return fileName; - } - - const { basename, extension } = this.splitCollisionFileName(fileName); - const match = basename.match(/^(.*?)(\d+)$/); - const nextBasename = match - ? `${match[1]}${String(parseInt(match[2], 10) + 1).padStart( - match[2].length, - "0", - )}` - : `${basename}1`; - const nextFileName = `${nextBasename}${extension}`; - - if (await this.app.vault.adapter.exists(nextFileName)) { - return await this.incrementFileName(nextFileName); - } - - return nextFileName; - } - - protected async appendDuplicateSuffix(fileName: string): Promise { - const fileExists = await this.app.vault.adapter.exists(fileName); - if (!fileExists) { - return fileName; - } - - const { basename, extension } = this.splitCollisionFileName(fileName); - const match = basename.match(/^(.*) \((\d+)\)$/); - const nextBasename = match - ? `${match[1]} (${parseInt(match[2], 10) + 1})` - : `${basename} (1)`; - const nextFileName = `${nextBasename}${extension}`; - - if (await this.app.vault.adapter.exists(nextFileName)) { - return await this.appendDuplicateSuffix(nextFileName); - } - - return nextFileName; - } - - private splitCollisionFileName(fileName: string) { - if (CANVAS_FILE_EXTENSION_REGEX.test(fileName)) { - return { - basename: fileName.replace(CANVAS_FILE_EXTENSION_REGEX, ""), - extension: ".canvas", - }; - } - if (BASE_FILE_EXTENSION_REGEX.test(fileName)) { - return { - basename: fileName.replace(BASE_FILE_EXTENSION_REGEX, ""), - extension: ".base", - }; - } - return { - basename: fileName.replace(MARKDOWN_FILE_EXTENSION_REGEX, ""), - extension: ".md", - }; - } - protected async createFileWithTemplate( filePath: string, templatePath: string diff --git a/src/engine/templateEngine-increment-canvas.test.ts b/src/engine/templateEngine-increment-canvas.test.ts deleted file mode 100644 index 6b7e8792..00000000 --- a/src/engine/templateEngine-increment-canvas.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { TemplateEngine } from "./TemplateEngine"; -import type { App } from "obsidian"; -import type QuickAdd from "../main"; -import type { IChoiceExecutor } from "../IChoiceExecutor"; - -vi.mock("../formatters/completeFormatter", () => ({ - CompleteFormatter: vi.fn().mockImplementation(() => ({ - setTitle: vi.fn(), - formatFileContent: vi.fn(async (content: string) => content), - formatFileName: vi.fn(async (name: string) => name), - })), -})); - -vi.mock("../utilityObsidian", () => ({ - getTemplater: vi.fn(() => null), - overwriteTemplaterOnce: vi.fn().mockResolvedValue(undefined), -})); - -class TestTemplateEngine extends TemplateEngine { - constructor(app: App, plugin: QuickAdd, choiceExecutor: IChoiceExecutor) { - super(app, plugin, choiceExecutor); - } - - public async run(): Promise {} - - public async testIncrement(fileName: string) { - return await this.incrementFileName(fileName); - } - - public async testDuplicateSuffix(fileName: string) { - return await this.appendDuplicateSuffix(fileName); - } -} - -describe("TemplateEngine collision file naming", () => { - let engine: TestTemplateEngine; - let mockApp: App; - - beforeEach(() => { - vi.clearAllMocks(); - - mockApp = { - vault: { - adapter: { - exists: vi.fn(), - }, - create: vi.fn(), - }, - } as unknown as App; - - engine = new TestTemplateEngine(mockApp, {} as QuickAdd, {} as IChoiceExecutor); - }); - - it("appends 1 before .md when no number exists", async () => { - (mockApp.vault.adapter.exists as ReturnType) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); - - await expect(engine.testIncrement("Note.md")).resolves.toBe("Note1.md"); - }); - - it("preserves zero padding for markdown files", async () => { - (mockApp.vault.adapter.exists as ReturnType) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); - - await expect(engine.testIncrement("Note009.md")).resolves.toBe( - "Note010.md", - ); - }); - - it("preserves zero padding for identifier-like markdown files", async () => { - (mockApp.vault.adapter.exists as ReturnType) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); - - await expect(engine.testIncrement("tt0780504.md")).resolves.toBe( - "tt0780505.md", - ); - }); - - it("preserves zero padding for .canvas files", async () => { - (mockApp.vault.adapter.exists as ReturnType) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); - - await expect(engine.testIncrement("tt009.canvas")).resolves.toBe( - "tt010.canvas", - ); - }); - - it("preserves zero padding for .base files", async () => { - (mockApp.vault.adapter.exists as ReturnType) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); - - await expect(engine.testIncrement("tt009.base")).resolves.toBe( - "tt010.base", - ); - }); - - it("recurses incrementing until an available file name is found", async () => { - (mockApp.vault.adapter.exists as ReturnType).mockImplementation( - async (path: string) => { - return path === "Note.md" || path === "Note1.md"; - }, - ); - - await expect(engine.testIncrement("Note.md")).resolves.toBe("Note2.md"); - }); - - it("appends a duplicate suffix to markdown files", async () => { - (mockApp.vault.adapter.exists as ReturnType) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); - - await expect(engine.testDuplicateSuffix("Note.md")).resolves.toBe( - "Note (1).md", - ); - }); - - it("increments an existing duplicate suffix", async () => { - (mockApp.vault.adapter.exists as ReturnType) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); - - await expect(engine.testDuplicateSuffix("Note (1).md")).resolves.toBe( - "Note (2).md", - ); - }); - - it("preserves trailing digits when adding a duplicate suffix", async () => { - (mockApp.vault.adapter.exists as ReturnType) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); - - await expect(engine.testDuplicateSuffix("Note1.md")).resolves.toBe( - "Note1 (1).md", - ); - }); - - it("adds a duplicate suffix for identifier-like markdown files", async () => { - (mockApp.vault.adapter.exists as ReturnType) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); - - await expect(engine.testDuplicateSuffix("tt0780504.md")).resolves.toBe( - "tt0780504 (1).md", - ); - }); -}); diff --git a/src/gui/ChoiceBuilder/templateChoiceBuilder.ts b/src/gui/ChoiceBuilder/templateChoiceBuilder.ts index 396cf600..ca95d1b3 100644 --- a/src/gui/ChoiceBuilder/templateChoiceBuilder.ts +++ b/src/gui/ChoiceBuilder/templateChoiceBuilder.ts @@ -5,20 +5,18 @@ import { TextComponent, ToggleComponent, } from "obsidian"; -import type { fileExistsChoices } from "src/constants"; -import { - fileExistsAppendToBottom, - fileExistsAppendToTop, - fileExistsDoNothing, - fileExistsDuplicateSuffix, - fileExistsIncrement, - getFileExistsBehaviorModeDescription, - fileExistsModeLabels, - fileExistsOverwriteFile, -} from "src/constants"; import { FileNameDisplayFormatter } from "../../formatters/fileNameDisplayFormatter"; import { log } from "../../logger/logManager"; import type QuickAdd from "../../main"; +import { + fileExistsBehaviorCategoryOptions, + getBehaviorCategory, + getDefaultBehaviorForCategory, + getFileExistsMode, + getModesForCategory, + type FileExistsBehaviorCategoryId, + type FileExistsModeId, +} from "../../template/fileExistsPolicy"; import type ITemplateChoice from "../../types/choices/ITemplateChoice"; import type { LinkPlacement, LinkType } from "../../types/linkPlacement"; import { @@ -73,29 +71,6 @@ export class TemplateChoiceBuilder extends ChoiceBuilder { this.addOnePageOverrideSetting(this.choice); } - private getExistingFileBehaviorCategory(): "prompt" | "update" | "create" | "keep" { - if (!this.choice.setFileExistsBehavior) { - return "prompt"; - } - - if ( - this.choice.fileExistsMode === fileExistsAppendToBottom || - this.choice.fileExistsMode === fileExistsAppendToTop || - this.choice.fileExistsMode === fileExistsOverwriteFile - ) { - return "update"; - } - - if ( - this.choice.fileExistsMode === fileExistsIncrement || - this.choice.fileExistsMode === fileExistsDuplicateSuffix - ) { - return "create"; - } - - return "keep"; - } - private addTemplatePathSetting(): void { new Setting(this.contentEl) .setName("Template Path") @@ -446,10 +421,8 @@ export class TemplateChoiceBuilder extends ChoiceBuilder { } private addFileAlreadyExistsSetting(): void { - if (!this.choice.fileExistsMode) - this.choice.fileExistsMode = fileExistsDoNothing; - - const behaviorCategory = this.getExistingFileBehaviorCategory(); + this.choice.fileExistsBehavior ??= { kind: "prompt" }; + const behaviorCategory = getBehaviorCategory(this.choice.fileExistsBehavior); new Setting(this.contentEl) .setName("If the target file already exists") @@ -457,40 +430,15 @@ export class TemplateChoiceBuilder extends ChoiceBuilder { "Choose whether QuickAdd should ask what to do, update the existing file, create another file, or keep the existing file.", ) .addDropdown((dropdown) => { - dropdown.addOption("prompt", "Ask every time"); - dropdown.addOption("update", "Update existing file"); - dropdown.addOption("create", "Create another file"); - dropdown.addOption("keep", "Keep existing file"); + fileExistsBehaviorCategoryOptions.forEach((option) => { + dropdown.addOption(option.id, option.label); + }); dropdown.setValue(behaviorCategory); - dropdown.onChange((value) => { - switch (value) { - case "prompt": - this.choice.setFileExistsBehavior = false; - break; - case "update": - this.choice.setFileExistsBehavior = true; - if ( - this.choice.fileExistsMode !== fileExistsAppendToBottom && - this.choice.fileExistsMode !== fileExistsAppendToTop && - this.choice.fileExistsMode !== fileExistsOverwriteFile - ) { - this.choice.fileExistsMode = fileExistsAppendToBottom; - } - break; - case "create": - this.choice.setFileExistsBehavior = true; - if ( - this.choice.fileExistsMode !== fileExistsIncrement && - this.choice.fileExistsMode !== fileExistsDuplicateSuffix - ) { - this.choice.fileExistsMode = fileExistsDuplicateSuffix; - } - break; - case "keep": - this.choice.setFileExistsBehavior = true; - this.choice.fileExistsMode = fileExistsDoNothing; - break; - } + dropdown.onChange((value: FileExistsBehaviorCategoryId) => { + this.choice.fileExistsBehavior = getDefaultBehaviorForCategory( + value, + this.choice.fileExistsBehavior, + ); this.reload(); }); }); @@ -504,27 +452,28 @@ export class TemplateChoiceBuilder extends ChoiceBuilder { } const isUpdateBehavior = behaviorCategory === "update"; - const selectedModes = isUpdateBehavior - ? [ - fileExistsAppendToBottom, - fileExistsAppendToTop, - fileExistsOverwriteFile, - ] - : [fileExistsIncrement, fileExistsDuplicateSuffix]; + const selectedModes = getModesForCategory( + isUpdateBehavior ? "update" : "create", + ); + const selectedMode = + this.choice.fileExistsBehavior.kind === "apply" + ? this.choice.fileExistsBehavior.mode + : selectedModes[0].id; new Setting(this.contentEl) .setName(isUpdateBehavior ? "Update action" : "New file naming") - .setDesc(getFileExistsBehaviorModeDescription(this.choice.fileExistsMode)) + .setDesc(getFileExistsMode(selectedMode).description) .addDropdown((dropdown) => { selectedModes.forEach((mode) => { - dropdown.addOption(mode, fileExistsModeLabels[mode]); + dropdown.addOption(mode.id, mode.label); }); - dropdown.setValue(this.choice.fileExistsMode).onChange( - (value: (typeof fileExistsChoices)[number]) => { - this.choice.fileExistsMode = value; + dropdown.setValue(selectedMode).onChange((value: FileExistsModeId) => { + this.choice.fileExistsBehavior = { + kind: "apply", + mode: value, + }; this.reload(); - }, - ); + }); }); } } diff --git a/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.test.ts b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.test.ts new file mode 100644 index 00000000..9286718c --- /dev/null +++ b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import migration from "./incrementFileNameSettingMoveToDefaultBehavior"; + +describe("incrementFileNameSettingMoveToDefaultBehavior migration", () => { + it("migrates legacy incrementFileName choices to the new behavior model", async () => { + const plugin = { + settings: { + choices: [ + { + id: "template-choice", + name: "Template", + type: "Template", + incrementFileName: true, + }, + ], + macros: [], + }, + } as any; + + await migration.migrate(plugin); + + expect(plugin.settings.choices[0]).toMatchObject({ + fileExistsBehavior: { kind: "apply", mode: "increment" }, + }); + expect(plugin.settings.choices[0].incrementFileName).toBeUndefined(); + expect(plugin.settings.choices[0].fileExistsMode).toBeUndefined(); + expect(plugin.settings.choices[0].setFileExistsBehavior).toBeUndefined(); + }); + + it("migrates split legacy settings on template choices", async () => { + const plugin = { + settings: { + choices: [ + { + id: "prompt-choice", + name: "Prompt Template", + type: "Template", + setFileExistsBehavior: false, + fileExistsMode: "Append to the bottom of the file", + }, + { + id: "apply-choice", + name: "Apply Template", + type: "Template", + setFileExistsBehavior: true, + fileExistsMode: "Append duplicate suffix", + }, + ], + macros: [], + }, + } as any; + + await migration.migrate(plugin); + + expect(plugin.settings.choices[0]).toMatchObject({ + fileExistsBehavior: { kind: "prompt" }, + }); + expect(plugin.settings.choices[1]).toMatchObject({ + fileExistsBehavior: { kind: "apply", mode: "duplicateSuffix" }, + }); + }); + + it("migrates nested macro command template choices", async () => { + const plugin = { + settings: { + choices: [], + macros: [ + { + id: "macro-1", + name: "Macro", + commands: [ + { + id: "command-1", + type: "Choice", + choice: { + id: "template-choice", + name: "Template", + type: "Template", + setFileExistsBehavior: true, + fileExistsMode: "Overwrite the file", + }, + }, + ], + }, + ], + }, + } as any; + + await migration.migrate(plugin); + + expect(plugin.settings.macros[0].commands[0].choice).toMatchObject({ + fileExistsBehavior: { kind: "apply", mode: "overwrite" }, + }); + expect( + plugin.settings.macros[0].commands[0].choice.fileExistsMode, + ).toBeUndefined(); + expect( + plugin.settings.macros[0].commands[0].choice.setFileExistsBehavior, + ).toBeUndefined(); + }); +}); diff --git a/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts index 13c2d0db..00ee520c 100644 --- a/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts +++ b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts @@ -2,22 +2,67 @@ import type QuickAdd from "src/main"; import type IChoice from "src/types/choices/IChoice"; import type { IMacro } from "src/types/macros/IMacro"; import { deepClone } from "src/utils/deepClone"; +import { + mapLegacyFileExistsModeToId, + type TemplateFileExistsBehavior, +} from "../template/fileExistsPolicy"; import { isMultiChoice } from "./helpers/isMultiChoice"; import { isNestedChoiceCommand } from "./helpers/isNestedChoiceCommand"; -import { isOldTemplateChoice } from "./helpers/isOldTemplateChoice"; import type { Migration } from "./Migrations"; +type LegacyTemplateChoice = { + type?: string; + incrementFileName?: boolean; + setFileExistsBehavior?: boolean; + fileExistsMode?: unknown; + fileExistsBehavior?: TemplateFileExistsBehavior; +}; + +function isTemplateChoice(choice: unknown): choice is LegacyTemplateChoice { + return ( + typeof choice === "object" && + choice !== null && + "type" in choice && + (choice as { type?: string }).type === "Template" + ); +} + +function migrateFileExistsBehavior( + choice: LegacyTemplateChoice, +): TemplateFileExistsBehavior { + if (choice.fileExistsBehavior) { + return choice.fileExistsBehavior; + } + + if (choice.incrementFileName) { + return { kind: "apply", mode: "increment" }; + } + + if (choice.setFileExistsBehavior) { + return { + kind: "apply", + mode: mapLegacyFileExistsModeToId(choice.fileExistsMode) ?? "increment", + }; + } + + return { kind: "prompt" }; +} + +function normalizeTemplateChoice(choice: LegacyTemplateChoice): void { + choice.fileExistsBehavior = migrateFileExistsBehavior(choice); + delete choice.incrementFileName; + delete choice.setFileExistsBehavior; + delete choice.fileExistsMode; +} + function recursiveRemoveIncrementFileName(choices: IChoice[]): IChoice[] { for (const choice of choices) { if (isMultiChoice(choice)) { choice.choices = recursiveRemoveIncrementFileName(choice.choices); } - if (isOldTemplateChoice(choice)) { - choice.setFileExistsBehavior = true; - choice.fileExistsMode = "Increment the file name"; - - delete choice.incrementFileName; + if (isTemplateChoice(choice)) { + normalizeTemplateChoice(choice); } } @@ -31,12 +76,9 @@ function removeIncrementFileName(macros: IMacro[]): IMacro[] { for (const command of macro.commands) { if ( isNestedChoiceCommand(command) && - isOldTemplateChoice(command.choice) + isTemplateChoice(command.choice) ) { - command.choice.setFileExistsBehavior = true; - command.choice.fileExistsMode = "Increment the file name"; - - delete command.choice.incrementFileName; + normalizeTemplateChoice(command.choice); } } } @@ -46,7 +88,7 @@ function removeIncrementFileName(macros: IMacro[]): IMacro[] { const incrementFileNameSettingMoveToDefaultBehavior: Migration = { description: - "'Increment file name' setting moved to 'Set default behavior if file already exists' setting", + "Template file collision settings consolidated into a single behavior model", migrate: async (plugin: QuickAdd): Promise => { const choicesCopy = deepClone(plugin.settings.choices); diff --git a/src/preflight/runOnePagePreflight.selection.test.ts b/src/preflight/runOnePagePreflight.selection.test.ts index 3b912394..f28bd899 100644 --- a/src/preflight/runOnePagePreflight.selection.test.ts +++ b/src/preflight/runOnePagePreflight.selection.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { TFile, type App } from "obsidian"; -import { fileExistsAppendToBottom } from "src/constants"; import { runOnePagePreflight } from "./runOnePagePreflight"; import type ICaptureChoice from "../types/choices/ICaptureChoice"; import type { IChoiceExecutor } from "../IChoiceExecutor"; @@ -122,8 +121,7 @@ const createTemplateChoice = (templatePath: string): ITemplateChoice => mode: "default", focus: true, }, - fileExistsMode: fileExistsAppendToBottom, - setFileExistsBehavior: false, + fileExistsBehavior: { kind: "prompt" }, }) as ITemplateChoice; describe("runOnePagePreflight selection-as-value", () => { diff --git a/src/template/fileExistsPolicy.test.ts b/src/template/fileExistsPolicy.test.ts new file mode 100644 index 00000000..7c6402cd --- /dev/null +++ b/src/template/fileExistsPolicy.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fileExistsBehaviorCategoryOptions, + getBehaviorCategory, + getDefaultBehaviorForCategory, + getFileExistsMode, + getModesForCategory, + getPromptModes, + mapLegacyFileExistsModeToId, + resolveDuplicateSuffixCollisionPath, + resolveIncrementedCollisionPath, +} from "./fileExistsPolicy"; + +describe("fileExistsPolicy registry", () => { + it("exposes stable behavior categories", () => { + expect(fileExistsBehaviorCategoryOptions.map((option) => option.id)).toEqual([ + "prompt", + "update", + "create", + "keep", + ]); + }); + + it("groups modes by category", () => { + expect(getModesForCategory("update").map((mode) => mode.id)).toEqual([ + "appendBottom", + "appendTop", + "overwrite", + ]); + expect(getModesForCategory("create").map((mode) => mode.id)).toEqual([ + "increment", + "duplicateSuffix", + ]); + expect(getModesForCategory("keep").map((mode) => mode.id)).toEqual([ + "doNothing", + ]); + }); + + it("derives behavior category from behavior state", () => { + expect(getBehaviorCategory({ kind: "prompt" })).toBe("prompt"); + expect( + getBehaviorCategory({ kind: "apply", mode: "appendBottom" }), + ).toBe("update"); + expect(getBehaviorCategory({ kind: "apply", mode: "increment" })).toBe( + "create", + ); + expect(getBehaviorCategory({ kind: "apply", mode: "doNothing" })).toBe( + "keep", + ); + }); + + it("returns category defaults and preserves modes already in-category", () => { + expect(getDefaultBehaviorForCategory("prompt")).toEqual({ kind: "prompt" }); + expect(getDefaultBehaviorForCategory("update")).toEqual({ + kind: "apply", + mode: "appendBottom", + }); + expect(getDefaultBehaviorForCategory("create")).toEqual({ + kind: "apply", + mode: "duplicateSuffix", + }); + expect( + getDefaultBehaviorForCategory("create", { + kind: "apply", + mode: "increment", + }), + ).toEqual({ + kind: "apply", + mode: "increment", + }); + }); + + it("exposes prompt modes and legacy mapping for all supported modes", () => { + expect(getPromptModes().map((mode) => mode.id)).toEqual([ + "appendBottom", + "appendTop", + "overwrite", + "increment", + "duplicateSuffix", + "doNothing", + ]); + expect(getFileExistsMode("duplicateSuffix").description).toContain( + "duplicate marker", + ); + expect( + mapLegacyFileExistsModeToId("Append to the bottom of the file"), + ).toBe("appendBottom"); + expect(mapLegacyFileExistsModeToId("Append duplicate suffix")).toBe( + "duplicateSuffix", + ); + expect(mapLegacyFileExistsModeToId("Nothing")).toBe("doNothing"); + }); +}); + +describe("fileExistsPolicy collision naming", () => { + it("appends 1 before .md when no number exists", async () => { + const exists = vi + .fn<(path: string) => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect(resolveIncrementedCollisionPath("Note.md", exists)).resolves.toBe( + "Note1.md", + ); + }); + + it("preserves zero padding for markdown files", async () => { + const exists = vi + .fn<(path: string) => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect( + resolveIncrementedCollisionPath("Note009.md", exists), + ).resolves.toBe("Note010.md"); + }); + + it("preserves zero padding for identifier-like markdown files", async () => { + const exists = vi + .fn<(path: string) => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect( + resolveIncrementedCollisionPath("tt0780504.md", exists), + ).resolves.toBe("tt0780505.md"); + }); + + it("preserves zero padding for .canvas files", async () => { + const exists = vi + .fn<(path: string) => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect( + resolveIncrementedCollisionPath("tt009.canvas", exists), + ).resolves.toBe("tt010.canvas"); + }); + + it("preserves zero padding for .base files", async () => { + const exists = vi + .fn<(path: string) => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect(resolveIncrementedCollisionPath("tt009.base", exists)).resolves.toBe( + "tt010.base", + ); + }); + + it("recurses incrementing until an available file name is found", async () => { + const exists = vi.fn(async (path: string) => { + return path === "Note.md" || path === "Note1.md"; + }); + + await expect(resolveIncrementedCollisionPath("Note.md", exists)).resolves.toBe( + "Note2.md", + ); + }); + + it("passes through unchanged when there is no collision", async () => { + const exists = vi.fn(async () => false); + + await expect(resolveIncrementedCollisionPath("Note.md", exists)).resolves.toBe( + "Note.md", + ); + await expect( + resolveDuplicateSuffixCollisionPath("Note.md", exists), + ).resolves.toBe("Note.md"); + }); + + it("appends a duplicate suffix to markdown files", async () => { + const exists = vi + .fn<(path: string) => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect( + resolveDuplicateSuffixCollisionPath("Note.md", exists), + ).resolves.toBe("Note (1).md"); + }); + + it("increments an existing duplicate suffix", async () => { + const exists = vi + .fn<(path: string) => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect( + resolveDuplicateSuffixCollisionPath("Note (1).md", exists), + ).resolves.toBe("Note (2).md"); + }); + + it("preserves trailing digits when adding a duplicate suffix", async () => { + const exists = vi + .fn<(path: string) => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect( + resolveDuplicateSuffixCollisionPath("Note1.md", exists), + ).resolves.toBe("Note1 (1).md"); + }); + + it("adds a duplicate suffix for identifier-like markdown files", async () => { + const exists = vi + .fn<(path: string) => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + await expect( + resolveDuplicateSuffixCollisionPath("tt0780504.md", exists), + ).resolves.toBe("tt0780504 (1).md"); + }); +}); diff --git a/src/template/fileExistsPolicy.ts b/src/template/fileExistsPolicy.ts new file mode 100644 index 00000000..745ad3be --- /dev/null +++ b/src/template/fileExistsPolicy.ts @@ -0,0 +1,258 @@ +import { + BASE_FILE_EXTENSION_REGEX, + CANVAS_FILE_EXTENSION_REGEX, + MARKDOWN_FILE_EXTENSION_REGEX, +} from "src/constants"; + +export const fileExistsBehaviorCategoryOptions = [ + { id: "prompt", label: "Ask every time" }, + { id: "update", label: "Update existing file" }, + { id: "create", label: "Create another file" }, + { id: "keep", label: "Keep existing file" }, +] as const; + +export type FileExistsBehaviorCategoryId = + (typeof fileExistsBehaviorCategoryOptions)[number]["id"]; +export type FileExistsModeCategoryId = Exclude< + FileExistsBehaviorCategoryId, + "prompt" +>; + +export const fileExistsModes = [ + { + id: "appendBottom", + category: "update", + label: "Append to bottom", + description: "Adds the template content to the end of the existing file.", + requiresExistingFile: true, + resolutionKind: "modifyExisting", + }, + { + id: "appendTop", + category: "update", + label: "Append to top", + description: + "Adds the template content to the beginning of the existing file.", + requiresExistingFile: true, + resolutionKind: "modifyExisting", + }, + { + id: "overwrite", + category: "update", + label: "Overwrite file", + description: "Replaces the existing file content with the template.", + requiresExistingFile: true, + resolutionKind: "modifyExisting", + }, + { + id: "increment", + category: "create", + label: "Increment trailing number", + description: + "Changes trailing digits only. Example: Draft009.md -> Draft010.md.", + requiresExistingFile: false, + resolutionKind: "createNew", + }, + { + id: "duplicateSuffix", + category: "create", + label: "Append duplicate suffix", + description: + "Keeps the original name and adds a duplicate marker. Example: Project Plan.md -> Project Plan (1).md.", + requiresExistingFile: false, + resolutionKind: "createNew", + }, + { + id: "doNothing", + category: "keep", + label: "Do nothing", + description: "Leaves the file unchanged and opens the existing file.", + requiresExistingFile: true, + resolutionKind: "reuseExisting", + }, +] as const; + +export type FileExistsModeId = (typeof fileExistsModes)[number]["id"]; +export type FileExistsResolutionKind = + (typeof fileExistsModes)[number]["resolutionKind"]; +export type FileExistsModeDefinition = (typeof fileExistsModes)[number]; +export type TemplateFileExistsBehavior = + | { kind: "prompt" } + | { kind: "apply"; mode: FileExistsModeId }; + +type ExistsFn = (path: string) => Promise; +type CreateNewModeId = Extract< + FileExistsModeDefinition, + { resolutionKind: "createNew" } +>["id"]; + +const fileExistsModeById = new Map( + fileExistsModes.map((mode) => [mode.id, mode]), +); + +const defaultModeByCategory: Record = { + update: "appendBottom", + create: "duplicateSuffix", + keep: "doNothing", +}; + +const legacyFileExistsModeMap: Record = { + "Append to the bottom of the file": "appendBottom", + "Append to the top of the file": "appendTop", + "Overwrite the file": "overwrite", + "Increment the file name": "increment", + "Append duplicate suffix": "duplicateSuffix", + Nothing: "doNothing", + appendBottom: "appendBottom", + appendTop: "appendTop", + overwrite: "overwrite", + increment: "increment", + duplicateSuffix: "duplicateSuffix", + doNothing: "doNothing", +}; + +const createNewPathResolvers: Record< + CreateNewModeId, + (filePath: string, exists: ExistsFn) => Promise +> = { + increment: resolveIncrementedCollisionPath, + duplicateSuffix: resolveDuplicateSuffixCollisionPath, +}; + +export function getFileExistsMode(id: FileExistsModeId): FileExistsModeDefinition { + const mode = fileExistsModeById.get(id); + if (!mode) { + throw new Error(`Unknown file exists mode: ${id}`); + } + return mode; +} + +export function getModesForCategory( + category: FileExistsModeCategoryId, +): FileExistsModeDefinition[] { + return fileExistsModes.filter((mode) => mode.category === category); +} + +export function getCategoryForMode( + modeId: FileExistsModeId, +): FileExistsModeCategoryId { + return getFileExistsMode(modeId).category; +} + +export function getPromptModes(): FileExistsModeDefinition[] { + return [...fileExistsModes]; +} + +export function getBehaviorCategory( + behavior: TemplateFileExistsBehavior, +): FileExistsBehaviorCategoryId { + if (behavior.kind === "prompt") { + return "prompt"; + } + + return getCategoryForMode(behavior.mode); +} + +export function getDefaultBehaviorForCategory( + category: FileExistsBehaviorCategoryId, + currentBehavior?: TemplateFileExistsBehavior, +): TemplateFileExistsBehavior { + if (category === "prompt") { + return { kind: "prompt" }; + } + + if ( + currentBehavior?.kind === "apply" && + getCategoryForMode(currentBehavior.mode) === category + ) { + return currentBehavior; + } + + return { kind: "apply", mode: defaultModeByCategory[category] }; +} + +export function mapLegacyFileExistsModeToId( + mode: unknown, +): FileExistsModeId | null { + if (typeof mode !== "string") { + return null; + } + + return legacyFileExistsModeMap[mode] ?? null; +} + +export async function resolveCreateNewCollisionFilePath( + filePath: string, + modeId: CreateNewModeId, + exists: ExistsFn, +): Promise { + return await createNewPathResolvers[modeId](filePath, exists); +} + +export async function resolveIncrementedCollisionPath( + filePath: string, + exists: ExistsFn, +): Promise { + if (!(await exists(filePath))) { + return filePath; + } + + const { basename, extension } = splitCollisionFileName(filePath); + const match = basename.match(/^(.*?)(\d+)$/); + const nextBasename = match + ? `${match[1]}${String(parseInt(match[2], 10) + 1).padStart( + match[2].length, + "0", + )}` + : `${basename}1`; + const nextFilePath = `${nextBasename}${extension}`; + + if (await exists(nextFilePath)) { + return await resolveIncrementedCollisionPath(nextFilePath, exists); + } + + return nextFilePath; +} + +export async function resolveDuplicateSuffixCollisionPath( + filePath: string, + exists: ExistsFn, +): Promise { + if (!(await exists(filePath))) { + return filePath; + } + + const { basename, extension } = splitCollisionFileName(filePath); + const match = basename.match(/^(.*) \((\d+)\)$/); + const nextBasename = match + ? `${match[1]} (${parseInt(match[2], 10) + 1})` + : `${basename} (1)`; + const nextFilePath = `${nextBasename}${extension}`; + + if (await exists(nextFilePath)) { + return await resolveDuplicateSuffixCollisionPath(nextFilePath, exists); + } + + return nextFilePath; +} + +function splitCollisionFileName(filePath: string) { + if (CANVAS_FILE_EXTENSION_REGEX.test(filePath)) { + return { + basename: filePath.replace(CANVAS_FILE_EXTENSION_REGEX, ""), + extension: ".canvas", + }; + } + + if (BASE_FILE_EXTENSION_REGEX.test(filePath)) { + return { + basename: filePath.replace(BASE_FILE_EXTENSION_REGEX, ""), + extension: ".base", + }; + } + + return { + basename: filePath.replace(MARKDOWN_FILE_EXTENSION_REGEX, ""), + extension: ".md", + }; +} diff --git a/src/types/choices/ITemplateChoice.ts b/src/types/choices/ITemplateChoice.ts index b4e67e66..7d3cddc5 100644 --- a/src/types/choices/ITemplateChoice.ts +++ b/src/types/choices/ITemplateChoice.ts @@ -1,7 +1,7 @@ import type IChoice from "./IChoice"; -import type { FileExistsMode } from "src/constants"; import type { AppendLinkOptions } from "../linkPlacement"; import type { OpenLocation, FileViewMode2 } from "../fileOpening"; +import type { TemplateFileExistsBehavior } from "../../template/fileExistsPolicy"; export default interface ITemplateChoice extends IChoice { templatePath: string; @@ -26,6 +26,5 @@ export default interface ITemplateChoice extends IChoice { mode: FileViewMode2; focus: boolean; }; - fileExistsMode: FileExistsMode; - setFileExistsBehavior: boolean; + fileExistsBehavior: TemplateFileExistsBehavior; } diff --git a/src/types/choices/TemplateChoice.ts b/src/types/choices/TemplateChoice.ts index 3e19e501..10b61947 100644 --- a/src/types/choices/TemplateChoice.ts +++ b/src/types/choices/TemplateChoice.ts @@ -1,9 +1,9 @@ import type ITemplateChoice from "./ITemplateChoice"; import { Choice } from "./Choice"; -import type { FileExistsMode } from "src/constants"; import type { OpenLocation, FileViewMode2 } from "../fileOpening"; import type { AppendLinkOptions } from "../linkPlacement"; import { normalizeFileOpening } from "../../utils/fileOpeningDefaults"; +import type { TemplateFileExistsBehavior } from "../../template/fileExistsPolicy"; export class TemplateChoice extends Choice implements ITemplateChoice { appendLink: boolean | AppendLinkOptions; @@ -23,8 +23,7 @@ export class TemplateChoice extends Choice implements ITemplateChoice { focus: boolean; }; templatePath: string; - fileExistsMode: FileExistsMode; - setFileExistsBehavior: boolean; + fileExistsBehavior: TemplateFileExistsBehavior; constructor(name: string) { super(name, "Template"); @@ -41,8 +40,7 @@ export class TemplateChoice extends Choice implements ITemplateChoice { this.appendLink = false; this.openFile = false; this.fileOpening = normalizeFileOpening(); - this.fileExistsMode = "Increment the file name"; - this.setFileExistsBehavior = false; + this.fileExistsBehavior = { kind: "prompt" }; } public static Load(choice: ITemplateChoice): TemplateChoice { From 60ac61c76adf26329492453fb57d6fec5a0d27a0 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 13 Mar 2026 22:18:43 +0100 Subject: [PATCH 6/8] fix(template): backfill file exists behavior migration --- src/engine/CaptureChoiceEngine.notice.test.ts | 1 + ...oiceEngine.template-property-types.test.ts | 1 + src/engine/MacroChoiceEngine.notice.test.ts | 1 + .../TemplateChoiceEngine.collision.test.ts | 19 ++++++ .../TemplateChoiceEngine.notice.test.ts | 1 + src/engine/TemplateChoiceEngine.ts | 2 + .../consolidateFileExistsBehavior.test.ts | 62 +++++++++++++++++++ .../consolidateFileExistsBehavior.ts | 54 ++++++++++++++++ .../normalizeTemplateFileExistsBehavior.ts | 49 +++++++++++++++ ...entFileNameSettingMoveToDefaultBehavior.ts | 51 +-------------- src/migrations/migrate.ts | 2 + src/settings.ts | 2 + 12 files changed, 197 insertions(+), 48 deletions(-) create mode 100644 src/migrations/consolidateFileExistsBehavior.test.ts create mode 100644 src/migrations/consolidateFileExistsBehavior.ts create mode 100644 src/migrations/helpers/normalizeTemplateFileExistsBehavior.ts diff --git a/src/engine/CaptureChoiceEngine.notice.test.ts b/src/engine/CaptureChoiceEngine.notice.test.ts index 3a734406..422ee565 100644 --- a/src/engine/CaptureChoiceEngine.notice.test.ts +++ b/src/engine/CaptureChoiceEngine.notice.test.ts @@ -27,6 +27,7 @@ vi.mock("../quickAddSettingsTab", () => { migrateToMacroIDFromEmbeddedMacro: true, useQuickAddTemplateFolder: false, incrementFileNameSettingMoveToDefaultBehavior: false, + consolidateFileExistsBehavior: false, mutualExclusionInsertAfterAndWriteToBottomOfFile: false, setVersionAfterUpdateModalRelease: false, addDefaultAIProviders: false, diff --git a/src/engine/CaptureChoiceEngine.template-property-types.test.ts b/src/engine/CaptureChoiceEngine.template-property-types.test.ts index d0633210..39d5de65 100644 --- a/src/engine/CaptureChoiceEngine.template-property-types.test.ts +++ b/src/engine/CaptureChoiceEngine.template-property-types.test.ts @@ -32,6 +32,7 @@ vi.mock("../quickAddSettingsTab", () => { migrateToMacroIDFromEmbeddedMacro: true, useQuickAddTemplateFolder: false, incrementFileNameSettingMoveToDefaultBehavior: false, + consolidateFileExistsBehavior: false, mutualExclusionInsertAfterAndWriteToBottomOfFile: false, setVersionAfterUpdateModalRelease: false, addDefaultAIProviders: false, diff --git a/src/engine/MacroChoiceEngine.notice.test.ts b/src/engine/MacroChoiceEngine.notice.test.ts index 5f365110..d9608499 100644 --- a/src/engine/MacroChoiceEngine.notice.test.ts +++ b/src/engine/MacroChoiceEngine.notice.test.ts @@ -27,6 +27,7 @@ vi.mock("../quickAddSettingsTab", () => { migrateToMacroIDFromEmbeddedMacro: true, useQuickAddTemplateFolder: false, incrementFileNameSettingMoveToDefaultBehavior: false, + consolidateFileExistsBehavior: false, mutualExclusionInsertAfterAndWriteToBottomOfFile: false, setVersionAfterUpdateModalRelease: false, addDefaultAIProviders: false, diff --git a/src/engine/TemplateChoiceEngine.collision.test.ts b/src/engine/TemplateChoiceEngine.collision.test.ts index 858030d9..23c042cc 100644 --- a/src/engine/TemplateChoiceEngine.collision.test.ts +++ b/src/engine/TemplateChoiceEngine.collision.test.ts @@ -27,6 +27,7 @@ vi.mock("../quickAddSettingsTab", () => { migrateToMacroIDFromEmbeddedMacro: true, useQuickAddTemplateFolder: false, incrementFileNameSettingMoveToDefaultBehavior: false, + consolidateFileExistsBehavior: false, mutualExclusionInsertAfterAndWriteToBottomOfFile: false, setVersionAfterUpdateModalRelease: false, addDefaultAIProviders: false, @@ -397,4 +398,22 @@ describe("TemplateChoiceEngine collision behavior", () => { engine.choice.templatePath, ); }); + + it("falls back to prompt behavior when fileExistsBehavior is missing at runtime", async () => { + const { app, engine } = createEngine(); + const existingFile = createExistingFile("Test Template.md"); + + (engine.choice as any).fileExistsBehavior = undefined; + + (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); + (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( + existingFile, + ); + vi.mocked(GenericSuggester.Suggest).mockResolvedValue("doNothing"); + + await engine.run(); + + expect(GenericSuggester.Suggest).toHaveBeenCalledTimes(1); + expect(engine.choice.fileExistsBehavior).toEqual({ kind: "prompt" }); + }); }); diff --git a/src/engine/TemplateChoiceEngine.notice.test.ts b/src/engine/TemplateChoiceEngine.notice.test.ts index 26e69bf3..146b940f 100644 --- a/src/engine/TemplateChoiceEngine.notice.test.ts +++ b/src/engine/TemplateChoiceEngine.notice.test.ts @@ -27,6 +27,7 @@ vi.mock("../quickAddSettingsTab", () => { migrateToMacroIDFromEmbeddedMacro: true, useQuickAddTemplateFolder: false, incrementFileNameSettingMoveToDefaultBehavior: false, + consolidateFileExistsBehavior: false, mutualExclusionInsertAfterAndWriteToBottomOfFile: false, setVersionAfterUpdateModalRelease: false, addDefaultAIProviders: false, diff --git a/src/engine/TemplateChoiceEngine.ts b/src/engine/TemplateChoiceEngine.ts index 722ec8e7..fb68b6e3 100644 --- a/src/engine/TemplateChoiceEngine.ts +++ b/src/engine/TemplateChoiceEngine.ts @@ -167,6 +167,8 @@ export class TemplateChoiceEngine extends TemplateEngine { } private async getSelectedFileExistsMode(): Promise { + this.choice.fileExistsBehavior ??= { kind: "prompt" }; + if (this.choice.fileExistsBehavior.kind === "apply") { return this.choice.fileExistsBehavior.mode; } diff --git a/src/migrations/consolidateFileExistsBehavior.test.ts b/src/migrations/consolidateFileExistsBehavior.test.ts new file mode 100644 index 00000000..43b32054 --- /dev/null +++ b/src/migrations/consolidateFileExistsBehavior.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import migration from "./consolidateFileExistsBehavior"; + +describe("consolidateFileExistsBehavior migration", () => { + it("converts split legacy template choice state even when the old migration already ran", async () => { + const plugin = { + settings: { + choices: [ + { + id: "template-choice", + name: "Template", + type: "Template", + setFileExistsBehavior: true, + fileExistsMode: "Append duplicate suffix", + }, + ], + macros: [], + }, + } as any; + + await migration.migrate(plugin); + + expect(plugin.settings.choices[0]).toMatchObject({ + fileExistsBehavior: { kind: "apply", mode: "duplicateSuffix" }, + }); + expect(plugin.settings.choices[0].fileExistsMode).toBeUndefined(); + expect(plugin.settings.choices[0].setFileExistsBehavior).toBeUndefined(); + }); + + it("normalizes nested macro command template choices", async () => { + const plugin = { + settings: { + choices: [], + macros: [ + { + id: "macro-1", + name: "Macro", + commands: [ + { + id: "command-1", + type: "Choice", + choice: { + id: "template-choice", + name: "Template", + type: "Template", + setFileExistsBehavior: false, + fileExistsMode: "Overwrite the file", + }, + }, + ], + }, + ], + }, + } as any; + + await migration.migrate(plugin); + + expect(plugin.settings.macros[0].commands[0].choice).toMatchObject({ + fileExistsBehavior: { kind: "prompt" }, + }); + }); +}); diff --git a/src/migrations/consolidateFileExistsBehavior.ts b/src/migrations/consolidateFileExistsBehavior.ts new file mode 100644 index 00000000..e07a84f2 --- /dev/null +++ b/src/migrations/consolidateFileExistsBehavior.ts @@ -0,0 +1,54 @@ +import type QuickAdd from "src/main"; +import type IChoice from "src/types/choices/IChoice"; +import type { IMacro } from "src/types/macros/IMacro"; +import { deepClone } from "src/utils/deepClone"; +import { + isTemplateChoice, + normalizeTemplateChoice, +} from "./helpers/normalizeTemplateFileExistsBehavior"; +import { isMultiChoice } from "./helpers/isMultiChoice"; +import { isNestedChoiceCommand } from "./helpers/isNestedChoiceCommand"; +import type { Migration } from "./Migrations"; + +function normalizeChoices(choices: IChoice[]): IChoice[] { + for (const choice of choices) { + if (isMultiChoice(choice)) { + choice.choices = normalizeChoices(choice.choices); + } + + if (isTemplateChoice(choice)) { + normalizeTemplateChoice(choice); + } + } + + return choices; +} + +function normalizeMacros(macros: IMacro[]): IMacro[] { + for (const macro of macros) { + if (!Array.isArray(macro.commands)) continue; + + for (const command of macro.commands) { + if (isNestedChoiceCommand(command) && isTemplateChoice(command.choice)) { + normalizeTemplateChoice(command.choice); + } + } + } + + return macros; +} + +const consolidateFileExistsBehavior: Migration = { + description: + "Re-run template file collision normalization for users with older migration state", + + migrate: async (plugin: QuickAdd): Promise => { + const choicesCopy = deepClone(plugin.settings.choices); + const macrosCopy = deepClone((plugin.settings as any).macros || []); + + plugin.settings.choices = deepClone(normalizeChoices(choicesCopy)); + (plugin.settings as any).macros = normalizeMacros(macrosCopy); + }, +}; + +export default consolidateFileExistsBehavior; diff --git a/src/migrations/helpers/normalizeTemplateFileExistsBehavior.ts b/src/migrations/helpers/normalizeTemplateFileExistsBehavior.ts new file mode 100644 index 00000000..4c2a0778 --- /dev/null +++ b/src/migrations/helpers/normalizeTemplateFileExistsBehavior.ts @@ -0,0 +1,49 @@ +import type { TemplateFileExistsBehavior } from "src/template/fileExistsPolicy"; +import { mapLegacyFileExistsModeToId } from "src/template/fileExistsPolicy"; + +export type LegacyTemplateChoice = { + type?: string; + incrementFileName?: boolean; + setFileExistsBehavior?: boolean; + fileExistsMode?: unknown; + fileExistsBehavior?: TemplateFileExistsBehavior; +}; + +export function isTemplateChoice( + choice: unknown, +): choice is LegacyTemplateChoice { + return ( + typeof choice === "object" && + choice !== null && + "type" in choice && + (choice as { type?: string }).type === "Template" + ); +} + +export function migrateFileExistsBehavior( + choice: LegacyTemplateChoice, +): TemplateFileExistsBehavior { + if (choice.fileExistsBehavior) { + return choice.fileExistsBehavior; + } + + if (choice.incrementFileName) { + return { kind: "apply", mode: "increment" }; + } + + if (choice.setFileExistsBehavior) { + return { + kind: "apply", + mode: mapLegacyFileExistsModeToId(choice.fileExistsMode) ?? "increment", + }; + } + + return { kind: "prompt" }; +} + +export function normalizeTemplateChoice(choice: LegacyTemplateChoice): void { + choice.fileExistsBehavior = migrateFileExistsBehavior(choice); + delete choice.incrementFileName; + delete choice.setFileExistsBehavior; + delete choice.fileExistsMode; +} diff --git a/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts index 00ee520c..b301f1ed 100644 --- a/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts +++ b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts @@ -3,58 +3,13 @@ import type IChoice from "src/types/choices/IChoice"; import type { IMacro } from "src/types/macros/IMacro"; import { deepClone } from "src/utils/deepClone"; import { - mapLegacyFileExistsModeToId, - type TemplateFileExistsBehavior, -} from "../template/fileExistsPolicy"; + isTemplateChoice, + normalizeTemplateChoice, +} from "./helpers/normalizeTemplateFileExistsBehavior"; import { isMultiChoice } from "./helpers/isMultiChoice"; import { isNestedChoiceCommand } from "./helpers/isNestedChoiceCommand"; import type { Migration } from "./Migrations"; -type LegacyTemplateChoice = { - type?: string; - incrementFileName?: boolean; - setFileExistsBehavior?: boolean; - fileExistsMode?: unknown; - fileExistsBehavior?: TemplateFileExistsBehavior; -}; - -function isTemplateChoice(choice: unknown): choice is LegacyTemplateChoice { - return ( - typeof choice === "object" && - choice !== null && - "type" in choice && - (choice as { type?: string }).type === "Template" - ); -} - -function migrateFileExistsBehavior( - choice: LegacyTemplateChoice, -): TemplateFileExistsBehavior { - if (choice.fileExistsBehavior) { - return choice.fileExistsBehavior; - } - - if (choice.incrementFileName) { - return { kind: "apply", mode: "increment" }; - } - - if (choice.setFileExistsBehavior) { - return { - kind: "apply", - mode: mapLegacyFileExistsModeToId(choice.fileExistsMode) ?? "increment", - }; - } - - return { kind: "prompt" }; -} - -function normalizeTemplateChoice(choice: LegacyTemplateChoice): void { - choice.fileExistsBehavior = migrateFileExistsBehavior(choice); - delete choice.incrementFileName; - delete choice.setFileExistsBehavior; - delete choice.fileExistsMode; -} - function recursiveRemoveIncrementFileName(choices: IChoice[]): IChoice[] { for (const choice of choices) { if (isMultiChoice(choice)) { diff --git a/src/migrations/migrate.ts b/src/migrations/migrate.ts index a90bf0a5..b0d4ae30 100644 --- a/src/migrations/migrate.ts +++ b/src/migrations/migrate.ts @@ -3,6 +3,7 @@ import type QuickAdd from "src/main"; import type { Migrations } from "./Migrations"; import useQuickAddTemplateFolder from "./useQuickAddTemplateFolder"; import incrementFileNameSettingMoveToDefaultBehavior from "./incrementFileNameSettingMoveToDefaultBehavior"; +import consolidateFileExistsBehavior from "./consolidateFileExistsBehavior"; import mutualExclusionInsertAfterAndWriteToBottomOfFile from "./mutualExclusionInsertAfterAndWriteToBottomOfFile"; import setVersionAfterUpdateModalRelease from "./setVersionAfterUpdateModalRelease"; import addDefaultAIProviders from "./addDefaultAIProviders"; @@ -16,6 +17,7 @@ import migrateProviderApiKeysToSecretStorage from "./migrateProviderApiKeysToSec const migrations: Migrations = { useQuickAddTemplateFolder, incrementFileNameSettingMoveToDefaultBehavior, + consolidateFileExistsBehavior, mutualExclusionInsertAfterAndWriteToBottomOfFile, setVersionAfterUpdateModalRelease, addDefaultAIProviders, diff --git a/src/settings.ts b/src/settings.ts index 025ff6fe..60e7a35f 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -42,6 +42,7 @@ export interface QuickAddSettings { migrateToMacroIDFromEmbeddedMacro: boolean; useQuickAddTemplateFolder: boolean; incrementFileNameSettingMoveToDefaultBehavior: boolean; + consolidateFileExistsBehavior: boolean; mutualExclusionInsertAfterAndWriteToBottomOfFile: boolean; setVersionAfterUpdateModalRelease: boolean; addDefaultAIProviders: boolean; @@ -84,6 +85,7 @@ export const DEFAULT_SETTINGS: QuickAddSettings = { migrateToMacroIDFromEmbeddedMacro: true, useQuickAddTemplateFolder: false, incrementFileNameSettingMoveToDefaultBehavior: false, + consolidateFileExistsBehavior: false, mutualExclusionInsertAfterAndWriteToBottomOfFile: false, setVersionAfterUpdateModalRelease: false, addDefaultAIProviders: false, From 9b56aa1939d24a04eb7a36d4c1fa9c91c61b4f74 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 13 Mar 2026 22:28:54 +0100 Subject: [PATCH 7/8] fix(migrations): preserve file exists migration history --- src/migrations/helpers/isOldTemplateChoice.ts | 13 ------- ...leNameSettingMoveToDefaultBehavior.test.ts | 32 ++++++++-------- ...entFileNameSettingMoveToDefaultBehavior.ts | 37 ++++++++++++++----- 3 files changed, 43 insertions(+), 39 deletions(-) delete mode 100644 src/migrations/helpers/isOldTemplateChoice.ts diff --git a/src/migrations/helpers/isOldTemplateChoice.ts b/src/migrations/helpers/isOldTemplateChoice.ts deleted file mode 100644 index d1c3701c..00000000 --- a/src/migrations/helpers/isOldTemplateChoice.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { TemplateChoice } from "src/types/choices/TemplateChoice"; - -export type OldTemplateChoice = TemplateChoice & { - incrementFileName?: boolean; -}; - -export function isOldTemplateChoice( - choice: unknown -): choice is OldTemplateChoice { - if (typeof choice !== "object" || choice === null) return false; - - return "incrementFileName" in choice; -} diff --git a/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.test.ts b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.test.ts index 9286718c..3f8b5fa8 100644 --- a/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.test.ts +++ b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import migration from "./incrementFileNameSettingMoveToDefaultBehavior"; describe("incrementFileNameSettingMoveToDefaultBehavior migration", () => { - it("migrates legacy incrementFileName choices to the new behavior model", async () => { + it("migrates legacy incrementFileName choices to the old split behavior model", async () => { const plugin = { settings: { choices: [ @@ -20,14 +20,13 @@ describe("incrementFileNameSettingMoveToDefaultBehavior migration", () => { await migration.migrate(plugin); expect(plugin.settings.choices[0]).toMatchObject({ - fileExistsBehavior: { kind: "apply", mode: "increment" }, + setFileExistsBehavior: true, + fileExistsMode: "Increment the file name", }); expect(plugin.settings.choices[0].incrementFileName).toBeUndefined(); - expect(plugin.settings.choices[0].fileExistsMode).toBeUndefined(); - expect(plugin.settings.choices[0].setFileExistsBehavior).toBeUndefined(); }); - it("migrates split legacy settings on template choices", async () => { + it("leaves already-split legacy settings unchanged", async () => { const plugin = { settings: { choices: [ @@ -53,14 +52,18 @@ describe("incrementFileNameSettingMoveToDefaultBehavior migration", () => { await migration.migrate(plugin); expect(plugin.settings.choices[0]).toMatchObject({ - fileExistsBehavior: { kind: "prompt" }, + setFileExistsBehavior: false, + fileExistsMode: "Append to the bottom of the file", }); expect(plugin.settings.choices[1]).toMatchObject({ - fileExistsBehavior: { kind: "apply", mode: "duplicateSuffix" }, + setFileExistsBehavior: true, + fileExistsMode: "Append duplicate suffix", }); + expect(plugin.settings.choices[0].fileExistsBehavior).toBeUndefined(); + expect(plugin.settings.choices[1].fileExistsBehavior).toBeUndefined(); }); - it("migrates nested macro command template choices", async () => { + it("migrates nested macro command template choices to the old split behavior model", async () => { const plugin = { settings: { choices: [], @@ -76,8 +79,7 @@ describe("incrementFileNameSettingMoveToDefaultBehavior migration", () => { id: "template-choice", name: "Template", type: "Template", - setFileExistsBehavior: true, - fileExistsMode: "Overwrite the file", + incrementFileName: true, }, }, ], @@ -89,13 +91,9 @@ describe("incrementFileNameSettingMoveToDefaultBehavior migration", () => { await migration.migrate(plugin); expect(plugin.settings.macros[0].commands[0].choice).toMatchObject({ - fileExistsBehavior: { kind: "apply", mode: "overwrite" }, + setFileExistsBehavior: true, + fileExistsMode: "Increment the file name", }); - expect( - plugin.settings.macros[0].commands[0].choice.fileExistsMode, - ).toBeUndefined(); - expect( - plugin.settings.macros[0].commands[0].choice.setFileExistsBehavior, - ).toBeUndefined(); + expect(plugin.settings.macros[0].commands[0].choice.incrementFileName).toBeUndefined(); }); }); diff --git a/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts index b301f1ed..3cc1c575 100644 --- a/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts +++ b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts @@ -2,22 +2,39 @@ import type QuickAdd from "src/main"; import type IChoice from "src/types/choices/IChoice"; import type { IMacro } from "src/types/macros/IMacro"; import { deepClone } from "src/utils/deepClone"; -import { - isTemplateChoice, - normalizeTemplateChoice, -} from "./helpers/normalizeTemplateFileExistsBehavior"; import { isMultiChoice } from "./helpers/isMultiChoice"; import { isNestedChoiceCommand } from "./helpers/isNestedChoiceCommand"; import type { Migration } from "./Migrations"; +type OldTemplateChoice = { + type?: string; + incrementFileName?: boolean; + setFileExistsBehavior?: boolean; + fileExistsMode?: unknown; +}; + +function isOldTemplateChoice( + choice: unknown, +): choice is IChoice & OldTemplateChoice { + return ( + typeof choice === "object" && + choice !== null && + "type" in choice && + (choice as { type?: string }).type === "Template" && + "incrementFileName" in choice + ); +} + function recursiveRemoveIncrementFileName(choices: IChoice[]): IChoice[] { for (const choice of choices) { if (isMultiChoice(choice)) { choice.choices = recursiveRemoveIncrementFileName(choice.choices); } - if (isTemplateChoice(choice)) { - normalizeTemplateChoice(choice); + if (isOldTemplateChoice(choice)) { + choice.setFileExistsBehavior = true; + choice.fileExistsMode = "Increment the file name"; + delete choice.incrementFileName; } } @@ -31,9 +48,11 @@ function removeIncrementFileName(macros: IMacro[]): IMacro[] { for (const command of macro.commands) { if ( isNestedChoiceCommand(command) && - isTemplateChoice(command.choice) + isOldTemplateChoice(command.choice) ) { - normalizeTemplateChoice(command.choice); + command.choice.setFileExistsBehavior = true; + command.choice.fileExistsMode = "Increment the file name"; + delete command.choice.incrementFileName; } } } @@ -43,7 +62,7 @@ function removeIncrementFileName(macros: IMacro[]): IMacro[] { const incrementFileNameSettingMoveToDefaultBehavior: Migration = { description: - "Template file collision settings consolidated into a single behavior model", + "'Increment file name' setting moved to 'Set default behavior if file already exists' setting", migrate: async (plugin: QuickAdd): Promise => { const choicesCopy = deepClone(plugin.settings.choices); From 0f8fa2e6262c39a4eb1d84bd2b477f56f95fee79 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 13 Mar 2026 22:31:09 +0100 Subject: [PATCH 8/8] fix(migrations): prefer explicit file exists mode --- .../consolidateFileExistsBehavior.test.ts | 24 +++++++++++++++++++ .../normalizeTemplateFileExistsBehavior.ts | 8 +++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/migrations/consolidateFileExistsBehavior.test.ts b/src/migrations/consolidateFileExistsBehavior.test.ts index 43b32054..750ae66f 100644 --- a/src/migrations/consolidateFileExistsBehavior.test.ts +++ b/src/migrations/consolidateFileExistsBehavior.test.ts @@ -27,6 +27,30 @@ describe("consolidateFileExistsBehavior migration", () => { expect(plugin.settings.choices[0].setFileExistsBehavior).toBeUndefined(); }); + it("prefers explicit legacy fileExistsMode over incrementFileName when both are present", async () => { + const plugin = { + settings: { + choices: [ + { + id: "template-choice", + name: "Template", + type: "Template", + incrementFileName: true, + setFileExistsBehavior: true, + fileExistsMode: "Append duplicate suffix", + }, + ], + macros: [], + }, + } as any; + + await migration.migrate(plugin); + + expect(plugin.settings.choices[0]).toMatchObject({ + fileExistsBehavior: { kind: "apply", mode: "duplicateSuffix" }, + }); + }); + it("normalizes nested macro command template choices", async () => { const plugin = { settings: { diff --git a/src/migrations/helpers/normalizeTemplateFileExistsBehavior.ts b/src/migrations/helpers/normalizeTemplateFileExistsBehavior.ts index 4c2a0778..04c4ecb0 100644 --- a/src/migrations/helpers/normalizeTemplateFileExistsBehavior.ts +++ b/src/migrations/helpers/normalizeTemplateFileExistsBehavior.ts @@ -27,10 +27,6 @@ export function migrateFileExistsBehavior( return choice.fileExistsBehavior; } - if (choice.incrementFileName) { - return { kind: "apply", mode: "increment" }; - } - if (choice.setFileExistsBehavior) { return { kind: "apply", @@ -38,6 +34,10 @@ export function migrateFileExistsBehavior( }; } + if (choice.incrementFileName) { + return { kind: "apply", mode: "increment" }; + } + return { kind: "prompt" }; }