/**
* Task management operations for Logseq.
* @module
*/
import type {
Task,
TaskMarker,
TaskPriority,
GetTasksOptions,
CreateTaskOptions,
SetTaskDeadlineOptions,
SetTaskScheduledOptions,
CreateBlockOptions,
SearchTasksOptions,
TaskStats,
GetTasksDueSoonOptions,
MarkTasksResult,
} from "../types.js";
import { LogseqNotFoundError, LogseqValidationError } from "../errors.js";
import { LogseqOperations } from "./base.js";
import {
parseTaskContent,
buildTaskContent,
formatLogseqDate,
parseLogseqDate,
getTodayIso,
} from "./helpers.js";
/**
* Retrieves tasks from Logseq.
*/
export async function getTasks(
ops: LogseqOperations,
options: GetTasksOptions = {}
): Promise<Task[]> {
const markers = options.markers ?? ["TODO", "DOING"];
const markerSet = markers.map((m) => `"${m}"`).join(" ");
let queryString: string;
if (options.pageName) {
const pageNameLower = options.pageName.toLowerCase();
queryString = `
[:find (pull ?b [*]) ?page-name
:where
[?b :block/marker ?m]
[?b :block/page ?p]
[?p :block/name ?page-name]
[(= ?page-name "${pageNameLower}")]
[(contains? #{${markerSet}} ?m)]]
`;
} else {
queryString = `
[:find (pull ?b [*]) ?page-name
:where
[?b :block/marker ?m]
[?b :block/page ?p]
[?p :block/name ?page-name]
[(contains? #{${markerSet}} ?m)]]
`;
}
const results = await ops.query(queryString);
return results.map((row) => {
const block = row[0] as Record<string, unknown>;
const pageName = row[1] as string;
const content = (block.content as string) ?? "";
const parsed = parseTaskContent(content);
const task: Task = {
uuid: block.uuid as string,
content,
marker: (block.marker as TaskMarker) ?? parsed.marker ?? "TODO",
description: parsed.description,
pageName,
};
if (parsed.priority) {
task.priority = parsed.priority;
}
const deadline = parseLogseqDate(block.deadline as string | undefined);
if (deadline) {
task.deadline = deadline;
}
const scheduled = parseLogseqDate(block.scheduled as string | undefined);
if (scheduled) {
task.scheduled = scheduled;
}
return task;
});
}
/**
* Creates a new task.
*/
export async function createTask(ops: LogseqOperations, options: CreateTaskOptions): Promise<Task> {
// Build the task content with marker and priority
let blockContent = buildTaskContent("TODO", options.priority, options.content);
// Append SCHEDULED and DEADLINE as org-mode style timestamps (not properties)
// Logseq uses "SCHEDULED: <date>" and "DEADLINE: <date>" format (single colon, uppercase)
if (options.scheduled) {
blockContent += `\nSCHEDULED: ${formatLogseqDate(options.scheduled)}`;
}
if (options.deadline) {
blockContent += `\nDEADLINE: ${formatLogseqDate(options.deadline)}`;
}
const createOptions: CreateBlockOptions = {
pageName: options.pageName,
content: blockContent,
};
const block = await ops.createBlock(createOptions);
const task: Task = {
uuid: block.uuid,
content: blockContent,
marker: "TODO",
description: options.content,
pageName: options.pageName,
};
if (options.priority) {
task.priority = options.priority;
}
if (options.deadline) {
task.deadline = options.deadline;
}
if (options.scheduled) {
task.scheduled = options.scheduled;
}
return task;
}
/**
* Changes a task's marker (status).
*/
export async function markTask(
ops: LogseqOperations,
uuid: string,
marker: TaskMarker
): Promise<void> {
const block = await ops.getBlock(uuid);
if (!block) {
throw new LogseqNotFoundError("task", uuid);
}
const parsed = parseTaskContent(block.content);
const newContent = buildTaskContent(marker, parsed.priority, parsed.description);
await ops.updateBlock({ uuid, content: newContent });
}
/**
* Changes multiple tasks' markers (status) in a single operation.
*
* @param ops - LogseqOperations instance
* @param uuids - Array of task block UUIDs to update
* @param marker - New marker for all tasks
* @returns Result with successful and failed UUIDs
*
* @remarks
* Tasks are processed sequentially. If a task fails, the error is recorded
* but processing continues for remaining tasks.
*/
export async function markTasks(
ops: LogseqOperations,
uuids: string[],
marker: TaskMarker
): Promise<MarkTasksResult> {
const result: MarkTasksResult = {
success: [],
failed: [],
};
for (const uuid of uuids) {
try {
await markTask(ops, uuid, marker);
result.success.push(uuid);
} catch (error) {
result.failed.push({
uuid,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
/**
* Sets or removes a task's priority.
*/
export async function setTaskPriority(
ops: LogseqOperations,
uuid: string,
priority: TaskPriority | null
): Promise<void> {
const block = await ops.getBlock(uuid);
if (!block) {
throw new LogseqNotFoundError("task", uuid);
}
const parsed = parseTaskContent(block.content);
if (!parsed.marker) {
throw new LogseqValidationError("Block is not a task (no marker found)", "uuid");
}
const newContent = buildTaskContent(parsed.marker, priority ?? undefined, parsed.description);
await ops.updateBlock({ uuid, content: newContent });
}
/**
* Updates or removes an org-mode style timestamp (SCHEDULED: or DEADLINE:) in block content.
*/
function updateOrgTimestamp(
content: string,
type: "SCHEDULED" | "DEADLINE",
date: string | null
): string {
// Pattern to match existing timestamp line (case-insensitive)
const pattern = new RegExp(`^${type}:\\s*<[^>]+>\\s*$`, "im");
// Remove existing timestamp line if present
let newContent = content.replace(pattern, "").trim();
// Also remove property-style format if present (scheduled:: or deadline::)
const propertyPattern = new RegExp(`^${type.toLowerCase()}::\\s*<[^>]+>\\s*$`, "im");
newContent = newContent.replace(propertyPattern, "").trim();
// Add new timestamp if date provided
if (date) {
newContent += `\n${type}: ${date}`;
}
return newContent;
}
/**
* Sets or removes a task's deadline.
*/
export async function setTaskDeadline(
ops: LogseqOperations,
options: SetTaskDeadlineOptions
): Promise<void> {
const block = await ops.getBlock(options.uuid);
if (!block) {
throw new LogseqNotFoundError("task", options.uuid);
}
const newDate = options.date ? formatLogseqDate(options.date) : null;
const newContent = updateOrgTimestamp(block.content, "DEADLINE", newDate);
await ops.updateBlock({ uuid: options.uuid, content: newContent });
}
/**
* Sets or removes a task's scheduled date.
*/
export async function setTaskScheduled(
ops: LogseqOperations,
options: SetTaskScheduledOptions
): Promise<void> {
const block = await ops.getBlock(options.uuid);
if (!block) {
throw new LogseqNotFoundError("task", options.uuid);
}
const newDate = options.date ? formatLogseqDate(options.date) : null;
const newContent = updateOrgTimestamp(block.content, "SCHEDULED", newDate);
await ops.updateBlock({ uuid: options.uuid, content: newContent });
}
/**
* Searches for tasks matching a query string.
*/
export async function searchTasks(
ops: LogseqOperations,
options: SearchTasksOptions
): Promise<Task[]> {
const markers = options.markers ?? ["TODO", "DOING", "DONE", "CANCELED"];
const markersSet = markers.map((m) => `"${m}"`).join(" ");
const searchTerm = options.query.replace(/"/g, '\\"');
let query: string;
if (options.pageName) {
query = `
[:find (pull ?b [*]) ?page-name
:where
[?b :block/marker ?m]
[?b :block/content ?c]
[?b :block/page ?p]
[?p :block/name ?page-name]
[(= ?page-name "${options.pageName.toLowerCase()}")]
[(contains? #{${markersSet}} ?m)]
[(clojure.string/includes? ?c "${searchTerm}")]]
`;
} else {
query = `
[:find (pull ?b [*]) ?page-name
:where
[?b :block/marker ?m]
[?b :block/content ?c]
[?b :block/page ?p]
[?p :block/name ?page-name]
[(contains? #{${markersSet}} ?m)]
[(clojure.string/includes? ?c "${searchTerm}")]]
`;
}
const result = await ops.query(query);
const tasks: Task[] = [];
for (const [blockData, pageName] of result as Array<[Record<string, unknown>, string]>) {
const content = blockData.content as string;
const parsed = parseTaskContent(content);
if (!parsed.marker) continue;
const task: Task = {
uuid: blockData.uuid as string,
content,
marker: parsed.marker,
description: parsed.description,
pageName,
};
if (parsed.priority) {
task.priority = parsed.priority;
}
const deadline = parseLogseqDate(blockData.deadline as number | undefined);
if (deadline) {
task.deadline = deadline;
}
const scheduled = parseLogseqDate(blockData.scheduled as number | undefined);
if (scheduled) {
task.scheduled = scheduled;
}
tasks.push(task);
}
return tasks;
}
/**
* Gets tasks that are past their deadline.
*/
export async function getOverdueTasks(ops: LogseqOperations): Promise<Task[]> {
const today = getTodayIso();
const allTasks = await getTasks(ops, { markers: ["TODO", "DOING"] });
return allTasks
.filter((task) => task.deadline && task.deadline < today)
.sort((a, b) => (a.deadline ?? "").localeCompare(b.deadline ?? ""));
}
/**
* Gets tasks due within a specified number of days.
*/
export async function getTasksDueSoon(
ops: LogseqOperations,
options?: GetTasksDueSoonOptions
): Promise<Task[]> {
const days = options?.days ?? 7;
const markers = options?.markers ?? ["TODO", "DOING"];
const today = getTodayIso();
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + days);
const endDate = `${futureDate.getFullYear()}-${String(futureDate.getMonth() + 1).padStart(2, "0")}-${String(futureDate.getDate()).padStart(2, "0")}`;
const allTasks = await getTasks(ops, { markers });
return allTasks
.filter((task) => task.deadline && task.deadline >= today && task.deadline <= endDate)
.sort((a, b) => (a.deadline ?? "").localeCompare(b.deadline ?? ""));
}
/**
* Gets aggregated statistics about tasks.
*/
export async function getTaskStats(ops: LogseqOperations, pageName?: string): Promise<TaskStats> {
const options: GetTasksOptions = {
markers: ["TODO", "DOING", "DONE", "CANCELED"],
};
if (pageName) {
options.pageName = pageName;
}
const tasks = await getTasks(ops, options);
const today = getTodayIso();
const stats: TaskStats = {
total: tasks.length,
byMarker: { TODO: 0, DOING: 0, DONE: 0, CANCELED: 0 },
byPriority: { A: 0, B: 0, C: 0, none: 0 },
withDeadline: 0,
overdue: 0,
};
for (const task of tasks) {
stats.byMarker[task.marker]++;
stats.byPriority[task.priority ?? "none"]++;
if (task.deadline) {
stats.withDeadline++;
if (task.deadline < today && (task.marker === "TODO" || task.marker === "DOING")) {
stats.overdue++;
}
}
}
return stats;
}