From 4a656159a449e60022c78102ceaa25f13209bcb1 Mon Sep 17 00:00:00 2001 From: johnyoonh <18730221+johnyoonh@users.noreply.github.com> Date: Tue, 5 May 2026 15:38:24 -0500 Subject: [PATCH 1/2] Add configurable task title formatting --- src/services/InstantTaskConvertService.ts | 23 ++ .../task-service/TaskCreationService.ts | 19 +- src/settings/defaults.ts | 11 +- src/types/settings.ts | 31 +++ src/utils/taskTitleFormatter.ts | 250 ++++++++++++++++++ tests/unit/services/TaskService.test.ts | 26 +- tests/unit/utils/taskTitleFormatter.test.ts | 68 +++++ 7 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 src/utils/taskTitleFormatter.ts create mode 100644 tests/unit/utils/taskTitleFormatter.test.ts diff --git a/src/services/InstantTaskConvertService.ts b/src/services/InstantTaskConvertService.ts index 3bc4b4401..335280080 100644 --- a/src/services/InstantTaskConvertService.ts +++ b/src/services/InstantTaskConvertService.ts @@ -14,6 +14,7 @@ import { StatusManager } from "./StatusManager"; import { PriorityManager } from "./PriorityManager"; import { dispatchTaskUpdate } from "../editor/TaskLinkOverlay"; import { splitListPreservingLinksAndQuotes } from "../utils/stringSplit"; +import { formatTaskTitle } from "../utils/taskTitleFormatter"; import { TranslationKey } from "../i18n"; export class InstantTaskConvertService { @@ -255,6 +256,11 @@ export class InstantTaskConvertService { return; } + parsedData = { + ...parsedData, + title: this.formatConvertedTaskTitle(currentLine, parsedData).canonicalTitle, + }; + // Create the task file with default settings and details const file = await this.createTaskFile(parsedData, details); @@ -696,6 +702,23 @@ export class InstantTaskConvertService { return title.trim().substring(0, 200); } + private formatConvertedTaskTitle(originalLine: string, parsedData: ParsedTaskData) { + const currentFile = this.plugin.app.workspace.getActiveFile(); + return formatTaskTitle( + { + rawLine: originalLine, + parsedTitle: parsedData.title, + sourcePath: currentFile?.path, + sourceFolder: currentFile?.parent?.path, + sourceBasename: currentFile?.basename, + tags: parsedData.tags, + priority: parsedData.priority, + status: parsedData.status, + }, + this.plugin.settings.taskTitleFormatting + ); + } + /** * Extract overflow text from a title that exceeds the max length. * Tries to preserve word boundaries for cleaner truncation. diff --git a/src/services/task-service/TaskCreationService.ts b/src/services/task-service/TaskCreationService.ts index 68d61f323..41a7a4819 100644 --- a/src/services/task-service/TaskCreationService.ts +++ b/src/services/task-service/TaskCreationService.ts @@ -10,6 +10,7 @@ import { FilenameContext, generateTaskFilename, generateUniqueFilename } from ". import { ensureFolderExists } from "../../utils/helpers"; import { getCurrentTimestamp } from "../../utils/dateUtils"; import { mergeTemplateFrontmatter } from "../../utils/templateProcessor"; +import { formatTaskTitle } from "../../utils/taskTitleFormatter"; import type TaskNotesPlugin from "../../main"; interface TemplateApplicationResult { @@ -247,6 +248,23 @@ export class TaskCreationService { taskData.creationContext === "inline-conversion" || taskData.creationContext === "modal-inline-creation" ) { + const currentFile = plugin.app.workspace.getActiveFile(); + const formatted = formatTaskTitle( + { + parsedTitle: taskData.title, + sourcePath: currentFile?.path, + sourceFolder: currentFile?.parent?.path, + sourceBasename: currentFile?.basename, + tags: taskData.tags, + priority: taskData.priority, + status: taskData.status, + }, + plugin.settings.taskTitleFormatting + ); + if (formatted.noteFolder) { + return this.deps.processFolderTemplate(formatted.noteFolder, taskData); + } + const inlineFolder = plugin.settings.inlineTaskConvertFolder || ""; if (inlineFolder.trim()) { folder = inlineFolder; @@ -254,7 +272,6 @@ export class TaskCreationService { inlineFolder.includes("{{currentNotePath}}") || inlineFolder.includes("{{currentNoteTitle}}") ) { - const currentFile = plugin.app.workspace.getActiveFile(); if (inlineFolder.includes("{{currentNotePath}}")) { const currentFolderPath = currentFile?.parent?.path || ""; folder = folder.replace(/\{\{currentNotePath\}\}/g, currentFolderPath); diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 6e11b929c..64139d570 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -7,6 +7,7 @@ import { ProjectAutosuggestSettings, NLPTriggersConfig, GoogleCalendarExportSettings, + TaskTitleFormattingSettings, } from "../types/settings"; /** @@ -226,6 +227,13 @@ export const DEFAULT_PROJECT_AUTOSUGGEST: ProjectAutosuggestSettings = { propertyValue: "", }; +export const DEFAULT_TASK_TITLE_FORMATTING: TaskTitleFormattingSettings = { + enabled: true, + preset: "taskforge", + maxLength: 200, + rules: [], +}; + // Default NLP triggers configuration export const DEFAULT_NLP_TRIGGERS: NLPTriggersConfig = { triggers: [ @@ -258,7 +266,7 @@ export const DEFAULT_NLP_TRIGGERS: NLPTriggersConfig = { }; export const DEFAULT_SETTINGS: TaskNotesSettings = { - tasksFolder: "TaskNotes/Tasks", + tasksFolder: "TaskNotes", moveArchivedTasks: false, archiveFolder: "TaskNotes/Archive", taskTag: "task", @@ -312,6 +320,7 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { // Inline task conversion defaults inlineTaskConvertFolder: "{{currentNotePath}}", + taskTitleFormatting: DEFAULT_TASK_TITLE_FORMATTING, // Performance defaults disableNoteIndexing: false, // Suggestion performance defaults diff --git a/src/types/settings.ts b/src/types/settings.ts index 5471de19b..f25d710f4 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -89,6 +89,36 @@ export interface ProjectAutosuggestSettings { propertyValue?: string; // Expected value for the property (empty = property must exist) } +export type TaskTitleFormattingPreset = "default" | "taskforge" | "raw" | "custom"; + +export type TaskTitleFormattingRuleOperation = + | "from" + | "replace" + | "match" + | "trim" + | "collapseWhitespace" + | "maxLength" + | "sanitizeFilename"; + +export interface TaskTitleFormattingRule { + enabled?: boolean; + handle: string; + op: TaskTitleFormattingRuleOperation; + pattern?: string; + flags?: string; + with?: string; + value?: string; + target?: string; + length?: number; +} + +export interface TaskTitleFormattingSettings { + enabled: boolean; + preset: TaskTitleFormattingPreset; + maxLength: number; + rules: TaskTitleFormattingRule[]; +} + export interface TaskNotesSettings { tasksFolder: string; // Now just a default location for new tasks moveArchivedTasks: boolean; // Whether to move tasks to archive folder when archived @@ -146,6 +176,7 @@ export interface TaskNotesSettings { doubleClickAction: "edit" | "openNote" | "none"; // Inline task conversion settings inlineTaskConvertFolder: string; // Folder for inline task conversion, supports {{currentNotePath}} and {{currentNoteTitle}} + taskTitleFormatting: TaskTitleFormattingSettings; // Performance settings disableNoteIndexing: boolean; /** Optional debounce in milliseconds for inline file suggestions (0 = disabled) */ diff --git a/src/utils/taskTitleFormatter.ts b/src/utils/taskTitleFormatter.ts new file mode 100644 index 000000000..8905cf20d --- /dev/null +++ b/src/utils/taskTitleFormatter.ts @@ -0,0 +1,250 @@ +import type { + TaskTitleFormattingRule, + TaskTitleFormattingSettings, +} from "../types/settings"; +import { sanitizeForFilename } from "./filenameGenerator"; + +export interface TaskTitleFormatContext { + rawLine?: string; + parsedTitle?: string; + sourcePath?: string; + sourceFolder?: string; + sourceBasename?: string; + taskForgeList?: string; + tags?: string[]; + priority?: string; + status?: string; +} + +export interface TaskTitleFormatResult { + handles: Record; + canonicalTitle: string; + filenameTitle: string; + noteFolder: string; + fullPath: string; + warnings: string[]; +} + +const DEFAULT_MAX_LENGTH = 200; + +const TASKFORGE_PRESET_RULES: TaskTitleFormattingRule[] = [ + { handle: "canonicalTitle", op: "from", value: "{{parsedTitle}}" }, + { handle: "canonicalTitle", op: "replace", pattern: "^\\s*(?:[-*+]|\\d+\\.)\\s+\\[[ xX]\\]\\s*", with: "", flags: "i" }, + { handle: "canonicalTitle", op: "replace", pattern: "\\s*%%.*?%%", with: " ", flags: "g" }, + { handle: "canonicalTitle", op: "replace", pattern: "\\[\\[[^\\]]+\\]\\]", with: " ", flags: "g" }, + { handle: "canonicalTitle", op: "replace", pattern: "\\s+\\[[A-Za-z_][A-Za-z0-9_-]*::\\s+[^\\]]+\\]", with: " ", flags: "g" }, + { handle: "canonicalTitle", op: "replace", pattern: "\\s+[β°πŸŽ―β³πŸ“…βœ…πŸ›«βž•]\\s+\\S+", with: " ", flags: "gu" }, + { handle: "canonicalTitle", op: "replace", pattern: "(?:^|\\s)#[\\w/-]+", with: " ", flags: "g" }, + { handle: "canonicalTitle", op: "replace", pattern: "^[\\sπŸ”Ίβ«πŸ”ΌπŸ”½β¬]+", with: "", flags: "u" }, + { handle: "canonicalTitle", op: "collapseWhitespace" }, + { handle: "canonicalTitle", op: "trim" }, + { handle: "filenameTitle", op: "from", value: "{{canonicalTitle}}" }, +]; + +const DEFAULT_PRESET_RULES: TaskTitleFormattingRule[] = [ + { handle: "canonicalTitle", op: "from", value: "{{parsedTitle}}" }, + { handle: "canonicalTitle", op: "collapseWhitespace" }, + { handle: "canonicalTitle", op: "trim" }, + { handle: "filenameTitle", op: "from", value: "{{canonicalTitle}}" }, +]; + +export function formatTaskTitle( + context: TaskTitleFormatContext, + settings?: Partial +): TaskTitleFormatResult { + const warnings: string[] = []; + const maxLength = settings?.maxLength && settings.maxLength > 0 + ? settings.maxLength + : DEFAULT_MAX_LENGTH; + const taskForgeList = context.taskForgeList || taskForgeListFromPath(context.sourcePath || ""); + const sourceFolder = context.sourceFolder || folderFromPath(context.sourcePath || ""); + const sourceBasename = context.sourceBasename || basenameFromPath(context.sourcePath || ""); + const taskNotesRoot = taskNotesRootFromSourceFolder(sourceFolder); + const baseTitle = (context.parsedTitle || context.rawLine || "").trim(); + const handles: Record = { + rawLine: context.rawLine || "", + parsedTitle: baseTitle, + sourcePath: context.sourcePath || "", + sourceFolder, + sourceBasename, + taskForgeList, + taskNotesRoot, + tags: (context.tags || []).join(" "), + priority: context.priority || "", + status: context.status || "", + canonicalTitle: baseTitle, + filenameTitle: "", + noteFolder: "", + fullPath: "", + }; + + if (settings?.enabled === false) { + return finalizeHandles(handles, maxLength, warnings); + } + + for (const rule of presetRules(settings?.preset)) { + applyRule(rule, handles, warnings, maxLength); + } + if (!handles.noteFolder.trim() && taskForgeList && taskNotesRoot) { + handles.noteFolder = `${taskNotesRoot}/${taskForgeList}`; + } + + for (const rule of settings?.rules || []) { + applyRule(rule, handles, warnings, maxLength); + } + + if (!handles.canonicalTitle.trim()) { + handles.canonicalTitle = baseTitle || "Untitled Task"; + } + if (!handles.filenameTitle.trim()) { + handles.filenameTitle = handles.canonicalTitle; + } + if (!handles.fullPath.trim() && handles.noteFolder.trim()) { + handles.fullPath = `${handles.noteFolder}/${sanitizeForFilename(handles.filenameTitle)}.md`; + } + + return finalizeHandles(handles, maxLength, warnings); +} + +function presetRules(preset?: string): TaskTitleFormattingRule[] { + switch (preset) { + case "raw": + case "custom": + return []; + case "default": + return DEFAULT_PRESET_RULES; + case "taskforge": + default: + return TASKFORGE_PRESET_RULES; + } +} + +function applyRule( + rule: TaskTitleFormattingRule, + handles: Record, + warnings: string[], + defaultMaxLength: number +): void { + if (rule.enabled === false || !rule.handle) { + return; + } + + const source = handles[rule.handle] || ""; + const target = rule.target || rule.handle; + + try { + switch (rule.op) { + case "from": + handles[target] = expandTemplate(rule.value || "", handles); + break; + case "replace": { + const pattern = new RegExp(rule.pattern || "", rule.flags || "g"); + handles[target] = source.replace(pattern, expandTemplate(rule.with || "", handles)); + break; + } + case "match": { + const pattern = new RegExp(rule.pattern || "", rule.flags || ""); + const match = source.match(pattern); + if (!match) { + return; + } + if (match.groups) { + for (const [key, value] of Object.entries(match.groups)) { + handles[key] = value || ""; + } + } + if (rule.value || rule.target) { + handles[target] = expandTemplate(rule.value || "{{" + rule.handle + "}}", handles); + } + break; + } + case "trim": + handles[target] = source.trim().replace(/^[-\s]+|[-\s]+$/g, ""); + break; + case "collapseWhitespace": + handles[target] = source.replace(/\s+/g, " "); + break; + case "maxLength": + handles[target] = source.substring(0, rule.length || defaultMaxLength).trim(); + break; + case "sanitizeFilename": + handles[target] = sanitizeForFilename(source); + break; + } + } catch (error) { + warnings.push(`Skipped ${rule.op} rule for ${rule.handle}: ${String(error)}`); + } +} + +function expandTemplate(template: string, handles: Record): string { + return template.replace(/\{\{\s*([^}|]+?)\s*(?:\|\s*([^}]+?)\s*)?\}\}/g, (_match, rawName, rawFilters) => { + let value = handles[String(rawName).trim()] || ""; + const filters = String(rawFilters || "") + .split("|") + .map((filter) => filter.trim()) + .filter(Boolean); + for (const filter of filters) { + if (filter === "trim") { + value = value.trim(); + } else if (filter === "collapseWhitespace") { + value = value.replace(/\s+/g, " "); + } else if (filter === "sanitizeFilename") { + value = sanitizeForFilename(value); + } + } + return value; + }); +} + +function finalizeHandles( + handles: Record, + maxLength: number, + warnings: string[] +): TaskTitleFormatResult { + const canonicalTitle = (handles.canonicalTitle || handles.parsedTitle || "Untitled Task") + .substring(0, maxLength) + .trim() || "Untitled Task"; + const filenameTitle = (handles.filenameTitle || canonicalTitle).substring(0, maxLength).trim(); + const noteFolder = normalizeFolder(handles.noteFolder || ""); + const fullPath = handles.fullPath || (noteFolder ? `${noteFolder}/${sanitizeForFilename(filenameTitle)}.md` : ""); + return { + handles: { + ...handles, + canonicalTitle, + filenameTitle, + noteFolder, + fullPath, + }, + canonicalTitle, + filenameTitle, + noteFolder, + fullPath, + warnings, + }; +} + +function taskForgeListFromPath(path: string): string { + const match = path.match(/(?:^|\/)TaskForge\/([^/]+)\.md$/); + return match?.[1] || ""; +} + +function folderFromPath(path: string): string { + const index = path.lastIndexOf("/"); + return index >= 0 ? path.substring(0, index) : ""; +} + +function basenameFromPath(path: string): string { + const name = path.substring(path.lastIndexOf("/") + 1); + return name.endsWith(".md") ? name.substring(0, name.length - 3) : name; +} + +function normalizeFolder(folder: string): string { + return folder.replace(/\/+/g, "/").replace(/^\/|\/$/g, ""); +} + +function taskNotesRootFromSourceFolder(sourceFolder: string): string { + if (!sourceFolder) { + return ""; + } + return sourceFolder.replace(/(^|\/)TaskForge$/, "$1TaskNotes"); +} diff --git a/tests/unit/services/TaskService.test.ts b/tests/unit/services/TaskService.test.ts index 0331e6995..456597f17 100644 --- a/tests/unit/services/TaskService.test.ts +++ b/tests/unit/services/TaskService.test.ts @@ -28,7 +28,8 @@ jest.mock('../../../src/utils/dateUtils', () => { jest.mock('../../../src/utils/filenameGenerator', () => ({ generateTaskFilename: jest.fn((context) => `${context.title.toLowerCase().replace(/\s+/g, '-')}`), - generateUniqueFilename: jest.fn((base) => base) + generateUniqueFilename: jest.fn((base) => base), + sanitizeForFilename: jest.fn((input) => input.trim().replace(/[<>:"/\\|?*#[\]]/g, "")) })); jest.mock('../../../src/utils/helpers', () => ({ @@ -186,6 +187,29 @@ describe('TaskService', () => { ); }); + it('should route TaskForge inline conversions to the canonical TaskNotes list folder', async () => { + mockPlugin.settings.tasksFolder = 'TaskNotes'; + mockPlugin.settings.inlineTaskConvertFolder = '{{currentNotePath}}'; + + const mockCurrentFile = new TFile('Projects/TaskForge/legal.md'); + Object.defineProperty(mockCurrentFile, 'basename', { value: 'legal', writable: false }); + Object.defineProperty(mockCurrentFile, 'extension', { value: 'md', writable: false }); + mockCurrentFile.parent = { path: 'Projects/TaskForge' } as any; + mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockCurrentFile); + + const taskData: TaskCreationData = { + title: 'Follow up on lawyers', + creationContext: 'inline-conversion' + }; + + await taskService.createTask(taskData); + + expect(mockPlugin.app.vault.create).toHaveBeenCalledWith( + 'Projects/TaskNotes/legal/follow-up-on-lawyers.md', + expect.stringContaining('title: Follow up on lawyers') + ); + }); + it('should handle inline conversion context without currentNotePath variable', async () => { mockPlugin.settings.inlineTaskConvertFolder = 'InlineTasks'; diff --git a/tests/unit/utils/taskTitleFormatter.test.ts b/tests/unit/utils/taskTitleFormatter.test.ts new file mode 100644 index 000000000..0c3d614ac --- /dev/null +++ b/tests/unit/utils/taskTitleFormatter.test.ts @@ -0,0 +1,68 @@ +import { formatTaskTitle } from "../../../src/utils/taskTitleFormatter"; + +describe("taskTitleFormatter", () => { + it("normalizes noisy TaskForge lines into canonical task titles", () => { + const result = formatTaskTitle({ + rawLine: + "- [ ] πŸ”Ί Eb2 visa (self-petition) ⏰ 8:30AM 🎯 8:00AM #apple-priority-inbox %%[apple_reminder_id:: ABC]%% [scheduled:: 2026-05-06]", + parsedTitle: + "πŸ”Ί Eb2 visa (self-petition) ⏰ 8:30AM 🎯 8:00AM #apple-priority-inbox %%[apple_reminder_id:: ABC]%% [scheduled:: 2026-05-06]", + sourcePath: "Projects/TaskForge/legal.md", + }); + + expect(result.canonicalTitle).toBe("Eb2 visa (self-petition)"); + expect(result.handles.taskForgeList).toBe("legal"); + expect(result.noteFolder).toBe("Projects/TaskNotes/legal"); + expect(result.fullPath).toBe("Projects/TaskNotes/legal/Eb2 visa (self-petition).md"); + }); + + it("allows custom regex rules to populate handles", () => { + const result = formatTaskTitle( + { + rawLine: "- [ ] Legal: File EB2 petition #visa", + parsedTitle: "Legal: File EB2 petition #visa", + sourcePath: "Projects/TaskForge/inbox.md", + }, + { + enabled: true, + preset: "taskforge", + maxLength: 200, + rules: [ + { + handle: "canonicalTitle", + op: "match", + pattern: "^(?[^:]+):\\s*(?.+)$", + }, + { handle: "canonicalTitle", op: "from", value: "{{clean}}" }, + { handle: "noteFolder", op: "from", value: "Projects/TaskNotes/{{area}}" }, + ], + } + ); + + expect(result.canonicalTitle).toBe("File EB2 petition"); + expect(result.handles.area).toBe("Legal"); + expect(result.noteFolder).toBe("Projects/TaskNotes/Legal"); + }); + + it("can build filename and full path from handle templates", () => { + const result = formatTaskTitle( + { + rawLine: "- [ ] Write A/B test: variant #growth", + parsedTitle: "Write A/B test: variant #growth", + sourcePath: "Projects/TaskForge/growth.md", + }, + { + enabled: true, + preset: "taskforge", + maxLength: 200, + rules: [ + { handle: "filenameTitle", op: "from", value: "{{canonicalTitle | sanitizeFilename}}" }, + { handle: "fullPath", op: "from", value: "{{noteFolder}}/{{filenameTitle}}.md" }, + ], + } + ); + + expect(result.filenameTitle).toBe("Write AB test variant"); + expect(result.fullPath).toBe("Projects/TaskNotes/growth/Write AB test variant.md"); + }); +}); From f2da9192a7bb79bad05b50ec97f7025362d1fb5d Mon Sep 17 00:00:00 2001 From: johnyoonh <18730221+johnyoonh@users.noreply.github.com> Date: Wed, 6 May 2026 15:05:13 -0500 Subject: [PATCH 2/2] Refine TaskNote path conflict handling --- docs/releases/unreleased.md | 10 + src/i18n/resources/en.ts | 30 +++ src/main.ts | 5 + src/modals/ExistingTaskNoteConflictModal.ts | 152 +++++++++++ src/services/InstantTaskConvertService.ts | 54 +++- .../task-service/TaskCreationService.ts | 253 +++++++++++++++--- src/settings/defaults.ts | 3 + src/settings/tabs/featuresTab.ts | 31 +++ .../tabs/taskProperties/titlePropertyCard.ts | 82 ++++++ src/types.ts | 1 + src/types/settings.ts | 7 + src/utils/filenameGenerator.ts | 25 +- src/utils/taskTitleFormatter.ts | 56 +++- .../.obsidian/plugins/tasknotes/data.json | 12 +- .../InstantTaskConvertService.test.ts | 63 ++++- tests/unit/services/TaskService.test.ts | 130 ++++++++- .../filenameGenerator.title-style.test.ts | 52 ++++ tests/unit/utils/taskTitleFormatter.test.ts | 57 ++++ 18 files changed, 954 insertions(+), 69 deletions(-) create mode 100644 src/modals/ExistingTaskNoteConflictModal.ts create mode 100644 tests/unit/utils/filenameGenerator.title-style.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index aa1214690..75d418a18 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -23,3 +23,13 @@ Example: ``` --> + +## Added + +- Added configurable title-derived TaskNote filename styles, including lowercase `snake_case` slugs and Title Case TaskForge source folders for canonical TaskNotes paths. + +## Fixed + +- Fixed inline task conversion so existing `[[...|note]]` canonical note links are used as the created TaskNote path and title. +- Fixed inline task conversion so natural language parsing can enrich metadata without renaming the existing task title. +- Added a setting for existing canonical TaskNotes during inline conversion, including an ask-with-diff flow to choose which metadata and body content to keep. diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 925d63136..fb62138ea 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -485,6 +485,16 @@ export const en: TranslationTree = { description: "Folder where tasks converted from checkboxes will be created. Leave empty to use the default tasks folder. Use {{currentNotePath}} for the current note's folder, or {{currentNoteTitle}} for a subfolder named after the current note.", }, + existingTaskNoteConflict: { + name: "When converted TaskNote already exists", + description: + "Choose what happens when an inline task has a canonical [[...|note]] link pointing to an existing TaskNote.", + options: { + ask: "Ask with diff", + reuse: "Reuse existing note", + createUnique: "Create unique copy", + }, + }, }, nlp: { header: "Natural Language Processing", @@ -1054,6 +1064,8 @@ export const en: TranslationTree = { storeTitleInFilename: "Store title in filename:", storedInFilename: "Stored in filename", filenameUpdatesWithTitle: "Filename will automatically update when the task title changes.", + filenameStyle: "Title filename style:", + sourceFolderStyle: "Source folder style:", filenameFormat: "Filename format:", customTemplate: "Custom template:", legacySyntaxWarning: "Single-brace syntax like {title} is deprecated. Please use double-brace syntax {{title}} instead for consistency with body templates.", @@ -1278,6 +1290,24 @@ export const en: TranslationTree = { custom: "Custom template", }, }, + filenameStyle: { + name: "Title filename style", + description: + "Used when task titles generate filenames, including title-stored tasks and TaskForge inline conversion paths.", + options: { + readable: "Readable title", + lowercaseSnake: "Lowercase snake_case slug", + }, + }, + sourceFolderStyle: { + name: "Source folder style", + description: + "Used for TaskForge source-list folders when creating TaskNotes folders.", + options: { + preserve: "Preserve source name", + titleCase: "Title Case", + }, + }, customTemplate: { name: "Custom filename template", description: diff --git a/src/main.ts b/src/main.ts index 68a66326f..aab1a3114 100644 --- a/src/main.ts +++ b/src/main.ts @@ -621,6 +621,11 @@ export default class TaskNotesPlugin extends Plugin { ...DEFAULT_SETTINGS.taskCreationDefaults, ...(loadedData?.taskCreationDefaults || {}), }, + taskTitleFormatting: { + ...DEFAULT_SETTINGS.taskTitleFormatting, + ...(loadedData?.taskTitleFormatting || {}), + rules: loadedData?.taskTitleFormatting?.rules || DEFAULT_SETTINGS.taskTitleFormatting.rules, + }, // Deep merge calendar view settings to ensure new fields get default values calendarViewSettings: { ...DEFAULT_SETTINGS.calendarViewSettings, diff --git a/src/modals/ExistingTaskNoteConflictModal.ts b/src/modals/ExistingTaskNoteConflictModal.ts new file mode 100644 index 000000000..ca02c3150 --- /dev/null +++ b/src/modals/ExistingTaskNoteConflictModal.ts @@ -0,0 +1,152 @@ +import { App, Modal, Setting, stringifyYaml } from "obsidian"; + +export type ExistingTaskNoteConflictAction = "apply" | "create-unique" | "cancel"; +export type ExistingTaskNoteConflictChoice = "existing" | "incoming"; + +export interface ExistingTaskNoteConflictDecision { + action: ExistingTaskNoteConflictAction; + metadataChoice: ExistingTaskNoteConflictChoice; + bodyChoice: ExistingTaskNoteConflictChoice; +} + +export interface ExistingTaskNoteConflictModalOptions { + path: string; + existingFrontmatter: Record; + incomingFrontmatter: Record; + existingBody: string; + incomingBody: string; +} + +export class ExistingTaskNoteConflictModal extends Modal { + private resolve: (decision: ExistingTaskNoteConflictDecision) => void; + private resolved = false; + private metadataChoice: ExistingTaskNoteConflictChoice = "incoming"; + private bodyChoice: ExistingTaskNoteConflictChoice = "existing"; + + constructor(app: App, private options: ExistingTaskNoteConflictModalOptions) { + super(app); + } + + show(): Promise { + return new Promise((resolve) => { + this.resolve = resolve; + this.open(); + }); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("tasknotes-existing-tasknote-conflict-modal"); + + new Setting(contentEl).setName("TaskNote already exists").setHeading(); + contentEl.createEl("p", { + text: `A TaskNote already exists at ${this.options.path}. Choose what to keep before converting this task.`, + }); + + new Setting(contentEl) + .setName("Metadata") + .setDesc("Choose which frontmatter should be saved to the existing TaskNote.") + .addDropdown((dropdown) => { + dropdown + .addOption("incoming", "Use converted task metadata") + .addOption("existing", "Keep existing metadata") + .setValue(this.metadataChoice) + .onChange((value) => { + this.metadataChoice = value as ExistingTaskNoteConflictChoice; + }); + }); + + this.renderDiffSection( + contentEl, + "Existing metadata", + stringifyYaml(this.options.existingFrontmatter || {}).trimEnd() + ); + this.renderDiffSection( + contentEl, + "Converted task metadata", + stringifyYaml(this.options.incomingFrontmatter || {}).trimEnd() + ); + + new Setting(contentEl) + .setName("Note body") + .setDesc("Choose which body content should be saved to the existing TaskNote.") + .addDropdown((dropdown) => { + dropdown + .addOption("existing", "Keep existing body") + .addOption("incoming", "Use converted task body") + .setValue(this.bodyChoice) + .onChange((value) => { + this.bodyChoice = value as ExistingTaskNoteConflictChoice; + }); + }); + + this.renderDiffSection(contentEl, "Existing body", this.options.existingBody); + this.renderDiffSection(contentEl, "Converted task body", this.options.incomingBody); + + const buttonContainer = contentEl.createDiv("modal-button-container"); + buttonContainer.style.display = "flex"; + buttonContainer.style.gap = "10px"; + buttonContainer.style.justifyContent = "flex-end"; + buttonContainer.style.marginTop = "20px"; + + const cancelButton = buttonContainer.createEl("button", { text: "Cancel conversion" }); + cancelButton.addEventListener("click", () => { + this.finish({ action: "cancel", metadataChoice: this.metadataChoice, bodyChoice: this.bodyChoice }); + }); + + const uniqueButton = buttonContainer.createEl("button", { text: "Create unique copy" }); + uniqueButton.addEventListener("click", () => { + this.finish({ + action: "create-unique", + metadataChoice: this.metadataChoice, + bodyChoice: this.bodyChoice, + }); + }); + + const applyButton = buttonContainer.createEl("button", { + text: "Apply to existing note", + cls: "mod-cta", + }); + applyButton.addEventListener("click", () => { + this.finish({ action: "apply", metadataChoice: this.metadataChoice, bodyChoice: this.bodyChoice }); + }); + } + + onClose(): void { + this.contentEl.empty(); + if (!this.resolved) { + this.finish({ action: "cancel", metadataChoice: this.metadataChoice, bodyChoice: this.bodyChoice }); + } + } + + private renderDiffSection(container: HTMLElement, label: string, value: string): void { + const section = container.createDiv("tasknotes-existing-tasknote-conflict-modal__section"); + section.createEl("h4", { text: label }); + const pre = section.createEl("pre"); + pre.style.maxHeight = "180px"; + pre.style.overflow = "auto"; + pre.style.padding = "8px"; + pre.style.background = "var(--background-secondary)"; + pre.style.border = "1px solid var(--background-modifier-border)"; + pre.style.borderRadius = "4px"; + pre.style.whiteSpace = "pre-wrap"; + pre.textContent = value.trim() || "(empty)"; + } + + private finish(decision: ExistingTaskNoteConflictDecision): void { + if (this.resolved) { + return; + } + this.resolved = true; + this.resolve(decision); + this.close(); + } +} + +export async function showExistingTaskNoteConflictModal( + app: App, + options: ExistingTaskNoteConflictModalOptions +): Promise { + return new ExistingTaskNoteConflictModal(app, options).show(); +} diff --git a/src/services/InstantTaskConvertService.ts b/src/services/InstantTaskConvertService.ts index 335280080..c9148358e 100644 --- a/src/services/InstantTaskConvertService.ts +++ b/src/services/InstantTaskConvertService.ts @@ -120,7 +120,17 @@ export class InstantTaskConvertService { throw new Error("Failed to parse task"); } - const file = await this.createTaskFile(parsedData); + const formattedTitle = this.formatConvertedTaskTitle( + task.line, + parsedData, + this.extractPreNlpTitle(task.line) || parsedData.title + ); + const formattedData = { + ...parsedData, + title: formattedTitle.canonicalTitle, + }; + + const file = await this.createTaskFile(formattedData, "", formattedTitle.fullPath); const linkText = this.generateLinkText(task.line, file); return { lineNumber: task.lineNumber, line: task.line, file, linkText }; @@ -174,6 +184,7 @@ export class InstantTaskConvertService { // Parse the current line for Tasks plugin format, with NLP fallback let parsedData: ParsedTaskData; + let titleForFormatting = ""; const taskLineInfo = TasksPluginParser.parseTaskLine(currentLine); @@ -181,6 +192,7 @@ export class InstantTaskConvertService { // Line is not a checkbox task, but we can still convert it to a tasknote // Extract the line content as the task title, removing any leading list markers const taskTitle = this.extractLineContentAsTitle(currentLine); + titleForFormatting = taskTitle; if (!taskTitle.trim()) { new Notice(this.translate("services.instantTaskConvert.notices.emptyLine")); @@ -216,6 +228,7 @@ export class InstantTaskConvertService { ); return; } + titleForFormatting = taskLineInfo.parsedData.title; // Always try NLP on the clean title to extract additional metadata // Then merge results, with TasksPlugin explicit metadata taking priority @@ -256,13 +269,18 @@ export class InstantTaskConvertService { return; } + const formattedTitle = this.formatConvertedTaskTitle( + currentLine, + parsedData, + titleForFormatting || parsedData.title + ); parsedData = { ...parsedData, - title: this.formatConvertedTaskTitle(currentLine, parsedData).canonicalTitle, + title: formattedTitle.canonicalTitle, }; // Create the task file with default settings and details - const file = await this.createTaskFile(parsedData, details); + const file = await this.createTaskFile(parsedData, details, formattedTitle.fullPath); // Replace the original line(s) with a link (includes race condition protection) const replaceResult = await this.replaceOriginalTaskLines( @@ -308,6 +326,9 @@ export class InstantTaskConvertService { // Trigger immediate refresh of task link overlays to show the inline widget await this.refreshTaskLinkOverlays(editor, file); } catch (error) { + if (error.message.includes("TaskNote conversion cancelled")) { + return; + } console.error("Error during instant task conversion:", error); if (error.message.includes("file already exists")) { new Notice(this.translate("services.instantTaskConvert.notices.fileExists")); @@ -443,7 +464,11 @@ export class InstantTaskConvertService { /** * Create a task file using default settings and parsed data */ - private async createTaskFile(parsedData: ParsedTaskData, details = ""): Promise { + private async createTaskFile( + parsedData: ParsedTaskData, + details = "", + targetPath?: string + ): Promise { // Sanitize and validate input data // Check if title will be truncated and preserve overflow text (issue #1310) const originalTitle = parsedData.title?.trim() || ""; @@ -683,6 +708,7 @@ export class InstantTaskConvertService { details: enhancedDetails, // Use enhanced details with any overflow from title truncation parentNote: parentNote, // Include parent note for template variable creationContext: "inline-conversion", // Mark as inline conversion for folder logic + targetPath: targetPath?.trim() || undefined, dateCreated: getCurrentTimestamp(), dateModified: getCurrentTimestamp(), customFrontmatter: Object.keys(customFrontmatter).length > 0 ? customFrontmatter : undefined, @@ -702,12 +728,16 @@ export class InstantTaskConvertService { return title.trim().substring(0, 200); } - private formatConvertedTaskTitle(originalLine: string, parsedData: ParsedTaskData) { + private formatConvertedTaskTitle( + originalLine: string, + parsedData: ParsedTaskData, + titleForFormatting = parsedData.title + ) { const currentFile = this.plugin.app.workspace.getActiveFile(); return formatTaskTitle( { rawLine: originalLine, - parsedTitle: parsedData.title, + parsedTitle: titleForFormatting, sourcePath: currentFile?.path, sourceFolder: currentFile?.parent?.path, sourceBasename: currentFile?.basename, @@ -719,6 +749,14 @@ export class InstantTaskConvertService { ); } + private extractPreNlpTitle(line: string): string { + const taskLineInfo = TasksPluginParser.parseTaskLine(line); + if (taskLineInfo.isTaskLine && taskLineInfo.parsedData?.title) { + return taskLineInfo.parsedData.title; + } + return this.extractLineContentAsTitle(line); + } + /** * Extract overflow text from a title that exceeds the max length. * Tries to preserve word boundaries for cleaner truncation. @@ -981,8 +1019,8 @@ export class InstantTaskConvertService { }; return { - // Use NLP title (cleaner, with NL phrases removed) unless it's empty - title: nlpData.title?.trim() || tasksPluginData.title, + // Keep the existing task title stable; NLP only enriches metadata. + title: tasksPluginData.title, // TasksPlugin explicit values take priority, fall back to NLP dueDate: tasksPluginData.dueDate || nlpData.dueDate, diff --git a/src/services/task-service/TaskCreationService.ts b/src/services/task-service/TaskCreationService.ts index 41a7a4819..da6eb724c 100644 --- a/src/services/task-service/TaskCreationService.ts +++ b/src/services/task-service/TaskCreationService.ts @@ -1,4 +1,4 @@ -import { Notice, TFile, normalizePath, stringifyYaml } from "obsidian"; +import { Notice, TFile, normalizePath, parseYaml, stringifyYaml } from "obsidian"; import { EVENT_TASK_UPDATED, IWebhookNotifier, @@ -7,11 +7,15 @@ import { } from "../../types"; import { addDTSTARTToRecurrenceRule } from "../../core/recurrence"; import { FilenameContext, generateTaskFilename, generateUniqueFilename } from "../../utils/filenameGenerator"; -import { ensureFolderExists } from "../../utils/helpers"; +import { ensureFolderExists, splitFrontmatterAndBody } from "../../utils/helpers"; import { getCurrentTimestamp } from "../../utils/dateUtils"; import { mergeTemplateFrontmatter } from "../../utils/templateProcessor"; import { formatTaskTitle } from "../../utils/taskTitleFormatter"; import type TaskNotesPlugin from "../../main"; +import { + ExistingTaskNoteConflictDecision, + showExistingTaskNoteConflictModal, +} from "../../modals/ExistingTaskNoteConflictModal"; interface TemplateApplicationResult { frontmatter: Record; @@ -26,6 +30,13 @@ export interface TaskCreationServiceDependencies { processFolderTemplate(folderTemplate: string, taskData?: TaskCreationData, date?: Date): string; sanitizeTitleForFilename(input: string): string; sanitizeTitleForStorage(input: string): string; + resolveExistingTaskNoteConflict?(options: { + path: string; + existingFrontmatter: Record; + incomingFrontmatter: Record; + existingBody: string; + incomingBody: string; + }): Promise; } export class TaskCreationService { @@ -78,15 +89,20 @@ export class TaskCreationService { scheduledDate: taskData.scheduled, }; - const baseFilename = generateTaskFilename(filenameContext, plugin.settings); - const folder = await this.resolveTargetFolder(taskData); + const targetPath = this.normalizeTargetPath(taskData.targetPath); + const targetFolder = targetPath ? this.folderFromPath(targetPath) : ""; + const targetFilename = targetPath ? this.basenameFromPath(targetPath) : ""; + const baseFilename = targetFilename || generateTaskFilename(filenameContext, plugin.settings); + const folder = targetPath ? targetFolder : await this.resolveTargetFolder(taskData); if (folder) { await ensureFolderExists(plugin.app.vault, folder); } - const uniqueFilename = await generateUniqueFilename(baseFilename, folder, plugin.app.vault); - const fullPath = folder ? `${folder}/${uniqueFilename}.md` : `${uniqueFilename}.md`; + let uniqueFilename = targetPath + ? baseFilename + : await generateUniqueFilename(baseFilename, folder, plugin.app.vault); + let fullPath = folder ? `${folder}/${uniqueFilename}.md` : `${uniqueFilename}.md`; const completeTaskData: Partial = { title, @@ -177,40 +193,86 @@ export class TaskCreationService { finalFrontmatter = { ...finalFrontmatter, ...taskData.customFrontmatter }; } - const yamlHeader = stringifyYaml(finalFrontmatter); - let content = `---\n${yamlHeader}---\n\n`; - if (normalizedBody.length > 0) { - content += `${normalizedBody}\n`; + if (targetPath) { + const existingFile = plugin.app.vault.getAbstractFileByPath(fullPath); + if (existingFile instanceof TFile) { + const behavior = + plugin.settings.existingTaskNoteConflictBehavior || "ask"; + + if (behavior === "create-unique") { + uniqueFilename = await generateUniqueFilename(baseFilename, folder, plugin.app.vault); + fullPath = folder ? `${folder}/${uniqueFilename}.md` : `${uniqueFilename}.md`; + } else { + const existingContent = await plugin.app.vault.read(existingFile); + const existingParts = splitFrontmatterAndBody(existingContent); + const existingFrontmatter = this.parseFrontmatter(existingParts.frontmatter); + const existingBody = existingParts.body.replace(/\r\n/g, "\n").trimEnd(); + const decision = + behavior === "reuse" + ? { + action: "apply" as const, + metadataChoice: "incoming" as const, + bodyChoice: "existing" as const, + } + : await this.resolveExistingTaskNoteConflict({ + path: fullPath, + existingFrontmatter, + incomingFrontmatter: finalFrontmatter, + existingBody, + incomingBody: normalizedBody, + }); + + if (decision.action === "cancel") { + throw new Error("TaskNote conversion cancelled"); + } + + if (decision.action === "create-unique") { + uniqueFilename = await generateUniqueFilename(baseFilename, folder, plugin.app.vault); + fullPath = folder ? `${folder}/${uniqueFilename}.md` : `${uniqueFilename}.md`; + } else { + const selectedFrontmatter = + decision.metadataChoice === "existing" + ? existingFrontmatter + : finalFrontmatter; + const selectedBody = + decision.bodyChoice === "existing" ? existingBody : normalizedBody; + const content = this.buildTaskFileContent(selectedFrontmatter, selectedBody); + await plugin.app.vault.modify(existingFile, content); + + const taskInfo = this.buildTaskInfo({ + completeTaskData, + finalFrontmatter: selectedFrontmatter, + title, + status, + priority, + file: existingFile, + tagsArray, + normalizedBody: selectedBody, + }); + await this.afterTaskWrite(existingFile, taskInfo); + return { file: existingFile, taskInfo }; + } + } + } } - const file = await plugin.app.vault.create(fullPath, content); - - const taskInfo: TaskInfo = { - ...completeTaskData, - ...finalFrontmatter, - title: String(finalFrontmatter.title || completeTaskData.title || title), - status: String(finalFrontmatter.status || completeTaskData.status || status), - priority: String(finalFrontmatter.priority || completeTaskData.priority || priority), - path: file.path, - tags: tagsArray, - archived: false, - details: normalizedBody, - }; + const content = this.buildTaskFileContent(finalFrontmatter, normalizedBody); - try { - if (plugin.cacheManager.waitForFreshTaskData) { - await plugin.cacheManager.waitForFreshTaskData(file); - } - plugin.cacheManager.updateTaskInfoInCache(file.path, taskInfo); - } catch (cacheError) { - console.error("Error updating cache for new task:", cacheError); - } + const file = await plugin.app.vault.create(fullPath, content); - plugin.emitter.trigger(EVENT_TASK_UPDATED, { - path: file.path, - updatedTask: taskInfo, + const taskInfo = this.buildTaskInfo({ + completeTaskData, + finalFrontmatter, + title, + status, + priority, + file, + tagsArray, + normalizedBody, }); + await this.afterTaskWrite(file, taskInfo); + if (this.deps.webhookNotifier) { try { await this.deps.webhookNotifier.triggerWebhook("task.created", { task: taskInfo }); @@ -231,11 +293,13 @@ export class TaskCreationService { return { file, taskInfo }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error("Error creating task:", { - error: errorMessage, - stack: error instanceof Error ? error.stack : undefined, - taskData, - }); + if (errorMessage !== "TaskNote conversion cancelled") { + console.error("Error creating task:", { + error: errorMessage, + stack: error instanceof Error ? error.stack : undefined, + taskData, + }); + } throw new Error(`Failed to create task: ${errorMessage}`); } } @@ -291,4 +355,115 @@ export class TaskCreationService { const tasksFolder = plugin.settings.tasksFolder || ""; return this.deps.processFolderTemplate(tasksFolder, taskData); } + + private normalizeTargetPath(targetPath?: string): string { + if (!targetPath?.trim()) { + return ""; + } + const normalized = normalizePath(targetPath.trim()); + return normalized.endsWith(".md") ? normalized : `${normalized}.md`; + } + + private folderFromPath(path: string): string { + const index = path.lastIndexOf("/"); + return index >= 0 ? path.substring(0, index) : ""; + } + + private basenameFromPath(path: string): string { + const name = path.substring(path.lastIndexOf("/") + 1); + return name.endsWith(".md") ? name.substring(0, name.length - 3) : name; + } + + private async resolveExistingTaskNoteConflict(options: { + path: string; + existingFrontmatter: Record; + incomingFrontmatter: Record; + existingBody: string; + incomingBody: string; + }): Promise { + if (this.deps.resolveExistingTaskNoteConflict) { + return this.deps.resolveExistingTaskNoteConflict(options); + } + return showExistingTaskNoteConflictModal(this.deps.plugin.app, options); + } + + private parseFrontmatter(frontmatterText: string | null): Record { + if (!frontmatterText) { + return {}; + } + try { + const parsed = parseYaml(frontmatterText); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + const fallback: Record = {}; + for (const line of frontmatterText.split(/\r?\n/)) { + const match = line.match(/^([^:#]+):\s*(.*)$/); + if (match) { + fallback[match[1].trim()] = match[2].trim(); + } + } + return fallback; + } + } + + private buildTaskFileContent(frontmatter: Record, body: string): string { + const yamlHeader = stringifyYaml(frontmatter); + let content = `---\n${yamlHeader}---\n\n`; + const normalizedBody = body.replace(/\r\n/g, "\n").trimEnd(); + if (normalizedBody.length > 0) { + content += `${normalizedBody}\n`; + } + return content; + } + + private async afterTaskWrite(file: TFile, taskInfo: TaskInfo): Promise { + const { plugin } = this.deps; + try { + if (plugin.cacheManager.waitForFreshTaskData) { + await plugin.cacheManager.waitForFreshTaskData(file); + } + plugin.cacheManager.updateTaskInfoInCache(file.path, taskInfo); + } catch (cacheError) { + console.error("Error updating cache for new task:", cacheError); + } + + plugin.emitter.trigger(EVENT_TASK_UPDATED, { + path: file.path, + updatedTask: taskInfo, + }); + } + + private buildTaskInfo({ + completeTaskData, + finalFrontmatter, + title, + status, + priority, + file, + tagsArray, + normalizedBody, + }: { + completeTaskData: Partial; + finalFrontmatter: Record; + title: string; + status: string; + priority: string; + file: TFile; + tagsArray: string[]; + normalizedBody: string; + }): TaskInfo { + return { + ...completeTaskData, + ...finalFrontmatter, + title: String(finalFrontmatter.title || completeTaskData.title || title), + status: String(finalFrontmatter.status || completeTaskData.status || status), + priority: String(finalFrontmatter.priority || completeTaskData.priority || priority), + path: file.path, + tags: tagsArray, + archived: false, + details: normalizedBody, + }; + } } diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 64139d570..50c635c52 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -231,6 +231,8 @@ export const DEFAULT_TASK_TITLE_FORMATTING: TaskTitleFormattingSettings = { enabled: true, preset: "taskforge", maxLength: 200, + filenameStyle: "readable", + sourceFolderStyle: "preserve", rules: [], }; @@ -321,6 +323,7 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { // Inline task conversion defaults inlineTaskConvertFolder: "{{currentNotePath}}", taskTitleFormatting: DEFAULT_TASK_TITLE_FORMATTING, + existingTaskNoteConflictBehavior: "ask", // Performance defaults disableNoteIndexing: false, // Suggestion performance defaults diff --git a/src/settings/tabs/featuresTab.ts b/src/settings/tabs/featuresTab.ts index e79c2cea8..37b9582f0 100644 --- a/src/settings/tabs/featuresTab.ts +++ b/src/settings/tabs/featuresTab.ts @@ -108,6 +108,37 @@ export function renderFeaturesTab( }, }) ); + + if (plugin.settings.enableInstantTaskConvert) { + group.addSetting((setting) => + configureDropdownSetting(setting, { + name: translate("settings.features.instantConvert.existingTaskNoteConflict.name"), + desc: translate("settings.features.instantConvert.existingTaskNoteConflict.description"), + options: [ + { + value: "ask", + label: translate("settings.features.instantConvert.existingTaskNoteConflict.options.ask"), + }, + { + value: "reuse", + label: translate("settings.features.instantConvert.existingTaskNoteConflict.options.reuse"), + }, + { + value: "create-unique", + label: translate("settings.features.instantConvert.existingTaskNoteConflict.options.createUnique"), + }, + ], + getValue: () => plugin.settings.existingTaskNoteConflictBehavior || "ask", + setValue: async (value: string) => { + plugin.settings.existingTaskNoteConflictBehavior = value as + | "ask" + | "reuse" + | "create-unique"; + save(); + }, + }) + ); + } } ); diff --git a/src/settings/tabs/taskProperties/titlePropertyCard.ts b/src/settings/tabs/taskProperties/titlePropertyCard.ts index af54aca5e..efd836b27 100644 --- a/src/settings/tabs/taskProperties/titlePropertyCard.ts +++ b/src/settings/tabs/taskProperties/titlePropertyCard.ts @@ -8,6 +8,7 @@ import { CardRow, } from "../../components/CardComponent"; import { createPropertyDescription, TranslateFn } from "./helpers"; +import type { TaskTitleFormattingSettings } from "../../../types/settings"; /** * Renders the Title property card with filename settings @@ -108,6 +109,67 @@ function renderFilenameSettingsContent( ): void { container.empty(); + // Filename style applies whenever the title is used to derive a filename, + // including title-stored tasks and TaskForge inline conversion paths. + const styleContainer = container.createDiv("tasknotes-settings__card-config-row"); + styleContainer.createSpan({ + text: translate("settings.taskProperties.titleCard.filenameStyle"), + cls: "tasknotes-settings__card-config-label", + }); + + const styleSelect = createCardSelect( + [ + { + value: "readable", + label: translate("settings.appearance.taskFilenames.filenameStyle.options.readable"), + }, + { + value: "lowercase-snake", + label: translate("settings.appearance.taskFilenames.filenameStyle.options.lowercaseSnake"), + }, + ], + plugin.settings.taskTitleFormatting?.filenameStyle || "readable" + ); + styleSelect.addEventListener("change", () => { + ensureTaskTitleFormattingSettings(plugin).filenameStyle = styleSelect.value as + | "readable" + | "lowercase-snake"; + save(); + }); + styleContainer.appendChild(styleSelect); + + const folderStyleContainer = container.createDiv("tasknotes-settings__card-config-row"); + folderStyleContainer.createSpan({ + text: translate("settings.taskProperties.titleCard.sourceFolderStyle"), + cls: "tasknotes-settings__card-config-label", + }); + + const folderStyleSelect = createCardSelect( + [ + { + value: "preserve", + label: translate("settings.appearance.taskFilenames.sourceFolderStyle.options.preserve"), + }, + { + value: "title-case", + label: translate("settings.appearance.taskFilenames.sourceFolderStyle.options.titleCase"), + }, + ], + plugin.settings.taskTitleFormatting?.sourceFolderStyle || "preserve" + ); + folderStyleSelect.addEventListener("change", () => { + ensureTaskTitleFormattingSettings(plugin).sourceFolderStyle = folderStyleSelect.value as + | "preserve" + | "title-case"; + save(); + }); + folderStyleContainer.appendChild(folderStyleSelect); + + container.createDiv({ + text: translate("settings.appearance.taskFilenames.filenameStyle.description"), + cls: "setting-item-description", + }); + // Only show filename format settings when storeTitleInFilename is off if (plugin.settings.storeTitleInFilename) { container.createDiv({ @@ -208,3 +270,23 @@ function renderFilenameSettingsContent( updateWarning(); } } + +function ensureTaskTitleFormattingSettings(plugin: TaskNotesPlugin): TaskTitleFormattingSettings { + if (!plugin.settings.taskTitleFormatting) { + plugin.settings.taskTitleFormatting = { + enabled: true, + preset: "taskforge", + maxLength: 200, + filenameStyle: "readable", + sourceFolderStyle: "preserve", + rules: [], + }; + } + if (!plugin.settings.taskTitleFormatting.filenameStyle) { + plugin.settings.taskTitleFormatting.filenameStyle = "readable"; + } + if (!plugin.settings.taskTitleFormatting.sourceFolderStyle) { + plugin.settings.taskTitleFormatting.sourceFolderStyle = "preserve"; + } + return plugin.settings.taskTitleFormatting; +} diff --git a/src/types.ts b/src/types.ts index 5d777591d..bdc3fc133 100644 --- a/src/types.ts +++ b/src/types.ts @@ -473,6 +473,7 @@ export interface TaskCreationData extends Partial { parentNote?: string; // Optional parent note name/path for template variable creationContext?: "inline-conversion" | "manual-creation" | "modal-inline-creation" | "api" | "import" | "ics-event"; // Context for folder determination customFrontmatter?: Record; // Custom frontmatter properties (including user fields) + targetPath?: string; // Optional exact note path for conversions that already carry a canonical link } export interface TimeEntry { diff --git a/src/types/settings.ts b/src/types/settings.ts index f25d710f4..378393eb9 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -90,6 +90,8 @@ export interface ProjectAutosuggestSettings { } export type TaskTitleFormattingPreset = "default" | "taskforge" | "raw" | "custom"; +export type TaskFilenameStyle = "readable" | "lowercase-snake"; +export type TaskSourceFolderStyle = "preserve" | "title-case"; export type TaskTitleFormattingRuleOperation = | "from" @@ -116,9 +118,13 @@ export interface TaskTitleFormattingSettings { enabled: boolean; preset: TaskTitleFormattingPreset; maxLength: number; + filenameStyle: TaskFilenameStyle; + sourceFolderStyle: TaskSourceFolderStyle; rules: TaskTitleFormattingRule[]; } +export type ExistingTaskNoteConflictBehavior = "ask" | "reuse" | "create-unique"; + export interface TaskNotesSettings { tasksFolder: string; // Now just a default location for new tasks moveArchivedTasks: boolean; // Whether to move tasks to archive folder when archived @@ -177,6 +183,7 @@ export interface TaskNotesSettings { // Inline task conversion settings inlineTaskConvertFolder: string; // Folder for inline task conversion, supports {{currentNotePath}} and {{currentNoteTitle}} taskTitleFormatting: TaskTitleFormattingSettings; + existingTaskNoteConflictBehavior: ExistingTaskNoteConflictBehavior; // Performance settings disableNoteIndexing: boolean; /** Optional debounce in milliseconds for inline file suggestions (0 = disabled) */ diff --git a/src/utils/filenameGenerator.ts b/src/utils/filenameGenerator.ts index 0d689f0e5..3652ea2b2 100644 --- a/src/utils/filenameGenerator.ts +++ b/src/utils/filenameGenerator.ts @@ -1,6 +1,6 @@ import { format } from "date-fns"; import { normalizePath } from "obsidian"; -import { TaskNotesSettings } from "../types/settings"; +import type { TaskFilenameStyle, TaskNotesSettings } from "../types/settings"; export interface FilenameContext { title: string; @@ -134,13 +134,13 @@ export function generateTaskFilename( } if (settings.storeTitleInFilename) { - return sanitizeForFilename(context.title); + return formatTitleForFilename(context.title, settings.taskTitleFormatting?.filenameStyle); } try { switch (settings.taskFilenameFormat) { case "title": - return sanitizeForFilename(context.title); + return formatTitleForFilename(context.title, settings.taskTitleFormatting?.filenameStyle); case "zettel": return generateZettelId(now); @@ -404,6 +404,25 @@ export function sanitizeForFilename(input: string): string { } } +export function formatTitleForFilename( + input: string, + style: TaskFilenameStyle = "readable" +): string { + if (style !== "lowercase-snake") { + return sanitizeForFilename(input); + } + + const sanitized = sanitizeForFilename(input) + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/['’]/g, "") + .replace(/[^\p{L}\p{N}]+/gu, "_") + .replace(/^_+|_+$/g, ""); + + return sanitizeForFilename(sanitized || "untitled"); +} + /** * Checks if a filename would be valid and unique */ diff --git a/src/utils/taskTitleFormatter.ts b/src/utils/taskTitleFormatter.ts index 8905cf20d..187f78561 100644 --- a/src/utils/taskTitleFormatter.ts +++ b/src/utils/taskTitleFormatter.ts @@ -1,8 +1,10 @@ import type { + TaskFilenameStyle, + TaskSourceFolderStyle, TaskTitleFormattingRule, TaskTitleFormattingSettings, } from "../types/settings"; -import { sanitizeForFilename } from "./filenameGenerator"; +import { formatTitleForFilename, sanitizeForFilename } from "./filenameGenerator"; export interface TaskTitleFormatContext { rawLine?: string; @@ -60,7 +62,10 @@ export function formatTaskTitle( const sourceFolder = context.sourceFolder || folderFromPath(context.sourcePath || ""); const sourceBasename = context.sourceBasename || basenameFromPath(context.sourcePath || ""); const taskNotesRoot = taskNotesRootFromSourceFolder(sourceFolder); + const sourceFolderStyle = settings?.sourceFolderStyle || "preserve"; + const filenameStyle = settings?.filenameStyle || "readable"; const baseTitle = (context.parsedTitle || context.rawLine || "").trim(); + const styledTaskForgeList = formatSourceFolderName(taskForgeList, sourceFolderStyle); const handles: Record = { rawLine: context.rawLine || "", parsedTitle: baseTitle, @@ -68,6 +73,7 @@ export function formatTaskTitle( sourceFolder, sourceBasename, taskForgeList, + styledTaskForgeList, taskNotesRoot, tags: (context.tags || []).join(" "), priority: context.priority || "", @@ -85,8 +91,16 @@ export function formatTaskTitle( for (const rule of presetRules(settings?.preset)) { applyRule(rule, handles, warnings, maxLength); } - if (!handles.noteFolder.trim() && taskForgeList && taskNotesRoot) { - handles.noteFolder = `${taskNotesRoot}/${taskForgeList}`; + if (!handles.noteFolder.trim() && styledTaskForgeList && taskNotesRoot) { + handles.noteFolder = `${taskNotesRoot}/${styledTaskForgeList}`; + } + + const canonicalNotePath = extractCanonicalNotePath(context.rawLine || ""); + if (canonicalNotePath) { + handles.canonicalTitle = basenameFromPath(canonicalNotePath); + handles.filenameTitle = handles.canonicalTitle; + handles.noteFolder = folderFromPath(canonicalNotePath); + handles.fullPath = ensureMarkdownExtension(canonicalNotePath); } for (const rule of settings?.rules || []) { @@ -99,11 +113,14 @@ export function formatTaskTitle( if (!handles.filenameTitle.trim()) { handles.filenameTitle = handles.canonicalTitle; } + if (filenameStyle !== "readable" && !handles.fullPath.trim()) { + handles.filenameTitle = formatTitleForFilename(handles.filenameTitle, filenameStyle); + } if (!handles.fullPath.trim() && handles.noteFolder.trim()) { - handles.fullPath = `${handles.noteFolder}/${sanitizeForFilename(handles.filenameTitle)}.md`; + handles.fullPath = `${handles.noteFolder}/${formatTitleForFilename(handles.filenameTitle, filenameStyle)}.md`; } - return finalizeHandles(handles, maxLength, warnings); + return finalizeHandles(handles, maxLength, warnings, filenameStyle); } function presetRules(preset?: string): TaskTitleFormattingRule[] { @@ -199,14 +216,15 @@ function expandTemplate(template: string, handles: Record): stri function finalizeHandles( handles: Record, maxLength: number, - warnings: string[] + warnings: string[], + filenameStyle: TaskFilenameStyle = "readable" ): TaskTitleFormatResult { const canonicalTitle = (handles.canonicalTitle || handles.parsedTitle || "Untitled Task") .substring(0, maxLength) .trim() || "Untitled Task"; const filenameTitle = (handles.filenameTitle || canonicalTitle).substring(0, maxLength).trim(); const noteFolder = normalizeFolder(handles.noteFolder || ""); - const fullPath = handles.fullPath || (noteFolder ? `${noteFolder}/${sanitizeForFilename(filenameTitle)}.md` : ""); + const fullPath = handles.fullPath || (noteFolder ? `${noteFolder}/${formatTitleForFilename(filenameTitle, filenameStyle)}.md` : ""); return { handles: { ...handles, @@ -238,6 +256,30 @@ function basenameFromPath(path: string): string { return name.endsWith(".md") ? name.substring(0, name.length - 3) : name; } +function formatSourceFolderName(name: string, style: TaskSourceFolderStyle): string { + if (!name || style !== "title-case") { + return name; + } + + return name + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .split(" ") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function extractCanonicalNotePath(rawLine: string): string { + const match = rawLine.match(/\[\[([^\]|#]+)(?:#[^\]|]+)?\|note\]\]/i); + return match?.[1]?.trim() || ""; +} + +function ensureMarkdownExtension(path: string): string { + return path.endsWith(".md") ? path : `${path}.md`; +} + function normalizeFolder(folder: string): string { return folder.replace(/\/+/g, "/").replace(/^\/|\/$/g, ""); } diff --git a/tasknotes-e2e-vault/.obsidian/plugins/tasknotes/data.json b/tasknotes-e2e-vault/.obsidian/plugins/tasknotes/data.json index 7461062b1..f09e70fb9 100644 --- a/tasknotes-e2e-vault/.obsidian/plugins/tasknotes/data.json +++ b/tasknotes-e2e-vault/.obsidian/plugins/tasknotes/data.json @@ -470,5 +470,13 @@ }, "enableMCP": false, "resetCheckboxesOnRecurrence": false, - "enableDebugLogging": false -} \ No newline at end of file + "enableDebugLogging": false, + "taskTitleFormatting": { + "enabled": true, + "preset": "taskforge", + "maxLength": 200, + "filenameStyle": "lowercase-snake", + "sourceFolderStyle": "title-case", + "rules": [] + } +} diff --git a/tests/unit/services/InstantTaskConvertService.test.ts b/tests/unit/services/InstantTaskConvertService.test.ts index a3f7f06cf..3c0a89b7c 100644 --- a/tests/unit/services/InstantTaskConvertService.test.ts +++ b/tests/unit/services/InstantTaskConvertService.test.ts @@ -28,7 +28,14 @@ jest.mock('../../../src/utils/dateUtils', () => ({ jest.mock('../../../src/utils/filenameGenerator', () => ({ generateTaskFilename: jest.fn((context) => `${context.title.toLowerCase().replace(/\s+/g, '-')}.md`), - generateUniqueFilename: jest.fn((base) => base) + generateUniqueFilename: jest.fn((base) => base), + sanitizeForFilename: jest.fn((name) => name.replace(/[<>:"/\\|?*]/g, '').trim()), + formatTitleForFilename: jest.fn((name, style = 'readable') => { + const sanitized = name.replace(/[<>:"/\\|?*]/g, '').trim(); + return style === 'lowercase-snake' + ? sanitized.toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') + : sanitized; + }) })); jest.mock('../../../src/utils/helpers', () => ({ @@ -78,6 +85,46 @@ describe('InstantTaskConvertService', () => { (service as any).nlParser = mockNLParser; }); + describe('TaskForge Title Formatting', () => { + it('keeps the existing task title for canonical inline conversion filenames', () => { + mockPlugin.app.workspace.getActiveFile = jest.fn().mockReturnValue({ + path: '10_journal/TaskForge/skills.md', + parent: { path: '10_journal/TaskForge' }, + basename: 'skills' + }); + + const result = (service as any).formatConvertedTaskTitle( + '- [ ] Monthly skill review #skill/workflow [duration:: 30m]', + { title: 'skill review duration', isCompleted: false }, + 'Monthly skill review [duration:: 30m]' + ); + + expect(result.canonicalTitle).toBe('Monthly skill review'); + expect(result.fullPath).toBe('10_journal/TaskNotes/skills/Monthly skill review.md'); + }); + + it('does not let NLP rename an existing checkbox task title', () => { + const result = (service as any).mergeParseResults( + { + title: 'Monthly skill review', + isCompleted: false + }, + { + title: 'skill review', + recurrence: 'FREQ=MONTHLY', + timeEstimate: 30, + isCompleted: false, + tags: ['skill'] + } + ); + + expect(result.title).toBe('Monthly skill review'); + expect(result.recurrence).toBe('FREQ=MONTHLY'); + expect(result.timeEstimate).toBe(30); + expect(result.tags).toEqual(['skill']); + }); + }); + describe('Context Detection - Natural Language Tasks', () => { it('should extract single context from @context syntax', async () => { // Mock NLP parser to return contexts @@ -1049,7 +1096,7 @@ describe('InstantTaskConvertService', () => { // Scenario: "- [ ] Buy milk tomorrow #groceries" // TasksPlugin extracts: tags: ["groceries"], title: "Buy milk tomorrow" // NLP parses "Buy milk tomorrow": dueDate/scheduledDate, title: "Buy milk" - // Result should have BOTH the tag AND the date + // Result should keep the existing title and add the date const tasksPluginData: ParsedTaskData = { title: 'Buy milk tomorrow', @@ -1065,7 +1112,7 @@ describe('InstantTaskConvertService', () => { const merged = service['mergeParseResults'](tasksPluginData, nlpData); - expect(merged.title).toBe('Buy milk'); // NLP cleaned title + expect(merged.title).toBe('Buy milk tomorrow'); // Existing title preserved expect(merged.tags).toEqual(['groceries']); // TasksPlugin tag preserved expect(merged.scheduledDate).toBe('2025-01-15'); // NLP date extracted expect(merged.isCompleted).toBe(false); @@ -1092,7 +1139,7 @@ describe('InstantTaskConvertService', () => { const merged = service['mergeParseResults'](tasksPluginData, nlpData); expect(merged.dueDate).toBe('2025-02-01'); // TasksPlugin explicit date wins - expect(merged.title).toBe('Meeting'); // NLP cleaned title + expect(merged.title).toBe('Meeting tomorrow'); // Existing title preserved }); it('should combine tags from both sources without duplicates', () => { @@ -1190,7 +1237,7 @@ describe('InstantTaskConvertService', () => { }); }); - it('should use NLP title when it is cleaner (NL phrases removed)', () => { + it('should preserve the existing title even when NLP returns a cleaner title', () => { const tasksPluginData: ParsedTaskData = { title: 'Call mom tomorrow at 3pm', tags: ['family'], @@ -1206,7 +1253,7 @@ describe('InstantTaskConvertService', () => { const merged = service['mergeParseResults'](tasksPluginData, nlpData); - expect(merged.title).toBe('Call mom'); // Cleaner NLP title + expect(merged.title).toBe('Call mom tomorrow at 3pm'); // Existing title preserved expect(merged.scheduledDate).toBe('2025-01-15'); expect(merged.scheduledTime).toBe('15:00'); expect(merged.tags).toEqual(['family']); @@ -1226,7 +1273,7 @@ describe('InstantTaskConvertService', () => { const merged = service['mergeParseResults'](tasksPluginData, nlpData); - expect(merged.title).toBe('Valid title'); // Falls back to TasksPlugin + expect(merged.title).toBe('Valid title'); // Existing title preserved }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/services/TaskService.test.ts b/tests/unit/services/TaskService.test.ts index 456597f17..d700facda 100644 --- a/tests/unit/services/TaskService.test.ts +++ b/tests/unit/services/TaskService.test.ts @@ -29,7 +29,13 @@ jest.mock('../../../src/utils/dateUtils', () => { jest.mock('../../../src/utils/filenameGenerator', () => ({ generateTaskFilename: jest.fn((context) => `${context.title.toLowerCase().replace(/\s+/g, '-')}`), generateUniqueFilename: jest.fn((base) => base), - sanitizeForFilename: jest.fn((input) => input.trim().replace(/[<>:"/\\|?*#[\]]/g, "")) + sanitizeForFilename: jest.fn((input) => input.trim().replace(/[<>:"/\\|?*#[\]]/g, "")), + formatTitleForFilename: jest.fn((input, style = "readable") => { + const sanitized = input.trim().replace(/[<>:"/\\|?*#[\]]/g, ""); + return style === "lowercase-snake" + ? sanitized.toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") + : sanitized; + }) })); jest.mock('../../../src/utils/helpers', () => ({ @@ -45,7 +51,12 @@ jest.mock('../../../src/utils/helpers', () => ({ addDTSTARTToRecurrenceRule: jest.fn((task: { recurrence?: string }) => task.recurrence ? `DTSTART:20250110T120000Z;${task.recurrence}` : null), updateDTSTARTInRecurrenceRule: jest.fn((rule: string) => rule), updateToNextScheduledOccurrence: jest.fn(), - splitFrontmatterAndBody: jest.fn(() => ({ frontmatter: {}, body: '' })) + splitFrontmatterAndBody: jest.fn((content: string) => { + const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/); + return match + ? { frontmatter: match[1], body: match[2] || '' } + : { frontmatter: null, body: content || '' }; + }) })); jest.mock('../../../src/utils/templateProcessor', () => ({ @@ -210,6 +221,121 @@ describe('TaskService', () => { ); }); + it('should create inline conversions at an explicit canonical target path', async () => { + mockPlugin.settings.inlineTaskConvertFolder = '{{currentNotePath}}'; + mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(null); + + const mockCurrentFile = new TFile('10_journal/TaskForge/Skills.md'); + mockCurrentFile.parent = { path: '10_journal/TaskForge' } as any; + mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockCurrentFile); + + const taskData: TaskCreationData = { + title: 'Live technical storytelling with diagrams', + creationContext: 'inline-conversion', + targetPath: '10_journal/TaskNotes/Skills/Live technical storytelling with diagrams.md' + }; + + await taskService.createTask(taskData); + + expect(mockPlugin.app.vault.create).toHaveBeenCalledWith( + '10_journal/TaskNotes/Skills/Live technical storytelling with diagrams.md', + expect.stringContaining('title: Live technical storytelling with diagrams') + ); + }); + + it('should reuse an existing explicit canonical target path', async () => { + mockPlugin.settings.existingTaskNoteConflictBehavior = 'reuse'; + const existingFile = new TFile( + '10_journal/TaskNotes/Skills/Live technical storytelling with diagrams.md' + ); + mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(existingFile); + mockPlugin.app.vault.read.mockResolvedValue('---\nstatus: old\n---\n\nExisting body\n'); + + const taskData: TaskCreationData = { + title: 'Live technical storytelling with diagrams', + creationContext: 'inline-conversion', + targetPath: '10_journal/TaskNotes/Skills/Live technical storytelling with diagrams.md' + }; + + const { file, taskInfo } = await taskService.createTask(taskData); + + expect(file).toBe(existingFile); + expect(taskInfo.path).toBe( + '10_journal/TaskNotes/Skills/Live technical storytelling with diagrams.md' + ); + expect(mockPlugin.app.vault.create).not.toHaveBeenCalled(); + expect(mockPlugin.app.vault.modify).toHaveBeenCalledWith( + existingFile, + expect.stringContaining('Existing body') + ); + }); + + it('should ask before updating an existing explicit canonical target path', async () => { + mockPlugin.settings.existingTaskNoteConflictBehavior = 'ask'; + const existingFile = new TFile( + '10_journal/TaskNotes/Skills/Live technical storytelling with diagrams.md' + ); + mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(existingFile); + mockPlugin.app.vault.read.mockResolvedValue('---\nstatus: old\n---\n\nExisting body\n'); + + const resolver = jest.fn().mockResolvedValue({ + action: 'apply', + metadataChoice: 'incoming', + bodyChoice: 'incoming' + }); + (taskService as any).taskCreationService.deps.resolveExistingTaskNoteConflict = resolver; + + const taskData: TaskCreationData = { + title: 'Live technical storytelling with diagrams', + details: 'Incoming body', + creationContext: 'inline-conversion', + targetPath: '10_journal/TaskNotes/Skills/Live technical storytelling with diagrams.md' + }; + + await taskService.createTask(taskData); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ + path: '10_journal/TaskNotes/Skills/Live technical storytelling with diagrams.md', + existingFrontmatter: expect.any(Object), + existingBody: 'Existing body', + incomingBody: 'Incoming body' + })); + expect(mockPlugin.app.vault.modify).toHaveBeenCalledWith( + existingFile, + expect.stringContaining('title: Live technical storytelling with diagrams') + ); + expect(mockPlugin.app.vault.modify).toHaveBeenCalledWith( + existingFile, + expect.stringContaining('Incoming body') + ); + }); + + it('should create a unique copy when configured for existing explicit canonical target paths', async () => { + const filenameGenerator = require('../../../src/utils/filenameGenerator'); + filenameGenerator.generateUniqueFilename.mockResolvedValueOnce( + 'Live technical storytelling with diagrams-2' + ); + mockPlugin.settings.existingTaskNoteConflictBehavior = 'create-unique'; + const existingFile = new TFile( + '10_journal/TaskNotes/Skills/Live technical storytelling with diagrams.md' + ); + mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(existingFile); + + const taskData: TaskCreationData = { + title: 'Live technical storytelling with diagrams', + creationContext: 'inline-conversion', + targetPath: '10_journal/TaskNotes/Skills/Live technical storytelling with diagrams.md' + }; + + await taskService.createTask(taskData); + + expect(mockPlugin.app.vault.modify).not.toHaveBeenCalled(); + expect(mockPlugin.app.vault.create).toHaveBeenCalledWith( + '10_journal/TaskNotes/Skills/Live technical storytelling with diagrams-2.md', + expect.stringContaining('title: Live technical storytelling with diagrams') + ); + }); + it('should handle inline conversion context without currentNotePath variable', async () => { mockPlugin.settings.inlineTaskConvertFolder = 'InlineTasks'; diff --git a/tests/unit/utils/filenameGenerator.title-style.test.ts b/tests/unit/utils/filenameGenerator.title-style.test.ts new file mode 100644 index 000000000..dba06317e --- /dev/null +++ b/tests/unit/utils/filenameGenerator.title-style.test.ts @@ -0,0 +1,52 @@ +import { formatTitleForFilename, generateTaskFilename } from "../../../src/utils/filenameGenerator"; +import { TaskNotesSettings } from "../../../src/types/settings"; + +describe("filenameGenerator title filename style", () => { + const baseContext = { + title: "Inventory possessions into keep, store, sell, donate, trash", + priority: "normal", + status: "open", + }; + + it("keeps readable title filenames by default", () => { + expect(formatTitleForFilename(baseContext.title)).toBe( + "Inventory possessions into keep, store, sell, donate, trash" + ); + }); + + it("can create lowercase snake_case title filenames", () => { + expect(formatTitleForFilename(baseContext.title, "lowercase-snake")).toBe( + "inventory_possessions_into_keep_store_sell_donate_trash" + ); + }); + + it("uses lowercase snake_case when storeTitleInFilename is enabled", () => { + const settings = { + storeTitleInFilename: true, + taskFilenameFormat: "zettel", + customFilenameTemplate: "{title}", + taskTitleFormatting: { + filenameStyle: "lowercase-snake", + }, + } as TaskNotesSettings; + + expect(generateTaskFilename(baseContext, settings)).toBe( + "inventory_possessions_into_keep_store_sell_donate_trash" + ); + }); + + it("uses lowercase snake_case for title filename format", () => { + const settings = { + storeTitleInFilename: false, + taskFilenameFormat: "title", + customFilenameTemplate: "{title}", + taskTitleFormatting: { + filenameStyle: "lowercase-snake", + }, + } as TaskNotesSettings; + + expect(generateTaskFilename(baseContext, settings)).toBe( + "inventory_possessions_into_keep_store_sell_donate_trash" + ); + }); +}); diff --git a/tests/unit/utils/taskTitleFormatter.test.ts b/tests/unit/utils/taskTitleFormatter.test.ts index 0c3d614ac..5cc11bc00 100644 --- a/tests/unit/utils/taskTitleFormatter.test.ts +++ b/tests/unit/utils/taskTitleFormatter.test.ts @@ -16,6 +16,63 @@ describe("taskTitleFormatter", () => { expect(result.fullPath).toBe("Projects/TaskNotes/legal/Eb2 visa (self-petition).md"); }); + it("uses an existing note alias link as the canonical task note target", () => { + const result = formatTaskTitle({ + rawLine: + "- [ ] Live technical storytelling with diagrams #skill/interview [[10_journal/TaskNotes/Skills/Live technical storytelling with diagrams|note]] [duration:: 30m]", + parsedTitle: "Live technical storytelling with diagrams duration", + sourcePath: "10_journal/TaskForge/Skills.md", + }); + + expect(result.canonicalTitle).toBe("Live technical storytelling with diagrams"); + expect(result.filenameTitle).toBe("Live technical storytelling with diagrams"); + expect(result.noteFolder).toBe("10_journal/TaskNotes/Skills"); + expect(result.fullPath).toBe( + "10_journal/TaskNotes/Skills/Live technical storytelling with diagrams.md" + ); + }); + + it("does not treat TaskForge inline fields as part of the canonical title", () => { + const result = formatTaskTitle({ + rawLine: + "- [ ] Monthly skill review #skill/workflow [duration:: 30m] [scheduled:: 2026-05-08]", + parsedTitle: "Monthly skill review [duration:: 30m] [scheduled:: 2026-05-08]", + sourcePath: "10_journal/TaskForge/skills.md", + }); + + expect(result.canonicalTitle).toBe("Monthly skill review"); + expect(result.filenameTitle).toBe("Monthly skill review"); + expect(result.noteFolder).toBe("10_journal/TaskNotes/skills"); + expect(result.fullPath).toBe("10_journal/TaskNotes/skills/Monthly skill review.md"); + }); + + it("can create Title Case source folders and lowercase snake_case filenames", () => { + const result = formatTaskTitle( + { + rawLine: + "- [ ] Inventory possessions into keep, store, sell, donate, trash [duration:: 30m]", + parsedTitle: + "Inventory possessions into keep, store, sell, donate, trash [duration:: 30m]", + sourcePath: "10_journal/TaskForge/minimalism.md", + }, + { + enabled: true, + preset: "taskforge", + maxLength: 200, + filenameStyle: "lowercase-snake", + sourceFolderStyle: "title-case", + rules: [], + } + ); + + expect(result.canonicalTitle).toBe("Inventory possessions into keep, store, sell, donate, trash"); + expect(result.filenameTitle).toBe("inventory_possessions_into_keep_store_sell_donate_trash"); + expect(result.noteFolder).toBe("10_journal/TaskNotes/Minimalism"); + expect(result.fullPath).toBe( + "10_journal/TaskNotes/Minimalism/inventory_possessions_into_keep_store_sell_donate_trash.md" + ); + }); + it("allows custom regex rules to populate handles", () => { const result = formatTaskTitle( {