commit 8a609da7785331977e60a62543e044c884328caa Author: h Date: Sat Aug 8 16:30:22 2026 +0200 feat: init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c66f83c --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +.idea + +# Build artifacts. Both are produced by `npm run build` and copied into the +# vault by `make install`. +main.js +styles.css +*.zip + +# Obsidian writes plugin settings here when the repo is symlinked into a +# vault as the live plugin directory. +data.json +tests/.entry.ts +preview/bundle.js diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..29ff3ac --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +PLUGIN_ID := beaver-calendar +ASSETS := manifest.json main.js styles.css versions.json + +.PHONY: install zip _build + +_build: + npm install --silent + npm run build + +install: _build + @test -n "$(VAULT)" || (echo "set VAULT=" >&2; exit 1) + @test -d "$(VAULT)/.obsidian" || (echo "no .obsidian dir at $(VAULT)" >&2; exit 1) + @target="$(VAULT)/.obsidian/plugins/$(PLUGIN_ID)"; \ + mkdir -p "$$target"; \ + cp $(ASSETS) "$$target/"; \ + echo "installed → $$target" + +zip: _build + @version=$$(node -p "require('./manifest.json').version"); \ + out="$(PLUGIN_ID)-$$version.zip"; \ + rm -f "$$out"; \ + zip -q -j "$$out" $(ASSETS); \ + echo "wrote $$out" diff --git a/README.md b/README.md new file mode 100644 index 0000000..5859e1d --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# beaver-calendar +[![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net) + +Calendar and task list for Obsidian Kanban+Tasks + +Sphere - md-file in Kanban boards directory. Project - h2 inside the file. + +## Build and install + +```bash +npm install +npm run dev # esbuild watch +npm run build # typecheck + bundle + css +npm test # round-trip, smoke +npm run preview # UI in browser, no Obsidian +make install VAULT=/путь/к/vault # build and copy to vault +make zip # distributable +``` + +After `make install` turn on plugin in settings. diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..d3ecd3d --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,57 @@ +import esbuild from "esbuild"; +import process from "node:process"; +import builtins from "builtin-modules"; +import sveltePlugin from "esbuild-svelte"; + +const prod = process.argv[2] === "production"; + +const ctx = await esbuild.context({ + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + // Svelte components ship their scoped `` + + +
+ + + diff --git a/preview/serve.mjs b/preview/serve.mjs new file mode 100644 index 0000000..12a5c23 --- /dev/null +++ b/preview/serve.mjs @@ -0,0 +1,118 @@ +import { + createReadStream, + existsSync, + mkdtempSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:http"; +import { homedir, tmpdir } from "node:os"; +import { extname, join, normalize } from "node:path"; +import { extractFile } from "@electron/asar"; +import esbuild from "esbuild"; +import sveltePlugin from "esbuild-svelte"; + +const root = new URL("..", import.meta.url).pathname; +const port = Number(process.env.PORT ?? 4173); + +const ASAR_CANDIDATES = [ + process.env.OBSIDIAN_ASAR, + "/Applications/Obsidian.app/Contents/Resources/obsidian.asar", + join(homedir(), "Applications/Obsidian.app/Contents/Resources/obsidian.asar"), + "/opt/Obsidian/resources/obsidian.asar", + "/usr/lib/obsidian/resources/obsidian.asar", +].filter(Boolean); + +function findAppCss() { + for (const candidate of ASAR_CANDIDATES) { + if (!existsSync(candidate)) continue; + try { + const path = join(mkdtempSync(join(tmpdir(), "bcal-app-")), "app.css"); + writeFileSync(path, extractFile(candidate, "app.css")); + return path; + } catch { + /* try the next candidate */ + } + } + console.warn( + "preview: could not read app.css from an Obsidian install. Set " + + "OBSIDIAN_ASAR to point at obsidian.asar. Without it, button and input " + + "chrome will not match the real app.", + ); + return null; +} + +function findTheme() { + if (process.env.THEME) return process.env.THEME; + const vault = process.env.VAULT; + if (!vault) return null; + const themes = join(vault, ".obsidian/themes"); + if (!existsSync(themes)) return null; + for (const name of readdirSync(themes)) { + const css = join(themes, name, "theme.css"); + if (existsSync(css)) return css; + } + return null; +} + +const appCss = findAppCss(); +const theme = findTheme(); +if (!theme) { + console.warn( + "preview: no community theme loaded. Set VAULT to a vault path, or " + + "THEME to a theme.css, to preview against one.", + ); +} + +const ctx = await esbuild.context({ + entryPoints: [join(root, "preview/entry.ts")], + bundle: true, + format: "iife", + target: "es2022", + outfile: join(root, "preview/bundle.js"), + logLevel: "info", + sourcemap: "inline", + conditions: ["svelte", "browser"], + mainFields: ["svelte", "browser", "module", "main"], + alias: { obsidian: join(root, "tests/obsidian-stub.ts") }, + plugins: [sveltePlugin({ compilerOptions: { css: "injected", runes: true } })], +}); +await ctx.rebuild(); +await ctx.watch(); + +const TYPES = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".md": "text/plain; charset=utf-8", +}; + +createServer((request, response) => { + const url = decodeURIComponent((request.url ?? "/").split("?")[0]); + const external = { "/app.css": appCss, "/theme.css": theme }; + + if (url in external) { + const file = external[url]; + if (!file) { + response.writeHead(200, { "content-type": TYPES[".css"] }).end(""); + return; + } + response.writeHead(200, { "content-type": TYPES[".css"], "cache-control": "no-store" }); + createReadStream(file).pipe(response); + return; + } + + const file = join(root, normalize(url === "/" ? "/preview/index.html" : url)); + if (!file.startsWith(root) || !existsSync(file) || statSync(file).isDirectory()) { + response.writeHead(404).end("not found"); + return; + } + response.writeHead(200, { + "content-type": TYPES[extname(file)] ?? "application/octet-stream", + "cache-control": "no-store", + }); + createReadStream(file).pipe(response); +}).listen(port, () => { + console.log(`preview: http://localhost:${port}/`); +}); diff --git a/src/lib/dnd.svelte.ts b/src/lib/dnd.svelte.ts new file mode 100644 index 0000000..eda127f --- /dev/null +++ b/src/lib/dnd.svelte.ts @@ -0,0 +1,189 @@ +export interface DragPayload { + id: string; + kind: string; +} + +export interface DropZone { + target: string; + after: boolean; +} + +let dragging = $state(null); +let zone = $state(null); + +export function isDragging(id: string): boolean { + return dragging?.id === id; +} + +export function dragPayload(): DragPayload | null { + return dragging; +} + +export function dropZone(): DropZone | null { + return zone; +} + +const MOVE_THRESHOLD = 6; +const HOLD_MS = 350; + +interface DraggableOptions { + payload: () => DragPayload; + ondrop: (target: string, after: boolean) => void; + enabled?: () => boolean; +} + +export function draggable(node: HTMLElement, options: DraggableOptions) { + let current = options; + let startX = 0; + let startY = 0; + let holdTimer: number | null = null; + let active = false; + let armed = false; + let pointerId: number | null = null; + let frame: number | null = null; + let lastX = 0; + let lastY = 0; + + const swallowClick = (event: Event): void => { + event.preventDefault(); + event.stopPropagation(); + }; + + const clearHold = (): void => { + if (holdTimer !== null) { + window.clearTimeout(holdTimer); + holdTimer = null; + } + }; + + const paint = (): void => { + frame = null; + node.style.transform = `translate3d(${lastX - startX}px, ${lastY - startY}px, 0)`; + }; + + const begin = (): void => { + if (active) return; + active = true; + dragging = current.payload(); + if (pointerId !== null) node.setPointerCapture(pointerId); + node.style.touchAction = "none"; + node.style.willChange = "transform"; + node.classList.add("bcal-dragging"); + }; + + const finish = (commit: boolean): void => { + clearHold(); + if (frame !== null) { + cancelAnimationFrame(frame); + frame = null; + } + if (pointerId !== null && node.hasPointerCapture(pointerId)) { + node.releasePointerCapture(pointerId); + } + const wasActive = active; + if (wasActive) { + node.addEventListener("click", swallowClick, { capture: true, once: true }); + node.style.transition = "transform 160ms ease-out"; + node.style.transform = ""; + node.classList.remove("bcal-dragging"); + window.setTimeout(() => { + node.style.transition = ""; + node.style.willChange = ""; + }, 180); + } + node.style.touchAction = ""; + const landed = zone; + active = false; + armed = false; + pointerId = null; + dragging = null; + zone = null; + if (commit && wasActive && landed) { + current.ondrop(landed.target, landed.after); + } + }; + + const onpointerdown = (event: PointerEvent): void => { + if (event.button !== 0) return; + if (current.enabled && !current.enabled()) return; + if ((event.target as HTMLElement).closest("[data-nodrag], a")) return; + armed = true; + pointerId = event.pointerId; + startX = event.clientX; + startY = event.clientY; + lastX = startX; + lastY = startY; + if (event.pointerType === "touch") { + holdTimer = window.setTimeout(() => { + holdTimer = null; + if (armed) begin(); + }, HOLD_MS); + } + }; + + const onpointermove = (event: PointerEvent): void => { + if (!armed || event.pointerId !== pointerId) return; + if (!active) { + const moved = + Math.abs(event.clientX - startX) > MOVE_THRESHOLD || + Math.abs(event.clientY - startY) > MOVE_THRESHOLD; + if (!moved) return; + if (event.pointerType === "touch") { + armed = false; + clearHold(); + return; + } + begin(); + } + event.preventDefault(); + + lastX = event.clientX; + lastY = event.clientY; + if (frame === null) frame = requestAnimationFrame(paint); + + const previous = node.style.pointerEvents; + node.style.pointerEvents = "none"; + const under = document.elementFromPoint(event.clientX, event.clientY); + node.style.pointerEvents = previous; + + const target = under?.closest("[data-drop]"); + if (!target) { + zone = null; + return; + } + const box = target.getBoundingClientRect(); + zone = { + target: target.dataset.drop as string, + after: event.clientY > box.top + box.height / 2, + }; + }; + + const onpointerup = (event: PointerEvent): void => { + if (event.pointerId !== pointerId) return; + finish(true); + }; + + const onpointercancel = (event: PointerEvent): void => { + if (event.pointerId !== pointerId) return; + finish(false); + }; + + node.addEventListener("pointerdown", onpointerdown); + node.addEventListener("pointermove", onpointermove); + node.addEventListener("pointerup", onpointerup); + node.addEventListener("pointercancel", onpointercancel); + + return { + update(next: DraggableOptions) { + current = next; + }, + destroy() { + clearHold(); + if (frame !== null) cancelAnimationFrame(frame); + node.removeEventListener("pointerdown", onpointerdown); + node.removeEventListener("pointermove", onpointermove); + node.removeEventListener("pointerup", onpointerup); + node.removeEventListener("pointercancel", onpointercancel); + }, + }; +} diff --git a/src/lib/due.ts b/src/lib/due.ts new file mode 100644 index 0000000..d65994a --- /dev/null +++ b/src/lib/due.ts @@ -0,0 +1,57 @@ +const monthDay = new Intl.DateTimeFormat("en-US", { + day: "numeric", + month: "short", +}); + +export function localKey(date: Date): string { + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${date.getFullYear()}-${month}-${day}`; +} + +export function today(): string { + return localKey(new Date()); +} + +export function fromKey(key: string): Date { + const [year, month, day] = key.split("-").map(Number); + return new Date(year, month - 1, day); +} + +export function shiftDays(key: string, days: number): string { + const date = fromKey(key); + date.setDate(date.getDate() + days); + return localKey(date); +} + +export function daysFromToday(key: string): number { + const target = fromKey(key); + const now = new Date(); + now.setHours(0, 0, 0, 0); + return Math.round((target.getTime() - now.getTime()) / 86_400_000); +} + +export function dueLabel(key: string | null): string { + if (!key) return ""; + const diff = daysFromToday(key); + if (diff === 0) return "today"; + if (diff === 1) return "tomorrow"; + if (diff === -1) return "yesterday"; + return monthDay.format(fromKey(key)); +} + +export type DueTone = "overdue" | "soon" | "normal" | "none"; + +export function dueTone(key: string | null): DueTone { + if (!key) return "none"; + const diff = daysFromToday(key); + if (diff < 0) return "overdue"; + if (diff <= 1) return "soon"; + return "normal"; +} + +export function nextFriday(): string { + const date = new Date(); + date.setDate(date.getDate() + ((5 - date.getDay() + 7) % 7 || 7)); + return localKey(date); +} diff --git a/src/lib/hue.ts b/src/lib/hue.ts new file mode 100644 index 0000000..06d05de --- /dev/null +++ b/src/lib/hue.ts @@ -0,0 +1,9 @@ +const LABEL_HUES = [200, 155, 75, 300, 25, 260]; + +export function labelHue(name: string): number { + let hash = 0; + for (let i = 0; i < name.length; i += 1) { + hash = (hash * 31 + name.charCodeAt(i)) % 997; + } + return LABEL_HUES[hash % LABEL_HUES.length]; +} diff --git a/src/lib/keys.ts b/src/lib/keys.ts new file mode 100644 index 0000000..9c26036 --- /dev/null +++ b/src/lib/keys.ts @@ -0,0 +1,12 @@ +export function laneKey(path: string, project: string): string { + return `${encodeURIComponent(path)} ${encodeURIComponent(project)}`; +} + +export function parseLaneKey(key: string): { path: string; project: string } | null { + const at = key.indexOf(" "); + if (at === -1) return null; + return { + path: decodeURIComponent(key.slice(0, at)), + project: decodeURIComponent(key.slice(at + 1)), + }; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..687226d --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx } from "clsx"; +import type { ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/src/lib/vocab.ts b/src/lib/vocab.ts new file mode 100644 index 0000000..9a67fed --- /dev/null +++ b/src/lib/vocab.ts @@ -0,0 +1,36 @@ +import Circle from "@lucide/svelte/icons/circle"; +import CircleCheck from "@lucide/svelte/icons/circle-check"; +import CircleDot from "@lucide/svelte/icons/circle-dot"; +import CircleSlash from "@lucide/svelte/icons/circle-slash"; +import type { Component } from "svelte"; +import type { Priority, Status } from "../model/types"; + +export interface StatusMeta { + icon: Component; + color: string; + label: string; +} + +export const STATUS_META: Record = { + " ": { icon: Circle, color: "var(--muted-foreground)", label: "Todo" }, + "/": { icon: CircleDot, color: "var(--status-reply)", label: "In progress" }, + x: { icon: CircleCheck, color: "var(--status-done)", label: "Done" }, + "-": { icon: CircleSlash, color: "var(--status-skip)", label: "Cancelled" }, +}; + +export const PRIORITY_LABEL: Record = { + 0: "Lowest", + 1: "Low", + 2: "No priority", + 3: "Medium", + 4: "High", + 5: "Highest", +}; + +export const PRIORITY_ORDER: Priority[] = [5, 4, 3, 2, 1, 0]; + +export function isHot(priority: Priority): boolean { + return priority === 5; +} + +export const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..dfada4f --- /dev/null +++ b/src/main.ts @@ -0,0 +1,120 @@ +import { Notice, Plugin, TFile } from "obsidian"; +import { + BeaverCalendarSettingsTab, + DEFAULT_SETTINGS, +} from "./settings"; +import type { BeaverCalendarSettings } from "./settings"; +import { BoardStore } from "./vault/store.svelte"; +import { archiveDone } from "./vault/mutate"; +import { BoardsView, VIEW_TYPE_BOARDS } from "./view"; + +export default class BeaverCalendarPlugin extends Plugin { + settings: BeaverCalendarSettings = { ...DEFAULT_SETTINGS }; + store!: BoardStore; + + async onload(): Promise { + await this.loadSettings(); + this.store = new BoardStore(this.app, this.settings, () => { + void this.saveSettings(); + }); + + this.addSettingTab(new BeaverCalendarSettingsTab(this.app, this)); + + this.registerView( + VIEW_TYPE_BOARDS, + (leaf) => new BoardsView(leaf, this), + ); + + this.addRibbonIcon("calendar-check", "Tasks", () => { + void this.activateView(); + }); + + this.addCommand({ + id: "open-boards", + name: "Open tasks", + callback: () => void this.activateView(), + }); + + this.addCommand({ + id: "archive-done", + name: "Archive completed tasks", + callback: () => void this.archiveAll(), + }); + + this.registerEvent( + this.app.vault.on("modify", (file) => { + if (file instanceof TFile) this.store.queueReload(file.path); + }), + ); + this.registerEvent( + this.app.vault.on("create", (file) => { + if (file instanceof TFile) this.store.queueReload(file.path); + }), + ); + this.registerEvent( + this.app.vault.on("delete", (file) => { + this.store.drop(file.path); + }), + ); + this.registerEvent( + this.app.vault.on("rename", (file, oldPath) => { + this.store.drop(oldPath); + if (file instanceof TFile) this.store.queueReload(file.path); + }), + ); + } + + onunload(): void { + } + + async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + this.store?.updateSettings(this.settings); + } + + private async activateView(): Promise { + const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_BOARDS); + if (existing.length > 0) { + await this.app.workspace.revealLeaf(existing[0]); + return; + } + const leaf = this.app.workspace.getLeaf("tab"); + await leaf.setViewState({ type: VIEW_TYPE_BOARDS, active: true }); + await this.app.workspace.revealLeaf(leaf); + } + + private async archiveAll(): Promise { + const folder = this.settings.boardsFolder; + if (!folder) { + new Notice( + "Beaver Calendar: no boards folder is set, archiving cancelled", + 6000, + ); + return; + } + const files = this.app.vault + .getMarkdownFiles() + .filter((file) => file.path.startsWith(`${folder}/`)); + if (files.length === 0) { + new Notice("Beaver Calendar: no boards found"); + return; + } + let moved = 0; + for (const file of files) { + moved += await archiveDone( + this.app, + file.path, + this.settings.archiveHeading, + ); + } + new Notice( + moved > 0 + ? `Beaver Calendar: archived ${moved} task(s)` + : "Beaver Calendar: nothing to archive", + ); + } +} diff --git a/src/model/board.ts b/src/model/board.ts new file mode 100644 index 0000000..1b52244 --- /dev/null +++ b/src/model/board.ts @@ -0,0 +1,126 @@ +import { parseTaskLine, serializeTaskLine } from "./serialize"; +import type { Board, Lane, Task } from "./types"; + +const HEADING = /^##\s+(.*?)\s*$/u; +const ARCHIVE_SEPARATOR = /^\*\*\*\s*$/u; +const SETTINGS_OPEN = /^%%\s*kanban:settings\s*$/u; + +export function sphereOf(path: string): string { + const base = path.slice(path.lastIndexOf("/") + 1); + return base.endsWith(".md") ? base.slice(0, -3) : base; +} + +function isCardBody(line: string): boolean { + return /^\t/u.test(line); +} + +export function parseBoard(path: string, content: string): Board { + const lines = content.split("\n"); + const sphere = sphereOf(path); + const lanes: Lane[] = []; + const tasks: Task[] = []; + + let index = 0; + if (lines[0] === "---") { + const close = lines.indexOf("---", 1); + index = close === -1 ? lines.length : close + 1; + } + + const bodyStart = index; + let settingsStart = lines.length; + for (let i = bodyStart; i < lines.length; i += 1) { + if (SETTINGS_OPEN.test(lines[i])) { + settingsStart = i; + break; + } + } + + let inArchive = false; + let current: Lane | null = null; + + const closeLane = (end: number): void => { + if (!current) return; + current.end = end; + lanes.push(current); + current = null; + }; + + for (let i = bodyStart; i < settingsStart; i += 1) { + const line = lines[i]; + + if (ARCHIVE_SEPARATOR.test(line)) { + closeLane(i); + inArchive = true; + continue; + } + + const heading = HEADING.exec(line); + if (heading) { + closeLane(i); + current = { + name: heading[1], + inArchive, + headingLine: i, + start: i + 1, + end: i + 1, + }; + continue; + } + + if (!current) continue; + + const parsed = parseTaskLine(line); + if (!parsed) continue; + + const body: string[] = []; + let cursor = i + 1; + while (cursor < settingsStart && isCardBody(lines[cursor])) { + body.push(lines[cursor]); + cursor += 1; + } + + tasks.push({ + ...parsed, + id: `${path}#${i}`, + path, + sphere, + project: current.name, + inArchive: current.inArchive, + line: i, + raw: line, + body, + }); + + i = cursor - 1; + } + + closeLane(settingsStart); + + return { path, sphere, lines, lanes, tasks }; +} + +export function renderBoard(board: Board): string { + return board.lines.join("\n"); +} + +export function taskLine(task: Task): string { + return serializeTaskLine(task); +} + +export function findLane( + board: Board, + project: string, + inArchive = false, +): Lane | undefined { + return board.lanes.find( + (lane) => lane.name === project && lane.inArchive === inArchive, + ); +} + +export function laneInsertPoint(board: Board, lane: Lane): number { + let end = lane.end; + while (end > lane.start && board.lines[end - 1].trim() === "") { + end -= 1; + } + return end; +} diff --git a/src/model/serialize.ts b/src/model/serialize.ts new file mode 100644 index 0000000..4722dc3 --- /dev/null +++ b/src/model/serialize.ts @@ -0,0 +1,232 @@ +import { NORMAL_PRIORITY } from "./types"; +import type { Priority, Status, Task } from "./types"; + +const DATE = String.raw`(\d{4}-\d{2}-\d{2})`; + +const DUE = "(?:\\u{1F4C5}|\\u{1F4C6}|\\u{1F5D3}\\u{FE0F}?)"; +const SCHEDULED = "(?:\\u{23F3}|\\u{231B})"; +const START = "\\u{1F6EB}"; +const CREATED = "\\u{2795}"; +const DONE = "\\u{2705}"; +const CANCELLED = "\\u{274C}"; +const RECURRENCE = "\\u{1F501}"; +const TASK_ID = "\\u{1F194}"; +const DEPENDS_ON = "\\u{26D4}"; +const ON_COMPLETION = "\\u{1F3C1}"; + +const PRIORITY_EMOJI: Record, string> = { + 0: "\u{23EC}", + 1: "\u{1F53D}", + 3: "\u{1F53C}", + 4: "\u{23EB}", + 5: "\u{1F53A}", +}; + +const PRIORITY_BY_EMOJI = new Map( + Object.entries(PRIORITY_EMOJI).map(([value, emoji]) => [ + emoji, + Number(value) as Priority, + ]), +); + +function tail(pattern: string): RegExp { + return new RegExp(`[ \\t]*${pattern}[ \\t]*$`, "u"); +} + +interface Tail { + priority: Priority; + recurrence: string | null; + created: string | null; + start: string | null; + scheduled: string | null; + due: string | null; + cancelled: string | null; + done: string | null; + dependsOn: string[]; + taskId: string | null; + onCompletion: string | null; + blockLink: string | null; +} + +interface Matcher { + re: RegExp; + apply: (into: Tail, capture: string) => void; +} + +const MATCHERS: Matcher[] = [ + { + re: tail(`(\\^[a-zA-Z0-9-]+)`), + apply: (into, value) => { + into.blockLink = value; + }, + }, + { + re: tail(`${DONE}[ \\t]*${DATE}`), + apply: (into, value) => { + into.done = value; + }, + }, + { + re: tail(`${CANCELLED}[ \\t]*${DATE}`), + apply: (into, value) => { + into.cancelled = value; + }, + }, + { + re: tail(`${DUE}[ \\t]*${DATE}`), + apply: (into, value) => { + into.due = value; + }, + }, + { + re: tail(`${SCHEDULED}[ \\t]*${DATE}`), + apply: (into, value) => { + into.scheduled = value; + }, + }, + { + re: tail(`${START}[ \\t]*${DATE}`), + apply: (into, value) => { + into.start = value; + }, + }, + { + re: tail(`${CREATED}[ \\t]*${DATE}`), + apply: (into, value) => { + into.created = value; + }, + }, + { + re: tail(`${TASK_ID}[ \\t]*([a-zA-Z0-9_-]+)`), + apply: (into, value) => { + into.taskId = value; + }, + }, + { + re: tail(`${DEPENDS_ON}[ \\t]*([a-zA-Z0-9_,\\- ]+?)`), + apply: (into, value) => { + into.dependsOn = value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + }, + }, + { + re: tail(`${ON_COMPLETION}[ \\t]*([a-zA-Z]+)`), + apply: (into, value) => { + into.onCompletion = value; + }, + }, + { + re: tail(`([\\u{23EC}\\u{1F53D}\\u{1F53C}\\u{23EB}\\u{1F53A}])`), + apply: (into, value) => { + into.priority = PRIORITY_BY_EMOJI.get(value) ?? NORMAL_PRIORITY; + }, + }, + { + re: tail(`${RECURRENCE}[ \\t]*(.+?)`), + apply: (into, value) => { + into.recurrence = value.trim(); + }, + }, +]; + +const CHECKBOX = /^([ \t]*)- \[(.)\](?:[ \t](.*))?$/u; + +export interface ParsedLine extends Tail { + indent: string; + status: Status; + text: string; + tags: string[]; +} + +const TAG = /(?:^|\s)(#[^\s#[\]()]+)/gu; + +function collectTags(text: string): string[] { + const found: string[] = []; + for (const match of text.matchAll(TAG)) { + found.push(match[1]); + } + return found; +} + +export function parseTaskLine(raw: string): ParsedLine | null { + const checkbox = CHECKBOX.exec(raw); + if (!checkbox) return null; + + const [, indent, symbol, rest = ""] = checkbox; + const parsed: Tail = { + priority: NORMAL_PRIORITY, + recurrence: null, + created: null, + start: null, + scheduled: null, + due: null, + cancelled: null, + done: null, + dependsOn: [], + taskId: null, + onCompletion: null, + blockLink: null, + }; + + let text = rest; + let matched = true; + while (matched) { + matched = false; + for (const matcher of MATCHERS) { + const hit = matcher.re.exec(text); + if (!hit) continue; + matcher.apply(parsed, hit[1]); + text = text.slice(0, hit.index); + matched = true; + break; + } + } + + return { + ...parsed, + indent, + status: ([" ", "/", "x", "-"].includes(symbol) ? symbol : " ") as Status, + text: text.trimEnd(), + tags: collectTags(text), + }; +} + +export type TaskFields = Omit< + Task, + | "id" + | "path" + | "sphere" + | "project" + | "inArchive" + | "line" + | "raw" + | "body" + | "tags" +>; + +export function serializeTaskLine(task: TaskFields): string { + const parts: string[] = []; + if (task.text) parts.push(task.text); + if (task.priority !== NORMAL_PRIORITY) { + parts.push(PRIORITY_EMOJI[task.priority as Exclude]); + } + if (task.recurrence) parts.push(`\u{1F501} ${task.recurrence}`); + if (task.onCompletion) parts.push(`\u{1F3C1} ${task.onCompletion}`); + if (task.created) parts.push(`\u{2795} ${task.created}`); + if (task.start) parts.push(`\u{1F6EB} ${task.start}`); + if (task.scheduled) parts.push(`\u{23F3} ${task.scheduled}`); + if (task.due) parts.push(`\u{1F4C5} ${task.due}`); + if (task.cancelled) parts.push(`\u{274C} ${task.cancelled}`); + if (task.done) parts.push(`\u{2705} ${task.done}`); + if (task.dependsOn.length > 0) { + parts.push(`\u{26D4} ${task.dependsOn.join(",")}`); + } + if (task.taskId) parts.push(`\u{1F194} ${task.taskId}`); + if (task.blockLink) parts.push(task.blockLink); + + const body = parts.join(" "); + const head = `${task.indent}- [${task.status}]`; + return body ? `${head} ${body}` : head; +} diff --git a/src/model/types.ts b/src/model/types.ts new file mode 100644 index 0000000..c789078 --- /dev/null +++ b/src/model/types.ts @@ -0,0 +1,66 @@ +export type Status = " " | "/" | "x" | "-"; + +export const STATUS_ORDER: Status[] = [" ", "/", "x", "-"]; + +export function isOpen(status: Status): boolean { + return status !== "x" && status !== "-"; +} + +export type Priority = 0 | 1 | 2 | 3 | 4 | 5; + +export const NORMAL_PRIORITY: Priority = 2; + +export type DateField = + | "created" + | "start" + | "scheduled" + | "due" + | "cancelled" + | "done"; + +export interface Task { + id: string; + path: string; + sphere: string; + project: string; + inArchive: boolean; + line: number; + raw: string; + body: string[]; + indent: string; + status: Status; + text: string; + priority: Priority; + recurrence: string | null; + created: string | null; + start: string | null; + scheduled: string | null; + due: string | null; + cancelled: string | null; + done: string | null; + dependsOn: string[]; + taskId: string | null; + onCompletion: string | null; + tags: string[]; + blockLink: string | null; +} + +export function anchorDate(task: Task): string | null { + return task.due ?? task.scheduled; +} + +export interface Lane { + name: string; + inArchive: boolean; + headingLine: number; + start: number; + end: number; +} + +export interface Board { + path: string; + sphere: string; + lines: string[]; + lanes: Lane[]; + tasks: Task[]; +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..b114b71 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,111 @@ +import { PluginSettingTab, Setting } from "obsidian"; +import type { App } from "obsidian"; +import type BeaverCalendarPlugin from "./main"; + +export type ViewMode = "list" | "calendar"; +export type GroupBy = "sphere" | "project" | "status" | "due"; +export type SortBy = "manual" | "priority" | "due"; + +export interface BeaverCalendarSettings { + boardsFolder: string; + archiveHeading: string; + view: ViewMode; + groupBy: GroupBy; + listSort: SortBy; + calendarSort: SortBy; + showDone: boolean; + includeArchive: boolean; + railCollapsed: boolean; + undatedCollapsed: boolean; +} + +export const DEFAULT_SETTINGS: BeaverCalendarSettings = { + boardsFolder: "Boards", + archiveHeading: "Archive", + view: "list", + groupBy: "sphere", + listSort: "due", + calendarSort: "priority", + showDone: false, + includeArchive: false, + railCollapsed: false, + undatedCollapsed: false, +}; + +export function normalizeFolder(value: string): string { + return value.trim().replace(/^\/+/u, "").replace(/\/+$/u, ""); +} + +export class BeaverCalendarSettingsTab extends PluginSettingTab { + constructor( + app: App, + private readonly plugin: BeaverCalendarPlugin, + ) { + super(app, plugin); + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + new Setting(containerEl) + .setName("Boards folder") + .setDesc( + "Every markdown file in this folder is a sphere, and every second-level heading inside it is a project.", + ) + .addText((text) => + text + .setPlaceholder("Boards") + .setValue(this.plugin.settings.boardsFolder) + .onChange(async (value) => { + this.plugin.settings.boardsFolder = normalizeFolder(value); + await this.plugin.saveSettings(); + await this.plugin.store.reloadAll(); + }), + ); + + new Setting(containerEl) + .setName("Archive heading") + .setDesc("The lane completed tasks are moved to by the archive command.") + .addText((text) => + text + .setPlaceholder("Archive") + .setValue(this.plugin.settings.archiveHeading) + .onChange(async (value) => { + this.plugin.settings.archiveHeading = value.trim() || "Archive"; + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Include the archive") + .setDesc( + "Widen the show-completed toggle to reach tasks below the archive " + + "separator. With this off, show-completed only reveals completed " + + "tasks still sitting in their lanes. Archived tasks carry an " + + "Archive badge instead of a project one.", + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.includeArchive) + .onChange(async (value) => { + this.plugin.settings.includeArchive = value; + this.plugin.store.includeArchive = value; + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Show completed") + .setDesc("Completed tasks are hidden from the list and the calendar by default.") + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.showDone) + .onChange(async (value) => { + this.plugin.settings.showDone = value; + this.plugin.store.showDone = value; + await this.plugin.saveSettings(); + }), + ); + } +} diff --git a/src/tailwind.css b/src/tailwind.css new file mode 100644 index 0000000..1ab7d17 --- /dev/null +++ b/src/tailwind.css @@ -0,0 +1,268 @@ +@layer theme, base, components, utilities; +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities) important; + +@source "./"; + +@custom-variant dark (&:where(.theme-dark, .theme-dark *)); + +.bcal-root { + --background: var(--background-primary); + --foreground: var(--text-normal); + --card: var(--background-primary-alt); + --card-foreground: var(--text-normal); + --popover: var(--background-secondary); + --popover-foreground: var(--text-normal); + --primary: var(--color-accent); + --primary-foreground: var(--text-on-accent); + --secondary: var(--background-modifier-hover); + --secondary-foreground: var(--text-normal); + --muted: var(--background-modifier-hover); + --muted-foreground: var(--text-muted); + --accent: var(--background-modifier-hover); + --accent-foreground: var(--text-normal); + --destructive: var(--text-error); + --destructive-foreground: var(--text-on-accent); + --border: var(--background-modifier-border); + --input: var(--background-modifier-border); + --ring: var(--color-accent); + --icon: var(--text-muted); + --signal: var(--color-accent); + --note: oklch(0.52 0.13 78); + + --status-new: var(--color-accent); + --status-done: oklch(0.55 0.14 155); + --status-skip: var(--text-faint); + --status-reply: oklch(0.55 0.14 235); + --status-snooze: oklch(0.66 0.13 75); + --status-meeting: oklch(0.5 0.2 300); + --status-work: oklch(0.55 0.13 195); + + --sidebar: var(--background-secondary); + --radius: 0.45rem; +} + +.theme-dark .bcal-root { + --note: oklch(0.84 0.14 88); + --status-done: oklch(0.74 0.14 155); + --status-reply: oklch(0.72 0.13 235); + --status-snooze: oklch(0.8 0.13 75); + --status-meeting: oklch(0.72 0.16 300); + --status-work: oklch(0.75 0.12 195); +} + +@theme inline { + --font-sans: var(--font-interface); + + --text-xs: 0.75rem; + --text-sm: 0.8125rem; + --text-base: 0.875rem; + --text-lg: 1rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + + --color-icon: var(--icon); + --color-signal: var(--signal); + --color-note: var(--note); + --color-sidebar: var(--sidebar); + + --color-status-new: var(--status-new); + --color-status-done: var(--status-done); + --color-status-skip: var(--status-skip); + --color-status-reply: var(--status-reply); + --color-status-snooze: var(--status-snooze); + --color-status-meeting: var(--status-meeting); + --color-status-work: var(--status-work); + + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive-foreground: var(--destructive-foreground); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); +} + +.bcal-root { + height: 100%; + font-family: var(--font-interface); + font-size: var(--text-sm); + color: var(--foreground); + background: var(--background); +} + +.bcal-app { + container-type: inline-size; + display: flex; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.bcal-root.bcal-root *, +.bcal-root.bcal-root *::before, +.bcal-root.bcal-root *::after { + box-sizing: border-box; + border: 0 solid var(--border); +} + +.bcal-root.bcal-root :is(h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd) { + margin: 0; + font-size: inherit; + font-weight: inherit; + line-height: inherit; +} + +.bcal-root.bcal-root :is(ul, ol) { + margin: 0; + padding: 0; + list-style: none; +} + +.bcal-root.bcal-root :is(button, input, select, textarea) { + height: auto; + min-height: 0; + margin: 0; + padding: 0; + font: inherit; + color: inherit; + letter-spacing: inherit; + background: transparent; + border: 0 solid var(--border); + border-radius: 0; + box-shadow: none; + outline: none; + -webkit-appearance: none; + appearance: none; +} + +.bcal-root.bcal-root :is(button, input, select, textarea):hover, +.bcal-root.bcal-root :is(button, input, select, textarea):focus, +.bcal-root.bcal-root :is(button, input, select, textarea):active { + box-shadow: none; +} + +.bcal-root.bcal-root button { + cursor: pointer; + text-align: inherit; +} + +.bcal-root.bcal-root button:hover { + background: transparent; +} + +.bcal-root.bcal-root button:disabled { + cursor: default; +} + +.bcal-root.bcal-root input::placeholder, +.bcal-root.bcal-root textarea::placeholder { + color: var(--muted-foreground); +} + +.bcal-root.bcal-root input[type="search"]::-webkit-search-cancel-button, +.bcal-root.bcal-root input[type="search"]::-webkit-search-decoration { + display: none; + -webkit-appearance: none; +} + +.bcal-root.bcal-root svg { + display: block; + flex-shrink: 0; +} + +.bcal-root.bcal-root a, +.bcal-root.bcal-root a:hover { + color: inherit; + text-decoration: inherit; +} + +.bcal-root.bcal-root img { + display: block; + max-width: 100%; + height: auto; +} + +.bcal-root.bcal-root .bcal-md > * { + display: inline; +} + +.bcal-root.bcal-root .bcal-md a { + color: var(--link-color, var(--color-accent)); + text-decoration: none; +} + +.bcal-root.bcal-root .bcal-md a:hover { + text-decoration: underline; +} + +.bcal-root.bcal-root .bcal-md a.is-unresolved { + color: var(--link-unresolved-color, var(--muted-foreground)); + opacity: 0.75; +} + +.bcal-root.bcal-root .bcal-md code { + padding: 0 0.25em; + font-size: 0.9em; + background: var(--secondary); + border-radius: var(--radius-sm); +} + +.bcal-root.bcal-root .bcal-md :is(img, .internal-embed) { + display: inline-block; + max-height: 1.4em; + vertical-align: text-bottom; +} + +.bcal-root.bcal-root .bcal-dragging { + position: relative; + z-index: 40; + cursor: grabbing; + box-shadow: 0 12px 28px -8px rgb(0 0 0 / 45%); + transition: none; +} + +@utility touch-shown { + @media (hover: none) { + opacity: 1; + } +} + +@utility tabular { + font-variant-numeric: tabular-nums; +} + +@utility hue-chip { + color: oklch(0.45 0.13 var(--hue)); + background: oklch(0.6 0.09 var(--hue) / 0.12); + border-color: oklch(0.6 0.09 var(--hue) / 0.4); + .theme-dark & { + color: oklch(0.82 0.08 var(--hue)); + } +} + +@utility scrollbar-none { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} diff --git a/src/tasks-api.ts b/src/tasks-api.ts new file mode 100644 index 0000000..55b9235 --- /dev/null +++ b/src/tasks-api.ts @@ -0,0 +1,31 @@ +import { Notice } from "obsidian"; +import type { App } from "obsidian"; + +interface TasksApiV1 { + createTaskLineModal(): Promise; + editTaskLineModal(taskLine: string): Promise; + executeToggleTaskDoneCommand(line: string, path: string): string; +} + +interface PluginHost { + plugins?: { plugins?: Record }; +} + +const TASKS_ID = "obsidian-tasks-plugin"; + +let warned = false; + +export function tasksApi(app: App): TasksApiV1 | null { + const host = app as unknown as PluginHost; + return host.plugins?.plugins?.[TASKS_ID]?.apiV1 ?? null; +} + +export function warnMissingTasks(): void { + if (warned) return; + warned = true; + new Notice( + "Beaver Calendar: the Tasks plugin was not found. Done dates are " + + "stamped locally and recurring tasks are not expanded.", + 8000, + ); +} diff --git a/src/ui/app.svelte b/src/ui/app.svelte new file mode 100644 index 0000000..2405c8a --- /dev/null +++ b/src/ui/app.svelte @@ -0,0 +1,98 @@ + + +
+
+ {#if !store.railCollapsed} + + {/if} +
+ { + creating = true; + title = ""; + }} + /> + {#if creating} +
+ { + if (event.key === "Enter") void commit(); + else if (event.key === "Escape") { + creating = false; + title = ""; + } + }} + placeholder={scope + ? `Task in ${scope.sphere} / ${scope.project}` + : "Create a board in the boards folder first"} + use:focus + bind:value={title} + /> +
+ {/if} + {#if store.view === "calendar"} + + {:else} + + {/if} +
+
+
diff --git a/src/ui/context-area.svelte b/src/ui/context-area.svelte new file mode 100644 index 0000000..4e5efc4 --- /dev/null +++ b/src/ui/context-area.svelte @@ -0,0 +1,35 @@ + + + + + {@render children()} + + + + { + menuOpen = false; + }} + {items} + /> + + + diff --git a/src/ui/context.ts b/src/ui/context.ts new file mode 100644 index 0000000..85973d7 --- /dev/null +++ b/src/ui/context.ts @@ -0,0 +1,22 @@ +import { getContext, setContext } from "svelte"; +import type { App, Component } from "obsidian"; +import type { BoardStore } from "../vault/store.svelte"; +import type { BeaverCalendarSettings } from "../settings"; + +export interface ViewContext { + app: App; + store: BoardStore; + settings: BeaverCalendarSettings; + component: Component; + portal: () => HTMLElement; +} + +const KEY = Symbol("beaver-calendar"); + +export function provideView(context: ViewContext): void { + setContext(KEY, context); +} + +export function useView(): ViewContext { + return getContext(KEY); +} diff --git a/src/ui/day-grid.svelte b/src/ui/day-grid.svelte new file mode 100644 index 0000000..ab730e2 --- /dev/null +++ b/src/ui/day-grid.svelte @@ -0,0 +1,112 @@ + + + + +
+
+ + + {monthName.format(month)} + {month.getFullYear()} + + +
+ +
+ {#each WEEKDAYS as weekday (weekday)} + + {weekday} + + {/each} +
+ +
+ {#each days as day (day.key)} + {@const shape = tone(day.key)} + + {/each} +
+
diff --git a/src/ui/drop.ts b/src/ui/drop.ts new file mode 100644 index 0000000..93d8162 --- /dev/null +++ b/src/ui/drop.ts @@ -0,0 +1,129 @@ +import { anchorDate } from "../model/types"; +import type { Task } from "../model/types"; +import { shiftDays, today } from "../lib/due"; +import { laneKey, parseLaneKey } from "../lib/keys"; +import { moveTask, reorderLane, setDate } from "../vault/mutate"; +import type { ViewContext } from "./context"; + +export const dropTarget = { + task: (id: string) => `task:${id}`, + day: (key: string) => `day:${key}`, + lane: (path: string, project: string) => `lane:${laneKey(path, project)}`, + sphere: (path: string) => `sphere:${encodeURIComponent(path)}`, + undated: "undated", +}; + +function dateField(task: Task): "due" | "scheduled" { + return !task.due && task.scheduled ? "scheduled" : "due"; +} + +export async function handleDrop( + view: ViewContext, + dragged: Task, + target: string, + after: boolean, +): Promise { + const { app, store } = view; + + if (target === dropTarget.undated) { + await setDate(app, dragged, dateField(dragged), null); + return; + } + + if (target.startsWith("group:")) { + await handleGroupDrop(view, dragged, target.slice(6)); + return; + } + + if (target.startsWith("day:")) { + const key = target.slice(4); + if (anchorDate(dragged) === key) return; + await setDate(app, dragged, dateField(dragged), key); + return; + } + + if (target.startsWith("sphere:")) { + const path = decodeURIComponent(target.slice(7)); + if (path === dragged.path) return; + const sphere = store.spheres.find((item) => item.path === path); + const project = sphere?.projects[0]; + if (!project) return; + await moveTask(app, dragged, { path, project }); + return; + } + + if (target.startsWith("lane:")) { + const lane = parseLaneKey(target.slice(5)); + if (!lane) return; + if (lane.path === dragged.path && lane.project === dragged.project) return; + await moveTask(app, dragged, lane); + return; + } + + if (target.startsWith("task:")) { + const id = target.slice(5); + if (id === dragged.id) return; + const onto = store.all.find((task) => task.id === id); + if (!onto) return; + + if (onto.path === dragged.path && onto.project === dragged.project) { + const board = store.boardFor(onto.path); + if (!board) return; + const lane = board.tasks.filter( + (task) => task.project === onto.project && !task.inArchive, + ); + const rest = lane + .map((task) => task.id) + .filter((each) => each !== dragged.id); + const at = rest.indexOf(onto.id); + if (at === -1) return; + rest.splice(after ? at + 1 : at, 0, dragged.id); + store.sortBy = "manual"; + await reorderLane(app, board, onto.project, rest); + return; + } + + const board = store.boardFor(onto.path); + const siblings = + board?.tasks.filter( + (task) => task.project === onto.project && !task.inArchive, + ) ?? []; + const at = siblings.findIndex((task) => task.id === onto.id); + await moveTask(app, dragged, { + path: onto.path, + project: onto.project, + index: at === -1 ? undefined : at + (after ? 1 : 0), + }); + } +} + +export async function handleGroupDrop( + view: ViewContext, + dragged: Task, + groupKey: string, +): Promise { + const { app, store } = view; + + if (store.groupBy === "due") { + if (groupKey === "today") { + await setDate(app, dragged, dateField(dragged), today()); + } else if (groupKey === "tomorrow") { + await setDate(app, dragged, dateField(dragged), shiftDays(today(), 1)); + } else if (groupKey === "none") { + await setDate(app, dragged, dateField(dragged), null); + } + return; + } + + if (store.groupBy === "sphere") { + await handleDrop(view, dragged, dropTarget.sphere(groupKey), false); + return; + } + + if (store.groupBy === "project") { + const lane = parseLaneKey(groupKey); + if (lane) { + await handleDrop(view, dragged, dropTarget.lane(lane.path, lane.project), false); + } + } +} diff --git a/src/ui/due-picker.svelte b/src/ui/due-picker.svelte new file mode 100644 index 0000000..86f81a6 --- /dev/null +++ b/src/ui/due-picker.svelte @@ -0,0 +1,139 @@ + + + + + {#if recurrence} + + + +
+ {#each QUICK as quick (quick.label)} + + {/each} +
+ + + + {#if onrepeat} + {@const known = RULES.some((rule) => rule.value === (recurrence ?? ""))} +
+
+ {#each RULES as rule (rule.value)} + + {/each} +
+ {#if recurrence && !known} +

+ Custom rule: {recurrence} +

+ {/if} +
+ {/if} + + {#if value} + + {/if} +
+
+
diff --git a/src/ui/lane-picker.svelte b/src/ui/lane-picker.svelte new file mode 100644 index 0000000..df4dd53 --- /dev/null +++ b/src/ui/lane-picker.svelte @@ -0,0 +1,47 @@ + + + { + const lane = parseLaneKey(value); + if (lane) onpick(lane.path, lane.project); + }} + {options} + searchable + triggerClass={cn("max-w-full", className)} + value={laneKey(path, project)} +> + {#snippet trigger(state)} + {@render renderTrigger(state)} + {/snippet} + diff --git a/src/ui/markdown.ts b/src/ui/markdown.ts new file mode 100644 index 0000000..4d964dc --- /dev/null +++ b/src/ui/markdown.ts @@ -0,0 +1,72 @@ +import { MarkdownRenderer } from "obsidian"; +import type { App, Component } from "obsidian"; + +export interface MarkdownOptions { + app: App; + component: Component; + sourcePath: string; + text: string; +} + +function unwrap(node: HTMLElement): void { + let child = node.firstElementChild; + while ( + node.childElementCount === 1 && + child && + (child.tagName === "P" || child.tagName === "DIV") + ) { + child.replaceWith(...child.childNodes); + child = node.firstElementChild; + } +} + +export function markdown(node: HTMLElement, options: MarkdownOptions) { + let current = options; + let generation = 0; + + const render = async (): Promise => { + const mine = (generation += 1); + const { app, component, sourcePath, text } = current; + if (typeof MarkdownRenderer?.render !== "function") { + node.textContent = text; + return; + } + const staging = document.createElement("div"); + await MarkdownRenderer.render(app, text, staging, sourcePath, component); + if (mine !== generation) return; + unwrap(staging); + node.replaceChildren(...staging.childNodes); + }; + + void render(); + + return { + update(next: MarkdownOptions) { + const changed = + next.text !== current.text || next.sourcePath !== current.sourcePath; + current = next; + if (changed) void render(); + }, + destroy() { + generation += 1; + }, + }; +} + +export function followLink( + app: App, + event: MouseEvent, + sourcePath: string, +): boolean { + const anchor = (event.target as HTMLElement).closest("a"); + if (!anchor) return false; + event.preventDefault(); + event.stopPropagation(); + const href = anchor.getAttribute("href") ?? ""; + if (anchor.classList.contains("internal-link")) { + void app.workspace.openLinkText(href, sourcePath, event.ctrlKey || event.metaKey); + } else if (href) { + window.open(href, "_blank"); + } + return true; +} diff --git a/src/ui/menu-body.svelte b/src/ui/menu-body.svelte new file mode 100644 index 0000000..1c35e41 --- /dev/null +++ b/src/ui/menu-body.svelte @@ -0,0 +1,71 @@ + + + + +
+ {#each items as item (item.label)} + {@const arming = armed === item.label} + + {/each} +
diff --git a/src/ui/picker.svelte b/src/ui/picker.svelte new file mode 100644 index 0000000..63caf88 --- /dev/null +++ b/src/ui/picker.svelte @@ -0,0 +1,164 @@ + + + + + + + {@render trigger({ open: isOpen })} + + + + {#if searchable} + + + {/if} + {#if options.length > 0} +
+ {#each shown as option, index (option.value)} + + {:else} +

+ Nothing found +

+ {/each} +
+ {/if} + {#if footer} +
0 ? "mt-1 border-border border-t pt-1" : ""}> + {@render footer({ + dismiss: () => { + isOpen = false; + }, + query: query.trim(), + })} +
+ {/if} +
+
+
diff --git a/src/ui/priority-bars.svelte b/src/ui/priority-bars.svelte new file mode 100644 index 0000000..e5291d0 --- /dev/null +++ b/src/ui/priority-bars.svelte @@ -0,0 +1,46 @@ + + + + {#if isHot(priority)} + diff --git a/src/ui/priority-picker.svelte b/src/ui/priority-picker.svelte new file mode 100644 index 0000000..7226520 --- /dev/null +++ b/src/ui/priority-picker.svelte @@ -0,0 +1,35 @@ + + + onchange(Number(value) as Priority)} + {options} + triggerClass={cn( + "h-6 gap-1 rounded-md px-1 text-muted-foreground transition-colors hover:bg-muted", + className, + )} + value={String(priority)} +> + {#snippet trigger()} + + {/snippet} + diff --git a/src/ui/row-menu.svelte b/src/ui/row-menu.svelte new file mode 100644 index 0000000..bdeb6ec --- /dev/null +++ b/src/ui/row-menu.svelte @@ -0,0 +1,42 @@ + + + + + + + + { + open = false; + }} + {items} + /> + + + diff --git a/src/ui/sphere-rail.svelte b/src/ui/sphere-rail.svelte new file mode 100644 index 0000000..e6e220f --- /dev/null +++ b/src/ui/sphere-rail.svelte @@ -0,0 +1,116 @@ + + + diff --git a/src/ui/status-picker.svelte b/src/ui/status-picker.svelte new file mode 100644 index 0000000..723e909 --- /dev/null +++ b/src/ui/status-picker.svelte @@ -0,0 +1,49 @@ + + + onchange(value as Status)} + {options} + triggerClass={cn( + "h-6 gap-1.5 rounded-md px-1 transition-colors hover:bg-muted", + className, + )} + value={status} +> + {#snippet trigger()} + diff --git a/src/ui/task-calendar.svelte b/src/ui/task-calendar.svelte new file mode 100644 index 0000000..de4c885 --- /dev/null +++ b/src/ui/task-calendar.svelte @@ -0,0 +1,304 @@ + + +{#snippet card(task: Task)} + {@const meta = STATUS_META[task.status]} + +
({ id: task.id, kind: "task" }), + ondrop: (to, after) => void handleDrop(view, task, to, after), + }} + > +
+ +
followLink(view.app, event, task.path)} + onkeydown={() => {}} + role="presentation" + title={task.text} + use:markdown={{ + app: view.app, + component: view.component, + sourcePath: task.path, + text: task.text, + }} + >
+ +
+
+ + {task.sphere} + + {#if task.inArchive} + + + {:else} + + {task.project} + + {/if} +
+
+
+{/snippet} + +{#snippet dayCell(day: DaySlot)} + {@const target = dropTarget.day(day.key)} +
+
+ + {day.date.getDate()} + + +
+ + {#if addingDay === day.key} + void addOnDay(day.key)} + onkeydown={(event) => { + if (event.key === "Enter") void addOnDay(day.key); + else if (event.key === "Escape") { + addingDay = null; + dayTitle = ""; + } + }} + placeholder={scope ? `Task in ${scope.project}` : "Task"} + use:focus + bind:value={dayTitle} + /> + {/if} + + {#each day.tasks as task (task.id)} + {@render card(task)} + {/each} +
+{/snippet} + +
+
+

+ {store.monthLabel} +

+
+ + + +
+
+ +
+ {#each store.agendaDays as day (day.key)} +
+

+ + {day.date.toLocaleDateString("en-US", { + day: "numeric", + month: "short", + weekday: "short", + })} + + {day.tasks.length} +

+
+ {#each day.tasks as task (task.id)} + {@render card(task)} + {/each} +
+
+ {:else} +

+ Nothing scheduled this month +

+ {/each} +
+ + + + {#if store.undated.length > 0} +
+ + {#if !store.undatedCollapsed} +
+ {#each store.undated as task (task.id)} +
{@render card(task)}
+ {/each} +
+ {/if} +
+ {/if} +
diff --git a/src/ui/task-list.svelte b/src/ui/task-list.svelte new file mode 100644 index 0000000..3f639fc --- /dev/null +++ b/src/ui/task-list.svelte @@ -0,0 +1,46 @@ + + +
+ {#each store.groups as group (group.key)} +
+

+ {group.label} + {#if group.hint} + {group.hint} + {/if} + {group.tasks.length} +

+ {#each group.tasks as task (task.id)} + + {/each} +
+ {:else} +

+ {store.search.trim() ? "Nothing matches" : "No tasks"} +

+ {/each} +
diff --git a/src/ui/task-menu.ts b/src/ui/task-menu.ts new file mode 100644 index 0000000..8724672 --- /dev/null +++ b/src/ui/task-menu.ts @@ -0,0 +1,70 @@ +import { Notice, TFile } from "obsidian"; +import ExternalLink from "@lucide/svelte/icons/external-link"; +import FileText from "@lucide/svelte/icons/file-text"; +import PenLine from "@lucide/svelte/icons/pen-line"; +import { STATUS_META } from "../lib/vocab"; +import { STATUS_ORDER } from "../model/types"; +import type { Task } from "../model/types"; +import { replaceRaw, setStatus } from "../vault/mutate"; +import { tasksApi, warnMissingTasks } from "../tasks-api"; +import type { MenuItem } from "./menu-body.svelte"; +import type { ViewContext } from "./context"; + +async function revealInFile(view: ViewContext, task: Task): Promise { + const file = view.app.vault.getAbstractFileByPath(task.path); + if (!(file instanceof TFile)) return; + const leaf = view.app.workspace.getLeaf("tab"); + await leaf.openFile(file); + const editor = view.app.workspace.activeEditor?.editor; + if (editor) { + editor.setCursor({ line: task.line, ch: 0 }); + editor.scrollIntoView( + { from: { line: task.line, ch: 0 }, to: { line: task.line, ch: 0 } }, + true, + ); + } +} + +async function editInTasks(view: ViewContext, task: Task): Promise { + const api = tasksApi(view.app); + if (!api) { + warnMissingTasks(); + return; + } + const edited = await api.editTaskLineModal(task.raw); + if (!edited || edited === task.raw) return; + await replaceRaw(view.app, task, edited); +} + +export function taskMenu(view: ViewContext, task: Task): MenuItem[] { + const statuses: MenuItem[] = STATUS_ORDER.filter( + (symbol) => symbol !== task.status, + ).map((symbol) => ({ + dot: STATUS_META[symbol].color, + label: STATUS_META[symbol].label, + onselect: () => void setStatus(view.app, task, symbol), + })); + + return [ + ...statuses, + { + icon: PenLine, + label: "Edit in Tasks", + onselect: () => void editInTasks(view, task), + }, + { + icon: FileText, + label: "Reveal in file", + hint: task.sphere, + onselect: () => void revealInFile(view, task), + }, + { + icon: ExternalLink, + label: "Copy raw line", + onselect: () => { + void navigator.clipboard.writeText(task.raw); + new Notice("Beaver Calendar: line copied"); + }, + }, + ]; +} diff --git a/src/ui/task-row.svelte b/src/ui/task-row.svelte new file mode 100644 index 0000000..dab8337 --- /dev/null +++ b/src/ui/task-row.svelte @@ -0,0 +1,194 @@ + + + +
({ id: task.id, kind: "task" }), + ondrop: (to, after) => void handleDrop(view, task, to, after), + enabled: () => !editing, + }} + > + + +
+ {#if showSphere} + + {/if} + {#if task.inArchive} + + {:else if showProject} + + {/if} + + {#if editing} + + + {:else} +
{ + if (!followLink(view.app, event, task.path)) startEdit(); + }} + onkeydown={(event) => { + if (event.key === "Enter") startEdit(); + }} + role="button" + tabindex="0" + title={task.text} + use:markdown={{ + app: view.app, + component: view.component, + sourcePath: task.path, + text: task.text, + }} + >
+ {/if} +
+ +
+ {#if task.priority !== 2} + + {/if} +
+ void setDate(view.app, task, field, null)} + onpick={(key) => void setDate(view.app, task, field, key)} + onrepeat={(rule) => void setRecurrence(view.app, task, rule)} + recurrence={task.recurrence} + value={date} + /> +
+ +
+
+
diff --git a/src/ui/toolbar.svelte b/src/ui/toolbar.svelte new file mode 100644 index 0000000..99c634b --- /dev/null +++ b/src/ui/toolbar.svelte @@ -0,0 +1,157 @@ + + +
+ + +
+ {#each VIEWS as mode (mode.id)} + + {/each} +
+ + {#if store.view === "list"} + { + store.groupBy = value as GroupBy; + store.savePrefs(); + }} + options={GROUPS} + triggerClass={PILL} + value={store.groupBy} + > + {#snippet trigger()} + + {/if} + + { + store.sortBy = value as SortBy; + }} + options={SORTS} + triggerClass={PILL} + value={store.sortBy} + > + {#snippet trigger()} + + + + + + + +
diff --git a/src/vault/edits.ts b/src/vault/edits.ts new file mode 100644 index 0000000..f1b0b40 --- /dev/null +++ b/src/vault/edits.ts @@ -0,0 +1,146 @@ +import { findLane, laneInsertPoint, parseBoard } from "../model/board"; +import type { Task } from "../model/types"; + +export class StaleTaskError extends Error { + constructor(public readonly task: Task) { + super(`line no longer present in ${task.path}`); + this.name = "StaleTaskError"; + } +} + +export class MissingLaneError extends Error { + constructor( + public readonly path: string, + public readonly project: string, + ) { + super(`no lane "${project}" in ${path}`); + this.name = "MissingLaneError"; + } +} + +export function locate(lines: string[], task: Task): number { + if (lines[task.line] === task.raw) return task.line; + let best = -1; + let bestDistance = Number.POSITIVE_INFINITY; + for (let i = 0; i < lines.length; i += 1) { + if (lines[i] !== task.raw) continue; + const distance = Math.abs(i - task.line); + if (distance < bestDistance) { + best = i; + bestDistance = distance; + } + } + if (best === -1) throw new StaleTaskError(task); + return best; +} + +export function extent(lines: string[], at: number, task: Task): number { + let count = 1; + while (count <= task.body.length && lines[at + count] === task.body[count - 1]) { + count += 1; + } + return count; +} + +export function replaceLine(lines: string[], task: Task, text: string): void { + const at = locate(lines, task); + lines.splice(at, 1, ...text.split("\n")); +} + +export function cutTask(lines: string[], task: Task): string[] { + const at = locate(lines, task); + const block = lines.splice(at, extent(lines, at, task)); + while (lines[at] === "" && lines[at - 1] === "" && lines[at + 1] !== undefined) { + lines.splice(at, 1); + } + return block; +} + +export interface InsertTarget { + project: string; + index?: number; +} + +export function insertBlock( + lines: string[], + path: string, + target: InsertTarget, + block: string[], +): void { + const board = parseBoard(path, lines.join("\n")); + const lane = findLane(board, target.project); + if (!lane) throw new MissingLaneError(path, target.project); + const siblings = board.tasks.filter( + (task) => task.project === target.project && !task.inArchive, + ); + const index = target.index ?? siblings.length; + const at = + index < siblings.length ? siblings[index].line : laneInsertPoint(board, lane); + lines.splice(at, 0, ...block); +} + +export function applyReorder( + lines: string[], + inLane: Task[], + ordered: Task[], +): void { + if (inLane.length < 2 || ordered.length !== inLane.length) return; + const slots = inLane.map((task) => { + const at = locate(lines, task); + return { at, size: extent(lines, at, task) }; + }); + const blocks = ordered.map((task) => { + const at = locate(lines, task); + return lines.slice(at, at + extent(lines, at, task)); + }); + for (let i = slots.length - 1; i >= 0; i -= 1) { + lines.splice(slots[i].at, slots[i].size); + } + for (let i = 0; i < slots.length; i += 1) { + lines.splice(slots[i].at, 0, ...blocks[i]); + } +} + +const SETTINGS_OPEN = /^%%\s*kanban:settings\s*$/u; + +export function applyArchive( + lines: string[], + path: string, + archiveHeading: string, +): number { + const board = parseBoard(path, lines.join("\n")); + const done = board.tasks.filter( + (task) => !task.inArchive && (task.status === "x" || task.status === "-"), + ); + if (done.length === 0) return 0; + + const blocks: string[][] = []; + for (const task of [...done].reverse()) { + const at = locate(lines, task); + blocks.unshift(lines.splice(at, extent(lines, at, task))); + } + + const heading = `## ${archiveHeading}`; + let archiveAt = lines.findIndex((line) => line.trim() === heading); + if (archiveAt === -1) { + const settingsAt = lines.findIndex((line) => SETTINGS_OPEN.test(line)); + let tail = settingsAt === -1 ? lines.length : settingsAt; + while (tail > 0 && lines[tail - 1].trim() === "") tail -= 1; + lines.splice(tail, 0, "", "***", "", heading, ""); + archiveAt = tail + 3; + } + + let insertAt = archiveAt + 1; + while ( + insertAt < lines.length && + !/^##\s/u.test(lines[insertAt]) && + !SETTINGS_OPEN.test(lines[insertAt]) + ) { + insertAt += 1; + } + while (insertAt > archiveAt + 1 && lines[insertAt - 1].trim() === "") { + insertAt -= 1; + } + lines.splice(insertAt, 0, ...blocks.flat()); + return blocks.length; +} diff --git a/src/vault/mutate.ts b/src/vault/mutate.ts new file mode 100644 index 0000000..3bf5786 --- /dev/null +++ b/src/vault/mutate.ts @@ -0,0 +1,266 @@ +import { Notice, TFile } from "obsidian"; +import type { App } from "obsidian"; +import { serializeTaskLine } from "../model/serialize"; +import { NORMAL_PRIORITY } from "../model/types"; +import type { Board, DateField, Priority, Status, Task } from "../model/types"; +import { tasksApi, warnMissingTasks } from "../tasks-api"; +import { today } from "../lib/due"; +import { + applyArchive, + applyReorder, + cutTask, + extent, + insertBlock, + locate, + MissingLaneError, + replaceLine, + StaleTaskError, +} from "./edits"; + +async function edit( + app: App, + path: string, + mutate: (lines: string[]) => void, +): Promise { + const file = app.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) { + new Notice(`Beaver Calendar: file not found - ${path}`, 6000); + return false; + } + let ok = true; + await app.vault.process(file, (data) => { + const lines = data.split("\n"); + try { + mutate(lines); + } catch (err) { + ok = false; + if (err instanceof StaleTaskError) { + new Notice( + "Beaver Calendar: the file changed, the edit was not applied", + 6000, + ); + return data; + } + if (err instanceof MissingLaneError) { + new Notice( + `Beaver Calendar: no lane "${err.project}" in ${err.path}`, + 6000, + ); + return data; + } + throw err; + } + return lines.join("\n"); + }); + return ok; +} + +export async function setDate( + app: App, + task: Task, + field: DateField, + value: string | null, +): Promise { + await edit(app, task.path, (lines) => { + replaceLine(lines, task, serializeTaskLine({ ...task, [field]: value })); + }); +} + +export async function setText( + app: App, + task: Task, + text: string, +): Promise { + const trimmed = text.trim(); + if (!trimmed || trimmed === task.text) return; + await edit(app, task.path, (lines) => { + replaceLine(lines, task, serializeTaskLine({ ...task, text: trimmed })); + }); +} + +export async function setPriority( + app: App, + task: Task, + priority: Priority, +): Promise { + await edit(app, task.path, (lines) => { + replaceLine(lines, task, serializeTaskLine({ ...task, priority })); + }); +} + +export async function setRecurrence( + app: App, + task: Task, + recurrence: string | null, +): Promise { + await edit(app, task.path, (lines) => { + replaceLine(lines, task, serializeTaskLine({ ...task, recurrence })); + }); +} + +export async function setStatus( + app: App, + task: Task, + status: Status, +): Promise { + const wasClosed = task.status === "x" || task.status === "-"; + const willClose = status === "x" || status === "-"; + + if (!wasClosed && willClose) { + const api = tasksApi(app); + if (api && status === "x") { + const replacement = api.executeToggleTaskDoneCommand(task.raw, task.path); + await edit(app, task.path, (lines) => { + replaceLine(lines, task, replacement); + }); + return; + } + if (!api) warnMissingTasks(); + const stamp = today(); + const next = + status === "x" + ? { ...task, status, done: stamp } + : { ...task, status, cancelled: stamp }; + await edit(app, task.path, (lines) => { + replaceLine(lines, task, serializeTaskLine(next)); + }); + return; + } + + const next = willClose + ? { ...task, status } + : { ...task, status, done: null, cancelled: null }; + await edit(app, task.path, (lines) => { + replaceLine(lines, task, serializeTaskLine(next)); + }); +} + +export async function replaceRaw( + app: App, + task: Task, + raw: string, +): Promise { + await edit(app, task.path, (lines) => { + replaceLine(lines, task, raw); + }); +} + +export interface MoveTarget { + path: string; + project: string; + index?: number; +} + +export async function moveTask( + app: App, + task: Task, + target: MoveTarget, +): Promise { + if (task.path === target.path) { + await edit(app, task.path, (lines) => { + const block = cutTask(lines, task); + insertBlock(lines, task.path, target, block); + }); + return; + } + + const source = app.vault.getAbstractFileByPath(task.path); + if (!(source instanceof TFile)) { + new Notice(`Beaver Calendar: file not found - ${task.path}`, 6000); + return; + } + const lines = (await app.vault.read(source)).split("\n"); + let block: string[]; + try { + const at = locate(lines, task); + block = lines.slice(at, at + extent(lines, at, task)); + } catch { + new Notice("Beaver Calendar: the file changed, the edit was not applied", 6000); + return; + } + + const pasted = await edit(app, target.path, (into) => { + insertBlock(into, target.path, target, block); + }); + if (!pasted) return; + + await edit(app, task.path, (from) => { + cutTask(from, task); + }); +} + +export async function reorderLane( + app: App, + board: Board, + project: string, + ids: string[], +): Promise { + const inLane = board.tasks.filter( + (task) => task.project === project && !task.inArchive, + ); + if (inLane.length < 2) return; + const byId = new Map(inLane.map((task) => [task.id, task])); + const ordered = ids + .map((id) => byId.get(id)) + .filter((task): task is Task => task !== undefined); + if (ordered.length !== inLane.length) return; + + await edit(app, board.path, (lines) => { + applyReorder(lines, inLane, ordered); + }); +} + +export async function createTask( + app: App, + path: string, + project: string, + text: string, + due: string | null, +): Promise { + const trimmed = text.trim(); + if (!trimmed) return; + const line = serializeTaskLine({ + indent: "", + status: " ", + text: trimmed, + priority: NORMAL_PRIORITY, + recurrence: null, + created: null, + start: null, + scheduled: null, + due, + cancelled: null, + done: null, + dependsOn: [], + taskId: null, + onCompletion: null, + blockLink: null, + }); + + await edit(app, path, (lines) => { + insertBlock(lines, path, { project }, [line]); + }); +} + +export async function appendRawTask( + app: App, + path: string, + project: string, + raw: string, +): Promise { + await edit(app, path, (lines) => { + insertBlock(lines, path, { project }, raw.split("\n")); + }); +} + +export async function archiveDone( + app: App, + path: string, + archiveHeading: string, +): Promise { + let moved = 0; + await edit(app, path, (lines) => { + moved = applyArchive(lines, path, archiveHeading); + }); + return moved; +} diff --git a/src/vault/store.svelte.ts b/src/vault/store.svelte.ts new file mode 100644 index 0000000..ea432af --- /dev/null +++ b/src/vault/store.svelte.ts @@ -0,0 +1,358 @@ +import { TFile } from "obsidian"; +import type { App } from "obsidian"; +import { parseBoard, sphereOf } from "../model/board"; +import { anchorDate, isOpen, STATUS_ORDER } from "../model/types"; +import type { Board, Status, Task } from "../model/types"; +import { daysFromToday, localKey } from "../lib/due"; +import { laneKey } from "../lib/keys"; +import type { + BeaverCalendarSettings, + GroupBy, + SortBy, + ViewMode, +} from "../settings"; + +const DAY = 86_400_000; + +export interface Group { + key: string; + label: string; + hint: string; + rank: number; + tasks: Task[]; + lane: { path: string; project: string } | null; +} + +export interface DaySlot { + date: Date; + key: string; + inMonth: boolean; + isToday: boolean; + tasks: Task[]; +} + +function dueBucket(key: string | null): { key: string; label: string; rank: number } { + if (!key) return { key: "none", label: "No date", rank: 5 }; + const diff = daysFromToday(key); + if (diff < 0) return { key: "overdue", label: "Overdue", rank: 0 }; + if (diff === 0) return { key: "today", label: "Today", rank: 1 }; + if (diff === 1) return { key: "tomorrow", label: "Tomorrow", rank: 2 }; + if (diff <= 7) return { key: "week", label: "This week", rank: 3 }; + return { key: "later", label: "Later", rank: 4 }; +} + +const STATUS_LABEL: Record = { + " ": "Todo", + "/": "In progress", + x: "Done", + "-": "Cancelled", +}; + +function compareTasks(sortBy: SortBy, a: Task, b: Task): number { + if (sortBy === "priority") return b.priority - a.priority; + if (sortBy === "due") { + return (anchorDate(a) ?? "￿").localeCompare(anchorDate(b) ?? "￿"); + } + return 0; +} + +export class BoardStore { + private readonly boards = new Map(); + private reloadTimer: number | null = null; + private readonly pending = new Set(); + + version = $state(0); + view = $state("list"); + groupBy = $state("sphere"); + listSortBy = $state("due"); + calendarSortBy = $state("priority"); + showDone = $state(false); + includeArchive = $state(false); + railCollapsed = $state(false); + undatedCollapsed = $state(false); + search = $state(""); + sphereFilter = $state(null); + projectFilter = $state(null); + statusFilter = $state([]); + monthKey = $state(localKey(new Date())); + selected = $state(null); + + constructor( + private readonly app: App, + private settings: BeaverCalendarSettings, + private readonly persist: () => void, + ) { + this.view = settings.view; + this.groupBy = settings.groupBy; + this.listSortBy = settings.listSort; + this.calendarSortBy = settings.calendarSort; + this.showDone = settings.showDone; + this.includeArchive = settings.includeArchive; + this.railCollapsed = settings.railCollapsed; + this.undatedCollapsed = settings.undatedCollapsed; + } + + get sortBy(): SortBy { + return this.view === "calendar" ? this.calendarSortBy : this.listSortBy; + } + + set sortBy(value: SortBy) { + if (this.view === "calendar") { + this.calendarSortBy = value; + this.settings.calendarSort = value; + } else { + this.listSortBy = value; + this.settings.listSort = value; + } + this.persist(); + } + + savePrefs(): void { + this.settings.view = this.view; + this.settings.groupBy = this.groupBy; + this.settings.showDone = this.showDone; + this.settings.includeArchive = this.includeArchive; + this.settings.railCollapsed = this.railCollapsed; + this.settings.undatedCollapsed = this.undatedCollapsed; + this.persist(); + } + + updateSettings(settings: BeaverCalendarSettings): void { + this.settings = settings; + } + + private inScope(path: string): boolean { + const folder = this.settings.boardsFolder; + if (!folder) return path.endsWith(".md"); + return path.startsWith(`${folder}/`) && path.endsWith(".md"); + } + + async reloadAll(): Promise { + this.boards.clear(); + const files = this.app.vault + .getMarkdownFiles() + .filter((file) => this.inScope(file.path)); + await Promise.all(files.map((file) => this.load(file))); + this.version += 1; + } + + private async load(file: TFile): Promise { + const content = await this.app.vault.cachedRead(file); + this.boards.set(file.path, parseBoard(file.path, content)); + } + + queueReload(path: string): void { + if (!this.inScope(path)) return; + this.pending.add(path); + if (this.reloadTimer !== null) window.clearTimeout(this.reloadTimer); + this.reloadTimer = window.setTimeout(() => { + this.reloadTimer = null; + void this.flush(); + }, 150); + } + + private async flush(): Promise { + const paths = [...this.pending]; + this.pending.clear(); + for (const path of paths) { + const file = this.app.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) { + this.boards.delete(path); + continue; + } + const content = await this.app.vault.cachedRead(file); + this.boards.set(path, parseBoard(path, content)); + } + this.version += 1; + } + + drop(path: string): void { + if (this.boards.delete(path)) this.version += 1; + } + + boardFor(path: string): Board | undefined { + return this.boards.get(path); + } + + all = $derived.by(() => { + void this.version; + const out: Task[] = []; + for (const board of this.boards.values()) { + for (const task of board.tasks) { + if (this.includeArchive || !task.inArchive) out.push(task); + } + } + return out; + }); + + spheres = $derived.by(() => { + void this.version; + return [...this.boards.values()] + .map((board) => ({ + path: board.path, + name: board.sphere, + projects: board.lanes + .filter((lane) => !lane.inArchive) + .map((lane) => lane.name), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + }); + + private matches(task: Task): boolean { + if (!this.showDone && !isOpen(task.status)) return false; + if (this.statusFilter.length > 0 && !this.statusFilter.includes(task.status)) { + return false; + } + const query = this.search.trim().toLowerCase(); + if (query) { + const haystack = + `${task.sphere} ${task.project} ${task.text}`.toLowerCase(); + if (!haystack.includes(query)) return false; + } + return true; + } + + visible = $derived.by(() => + this.all + .filter((task) => this.matches(task)) + .filter( + (task) => + (!this.sphereFilter || task.path === this.sphereFilter) && + (!this.projectFilter || task.project === this.projectFilter), + ) + .sort( + (a, b) => + Number(isOpen(b.status)) - Number(isOpen(a.status)) || + compareTasks(this.sortBy, a, b), + ), + ); + + inScopeCount = $derived.by(() => { + const byPath = new Map(); + const byProject = new Map(); + for (const task of this.all) { + if (!this.matches(task)) continue; + byPath.set(task.path, (byPath.get(task.path) ?? 0) + 1); + const key = laneKey(task.path, task.project); + byProject.set(key, (byProject.get(key) ?? 0) + 1); + } + return { byPath, byProject }; + }); + + groups = $derived.by(() => { + const buckets = new Map(); + for (const task of this.visible) { + const slot = this.slotFor(task); + let bucket = buckets.get(slot.key); + if (!bucket) { + bucket = { ...slot, tasks: [], lane: null }; + buckets.set(slot.key, bucket); + } + bucket.tasks.push(task); + } + const groups = [...buckets.values()].sort( + (a, b) => a.rank - b.rank || a.label.localeCompare(b.label), + ); + for (const group of groups) { + const first = group.tasks[0]; + const single = group.tasks.every( + (task) => task.path === first.path && task.project === first.project, + ); + group.lane = single + ? { path: first.path, project: first.project } + : null; + } + return groups; + }); + + private slotFor(task: Task): Omit { + if (this.groupBy === "project") { + return { + key: laneKey(task.path, task.project), + label: task.project, + hint: task.sphere, + rank: 0, + }; + } + if (this.groupBy === "status") { + return { + key: task.status, + label: STATUS_LABEL[task.status], + hint: "", + rank: STATUS_ORDER.indexOf(task.status), + }; + } + if (this.groupBy === "due") { + const bucket = dueBucket(anchorDate(task)); + return { key: bucket.key, label: bucket.label, hint: "", rank: bucket.rank }; + } + return { key: task.path, label: task.sphere, hint: "", rank: 0 }; + } + + byDay = $derived.by(() => { + const map = new Map(); + for (const task of this.visible) { + const key = anchorDate(task); + if (!key) continue; + const list = map.get(key); + if (list) list.push(task); + else map.set(key, [task]); + } + return map; + }); + + undated = $derived.by(() => this.visible.filter((task) => !anchorDate(task))); + + monthDays = $derived.by(() => { + const anchor = new Date(this.monthKey); + const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1); + const offset = (first.getDay() + 6) % 7; + const start = new Date(first.getTime() - offset * DAY); + const todayKey = localKey(new Date()); + const byDay = this.byDay; + return Array.from({ length: 42 }, (_, index) => { + const date = new Date(start.getTime() + index * DAY); + date.setHours(0, 0, 0, 0); + const key = localKey(date); + return { + date, + key, + inMonth: date.getMonth() === first.getMonth(), + isToday: key === todayKey, + tasks: byDay.get(key) ?? [], + }; + }); + }); + + monthWeeks = $derived.by(() => { + const weeks: DaySlot[][] = []; + for (let i = 0; i < 42; i += 7) weeks.push(this.monthDays.slice(i, i + 7)); + return weeks; + }); + + agendaDays = $derived.by(() => + this.monthDays.filter((day) => day.inMonth && day.tasks.length > 0), + ); + + monthLabel = $derived.by(() => + new Intl.DateTimeFormat("en-US", { + month: "long", + year: "numeric", + }).format(new Date(this.monthKey)), + ); + + stepMonth(delta: number): void { + const anchor = new Date(this.monthKey); + this.monthKey = localKey( + new Date(anchor.getFullYear(), anchor.getMonth() + delta, 1), + ); + } + + goToday(): void { + this.monthKey = localKey(new Date()); + } + + sphereName(path: string): string { + return this.boards.get(path)?.sphere ?? sphereOf(path); + } +} diff --git a/src/view.ts b/src/view.ts new file mode 100644 index 0000000..b44a201 --- /dev/null +++ b/src/view.ts @@ -0,0 +1,55 @@ +import { ItemView } from "obsidian"; +import type { WorkspaceLeaf } from "obsidian"; +import { mount, unmount } from "svelte"; +import App from "./ui/app.svelte"; +import type BeaverCalendarPlugin from "./main"; + +export const VIEW_TYPE_BOARDS = "beaver-calendar-boards"; + +export class BoardsView extends ItemView { + private root: Record | null = null; + + constructor( + leaf: WorkspaceLeaf, + private readonly plugin: BeaverCalendarPlugin, + ) { + super(leaf); + this.navigation = false; + } + + getViewType(): string { + return VIEW_TYPE_BOARDS; + } + + getDisplayText(): string { + return "Tasks"; + } + + getIcon(): string { + return "calendar-check"; + } + + async onOpen(): Promise { + this.contentEl.empty(); + this.contentEl.style.padding = "0"; + this.contentEl.style.overflow = "hidden"; + await this.plugin.store.reloadAll(); + this.root = mount(App, { + target: this.contentEl, + props: { + app: this.app, + store: this.plugin.store, + settings: this.plugin.settings, + component: this, + }, + }); + } + + async onClose(): Promise { + if (this.root) { + await unmount(this.root); + this.root = null; + } + this.contentEl.empty(); + } +} diff --git a/tests/edits.test.ts b/tests/edits.test.ts new file mode 100644 index 0000000..aac3fb0 --- /dev/null +++ b/tests/edits.test.ts @@ -0,0 +1,272 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseBoard } from "../src/model/board"; +import { serializeTaskLine } from "../src/model/serialize"; +import type { Task } from "../src/model/types"; +import { + applyArchive, + applyReorder, + cutTask, + insertBlock, + locate, + MissingLaneError, + replaceLine, + StaleTaskError, +} from "../src/vault/edits"; + +const FIXTURES = join(import.meta.dirname, "fixtures"); + +function board(name: string) { + const content = readFileSync(join(FIXTURES, name), "utf8"); + return { content, board: parseBoard(`Boards/${name}`, content) }; +} + +function landmarks(text: string) { + const lines = text.split("\n"); + return { + settings: lines.filter((line) => line.startsWith("%% kanban:settings")).length, + fences: lines.filter((line) => line.trim() === "***").length, + headings: lines.filter((line) => line.startsWith("## ")), + frontmatter: text.startsWith("---\n") + ? text.slice(0, text.indexOf("\n---\n", 4)) + : "", + }; +} + +function edited(name: string, mutate: (lines: string[]) => void) { + const { content } = board(name); + const lines = content.split("\n"); + mutate(lines); + return lines.join("\n"); +} + +describe("locate", () => { + it("finds a line that has shifted", () => { + const { board: parsed } = board("personal.md"); + const task = parsed.tasks.find((item) => !item.inArchive) as Task; + const lines = readFileSync(join(FIXTURES, "personal.md"), "utf8").split("\n"); + lines.unshift("", "", ""); + expect(locate(lines, task)).toBe(task.line + 3); + }); + + it("refuses when the line is gone", () => { + const { board: parsed } = board("personal.md"); + expect(() => locate(["nothing", "here"], parsed.tasks[0])).toThrow( + StaleTaskError, + ); + }); + + it("picks the nearest of several identical lines", () => { + const { board: parsed } = board("work.md"); + const counts = new Map(); + for (const task of parsed.tasks) { + const list = counts.get(task.raw) ?? []; + list.push(task); + counts.set(task.raw, list); + } + const dupes = [...counts.values()].find((list) => list.length > 1); + expect(dupes, "work.md should carry duplicate archived lines").toBeDefined(); + const lines = readFileSync(join(FIXTURES, "work.md"), "utf8").split("\n"); + for (const task of dupes as Task[]) { + expect(locate(lines, task)).toBe(task.line); + } + }); +}); + +describe("replaceLine", () => { + it("changes only the target line", () => { + const { content, board: parsed } = board("personal.md"); + const task = parsed.tasks.find( + (item) => item.text === "renew the gym membership", + ) as Task; + expect(task).toBeDefined(); + const next = edited("personal.md", (lines) => { + replaceLine(lines, task, serializeTaskLine({ ...task, due: "2026-09-01" })); + }); + const before = content.split("\n"); + const after = next.split("\n"); + expect(after).toHaveLength(before.length); + expect(after.filter((line, index) => line !== before[index])).toEqual([ + "- [ ] renew the gym membership 📅 2026-09-01", + ]); + }); + + it("expands a recurring toggle into two lines", () => { + const { board: parsed } = board("personal.md"); + const task = parsed.tasks.find( + (item) => !item.inArchive && item.recurrence, + ) as Task; + const next = edited("personal.md", (lines) => { + replaceLine( + lines, + task, + `- [ ] ${task.text} 🔁 every day 📅 2026-08-09\n- [x] ${task.text} 🔁 every day 📅 2026-08-08 ✅ 2026-08-08`, + ); + }); + expect(next).toContain("📅 2026-08-09"); + expect(next).toContain("✅ 2026-08-08"); + expect(landmarks(next).settings).toBe(1); + }); +}); + +describe("cutTask and insertBlock", () => { + it("moves a card between lanes of the same board", () => { + const { content, board: parsed } = board("personal.md"); + const task = parsed.tasks.find( + (item) => !item.inArchive && item.project === "errands", + ) as Task; + const next = edited("personal.md", (lines) => { + const block = cutTask(lines, task); + insertBlock(lines, "personal.md", { project: "someday" }, block); + }); + + const reparsed = parseBoard("personal.md", next); + const moved = reparsed.tasks.find((item) => item.text === task.text) as Task; + expect(moved.project).toBe("someday"); + expect(reparsed.tasks).toHaveLength(parsed.tasks.length); + + const before = landmarks(content); + const after = landmarks(next); + expect(after.settings).toBe(before.settings); + expect(after.fences).toBe(before.fences); + expect(after.headings).toEqual(before.headings); + }); + + it("carries a multi-line card body along", () => { + const { content, board: parsed } = board("work.md"); + const task = parsed.tasks.find((item) => item.body.length > 1) as Task; + expect(task).toBeDefined(); + const lines = content.split("\n"); + const before = lines.length; + const block = cutTask(lines, task); + expect(block).toHaveLength(1 + task.body.length); + expect(block.slice(1)).toEqual(task.body); + expect(lines.join("\n")).not.toContain(block.join("\n")); + expect(before - lines.length).toBeGreaterThanOrEqual(block.length); + }); + + it("reports a missing lane instead of writing", () => { + const lines = board("personal.md").content.split("\n"); + expect(() => + insertBlock(lines, "personal.md", { project: "nope" }, ["- [ ] x"]), + ).toThrow(MissingLaneError); + }); + + it("appends after the last card, before the lane's blank tail", () => { + const next = edited("personal.md", (lines) => { + insertBlock(lines, "personal.md", { project: "errands" }, [ + "- [ ] new one 📅 2026-08-08", + ]); + }); + const lane = parseBoard("personal.md", next).tasks.filter( + (item) => item.project === "errands" && !item.inArchive, + ); + expect(lane[lane.length - 1].text).toBe("new one"); + expect(landmarks(next).settings).toBe(1); + }); +}); + +describe("applyReorder", () => { + it("reverses a lane without disturbing the rest of the file", () => { + const { content, board: parsed } = board("personal.md"); + const inLane = parsed.tasks.filter( + (item) => item.project === "errands" && !item.inArchive, + ); + expect(inLane.length).toBeGreaterThan(2); + + const next = edited("personal.md", (lines) => { + applyReorder(lines, inLane, [...inLane].reverse()); + }); + + const after = parseBoard("personal.md", next) + .tasks.filter((item) => item.project === "errands" && !item.inArchive) + .map((item) => item.text); + expect(after).toEqual([...inLane].reverse().map((item) => item.text)); + + expect(next.split("\n").sort()).toEqual(content.split("\n").sort()); + expect(landmarks(next)).toEqual(landmarks(content)); + }); +}); + +describe("applyArchive", () => { + it("moves done cards under the archive heading", () => { + const { content, board: parsed } = board("personal.md"); + const lines = content.split("\n"); + const open = parsed.tasks.find((item) => !item.inArchive) as Task; + replaceLine( + lines, + open, + serializeTaskLine({ ...open, status: "x", done: "2026-08-08" }), + ); + + expect(applyArchive(lines, "personal.md", "Archive")).toBe(1); + + const next = lines.join("\n"); + const reparsed = parseBoard("personal.md", next); + const archived = reparsed.tasks.find((item) => item.text === open.text) as Task; + expect(archived.inArchive).toBe(true); + expect(archived.done).toBe("2026-08-08"); + expect(reparsed.tasks.filter((item) => item.text === open.text)).toHaveLength(1); + expect(landmarks(next).settings).toBe(1); + expect(landmarks(next).fences).toBe(1); + }); + + it("is a no-op when nothing is done", () => { + const { content } = board("home.md"); + const lines = content.split("\n"); + expect(applyArchive(lines, "home.md", "Archive")).toBe(0); + expect(lines.join("\n")).toBe(content); + }); + + it("creates the fence and heading on a board without one", () => { + const { content } = board("home.md"); + expect(content).not.toContain("## Archive"); + const lines = content.split("\n"); + const parsed = parseBoard("home.md", content); + for (const task of parsed.tasks) { + replaceLine( + lines, + task, + serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }), + ); + } + expect(applyArchive(lines, "home.md", "Archive")).toBe(parsed.tasks.length); + + const next = lines.join("\n"); + expect(next).toContain("***"); + expect(next).toContain("## Archive"); + expect(next).toContain("%% kanban:settings"); + expect(parseBoard("home.md", next).tasks.every((t) => t.inArchive)).toBe(true); + }); +}); + +describe("every board survives a full archive pass", () => { + for (const name of readdirSync(FIXTURES).filter((n) => n.endsWith(".md"))) { + it(`${name}: landmarks intact, no task lost`, () => { + const { content, board: parsed } = board(name); + const lines = content.split("\n"); + for (const task of parsed.tasks.filter((item) => !item.inArchive)) { + replaceLine( + lines, + task, + serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }), + ); + } + applyArchive(lines, name, "Archive"); + const next = lines.join("\n"); + + const reparsed = parseBoard(name, next); + expect(reparsed.tasks).toHaveLength(parsed.tasks.length); + expect(reparsed.tasks.every((item) => item.inArchive)).toBe(true); + + const before = landmarks(content); + const after = landmarks(next); + expect(after.settings).toBe(before.settings); + expect(after.frontmatter).toBe(before.frontmatter); + for (const heading of before.headings) { + expect(after.headings, `${heading} must survive`).toContain(heading); + } + }); + } +}); diff --git a/tests/fixtures/home.md b/tests/fixtures/home.md new file mode 100644 index 0000000..c690b36 --- /dev/null +++ b/tests/fixtures/home.md @@ -0,0 +1,17 @@ +--- + +kanban-plugin: board + +--- + +## repairs + +- [ ] replace the kitchen tap 📅 2026-08-20 +- [ ] fix the squeaky door + + +%% kanban:settings +``` +{"kanban-plugin":"board","list-collapse":[false]} +``` +%% diff --git a/tests/fixtures/personal.md b/tests/fixtures/personal.md new file mode 100644 index 0000000..448639d --- /dev/null +++ b/tests/fixtures/personal.md @@ -0,0 +1,34 @@ +--- + +kanban-plugin: board + +--- + +## errands + +- [ ] renew the gym membership 📅 2026-08-08 +- [ ] book a dentist appointment ⏳ 2026-08-09 📅 2026-08-12 +- [ ] water the plants 🔁 every day 📅 2026-08-08 +- [ ] call [[Alex Rivera|Alex]] about the move 📅 2026-08-09 + + +## someday + +- [ ] learn to bake sourdough +- [ ] read **Thinking in Systems** + + +*** + +## Archive + +- [x] pick up the parcel 📅 2026-07-30 ✅ 2026-07-30 +- [x] pay the electricity bill ⏳ 2026-07-28 📅 2026-07-29 ✅ 2026-07-28 +- [x] water the plants 🔁 every day 📅 2026-07-27 ✅ 2026-07-27 +- [x] water the plants 🔁 every day 📅 2026-07-26 ✅ 2026-07-27 + +%% kanban:settings +``` +{"kanban-plugin":"board","list-collapse":[false,false]} +``` +%% diff --git a/tests/fixtures/reading.md b/tests/fixtures/reading.md new file mode 100644 index 0000000..45ac315 --- /dev/null +++ b/tests/fixtures/reading.md @@ -0,0 +1,24 @@ +--- + +kanban-plugin: board + +--- + +## queue + +- [ ] ![[cover.png]] +- [ ] compare the pricing tiers https://example.com/pricing#plans +- [ ] finish the chapter on caching 📅 2026-08-15 + + +*** + +## Archive + +- [x] skim the release notes ✅ 2026-07-15 + +%% kanban:settings +``` +{"kanban-plugin":"board","list-collapse":[false,false]} +``` +%% diff --git a/tests/fixtures/work.md b/tests/fixtures/work.md new file mode 100644 index 0000000..b82260a --- /dev/null +++ b/tests/fixtures/work.md @@ -0,0 +1,43 @@ +--- +planner: + log: [] +kanban-plugin: board +TQ_explain: +TQ_short_mode: +TQ_show_due_date: +TQ_show_tags: +--- + +## website + +- [ ] fix the mobile nav overlap ⏫ 📅 2026-08-10 +- [ ] compress the hero images https://example.com/tools/images + + +## backlog + + + +## notes + +- [x] rewrite the onboarding copy ✅ 2026-08-01 + - shorter intro + - drop the second screenshot + - link to the docs instead +- [x] audit the third-party scripts ✅ 2026-07-31 + + follow-up: check the analytics bundle size + + +*** + +## Archive + +- [x] ship the pricing page 📅 2026-07-20 ✅ 2026-07-21 +- [x] ship the pricing page 📅 2026-07-20 ✅ 2026-07-21 + +%% kanban:settings +``` +{"kanban-plugin":"board","list-collapse":[false,false,false,false]} +``` +%% diff --git a/tests/obsidian-stub.ts b/tests/obsidian-stub.ts new file mode 100644 index 0000000..4748297 --- /dev/null +++ b/tests/obsidian-stub.ts @@ -0,0 +1,57 @@ +export class TFile { + constructor( + public path: string, + public extension = "md", + ) {} +} + +export class TFolder { + constructor(public path: string) {} +} + +export const notices: string[] = []; + +export class Notice { + constructor(message: string) { + notices.push(message); + } + hide(): void {} +} + +export class Plugin {} +export class ItemView {} +export class PluginSettingTab {} +export class Setting {} +export class Component {} +export const moment = () => undefined; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">"); +} + +export const MarkdownRenderer = { + async render( + _app: unknown, + markdown: string, + el: HTMLElement, + _sourcePath: string, + _component: unknown, + ): Promise { + const html = escapeHtml(markdown) + .replace( + /!?\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, + (_full, target: string, alias?: string) => + `${alias ?? target}`, + ) + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace( + /(^|\s)(https?:\/\/[^\s]+)/g, + (_full, lead: string, url: string) => + `${lead}${url}`, + ); + el.innerHTML = `

${html}

`; + }, +}; diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts new file mode 100644 index 0000000..ff02e39 --- /dev/null +++ b/tests/roundtrip.test.ts @@ -0,0 +1,106 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseBoard, renderBoard } from "../src/model/board"; +import { serializeTaskLine } from "../src/model/serialize"; + +const FIXTURES = join(import.meta.dirname, "fixtures"); +const BOARDS = readdirSync(FIXTURES).filter((name) => name.endsWith(".md")); + +describe("board round-trip", () => { + it("has boards to work with", () => { + expect(BOARDS.length).toBeGreaterThan(0); + }); + + for (const name of BOARDS) { + const content = readFileSync(join(FIXTURES, name), "utf8"); + const board = parseBoard(`Boards/${name}`, content); + + it(`${name}: re-renders byte for byte`, () => { + expect(renderBoard(board)).toBe(content); + }); + + it(`${name}: every task line survives parse then serialize`, () => { + for (const task of board.tasks) { + expect(serializeTaskLine(task), `line ${task.line + 1}`).toBe(task.raw); + } + }); + + it(`${name}: finds tasks and lanes`, () => { + expect(board.lanes.length).toBeGreaterThan(0); + expect(board.tasks.length).toBeGreaterThan(0); + for (const task of board.tasks) { + expect(board.lines[task.line]).toBe(task.raw); + } + }); + } +}); + +describe("field extraction", () => { + const parse = (line: string) => { + const board = parseBoard("x.md", `## lane\n\n${line}\n`); + return board.tasks[0]; + }; + + it("reads recurrence followed by dates", () => { + const task = parse("- [ ] water the plants 🔁 every day 📅 2026-08-08"); + expect(task.text).toBe("water the plants"); + expect(task.recurrence).toBe("every day"); + expect(task.due).toBe("2026-08-08"); + expect(task.scheduled).toBeNull(); + }); + + it("reads scheduled, due and done together", () => { + const task = parse( + "- [x] pay the electricity bill ⏳ 2026-07-28 📅 2026-07-29 ✅ 2026-07-28", + ); + expect(task.status).toBe("x"); + expect(task.text).toBe("pay the electricity bill"); + expect(task.scheduled).toBe("2026-07-28"); + expect(task.due).toBe("2026-07-29"); + expect(task.done).toBe("2026-07-28"); + }); + + it("keeps wikilinks and embeds inside the description", () => { + const task = parse("- [ ] call [[Alex Rivera|Alex]] about the move 📅 2026-08-09"); + expect(task.text).toBe("call [[Alex Rivera|Alex]] about the move"); + expect(task.due).toBe("2026-08-09"); + }); + + it("does not mistake a url fragment for a tag", () => { + const task = parse("- [ ] compare tiers https://example.com/pricing#plans"); + expect(task.tags).toEqual([]); + expect(task.text).toBe("compare tiers https://example.com/pricing#plans"); + }); + + it("reads priority and writes it back in Tasks' order", () => { + const task = parse("- [ ] fix the mobile nav ⏫ 📅 2026-08-10"); + expect(task.priority).toBe(4); + expect(serializeTaskLine(task)).toBe("- [ ] fix the mobile nav ⏫ 📅 2026-08-10"); + }); + + it("captures a multi-line kanban card body", () => { + const board = parseBoard( + "x.md", + "## lane\n\n- [x] rewrite the copy ✅ 2026-08-01\n\t- shorter intro\n\t- link the docs\n", + ); + expect(board.tasks[0].body).toEqual(["\t- shorter intro", "\t- link the docs"]); + }); + + it("marks archive lanes", () => { + const board = parseBoard( + "x.md", + "## live\n\n- [ ] a\n\n***\n\n## Archive\n\n- [x] b ✅ 2026-01-01\n", + ); + expect(board.tasks[0].inArchive).toBe(false); + expect(board.tasks[1].inArchive).toBe(true); + }); + + it("ignores anything inside the kanban settings block", () => { + const board = parseBoard( + "x.md", + '## lane\n\n- [ ] real\n\n%% kanban:settings\n```\n{"kanban-plugin":"board"}\n```\n%%\n', + ); + expect(board.tasks).toHaveLength(1); + }); +}); diff --git a/tests/smoke.mjs b/tests/smoke.mjs new file mode 100644 index 0000000..2892804 --- /dev/null +++ b/tests/smoke.mjs @@ -0,0 +1,203 @@ +import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import esbuild from "esbuild"; +import sveltePlugin from "esbuild-svelte"; +import { JSDOM } from "jsdom"; + +const root = new URL("..", import.meta.url).pathname; +const fixtures = join(root, "tests/fixtures"); +const FOLDER = "Boards"; + +const out = join(mkdtempSync(join(tmpdir(), "bcal-")), "bundle.mjs"); +writeFileSync( + join(root, "tests/.entry.ts"), + `export { default as App } from "../src/ui/app.svelte"; +export { BoardStore } from "../src/vault/store.svelte"; +export { mount, unmount, flushSync } from "svelte"; +export { TFile, Component } from "obsidian"; +`, +); + +await esbuild.build({ + entryPoints: [join(root, "tests/.entry.ts")], + bundle: true, + format: "esm", + target: "es2022", + outfile: out, + logLevel: "error", + conditions: ["svelte", "browser"], + mainFields: ["svelte", "browser", "module", "main"], + alias: { obsidian: join(root, "tests/obsidian-stub.ts") }, + plugins: [sveltePlugin({ compilerOptions: { css: "injected", runes: true } })], +}); + +const dom = new JSDOM("", { + pretendToBeVisual: true, + url: "http://localhost/", +}); +for (const key of Object.getOwnPropertyNames(dom.window)) { + if (key.startsWith("_")) continue; + if (globalThis[key] !== undefined) continue; + try { + globalThis[key] = dom.window[key]; + } catch { + /* getter-only window properties are fine to skip */ + } +} +globalThis.window = dom.window; +globalThis.document = dom.window.document; +dom.window.Element.prototype.getBoundingClientRect = () => ({ + bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0, + toJSON: () => ({}), +}); +dom.window.Element.prototype.setPointerCapture = () => {}; +dom.window.Element.prototype.releasePointerCapture = () => {}; +dom.window.Element.prototype.hasPointerCapture = () => false; +dom.window.Element.prototype.scrollIntoView = () => {}; +globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; + +const { App, BoardStore, mount, unmount, flushSync, TFile, Component } = + await import(pathToFileURL(out).href); + +const names = readdirSync(fixtures).filter((name) => name.endsWith(".md")); +const files = new Map( + names.map((name) => [ + `${FOLDER}/${name}`, + readFileSync(join(fixtures, name), "utf8"), + ]), +); +const handles = new Map([...files.keys()].map((path) => [path, new TFile(path)])); + +const writes = []; +const app = { + vault: { + getMarkdownFiles: () => [...handles.values()], + getAbstractFileByPath: (path) => handles.get(path) ?? null, + cachedRead: async (file) => files.get(file.path), + read: async (file) => files.get(file.path), + process: async (file, fn) => { + const next = fn(files.get(file.path)); + writes.push({ path: file.path, content: next }); + files.set(file.path, next); + return next; + }, + on: () => ({}), + }, + workspace: { getLeaf: () => ({}), activeEditor: null }, + plugins: { plugins: {} }, +}; + +const settings = { + boardsFolder: FOLDER, + archiveHeading: "Archive", + view: "list", + groupBy: "sphere", + listSort: "due", + calendarSort: "priority", + showDone: false, + includeArchive: false, + railCollapsed: false, + undatedCollapsed: false, +}; + +const failures = []; +function check(name, condition, detail = "") { + if (condition) console.log(` ok ${name}`); + else { + failures.push(name); + console.log(` FAIL ${name}${detail ? ` -- ${detail}` : ""}`); + } +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 60)); + +const store = new BoardStore(app, settings, () => {}); +await store.reloadAll(); + +// Every task outside the archive, done ones included; the done filter +// applies to `visible`, not to the pool. +const outsideArchive = 15; +check("parsed every board", store.spheres.length === names.length, `${store.spheres.length}`); +check(`${outsideArchive} tasks outside the archive`, store.all.length === outsideArchive, `got ${store.all.length}`); + +const target = dom.window.document.body; +const instance = mount(App, { + target, + props: { app, store, settings, component: new Component() }, +}); +flushSync(); +await settle(); +flushSync(); + +const text = () => target.textContent ?? ""; +check("list rendered a task", text().includes("renew the gym membership")); +check("sphere rail rendered", text().includes("All spheres")); +check("group header rendered", text().includes("personal")); +check("project chip rendered", text().includes("errands")); +check("no crash markers", !text().includes("undefined")); + +const links = target.querySelectorAll("a.internal-link"); +check("wikilink rendered as a link", links.length > 0, `${links.length} links`); +check("alias is displayed, not the target", text().includes("Alex")); +check("no raw wikilink syntax leaked", !text().includes("[[")); + +store.includeArchive = true; +store.showDone = true; +flushSync(); +await settle(); +check("archive appears when both toggles are on", text().includes("pick up the parcel")); +check("archived task carries the Archive badge", text().includes("Archive")); + +store.includeArchive = false; +flushSync(); +check("archive hidden again", !text().includes("pick up the parcel")); +store.showDone = false; +flushSync(); + +store.view = "calendar"; +flushSync(); +await settle(); +check("calendar rendered a month label", /\d{4}/.test(store.monthLabel)); +check("calendar rendered the undated tray", text().includes("No date")); + +store.view = "list"; +store.groupBy = "due"; +flushSync(); +const BUCKETS = ["Overdue", "Today", "Tomorrow", "This week", "Later", "No date"]; +check( + "due grouping produced known buckets", + store.groups.length > 0 && store.groups.every((g) => BUCKETS.includes(g.label)), + store.groups.map((g) => g.label).join(", "), +); + +const button = target.querySelector('button[aria-label^="Status"]'); +check("status button present", button !== null); +if (button) { + button.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); + await settle(); + check("clicking status wrote to the vault", writes.length > 0, `${writes.length}`); + if (writes.length > 0) { + const after = writes[writes.length - 1]; + const before = readFileSync( + join(fixtures, after.path.slice(FOLDER.length + 1)), + "utf8", + ).split("\n"); + const now = after.content.split("\n"); + const diff = now.filter((line, index) => line !== before[index]); + check("exactly one line changed", diff.length === 1, JSON.stringify(diff)); + check("line is now done", diff[0]?.startsWith("- [x] "), diff[0]); + check("done date stamped", /✅ \d{4}-\d{2}-\d{2}$/u.test(diff[0] ?? ""), diff[0]); + check("settings block intact", after.content.includes("%% kanban:settings")); + } +} + +await unmount(instance); + +console.log(failures.length === 0 ? "\nsmoke: all good" : `\nsmoke: ${failures.length} FAILED`); +process.exit(failures.length === 0 ? 0 : 1); diff --git a/tests/styles.test.ts b/tests/styles.test.ts new file mode 100644 index 0000000..ffffd90 --- /dev/null +++ b/tests/styles.test.ts @@ -0,0 +1,91 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync( + join(import.meta.dirname, "../src/tailwind.css"), + "utf8", +); + +function block(selector: string): string { + const at = source.indexOf(`\n${selector} {`); + if (at === -1) return ""; + const open = source.indexOf("{", at); + const close = source.indexOf("\n}", open); + return source.slice(open, close); +} + +describe("theme aliases", () => { + const OBSIDIAN_VARS = [ + "--background-primary", + "--background-secondary", + "--background-modifier-border", + "--text-normal", + "--text-muted", + "--color-accent", + "--text-on-accent", + "--text-error", + ]; + + it("are declared on .bcal-root, not :root", () => { + const root = block(".bcal-root"); + expect(root).not.toBe(""); + for (const name of OBSIDIAN_VARS) { + expect(root, `${name} must be aliased on .bcal-root`).toContain(name); + } + }); + + it("never reference Obsidian variables from :root", () => { + const rootBlocks = [...source.matchAll(/(^|\n):root\s*\{([^}]*)\}/g)]; + for (const [, , body] of rootBlocks) { + for (const name of OBSIDIAN_VARS) { + expect(body, `:root must not reference ${name}`).not.toContain(name); + } + } + }); + + it("scopes the dark override under the view root", () => { + expect(source).toContain(".theme-dark .bcal-root"); + expect(source).not.toMatch(/\n\.theme-dark\s*\{/); + }); +}); + +describe("reset specificity", () => { + it("does not use :where() for element resets", () => { + expect(source).not.toMatch(/\.bcal-root\s+:where\(/); + }); + + it("uses a doubled class to outrank app and theme styles", () => { + expect(source).toContain(".bcal-root.bcal-root"); + }); + + it("neutralises the native search affordance", () => { + expect(source).toContain("-webkit-search-cancel-button"); + }); +}); + +describe("cascade against Obsidian", () => { + it("marks utilities important so they outrank app.css", () => { + expect(source).toMatch( + /@import "tailwindcss\/utilities\.css"[^;]*\bimportant\b/, + ); + }); + + it("keeps the element reset out of a cascade layer", () => { + const reset = source.indexOf(".bcal-root.bcal-root :is(button"); + expect(reset).toBeGreaterThan(-1); + const before = source.slice(0, reset); + const opens = (before.match(/@layer[^;{]*\{/g) ?? []).length; + const closes = (before.match(/\n\}/g) ?? []).length; + expect(opens, "reset must be unlayered").toBeLessThanOrEqual(closes); + }); +}); + +describe("preflight stays out", () => { + it("imports only the theme and utility layers", () => { + expect(source).toContain('@import "tailwindcss/theme.css"'); + expect(source).toContain('@import "tailwindcss/utilities.css"'); + expect(source).not.toMatch(/@import "tailwindcss"\s*[;l]/); + expect(source).not.toMatch(/@import\s+"tailwindcss\/preflight/); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..019d8a6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2022", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "importHelpers": true, + "isolatedModules": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "types": ["node", "svelte"], + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "paths": { + "$lib/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.svelte", "tests/**/*.ts"] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..708016d --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.5.0" +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8c05070 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + // ``obsidian`` is provided by the host app at runtime and has no + // installable implementation, so tests link against a stub. + alias: { + obsidian: new URL("./tests/obsidian-stub.ts", import.meta.url).pathname, + }, + }, + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + }, +});