Github attestation and fixes (#6)

Added github attestation for artefacts
Fixed obsidian review warnings
Fixed a bug - when deleting a task, its children were reparented with incorrect priorities which broke manual repositioning
Fixed a visual bug - edited task was drawn behind its siblings which made name editing clunky for long task names
This commit is contained in:
Poluboiarinov Anton 2026-07-13 09:37:48 +03:00 committed by GitHub
parent adfcddd74c
commit 39c806a2e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 333 additions and 54 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

3
.gitignore vendored
View file

@ -1,3 +1,6 @@
# cursor
.cursor
# vscode
.vscode

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

@ -16,7 +16,7 @@ export class DraggingManager {
public onPointerDown = (e: PointerEvent) => {
console.debug("DraggingManager pointerDown");
this.mouseDown = e.button as MouseDown;
this.mouseDown = e.button;
if (this.mouseCodes.includes(this.mouseDown)) {
this.startX = e.clientX;
this.startY = e.clientY;

View file

@ -119,6 +119,7 @@
{#key context.updateOnZoomCounter}
<div
class="task-container"
class:editing={context.editingTaskId === taskId}
bind:this={self}
role="presentation"
style="
@ -207,6 +208,9 @@
z-index: 3; /* over lines*/
position: absolute;
}
.task-container.editing {
z-index: 4; /* raise the task being renamed above its siblings */
}
.task {
/*background: #111;*/
/*background-color: #1E1E1E;*/

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

@ -9,9 +9,7 @@ export class FolderSuggest extends AbstractInputSuggest<TFolder> {
textInputEl: HTMLInputElement;
constructor(app: App, textInputEl: HTMLInputElement) {
// Cast the textarea to HTMLInputElement to satisfy TypeScript.
// This is a dirty hack, but runtime behavior is compatible
super(app, textInputEl as unknown as HTMLInputElement);
super(app, textInputEl);
this.textInputEl = textInputEl;
}

View file

@ -55,7 +55,7 @@ export const classStringFromStatusCode = (code: StatusCode) => {
};
export const toIconCode = (s: StatusCode) => {
return (s + IconCode.STATUS_DRAFT) as IconCode;
return s + IconCode.STATUS_DRAFT;
};
export const toStatusCode = (s: IconCode) => {

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

@ -0,0 +1,98 @@
import * as assert from "node:assert/strict";
import { test } from "node:test";
import { NoTaskId } from "../src/NodePositionsCalculator";
import { ChangeParentAction } 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,
};
}
function createProjectData(): ProjectData {
return new ProjectData({
schemaVersion: TASKMAP_FILE_SCHEMA_VERSION,
tasks: [
task(0, NoTaskId, 0, 0, "root"),
task(1, 0, 1, 0, "old parent"),
task(2, 0, 1, 1, "new parent"),
task(3, 1, 2, 0, "old child before moved"),
task(4, 1, 2, 1, "moved child"),
task(5, 1, 2, 2, "old child after moved"),
task(6, 2, 2, 0, "new child first"),
task(7, 2, 2, 1, "new child second"),
],
blockerPairs: [],
folderPath: undefined,
curTaskId: 8,
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("reparenting normalizes sibling priorities for both parents", () => {
const data = createProjectData();
assert.equal(data.getTask(4).parentId, 1);
new ChangeParentAction(4, 2).do(data);
assert.equal(data.getTask(4).parentId, 2);
assertChildrenByPriority(data, 1, [3, 5]);
// 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", () => {
const data = createProjectData();
const action = new ChangeParentAction(4, 2);
action.do(data);
action.undo(data);
assert.equal(data.getTask(4).parentId, 1);
assertChildrenByPriority(data, 1, [3, 4, 5]);
assertChildrenByPriority(data, 2, [6, 7]);
});

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