diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3840d02..ff4cf61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,8 @@ on: permissions: contents: write + id-token: write + attestations: write jobs: build-and-upload: @@ -29,8 +31,16 @@ jobs: - name: Verify Files Exist run: ls -la main.js styles.css manifest.json + - name: Generate Artifact Attestations + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + main.js + styles.css + manifest.json + - name: Upload Release Assets (Original Names) - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: files: | main.js diff --git a/manifest.json b/manifest.json index e19c9b3..e4d30c9 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "taskmap", "name": "Taskmap", - "version": "0.1.8", + "version": "0.1.9", "minAppVersion": "1.12.4", "description": "Plan projects via interactive GUI task trees with automatic layout.", "author": "poanse", diff --git a/package.json b/package.json index 9c582f3..9119c1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-taskmap", - "version": "0.1.8", + "version": "0.1.9", "description": "Taskmap plugin for Obsidian (https://obsidian.md)", "main": "main.js", "scripts": { @@ -9,7 +9,7 @@ "version": "node version-bump.mjs && git add manifest.json versions.json", "svelte-check": "svelte-check --tsconfig tsconfig.json", "perf:tasks": "tsx scripts/perf-tasks.ts", - "test": "tsx --test tests/reparent-priorities.test.ts", + "test": "tsx --test tests/*.test.ts", "lint": "eslint .", "lint:fix": "eslint . --fix" }, diff --git a/src/data/Action.ts b/src/data/Action.ts index f243a66..272ca64 100644 --- a/src/data/Action.ts +++ b/src/data/Action.ts @@ -58,23 +58,15 @@ export class RemoveTaskSingleAction implements Action { do(data: ProjectData): void { const task = data.getTask(this.taskId); - const parentTask = data.getTask(task.parentId); + const parentId = task.parentId; task.deleted = true; this.children = data.getChildren(this.taskId); - data.getChildren(task.parentId).forEach((taskId) => { - const t = data.getTask(taskId); - if (t.priority > task.priority) { - t.priority += this.children!.length; - } - }); - this.children.forEach((taskId) => { - const t = data.getTask(taskId); - t.priority += task.priority; - t.parentId = parentTask.taskId; - t.depth = parentTask.depth + 1; - }); - data.recalcPriorities(task.parentId); - data.recalcStatusRecursive(task.parentId); + // Promote the children into the deleted task's slot, or recalc the parent. + data.reparentChildren( + this.children, + parentId, + task.priority, + ); data.updateTasksView(); } @@ -84,14 +76,7 @@ export class RemoveTaskSingleAction implements Action { } const task = data.getTask(this.taskId); task.deleted = false; - this.children.forEach((taskId) => { - const t = data.getTask(taskId); - t.parentId = task.taskId; - t.depth = task.depth + 1; - }); - data.recalcPriorities(task.parentId); - data.recalcPriorities(task.taskId); - data.recalcStatusRecursive(task.taskId); + data.reparentChildren(this.children, task.taskId); this.children = undefined; } } @@ -250,22 +235,16 @@ export class ChangeParentAction implements Action { do(data: ProjectData) { this.oldParentId = data.getTask(this.taskId).parentId; this.oldPriority = data.getTask(this.taskId).priority; - data.getTask(this.taskId).priority = -1; - data.changeParent(this.taskId, this.newParentId); - data.recalcPriorities(this.newParentId); - data.recalcPriorities(this.oldParentId); + // Insert at the beginning of the new parent's children. + data.reparentChild(this.taskId, this.newParentId, 0); } undo(data: ProjectData) { if (this.oldParentId === undefined || this.oldPriority === undefined) { throw new Error(); } - // Use oldPriority - 0.5 so recalcPriorities places task exactly at oldPriority - // (sibling priorities are always whole numbers, so this slots in unambiguously) - data.getTask(this.taskId).priority = this.oldPriority - 0.5; - data.changeParent(this.taskId, this.oldParentId); - data.recalcPriorities(this.oldParentId); - data.recalcPriorities(this.newParentId); + // Restore the task to its exact original slot among its old siblings. + data.reparentChild(this.taskId, this.oldParentId, this.oldPriority); this.oldParentId = undefined; this.oldPriority = undefined; } diff --git a/src/data/ProjectData.svelte.ts b/src/data/ProjectData.svelte.ts index 4308b00..ac718a4 100644 --- a/src/data/ProjectData.svelte.ts +++ b/src/data/ProjectData.svelte.ts @@ -212,17 +212,93 @@ export class ProjectData { return this.getTask(taskId).deleted; } - public changeParent(taskId: TaskId, newParentId: TaskId) { - const taskData = this.getTask(taskId); - const oldParentId = taskData.parentId; - taskData.parentId = newParentId; - this.rebuildCaches(); - this.recalcStatusRecursive(oldParentId); - this.recalcStatusRecursive(newParentId); - this.getDescendantIds(taskId).forEach((taskId) => { - const task = this.getTask(taskId); - task.depth = this.getTask(task.parentId).depth + 1; - }); + // Normalize a parent's child priorities and refresh its status upward. + public recalcParent(parentId: TaskId) { + this.recalcPriorities(parentId); + this.recalcStatusRecursive(parentId); + } + + public reparentChild( + taskId: TaskId, + newParentId: TaskId, + insertPriority?: number, + ) { + this.reparentChildren([taskId], newParentId, insertPriority); + } + + /** + * Reparent tasks under newParentId with a single cache rebuild. Updates + * subtree depths and inserts the batch into the new parent's priority + * ordering, preserving the batch's own relative order while existing + * siblings shift to make room. When insertPriority is omitted the batch is + * appended after the current siblings. + * + * When taskIds is empty, recalcParent runs for newParentId and its parent. + */ + public reparentChildren( + taskIds: TaskId[], + newParentId: TaskId, + insertPriority?: number, + ) { + const oldParentIds: TaskId[] = []; + for (const taskId of taskIds) { + const parentId = this.getTask(taskId).parentId; + if (!oldParentIds.includes(parentId)) { + oldParentIds.push(parentId); + } + } + if (taskIds.length > 0) { + // Capture the batch's relative order before assigning new priorities. + const ordered = [...taskIds].sort( + (a, b) => this.getTask(a).priority - this.getTask(b).priority, + ); + for (const taskId of taskIds) { + this.getTask(taskId).parentId = newParentId; + } + this.rebuildCaches(); + for (const taskId of taskIds) { + this.getDescendantIds(taskId).forEach((descendantId) => { + const descendant = this.getTask(descendantId); + descendant.depth = + this.getTask(descendant.parentId).depth + 1; + }); + } + const siblings = this.getChildren(newParentId).filter( + (id) => !taskIds.includes(id), + ); + const insertAt = + insertPriority ?? + siblings.reduce( + (max, id) => Math.max(max, this.getTask(id).priority + 1), + 0, + ); + siblings.forEach((id) => { + const sibling = this.getTask(id); + if (sibling.priority >= insertAt) { + sibling.priority += taskIds.length; + } + }); + ordered.forEach((id, idx) => { + this.getTask(id).priority = insertAt + idx; + }); + } + const parentsToRecalc: TaskId[] = []; + const addParent = (parentId: TaskId) => { + if (!parentsToRecalc.includes(parentId)) { + parentsToRecalc.push(parentId); + } + }; + addParent(newParentId); + oldParentIds.forEach(addParent); + if (taskIds.length === 0) { + const parentId = this.getTask(newParentId).parentId; + if (parentId !== NoTaskId) { + addParent(parentId); + } + } + for (const parentId of parentsToRecalc) { + this.recalcParent(parentId); + } } public recalcStatusRecursive(taskId: TaskId) { diff --git a/tests/remove-task-priorities.test.ts b/tests/remove-task-priorities.test.ts new file mode 100644 index 0000000..e8538e4 --- /dev/null +++ b/tests/remove-task-priorities.test.ts @@ -0,0 +1,110 @@ +import * as assert from "node:assert/strict"; +import { test } from "node:test"; +import { NoTaskId } from "../src/NodePositionsCalculator"; +import { RemoveTaskSingleAction } from "../src/data/Action"; +import { ProjectData } from "../src/data/ProjectData.svelte"; +import { TASKMAP_FILE_SCHEMA_VERSION } from "../src/data/ProjectDataSchema"; +import { StatusCode, type TaskData, type TaskId } from "../src/types"; + +if (!("$state" in globalThis)) { + Object.defineProperty(globalThis, "$state", { + value: (value: T) => value, + }); +} + +function task( + taskId: TaskId, + parentId: TaskId, + depth: number, + priority: number, + name: string, +): TaskData { + return { + taskId, + parentId, + depth, + priority, + deleted: false, + hidden: false, + status: StatusCode.READY, + name, + }; +} + +// Tree: +// root (0) +// A (1) +// B (2) prio 0 +// C (4) prio 0 +// D (5) prio 1 +// E (3) prio 1 +// F (6) prio 2 +function createProjectData(): ProjectData { + return new ProjectData({ + schemaVersion: TASKMAP_FILE_SCHEMA_VERSION, + tasks: [ + task(0, NoTaskId, 0, 0, "root"), + task(1, 0, 1, 0, "A"), + task(2, 1, 2, 0, "B"), + task(3, 1, 2, 1, "E"), + task(4, 2, 3, 0, "C"), + task(5, 2, 3, 1, "D"), + task(6, 1, 2, 2, "F"), + ], + blockerPairs: [], + folderPath: undefined, + curTaskId: 7, + taskSizeOverrides: [], + }); +} + +function assertChildrenByPriority( + data: ProjectData, + parentId: TaskId, + expectedTaskIds: TaskId[], +) { + const children = data + .getChildren(parentId) + .map((id) => data.getTask(id)) + .sort((a, b) => a.priority - b.priority); + + assert.deepEqual( + children.map((t) => t.taskId), + expectedTaskIds, + ); + const priorities = children.map((t) => t.priority); + assert.ok(priorities.every(Number.isInteger)); + assert.deepEqual( + priorities, + Array.from({ length: children.length }, (_, idx) => idx), + ); +} + +void test("removing a task promotes its children into its slot with contiguous priorities", () => { + const data = createProjectData(); + + // Delete B (id 2). Its children C (4) and D (5) should be promoted into A, + // taking B's slot, giving order C, D, E, F with priorities 0, 1, 2, 3. + new RemoveTaskSingleAction(2).do(data); + + assert.equal(data.isTaskDeleted(2), true); + assert.equal(data.getTask(4).parentId, 1); + assert.equal(data.getTask(5).parentId, 1); + assert.equal(data.getTask(4).depth, 2); + assert.equal(data.getTask(5).depth, 2); + assertChildrenByPriority(data, 1, [4, 5, 3, 6]); +}); + +void test("undoing a task removal restores the original tree structure and priorities", () => { + const data = createProjectData(); + const action = new RemoveTaskSingleAction(2); + + action.do(data); + action.undo(data); + + assert.equal(data.isTaskDeleted(2), false); + assert.equal(data.getTask(4).parentId, 2); + assert.equal(data.getTask(5).parentId, 2); + assertChildrenByPriority(data, 1, [2, 3, 6]); + assertChildrenByPriority(data, 2, [4, 5]); +}); diff --git a/tests/reparent-priorities.test.ts b/tests/reparent-priorities.test.ts index a2d8bd7..248f477 100644 --- a/tests/reparent-priorities.test.ts +++ b/tests/reparent-priorities.test.ts @@ -47,6 +47,7 @@ function createProjectData(): ProjectData { blockerPairs: [], folderPath: undefined, curTaskId: 8, + taskSizeOverrides: [], }); } @@ -80,7 +81,8 @@ void test("reparenting normalizes sibling priorities for both parents", () => { assert.equal(data.getTask(4).parentId, 2); assertChildrenByPriority(data, 1, [3, 5]); - assertChildrenByPriority(data, 2, [6, 7, 4]); + // Reparented task is inserted at the beginning of the new parent's children. + assertChildrenByPriority(data, 2, [4, 6, 7]); }); void test("undoing a reparent restores the original sibling priority", () => { diff --git a/tsconfig.json b/tsconfig.json index 7b5ba36..093e496 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "importHelpers": true, "isolatedModules": true, "strictNullChecks": true, + "types": ["node"], "lib": ["DOM", "ES5", "ES6", "ES7"] }, "include": ["**/*.ts", "**/*.svelte"] diff --git a/versions.json b/versions.json index 26382a1..332b516 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,3 @@ { - "1.0.0": "0.15.0" + "0.1.9": "1.12.4" }