obsidian-plugin-markdown-tasks/test/logbookCore.test.ts
Felipe M. cd477931af
Add logbook feature for tasks
Add an opt-in logbook feature: a list item under a task that records
dated notes. Enabling it adds:

- Live Preview styling for the logbook header and entry dates
- An "Add logbook entry to task" command that, for the task at the
  cursor, creates the logbook, appends today's entry, or extends an
  existing entry. It reuses the document's indentation and handles
  nested and sibling logbook layouts, scoping each logbook to its task.

Configurable header text and date format.
2026-06-01 15:49:47 +02:00

381 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {describe, expect, it} from "bun:test";
import {addLogbookEntry, dateFormatToRegexSource} from "../src/logbookCore";
import type {LogbookSettings} from "../src/logbookCore";
const settings = (overrides: Partial<LogbookSettings> = {}): LogbookSettings => ({
logbookHeader: "**Logbook**",
logbookDateFormat: "YYYY-MM-DD",
...overrides,
});
const TODAY = new Date(2026, 5, 1); // 2026-06-01 (local time)
function run(lines: string[], cursorLine: number, overrides: Partial<LogbookSettings> = {}) {
return addLogbookEntry(lines, cursorLine, settings(overrides), TODAY);
}
describe("addLogbookEntry no logbook yet", () => {
it("adds a logbook header and a dated entry under a bare task", () => {
const edit = run(["- [ ] My task"], 0);
expect(edit).not.toBeNull();
expect(edit.lines).toEqual(["- [ ] My task", " - **Logbook**", " - 2026-06-01: "]);
expect(edit.cursorLine).toBe(2);
expect(edit.cursorCh).toBe(" - 2026-06-01: ".length);
});
it("appends the logbook after existing subtasks", () => {
const edit = run(["- [ ] My task", " - [ ] subtask"], 0);
expect(edit.lines).toEqual([
"- [ ] My task",
" - [ ] subtask",
" - **Logbook**",
" - 2026-06-01: ",
]);
});
it("reuses the document's existing indentation unit (tabs)", () => {
const edit = run(["- [ ] My task", "\t- [ ] subtask"], 0);
expect(edit.lines).toEqual([
"- [ ] My task",
"\t- [ ] subtask",
"\t- **Logbook**",
"\t\t- 2026-06-01: ",
]);
});
it("works when invoked from a subtask line (uses that task)", () => {
const edit = run(["- [ ] parent", " - [ ] child"], 1);
expect(edit.lines).toEqual([
"- [ ] parent",
" - [ ] child",
" - **Logbook**",
" - 2026-06-01: ",
]);
});
});
describe("addLogbookEntry logbook present, no entry for today", () => {
it("adds a dated entry to an existing logbook", () => {
const edit = run(["- [ ] My task", " - **Logbook**", " - 2026-05-31: did stuff"], 0);
expect(edit.lines).toEqual([
"- [ ] My task",
" - **Logbook**",
" - 2026-05-31: did stuff",
" - 2026-06-01: ",
]);
expect(edit.cursorLine).toBe(3);
expect(edit.cursorCh).toBe(" - 2026-06-01: ".length);
});
it("finds the task when the cursor is on a logbook date line", () => {
const edit = run(["- [ ] My task", " - **Logbook**", " - 2026-05-31: did stuff"], 2);
expect(edit.lines[3]).toBe(" - 2026-06-01: ");
});
it("adds today to an empty logbook (header only)", () => {
const edit = run(["- [ ] My task", " - **Logbook**"], 0);
expect(edit.lines).toEqual(["- [ ] My task", " - **Logbook**", " - 2026-06-01: "]);
expect(edit.lines.filter((l) => l.includes("**Logbook**")).length).toBe(1);
});
});
describe("addLogbookEntry entry for today exists", () => {
it("converts a single-line entry into a nested list and adds a fresh bullet", () => {
const edit = run(
["- [ ] My task", " - **Logbook**", " - 2026-06-01: This and that"],
0,
);
expect(edit.lines).toEqual([
"- [ ] My task",
" - **Logbook**",
" - 2026-06-01:",
" - This and that",
" - ",
]);
expect(edit.cursorLine).toBe(4);
expect(edit.cursorCh).toBe(" - ".length);
});
it("appends a fresh bullet to a multi-line entry", () => {
const edit = run(
[
"- [ ] My task",
" - **Logbook**",
" - 2026-06-01:",
" - This and that",
" - And this other thing",
],
0,
);
expect(edit.lines).toEqual([
"- [ ] My task",
" - **Logbook**",
" - 2026-06-01:",
" - This and that",
" - And this other thing",
" - ",
]);
expect(edit.cursorLine).toBe(5);
});
it("places the cursor on an empty single-line entry without adding lines", () => {
const edit = run(["- [ ] My task", " - **Logbook**", " - 2026-06-01: "], 0);
expect(edit.lines).toEqual(["- [ ] My task", " - **Logbook**", " - 2026-06-01: "]);
expect(edit.cursorLine).toBe(2);
expect(edit.cursorCh).toBe(" - 2026-06-01: ".length);
});
});
describe("addLogbookEntry edge cases", () => {
it("returns null when the cursor is not inside a task", () => {
expect(run(["just some text"], 0)).toBeNull();
expect(run(["- a plain bullet"], 0)).toBeNull();
});
it("respects a custom logbook header", () => {
const edit = run(["- [ ] My task"], 0, {logbookHeader: "Journal"});
expect(edit.lines).toEqual(["- [ ] My task", " - Journal", " - 2026-06-01: "]);
});
it("respects a custom date format", () => {
const edit = run(["- [ ] My task"], 0, {logbookDateFormat: "DD/MM/YYYY"});
expect(edit.lines[2]).toBe(" - 01/06/2026: ");
});
it("does not confuse a sibling task's logbook", () => {
const edit = run(
["- [ ] first", " - **Logbook**", " - 2026-05-30: old", "- [ ] second"],
3,
);
expect(edit.lines).toEqual([
"- [ ] first",
" - **Logbook**",
" - 2026-05-30: old",
"- [ ] second",
" - **Logbook**",
" - 2026-06-01: ",
]);
});
});
describe("addLogbookEntry reuses an existing logbook", () => {
function countHeaders(lines: string[]): number {
return lines.filter((l) => l.includes("**Logbook**")).length;
}
it("appends to a sibling logbook written at the task's own indent", () => {
const edit = run(["- [ ] My task", "- **Logbook**", " - 2026-05-31: did stuff"], 0);
expect(countHeaders(edit.lines)).toBe(1);
expect(edit.lines).toEqual([
"- [ ] My task",
"- **Logbook**",
" - 2026-05-31: did stuff",
" - 2026-06-01: ",
]);
});
it("appends to a sibling logbook when invoked from one of its date lines", () => {
const edit = run(["- [ ] My task", "- **Logbook**", " - 2026-05-31: did stuff"], 2);
expect(countHeaders(edit.lines)).toBe(1);
expect(edit.lines[3]).toBe(" - 2026-06-01: ");
});
it("adds today to an empty sibling logbook (header only)", () => {
const edit = run(["- [ ] My task", "- **Logbook**"], 0);
expect(countHeaders(edit.lines)).toBe(1);
expect(edit.lines).toEqual(["- [ ] My task", "- **Logbook**", " - 2026-06-01: "]);
});
it("converts a sibling single-line entry for today into a nested list", () => {
const edit = run(["- [ ] My task", "- **Logbook**", " - 2026-06-01: This and that"], 0);
expect(countHeaders(edit.lines)).toBe(1);
expect(edit.lines).toEqual([
"- [ ] My task",
"- **Logbook**",
" - 2026-06-01:",
" - This and that",
" - ",
]);
});
it("does not create a second logbook when run repeatedly across days", () => {
// Day 1: create from a bare task and type an entry.
let lines = run(["- [ ] My task"], 0, {}).lines;
lines[2] = " - 2026-05-31: day one note"; // simulate the user typing (run uses TODAY=06-01)
// Re-run on a fresh day with the cursor left on the last entry.
const day2 = addLogbookEntry(lines, 2, settings(), new Date(2026, 5, 2));
expect(day2).not.toBeNull();
lines = day2.lines;
expect(lines.filter((l) => l.includes("**Logbook**")).length).toBe(1);
expect(lines).toEqual([
"- [ ] My task",
" - **Logbook**",
" - 2026-05-31: day one note",
" - 2026-06-02: ",
]);
});
it("targets the subtask's own logbook, not the parent's", () => {
const edit = run(
[
"- [ ] parent",
" - **Logbook**",
" - 2026-05-31: parent note",
" - [ ] child",
" - **Logbook**",
" - 2026-05-31: child note",
],
3,
);
expect(edit.lines).toEqual([
"- [ ] parent",
" - **Logbook**",
" - 2026-05-31: parent note",
" - [ ] child",
" - **Logbook**",
" - 2026-05-31: child note",
" - 2026-06-01: ",
]);
});
});
describe("addLogbookEntry indentation configurations", () => {
it("creates a logbook with a 4-space indentation unit", () => {
const edit = run(["- [ ] Task", " - [ ] Subtask"], 0);
expect(edit.lines).toEqual([
"- [ ] Task",
" - [ ] Subtask",
" - **Logbook**",
" - 2026-06-01: ",
]);
});
it("creates a logbook on a subtask using the tab unit", () => {
const edit = run(["- [ ] Task", "\t- [ ] Subtask"], 1);
expect(edit.lines).toEqual([
"- [ ] Task",
"\t- [ ] Subtask",
"\t\t- **Logbook**",
"\t\t\t- 2026-06-01: ",
]);
});
it("adds today to a deeply nested logbook on a subtask", () => {
const edit = run(
[
"- [/] In progress task",
" - [ ] Subtask",
" - **Logbook**",
" - 2026-05-31: yesterday",
],
1,
);
expect(edit.lines).toEqual([
"- [/] In progress task",
" - [ ] Subtask",
" - **Logbook**",
" - 2026-05-31: yesterday",
" - 2026-06-01: ",
]);
expect(edit.cursorLine).toBe(4);
expect(edit.cursorCh).toBe(" - 2026-06-01: ".length);
});
it("converts a deeply nested single-line entry for today", () => {
const edit = run(
[
"- [/] In progress task",
" - [ ] Subtask",
" - **Logbook**",
" - 2026-06-01: did a thing",
],
1,
);
expect(edit.lines).toEqual([
"- [/] In progress task",
" - [ ] Subtask",
" - **Logbook**",
" - 2026-06-01:",
" - did a thing",
" - ",
]);
expect(edit.cursorLine).toBe(5);
expect(edit.cursorCh).toBe(" - ".length);
});
it("finds a deeply nested logbook from any line within it", () => {
const lines = [
"- [/] In progress task",
" - [ ] Subtask",
" - **Logbook**",
" - 2026-05-31: yesterday",
];
// cursor on the header line and on a date line both resolve to the subtask
for (const cursor of [2, 3]) {
const edit = run([...lines], cursor);
expect(edit.lines[4]).toBe(" - 2026-06-01: ");
}
});
it("does not hijack a subtask's logbook when the parent is the target", () => {
const edit = run(
[
"- [/] In progress task",
" - [ ] Subtask",
" - **Logbook**",
" - 2026-05-31: yesterday",
],
0,
);
// The parent gets its own logbook at the parent's child indent; the
// subtask's logbook is left untouched.
expect(edit.lines).toEqual([
"- [/] In progress task",
" - [ ] Subtask",
" - **Logbook**",
" - 2026-05-31: yesterday",
" - **Logbook**",
" - 2026-06-01: ",
]);
expect(edit.lines.filter((l) => l.includes("**Logbook**")).length).toBe(2);
});
it("finds the parent's own logbook past a subtask that has its own", () => {
const edit = run(
[
"- [ ] parent",
" - [ ] subtask",
" - **Logbook**",
" - 2026-05-31: sub note",
" - **Logbook**",
" - 2026-05-30: parent note",
],
0,
);
// Appends to the parent's logbook (line 4), not the subtask's (line 2).
expect(edit.lines).toEqual([
"- [ ] parent",
" - [ ] subtask",
" - **Logbook**",
" - 2026-05-31: sub note",
" - **Logbook**",
" - 2026-05-30: parent note",
" - 2026-06-01: ",
]);
});
});
describe("dateFormatToRegexSource", () => {
it("builds a matcher for the default format", () => {
const re = new RegExp(`^${dateFormatToRegexSource("YYYY-MM-DD")}$`);
expect(re.test("2026-06-01")).toBe(true);
expect(re.test("2026/06/01")).toBe(false);
});
it("escapes literal characters", () => {
const re = new RegExp(`^${dateFormatToRegexSource("DD.MM.YYYY")}$`);
expect(re.test("01.06.2026")).toBe(true);
expect(re.test("01x06x2026")).toBe(false);
});
});