Compare commits
10
Commits
0a7742eb5e
...
b415b17007
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b415b17007 | ||
|
|
fa36710f0c | ||
|
|
f980abcc1d | ||
|
|
f7ccd21d7b | ||
|
|
b70331f7bd | ||
|
|
9c0e57e29b | ||
|
|
0cc4655f9f | ||
|
|
d692dff19a | ||
|
|
4f485b0bc4 | ||
|
|
8c971e431a |
No files matched your search
@@ -5,6 +5,7 @@ node_modules/
|
|||||||
# vault by `make install`.
|
# vault by `make install`.
|
||||||
main.js
|
main.js
|
||||||
styles.css
|
styles.css
|
||||||
|
.tailwind.css
|
||||||
*.zip
|
*.zip
|
||||||
|
|
||||||
# Obsidian writes plugin settings here when the repo is symlinked into a
|
# Obsidian writes plugin settings here when the repo is symlinked into a
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
# beaver-calendar
|
# beaver-calendar
|
||||||
[](https://sladge.net)
|
[](https://sladge.net)
|
||||||
|
|
||||||
Calendar and task list for Obsidian Kanban+Tasks
|
A calendar and a task list for Obsidian on top of Kanban boards and the Tasks plugin. A sphere is a board file, a project is an h2 inside it; every task keeps living in its board, the plugin only shows them together - by deadline, by sphere, or on a month grid - and lets you drag, edit and close them without opening the board.
|
||||||
|
|
||||||
Sphere - md-file in Kanban boards directory. Project - h2 inside the file.
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Archiving
|
||||||
|
|
||||||
|
"Archive completed tasks" empties every board of what is finished with it, into
|
||||||
|
a mirror of the boards folder: `Boards/work.md` archives into
|
||||||
|
`Boards/archive/work.md`, itself a Kanban board with the same lanes, so the
|
||||||
|
history opens normally and the board stays short. The same pass lifts a board's
|
||||||
|
old native archive - everything below the `***` separator - into that mirror,
|
||||||
|
headings and all, so nothing is left behind in two places.
|
||||||
|
|
||||||
|
Set "Archive folder" before the first run: renaming it later leaves the mirrors
|
||||||
|
already written behind, where they read as ordinary boards.
|
||||||
|
|
||||||
|
Archived tasks render alongside live ones when both "Include the archive" and
|
||||||
|
"Show completed" are on - from the mirror files and from any `***` section still
|
||||||
|
sitting in a board. They carry an Archive badge instead of a project chip, ride
|
||||||
|
with the sphere they came from in the rail, and are not drop targets.
|
||||||
|
|
||||||
## Build and install
|
## Build and install
|
||||||
|
|
||||||
@@ -18,3 +37,19 @@ make zip # distributable
|
|||||||
```
|
```
|
||||||
|
|
||||||
After `make install` turn on plugin in settings.
|
After `make install` turn on plugin in settings.
|
||||||
|
|
||||||
|
`styles.css` is Tailwind output run through `scripts/scope-css.mjs`: every
|
||||||
|
selector is prefixed with `:is(.bcal-root, .bcal-root *)`. Other plugins built
|
||||||
|
with Tailwind put their utilities into the same `utilities` cascade layer, and
|
||||||
|
without the extra specificity whichever plugin loaded last would win (their
|
||||||
|
`.hidden` over our `@container` variants, for one).
|
||||||
|
|
||||||
|
Preview knobs, all optional:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VAULT=/path/to/vault npm run preview # the vault's theme
|
||||||
|
BOARDS=/path/to/vault/Boards npm run preview # real boards, not fixtures
|
||||||
|
EXTRA_CSS=/path/to/other-plugin/styles.css … # another plugin's css, loaded after ours
|
||||||
|
open 'http://localhost:4173/?groupBy=due&railCollapsed=1#edit-date' # prefs + scene
|
||||||
|
node preview/shot.mjs out.png 'http://localhost:4173/#cal' # screenshot + console
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
+3
-3
@@ -6,10 +6,10 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"css": "tailwindcss -i src/tailwind.css -o styles.css",
|
"css": "node scripts/css.mjs",
|
||||||
"css:watch": "tailwindcss -i src/tailwind.css -o styles.css --watch",
|
"css:watch": "node scripts/css.mjs --watch",
|
||||||
"dev": "npm run css && node esbuild.config.mjs",
|
"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",
|
"build": "svelte-check --tsconfig ./tsconfig.json --threshold error && node scripts/css.mjs --minify && node esbuild.config.mjs production",
|
||||||
"test": "vitest run && node tests/smoke.mjs",
|
"test": "vitest run && node tests/smoke.mjs",
|
||||||
"preview": "node preview/serve.mjs"
|
"preview": "node preview/serve.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
+64
-9
@@ -2,17 +2,46 @@ import { Component, TFile } from "obsidian";
|
|||||||
import { mount } from "svelte";
|
import { mount } from "svelte";
|
||||||
import App from "../src/ui/app.svelte";
|
import App from "../src/ui/app.svelte";
|
||||||
import { DEFAULT_SETTINGS } from "../src/settings";
|
import { DEFAULT_SETTINGS } from "../src/settings";
|
||||||
|
import type { BeaverCalendarSettings } from "../src/settings";
|
||||||
import { BoardStore } from "../src/vault/store.svelte";
|
import { BoardStore } from "../src/vault/store.svelte";
|
||||||
|
|
||||||
const BOARDS = ["personal", "work", "reading", "home"];
|
const FIXTURES = ["personal", "work", "reading", "home"];
|
||||||
const FOLDER = "Boards";
|
|
||||||
|
/**
|
||||||
|
* Settings come off the query string, so a screenshot can start from the
|
||||||
|
* same preferences a real vault has: ?groupBy=due&includeArchive=1&railCollapsed=1
|
||||||
|
*/
|
||||||
|
function prefsFromQuery(): Partial<BeaverCalendarSettings> {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
const flag = (value: string): boolean => value === "1" || value === "true";
|
||||||
|
for (const [key, value] of params) {
|
||||||
|
if (!(key in DEFAULT_SETTINGS)) continue;
|
||||||
|
const current = DEFAULT_SETTINGS[key as keyof BeaverCalendarSettings];
|
||||||
|
out[key] = typeof current === "boolean" ? flag(value) : value;
|
||||||
|
}
|
||||||
|
return out as Partial<BeaverCalendarSettings>;
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
|
// Real boards when the server was given a folder, the fixtures otherwise.
|
||||||
|
const index = (await (await fetch("/boards.json")).json()) as {
|
||||||
|
folder: string;
|
||||||
|
names: string[];
|
||||||
|
} | null;
|
||||||
|
const folder = index?.folder ?? "Boards";
|
||||||
|
const sources = index
|
||||||
|
? index.names.map((name) => ({ path: `${folder}/${name}`, url: `/boards/${name}` }))
|
||||||
|
: FIXTURES.map((name) => ({
|
||||||
|
path: `${folder}/${name}.md`,
|
||||||
|
url: `/tests/fixtures/${name}.md`,
|
||||||
|
}));
|
||||||
|
|
||||||
const files = new Map<string, string>();
|
const files = new Map<string, string>();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
BOARDS.map(async (name) => {
|
sources.map(async ({ path, url }) => {
|
||||||
const response = await fetch(`/tests/fixtures/${name}.md`);
|
const response = await fetch(url);
|
||||||
files.set(`${FOLDER}/${name}.md`, await response.text());
|
files.set(path, await response.text());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const handles = new Map(
|
const handles = new Map(
|
||||||
@@ -37,7 +66,7 @@ async function main(): Promise<void> {
|
|||||||
plugins: { plugins: {} },
|
plugins: { plugins: {} },
|
||||||
};
|
};
|
||||||
|
|
||||||
const settings = { ...DEFAULT_SETTINGS, boardsFolder: FOLDER };
|
const settings = { ...DEFAULT_SETTINGS, boardsFolder: folder, ...prefsFromQuery() };
|
||||||
const store = new BoardStore(app as never, settings, () => {});
|
const store = new BoardStore(app as never, settings, () => {});
|
||||||
await store.reloadAll();
|
await store.reloadAll();
|
||||||
|
|
||||||
@@ -58,6 +87,14 @@ async function main(): Promise<void> {
|
|||||||
await wait(50);
|
await wait(50);
|
||||||
store.keyboard = true;
|
store.keyboard = true;
|
||||||
const first = store.spheres[0];
|
const first = store.spheres[0];
|
||||||
|
const tab = async (times: number): Promise<void> => {
|
||||||
|
for (let i = 0; i < times; i += 1) {
|
||||||
|
document.activeElement?.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "Tab", code: "Tab", bubbles: true }),
|
||||||
|
);
|
||||||
|
await wait(20);
|
||||||
|
}
|
||||||
|
};
|
||||||
if (scene.startsWith("rail")) {
|
if (scene.startsWith("rail")) {
|
||||||
store.toggleRail();
|
store.toggleRail();
|
||||||
store.railExpanded = new Set([first.path, store.spheres[1].path]);
|
store.railExpanded = new Set([first.path, store.spheres[1].path]);
|
||||||
@@ -68,6 +105,17 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (scene === "rail-none") store.filterNone();
|
if (scene === "rail-none") store.filterNone();
|
||||||
}
|
}
|
||||||
|
// The month grid on its own, opened on the month the fixtures are busiest in.
|
||||||
|
if (scene === "cal") {
|
||||||
|
const months = new Map<string, number>();
|
||||||
|
for (const item of store.visible) {
|
||||||
|
const key = (item.due ?? item.scheduled)?.slice(0, 7);
|
||||||
|
if (key) months.set(key, (months.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const busiest = [...months.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||||
|
store.view = "calendar";
|
||||||
|
if (busiest) store.focusDay(`${busiest}-15`);
|
||||||
|
}
|
||||||
if (scene.startsWith("peek")) {
|
if (scene.startsWith("peek")) {
|
||||||
if (scene === "peek-cal") store.view = "calendar";
|
if (scene === "peek-cal") store.view = "calendar";
|
||||||
if (scene === "peek-notes") store.showDone = true;
|
if (scene === "peek-notes") store.showDone = true;
|
||||||
@@ -85,10 +133,17 @@ async function main(): Promise<void> {
|
|||||||
if (scene === "date") {
|
if (scene === "date") {
|
||||||
store.write(null, "[data-composer='toolbar']");
|
store.write(null, "[data-composer='toolbar']");
|
||||||
await wait(50);
|
await wait(50);
|
||||||
for (let i = 0; i < 3; i += 1) {
|
await tab(3);
|
||||||
document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
|
||||||
await wait(20);
|
|
||||||
}
|
}
|
||||||
|
// The editor over an existing task, tabbed through to its date field.
|
||||||
|
if (scene === "edit-date") {
|
||||||
|
const task = store.visible[0];
|
||||||
|
store.zone = "task";
|
||||||
|
store.cursorTask = task.id;
|
||||||
|
await wait(20);
|
||||||
|
store.edit(task.id);
|
||||||
|
await wait(50);
|
||||||
|
await tab(3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
<!-- Without this a phone-sized window still lays out at 980px, so a
|
||||||
|
"mobile" screenshot is not one and coarse-pointer rules never apply. -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>beaver-calendar preview</title>
|
<title>beaver-calendar preview</title>
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css" />
|
||||||
<link rel="stylesheet" href="/theme.css" />
|
<link rel="stylesheet" href="/theme.css" />
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
<!-- Other plugins' stylesheets (EXTRA_CSS), after ours like a later plugin. -->
|
||||||
|
<link rel="stylesheet" href="/extra.css" />
|
||||||
<style>
|
<style>
|
||||||
body.theme-dark,
|
body.theme-dark,
|
||||||
body.theme-light {
|
body.theme-light {
|
||||||
|
|||||||
+63
-1
@@ -3,12 +3,13 @@ import {
|
|||||||
existsSync,
|
existsSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
statSync,
|
statSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { homedir, tmpdir } from "node:os";
|
import { homedir, tmpdir } from "node:os";
|
||||||
import { extname, join, normalize } from "node:path";
|
import { basename, extname, join, normalize, resolve } from "node:path";
|
||||||
import { extractFile } from "@electron/asar";
|
import { extractFile } from "@electron/asar";
|
||||||
import esbuild from "esbuild";
|
import esbuild from "esbuild";
|
||||||
import sveltePlugin from "esbuild-svelte";
|
import sveltePlugin from "esbuild-svelte";
|
||||||
@@ -65,6 +66,26 @@ if (!theme) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other plugins' stylesheets, loaded after ours the way Obsidian would load a
|
||||||
|
* plugin enabled later: EXTRA_CSS=path[,path…]. This is how a cascade fight
|
||||||
|
* with another Tailwind build is reproduced outside Obsidian.
|
||||||
|
*/
|
||||||
|
const extraCss = (process.env.EXTRA_CSS ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((item) => resolve(item));
|
||||||
|
for (const file of extraCss) {
|
||||||
|
if (!existsSync(file)) console.warn(`preview: EXTRA_CSS not found: ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real boards instead of the fixtures: BOARDS=<folder of .md boards>. */
|
||||||
|
const boards = process.env.BOARDS ? resolve(process.env.BOARDS) : null;
|
||||||
|
if (boards && !existsSync(boards)) {
|
||||||
|
console.warn(`preview: BOARDS folder not found: ${boards}`);
|
||||||
|
}
|
||||||
|
|
||||||
const ctx = await esbuild.context({
|
const ctx = await esbuild.context({
|
||||||
entryPoints: [join(root, "preview/entry.ts")],
|
entryPoints: [join(root, "preview/entry.ts")],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
@@ -85,9 +106,17 @@ const TYPES = {
|
|||||||
".css": "text/css; charset=utf-8",
|
".css": "text/css; charset=utf-8",
|
||||||
".html": "text/html; charset=utf-8",
|
".html": "text/html; charset=utf-8",
|
||||||
".js": "text/javascript; charset=utf-8",
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
".md": "text/plain; charset=utf-8",
|
".md": "text/plain; charset=utf-8",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function boardNames() {
|
||||||
|
if (!boards || !existsSync(boards)) return null;
|
||||||
|
return readdirSync(boards)
|
||||||
|
.filter((name) => name.endsWith(".md"))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
createServer((request, response) => {
|
createServer((request, response) => {
|
||||||
const url = decodeURIComponent((request.url ?? "/").split("?")[0]);
|
const url = decodeURIComponent((request.url ?? "/").split("?")[0]);
|
||||||
const external = { "/app.css": appCss, "/theme.css": theme };
|
const external = { "/app.css": appCss, "/theme.css": theme };
|
||||||
@@ -103,6 +132,37 @@ createServer((request, response) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url === "/extra.css") {
|
||||||
|
const body = extraCss
|
||||||
|
.filter((file) => existsSync(file))
|
||||||
|
.map((file) => `/* ${file} */\n${readFileSync(file, "utf8")}`)
|
||||||
|
.join("\n");
|
||||||
|
response
|
||||||
|
.writeHead(200, { "content-type": TYPES[".css"], "cache-control": "no-store" })
|
||||||
|
.end(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/boards.json") {
|
||||||
|
const names = boardNames();
|
||||||
|
const body = names ? { folder: basename(boards), names } : null;
|
||||||
|
response
|
||||||
|
.writeHead(200, { "content-type": TYPES[".json"], "cache-control": "no-store" })
|
||||||
|
.end(JSON.stringify(body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith("/boards/") && boards) {
|
||||||
|
const file = join(boards, normalize(url.slice("/boards/".length)));
|
||||||
|
if (!file.startsWith(boards) || !existsSync(file) || statSync(file).isDirectory()) {
|
||||||
|
response.writeHead(404).end("not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(200, { "content-type": TYPES[".md"], "cache-control": "no-store" });
|
||||||
|
createReadStream(file).pipe(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const file = join(root, normalize(url === "/" ? "/preview/index.html" : url));
|
const file = join(root, normalize(url === "/" ? "/preview/index.html" : url));
|
||||||
if (!file.startsWith(root) || !existsSync(file) || statSync(file).isDirectory()) {
|
if (!file.startsWith(root) || !existsSync(file) || statSync(file).isDirectory()) {
|
||||||
response.writeHead(404).end("not found");
|
response.writeHead(404).end("not found");
|
||||||
@@ -115,4 +175,6 @@ createServer((request, response) => {
|
|||||||
createReadStream(file).pipe(response);
|
createReadStream(file).pipe(response);
|
||||||
}).listen(port, () => {
|
}).listen(port, () => {
|
||||||
console.log(`preview: http://localhost:${port}/`);
|
console.log(`preview: http://localhost:${port}/`);
|
||||||
|
if (boards) console.log(`preview: boards from ${boards}`);
|
||||||
|
if (extraCss.length > 0) console.log(`preview: extra css ${extraCss.join(", ")}`);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// node preview/shot.mjs <out.png> <url> [width] [height]
|
||||||
|
//
|
||||||
|
// Headless Chrome over the DevTools protocol: a screenshot plus everything the
|
||||||
|
// page said on the console, and a note if the page stopped answering — which
|
||||||
|
// a plain --screenshot run would only report as a silent timeout.
|
||||||
|
// DUMP=1 also print the page text
|
||||||
|
// PROBE=js evaluate an expression at the end and print its value
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const [out, url, w = "1100", h = "720"] = process.argv.slice(2);
|
||||||
|
if (!out || !url) {
|
||||||
|
console.error("usage: node preview/shot.mjs <out.png> <url> [width] [height]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const port = 9334;
|
||||||
|
const chrome =
|
||||||
|
process.env.CHROME ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
||||||
|
const proc = spawn(
|
||||||
|
chrome,
|
||||||
|
[
|
||||||
|
"--headless=new",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--hide-scrollbars",
|
||||||
|
`--remote-debugging-port=${port}`,
|
||||||
|
`--window-size=${w},${h}`,
|
||||||
|
"--user-data-dir=/tmp/bcal-chrome-profile",
|
||||||
|
"about:blank",
|
||||||
|
],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
let targets = [];
|
||||||
|
for (let i = 0; i < 40; i += 1) {
|
||||||
|
try {
|
||||||
|
targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
|
||||||
|
if (targets.length > 0) break;
|
||||||
|
} catch {
|
||||||
|
/* not up yet */
|
||||||
|
}
|
||||||
|
await wait(250);
|
||||||
|
}
|
||||||
|
const page = targets.find((target) => target.type === "page");
|
||||||
|
if (!page) {
|
||||||
|
console.error("shot: chrome did not come up");
|
||||||
|
proc.kill();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
ws.onopen = resolve;
|
||||||
|
});
|
||||||
|
let id = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
const logs = [];
|
||||||
|
ws.onmessage = (message) => {
|
||||||
|
const data = JSON.parse(String(message.data));
|
||||||
|
if (data.id && pending.has(data.id)) {
|
||||||
|
pending.get(data.id)(data);
|
||||||
|
pending.delete(data.id);
|
||||||
|
}
|
||||||
|
if (data.method === "Runtime.consoleAPICalled") {
|
||||||
|
logs.push(
|
||||||
|
`${data.params.type}: ${data.params.args
|
||||||
|
.map((arg) => arg.value ?? arg.description)
|
||||||
|
.join(" ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.method === "Runtime.exceptionThrown") {
|
||||||
|
const details = data.params.exceptionDetails;
|
||||||
|
logs.push(`EXC: ${details.exception?.description ?? details.text}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const send = (method, params = {}, timeout = 15_000) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const next = ++id;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pending.delete(next);
|
||||||
|
reject(new Error(`${method} timed out after ${timeout} ms`));
|
||||||
|
}, timeout);
|
||||||
|
pending.set(next, (value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
});
|
||||||
|
ws.send(JSON.stringify({ id: next, method, params }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await send("Emulation.setDeviceMetricsOverride", {
|
||||||
|
width: Number(w),
|
||||||
|
height: Number(h),
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
mobile: false,
|
||||||
|
});
|
||||||
|
await send("Page.enable");
|
||||||
|
await send("Runtime.enable");
|
||||||
|
await send("Page.navigate", { url });
|
||||||
|
await wait(Number(process.env.SETTLE ?? 2500));
|
||||||
|
|
||||||
|
let alive = true;
|
||||||
|
try {
|
||||||
|
const ping = await send("Runtime.evaluate", { expression: "1 + 1", returnByValue: true }, 5000);
|
||||||
|
alive = ping.result?.result?.value === 2;
|
||||||
|
} catch {
|
||||||
|
alive = false;
|
||||||
|
}
|
||||||
|
if (!alive) {
|
||||||
|
console.log("shot: PAGE UNRESPONSIVE (main thread busy — a hang)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alive && process.env.PROBE) {
|
||||||
|
const probe = await send("Runtime.evaluate", {
|
||||||
|
expression: process.env.PROBE,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log("PROBE:", JSON.stringify(probe.result?.result?.value ?? probe.result));
|
||||||
|
}
|
||||||
|
if (alive && process.env.DUMP) {
|
||||||
|
const text = await send("Runtime.evaluate", {
|
||||||
|
expression: "document.body.innerText.slice(0, 1500)",
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log("TEXT:", text.result?.result?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const shot = await send("Page.captureScreenshot", { format: "png" }, 10_000);
|
||||||
|
writeFileSync(out, Buffer.from(shot.result.data, "base64"));
|
||||||
|
console.log("wrote", out);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("shot: no screenshot —", error.message);
|
||||||
|
}
|
||||||
|
if (logs.length > 0) console.log("LOGS:\n" + logs.join("\n"));
|
||||||
|
ws.close();
|
||||||
|
proc.kill();
|
||||||
|
process.exit(alive ? 0 : 3);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// node scripts/css.mjs [--minify] [--watch]
|
||||||
|
//
|
||||||
|
// Tailwind writes to a scratch file; the scoped, optionally minified result
|
||||||
|
// is what lands in styles.css. See scope-css.mjs for why the extra step.
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import { readFileSync, watch, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { scopeCss } from "./scope-css.mjs";
|
||||||
|
|
||||||
|
const root = new URL("..", import.meta.url).pathname;
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
const minify = args.has("--minify");
|
||||||
|
const watching = args.has("--watch");
|
||||||
|
|
||||||
|
const input = join(root, "src/tailwind.css");
|
||||||
|
const scratch = join(root, ".tailwind.css");
|
||||||
|
const output = join(root, "styles.css");
|
||||||
|
const cli = join(root, "node_modules/.bin/tailwindcss");
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
const built = readFileSync(scratch, "utf8");
|
||||||
|
writeFileSync(output, scopeCss(built, { minify }));
|
||||||
|
console.log(`css: wrote styles.css (${minify ? "minified, " : ""}scoped)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!watching) {
|
||||||
|
const run = spawnSync(cli, ["-i", input, "-o", scratch], { stdio: "inherit" });
|
||||||
|
if (run.status !== 0) process.exit(run.status ?? 1);
|
||||||
|
finish();
|
||||||
|
} else {
|
||||||
|
writeFileSync(scratch, "");
|
||||||
|
const child = spawn(cli, ["-i", input, "-o", scratch, "--watch"], {
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
let timer = null;
|
||||||
|
watch(scratch, () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
try {
|
||||||
|
finish();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("css:", error);
|
||||||
|
}
|
||||||
|
}, 60);
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => process.exit(code ?? 0));
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { transform } from "lightningcss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obsidian loads every plugin's stylesheet into one document, and Tailwind
|
||||||
|
* puts its utilities into a cascade layer called `utilities` in each of them.
|
||||||
|
* Layers with the same name merge, so another Tailwind plugin's
|
||||||
|
* `.hidden{display:none!important}` and this plugin's
|
||||||
|
* `@container … .\@min-\[30rem\]\:inline-block{…!important}` end up in the
|
||||||
|
* same layer with the same specificity, and whichever plugin loaded last
|
||||||
|
* wins. Prefixing every selector with `:is(.bcal-root, .bcal-root *)` adds a
|
||||||
|
* class of specificity that no unscoped utility can match, whatever the load
|
||||||
|
* order, while still reaching the view root itself and the portal layer,
|
||||||
|
* both of which carry `.bcal-root`.
|
||||||
|
*
|
||||||
|
* Tailwind's own theme variables stay on `:root, :host`: moved onto the view
|
||||||
|
* root they would sit above Obsidian's `body` values, and `--color-accent`
|
||||||
|
* is a name both sides use — the accent would turn into the hover grey.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROOT = "bcal-root";
|
||||||
|
|
||||||
|
const SCOPE = {
|
||||||
|
type: "pseudo-class",
|
||||||
|
kind: "is",
|
||||||
|
selectors: [
|
||||||
|
[{ type: "class", name: ROOT }],
|
||||||
|
[
|
||||||
|
{ type: "class", name: ROOT },
|
||||||
|
{ type: "combinator", value: "descendant" },
|
||||||
|
{ type: "universal" },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function isPseudo(selector, kind) {
|
||||||
|
return (
|
||||||
|
selector.length === 1 &&
|
||||||
|
selector[0].type === "pseudo-class" &&
|
||||||
|
selector[0].kind === kind
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentions(selector, name) {
|
||||||
|
for (const part of selector) {
|
||||||
|
if (part.type === "class" && part.name === name) return true;
|
||||||
|
if (part.selectors?.some((inner) => mentions(inner, name))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeSelector(selector) {
|
||||||
|
if (isPseudo(selector, "root") || isPseudo(selector, "host")) {
|
||||||
|
return selector;
|
||||||
|
}
|
||||||
|
if (mentions(selector, ROOT)) {
|
||||||
|
return selector;
|
||||||
|
}
|
||||||
|
// The scope goes on the subject — the last compound — not the first:
|
||||||
|
// `.theme-dark .hue-chip` must keep matching with `.theme-dark` on body,
|
||||||
|
// outside the root.
|
||||||
|
let at = 0;
|
||||||
|
for (let i = 0; i < selector.length; i += 1) {
|
||||||
|
if (selector[i].type === "combinator") {
|
||||||
|
at = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const head = selector.slice(0, at);
|
||||||
|
const subject = selector.slice(at);
|
||||||
|
// `*` and type selectors must open a compound; :is() goes in their place
|
||||||
|
// or right after them.
|
||||||
|
if (subject[0]?.type === "universal") {
|
||||||
|
return [...head, SCOPE, ...subject.slice(1)];
|
||||||
|
}
|
||||||
|
if (subject[0]?.type === "type") {
|
||||||
|
return [...head, subject[0], SCOPE, ...subject.slice(1)];
|
||||||
|
}
|
||||||
|
return [...head, SCOPE, ...subject];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrites a built stylesheet so its rules only reach the plugin's own DOM. */
|
||||||
|
export function scopeCss(code, { minify = false } = {}) {
|
||||||
|
// The selector visitor, not the rule one: handing a whole rule back makes
|
||||||
|
// lightningcss re-read its declarations, and some of Tailwind's it cannot.
|
||||||
|
const result = transform({
|
||||||
|
filename: "styles.css",
|
||||||
|
code: Buffer.from(code),
|
||||||
|
minify,
|
||||||
|
visitor: { Selector: scopeSelector },
|
||||||
|
});
|
||||||
|
return result.code.toString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* How much of the view's bottom is covered by something the operator cannot
|
||||||
|
* move: Obsidian's desktop status bar, a phone's navbar (which paints over a
|
||||||
|
* leaf instead of shortening it), and the software keyboard, which shrinks the
|
||||||
|
* visual viewport while the layout keeps its full height.
|
||||||
|
*
|
||||||
|
* Pure on purpose - the view feeds it live rectangles, the tests feed it the
|
||||||
|
* numbers a phone actually reports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Rect {
|
||||||
|
bottom: number;
|
||||||
|
height: number;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Visible {
|
||||||
|
/** `visualViewport.height`: what is left once the keyboard is up. */
|
||||||
|
height: number;
|
||||||
|
/** `visualViewport.offsetTop`: where the visible area starts. */
|
||||||
|
offsetTop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bars that float over a leaf rather than making room for it. */
|
||||||
|
export const BOTTOM_BARS = [
|
||||||
|
".status-bar",
|
||||||
|
".mobile-navbar",
|
||||||
|
".mobile-toolbar",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function insetFor(
|
||||||
|
leaf: Rect,
|
||||||
|
bars: Rect[],
|
||||||
|
visible: Visible | null,
|
||||||
|
): number {
|
||||||
|
if (leaf.height <= 0) return 0;
|
||||||
|
|
||||||
|
let overlap = 0;
|
||||||
|
for (const bar of bars) {
|
||||||
|
// A bar that does not sit over this leaf horizontally covers nothing of
|
||||||
|
// it - the desktop status bar beside a left-hand sidedock, say.
|
||||||
|
if (bar.height > 0 && bar.left < leaf.right && bar.right > leaf.left) {
|
||||||
|
overlap = Math.max(overlap, leaf.bottom - bar.top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (visible) {
|
||||||
|
// While the keyboard is up the navbar is behind it: the deeper of the two
|
||||||
|
// is the inset, and summing them would push the view twice as far.
|
||||||
|
overlap = Math.max(overlap, leaf.bottom - (visible.offsetTop + visible.height));
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.round(overlap));
|
||||||
|
}
|
||||||
+64
-8
@@ -13,6 +13,53 @@ export interface Spot {
|
|||||||
const GAP = 6;
|
const GAP = 6;
|
||||||
const PAD = 10;
|
const PAD = 10;
|
||||||
|
|
||||||
|
export interface Viewport {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** Where the visible area starts, once a keyboard has pushed it up. */
|
||||||
|
top?: number;
|
||||||
|
left?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is actually visible, which on a phone is not the window: the software
|
||||||
|
* keyboard shrinks the visual viewport and leaves `innerHeight` alone, so a
|
||||||
|
* panel placed against the window lands behind the keys. The navbar is not in
|
||||||
|
* the viewport at all - it floats over it - so it is subtracted by hand.
|
||||||
|
*/
|
||||||
|
export function viewport(win: Window = window): Viewport {
|
||||||
|
const vv = win.visualViewport;
|
||||||
|
const base: Viewport = vv
|
||||||
|
? { width: vv.width, height: vv.height, top: vv.offsetTop, left: vv.offsetLeft }
|
||||||
|
: { width: win.innerWidth, height: win.innerHeight };
|
||||||
|
return { ...base, height: base.height - floating(win, base) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of the bottom of `view` a floating bar covers. Obsidian's phone
|
||||||
|
* navbar paints over every leaf, so a panel that ends under it cannot be
|
||||||
|
* reached even though it is inside the viewport.
|
||||||
|
*/
|
||||||
|
function floating(win: Window, view: Viewport): number {
|
||||||
|
const doc = win.document;
|
||||||
|
const bottom = (view.top ?? 0) + view.height;
|
||||||
|
let covered = 0;
|
||||||
|
for (const selector of [".mobile-navbar", ".mobile-toolbar", ".status-bar"]) {
|
||||||
|
const node = doc.querySelector(selector) as HTMLElement | null;
|
||||||
|
if (!node || node.offsetParent === null) continue;
|
||||||
|
const box = node.getBoundingClientRect();
|
||||||
|
if (box.height > 0 && box.top < bottom) {
|
||||||
|
covered = Math.max(covered, bottom - box.top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.min(covered, Math.max(0, view.height - 120));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tallest a panel may be here before it has to scroll its own body. */
|
||||||
|
export function roomFor(view: Viewport): number {
|
||||||
|
return Math.max(160, view.height - 2 * PAD);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The calendar draws its cards twice — once in the agenda and once in the
|
* The calendar draws its cards twice — once in the agenda and once in the
|
||||||
* month grid — and hides whichever the width does not call for. Both answer
|
* month grid — and hides whichever the width does not call for. Both answer
|
||||||
@@ -31,24 +78,33 @@ export function onScreen(boxes: Box[]): Box | null {
|
|||||||
export function place(
|
export function place(
|
||||||
anchor: Box | null,
|
anchor: Box | null,
|
||||||
panel: { width: number; height: number },
|
panel: { width: number; height: number },
|
||||||
view: { width: number; height: number },
|
view: Viewport
|
||||||
): Spot {
|
): Spot {
|
||||||
|
const originTop = view.top ?? 0;
|
||||||
|
const originLeft = view.left ?? 0;
|
||||||
|
const bottom = originTop + view.height;
|
||||||
|
const right = originLeft + view.width;
|
||||||
|
|
||||||
if (!anchor) {
|
if (!anchor) {
|
||||||
return {
|
return {
|
||||||
top: Math.max(PAD, (view.height - panel.height) / 2),
|
top: Math.max(originTop + PAD, originTop + (view.height - panel.height) / 2),
|
||||||
left: Math.max(PAD, (view.width - panel.width) / 2),
|
left: Math.max(originLeft + PAD, originLeft + (view.width - panel.width) / 2),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let top = anchor.top + anchor.height + GAP;
|
let top = anchor.top + anchor.height + GAP;
|
||||||
if (top + panel.height > view.height - PAD) {
|
if (top + panel.height > bottom - PAD) {
|
||||||
const above = anchor.top - GAP - panel.height;
|
top = anchor.top - GAP - panel.height;
|
||||||
top = above >= PAD ? above : Math.max(PAD, view.height - PAD - panel.height);
|
|
||||||
}
|
}
|
||||||
|
// The anchor itself can be outside the visible area - a keyboard covers the
|
||||||
|
// row the panel hangs off - and then neither side of it is reachable. What
|
||||||
|
// is visible wins over staying next to the anchor.
|
||||||
|
top = Math.min(top, bottom - PAD - panel.height);
|
||||||
|
top = Math.max(top, originTop + PAD);
|
||||||
|
|
||||||
const left = Math.min(
|
const left = Math.min(
|
||||||
Math.max(PAD, anchor.left),
|
Math.max(originLeft + PAD, anchor.left),
|
||||||
Math.max(PAD, view.width - PAD - panel.width),
|
Math.max(originLeft + PAD, right - PAD - panel.width)
|
||||||
);
|
);
|
||||||
|
|
||||||
return { top, left };
|
return { top, left };
|
||||||
|
|||||||
+22
-11
@@ -4,9 +4,10 @@ import {
|
|||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
} from "./settings";
|
} from "./settings";
|
||||||
import type { BeaverCalendarSettings } from "./settings";
|
import type { BeaverCalendarSettings } from "./settings";
|
||||||
|
import { archiveRoot, toArchivePath } from "./model/archive";
|
||||||
import { BoardStore } from "./vault/store.svelte";
|
import { BoardStore } from "./vault/store.svelte";
|
||||||
import { transaction } from "./vault/history";
|
import { transaction } from "./vault/history";
|
||||||
import { archiveDone } from "./vault/mutate";
|
import { archiveBoard } from "./vault/mutate";
|
||||||
import { BoardsView, VIEW_TYPE_BOARDS } from "./view";
|
import { BoardsView, VIEW_TYPE_BOARDS } from "./view";
|
||||||
|
|
||||||
export default class BeaverCalendarPlugin extends Plugin {
|
export default class BeaverCalendarPlugin extends Plugin {
|
||||||
@@ -90,34 +91,44 @@ export default class BeaverCalendarPlugin extends Plugin {
|
|||||||
|
|
||||||
private async archiveAll(): Promise<void> {
|
private async archiveAll(): Promise<void> {
|
||||||
const folder = this.settings.boardsFolder;
|
const folder = this.settings.boardsFolder;
|
||||||
if (!folder) {
|
const archive = this.settings.archiveFolder;
|
||||||
|
if (!folder || !archive) {
|
||||||
new Notice(
|
new Notice(
|
||||||
"Beaver Calendar: no boards folder is set, archiving cancelled",
|
"Beaver Calendar: the boards and archive folders must both be set",
|
||||||
6000,
|
6000,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The mirror is under the boards folder, so it would sweep itself.
|
||||||
|
const root = `${archiveRoot(folder, archive)}/`;
|
||||||
const files = this.app.vault
|
const files = this.app.vault
|
||||||
.getMarkdownFiles()
|
.getMarkdownFiles()
|
||||||
.filter((file) => file.path.startsWith(`${folder}/`));
|
.filter(
|
||||||
|
(file) =>
|
||||||
|
file.path.startsWith(`${folder}/`) && !file.path.startsWith(root),
|
||||||
|
);
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
new Notice("Beaver Calendar: no boards found");
|
new Notice("Beaver Calendar: no boards found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let moved = 0;
|
let moved = 0;
|
||||||
|
let migrated = 0;
|
||||||
// One undo step for the whole sweep, however many boards it touched.
|
// One undo step for the whole sweep, however many boards it touched.
|
||||||
await transaction("the archive", async () => {
|
await transaction("the archive", async () => {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
moved += await archiveDone(
|
const mirror = toArchivePath(file.path, folder, archive);
|
||||||
this.app,
|
if (!mirror) continue;
|
||||||
file.path,
|
const outcome = await archiveBoard(this.app, file.path, mirror, true);
|
||||||
this.settings.archiveHeading,
|
moved += outcome.moved;
|
||||||
);
|
if (outcome.migrated) migrated += 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (moved > 0) parts.push(`archived ${moved} task(s)`);
|
||||||
|
if (migrated > 0) parts.push(`moved the old archive of ${migrated} board(s)`);
|
||||||
new Notice(
|
new Notice(
|
||||||
moved > 0
|
parts.length > 0
|
||||||
? `Beaver Calendar: archived ${moved} task(s)`
|
? `Beaver Calendar: ${parts.join(", ")}`
|
||||||
: "Beaver Calendar: nothing to archive",
|
: "Beaver Calendar: nothing to archive",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Completed cards leave the board for a mirror of the boards folder: a board
|
||||||
|
* at `Boards/work.md` archives into `Boards/archive/work.md`, which is itself
|
||||||
|
* a Kanban board, so the history opens like any other one. The mirror keeps
|
||||||
|
* the boards folder's shape, so a nested board archives to a nested twin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function strip(value: string): string {
|
||||||
|
return value.trim().replace(/^\/+/u, "").replace(/\/+$/u, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folder the mirror lives in, or `""` when there is no archive folder. */
|
||||||
|
export function archiveRoot(boardsFolder: string, archiveFolder: string): string {
|
||||||
|
const name = strip(archiveFolder);
|
||||||
|
if (!name) return "";
|
||||||
|
const boards = strip(boardsFolder);
|
||||||
|
return boards ? `${boards}/${name}` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isArchivePath(
|
||||||
|
path: string,
|
||||||
|
boardsFolder: string,
|
||||||
|
archiveFolder: string,
|
||||||
|
): boolean {
|
||||||
|
const root = archiveRoot(boardsFolder, archiveFolder);
|
||||||
|
return root !== "" && path.startsWith(`${root}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The archive twin of a board, or `null` when the board is outside the boards
|
||||||
|
* folder and so has nowhere to mirror to. An archive file is its own twin.
|
||||||
|
*/
|
||||||
|
export function toArchivePath(
|
||||||
|
path: string,
|
||||||
|
boardsFolder: string,
|
||||||
|
archiveFolder: string,
|
||||||
|
): string | null {
|
||||||
|
const root = archiveRoot(boardsFolder, archiveFolder);
|
||||||
|
if (!root) return null;
|
||||||
|
if (path.startsWith(`${root}/`)) return path;
|
||||||
|
const boards = strip(boardsFolder);
|
||||||
|
if (!boards) return `${root}/${path}`;
|
||||||
|
if (!path.startsWith(`${boards}/`)) return null;
|
||||||
|
return `${root}/${path.slice(boards.length + 1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The board an archive file mirrors; any other path is its own board. */
|
||||||
|
export function toBoardPath(
|
||||||
|
path: string,
|
||||||
|
boardsFolder: string,
|
||||||
|
archiveFolder: string,
|
||||||
|
): string {
|
||||||
|
const root = archiveRoot(boardsFolder, archiveFolder);
|
||||||
|
if (!root || !path.startsWith(`${root}/`)) return path;
|
||||||
|
const boards = strip(boardsFolder);
|
||||||
|
const rest = path.slice(root.length + 1);
|
||||||
|
return boards ? `${boards}/${rest}` : rest;
|
||||||
|
}
|
||||||
+17
-2
@@ -14,7 +14,16 @@ function isCardBody(line: string): boolean {
|
|||||||
return /^\t/u.test(line);
|
return /^\t/u.test(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseBoard(path: string, content: string): Board {
|
/**
|
||||||
|
* `archived` marks a whole file as archive - the mirror files under the
|
||||||
|
* archive folder hold nothing else, so every lane in them is one, separator
|
||||||
|
* or no separator.
|
||||||
|
*/
|
||||||
|
export function parseBoard(
|
||||||
|
path: string,
|
||||||
|
content: string,
|
||||||
|
archived = false,
|
||||||
|
): Board {
|
||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
const sphere = sphereOf(path);
|
const sphere = sphereOf(path);
|
||||||
const lanes: Lane[] = [];
|
const lanes: Lane[] = [];
|
||||||
@@ -35,7 +44,7 @@ export function parseBoard(path: string, content: string): Board {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let inArchive = false;
|
let inArchive = archived;
|
||||||
let current: Lane | null = null;
|
let current: Lane | null = null;
|
||||||
|
|
||||||
const closeLane = (end: number): void => {
|
const closeLane = (end: number): void => {
|
||||||
@@ -117,6 +126,12 @@ export function findLane(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where a heading-less board ends: before its Kanban settings block. */
|
||||||
|
export function bodyEnd(lines: string[]): number {
|
||||||
|
const settingsAt = lines.findIndex((line) => SETTINGS_OPEN.test(line));
|
||||||
|
return settingsAt === -1 ? lines.length : settingsAt;
|
||||||
|
}
|
||||||
|
|
||||||
export function laneInsertPoint(board: Board, lane: Lane): number {
|
export function laneInsertPoint(board: Board, lane: Lane): number {
|
||||||
let end = lane.end;
|
let end = lane.end;
|
||||||
while (end > lane.start && board.lines[end - 1].trim() === "") {
|
while (end > lane.start && board.lines[end - 1].trim() === "") {
|
||||||
|
|||||||
+18
-10
@@ -8,7 +8,7 @@ export type SortBy = "manual" | "priority" | "due";
|
|||||||
|
|
||||||
export interface BeaverCalendarSettings {
|
export interface BeaverCalendarSettings {
|
||||||
boardsFolder: string;
|
boardsFolder: string;
|
||||||
archiveHeading: string;
|
archiveFolder: string;
|
||||||
view: ViewMode;
|
view: ViewMode;
|
||||||
groupBy: GroupBy;
|
groupBy: GroupBy;
|
||||||
listSort: SortBy;
|
listSort: SortBy;
|
||||||
@@ -24,7 +24,7 @@ export interface BeaverCalendarSettings {
|
|||||||
|
|
||||||
export const DEFAULT_SETTINGS: BeaverCalendarSettings = {
|
export const DEFAULT_SETTINGS: BeaverCalendarSettings = {
|
||||||
boardsFolder: "Boards",
|
boardsFolder: "Boards",
|
||||||
archiveHeading: "Archive",
|
archiveFolder: "archive",
|
||||||
view: "list",
|
view: "list",
|
||||||
groupBy: "sphere",
|
groupBy: "sphere",
|
||||||
listSort: "due",
|
listSort: "due",
|
||||||
@@ -70,25 +70,33 @@ export class BeaverCalendarSettingsTab extends PluginSettingTab {
|
|||||||
);
|
);
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Archive heading")
|
.setName("Archive folder")
|
||||||
.setDesc("The lane completed tasks are moved to by the archive command.")
|
.setDesc(
|
||||||
|
"A subfolder mirroring the boards folder, where the archive command " +
|
||||||
|
"puts completed tasks: a board at Boards/work.md archives into " +
|
||||||
|
"Boards/archive/work.md, itself a Kanban board with the same lanes. " +
|
||||||
|
"The command also lifts a board's old *** archive section in there.",
|
||||||
|
)
|
||||||
.addText((text) =>
|
.addText((text) =>
|
||||||
text
|
text
|
||||||
.setPlaceholder("Archive")
|
.setPlaceholder("archive")
|
||||||
.setValue(this.plugin.settings.archiveHeading)
|
.setValue(this.plugin.settings.archiveFolder)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.archiveHeading = value.trim() || "Archive";
|
this.plugin.settings.archiveFolder =
|
||||||
|
normalizeFolder(value) || "archive";
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
|
await this.plugin.store.reloadAll();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Include the archive")
|
.setName("Include the archive")
|
||||||
.setDesc(
|
.setDesc(
|
||||||
"Widen the show-completed toggle to reach tasks below the archive " +
|
"Widen the show-completed toggle to reach archived tasks - both the " +
|
||||||
|
"archive folder and whatever still sits below a board's *** " +
|
||||||
"separator. With this off, show-completed only reveals completed " +
|
"separator. With this off, show-completed only reveals completed " +
|
||||||
"tasks still sitting in their lanes. Archived tasks carry an " +
|
"tasks still in their lanes. Archived tasks carry an Archive badge " +
|
||||||
"Archive badge instead of a project one.",
|
"instead of a project one.",
|
||||||
)
|
)
|
||||||
.addToggle((toggle) =>
|
.addToggle((toggle) =>
|
||||||
toggle
|
toggle
|
||||||
|
|||||||
@@ -102,7 +102,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bcal-root {
|
.bcal-root {
|
||||||
|
box-sizing: border-box;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
/* The phone's navbar floats over the leaf rather than shortening it, so the
|
||||||
|
last row of the list would sit underneath it. The view measures how much
|
||||||
|
and sets this; on a desktop it stays 0. */
|
||||||
|
padding-bottom: var(--bcal-inset-bottom, 0px);
|
||||||
font-family: var(--font-interface);
|
font-family: var(--font-interface);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Portal } from "bits-ui";
|
import { Portal } from "bits-ui";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { onScreen, place } from "../lib/place";
|
import { onScreen, place, roomFor, viewport } from "../lib/place";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useView } from "./context";
|
import { useView } from "./context";
|
||||||
|
|
||||||
@@ -27,13 +27,31 @@
|
|||||||
|
|
||||||
const view = useView();
|
const view = useView();
|
||||||
|
|
||||||
|
// The keyboard animates both ways: rather than guess how long that takes,
|
||||||
|
// the panel re-places every frame until its position holds still. Long enough
|
||||||
|
// to outlast Obsidian hiding its navbar, which moves the ground a second time.
|
||||||
|
const STEADY_FRAMES = 12;
|
||||||
|
|
||||||
|
/** Obsidian's Capacitor keyboard events on mobile; absent on the desktop. */
|
||||||
|
const KEYBOARD_EVENTS = [
|
||||||
|
"keyboardWillShow",
|
||||||
|
"keyboardDidShow",
|
||||||
|
"keyboardWillHide",
|
||||||
|
"keyboardDidHide",
|
||||||
|
];
|
||||||
|
|
||||||
let panel = $state<HTMLElement | null>(null);
|
let panel = $state<HTMLElement | null>(null);
|
||||||
let at = $state({ top: 0, left: 0 });
|
let at = $state({ top: 0, left: 0 });
|
||||||
|
// Kept in state as well as on the node: the `style` attribute is rendered
|
||||||
|
// from `at`, so a re-render would drop a height only set imperatively.
|
||||||
|
let room = $state(0);
|
||||||
let placed = $state(false);
|
let placed = $state(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Placed by hand rather than by a floating layer: these panels are summoned
|
* Placed by hand rather than by a floating layer: these panels are summoned
|
||||||
* by a key press, and a floating layer wants a trigger element to hang off.
|
* by a key press, and a floating layer wants a trigger element to hang off.
|
||||||
|
* Measured against the visual viewport, so a phone's keyboard pushes the
|
||||||
|
* panel up instead of hiding it.
|
||||||
*/
|
*/
|
||||||
function measure(): void {
|
function measure(): void {
|
||||||
const node = panel;
|
const node = panel;
|
||||||
@@ -42,15 +60,19 @@
|
|||||||
const boxes = [...document.querySelectorAll(anchor)].map((element) =>
|
const boxes = [...document.querySelectorAll(anchor)].map((element) =>
|
||||||
element.getBoundingClientRect(),
|
element.getBoundingClientRect(),
|
||||||
);
|
);
|
||||||
at = place(onScreen(boxes), node.getBoundingClientRect(), {
|
const view = viewport(node.ownerDocument.defaultView ?? window);
|
||||||
width: window.innerWidth,
|
// Applied to the node before it is measured: a keyboard that just took
|
||||||
height: window.innerHeight,
|
// half the screen makes the panel shorter, and placing it against its old
|
||||||
});
|
// height would leave it hanging below the keys.
|
||||||
|
room = roomFor(view);
|
||||||
|
node.style.maxHeight = `${room}px`;
|
||||||
|
at = place(onScreen(boxes), node.getBoundingClientRect(), view);
|
||||||
placed = true;
|
placed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mount(node: HTMLElement) {
|
function mount(node: HTMLElement) {
|
||||||
panel = node;
|
panel = node;
|
||||||
|
const win = node.ownerDocument.defaultView ?? window;
|
||||||
measure();
|
measure();
|
||||||
if (autofocus) {
|
if (autofocus) {
|
||||||
(node.querySelector<HTMLElement>("input") ?? node).focus({
|
(node.querySelector<HTMLElement>("input") ?? node).focus({
|
||||||
@@ -62,19 +84,84 @@
|
|||||||
if (!node.contains(event.target as Node)) onclose();
|
if (!node.contains(event.target as Node)) onclose();
|
||||||
};
|
};
|
||||||
const reflow = (): void => measure();
|
const reflow = (): void => measure();
|
||||||
|
/**
|
||||||
|
* Re-place every frame until the position has held still for a while. The
|
||||||
|
* keyboard animates, and Obsidian hides its navbar *after* it is up, which
|
||||||
|
* moves the ground under the panel a second time - a short "steady" run
|
||||||
|
* exits between the two and leaves the panel where neither wanted it. In a
|
||||||
|
* sidedock no resize event fires at all, so a frame loop is the only thing
|
||||||
|
* that notices either move.
|
||||||
|
*/
|
||||||
|
let frame: number | null = null;
|
||||||
|
const settle = (): void => {
|
||||||
|
let last = Number.NaN;
|
||||||
|
let steady = 0;
|
||||||
|
if (frame !== null) win.cancelAnimationFrame(frame);
|
||||||
|
const tick = (): void => {
|
||||||
|
measure();
|
||||||
|
steady = at.top === last ? steady + 1 : 0;
|
||||||
|
last = at.top;
|
||||||
|
if (steady >= STEADY_FRAMES) {
|
||||||
|
frame = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frame = win.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
frame = win.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
document.addEventListener("pointerdown", outside, true);
|
document.addEventListener("pointerdown", outside, true);
|
||||||
window.addEventListener("resize", reflow);
|
window.addEventListener("resize", reflow);
|
||||||
|
// The keyboard changes the visual viewport without resizing the window.
|
||||||
|
const vv = win.visualViewport;
|
||||||
|
vv?.addEventListener("resize", settle);
|
||||||
|
vv?.addEventListener("scroll", settle);
|
||||||
|
// Obsidian's own keyboard events (Capacitor, mobile only): they fire before
|
||||||
|
// the animation and again as the navbar comes back, which `visualViewport`
|
||||||
|
// on its own does not always report.
|
||||||
|
for (const name of KEYBOARD_EVENTS) {
|
||||||
|
win.addEventListener(name, settle);
|
||||||
|
} // The panel's own input takes focus a beat before the keyboard is up, and
|
||||||
|
// swapping fields re-opens it.
|
||||||
|
node.addEventListener("focusin", settle);
|
||||||
|
settle();
|
||||||
|
// A panel whose body grew (an option list opened) re-places itself. Only
|
||||||
|
// the content height is watched: measure() sets max-height, so observing
|
||||||
|
// the panel itself would feed its own change back in.
|
||||||
|
const body = node.firstElementChild;
|
||||||
|
const watcher =
|
||||||
|
typeof ResizeObserver === "undefined" || !body
|
||||||
|
? null
|
||||||
|
: new ResizeObserver(reflow);
|
||||||
|
if (body) watcher?.observe(body);
|
||||||
|
win.addEventListener("keydown", keys, true);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
document.removeEventListener("pointerdown", outside, true);
|
document.removeEventListener("pointerdown", outside, true);
|
||||||
window.removeEventListener("resize", reflow);
|
window.removeEventListener("resize", reflow);
|
||||||
|
vv?.removeEventListener("resize", settle);
|
||||||
|
vv?.removeEventListener("scroll", settle);
|
||||||
|
for (const name of KEYBOARD_EVENTS) {
|
||||||
|
win.removeEventListener(name, settle);
|
||||||
|
}
|
||||||
|
node.removeEventListener("focusin", settle);
|
||||||
|
if (frame !== null) win.cancelAnimationFrame(frame);
|
||||||
|
watcher?.disconnect();
|
||||||
|
win.removeEventListener("keydown", keys, true);
|
||||||
panel = null;
|
panel = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read at the window's capture phase rather than on the panel itself.
|
||||||
|
* Obsidian's keymap listens there first, and a plugin that claims a key
|
||||||
|
* through `app.scope` — Tasks claims Tab — stops the event before it ever
|
||||||
|
* reaches an element handler. A listener on the same node still runs after
|
||||||
|
* that, so this is the one place the panel's keys cannot be taken away.
|
||||||
|
*/
|
||||||
function keys(event: KeyboardEvent): void {
|
function keys(event: KeyboardEvent): void {
|
||||||
|
if (!panel?.contains(event.target as Node)) return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (event.key === "Escape" && !event.defaultPrevented) {
|
if (event.key === "Escape" && !event.defaultPrevented) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -91,15 +178,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Portal to={view.portal()}>
|
<Portal to={view.portal()}>
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
"fixed z-50 overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-[0_16px_40px_-12px_rgb(0_0_0/45%)] outline-none",
|
"fixed z-50 flex flex-col overflow-y-auto overscroll-contain rounded-xl border border-border bg-popover text-popover-foreground shadow-[0_16px_40px_-12px_rgb(0_0_0/45%)] outline-none",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onkeydown={keys}
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
style="top: {at.top}px; left: {at.left}px; opacity: {placed ? 1 : 0}"
|
style="top: {at.top}px; left: {at.left}px; max-height: {room}px; opacity: {placed
|
||||||
|
? 1
|
||||||
|
: 0}"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
use:mount
|
use:mount
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export async function handleDrop(
|
|||||||
if (id === dragged.id) return;
|
if (id === dragged.id) return;
|
||||||
const onto = store.all.find((task) => task.id === id);
|
const onto = store.all.find((task) => task.id === id);
|
||||||
if (!onto) return;
|
if (!onto) return;
|
||||||
|
// Archived cards are read-only ground: they live in the mirror file, and
|
||||||
|
// dropping onto one has no lane to mean.
|
||||||
|
if (onto.inArchive) return;
|
||||||
|
|
||||||
if (onto.path === dragged.path && onto.project === dragged.project) {
|
if (onto.path === dragged.path && onto.project === dragged.project) {
|
||||||
const board = store.boardFor(onto.path);
|
const board = store.boardFor(onto.path);
|
||||||
|
|||||||
@@ -82,7 +82,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={box} class={cn("max-h-56 overflow-y-auto", className)}>
|
<div bind:this={box} class={cn("max-h-56 overflow-y-auto", className)}>
|
||||||
{#each shown as { item, ranges }, at (item.value)}
|
<!-- Keyed by position, not by value: a caller that offers the same value
|
||||||
|
twice should read oddly, never tear the list down. -->
|
||||||
|
{#each shown as { item, ranges }, at (`${at}:${item.value}`)}
|
||||||
<button
|
<button
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors",
|
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors",
|
||||||
|
|||||||
@@ -82,13 +82,22 @@
|
|||||||
label,
|
label,
|
||||||
hint: monthDay.format(fromKey(key)),
|
hint: monthDay.format(fromKey(key)),
|
||||||
});
|
});
|
||||||
return [
|
const offers = [
|
||||||
{ value: "", label: "No date" },
|
{ value: "", label: "No date" },
|
||||||
on(now, "Today"),
|
on(now, "Today"),
|
||||||
on(shiftDays(now, 1), "Tomorrow"),
|
on(shiftDays(now, 1), "Tomorrow"),
|
||||||
on(nextFriday(now), "Friday"),
|
on(nextFriday(now), "Friday"),
|
||||||
on(shiftDays(now, 7), "In a week"),
|
on(shiftDays(now, 7), "In a week"),
|
||||||
];
|
];
|
||||||
|
// Two offers can land on the same day - a Saturday's "Friday" is also
|
||||||
|
// "In a week" - and the list is keyed by the day it writes. The first
|
||||||
|
// name wins, which is the more specific one.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return offers.filter((option) => {
|
||||||
|
if (seen.has(option.value)) return false;
|
||||||
|
seen.add(option.value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const repeatOptions = $derived<Option[]>([
|
const repeatOptions = $derived<Option[]>([
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import ExternalLink from "@lucide/svelte/icons/external-link";
|
|||||||
import FileText from "@lucide/svelte/icons/file-text";
|
import FileText from "@lucide/svelte/icons/file-text";
|
||||||
import PenLine from "@lucide/svelte/icons/pen-line";
|
import PenLine from "@lucide/svelte/icons/pen-line";
|
||||||
import ScanEye from "@lucide/svelte/icons/scan-eye";
|
import ScanEye from "@lucide/svelte/icons/scan-eye";
|
||||||
|
import SquarePen from "@lucide/svelte/icons/square-pen";
|
||||||
import { STATUS_META } from "../lib/vocab";
|
import { STATUS_META } from "../lib/vocab";
|
||||||
import { STATUS_ORDER } from "../model/types";
|
import { STATUS_ORDER } from "../model/types";
|
||||||
import type { Task } from "../model/types";
|
import type { Task } from "../model/types";
|
||||||
@@ -60,6 +61,15 @@ export function taskMenu(view: ViewContext, task: Task): MenuItem[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: PenLine,
|
icon: PenLine,
|
||||||
|
label: "Edit",
|
||||||
|
hint: "e",
|
||||||
|
onselect: () => {
|
||||||
|
view.store.cursorTask = task.id;
|
||||||
|
view.store.edit(task.id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: SquarePen,
|
||||||
label: "Edit in Tasks",
|
label: "Edit in Tasks",
|
||||||
onselect: () => void editInTasks(view, task),
|
onselect: () => void editInTasks(view, task),
|
||||||
},
|
},
|
||||||
|
|||||||
+38
-8
@@ -14,7 +14,7 @@
|
|||||||
import { dueLabel, dueTone, fromKey } from "../lib/due";
|
import { dueLabel, dueTone, fromKey } from "../lib/due";
|
||||||
import { labelHue } from "../lib/hue";
|
import { labelHue } from "../lib/hue";
|
||||||
import type { Action } from "../lib/keymap";
|
import type { Action } from "../lib/keymap";
|
||||||
import { onScreen } from "../lib/place";
|
import { onScreen, viewport } from "../lib/place";
|
||||||
import type { Box } from "../lib/place";
|
import type { Box } from "../lib/place";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { PRIORITY_LABEL, STATUS_META } from "../lib/vocab";
|
import { PRIORITY_LABEL, STATUS_META } from "../lib/vocab";
|
||||||
@@ -63,6 +63,8 @@
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every row is a button first and a key second: on a phone the keys are not
|
||||||
|
// there to be pressed, so the lens has to be operable by thumb alone.
|
||||||
const rows = $derived<Row[]>(
|
const rows = $derived<Row[]>(
|
||||||
task
|
task
|
||||||
? [
|
? [
|
||||||
@@ -72,6 +74,7 @@
|
|||||||
{ action: "taskBack", keys: ["⇧h"], label: "−day" },
|
{ action: "taskBack", keys: ["⇧h"], label: "−day" },
|
||||||
{ action: "taskForward", keys: ["⇧l"], label: "+day" },
|
{ action: "taskForward", keys: ["⇧l"], label: "+day" },
|
||||||
{ action: "taskToday", keys: ["⇧t"], label: "today" },
|
{ action: "taskToday", keys: ["⇧t"], label: "today" },
|
||||||
|
{ action: "taskUnschedule", keys: ["⇧x"], label: "no date" },
|
||||||
{ action: "taskOpen", keys: ["⏎"], label: "open note" },
|
{ action: "taskOpen", keys: ["⏎"], label: "open note" },
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
@@ -85,13 +88,23 @@
|
|||||||
* the middle, the way a window feels centred rather than measures it.
|
* the middle, the way a window feels centred rather than measures it.
|
||||||
*/
|
*/
|
||||||
function centre(node: HTMLElement): void {
|
function centre(node: HTMLElement): void {
|
||||||
const box = view.rootBox() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);
|
const win = node.ownerDocument.defaultView ?? window;
|
||||||
|
const area = viewport(win);
|
||||||
|
const box =
|
||||||
|
view.rootBox() ??
|
||||||
|
new DOMRect(area.left ?? 0, area.top ?? 0, area.width, area.height);
|
||||||
const width = node.offsetWidth;
|
const width = node.offsetWidth;
|
||||||
const height = node.offsetHeight;
|
const height = node.offsetHeight;
|
||||||
const left = box.left + (box.width - width) / 2;
|
const left = box.left + (box.width - width) / 2;
|
||||||
const top = box.top + (box.height - height) * 0.42;
|
const top = box.top + (box.height - height) * 0.42;
|
||||||
node.style.left = `${Math.max(8, Math.min(left, window.innerWidth - width - 8))}px`;
|
// Kept inside what is actually visible: a phone's keyboard shrinks the
|
||||||
node.style.top = `${Math.max(8, Math.min(top, window.innerHeight - height - 8))}px`;
|
// viewport without touching the window, and the lens must stay reachable.
|
||||||
|
const minLeft = (area.left ?? 0) + 8;
|
||||||
|
const maxLeft = (area.left ?? 0) + area.width - width - 8;
|
||||||
|
const minTop = (area.top ?? 0) + 8;
|
||||||
|
const maxTop = (area.top ?? 0) + area.height - height - 8;
|
||||||
|
node.style.left = `${Math.max(minLeft, Math.min(left, Math.max(minLeft, maxLeft)))}px`;
|
||||||
|
node.style.top = `${Math.max(minTop, Math.min(top, Math.max(minTop, maxTop)))}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The row or card under the cursor, wherever the current view draws it. */
|
/** The row or card under the cursor, wherever the current view draws it. */
|
||||||
@@ -151,6 +164,11 @@
|
|||||||
watcher?.observe(node);
|
watcher?.observe(node);
|
||||||
const reflow = (): void => centre(node);
|
const reflow = (): void => centre(node);
|
||||||
window.addEventListener("resize", reflow);
|
window.addEventListener("resize", reflow);
|
||||||
|
// A keyboard (an editor opening behind the lens) moves the visible area
|
||||||
|
// without resizing the window.
|
||||||
|
const vv = (node.ownerDocument.defaultView ?? window).visualViewport;
|
||||||
|
vv?.addEventListener("resize", reflow);
|
||||||
|
vv?.addEventListener("scroll", reflow);
|
||||||
|
|
||||||
// A click on another task moves the lens rather than closing it; a click
|
// A click on another task moves the lens rather than closing it; a click
|
||||||
// anywhere else puts it away.
|
// anywhere else puts it away.
|
||||||
@@ -166,6 +184,8 @@
|
|||||||
destroy() {
|
destroy() {
|
||||||
watcher?.disconnect();
|
watcher?.disconnect();
|
||||||
window.removeEventListener("resize", reflow);
|
window.removeEventListener("resize", reflow);
|
||||||
|
vv?.removeEventListener("resize", reflow);
|
||||||
|
vv?.removeEventListener("scroll", reflow);
|
||||||
document.removeEventListener("pointerdown", outside, true);
|
document.removeEventListener("pointerdown", outside, true);
|
||||||
// Only take the keyboard back if the lens had it; an editor that has
|
// Only take the keyboard back if the lens had it; an editor that has
|
||||||
// just opened keeps its caret.
|
// just opened keeps its caret.
|
||||||
@@ -348,23 +368,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center gap-x-0.5 gap-y-0.5 border-border/70 border-t bg-secondary/25 px-2 py-1.5 text-[11px] text-muted-foreground"
|
class="flex flex-wrap items-center gap-x-0.5 gap-y-0.5 border-border/70 border-t bg-secondary/25 px-2 py-1.5 text-[11px] text-muted-foreground pointer-coarse:gap-x-1.5 pointer-coarse:gap-y-1.5 pointer-coarse:p-2"
|
||||||
>
|
>
|
||||||
{#each rows as row (row.action)}
|
{#each rows as row (row.action)}
|
||||||
<button
|
<button
|
||||||
class="inline-flex h-6 items-center gap-1 whitespace-nowrap rounded-md px-1.5 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
class="inline-flex h-6 items-center gap-1 whitespace-nowrap rounded-md px-1.5 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-9 pointer-coarse:border pointer-coarse:border-border pointer-coarse:bg-popover pointer-coarse:px-3 pointer-coarse:text-xs pointer-coarse:text-foreground"
|
||||||
onclick={() => act(row.action)}
|
onclick={() => act(row.action)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{#each row.keys as key (key)}
|
{#each row.keys as key (key)}
|
||||||
<kbd class="kbd">{key}</kbd>
|
<kbd class="kbd pointer-coarse:hidden">{key}</kbd>
|
||||||
{/each}
|
{/each}
|
||||||
{row.label}
|
{row.label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
<span class="ml-auto whitespace-nowrap px-1.5">
|
<span class="ml-auto whitespace-nowrap px-1.5 pointer-coarse:hidden">
|
||||||
<kbd class="kbd">␣</kbd> close
|
<kbd class="kbd">␣</kbd> close
|
||||||
</span>
|
</span>
|
||||||
|
<!-- No Escape key on a phone, so the lens needs a way out by thumb. -->
|
||||||
|
<button
|
||||||
|
class="ml-auto hidden h-9 items-center rounded-md border border-border bg-popover px-3 text-foreground text-xs pointer-coarse:inline-flex"
|
||||||
|
onclick={() => {
|
||||||
|
store.peeking = false;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
close
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|||||||
+119
-26
@@ -1,4 +1,4 @@
|
|||||||
import { findLane, laneInsertPoint, parseBoard } from "../model/board";
|
import { bodyEnd, findLane, laneInsertPoint, parseBoard } from "../model/board";
|
||||||
import type { Task } from "../model/types";
|
import type { Task } from "../model/types";
|
||||||
|
|
||||||
export class StaleTaskError extends Error {
|
export class StaleTaskError extends Error {
|
||||||
@@ -101,46 +101,139 @@ export function applyReorder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_OPEN = /^%%\s*kanban:settings\s*$/u;
|
// ── The archive ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function applyArchive(
|
const SEPARATOR = /^\*\*\*\s*$/u;
|
||||||
|
|
||||||
|
/** Where loose cards land when a native archive section carries no heading. */
|
||||||
|
const LOOSE_LANE = "Archive";
|
||||||
|
|
||||||
|
/** A fresh mirror file: enough front matter for Kanban to open it as a board. */
|
||||||
|
export const ARCHIVE_SEED = ["---", "", "kanban-plugin: board", "", "---", ""];
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
project: string;
|
||||||
|
block: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Harvest {
|
||||||
|
/** Completed cards taken off the board, each with the lane it sat in. */
|
||||||
|
cards: Section[];
|
||||||
|
/** The board's native `***` archive, lifted whole, lane by lane. */
|
||||||
|
legacy: Section[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimBlanks(block: string[]): string[] {
|
||||||
|
let from = 0;
|
||||||
|
let to = block.length;
|
||||||
|
while (from < to && block[from].trim() === "") from += 1;
|
||||||
|
while (to > from && block[to - 1].trim() === "") to -= 1;
|
||||||
|
return block.slice(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits a run of board lines into its `## lane` sections, content and all. */
|
||||||
|
function sections(block: string[]): Section[] {
|
||||||
|
const out: Section[] = [];
|
||||||
|
let current: Section | null = null;
|
||||||
|
for (const line of block) {
|
||||||
|
const heading = /^##\s+(.*?)\s*$/u.exec(line);
|
||||||
|
if (heading) {
|
||||||
|
current = { project: heading[1], block: [] };
|
||||||
|
out.push(current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!current) {
|
||||||
|
if (line.trim() === "") continue;
|
||||||
|
current = { project: LOOSE_LANE, block: [] };
|
||||||
|
out.push(current);
|
||||||
|
}
|
||||||
|
current.block.push(line);
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
.map((section) => ({ ...section, block: trimBlanks(section.block) }))
|
||||||
|
.filter((section) => section.block.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the completed cards off a board, and with `migrate` the native archive
|
||||||
|
* below the `***` separator as well - the mirror file is where history lives
|
||||||
|
* now, so the separator goes with it rather than growing forever in place.
|
||||||
|
* Mutates `lines`; what it returns is what the mirror has to be given.
|
||||||
|
*/
|
||||||
|
export function takeDone(
|
||||||
lines: string[],
|
lines: string[],
|
||||||
path: string,
|
path: string,
|
||||||
archiveHeading: string,
|
migrate: boolean,
|
||||||
): number {
|
): Harvest {
|
||||||
const board = parseBoard(path, lines.join("\n"));
|
const board = parseBoard(path, lines.join("\n"));
|
||||||
const done = board.tasks.filter(
|
const done = board.tasks.filter(
|
||||||
(task) => !task.inArchive && (task.status === "x" || task.status === "-"),
|
(task) => !task.inArchive && (task.status === "x" || task.status === "-"),
|
||||||
);
|
);
|
||||||
if (done.length === 0) return 0;
|
|
||||||
|
|
||||||
const blocks: string[][] = [];
|
const cards: Section[] = [];
|
||||||
for (const task of [...done].reverse()) {
|
for (const task of [...done].reverse()) {
|
||||||
const at = locate(lines, task);
|
const at = locate(lines, task);
|
||||||
blocks.unshift(lines.splice(at, extent(lines, at, task)));
|
cards.unshift({
|
||||||
|
project: task.project,
|
||||||
|
block: lines.splice(at, extent(lines, at, task)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const heading = `## ${archiveHeading}`;
|
let legacy: Section[] = [];
|
||||||
let archiveAt = lines.findIndex((line) => line.trim() === heading);
|
if (migrate) {
|
||||||
if (archiveAt === -1) {
|
const from = lines.findIndex((line) => SEPARATOR.test(line));
|
||||||
const settingsAt = lines.findIndex((line) => SETTINGS_OPEN.test(line));
|
if (from !== -1) {
|
||||||
let tail = settingsAt === -1 ? lines.length : settingsAt;
|
const taken = lines.splice(from, bodyEnd(lines) - from);
|
||||||
|
legacy = sections(taken.slice(1));
|
||||||
|
// Close the gap the section left, in the spacing the boards already use.
|
||||||
|
let tail = from;
|
||||||
while (tail > 0 && lines[tail - 1].trim() === "") tail -= 1;
|
while (tail > 0 && lines[tail - 1].trim() === "") tail -= 1;
|
||||||
lines.splice(tail, 0, "", "***", "", heading, "");
|
const pad = from < lines.length ? ["", ""] : [""];
|
||||||
archiveAt = tail + 3;
|
lines.splice(tail, from - tail, ...pad);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let insertAt = archiveAt + 1;
|
return { cards, legacy };
|
||||||
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;
|
/** Appends a block to a lane of a mirror file, opening the lane if it is new. */
|
||||||
|
function appendToLane(
|
||||||
|
lines: string[],
|
||||||
|
path: string,
|
||||||
|
project: string,
|
||||||
|
block: string[],
|
||||||
|
): void {
|
||||||
|
const board = parseBoard(path, lines.join("\n"), true);
|
||||||
|
const lane = findLane(board, project, true);
|
||||||
|
if (lane) {
|
||||||
|
lines.splice(laneInsertPoint(board, lane), 0, ...block);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tail = bodyEnd(lines);
|
||||||
|
while (tail > 0 && lines[tail - 1].trim() === "") tail -= 1;
|
||||||
|
// The boards leave one blank line under the front matter and two between
|
||||||
|
// lanes; a mirror nobody has to squint at reads the same way.
|
||||||
|
const lead = board.lanes.length > 0 ? ["", ""] : [""];
|
||||||
|
lines.splice(tail, 0, ...lead, `## ${project}`, "", ...block, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a harvest into its mirror file. Cards join the lane they came from,
|
||||||
|
* so the mirror reads like the board it shadows; the old native archive keeps
|
||||||
|
* its own headings, whatever they were called.
|
||||||
|
*/
|
||||||
|
export function fillArchive(
|
||||||
|
lines: string[],
|
||||||
|
path: string,
|
||||||
|
harvest: Harvest,
|
||||||
|
): void {
|
||||||
|
if (lines.every((line) => line.trim() === "")) {
|
||||||
|
lines.splice(0, lines.length, ...ARCHIVE_SEED);
|
||||||
|
}
|
||||||
|
for (const { project, block } of [...harvest.cards, ...harvest.legacy]) {
|
||||||
|
appendToLane(lines, path, project, block);
|
||||||
|
}
|
||||||
|
while (lines.length > 1 && lines.at(-1) === "" && lines.at(-2) === "") {
|
||||||
|
lines.pop();
|
||||||
}
|
}
|
||||||
lines.splice(insertAt, 0, ...blocks.flat());
|
|
||||||
return blocks.length;
|
|
||||||
}
|
}
|
||||||
+87
-8
@@ -7,15 +7,17 @@ import { tasksApi, warnMissingTasks } from "../tasks-api";
|
|||||||
import { today } from "../lib/due";
|
import { today } from "../lib/due";
|
||||||
import { record, transaction } from "./history";
|
import { record, transaction } from "./history";
|
||||||
import {
|
import {
|
||||||
applyArchive,
|
|
||||||
applyReorder,
|
applyReorder,
|
||||||
|
ARCHIVE_SEED,
|
||||||
cutTask,
|
cutTask,
|
||||||
extent,
|
extent,
|
||||||
|
fillArchive,
|
||||||
insertBlock,
|
insertBlock,
|
||||||
locate,
|
locate,
|
||||||
MissingLaneError,
|
MissingLaneError,
|
||||||
replaceLine,
|
replaceLine,
|
||||||
StaleTaskError,
|
StaleTaskError,
|
||||||
|
takeDone,
|
||||||
} from "./edits";
|
} from "./edits";
|
||||||
|
|
||||||
async function edit(
|
async function edit(
|
||||||
@@ -266,14 +268,91 @@ export async function appendRawTask(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function archiveDone(
|
/** Replaces a whole file, but only if it still reads the way it was read. */
|
||||||
|
async function overwrite(
|
||||||
app: App,
|
app: App,
|
||||||
path: string,
|
path: string,
|
||||||
archiveHeading: string,
|
before: string,
|
||||||
): Promise<number> {
|
after: string,
|
||||||
let moved = 0;
|
): Promise<boolean> {
|
||||||
await edit(app, path, (lines) => {
|
const file = app.vault.getAbstractFileByPath(path);
|
||||||
moved = applyArchive(lines, path, archiveHeading);
|
if (!(file instanceof TFile)) return false;
|
||||||
|
let ok = true;
|
||||||
|
await app.vault.process(file, (data) => {
|
||||||
|
if (data !== before) {
|
||||||
|
ok = false;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
record(path, data, after);
|
||||||
|
return after;
|
||||||
});
|
});
|
||||||
return moved;
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mirror file, made along with any folder it needs. */
|
||||||
|
async function openArchive(app: App, path: string): Promise<TFile | null> {
|
||||||
|
const existing = app.vault.getAbstractFileByPath(path);
|
||||||
|
if (existing instanceof TFile) return existing;
|
||||||
|
|
||||||
|
const parts = path.split("/");
|
||||||
|
parts.pop();
|
||||||
|
let folder = "";
|
||||||
|
for (const part of parts) {
|
||||||
|
folder = folder ? `${folder}/${part}` : part;
|
||||||
|
if (app.vault.getAbstractFileByPath(folder)) continue;
|
||||||
|
try {
|
||||||
|
await app.vault.createFolder(folder);
|
||||||
|
} catch {
|
||||||
|
/* another writer got there first, which is all we wanted */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await app.vault.create(path, ARCHIVE_SEED.join("\n"));
|
||||||
|
} catch {
|
||||||
|
new Notice(`Beaver Calendar: could not create ${path}`, 6000);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveOutcome {
|
||||||
|
/** Completed cards moved out of the board. */
|
||||||
|
moved: number;
|
||||||
|
/** Whether the board's native `***` archive went with them. */
|
||||||
|
migrated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empties a board of what is finished with it: the completed cards, and with
|
||||||
|
* `migrate` the native archive section too. The mirror is written first, so a
|
||||||
|
* board only loses lines once the file that is to keep them has them.
|
||||||
|
*/
|
||||||
|
export async function archiveBoard(
|
||||||
|
app: App,
|
||||||
|
path: string,
|
||||||
|
archivePath: string,
|
||||||
|
migrate: boolean,
|
||||||
|
): Promise<ArchiveOutcome> {
|
||||||
|
const nothing: ArchiveOutcome = { moved: 0, migrated: false };
|
||||||
|
const source = app.vault.getAbstractFileByPath(path);
|
||||||
|
if (!(source instanceof TFile)) return nothing;
|
||||||
|
|
||||||
|
const before = await app.vault.read(source);
|
||||||
|
const lines = before.split("\n");
|
||||||
|
const harvest = takeDone(lines, path, migrate);
|
||||||
|
if (harvest.cards.length === 0 && harvest.legacy.length === 0) return nothing;
|
||||||
|
|
||||||
|
if (!(await openArchive(app, archivePath))) return nothing;
|
||||||
|
const pasted = await edit(app, archivePath, (into) => {
|
||||||
|
fillArchive(into, archivePath, harvest);
|
||||||
|
});
|
||||||
|
if (!pasted) return nothing;
|
||||||
|
|
||||||
|
if (!(await overwrite(app, path, before, lines.join("\n")))) {
|
||||||
|
new Notice(
|
||||||
|
`Beaver Calendar: ${path} changed mid-archive, undo to put it back`,
|
||||||
|
6000,
|
||||||
|
);
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
return { moved: harvest.cards.length, migrated: harvest.legacy.length > 0 };
|
||||||
}
|
}
|
||||||
+62
-16
@@ -1,5 +1,6 @@
|
|||||||
import { TFile } from "obsidian";
|
import { TFile } from "obsidian";
|
||||||
import type { App } from "obsidian";
|
import type { App } from "obsidian";
|
||||||
|
import { isArchivePath, toBoardPath } from "../model/archive";
|
||||||
import { parseBoard, sphereOf } from "../model/board";
|
import { parseBoard, sphereOf } from "../model/board";
|
||||||
import { anchorDate, isOpen, STATUS_ORDER } from "../model/types";
|
import { anchorDate, isOpen, STATUS_ORDER } from "../model/types";
|
||||||
import type { Board, Status, Task } from "../model/types";
|
import type { Board, Status, Task } from "../model/types";
|
||||||
@@ -91,6 +92,9 @@ function compareTasks(sortBy: SortBy, a: Task, b: Task): number {
|
|||||||
|
|
||||||
export class BoardStore {
|
export class BoardStore {
|
||||||
private readonly boards = new Map<string, Board>();
|
private readonly boards = new Map<string, Board>();
|
||||||
|
/** The mirror files under the archive folder, kept apart from the boards
|
||||||
|
* they shadow: they are history, not lanes to write into. */
|
||||||
|
private readonly archives = new Map<string, Board>();
|
||||||
private reloadTimer: number | null = null;
|
private reloadTimer: number | null = null;
|
||||||
private readonly pending = new Set<string>();
|
private readonly pending = new Set<string>();
|
||||||
|
|
||||||
@@ -228,13 +232,31 @@ export class BoardStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private inScope(path: string): boolean {
|
private inScope(path: string): boolean {
|
||||||
|
if (!path.endsWith(".md")) return false;
|
||||||
const folder = this.settings.boardsFolder;
|
const folder = this.settings.boardsFolder;
|
||||||
if (!folder) return path.endsWith(".md");
|
return !folder || path.startsWith(`${folder}/`);
|
||||||
return path.startsWith(`${folder}/`) && path.endsWith(".md");
|
}
|
||||||
|
|
||||||
|
private isArchive(path: string): boolean {
|
||||||
|
return isArchivePath(
|
||||||
|
path,
|
||||||
|
this.settings.boardsFolder,
|
||||||
|
this.settings.archiveFolder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The board a path belongs to: a mirror file answers with what it shadows. */
|
||||||
|
private boardPath(path: string): string {
|
||||||
|
return toBoardPath(
|
||||||
|
path,
|
||||||
|
this.settings.boardsFolder,
|
||||||
|
this.settings.archiveFolder,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reloadAll(): Promise<void> {
|
async reloadAll(): Promise<void> {
|
||||||
this.boards.clear();
|
this.boards.clear();
|
||||||
|
this.archives.clear();
|
||||||
const files = this.app.vault
|
const files = this.app.vault
|
||||||
.getMarkdownFiles()
|
.getMarkdownFiles()
|
||||||
.filter((file) => this.inScope(file.path));
|
.filter((file) => this.inScope(file.path));
|
||||||
@@ -244,7 +266,14 @@ export class BoardStore {
|
|||||||
|
|
||||||
private async load(file: TFile): Promise<void> {
|
private async load(file: TFile): Promise<void> {
|
||||||
const content = await this.app.vault.cachedRead(file);
|
const content = await this.app.vault.cachedRead(file);
|
||||||
this.boards.set(file.path, parseBoard(file.path, content));
|
this.keep(file.path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private keep(path: string, content: string): void {
|
||||||
|
const archived = this.isArchive(path);
|
||||||
|
const into = archived ? this.archives : this.boards;
|
||||||
|
(archived ? this.boards : this.archives).delete(path);
|
||||||
|
into.set(path, parseBoard(path, content, archived));
|
||||||
}
|
}
|
||||||
|
|
||||||
queueReload(path: string): void {
|
queueReload(path: string): void {
|
||||||
@@ -264,20 +293,21 @@ export class BoardStore {
|
|||||||
const file = this.app.vault.getAbstractFileByPath(path);
|
const file = this.app.vault.getAbstractFileByPath(path);
|
||||||
if (!(file instanceof TFile)) {
|
if (!(file instanceof TFile)) {
|
||||||
this.boards.delete(path);
|
this.boards.delete(path);
|
||||||
|
this.archives.delete(path);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const content = await this.app.vault.cachedRead(file);
|
this.keep(path, await this.app.vault.cachedRead(file));
|
||||||
this.boards.set(path, parseBoard(path, content));
|
|
||||||
}
|
}
|
||||||
this.version += 1;
|
this.version += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
drop(path: string): void {
|
drop(path: string): void {
|
||||||
if (this.boards.delete(path)) this.version += 1;
|
const gone = this.boards.delete(path) || this.archives.delete(path);
|
||||||
|
if (gone) this.version += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
boardFor(path: string): Board | undefined {
|
boardFor(path: string): Board | undefined {
|
||||||
return this.boards.get(path);
|
return this.boards.get(path) ?? this.archives.get(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
all = $derived.by<Task[]>(() => {
|
all = $derived.by<Task[]>(() => {
|
||||||
@@ -288,6 +318,9 @@ export class BoardStore {
|
|||||||
if (this.includeArchive || !task.inArchive) out.push(task);
|
if (this.includeArchive || !task.inArchive) out.push(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this.includeArchive) {
|
||||||
|
for (const board of this.archives.values()) out.push(...board.tasks);
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,8 +366,9 @@ export class BoardStore {
|
|||||||
const byProject = new Map<string, number>();
|
const byProject = new Map<string, number>();
|
||||||
for (const task of this.all) {
|
for (const task of this.all) {
|
||||||
if (!this.matches(task)) continue;
|
if (!this.matches(task)) continue;
|
||||||
byPath.set(task.path, (byPath.get(task.path) ?? 0) + 1);
|
const path = this.boardPath(task.path);
|
||||||
const key = laneKey(task.path, task.project);
|
byPath.set(path, (byPath.get(path) ?? 0) + 1);
|
||||||
|
const key = laneKey(path, task.project);
|
||||||
byProject.set(key, (byProject.get(key) ?? 0) + 1);
|
byProject.set(key, (byProject.get(key) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
return { byPath, byProject };
|
return { byPath, byProject };
|
||||||
@@ -356,11 +390,16 @@ export class BoardStore {
|
|||||||
);
|
);
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
const first = group.tasks[0];
|
const first = group.tasks[0];
|
||||||
|
const path = this.boardPath(first.path);
|
||||||
|
// A group only offers a composer when every card in it shares one live
|
||||||
|
// lane; archived ones ride along, so they are read through their board.
|
||||||
const single = group.tasks.every(
|
const single = group.tasks.every(
|
||||||
(task) => task.path === first.path && task.project === first.project,
|
(task) =>
|
||||||
|
this.boardPath(task.path) === path && task.project === first.project,
|
||||||
);
|
);
|
||||||
group.lane = single
|
group.lane =
|
||||||
? { path: first.path, project: first.project }
|
single && this.boards.has(path)
|
||||||
|
? { path, project: first.project }
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
@@ -369,7 +408,7 @@ export class BoardStore {
|
|||||||
private slotFor(task: Task): Omit<Group, "tasks" | "lane"> {
|
private slotFor(task: Task): Omit<Group, "tasks" | "lane"> {
|
||||||
if (this.groupBy === "project") {
|
if (this.groupBy === "project") {
|
||||||
return {
|
return {
|
||||||
key: laneKey(task.path, task.project),
|
key: laneKey(this.boardPath(task.path), task.project),
|
||||||
label: task.project,
|
label: task.project,
|
||||||
hint: task.sphere,
|
hint: task.sphere,
|
||||||
rank: 0,
|
rank: 0,
|
||||||
@@ -387,7 +426,12 @@ export class BoardStore {
|
|||||||
const bucket = dueBucket(anchorDate(task), this.today);
|
const bucket = dueBucket(anchorDate(task), this.today);
|
||||||
return { key: bucket.key, label: bucket.label, hint: "", rank: bucket.rank };
|
return { key: bucket.key, label: bucket.label, hint: "", rank: bucket.rank };
|
||||||
}
|
}
|
||||||
return { key: task.path, label: task.sphere, hint: "", rank: 0 };
|
return {
|
||||||
|
key: this.boardPath(task.path),
|
||||||
|
label: task.sphere,
|
||||||
|
hint: "",
|
||||||
|
rank: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
byDay = $derived.by(() => {
|
byDay = $derived.by(() => {
|
||||||
@@ -463,7 +507,7 @@ export class BoardStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sphereName(path: string): string {
|
sphereName(path: string): string {
|
||||||
return this.boards.get(path)?.sphere ?? sphereOf(path);
|
return this.boardFor(path)?.sphere ?? sphereOf(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Keyboard cursor ──────────────────────────────────────────────────────
|
// ── Keyboard cursor ──────────────────────────────────────────────────────
|
||||||
@@ -714,7 +758,9 @@ export class BoardStore {
|
|||||||
throughRail(task: Task): boolean {
|
throughRail(task: Task): boolean {
|
||||||
if (this.lanes === null) return true;
|
if (this.lanes === null) return true;
|
||||||
// Archived tasks have no lane of their own; they ride with their sphere.
|
// Archived tasks have no lane of their own; they ride with their sphere.
|
||||||
if (task.inArchive) return this.sphereState(task.path) !== "none";
|
if (task.inArchive) {
|
||||||
|
return this.sphereState(this.boardPath(task.path)) !== "none";
|
||||||
|
}
|
||||||
return this.lanes.has(laneKey(task.path, task.project));
|
return this.lanes.has(laneKey(task.path, task.project));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+93
@@ -1,13 +1,29 @@
|
|||||||
import { ItemView } from "obsidian";
|
import { ItemView } from "obsidian";
|
||||||
import type { WorkspaceLeaf } from "obsidian";
|
import type { WorkspaceLeaf } from "obsidian";
|
||||||
import { mount, unmount } from "svelte";
|
import { mount, unmount } from "svelte";
|
||||||
|
import { BOTTOM_BARS, insetFor } from "./lib/inset";
|
||||||
|
import type { Rect } from "./lib/inset";
|
||||||
import App from "./ui/app.svelte";
|
import App from "./ui/app.svelte";
|
||||||
import type BeaverCalendarPlugin from "./main";
|
import type BeaverCalendarPlugin from "./main";
|
||||||
|
|
||||||
export const VIEW_TYPE_BOARDS = "beaver-calendar-boards";
|
export const VIEW_TYPE_BOARDS = "beaver-calendar-boards";
|
||||||
|
|
||||||
|
// The keyboard and the navbar both animate, and Obsidian hides the navbar
|
||||||
|
// *after* the keyboard is up: the inset is re-measured every frame until the
|
||||||
|
// reading has held still long enough to outlast both moves.
|
||||||
|
const STEADY_FRAMES = 12;
|
||||||
|
|
||||||
|
/** Obsidian's Capacitor keyboard events on mobile; absent on the desktop. */
|
||||||
|
const KEYBOARD_EVENTS = [
|
||||||
|
"keyboardWillShow",
|
||||||
|
"keyboardDidShow",
|
||||||
|
"keyboardWillHide",
|
||||||
|
"keyboardDidHide",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export class BoardsView extends ItemView {
|
export class BoardsView extends ItemView {
|
||||||
private root: Record<string, unknown> | null = null;
|
private root: Record<string, unknown> | null = null;
|
||||||
|
private settling: number | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
leaf: WorkspaceLeaf,
|
leaf: WorkspaceLeaf,
|
||||||
@@ -51,6 +67,30 @@ export class BoardsView extends ItemView {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.registerEvent(this.app.workspace.on("resize", () => this.settle()));
|
||||||
|
this.registerEvent(
|
||||||
|
this.app.workspace.on("layout-change", () => this.settle()),
|
||||||
|
);
|
||||||
|
// The keyboard shrinks the visual viewport without resizing the layout, so
|
||||||
|
// no workspace event fires - and in a sidedock none fires at all.
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (vv) {
|
||||||
|
const onViewport = () => this.settle();
|
||||||
|
vv.addEventListener("resize", onViewport);
|
||||||
|
vv.addEventListener("scroll", onViewport);
|
||||||
|
this.register(() => {
|
||||||
|
vv.removeEventListener("resize", onViewport);
|
||||||
|
vv.removeEventListener("scroll", onViewport);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.registerDomEvent(this.contentEl, "focusin", () => this.settle());
|
||||||
|
this.registerDomEvent(this.contentEl, "focusout", () => this.settle());
|
||||||
|
for (const name of KEYBOARD_EVENTS) {
|
||||||
|
const onKeyboard = () => this.settle();
|
||||||
|
window.addEventListener(name, onKeyboard);
|
||||||
|
this.register(() => window.removeEventListener(name, onKeyboard));
|
||||||
|
}
|
||||||
|
|
||||||
await this.plugin.store.reloadAll();
|
await this.plugin.store.reloadAll();
|
||||||
this.root = mount(App, {
|
this.root = mount(App, {
|
||||||
target: this.contentEl,
|
target: this.contentEl,
|
||||||
@@ -61,6 +101,59 @@ export class BoardsView extends ItemView {
|
|||||||
component: this,
|
component: this,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-measure until the viewport stops moving: the keyboard animates both
|
||||||
|
* ways, Obsidian hides its navbar while it is up, and a frame loop needs
|
||||||
|
* neither a duration to guess nor an event that a sidedock never sends.
|
||||||
|
*/
|
||||||
|
private settle(): void {
|
||||||
|
let last = Number.NaN;
|
||||||
|
let steady = 0;
|
||||||
|
// A fresh trigger restarts the watch rather than being dropped: the navbar
|
||||||
|
// coming back is a second move, after the first one has settled.
|
||||||
|
if (this.settling !== null) cancelAnimationFrame(this.settling);
|
||||||
|
const tick = () => {
|
||||||
|
const inset = this.inset();
|
||||||
|
steady = inset === last ? steady + 1 : 0;
|
||||||
|
last = inset;
|
||||||
|
if (steady >= STEADY_FRAMES) {
|
||||||
|
this.settling = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.settling = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
this.settling = requestAnimationFrame(tick);
|
||||||
|
this.register(() => {
|
||||||
|
if (this.settling !== null) {
|
||||||
|
cancelAnimationFrame(this.settling);
|
||||||
|
this.settling = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pads the view clear of the navbar and the keyboard. */
|
||||||
|
private inset(): number {
|
||||||
|
const box = this.contentEl.getBoundingClientRect();
|
||||||
|
if (box.height <= 0) return Number.NaN;
|
||||||
|
|
||||||
|
const bars: Rect[] = [];
|
||||||
|
for (const selector of BOTTOM_BARS) {
|
||||||
|
const node = document.querySelector<HTMLElement>(selector);
|
||||||
|
if (node && node.offsetParent !== null) {
|
||||||
|
bars.push(node.getBoundingClientRect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
const inset = insetFor(
|
||||||
|
box,
|
||||||
|
bars,
|
||||||
|
vv ? { height: vv.height, offsetTop: vv.offsetTop } : null,
|
||||||
|
);
|
||||||
|
this.contentEl.style.setProperty("--bcal-inset-bottom", `${inset}px`);
|
||||||
|
return inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
async onClose(): Promise<void> {
|
async onClose(): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
archiveRoot,
|
||||||
|
isArchivePath,
|
||||||
|
toArchivePath,
|
||||||
|
toBoardPath,
|
||||||
|
} from "../src/model/archive";
|
||||||
|
import { parseBoard, sphereOf } from "../src/model/board";
|
||||||
|
|
||||||
|
describe("archive paths", () => {
|
||||||
|
it("mirrors a board into the archive folder", () => {
|
||||||
|
expect(toArchivePath("Boards/work.md", "Boards", "archive")).toBe(
|
||||||
|
"Boards/archive/work.md",
|
||||||
|
);
|
||||||
|
expect(toArchivePath("📆 доски/работа.md", "📆 доски", "архив")).toBe(
|
||||||
|
"📆 доски/архив/работа.md",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the shape of a nested board", () => {
|
||||||
|
expect(toArchivePath("Boards/team/ops.md", "Boards", "archive")).toBe(
|
||||||
|
"Boards/archive/team/ops.md",
|
||||||
|
);
|
||||||
|
expect(toBoardPath("Boards/archive/team/ops.md", "Boards", "archive")).toBe(
|
||||||
|
"Boards/team/ops.md",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a mirror file where it is", () => {
|
||||||
|
const mirror = "Boards/archive/work.md";
|
||||||
|
expect(toArchivePath(mirror, "Boards", "archive")).toBe(mirror);
|
||||||
|
expect(isArchivePath(mirror, "Boards", "archive")).toBe(true);
|
||||||
|
expect(isArchivePath("Boards/work.md", "Boards", "archive")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has nowhere to put a board outside the boards folder", () => {
|
||||||
|
expect(toArchivePath("Notes/work.md", "Boards", "archive")).toBeNull();
|
||||||
|
expect(toArchivePath("Boards/work.md", "Boards", "")).toBeNull();
|
||||||
|
expect(archiveRoot("Boards", " ")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works with no boards folder at all", () => {
|
||||||
|
expect(archiveRoot("", "archive")).toBe("archive");
|
||||||
|
expect(toArchivePath("work.md", "", "archive")).toBe("archive/work.md");
|
||||||
|
expect(toBoardPath("archive/work.md", "", "archive")).toBe("work.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shrugs at slashes around the folder names", () => {
|
||||||
|
expect(toArchivePath("Boards/work.md", "/Boards/", "/archive/")).toBe(
|
||||||
|
"Boards/archive/work.md",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a mirror file keeps the sphere of the board it shadows", () => {
|
||||||
|
expect(sphereOf("Boards/archive/работа.md")).toBe("работа");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsing a mirror file", () => {
|
||||||
|
const content = [
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"kanban-plugin: board",
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"## errands",
|
||||||
|
"",
|
||||||
|
"- [x] pick up the parcel ✅ 2026-07-30",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
it("marks everything archive, separator or no separator", () => {
|
||||||
|
const board = parseBoard("Boards/archive/personal.md", content, true);
|
||||||
|
expect(board.tasks).toHaveLength(1);
|
||||||
|
expect(board.tasks[0].inArchive).toBe(true);
|
||||||
|
expect(board.tasks[0].project).toBe("errands");
|
||||||
|
expect(board.lanes.every((lane) => lane.inArchive)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads as a plain board when nobody says it is a mirror", () => {
|
||||||
|
const board = parseBoard("Boards/personal.md", content);
|
||||||
|
expect(board.tasks[0].inArchive).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+149
-58
@@ -5,14 +5,15 @@ import { parseBoard } from "../src/model/board";
|
|||||||
import { serializeTaskLine } from "../src/model/serialize";
|
import { serializeTaskLine } from "../src/model/serialize";
|
||||||
import type { Task } from "../src/model/types";
|
import type { Task } from "../src/model/types";
|
||||||
import {
|
import {
|
||||||
applyArchive,
|
|
||||||
applyReorder,
|
applyReorder,
|
||||||
|
fillArchive,
|
||||||
cutTask,
|
cutTask,
|
||||||
insertBlock,
|
insertBlock,
|
||||||
locate,
|
locate,
|
||||||
MissingLaneError,
|
MissingLaneError,
|
||||||
replaceLine,
|
replaceLine,
|
||||||
StaleTaskError,
|
StaleTaskError,
|
||||||
|
takeDone,
|
||||||
} from "../src/vault/edits";
|
} from "../src/vault/edits";
|
||||||
|
|
||||||
const FIXTURES = join(import.meta.dirname, "fixtures");
|
const FIXTURES = join(import.meta.dirname, "fixtures");
|
||||||
@@ -189,61 +190,17 @@ describe("applyReorder", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("applyArchive", () => {
|
/** Runs the archive over one board, mirror file and all. */
|
||||||
it("moves done cards under the archive heading", () => {
|
function archived(name: string, migrate = true) {
|
||||||
const { content, board: parsed } = board("personal.md");
|
const { content } = board(name);
|
||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
const open = parsed.tasks.find((item) => !item.inArchive) as Task;
|
const harvest = takeDone(lines, name, migrate);
|
||||||
replaceLine(
|
const mirror: string[] = [];
|
||||||
lines,
|
fillArchive(mirror, `archive/${name}`, harvest);
|
||||||
open,
|
return { harvest, board: lines.join("\n"), mirror: mirror.join("\n") };
|
||||||
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");
|
function markAllDone(name: string): string[] {
|
||||||
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 { content, board: parsed } = board(name);
|
||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
for (const task of parsed.tasks.filter((item) => !item.inArchive)) {
|
for (const task of parsed.tasks.filter((item) => !item.inArchive)) {
|
||||||
@@ -253,20 +210,154 @@ describe("every board survives a full archive pass", () => {
|
|||||||
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
|
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
applyArchive(lines, name, "Archive");
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("takeDone", () => {
|
||||||
|
it("lifts a completed card out with the lane it sat in", () => {
|
||||||
|
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" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const harvest = takeDone(lines, "personal.md", false);
|
||||||
|
expect(harvest.cards).toHaveLength(1);
|
||||||
|
expect(harvest.cards[0].project).toBe("errands");
|
||||||
|
expect(harvest.cards[0].block[0]).toContain(open.text);
|
||||||
|
expect(lines.join("\n")).not.toContain(open.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when nothing is done and nothing was archived", () => {
|
||||||
|
const { content } = board("home.md");
|
||||||
|
const lines = content.split("\n");
|
||||||
|
const harvest = takeDone(lines, "home.md", true);
|
||||||
|
expect(harvest.cards).toHaveLength(0);
|
||||||
|
expect(harvest.legacy).toHaveLength(0);
|
||||||
|
expect(lines.join("\n")).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the native archive alone without migrate", () => {
|
||||||
|
const { content } = board("personal.md");
|
||||||
|
const lines = content.split("\n");
|
||||||
|
takeDone(lines, "personal.md", false);
|
||||||
const next = lines.join("\n");
|
const next = lines.join("\n");
|
||||||
|
expect(next).toContain("***");
|
||||||
|
expect(next).toContain("## Archive");
|
||||||
|
expect(next).toContain("pick up the parcel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the native archive whole, separator and all", () => {
|
||||||
|
const { board: parsed } = board("personal.md");
|
||||||
|
const { harvest, board: next } = archived("personal.md");
|
||||||
|
|
||||||
|
expect(harvest.legacy).toHaveLength(1);
|
||||||
|
expect(harvest.legacy[0].project).toBe("Archive");
|
||||||
|
const wasArchived = parsed.tasks.filter((item) => item.inArchive);
|
||||||
|
expect(harvest.legacy[0].block).toHaveLength(wasArchived.length);
|
||||||
|
|
||||||
|
expect(next).not.toContain("***");
|
||||||
|
expect(next).not.toContain("## Archive");
|
||||||
|
expect(next).not.toContain("pick up the parcel");
|
||||||
|
// The lanes that were live stay untouched, settings block included.
|
||||||
|
expect(landmarks(next).settings).toBe(1);
|
||||||
|
expect(landmarks(next).headings).toEqual(["## errands", "## someday"]);
|
||||||
|
expect(next).toContain("- [ ] renew the gym membership");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fillArchive", () => {
|
||||||
|
it("writes a Kanban board with the lanes the cards came from", () => {
|
||||||
|
const lines = markAllDone("work.md");
|
||||||
|
const harvest = takeDone(lines, "work.md", true);
|
||||||
|
const mirror: string[] = [];
|
||||||
|
fillArchive(mirror, "archive/work.md", harvest);
|
||||||
|
const next = mirror.join("\n");
|
||||||
|
|
||||||
|
expect(next.startsWith("---\n\nkanban-plugin: board\n\n---\n")).toBe(true);
|
||||||
|
expect(next).toContain("## website");
|
||||||
|
expect(next).toContain("## notes");
|
||||||
|
// The old native archive keeps its own heading.
|
||||||
|
expect(next).toContain("## Archive");
|
||||||
|
expect(next).toContain("- [x] ship the pricing page 📅 2026-07-20 ✅ 2026-07-21");
|
||||||
|
|
||||||
|
const parsed = parseBoard("archive/work.md", next, true);
|
||||||
|
expect(parsed.tasks.every((task) => task.inArchive)).toBe(true);
|
||||||
|
expect(parsed.tasks.map((task) => task.project)).toContain("website");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a card's indented body with it", () => {
|
||||||
|
const lines = markAllDone("work.md");
|
||||||
|
const harvest = takeDone(lines, "work.md", false);
|
||||||
|
const mirror: string[] = [];
|
||||||
|
fillArchive(mirror, "archive/work.md", harvest);
|
||||||
|
const next = mirror.join("\n");
|
||||||
|
expect(next).toContain("\t- shorter intro");
|
||||||
|
expect(next).toContain("\tfollow-up: check the analytics bundle size");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends to a mirror that already holds history", () => {
|
||||||
|
const first = markAllDone("personal.md");
|
||||||
|
const harvest = takeDone(first, "personal.md", true);
|
||||||
|
const mirror: string[] = [];
|
||||||
|
fillArchive(mirror, "archive/personal.md", harvest);
|
||||||
|
const before = parseBoard("archive/personal.md", mirror.join("\n"), true);
|
||||||
|
|
||||||
|
fillArchive(mirror, "archive/personal.md", {
|
||||||
|
cards: [{ project: "errands", block: ["- [x] a later one ✅ 2026-09-01"] }],
|
||||||
|
legacy: [],
|
||||||
|
});
|
||||||
|
const after = parseBoard("archive/personal.md", mirror.join("\n"), true);
|
||||||
|
|
||||||
|
expect(after.tasks).toHaveLength(before.tasks.length + 1);
|
||||||
|
expect(after.lanes).toHaveLength(before.lanes.length);
|
||||||
|
const errands = after.tasks.filter((task) => task.project === "errands");
|
||||||
|
expect(errands.at(-1)?.text).toBe("a later one");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("every board survives a full archive pass", () => {
|
||||||
|
for (const name of readdirSync(FIXTURES).filter((n) => n.endsWith(".md"))) {
|
||||||
|
it(`${name}: nothing lost, the board keeps its shape`, () => {
|
||||||
|
const { content, board: parsed } = board(name);
|
||||||
|
const lines = markAllDone(name);
|
||||||
|
const harvest = takeDone(lines, name, true);
|
||||||
|
const next = lines.join("\n");
|
||||||
|
const mirror: string[] = [];
|
||||||
|
fillArchive(mirror, `archive/${name}`, harvest);
|
||||||
|
|
||||||
|
// The board keeps its front matter, its settings and its live lanes,
|
||||||
|
// and holds no task at all any more.
|
||||||
const reparsed = parseBoard(name, next);
|
const reparsed = parseBoard(name, next);
|
||||||
expect(reparsed.tasks).toHaveLength(parsed.tasks.length);
|
expect(reparsed.tasks).toHaveLength(0);
|
||||||
expect(reparsed.tasks.every((item) => item.inArchive)).toBe(true);
|
|
||||||
|
|
||||||
const before = landmarks(content);
|
const before = landmarks(content);
|
||||||
const after = landmarks(next);
|
const after = landmarks(next);
|
||||||
expect(after.settings).toBe(before.settings);
|
expect(after.settings).toBe(before.settings);
|
||||||
expect(after.frontmatter).toBe(before.frontmatter);
|
expect(after.frontmatter).toBe(before.frontmatter);
|
||||||
for (const heading of before.headings) {
|
expect(after.fences).toBe(0);
|
||||||
|
for (const heading of before.headings.filter((h) => h !== "## Archive")) {
|
||||||
expect(after.headings, `${heading} must survive`).toContain(heading);
|
expect(after.headings, `${heading} must survive`).toContain(heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every task that was on the board is now in the mirror, once.
|
||||||
|
const mirrored = parseBoard(`archive/${name}`, mirror.join("\n"), true);
|
||||||
|
expect(mirrored.tasks).toHaveLength(parsed.tasks.length);
|
||||||
|
expect(mirrored.tasks.every((task) => task.inArchive)).toBe(true);
|
||||||
|
expect(mirrored.tasks.map((task) => task.raw).sort()).toEqual(
|
||||||
|
[
|
||||||
|
...parsed.tasks.filter((task) => task.inArchive).map((t) => t.raw),
|
||||||
|
...markedDone(parsed.tasks.filter((task) => !task.inArchive)),
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function markedDone(tasks: Task[]): string[] {
|
||||||
|
return tasks.map((task) =>
|
||||||
|
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BOTTOM_BARS, insetFor } from "../src/lib/inset";
|
||||||
|
import type { Rect } from "../src/lib/inset";
|
||||||
|
|
||||||
|
const rect = (over: Partial<Rect> = {}): Rect => ({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 390,
|
||||||
|
bottom: 780,
|
||||||
|
height: 780,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A phone: 780 tall, a 56px navbar floating over the bottom, 340px of keyboard.
|
||||||
|
const NAVBAR = rect({ top: 724, bottom: 780, height: 56 });
|
||||||
|
|
||||||
|
describe("insetFor", () => {
|
||||||
|
it("clears nothing when the view stands alone", () => {
|
||||||
|
expect(insetFor(rect(), [], null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the navbar that paints over the view", () => {
|
||||||
|
expect(insetFor(rect(), [NAVBAR], null)).toBe(56);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the keyboard, which shortens no layout", () => {
|
||||||
|
expect(insetFor(rect(), [], { height: 440, offsetTop: 0 })).toBe(340);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the deeper of the two rather than their sum", () => {
|
||||||
|
// The navbar hides behind the keyboard while it is up.
|
||||||
|
expect(insetFor(rect(), [NAVBAR], { height: 440, offsetTop: 0 })).toBe(340);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("comes back to the navbar once the keyboard goes", () => {
|
||||||
|
expect(insetFor(rect(), [NAVBAR], { height: 780, offsetTop: 0 })).toBe(56);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a bar that sits beside the view, not over it", () => {
|
||||||
|
const sidedock = rect({ right: 300 });
|
||||||
|
const statusBar = rect({
|
||||||
|
top: 760,
|
||||||
|
bottom: 780,
|
||||||
|
height: 20,
|
||||||
|
left: 700,
|
||||||
|
right: 980,
|
||||||
|
});
|
||||||
|
expect(insetFor(sidedock, [statusBar], null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("measures from where the visible area starts", () => {
|
||||||
|
expect(insetFor(rect(), [], { height: 440, offsetTop: 100 })).toBe(240);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about a view with no height yet", () => {
|
||||||
|
expect(insetFor(rect({ height: 0, bottom: 0 }), [NAVBAR], null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a negative inset", () => {
|
||||||
|
const short = rect({ bottom: 300, height: 300 });
|
||||||
|
expect(insetFor(short, [], { height: 780, offsetTop: 0 })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the bars that float rather than make room", () => {
|
||||||
|
expect(BOTTOM_BARS).toContain(".mobile-navbar");
|
||||||
|
expect(BOTTOM_BARS).toContain(".status-bar");
|
||||||
|
});
|
||||||
|
});
|
||||||
+76
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { onScreen, place } from "../src/lib/place";
|
import { onScreen, place, roomFor, viewport } from "../src/lib/place";
|
||||||
import type { Box } from "../src/lib/place";
|
import type { Box } from "../src/lib/place";
|
||||||
|
|
||||||
const VIEW = { width: 1280, height: 800 };
|
const VIEW = { width: 1280, height: 800 };
|
||||||
@@ -58,3 +58,78 @@ describe("place", () => {
|
|||||||
expect(place(null, PANEL, VIEW)).toEqual({ top: 300, left: 480 });
|
expect(place(null, PANEL, VIEW)).toEqual({ top: 300, left: 480 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A phone's keyboard shrinks the visual viewport and leaves the window alone.
|
||||||
|
describe("place against a keyboard", () => {
|
||||||
|
const SHRUNK = { width: 390, height: 400, top: 0, left: 0 };
|
||||||
|
|
||||||
|
it("keeps the panel above the keyboard", () => {
|
||||||
|
const spot = place(row({ top: 380, left: 10, width: 370 }), PANEL, SHRUNK);
|
||||||
|
expect(spot.top + PANEL.height).toBeLessThanOrEqual(SHRUNK.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("measures from where the visible area starts", () => {
|
||||||
|
const scrolled = { width: 390, height: 400, top: 120, left: 0 };
|
||||||
|
const spot = place(null, PANEL, scrolled);
|
||||||
|
expect(spot.top).toBeGreaterThanOrEqual(scrolled.top);
|
||||||
|
expect(spot.top + PANEL.height).toBeLessThanOrEqual(
|
||||||
|
scrolled.top + scrolled.height
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("comes back into view when the anchor itself is under the keyboard", () => {
|
||||||
|
// The row the panel hangs off is at 650, the keyboard leaves 380: neither
|
||||||
|
// above nor below the anchor is reachable, so the visible area wins.
|
||||||
|
const spot = place(row({ top: 650, height: 40 }), PANEL, SHRUNK);
|
||||||
|
expect(spot.top).toBeGreaterThanOrEqual(10);
|
||||||
|
expect(spot.top + PANEL.height).toBeLessThanOrEqual(SHRUNK.height);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("roomFor", () => {
|
||||||
|
it("leaves the panel less height than the visible area", () => {
|
||||||
|
expect(roomFor({ width: 390, height: 400 })).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never collapses to nothing when the keyboard takes almost everything", () => {
|
||||||
|
expect(roomFor({ width: 390, height: 60 })).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obsidian's phone navbar is not part of the visual viewport - it floats over
|
||||||
|
// it - so a panel sized to the viewport still ends up underneath it.
|
||||||
|
describe("viewport minus the floating navbar", () => {
|
||||||
|
function fakeWin(navbarTop: number | null, height = 780): Window {
|
||||||
|
const bar = {
|
||||||
|
getBoundingClientRect: () => ({
|
||||||
|
top: navbarTop ?? 0,
|
||||||
|
bottom: height,
|
||||||
|
height: height - (navbarTop ?? 0),
|
||||||
|
left: 0,
|
||||||
|
right: 390,
|
||||||
|
}),
|
||||||
|
offsetParent: {},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
innerWidth: 390,
|
||||||
|
innerHeight: height,
|
||||||
|
visualViewport: { width: 390, height, offsetTop: 0, offsetLeft: 0 },
|
||||||
|
document: {
|
||||||
|
querySelector: (selector: string) =>
|
||||||
|
selector === ".mobile-navbar" && navbarTop !== null ? bar : null,
|
||||||
|
},
|
||||||
|
} as unknown as Window;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("takes the navbar off the bottom", () => {
|
||||||
|
expect(viewport(fakeWin(724)).height).toBe(724);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the height alone with no navbar in the way", () => {
|
||||||
|
expect(viewport(fakeWin(null)).height).toBe(780);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still leaves a usable strip if a bar somehow covers everything", () => {
|
||||||
|
expect(viewport(fakeWin(0)).height).toBeGreaterThanOrEqual(120);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { scopeCss } from "../scripts/scope-css.mjs";
|
||||||
|
|
||||||
|
const one = (css: string) => scopeCss(css).replace(/\s+/gu, " ").trim();
|
||||||
|
|
||||||
|
describe("scoping the built stylesheet", () => {
|
||||||
|
it("prefixes a utility with the view root, keeping the root itself in reach", () => {
|
||||||
|
expect(one(".flex{display:flex!important}")).toBe(
|
||||||
|
":is(.bcal-root, .bcal-root *).flex { display: flex !important; }",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outranks the same utility from another plugin's Tailwind", () => {
|
||||||
|
// Class specificity: theirs is (0,1,0); ours must be (0,2,0).
|
||||||
|
const out = one(".hidden{display:none!important}");
|
||||||
|
expect(out.startsWith(":is(.bcal-root, .bcal-root *).hidden")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps pseudo-elements at the end where they are valid", () => {
|
||||||
|
expect(one(".x::placeholder{color:red}")).toBe(
|
||||||
|
":is(.bcal-root, .bcal-root *).x::placeholder { color: red; }",
|
||||||
|
);
|
||||||
|
expect(one(".y::first-letter{t:1}")).toContain(".y:first-letter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes rules nested in container queries and layers", () => {
|
||||||
|
const out = one(
|
||||||
|
"@layer utilities{@container (min-width:30rem){.a{display:inline-block!important}}}",
|
||||||
|
);
|
||||||
|
// lightningcss rewrites the query into range syntax; Obsidian's Chromium
|
||||||
|
// has understood that inside @container since container queries landed.
|
||||||
|
expect(out).toContain("@container (width >= 30rem)");
|
||||||
|
expect(out).toContain(":is(.bcal-root, .bcal-root *).a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a leading universal selector instead of producing `:is()*`", () => {
|
||||||
|
const out = one("*,:before,::backdrop{--tw-a:initial}");
|
||||||
|
expect(out).toContain(":is(.bcal-root, .bcal-root *),");
|
||||||
|
expect(out).toContain(":is(.bcal-root, .bcal-root *):before");
|
||||||
|
expect(out).not.toContain("*)*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves tailwind's theme variables on :root, under obsidian's body values", () => {
|
||||||
|
expect(one(":root,:host{--spacing:.25rem}")).toBe(
|
||||||
|
":root, :host { --spacing: .25rem; }",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes the subject, so an ancestor outside the root still matches", () => {
|
||||||
|
expect(one(".theme-dark .hue-chip{c:1}")).toBe(
|
||||||
|
".theme-dark :is(.bcal-root, .bcal-root *).hue-chip { c: 1; }",
|
||||||
|
);
|
||||||
|
expect(one(".a > button{c:2}")).toBe(
|
||||||
|
".a > button:is(.bcal-root, .bcal-root *) { c: 2; }",
|
||||||
|
);
|
||||||
|
expect(one(".a *{c:3}")).toBe(".a :is(.bcal-root, .bcal-root *) { c: 3; }");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves rules that already name the root alone", () => {
|
||||||
|
const reset = ".bcal-root.bcal-root button{cursor:pointer}";
|
||||||
|
expect(one(reset)).toBe(".bcal-root.bcal-root button { cursor: pointer; }");
|
||||||
|
expect(one(".theme-dark .bcal-root{--note:red}")).toBe(
|
||||||
|
".theme-dark .bcal-root { --note: red; }",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes variant selectors without breaking their inner :where()", () => {
|
||||||
|
const out = one(".dark\\:bg-x:where(.theme-dark,.theme-dark *){b:1}");
|
||||||
|
expect(out).toBe(
|
||||||
|
":is(.bcal-root, .bcal-root *).dark\\:bg-x:where(.theme-dark, .theme-dark *) { b: 1; }",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+81
-1
@@ -9,6 +9,7 @@ import { JSDOM } from "jsdom";
|
|||||||
const root = new URL("..", import.meta.url).pathname;
|
const root = new URL("..", import.meta.url).pathname;
|
||||||
const fixtures = join(root, "tests/fixtures");
|
const fixtures = join(root, "tests/fixtures");
|
||||||
const FOLDER = "Boards";
|
const FOLDER = "Boards";
|
||||||
|
const ARCHIVE = "archive";
|
||||||
|
|
||||||
const out = join(mkdtempSync(join(tmpdir(), "bcal-")), "bundle.mjs");
|
const out = join(mkdtempSync(join(tmpdir(), "bcal-")), "bundle.mjs");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
@@ -96,6 +97,23 @@ const files = new Map(
|
|||||||
readFileSync(join(fixtures, name), "utf8"),
|
readFileSync(join(fixtures, name), "utf8"),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
// A mirror file, the way the archive command leaves one behind.
|
||||||
|
const MIRROR = `${FOLDER}/${ARCHIVE}/personal.md`;
|
||||||
|
files.set(
|
||||||
|
MIRROR,
|
||||||
|
[
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"kanban-plugin: board",
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"## errands",
|
||||||
|
"",
|
||||||
|
"- [x] hang the shelf 📅 2026-07-02 ✅ 2026-07-03",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
const handles = new Map([...files.keys()].map((path) => [path, new TFile(path)]));
|
const handles = new Map([...files.keys()].map((path) => [path, new TFile(path)]));
|
||||||
|
|
||||||
const writes = [];
|
const writes = [];
|
||||||
@@ -122,7 +140,7 @@ const app = {
|
|||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
boardsFolder: FOLDER,
|
boardsFolder: FOLDER,
|
||||||
archiveHeading: "Archive",
|
archiveFolder: ARCHIVE,
|
||||||
view: "list",
|
view: "list",
|
||||||
groupBy: "sphere",
|
groupBy: "sphere",
|
||||||
listSort: "due",
|
listSort: "due",
|
||||||
@@ -153,6 +171,11 @@ await store.reloadAll();
|
|||||||
const outsideArchive = 15;
|
const outsideArchive = 15;
|
||||||
check("parsed every board", store.spheres.length === names.length, `${store.spheres.length}`);
|
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}`);
|
check(`${outsideArchive} tasks outside the archive`, store.all.length === outsideArchive, `got ${store.all.length}`);
|
||||||
|
check(
|
||||||
|
"the mirror file is not a sphere of its own",
|
||||||
|
store.spheres.every((sphere) => !sphere.path.includes(`/${ARCHIVE}/`)),
|
||||||
|
store.spheres.map((sphere) => sphere.path).join(", "),
|
||||||
|
);
|
||||||
|
|
||||||
const target = dom.window.document.body;
|
const target = dom.window.document.body;
|
||||||
const instance = mount(App, {
|
const instance = mount(App, {
|
||||||
@@ -181,10 +204,39 @@ flushSync();
|
|||||||
await settle();
|
await settle();
|
||||||
check("archive appears when both toggles are on", text().includes("pick up the parcel"));
|
check("archive appears when both toggles are on", text().includes("pick up the parcel"));
|
||||||
check("archived task carries the Archive badge", text().includes("Archive"));
|
check("archived task carries the Archive badge", text().includes("Archive"));
|
||||||
|
check("the archive folder is rendered too", text().includes("hang the shelf"));
|
||||||
|
|
||||||
|
const mirrored = store.all.find((task) => task.text === "hang the shelf");
|
||||||
|
check("a mirrored task is archive", mirrored?.inArchive === true);
|
||||||
|
check(
|
||||||
|
"it keeps the lane it was archived from",
|
||||||
|
mirrored?.project === "errands",
|
||||||
|
`${mirrored?.project}`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"it groups under the board it mirrors, not beside it",
|
||||||
|
store.groups.filter((group) => group.label === "personal").length === 1,
|
||||||
|
store.groups.map((group) => group.label).join(", "),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The rail knows nothing of the mirror file, so archived cards have to ride
|
||||||
|
// with the sphere they came from.
|
||||||
|
store.soloSphere(`${FOLDER}/personal.md`);
|
||||||
|
flushSync();
|
||||||
|
await settle();
|
||||||
|
check("the rail keeps mirrored cards with their sphere", text().includes("hang the shelf"));
|
||||||
|
store.soloSphere(`${FOLDER}/work.md`);
|
||||||
|
flushSync();
|
||||||
|
await settle();
|
||||||
|
check("another sphere leaves them out", !text().includes("hang the shelf"));
|
||||||
|
store.filterAll();
|
||||||
|
flushSync();
|
||||||
|
await settle();
|
||||||
|
|
||||||
store.includeArchive = false;
|
store.includeArchive = false;
|
||||||
flushSync();
|
flushSync();
|
||||||
check("archive hidden again", !text().includes("pick up the parcel"));
|
check("archive hidden again", !text().includes("pick up the parcel"));
|
||||||
|
check("the archive folder is hidden with it", !text().includes("hang the shelf"));
|
||||||
store.showDone = false;
|
store.showDone = false;
|
||||||
flushSync();
|
flushSync();
|
||||||
|
|
||||||
@@ -609,6 +661,34 @@ for (const [name, init] of [
|
|||||||
store.tick();
|
store.tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On a Friday "Friday" and "In a week" are the same day. The date field used
|
||||||
|
// to be keyed by that day and tore itself down, so the picker never opened.
|
||||||
|
{
|
||||||
|
store.today = "2026-09-04";
|
||||||
|
store.leaveToMain();
|
||||||
|
flushSync();
|
||||||
|
key(viewRoot, { key: "n", code: "KeyN" });
|
||||||
|
flushSync();
|
||||||
|
store.today = "2026-09-04";
|
||||||
|
flushSync();
|
||||||
|
const panel = panelOf();
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
(dom.window.document.activeElement ?? panel).dispatchEvent(
|
||||||
|
new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Tab", code: "Tab" }),
|
||||||
|
);
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
check("the date field opens on a friday", panelOf() !== null && store.editing !== null);
|
||||||
|
check(
|
||||||
|
"the day two offers share is listed once",
|
||||||
|
(panel.textContent.match(/Sep 11/gu) ?? []).length === 1,
|
||||||
|
panel.textContent.slice(0, 200),
|
||||||
|
);
|
||||||
|
panel.dispatchEvent(new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Escape", code: "Escape" }));
|
||||||
|
flushSync();
|
||||||
|
store.tick();
|
||||||
|
}
|
||||||
|
|
||||||
// A request whose task vanished must not leave the view deaf.
|
// A request whose task vanished must not leave the view deaf.
|
||||||
store.request = "priority";
|
store.request = "priority";
|
||||||
store.cursorTask = "nothing/at/all#0";
|
store.cursorTask = "nothing/at/all#0";
|
||||||
|
|||||||
Reference in new issue
Block a user