task deletion bugfix and github attestation added

This commit is contained in:
poanse 2026-07-13 09:11:47 +03:00
parent 5b47fcd94a
commit 3adda62610
9 changed files with 228 additions and 50 deletions

View file

@ -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

View file

@ -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",

View file

@ -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"
},

View file

@ -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;
}

View file

@ -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) {

View file

@ -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: <T>(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]);
});

View file

@ -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", () => {

View file

@ -13,6 +13,7 @@
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"types": ["node"],
"lib": ["DOM", "ES5", "ES6", "ES7"]
},
"include": ["**/*.ts", "**/*.svelte"]

View file

@ -1,3 +1,3 @@
{
"1.0.0": "0.15.0"
"0.1.9": "1.12.4"
}