Improve task dependency modelling and selection UI

This commit is contained in:
callumalpass 2025-09-28 15:28:41 +10:00
parent 8c85806b83
commit f8ba9a9920
15 changed files with 528 additions and 234 deletions

View file

@ -1,14 +1,19 @@
import { Menu, Notice, TFile } from "obsidian";
import TaskNotesPlugin from "../main";
import { TaskInfo } from "../types";
import { TaskDependency, TaskInfo } from "../types";
import { formatDateForStorage } from "../utils/dateUtils";
import { ReminderModal } from "../modals/ReminderModal";
import { CalendarExportService } from "../services/CalendarExportService";
import { showConfirmationModal } from "../modals/ConfirmationModal";
import { showTextInputModal } from "../modals/TextInputModal";
import { DateContextMenu } from "./DateContextMenu";
import { RecurrenceContextMenu } from "./RecurrenceContextMenu";
import { parseDependencyInput, resolveDependencyEntry } from "../utils/dependencyUtils";
import { showTextInputModal } from "../modals/TextInputModal";
import { TaskSelectorModal } from "../modals/TaskSelectorModal";
import {
DEFAULT_DEPENDENCY_RELTYPE,
formatDependencyLink,
normalizeDependencyEntry,
} from "../utils/dependencyUtils";
export interface TaskContextMenuOptions {
task: TaskInfo;
@ -573,36 +578,9 @@ export class TaskContextMenu {
menu.addItem((subItem: any) => {
subItem.setTitle(this.t("contextMenus.task.dependencies.addBlockedBy"));
subItem.setIcon("link-2");
subItem.onClick(async () => {
try {
const value = await showTextInputModal(plugin.app, {
title: this.t("contextMenus.task.dependencies.addBlockedByTitle"),
placeholder: this.t("contextMenus.task.dependencies.inputPlaceholder"),
});
if (!value) {
return;
}
const additions = parseDependencyInput(value);
if (additions.length === 0) {
new Notice(this.t("contextMenus.task.dependencies.notices.noEntries"));
return;
}
const existing = [...blockedByEntries];
const combined = this.dedupeDependencyEntries([...existing, ...additions]);
await plugin.updateTaskProperty(task, "blockedBy", combined);
new Notice(
this.t("contextMenus.task.dependencies.notices.blockedByAdded", {
count: additions.length,
})
);
this.options.onUpdate?.();
} catch (error) {
console.error("Failed to add blocked-by dependency:", error);
new Notice(this.t("contextMenus.task.dependencies.notices.updateFailed"));
}
subItem.onClick(() => {
this.menu.hide();
void this.openBlockedBySelector(task, plugin);
});
});
@ -612,19 +590,18 @@ export class TaskContextMenu {
subItem.setTitle(this.t("contextMenus.task.dependencies.removeBlockedBy"));
subItem.setIcon("unlink");
const innerMenu = (subItem as any).setSubmenu();
blockedByEntries.forEach((entry) => {
blockedByEntries.forEach((entry, index) => {
innerMenu.addItem((item: any) => {
item.setTitle(entry);
item.setTitle(entry.uid);
item.onClick(async () => {
try {
const remaining = blockedByEntries.filter(
(existing) => existing !== entry
);
await plugin.updateTaskProperty(
const remaining = blockedByEntries.filter((_, i) => i !== index);
const updatedTask = await plugin.updateTaskProperty(
task,
"blockedBy",
remaining.length > 0 ? remaining : undefined
);
Object.assign(task, updatedTask);
new Notice(
this.t(
"contextMenus.task.dependencies.notices.blockedByRemoved"
@ -648,50 +625,9 @@ export class TaskContextMenu {
menu.addItem((subItem: any) => {
subItem.setTitle(this.t("contextMenus.task.dependencies.addBlocking"));
subItem.setIcon("git-branch-plus");
subItem.onClick(async () => {
try {
const value = await showTextInputModal(plugin.app, {
title: this.t("contextMenus.task.dependencies.addBlockingTitle"),
placeholder: this.t("contextMenus.task.dependencies.inputPlaceholder"),
});
if (!value) {
return;
}
const entries = parseDependencyInput(value);
if (entries.length === 0) {
new Notice(this.t("contextMenus.task.dependencies.notices.noEntries"));
return;
}
const { added, raw, unresolved } = this.resolveBlockingEntries(
task,
entries,
plugin
);
if (added.length > 0) {
await plugin.taskService.updateBlockingRelationships(task, added, [], raw);
new Notice(
this.t("contextMenus.task.dependencies.notices.blockingAdded", {
count: added.length,
})
);
this.options.onUpdate?.();
}
if (unresolved.length > 0) {
new Notice(
this.t("contextMenus.task.dependencies.notices.unresolved", {
entries: unresolved.join(", "),
})
);
}
} catch (error) {
console.error("Failed to add blocking dependency:", error);
new Notice(this.t("contextMenus.task.dependencies.notices.updateFailed"));
}
subItem.onClick(() => {
this.menu.hide();
void this.openBlockingSelector(task, plugin);
});
});
@ -717,6 +653,10 @@ export class TaskContextMenu {
[path],
{}
);
const refreshed = await plugin.cacheManager.getTaskInfo(task.path);
if (refreshed) {
Object.assign(task, refreshed);
}
new Notice(
this.t("contextMenus.task.dependencies.notices.blockingRemoved")
);
@ -734,42 +674,156 @@ export class TaskContextMenu {
}
}
private dedupeDependencyEntries(entries: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
private dedupeDependencyEntries(entries: Array<TaskDependency | string>): TaskDependency[] {
const seen = new Map<string, TaskDependency>();
for (const entry of entries) {
const key = entry.trim();
if (!seen.has(key)) {
seen.add(key);
result.push(entry);
}
}
return result;
}
private resolveBlockingEntries(
task: TaskInfo,
entries: string[],
plugin: TaskNotesPlugin
): { added: string[]; raw: Record<string, string>; unresolved: string[] } {
const added: string[] = [];
const raw: Record<string, string> = {};
const unresolved: string[] = [];
for (const entry of entries) {
const resolved = resolveDependencyEntry(plugin.app, task.path, entry);
if (!resolved) {
unresolved.push(entry);
const normalized = normalizeDependencyEntry(entry);
if (!normalized) {
continue;
}
if (!added.includes(resolved.path)) {
added.push(resolved.path);
raw[resolved.path] = entry;
const key = this.getDependencyKey(normalized);
if (!seen.has(key)) {
seen.set(key, normalized);
}
}
return Array.from(seen.values());
}
return { added, raw, unresolved };
private async openBlockedBySelector(task: TaskInfo, plugin: TaskNotesPlugin): Promise<void> {
const existingUids = new Set(
(Array.isArray(task.blockedBy) ? task.blockedBy : []).map((dependency) => dependency.uid)
);
await this.openTaskDependencySelector(
plugin,
(candidate) => {
if (candidate.path === task.path) return false;
const candidateUid = formatDependencyLink(plugin.app, task.path, candidate.path);
return !existingUids.has(candidateUid);
},
async (selected) => {
await this.handleBlockedBySelection(task, plugin, selected);
}
);
}
private async openBlockingSelector(task: TaskInfo, plugin: TaskNotesPlugin): Promise<void> {
const existingPaths = new Set(task.blocking ?? []);
await this.openTaskDependencySelector(
plugin,
(candidate) => {
if (candidate.path === task.path) return false;
return !existingPaths.has(candidate.path);
},
async (selected) => {
await this.handleBlockingSelection(task, plugin, selected);
}
);
}
private async openTaskDependencySelector(
plugin: TaskNotesPlugin,
filter: (candidate: TaskInfo) => boolean,
onSelect: (selected: TaskInfo) => Promise<void>
): Promise<void> {
try {
const cacheManager: any = plugin.cacheManager;
const allTasks: TaskInfo[] = (await cacheManager?.getAllTasks?.()) ?? [];
const candidates = allTasks.filter(filter);
if (candidates.length === 0) {
new Notice(
this.t("contextMenus.task.dependencies.notices.noEligibleTasks")
);
return;
}
const selector = new TaskSelectorModal(plugin.app, plugin, candidates, async (task) => {
if (!task) return;
await onSelect(task);
});
selector.open();
} catch (error) {
console.error("Failed to open task selector for dependencies:", error);
new Notice(this.t("contextMenus.task.dependencies.notices.updateFailed"));
}
}
private async handleBlockedBySelection(
task: TaskInfo,
plugin: TaskNotesPlugin,
selectedTask: TaskInfo
): Promise<void> {
if (selectedTask.path === task.path) {
return;
}
try {
const dependency: TaskDependency = {
uid: formatDependencyLink(plugin.app, task.path, selectedTask.path),
reltype: DEFAULT_DEPENDENCY_RELTYPE,
};
const existing = Array.isArray(task.blockedBy) ? task.blockedBy : [];
const combined = this.dedupeDependencyEntries([...existing, dependency]);
if (combined.length === existing.length) {
return;
}
const updatedTask = await plugin.updateTaskProperty(task, "blockedBy", combined);
Object.assign(task, updatedTask);
new Notice(
this.t("contextMenus.task.dependencies.notices.blockedByAdded", { count: 1 })
);
this.options.onUpdate?.();
} catch (error) {
console.error("Failed to add blocked-by dependency via selector:", error);
new Notice(this.t("contextMenus.task.dependencies.notices.updateFailed"));
}
}
private async handleBlockingSelection(
task: TaskInfo,
plugin: TaskNotesPlugin,
selectedTask: TaskInfo
): Promise<void> {
const blockedPath = selectedTask.path;
if (blockedPath === task.path) {
return;
}
if (task.blocking?.includes(blockedPath)) {
return;
}
try {
const rawEntry: TaskDependency = {
uid: formatDependencyLink(plugin.app, blockedPath, task.path),
reltype: DEFAULT_DEPENDENCY_RELTYPE,
};
await plugin.taskService.updateBlockingRelationships(task, [blockedPath], [], {
[blockedPath]: rawEntry,
});
const refreshed = await plugin.cacheManager.getTaskInfo(task.path);
if (refreshed) {
Object.assign(task, refreshed);
} else if (Array.isArray(task.blocking)) {
task.blocking = Array.from(new Set([...task.blocking, blockedPath]));
} else {
task.blocking = [blockedPath];
}
new Notice(
this.t("contextMenus.task.dependencies.notices.blockingAdded", { count: 1 })
);
this.options.onUpdate?.();
} catch (error) {
console.error("Failed to add blocking dependency via selector:", error);
new Notice(this.t("contextMenus.task.dependencies.notices.updateFailed"));
}
}
private getDependencyKey(entry: TaskDependency): string {
return `${entry.uid}::${entry.reltype}::${entry.gap ?? ""}`;
}
private updateMainMenuIconColors(task: TaskInfo, plugin: TaskNotesPlugin): void {

View file

@ -1763,16 +1763,17 @@ export const en: TranslationTree = {
removeBlockedBy: "Remove blocked-by…",
removeBlocking: "Remove blocking…",
inputPlaceholder: "[[Task Note]]",
notices: {
noEntries: "Please enter at least one task",
blockedByAdded: "{count} dependency added",
blockedByRemoved: "Dependency removed",
blockingAdded: "{count} dependent task added",
blockingRemoved: "Dependent task removed",
unresolved: "Could not resolve: {entries}",
updateFailed: "Failed to update dependencies",
notices: {
noEntries: "Please enter at least one task",
blockedByAdded: "{count} dependency added",
blockedByRemoved: "Dependency removed",
blockingAdded: "{count} dependent task added",
blockingRemoved: "Dependent task removed",
unresolved: "Could not resolve: {entries}",
noEligibleTasks: "No matching tasks available",
updateFailed: "Failed to update dependencies",
},
},
},
subtasks: {
loading: "Loading subtasks...",
noSubtasks: "No subtasks found",

View file

@ -1809,6 +1809,7 @@ export const fr: TranslationTree = {
blockingAdded: "{count} tâche dépendante ajoutée",
blockingRemoved: "Tâche dépendante retirée",
unresolved: "Impossible de résoudre : {entries}",
noEligibleTasks: "Aucune tâche correspondante disponible",
updateFailed: "Impossible de mettre à jour les dépendances",
},
},

View file

@ -1791,6 +1791,7 @@ export const ru: TranslationTree = {
blockingAdded: "Добавлено {count} зависимых задач",
blockingRemoved: "Зависимая задача удалена",
unresolved: "Не удалось определить: {entries}",
noEligibleTasks: "Нет доступных подходящих задач",
updateFailed: "Не удалось обновить зависимости",
},
},

View file

@ -8,7 +8,7 @@ import {
} from "obsidian";
import TaskNotesPlugin from "../main";
import { TaskModal } from "./TaskModal";
import { TaskInfo, TaskCreationData } from "../types";
import { TaskInfo, TaskCreationData, TaskDependency } from "../types";
import { getCurrentTimestamp } from "../utils/dateUtils";
import { generateTaskFilename, FilenameContext } from "../utils/filenameGenerator";
import { calculateDefaultDate, sanitizeTags } from "../utils/helpers";
@ -998,17 +998,17 @@ export class TaskCreationModal extends TaskModal {
if (this.blockingItems.length > 0) {
const addedPaths: string[] = [];
const rawMap: Record<string, string> = {};
const rawMap: Record<string, TaskDependency> = {};
const unresolved: string[] = [];
this.blockingItems.forEach((item) => {
if (item.path) {
if (!addedPaths.includes(item.path)) {
addedPaths.push(item.path);
rawMap[item.path] = item.raw;
rawMap[item.path] = { ...item.dependency };
}
} else {
unresolved.push(item.raw);
unresolved.push(item.dependency.uid);
}
});
@ -1087,9 +1087,11 @@ export class TaskCreationModal extends TaskModal {
customFrontmatter: this.buildCustomFrontmatter(),
};
const blockedEntries = this.blockedByItems.map((item) => item.raw);
if (blockedEntries.length > 0) {
taskData.blockedBy = blockedEntries;
const blockedDependencies = this.blockedByItems.map((item) => ({
...item.dependency,
}));
if (blockedDependencies.length > 0) {
taskData.blockedBy = blockedDependencies;
}
// Add details if provided

View file

@ -2,7 +2,7 @@
import { App, Notice, TFile, setIcon, setTooltip } from "obsidian";
import TaskNotesPlugin from "../main";
import { TaskModal } from "./TaskModal";
import { TaskInfo } from "../types";
import { TaskDependency, TaskInfo } from "../types";
import {
getCurrentTimestamp,
formatDateForStorage,
@ -38,12 +38,12 @@ export class TaskEditModal extends TaskModal {
private metadataContainer: HTMLElement;
private completedInstancesChanges: Set<string> = new Set();
private calendarWrapper: HTMLElement | null = null;
private initialBlockedBy: string[] = [];
private initialBlockedBy: TaskDependency[] = [];
private initialBlockingPaths: string[] = [];
private pendingBlockingUpdates: {
added: string[];
removed: string[];
raw: Record<string, string>;
raw: Record<string, TaskDependency>;
} = { added: [], removed: [], raw: {} };
private unresolvedBlockingEntries: string[] = [];
@ -116,10 +116,10 @@ export class TaskEditModal extends TaskModal {
this.details = this.normalizeDetails(this.details);
this.originalDetails = this.details;
this.blockedByItems = (this.task.blockedBy ?? []).map((raw) =>
this.createDependencyItemFromRaw(raw, this.task.path)
this.blockedByItems = (this.task.blockedBy ?? []).map((dependency) =>
this.createDependencyItemFromDependency(dependency, this.task.path)
);
this.initialBlockedBy = this.blockedByItems.map((item) => item.raw);
this.initialBlockedBy = this.blockedByItems.map((item) => ({ ...item.dependency }));
this.blockingItems = (this.task.blocking ?? []).map((path) =>
this.createDependencyItemFromPath(path)
@ -164,17 +164,25 @@ export class TaskEditModal extends TaskModal {
}
}
private arraysEqual(a: string[], b: string[]): boolean {
private dependenciesEqual(a: TaskDependency[], b: TaskDependency[]): boolean {
if (a.length !== b.length) {
return false;
}
const normalize = (arr: string[]) => arr.map((item) => item.trim()).sort();
const normalizedA = normalize(a);
const normalizedB = normalize(b);
const sortDependencies = (deps: TaskDependency[]) =>
[...deps].sort((left, right) => left.uid.localeCompare(right.uid));
for (let i = 0; i < normalizedA.length; i++) {
if (normalizedA[i] !== normalizedB[i]) {
const sortedA = sortDependencies(a);
const sortedB = sortDependencies(b);
for (let i = 0; i < sortedA.length; i++) {
const depA = sortedA[i];
const depB = sortedB[i];
if (
depA.uid !== depB.uid ||
depA.reltype !== depB.reltype ||
(depA.gap || "") !== (depB.gap || "")
) {
return false;
}
}
@ -677,19 +685,22 @@ export class TaskEditModal extends TaskModal {
changes.reminders = newReminders.length > 0 ? newReminders : undefined;
}
const newBlockedEntries = this.blockedByItems.map((item) => item.raw);
if (!this.arraysEqual(newBlockedEntries, this.initialBlockedBy)) {
changes.blockedBy = newBlockedEntries.length > 0 ? newBlockedEntries : undefined;
const newBlockedDependencies = this.blockedByItems.map((item) => ({
...item.dependency,
}));
if (!this.dependenciesEqual(newBlockedDependencies, this.initialBlockedBy)) {
changes.blockedBy =
newBlockedDependencies.length > 0 ? newBlockedDependencies : undefined;
}
const resolvedBlocking = new Map<string, string>();
const resolvedBlocking = new Map<string, TaskDependency>();
const unresolvedEntries: string[] = [];
this.blockingItems.forEach((item) => {
if (item.path) {
resolvedBlocking.set(item.path, item.raw);
resolvedBlocking.set(item.path, { ...item.dependency });
} else {
unresolvedEntries.push(item.raw);
unresolvedEntries.push(item.dependency.uid);
}
});
@ -700,11 +711,11 @@ export class TaskEditModal extends TaskModal {
const addedPaths = newBlockingPaths.filter((path) => !originalPaths.has(path));
const removedPaths = this.initialBlockingPaths.filter((path) => !newPathSet.has(path));
const rawAdditions: Record<string, string> = {};
const rawAdditions: Record<string, TaskDependency> = {};
for (const path of addedPaths) {
const raw = resolvedBlocking.get(path);
if (raw) {
rawAdditions[path] = raw;
rawAdditions[path] = { ...raw };
}
}

View file

@ -1,6 +1,7 @@
import {
App,
Modal,
Notice,
Setting,
setIcon,
TAbstractFile,
@ -17,12 +18,17 @@ import { ReminderContextMenu } from "../components/ReminderContextMenu";
import { getDatePart, getTimePart, combineDateAndTime } from "../utils/dateUtils";
import { sanitizeTags, splitFrontmatterAndBody } from "../utils/helpers";
import { ProjectSelectModal } from "./ProjectSelectModal";
import { TaskInfo, Reminder } from "../types";
import { formatDependencyLink, resolveDependencyEntry } from "../utils/dependencyUtils";
import { TaskDependency, TaskInfo, Reminder } from "../types";
import {
DEFAULT_DEPENDENCY_RELTYPE,
formatDependencyLink,
resolveDependencyEntry,
} from "../utils/dependencyUtils";
import { appendInternalLink, type LinkServices } from "../ui/renderers/linkRenderer";
import { TaskSelectorModal } from "./TaskSelectorModal";
interface DependencyItem {
raw: string;
dependency: TaskDependency;
name: string;
path?: string;
unresolved?: boolean;
@ -37,33 +43,39 @@ export abstract class TaskModal extends Modal {
options: { sourcePath?: string } = {}
): DependencyItem {
const sourcePath = options.sourcePath ?? this.getDependencySourcePath();
const raw = formatDependencyLink(this.plugin.app, sourcePath, file.path);
const uid = formatDependencyLink(this.plugin.app, sourcePath, file.path);
return {
raw,
dependency: { uid, reltype: DEFAULT_DEPENDENCY_RELTYPE },
path: file.path,
name: file.basename,
};
}
protected createDependencyItemFromRaw(raw: string, sourcePath?: string): DependencyItem {
protected createDependencyItemFromDependency(
dependency: TaskDependency,
sourcePath?: string
): DependencyItem {
const resolution = resolveDependencyEntry(
this.plugin.app,
sourcePath ?? this.getDependencySourcePath(),
raw
dependency
);
if (resolution) {
const name = resolution.file?.basename || resolution.path.split("/").pop() || raw;
const name =
resolution.file?.basename ||
resolution.path.split("/").pop() ||
dependency.uid;
return {
raw,
dependency,
path: resolution.path,
name,
};
}
const cleaned = raw.replace(/^\[\[/, "").replace(/\]\]$/, "");
const cleaned = dependency.uid.replace(/^\[\[/, "").replace(/\]\]$/, "");
return {
raw,
name: cleaned || raw,
dependency,
name: cleaned || dependency.uid,
unresolved: true,
};
}
@ -73,16 +85,21 @@ export abstract class TaskModal extends Modal {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
return {
raw: formatDependencyLink(this.plugin.app, sourcePath, file.path),
dependency: {
uid: formatDependencyLink(this.plugin.app, sourcePath, file.path),
reltype: DEFAULT_DEPENDENCY_RELTYPE,
},
path: file.path,
name: file.basename,
};
}
const basename = path.split("/").pop() || path;
const raw = `[[${basename.replace(/\.md$/i, "")}]]`;
return {
raw,
dependency: {
uid: `[[${basename.replace(/\.md$/i, "")}]]`,
reltype: DEFAULT_DEPENDENCY_RELTYPE,
},
path,
name: basename.replace(/\.md$/i, ""),
unresolved: true,
@ -148,7 +165,7 @@ export abstract class TaskModal extends Modal {
setTooltip(
itemEl,
this.t("contextMenus.task.dependencies.notices.unresolved", {
entries: item.raw,
entries: item.dependency.uid,
}),
{ placement: "top" }
);
@ -175,7 +192,7 @@ export abstract class TaskModal extends Modal {
}
} else {
nameEl.textContent = item.name;
const pathText = item.path ?? item.raw;
const pathText = item.path ?? item.dependency.uid;
infoEl.createDiv({ cls: "task-project-path", text: pathText });
}
@ -204,13 +221,24 @@ export abstract class TaskModal extends Modal {
}
protected addBlockedByTask(file: TFile): void {
const currentPath = this.getCurrentTaskPath();
if (currentPath && file.path === currentPath) {
return;
}
const item = this.createDependencyItemFromFile(file);
const dependency: TaskDependency = {
uid: formatDependencyLink(this.plugin.app, this.getDependencySourcePath(), file.path),
reltype: DEFAULT_DEPENDENCY_RELTYPE,
};
this.addBlockedByDependency(dependency);
}
protected addBlockingTask(file: TFile): void {
this.addBlockingTaskFromPath(file.path);
}
protected addBlockedByDependency(dependency: TaskDependency): void {
const sourcePath = this.getDependencySourcePath();
const item = this.createDependencyItemFromDependency(dependency, sourcePath);
const exists = this.blockedByItems.some(
(existing) => existing.path === item.path || existing.raw === item.raw
(existing) =>
existing.dependency.uid === item.dependency.uid ||
(item.path && existing.path === item.path)
);
if (exists) {
return;
@ -219,13 +247,15 @@ export abstract class TaskModal extends Modal {
this.renderBlockedByList();
}
protected addBlockingTask(file: TFile): void {
protected addBlockingTaskFromPath(path: string): void {
const currentPath = this.getCurrentTaskPath();
if (currentPath && file.path === currentPath) {
if (currentPath && path === currentPath) {
return;
}
const item = this.createDependencyItemFromFile(file);
const exists = this.blockingItems.some((existing) => existing.path === item.path);
const item = this.createDependencyItemFromPath(path);
const exists = this.blockingItems.some(
(existing) => existing.path === item.path || existing.dependency.uid === item.dependency.uid
);
if (exists) {
return;
}
@ -233,6 +263,100 @@ export abstract class TaskModal extends Modal {
this.renderBlockingList();
}
protected async openBlockedBySelector(): Promise<void> {
const sourcePath = this.getDependencySourcePath();
const currentPath = this.getCurrentTaskPath();
const existingUids = new Set(
this.blockedByItems.map((item) => item.dependency.uid)
);
await this.openTaskDependencySelector(
(candidate) => {
if (currentPath && candidate.path === currentPath) {
return false;
}
const candidateUid = formatDependencyLink(
this.plugin.app,
sourcePath,
candidate.path
);
return !existingUids.has(candidateUid);
},
(selected) => {
const dependency: TaskDependency = {
uid: formatDependencyLink(this.plugin.app, sourcePath, selected.path),
reltype: DEFAULT_DEPENDENCY_RELTYPE,
};
this.addBlockedByDependency(dependency);
}
);
}
protected async openBlockingSelector(): Promise<void> {
const sourcePath = this.getDependencySourcePath();
const currentPath = this.getCurrentTaskPath();
const existingPaths = new Set(
this.blockingItems
.map((item) => item.path)
.filter((path): path is string => typeof path === "string")
);
const existingUids = new Set(
this.blockingItems.map((item) => item.dependency.uid)
);
await this.openTaskDependencySelector(
(candidate) => {
if (currentPath && candidate.path === currentPath) {
return false;
}
if (existingPaths.has(candidate.path)) {
return false;
}
const candidateUid = formatDependencyLink(
this.plugin.app,
sourcePath,
candidate.path
);
return !existingUids.has(candidateUid);
},
(selected) => {
this.addBlockingTaskFromPath(selected.path);
}
);
}
private async openTaskDependencySelector(
filter: (candidate: TaskInfo) => boolean,
onSelect: (selected: TaskInfo) => void
): Promise<void> {
try {
const allTasks: TaskInfo[] =
(await this.plugin.cacheManager.getAllTasks?.()) ?? [];
const candidates = allTasks.filter(filter);
if (candidates.length === 0) {
new Notice(
this.t("contextMenus.task.dependencies.notices.noEligibleTasks")
);
return;
}
const modal = new TaskSelectorModal(
this.app,
this.plugin,
candidates,
(task) => {
if (!task) return;
onSelect(task);
}
);
modal.open();
} catch (error) {
console.error("Failed to open task selector for dependencies:", error);
new Notice(this.t("contextMenus.task.dependencies.notices.updateFailed"));
}
}
// Core task properties
protected title = "";
protected details = "";
@ -576,12 +700,7 @@ export abstract class TaskModal extends Modal {
.setButtonText(this.t("modals.task.dependencies.addTaskButton"))
.setTooltip(this.t("modals.task.dependencies.selectTaskTooltip"))
.onClick(() => {
const modal = new ProjectSelectModal(this.app, this.plugin, (file) => {
if (file instanceof TFile) {
this.addBlockedByTask(file);
}
});
modal.open();
void this.openBlockedBySelector();
});
button.buttonEl.addClasses(["tn-btn", "tn-btn--ghost"]);
});
@ -595,12 +714,7 @@ export abstract class TaskModal extends Modal {
.setButtonText(this.t("modals.task.dependencies.addTaskButton"))
.setTooltip(this.t("modals.task.dependencies.selectTaskTooltip"))
.onClick(() => {
const modal = new ProjectSelectModal(this.app, this.plugin, (file) => {
if (file instanceof TFile) {
this.addBlockingTask(file);
}
});
modal.open();
void this.openBlockingSelector();
});
button.buttonEl.addClasses(["tn-btn", "tn-btn--ghost"]);
});

View file

@ -1,4 +1,9 @@
import { FieldMapping, TaskInfo } from "../types";
import {
normalizeDependencyEntry,
normalizeDependencyList,
serializeDependencies,
} from "../utils/dependencyUtils";
import { validateCompleteInstances } from "../utils/dateUtils";
/**
@ -124,11 +129,9 @@ export class FieldMapper {
}
if (this.mapping.blockedBy && frontmatter[this.mapping.blockedBy] !== undefined) {
const blocked = frontmatter[this.mapping.blockedBy];
if (Array.isArray(blocked)) {
mapped.blockedBy = blocked.filter((item) => typeof item === "string");
} else if (typeof blocked === "string" && blocked.trim().length > 0) {
mapped.blockedBy = [blocked];
const dependencies = normalizeDependencyList(frontmatter[this.mapping.blockedBy]);
if (dependencies) {
mapped.blockedBy = dependencies;
}
}
@ -244,7 +247,14 @@ export class FieldMapper {
}
if (taskData.blockedBy !== undefined) {
frontmatter[this.mapping.blockedBy] = taskData.blockedBy;
if (Array.isArray(taskData.blockedBy)) {
const normalized = taskData.blockedBy
.map((item) => normalizeDependencyEntry(item))
.filter((item): item is NonNullable<ReturnType<typeof normalizeDependencyEntry>> => !!item);
frontmatter[this.mapping.blockedBy] = normalized.length > 0 ? serializeDependencies(normalized) : [];
} else {
frontmatter[this.mapping.blockedBy] = taskData.blockedBy;
}
}
if (taskData.icsEventId !== undefined && taskData.icsEventId.length > 0) {

View file

@ -2,6 +2,7 @@ import {
EVENT_TASK_DELETED,
EVENT_TASK_UPDATED,
TaskCreationData,
TaskDependency,
TaskInfo,
TimeEntry,
IWebhookNotifier,
@ -24,7 +25,12 @@ import {
updateToNextScheduledOccurrence,
splitFrontmatterAndBody,
} from "../utils/helpers";
import { formatDependencyLink, resolveDependencyEntry } from "../utils/dependencyUtils";
import {
DEFAULT_DEPENDENCY_RELTYPE,
formatDependencyLink,
normalizeDependencyEntry,
resolveDependencyEntry,
} from "../utils/dependencyUtils";
import {
formatDateForStorage,
getCurrentDateString,
@ -1519,7 +1525,7 @@ export class TaskService {
currentTask: TaskInfo,
addedBlockedTaskPaths: string[],
removedBlockedTaskPaths: string[],
rawEntries: Record<string, string> = {}
rawEntries: Record<string, TaskDependency | string> = {}
): Promise<void> {
// This method is called when the current task's "blocking" list is updated in the UI.
// The current task is the one blocking other tasks.
@ -1562,7 +1568,8 @@ export class TaskService {
const updatedBlockedBy = this.computeBlockedByUpdate(
blockedTask,
currentTask.path,
"add"
"add",
rawEntries[blockedTaskPath]
);
if (updatedBlockedBy === null) {
continue;
@ -1576,16 +1583,21 @@ export class TaskService {
blockedTask: TaskInfo,
blockingTaskPath: string,
action: "add" | "remove",
rawEntry?: string
): string[] | null {
const existing = Array.isArray(blockedTask.blockedBy) ? [...blockedTask.blockedBy] : [];
rawEntry?: TaskDependency | string
): TaskDependency[] | null {
const existing = Array.isArray(blockedTask.blockedBy)
? blockedTask.blockedBy
.map((entry) => normalizeDependencyEntry(entry))
.filter((entry): entry is TaskDependency => !!entry)
: [];
if (existing.length === 0 && action === "remove") {
return null;
}
let modified = false;
let hasExistingEntry = false;
const result: string[] = [];
const result: TaskDependency[] = [];
for (const entry of existing) {
const resolved = resolveDependencyEntry(this.plugin.app, blockedTask.path, entry);
@ -1599,16 +1611,18 @@ export class TaskService {
result.push(entry);
}
if (action === "add") {
if (!hasExistingEntry) {
const trimmed = rawEntry?.trim();
const entryToAdd =
trimmed && trimmed.length > 0
? trimmed
: formatDependencyLink(this.plugin.app, blockedTask.path, blockingTaskPath);
result.push(entryToAdd);
modified = true;
if (action === "add" && !hasExistingEntry) {
const normalizedIncoming = rawEntry ? normalizeDependencyEntry(rawEntry) : null;
const uid = formatDependencyLink(this.plugin.app, blockedTask.path, blockingTaskPath);
const dependency: TaskDependency = {
uid,
reltype: normalizedIncoming?.reltype ?? DEFAULT_DEPENDENCY_RELTYPE,
};
if (normalizedIncoming?.gap) {
dependency.gap = normalizedIncoming.gap;
}
result.push(dependency);
modified = true;
}
if (!modified) {

View file

@ -445,6 +445,18 @@ export interface RecurrenceInfo {
month_of_year?: number; // For yearly recurrence: 1-12
}
export type TaskDependencyRelType =
| "FINISHTOSTART"
| "FINISHTOFINISH"
| "STARTTOSTART"
| "STARTTOFINISH";
export interface TaskDependency {
uid: string; // Identifier for the blocking task (typically an Obsidian link)
reltype: TaskDependencyRelType; // Relationship type per RFC 9253 terminology
gap?: string; // Optional ISO 8601 duration offset between tasks
}
export interface TaskInfo {
id?: string; // Task identifier (typically same as path for API consistency)
title: string;
@ -469,7 +481,7 @@ export interface TaskInfo {
reminders?: Reminder[]; // Task reminders
customProperties?: Record<string, any>; // Custom properties from Bases or other sources
basesData?: any; // Raw Bases data for formula computation (internal use)
blockedBy?: string[]; // Raw dependency entries referencing blocking tasks
blockedBy?: TaskDependency[]; // Dependencies that must be satisfied before this task can start
blocking?: string[]; // Task paths that this task is blocking
isBlocked?: boolean; // True if any blocking dependency is incomplete
isBlocking?: boolean; // True if this task blocks at least one other task

View file

@ -1178,7 +1178,7 @@ export function createTaskCard(
chevronPlaceholder.remove();
}
// Main content container
// Blocking toggle sits left of the main content row
const blockingToggle = mainRow.createEl("div", { cls: "task-card__blocking-toggle" });
if (task.blocking && task.blocking.length > 0) {
blockingToggle.classList.add("is-visible");

View file

@ -347,7 +347,7 @@ export class FilterUtils {
case "projects":
return task.projects || [];
case "blockedBy":
return task.blockedBy || [];
return task.blockedBy?.map((dependency) => dependency.uid) || [];
case "blocking":
return task.blocking || [];
case "due":

View file

@ -1,6 +1,6 @@
/* eslint-disable no-console */
import { TFile, App, Events, EventRef, parseLinktext } from "obsidian";
import { TaskInfo, NoteInfo } from "../types";
import { TaskInfo, NoteInfo, TaskDependency } from "../types";
import { FieldMapper } from "../services/FieldMapper";
import {
getTodayString,
@ -10,7 +10,7 @@ import {
formatDateForStorage,
} from "./dateUtils";
import { filterEmptyProjects, calculateTotalTimeSpent } from "./helpers";
import { resolveDependencyEntry } from "./dependencyUtils";
import { normalizeDependencyList, resolveDependencyEntry } from "./dependencyUtils";
import { TaskNotesSettings } from "../types/settings";
/**
@ -1221,7 +1221,7 @@ export class MinimalNativeCache extends Events {
private updateDependencyIndexes(
taskPath: string,
blockedByEntries: string[]
blockedByEntries: TaskDependency[]
): { resolvedBlockers: string[]; blocking: string[] } {
const previousBlockers = this.dependencySources.get(taskPath);
if (previousBlockers) {
@ -1646,11 +1646,7 @@ export class MinimalNativeCache extends Events {
this.storeTitleInFilename
);
const blockedByEntries = Array.isArray(mappedTask.blockedBy)
? (mappedTask.blockedBy.filter(
(entry: unknown) => typeof entry === "string"
) as string[])
: [];
const blockedByEntries = normalizeDependencyList(mappedTask.blockedBy) ?? [];
const { resolvedBlockers, blocking } = this.updateDependencyIndexes(
path,
blockedByEntries

View file

@ -1,6 +1,78 @@
import { App, TFile } from "obsidian";
import type { TaskDependency, TaskDependencyRelType } from "../types";
import { splitListPreservingLinksAndQuotes } from "./stringSplit";
export const DEFAULT_DEPENDENCY_RELTYPE: TaskDependencyRelType = "FINISHTOSTART";
const VALID_RELATIONSHIP_TYPES: TaskDependencyRelType[] = [
"FINISHTOSTART",
"FINISHTOFINISH",
"STARTTOSTART",
"STARTTOFINISH",
];
export function isValidDependencyRelType(value: string): value is TaskDependencyRelType {
return VALID_RELATIONSHIP_TYPES.includes(value as TaskDependencyRelType);
}
export function extractDependencyUid(entry: TaskDependency | string): string {
return typeof entry === "string" ? entry : entry.uid;
}
export function normalizeDependencyEntry(value: unknown): TaskDependency | null {
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return null;
return { uid: trimmed, reltype: DEFAULT_DEPENDENCY_RELTYPE };
}
if (typeof value === "object" && value !== null) {
const raw = value as Record<string, unknown>;
const rawUid = typeof raw.uid === "string" ? raw.uid.trim() : "";
if (!rawUid) {
return null;
}
const reltypeRaw = typeof raw.reltype === "string" ? raw.reltype.trim().toUpperCase() : "";
const reltype = isValidDependencyRelType(reltypeRaw)
? (reltypeRaw as TaskDependencyRelType)
: DEFAULT_DEPENDENCY_RELTYPE;
const gap = typeof raw.gap === "string" && raw.gap.trim().length > 0 ? raw.gap.trim() : undefined;
return gap ? { uid: rawUid, reltype, gap } : { uid: rawUid, reltype };
}
return null;
}
export function normalizeDependencyList(value: unknown): TaskDependency[] | undefined {
if (value === null || value === undefined) {
return undefined;
}
const arrayValue = Array.isArray(value) ? value : [value];
const normalized: TaskDependency[] = [];
for (const entry of arrayValue) {
const normalizedEntry = normalizeDependencyEntry(entry);
if (normalizedEntry) {
normalized.push(normalizedEntry);
}
}
return normalized.length > 0 ? normalized : undefined;
}
export function serializeDependencies(dependencies: TaskDependency[]): any[] {
return dependencies.map((dependency) => {
const serialized: Record<string, string> = {
uid: dependency.uid,
reltype: dependency.reltype,
};
if (dependency.gap && dependency.gap.trim().length > 0) {
serialized.gap = dependency.gap;
}
return serialized;
});
}
export interface DependencyResolution {
path: string;
file: TFile | null;
@ -43,13 +115,14 @@ export function parseDependencyInput(value: string): string[] {
export function resolveDependencyEntry(
app: App,
sourcePath: string,
entry: string
entry: TaskDependency | string
): DependencyResolution | null {
if (!entry) {
const rawUid = extractDependencyUid(entry);
if (!rawUid) {
return null;
}
const trimmed = entry.trim();
const trimmed = rawUid.trim();
if (!trimmed) {
return null;
}

View file

@ -259,7 +259,7 @@
.tasknotes-plugin .task-card__recurring-indicator {
position: absolute;
top: 8px;
right: 26px;
right: 20px;
width: 16px;
height: 16px;
color: var(--tn-text-muted);
@ -294,7 +294,7 @@
position: absolute;
top: 8px;
width: 16px;
right: 44px;
right: 40px;
height: 16px;
color: var(--tn-text-muted);
opacity: 0.8;
@ -324,7 +324,7 @@
.tasknotes-plugin .task-card__project-indicator {
position: absolute;
top: 8px;
right: 62px;
right: 60px;
width: 16px;
height: 16px;
color: var(--tn-text-muted);
@ -353,7 +353,7 @@
.tasknotes-plugin .task-card__chevron {
position: absolute;
top: 8px;
right: 80px; /* Position after project indicator */
right: 100px; /* Position after blocking toggle */
width: 16px;
height: 16px;
color: var(--tn-text-muted);
@ -401,20 +401,24 @@
.tasknotes-plugin .task-card__blocking-toggle {
position: absolute;
top: 8px;
right: 56px;
width: 18px;
height: 18px;
right: 80px;
width: 16px;
height: 16px;
color: var(--tn-text-muted);
opacity: 0;
opacity: 0.8;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color var(--tn-transition-fast), opacity var(--tn-transition-fast);
transition: color var(--tn-transition-fast), background var(--tn-transition-fast), opacity var(--tn-transition-fast);
border-radius: var(--radius-s);
padding: 2px;
}
.tasknotes-plugin .task-card:hover .task-card__blocking-toggle.is-visible {
.tasknotes-plugin .task-card__blocking-toggle:hover {
opacity: 1;
color: var(--interactive-accent);
background: var(--background-modifier-hover);
}
.tasknotes-plugin .task-card__blocking-toggle svg {
@ -424,6 +428,7 @@
.tasknotes-plugin .task-card__blocking-toggle--expanded {
color: var(--interactive-accent);
opacity: 1;
}
.tasknotes-plugin .task-card__blocking-toggle.is-hidden {