diff --git a/docs/docs/Choices/TemplateChoice.md b/docs/docs/Choices/TemplateChoice.md index dc0cc746..0026c048 100644 --- a/docs/docs/Choices/TemplateChoice.md +++ b/docs/docs/Choices/TemplateChoice.md @@ -31,20 +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 -**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 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 will prompt you with several options: +When a file with the target name already exists, the setting works in two steps: -- **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 +- **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** -**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. +### Ask Every Time -![image](https://user-images.githubusercontent.com/29108628/121773888-3f680980-cb7f-11eb-919b-97d56ef9268e.png) +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 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 + +Selecting **Keep existing file** applies the same result as choosing +**Do nothing** from the prompt: + +- **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 b044ffde..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 -- **File already exists behavior**: `Increment the file name` +- **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`. diff --git a/src/constants.ts b/src/constants.ts index 0196110b..ce9e31c8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -185,20 +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 fileExistsDoNothing = "Nothing" as const; -export const fileExistsChoices = [ - fileExistsAppendToBottom, - fileExistsAppendToTop, - fileExistsOverwriteFile, - fileExistsIncrement, - fileExistsDoNothing, -] as const; - // == MISC == // export const WIKI_LINK_REGEX = new RegExp(/\[\[([^\]]*)\]\]/); 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 new file mode 100644 index 00000000..23c042cc --- /dev/null +++ b/src/engine/TemplateChoiceEngine.collision.test.ts @@ -0,0 +1,419 @@ +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, + consolidateFileExistsBehavior: 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, 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"; + +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, + }, + fileExistsBehavior: { kind: "prompt" }, +}); + +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 createExistingFolder = (path: string) => { + const folder = new TFolder(); + folder.path = path; + folder.name = path.split("/").pop() ?? path; + return folder; +}; + +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.fileExistsBehavior = { kind: "prompt" }; + + (app.vault.adapter.exists as ReturnType).mockResolvedValue(true); + (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( + existingFile, + ); + vi.mocked(GenericSuggester.Suggest).mockResolvedValue("doNothing"); + const createSpy = vi.spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ); + + await engine.run(); + + expect(GenericSuggester.Suggest).toHaveBeenCalledWith( + app, + expect.arrayContaining([ + getPromptModes().find((mode) => mode.id === "increment")?.label, + getPromptModes().find((mode) => mode.id === "duplicateSuffix")?.label, + ]), + expect.arrayContaining(["appendBottom", "increment", "duplicateSuffix"]), + "If the target file already exists", + ); + 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 createdFile = createExistingFile("Test Template1.md"); + + engine.choice.fileExistsBehavior = { kind: "prompt" }; + + (app.vault.adapter.exists as ReturnType).mockImplementation( + async (path: string) => path === "Test Template.md", + ); + vi.mocked(GenericSuggester.Suggest).mockResolvedValue("increment"); + + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + await engine.run(); + + 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 createdFile = createExistingFile("Test Template (1).md"); + + engine.choice.fileExistsBehavior = { kind: "prompt" }; + + (app.vault.adapter.exists as ReturnType).mockImplementation( + async (path: string) => path === "Test Template.md", + ); + vi.mocked(GenericSuggester.Suggest).mockResolvedValue("duplicateSuffix"); + + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + await engine.run(); + + expect(createSpy).toHaveBeenCalledWith( + "Test Template (1).md", + engine.choice.templatePath, + ); + }); + + 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.fileExistsBehavior = { kind: "prompt" }; + + (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("duplicateSuffix"); + + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + await engine.run(); + + expect(createSpy).toHaveBeenCalledWith( + "Test Template (1).md", + engine.choice.templatePath, + ); + }); + + it("increments automatically when auto behavior is on", async () => { + const { app, engine } = createEngine(); + const createdFile = createExistingFile("Test Template1.md"); + + engine.choice.fileExistsBehavior = { kind: "apply", mode: "increment" }; + + (app.vault.adapter.exists as ReturnType).mockImplementation( + async (path: string) => path === "Test Template.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(createSpy).toHaveBeenCalledWith( + "Test Template1.md", + engine.choice.templatePath, + ); + }); + + it("applies duplicate suffix automatically when auto behavior is on", async () => { + const { app, engine } = createEngine(); + const createdFile = createExistingFile("Test Template (1).md"); + + engine.choice.fileExistsBehavior = { + kind: "apply", + mode: "duplicateSuffix", + }; + + (app.vault.adapter.exists as ReturnType).mockImplementation( + async (path: string) => path === "Test Template.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(createSpy).toHaveBeenCalledWith( + "Test Template (1).md", + 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 67b3a9f9..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, @@ -107,7 +108,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 +139,7 @@ const createTemplateChoice = (): ITemplateChoice => ({ mode: "source", focus: false, }, - fileExistsMode: fileExistsAppendToBottom, - setFileExistsBehavior: false, + fileExistsBehavior: { kind: "prompt" }, }); const createEngine = ( @@ -299,8 +298,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 +344,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 06333556..fb68b6e3 100644 --- a/src/engine/TemplateChoiceEngine.ts +++ b/src/engine/TemplateChoiceEngine.ts @@ -2,19 +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, - fileExistsIncrement, - 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 { @@ -90,94 +88,46 @@ 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 modeId = await this.getSelectedFileExistsMode(); + const mode = getFileExistsMode(modeId); + const existingFile = mode.requiresExistingFile + ? this.findExistingFile(targetFilePath) + : null; + if ( - !(file instanceof TFile) || - (file.extension !== "md" && - file.extension !== "canvas" && - file.extension !== "base") + mode.requiresExistingFile && + (!(existingFile instanceof TFile) || + (existingFile.extension !== "md" && + existingFile.extension !== "canvas" && + existingFile.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; - - if (!this.choice.setFileExistsBehavior) { - try { - userChoice = await GenericSuggester.Suggest( - this.app, - [...fileExistsChoices], - [...fileExistsChoices], - ); - } catch (error) { - if (isCancellationError(error)) { - throw new MacroAbortError("Input cancelled by user"); - } - throw error; - } - } - - switch (userChoice) { - case fileExistsAppendToTop: - createdFile = await this.appendToFileWithTemplate( - file, - this.choice.templatePath, - "top", - ); - break; - case fileExistsAppendToBottom: - createdFile = await this.appendToFileWithTemplate( - file, - this.choice.templatePath, - "bottom", - ); - break; - case fileExistsOverwriteFile: - createdFile = await this.overwriteFileWithTemplate( - file, - this.choice.templatePath, - ); - break; - case fileExistsDoNothing: - createdFile = file; - shouldAutoOpen = true; // Auto-open existing file when user chooses "Nothing" - log.logMessage(`Opening existing file: ${file.path}`); - break; - case fileExistsIncrement: { - const incrementFileName = await this.incrementFileName(filePath); - createdFile = await this.createFileWithTemplate( - incrementFileName, - 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( - filePath, + targetFilePath, this.choice.templatePath, ); if (!createdFile) { - log.logWarning(`Could not create file '${filePath}'.`); + log.logWarning(`Could not create file '${targetFilePath}'.`); return; } } @@ -216,6 +166,95 @@ 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; + } + + 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 0726d071..accc2d4e 100644 --- a/src/engine/TemplateEngine.ts +++ b/src/engine/TemplateEngine.ts @@ -466,45 +466,6 @@ export abstract class TemplateEngine extends QuickAddEngine { return `${actualFolderPath}${formattedFileName}${extension}`; } - protected async incrementFileName(fileName: string) { - const fileExists = await this.app.vault.adapter.exists(fileName); - let newFileName = 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 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}`); - } - - const newFileExists = await this.app.vault.adapter.exists(newFileName); - if (newFileExists) - newFileName = await this.incrementFileName(newFileName); - - return newFileName; - } - 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 655de852..00000000 --- a/src/engine/templateEngine-increment-canvas.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -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), - })), -})); - -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); - } -} - -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'); - }); -}); diff --git a/src/gui/ChoiceBuilder/templateChoiceBuilder.ts b/src/gui/ChoiceBuilder/templateChoiceBuilder.ts index 437a06fe..ca95d1b3 100644 --- a/src/gui/ChoiceBuilder/templateChoiceBuilder.ts +++ b/src/gui/ChoiceBuilder/templateChoiceBuilder.ts @@ -5,17 +5,18 @@ import { TextComponent, ToggleComponent, } from "obsidian"; -import type { fileExistsChoices } from "src/constants"; -import { - fileExistsAppendToBottom, - fileExistsAppendToTop, - fileExistsDoNothing, - fileExistsIncrement, - 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 { @@ -420,35 +421,59 @@ export class TemplateChoiceBuilder extends ChoiceBuilder { } private addFileAlreadyExistsSetting(): void { - const fileAlreadyExistsSetting: Setting = new Setting(this.contentEl); - fileAlreadyExistsSetting - .setName("Set default behavior if file already exists") + this.choice.fileExistsBehavior ??= { kind: "prompt" }; + const behaviorCategory = getBehaviorCategory(this.choice.fileExistsBehavior); + + 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.", + "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.selectEl.style.marginLeft = "10px"; - - if (!this.choice.fileExistsMode) - this.choice.fileExistsMode = fileExistsDoNothing; - - dropdown - .addOption(fileExistsAppendToBottom, fileExistsAppendToBottom) - .addOption(fileExistsAppendToTop, fileExistsAppendToTop) - .addOption(fileExistsIncrement, fileExistsIncrement) - .addOption(fileExistsOverwriteFile, fileExistsOverwriteFile) - .addOption(fileExistsDoNothing, fileExistsDoNothing) - .setValue(this.choice.fileExistsMode) - .onChange( - (value: (typeof fileExistsChoices)[number]) => - (this.choice.fileExistsMode = value), + fileExistsBehaviorCategoryOptions.forEach((option) => { + dropdown.addOption(option.id, option.label); + }); + dropdown.setValue(behaviorCategory); + dropdown.onChange((value: FileExistsBehaviorCategoryId) => { + this.choice.fileExistsBehavior = getDefaultBehaviorForCategory( + value, + this.choice.fileExistsBehavior, ); + this.reload(); + }); + }); + + if (behaviorCategory === "prompt") { + return; + } + + if (behaviorCategory === "keep") { + return; + } + + const isUpdateBehavior = behaviorCategory === "update"; + 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(getFileExistsMode(selectedMode).description) + .addDropdown((dropdown) => { + selectedModes.forEach((mode) => { + dropdown.addOption(mode.id, mode.label); + }); + dropdown.setValue(selectedMode).onChange((value: FileExistsModeId) => { + this.choice.fileExistsBehavior = { + kind: "apply", + mode: value, + }; + this.reload(); + }); }); } } diff --git a/src/migrations/consolidateFileExistsBehavior.test.ts b/src/migrations/consolidateFileExistsBehavior.test.ts new file mode 100644 index 00000000..750ae66f --- /dev/null +++ b/src/migrations/consolidateFileExistsBehavior.test.ts @@ -0,0 +1,86 @@ +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("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: { + 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/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/helpers/normalizeTemplateFileExistsBehavior.ts b/src/migrations/helpers/normalizeTemplateFileExistsBehavior.ts new file mode 100644 index 00000000..04c4ecb0 --- /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.setFileExistsBehavior) { + return { + kind: "apply", + mode: mapLegacyFileExistsModeToId(choice.fileExistsMode) ?? "increment", + }; + } + + if (choice.incrementFileName) { + return { kind: "apply", mode: "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.test.ts b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.test.ts new file mode 100644 index 00000000..3f8b5fa8 --- /dev/null +++ b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import migration from "./incrementFileNameSettingMoveToDefaultBehavior"; + +describe("incrementFileNameSettingMoveToDefaultBehavior migration", () => { + it("migrates legacy incrementFileName choices to the old split 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({ + setFileExistsBehavior: true, + fileExistsMode: "Increment the file name", + }); + expect(plugin.settings.choices[0].incrementFileName).toBeUndefined(); + }); + + it("leaves already-split legacy settings unchanged", 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({ + setFileExistsBehavior: false, + fileExistsMode: "Append to the bottom of the file", + }); + expect(plugin.settings.choices[1]).toMatchObject({ + 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 to the old split behavior model", 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", + incrementFileName: true, + }, + }, + ], + }, + ], + }, + } as any; + + await migration.migrate(plugin); + + expect(plugin.settings.macros[0].commands[0].choice).toMatchObject({ + setFileExistsBehavior: true, + fileExistsMode: "Increment the file name", + }); + expect(plugin.settings.macros[0].commands[0].choice.incrementFileName).toBeUndefined(); + }); +}); diff --git a/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts index 13c2d0db..3cc1c575 100644 --- a/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts +++ b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts @@ -4,9 +4,27 @@ import type { IMacro } from "src/types/macros/IMacro"; import { deepClone } from "src/utils/deepClone"; import { isMultiChoice } from "./helpers/isMultiChoice"; import { isNestedChoiceCommand } from "./helpers/isNestedChoiceCommand"; -import { isOldTemplateChoice } from "./helpers/isOldTemplateChoice"; 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)) { @@ -16,7 +34,6 @@ function recursiveRemoveIncrementFileName(choices: IChoice[]): IChoice[] { if (isOldTemplateChoice(choice)) { choice.setFileExistsBehavior = true; choice.fileExistsMode = "Increment the file name"; - delete choice.incrementFileName; } } @@ -35,7 +52,6 @@ function removeIncrementFileName(macros: IMacro[]): IMacro[] { ) { command.choice.setFileExistsBehavior = true; command.choice.fileExistsMode = "Increment the file name"; - delete command.choice.incrementFileName; } } 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/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/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, 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 569198a6..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 { fileExistsChoices } 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: (typeof fileExistsChoices)[number]; - setFileExistsBehavior: boolean; + fileExistsBehavior: TemplateFileExistsBehavior; } diff --git a/src/types/choices/TemplateChoice.ts b/src/types/choices/TemplateChoice.ts index 44c7fe75..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 { fileExistsChoices } 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: (typeof fileExistsChoices)[number]; - 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 {