mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
2743 lines
88 KiB
TypeScript
2743 lines
88 KiB
TypeScript
/* eslint-disable no-console */
|
|
import { TFile, setIcon, Notice, Modal, App, setTooltip, parseLinktext, Menu, type CachedMetadata } from "obsidian";
|
|
import { TaskInfo } from "../types";
|
|
import TaskNotesPlugin from "../main";
|
|
import { TaskContextMenu } from "../components/TaskContextMenu";
|
|
import {
|
|
getEffectiveTaskStatus,
|
|
getRecurrenceDisplayText,
|
|
filterEmptyProjects,
|
|
calculateTotalTimeSpent,
|
|
sanitizeForCssClass,
|
|
} from "../utils/helpers";
|
|
import { FilterUtils } from "../utils/FilterUtils";
|
|
import {
|
|
formatDateTimeForDisplay,
|
|
isTodayTimeAware,
|
|
isOverdueTimeAware,
|
|
getDatePart,
|
|
getTimePart,
|
|
formatDateForStorage,
|
|
} from "../utils/dateUtils";
|
|
import { DateContextMenu } from "../components/DateContextMenu";
|
|
import { PriorityContextMenu } from "../components/PriorityContextMenu";
|
|
import { RecurrenceContextMenu } from "../components/RecurrenceContextMenu";
|
|
import { createTaskClickHandler, createTaskHoverHandler } from "../utils/clickHandlers";
|
|
import { ReminderModal } from "../modals/ReminderModal";
|
|
import {
|
|
renderProjectLinks,
|
|
renderTextWithLinks,
|
|
type LinkServices,
|
|
} from "./renderers/linkRenderer";
|
|
import { renderTagsValue, renderContextsValue, type TagServices } from "./renderers/tagRenderer";
|
|
import {
|
|
convertInternalToUserProperties,
|
|
isPropertyForField,
|
|
} from "../utils/propertyMapping";
|
|
import { DEFAULT_INTERNAL_VISIBLE_PROPERTIES } from "../settings/defaults";
|
|
import {
|
|
extractBasesValue,
|
|
resolveTaskCardPropertyLabel,
|
|
type TaskCardPresentationOptions,
|
|
} from "./taskCardPresentation";
|
|
|
|
export interface TaskCardOptions {
|
|
targetDate?: Date;
|
|
layout?: "default" | "compact" | "inline";
|
|
/** When true, hide status indicator (e.g., when Kanban is grouped by status) */
|
|
hideStatusIndicator?: boolean;
|
|
/** Optional display labels for properties, typically sourced from Bases config. */
|
|
propertyLabels?: TaskCardPresentationOptions["propertyLabels"];
|
|
}
|
|
|
|
export const DEFAULT_TASK_CARD_OPTIONS: TaskCardOptions = {
|
|
layout: "default",
|
|
};
|
|
|
|
/* =================================================================
|
|
BADGE INDICATOR HELPERS
|
|
================================================================= */
|
|
|
|
interface BadgeIndicatorConfig {
|
|
container: HTMLElement;
|
|
className: string;
|
|
icon: string;
|
|
tooltip: string;
|
|
ariaLabel?: string;
|
|
onClick?: (e: MouseEvent) => void;
|
|
visible?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Creates a badge indicator element with icon, tooltip, and optional click handler.
|
|
* Returns the element, or null if visible is false.
|
|
*/
|
|
function createBadgeIndicator(config: BadgeIndicatorConfig): HTMLElement | null {
|
|
const { container, className, icon, tooltip, ariaLabel, onClick, visible = true } = config;
|
|
|
|
if (!visible) return null;
|
|
|
|
const indicator = container.createEl("div", {
|
|
cls: className,
|
|
attr: { "aria-label": ariaLabel || tooltip },
|
|
});
|
|
|
|
setIcon(indicator, icon);
|
|
setTooltip(indicator, tooltip, { placement: "top" });
|
|
|
|
if (onClick) {
|
|
prepareInteractiveControl(indicator);
|
|
indicator.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
onClick(e);
|
|
});
|
|
}
|
|
|
|
return indicator;
|
|
}
|
|
|
|
/**
|
|
* Updates or creates a badge indicator, returning the element.
|
|
* If the indicator should not exist, removes any existing one and returns null.
|
|
*/
|
|
function updateBadgeIndicator(
|
|
container: HTMLElement,
|
|
selector: string,
|
|
config: Omit<BadgeIndicatorConfig, "container"> & { shouldExist: boolean }
|
|
): HTMLElement | null {
|
|
const existing = container.querySelector(selector) as HTMLElement | null;
|
|
|
|
if (!config.shouldExist) {
|
|
existing?.remove();
|
|
return null;
|
|
}
|
|
|
|
if (existing) {
|
|
// Update existing indicator
|
|
existing.setAttribute("aria-label", config.ariaLabel || config.tooltip);
|
|
setTooltip(existing, config.tooltip, { placement: "top" });
|
|
if (config.onClick) {
|
|
prepareInteractiveControl(existing);
|
|
}
|
|
return existing;
|
|
}
|
|
|
|
// Create new indicator
|
|
const badgesContainer = container.querySelector(".task-card__badges") as HTMLElement;
|
|
const targetContainer = badgesContainer || container.querySelector(".task-card__main-row") as HTMLElement;
|
|
|
|
if (!targetContainer) return null;
|
|
|
|
return createBadgeIndicator({
|
|
container: targetContainer,
|
|
...config,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mark interactive task-card controls so draggable parent cards do not swallow clicks.
|
|
*/
|
|
function prepareInteractiveControl(element: HTMLElement): void {
|
|
if (element.dataset.tnNoDrag === "true") {
|
|
element.setAttribute("draggable", "false");
|
|
return;
|
|
}
|
|
|
|
element.dataset.tnNoDrag = "true";
|
|
element.setAttribute("draggable", "false");
|
|
element.addEventListener("mousedown", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
});
|
|
}
|
|
|
|
/* =================================================================
|
|
CLICK HANDLER FACTORIES
|
|
================================================================= */
|
|
|
|
/**
|
|
* Creates a click handler for cycling task status
|
|
*/
|
|
function createStatusCycleHandler(
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
card: HTMLElement,
|
|
statusDot: HTMLElement,
|
|
targetDate: Date
|
|
): (e: MouseEvent) => Promise<void> {
|
|
return async (e: MouseEvent) => {
|
|
e.stopPropagation();
|
|
try {
|
|
const updateStatusVisuals = (
|
|
updatedTask: TaskInfo,
|
|
effectiveStatus: string,
|
|
isCompleted: boolean
|
|
) => {
|
|
const statusConfig = plugin.statusManager.getStatusConfig(effectiveStatus);
|
|
if (statusConfig?.color) {
|
|
statusDot.style.borderColor = statusConfig.color;
|
|
} else {
|
|
statusDot.style.borderColor = "";
|
|
}
|
|
|
|
if (statusConfig?.icon) {
|
|
statusDot.addClass("task-card__status-dot--icon");
|
|
statusDot.empty();
|
|
setIcon(statusDot, statusConfig.icon);
|
|
} else {
|
|
statusDot.removeClass("task-card__status-dot--icon");
|
|
statusDot.empty();
|
|
}
|
|
|
|
if (statusConfig?.color) {
|
|
card.style.setProperty("--current-status-color", statusConfig.color);
|
|
} else {
|
|
card.style.removeProperty("--current-status-color");
|
|
}
|
|
|
|
const nextStatus = plugin.statusManager.getNextStatus(effectiveStatus);
|
|
const nextStatusConfig = plugin.statusManager.getStatusConfig(nextStatus);
|
|
if (nextStatusConfig?.color) {
|
|
card.style.setProperty("--next-status-color", nextStatusConfig.color);
|
|
} else {
|
|
card.style.removeProperty("--next-status-color");
|
|
}
|
|
|
|
const checkbox = card.querySelector(".task-card__checkbox") as HTMLInputElement | null;
|
|
if (checkbox) {
|
|
checkbox.checked = isCompleted;
|
|
}
|
|
|
|
updateCardCompletionState(card, updatedTask, plugin, isCompleted, effectiveStatus);
|
|
};
|
|
|
|
if (task.recurrence) {
|
|
// For recurring tasks, toggle completion for the target date
|
|
const updatedTask = await plugin.toggleRecurringTaskComplete(task, targetDate);
|
|
const newEffectiveStatus = getEffectiveTaskStatus(updatedTask, targetDate, plugin.statusManager.getCompletedStatuses()[0]);
|
|
const isNowCompleted = plugin.statusManager.isCompletedStatus(newEffectiveStatus);
|
|
updateStatusVisuals(updatedTask, newEffectiveStatus, isNowCompleted);
|
|
} else {
|
|
// For regular tasks, cycle to next/previous status based on shift key
|
|
const freshTask = await plugin.cacheManager.getTaskInfo(task.path);
|
|
if (!freshTask) {
|
|
new Notice("Task not found");
|
|
return;
|
|
}
|
|
const currentStatus = freshTask.status || plugin.settings.defaultTaskStatus;
|
|
const nextStatus = e.shiftKey
|
|
? plugin.statusManager.getPreviousStatus(currentStatus)
|
|
: plugin.statusManager.getNextStatus(currentStatus);
|
|
const updatedTask = await plugin.updateTaskProperty(freshTask, "status", nextStatus);
|
|
const isNowCompleted = plugin.statusManager.isCompletedStatus(nextStatus);
|
|
updateStatusVisuals(updatedTask, nextStatus, isNowCompleted);
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
console.error("Error cycling task status:", { error: errorMessage, taskPath: task.path });
|
|
new Notice(`Failed to update task status: ${errorMessage}`);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Updates card classes based on completion state
|
|
*/
|
|
function updateCardCompletionState(
|
|
card: HTMLElement,
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
isCompleted: boolean,
|
|
effectiveStatus: string
|
|
): void {
|
|
card.classList.toggle("task-card--completed", isCompleted);
|
|
card.classList.toggle("task-card--archived", !!task.archived);
|
|
card.classList.toggle("task-card--actively-tracked", plugin.getActiveTimeSession(task) !== null);
|
|
card.classList.toggle("task-card--recurring", !!task.recurrence);
|
|
card.classList.toggle("task-card--chevron-left", plugin.settings?.subtaskChevronPosition === "left");
|
|
|
|
for (const className of Array.from(card.classList)) {
|
|
if (className.startsWith("task-card--priority-")) {
|
|
card.classList.remove(className);
|
|
}
|
|
}
|
|
if (task.priority) {
|
|
card.classList.add(`task-card--priority-${sanitizeForCssClass(task.priority)}`);
|
|
}
|
|
|
|
for (const className of Array.from(card.classList)) {
|
|
if (className.startsWith("task-card--status-")) {
|
|
card.classList.remove(className);
|
|
}
|
|
}
|
|
if (effectiveStatus) {
|
|
card.classList.add(`task-card--status-${sanitizeForCssClass(effectiveStatus)}`);
|
|
}
|
|
|
|
card.dataset.status = effectiveStatus;
|
|
|
|
// Update title styling
|
|
const titleEl = card.querySelector(".task-card__title") as HTMLElement;
|
|
const titleTextEl = card.querySelector(".task-card__title-text") as HTMLElement;
|
|
if (titleEl) titleEl.classList.toggle("completed", isCompleted);
|
|
if (titleTextEl) titleTextEl.classList.toggle("completed", isCompleted);
|
|
}
|
|
|
|
/**
|
|
* Creates a click handler for priority dot
|
|
*/
|
|
function createPriorityClickHandler(
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin
|
|
): (e: MouseEvent) => void {
|
|
return (e: MouseEvent) => {
|
|
e.stopPropagation();
|
|
const menu = new PriorityContextMenu({
|
|
currentValue: task.priority,
|
|
onSelect: async (newPriority) => {
|
|
try {
|
|
await plugin.updateTaskProperty(task, "priority", newPriority);
|
|
} catch (error) {
|
|
console.error("Error updating priority:", error);
|
|
new Notice("Failed to update priority");
|
|
}
|
|
},
|
|
plugin: plugin,
|
|
});
|
|
menu.show(e);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a click handler for recurrence indicator
|
|
*/
|
|
function createRecurrenceClickHandler(
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin
|
|
): (e: MouseEvent) => void {
|
|
return (e: MouseEvent) => {
|
|
e.stopPropagation();
|
|
const menu = new RecurrenceContextMenu({
|
|
currentValue: typeof task.recurrence === "string" ? task.recurrence : undefined,
|
|
currentAnchor: task.recurrence_anchor || "scheduled",
|
|
scheduledDate: task.scheduled,
|
|
onSelect: async (newRecurrence, anchor) => {
|
|
try {
|
|
await plugin.updateTaskProperty(task, "recurrence", newRecurrence || undefined);
|
|
if (anchor !== undefined) {
|
|
await plugin.updateTaskProperty(task, "recurrence_anchor", anchor);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error updating recurrence:", error);
|
|
new Notice("Failed to update recurrence");
|
|
}
|
|
},
|
|
app: plugin.app,
|
|
plugin: plugin,
|
|
});
|
|
menu.show(e);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a click handler for reminder indicator
|
|
*/
|
|
function createReminderClickHandler(
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin
|
|
): () => void {
|
|
return () => {
|
|
const modal = new ReminderModal(plugin.app, plugin, task, async (reminders) => {
|
|
try {
|
|
await plugin.updateTaskProperty(task, "reminders", reminders.length > 0 ? reminders : undefined);
|
|
} catch (error) {
|
|
console.error("Error updating reminders:", error);
|
|
new Notice("Failed to update reminders");
|
|
}
|
|
});
|
|
modal.open();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a click handler for project indicator
|
|
*/
|
|
function createProjectClickHandler(
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin
|
|
): () => Promise<void> {
|
|
return async () => {
|
|
try {
|
|
await plugin.applyProjectSubtaskFilter(task);
|
|
} catch (error) {
|
|
console.error("Error filtering project subtasks:", error);
|
|
new Notice("Failed to filter project subtasks");
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a click handler for chevron (expand/collapse subtasks)
|
|
*/
|
|
function createChevronClickHandler(
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
card: HTMLElement,
|
|
chevron: HTMLElement
|
|
): () => Promise<void> {
|
|
return async () => {
|
|
try {
|
|
if (!plugin.expandedProjectsService) {
|
|
new Notice("Service not available. Please try reloading the plugin.");
|
|
return;
|
|
}
|
|
const newExpanded = plugin.expandedProjectsService.toggle(task.path);
|
|
chevron.classList.toggle("task-card__chevron--expanded", newExpanded);
|
|
const newTooltip = getChevronTooltip(plugin, newExpanded);
|
|
chevron.setAttribute("aria-label", newTooltip);
|
|
setTooltip(chevron, newTooltip, { placement: "top" });
|
|
await toggleSubtasks(card, task, plugin, newExpanded);
|
|
} catch (error) {
|
|
console.error("Error toggling subtasks:", error);
|
|
new Notice("Failed to toggle subtasks");
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a click handler for blocking toggle
|
|
*/
|
|
function createBlockingToggleClickHandler(
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
card: HTMLElement,
|
|
toggle: HTMLElement
|
|
): () => Promise<void> {
|
|
return async () => {
|
|
const expanded = toggle.classList.toggle("task-card__blocking-toggle--expanded");
|
|
await toggleBlockingTasks(card, task, plugin, expanded);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper function to attach date context menu click handlers
|
|
*/
|
|
function attachDateClickHandler(
|
|
span: HTMLElement,
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
dateType: "due" | "scheduled"
|
|
): void {
|
|
prepareInteractiveControl(span);
|
|
span.addEventListener("click", (e) => {
|
|
e.stopPropagation(); // Don't trigger card click
|
|
const currentValue = dateType === "due" ? task.due : task.scheduled;
|
|
const menu = new DateContextMenu({
|
|
currentValue: getDatePart(currentValue || ""),
|
|
currentTime: getTimePart(currentValue || ""),
|
|
onSelect: async (dateValue, timeValue) => {
|
|
try {
|
|
let finalValue: string | undefined;
|
|
if (!dateValue) {
|
|
finalValue = undefined;
|
|
} else if (timeValue) {
|
|
finalValue = `${dateValue}T${timeValue}`;
|
|
} else {
|
|
finalValue = dateValue;
|
|
}
|
|
await plugin.updateTaskProperty(task, dateType, finalValue);
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
console.error(`Error updating ${dateType} date:`, errorMessage);
|
|
const noticeKey =
|
|
dateType === "due"
|
|
? "contextMenus.task.notices.updateDueDateFailure"
|
|
: "contextMenus.task.notices.updateScheduledFailure";
|
|
new Notice(plugin.i18n.translate(noticeKey, { message: errorMessage }));
|
|
}
|
|
},
|
|
plugin,
|
|
app: plugin.app,
|
|
});
|
|
menu.show(e as MouseEvent);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get default visible properties when no custom configuration is provided.
|
|
* Returns user-configured property names (e.g., "task-status" if user customized the status field).
|
|
*
|
|
* @param plugin - The plugin instance with fieldMapper
|
|
* @returns Array of user-configured property names
|
|
*/
|
|
function getDefaultVisibleProperties(plugin: TaskNotesPlugin): string[] {
|
|
// Combine FieldMapping properties with special properties
|
|
const internalDefaults = [
|
|
...DEFAULT_INTERNAL_VISIBLE_PROPERTIES,
|
|
"tags", // Special property (not in FieldMapping)
|
|
"blocked", // Special property (computed from blockedBy)
|
|
"blocking", // Special property (not in FieldMapping)
|
|
"googleCalendarSync", // Special property (shows if task is synced to Google Calendar)
|
|
];
|
|
|
|
return convertInternalToUserProperties(internalDefaults, plugin);
|
|
}
|
|
|
|
interface ChecklistProgress {
|
|
completed: number;
|
|
total: number;
|
|
percent: number;
|
|
}
|
|
|
|
/**
|
|
* Get checklist progress from Obsidian's metadata cache listItems.
|
|
* Uses parsed metadata only (no vault body reads) for better performance.
|
|
*/
|
|
function getChecklistProgress(taskPath: string, plugin: TaskNotesPlugin): ChecklistProgress | null {
|
|
const file = plugin.app.vault.getAbstractFileByPath(taskPath);
|
|
if (!(file instanceof TFile)) return null;
|
|
|
|
const fileCache = plugin.app.metadataCache.getFileCache(file);
|
|
return calculateChecklistProgress(fileCache);
|
|
}
|
|
|
|
/**
|
|
* Calculate first-level checklist progress from cached list items.
|
|
* Only top-level task list items are counted (nested subtasks are ignored).
|
|
*/
|
|
function calculateChecklistProgress(cache: CachedMetadata | null): ChecklistProgress | null {
|
|
const listItems = cache?.listItems;
|
|
if (!Array.isArray(listItems) || listItems.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
let total = 0;
|
|
let completed = 0;
|
|
|
|
for (const item of listItems) {
|
|
if (!item || typeof item.task !== "string") continue;
|
|
|
|
// Obsidian uses parent >= 0 for nested list items.
|
|
const isNested = typeof item.parent === "number" && item.parent >= 0;
|
|
if (isNested) continue;
|
|
|
|
total += 1;
|
|
// Count only explicit checked boxes as complete ([x] / [X]).
|
|
// Other task markers (e.g. [-], [>]) are treated as not complete.
|
|
if (item.task.toLowerCase() === "x") {
|
|
completed += 1;
|
|
}
|
|
}
|
|
|
|
if (total === 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
completed,
|
|
total,
|
|
percent: Math.round((completed / total) * 100),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Property value extractors for better type safety and error handling
|
|
*/
|
|
const PROPERTY_EXTRACTORS: Record<string, (task: TaskInfo) => any> = {
|
|
due: (task) => task.due,
|
|
scheduled: (task) => task.scheduled,
|
|
projects: (task) => task.projects,
|
|
contexts: (task) => task.contexts,
|
|
tags: (task) => task.tags,
|
|
blocked: (task) => task.isBlocked,
|
|
blocking: (task) => task.isBlocking,
|
|
blockedBy: (task) => task.blockedBy,
|
|
blockingTasks: (task) => task.blocking,
|
|
timeEstimate: (task) => task.timeEstimate,
|
|
timeEntries: (task) => task.timeEntries,
|
|
totalTrackedTime: (task) => task.totalTrackedTime,
|
|
recurrence: (task) => task.recurrence,
|
|
completedDate: (task) => task.completedDate,
|
|
reminders: (task) => task.reminders,
|
|
icsEventId: (task) => task.icsEventId,
|
|
completeInstances: (task) => task.complete_instances,
|
|
skippedInstances: (task) => task.skipped_instances,
|
|
dateCreated: (task) => task.dateCreated,
|
|
dateModified: (task) => task.dateModified,
|
|
googleCalendarSync: (task) => task.path, // Used to check if task is synced via plugin settings
|
|
checklistProgress: (task) => task.path, // Used to compute checklist progress from metadata cache listItems
|
|
};
|
|
|
|
function tTaskCard(
|
|
plugin: TaskNotesPlugin,
|
|
key: string,
|
|
vars?: Record<string, string | number>
|
|
): string {
|
|
return plugin.i18n.translate(`ui.taskCard.${key}`, vars);
|
|
}
|
|
|
|
function getTaskCardPropertyLabel(
|
|
propertyId: string,
|
|
plugin: TaskNotesPlugin,
|
|
propertyLabels?: Record<string, string>
|
|
): string {
|
|
const fallbackLabels: Record<string, string> = {
|
|
due: tTaskCard(plugin, "labels.due"),
|
|
scheduled: tTaskCard(plugin, "labels.scheduled"),
|
|
recurrence: tTaskCard(plugin, "labels.recurrence"),
|
|
completedDate: tTaskCard(plugin, "labels.completed"),
|
|
dateCreated: tTaskCard(plugin, "labels.created"),
|
|
dateModified: tTaskCard(plugin, "labels.modified"),
|
|
blocked: tTaskCard(plugin, "labels.blocked"),
|
|
blocking: tTaskCard(plugin, "labels.blocking"),
|
|
};
|
|
|
|
return resolveTaskCardPropertyLabel(
|
|
propertyId,
|
|
{ propertyLabels },
|
|
fallbackLabels[propertyId]
|
|
);
|
|
}
|
|
|
|
function getRecurrenceTooltip(
|
|
plugin: TaskNotesPlugin,
|
|
recurrence: string,
|
|
propertyLabels?: Record<string, string>
|
|
): string {
|
|
return tTaskCard(plugin, "recurrenceTooltip", {
|
|
label: getTaskCardPropertyLabel("recurrence", plugin, propertyLabels),
|
|
value: getRecurrenceDisplayText(recurrence),
|
|
});
|
|
}
|
|
|
|
function getReminderTooltip(plugin: TaskNotesPlugin, count: number): string {
|
|
return count === 1
|
|
? tTaskCard(plugin, "reminderTooltipOne")
|
|
: tTaskCard(plugin, "reminderTooltipMany", { count });
|
|
}
|
|
|
|
function getChevronTooltip(plugin: TaskNotesPlugin, expanded: boolean): string {
|
|
return expanded
|
|
? tTaskCard(plugin, "collapseSubtasks")
|
|
: tTaskCard(plugin, "expandSubtasks");
|
|
}
|
|
|
|
/**
|
|
* Get property value from a task with improved error handling and type safety.
|
|
*
|
|
* @param task - The task to extract the property from
|
|
* @param propertyId - The property identifier (user-configured or internal name)
|
|
* @param plugin - TaskNotes plugin instance
|
|
* @returns The property value, or undefined if not found
|
|
*/
|
|
function getPropertyValue(task: TaskInfo, propertyId: string, plugin: TaskNotesPlugin): unknown {
|
|
try {
|
|
// Check if this is a user-configured name for a mapped field
|
|
const mappingKey = plugin.fieldMapper.lookupMappingKey(propertyId);
|
|
if (mappingKey) {
|
|
// Use the mapping key as the extractor key (e.g., "due", "scheduled")
|
|
if (mappingKey in PROPERTY_EXTRACTORS) {
|
|
return PROPERTY_EXTRACTORS[mappingKey](task);
|
|
}
|
|
}
|
|
|
|
// Try direct property lookup (for non-mapped properties)
|
|
if (propertyId in PROPERTY_EXTRACTORS) {
|
|
return PROPERTY_EXTRACTORS[propertyId](task);
|
|
}
|
|
|
|
// Handle user properties
|
|
if (propertyId.startsWith("user:")) {
|
|
return getUserPropertyValue(task, propertyId, plugin);
|
|
}
|
|
|
|
// Check custom properties from Bases or other sources
|
|
// Values may be Bases Value objects, so extract the raw value
|
|
if (task.customProperties && propertyId in task.customProperties) {
|
|
return extractBasesValue(task.customProperties[propertyId]);
|
|
}
|
|
|
|
// Check for file properties (stored as "file.name", "file.basename", etc.)
|
|
if (task.customProperties) {
|
|
const filePropertyId = `file.${propertyId}`;
|
|
if (filePropertyId in task.customProperties) {
|
|
return extractBasesValue(task.customProperties[filePropertyId]);
|
|
}
|
|
}
|
|
|
|
// Lazy fetch for file.* properties (backlinks, links, embeds, etc.)
|
|
// These are NOT pre-extracted for performance - only computed when visible
|
|
if (propertyId.startsWith("file.") && task.basesData && typeof task.basesData.getValue === "function") {
|
|
try {
|
|
const value = task.basesData.getValue(propertyId as any);
|
|
if (value !== null && value !== undefined) {
|
|
return extractBasesValue(value);
|
|
}
|
|
} catch (error) {
|
|
// Property doesn't exist or error fetching
|
|
}
|
|
}
|
|
|
|
// Handle Bases formula properties
|
|
if (propertyId.startsWith("formula.")) {
|
|
try {
|
|
const basesData = task.basesData;
|
|
|
|
if (!basesData || typeof basesData.getValue !== "function") {
|
|
return "";
|
|
}
|
|
|
|
// Use BasesEntry.getValue() to get formula result
|
|
// BasesEntry is from Obsidian's Bases API (1.10.0+)
|
|
const value = basesData.getValue(propertyId as any);
|
|
|
|
// Handle null/undefined
|
|
if (value === null || value === undefined) {
|
|
return "";
|
|
}
|
|
|
|
// Extract raw value from Bases Value object
|
|
const extracted = extractBasesValue(value);
|
|
return extracted !== "" ? extracted : "";
|
|
} catch (error) {
|
|
console.debug(`[TaskNotes] Error computing formula ${propertyId}:`, error);
|
|
return "[Formula Error]";
|
|
}
|
|
}
|
|
|
|
// Try to get property from Bases API first (for custom properties)
|
|
// This ensures we get the same value that Bases is displaying
|
|
if (task.basesData && typeof task.basesData.getValue === "function") {
|
|
try {
|
|
// Try with "note." prefix first (most common for custom frontmatter properties)
|
|
const notePropertyId = `note.${propertyId}`;
|
|
const value = task.basesData.getValue(notePropertyId as any);
|
|
if (value !== null && value !== undefined) {
|
|
return extractBasesValue(value);
|
|
}
|
|
} catch (error) {
|
|
// Property doesn't exist in Bases, try fallback
|
|
}
|
|
}
|
|
|
|
// Fallback: try to get arbitrary property from frontmatter
|
|
if (task.path) {
|
|
const value = getFrontmatterValue(task.path, propertyId, plugin);
|
|
if (value !== undefined) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.warn(`TaskCard: Error getting property ${propertyId}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract user property value with improved error handling and type safety
|
|
*/
|
|
function getUserPropertyValue(
|
|
task: TaskInfo,
|
|
propertyId: string,
|
|
plugin: TaskNotesPlugin
|
|
): unknown {
|
|
const fieldId = propertyId.slice(5);
|
|
const userField = plugin.settings.userFields?.find((f) => f.id === fieldId);
|
|
|
|
if (!userField?.key) {
|
|
return null;
|
|
}
|
|
|
|
// Try task object first (backward compatibility)
|
|
let value = (task as unknown as Record<string, unknown>)[userField.key];
|
|
|
|
// Fall back to frontmatter if needed
|
|
if (value === undefined) {
|
|
value = getFrontmatterValue(task.path, userField.key, plugin);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Safely extract frontmatter value with proper typing
|
|
*/
|
|
function getFrontmatterValue(taskPath: string, key: string, plugin: TaskNotesPlugin): unknown {
|
|
try {
|
|
const fileMetadata = plugin.app.metadataCache.getCache(taskPath);
|
|
if (!fileMetadata?.frontmatter) {
|
|
return undefined;
|
|
}
|
|
|
|
const frontmatter = fileMetadata.frontmatter as Record<string, unknown>;
|
|
return frontmatter[key];
|
|
} catch (error) {
|
|
console.warn(`TaskCard: Error accessing frontmatter for ${taskPath}:`, error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Property renderer function type for better type safety
|
|
*/
|
|
type PropertyRenderer = (
|
|
element: HTMLElement,
|
|
value: unknown,
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
options?: Partial<TaskCardOptions>
|
|
) => void;
|
|
|
|
/**
|
|
* Property renderers for cleaner separation of concerns
|
|
*/
|
|
const PROPERTY_RENDERERS: Record<string, PropertyRenderer> = {
|
|
due: (element, value, task, plugin, options) => {
|
|
if (typeof value === "string") {
|
|
renderDueDateProperty(element, value, task, plugin, options?.propertyLabels);
|
|
}
|
|
},
|
|
scheduled: (element, value, task, plugin, options) => {
|
|
if (typeof value === "string") {
|
|
renderScheduledDateProperty(element, value, task, plugin, options?.propertyLabels);
|
|
}
|
|
},
|
|
projects: (element, value, task, plugin) => {
|
|
if (Array.isArray(value)) {
|
|
const linkServices: LinkServices = {
|
|
metadataCache: plugin.app.metadataCache,
|
|
workspace: plugin.app.workspace,
|
|
sourcePath: task.path,
|
|
};
|
|
renderProjectLinks(element, value as string[], linkServices);
|
|
}
|
|
},
|
|
contexts: (element, value, _, plugin) => {
|
|
if (Array.isArray(value)) {
|
|
const tagServices: TagServices = {
|
|
onTagClick: async (context, _event) => {
|
|
// Remove @ prefix if present for search
|
|
const searchTag = context.startsWith("@") ? context.slice(1) : context;
|
|
const success = await plugin.openTagsPane(`#${searchTag}`);
|
|
if (!success) {
|
|
console.log("Could not open search pane, context clicked:", context);
|
|
}
|
|
},
|
|
};
|
|
renderContextsValue(element, value, tagServices);
|
|
}
|
|
},
|
|
tags: (element, value, _, plugin) => {
|
|
if (Array.isArray(value)) {
|
|
// Filter out identifying tags if setting is enabled and using tag-based identification
|
|
let tagsToRender = value;
|
|
if (
|
|
plugin.settings.taskIdentificationMethod === "tag" &&
|
|
plugin.settings.hideIdentifyingTagsInCards
|
|
) {
|
|
tagsToRender = value.filter(
|
|
(tag) =>
|
|
!FilterUtils.matchesHierarchicalTagExact(
|
|
tag,
|
|
plugin.settings.taskTag,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Only render if there are tags to display
|
|
if (tagsToRender.length > 0) {
|
|
const tagServices: TagServices = {
|
|
onTagClick: async (tag, _event) => {
|
|
// Remove # prefix if present for search
|
|
const searchTag = tag.startsWith("#") ? tag.slice(1) : tag;
|
|
const success = await plugin.openTagsPane(`#${searchTag}`);
|
|
if (!success) {
|
|
console.log("Could not open search pane, tag clicked:", tag);
|
|
}
|
|
},
|
|
};
|
|
renderTagsValue(element, tagsToRender, tagServices);
|
|
}
|
|
}
|
|
},
|
|
timeEstimate: (element, value, _, plugin) => {
|
|
if (typeof value === "number") {
|
|
element.textContent = `${plugin.formatTime(value)} estimated`;
|
|
}
|
|
},
|
|
totalTrackedTime: (element, value, _, plugin) => {
|
|
if (typeof value === "number" && value > 0) {
|
|
element.textContent = `${plugin.formatTime(value)} tracked`;
|
|
}
|
|
},
|
|
recurrence: (element, value, _task, plugin, options) => {
|
|
if (typeof value === "string") {
|
|
element.textContent = getRecurrenceTooltip(plugin, value, options?.propertyLabels);
|
|
}
|
|
},
|
|
completeInstances: (element, value, task) => {
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
const count = value.length;
|
|
const skippedCount = task.skipped_instances?.length || 0;
|
|
const total = count + skippedCount;
|
|
|
|
if (total > 0) {
|
|
const completionRate = Math.round((count / total) * 100);
|
|
element.textContent = `✓ ${count} completed (${completionRate}%)`;
|
|
element.classList.add("task-card__metadata-pill--completed-instances");
|
|
} else {
|
|
element.textContent = `✓ ${count} completed`;
|
|
element.classList.add("task-card__metadata-pill--completed-instances");
|
|
}
|
|
}
|
|
},
|
|
skippedInstances: (element, value, task) => {
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
const count = value.length;
|
|
element.textContent = `⊘ ${count} skipped`;
|
|
element.classList.add("task-card__metadata-pill--skipped-instances");
|
|
}
|
|
},
|
|
completedDate: (element, value, task, plugin, options) => {
|
|
if (typeof value === "string") {
|
|
const label = getTaskCardPropertyLabel("completedDate", plugin, options?.propertyLabels);
|
|
element.textContent = `${label}: ${formatDateTimeForDisplay(value, {
|
|
dateFormat: "MMM d",
|
|
showTime: false,
|
|
userTimeFormat: plugin.settings.calendarViewSettings.timeFormat,
|
|
})}`;
|
|
}
|
|
},
|
|
dateCreated: (element, value, task, plugin, options) => {
|
|
if (typeof value === "string") {
|
|
const label = getTaskCardPropertyLabel("dateCreated", plugin, options?.propertyLabels);
|
|
element.textContent = `${label}: ${formatDateTimeForDisplay(value, {
|
|
dateFormat: "MMM d",
|
|
showTime: false,
|
|
userTimeFormat: plugin.settings.calendarViewSettings.timeFormat,
|
|
})}`;
|
|
}
|
|
},
|
|
dateModified: (element, value, task, plugin, options) => {
|
|
if (typeof value === "string") {
|
|
const label = getTaskCardPropertyLabel("dateModified", plugin, options?.propertyLabels);
|
|
element.textContent = `${label}: ${formatDateTimeForDisplay(value, {
|
|
dateFormat: "MMM d",
|
|
showTime: false,
|
|
userTimeFormat: plugin.settings.calendarViewSettings.timeFormat,
|
|
})}`;
|
|
}
|
|
},
|
|
blocked: (element, value, task, plugin, options) => {
|
|
// Show blocked status with count if available
|
|
if (value === true) {
|
|
const blockedCount = task.blockedBy?.length ?? 0;
|
|
const label = getTaskCardPropertyLabel("blocked", plugin, options?.propertyLabels);
|
|
element.textContent = blockedCount > 0 ? `${label} (${blockedCount})` : label;
|
|
element.classList.add("task-card__metadata-pill--blocked");
|
|
}
|
|
},
|
|
blocking: (element, value, task, plugin, options) => {
|
|
// Show blocking status with count if available
|
|
if (value === true) {
|
|
const blockingCount = task.blocking?.length ?? 0;
|
|
const label = getTaskCardPropertyLabel("blocking", plugin, options?.propertyLabels);
|
|
element.textContent = blockingCount > 0 ? `${label} (${blockingCount})` : label;
|
|
element.classList.add("task-card__metadata-pill--blocking");
|
|
}
|
|
},
|
|
blockedBy: (element, value, task, plugin) => {
|
|
// Show list of tasks blocking this one
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
element.createEl("span", { text: "Blocked by: " });
|
|
const linksContainer = element.createEl("span");
|
|
value.forEach((dep, idx) => {
|
|
if (idx > 0) linksContainer.appendChild(document.createTextNode(", "));
|
|
// Each dependency has a path property
|
|
const depPath = typeof dep === "string" ? dep : dep.path;
|
|
if (depPath) {
|
|
const linkEl = linksContainer.createEl("a", {
|
|
cls: "internal-link",
|
|
attr: { href: depPath },
|
|
});
|
|
linkEl.textContent = depPath.split("/").pop()?.replace(".md", "") || depPath;
|
|
linkEl.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
plugin.app.workspace.openLinkText(depPath, "", false);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
},
|
|
blockingTasks: (element, value, task, plugin) => {
|
|
// Show list of tasks that this one is blocking
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
element.createEl("span", { text: "Blocking: " });
|
|
const linksContainer = element.createEl("span");
|
|
value.forEach((path, idx) => {
|
|
if (idx > 0) linksContainer.appendChild(document.createTextNode(", "));
|
|
const linkEl = linksContainer.createEl("a", {
|
|
cls: "internal-link",
|
|
attr: { href: path },
|
|
});
|
|
linkEl.textContent = path.split("/").pop()?.replace(".md", "") || path;
|
|
linkEl.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
plugin.app.workspace.openLinkText(path, "", false);
|
|
});
|
|
});
|
|
}
|
|
},
|
|
timeEntries: (element, value, _, plugin) => {
|
|
// Show total tracked time from time entries
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
const totalTime = calculateTotalTimeSpent(value);
|
|
if (totalTime > 0) {
|
|
element.textContent = `${plugin.formatTime(totalTime)} tracked (${value.length} ${value.length === 1 ? "entry" : "entries"})`;
|
|
}
|
|
}
|
|
},
|
|
reminders: (element, value) => {
|
|
// Show reminder count
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
element.textContent = `${value.length} ${value.length === 1 ? "reminder" : "reminders"}`;
|
|
}
|
|
},
|
|
icsEventId: (element, value) => {
|
|
// Show calendar event indicator
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
element.textContent = `Linked to ${value.length} calendar ${value.length === 1 ? "event" : "events"}`;
|
|
}
|
|
},
|
|
checklistProgress: (element, _value, task, plugin) => {
|
|
const progress = getChecklistProgress(task.path, plugin);
|
|
if (!progress) {
|
|
return;
|
|
}
|
|
|
|
const progressEl = element.createEl("span", { cls: "task-card__progress" });
|
|
const progressBar = progressEl.createEl("span", { cls: "task-card__progress-bar" });
|
|
const progressFill = progressBar.createEl("span", { cls: "task-card__progress-fill" });
|
|
progressFill.style.width = `${progress.percent}%`;
|
|
if (progress.percent > 0 && progress.percent < 5) {
|
|
progressFill.style.minWidth = "2px";
|
|
}
|
|
|
|
progressEl.createEl("span", {
|
|
cls: "task-card__progress-label",
|
|
text: `${progress.completed}/${progress.total}`,
|
|
});
|
|
|
|
setTooltip(progressEl, `${progress.percent}% complete (${progress.completed}/${progress.total})`, {
|
|
placement: "top",
|
|
});
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Render a single property as a metadata element with improved organization
|
|
*/
|
|
function renderPropertyMetadata(
|
|
container: HTMLElement,
|
|
propertyId: string,
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
options: Partial<TaskCardOptions> = {}
|
|
): HTMLElement | null {
|
|
const value = getPropertyValue(task, propertyId, plugin);
|
|
|
|
if (!hasValidValue(value)) {
|
|
return null;
|
|
}
|
|
|
|
const element = container.createEl("span", {
|
|
cls: `task-card__metadata-property task-card__metadata-property--${propertyId.replace(":", "-")}`,
|
|
});
|
|
|
|
try {
|
|
// Check if this is a user-configured name for a mapped field
|
|
const mappingKey = plugin.fieldMapper.lookupMappingKey(propertyId);
|
|
|
|
// Try using the mapping key as the renderer key
|
|
const rendererKey = mappingKey || propertyId;
|
|
|
|
if (rendererKey in PROPERTY_RENDERERS) {
|
|
PROPERTY_RENDERERS[rendererKey](element, value, task, plugin, options);
|
|
} else if (propertyId.startsWith("user:")) {
|
|
renderUserProperty(element, propertyId, value, plugin);
|
|
} else {
|
|
// Fallback: render arbitrary property with generic format
|
|
renderGenericProperty(
|
|
element,
|
|
propertyId,
|
|
value,
|
|
plugin,
|
|
getTaskCardPropertyLabel(propertyId, plugin, options.propertyLabels)
|
|
);
|
|
}
|
|
|
|
// If the renderer didn't add any content, remove the element and return null
|
|
if (!element.textContent && !element.hasChildNodes()) {
|
|
element.remove();
|
|
return null;
|
|
}
|
|
|
|
return element;
|
|
} catch (error) {
|
|
console.warn(`TaskCard: Error rendering property ${propertyId}:`, error);
|
|
element.textContent = `${propertyId}: (error)`;
|
|
return element;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a value is valid for display
|
|
*/
|
|
function hasValidValue(value: any): boolean {
|
|
return (
|
|
value !== null &&
|
|
value !== undefined &&
|
|
!(Array.isArray(value) && value.length === 0) &&
|
|
!(typeof value === "string" && value.trim() === "")
|
|
);
|
|
}
|
|
|
|
|
|
/**
|
|
* Render user-defined property with type safety and enhanced link/tag support
|
|
*/
|
|
function renderUserProperty(
|
|
element: HTMLElement,
|
|
propertyId: string,
|
|
value: unknown,
|
|
plugin: TaskNotesPlugin
|
|
): void {
|
|
const fieldId = propertyId.slice(5);
|
|
const userField = plugin.settings.userFields?.find((f) => f.id === fieldId);
|
|
|
|
if (!userField) {
|
|
element.textContent = `${fieldId}: (not found)`;
|
|
return;
|
|
}
|
|
|
|
const fieldName = userField.displayName || fieldId;
|
|
|
|
// Add field label
|
|
element.createEl("span", { text: `${fieldName}: ` });
|
|
|
|
// Create value container
|
|
const valueContainer = element.createEl("span");
|
|
|
|
// Create shared services to avoid redundant object creation
|
|
const linkServices: LinkServices = {
|
|
metadataCache: plugin.app.metadataCache,
|
|
workspace: plugin.app.workspace,
|
|
};
|
|
|
|
// Check if the value might contain links or tags and render appropriately
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
const stringValue = value.trim();
|
|
|
|
// Check if string contains links or tags
|
|
if (
|
|
stringValue.includes("[[") ||
|
|
stringValue.includes("](") ||
|
|
(stringValue.includes("#") && /\s#\w+|#\w+/.test(stringValue))
|
|
) {
|
|
renderTextWithLinks(valueContainer, stringValue, linkServices);
|
|
} else {
|
|
// Format according to field type
|
|
const displayValue = formatUserPropertyValue(value, userField);
|
|
valueContainer.textContent = displayValue;
|
|
}
|
|
} else if (userField.type === "list" && Array.isArray(value)) {
|
|
// Handle list fields - avoid recursive renderPropertyValue call to prevent stack overflow
|
|
const validItems = value.filter((item) => item !== null && item !== undefined);
|
|
validItems.forEach((item, idx) => {
|
|
if (idx > 0) valueContainer.appendChild(document.createTextNode(", "));
|
|
|
|
// Render each list item directly instead of recursively calling renderPropertyValue
|
|
if (typeof item === "string" && item.trim() !== "") {
|
|
const itemString = item.trim();
|
|
if (
|
|
itemString.includes("[[") ||
|
|
itemString.includes("](") ||
|
|
(itemString.includes("#") && /\s#\w+|#\w+/.test(itemString))
|
|
) {
|
|
const itemContainer = valueContainer.createEl("span");
|
|
renderTextWithLinks(itemContainer, itemString, linkServices);
|
|
} else {
|
|
valueContainer.appendChild(document.createTextNode(String(item)));
|
|
}
|
|
} else {
|
|
valueContainer.appendChild(document.createTextNode(String(item)));
|
|
}
|
|
});
|
|
} else {
|
|
// Use standard formatting for other types or empty values
|
|
const displayValue = formatUserPropertyValue(value, userField);
|
|
if (displayValue.trim() !== "") {
|
|
valueContainer.textContent = displayValue;
|
|
} else {
|
|
valueContainer.textContent = "(empty)";
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* User field type definition for better type safety
|
|
*/
|
|
interface UserField {
|
|
id: string;
|
|
key: string;
|
|
type: "text" | "number" | "date" | "boolean" | "list";
|
|
displayName?: string;
|
|
}
|
|
|
|
/**
|
|
* Render generic property with smart formatting and link detection
|
|
*/
|
|
function renderGenericProperty(
|
|
element: HTMLElement,
|
|
propertyId: string,
|
|
value: unknown,
|
|
plugin?: TaskNotesPlugin,
|
|
displayNameOverride?: string
|
|
): void {
|
|
const displayName = resolveTaskCardPropertyLabel(propertyId, {}, displayNameOverride);
|
|
|
|
// Add property label
|
|
element.createEl("span", { text: `${displayName}: ` });
|
|
|
|
// Create value container
|
|
const valueContainer = element.createEl("span");
|
|
|
|
if (Array.isArray(value)) {
|
|
// Handle arrays - render each item separately to detect links
|
|
// Extract Bases values from array items as they may be wrapped objects
|
|
const filtered = value
|
|
.map((v) => extractBasesValue(v))
|
|
.filter((v) => v !== null && v !== undefined && v !== "");
|
|
filtered.forEach((item, idx) => {
|
|
if (idx > 0) valueContainer.appendChild(document.createTextNode(", "));
|
|
renderPropertyValue(valueContainer, item, plugin);
|
|
});
|
|
} else {
|
|
renderPropertyValue(valueContainer, value, plugin);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render a single property value with link detection
|
|
*/
|
|
function renderPropertyValue(
|
|
container: HTMLElement,
|
|
value: unknown,
|
|
plugin?: TaskNotesPlugin
|
|
): void {
|
|
if (typeof value === "string" && plugin) {
|
|
// Check if string contains links and render appropriately
|
|
const linkServices: LinkServices = {
|
|
metadataCache: plugin.app.metadataCache,
|
|
workspace: plugin.app.workspace,
|
|
};
|
|
|
|
// If the string contains wikilinks, markdown links, or tags, render with enhanced support
|
|
if (
|
|
value.includes("[[") ||
|
|
(value.includes("[") && value.includes("](")) ||
|
|
(value.includes("#") && /\s#\w+|#\w+/.test(value))
|
|
) {
|
|
renderTextWithLinks(container, value, linkServices, {
|
|
onTagClick: async (tag, _event) => {
|
|
// Remove # prefix if present for search
|
|
const searchTag = tag.startsWith("#") ? tag.slice(1) : tag;
|
|
const success = await plugin.openTagsPane(`#${searchTag}`);
|
|
if (!success) {
|
|
console.log(
|
|
"Could not open search pane, generic property tag clicked:",
|
|
tag
|
|
);
|
|
}
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Plain string
|
|
container.appendChild(document.createTextNode(value));
|
|
return;
|
|
}
|
|
|
|
let displayValue: string;
|
|
|
|
if (typeof value === "object" && value !== null) {
|
|
// Handle Date objects specially
|
|
if (value instanceof Date) {
|
|
displayValue = formatDateTimeForDisplay(value.toISOString(), {
|
|
dateFormat: "MMM d, yyyy",
|
|
timeFormat: "",
|
|
showTime: false,
|
|
});
|
|
}
|
|
// Handle objects with meaningful toString methods or simple key-value pairs
|
|
else if (typeof value.toString === "function" && value.toString() !== "[object Object]") {
|
|
displayValue = value.toString();
|
|
}
|
|
// For simple objects with a few key-value pairs, show them nicely
|
|
else {
|
|
const entries = Object.entries(value as Record<string, any>);
|
|
if (entries.length <= 3) {
|
|
displayValue = entries.map(([k, v]) => `${k}: ${v}`).join(", ");
|
|
} else {
|
|
// Fallback to JSON for complex objects
|
|
displayValue = JSON.stringify(value);
|
|
}
|
|
}
|
|
} else if (typeof value === "boolean") {
|
|
// Handle booleans with checkmark/x symbols for better visual
|
|
displayValue = value ? "✓" : "✗";
|
|
} else if (typeof value === "number") {
|
|
// Format numbers with appropriate precision
|
|
displayValue = Number.isInteger(value) ? String(value) : value.toFixed(2);
|
|
} else {
|
|
// Handle strings and other primitive types
|
|
displayValue = String(value);
|
|
}
|
|
|
|
// Truncate very long values to keep card readable
|
|
if (displayValue.length > 100) {
|
|
displayValue = displayValue.substring(0, 97) + "...";
|
|
}
|
|
|
|
container.appendChild(document.createTextNode(displayValue));
|
|
}
|
|
|
|
/**
|
|
* Format user property value based on field type with improved type safety
|
|
*/
|
|
function formatUserPropertyValue(value: unknown, userField: UserField): string {
|
|
if (value === null || value === undefined) return "";
|
|
|
|
try {
|
|
switch (userField.type) {
|
|
case "text":
|
|
case "number":
|
|
return String(value);
|
|
case "date":
|
|
return formatDateTimeForDisplay(String(value), {
|
|
dateFormat: "MMM d, yyyy",
|
|
timeFormat: "",
|
|
showTime: false,
|
|
});
|
|
case "boolean":
|
|
return value ? "✓" : "✗";
|
|
case "list":
|
|
if (Array.isArray(value)) {
|
|
return (value as unknown[]).flat(2).join(", ");
|
|
}
|
|
return String(value);
|
|
default:
|
|
return String(value);
|
|
}
|
|
} catch (error) {
|
|
console.warn("TaskCard: Error formatting user property value:", error);
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render due date property with click handler
|
|
*/
|
|
function renderDueDateProperty(
|
|
element: HTMLElement,
|
|
due: string,
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
propertyLabels?: Record<string, string>
|
|
): void {
|
|
const isDueToday = isTodayTimeAware(due);
|
|
const isCompleted = plugin.statusManager.isCompletedStatus(task.status);
|
|
const hideCompletedFromOverdue = plugin.settings?.hideCompletedFromOverdue ?? true;
|
|
const isDueOverdue = isOverdueTimeAware(due, isCompleted, hideCompletedFromOverdue);
|
|
|
|
const userTimeFormat = plugin.settings.calendarViewSettings.timeFormat;
|
|
const dueLabel = getTaskCardPropertyLabel("due", plugin, propertyLabels);
|
|
let dueDateText = "";
|
|
if (isDueToday) {
|
|
const timeDisplay = formatDateTimeForDisplay(due, {
|
|
dateFormat: "",
|
|
showTime: true,
|
|
userTimeFormat,
|
|
});
|
|
dueDateText =
|
|
timeDisplay.trim() === ""
|
|
? tTaskCard(plugin, "dueToday", { label: dueLabel })
|
|
: tTaskCard(plugin, "dueTodayAt", { label: dueLabel, time: timeDisplay });
|
|
} else if (isDueOverdue) {
|
|
const display = formatDateTimeForDisplay(due, {
|
|
dateFormat: "MMM d",
|
|
showTime: true,
|
|
userTimeFormat,
|
|
});
|
|
dueDateText = tTaskCard(plugin, "dueOverdue", { label: dueLabel, display });
|
|
} else {
|
|
const display = formatDateTimeForDisplay(due, {
|
|
dateFormat: "MMM d",
|
|
showTime: true,
|
|
userTimeFormat,
|
|
});
|
|
dueDateText = tTaskCard(plugin, "dueLabel", { label: dueLabel, display });
|
|
}
|
|
|
|
element.textContent = dueDateText;
|
|
element.classList.add("task-card__metadata-date", "task-card__metadata-date--due");
|
|
if (isDueOverdue) {
|
|
element.classList.add("task-card__metadata-date--overdue");
|
|
}
|
|
element.dataset.tnAction = "edit-date";
|
|
element.dataset.tnDateType = "due";
|
|
|
|
attachDateClickHandler(element, task, plugin, "due");
|
|
}
|
|
|
|
/**
|
|
* Render scheduled date property with click handler
|
|
*/
|
|
function renderScheduledDateProperty(
|
|
element: HTMLElement,
|
|
scheduled: string,
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
propertyLabels?: Record<string, string>
|
|
): void {
|
|
const isScheduledToday = isTodayTimeAware(scheduled);
|
|
const isCompleted = plugin.statusManager.isCompletedStatus(task.status);
|
|
const hideCompletedFromOverdue = plugin.settings?.hideCompletedFromOverdue ?? true;
|
|
const isScheduledPast = isOverdueTimeAware(scheduled, isCompleted, hideCompletedFromOverdue);
|
|
|
|
const userTimeFormat = plugin.settings.calendarViewSettings.timeFormat;
|
|
const scheduledLabel = getTaskCardPropertyLabel("scheduled", plugin, propertyLabels);
|
|
let scheduledDateText = "";
|
|
if (isScheduledToday) {
|
|
const timeDisplay = formatDateTimeForDisplay(scheduled, {
|
|
dateFormat: "",
|
|
showTime: true,
|
|
userTimeFormat,
|
|
});
|
|
scheduledDateText =
|
|
timeDisplay.trim() === ""
|
|
? tTaskCard(plugin, "scheduledToday", { label: scheduledLabel })
|
|
: tTaskCard(plugin, "scheduledTodayAt", { label: scheduledLabel, time: timeDisplay });
|
|
} else if (isScheduledPast) {
|
|
const display = formatDateTimeForDisplay(scheduled, {
|
|
dateFormat: "MMM d",
|
|
showTime: true,
|
|
userTimeFormat,
|
|
});
|
|
scheduledDateText = tTaskCard(plugin, "scheduledPast", { label: scheduledLabel, display });
|
|
} else {
|
|
const display = formatDateTimeForDisplay(scheduled, {
|
|
dateFormat: "MMM d",
|
|
showTime: true,
|
|
userTimeFormat,
|
|
});
|
|
scheduledDateText = tTaskCard(plugin, "scheduledLabel", { label: scheduledLabel, display });
|
|
}
|
|
|
|
element.textContent = scheduledDateText;
|
|
element.classList.add("task-card__metadata-date", "task-card__metadata-date--scheduled");
|
|
if (isScheduledPast) {
|
|
element.classList.add("task-card__metadata-date--past");
|
|
}
|
|
element.dataset.tnAction = "edit-date";
|
|
element.dataset.tnDateType = "scheduled";
|
|
|
|
attachDateClickHandler(element, task, plugin, "scheduled");
|
|
}
|
|
|
|
/**
|
|
* Show or hide metadata line based on whether it has content
|
|
*/
|
|
function updateMetadataVisibility(metadataLine: HTMLElement, metadataElements: HTMLElement[]): void {
|
|
metadataLine.style.display = metadataElements.length > 0 ? "" : "none";
|
|
}
|
|
|
|
/**
|
|
* Create a minimalist, unified task card element
|
|
*
|
|
* @param task - The task to render
|
|
* @param plugin - TaskNotes plugin instance
|
|
* @param visibleProperties - IMPORTANT: Must be user-configured frontmatter property names
|
|
* (e.g., "task-status", "complete_instances"), NOT internal FieldMapping keys.
|
|
* If passing from settings.defaultVisibleProperties, convert using
|
|
* convertInternalToUserProperties() first.
|
|
* @param options - Optional rendering options (layout, targetDate, etc.)
|
|
*
|
|
* @example
|
|
* // Correct: Convert internal names before passing
|
|
* const props = plugin.settings.defaultVisibleProperties
|
|
* ? convertInternalToUserProperties(plugin.settings.defaultVisibleProperties, plugin)
|
|
* : undefined;
|
|
* createTaskCard(task, plugin, props);
|
|
*
|
|
* // Correct: Pass frontmatter names from Bases
|
|
* createTaskCard(task, plugin, ["complete_instances", "task-status"]);
|
|
*
|
|
* // WRONG: Don't pass internal keys directly
|
|
* createTaskCard(task, plugin, ["completeInstances", "status"]); // ❌
|
|
*/
|
|
export function createTaskCard(
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
visibleProperties?: string[],
|
|
options: Partial<TaskCardOptions> = {}
|
|
): HTMLElement {
|
|
const opts = { ...DEFAULT_TASK_CARD_OPTIONS, ...options };
|
|
// Use fresh UTC-anchored "today" if no targetDate provided
|
|
// This ensures recurring tasks show correct completion status for the current day
|
|
const targetDate = opts.targetDate || (() => {
|
|
const todayLocal = new Date();
|
|
return new Date(Date.UTC(todayLocal.getFullYear(), todayLocal.getMonth(), todayLocal.getDate()));
|
|
})();
|
|
|
|
// Determine effective status for recurring tasks
|
|
const effectiveStatus = task.recurrence
|
|
? getEffectiveTaskStatus(task, targetDate, plugin.statusManager.getCompletedStatuses()[0])
|
|
: task.status;
|
|
|
|
// Determine layout mode first
|
|
const layout = opts.layout || "default";
|
|
|
|
// Main container with BEM class structure
|
|
// Use span for inline layout to ensure proper inline flow in CodeMirror
|
|
const card = document.createElement(layout === "inline" ? "span" : "div");
|
|
|
|
// Store task path for circular reference detection
|
|
(card as any)._taskPath = task.path;
|
|
|
|
const isActivelyTracked = plugin.getActiveTimeSession(task) !== null;
|
|
const isCompleted = task.recurrence
|
|
? task.complete_instances?.includes(formatDateForStorage(targetDate)) || false // Direct check of complete_instances
|
|
: plugin.statusManager.isCompletedStatus(effectiveStatus); // Regular tasks use status config
|
|
const isSkipped = task.recurrence
|
|
? task.skipped_instances?.includes(formatDateForStorage(targetDate)) || false // Direct check of skipped_instances
|
|
: false; // Only recurring tasks can have skipped instances
|
|
const isRecurring = !!task.recurrence;
|
|
|
|
// Build BEM class names
|
|
const cardClasses = ["task-card"];
|
|
|
|
// Add layout modifier
|
|
if (layout !== "default") {
|
|
cardClasses.push(`task-card--layout-${layout}`);
|
|
}
|
|
|
|
// Add modifiers
|
|
if (isCompleted) cardClasses.push("task-card--completed");
|
|
if (isSkipped) cardClasses.push("task-card--skipped");
|
|
if (task.archived) cardClasses.push("task-card--archived");
|
|
if (isActivelyTracked) cardClasses.push("task-card--actively-tracked");
|
|
if (isRecurring) cardClasses.push("task-card--recurring");
|
|
|
|
// Add priority modifier
|
|
if (task.priority) {
|
|
cardClasses.push(`task-card--priority-${task.priority}`);
|
|
}
|
|
|
|
// Add status modifier
|
|
if (effectiveStatus) {
|
|
cardClasses.push(`task-card--status-${effectiveStatus}`);
|
|
}
|
|
|
|
// Chevron position preference
|
|
if (plugin.settings?.subtaskChevronPosition === "left") {
|
|
cardClasses.push("task-card--chevron-left");
|
|
}
|
|
|
|
// Add project modifier (for issue #355)
|
|
const hasProjects = filterEmptyProjects(task.projects || []).length > 0;
|
|
if (hasProjects) {
|
|
cardClasses.push("task-card--has-projects");
|
|
}
|
|
|
|
card.className = cardClasses.join(" ");
|
|
card.dataset.taskPath = task.path;
|
|
card.dataset.key = task.path; // For DOMReconciler compatibility
|
|
card.dataset.status = effectiveStatus;
|
|
|
|
// Create main row container for horizontal layout
|
|
// Use span for inline layout to maintain inline flow
|
|
const mainRow = card.createEl(layout === "inline" ? "span" : "div", { cls: "task-card__main-row" });
|
|
|
|
// Apply priority and status colors as CSS custom properties
|
|
const priorityConfig = plugin.priorityManager.getPriorityConfig(task.priority);
|
|
if (priorityConfig) {
|
|
card.style.setProperty("--priority-color", priorityConfig.color);
|
|
}
|
|
|
|
const statusConfig = plugin.statusManager.getStatusConfig(effectiveStatus);
|
|
if (statusConfig) {
|
|
card.style.setProperty("--current-status-color", statusConfig.color);
|
|
}
|
|
|
|
// Set next status color for hover preview
|
|
const nextStatus = plugin.statusManager.getNextStatus(effectiveStatus);
|
|
const nextStatusConfig = plugin.statusManager.getStatusConfig(nextStatus);
|
|
if (nextStatusConfig) {
|
|
card.style.setProperty("--next-status-color", nextStatusConfig.color);
|
|
}
|
|
|
|
// Status indicator dot (conditional based on visible properties and options)
|
|
let statusDot: HTMLElement | null = null;
|
|
const shouldShowStatus =
|
|
!opts.hideStatusIndicator &&
|
|
(!visibleProperties ||
|
|
visibleProperties.some((prop) => isPropertyForField(prop, "status", plugin)));
|
|
if (shouldShowStatus) {
|
|
statusDot = mainRow.createEl("span", { cls: "task-card__status-dot" });
|
|
if (statusConfig) {
|
|
statusDot.style.borderColor = statusConfig.color;
|
|
// If status has an icon configured, render it instead of colored dot
|
|
if (statusConfig.icon) {
|
|
statusDot.addClass("task-card__status-dot--icon");
|
|
setIcon(statusDot, statusConfig.icon);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add click handler to cycle through statuses
|
|
if (statusDot) {
|
|
prepareInteractiveControl(statusDot);
|
|
statusDot.addEventListener("click", createStatusCycleHandler(task, plugin, card, statusDot, targetDate));
|
|
}
|
|
|
|
// Priority indicator dot (conditional based on visible properties)
|
|
const shouldShowPriority =
|
|
!visibleProperties ||
|
|
visibleProperties.some((prop) => isPropertyForField(prop, "priority", plugin));
|
|
if (task.priority && priorityConfig && shouldShowPriority) {
|
|
const priorityDot = mainRow.createEl("span", {
|
|
cls: "task-card__priority-dot",
|
|
attr: { "aria-label": tTaskCard(plugin, "priorityAriaLabel", { label: priorityConfig.label }) },
|
|
});
|
|
priorityDot.style.borderColor = priorityConfig.color;
|
|
prepareInteractiveControl(priorityDot);
|
|
priorityDot.addEventListener("click", createPriorityClickHandler(task, plugin));
|
|
}
|
|
|
|
// Content container
|
|
const contentContainer = mainRow.createEl(layout === "inline" ? "span" : "div", { cls: "task-card__content" });
|
|
|
|
// Badge area for secondary indicators (only in non-inline mode)
|
|
const badgesContainer = layout !== "inline" ? mainRow.createEl("div", { cls: "task-card__badges" }) : null;
|
|
|
|
if (badgesContainer) {
|
|
// Recurring indicator
|
|
if (task.recurrence) {
|
|
const recurrenceTooltip = getRecurrenceTooltip(plugin, task.recurrence, opts.propertyLabels);
|
|
createBadgeIndicator({
|
|
container: badgesContainer,
|
|
className: "task-card__recurring-indicator",
|
|
icon: "rotate-ccw",
|
|
tooltip: recurrenceTooltip,
|
|
onClick: createRecurrenceClickHandler(task, plugin),
|
|
});
|
|
}
|
|
|
|
// Reminder indicator
|
|
if (task.reminders && task.reminders.length > 0) {
|
|
const count = task.reminders.length;
|
|
const reminderTooltip = getReminderTooltip(plugin, count);
|
|
createBadgeIndicator({
|
|
container: badgesContainer,
|
|
className: "task-card__reminder-indicator",
|
|
icon: "bell",
|
|
tooltip: reminderTooltip,
|
|
onClick: createReminderClickHandler(task, plugin),
|
|
});
|
|
}
|
|
|
|
// Project indicator
|
|
const isProject = plugin.projectSubtasksService.isTaskUsedAsProjectSync(task.path);
|
|
if (isProject) {
|
|
createBadgeIndicator({
|
|
container: badgesContainer,
|
|
className: "task-card__project-indicator",
|
|
icon: "folder",
|
|
tooltip: tTaskCard(plugin, "projectTooltip"),
|
|
onClick: createProjectClickHandler(task, plugin),
|
|
});
|
|
|
|
// Chevron for expandable subtasks
|
|
if (plugin.settings?.showExpandableSubtasks) {
|
|
const isExpanded = plugin.expandedProjectsService?.isExpanded(task.path) || false;
|
|
createBadgeIndicator({
|
|
container: badgesContainer,
|
|
className: `task-card__chevron${isExpanded ? " task-card__chevron--expanded" : ""}`,
|
|
icon: "chevron-right",
|
|
tooltip: getChevronTooltip(plugin, isExpanded),
|
|
onClick: () => {
|
|
const chevron = card.querySelector(".task-card__chevron") as HTMLElement | null;
|
|
if (chevron) {
|
|
void createChevronClickHandler(task, plugin, card, chevron)();
|
|
}
|
|
},
|
|
});
|
|
|
|
// Show subtasks if already expanded
|
|
if (isExpanded) {
|
|
toggleSubtasks(card, task, plugin, true).catch((error) => {
|
|
console.error("Error showing initial subtasks:", error);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Blocking toggle
|
|
const hasBlocking = task.blocking && task.blocking.length > 0;
|
|
if (hasBlocking) {
|
|
const toggleLabel = plugin.i18n.translate("ui.taskCard.blockingToggle", { count: task.blocking!.length });
|
|
createBadgeIndicator({
|
|
container: badgesContainer,
|
|
className: "task-card__blocking-toggle is-visible",
|
|
icon: "git-branch",
|
|
tooltip: toggleLabel,
|
|
onClick: () => {
|
|
const toggle = card.querySelector(".task-card__blocking-toggle") as HTMLElement | null;
|
|
if (toggle) {
|
|
void createBlockingToggleClickHandler(task, plugin, card, toggle)();
|
|
}
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// Context menu icon (appears on hover)
|
|
const contextIcon = mainRow.createEl("div", {
|
|
cls: "task-card__context-menu",
|
|
attr: {
|
|
"aria-label": tTaskCard(plugin, "taskOptions"),
|
|
},
|
|
});
|
|
|
|
// Use Obsidian's built-in ellipsis-vertical icon
|
|
setIcon(contextIcon, "ellipsis-vertical");
|
|
setTooltip(contextIcon, tTaskCard(plugin, "taskOptions"), { placement: "top" });
|
|
prepareInteractiveControl(contextIcon);
|
|
|
|
contextIcon.addEventListener("click", async (e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
await showTaskContextMenu(e as MouseEvent, task.path, plugin, targetDate);
|
|
});
|
|
|
|
// First line: Task title
|
|
const titleEl = contentContainer.createEl(layout === "inline" ? "span" : "div", { cls: "task-card__title" });
|
|
const titleTextEl = titleEl.createSpan({ cls: "task-card__title-text", text: task.title });
|
|
|
|
if (isCompleted) {
|
|
titleEl.classList.add("completed");
|
|
titleTextEl.classList.add("completed");
|
|
}
|
|
|
|
// Second line: Metadata (dynamic based on visible properties)
|
|
const metadataLine = contentContainer.createEl(layout === "inline" ? "span" : "div", { cls: "task-card__metadata" });
|
|
const metadataElements: HTMLElement[] = [];
|
|
|
|
// Get properties to display
|
|
const propertiesToShow =
|
|
visibleProperties ||
|
|
(plugin.settings.defaultVisibleProperties
|
|
? convertInternalToUserProperties(plugin.settings.defaultVisibleProperties, plugin)
|
|
: getDefaultVisibleProperties(plugin));
|
|
|
|
// Render each visible property
|
|
for (const propertyId of propertiesToShow) {
|
|
// Skip status and priority as they're rendered separately
|
|
if (
|
|
isPropertyForField(propertyId, "status", plugin) ||
|
|
isPropertyForField(propertyId, "priority", plugin)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (propertyId === "blocked") {
|
|
if (task.isBlocked) {
|
|
const blockedLabel = plugin.i18n.translate("ui.taskCard.blockedBadge");
|
|
const blockedCount = task.blockedBy?.length ?? 0;
|
|
const pillText =
|
|
blockedCount > 0 ? `${blockedLabel} (${blockedCount})` : blockedLabel;
|
|
const blockedPill = metadataLine.createSpan({
|
|
cls: "task-card__metadata-pill task-card__metadata-pill--blocked",
|
|
text: pillText,
|
|
});
|
|
setTooltip(
|
|
blockedPill,
|
|
plugin.i18n.translate("ui.taskCard.blockedBadgeTooltip"),
|
|
{
|
|
placement: "top",
|
|
}
|
|
);
|
|
metadataElements.push(blockedPill);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (propertyId === "blocking") {
|
|
if (task.isBlocking) {
|
|
const blockingLabel = plugin.i18n.translate("ui.taskCard.blockingBadge");
|
|
const blockingCount = task.blocking?.length ?? 0;
|
|
const pillText =
|
|
blockingCount > 0 ? `${blockingLabel} (${blockingCount})` : blockingLabel;
|
|
const blockingPill = metadataLine.createSpan({
|
|
cls: "task-card__metadata-pill task-card__metadata-pill--blocking",
|
|
text: pillText,
|
|
});
|
|
setTooltip(
|
|
blockingPill,
|
|
plugin.i18n.translate("ui.taskCard.blockingBadgeTooltip"),
|
|
{ placement: "top" }
|
|
);
|
|
metadataElements.push(blockingPill);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Google Calendar sync indicator
|
|
if (propertyId === "googleCalendarSync") {
|
|
// Check if task has a Google Calendar event ID in frontmatter
|
|
if (task.googleCalendarEventId) {
|
|
const syncPill = metadataLine.createSpan({
|
|
cls: "task-card__metadata-pill task-card__metadata-pill--google-calendar",
|
|
});
|
|
// Add calendar icon
|
|
setIcon(syncPill, "calendar");
|
|
setTooltip(
|
|
syncPill,
|
|
plugin.i18n.translate("ui.taskCard.googleCalendarSyncTooltip"),
|
|
{ placement: "top" }
|
|
);
|
|
metadataElements.push(syncPill);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const element = renderPropertyMetadata(
|
|
metadataLine,
|
|
propertyId,
|
|
task,
|
|
plugin,
|
|
opts
|
|
);
|
|
if (element) {
|
|
metadataElements.push(element);
|
|
}
|
|
}
|
|
|
|
// Show/hide metadata line based on content
|
|
updateMetadataVisibility(metadataLine, metadataElements);
|
|
|
|
// Add click handlers with single/double click distinction
|
|
const { clickHandler, dblclickHandler, contextmenuHandler } = createTaskClickHandler({
|
|
task,
|
|
plugin,
|
|
contextMenuHandler: async (e) => {
|
|
const path = card.dataset.taskPath;
|
|
if (!path) return;
|
|
await showTaskContextMenu(e, path, plugin, targetDate);
|
|
},
|
|
});
|
|
|
|
card.addEventListener("click", clickHandler);
|
|
card.addEventListener("dblclick", dblclickHandler);
|
|
card.addEventListener("contextmenu", contextmenuHandler);
|
|
|
|
// Hover preview
|
|
card.addEventListener("mouseover", createTaskHoverHandler(task, plugin));
|
|
|
|
return card;
|
|
}
|
|
|
|
/**
|
|
* Show context menu for task card
|
|
*/
|
|
export async function showTaskContextMenu(
|
|
event: MouseEvent,
|
|
taskPath: string,
|
|
plugin: TaskNotesPlugin,
|
|
targetDate: Date
|
|
) {
|
|
const file = plugin.app.vault.getAbstractFileByPath(taskPath);
|
|
const showFileMenuFallback = () => {
|
|
if (file instanceof TFile) {
|
|
showFileContextMenu(event, file, plugin);
|
|
}
|
|
};
|
|
|
|
try {
|
|
// Always fetch fresh task data - ignore any stale captured data
|
|
const task = await plugin.cacheManager.getTaskInfo(taskPath);
|
|
if (!task) {
|
|
showFileMenuFallback();
|
|
return;
|
|
}
|
|
|
|
const contextMenu = new TaskContextMenu({
|
|
task: task,
|
|
plugin: plugin,
|
|
targetDate: targetDate,
|
|
onUpdate: () => {
|
|
// Trigger refresh of views
|
|
plugin.app.workspace.trigger("tasknotes:refresh-views");
|
|
},
|
|
});
|
|
|
|
contextMenu.show(event);
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
console.error("Error creating context menu:", {
|
|
error: errorMessage,
|
|
taskPath,
|
|
});
|
|
new Notice(`Failed to create context menu: ${errorMessage}`);
|
|
showFileMenuFallback();
|
|
}
|
|
}
|
|
|
|
function showFileContextMenu(event: MouseEvent, file: TFile, plugin: TaskNotesPlugin) {
|
|
const menu = new Menu();
|
|
|
|
let populated = false;
|
|
try {
|
|
plugin.app.workspace.trigger("file-menu", menu, file, "tasknotes-bases-view");
|
|
populated = (menu as any).items?.length > 0;
|
|
} catch (error) {
|
|
populated = false;
|
|
}
|
|
|
|
if (!populated) {
|
|
menu.addItem((item) => {
|
|
item.setTitle("Open");
|
|
item.setIcon("file-text");
|
|
item.onClick(() => {
|
|
plugin.app.workspace.getLeaf(false).openFile(file);
|
|
});
|
|
});
|
|
menu.addItem((item) => {
|
|
item.setTitle("Open in new tab");
|
|
item.setIcon("external-link");
|
|
item.onClick(() => {
|
|
plugin.app.workspace.openLinkText(file.path, "", true);
|
|
});
|
|
});
|
|
}
|
|
|
|
menu.showAtMouseEvent(event);
|
|
}
|
|
|
|
/**
|
|
* Update an existing task card with new data
|
|
*/
|
|
export function updateTaskCard(
|
|
element: HTMLElement,
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
visibleProperties?: string[],
|
|
options: Partial<TaskCardOptions> = {}
|
|
): void {
|
|
const opts = { ...DEFAULT_TASK_CARD_OPTIONS, ...options };
|
|
// Use fresh UTC-anchored "today" if no targetDate provided
|
|
// This ensures recurring tasks show correct completion status for the current day
|
|
const targetDate = opts.targetDate || (() => {
|
|
const todayLocal = new Date();
|
|
return new Date(Date.UTC(todayLocal.getFullYear(), todayLocal.getMonth(), todayLocal.getDate()));
|
|
})();
|
|
|
|
// Update effective status
|
|
const effectiveStatus = task.recurrence
|
|
? getEffectiveTaskStatus(task, targetDate, plugin.statusManager.getCompletedStatuses()[0])
|
|
: task.status;
|
|
|
|
// Update main element classes using BEM structure
|
|
const isActivelyTracked = plugin.getActiveTimeSession(task) !== null;
|
|
const isCompleted = task.recurrence
|
|
? task.complete_instances?.includes(formatDateForStorage(targetDate)) || false // Direct check of complete_instances
|
|
: plugin.statusManager.isCompletedStatus(effectiveStatus); // Regular tasks use status config
|
|
const isSkipped = task.recurrence
|
|
? task.skipped_instances?.includes(formatDateForStorage(targetDate)) || false // Direct check of skipped_instances
|
|
: false; // Only recurring tasks can have skipped instances
|
|
const isRecurring = !!task.recurrence;
|
|
|
|
// Build BEM class names for update
|
|
const cardClasses = ["task-card"];
|
|
|
|
// Add modifiers
|
|
if (isCompleted) cardClasses.push("task-card--completed");
|
|
if (isSkipped) cardClasses.push("task-card--skipped");
|
|
if (task.archived) cardClasses.push("task-card--archived");
|
|
if (isActivelyTracked) cardClasses.push("task-card--actively-tracked");
|
|
if (isRecurring) cardClasses.push("task-card--recurring");
|
|
|
|
// Add priority modifier
|
|
if (task.priority) {
|
|
cardClasses.push(`task-card--priority-${task.priority}`);
|
|
}
|
|
|
|
// Add status modifier
|
|
if (effectiveStatus) {
|
|
cardClasses.push(`task-card--status-${effectiveStatus}`);
|
|
}
|
|
|
|
// Chevron position preference
|
|
if (plugin.settings?.subtaskChevronPosition === "left") {
|
|
cardClasses.push("task-card--chevron-left");
|
|
}
|
|
|
|
element.className = cardClasses.join(" ");
|
|
element.dataset.status = effectiveStatus;
|
|
|
|
// Get the main row container
|
|
const mainRow = element.querySelector(".task-card__main-row") as HTMLElement;
|
|
|
|
// Update priority and status colors
|
|
const priorityConfig = plugin.priorityManager.getPriorityConfig(task.priority);
|
|
if (priorityConfig) {
|
|
element.style.setProperty("--priority-color", priorityConfig.color);
|
|
}
|
|
|
|
const statusConfig = plugin.statusManager.getStatusConfig(effectiveStatus);
|
|
if (statusConfig) {
|
|
element.style.setProperty("--current-status-color", statusConfig.color);
|
|
}
|
|
|
|
// Update next status color for hover preview
|
|
const nextStatus = plugin.statusManager.getNextStatus(effectiveStatus);
|
|
const nextStatusConfig = plugin.statusManager.getStatusConfig(nextStatus);
|
|
if (nextStatusConfig) {
|
|
element.style.setProperty("--next-status-color", nextStatusConfig.color);
|
|
}
|
|
|
|
// Update checkbox if present
|
|
const checkbox = element.querySelector(".task-card__checkbox") as HTMLInputElement;
|
|
if (checkbox) {
|
|
checkbox.checked = plugin.statusManager.isCompletedStatus(effectiveStatus);
|
|
}
|
|
|
|
// Update status dot (conditional based on visible properties)
|
|
const shouldShowStatus =
|
|
!visibleProperties ||
|
|
visibleProperties.some((prop) => isPropertyForField(prop, "status", plugin));
|
|
const statusDot = element.querySelector(".task-card__status-dot") as HTMLElement;
|
|
|
|
if (shouldShowStatus) {
|
|
if (statusDot) {
|
|
// Update existing dot
|
|
if (statusConfig) {
|
|
statusDot.style.borderColor = statusConfig.color;
|
|
}
|
|
} else if (mainRow) {
|
|
// Add missing dot
|
|
const newStatusDot = mainRow.createEl("span", { cls: "task-card__status-dot" });
|
|
if (statusConfig) {
|
|
newStatusDot.style.borderColor = statusConfig.color;
|
|
}
|
|
prepareInteractiveControl(newStatusDot);
|
|
newStatusDot.addEventListener(
|
|
"click",
|
|
createStatusCycleHandler(task, plugin, element, newStatusDot, targetDate)
|
|
);
|
|
|
|
// Insert at the beginning after checkbox
|
|
const checkbox = element.querySelector(".task-card__checkbox");
|
|
if (checkbox) {
|
|
checkbox.insertAdjacentElement("afterend", newStatusDot);
|
|
} else {
|
|
mainRow.insertBefore(newStatusDot, mainRow.firstChild);
|
|
}
|
|
}
|
|
} else if (statusDot) {
|
|
// Remove dot if it shouldn't be visible
|
|
statusDot.remove();
|
|
}
|
|
|
|
// Update priority indicator (conditional based on visible properties)
|
|
const shouldShowPriority =
|
|
!visibleProperties ||
|
|
visibleProperties.some((prop) => isPropertyForField(prop, "priority", plugin));
|
|
const existingPriorityDot = element.querySelector(".task-card__priority-dot") as HTMLElement;
|
|
|
|
if (shouldShowPriority && task.priority && priorityConfig) {
|
|
if (!existingPriorityDot && mainRow) {
|
|
// Add priority dot if task has priority but no dot exists
|
|
const priorityDot = mainRow.createEl("span", {
|
|
cls: "task-card__priority-dot",
|
|
attr: { "aria-label": `Priority: ${priorityConfig.label}` },
|
|
});
|
|
priorityDot.style.borderColor = priorityConfig.color;
|
|
prepareInteractiveControl(priorityDot);
|
|
|
|
// Add click context menu for priority
|
|
priorityDot.addEventListener("click", (e) => {
|
|
e.stopPropagation(); // Don't trigger card click
|
|
const menu = new PriorityContextMenu({
|
|
currentValue: task.priority,
|
|
onSelect: async (newPriority) => {
|
|
try {
|
|
await plugin.updateTaskProperty(task, "priority", newPriority);
|
|
} catch (error) {
|
|
console.error("Error updating priority:", error);
|
|
new Notice("Failed to update priority");
|
|
}
|
|
},
|
|
plugin: plugin,
|
|
});
|
|
menu.show(e as MouseEvent);
|
|
});
|
|
|
|
// Insert after status dot if it exists, otherwise after checkbox
|
|
const statusDotForInsert = element.querySelector(".task-card__status-dot");
|
|
const checkbox = element.querySelector(".task-card__checkbox");
|
|
if (statusDotForInsert) {
|
|
statusDotForInsert.insertAdjacentElement("afterend", priorityDot);
|
|
} else if (checkbox) {
|
|
checkbox.insertAdjacentElement("afterend", priorityDot);
|
|
} else {
|
|
mainRow.insertBefore(priorityDot, mainRow.firstChild);
|
|
}
|
|
} else if (existingPriorityDot) {
|
|
// Update existing priority dot
|
|
existingPriorityDot.style.borderColor = priorityConfig.color;
|
|
existingPriorityDot.setAttribute("aria-label", `Priority: ${priorityConfig.label}`);
|
|
|
|
// Remove old event listener and add new one with updated task data
|
|
const newPriorityDot = existingPriorityDot.cloneNode(true) as HTMLElement;
|
|
prepareInteractiveControl(newPriorityDot);
|
|
newPriorityDot.addEventListener("click", (e) => {
|
|
e.stopPropagation(); // Don't trigger card click
|
|
const menu = new PriorityContextMenu({
|
|
currentValue: task.priority,
|
|
onSelect: async (newPriority) => {
|
|
try {
|
|
await plugin.updateTaskProperty(task, "priority", newPriority);
|
|
} catch (error) {
|
|
console.error("Error updating priority:", error);
|
|
new Notice("Failed to update priority");
|
|
}
|
|
},
|
|
plugin: plugin,
|
|
});
|
|
menu.show(e as MouseEvent);
|
|
});
|
|
existingPriorityDot.replaceWith(newPriorityDot);
|
|
}
|
|
} else if (existingPriorityDot) {
|
|
// Remove priority dot if it shouldn't be visible or task no longer has priority
|
|
existingPriorityDot.remove();
|
|
}
|
|
|
|
// Update badge indicators using helper
|
|
const badgesContainer = element.querySelector(".task-card__badges") as HTMLElement;
|
|
|
|
// Update recurring indicator
|
|
const recurrenceTooltip = task.recurrence
|
|
? getRecurrenceTooltip(plugin, task.recurrence, opts.propertyLabels)
|
|
: "";
|
|
updateBadgeIndicator(element, ".task-card__recurring-indicator", {
|
|
shouldExist: !!task.recurrence,
|
|
className: "task-card__recurring-indicator",
|
|
icon: "rotate-ccw",
|
|
tooltip: recurrenceTooltip,
|
|
onClick: createRecurrenceClickHandler(task, plugin),
|
|
});
|
|
|
|
// Update reminder indicator
|
|
const hasReminders = !!(task.reminders && task.reminders.length > 0);
|
|
const reminderCount = task.reminders?.length || 0;
|
|
const reminderTooltip = getReminderTooltip(plugin, reminderCount);
|
|
updateBadgeIndicator(element, ".task-card__reminder-indicator", {
|
|
shouldExist: hasReminders,
|
|
className: "task-card__reminder-indicator",
|
|
icon: "bell",
|
|
tooltip: reminderTooltip,
|
|
onClick: createReminderClickHandler(task, plugin),
|
|
});
|
|
|
|
// Update project indicator and chevron (async)
|
|
plugin.projectSubtasksService
|
|
.isTaskUsedAsProject(task.path)
|
|
.then((isProject: boolean) => {
|
|
// Remove old placeholders if they exist
|
|
element.querySelector(".task-card__project-indicator-placeholder")?.remove();
|
|
element.querySelector(".task-card__chevron-placeholder")?.remove();
|
|
|
|
// Update project indicator
|
|
updateBadgeIndicator(element, ".task-card__project-indicator", {
|
|
shouldExist: isProject,
|
|
className: "task-card__project-indicator",
|
|
icon: "folder",
|
|
tooltip: tTaskCard(plugin, "projectTooltip"),
|
|
onClick: createProjectClickHandler(task, plugin),
|
|
});
|
|
|
|
// Update chevron
|
|
const showChevron = isProject && plugin.settings?.showExpandableSubtasks;
|
|
const existingChevron = element.querySelector(".task-card__chevron") as HTMLElement;
|
|
|
|
if (showChevron && !existingChevron) {
|
|
const isExpanded = plugin.expandedProjectsService?.isExpanded(task.path) || false;
|
|
createBadgeIndicator({
|
|
container: badgesContainer || mainRow,
|
|
className: `task-card__chevron${isExpanded ? " task-card__chevron--expanded" : ""}`,
|
|
icon: "chevron-right",
|
|
tooltip: getChevronTooltip(plugin, isExpanded),
|
|
onClick: () => {
|
|
const chevron = element.querySelector(".task-card__chevron") as HTMLElement | null;
|
|
if (chevron) {
|
|
void createChevronClickHandler(task, plugin, element, chevron)();
|
|
}
|
|
},
|
|
});
|
|
|
|
if (isExpanded) {
|
|
toggleSubtasks(element, task, plugin, true).catch((error) => {
|
|
console.error("Error showing initial subtasks in update:", error);
|
|
});
|
|
}
|
|
} else if (!showChevron && existingChevron) {
|
|
existingChevron.remove();
|
|
// Clean up subtasks container
|
|
const subtasksContainer = element.querySelector(".task-card__subtasks") as HTMLElement;
|
|
if (subtasksContainer) {
|
|
const clickHandler = (subtasksContainer as any)._clickHandler;
|
|
if (clickHandler) {
|
|
subtasksContainer.removeEventListener("click", clickHandler);
|
|
delete (subtasksContainer as any)._clickHandler;
|
|
}
|
|
subtasksContainer.remove();
|
|
}
|
|
}
|
|
})
|
|
.catch((error: any) => {
|
|
console.error("Error checking if task is used as project in update:", error);
|
|
});
|
|
|
|
const blockingToggleEl = element.querySelector(".task-card__blocking-toggle") as HTMLElement;
|
|
if (blockingToggleEl) {
|
|
if (task.blocking && task.blocking.length > 0) {
|
|
blockingToggleEl.classList.add("is-visible");
|
|
blockingToggleEl.classList.remove("is-hidden");
|
|
const toggleLabel = plugin.i18n.translate("ui.taskCard.blockingToggle", {
|
|
count: task.blocking.length,
|
|
});
|
|
blockingToggleEl.setAttribute("aria-label", toggleLabel);
|
|
setTooltip(blockingToggleEl, toggleLabel, { placement: "top" });
|
|
blockingToggleEl.dataset.count = String(task.blocking.length);
|
|
if (blockingToggleEl.classList.contains("task-card__blocking-toggle--expanded")) {
|
|
toggleBlockingTasks(element, task, plugin, true).catch((error) => {
|
|
console.error("Error refreshing blocking tasks:", error);
|
|
});
|
|
}
|
|
} else {
|
|
blockingToggleEl.classList.remove("is-visible", "task-card__blocking-toggle--expanded");
|
|
blockingToggleEl.classList.add("is-hidden");
|
|
const existingBlockingContainer = element.querySelector(".task-card__blocking");
|
|
if (existingBlockingContainer) {
|
|
existingBlockingContainer.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update title
|
|
const titleText = element.querySelector(".task-card__title-text") as HTMLElement;
|
|
const titleContainer = element.querySelector(".task-card__title") as HTMLElement;
|
|
const titleIsCompleted = isCompleted;
|
|
if (titleText) {
|
|
titleText.textContent = task.title;
|
|
titleText.classList.toggle("completed", titleIsCompleted);
|
|
}
|
|
if (titleContainer) {
|
|
titleContainer.classList.toggle("completed", titleIsCompleted);
|
|
}
|
|
|
|
const legacyBlockedBadge = element.querySelector(".task-card__badge--blocked");
|
|
if (legacyBlockedBadge) {
|
|
legacyBlockedBadge.remove();
|
|
}
|
|
|
|
// Update metadata line
|
|
const metadataLine = element.querySelector(".task-card__metadata") as HTMLElement;
|
|
if (metadataLine) {
|
|
// Clear the metadata line and rebuild with DOM elements to support project links
|
|
metadataLine.innerHTML = "";
|
|
const metadataElements: HTMLElement[] = [];
|
|
|
|
// Get properties to display
|
|
const propertiesToShow =
|
|
visibleProperties ||
|
|
(plugin.settings.defaultVisibleProperties
|
|
? convertInternalToUserProperties(plugin.settings.defaultVisibleProperties, plugin)
|
|
: getDefaultVisibleProperties(plugin));
|
|
|
|
for (const propertyId of propertiesToShow) {
|
|
// Skip status and priority as they're rendered separately
|
|
if (
|
|
isPropertyForField(propertyId, "status", plugin) ||
|
|
isPropertyForField(propertyId, "priority", plugin)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (propertyId === "blocked") {
|
|
if (task.isBlocked) {
|
|
const blockedLabel = plugin.i18n.translate("ui.taskCard.blockedBadge");
|
|
const blockedCount = task.blockedBy?.length ?? 0;
|
|
const pillText =
|
|
blockedCount > 0 ? `${blockedLabel} (${blockedCount})` : blockedLabel;
|
|
const blockedPill = metadataLine.createSpan({
|
|
cls: "task-card__metadata-pill task-card__metadata-pill--blocked",
|
|
text: pillText,
|
|
});
|
|
setTooltip(
|
|
blockedPill,
|
|
plugin.i18n.translate("ui.taskCard.blockedBadgeTooltip"),
|
|
{ placement: "top" }
|
|
);
|
|
metadataElements.push(blockedPill);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (propertyId === "blocking") {
|
|
if (task.isBlocking) {
|
|
const blockingLabel = plugin.i18n.translate("ui.taskCard.blockingBadge");
|
|
const blockingCount = task.blocking?.length ?? 0;
|
|
const pillText =
|
|
blockingCount > 0 ? `${blockingLabel} (${blockingCount})` : blockingLabel;
|
|
const blockingPill = metadataLine.createSpan({
|
|
cls: "task-card__metadata-pill task-card__metadata-pill--blocking",
|
|
text: pillText,
|
|
});
|
|
setTooltip(
|
|
blockingPill,
|
|
plugin.i18n.translate("ui.taskCard.blockingBadgeTooltip"),
|
|
{ placement: "top" }
|
|
);
|
|
metadataElements.push(blockingPill);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const element = renderPropertyMetadata(metadataLine, propertyId, task, plugin, opts);
|
|
if (element) {
|
|
metadataElements.push(element);
|
|
}
|
|
}
|
|
|
|
// Hide metadata line if empty
|
|
updateMetadataVisibility(metadataLine, metadataElements);
|
|
}
|
|
|
|
// Animation is now handled separately - don't add it here during reconciler updates
|
|
}
|
|
|
|
/**
|
|
* Confirmation modal for task deletion
|
|
*/
|
|
class DeleteTaskConfirmationModal extends Modal {
|
|
private task: TaskInfo;
|
|
private onConfirm: () => Promise<void>;
|
|
|
|
constructor(app: App, task: TaskInfo, onConfirm: () => Promise<void>) {
|
|
super(app);
|
|
this.task = task;
|
|
this.onConfirm = onConfirm;
|
|
}
|
|
|
|
onOpen() {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
|
|
contentEl.createEl("h2", { text: "Delete Task" });
|
|
|
|
const description = contentEl.createEl("p");
|
|
description.appendText('Are you sure you want to delete the task "');
|
|
description.createEl("strong", { text: this.task.title });
|
|
description.appendText('"?');
|
|
|
|
contentEl.createEl("p", {
|
|
cls: "mod-warning",
|
|
text: "This action cannot be undone. The task file will be permanently deleted.",
|
|
});
|
|
|
|
const buttonContainer = contentEl.createEl("div", { cls: "modal-button-container" });
|
|
buttonContainer.style.display = "flex";
|
|
buttonContainer.style.gap = "10px";
|
|
buttonContainer.style.justifyContent = "flex-end";
|
|
buttonContainer.style.marginTop = "20px";
|
|
|
|
const cancelButton = buttonContainer.createEl("button", { text: "Cancel" });
|
|
cancelButton.addEventListener("click", () => {
|
|
this.close();
|
|
});
|
|
|
|
const deleteButton = buttonContainer.createEl("button", {
|
|
text: "Delete",
|
|
cls: "mod-warning",
|
|
});
|
|
deleteButton.style.backgroundColor = "var(--color-red)";
|
|
deleteButton.style.color = "white";
|
|
|
|
deleteButton.addEventListener("click", async () => {
|
|
try {
|
|
await this.onConfirm();
|
|
this.close();
|
|
new Notice("Task deleted successfully");
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
new Notice(`Failed to delete task: ${errorMessage}`);
|
|
console.error("Error in delete confirmation:", error);
|
|
}
|
|
});
|
|
|
|
// Focus the cancel button by default
|
|
cancelButton.focus();
|
|
}
|
|
|
|
onClose() {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show delete confirmation modal and handle task deletion
|
|
*/
|
|
export async function showDeleteConfirmationModal(
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const modal = new DeleteTaskConfirmationModal(plugin.app, task, async () => {
|
|
try {
|
|
await plugin.taskService.deleteTask(task);
|
|
resolve();
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
modal.open();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clean up event listeners and resources for a task card
|
|
*/
|
|
export function cleanupTaskCard(card: HTMLElement): void {
|
|
// Clean up subtasks container if it exists
|
|
const subtasksContainer = card.querySelector(".task-card__subtasks") as HTMLElement;
|
|
if (subtasksContainer) {
|
|
// Clean up the click handler
|
|
const clickHandler = (subtasksContainer as any)._clickHandler;
|
|
if (clickHandler) {
|
|
subtasksContainer.removeEventListener("click", clickHandler);
|
|
delete (subtasksContainer as any)._clickHandler;
|
|
}
|
|
}
|
|
|
|
// Note: Other event listeners on the card itself are automatically cleaned up
|
|
// when the card is removed from the DOM. We only need to manually clean up
|
|
// listeners that we store references to.
|
|
}
|
|
|
|
/**
|
|
* Toggle subtasks display for a project task card
|
|
*/
|
|
export async function toggleSubtasks(
|
|
card: HTMLElement,
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
expanded: boolean
|
|
): Promise<void> {
|
|
try {
|
|
let subtasksContainer = card.querySelector(".task-card__subtasks") as HTMLElement;
|
|
|
|
if (expanded) {
|
|
// Show subtasks
|
|
if (!subtasksContainer) {
|
|
// Create subtasks container after the main content
|
|
// Use card.ownerDocument for pop-out window support
|
|
subtasksContainer = card.ownerDocument.createElement("div");
|
|
subtasksContainer.className = "task-card__subtasks";
|
|
|
|
// Prevent clicks inside subtasks container from bubbling to parent card
|
|
const clickHandler = (e: Event) => {
|
|
e.stopPropagation();
|
|
};
|
|
subtasksContainer.addEventListener("click", clickHandler);
|
|
|
|
// Store handler reference for cleanup
|
|
(subtasksContainer as any)._clickHandler = clickHandler;
|
|
|
|
card.appendChild(subtasksContainer);
|
|
}
|
|
|
|
// Clear existing content properly (this will clean up subtask event listeners)
|
|
while (subtasksContainer.firstChild) {
|
|
subtasksContainer.removeChild(subtasksContainer.firstChild);
|
|
}
|
|
|
|
// Show loading state
|
|
const loadingEl = subtasksContainer.createEl("div", {
|
|
cls: "task-card__subtasks-loading",
|
|
text: plugin.i18n.translate("contextMenus.task.subtasks.loading"),
|
|
});
|
|
|
|
try {
|
|
// Get the file for this task
|
|
const file = plugin.app.vault.getAbstractFileByPath(task.path);
|
|
if (!(file instanceof TFile)) {
|
|
throw new Error("Task file not found");
|
|
}
|
|
|
|
// Get subtasks
|
|
if (!plugin.projectSubtasksService) {
|
|
throw new Error("projectSubtasksService not initialized");
|
|
}
|
|
|
|
const subtasks = await plugin.projectSubtasksService.getTasksLinkedToProject(file);
|
|
|
|
// Apply current filter to subtasks if available
|
|
// For now, we'll show all subtasks to keep the implementation simple
|
|
// Future enhancement: Apply the current view's filter to subtasks
|
|
// This could be implemented by accessing the FilterService's evaluateFilterNode method
|
|
|
|
// Remove loading indicator
|
|
loadingEl.remove();
|
|
|
|
if (subtasks.length === 0) {
|
|
subtasksContainer.createEl("div", {
|
|
cls: "task-card__subtasks-loading",
|
|
text: plugin.i18n.translate("contextMenus.task.subtasks.noSubtasks"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Sort subtasks
|
|
const sortedSubtasks = plugin.projectSubtasksService.sortTasks(subtasks);
|
|
|
|
// Build parent chain by traversing up the DOM hierarchy
|
|
const buildParentChain = (element: HTMLElement): string[] => {
|
|
const chain: string[] = [];
|
|
let current = element.closest(".task-card");
|
|
|
|
while (current) {
|
|
const taskPath = (current as any)._taskPath;
|
|
if (taskPath) {
|
|
chain.unshift(taskPath); // Add to beginning
|
|
}
|
|
// Find next parent task card (skip current)
|
|
current = current.parentElement?.closest(".task-card") as HTMLElement;
|
|
}
|
|
return chain;
|
|
};
|
|
|
|
const parentChain = buildParentChain(card);
|
|
|
|
// Render each subtask (but prevent circular references)
|
|
for (const subtask of sortedSubtasks) {
|
|
// Check for circular reference in the parent chain
|
|
if (parentChain.includes(subtask.path)) {
|
|
console.warn("Circular reference detected in task chain:", {
|
|
subtask: subtask.path,
|
|
parentChain,
|
|
cycle: [...parentChain, subtask.path],
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const subtaskCard = createTaskCard(subtask, plugin, undefined);
|
|
|
|
// Add subtask modifier class
|
|
subtaskCard.classList.add("task-card--subtask");
|
|
|
|
subtasksContainer.appendChild(subtaskCard);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error loading subtasks:", error);
|
|
loadingEl.textContent = plugin.i18n.translate(
|
|
"contextMenus.task.subtasks.loadFailed"
|
|
);
|
|
}
|
|
} else {
|
|
// Hide subtasks
|
|
if (subtasksContainer) {
|
|
// Clean up the click handler
|
|
const clickHandler = (subtasksContainer as any)._clickHandler;
|
|
if (clickHandler) {
|
|
subtasksContainer.removeEventListener("click", clickHandler);
|
|
delete (subtasksContainer as any)._clickHandler;
|
|
}
|
|
|
|
// Remove the container (this will also clean up child elements and their listeners)
|
|
subtasksContainer.remove();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error in toggleSubtasks:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function toggleBlockingTasks(
|
|
card: HTMLElement,
|
|
task: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
shouldExpand: boolean
|
|
): Promise<void> {
|
|
let container = card.querySelector(".task-card__blocking") as HTMLElement | null;
|
|
|
|
if (!shouldExpand) {
|
|
if (container) {
|
|
container.remove();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!container) {
|
|
container = card.createDiv({ cls: "task-card__blocking" });
|
|
// Prevent clicks within the dependency list from bubbling up to the parent card.
|
|
// Otherwise both the blocking task and the dependent task modals would open.
|
|
container.addEventListener("click", (event) => event.stopPropagation());
|
|
container.addEventListener("dblclick", (event) => event.stopPropagation());
|
|
container.addEventListener("contextmenu", (event) => event.stopPropagation());
|
|
}
|
|
|
|
container.empty();
|
|
const loadingEl = container.createDiv({
|
|
cls: "task-card__blocking-loading",
|
|
text: plugin.i18n.translate("ui.taskCard.loadingDependencies"),
|
|
});
|
|
|
|
try {
|
|
const dependentInfos = task.blocking
|
|
? await Promise.all(task.blocking.map((path) => plugin.cacheManager.getTaskInfo(path)))
|
|
: [];
|
|
const dependents = dependentInfos.filter((info): info is TaskInfo => Boolean(info));
|
|
|
|
loadingEl.remove();
|
|
|
|
if (dependents.length === 0) {
|
|
container.createDiv({
|
|
cls: "task-card__blocking-empty",
|
|
text: plugin.i18n.translate("ui.taskCard.blockingEmpty"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
dependents.forEach((dependentTask) => {
|
|
const dependentCard = createTaskCard(dependentTask, plugin, undefined);
|
|
dependentCard.classList.add("task-card--dependency");
|
|
container!.appendChild(dependentCard);
|
|
});
|
|
} catch (error) {
|
|
console.error("Error loading blocking tasks:", error);
|
|
loadingEl.textContent = plugin.i18n.translate("ui.taskCard.blockingLoadError");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh expanded subtasks in parent task cards when a subtask is updated
|
|
* This ensures that when a subtask is modified, any parent task cards that have
|
|
* that subtask expanded will refresh their subtasks display
|
|
*/
|
|
export async function refreshParentTaskSubtasks(
|
|
updatedTask: TaskInfo,
|
|
plugin: TaskNotesPlugin,
|
|
container: HTMLElement
|
|
): Promise<void> {
|
|
// Only process if the updated task has projects (i.e., is a subtask)
|
|
if (!updatedTask || !updatedTask.projects || updatedTask.projects.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Wait for cache to contain the updated task data to prevent race condition
|
|
// Try to get the updated task from cache, with a short retry loop
|
|
let attempts = 0;
|
|
const maxAttempts = 10; // Max 100ms wait
|
|
while (attempts < maxAttempts) {
|
|
try {
|
|
const cachedTask = await plugin.cacheManager.getTaskInfo(updatedTask.path);
|
|
if (cachedTask && cachedTask.dateModified === updatedTask.dateModified) {
|
|
// Cache has been updated
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
// Cache not ready yet
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
attempts++;
|
|
}
|
|
|
|
// Find all expanded project task cards in the container
|
|
const expandedChevrons = container.querySelectorAll(".task-card__chevron--expanded");
|
|
|
|
for (const chevron of expandedChevrons) {
|
|
const taskCard = chevron.closest(".task-card") as HTMLElement;
|
|
if (!taskCard) continue;
|
|
|
|
const projectTaskPath = taskCard.dataset.taskPath;
|
|
if (!projectTaskPath) continue;
|
|
|
|
// Check if this project task is referenced by the updated subtask
|
|
const projectFile = plugin.app.vault.getAbstractFileByPath(projectTaskPath);
|
|
if (!(projectFile instanceof TFile)) continue;
|
|
|
|
const projectFileName = projectFile.basename;
|
|
|
|
// Check if the updated task references this project
|
|
const isSubtaskOfThisProject = updatedTask.projects.flat(2).some((project) => {
|
|
if (
|
|
project &&
|
|
typeof project === "string" &&
|
|
project.startsWith("[[") &&
|
|
project.endsWith("]]")
|
|
) {
|
|
const linkContent = project.slice(2, -2).trim();
|
|
const linkedNoteName = parseLinktext(linkContent).path;
|
|
// Check both exact match and resolved file match
|
|
const resolvedFile = plugin.app.metadataCache.getFirstLinkpathDest(
|
|
linkedNoteName,
|
|
""
|
|
);
|
|
return (
|
|
linkedNoteName === projectFileName ||
|
|
(resolvedFile && resolvedFile.path === projectTaskPath)
|
|
);
|
|
}
|
|
return project === projectFileName || project === projectTaskPath;
|
|
});
|
|
|
|
if (isSubtaskOfThisProject) {
|
|
// Find the subtasks container
|
|
const subtasksContainer = taskCard.querySelector(".task-card__subtasks") as HTMLElement;
|
|
if (subtasksContainer) {
|
|
// Re-render the subtasks by calling toggleSubtasks
|
|
try {
|
|
// Get the parent task info
|
|
const parentTask = await plugin.cacheManager.getTaskInfo(projectTaskPath);
|
|
if (parentTask) {
|
|
// Clear and re-render subtasks
|
|
await toggleSubtasks(taskCard, parentTask, plugin, true);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error refreshing parent task subtasks:", error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|