Address Obsidian review feedback

This commit is contained in:
Savar-G 2026-07-18 15:33:24 -07:00
parent b3d1bceb89
commit 082f318bf7
19 changed files with 4669 additions and 57 deletions

View file

@ -6,10 +6,10 @@ on:
- "[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
contents: read
jobs:
release:
build:
runs-on: ubuntu-latest
steps:
- name: Check out repository
@ -29,6 +29,9 @@ jobs:
- name: Run tests
run: npm test
- name: Run Obsidian lint
run: npm run lint
- name: Build plugin
run: npm run build
@ -37,6 +40,38 @@ jobs:
RELEASE_TAG: ${{ github.ref_name }}
run: node -e 'const manifest = require("./manifest.json"); if (manifest.version !== process.env.RELEASE_TAG) { throw new Error(`Tag ${process.env.RELEASE_TAG} does not match manifest ${manifest.version}`); }'
- name: Stage release assets
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: taskline-release
path: |
main.js
manifest.json
styles.css
if-no-files-found: error
retention-days: 1
release:
needs: build
runs-on: ubuntu-latest
permissions:
attestations: write
contents: write
id-token: write
steps:
- name: Download release assets
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: taskline-release
- name: Attest release assets
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-path: |
main.js
manifest.json
styles.css
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}

View file

@ -33,7 +33,7 @@ Taskline does not connect to meeting or messaging services itself. Your capture
## Requirements
- Obsidian 1.5.0 or later
- Obsidian 1.7.2 or later
- Markdown task notes that use `- [ ] Task` syntax
- The [Tasks plugin](https://github.com/obsidian-tasks-group/obsidian-tasks) only if you complete recurring tasks through Taskline

20
eslint.config.mjs Normal file
View file

@ -0,0 +1,20 @@
import tsParser from "@typescript-eslint/parser";
import { defineConfig } from "eslint/config";
import obsidianmd from "eslint-plugin-obsidianmd";
export default defineConfig([
...obsidianmd.configs.recommended,
{
files: ["**/*.ts"],
languageOptions: {
parser: tsParser,
parserOptions: { project: "./tsconfig.json" },
},
rules: {
"obsidianmd/ui/sentence-case": ["warn", { brands: ["Taskline"], acronyms: ["JSON", "ID"] }],
},
},
{
ignores: ["esbuild.config.mjs", "main.js", "node_modules/**", "tests/**"],
},
]);

View file

@ -1,10 +1,10 @@
{
"id": "vault-tasks",
"name": "Taskline",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "Configurable task capture and Today view for Obsidian",
"version": "0.1.1",
"minAppVersion": "1.7.2",
"description": "Configurable task capture and Today view.",
"author": "Taskline contributors",
"authorUrl": "https://github.com/Savar-G/taskline",
"authorUrl": "https://github.com/Savar-G",
"isDesktopOnly": false
}

4556
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
{
"name": "taskline",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"description": "Configurable task capture and Today view for Obsidian",
"description": "Configurable task capture and Today view.",
"main": "main.js",
"license": "MIT",
"repository": {
@ -16,6 +16,7 @@
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"lint": "eslint . --max-warnings 2",
"test": "vitest run"
},
"keywords": [
@ -25,7 +26,10 @@
"productivity"
],
"devDependencies": {
"@typescript-eslint/parser": "^8.64.0",
"esbuild": "0.28.1",
"eslint": "^9.39.5",
"eslint-plugin-obsidianmd": "^0.4.1",
"obsidian": "^1.13.1",
"tslib": "2.6.2",
"typescript": "5.9.3",

View file

@ -243,7 +243,7 @@ function parseProtectedTitle(input: string): ProtectedTitle | null {
if (char !== '"') continue;
if (i + 1 < input.length && !/\s/.test(input[i + 1])) return null;
try {
const value = JSON.parse(input.slice(0, i + 1));
const value: unknown = JSON.parse(input.slice(0, i + 1));
return typeof value === "string" ? { value, end: i + 1 } : null;
} catch {
return null;

View file

@ -106,7 +106,7 @@ const SIGNIFIER_TOKEN =
"|✅\\s*\\d{4}-\\d{2}-\\d{2}" +
"|❌\\s*\\d{4}-\\d{2}-\\d{2}";
const TRAILING_RUN_RE = new RegExp(`(?:\\s*(?:${SIGNIFIER_TOKEN}))+$`);
const TRAILING_RUN_RE = new RegExp(`(?:\\s*(?:${SIGNIFIER_TOKEN}))+$`, "u");
/** Inserts annotation text immediately before the trailing signifier run (or at the end of
* the line if there is no such run), preserving the invariant. */

View file

@ -26,7 +26,7 @@ export default class TasklinePlugin extends Plugin {
async onload(): Promise<void> {
this.loaded = true;
const rawSettings = await this.loadData();
const rawSettings: unknown = await this.loadData();
const loadedWorkspace = compileWorkspace(rawSettings);
this.settingsLoadIssues = loadedWorkspace.issues.filter((issue) => issue.level === "error");
this.rejectedSettingsRaw = this.settingsLoadIssues.length > 0 ? rawSettings : null;
@ -37,8 +37,14 @@ export default class TasklinePlugin extends Plugin {
this.addSettingTab(new TasklineSettingTab(this));
this.registerView(VIEW_TYPE_TODAY, (leaf: WorkspaceLeaf) => new TodayView(leaf, this));
this.addRibbonIcon("check-circle", "Open Taskline Today", () => void this.openTodayView());
this.addCommand({ id: "open-today", name: "Open Today", callback: () => void this.openTodayView() });
this.addRibbonIcon("check-circle", "Open Taskline today", () => {
this.openTodayView().catch((error: unknown) => console.error("taskline: could not open Today view", error));
});
this.addCommand({
id: "open-today",
name: "Open today",
callback: () => this.openTodayView().catch((error: unknown) => console.error("taskline: could not open Today view", error)),
});
this.addCommand({ id: "add-task", name: "Add task", callback: () => this.openCapture() });
this.app.workspace.onLayoutReady(() => {
@ -173,11 +179,11 @@ export default class TasklinePlugin extends Plugin {
async openTodayView(): Promise<void> {
const existingLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_TODAY)[0];
if (existingLeaf) {
this.app.workspace.revealLeaf(existingLeaf);
await this.app.workspace.revealLeaf(existingLeaf);
return;
}
const leaf = this.app.workspace.getLeaf("tab");
await leaf.setViewState({ type: VIEW_TYPE_TODAY, active: true });
this.app.workspace.revealLeaf(leaf);
await this.app.workspace.revealLeaf(leaf);
}
}

View file

@ -94,7 +94,7 @@ export function parseTaskLine(line: string, ctx: ParseCtx): VtTask | null {
// recurrence - everything after 🔁 up to the next signifier emoji or end of string
let recurrence: string | undefined;
const recRe = new RegExp(`🔁\\s*([^${SIGNIFIER_CLASS}]*)`);
const recRe = new RegExp(`🔁\\s*([^${SIGNIFIER_CLASS}]*)`, "u");
const recMatch = work.match(recRe);
if (recMatch) {
recurrence = recMatch[1].trim();

View file

@ -24,9 +24,8 @@ export class TasklineSettingTab extends PluginSettingTab {
const draft = createSettingsDraft(this.taskline.settings, this.taskline.rejectedSettingsRaw);
const parseErrors = new Map<string, string>();
containerEl.empty();
containerEl.createEl("h2", { text: "Taskline settings" });
containerEl.createEl("p", {
text: "Changes remain a draft until Apply. Taskline never creates or changes task files from settings.",
text: "Changes remain a draft until you apply them. Taskline never creates or changes task files from settings.",
});
this.renderIssues(
@ -47,7 +46,7 @@ export class TasklineSettingTab extends PluginSettingTab {
}
draftIssues.show();
draftIssues.setAttr("role", "alert");
draftIssues.createEl("h3", { text: "Draft validation" });
new Setting(draftIssues).setName("Draft validation").setHeading();
const list = draftIssues.createEl("ul");
for (const message of messages) list.createEl("li", { text: message });
};
@ -95,7 +94,7 @@ export class TasklineSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName("Fallback capture destination")
.setDesc("JSON object with sourceId and a level-two heading, or null.")
.setDesc("JSON object with source ID and a level-two heading, or null.")
.addTextArea((text) => {
text.inputEl.rows = 3;
text.setValue(JSON.stringify(draft.fallbackCaptureDestination, null, 2));
@ -143,7 +142,7 @@ export class TasklineSettingTab extends PluginSettingTab {
if (issues.length === 0) return;
const block = this.containerEl.createDiv({ cls: "vt-settings-issues" });
block.setAttr("role", "status");
block.createEl("h3", { text: title });
new Setting(block).setName(title).setHeading();
const list = block.createEl("ul");
for (const issue of issues) {
list.createEl("li", { text: `${issue.level === "error" ? "Error" : "Warning"}: ${issue.path} - ${issue.message}` });

View file

@ -468,7 +468,7 @@ export class TodayView extends ItemView {
const tasks = this.filtered(group.tasks);
if (tasks.length === 0) continue;
anyVisible = true;
if (group.mode === "by-heading") this.buildGroupedSourceSection(parent, group.label, tasks, today, group.color);
if (group.mode === "by-heading") this.buildGroupedSourceSection(parent, group.label, tasks, today);
else this.buildTaskSection(parent, group.label, this.sortTasks(tasks), today, { dot: areaColor(group.key, this.plugin.workspace) });
}
@ -536,7 +536,7 @@ export class TodayView extends ItemView {
}
/** A by-heading source group renders one group header with source headings as sub-labels. */
private buildGroupedSourceSection(parent: HTMLElement, label: string, tasks: VtTask[], today: Date, color?: string): void {
private buildGroupedSourceSection(parent: HTMLElement, label: string, tasks: VtTask[], today: Date): void {
const section = parent.createDiv({ cls: "vt-section" });
this.buildSectionHead(section, label, tasks.length, { dot: areaColor(label, this.plugin.workspace) });
@ -620,7 +620,6 @@ export class TodayView extends ItemView {
row.removeAttribute("tabindex");
const ring = row.querySelector<HTMLElement>(".vt-ring");
if (ring) {
ring.style.pointerEvents = "none";
ring.dataset.status = task.status;
ring.removeAttribute("role");
ring.setAttr("aria-hidden", "true");

View file

@ -295,8 +295,8 @@ export abstract class CaptureModalBase extends Modal {
private syncInputHeight(): void {
if (!this.wrapsInput()) return;
this.input.style.height = "auto";
this.input.style.height = `${Math.min(this.input.scrollHeight, 160)}px`;
this.input.setCssStyles({ height: "auto" });
this.input.setCssStyles({ height: `${Math.min(this.input.scrollHeight, 160)}px` });
}
/** Repaints the highlight backdrop from the current parse. Synchronous and cheap so typing
@ -376,7 +376,6 @@ export abstract class CaptureModalBase extends Modal {
ring.removeAttribute("aria-checked");
ring.removeAttribute("aria-label");
ring.removeAttribute("tabindex");
ring.style.pointerEvents = "none";
}
}

View file

@ -6,7 +6,12 @@ export interface AnimToken {
cancelled: boolean;
}
const CANCELLED = Symbol("vt-anim-cancelled");
class AnimationCancelledError extends Error {
constructor() {
super("Taskline animation cancelled");
this.name = "AnimationCancelledError";
}
}
export function prefersReducedMotion(): boolean {
return (
@ -21,28 +26,26 @@ function sleep(ms: number): Promise<void> {
}
function checkpoint(token?: AnimToken): void {
if (token?.cancelled) throw CANCELLED;
if (token?.cancelled) throw new AnimationCancelledError();
}
export function isCancellation(err: unknown): boolean {
return err === CANCELLED;
return err instanceof AnimationCancelledError;
}
/** Collapses a row to zero height then resolves. One-shot max-height animation (allowed off
* the typing path per DESIGN section 4). Reduced motion -> instant. */
async function collapseRow(row: HTMLElement, reduced: boolean): Promise<void> {
if (reduced) {
row.style.display = "none";
row.setCssStyles({ display: "none" });
return;
}
const start = row.scrollHeight;
row.style.maxHeight = `${start}px`;
row.style.overflow = "hidden";
row.setCssStyles({ maxHeight: `${start}px`, overflow: "hidden" });
// Force reflow so the transition has a start value to animate from.
void row.offsetHeight;
row.classList.add("vt-collapsing");
row.style.maxHeight = "0px";
row.style.opacity = "0";
row.setCssStyles({ maxHeight: "0px", opacity: "0" });
await sleep(160);
}
@ -111,10 +114,7 @@ export function revertRow(row: HTMLElement): void {
"vt-confirming",
"vt-reduced"
);
row.style.removeProperty("max-height");
row.style.removeProperty("overflow");
row.style.removeProperty("opacity");
row.style.removeProperty("display");
row.setCssStyles({ maxHeight: "", overflow: "", opacity: "", display: "" });
const ring = row.querySelector(".vt-ring");
ring?.setAttribute("aria-checked", "false");
}

View file

@ -8,9 +8,7 @@ import {
assertSameFileProposal,
blockLength,
completeLine,
cancelLine,
editLineText,
joinText,
locateLine,
moveTaskWithinText,
rollbackInsertedBlockText,
@ -20,7 +18,6 @@ import {
TasksPluginApiV1,
VtRecurrenceUnavailableError,
InsertedBlockReceipt,
VtPartialMoveError,
VtStaleError,
} from "./writerCore";

View file

@ -118,10 +118,10 @@ export async function runCrossFileMove<T>(operations: {
const receipt = await operations.appendDestination();
try {
await operations.removeSource();
} catch (sourceError) {
} catch (sourceError: unknown) {
try {
await operations.rollbackDestination(receipt);
} catch (rollbackError) {
} catch (rollbackError: unknown) {
throw new VtPartialMoveError(
"taskline: source removal failed and the exact destination insertion could not be rolled back. The task may now exist in both files; verify both files and remove only the extra destination copy.",
{ sourceError, rollbackError }
@ -198,7 +198,7 @@ function todayIso(): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export function cancelLine(task: VtTask, line: string, source?: string): string {
export function cancelLine(line: string, source?: string): string {
const annotated = source ? insertAnnotation(line, `(reconciled from ${source})`) : line;
return insertAnnotation(setStatusChar(annotated, "-"), `${todayIso()}`);
}
@ -211,7 +211,7 @@ export function applyProposalText(
): string {
const source = proposal.source ?? "proposal";
const mutated = editLineText(content, task, (line) => proposal.action === "cancel"
? cancelLine(task, line, source)
? cancelLine(line, source)
: completeLine(api, task, insertAnnotation(line, `(reconciled from ${source})`)));
return editLineText(mutated, { rawLine: proposal.rawLine }, () => []);
}

View file

@ -898,8 +898,8 @@
.vt-capture-modal .vt-capture-input:hover,
.vt-capture-modal .vt-capture-input:focus,
.vt-capture-modal .vt-capture-input:active {
color: transparent !important;
background: transparent !important;
color: transparent;
background: transparent;
-webkit-text-fill-color: transparent;
}
@ -1416,8 +1416,7 @@
.vt-tok--date { --vt-tok-base: var(--vt-tok-date); }
.vt-tok--scheduled {
--vt-tok-base: var(--vt-tok-date);
text-decoration: underline dotted var(--vt-tok-date);
text-underline-offset: 3px;
border-bottom: 1px dotted var(--vt-tok-date);
}
.vt-tok--area { --vt-tok-base: var(--vt-tok-area); }
.vt-tok--recurrence { --vt-tok-base: var(--vt-tok-recur); }
@ -1448,6 +1447,7 @@
/* the preview ring is display-only; drop the pointer affordance */
.vt-capture-preview .vt-ring {
cursor: default;
pointer-events: none;
}
/* destination indicator */
@ -1696,6 +1696,7 @@
.vt-row--done .vt-ring {
--vt-ring-color: var(--vt-text-faint);
background: color-mix(in srgb, var(--vt-text-faint) 30%, transparent);
pointer-events: none;
}
.vt-row--done:hover {
@ -1756,8 +1757,7 @@
.vt-task-row {
display: grid;
grid-template-columns: var(--vt-ring-size) minmax(0, 1fr);
column-gap: var(--vt-space-3);
row-gap: var(--vt-space-1);
gap: var(--vt-space-1) var(--vt-space-3);
}
.vt-ring {

View file

@ -63,7 +63,7 @@ describe("atomic text mutations", () => {
});
it("serializes cancellation without completion semantics", () => {
expect(cancelLine(makeTask({ title: "Drop" }), "- [ ] Drop")).toMatch(/^- \[-\] Drop ❌ \d{4}-\d{2}-\d{2}$/);
expect(cancelLine("- [ ] Drop")).toMatch(/^- \[-\] Drop ❌ \d{4}-\d{2}-\d{2}$/);
});
it("preserves CRLF while editing and appending", () => {

View file

@ -1,3 +1,4 @@
{
"0.1.0": "1.5.0"
"0.1.0": "1.5.0",
"0.1.1": "1.7.2"
}