diff --git a/README.md b/README.md index ea7ae71f..bad6a3a3 100644 --- a/README.md +++ b/README.md @@ -156,3 +156,18 @@ This plugin uses [FullCalendar.io](https://fullcalendar.io/) for its calendar co ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +--- + +## Feature Tweaks & Fixes + +- Angle-bracket markdown links resolve correctly across filters, overlays, projects, and dependencies. +- Kanban column headers show configured status/priority labels. +- Property-based task identification no longer injects or mutates `task`/`Task` tags unless explicitly set. +- Project folder icon toggles subtasks (chevron removed on task cards). +- Project titles are bold on task cards; subtasks remain normal weight. +- Subtasks inherit visible properties (including custom fields) and use Bases sort rules with caching. +- Status color shows as dot when visible, or as a right-edge stripe when hidden. +- Manual sorting for Kanban columns and project subtasks with persisted rank fields. +- Project cards refresh after subtask changes and deletes. + diff --git a/docs/releases/4.1.4.md b/docs/releases/4.1.4.md new file mode 100644 index 00000000..35c80ac8 --- /dev/null +++ b/docs/releases/4.1.4.md @@ -0,0 +1,38 @@ +# TaskNotes 4.1.4 + +This fork release consolidates all changes since upstream 4.1.3 into a single 4.1.4 patch. It focuses on link correctness, kanban usability, project/subtask handling, and quality fixes. + +## Highlights + +- Full support for markdown links with angle brackets in all key parsing paths: `[Title]()` is treated like `[Title](path)`. +- Kanban columns now use configured status/priority labels instead of raw values. +- Manual sorting for Kanban columns and project subtasks, with persisted ranks. +- Project/subtask UX improvements (folder icon toggle, bold project titles, custom fields on subtasks). + +## Added + +- Manual sorting in Kanban when the only sort rule is `rankByColumn` (drag handle + persisted `rankByColumn` ranks). +- Manual subtask ordering in the edit dialog when the only sort rule is `rankByColumn` (persisted `rankByProject`). + +## Improved + +- Project folder icon is now the expand/collapse toggle for subtasks (no separate chevron). +- Project titles render bold on task cards (subtasks remain normal weight). +- Subtask cards inherit the same visible properties as their parent cards, including custom fields. +- Subtask lists reuse the active Bases sort rules and stay consistent even when focus changes. +- Kanban project cards refresh immediately after adding subtasks and after external deletes. +- Kanban status/priority headers use user-configured labels. + +## Fixed + +- Angle-bracket markdown links resolve correctly across projects, subtasks, dependency badges, overlays, and filters. +- Blocked/Blocking relationships resolve and render correctly with markdown links. +- Property-based task identification no longer injects or modifies `task`/`Task` tags when tags are not explicitly set. +- Status color now shows as a stripe on the right edge when the status property is hidden. + +## Refactors / Quality + +- Centralized project display-name parsing to reduce duplication and ensure consistent link handling. +- Removed verbose Bases rendering logs for better performance. +- Removed license validation console logging to avoid leaking sensitive data. + diff --git a/generate-release-notes-import.mjs b/generate-release-notes-import.mjs index 3bb71d3e..4d49304b 100644 --- a/generate-release-notes-import.mjs +++ b/generate-release-notes-import.mjs @@ -67,6 +67,9 @@ const versionsWithDates = versionsToBundle.map(version => ({ version, date: getVersionDate(version) })).sort((a, b) => { + // Always show the current version first + if (a.version === currentVersion && b.version !== currentVersion) return -1; + if (b.version === currentVersion && a.version !== currentVersion) return 1; // Versions without dates go to the end if (!a.date && !b.date) return 0; if (!a.date) return 1; diff --git a/manifest.json b/manifest.json index b51908bd..0dcb7be1 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { "id": "tasknotes", "name": "TaskNotes", - "version": "4.1.3", "minAppVersion": "1.10.1", "description": "Note-based task management with calendar, pomodoro and time-tracking integration.", "author": "Callum Alpass", "authorUrl": "https://github.com/callumalpass", - "isDesktopOnly": false + "isDesktopOnly": false, + "version": "4.1.4" } diff --git a/package-lock.json b/package-lock.json index 0a5ef61c..cab575e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tasknotes", - "version": "4.0.3", + "version": "4.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tasknotes", - "version": "4.0.3", + "version": "4.1.4", "license": "MIT", "dependencies": { "@codemirror/view": "^6.37.2", @@ -140,6 +140,7 @@ "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -741,6 +742,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -750,6 +752,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -845,6 +848,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -868,6 +872,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1464,6 +1469,7 @@ "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.19.tgz", "integrity": "sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ==", "license": "MIT", + "peer": true, "dependencies": { "preact": "~10.12.1" } @@ -2651,6 +2657,7 @@ "integrity": "sha512-7199re3wvMAlVqXLaCyAr8IkJSXqkeVAxcYyB2rBu4Id5m2hhlGX1dQsdMBiCXLwu6/LLVqDvJggSNVQBzL6ZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/css-font-loading-module": "^0.0.7" }, @@ -2700,6 +2707,7 @@ "integrity": "sha512-0XtvrfxHlS2T+beBBSpo7GI8+QLyyTqMVQpNmPqB4woYxzrOEJ9JaUFBaBfCvycLeUkfVih1u6HAbtF+2d1EjQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@pixi/color": "7.2.4", "@pixi/constants": "7.2.4", @@ -2722,6 +2730,7 @@ "integrity": "sha512-w5tqb8cWEO5qIDaO9GEqRvxYhL0iMk0Wsngw23bbLm1gLEQmrFkB2tpJlRAqd7H82C3DrDDeWvkrrxW6+m4apg==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.2.4" } @@ -2732,6 +2741,7 @@ "integrity": "sha512-/JtmoB98fzIU8giN9xvlRvmvOi6u4MaD2DnKNOMHkQ1MBraj3pmrXM9fZ0JbNzi+324GraAAY76QidgHjIYoYQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.2.4", "@pixi/display": "7.2.4" @@ -2820,6 +2830,7 @@ "integrity": "sha512-3A2EumTjWJgXlDLOyuBrl9b6v1Za/E+/IjOGUIX843HH4NYaf1a2sfDfljx6r3oiDvy+VhuBFmgynRcV5IyA0Q==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.2.4", "@pixi/display": "7.2.4", @@ -2839,6 +2850,7 @@ "integrity": "sha512-wiALIqcRKib2BqeH9kOA5fOKWN352nqAspgbDa8gA7OyWzmNwqIedIlElixd0oLFOrIN5jOZAdzeKnoYQlt9Aw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.2.4", "@pixi/display": "7.2.4" @@ -2945,6 +2957,7 @@ "integrity": "sha512-DhR1B+/d0eXpxHIesJMXcVPrKFwQ+zRA1LvEIFfzewqfaRN3X6PMIuoKX8SIb6tl+Hq8Ba9Pe28zI7d2rmRzrA==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.2.4", "@pixi/display": "7.2.4" @@ -2990,6 +3003,7 @@ "integrity": "sha512-DGu7ktpe+zHhqR2sG9NsJt4mgvSObv5EqXTtUxD4Z0li1gmqF7uktpLyn5I6vSg1TTEL4TECClRDClVDGiykWw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.2.4", "@pixi/sprite": "7.2.4" @@ -3040,6 +3054,7 @@ "integrity": "sha512-VUGQHBOINIS4ePzoqafwxaGPVRTa3oM/mEutIIHbNGI3b+QvSO+1Dnk40M0zcH6Bo+MxQZbOZK5X/wO9oU5+LQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@pixi/color": "7.2.4", "@pixi/constants": "7.2.4", @@ -3860,6 +3875,7 @@ "integrity": "sha512-ruKWTv+x0OOxbzIw9nW5oWlUopvP/IQDjB5ZqmTglLIoDTctLlAJpAQFpNPJP/ZI7hTT9sARBosEfaKbcFuECw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.29.0", "@typescript-eslint/types": "5.29.0", @@ -4478,6 +4494,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4931,6 +4948,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", @@ -5166,6 +5184,7 @@ "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -5378,6 +5397,7 @@ "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -5812,6 +5832,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -6557,6 +6578,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6629,6 +6651,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8984,6 +9007,7 @@ "integrity": "sha512-yC3JvpP/ZcAZX5rYCtXO/g9k6VTCQz0VFE2v1FpxytWzUqfDtu0XL/pwnNvptzYItvGwomh1ehomRNMOyhCJKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.1.1", "@jest/types": "30.0.5", @@ -9745,6 +9769,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -11160,6 +11185,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12712,6 +12738,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12788,6 +12815,7 @@ "integrity": "sha512-V1EknKUubZ1gWFjiOZhDSNToOjs63/9O0puCgGS8aDOgpZY326fzFu15QAUjwaXzRZjf/qdsdBrckYdv9YxB8w==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.1.0", "@typescript-eslint/types": "7.1.0", diff --git a/package.json b/package.json index eb44eecb..c536938e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tasknotes", - "version": "4.1.3", + "version": "4.1.4", "description": "Note-based task management with calendar, pomodoro and time-tracking integration.", "main": "main.js", "scripts": { diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index f0e04bef..80da6b7f 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -2,7 +2,7 @@ import { Component, App, setIcon } from "obsidian"; import TaskNotesPlugin from "../main"; import { BasesDataAdapter } from "./BasesDataAdapter"; import { PropertyMappingService } from "./PropertyMappingService"; -import { TaskInfo, EVENT_TASK_UPDATED } from "../types"; +import { TaskInfo, EVENT_TASK_UPDATED, EVENT_TASK_DELETED } from "../types"; import { convertInternalToUserProperties } from "../utils/propertyMapping"; import { DEFAULT_INTERNAL_VISIBLE_PROPERTIES } from "../settings/defaults"; import { SearchBox } from "./components/SearchBox"; @@ -25,7 +25,8 @@ export abstract class BasesViewBase extends Component { protected propertyMapper: PropertyMappingService; protected containerEl: HTMLElement; protected rootElement: HTMLElement | null = null; - protected taskUpdateListener: any = null; + protected taskUpdateListener: any = null; + protected taskDeleteListener: any = null; protected updateDebounceTimer: number | null = null; protected dataUpdateDebounceTimer: number | null = null; protected relevantPathsCache: Set = new Set(); @@ -282,8 +283,8 @@ export abstract class BasesViewBase extends Component { * Setup listener for real-time task updates. * Uses Component.register() for automatic cleanup on unload. */ - protected setupTaskUpdateListener(): void { - if (this.taskUpdateListener) return; + protected setupTaskUpdateListener(): void { + if (this.taskUpdateListener) return; this.taskUpdateListener = this.plugin.emitter.on(EVENT_TASK_UPDATED, async (eventData: any) => { try { @@ -305,14 +306,25 @@ export abstract class BasesViewBase extends Component { } }); - // Register cleanup using Component lifecycle - this.register(() => { - if (this.taskUpdateListener) { - this.plugin.emitter.offref(this.taskUpdateListener); - this.taskUpdateListener = null; - } - }); - } + // Register cleanup using Component lifecycle + this.register(() => { + if (this.taskUpdateListener) { + this.plugin.emitter.offref(this.taskUpdateListener); + this.taskUpdateListener = null; + } + if (this.taskDeleteListener) { + this.plugin.emitter.offref(this.taskDeleteListener); + this.taskDeleteListener = null; + } + }); + + this.taskDeleteListener = this.plugin.emitter.on(EVENT_TASK_DELETED, () => { + if (!this.rootElement?.isConnected) return; + // Ensure subtasks/project indices are up to date + this.plugin.projectSubtasksService?.invalidateIndex(); + this.debouncedRefresh(); + }); + } /** * Debounced refresh to prevent multiple rapid re-renders. diff --git a/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index 46cdaa82..5a080511 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -24,6 +24,9 @@ export class KanbanView extends BasesViewBase { private taskInfoCache = new Map(); private containerListenersRegistered = false; private columnScrollers = new Map>(); // columnKey -> scroller + private columnTaskLists = new Map(); // column/cell -> tasks in render order + private readonly rankByColumnField = "rankByColumn"; + private dragReorderIntent = false; // View options (accessed via BasesViewConfig) private swimLanePropertyId: string | null = null; @@ -33,6 +36,7 @@ export class KanbanView extends BasesViewBase { private explodeListColumns = true; // Show items with list properties in multiple columns private columnOrders: Record = {}; private configLoaded = false; // Track if we've successfully loaded config + public manualSortEnabled = false; /** * Threshold for enabling virtual scrolling in kanban columns/swimlane cells. * Virtual scrolling activates when a column or cell has >= 15 cards. @@ -104,6 +108,8 @@ export class KanbanView extends BasesViewBase { const columnOrderStr = (this.config.get('columnOrder') as string) || '{}'; this.columnOrders = JSON.parse(columnOrderStr); + this.manualSortEnabled = this.isManualSortEnabled(); + // Read enableSearch toggle (default: false for backward compatibility) const enableSearchValue = this.config.get('enableSearch'); this.enableSearch = (enableSearchValue as boolean) ?? false; @@ -234,6 +240,7 @@ export class KanbanView extends BasesViewBase { if (!this.configLoaded && this.config) { this.readViewOptions(); } + this.manualSortEnabled = this.isManualSortEnabled(); // Now that config is loaded, setup search (idempotent: will only create once) if (this.rootElement) { @@ -250,6 +257,7 @@ export class KanbanView extends BasesViewBase { // Clear board and cleanup scrollers this.destroyColumnScrollers(); this.boardEl.empty(); + this.columnTaskLists.clear(); if (filteredTasks.length === 0) { // Show "no results" if search returned empty but we had tasks @@ -548,14 +556,16 @@ export class KanbanView extends BasesViewBase { for (const groupKey of orderedKeys) { const tasks = groups.get(groupKey) || []; + const sortedTasks = this.sortTasksForColumn(tasks, groupKey, null, groupByPropertyId); + this.columnTaskLists.set(this.getColumnListKey(groupKey, null), sortedTasks); // Filter empty columns if option enabled - if (this.hideEmptyColumns && tasks.length === 0) { + if (this.hideEmptyColumns && sortedTasks.length === 0) { continue; } // Create column - const column = await this.createColumn(groupKey, tasks, visibleProperties); + const column = await this.createColumn(groupKey, sortedTasks, visibleProperties, groupByPropertyId); if (this.boardEl) { this.boardEl.appendChild(column); } @@ -643,13 +653,14 @@ export class KanbanView extends BasesViewBase { const orderedKeys = this.applyColumnOrder(groupByPropertyId, columnKeys); // Render swimlane table - await this.renderSwimLaneTable(swimLanes, orderedKeys, pathToProps); + await this.renderSwimLaneTable(swimLanes, orderedKeys, pathToProps, groupByPropertyId); } private async renderSwimLaneTable( swimLanes: Map>, columnKeys: string[], - pathToProps: Map> + pathToProps: Map>, + groupByPropertyId: string ): Promise { if (!this.boardEl) return; @@ -681,7 +692,7 @@ export class KanbanView extends BasesViewBase { dragHandle.textContent = "⋮⋮"; const titleContainer = headerCell.createSpan({ cls: "kanban-view__column-title" }); - this.renderGroupTitleWrapper(titleContainer, columnKey); + this.renderGroupTitleWrapper(titleContainer, columnKey, groupByPropertyId); // Setup column header drag handlers for swimlane mode this.setupColumnHeaderDragHandlers(headerCell); @@ -702,7 +713,7 @@ export class KanbanView extends BasesViewBase { // Add swimlane title and count const titleEl = labelCell.createEl("div", { cls: "kanban-view__swimlane-title" }); - this.renderGroupTitleWrapper(titleEl, swimLaneKey); + this.renderGroupTitleWrapper(titleEl, swimLaneKey, this.swimLanePropertyId); // Count total tasks in this swimlane const totalTasks = Array.from(columns.values()).reduce((sum, tasks) => sum + tasks.length, 0); @@ -714,6 +725,8 @@ export class KanbanView extends BasesViewBase { // Render columns in this swimlane for (const columnKey of columnKeys) { const tasks = columns.get(columnKey) || []; + const sortedTasks = this.sortTasksForColumn(tasks, columnKey, swimLaneKey, groupByPropertyId); + this.columnTaskLists.set(this.getColumnListKey(columnKey, swimLaneKey), sortedTasks); // Create cell const cell = row.createEl("div", { @@ -731,17 +744,17 @@ export class KanbanView extends BasesViewBase { const tasksContainer = cell.createDiv({ cls: "kanban-view__tasks-container" }); // Use virtual scrolling for cells with 30+ tasks - if (tasks.length >= this.VIRTUAL_SCROLL_THRESHOLD) { + if (sortedTasks.length >= this.VIRTUAL_SCROLL_THRESHOLD) { await this.createVirtualSwimLaneCell( tasksContainer, `${swimLaneKey}:${columnKey}`, - tasks, + sortedTasks, visibleProperties ); } else { // Render tasks normally for smaller cells const cardOptions = this.getCardOptions(); - for (const task of tasks) { + for (const task of sortedTasks) { const cardWrapper = tasksContainer.createDiv({ cls: "kanban-view__card-wrapper" }); cardWrapper.setAttribute("draggable", "true"); cardWrapper.setAttribute("data-task-path", task.path); @@ -763,7 +776,8 @@ export class KanbanView extends BasesViewBase { private async createColumn( groupKey: string, tasks: TaskInfo[], - visibleProperties: string[] + visibleProperties: string[], + groupByPropertyId?: string | null ): Promise { const column = document.createElement("div"); column.className = "kanban-view__column"; @@ -780,7 +794,7 @@ export class KanbanView extends BasesViewBase { dragHandle.textContent = "⋮⋮"; const titleContainer = header.createSpan({ cls: "kanban-view__column-title" }); - this.renderGroupTitleWrapper(titleContainer, groupKey); + this.renderGroupTitleWrapper(titleContainer, groupKey, groupByPropertyId); header.createSpan({ cls: "kanban-view__column-count", @@ -992,6 +1006,7 @@ export class KanbanView extends BasesViewBase { column.addEventListener("dragover", (e: DragEvent) => { // Only handle task drags (not column drags) if (e.dataTransfer?.types.includes("text/x-kanban-column")) return; + if (e.dataTransfer?.types.includes("text/x-subtask")) return; e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; @@ -1017,17 +1032,28 @@ export class KanbanView extends BasesViewBase { column.addEventListener("drop", async (e: DragEvent) => { // Only handle task drags (not column drags) if (e.dataTransfer?.types.includes("text/x-kanban-column")) return; + if (e.dataTransfer?.types.includes("text/x-subtask")) return; e.preventDefault(); e.stopPropagation(); column.classList.remove("kanban-view__column--dragover"); if (!this.draggedTaskPath) return; + const groupByPropertyId = this.getGroupByPropertyId(); + // Update the task's groupBy property in Bases - await this.handleTaskDrop(this.draggedTaskPath, groupKey, null); + await this.handleTaskDrop(this.draggedTaskPath, groupKey, null, { skipRefresh: true }); + + // Append dragged tasks to the end of the column order + const orderedPaths = this.getOrderedPathsForColumn(groupKey, null) + .filter((path) => !this.draggedTaskPaths.includes(path)) + .concat(this.draggedTaskPaths); + + await this.updateManualOrderForColumn(orderedPaths, groupKey, null, groupByPropertyId); this.draggedTaskPath = null; this.draggedFromColumn = null; + this.debouncedRefresh(); }); // Drag end handler - cleanup in case drop doesn't fire @@ -1043,6 +1069,7 @@ export class KanbanView extends BasesViewBase { ): void { // Drag over handler cell.addEventListener("dragover", (e: DragEvent) => { + if (e.dataTransfer?.types.includes("text/x-subtask")) return; e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; @@ -1066,17 +1093,28 @@ export class KanbanView extends BasesViewBase { // Drop handler cell.addEventListener("drop", async (e: DragEvent) => { + if (e.dataTransfer?.types.includes("text/x-subtask")) return; e.preventDefault(); e.stopPropagation(); cell.classList.remove("kanban-view__swimlane-column--dragover"); if (!this.draggedTaskPath) return; + const groupByPropertyId = this.getGroupByPropertyId(); + // Update both the groupBy property and swimlane property - await this.handleTaskDrop(this.draggedTaskPath, columnKey, swimLaneKey); + await this.handleTaskDrop(this.draggedTaskPath, columnKey, swimLaneKey, { skipRefresh: true }); + + // Append dragged tasks to the end of the cell order + const orderedPaths = this.getOrderedPathsForColumn(columnKey, swimLaneKey) + .filter((path) => !this.draggedTaskPaths.includes(path)) + .concat(this.draggedTaskPaths); + + await this.updateManualOrderForColumn(orderedPaths, columnKey, swimLaneKey, groupByPropertyId); this.draggedTaskPath = null; this.draggedFromColumn = null; + this.debouncedRefresh(); }); // Drag end handler - cleanup in case drop doesn't fire @@ -1086,6 +1124,14 @@ export class KanbanView extends BasesViewBase { } private setupCardDragHandlers(cardWrapper: HTMLElement, task: TaskInfo): void { + cardWrapper.addEventListener("mousedown", (e: MouseEvent) => { + if (!this.manualSortEnabled) return; + const target = e.target as HTMLElement; + if (target.closest(".task-card__drag-handle")) { + this.dragReorderIntent = true; + } + }); + // Handle click for selection mode cardWrapper.addEventListener("click", (e: MouseEvent) => { // Check if this is a selection click @@ -1116,7 +1162,71 @@ export class KanbanView extends BasesViewBase { showTaskContextMenu(e, task.path, this.plugin, new Date()); }); + // Card-level dragover for manual reordering + cardWrapper.addEventListener("dragover", (e: DragEvent) => { + if (e.dataTransfer?.types.includes("text/x-kanban-column")) return; + if (e.dataTransfer?.types.includes("text/x-subtask")) return; + if (!this.draggedTaskPath) return; + if (!this.manualSortEnabled) return; + if (!this.dragReorderIntent) return; + e.preventDefault(); + e.stopPropagation(); + + const rect = cardWrapper.getBoundingClientRect(); + const insertBefore = (e as any).clientY < rect.top + rect.height / 2; + cardWrapper.classList.toggle("kanban-view__card-wrapper--dragover-top", insertBefore); + cardWrapper.classList.toggle("kanban-view__card-wrapper--dragover-bottom", !insertBefore); + + const card = cardWrapper.querySelector(".task-card") as HTMLElement | null; + if (card) { + card.classList.toggle("task-card--dragover-top", insertBefore); + card.classList.toggle("task-card--dragover-bottom", !insertBefore); + } + }); + + cardWrapper.addEventListener("dragleave", () => { + cardWrapper.classList.remove("kanban-view__card-wrapper--dragover-top"); + cardWrapper.classList.remove("kanban-view__card-wrapper--dragover-bottom"); + const card = cardWrapper.querySelector(".task-card") as HTMLElement | null; + if (card) { + card.classList.remove("task-card--dragover-top"); + card.classList.remove("task-card--dragover-bottom"); + } + }); + + cardWrapper.addEventListener("drop", async (e: DragEvent) => { + if (e.dataTransfer?.types.includes("text/x-kanban-column")) return; + if (e.dataTransfer?.types.includes("text/x-subtask")) return; + if (!this.draggedTaskPath) return; + if (!this.manualSortEnabled) return; + if (!this.dragReorderIntent) return; + e.preventDefault(); + e.stopPropagation(); + + const rect = cardWrapper.getBoundingClientRect(); + const insertBefore = (e as any).clientY < rect.top + rect.height / 2; + cardWrapper.classList.remove("kanban-view__card-wrapper--dragover-top"); + cardWrapper.classList.remove("kanban-view__card-wrapper--dragover-bottom"); + const card = cardWrapper.querySelector(".task-card") as HTMLElement | null; + if (card) { + card.classList.remove("task-card--dragover-top"); + card.classList.remove("task-card--dragover-bottom"); + } + + await this.handleCardDrop(cardWrapper, insertBefore); + }); + cardWrapper.addEventListener("dragstart", (e: DragEvent) => { + const target = e.target as HTMLElement; + if (target.closest(".task-card__subtasks")) { + e.preventDefault(); + e.stopPropagation(); + return; + } + if (target.closest("[data-tn-action]") || target.closest(".task-card__context-menu")) { + e.preventDefault(); + return; + } // Check if we're dragging selected tasks (batch drag) const selectionService = this.plugin.taskSelectionService; if (selectionService && selectionService.isSelected(task.path) && selectionService.getSelectionCount() > 1) { @@ -1172,6 +1282,7 @@ export class KanbanView extends BasesViewBase { }); cardWrapper.addEventListener("dragend", () => { + this.dragReorderIntent = false; // Remove dragging class from all dragged cards for (const path of this.draggedTaskPaths) { const wrapper = this.currentTaskElements.get(path); @@ -1196,34 +1307,53 @@ export class KanbanView extends BasesViewBase { }); } + private async handleCardDrop( + cardWrapper: HTMLElement, + insertBefore: boolean + ): Promise { + if (!this.manualSortEnabled) return; + const targetPath = cardWrapper.dataset.taskPath; + if (!targetPath) return; + + const column = cardWrapper.closest('[data-group]') as HTMLElement; + const swimlaneColumn = cardWrapper.closest('[data-column]') as HTMLElement; + const swimlaneRow = cardWrapper.closest('[data-swimlane]') as HTMLElement; + const columnKey = column?.dataset.group || swimlaneColumn?.dataset.column; + const swimlaneKey = swimlaneRow?.dataset.swimlane || null; + if (!columnKey) return; + + const draggedPaths = this.draggedTaskPaths.length > 0 + ? [...this.draggedTaskPaths] + : (this.draggedTaskPath ? [this.draggedTaskPath] : []); + if (draggedPaths.length === 0) return; + + const groupByPropertyId = this.getGroupByPropertyId(); + const orderedPaths = this.getOrderedPathsForColumn(columnKey, swimlaneKey) + .filter((path) => !draggedPaths.includes(path)); + + const targetIndex = orderedPaths.indexOf(targetPath); + const insertIndex = targetIndex === -1 + ? orderedPaths.length + : targetIndex + (insertBefore ? 0 : 1); + + orderedPaths.splice(insertIndex, 0, ...draggedPaths); + + await this.handleTaskDrop(draggedPaths[0], columnKey, swimlaneKey, { skipRefresh: true }); + await this.updateManualOrderForColumn(orderedPaths, columnKey, swimlaneKey, groupByPropertyId); + this.debouncedRefresh(); + } + private async handleTaskDrop( taskPath: string, newGroupValue: string, - newSwimLaneValue: string | null + newSwimLaneValue: string | null, + options?: { skipRefresh?: boolean } ): Promise { try { // Get the groupBy property from the controller const groupByPropertyId = this.getGroupByPropertyId(); if (!groupByPropertyId) return; - // Check if groupBy is a formula - formulas are read-only - if (groupByPropertyId.startsWith('formula.')) { - new Notice( - this.plugin.i18n.translate("views.kanban.errors.formulaGroupingReadOnly") || - "Cannot move tasks between formula-based columns. Formula values are computed and cannot be directly modified." - ); - return; - } - - // Check if swimlane is a formula - formulas are read-only - if (newSwimLaneValue !== null && this.swimLanePropertyId?.startsWith('formula.')) { - new Notice( - this.plugin.i18n.translate("views.kanban.errors.formulaSwimlaneReadOnly") || - "Cannot move tasks between formula-based swimlanes. Formula values are computed and cannot be directly modified." - ); - return; - } - const cleanGroupBy = this.stripPropertyPrefix(groupByPropertyId); const isGroupByListProperty = this.explodeListColumns && this.isListTypeProperty(cleanGroupBy); @@ -1244,23 +1374,48 @@ export class KanbanView extends BasesViewBase { ? this.draggedSourceSwimlanes.get(path) : this.draggedFromSwimlane; + const sameColumn = sourceColumn === newGroupValue; + const sameSwimlane = newSwimLaneValue === null || sourceSwimlane === newSwimLaneValue; + + // Check if groupBy is a formula - formulas are read-only when moving columns + if (groupByPropertyId.startsWith('formula.') && !sameColumn) { + new Notice( + this.plugin.i18n.translate("views.kanban.errors.formulaGroupingReadOnly") || + "Cannot move tasks between formula-based columns. Formula values are computed and cannot be directly modified." + ); + return; + } + + // Check if swimlane is a formula - formulas are read-only when moving swimlanes + if (newSwimLaneValue !== null && this.swimLanePropertyId?.startsWith('formula.') && !sameSwimlane) { + new Notice( + this.plugin.i18n.translate("views.kanban.errors.formulaSwimlaneReadOnly") || + "Cannot move tasks between formula-based swimlanes. Formula values are computed and cannot be directly modified." + ); + return; + } + // Update groupBy property - if (isGroupByListProperty && sourceColumn) { - // For list properties, remove the source value and add the target value - await this.updateListPropertyOnDrop(path, groupByPropertyId, sourceColumn, newGroupValue); - } else { - // For non-list properties, simply replace the value - await this.updateTaskFrontmatterProperty(path, groupByPropertyId, newGroupValue); + if (!sameColumn) { + if (isGroupByListProperty && sourceColumn) { + // For list properties, remove the source value and add the target value + await this.updateListPropertyOnDrop(path, groupByPropertyId, sourceColumn, newGroupValue); + } else { + // For non-list properties, simply replace the value + await this.updateTaskFrontmatterProperty(path, groupByPropertyId, newGroupValue); + } } // Update swimlane property if applicable if (newSwimLaneValue !== null && this.swimLanePropertyId) { - if (isSwimlaneListProperty && sourceSwimlane) { - // For list swimlane properties, remove source and add target - await this.updateListPropertyOnDrop(path, this.swimLanePropertyId, sourceSwimlane, newSwimLaneValue); - } else { - // For non-list swimlane properties, simply replace the value - await this.updateTaskFrontmatterProperty(path, this.swimLanePropertyId, newSwimLaneValue); + if (!sameSwimlane) { + if (isSwimlaneListProperty && sourceSwimlane) { + // For list swimlane properties, remove source and add target + await this.updateListPropertyOnDrop(path, this.swimLanePropertyId, sourceSwimlane, newSwimLaneValue); + } else { + // For non-list swimlane properties, simply replace the value + await this.updateTaskFrontmatterProperty(path, this.swimLanePropertyId, newSwimLaneValue); + } } } } @@ -1272,7 +1427,9 @@ export class KanbanView extends BasesViewBase { } // Refresh to show updated position - this.debouncedRefresh(); + if (!options?.skipRefresh) { + this.debouncedRefresh(); + } } catch (error) { console.error("[TaskNotes][KanbanView] Error updating task:", error); } @@ -1492,15 +1649,193 @@ export class KanbanView extends BasesViewBase { return String(value); } - private renderGroupTitleWrapper(container: HTMLElement, title: string): void { + private getGroupDisplayTitle(title: string, propertyId?: string | null): string { + if (!propertyId) { + return title; + } + + const cleanProperty = this.stripPropertyPrefix(propertyId); + + // Use labels for status columns + const statusField = this.plugin.fieldMapper.toUserField('status'); + if (cleanProperty === statusField) { + const statusConfig = this.plugin.statusManager.getStatusConfig(title); + if (statusConfig?.label) { + return statusConfig.label; + } + } + + // Use labels for priority columns + const priorityField = this.plugin.fieldMapper.toUserField('priority'); + if (cleanProperty === priorityField) { + const priorityConfig = this.plugin.priorityManager.getPriorityConfig(title); + if (priorityConfig?.label) { + return priorityConfig.label; + } + } + + return title; + } + + private renderGroupTitleWrapper(container: HTMLElement, title: string, propertyId?: string | null): void { // Use this.app if available (set by Bases), otherwise fall back to plugin.app const app = this.app || this.plugin.app; + const displayTitle = this.getGroupDisplayTitle(title, propertyId); const linkServices: LinkServices = { metadataCache: app.metadataCache, workspace: app.workspace, }; - renderGroupTitle(container, title, linkServices); + renderGroupTitle(container, displayTitle, linkServices); + } + + private getColumnListKey(columnKey: string, swimlaneKey: string | null): string { + if (swimlaneKey !== null && this.swimLanePropertyId) { + return `${swimlaneKey}:${columnKey}`; + } + return columnKey; + } + + private getRankKey( + groupByPropertyId: string, + columnKey: string, + swimlaneKey: string | null + ): string { + const base = `${groupByPropertyId}::${columnKey}`; + if (swimlaneKey !== null && this.swimLanePropertyId) { + return `${base}::${this.swimLanePropertyId}::${swimlaneKey}`; + } + return base; + } + + private getSortRules(): Array<{ property?: string; direction?: string }> { + try { + const sortConfig = this.dataAdapter?.getSortConfig?.() ?? this.config?.getSort?.(); + if (!sortConfig) return []; + if (Array.isArray(sortConfig)) return sortConfig; + if (Array.isArray(sortConfig.rules)) return sortConfig.rules; + if (Array.isArray(sortConfig.sort)) return sortConfig.sort; + if (Array.isArray(sortConfig.items)) return sortConfig.items; + } catch (e) { + console.warn("[TaskNotes][KanbanView] Failed to read sort rules:", e); + } + return []; + } + + private getSortRuleProperty(rule: any): string { + if (!rule) return ""; + if (typeof rule === "string") return rule; + const prop = rule.property ?? rule.prop ?? rule.id; + if (typeof prop === "string") return prop; + if (prop?.id && typeof prop.id === "string") return prop.id; + if (prop?.name && typeof prop.name === "string") return prop.name; + return ""; + } + + private isManualSortEnabled(): boolean { + const sortRules = this.getSortRules(); + if (sortRules.length !== 1) return false; + return this.isManualSortRule(sortRules[0]); + } + + private isManualSortRule(rule: { property?: string } | any): boolean { + const prop = this.getSortRuleProperty(rule); + return prop === this.rankByColumnField || prop.endsWith(`.${this.rankByColumnField}`); + } + + private getTaskRank(task: TaskInfo, rankKey: string): number | null { + const custom = (task as any)?.rankByColumn ?? task.customProperties?.[this.rankByColumnField]; + if (!custom || typeof custom !== "object") return null; + const raw = (custom as Record)[rankKey]; + if (raw === null || raw === undefined) return null; + const parsed = typeof raw === "number" ? raw : Number(raw); + return Number.isFinite(parsed) ? parsed : null; + } + + private sortTasksForColumn( + tasks: TaskInfo[], + columnKey: string, + swimlaneKey: string | null, + groupByPropertyId: string | null + ): TaskInfo[] { + if (!this.manualSortEnabled) return tasks; + if (tasks.length < 2) return tasks; + if (!groupByPropertyId) return tasks; + const rankKey = this.getRankKey(groupByPropertyId, columnKey, swimlaneKey); + const indexed = tasks.map((task, index) => ({ + task, + index, + rank: this.getTaskRank(task, rankKey), + })); + + indexed.sort((a, b) => { + const aHasRank = a.rank !== null && a.rank !== undefined; + const bHasRank = b.rank !== null && b.rank !== undefined; + if (aHasRank && bHasRank) { + if (a.rank! === b.rank!) return a.index - b.index; + return a.rank! - b.rank!; + } + if (aHasRank) return -1; + if (bHasRank) return 1; + return a.index - b.index; + }); + + return indexed.map((entry) => entry.task); + } + + private getOrderedPathsForColumn( + columnKey: string, + swimlaneKey: string | null + ): string[] { + const listKey = this.getColumnListKey(columnKey, swimlaneKey); + const tasks = this.columnTaskLists.get(listKey) || []; + return tasks.map((task) => task.path); + } + + private async updateRankByColumnForPaths( + paths: string[], + rankKey: string + ): Promise { + for (let i = 0; i < paths.length; i++) { + const path = paths[i]; + const rank = i * 1000; + const task = this.taskInfoCache.get(path); + const currentRank = task ? this.getTaskRank(task, rankKey) : null; + if (currentRank === rank) continue; + await this.updateTaskRankByColumn(path, rankKey, rank); + } + } + + private async updateTaskRankByColumn( + taskPath: string, + rankKey: string, + rank: number + ): Promise { + const file = this.plugin.app.vault.getAbstractFileByPath(taskPath); + if (!file || !(file instanceof TFile)) { + throw new Error(`Cannot find task file: ${taskPath}`); + } + + await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + const current = frontmatter[this.rankByColumnField]; + const rankMap = current && typeof current === "object" && !Array.isArray(current) + ? { ...current } + : {}; + rankMap[rankKey] = rank; + frontmatter[this.rankByColumnField] = rankMap; + }); + } + + private async updateManualOrderForColumn( + orderedPaths: string[], + columnKey: string, + swimlaneKey: string | null, + groupByPropertyId: string | null + ): Promise { + if (!this.manualSortEnabled) return; + if (!groupByPropertyId) return; + const rankKey = this.getRankKey(groupByPropertyId, columnKey, swimlaneKey); + await this.updateRankByColumnForPaths(orderedPaths, rankKey); } private applyColumnOrder(groupBy: string, actualKeys: string[]): string[] { @@ -1557,6 +1892,7 @@ export class KanbanView extends BasesViewBase { const targetDate = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())); return { targetDate, + manualSortEnabled: this.manualSortEnabled, }; } diff --git a/src/bases/helpers.ts b/src/bases/helpers.ts index e0b9ddae..4cfc4077 100644 --- a/src/bases/helpers.ts +++ b/src/bases/helpers.ts @@ -189,12 +189,22 @@ export function createTaskInfoFromBasesData( } }); + // Preserve manual ordering metadata stored in frontmatter + const manualOrderProps: Record = {}; + if (props.rankByColumn !== undefined) { + manualOrderProps.rankByColumn = props.rankByColumn; + } + if (props.rankByProject !== undefined) { + manualOrderProps.rankByProject = props.rankByProject; + } + // Merge file properties with existing custom properties return { ...taskInfo, customProperties: { ...mappedTaskInfo.customProperties, ...taskInfo.customProperties, + ...manualOrderProps, ...fileProperties, }, }; @@ -238,12 +248,9 @@ export function getBasesVisibleProperties(basesContainer: any): BasesSelectedPro try { const controller = (basesContainer?.controller ?? basesContainer) as any; const query = (basesContainer?.query ?? controller?.query) as any; - console.log("[TaskNotes][Bases] getBasesVisibleProperties - controller:", !!controller, "query:", !!query); - - if (!controller) { - console.log("[TaskNotes][Bases] getBasesVisibleProperties - no controller, returning empty"); - return []; - } + if (!controller) { + return []; + } // Build index from available properties const propsMap: Record | undefined = query?.properties; @@ -273,11 +280,9 @@ export function getBasesVisibleProperties(basesContainer: any): BasesSelectedPro if (config && typeof config.getOrder === "function") { try { order = config.getOrder(); - console.log("[TaskNotes][Bases] getBasesVisibleProperties - got order from config.getOrder():", order); - } catch (e) { - console.log("[TaskNotes][Bases] getBasesVisibleProperties - config.getOrder() failed:", e); + } catch (e) { + } } - } // Fallback to internal API if public API didn't work if (!order || !Array.isArray(order) || order.length === 0) { @@ -287,18 +292,16 @@ export function getBasesVisibleProperties(basesContainer: any): BasesSelectedPro (query?.getViewConfig?.("order") as string[] | undefined) ?? (fullCfg as any)?.order ?? (fullCfg as any)?.columns?.order; - if (order && Array.isArray(order) && order.length > 0) { - console.log("[TaskNotes][Bases] getBasesVisibleProperties - got order from internal API:", order); + if (order && Array.isArray(order) && order.length > 0) { + } + } catch (_) { + order = (fullCfg as any)?.order ?? (fullCfg as any)?.columns?.order; } - } catch (_) { - order = (fullCfg as any)?.order ?? (fullCfg as any)?.columns?.order; } - } - if (!order || !Array.isArray(order) || order.length === 0) { - console.log("[TaskNotes][Bases] getBasesVisibleProperties - no order found, returning empty. order:", order); - return []; - } + if (!order || !Array.isArray(order) || order.length === 0) { + return []; + } const orderedIds: string[] = order.map(normalizeToId).filter((id): id is string => !!id); @@ -312,11 +315,10 @@ export function getBasesVisibleProperties(basesContainer: any): BasesSelectedPro visible: true, }; }); - } catch (e) { - console.log("[TaskNotes][Bases] getBasesVisibleProperties failed:", e); - return []; + } catch (e) { + return []; + } } -} export async function renderTaskNotesInBasesView( container: HTMLElement, @@ -326,7 +328,6 @@ export async function renderTaskNotesInBasesView( taskElementsMap?: Map, precomputedVisibleProperties?: string[] ): Promise { - console.log("[TaskNotes][Bases] renderTaskNotesInBasesView ENTRY - tasks:", taskNotes.length, "basesContainer:", !!basesContainer, "precomputed props:", precomputedVisibleProperties?.length); const { createTaskCard } = await import("../ui/TaskCard"); const taskListEl = document.createElement("div"); @@ -340,41 +341,32 @@ export async function renderTaskNotesInBasesView( // Only extract properties if not precomputed if (!visibleProperties && basesContainer) { - console.log("[TaskNotes][Bases] basesContainer type:", typeof basesContainer, "keys:", basesContainer ? Object.keys(basesContainer).slice(0, 10) : []); - const basesVisibleProperties = getBasesVisibleProperties(basesContainer); - console.log("[TaskNotes][Bases] getBasesVisibleProperties returned:", basesVisibleProperties.length, "properties"); - - if (basesVisibleProperties.length > 0) { - // Extract just the property IDs for TaskCard - visibleProperties = basesVisibleProperties.map((p) => p.id); - console.log("[TaskNotes][Bases] Raw property IDs from Bases:", visibleProperties); - - // Map Bases property IDs to TaskCard-compatible property names - const hasBlockedByRequested = basesVisibleProperties.some(p => - p.id === "blockedBy" || p.id === "note.blockedBy" || p.id === "task.blockedBy" - ); - console.log("[TaskNotes][Bases] hasBlockedByRequested:", hasBlockedByRequested); - - visibleProperties = visibleProperties - .map((propId) => { - const mapped = mapBasesPropertyToTaskCardProperty(propId, plugin); - if (propId !== mapped) { - console.log(`[TaskNotes][Bases] Mapped ${propId} → ${mapped}`); - } - return mapped; - }) - // Filter out computed dependency properties unless explicitly requested via blockedBy - .filter((propId) => { - if (propId === "blocked" || propId === "blocking") { - const keep = hasBlockedByRequested; - console.log(`[TaskNotes][Bases] Filtering ${propId}: ${keep ? "KEEP" : "REMOVE"}`); - return keep; - } - return true; - }); - console.log("[TaskNotes][Bases] Final visible properties:", visibleProperties); + const basesVisibleProperties = getBasesVisibleProperties(basesContainer); + + if (basesVisibleProperties.length > 0) { + // Extract just the property IDs for TaskCard + visibleProperties = basesVisibleProperties.map((p) => p.id); + + // Map Bases property IDs to TaskCard-compatible property names + const hasBlockedByRequested = basesVisibleProperties.some(p => + p.id === "blockedBy" || p.id === "note.blockedBy" || p.id === "task.blockedBy" + ); + + visibleProperties = visibleProperties + .map((propId) => { + const mapped = mapBasesPropertyToTaskCardProperty(propId, plugin); + return mapped; + }) + // Filter out computed dependency properties unless explicitly requested via blockedBy + .filter((propId) => { + if (propId === "blocked" || propId === "blocking") { + const keep = hasBlockedByRequested; + return keep; + } + return true; + }); + } } - } // Use plugin default properties if no Bases properties available if (!visibleProperties || visibleProperties.length === 0) { @@ -388,8 +380,7 @@ export async function renderTaskNotesInBasesView( // Filter out blocked/blocking from defaults since they're computed properties // that should only show when explicitly requested via blockedBy visibleProperties = visibleProperties.filter((p) => p !== "blocked" && p !== "blocking"); - console.log("[TaskNotes][Bases] Using default properties (filtered):", visibleProperties); - } + } for (const taskInfo of taskNotes) { try { @@ -442,33 +433,25 @@ export async function renderGroupedTasksInBasesView( if (basesVisibleProperties.length > 0) { visibleProperties = basesVisibleProperties.map((p) => p.id); - console.log("[TaskNotes][Bases][Grouped] Raw property IDs from Bases:", visibleProperties); - - // Map Bases property IDs to TaskCard-compatible property names - const hasBlockedByRequested = basesVisibleProperties.some(p => - p.id === "blockedBy" || p.id === "note.blockedBy" || p.id === "task.blockedBy" - ); - console.log("[TaskNotes][Bases][Grouped] hasBlockedByRequested:", hasBlockedByRequested); + // Map Bases property IDs to TaskCard-compatible property names + const hasBlockedByRequested = basesVisibleProperties.some(p => + p.id === "blockedBy" || p.id === "note.blockedBy" || p.id === "task.blockedBy" + ); - visibleProperties = visibleProperties - .map((propId) => { - const mapped = mapBasesPropertyToTaskCardProperty(propId, plugin); - if (propId !== mapped) { - console.log(`[TaskNotes][Bases][Grouped] Mapped ${propId} → ${mapped}`); - } - return mapped; - }) - // Filter out computed dependency properties unless explicitly requested via blockedBy - .filter((propId) => { - if (propId === "blocked" || propId === "blocking") { - const keep = hasBlockedByRequested; - console.log(`[TaskNotes][Bases][Grouped] Filtering ${propId}: ${keep ? "KEEP" : "REMOVE"}`); - return keep; - } - return true; - }); - console.log("[TaskNotes][Bases][Grouped] Final visible properties:", visibleProperties); - } + visibleProperties = visibleProperties + .map((propId) => { + const mapped = mapBasesPropertyToTaskCardProperty(propId, plugin); + return mapped; + }) + // Filter out computed dependency properties unless explicitly requested via blockedBy + .filter((propId) => { + if (propId === "blocked" || propId === "blocking") { + const keep = hasBlockedByRequested; + return keep; + } + return true; + }); + } // Use plugin default properties if no Bases properties available if (!visibleProperties || visibleProperties.length === 0) { @@ -482,8 +465,7 @@ export async function renderGroupedTasksInBasesView( // Filter out blocked/blocking from defaults since they're computed properties // that should only show when explicitly requested via blockedBy visibleProperties = visibleProperties.filter((p) => p !== "blocked" && p !== "blocking"); - console.log("[TaskNotes][Bases] Using default properties (filtered):", visibleProperties); - } + } // Get groupedData from public API const groupedData = viewContext?.data?.groupedData; diff --git a/src/editor/TaskLinkOverlay.ts b/src/editor/TaskLinkOverlay.ts index 11b8263c..92c32aaf 100644 --- a/src/editor/TaskLinkOverlay.ts +++ b/src/editor/TaskLinkOverlay.ts @@ -423,6 +423,11 @@ function parseMarkdownLinkSync( const displayText = match[1].trim(); let linkPath = match[2].trim(); + // Strip angle brackets used to escape special characters or spaces + if (linkPath.startsWith("<") && linkPath.endsWith(">")) { + linkPath = linkPath.slice(1, -1).trim(); + } + if (!linkPath || linkPath.length === 0) { return null; } diff --git a/src/main.ts b/src/main.ts index 528e47a4..1c030a51 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,6 +34,7 @@ import { TaskInfo, EVENT_DATA_CHANGED, EVENT_TASK_UPDATED, + EVENT_TASK_DELETED, EVENT_DATE_CHANGED, } from "./types"; @@ -362,6 +363,20 @@ export default class TaskNotesPlugin extends Plugin { this.notificationService = new NotificationService(this); this.viewPerformanceService = new ViewPerformanceService(this); + // Listen for vault deletes triggered outside TaskNotes to keep caches and views in sync + this.registerEvent( + this.app.vault.on("delete", (file) => { + if (!(file instanceof TFile)) return; + const cache = this.app.metadataCache.getCache(file.path); + if (!cache?.frontmatter || !this.cacheManager.isTaskFile(cache.frontmatter)) { + return; + } + this.cacheManager.clearCacheEntry(file.path); + this.projectSubtasksService?.invalidateIndex(); + this.emitter.trigger(EVENT_TASK_DELETED, { path: file.path }); + }) + ); + // Initialize Bases filter converter for saved view export const { BasesFilterConverter } = await import("./services/BasesFilterConverter"); this.basesFilterConverter = new BasesFilterConverter(this); diff --git a/src/modals/TaskEditModal.ts b/src/modals/TaskEditModal.ts index 1b7b6d30..23c739c6 100644 --- a/src/modals/TaskEditModal.ts +++ b/src/modals/TaskEditModal.ts @@ -2,7 +2,7 @@ import { App, Notice, TFile, TAbstractFile, setIcon, setTooltip } from "obsidian"; import TaskNotesPlugin from "../main"; import { TaskModal } from "./TaskModal"; -import { TaskDependency, TaskInfo } from "../types"; +import { EVENT_TASK_UPDATED, TaskDependency, TaskInfo } from "../types"; import { getCurrentTimestamp, formatDateForStorage, @@ -674,6 +674,12 @@ export class TaskEditModal extends TaskModal { if (hasSubtaskChanges) { await this.applySubtaskChanges(updatedTask); + this.plugin.projectSubtasksService?.invalidateIndex(); + this.plugin.emitter.trigger(EVENT_TASK_UPDATED, { + path: updatedTask.path, + originalTask: updatedTask, + updatedTask: updatedTask, + }); } if (this.unresolvedBlockingEntries.length > 0) { @@ -1078,16 +1084,31 @@ export class TaskEditModal extends TaskModal { if (!(taskFile instanceof TFile)) return; const subtasks = await this.plugin.projectSubtasksService.getTasksLinkedToProject(taskFile); + const manualSortEnabled = this.isManualSortEnabled(); + const rankedSubtasks = subtasks.map((subtask, index) => ({ + subtask, + index, + rank: this.getProjectRankForSubtask(subtask, taskFile.path), + })); + if (manualSortEnabled) { + rankedSubtasks.sort((a, b) => { + const rankA = a.rank ?? Number.MAX_SAFE_INTEGER; + const rankB = b.rank ?? Number.MAX_SAFE_INTEGER; + if (rankA !== rankB) return rankA - rankB; + return a.index - b.index; + }); + } this.selectedSubtaskFiles = []; this.initialSubtaskFiles = []; - for (const subtask of subtasks) { - const subtaskFile = this.app.vault.getAbstractFileByPath(subtask.path); + for (const entry of rankedSubtasks) { + const subtaskFile = this.app.vault.getAbstractFileByPath(entry.subtask.path); if (subtaskFile) { this.selectedSubtaskFiles.push(subtaskFile); this.initialSubtaskFiles.push(subtaskFile); } } + this.initialSubtaskOrder = this.selectedSubtaskFiles.map((file) => file.path); } catch (error) { console.error("Error initializing subtasks:", error); } @@ -1095,11 +1116,20 @@ export class TaskEditModal extends TaskModal { protected hasSubtaskChanges(): boolean { // Check if subtasks have changed - const current = this.selectedSubtaskFiles.map(f => f.path).sort(); - const initial = this.initialSubtaskFiles.map(f => f.path).sort(); - - return current.length !== initial.length || - current.some((path, index) => path !== initial[index]); + const currentPaths = this.selectedSubtaskFiles.map((file) => file.path); + const initialPaths = this.initialSubtaskFiles.map((file) => file.path); + const currentSorted = [...currentPaths].sort(); + const initialSorted = [...initialPaths].sort(); + + const membershipChanged = + currentSorted.length !== initialSorted.length || + currentSorted.some((path, index) => path !== initialSorted[index]); + + if (membershipChanged) return true; + if (!this.isManualSortEnabled()) return false; + if (this.subtaskOrderDirty) return true; + if (currentPaths.length !== this.initialSubtaskOrder.length) return true; + return currentPaths.some((path, index) => path !== this.initialSubtaskOrder[index]); } protected async applySubtaskChanges(task: TaskInfo): Promise { @@ -1113,6 +1143,7 @@ export class TaskEditModal extends TaskModal { const toRemove = this.initialSubtaskFiles.filter(f => !currentPaths.has(f.path)); for (const file of toRemove) { await this.removeSubtaskRelation(file, currentTaskFile); + await this.removeSubtaskOrder(file, currentTaskFile); } // Add current task to tasks that should become subtasks @@ -1121,8 +1152,15 @@ export class TaskEditModal extends TaskModal { await this.addSubtaskRelation(file, currentTaskFile); } + if (this.isManualSortEnabled()) { + this.syncSubtaskOrderFromDom(); + await this.applySubtaskOrder(currentTaskFile); + } + // Update the initial state to reflect changes this.initialSubtaskFiles = [...this.selectedSubtaskFiles]; + this.initialSubtaskOrder = this.selectedSubtaskFiles.map((file) => file.path); + this.subtaskOrderDirty = false; } protected async addSubtaskRelation(subtaskFile: TAbstractFile, parentTaskFile: TFile): Promise { @@ -1167,6 +1205,75 @@ export class TaskEditModal extends TaskModal { } } + private getProjectRankForSubtask(subtask: TaskInfo, projectPath: string): number | null { + const rankMap = (subtask as any)?.rankByProject ?? subtask.customProperties?.rankByProject; + let value: any = null; + if (rankMap && typeof rankMap === "object" && !Array.isArray(rankMap)) { + value = rankMap[projectPath]; + } + if (value === null || value === undefined) { + const cache = this.app.metadataCache.getCache(subtask.path); + const fmRank = cache?.frontmatter?.rankByProject; + if (fmRank && typeof fmRank === "object" && !Array.isArray(fmRank)) { + value = (fmRank as Record)[projectPath]; + } + } + return typeof value === "number" ? value : null; + } + + private async applySubtaskOrder(parentTaskFile: TFile): Promise { + for (let index = 0; index < this.selectedSubtaskFiles.length; index++) { + const subtaskFile = this.selectedSubtaskFiles[index]; + if (!(subtaskFile instanceof TFile)) continue; + const rank = index * 1000; + await this.plugin.app.fileManager.processFrontMatter(subtaskFile, (frontmatter) => { + const current = frontmatter.rankByProject; + const rankMap = + current && typeof current === "object" && !Array.isArray(current) + ? { ...current } + : {}; + if (rankMap[parentTaskFile.path] === rank) return; + rankMap[parentTaskFile.path] = rank; + frontmatter.rankByProject = rankMap; + }); + } + } + + private async removeSubtaskOrder( + subtaskFile: TAbstractFile, + parentTaskFile: TFile + ): Promise { + if (!(subtaskFile instanceof TFile)) return; + await this.plugin.app.fileManager.processFrontMatter(subtaskFile, (frontmatter) => { + const current = frontmatter.rankByProject; + if (!current || typeof current !== "object" || Array.isArray(current)) return; + const rankMap = { ...current }; + if (!Object.prototype.hasOwnProperty.call(rankMap, parentTaskFile.path)) return; + delete rankMap[parentTaskFile.path]; + if (Object.keys(rankMap).length === 0) { + delete frontmatter.rankByProject; + } else { + frontmatter.rankByProject = rankMap; + } + }); + } + + private syncSubtaskOrderFromDom(): void { + if (!this.subtasksList) return; + const orderedPaths = Array.from( + this.subtasksList.querySelectorAll(".task-project-item") + ) + .map((el) => el.dataset.path) + .filter((path): path is string => typeof path === "string" && path.length > 0); + if (orderedPaths.length === 0) return; + const fileMap = new Map(this.selectedSubtaskFiles.map((file) => [file.path, file])); + const orderedFiles = orderedPaths + .map((path) => fileMap.get(path)) + .filter((file): file is TAbstractFile => Boolean(file)); + if (orderedFiles.length === 0) return; + this.selectedSubtaskFiles = orderedFiles; + } + // Start expanded for edit modal - override parent property protected isExpanded = true; } diff --git a/src/modals/TaskModal.ts b/src/modals/TaskModal.ts index a556b549..d18ef66a 100644 --- a/src/modals/TaskModal.ts +++ b/src/modals/TaskModal.ts @@ -22,6 +22,7 @@ import { TaskDependency, TaskInfo, Reminder } from "../types"; import { DEFAULT_DEPENDENCY_RELTYPE, formatDependencyLink, + normalizeDependencyEntry, resolveDependencyEntry, } from "../utils/dependencyUtils"; import { @@ -74,24 +75,37 @@ export abstract class TaskModal extends Modal { dependency: TaskDependency, sourcePath?: string ): DependencyItem { + const normalized = normalizeDependencyEntry(dependency); + if (!normalized) { + const fallbackName = + (typeof dependency === "object" && dependency && "uid" in dependency && typeof dependency.uid === "string" + ? dependency.uid + : String(dependency)); + return { + dependency: { uid: fallbackName, reltype: DEFAULT_DEPENDENCY_RELTYPE }, + name: fallbackName, + unresolved: true, + }; + } + const resolution = resolveDependencyEntry( this.plugin.app, sourcePath ?? this.getDependencySourcePath(), - dependency + normalized ); if (resolution) { const name = - resolution.file?.basename || resolution.path.split("/").pop() || dependency.uid; + resolution.file?.basename || resolution.path.split("/").pop() || normalized.uid; return { - dependency, + dependency: normalized, path: resolution.path, name, }; } - const cleaned = dependency.uid.replace(/^\[\[/, "").replace(/\]\]$/, ""); + const cleaned = normalized.uid.replace(/^\[\[/, "").replace(/\]\]$/, ""); return { - dependency, + dependency: normalized, name: cleaned || dependency.uid, unresolved: true, }; @@ -151,6 +165,7 @@ export abstract class TaskModal extends Modal { return { metadataCache: this.plugin.app.metadataCache, workspace: this.plugin.app.workspace, + sourcePath: this.getCurrentTaskPath() || this.plugin.app.workspace.getActiveFile()?.path || "", }; } @@ -205,7 +220,7 @@ export abstract class TaskModal extends Modal { nameEl.addClass("clickable-dependency"); appendInternalLink( nameEl, - item.path.replace(/\.md$/i, ""), + item.path, item.name, linkServices, { @@ -410,6 +425,8 @@ export abstract class TaskModal extends Modal { // Subtask storage - tracks tasks that should become subtasks of this task protected selectedSubtaskFiles: TAbstractFile[] = []; protected initialSubtaskFiles: TAbstractFile[] = []; + protected initialSubtaskOrder: string[] = []; + protected subtaskOrderDirty = false; // UI elements protected titleInput: HTMLInputElement; @@ -1912,10 +1929,108 @@ export abstract class TaskModal extends Modal { return; } + const manualSortEnabled = this.isManualSortEnabled(); + let draggedPath: string | null = null; + let activeTarget: HTMLElement | null = null; + let lastInsertBefore = true; + + const clearDragOver = () => { + this.subtasksList.querySelectorAll(".task-project-item").forEach((el) => { + el.classList.remove("task-project-item--dragover-top"); + el.classList.remove("task-project-item--dragover-bottom"); + }); + }; + + const updateDragOver = (target: HTMLElement, insertBefore: boolean) => { + clearDragOver(); + activeTarget = target; + lastInsertBefore = insertBefore; + target.classList.toggle("task-project-item--dragover-top", insertBefore); + target.classList.toggle("task-project-item--dragover-bottom", !insertBefore); + }; + + const finalizeDrop = () => { + if (!draggedPath || !activeTarget) return; + const targetPath = activeTarget.dataset.path; + if (!targetPath || draggedPath === targetPath) return; + + const ordered = this.selectedSubtaskFiles.map((f) => f.path); + const withoutDragged = ordered.filter((p) => p !== draggedPath); + const targetIndex = withoutDragged.indexOf(targetPath); + const insertIndex = targetIndex === -1 + ? withoutDragged.length + : targetIndex + (lastInsertBefore ? 0 : 1); + withoutDragged.splice(insertIndex, 0, draggedPath); + + const fileMap = new Map(this.selectedSubtaskFiles.map((f) => [f.path, f])); + this.selectedSubtaskFiles = withoutDragged + .map((p) => fileMap.get(p)) + .filter((f): f is TAbstractFile => Boolean(f)); + this.subtaskOrderDirty = true; + this.renderSubtasksList(); + }; + this.selectedSubtaskFiles.forEach((file) => { if (!(file instanceof TFile)) return; - const subtaskItem = this.subtasksList.createDiv({ cls: "task-project-item" }); + const subtaskItem = this.subtasksList.createDiv({ + cls: "task-project-item", + attr: manualSortEnabled ? { draggable: "true" } : undefined, + }); + subtaskItem.dataset.path = file.path; + + if (manualSortEnabled) { + const handle = subtaskItem.createDiv({ cls: "task-project-drag-handle" }); + handle.setAttribute("aria-label", "Drag to reorder"); + handle.addEventListener("mousedown", (e) => { + subtaskItem.dataset.tnDragReorder = "true"; + e.stopPropagation(); + }); + } + + subtaskItem.addEventListener("dragstart", (e) => { + if (!manualSortEnabled) return; + if (subtaskItem.dataset.tnDragReorder !== "true") { + e.preventDefault(); + return; + } + delete subtaskItem.dataset.tnDragReorder; + draggedPath = file.path; + subtaskItem.classList.add("task-project-item--dragging"); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", file.path); + } + }); + + subtaskItem.addEventListener("dragover", (e) => { + if (!manualSortEnabled || !draggedPath) return; + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + const rect = subtaskItem.getBoundingClientRect(); + const insertBefore = (e as any).clientY < rect.top + rect.height / 2; + updateDragOver(subtaskItem, insertBefore); + }); + + subtaskItem.addEventListener("dragleave", () => { + subtaskItem.classList.remove("task-project-item--dragover-top"); + subtaskItem.classList.remove("task-project-item--dragover-bottom"); + }); + + subtaskItem.addEventListener("drop", (e) => { + if (!manualSortEnabled || !draggedPath) return; + e.preventDefault(); + e.stopPropagation(); + finalizeDrop(); + }); + + subtaskItem.addEventListener("dragend", () => { + subtaskItem.classList.remove("task-project-item--dragging"); + draggedPath = null; + activeTarget = null; + clearDragOver(); + }); const infoEl = subtaskItem.createDiv({ cls: "task-project-info" }); const nameEl = infoEl.createDiv({ cls: "task-project-name clickable-project" }); @@ -1945,16 +2060,19 @@ export abstract class TaskModal extends Modal { }); } + protected isManualSortEnabled(): boolean { + const view = this.plugin.app.workspace.activeLeaf?.view as any; + if (view?.manualSortEnabled) return true; + return !!this.plugin.projectSubtasksService?.isManualSortEnabled?.(); + } + protected renderOrganizationLists(): void { this.renderProjectsList(); this.renderSubtasksList(); } protected renderProjectLinksWithoutPrefix(container: HTMLElement, links: string[]): void { - const linkServices: LinkServices = { - metadataCache: this.app.metadataCache, - workspace: this.app.workspace, - }; + const linkServices = this.getLinkServices(); renderProjectLinks(container, links, linkServices); diff --git a/src/modals/UnscheduledTasksSelectorModal.ts b/src/modals/UnscheduledTasksSelectorModal.ts index 11df1bf1..d25d368c 100644 --- a/src/modals/UnscheduledTasksSelectorModal.ts +++ b/src/modals/UnscheduledTasksSelectorModal.ts @@ -1,4 +1,4 @@ -import { App, FuzzySuggestModal, FuzzyMatch, setIcon, TFile, Notice } from "obsidian"; +import { App, FuzzySuggestModal, FuzzyMatch, setIcon } from "obsidian"; import { format } from "date-fns"; import { TaskInfo } from "../types"; import { @@ -11,6 +11,8 @@ import { import { filterEmptyProjects } from "../utils/helpers"; import TaskNotesPlugin from "../main"; import { TranslationKey } from "../i18n"; +import { appendInternalLink, LinkServices } from "../ui/renderers/linkRenderer"; +import { parseLinkToPath } from "../utils/linkUtils"; export interface ScheduleTaskOptions { date?: Date; @@ -264,6 +266,12 @@ function renderProjectLinksForSelector( ): void { container.innerHTML = ""; + const linkServices: LinkServices = { + metadataCache: plugin.app.metadataCache, + workspace: plugin.app.workspace, + sourcePath: plugin.app.workspace.getActiveFile()?.path || "", + }; + projects.forEach((project, index) => { if (index > 0) { const separator = document.createTextNode(", "); @@ -275,30 +283,24 @@ function renderProjectLinksForSelector( if (isWikilinkProject(project)) { // Extract the note name from [[Note Name]] - const noteName = project.slice(2, -2); - - // Create a clickable link - const linkEl = container.createEl("a", { - cls: "unscheduled-tasks-selector__project-link internal-link", - text: noteName, - attr: { "data-href": noteName }, - }); - - // Add click handler to open the note - linkEl.addEventListener("click", async (e) => { - e.preventDefault(); - e.stopPropagation(); - - // Resolve the link to get the actual file - const file = plugin.app.metadataCache.getFirstLinkpathDest(noteName, ""); - if (file instanceof TFile) { - // Open the file in the current leaf - await plugin.app.workspace.getLeaf(false).openFile(file); - } else { - // File not found, show notice - new Notice(`Note not found: ${noteName}`); - } + const noteName = project.slice(2, -2).trim(); + appendInternalLink(container, noteName, noteName, linkServices, { + cssClass: "unscheduled-tasks-selector__project-link internal-link", + hoverSource: "tasknotes-project-link", + showErrorNotices: true, }); + } else if (/^\[([^\]]+)\]\(([^)]+)\)$/.test(project)) { + const match = project.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (match) { + const displayText = match[1].trim(); + const rawPath = match[2].trim(); + const path = parseLinkToPath(rawPath); + appendInternalLink(container, path, displayText, linkServices, { + cssClass: "unscheduled-tasks-selector__project-link internal-link", + hoverSource: "tasknotes-project-link", + showErrorNotices: true, + }); + } } else { // Plain text project const textNode = document.createTextNode(project); diff --git a/src/services/FilterService.ts b/src/services/FilterService.ts index 0094b896..3afec89b 100644 --- a/src/services/FilterService.ts +++ b/src/services/FilterService.ts @@ -11,6 +11,7 @@ import { FilterOperator, } from "../types"; import { parseLinktext } from "obsidian"; +import { getProjectDisplayName, parseLinkToPath } from "../utils/linkUtils"; import { TaskManager } from "../utils/TaskManager"; import { StatusManager } from "./StatusManager"; import { PriorityManager } from "./PriorityManager"; @@ -996,17 +997,8 @@ export class FilterService extends EventEmitter { if (!projectValue || typeof projectValue !== "string") { return null; } - - // Handle [[Name]] format - if (projectValue.startsWith("[[") && projectValue.endsWith("]]")) { - const linkContent = projectValue.slice(2, -2); - // Extract just the name part if it's a path - const parts = linkContent.split("/"); - return parts[parts.length - 1] || null; - } - - // Handle plain name - return projectValue.trim() || null; + const displayName = getProjectDisplayName(projectValue, this.plugin?.app); + return displayName ? displayName : null; } /** @@ -1091,44 +1083,7 @@ export class FilterService extends EventEmitter { * For non-link strings, returns the input as-is. */ private parseLinkToPath(linkText: string): string { - if (!linkText) return linkText; - - const trimmed = linkText.trim(); - - // Handle wikilinks: [[path]] or [[path|alias]] - if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) { - const linkContent = trimmed.slice(2, -2); - const pipeIndex = linkContent.indexOf("|"); - if (pipeIndex !== -1) { - return linkContent.substring(0, pipeIndex).trim(); - } - return linkContent.trim(); - } - - // Handle markdown links: [text](path) - const markdownMatch = trimmed.match(/^\[([^\]]*)\]\(([^)]+)\)$/); - if (markdownMatch) { - let linkPath = markdownMatch[2].trim(); - - // URL decode the link path - crucial for paths with spaces like Car%20Maintenance.md - try { - linkPath = decodeURIComponent(linkPath); - } catch (error) { - // If decoding fails, use the original path - console.debug("Failed to decode URI component:", linkPath, error); - } - - return linkPath; - } - - // Handle legacy pipe syntax like "../projects/Genealogy|Genealogy" - if (trimmed.includes("|")) { - const parts = trimmed.split("|"); - return parts[0].trim(); - } - - // Not a link format, return as-is - return trimmed; + return parseLinkToPath(linkText); } /** diff --git a/src/services/LicenseService.ts b/src/services/LicenseService.ts index cf716919..29aecf4d 100644 --- a/src/services/LicenseService.ts +++ b/src/services/LicenseService.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import { requestUrl } from "obsidian"; import TaskNotesPlugin from "../main"; @@ -30,11 +29,7 @@ export class LicenseService { * Validates a license key against Lemon Squeezy API */ async validateLicense(licenseKey: string): Promise { - console.log("=== LICENSE VALIDATION STARTED ==="); - console.log("License key:", licenseKey); - if (!licenseKey || !licenseKey.trim()) { - console.log("License key is empty"); return false; } @@ -43,11 +38,9 @@ export class LicenseService { this.cachedValidation?.key === licenseKey && Date.now() < this.cachedValidation.validUntil ) { - console.log("Using cached validation result:", this.cachedValidation.valid); return this.cachedValidation.valid; } - console.log("Making API request to Lemon Squeezy..."); try { const response = await requestUrl({ url: "https://api.lemonsqueezy.com/v1/licenses/validate", @@ -60,9 +53,6 @@ export class LicenseService { throw: false, }); - console.log("License validation response status:", response.status); - console.log("License validation response body:", JSON.stringify(response.json, null, 2)); - if (response.status !== 200) { console.error("License validation failed with status:", response.status); return this.handleValidationFailure(licenseKey); @@ -106,7 +96,6 @@ export class LicenseService { this.cachedValidation?.key === licenseKey && Date.now() < this.cachedValidation.validUntil + this.GRACE_PERIOD ) { - console.log("Using cached validation result (grace period)"); return this.cachedValidation.valid; } diff --git a/src/services/ProjectSubtasksService.ts b/src/services/ProjectSubtasksService.ts index f1ca1bc2..076b367d 100644 --- a/src/services/ProjectSubtasksService.ts +++ b/src/services/ProjectSubtasksService.ts @@ -10,6 +10,7 @@ export class ProjectSubtasksService { // Pre-computed reverse index: taskPath -> isUsedAsProject private projectIndex = new Map(); private indexLastBuilt = 0; + private cachedBasesSortRules: { property: string; direction: string }[] = []; private readonly INDEX_TTL = 30000; // Rebuild index every 30 seconds // Performance stats (kept for monitoring) @@ -238,6 +239,14 @@ export class ProjectSubtasksService { } } + /** + * Invalidate the cached project index so the next lookup rebuilds it. + */ + invalidateIndex(): void { + this.projectIndex.clear(); + this.indexLastBuilt = 0; + } + /** * Cleanup when service is destroyed */ @@ -252,37 +261,266 @@ export class ProjectSubtasksService { } /** - * Sort tasks by priority and status + * Sort tasks using the active Bases view sort config (if available), + * otherwise fall back to the legacy priority/status/due/title ordering. */ sortTasks(tasks: TaskInfo[]): TaskInfo[] { + const sortRules = this.getActiveBasesSortRules(); + if (sortRules.length > 0) { + return tasks.sort((a, b) => { + for (const rule of sortRules) { + const direction = (rule.direction || "ASC").toUpperCase() === "DESC" ? -1 : 1; + const prop = rule.property; + + const result = this.compareByProperty(a, b, prop); + if (result !== 0) return result * direction; + } + + // Fallback to legacy ordering if all sort keys are equal + return this.compareLegacy(a, b); + }); + } + + // Legacy sort + console.debug("[ProjectSubtasksService] No Bases sort rules found, using legacy subtask sort", { + activeViewType: this.plugin.app.workspace.activeLeaf?.view?.getViewType?.(), + }); return tasks.sort((a, b) => { - // First sort by completion status (incomplete first) - const aCompleted = this.plugin.statusManager.isCompletedStatus(a.status); - const bCompleted = this.plugin.statusManager.isCompletedStatus(b.status); + return this.compareLegacy(a, b); + }); + } + + /** + * Returns true when Bases sort rules indicate manual column ranking. + */ + isManualSortEnabled(): boolean { + const sortRules = this.getActiveBasesSortRules(); + if (sortRules.length !== 1) return false; + const prop = sortRules[0]?.property; + if (typeof prop !== "string") return false; + return prop === "rankByColumn" || prop.endsWith(".rankByColumn"); + } - if (aCompleted !== bCompleted) { - return aCompleted ? 1 : -1; + /** + * Read sort rules from the active Bases view (Kanban or Task List). + */ + private getActiveBasesSortRules(): { property: string; direction: string }[] { + try { + const activeView: any = this.plugin.app.workspace.activeLeaf?.view; + + // Try multiple potential locations where Bases might expose getSort() + const attempts: Array<{ label: string; getter: () => any }> = [ + { label: "view.config.getSort", getter: () => activeView?.config?.getSort?.() }, + { label: "view.getSort", getter: () => activeView?.getSort?.() }, + { label: "view.controller.config.getSort", getter: () => activeView?.controller?.config?.getSort?.() }, + { label: "view.query.config.getSort", getter: () => activeView?.query?.config?.getSort?.() }, + { label: "view.config.sort", getter: () => activeView?.config?.sort }, + { label: "view.sort", getter: () => activeView?.sort }, + { label: "view.query.sort", getter: () => activeView?.query?.sort }, + { label: "view.results.sort", getter: () => activeView?.results?.sort }, + ]; + + for (const attempt of attempts) { + try { + const sort = attempt.getter(); + if (Array.isArray(sort)) { + console.debug("[ProjectSubtasksService] Using Bases sort from", attempt.label, sort); + const normalized = sort + .map((rule: any) => ({ + property: rule?.property, + direction: rule?.direction || "ASC", + })) + .filter((rule) => !!rule.property); + if (normalized.length > 0) { + this.cachedBasesSortRules = normalized; + return normalized; + } + } + } catch { + // ignore and try next + } + } + + // Fallback: inspect activeView.query?.sort or results?.sort since queryKeys includes 'query' and 'results' + const scanForSortArray = (root: any, maxDepth = 3): { path: string; sort: any[] } | null => { + if (!root || maxDepth < 0) return null; + const queue: Array<{ value: any; path: string; depth: number }> = [{ value: root, path: "root", depth: 0 }]; + while (queue.length > 0) { + const { value, path, depth } = queue.shift()!; + if (Array.isArray(value) && value.every((v) => v && typeof v === "object" && "property" in v)) { + return { path, sort: value }; + } + if (value && typeof value === "object" && depth < maxDepth) { + for (const [k, v] of Object.entries(value)) { + queue.push({ value: v, path: `${path}.${k}`, depth: depth + 1 }); + } + } + } + return null; + }; + + const scanned = + scanForSortArray(activeView?.query) || + scanForSortArray(activeView?.results) || + scanForSortArray(activeView); + if (scanned) { + console.debug("[ProjectSubtasksService] Using Bases sort from scanned path", scanned.path, scanned.sort); + const normalized = scanned.sort + .map((rule: any) => ({ + property: rule?.property, + direction: rule?.direction || "ASC", + })) + .filter((rule: any) => !!rule.property); + if (normalized.length > 0) { + this.cachedBasesSortRules = normalized; + return normalized; + } } - // Then sort by priority - const aPriorityWeight = this.plugin.priorityManager.getPriorityWeight(a.priority); - const bPriorityWeight = this.plugin.priorityManager.getPriorityWeight(b.priority); + } catch (error) { + console.warn("[ProjectSubtasksService] Failed to read Bases sort config:", error); + } + + // When focus is elsewhere (e.g., editing a note), fall back to the last known Bases sort + if (this.cachedBasesSortRules.length > 0) { + console.debug("[ProjectSubtasksService] Using cached Bases sort rules", this.cachedBasesSortRules); + return this.cachedBasesSortRules; + } + + return []; + } - if (aPriorityWeight !== bPriorityWeight) { - return bPriorityWeight - aPriorityWeight; // Higher priority first + /** + * Resolve a property value for sorting (supports core fields, user fields, and customProperties). + */ + private getPropertyValueForSort(task: TaskInfo, prop: string): any { + const userFields = this.plugin.settings.userFields || []; + + const normalizeKey = (key: string) => { + if (typeof key !== "string") return key; + let k = key; + // Strip common prefixes from Bases (task./note./file./formula./user:) + k = k.replace(/^(task\.|note\.|file\.|formula\.|user:)/, ""); + if (k.includes(".")) k = k.split(".").pop() || k; + return k; + }; + + const normalized = normalizeKey(prop); + const matchedUserField = userFields.find( + (f) => f.id === prop || f.key === prop || f.id === normalized || f.key === normalized + ); + const userKey = matchedUserField?.key; + + const candidates = [ + prop, + normalized, + userKey, + typeof normalized === "string" ? normalized.toLowerCase() : normalized, + userKey ? userKey.toLowerCase() : undefined, + ].filter((c) => c !== undefined && c !== null); + + for (const candidate of candidates) { + const core = (task as any)[candidate]; + if (core !== undefined) return core; + } + + if (task.customProperties) { + for (const [propKey, propValue] of Object.entries(task.customProperties)) { + if (candidates.some((c) => typeof c === "string" && c === propKey)) { + return propValue; + } + if (candidates.some((c) => typeof c === "string" && c === propKey.toLowerCase())) { + return propValue; + } } + } + + return undefined; + } + + /** + * Compare two tasks for a given property. + */ + private compareByProperty(a: TaskInfo, b: TaskInfo, prop: string): number { + const toNumber = (val: any): number | undefined => { + if (val === null || val === undefined) return undefined; + const num = Number(val); + return Number.isNaN(num) ? undefined : num; + }; - // Then sort by due date (earliest first) - if (a.due && b.due) { - return new Date(a.due).getTime() - new Date(b.due).getTime(); - } else if (a.due) { - return -1; // Tasks with due dates come first - } else if (b.due) { - return 1; + const aVal = this.getPropertyValueForSort(a, prop); + const bVal = this.getPropertyValueForSort(b, prop); + + switch (prop) { + case "scheduled": + return this.compareDates(aVal, bVal); + case "due": + return this.compareDates(aVal, bVal); + case "priority": + return ( + this.plugin.priorityManager.getPriorityWeight(aVal) - + this.plugin.priorityManager.getPriorityWeight(bVal) + ); + case "weight": + return (toNumber(aVal) || 0) - (toNumber(bVal) || 0); + case "status": { + const aCompleted = this.plugin.statusManager.isCompletedStatus(aVal); + const bCompleted = this.plugin.statusManager.isCompletedStatus(bVal); + if (aCompleted !== bCompleted) return aCompleted ? 1 : -1; + return 0; } + case "title": + return (aVal || "").localeCompare(bVal || ""); + default: { + // Generic fallback for other properties, including customProperties + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + if (typeof aVal === "number" && typeof bVal === "number") { + return aVal - bVal; + } + if (typeof aVal === "string" && typeof bVal === "string") { + return aVal.localeCompare(bVal); + } + return 0; + } + } + } - // Finally sort by title - return a.title.localeCompare(b.title); - }); + private compareDates(a?: string, b?: string): number { + if (!a && !b) return 0; + if (!a) return 1; + if (!b) return -1; + return new Date(a).getTime() - new Date(b).getTime(); + } + + private compareLegacy(a: TaskInfo, b: TaskInfo): number { + // First sort by completion status (incomplete first) + const aCompleted = this.plugin.statusManager.isCompletedStatus(a.status); + const bCompleted = this.plugin.statusManager.isCompletedStatus(b.status); + + if (aCompleted !== bCompleted) { + return aCompleted ? 1 : -1; + } + + // Then sort by priority + const aPriorityWeight = this.plugin.priorityManager.getPriorityWeight(a.priority); + const bPriorityWeight = this.plugin.priorityManager.getPriorityWeight(b.priority); + + if (aPriorityWeight !== bPriorityWeight) { + return bPriorityWeight - aPriorityWeight; // Higher priority first + } + + // Then sort by due date (earliest first) + if (a.due && b.due) { + return new Date(a.due).getTime() - new Date(b.due).getTime(); + } else if (a.due) { + return -1; // Tasks with due dates come first + } else if (b.due) { + return 1; + } + + // Finally sort by title + return a.title.localeCompare(b.title); } } diff --git a/src/services/TaskLinkDetectionService.ts b/src/services/TaskLinkDetectionService.ts index 1763f5d2..188b5e29 100644 --- a/src/services/TaskLinkDetectionService.ts +++ b/src/services/TaskLinkDetectionService.ts @@ -133,6 +133,11 @@ export class TaskLinkDetectionService { const displayText = match[1].trim(); let linkPath = match[2].trim(); + // Strip angle brackets used to escape spaces/special characters in markdown links + if (linkPath.startsWith("<") && linkPath.endsWith(">")) { + linkPath = linkPath.slice(1, -1).trim(); + } + if (!linkPath) return null; // URL decode the link path - this is crucial for markdown links diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 900d4758..46e4b7cb 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -33,6 +33,7 @@ import { normalizeDependencyEntry, resolveDependencyEntry, } from "../utils/dependencyUtils"; +import { getProjectDisplayName } from "../utils/linkUtils"; import { formatDateForStorage, getCurrentDateString, @@ -306,10 +307,13 @@ export class TaskService { icsEventId: taskData.icsEventId || undefined, }; + const shouldAddTaskTag = this.plugin.settings.taskIdentificationMethod === "tag"; + const taskTagForFrontmatter = shouldAddTaskTag ? this.plugin.settings.taskTag : undefined; + // Use field mapper to convert to frontmatter with proper field mapping const frontmatter = this.plugin.fieldMapper.mapToFrontmatter( completeTaskData, - this.plugin.settings.taskTag, + taskTagForFrontmatter, this.plugin.settings.storeTitleInFilename ); @@ -324,12 +328,10 @@ export class TaskService { lower === "true" || lower === "false" ? lower === "true" : propValue; frontmatter[propName] = coercedValue as any; } - // Remove task tag from tags array if using property identification - const filteredTags = tagsArray.filter( - (tag: string) => tag !== this.plugin.settings.taskTag - ); - if (filteredTags.length > 0) { - frontmatter.tags = filteredTags; + if (tagsArray.length > 0) { + frontmatter.tags = tagsArray; + } else { + delete frontmatter.tags; } } else { // Tags are handled separately (not via field mapper) @@ -1337,23 +1339,12 @@ export class TaskService { } if (updates.hasOwnProperty("tags")) { - let tagsToSet = updates.tags; - // Remove task tag if using property identification - if (this.plugin.settings.taskIdentificationMethod === "property" && tagsToSet) { - tagsToSet = tagsToSet.filter( - (tag: string) => tag !== this.plugin.settings.taskTag - ); - } - frontmatter.tags = tagsToSet; - } else if (originalTask.tags) { - let tagsToSet = originalTask.tags; - // Remove task tag if using property identification - if (this.plugin.settings.taskIdentificationMethod === "property") { - tagsToSet = tagsToSet.filter( - (tag: string) => tag !== this.plugin.settings.taskTag - ); + const tagsToSet = Array.isArray(updates.tags) ? [...updates.tags] : []; + if (tagsToSet.length > 0) { + frontmatter.tags = tagsToSet; + } else { + delete frontmatter.tags; } - frontmatter.tags = tagsToSet; } }); @@ -2100,41 +2091,6 @@ export class TaskService { * - "simple string" -> "simple string" */ private extractProjectBasename(project: string): string { - if (!project) return ""; - - // Check if it's a wikilink format [[...]] - const linkMatch = project.match(/^\[\[([^\]]+)\]\]$/); - if (linkMatch) { - const linkContent = linkMatch[1]; - - // Handle pipe syntax: "path|display" -> use "display" - if (linkContent.includes("|")) { - return linkContent.split("|")[1].trim(); - } - - // Try to resolve the file using Obsidian's metadata cache - if (this.plugin.app?.metadataCache) { - try { - const file = this.plugin.app.metadataCache.getFirstLinkpathDest( - linkContent, - "" - ); - if (file) { - // Return the file's basename (name without extension) - return file.basename; - } - } catch (error) { - // File resolution failed, fall back to manual extraction - console.debug("Error resolving project file:", error); - } - } - - // Fallback: extract basename manually from the path - const pathParts = linkContent.split("/"); - return pathParts[pathParts.length - 1] || linkContent; - } - - // For non-wikilink strings, return as-is - return project; + return getProjectDisplayName(project, this.plugin.app); } } diff --git a/src/types.ts b/src/types.ts index c88a080d..e3947f25 100644 --- a/src/types.ts +++ b/src/types.ts @@ -459,6 +459,7 @@ export interface TaskInfo { reminders?: Reminder[]; // Task reminders customProperties?: Record; // Custom properties from Bases or other sources basesData?: any; // Raw Bases data for formula computation (internal use) + weight?: number; // Optional ordering weight (higher = more important) blockedBy?: TaskDependency[]; // Dependencies that must be satisfied before this task can start blocking?: string[]; // Task paths that this task is blocking isBlocked?: boolean; // True if any blocking dependency is incomplete diff --git a/src/ui/TaskCard.ts b/src/ui/TaskCard.ts index 7f572e3d..a14f4e22 100644 --- a/src/ui/TaskCard.ts +++ b/src/ui/TaskCard.ts @@ -38,6 +38,7 @@ import { DEFAULT_INTERNAL_VISIBLE_PROPERTIES } from "../settings/defaults"; export interface TaskCardOptions { targetDate?: Date; layout?: "default" | "compact" | "inline"; + manualSortEnabled?: boolean; } export const DEFAULT_TASK_CARD_OPTIONS: TaskCardOptions = { @@ -404,6 +405,37 @@ function getDefaultVisibleProperties(plugin: TaskNotesPlugin): string[] { return convertInternalToUserProperties(internalDefaults, plugin); } +function resolveVisibleProperties( + visibleProperties: string[] | undefined, + plugin: TaskNotesPlugin +): string[] { + if (visibleProperties && visibleProperties.length > 0) { + return visibleProperties; + } + + if (plugin.settings.defaultVisibleProperties) { + return convertInternalToUserProperties(plugin.settings.defaultVisibleProperties, plugin); + } + + return getDefaultVisibleProperties(plugin); +} + +function getSubtaskVisibleProperties(card: HTMLElement, plugin: TaskNotesPlugin): string[] { + const raw = card?.dataset?.visibleProperties; + if (raw) { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + return parsed; + } + } catch (error) { + console.warn("Failed to parse visibleProperties from card dataset", error); + } + } + + return resolveVisibleProperties(undefined, plugin); +} + /** * Property value extractors for better type safety and error handling */ @@ -666,13 +698,34 @@ const PROPERTY_RENDERERS: Record = { renderScheduledDateProperty(element, value, task, plugin); } }, - projects: (element, value, _, plugin) => { + projects: (element, value, task, plugin) => { if (Array.isArray(value)) { const linkServices: LinkServices = { metadataCache: plugin.app.metadataCache, workspace: plugin.app.workspace, + sourcePath: task.path, + }; + const onPrimaryNavigate = async (normalizedPath: string) => { + try { + const file = + plugin.app.metadataCache.getFirstLinkpathDest(normalizedPath, task.path) || + plugin.app.metadataCache.getFirstLinkpathDest(normalizedPath, ""); + const resolvedPath = file?.path ?? normalizedPath; + const projectTask = await plugin.cacheManager.getTaskInfo(resolvedPath); + if (projectTask) { + await plugin.openTaskEditModal(projectTask); + return true; + } + } catch (error) { + console.error("[TaskNotes] Failed to open project modal:", error); + } + // Returning false falls back to default open note behavior + return false; }; - renderProjectLinks(element, value as string[], linkServices); + + renderProjectLinks(element, value as string[], linkServices, { + onPrimaryNavigate, + }); } }, contexts: (element, value, _, plugin) => { @@ -1322,6 +1375,7 @@ export function createTaskCard( const todayLocal = new Date(); return new Date(Date.UTC(todayLocal.getFullYear(), todayLocal.getMonth(), todayLocal.getDate())); })(); + const resolvedVisibleProperties = resolveVisibleProperties(visibleProperties, plugin); // Determine effective status for recurring tasks const effectiveStatus = task.recurrence @@ -1334,6 +1388,7 @@ export function createTaskCard( // 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"); + card.dataset.visibleProperties = JSON.stringify(resolvedVisibleProperties); // Store task path for circular reference detection (card as any)._taskPath = task.path; @@ -1346,6 +1401,7 @@ export function createTaskCard( ? task.skipped_instances?.includes(formatDateForStorage(targetDate)) || false // Direct check of skipped_instances : false; // Only recurring tasks can have skipped instances const isRecurring = !!task.recurrence; + const isProjectTask = plugin.projectSubtasksService.isTaskUsedAsProjectSync(task.path); // Build BEM class names const cardClasses = ["task-card"]; @@ -1377,21 +1433,37 @@ export function createTaskCard( cardClasses.push("task-card--chevron-left"); } + const manualSortActive = opts.manualSortEnabled ?? isManualSortActive(plugin); + if (manualSortActive) { + cardClasses.push("task-card--manual-sort"); + } + // Add project modifier (for issue #355) const hasProjects = filterEmptyProjects(task.projects || []).length > 0; if (hasProjects) { cardClasses.push("task-card--has-projects"); } + if (isProjectTask) { + cardClasses.push("task-card--project"); + } card.className = cardClasses.join(" "); card.dataset.taskPath = task.path; card.dataset.key = task.path; // For DOMReconciler compatibility card.dataset.status = effectiveStatus; + card.dataset.isProject = isProjectTask ? "true" : "false"; // 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" }); + if (layout === "default" && manualSortActive) { + mainRow.createEl("span", { + cls: "task-card__drag-handle", + attr: { "aria-label": "Drag to reorder", "data-tn-drag-handle": "true" }, + }); + } + // Apply priority and status colors as CSS custom properties const priorityConfig = plugin.priorityManager.getPriorityConfig(task.priority); if (priorityConfig) { @@ -1402,6 +1474,7 @@ export function createTaskCard( if (statusConfig) { card.style.setProperty("--current-status-color", statusConfig.color); } + const statusStripeColor = statusConfig?.color; // Set next status color for hover preview const nextStatus = plugin.statusManager.getNextStatus(effectiveStatus); @@ -1425,8 +1498,12 @@ export function createTaskCard( setIcon(statusDot, statusConfig.icon); } } + card.style.removeProperty("box-shadow"); + } else if (statusStripeColor) { + card.style.boxShadow = `inset -2px 0 0 ${statusStripeColor}`; + } else { + card.style.removeProperty("box-shadow"); } - // Add click handler to cycle through statuses if (statusDot) { // Prevent mousedown from propagating to editor (fixes inline widget de-rendering) @@ -1482,41 +1559,25 @@ export function createTaskCard( }); } - // Project indicator - const isProject = plugin.projectSubtasksService.isTaskUsedAsProjectSync(task.path); - if (isProject) { - createBadgeIndicator({ + // Project indicator doubles as the subtasks toggle (same behavior as the chevron) + if (isProjectTask && plugin.settings?.showExpandableSubtasks) { + const isExpanded = plugin.expandedProjectsService?.isExpanded(task.path) || false; + const projectToggle = createBadgeIndicator({ container: badgesContainer, - className: "task-card__project-indicator", + className: `task-card__project-indicator task-card__chevron${isExpanded ? " task-card__chevron--expanded" : ""}`, icon: "folder", - tooltip: "This task is used as a project (click to filter subtasks)", - onClick: createProjectClickHandler(task, plugin), + tooltip: isExpanded ? "Collapse subtasks" : "Expand subtasks", + onClick: (e) => { + e.stopPropagation(); + createChevronClickHandler(task, plugin, card, projectToggle as HTMLElement)(); + }, }); - // Chevron for expandable subtasks - if (plugin.settings?.showExpandableSubtasks) { - const isExpanded = plugin.expandedProjectsService?.isExpanded(task.path) || false; - const chevron = createBadgeIndicator({ - container: badgesContainer, - className: `task-card__chevron${isExpanded ? " task-card__chevron--expanded" : ""}`, - icon: "chevron-right", - tooltip: isExpanded ? "Collapse subtasks" : "Expand subtasks", + // Show subtasks if already expanded + if (isExpanded) { + toggleSubtasks(card, task, plugin, true).catch((error) => { + console.error("Error showing initial subtasks:", error); }); - - // Chevron needs special handler since it updates its own state - if (chevron) { - chevron.addEventListener("click", (e) => { - e.stopPropagation(); - 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); - }); - } } } @@ -1566,6 +1627,13 @@ export function createTaskCard( titleEl.classList.add("completed"); titleTextEl.classList.add("completed"); } + if (isProjectTask) { + card.dataset.isProject = "true"; + titleTextEl.style.fontWeight = "600"; + } else { + delete card.dataset.isProject; + titleTextEl.style.fontWeight = ""; + } // Second line: Metadata (dynamic based on visible properties) const metadataLine = contentContainer.createEl(layout === "inline" ? "span" : "div", { cls: "task-card__metadata" }); @@ -1748,6 +1816,7 @@ export function updateTaskCard( options: Partial = {} ): void { const opts = { ...DEFAULT_TASK_CARD_OPTIONS, ...options }; + const resolvedVisibleProperties = resolveVisibleProperties(visibleProperties, plugin); // 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 || (() => { @@ -1797,6 +1866,7 @@ export function updateTaskCard( element.className = cardClasses.join(" "); element.dataset.status = effectiveStatus; + element.dataset.visibleProperties = JSON.stringify(resolvedVisibleProperties); // Get the main row container const mainRow = element.querySelector(".task-card__main-row") as HTMLElement; @@ -1827,8 +1897,8 @@ export function updateTaskCard( // Update status dot (conditional based on visible properties) const shouldShowStatus = - !visibleProperties || - visibleProperties.some((prop) => isPropertyForField(prop, "status", plugin)); + !resolvedVisibleProperties || + resolvedVisibleProperties.some((prop) => isPropertyForField(prop, "status", plugin)); const statusDot = element.querySelector(".task-card__status-dot") as HTMLElement; if (shouldShowStatus) { @@ -1932,10 +2002,24 @@ export function updateTaskCard( statusDot.remove(); } + // Ensure data attribute stays accurate for project cards (used for styling) + element.dataset.isProject = plugin.projectSubtasksService.isTaskUsedAsProjectSync(task.path) ? "true" : "false"; + + // Apply a status stripe when the status property is hidden + if (!shouldShowStatus) { + if (statusConfig?.color) { + element.style.boxShadow = `inset -2px 0 0 ${statusConfig.color}`; + } else { + element.style.removeProperty("box-shadow"); + } + } else { + element.style.removeProperty("box-shadow"); + } + // Update priority indicator (conditional based on visible properties) const shouldShowPriority = - !visibleProperties || - visibleProperties.some((prop) => isPropertyForField(prop, "priority", plugin)); + !resolvedVisibleProperties || + resolvedVisibleProperties.some((prop) => isPropertyForField(prop, "priority", plugin)); const existingPriorityDot = element.querySelector(".task-card__priority-dot") as HTMLElement; if (shouldShowPriority && task.priority && priorityConfig) { @@ -2042,52 +2126,57 @@ export function updateTaskCard( 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: "This task is used as a project (click to filter subtasks)", - onClick: createProjectClickHandler(task, plugin), - }); + const showProjectToggle = isProject && plugin.settings?.showExpandableSubtasks; + const existingToggle = element.querySelector(".task-card__project-indicator") as HTMLElement | null; - // 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; - const chevron = createBadgeIndicator({ - container: badgesContainer || mainRow, - className: `task-card__chevron${isExpanded ? " task-card__chevron--expanded" : ""}`, - icon: "chevron-right", - tooltip: isExpanded ? "Collapse subtasks" : "Expand subtasks", - }); - - if (chevron) { - chevron.addEventListener("click", (e) => { - e.stopPropagation(); - createChevronClickHandler(task, plugin, element, chevron)(); - }); + if (!showProjectToggle) { + existingToggle?.remove(); + // Clean up subtasks container if we remove the toggle + 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; } - - if (isExpanded) { - toggleSubtasks(element, task, plugin, true).catch((error) => { - console.error("Error showing initial subtasks in update:", error); - }); + const dragStartHandler = (subtasksContainer as any)._dragStartHandler; + if (dragStartHandler) { + subtasksContainer.removeEventListener("dragstart", dragStartHandler, true); + delete (subtasksContainer as any)._dragStartHandler; } - } 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(); + const dragEndHandler = (subtasksContainer as any)._dragEndHandler; + if (dragEndHandler) { + subtasksContainer.removeEventListener("dragend", dragEndHandler, true); + subtasksContainer.removeEventListener("drop", dragEndHandler, true); + delete (subtasksContainer as any)._dragEndHandler; } + subtasksContainer.remove(); + } + return; + } + + const isExpanded = plugin.expandedProjectsService?.isExpanded(task.path) || false; + const tooltip = isExpanded ? "Collapse subtasks" : "Expand subtasks"; + + if (existingToggle) { + existingToggle.remove(); + } + + const projectToggle = createBadgeIndicator({ + container: badgesContainer || mainRow, + className: `task-card__project-indicator task-card__chevron${isExpanded ? " task-card__chevron--expanded" : ""}`, + icon: "folder", + tooltip, + onClick: (e) => { + e.stopPropagation(); + createChevronClickHandler(task, plugin, element, projectToggle as HTMLElement)(); + }, + }); + + if (projectToggle && isExpanded) { + toggleSubtasks(element, task, plugin, true).catch((error) => { + console.error("Error showing initial subtasks in update:", error); + }); } }) .catch((error: any) => { @@ -2127,6 +2216,14 @@ export function updateTaskCard( if (titleText) { titleText.textContent = task.title; titleText.classList.toggle("completed", titleIsCompleted); + const isProject = plugin.projectSubtasksService.isTaskUsedAsProjectSync(task.path); + if (isProject) { + element.dataset.isProject = "true"; + titleText.style.fontWeight = "600"; + } else { + delete element.dataset.isProject; + titleText.style.fontWeight = ""; + } } if (titleContainer) { titleContainer.classList.toggle("completed", titleIsCompleted); @@ -2145,11 +2242,7 @@ export function updateTaskCard( const metadataElements: HTMLElement[] = []; // Get properties to display - const propertiesToShow = - visibleProperties || - (plugin.settings.defaultVisibleProperties - ? convertInternalToUserProperties(plugin.settings.defaultVisibleProperties, plugin) - : getDefaultVisibleProperties(plugin)); + const propertiesToShow = resolvedVisibleProperties; for (const propertyId of propertiesToShow) { // Skip status and priority as they're rendered separately @@ -2333,7 +2426,6 @@ export async function toggleSubtasks( ): Promise { try { let subtasksContainer = card.querySelector(".task-card__subtasks") as HTMLElement; - if (expanded) { // Show subtasks if (!subtasksContainer) { @@ -2394,8 +2486,12 @@ export async function toggleSubtasks( return; } - // Sort subtasks - const sortedSubtasks = plugin.projectSubtasksService.sortTasks(subtasks); + // Sort subtasks (manual order only when active) + const baseSortedSubtasks = plugin.projectSubtasksService.sortTasks(subtasks); + const manualSortActive = card.classList.contains("task-card--manual-sort"); + const sortedSubtasks = manualSortActive + ? sortSubtasksByManualRank(baseSortedSubtasks, task.path, plugin) + : baseSortedSubtasks; // Build parent chain by traversing up the DOM hierarchy const buildParentChain = (element: HTMLElement): string[] => { @@ -2427,13 +2523,23 @@ export async function toggleSubtasks( continue; } - const subtaskCard = createTaskCard(subtask, plugin, undefined); + const subtaskVisibleProps = getSubtaskVisibleProperties(card, plugin); + const subtaskCard = createTaskCard(subtask, plugin, subtaskVisibleProps, { + manualSortEnabled: manualSortActive, + }); // Add subtask modifier class subtaskCard.classList.add("task-card--subtask"); + subtaskCard.setAttribute("draggable", "true"); + (subtaskCard as HTMLElement).draggable = true; + subtaskCard.dataset.taskPath = subtask.path; + // Remove manual-sort handle for subtasks in kanban; ordering happens in edit modal + subtaskCard.querySelector(".task-card__drag-handle")?.remove(); subtasksContainer.appendChild(subtaskCard); } + + // Subtask manual sorting happens in the edit modal, not on the kanban board. } catch (error) { console.error("Error loading subtasks:", error); loadingEl.textContent = plugin.i18n.translate( @@ -2449,7 +2555,6 @@ export async function toggleSubtasks( subtasksContainer.removeEventListener("click", clickHandler); delete (subtasksContainer as any)._clickHandler; } - // Remove the container (this will also clean up child elements and their listeners) subtasksContainer.remove(); } @@ -2460,6 +2565,62 @@ export async function toggleSubtasks( } } +function getSubtaskRank( + subtask: TaskInfo, + parentPath: string, + plugin: TaskNotesPlugin +): number | null { + const rankMap = (subtask as any)?.rankByProject ?? subtask.customProperties?.rankByProject; + let raw: any = null; + if (rankMap && typeof rankMap === "object") { + raw = (rankMap as Record)[parentPath]; + } + if (raw === null || raw === undefined) { + const cache = plugin.app.metadataCache.getCache(subtask.path); + const fmRank = cache?.frontmatter?.rankByProject; + if (fmRank && typeof fmRank === "object") { + raw = (fmRank as Record)[parentPath]; + } + } + if (raw === null || raw === undefined) return null; + const parsed = typeof raw === "number" ? raw : Number(raw); + return Number.isFinite(parsed) ? parsed : null; +} + +function isManualSortActive(plugin: TaskNotesPlugin): boolean { + const activeView = plugin.app.workspace.activeLeaf?.view as any; + return !!activeView?.manualSortEnabled; +} + +function sortSubtasksByManualRank( + subtasks: TaskInfo[], + parentPath: string, + plugin: TaskNotesPlugin +): TaskInfo[] { + if (subtasks.length < 2) return subtasks; + const indexed = subtasks.map((task, index) => ({ + task, + index, + rank: getSubtaskRank(task, parentPath, plugin), + })); + + indexed.sort((a, b) => { + const aHasRank = a.rank !== null && a.rank !== undefined; + const bHasRank = b.rank !== null && b.rank !== undefined; + if (aHasRank && bHasRank) { + if (a.rank! === b.rank!) return a.index - b.index; + return a.rank! - b.rank!; + } + if (aHasRank) return -1; + if (bHasRank) return 1; + return a.index - b.index; + }); + + return indexed.map((entry) => entry.task); +} + +// Subtask ordering is handled in the edit modal. + export async function toggleBlockingTasks( card: HTMLElement, task: TaskInfo, diff --git a/src/ui/renderers/linkRenderer.ts b/src/ui/renderers/linkRenderer.ts index a4e9df0a..21d4a224 100644 --- a/src/ui/renderers/linkRenderer.ts +++ b/src/ui/renderers/linkRenderer.ts @@ -2,11 +2,22 @@ // Link and tag rendering utilities for UI components import { App, TFile, Notice } from "obsidian"; +import { parseLinkToPath } from "../../utils/linkUtils"; + +export type LinkNavigateHandler = ( + normalizedPath: string, + event: MouseEvent +) => Promise | boolean | void; /** Minimal services required to render internal links (DI-friendly) */ export interface LinkServices { metadataCache: App["metadataCache"]; workspace: App["workspace"]; + /** + * Optional source path to resolve relative links and support angle-bracket markdown links. + * Defaults to root resolution when not provided. + */ + sourcePath?: string; } /** Type for hover-link event payload */ @@ -33,19 +44,24 @@ export function appendInternalLink( cssClass?: string; hoverSource?: string; showErrorNotices?: boolean; + onPrimaryNavigate?: LinkNavigateHandler; } = {} ): void { const { cssClass = "internal-link", hoverSource = "tasknotes-property-link", showErrorNotices = false, + onPrimaryNavigate, } = options; + const sourcePath = deps.sourcePath ?? ""; + const normalizedPath = parseLinkToPath(filePath); + const linkEl = container.createEl("a", { cls: cssClass, text: displayText, attr: { - "data-href": filePath, + "data-href": normalizedPath, role: "link", tabindex: "0", }, @@ -55,14 +71,24 @@ export function appendInternalLink( e.preventDefault(); e.stopPropagation(); try { - const file = deps.metadataCache.getFirstLinkpathDest(filePath, ""); - if (file instanceof TFile) { - if (e.ctrlKey || e.metaKey) { - // Ctrl/Cmd+Click opens in new tab - deps.workspace.openLinkText(filePath, "", true); - } else { - await deps.workspace.getLeaf(false).openFile(file); + if (e.ctrlKey || e.metaKey) { + // Ctrl/Cmd+Click opens in new tab + deps.workspace.openLinkText(normalizedPath, sourcePath, true); + return; + } + + if (onPrimaryNavigate) { + const handled = await onPrimaryNavigate(normalizedPath, e); + if (handled !== false) { + return; } + } + + const file = + deps.metadataCache.getFirstLinkpathDest(normalizedPath, sourcePath) || + deps.metadataCache.getFirstLinkpathDest(normalizedPath, ""); + if (file instanceof TFile) { + await deps.workspace.getLeaf(false).openFile(file); } else if (showErrorNotices) { new Notice(`Note "${displayText}" not found`); } @@ -80,9 +106,11 @@ export function appendInternalLink( e.preventDefault(); e.stopPropagation(); try { - const file = deps.metadataCache.getFirstLinkpathDest(filePath, ""); + const file = + deps.metadataCache.getFirstLinkpathDest(normalizedPath, sourcePath) || + deps.metadataCache.getFirstLinkpathDest(normalizedPath, ""); if (file instanceof TFile) { - deps.workspace.openLinkText(filePath, "", true); + deps.workspace.openLinkText(normalizedPath, sourcePath, true); } } catch (error) { console.error("[TaskNotes] Error opening internal link:", { filePath, error }); @@ -98,15 +126,17 @@ export function appendInternalLink( }); linkEl.addEventListener("mouseover", (event) => { - const file = deps.metadataCache.getFirstLinkpathDest(filePath, ""); + const file = + deps.metadataCache.getFirstLinkpathDest(normalizedPath, sourcePath) || + deps.metadataCache.getFirstLinkpathDest(normalizedPath, ""); if (file instanceof TFile) { const hoverEvent: HoverLinkEvent = { event: event as MouseEvent, source: hoverSource, hoverParent: container, targetEl: linkEl, - linktext: filePath, - sourcePath: file.path, + linktext: normalizedPath, + sourcePath: sourcePath || file.path, }; deps.workspace.trigger("hover-link", hoverEvent); } @@ -280,14 +310,8 @@ function parseMarkdownLink(text: string): { displayText: string; filePath: strin if (!match) return null; const displayText = match[1].trim(); - let filePath = match[2].trim(); - - // URL decode the link path - crucial for paths with spaces - try { - filePath = decodeURIComponent(filePath); - } catch (error) { - console.debug("Failed to decode URI component:", filePath, error); - } + const rawPath = match[2].trim(); + const filePath = parseLinkToPath(rawPath); return { displayText, filePath }; } @@ -298,7 +322,8 @@ function parseMarkdownLink(text: string): { displayText: string; filePath: strin export function renderProjectLinks( container: HTMLElement, projects: string[], - deps: LinkServices + deps: LinkServices, + options: { onPrimaryNavigate?: LinkNavigateHandler } = {} ): void { container.innerHTML = ""; @@ -335,6 +360,7 @@ export function renderProjectLinks( cssClass: "task-card__project-link internal-link", hoverSource: "tasknotes-project-link", showErrorNotices: true, + onPrimaryNavigate: options.onPrimaryNavigate, }); } else if (isMarkdownLink(project)) { // Parse markdown link: [text](path) @@ -344,6 +370,7 @@ export function renderProjectLinks( cssClass: "task-card__project-link internal-link", hoverSource: "tasknotes-project-link", showErrorNotices: true, + onPrimaryNavigate: options.onPrimaryNavigate, }); } else { // Fallback to plain text if parsing fails diff --git a/src/utils/TaskManager.ts b/src/utils/TaskManager.ts index 281c8afc..574d2b25 100644 --- a/src/utils/TaskManager.ts +++ b/src/utils/TaskManager.ts @@ -259,6 +259,22 @@ export class TaskManager extends Events { ? calculateTotalTimeSpent(mappedTask.timeEntries) : 0; + // Collect custom properties based on userFields configuration + const customProps: Record = {}; + const userFields = this.settings.userFields || []; + for (const field of userFields) { + const key = field.key || field.id; + if (!key) continue; + if (frontmatter[key] !== undefined) { + customProps[key] = frontmatter[key]; + } + } + + const mergedCustomProperties = + Object.keys(customProps).length > 0 + ? { ...(mappedTask.customProperties || {}), ...customProps } + : mappedTask.customProperties; + // Get dependency information from DependencyCache let isBlocked = false; let blockingTasks: string[] = []; @@ -291,7 +307,8 @@ export class TaskManager extends Events { isBlocked, isBlocking, blocking: blockingTasks.length > 0 ? blockingTasks : undefined, - }; + customProperties: mergedCustomProperties, + }; } catch (error) { console.error(`Error extracting task info from native metadata for ${path}:`, error); return null; diff --git a/src/utils/dependencyUtils.ts b/src/utils/dependencyUtils.ts index 8a2e663a..821b6579 100644 --- a/src/utils/dependencyUtils.ts +++ b/src/utils/dependencyUtils.ts @@ -24,7 +24,7 @@ export function normalizeDependencyEntry(value: unknown): TaskDependency | null if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) return null; - return { uid: trimmed, reltype: DEFAULT_DEPENDENCY_RELTYPE }; + return { uid: parseLinkToPath(trimmed), reltype: DEFAULT_DEPENDENCY_RELTYPE }; } if (typeof value === "object" && value !== null) { @@ -33,12 +33,13 @@ export function normalizeDependencyEntry(value: unknown): TaskDependency | null if (!rawUid) { return null; } + const normalizedUid = parseLinkToPath(rawUid); const reltypeRaw = typeof raw.reltype === "string" ? raw.reltype.trim().toUpperCase() : ""; const reltype = isValidDependencyRelType(reltypeRaw) ? (reltypeRaw as TaskDependencyRelType) : DEFAULT_DEPENDENCY_RELTYPE; const gap = typeof raw.gap === "string" && raw.gap.trim().length > 0 ? raw.gap.trim() : undefined; - return gap ? { uid: rawUid, reltype, gap } : { uid: rawUid, reltype }; + return gap ? { uid: normalizedUid, reltype, gap } : { uid: normalizedUid, reltype }; } return null; @@ -129,14 +130,26 @@ export function resolveDependencyEntry( return null; } - const resolved = app.metadataCache.getFirstLinkpathDest(target, sourcePath); - if (resolved instanceof TFile) { - return { path: resolved.path, file: resolved }; + // Try multiple candidate targets to tolerate .md suffix and parsed linktext variants + const candidates = new Set(); + candidates.add(target); + if (target.endsWith(".md")) { + candidates.add(target.replace(/\.md$/i, "")); + } + const parsed = parseLinktext(target); + if (parsed.path && parsed.path !== target) { + candidates.add(parsed.path); } - const fallback = app.vault.getAbstractFileByPath(target); - if (fallback instanceof TFile) { - return { path: fallback.path, file: fallback }; + for (const candidate of candidates) { + const resolved = app.metadataCache.getFirstLinkpathDest(candidate, sourcePath); + if (resolved instanceof TFile) { + return { path: resolved.path, file: resolved }; + } + const fallback = app.vault.getAbstractFileByPath(candidate); + if (fallback instanceof TFile) { + return { path: fallback.path, file: fallback }; + } } return null; diff --git a/src/utils/linkUtils.ts b/src/utils/linkUtils.ts index 45ff7a9b..850af60d 100644 --- a/src/utils/linkUtils.ts +++ b/src/utils/linkUtils.ts @@ -12,6 +12,20 @@ export function parseLinkToPath(linkText: string): string { const trimmed = linkText.trim(); + // Handle plain angle-bracket autolink style: + if (trimmed.startsWith("<") && trimmed.endsWith(">")) { + let inner = trimmed.slice(1, -1).trim(); + const hasMdExt = /\.md$/i.test(inner); + try { + inner = decodeURIComponent(inner); + } catch (error) { + console.debug("Failed to decode URI component:", inner, error); + } + + const parsed = parseLinktext(inner); + return hasMdExt ? inner : parsed.path || inner; + } + // Handle wikilinks: [[path]] or [[path|alias]] if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) { const inner = trimmed.slice(2, -2).trim(); @@ -30,6 +44,13 @@ export function parseLinkToPath(linkText: string): string { if (markdownMatch) { let linkPath = markdownMatch[2].trim(); + // Strip angle brackets used to allow special characters/spaces in markdown links + if (linkPath.startsWith("<") && linkPath.endsWith(">")) { + linkPath = linkPath.slice(1, -1).trim(); + } + + const hasMdExt = /\.md$/i.test(linkPath); + // URL decode the link path - crucial for paths with spaces like Car%20Maintenance.md try { linkPath = decodeURIComponent(linkPath); @@ -40,13 +61,62 @@ export function parseLinkToPath(linkText: string): string { // Use parseLinktext to handle subpaths/headings const parsed = parseLinktext(linkPath); - return parsed.path; + return hasMdExt ? linkPath : parsed.path; } // Not a link format, return as-is return trimmed; } +/** + * Extract a human-friendly display name for project values. + * Supports wikilinks, markdown links (including <...> paths), and plain text. + * + * @param projectValue - The raw project value + * @param app - Optional Obsidian app for resolving to basename + */ +export function getProjectDisplayName(projectValue: string, app?: App): string { + if (!projectValue) return ""; + + const trimmed = projectValue.trim(); + + // Handle markdown links: [text](path) + const markdownMatch = trimmed.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (markdownMatch) { + const displayText = markdownMatch[1].trim(); + const rawPath = markdownMatch[2].trim(); + if (displayText) { + return displayText; + } + const linkPath = parseLinkToPath(rawPath); + const resolved = app?.metadataCache.getFirstLinkpathDest(linkPath, ""); + if (resolved) return resolved.basename; + const cleanPath = linkPath.replace(/\.md$/i, ""); + const parts = cleanPath.split("/"); + return parts[parts.length - 1] || cleanPath; + } + + // Handle wikilinks: [[path]] or [[path|alias]] + if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) { + const linkContent = trimmed.slice(2, -2).trim(); + const pipeIndex = linkContent.indexOf("|"); + if (pipeIndex !== -1) { + const alias = linkContent.slice(pipeIndex + 1).trim(); + if (alias) return alias; + } + const parsed = parseLinktext(linkContent.split("|")[0] || linkContent); + const linkPath = parsed.path || parseLinkToPath(trimmed); + const resolved = app?.metadataCache.getFirstLinkpathDest(linkPath, ""); + if (resolved) return resolved.basename; + const cleanPath = linkPath.replace(/\.md$/i, ""); + const parts = cleanPath.split("/"); + return parts[parts.length - 1] || cleanPath; + } + + // Plain text + return trimmed; +} + /** * Generate a link for use in frontmatter properties. * By default generates wikilink format because Obsidian does not support markdown links diff --git a/src/views/StatsView.ts b/src/views/StatsView.ts index 84c33388..d95e6ea3 100644 --- a/src/views/StatsView.ts +++ b/src/views/StatsView.ts @@ -14,6 +14,7 @@ import { calculateTotalTimeSpent, filterEmptyProjects } from "../utils/helpers"; import { getTodayLocal, createUTCDateFromLocalCalendarDate } from "../utils/dateUtils"; import { createTaskCard } from "../ui/TaskCard"; import { convertInternalToUserProperties } from "../utils/propertyMapping"; +import { getProjectDisplayName } from "../utils/linkUtils"; interface ProjectStats { projectName: string; @@ -472,30 +473,8 @@ export class StatsView extends ItemView { */ private extractProjectName(projectValue: string): string | null { if (!projectValue) return null; - - // For wikilinks, extract the link content - if (projectValue.startsWith("[[") && projectValue.endsWith("]]")) { - const linkPath = this.extractWikilinkPath(projectValue); - if (!linkPath) return null; - - // Extract basename from path - const parts = linkPath.split("/"); - return parts[parts.length - 1] || linkPath; - } - - // For pipe syntax, get the display name - if (projectValue.includes("|")) { - const parts = projectValue.split("|"); - return parts[parts.length - 1] || projectValue; - } - - // For paths, get the final segment - if (projectValue.includes("/")) { - const parts = projectValue.split("/"); - return parts[parts.length - 1] || projectValue; - } - - return projectValue; + const displayName = getProjectDisplayName(projectValue, this.plugin?.app); + return displayName || null; } private calculateOverallStats(tasks: TaskInfo[]): OverallStats { diff --git a/styles/kanban-view.css b/styles/kanban-view.css index 42cb839a..f08040eb 100644 --- a/styles/kanban-view.css +++ b/styles/kanban-view.css @@ -386,6 +386,33 @@ min-height: 0; /* Allow container to shrink and enable scrolling */ } +/* Drop indicator for manual card reordering */ +.tasknotes-plugin .kanban-view__card-wrapper--dragover-top, +.tasknotes-plugin .kanban-view__card-wrapper--dragover-bottom { + position: relative; +} + +.tasknotes-plugin .kanban-view__card-wrapper--dragover-top::before, +.tasknotes-plugin .kanban-view__card-wrapper--dragover-bottom::after { + content: ""; + position: absolute; + left: 8px; + right: 8px; + height: 2px; + background: color-mix(in srgb, var(--tn-interactive-accent) 70%, transparent); + border-radius: 2px; + pointer-events: none; +} + +.tasknotes-plugin .kanban-view__card-wrapper--dragover-top::before { + top: -2px; +} + +.tasknotes-plugin .kanban-view__card-wrapper--dragover-bottom::after { + bottom: -2px; +} + + /* Dragging state for card wrappers */ .tasknotes-plugin .kanban-view__card-wrapper[draggable="true"]:hover { cursor: grab; @@ -456,4 +483,4 @@ font-size: var(--tn-font-size-xs); padding: 0 var(--tn-spacing-md); } -} \ No newline at end of file +} diff --git a/styles/task-card-bem.css b/styles/task-card-bem.css index 3ffbd15e..3ca6d80e 100644 --- a/styles/task-card-bem.css +++ b/styles/task-card-bem.css @@ -197,12 +197,10 @@ /* Drag handle indicator */ .tasknotes-plugin .task-card__drag-handle { - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 4px; - height: 20px; + width: 6px; + height: 18px; + margin-right: 8px; + border-radius: 3px; background: repeating-linear-gradient( to bottom, var(--tn-text-faint) 0px, @@ -210,17 +208,31 @@ transparent 2px, transparent 4px ); - opacity: 0; + opacity: 0.25; + cursor: grab; + flex: 0 0 auto; + align-self: center; transition: opacity var(--tn-transition-fast); - border-radius: var(--tn-radius-xs); } -.tasknotes-plugin .task-card[draggable="true"]:hover .task-card__drag-handle { - opacity: 0.5; +.tasknotes-plugin .task-card--manual-sort .task-card__drag-handle { + opacity: 0.6; } -.tasknotes-plugin .task-card[draggable="true"]:active .task-card__drag-handle { - opacity: 0.8; +.tasknotes-plugin .task-card--manual-sort .task-card__drag-handle:hover { + opacity: 0.9; +} + +/* Subtasks use the same subtle handle color as top-level */ +.tasknotes-plugin .task-card--manual-sort.task-card--subtask .task-card__drag-handle { + opacity: 0.6; + background: repeating-linear-gradient( + to bottom, + var(--tn-text-faint) 0px, + var(--tn-text-faint) 2px, + transparent 2px, + transparent 4px + ); } /* Container queries for adaptive layouts */ @@ -439,6 +451,7 @@ border-radius: var(--tn-radius-sm); transition: all var(--tn-transition-fast); flex-shrink: 0; + cursor: pointer; } .tasknotes-plugin .task-card__chevron:hover { @@ -462,6 +475,10 @@ opacity: 1; } +.tasknotes-plugin .task-card__project-indicator.task-card__chevron { + opacity: 0.7; +} + .tasknotes-plugin .task-card__chevron svg { width: 14px; height: 14px; @@ -484,6 +501,7 @@ transition: color var(--tn-transition-fast), background var(--tn-transition-fast), opacity var(--tn-transition-fast); border-radius: var(--tn-radius-sm); flex-shrink: 0; + cursor: pointer; } .tasknotes-plugin .task-card__blocking-toggle:hover { @@ -559,6 +577,8 @@ background: color-mix(in srgb, var(--tn-bg-secondary) 25%, transparent); border-radius: var(--tn-radius-sm); backdrop-filter: blur(2px); + border-top-right-radius: 0; + border-bottom-right-radius: 0; } .tasknotes-plugin .task-card__blocking { @@ -632,6 +652,7 @@ border-radius: var(--tn-radius-sm); flex-shrink: 0; margin-left: auto; + cursor: pointer; } .tasknotes-plugin .task-card__context-menu:hover { @@ -996,6 +1017,32 @@ animation: drag-pulse 2s infinite ease-in-out; } +/* Drop indicator for subtask reordering */ +.tasknotes-plugin .task-card--dragover-top, +.tasknotes-plugin .task-card--dragover-bottom { + position: relative; +} + +.tasknotes-plugin .task-card--dragover-top::before, +.tasknotes-plugin .task-card--dragover-bottom::after { + content: ""; + position: absolute; + left: 12px; + right: 12px; + height: 2px; + background: color-mix(in srgb, var(--tn-interactive-accent) 70%, transparent); + border-radius: 2px; + pointer-events: none; +} + +.tasknotes-plugin .task-card--dragover-top::before { + top: -2px; +} + +.tasknotes-plugin .task-card--dragover-bottom::after { + bottom: -2px; +} + @keyframes drag-pulse { 0%, 100% { box-shadow: 0 8px 32px color-mix(in srgb, var(--tn-shadow-color) 30%, transparent), @@ -1222,6 +1269,7 @@ height: 0.9em; padding: 0; vertical-align: baseline; + cursor: pointer; } /* Compact date displays in inline mode */ @@ -1319,11 +1367,13 @@ .tasknotes-plugin .task-card--layout-compact .task-card__blocking-toggle { width: 12px; height: 12px; + cursor: pointer; } .tasknotes-plugin .task-card--layout-compact .task-card__context-menu { width: 12px; height: 12px; + cursor: pointer; } /* ================================================================= diff --git a/styles/task-modal.css b/styles/task-modal.css index 0860eb50..c948f978 100644 --- a/styles/task-modal.css +++ b/styles/task-modal.css @@ -1181,6 +1181,57 @@ body:not(.is-mobile) .modal.mod-tasknotes .modal-button-container .mod-cta { background-color: var(--background-modifier-hover); } +.tasknotes-plugin .task-project-item[draggable="true"] { + cursor: grab; +} + +.tasknotes-plugin .task-project-item--dragging { + opacity: 0.8; + transform: rotate(1deg); +} + +.tasknotes-plugin .task-project-drag-handle { + width: 6px; + height: 16px; + border-radius: 3px; + background: repeating-linear-gradient( + to bottom, + var(--tn-text-faint) 0px, + var(--tn-text-faint) 2px, + transparent 2px, + transparent 4px + ); + opacity: 0.5; + cursor: grab; + flex: 0 0 auto; + align-self: center; +} + +.tasknotes-plugin .task-project-item--dragover-top, +.tasknotes-plugin .task-project-item--dragover-bottom { + position: relative; +} + +.tasknotes-plugin .task-project-item--dragover-top::before, +.tasknotes-plugin .task-project-item--dragover-bottom::after { + content: ""; + position: absolute; + left: 8px; + right: 8px; + height: 2px; + background: color-mix(in srgb, var(--tn-interactive-accent) 70%, transparent); + border-radius: 2px; + pointer-events: none; +} + +.tasknotes-plugin .task-project-item--dragover-top::before { + top: -2px; +} + +.tasknotes-plugin .task-project-item--dragover-bottom::after { + bottom: -2px; +} + .tasknotes-plugin .task-project-info { flex: 1; min-width: 0; diff --git a/tests/unit/ui/TaskCard.test.ts b/tests/unit/ui/TaskCard.test.ts index c9bc4199..1f19fe50 100644 --- a/tests/unit/ui/TaskCard.test.ts +++ b/tests/unit/ui/TaskCard.test.ts @@ -123,7 +123,10 @@ describe('TaskCard Component', () => { app: mockApp, selectedDate: new Date('2025-01-15'), fieldMapper: { - isPropertyForField: jest.fn(() => false), + isPropertyForField: jest.fn((property: string, field: string) => { + const mapping = mockPlugin?.fieldMapper?.getMapping?.(); + return Boolean(mapping && mapping[field] === property); + }), toUserField: jest.fn((field) => field), toInternalField: jest.fn((field) => field), getMapping: jest.fn(() => ({ diff --git a/tests/unit/utils/linkUtils.test.ts b/tests/unit/utils/linkUtils.test.ts index 169a0e75..a4c8e7cb 100644 --- a/tests/unit/utils/linkUtils.test.ts +++ b/tests/unit/utils/linkUtils.test.ts @@ -1,4 +1,4 @@ -import { generateLink, generateLinkWithBasename, generateLinkWithDisplay } from '../../../src/utils/linkUtils'; +import { generateLink, generateLinkWithBasename, generateLinkWithDisplay, getProjectDisplayName, parseLinkToPath } from '../../../src/utils/linkUtils'; import { MockObsidian } from '../../__mocks__/obsidian'; import type { App, TFile } from 'obsidian'; @@ -92,4 +92,53 @@ describe('linkUtils - frontmatter link format', () => { expect(link).toBe('[[Test Project]]'); }); }); + + describe('parseLinkToPath', () => { + it('should parse wikilinks with alias', () => { + const link = '[[Folder/My Note|Alias]]'; + expect(parseLinkToPath(link)).toBe('Folder/My Note'); + }); + + it('should parse markdown links with angle bracket paths', () => { + const link = '[My Note]()'; + expect(parseLinkToPath(link)).toBe('Folder/My Note.md'); + }); + + it('should parse plain angle bracket autolinks', () => { + const link = ''; + expect(parseLinkToPath(link)).toBe('Folder/My Note.md'); + }); + + it('should decode markdown link paths without .md extension', () => { + const link = '[My Note](Folder/My%20Note)'; + expect(parseLinkToPath(link)).toBe('Folder/My Note'); + }); + }); + + describe('getProjectDisplayName', () => { + it('should prefer markdown link display text', () => { + const link = '[Display Title]()'; + expect(getProjectDisplayName(link)).toBe('Display Title'); + }); + + it('should prefer wikilink alias', () => { + const link = '[[Folder/My Note|Alias Title]]'; + expect(getProjectDisplayName(link)).toBe('Alias Title'); + }); + + it('should resolve basename from app when possible', () => { + const appWithResolver = createMockApp(MockObsidian.createMockApp()); + (appWithResolver.metadataCache as any).getFirstLinkpathDest = jest.fn().mockReturnValue({ + basename: 'Resolved Project' + }); + + const link = '[[Folder/My Note]]'; + expect(getProjectDisplayName(link, appWithResolver)).toBe('Resolved Project'); + }); + + it('should fall back to last path segment when unresolved', () => { + const link = '[[Folder/My Note]]'; + expect(getProjectDisplayName(link)).toBe('My Note'); + }); + }); }); diff --git a/versions.json b/versions.json index 6c46d6f5..6a8d58fe 100644 --- a/versions.json +++ b/versions.json @@ -4,5 +4,6 @@ "3.24.6": "1.0.0", "3.25.2": "1.0.0", "3.25.3": "1.0.0", - "3.25.4": "1.0.0" -} \ No newline at end of file + "3.25.4": "1.0.0", + "4.1.4": "1.10.1" +}