Add TaskNotes CLI capture command

This commit is contained in:
callumalpass 2026-03-29 10:16:12 +11:00
parent 7a1e25588f
commit da140a4387
11 changed files with 483 additions and 91 deletions

View file

@ -9,7 +9,7 @@ This is an Obsidian plugin. The plugin ID is `tasknotes`.
npm run build:test
# After building, reload the plugin in the running Obsidian instance
obsidian plugin:reload id=tasknotes
obsidian plugin:reload id=tasknotes vault=test
```
Always run both commands after making changes. Obsidian must be running for the CLI to work.
@ -18,19 +18,19 @@ Always run both commands after making changes. Obsidian must be running for the
```bash
# Check for JavaScript errors after reload
obsidian dev:errors
obsidian dev:errors vault=test
# View console output
obsidian dev:console
obsidian dev:console vault=test
# Run JavaScript in the Obsidian context
obsidian dev:eval code="app.vault.getFiles().length"
obsidian dev:eval code="app.vault.getFiles().length" vault=test
# Take a screenshot to verify UI changes
obsidian dev:screenshot path=screenshot.png
obsidian dev:screenshot path=screenshot.png vault=test
# Open developer tools
obsidian dev:open
obsidian dev:open vault=test
```
## Other Build Commands

View file

@ -56,6 +56,7 @@ Example:
- (#1678) Added task-first timeblock creation and editing helpers, including prefilled task-title context-menu creation, prefilled task attachments, Add Task actions in the create/edit modals, auto-fill of empty titles from selected attachments, and configurable attachment search ordering
- Thanks to @Lorite for the fix
- Added a `tasknotes:capture` Obsidian CLI command for task creation, with shared NLP-to-task conversion across CLI, modal, and API capture flows plus explicit override flags for title, details, dates, tags, contexts, projects, recurrence, recurrence anchor, reminders, estimate, and literal-title capture
- (#1619, #386, #621) Added drag-to-reorder for Kanban and Task List views, including grouped Task List moves, manual ordering support via the `tasknotes_manual_order` property, updated generated `.base` templates, and polished drag/drop feedback for swimlanes, filtered views, and interactive task controls
- Thanks to @ac8318740 for the original contribution in PR #1619
- Thanks to @iholston and @dsebastien for opening #386 and #621

View file

@ -0,0 +1,84 @@
import type { CliData } from "obsidian";
import type { CliCommandDefinition } from "../types";
import { buildTaskCreationDataFromCli } from "../../utils/buildTaskCreationDataFromCli";
export const captureCliCommand: CliCommandDefinition = {
command: "capture",
description: "Create a TaskNotes task from free text or explicit flags",
flags: {
text: {
value: "<text>",
description: "Task text to parse with NLP unless --literal is set",
},
title: {
value: "<title>",
description: "Explicit task title; overrides NLP-derived title",
},
details: {
value: "<details>",
description: "Task details/body; overrides NLP-derived details",
},
status: {
value: "<status>",
description: "Explicit task status",
},
priority: {
value: "<priority>",
description: "Explicit task priority",
},
due: {
value: "<date>",
description: "Due date or datetime (YYYY-MM-DD or YYYY-MM-DDTHH:MM)",
},
scheduled: {
value: "<date>",
description: "Scheduled date or datetime (YYYY-MM-DD or YYYY-MM-DDTHH:MM)",
},
tags: {
value: "<tag1,tag2>",
description: "Comma-separated tags; overrides NLP-derived tags",
},
contexts: {
value: "<ctx1,ctx2>",
description: "Comma-separated contexts; overrides NLP-derived contexts",
},
projects: {
value: "<proj1,proj2>",
description: "Comma-separated projects; overrides NLP-derived projects",
},
recurrence: {
value: "<rrule>",
description: "Explicit recurrence rule",
},
"recurrence-anchor": {
value: "<scheduled|completion>",
description: "How recurring tasks advance: from the scheduled date or completion date",
},
reminders: {
value: "<spec>",
description: "Reminder spec(s): due:-PT1H;scheduled:-PT30M;at:2026-04-02T09:00 or a JSON array",
},
estimate: {
value: "<minutes>",
description: "Time estimate in minutes",
},
literal: {
description: "Treat --text as a literal title instead of parsing it with NLP",
},
},
async handler(plugin, params: CliData): Promise<string> {
const { taskData, usedNlp } = buildTaskCreationDataFromCli(plugin, params);
const result = await plugin.taskService.createTask(taskData);
return JSON.stringify(
{
title: result.taskInfo.title,
path: result.taskInfo.path,
status: result.taskInfo.status,
priority: result.taskInfo.priority,
usedNlp,
},
null,
2
);
},
};

View file

@ -0,0 +1,25 @@
import { requireApiVersion } from "obsidian";
import type TaskNotesPlugin from "../main";
import { captureCliCommand } from "./commands/captureCommand";
import type { CliCommandDefinition } from "./types";
const CLI_COMMANDS: CliCommandDefinition[] = [captureCliCommand];
export function registerCliHandlers(plugin: TaskNotesPlugin): void {
if (!requireApiVersion("1.12.2")) {
return;
}
if (typeof plugin.registerCliHandler !== "function") {
return;
}
for (const definition of CLI_COMMANDS) {
plugin.registerCliHandler(
`${plugin.manifest.id}:${definition.command}`,
definition.description,
definition.flags,
(params) => definition.handler(plugin, params)
);
}
}

9
src/cli/types.ts Normal file
View file

@ -0,0 +1,9 @@
import type { CliData, CliFlags } from "obsidian";
import type TaskNotesPlugin from "../main";
export interface CliCommandDefinition {
command: string;
description: string;
flags: CliFlags | null;
handler: (plugin: TaskNotesPlugin, params: CliData) => string | Promise<string>;
}

View file

@ -87,6 +87,7 @@ import {
registerRibbonIcons,
registerTaskNotesIcon,
} from "./bootstrap/pluginBootstrap";
import { registerCliHandlers } from "./cli/registerCliHandlers";
interface TranslatedCommandDefinition {
id: string;
@ -297,6 +298,7 @@ export default class TaskNotesPlugin extends Plugin {
// Add commands
this.addCommands();
registerCliHandlers(this);
// Add settings tab
this.addSettingTab(new TaskNotesSettingTab(this.app, this));

View file

@ -1,7 +1,6 @@
import { App, SuggestModal, TFile, Notice, setIcon, debounce } from "obsidian";
import { TaskInfo, TaskCreationData } from "../types";
import { combineDateAndTime, getCurrentTimestamp } from "../utils/dateUtils";
import { filterEmptyProjects, sanitizeTags } from "../utils/helpers";
import { filterEmptyProjects } from "../utils/helpers";
import type TaskNotesPlugin from "../main";
import { TranslationKey } from "../i18n";
import {
@ -9,6 +8,7 @@ import {
ParsedTaskData,
} from "../services/NaturalLanguageParser";
import { createTaskCard } from "../ui/TaskCard";
import { buildTaskCreationDataFromParsed } from "../utils/buildTaskCreationDataFromParsed";
export type TaskSelectorWithCreateResult =
| { type: "selected"; task: TaskInfo }
@ -306,74 +306,7 @@ export class TaskSelectorWithCreateModal extends SuggestModal<TaskInfo> {
}
private buildTaskDataFromParsed(parsed: ParsedTaskData): TaskCreationData {
const now = getCurrentTimestamp();
const taskData: TaskCreationData = {
title: parsed.title.trim(),
status: parsed.status || this.plugin.settings.defaultTaskStatus,
priority: parsed.priority || this.plugin.settings.defaultTaskPriority,
dateCreated: now,
dateModified: now,
};
// Handle due date with time
if (parsed.dueDate) {
taskData.due = parsed.dueTime
? combineDateAndTime(parsed.dueDate, parsed.dueTime)
: parsed.dueDate;
}
// Handle scheduled date with time
if (parsed.scheduledDate) {
taskData.scheduled = parsed.scheduledTime
? combineDateAndTime(parsed.scheduledDate, parsed.scheduledTime)
: parsed.scheduledDate;
}
if (parsed.contexts && parsed.contexts.length > 0) {
taskData.contexts = parsed.contexts;
}
if (parsed.projects && parsed.projects.length > 0) {
taskData.projects = parsed.projects;
}
if (parsed.tags && parsed.tags.length > 0) {
taskData.tags = parsed.tags.map((t) => sanitizeTags(t));
}
if (parsed.details) {
taskData.details = parsed.details;
}
if (parsed.recurrence) {
taskData.recurrence = parsed.recurrence;
}
if (parsed.estimate && parsed.estimate > 0) {
taskData.timeEstimate = parsed.estimate;
}
// Handle NLP-parsed user fields - convert from fieldId to frontmatter key
// Default values for user fields are applied by TaskService.createTask()
if (parsed.userFields) {
const userFieldDefs = this.plugin.settings.userFields || [];
const customFrontmatter: Record<string, any> = {};
for (const [fieldId, value] of Object.entries(parsed.userFields)) {
const fieldDef = userFieldDefs.find((f) => f.id === fieldId);
if (fieldDef) {
// Use the frontmatter key from the field definition
customFrontmatter[fieldDef.key] = Array.isArray(value) ? value.join(", ") : value;
}
}
if (Object.keys(customFrontmatter).length > 0) {
taskData.customFrontmatter = customFrontmatter;
}
}
return taskData;
return buildTaskCreationDataFromParsed(this.plugin, parsed);
}
getSuggestions(query: string): TaskInfo[] {

View file

@ -23,6 +23,7 @@ import {
computeTaskTimeData,
} from "../utils/timeTrackingUtils";
import { collectCalendarEvents } from "../utils/calendarUtils";
import { buildTaskCreationDataFromParsed } from "../utils/buildTaskCreationDataFromParsed";
/**
* MCP (Model Context Protocol) server for TaskNotes.
@ -326,22 +327,9 @@ export class MCPService {
async ({ text }: any) => {
try {
const parsed = this.nlParser.parseInput(text);
const taskData: TaskCreationData = {
title: parsed.title,
path: "",
archived: false,
status: parsed.status || this.plugin.settings.defaultTaskStatus,
priority: parsed.priority || this.plugin.settings.defaultTaskPriority,
due: parsed.dueDate,
scheduled: parsed.scheduledDate,
tags: parsed.tags,
contexts: parsed.contexts,
projects: parsed.projects,
recurrence: parsed.recurrence,
timeEstimate: parsed.estimate,
details: parsed.details,
const taskData = buildTaskCreationDataFromParsed(this.plugin, parsed, {
creationContext: "api",
};
});
const result = await this.taskService.createTask(taskData);
return this.jsonResult({ parsed, task: result.taskInfo });

34
src/types/obsidian-1.12-cli.d.ts vendored Normal file
View file

@ -0,0 +1,34 @@
/**
* Type declarations for Obsidian 1.12.2 CLI APIs
* These augment the existing obsidian module types
*/
import "obsidian";
declare module "obsidian" {
export interface CliData {
[key: string]: string | "true";
}
export interface CliFlag {
value?: string;
description: string;
required?: boolean;
}
export type CliFlags = Record<string, CliFlag>;
export type CliHandler = (params: CliData) => string | Promise<string>;
interface Plugin {
/**
* Register a CLI handler to handle a command from the CLI.
* @since Obsidian 1.12.2
*/
registerCliHandler(
command: string,
description: string,
flags: CliFlags | null,
handler: CliHandler
): void;
}
}

View file

@ -0,0 +1,234 @@
import type { CliData } from "obsidian";
import type TaskNotesPlugin from "../main";
import type { Reminder, TaskCreationData } from "../types";
import { NaturalLanguageParser } from "../services/NaturalLanguageParser";
import { getCurrentTimestamp } from "./dateUtils";
import { sanitizeTags } from "./helpers";
import { buildTaskCreationDataFromParsed } from "./buildTaskCreationDataFromParsed";
export interface CliCaptureBuildResult {
taskData: TaskCreationData;
usedNlp: boolean;
}
function parseCsvList(value?: string): string[] | undefined {
if (!value) {
return undefined;
}
const items = value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
return items.length > 0 ? items : undefined;
}
function parseEstimate(value?: string): number | undefined {
if (!value) {
return undefined;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error("--estimate must be a non-negative number of minutes");
}
return parsed;
}
function buildReminderId(index: number): string {
return `rem_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 11)}`;
}
function normalizeAbsoluteTime(value: string): string {
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)) {
return `${value}:00`;
}
return value;
}
function parseReminderShorthand(value: string, index: number): Reminder {
const trimmed = value.trim();
if (trimmed.startsWith("at:")) {
const absoluteTime = normalizeAbsoluteTime(trimmed.slice(3).trim());
if (!absoluteTime) {
throw new Error("Absolute reminders must use at:YYYY-MM-DDTHH:MM or full ISO datetime");
}
return {
id: buildReminderId(index),
type: "absolute",
description: "Reminder",
absoluteTime,
};
}
const separatorIndex = trimmed.indexOf(":");
if (separatorIndex === -1) {
throw new Error(
"Reminder shorthand must use due:<offset>, scheduled:<offset>, or at:<datetime>"
);
}
const relatedTo = trimmed.slice(0, separatorIndex).trim();
const offset = trimmed.slice(separatorIndex + 1).trim();
if ((relatedTo !== "due" && relatedTo !== "scheduled") || !offset) {
throw new Error(
"Relative reminders must use due:<offset> or scheduled:<offset> with ISO 8601 durations like -PT1H"
);
}
return {
id: buildReminderId(index),
type: "relative",
description: "Reminder",
relatedTo,
offset,
};
}
function parseReminders(value?: string): Reminder[] | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.startsWith("[")) {
const parsed = JSON.parse(trimmed);
if (!Array.isArray(parsed)) {
throw new Error("--reminders JSON must be an array");
}
return parsed as Reminder[];
}
const reminders = trimmed
.split(";")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.map((entry, index) => parseReminderShorthand(entry, index));
return reminders.length > 0 ? reminders : undefined;
}
function parseRecurrenceAnchor(
value?: string
): TaskCreationData["recurrence_anchor"] | undefined {
if (!value) {
return undefined;
}
if (value !== "scheduled" && value !== "completion") {
throw new Error("--recurrence-anchor must be either 'scheduled' or 'completion'");
}
return value;
}
export function buildTaskCreationDataFromCli(
plugin: TaskNotesPlugin,
params: CliData
): CliCaptureBuildResult {
const text = params.text?.trim();
const titleOverride = params.title?.trim();
const literal = params.literal === "true";
let taskData: TaskCreationData;
let usedNlp = false;
if (text && !literal) {
const parsed = NaturalLanguageParser.fromPlugin(plugin).parseInput(text);
taskData = buildTaskCreationDataFromParsed(plugin, parsed, {
creationContext: "api",
});
usedNlp = true;
} else {
const now = getCurrentTimestamp();
const title = titleOverride || text;
if (!title) {
throw new Error("Either --text or --title is required");
}
taskData = {
title,
status: plugin.settings.defaultTaskStatus,
priority: plugin.settings.defaultTaskPriority,
dateCreated: now,
dateModified: now,
creationContext: "api",
};
}
if (!taskData.title || taskData.title.trim() === "" || taskData.title === "Untitled Task") {
if (!titleOverride) {
throw new Error("Could not derive a task title from --text; pass --title to override");
}
}
if (titleOverride) {
taskData.title = titleOverride;
}
if (params.details) {
taskData.details = params.details;
}
if (params.status) {
taskData.status = params.status;
}
if (params.priority) {
taskData.priority = params.priority;
}
if (params.due) {
taskData.due = params.due;
}
if (params.scheduled) {
taskData.scheduled = params.scheduled;
}
const tags = parseCsvList(params.tags);
if (tags) {
taskData.tags = tags.map((tag) => sanitizeTags(tag));
}
const contexts = parseCsvList(params.contexts);
if (contexts) {
taskData.contexts = contexts;
}
const projects = parseCsvList(params.projects);
if (projects) {
taskData.projects = projects;
}
if (params.recurrence) {
taskData.recurrence = params.recurrence;
}
const recurrenceAnchor = parseRecurrenceAnchor(params["recurrence-anchor"]);
if (recurrenceAnchor) {
taskData.recurrence_anchor = recurrenceAnchor;
}
const estimate = parseEstimate(params.estimate);
if (estimate !== undefined) {
taskData.timeEstimate = estimate;
}
const reminders = parseReminders(params.reminders);
if (reminders) {
taskData.reminders = reminders;
}
return { taskData, usedNlp };
}

View file

@ -0,0 +1,82 @@
import type TaskNotesPlugin from "../main";
import type { ParsedTaskData } from "../services/NaturalLanguageParser";
import type { TaskCreationData } from "../types";
import { combineDateAndTime, getCurrentTimestamp } from "./dateUtils";
import { sanitizeTags } from "./helpers";
interface BuildTaskCreationDataOptions {
creationContext?: TaskCreationData["creationContext"];
}
export function buildTaskCreationDataFromParsed(
plugin: TaskNotesPlugin,
parsed: ParsedTaskData,
options: BuildTaskCreationDataOptions = {}
): TaskCreationData {
const now = getCurrentTimestamp();
const taskData: TaskCreationData = {
title: parsed.title.trim(),
status: parsed.status || plugin.settings.defaultTaskStatus,
priority: parsed.priority || plugin.settings.defaultTaskPriority,
dateCreated: now,
dateModified: now,
};
if (options.creationContext) {
taskData.creationContext = options.creationContext;
}
if (parsed.dueDate) {
taskData.due = parsed.dueTime
? combineDateAndTime(parsed.dueDate, parsed.dueTime)
: parsed.dueDate;
}
if (parsed.scheduledDate) {
taskData.scheduled = parsed.scheduledTime
? combineDateAndTime(parsed.scheduledDate, parsed.scheduledTime)
: parsed.scheduledDate;
}
if (parsed.contexts && parsed.contexts.length > 0) {
taskData.contexts = parsed.contexts;
}
if (parsed.projects && parsed.projects.length > 0) {
taskData.projects = parsed.projects;
}
if (parsed.tags && parsed.tags.length > 0) {
taskData.tags = parsed.tags.map((tag) => sanitizeTags(tag));
}
if (parsed.details) {
taskData.details = parsed.details;
}
if (parsed.recurrence) {
taskData.recurrence = parsed.recurrence;
}
if (parsed.estimate && parsed.estimate > 0) {
taskData.timeEstimate = parsed.estimate;
}
if (parsed.userFields) {
const userFieldDefs = plugin.settings.userFields || [];
const customFrontmatter: Record<string, any> = {};
for (const [fieldId, value] of Object.entries(parsed.userFields)) {
const fieldDef = userFieldDefs.find((field) => field.id === fieldId);
if (fieldDef) {
customFrontmatter[fieldDef.key] = Array.isArray(value) ? value.join(", ") : value;
}
}
if (Object.keys(customFrontmatter).length > 0) {
taskData.customFrontmatter = customFrontmatter;
}
}
return taskData;
}