Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
30 changes: 30 additions & 0 deletions src/i18n/resources/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
152 changes: 152 additions & 0 deletions src/modals/ExistingTaskNoteConflictModal.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
incomingFrontmatter: Record<string, unknown>;
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<ExistingTaskNoteConflictDecision> {
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<ExistingTaskNoteConflictDecision> {
return new ExistingTaskNoteConflictModal(app, options).show();
}
71 changes: 66 additions & 5 deletions src/services/InstantTaskConvertService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -119,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 };
Expand Down Expand Up @@ -173,13 +184,15 @@ export class InstantTaskConvertService {

// Parse the current line for Tasks plugin format, with NLP fallback
let parsedData: ParsedTaskData;
let titleForFormatting = "";

const taskLineInfo = TasksPluginParser.parseTaskLine(currentLine);

if (!taskLineInfo.isTaskLine) {
// 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"));
Expand Down Expand Up @@ -215,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
Expand Down Expand Up @@ -255,8 +269,18 @@ export class InstantTaskConvertService {
return;
}

const formattedTitle = this.formatConvertedTaskTitle(
currentLine,
parsedData,
titleForFormatting || parsedData.title
);
parsedData = {
...parsedData,
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(
Expand Down Expand Up @@ -302,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"));
Expand Down Expand Up @@ -437,7 +464,11 @@ export class InstantTaskConvertService {
/**
* Create a task file using default settings and parsed data
*/
private async createTaskFile(parsedData: ParsedTaskData, details = ""): Promise<TFile> {
private async createTaskFile(
parsedData: ParsedTaskData,
details = "",
targetPath?: string
): Promise<TFile> {
// Sanitize and validate input data
// Check if title will be truncated and preserve overflow text (issue #1310)
const originalTitle = parsedData.title?.trim() || "";
Expand Down Expand Up @@ -677,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,
Expand All @@ -696,6 +728,35 @@ export class InstantTaskConvertService {
return title.trim().substring(0, 200);
}

private formatConvertedTaskTitle(
originalLine: string,
parsedData: ParsedTaskData,
titleForFormatting = parsedData.title
) {
const currentFile = this.plugin.app.workspace.getActiveFile();
return formatTaskTitle(
{
rawLine: originalLine,
parsedTitle: titleForFormatting,
sourcePath: currentFile?.path,
sourceFolder: currentFile?.parent?.path,
sourceBasename: currentFile?.basename,
tags: parsedData.tags,
priority: parsedData.priority,
status: parsedData.status,
},
this.plugin.settings.taskTitleFormatting
);
}

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.
Expand Down Expand Up @@ -958,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,
Expand Down
Loading