mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Improve task dependency modelling and selection UI
This commit is contained in:
parent
8c85806b83
commit
f8ba9a9920
15 changed files with 528 additions and 234 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1791,6 +1791,7 @@ export const ru: TranslationTree = {
|
|||
blockingAdded: "Добавлено {count} зависимых задач",
|
||||
blockingRemoved: "Зависимая задача удалена",
|
||||
unresolved: "Не удалось определить: {entries}",
|
||||
noEligibleTasks: "Нет доступных подходящих задач",
|
||||
updateFailed: "Не удалось обновить зависимости",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
14
src/types.ts
14
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<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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue