From f8ba9a9920518584248f287c37a98c055ba5c22a Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 28 Sep 2025 15:28:41 +1000 Subject: [PATCH] Improve task dependency modelling and selection UI --- src/components/TaskContextMenu.ts | 280 ++++++++++++++++++------------ src/i18n/resources/en.ts | 19 +- src/i18n/resources/fr.ts | 1 + src/i18n/resources/ru.ts | 1 + src/modals/TaskCreationModal.ts | 16 +- src/modals/TaskEditModal.ts | 51 +++--- src/modals/TaskModal.ts | 192 +++++++++++++++----- src/services/FieldMapper.ts | 22 ++- src/services/TaskService.ts | 46 +++-- src/types.ts | 14 +- src/ui/TaskCard.ts | 2 +- src/utils/FilterUtils.ts | 2 +- src/utils/MinimalNativeCache.ts | 12 +- src/utils/dependencyUtils.ts | 79 ++++++++- styles/task-card-bem.css | 25 +-- 15 files changed, 528 insertions(+), 234 deletions(-) diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 2a145198..f0b62b9c 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -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(); - const result: string[] = []; + private dedupeDependencyEntries(entries: Array): TaskDependency[] { + const seen = new Map(); 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; unresolved: string[] } { - const added: string[] = []; - const raw: Record = {}; - 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 { + 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 { + 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 + ): Promise { + 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 { + 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 { + 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 { diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index be74011b..97c9168b 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -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", diff --git a/src/i18n/resources/fr.ts b/src/i18n/resources/fr.ts index 3073cbad..5a381e4f 100644 --- a/src/i18n/resources/fr.ts +++ b/src/i18n/resources/fr.ts @@ -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", }, }, diff --git a/src/i18n/resources/ru.ts b/src/i18n/resources/ru.ts index 637ba08e..a39b29a1 100644 --- a/src/i18n/resources/ru.ts +++ b/src/i18n/resources/ru.ts @@ -1791,6 +1791,7 @@ export const ru: TranslationTree = { blockingAdded: "Добавлено {count} зависимых задач", blockingRemoved: "Зависимая задача удалена", unresolved: "Не удалось определить: {entries}", + noEligibleTasks: "Нет доступных подходящих задач", updateFailed: "Не удалось обновить зависимости", }, }, diff --git a/src/modals/TaskCreationModal.ts b/src/modals/TaskCreationModal.ts index 9b08af1d..5df54c0e 100644 --- a/src/modals/TaskCreationModal.ts +++ b/src/modals/TaskCreationModal.ts @@ -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 = {}; + const rawMap: Record = {}; 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 diff --git a/src/modals/TaskEditModal.ts b/src/modals/TaskEditModal.ts index b2205f87..1d8f0426 100644 --- a/src/modals/TaskEditModal.ts +++ b/src/modals/TaskEditModal.ts @@ -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 = new Set(); private calendarWrapper: HTMLElement | null = null; - private initialBlockedBy: string[] = []; + private initialBlockedBy: TaskDependency[] = []; private initialBlockingPaths: string[] = []; private pendingBlockingUpdates: { added: string[]; removed: string[]; - raw: Record; + raw: Record; } = { 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(); + const resolvedBlocking = new Map(); 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 = {}; + const rawAdditions: Record = {}; for (const path of addedPaths) { const raw = resolvedBlocking.get(path); if (raw) { - rawAdditions[path] = raw; + rawAdditions[path] = { ...raw }; } } diff --git a/src/modals/TaskModal.ts b/src/modals/TaskModal.ts index cba740ae..27fd8d6d 100644 --- a/src/modals/TaskModal.ts +++ b/src/modals/TaskModal.ts @@ -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 { + 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 { + 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 { + 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"]); }); diff --git a/src/services/FieldMapper.ts b/src/services/FieldMapper.ts index be32edcd..b5e4c8fe 100644 --- a/src/services/FieldMapper.ts +++ b/src/services/FieldMapper.ts @@ -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> => !!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) { diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 8da6b486..7613eced 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -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 = {} + rawEntries: Record = {} ): Promise { // 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) { diff --git a/src/types.ts b/src/types.ts index 467d76bc..56b4dba9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; // 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 diff --git a/src/ui/TaskCard.ts b/src/ui/TaskCard.ts index 2ff01edf..90fb778e 100644 --- a/src/ui/TaskCard.ts +++ b/src/ui/TaskCard.ts @@ -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"); diff --git a/src/utils/FilterUtils.ts b/src/utils/FilterUtils.ts index d1fc611a..a76beca7 100644 --- a/src/utils/FilterUtils.ts +++ b/src/utils/FilterUtils.ts @@ -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": diff --git a/src/utils/MinimalNativeCache.ts b/src/utils/MinimalNativeCache.ts index 05f2b868..5b6ef852 100644 --- a/src/utils/MinimalNativeCache.ts +++ b/src/utils/MinimalNativeCache.ts @@ -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 diff --git a/src/utils/dependencyUtils.ts b/src/utils/dependencyUtils.ts index 92cbdc7d..a4e314a1 100644 --- a/src/utils/dependencyUtils.ts +++ b/src/utils/dependencyUtils.ts @@ -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; + 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 = { + 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; } diff --git a/styles/task-card-bem.css b/styles/task-card-bem.css index 3866cd0d..659cb43c 100644 --- a/styles/task-card-bem.css +++ b/styles/task-card-bem.css @@ -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 {