feat: init
This commit is contained in:
+14
@@ -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
|
||||||
@@ -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=<path-to-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"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# beaver-calendar
|
||||||
|
[](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.
|
||||||
@@ -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 ``<style>`` blocks inside the JS
|
||||||
|
// bundle (``css: "injected"``) rather than as a separate stylesheet.
|
||||||
|
// Obsidian only auto-loads a single ``styles.css`` per plugin, and that
|
||||||
|
// slot is taken by the Tailwind output — injecting keeps us to one CSS
|
||||||
|
// artifact instead of two that would have to be concatenated.
|
||||||
|
//
|
||||||
|
// No preprocessor: Svelte 5 strips type-only TS syntax itself, and we
|
||||||
|
// stick to that subset (no enums, no decorators, no parameter
|
||||||
|
// properties) so ``lang="ts"`` components compile as-is.
|
||||||
|
plugins: [
|
||||||
|
sveltePlugin({
|
||||||
|
compilerOptions: { css: "injected", runes: true },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
format: "cjs",
|
||||||
|
target: "es2022",
|
||||||
|
logLevel: "info",
|
||||||
|
sourcemap: prod ? false : "inline",
|
||||||
|
treeShaking: true,
|
||||||
|
outfile: "main.js",
|
||||||
|
minify: prod,
|
||||||
|
conditions: ["svelte", "browser"],
|
||||||
|
mainFields: ["svelte", "browser", "module", "main"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prod) {
|
||||||
|
await ctx.rebuild();
|
||||||
|
await ctx.dispose();
|
||||||
|
} else {
|
||||||
|
await ctx.watch();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "beaver-calendar",
|
||||||
|
"name": "Beaver Calendar",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"minAppVersion": "1.5.0",
|
||||||
|
"description": "Calendar and list views over Tasks-plugin todos living in Kanban boards.",
|
||||||
|
"author": "h",
|
||||||
|
"isDesktopOnly": false
|
||||||
|
}
|
||||||
Generated
+4758
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "beaver-calendar",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Calendar and list views over Tasks-plugin todos living in Kanban boards.",
|
||||||
|
"main": "main.js",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"css": "tailwindcss -i src/tailwind.css -o styles.css",
|
||||||
|
"css:watch": "tailwindcss -i src/tailwind.css -o styles.css --watch",
|
||||||
|
"dev": "npm run css && node esbuild.config.mjs",
|
||||||
|
"build": "svelte-check --tsconfig ./tsconfig.json --threshold error && npm run css -- --minify && node esbuild.config.mjs production",
|
||||||
|
"test": "vitest run && node tests/smoke.mjs",
|
||||||
|
"preview": "node preview/serve.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@electron/asar": "^4.2.1",
|
||||||
|
"@lucide/svelte": "^1.23.0",
|
||||||
|
"@tailwindcss/cli": "^4.3.0",
|
||||||
|
"@testing-library/svelte": "^5.4.2",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"bits-ui": "^2.16.3",
|
||||||
|
"builtin-modules": "^4.0.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"esbuild-svelte": "^0.9.3",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
|
"obsidian": "^1.5.7",
|
||||||
|
"svelte": "^5.56.1",
|
||||||
|
"svelte-check": "^4.6.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwind-variants": "^3.2.2",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Component, TFile } from "obsidian";
|
||||||
|
import { mount } from "svelte";
|
||||||
|
import App from "../src/ui/app.svelte";
|
||||||
|
import { DEFAULT_SETTINGS } from "../src/settings";
|
||||||
|
import { BoardStore } from "../src/vault/store.svelte";
|
||||||
|
|
||||||
|
const BOARDS = ["personal", "work", "reading", "home"];
|
||||||
|
const FOLDER = "Boards";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const files = new Map<string, string>();
|
||||||
|
await Promise.all(
|
||||||
|
BOARDS.map(async (name) => {
|
||||||
|
const response = await fetch(`/tests/fixtures/${name}.md`);
|
||||||
|
files.set(`${FOLDER}/${name}.md`, await response.text());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const handles = new Map(
|
||||||
|
[...files.keys()].map((path) => [path, new TFile(path)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const app = {
|
||||||
|
vault: {
|
||||||
|
getMarkdownFiles: () => [...handles.values()],
|
||||||
|
getAbstractFileByPath: (path: string) => handles.get(path) ?? null,
|
||||||
|
cachedRead: async (file: TFile) => files.get(file.path) ?? "",
|
||||||
|
read: async (file: TFile) => files.get(file.path) ?? "",
|
||||||
|
process: async (file: TFile, fn: (data: string) => string) => {
|
||||||
|
const next = fn(files.get(file.path) ?? "");
|
||||||
|
files.set(file.path, next);
|
||||||
|
store.queueReload(file.path);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
on: () => ({}),
|
||||||
|
},
|
||||||
|
workspace: { getLeaf: () => ({}), activeEditor: null },
|
||||||
|
plugins: { plugins: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const settings = { ...DEFAULT_SETTINGS, boardsFolder: FOLDER };
|
||||||
|
const store = new BoardStore(app as never, settings, () => {});
|
||||||
|
await store.reloadAll();
|
||||||
|
|
||||||
|
mount(App, {
|
||||||
|
target: document.getElementById("app") as HTMLElement,
|
||||||
|
props: {
|
||||||
|
app: app as never,
|
||||||
|
store,
|
||||||
|
settings,
|
||||||
|
component: new Component() as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>beaver-calendar preview</title>
|
||||||
|
<link rel="stylesheet" href="/app.css" />
|
||||||
|
<link rel="stylesheet" href="/theme.css" />
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
<style>
|
||||||
|
body.theme-dark,
|
||||||
|
body.theme-light {
|
||||||
|
--accent-h: 254;
|
||||||
|
--accent-s: 80%;
|
||||||
|
--accent-l: 68%;
|
||||||
|
--font-interface:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--background-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="theme-dark">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="/preview/bundle.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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}/`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
export interface DragPayload {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropZone {
|
||||||
|
target: string;
|
||||||
|
after: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dragging = $state<DragPayload | null>(null);
|
||||||
|
let zone = $state<DropZone | null>(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<HTMLElement>("[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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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<Status, StatusMeta> = {
|
||||||
|
" ": { 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<Priority, string> = {
|
||||||
|
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"];
|
||||||
+120
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings(): Promise<void> {
|
||||||
|
await this.saveData(this.settings);
|
||||||
|
this.store?.updateSettings(this.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async activateView(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Exclude<Priority, 2>, string> = {
|
||||||
|
0: "\u{23EC}",
|
||||||
|
1: "\u{1F53D}",
|
||||||
|
3: "\u{1F53C}",
|
||||||
|
4: "\u{23EB}",
|
||||||
|
5: "\u{1F53A}",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_BY_EMOJI = new Map<string, Priority>(
|
||||||
|
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<Priority, 2>]);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
+111
@@ -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();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Notice } from "obsidian";
|
||||||
|
import type { App } from "obsidian";
|
||||||
|
|
||||||
|
interface TasksApiV1 {
|
||||||
|
createTaskLineModal(): Promise<string>;
|
||||||
|
editTaskLineModal(taskLine: string): Promise<string>;
|
||||||
|
executeToggleTaskDoneCommand(line: string, path: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginHost {
|
||||||
|
plugins?: { plugins?: Record<string, { apiV1?: TasksApiV1 } | undefined> };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { App, Component } from "obsidian";
|
||||||
|
import type { BeaverCalendarSettings } from "../settings";
|
||||||
|
import type { BoardStore } from "../vault/store.svelte";
|
||||||
|
import { createTask } from "../vault/mutate";
|
||||||
|
import { provideView } from "./context";
|
||||||
|
import SphereRail from "./sphere-rail.svelte";
|
||||||
|
import TaskCalendar from "./task-calendar.svelte";
|
||||||
|
import TaskList from "./task-list.svelte";
|
||||||
|
import Toolbar from "./toolbar.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
app: App;
|
||||||
|
component: Component;
|
||||||
|
settings: BeaverCalendarSettings;
|
||||||
|
store: BoardStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { app, store, settings, component }: Props = $props();
|
||||||
|
|
||||||
|
let root = $state<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
provideView({
|
||||||
|
app,
|
||||||
|
store,
|
||||||
|
settings,
|
||||||
|
component,
|
||||||
|
portal: () => root as HTMLElement,
|
||||||
|
});
|
||||||
|
|
||||||
|
let creating = $state(false);
|
||||||
|
let title = $state("");
|
||||||
|
|
||||||
|
const scope = $derived.by(() => {
|
||||||
|
const sphere =
|
||||||
|
store.spheres.find((item) => item.path === store.sphereFilter) ??
|
||||||
|
store.spheres[0];
|
||||||
|
if (!sphere) return null;
|
||||||
|
const project =
|
||||||
|
(store.projectFilter && sphere.projects.includes(store.projectFilter)
|
||||||
|
? store.projectFilter
|
||||||
|
: undefined) ?? sphere.projects[0];
|
||||||
|
return project ? { path: sphere.path, project, sphere: sphere.name } : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
const text = title.trim();
|
||||||
|
title = "";
|
||||||
|
creating = false;
|
||||||
|
if (!text || !scope) return;
|
||||||
|
await createTask(app, scope.path, scope.project, text, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focus(node: HTMLInputElement) {
|
||||||
|
node.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={root} class="bcal-root">
|
||||||
|
<div class="bcal-app">
|
||||||
|
{#if !store.railCollapsed}
|
||||||
|
<SphereRail />
|
||||||
|
{/if}
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<Toolbar
|
||||||
|
oncreate={() => {
|
||||||
|
creating = true;
|
||||||
|
title = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{#if creating}
|
||||||
|
<div class="shrink-0 border-border border-b px-3 py-2">
|
||||||
|
<input
|
||||||
|
class="h-8 w-full rounded-md border border-ring bg-background px-2 text-sm outline-none"
|
||||||
|
onblur={commit}
|
||||||
|
onkeydown={(event) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if store.view === "calendar"}
|
||||||
|
<TaskCalendar />
|
||||||
|
{:else}
|
||||||
|
<TaskList />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ContextMenu } from "bits-ui";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import MenuBody, { MENU_CONTENT } from "./menu-body.svelte";
|
||||||
|
import type { MenuItem } from "./menu-body.svelte";
|
||||||
|
import { useView } from "./context";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
class?: string;
|
||||||
|
items: MenuItem[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, items, class: className, ...rest }: Props = $props();
|
||||||
|
|
||||||
|
const view = useView();
|
||||||
|
let menuOpen = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ContextMenu.Root bind:open={menuOpen}>
|
||||||
|
<ContextMenu.Trigger class={className} {...rest}>
|
||||||
|
{@render children()}
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenu.Portal to={view.portal()}>
|
||||||
|
<ContextMenu.Content class={MENU_CONTENT}>
|
||||||
|
<MenuBody
|
||||||
|
close={() => {
|
||||||
|
menuOpen = false;
|
||||||
|
}}
|
||||||
|
{items}
|
||||||
|
/>
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Portal>
|
||||||
|
</ContextMenu.Root>
|
||||||
@@ -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<ViewContext>(KEY);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export type DayTone = "none" | "inside" | "start" | "end" | "solo";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
||||||
|
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import { fromKey, localKey } from "../lib/due";
|
||||||
|
import { WEEKDAYS } from "../lib/vocab";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
anchor: string | null;
|
||||||
|
onhover?: (key: string) => void;
|
||||||
|
onpick: (key: string) => void;
|
||||||
|
tone: (key: string) => DayTone;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { anchor, tone, onpick, onhover }: Props = $props();
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
const monthName = new Intl.DateTimeFormat("en-US", { month: "long" });
|
||||||
|
const todayKey = localKey(new Date());
|
||||||
|
|
||||||
|
let picked = $state<Date | null>(null);
|
||||||
|
|
||||||
|
const month = $derived.by(() => {
|
||||||
|
if (picked) return picked;
|
||||||
|
const base = anchor ? fromKey(anchor) : new Date();
|
||||||
|
return new Date(base.getFullYear(), base.getMonth(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const days = $derived.by(() => {
|
||||||
|
const offset = (month.getDay() + 6) % 7;
|
||||||
|
const start = new Date(month.getTime() - offset * DAY);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
return Array.from({ length: 42 }, (_, index) => {
|
||||||
|
const date = new Date(start.getTime() + index * DAY);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
inMonth: date.getMonth() === month.getMonth(),
|
||||||
|
key: localKey(date),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function shiftMonth(delta: number) {
|
||||||
|
picked = new Date(month.getFullYear(), month.getMonth() + delta, 1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-60 select-none">
|
||||||
|
<div class="flex items-center gap-1 px-1 pb-1">
|
||||||
|
<button
|
||||||
|
aria-label="Previous month"
|
||||||
|
class="grid size-6 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => shiftMonth(-1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden="true" class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<span class="flex-1 text-center font-medium text-xs first-letter:uppercase">
|
||||||
|
{monthName.format(month)}
|
||||||
|
{month.getFullYear()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-label="Next month"
|
||||||
|
class="grid size-6 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => shiftMonth(1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronRight aria-hidden="true" class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-7 pb-0.5">
|
||||||
|
{#each WEEKDAYS as weekday (weekday)}
|
||||||
|
<span class="py-0.5 text-center text-[10px] text-muted-foreground">
|
||||||
|
{weekday}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-7 gap-y-px">
|
||||||
|
{#each days as day (day.key)}
|
||||||
|
{@const shape = tone(day.key)}
|
||||||
|
<button
|
||||||
|
aria-label={day.date.toLocaleDateString("en-US")}
|
||||||
|
aria-pressed={shape !== "none"}
|
||||||
|
class={cn(
|
||||||
|
"h-8 text-xs outline-none transition-colors tabular focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
day.inMonth ? "text-foreground" : "text-muted-foreground/50",
|
||||||
|
shape === "none" && "rounded-md hover:bg-muted",
|
||||||
|
shape === "inside" && "bg-secondary",
|
||||||
|
shape !== "none" &&
|
||||||
|
shape !== "inside" &&
|
||||||
|
"bg-primary font-medium text-primary-foreground",
|
||||||
|
shape === "solo" && "rounded-md",
|
||||||
|
shape === "start" && "rounded-l-md",
|
||||||
|
shape === "end" && "rounded-r-md",
|
||||||
|
day.key === todayKey && shape === "none" && "font-medium text-signal",
|
||||||
|
)}
|
||||||
|
onclick={() => onpick(day.key)}
|
||||||
|
onmouseenter={() => onhover?.(day.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{day.date.getDate()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+129
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CalendarDays from "@lucide/svelte/icons/calendar-days";
|
||||||
|
import Repeat from "@lucide/svelte/icons/repeat";
|
||||||
|
import { Popover } from "bits-ui";
|
||||||
|
import { dueLabel, dueTone, nextFriday, shiftDays, today } from "../lib/due";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import DayGrid from "./day-grid.svelte";
|
||||||
|
import type { DayTone } from "./day-grid.svelte";
|
||||||
|
import { useView } from "./context";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
field?: "due" | "scheduled";
|
||||||
|
onclear: () => void;
|
||||||
|
onpick: (key: string) => void;
|
||||||
|
onrepeat?: (rule: string | null) => void;
|
||||||
|
recurrence?: string | null;
|
||||||
|
value: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value,
|
||||||
|
recurrence = null,
|
||||||
|
onpick,
|
||||||
|
onclear,
|
||||||
|
onrepeat,
|
||||||
|
field = "due",
|
||||||
|
class: className,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const view = useView();
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
const tone = $derived(dueTone(value));
|
||||||
|
const label = $derived(dueLabel(value));
|
||||||
|
|
||||||
|
const RULES = [
|
||||||
|
{ label: "None", value: "" },
|
||||||
|
{ label: "Daily", value: "every day" },
|
||||||
|
{ label: "Weekly", value: "every week" },
|
||||||
|
{ label: "Monthly", value: "every month" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const QUICK = $derived([
|
||||||
|
{ label: "Today", key: today() },
|
||||||
|
{ label: "Tomorrow", key: shiftDays(today(), 1) },
|
||||||
|
{ label: "Friday", key: nextFriday() },
|
||||||
|
{ label: "In a week", key: shiftDays(today(), 7) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
function dayTone(key: string): DayTone {
|
||||||
|
return key === value ? "solo" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(key: string) {
|
||||||
|
onpick(key);
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Trigger
|
||||||
|
aria-label={field === "due" ? "Due" : "Scheduled"}
|
||||||
|
class={cn(
|
||||||
|
"inline-flex h-6 items-center gap-1 rounded-md px-1 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
tone === "overdue" && "text-destructive",
|
||||||
|
tone === "soon" && "text-status-snooze",
|
||||||
|
tone === "none" && "text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-nodrag
|
||||||
|
>
|
||||||
|
{#if recurrence}
|
||||||
|
<Repeat aria-hidden="true" class="size-3" />
|
||||||
|
{:else}
|
||||||
|
<CalendarDays aria-hidden="true" class="size-3" />
|
||||||
|
{/if}
|
||||||
|
{#if label}
|
||||||
|
<span class="tabular">{label}</span>
|
||||||
|
{/if}
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal to={view.portal()}>
|
||||||
|
<Popover.Content
|
||||||
|
align="start"
|
||||||
|
class="z-50 w-fit rounded-lg border border-border bg-popover p-2 text-popover-foreground shadow-lg outline-none"
|
||||||
|
sideOffset={6}
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-2 gap-1 pb-2">
|
||||||
|
{#each QUICK as quick (quick.label)}
|
||||||
|
<button
|
||||||
|
class="h-7 rounded-md border border-border px-2 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => pick(quick.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{quick.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DayGrid anchor={value} onpick={pick} tone={dayTone} />
|
||||||
|
|
||||||
|
{#if onrepeat}
|
||||||
|
{@const known = RULES.some((rule) => rule.value === (recurrence ?? ""))}
|
||||||
|
<div class="mt-2 border-border border-t pt-2">
|
||||||
|
<div class="grid grid-cols-4 gap-1">
|
||||||
|
{#each RULES as rule (rule.value)}
|
||||||
|
<button
|
||||||
|
aria-pressed={(recurrence ?? "") === rule.value}
|
||||||
|
class="h-7 rounded-md border border-border px-1 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-secondary aria-pressed:text-foreground"
|
||||||
|
onclick={() => onrepeat?.(rule.value || null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{rule.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if recurrence && !known}
|
||||||
|
<p class="pt-1.5 text-center text-muted-foreground text-[11px]">
|
||||||
|
Custom rule: {recurrence}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if value}
|
||||||
|
<button
|
||||||
|
class="mt-2 h-7 w-full rounded-md text-muted-foreground text-xs outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => {
|
||||||
|
onclear();
|
||||||
|
open = false;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Clear date
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { laneKey, parseLaneKey } from "../lib/keys";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import Picker from "./picker.svelte";
|
||||||
|
import { useView } from "./context";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
onpick: (path: string, project: string) => void;
|
||||||
|
path: string;
|
||||||
|
project: string;
|
||||||
|
trigger: Snippet<[{ open: boolean }]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { path, project, onpick, trigger, class: className }: Props = $props();
|
||||||
|
|
||||||
|
const renderTrigger = $derived(trigger);
|
||||||
|
|
||||||
|
const view = useView();
|
||||||
|
|
||||||
|
const options = $derived(
|
||||||
|
view.store.spheres.flatMap((sphere) =>
|
||||||
|
sphere.projects.map((name) => ({
|
||||||
|
label: name,
|
||||||
|
hint: sphere.name,
|
||||||
|
value: laneKey(sphere.path, name),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Picker
|
||||||
|
label="Move to"
|
||||||
|
onselect={(value) => {
|
||||||
|
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}
|
||||||
|
</Picker>
|
||||||
@@ -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<void> => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export interface MenuItem {
|
||||||
|
confirm?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
dot?: string;
|
||||||
|
hint?: string;
|
||||||
|
icon?: Component;
|
||||||
|
label: string;
|
||||||
|
onselect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MENU_CONTENT =
|
||||||
|
"z-50 w-56 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Component } from "svelte";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
close: () => void;
|
||||||
|
items: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { items, close }: Props = $props();
|
||||||
|
|
||||||
|
let armed = $state("");
|
||||||
|
|
||||||
|
function choose(item: MenuItem) {
|
||||||
|
if (item.confirm && armed !== item.label) {
|
||||||
|
armed = item.label;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
armed = "";
|
||||||
|
close();
|
||||||
|
item.onselect();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div role="presentation">
|
||||||
|
{#each items as item (item.label)}
|
||||||
|
{@const arming = armed === item.label}
|
||||||
|
<button
|
||||||
|
class={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-40",
|
||||||
|
item.danger && "text-destructive hover:bg-destructive/10",
|
||||||
|
arming && "bg-destructive/10 font-medium",
|
||||||
|
)}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onclick={() => choose(item)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if item.dot}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="size-2 shrink-0 rounded-full"
|
||||||
|
style="background: {item.dot}"
|
||||||
|
></span>
|
||||||
|
{:else if item.icon}
|
||||||
|
<item.icon aria-hidden="true" class="size-3.5 shrink-0" />
|
||||||
|
{/if}
|
||||||
|
<span class="min-w-0 flex-1 truncate">
|
||||||
|
{arming ? item.confirm : item.label}
|
||||||
|
</span>
|
||||||
|
{#if item.hint && !arming}
|
||||||
|
<span class="shrink-0 text-muted-foreground text-xs">{item.hint}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export interface PickerOption {
|
||||||
|
color?: string;
|
||||||
|
hint?: string;
|
||||||
|
icon?: Component;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Check from "@lucide/svelte/icons/check";
|
||||||
|
import { Popover } from "bits-ui";
|
||||||
|
import type { Component, Snippet } from "svelte";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { useView } from "./context";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
footer?: Snippet<[{ dismiss: () => void; query: string }]>;
|
||||||
|
label: string;
|
||||||
|
onselect: (value: string) => void;
|
||||||
|
open?: boolean;
|
||||||
|
options: PickerOption[];
|
||||||
|
searchable?: boolean;
|
||||||
|
title?: string;
|
||||||
|
trigger: Snippet<[{ open: boolean }]>;
|
||||||
|
triggerClass?: string;
|
||||||
|
value?: string | null;
|
||||||
|
width?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
options,
|
||||||
|
footer,
|
||||||
|
value = null,
|
||||||
|
onselect,
|
||||||
|
label,
|
||||||
|
trigger,
|
||||||
|
triggerClass,
|
||||||
|
searchable = false,
|
||||||
|
title,
|
||||||
|
width,
|
||||||
|
open: isOpen = $bindable(false),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const view = useView();
|
||||||
|
|
||||||
|
let query = $state("");
|
||||||
|
let active = $state(0);
|
||||||
|
|
||||||
|
const shown = $derived(
|
||||||
|
query.trim()
|
||||||
|
? options.filter((option) =>
|
||||||
|
option.label.toLowerCase().includes(query.trim().toLowerCase()),
|
||||||
|
)
|
||||||
|
: options,
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
query = "";
|
||||||
|
active = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function choose(option: PickerOption) {
|
||||||
|
onselect(option.value);
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
const delta = event.key === "ArrowDown" ? 1 : -1;
|
||||||
|
active = Math.max(0, Math.min(shown.length - 1, active + delta));
|
||||||
|
} else if (event.key === "Enter" && shown[active]) {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(shown[active]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open={isOpen}>
|
||||||
|
<Popover.Trigger
|
||||||
|
aria-label={label}
|
||||||
|
class={cn(
|
||||||
|
"inline-flex items-center outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
triggerClass,
|
||||||
|
)}
|
||||||
|
data-nodrag
|
||||||
|
{title}
|
||||||
|
>
|
||||||
|
{@render trigger({ open: isOpen })}
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal to={view.portal()}>
|
||||||
|
<Popover.Content
|
||||||
|
align="start"
|
||||||
|
class={cn(
|
||||||
|
"z-50 overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none",
|
||||||
|
width ?? (footer ? "w-72" : "w-56"),
|
||||||
|
)}
|
||||||
|
sideOffset={6}
|
||||||
|
>
|
||||||
|
{#if searchable}
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
autofocus
|
||||||
|
class="mb-1 h-8 w-full rounded-md bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
{onkeydown}
|
||||||
|
placeholder={label}
|
||||||
|
bind:value={query}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if options.length > 0}
|
||||||
|
<div class="max-h-64 overflow-y-auto">
|
||||||
|
{#each shown as option, index (option.value)}
|
||||||
|
<button
|
||||||
|
class={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted",
|
||||||
|
index === active && "bg-muted",
|
||||||
|
)}
|
||||||
|
onclick={() => choose(option)}
|
||||||
|
onmouseenter={() => {
|
||||||
|
active = index;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if option.icon}
|
||||||
|
<option.icon
|
||||||
|
aria-hidden="true"
|
||||||
|
class="size-4 shrink-0"
|
||||||
|
style={option.color ? `color: ${option.color}` : undefined}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||||
|
{#if option.hint}
|
||||||
|
<span class="shrink-0 text-muted-foreground text-xs tabular">
|
||||||
|
{option.hint}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if option.value === value}
|
||||||
|
<Check aria-hidden="true" class="size-3.5 shrink-0 text-signal" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="px-2 py-3 text-center text-muted-foreground text-xs">
|
||||||
|
Nothing found
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if footer}
|
||||||
|
<div class={options.length > 0 ? "mt-1 border-border border-t pt-1" : ""}>
|
||||||
|
{@render footer({
|
||||||
|
dismiss: () => {
|
||||||
|
isOpen = false;
|
||||||
|
},
|
||||||
|
query: query.trim(),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||||
|
import ChevronsDown from "@lucide/svelte/icons/chevrons-down";
|
||||||
|
import Flame from "@lucide/svelte/icons/flame";
|
||||||
|
import { isHot, PRIORITY_LABEL } from "../lib/vocab";
|
||||||
|
import type { Priority } from "../model/types";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
priority: Priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { priority, class: className }: Props = $props();
|
||||||
|
|
||||||
|
const bars = $derived(Math.max(0, priority - 2));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"inline-flex items-end gap-px",
|
||||||
|
className,
|
||||||
|
isHot(priority) && "text-destructive",
|
||||||
|
)}
|
||||||
|
title={PRIORITY_LABEL[priority]}
|
||||||
|
>
|
||||||
|
{#if isHot(priority)}
|
||||||
|
<Flame aria-hidden="true" class="size-3.5" />
|
||||||
|
{:else if priority === 1}
|
||||||
|
<ChevronDown aria-hidden="true" class="size-3.5" />
|
||||||
|
{:else if priority === 0}
|
||||||
|
<ChevronsDown aria-hidden="true" class="size-3.5" />
|
||||||
|
{:else}
|
||||||
|
{#each [3, 5, 7] as height, index (height)}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class={cn(
|
||||||
|
"w-[3px] rounded-[1px] bg-current transition-opacity",
|
||||||
|
index < bars ? "opacity-100" : "opacity-25",
|
||||||
|
)}
|
||||||
|
style="height: {height}px"
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
<span class="sr-only">{PRIORITY_LABEL[priority]}</span>
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { PRIORITY_LABEL, PRIORITY_ORDER } from "../lib/vocab";
|
||||||
|
import type { Priority } from "../model/types";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import Picker from "./picker.svelte";
|
||||||
|
import PriorityBars from "./priority-bars.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
onchange: (priority: Priority) => void;
|
||||||
|
priority: Priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { priority, onchange, class: className }: Props = $props();
|
||||||
|
|
||||||
|
const options = PRIORITY_ORDER.map((value) => ({
|
||||||
|
label: PRIORITY_LABEL[value],
|
||||||
|
value: String(value),
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Picker
|
||||||
|
label="Priority"
|
||||||
|
onselect={(value) => 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()}
|
||||||
|
<PriorityBars {priority} />
|
||||||
|
{/snippet}
|
||||||
|
</Picker>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Ellipsis from "@lucide/svelte/icons/ellipsis";
|
||||||
|
import { DropdownMenu } from "bits-ui";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import MenuBody, { MENU_CONTENT } from "./menu-body.svelte";
|
||||||
|
import type { MenuItem } from "./menu-body.svelte";
|
||||||
|
import { useView } from "./context";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
items: MenuItem[];
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { items, label = "Task menu", class: className }: Props = $props();
|
||||||
|
|
||||||
|
const view = useView();
|
||||||
|
let open = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenu.Root bind:open>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
aria-label={label}
|
||||||
|
class={cn(
|
||||||
|
"grid size-5 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-nodrag
|
||||||
|
>
|
||||||
|
<Ellipsis aria-hidden="true" class="size-3.5" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal to={view.portal()}>
|
||||||
|
<DropdownMenu.Content align="end" class={MENU_CONTENT} sideOffset={4}>
|
||||||
|
<MenuBody
|
||||||
|
close={() => {
|
||||||
|
open = false;
|
||||||
|
}}
|
||||||
|
{items}
|
||||||
|
/>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import { labelHue } from "../lib/hue";
|
||||||
|
import { dropZone } from "../lib/dnd.svelte";
|
||||||
|
import { laneKey } from "../lib/keys";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { dropTarget } from "./drop";
|
||||||
|
import { useView } from "./context";
|
||||||
|
|
||||||
|
const view = useView();
|
||||||
|
const store = view.store;
|
||||||
|
|
||||||
|
let expanded = $state<Set<string>>(new Set());
|
||||||
|
const zone = $derived(dropZone());
|
||||||
|
|
||||||
|
function toggle(path: string) {
|
||||||
|
const next = new Set(expanded);
|
||||||
|
if (next.has(path)) next.delete(path);
|
||||||
|
else next.add(path);
|
||||||
|
expanded = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSphere(path: string) {
|
||||||
|
store.sphereFilter = store.sphereFilter === path ? null : path;
|
||||||
|
store.projectFilter = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectProject(path: string, project: string) {
|
||||||
|
store.sphereFilter = path;
|
||||||
|
store.projectFilter = store.projectFilter === project ? null : project;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
class="flex w-52 shrink-0 flex-col overflow-y-auto border-border border-r bg-sidebar/50 py-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-current={store.sphereFilter === null}
|
||||||
|
class="mx-2 flex h-7 items-center rounded-md px-2 text-left font-medium text-xs outline-none transition-colors hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-secondary"
|
||||||
|
onclick={() => {
|
||||||
|
store.sphereFilter = null;
|
||||||
|
store.projectFilter = null;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="flex-1 truncate">All spheres</span>
|
||||||
|
<span class="text-muted-foreground tabular">{store.all.length}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#each store.spheres as sphere (sphere.path)}
|
||||||
|
{@const open = expanded.has(sphere.path)}
|
||||||
|
{@const target = dropTarget.sphere(sphere.path)}
|
||||||
|
<div class="mt-1 px-2">
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"flex h-7 items-center gap-0.5 rounded-md transition-colors",
|
||||||
|
zone?.target === target && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
|
||||||
|
)}
|
||||||
|
data-drop={target}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label={open ? "Collapse" : "Expand"}
|
||||||
|
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => toggle(sphere.path)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden="true"
|
||||||
|
class={cn("size-3 transition-transform", open && "rotate-90")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-current={store.sphereFilter === sphere.path &&
|
||||||
|
store.projectFilter === null}
|
||||||
|
class="flex h-7 min-w-0 flex-1 items-center rounded-md px-1 text-left font-medium text-xs outline-none transition-colors hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-secondary"
|
||||||
|
onclick={() => selectSphere(sphere.path)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{sphere.name}</span>
|
||||||
|
<span class="shrink-0 text-muted-foreground tabular">
|
||||||
|
{store.inScopeCount.byPath.get(sphere.path) ?? 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="flex flex-col pt-0.5 pl-5">
|
||||||
|
{#each sphere.projects as project (project)}
|
||||||
|
{@const laneTarget = dropTarget.lane(sphere.path, project)}
|
||||||
|
<button
|
||||||
|
aria-current={store.sphereFilter === sphere.path &&
|
||||||
|
store.projectFilter === project}
|
||||||
|
class={cn(
|
||||||
|
"flex h-6 items-center gap-1.5 rounded-md px-1 text-left text-xs outline-none transition-colors hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-secondary",
|
||||||
|
zone?.target === laneTarget && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
|
||||||
|
)}
|
||||||
|
data-drop={laneTarget}
|
||||||
|
onclick={() => selectProject(sphere.path, project)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="size-1.5 shrink-0 rounded-full"
|
||||||
|
style="background: oklch(0.6 0.12 {labelHue(project)})"
|
||||||
|
></span>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{project}</span>
|
||||||
|
<span class="shrink-0 text-muted-foreground tabular">
|
||||||
|
{store.inScopeCount.byProject.get(laneKey(sphere.path, project)) ?? 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { STATUS_META } from "../lib/vocab";
|
||||||
|
import { STATUS_ORDER } from "../model/types";
|
||||||
|
import type { Status } from "../model/types";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import Picker from "./picker.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
onchange: (status: Status) => void;
|
||||||
|
showLabel?: boolean;
|
||||||
|
status: Status;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { status, onchange, showLabel = false, class: className }: Props =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const options = STATUS_ORDER.map((symbol) => ({
|
||||||
|
color: STATUS_META[symbol].color,
|
||||||
|
icon: STATUS_META[symbol].icon,
|
||||||
|
label: STATUS_META[symbol].label,
|
||||||
|
value: symbol,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const meta = $derived(STATUS_META[status]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Picker
|
||||||
|
label="Status"
|
||||||
|
onselect={(value) => 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()}
|
||||||
|
<meta.icon
|
||||||
|
aria-hidden="true"
|
||||||
|
class="size-4 shrink-0"
|
||||||
|
style="color: {meta.color}"
|
||||||
|
/>
|
||||||
|
{#if showLabel}
|
||||||
|
<span class="truncate text-sm">{meta.label}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="sr-only">Status: {meta.label}</span>
|
||||||
|
{/snippet}
|
||||||
|
</Picker>
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
||||||
|
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import Archive from "@lucide/svelte/icons/archive";
|
||||||
|
import Plus from "@lucide/svelte/icons/plus";
|
||||||
|
import { anchorDate, isOpen } from "../model/types";
|
||||||
|
import type { Task } from "../model/types";
|
||||||
|
import type { DaySlot } from "../vault/store.svelte";
|
||||||
|
import { draggable, dropZone } from "../lib/dnd.svelte";
|
||||||
|
import { labelHue } from "../lib/hue";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { STATUS_META, WEEKDAYS } from "../lib/vocab";
|
||||||
|
import { createTask, setStatus } from "../vault/mutate";
|
||||||
|
import ContextArea from "./context-area.svelte";
|
||||||
|
import RowMenu from "./row-menu.svelte";
|
||||||
|
import { dropTarget, handleDrop } from "./drop";
|
||||||
|
import { followLink, markdown } from "./markdown";
|
||||||
|
import { taskMenu } from "./task-menu";
|
||||||
|
import { useView } from "./context";
|
||||||
|
|
||||||
|
const view = useView();
|
||||||
|
const store = view.store;
|
||||||
|
|
||||||
|
let addingDay = $state<string | null>(null);
|
||||||
|
let dayTitle = $state("");
|
||||||
|
|
||||||
|
const zone = $derived(dropZone());
|
||||||
|
|
||||||
|
const scope = $derived.by(() => {
|
||||||
|
const sphere =
|
||||||
|
store.spheres.find((item) => item.path === store.sphereFilter) ??
|
||||||
|
store.spheres[0];
|
||||||
|
if (!sphere) return null;
|
||||||
|
const project =
|
||||||
|
(store.projectFilter && sphere.projects.includes(store.projectFilter)
|
||||||
|
? store.projectFilter
|
||||||
|
: undefined) ?? sphere.projects[0];
|
||||||
|
return project ? { path: sphere.path, project, sphere: sphere.name } : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function addOnDay(key: string) {
|
||||||
|
const title = dayTitle.trim();
|
||||||
|
dayTitle = "";
|
||||||
|
addingDay = null;
|
||||||
|
if (!title || !scope) return;
|
||||||
|
await createTask(view.app, scope.path, scope.project, title, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focus(node: HTMLInputElement) {
|
||||||
|
node.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet card(task: Task)}
|
||||||
|
{@const meta = STATUS_META[task.status]}
|
||||||
|
<ContextArea class="contents" items={taskMenu(view, task)}>
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"group/card flex min-w-0 cursor-grab select-none flex-col gap-1 rounded-md border border-border bg-card p-1.5 transition-colors hover:border-border/90 hover:bg-secondary/40",
|
||||||
|
!anchorDate(task) && "border-dashed hover:border-solid",
|
||||||
|
!isOpen(task.status) && "opacity-60 hover:opacity-100",
|
||||||
|
)}
|
||||||
|
use:draggable={{
|
||||||
|
payload: () => ({ id: task.id, kind: "task" }),
|
||||||
|
ondrop: (to, after) => void handleDrop(view, task, to, after),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-start gap-1">
|
||||||
|
<button
|
||||||
|
aria-label={`Status: ${meta.label}`}
|
||||||
|
class="grid size-4 shrink-0 place-items-center rounded outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
data-nodrag
|
||||||
|
onclick={() =>
|
||||||
|
void setStatus(view.app, task, isOpen(task.status) ? "x" : " ")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<meta.icon aria-hidden="true" class="size-3.5" style="color: {meta.color}" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"bcal-md line-clamp-2 min-w-0 flex-1 font-medium text-xs leading-snug",
|
||||||
|
!isOpen(task.status) && "line-through decoration-muted-foreground/50",
|
||||||
|
)}
|
||||||
|
onclick={(event) => 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,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<RowMenu
|
||||||
|
class="size-4 opacity-0 transition-opacity group-hover/card:opacity-100 touch-shown"
|
||||||
|
items={taskMenu(view, task)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-1 pl-5">
|
||||||
|
<span
|
||||||
|
class="max-w-20 shrink-0 truncate rounded border border-border bg-secondary/50 px-1 text-[10px] text-muted-foreground leading-[1.4]"
|
||||||
|
>
|
||||||
|
{task.sphere}
|
||||||
|
</span>
|
||||||
|
{#if task.inArchive}
|
||||||
|
<span
|
||||||
|
class="inline-flex max-w-24 shrink-0 items-center gap-0.5 truncate rounded border border-border border-dashed px-1 text-[10px] text-muted-foreground leading-[1.4]"
|
||||||
|
>
|
||||||
|
<Archive aria-hidden="true" class="size-2.5" />
|
||||||
|
{task.project}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="hue-chip max-w-24 truncate rounded border px-1 text-[10px] leading-[1.4]"
|
||||||
|
style="--hue: {labelHue(task.project)}"
|
||||||
|
>
|
||||||
|
{task.project}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ContextArea>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet dayCell(day: DaySlot)}
|
||||||
|
{@const target = dropTarget.day(day.key)}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"group/day flex min-h-[7rem] min-w-0 flex-col gap-1 border-border/40 border-r border-b p-1.5 transition-colors last:border-r-0",
|
||||||
|
day.inMonth ? "bg-transparent" : "bg-secondary/25",
|
||||||
|
day.isToday && "bg-primary/[0.06]",
|
||||||
|
zone?.target === target &&
|
||||||
|
"bg-secondary/70 inset-ring-2 inset-ring-signal/70",
|
||||||
|
)}
|
||||||
|
data-drop={target}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"rounded-md px-1 text-xs tabular",
|
||||||
|
day.isToday && "bg-primary font-semibold text-primary-foreground",
|
||||||
|
!day.inMonth && "text-muted-foreground/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day.date.getDate()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-label="Add task"
|
||||||
|
class="ml-auto grid size-5 place-items-center rounded-md text-muted-foreground opacity-0 outline-none transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover/day:opacity-100 touch-shown"
|
||||||
|
onclick={() => {
|
||||||
|
addingDay = day.key;
|
||||||
|
dayTitle = "";
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if addingDay === day.key}
|
||||||
|
<input
|
||||||
|
class="h-6 w-full shrink-0 rounded-md border border-ring bg-background px-1.5 text-xs outline-none"
|
||||||
|
onblur={() => 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}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div class="flex shrink-0 items-center gap-1 px-3 py-2">
|
||||||
|
<h2 class="font-semibold text-sm first-letter:uppercase">
|
||||||
|
{store.monthLabel}
|
||||||
|
</h2>
|
||||||
|
<div class="ml-auto flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
aria-label="Previous month"
|
||||||
|
class="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => store.stepMonth(-1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden="true" class="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="h-7 rounded-4xl border border-border px-2.5 font-medium text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => store.goToday()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Next month"
|
||||||
|
class="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => store.stepMonth(1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronRight aria-hidden="true" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto @min-[46rem]:hidden">
|
||||||
|
{#each store.agendaDays as day (day.key)}
|
||||||
|
<div class="px-3">
|
||||||
|
<h3
|
||||||
|
class="sticky top-0 z-10 flex items-center gap-2 bg-background/95 py-1.5 font-medium text-muted-foreground text-xs backdrop-blur"
|
||||||
|
data-drop={dropTarget.day(day.key)}
|
||||||
|
>
|
||||||
|
<span class={cn("tabular", day.isToday && "text-signal")}>
|
||||||
|
{day.date.toLocaleDateString("en-US", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
weekday: "short",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span class="ml-auto tabular">{day.tasks.length}</span>
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-col gap-1.5 pb-2">
|
||||||
|
{#each day.tasks as task (task.id)}
|
||||||
|
{@render card(task)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="px-3 py-6 text-center text-muted-foreground text-xs">
|
||||||
|
Nothing scheduled this month
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden min-h-0 flex-1 flex-col px-3 pb-3 @min-[46rem]:flex">
|
||||||
|
<div
|
||||||
|
class="grid shrink-0 grid-cols-7 border-border border-b pb-1.5 text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
{#each WEEKDAYS as weekday (weekday)}
|
||||||
|
<span class="text-center">{weekday}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="min-h-0 flex-1 overflow-y-auto rounded-b-lg border-border border-x border-b"
|
||||||
|
>
|
||||||
|
{#each store.monthWeeks as week, index (index)}
|
||||||
|
<div class="grid grid-cols-7">
|
||||||
|
{#each week as day (day.key)}
|
||||||
|
{@render dayCell(day)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if store.undated.length > 0}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"shrink-0 border-border border-t bg-sidebar/40 transition-colors",
|
||||||
|
zone?.target === dropTarget.undated && "bg-secondary/70",
|
||||||
|
)}
|
||||||
|
data-drop={dropTarget.undated}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-expanded={!store.undatedCollapsed}
|
||||||
|
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left font-medium text-muted-foreground text-xs outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => {
|
||||||
|
store.undatedCollapsed = !store.undatedCollapsed;
|
||||||
|
store.savePrefs();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden="true"
|
||||||
|
class={cn(
|
||||||
|
"size-3 transition-transform",
|
||||||
|
!store.undatedCollapsed && "rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>No date</span>
|
||||||
|
<span class="tabular">{store.undated.length}</span>
|
||||||
|
</button>
|
||||||
|
{#if !store.undatedCollapsed}
|
||||||
|
<div
|
||||||
|
class="flex max-h-44 flex-wrap gap-1.5 overflow-y-auto px-3 pt-0.5 pb-2"
|
||||||
|
>
|
||||||
|
{#each store.undated as task (task.id)}
|
||||||
|
<div class="w-48">{@render card(task)}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { dropZone } from "../lib/dnd.svelte";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import TaskRow from "./task-row.svelte";
|
||||||
|
import { useView } from "./context";
|
||||||
|
|
||||||
|
const view = useView();
|
||||||
|
const store = view.store;
|
||||||
|
|
||||||
|
const zone = $derived(dropZone());
|
||||||
|
|
||||||
|
function groupTarget(key: string): string {
|
||||||
|
return `group:${key}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{#each store.groups as group (group.key)}
|
||||||
|
<section>
|
||||||
|
<h2
|
||||||
|
class={cn(
|
||||||
|
"sticky top-0 z-10 flex items-center gap-2 border-border/60 border-b bg-background/95 px-3 py-1.5 font-medium text-muted-foreground text-xs backdrop-blur transition-colors",
|
||||||
|
zone?.target === groupTarget(group.key) && "bg-secondary/70",
|
||||||
|
)}
|
||||||
|
data-drop={groupTarget(group.key)}
|
||||||
|
>
|
||||||
|
<span class="truncate">{group.label}</span>
|
||||||
|
{#if group.hint}
|
||||||
|
<span class="truncate text-muted-foreground/60">{group.hint}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="ml-auto tabular">{group.tasks.length}</span>
|
||||||
|
</h2>
|
||||||
|
{#each group.tasks as task (task.id)}
|
||||||
|
<TaskRow
|
||||||
|
showProject={store.groupBy !== "project"}
|
||||||
|
showSphere={store.groupBy !== "sphere"}
|
||||||
|
{task}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<p class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||||
|
{store.search.trim() ? "Nothing matches" : "No tasks"}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Archive from "@lucide/svelte/icons/archive";
|
||||||
|
import { anchorDate, isOpen } from "../model/types";
|
||||||
|
import type { Task } from "../model/types";
|
||||||
|
import { draggable, dropZone } from "../lib/dnd.svelte";
|
||||||
|
import { labelHue } from "../lib/hue";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { STATUS_META } from "../lib/vocab";
|
||||||
|
import {
|
||||||
|
moveTask,
|
||||||
|
setDate,
|
||||||
|
setRecurrence,
|
||||||
|
setStatus,
|
||||||
|
setText,
|
||||||
|
} from "../vault/mutate";
|
||||||
|
import ContextArea from "./context-area.svelte";
|
||||||
|
import DuePicker from "./due-picker.svelte";
|
||||||
|
import LanePicker from "./lane-picker.svelte";
|
||||||
|
import PriorityBars from "./priority-bars.svelte";
|
||||||
|
import RowMenu from "./row-menu.svelte";
|
||||||
|
import { dropTarget, handleDrop } from "./drop";
|
||||||
|
import { followLink, markdown } from "./markdown";
|
||||||
|
import { taskMenu } from "./task-menu";
|
||||||
|
import { useView } from "./context";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showProject?: boolean;
|
||||||
|
showSphere?: boolean;
|
||||||
|
task: Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { task, showSphere = true, showProject = true }: Props = $props();
|
||||||
|
|
||||||
|
const view = useView();
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
let draft = $state("");
|
||||||
|
|
||||||
|
const meta = $derived(STATUS_META[task.status]);
|
||||||
|
const target = $derived(dropTarget.task(task.id));
|
||||||
|
const zone = $derived(dropZone());
|
||||||
|
const isDropTarget = $derived(zone?.target === target);
|
||||||
|
const date = $derived(anchorDate(task));
|
||||||
|
const field = $derived(!task.due && task.scheduled ? "scheduled" : "due");
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
draft = task.text;
|
||||||
|
editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitEdit() {
|
||||||
|
if (!editing) return;
|
||||||
|
editing = false;
|
||||||
|
await setText(view.app, task, draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
void commitEdit();
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
await setStatus(view.app, task, isOpen(task.status) ? "x" : " ");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ContextArea class="contents" items={taskMenu(view, task)}>
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"group/task grid select-none items-center gap-2 border-border/60 border-b px-3 transition-colors hover:bg-secondary/50",
|
||||||
|
!isOpen(task.status) && "opacity-60 hover:opacity-100",
|
||||||
|
isDropTarget &&
|
||||||
|
(zone?.after
|
||||||
|
? "shadow-[inset_0_-2px_0_0_var(--signal)]"
|
||||||
|
: "shadow-[inset_0_2px_0_0_var(--signal)]"),
|
||||||
|
)}
|
||||||
|
data-drop={target}
|
||||||
|
style="grid-template-columns: 1.25rem minmax(0, 1fr) auto;"
|
||||||
|
use:draggable={{
|
||||||
|
payload: () => ({ id: task.id, kind: "task" }),
|
||||||
|
ondrop: (to, after) => void handleDrop(view, task, to, after),
|
||||||
|
enabled: () => !editing,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label={`Status: ${meta.label}`}
|
||||||
|
class="grid size-5 place-items-center rounded-md outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
data-nodrag
|
||||||
|
onclick={toggle}
|
||||||
|
title={meta.label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<meta.icon aria-hidden="true" class="size-4" style="color: {meta.color}" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 items-center gap-1.5 py-2.5">
|
||||||
|
{#if showSphere}
|
||||||
|
<span
|
||||||
|
class="hidden shrink-0 rounded-md border border-border bg-secondary/40 px-1.5 py-0.5 font-medium text-[11px] text-muted-foreground @min-[30rem]:inline-block"
|
||||||
|
>
|
||||||
|
{task.sphere}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if task.inArchive}
|
||||||
|
<span
|
||||||
|
class="hidden shrink-0 items-center gap-1 rounded-md border border-border border-dashed px-1.5 py-0.5 text-[11px] text-muted-foreground @min-[24rem]:inline-flex"
|
||||||
|
title={task.project}
|
||||||
|
>
|
||||||
|
<Archive aria-hidden="true" class="size-3" />
|
||||||
|
{task.project}
|
||||||
|
</span>
|
||||||
|
{:else if showProject}
|
||||||
|
<LanePicker
|
||||||
|
class="hidden shrink-0 @min-[24rem]:inline-flex"
|
||||||
|
onpick={(path, project) =>
|
||||||
|
void moveTask(view.app, task, { path, project })}
|
||||||
|
path={task.path}
|
||||||
|
project={task.project}
|
||||||
|
>
|
||||||
|
{#snippet trigger()}
|
||||||
|
<span
|
||||||
|
class="hue-chip max-w-32 truncate rounded-md border px-1.5 py-0.5 text-[11px]"
|
||||||
|
style="--hue: {labelHue(task.project)}"
|
||||||
|
>
|
||||||
|
{task.project}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</LanePicker>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editing}
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
autofocus
|
||||||
|
class="min-w-0 flex-1 rounded-md border border-ring bg-background px-1.5 py-0.5 text-sm outline-none"
|
||||||
|
onblur={commitEdit}
|
||||||
|
{onkeydown}
|
||||||
|
bind:value={draft}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"bcal-md min-w-0 flex-1 cursor-text truncate font-medium text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
!isOpen(task.status) && "line-through decoration-muted-foreground/50",
|
||||||
|
)}
|
||||||
|
onclick={(event) => {
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#if task.priority !== 2}
|
||||||
|
<PriorityBars class="shrink-0" priority={task.priority} />
|
||||||
|
{/if}
|
||||||
|
<div class="flex justify-end @min-[22rem]:w-24">
|
||||||
|
<DuePicker
|
||||||
|
class={cn(
|
||||||
|
!date &&
|
||||||
|
"opacity-0 transition-opacity group-hover/task:opacity-100 focus-visible:opacity-100 touch-shown",
|
||||||
|
)}
|
||||||
|
{field}
|
||||||
|
onclear={() => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<RowMenu
|
||||||
|
class="opacity-0 transition-opacity focus-visible:opacity-100 group-hover/task:opacity-100 touch-shown"
|
||||||
|
items={taskMenu(view, task)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ContextArea>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ArrowDownWideNarrow from "@lucide/svelte/icons/arrow-down-wide-narrow";
|
||||||
|
import CalendarDays from "@lucide/svelte/icons/calendar-days";
|
||||||
|
import CircleCheck from "@lucide/svelte/icons/circle-check";
|
||||||
|
import Group from "@lucide/svelte/icons/group";
|
||||||
|
import List from "@lucide/svelte/icons/list";
|
||||||
|
import PanelLeft from "@lucide/svelte/icons/panel-left";
|
||||||
|
import Plus from "@lucide/svelte/icons/plus";
|
||||||
|
import Search from "@lucide/svelte/icons/search";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import type { GroupBy, SortBy, ViewMode } from "../settings";
|
||||||
|
import Picker from "./picker.svelte";
|
||||||
|
import { useView } from "./context";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
oncreate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { oncreate }: Props = $props();
|
||||||
|
|
||||||
|
const view = useView();
|
||||||
|
const store = view.store;
|
||||||
|
|
||||||
|
const VIEWS: { icon: typeof List; id: ViewMode; label: string }[] = [
|
||||||
|
{ icon: List, id: "list", label: "List" },
|
||||||
|
{ icon: CalendarDays, id: "calendar", label: "Calendar" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUPS: { label: string; value: GroupBy }[] = [
|
||||||
|
{ label: "Spheres", value: "sphere" },
|
||||||
|
{ label: "Projects", value: "project" },
|
||||||
|
{ label: "Status", value: "status" },
|
||||||
|
{ label: "Due", value: "due" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORTS = $derived(
|
||||||
|
[
|
||||||
|
{ label: "Manual", value: "manual" as SortBy },
|
||||||
|
{ label: "Priority", value: "priority" as SortBy },
|
||||||
|
{ label: "Due", value: "due" as SortBy },
|
||||||
|
].filter((option) => store.view === "list" || option.value !== "due"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const PILL =
|
||||||
|
"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-4xl border border-border px-2.5 font-medium text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center gap-2 overflow-x-auto border-border border-b px-3 py-2 scrollbar-none"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="Toggle spheres"
|
||||||
|
aria-pressed={!store.railCollapsed}
|
||||||
|
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
||||||
|
onclick={() => {
|
||||||
|
store.railCollapsed = !store.railCollapsed;
|
||||||
|
store.savePrefs();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<PanelLeft aria-hidden="true" class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center gap-0.5 rounded-4xl border border-border p-0.5"
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
|
{#each VIEWS as mode (mode.id)}
|
||||||
|
<button
|
||||||
|
aria-selected={store.view === mode.id}
|
||||||
|
class="inline-flex h-6 items-center gap-1.5 rounded-4xl px-2.5 font-medium text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring aria-selected:bg-primary aria-selected:text-primary-foreground"
|
||||||
|
onclick={() => {
|
||||||
|
store.view = mode.id;
|
||||||
|
store.savePrefs();
|
||||||
|
}}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<mode.icon aria-hidden="true" class="size-3.5" />
|
||||||
|
<span class="hidden @min-[34rem]:inline">{mode.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if store.view === "list"}
|
||||||
|
<Picker
|
||||||
|
label="Group by"
|
||||||
|
onselect={(value) => {
|
||||||
|
store.groupBy = value as GroupBy;
|
||||||
|
store.savePrefs();
|
||||||
|
}}
|
||||||
|
options={GROUPS}
|
||||||
|
triggerClass={PILL}
|
||||||
|
value={store.groupBy}
|
||||||
|
>
|
||||||
|
{#snippet trigger()}
|
||||||
|
<Group aria-hidden="true" class="size-3.5" />
|
||||||
|
<span class="hidden @min-[40rem]:inline">
|
||||||
|
{GROUPS.find((item) => item.value === store.groupBy)?.label}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Picker>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Picker
|
||||||
|
label="Sort by"
|
||||||
|
onselect={(value) => {
|
||||||
|
store.sortBy = value as SortBy;
|
||||||
|
}}
|
||||||
|
options={SORTS}
|
||||||
|
triggerClass={PILL}
|
||||||
|
value={store.sortBy}
|
||||||
|
>
|
||||||
|
{#snippet trigger()}
|
||||||
|
<ArrowDownWideNarrow aria-hidden="true" class="size-3.5" />
|
||||||
|
<span class="hidden @min-[40rem]:inline">
|
||||||
|
{SORTS.find((item) => item.value === store.sortBy)?.label}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Picker>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="Show completed"
|
||||||
|
aria-pressed={store.showDone}
|
||||||
|
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
||||||
|
onclick={() => {
|
||||||
|
store.showDone = !store.showDone;
|
||||||
|
store.savePrefs();
|
||||||
|
}}
|
||||||
|
title="Show completed"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<CircleCheck aria-hidden="true" class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-4xl border border-border px-2.5 focus-within:ring-2 focus-within:ring-ring"
|
||||||
|
>
|
||||||
|
<Search aria-hidden="true" class="size-3.5 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
class="w-24 min-w-0 bg-transparent text-xs outline-none transition-[width] placeholder:text-muted-foreground focus:w-40"
|
||||||
|
placeholder="Search"
|
||||||
|
type="text"
|
||||||
|
bind:value={store.search}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="New task"
|
||||||
|
class={cn(PILL, "ml-auto bg-primary text-primary-foreground hover:bg-primary/90")}
|
||||||
|
onclick={oncreate}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" class="size-3.5" />
|
||||||
|
<span class="hidden @min-[34rem]:inline">Task</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
await edit(app, task.path, (lines) => {
|
||||||
|
replaceLine(lines, task, serializeTaskLine({ ...task, [field]: value }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setText(
|
||||||
|
app: App,
|
||||||
|
task: Task,
|
||||||
|
text: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await edit(app, task.path, (lines) => {
|
||||||
|
replaceLine(lines, task, serializeTaskLine({ ...task, priority }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRecurrence(
|
||||||
|
app: App,
|
||||||
|
task: Task,
|
||||||
|
recurrence: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
await edit(app, task.path, (lines) => {
|
||||||
|
replaceLine(lines, task, serializeTaskLine({ ...task, recurrence }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStatus(
|
||||||
|
app: App,
|
||||||
|
task: Task,
|
||||||
|
status: Status,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await edit(app, path, (lines) => {
|
||||||
|
insertBlock(lines, path, { project }, raw.split("\n"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveDone(
|
||||||
|
app: App,
|
||||||
|
path: string,
|
||||||
|
archiveHeading: string,
|
||||||
|
): Promise<number> {
|
||||||
|
let moved = 0;
|
||||||
|
await edit(app, path, (lines) => {
|
||||||
|
moved = applyArchive(lines, path, archiveHeading);
|
||||||
|
});
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
@@ -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<Status, string> = {
|
||||||
|
" ": "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<string, Board>();
|
||||||
|
private reloadTimer: number | null = null;
|
||||||
|
private readonly pending = new Set<string>();
|
||||||
|
|
||||||
|
version = $state(0);
|
||||||
|
view = $state<ViewMode>("list");
|
||||||
|
groupBy = $state<GroupBy>("sphere");
|
||||||
|
listSortBy = $state<SortBy>("due");
|
||||||
|
calendarSortBy = $state<SortBy>("priority");
|
||||||
|
showDone = $state(false);
|
||||||
|
includeArchive = $state(false);
|
||||||
|
railCollapsed = $state(false);
|
||||||
|
undatedCollapsed = $state(false);
|
||||||
|
search = $state("");
|
||||||
|
sphereFilter = $state<string | null>(null);
|
||||||
|
projectFilter = $state<string | null>(null);
|
||||||
|
statusFilter = $state<Status[]>([]);
|
||||||
|
monthKey = $state(localKey(new Date()));
|
||||||
|
selected = $state<string | null>(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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<Task[]>(() => {
|
||||||
|
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<Task[]>(() =>
|
||||||
|
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<string, number>();
|
||||||
|
const byProject = new Map<string, number>();
|
||||||
|
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<Group[]>(() => {
|
||||||
|
const buckets = new Map<string, Group>();
|
||||||
|
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<Group, "tasks" | "lane"> {
|
||||||
|
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<string, Task[]>();
|
||||||
|
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<DaySlot[]>(() => {
|
||||||
|
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<DaySlot[][]>(() => {
|
||||||
|
const weeks: DaySlot[][] = [];
|
||||||
|
for (let i = 0; i < 42; i += 7) weeks.push(this.monthDays.slice(i, i + 7));
|
||||||
|
return weeks;
|
||||||
|
});
|
||||||
|
|
||||||
|
agendaDays = $derived.by<DaySlot[]>(() =>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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<string, unknown> | 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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (this.root) {
|
||||||
|
await unmount(this.root);
|
||||||
|
this.root = null;
|
||||||
|
}
|
||||||
|
this.contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, Task[]>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Vendored
+17
@@ -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]}
|
||||||
|
```
|
||||||
|
%%
|
||||||
Vendored
+34
@@ -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]}
|
||||||
|
```
|
||||||
|
%%
|
||||||
Vendored
+24
@@ -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]}
|
||||||
|
```
|
||||||
|
%%
|
||||||
Vendored
+43
@@ -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]}
|
||||||
|
```
|
||||||
|
%%
|
||||||
@@ -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, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkdownRenderer = {
|
||||||
|
async render(
|
||||||
|
_app: unknown,
|
||||||
|
markdown: string,
|
||||||
|
el: HTMLElement,
|
||||||
|
_sourcePath: string,
|
||||||
|
_component: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
const html = escapeHtml(markdown)
|
||||||
|
.replace(
|
||||||
|
/!?\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
|
||||||
|
(_full, target: string, alias?: string) =>
|
||||||
|
`<a class="internal-link" href="${target}">${alias ?? target}</a>`,
|
||||||
|
)
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||||
|
.replace(
|
||||||
|
/(^|\s)(https?:\/\/[^\s]+)/g,
|
||||||
|
(_full, lead: string, url: string) =>
|
||||||
|
`${lead}<a class="external-link" href="${url}">${url}</a>`,
|
||||||
|
);
|
||||||
|
el.innerHTML = `<p>${html}</p>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+203
@@ -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("<!doctype html><html><body></body></html>", {
|
||||||
|
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);
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"0.1.0": "1.5.0"
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user