tuiboard 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tuiboard/config.example.yaml +32 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/bin/tuiboard.ts +28 -0
- package/package.json +62 -0
- package/src/app.tsx +129 -0
- package/src/cli/args.test.ts +40 -0
- package/src/cli/args.ts +41 -0
- package/src/config/loader.ts +169 -0
- package/src/input/handleKey.ts +733 -0
- package/src/io/watcher.ts +85 -0
- package/src/io/writer.ts +92 -0
- package/src/parser/markdown.ts +351 -0
- package/src/parser/serialize.ts +97 -0
- package/src/scripts/agents-check.ts +24 -0
- package/src/scripts/parse-check.ts +124 -0
- package/src/scripts/roundtrip-check.ts +79 -0
- package/src/store/agents.test.ts +181 -0
- package/src/store/agents.ts +435 -0
- package/src/store/index.test.ts +110 -0
- package/src/store/index.ts +972 -0
- package/src/store/parsers.ts +243 -0
- package/src/store/timeline.test.ts +279 -0
- package/src/store/timeline.ts +279 -0
- package/src/store/virtual-panel.ts +0 -0
- package/src/types.ts +116 -0
- package/src/ui/AgentRow.tsx +79 -0
- package/src/ui/AgentsBar.tsx +102 -0
- package/src/ui/BoardView.tsx +333 -0
- package/src/ui/Chrome.tsx +122 -0
- package/src/ui/Modal.tsx +613 -0
- package/src/ui/TaskRow.tsx +240 -0
- package/src/ui/TimelineView.tsx +643 -0
- package/src/ui/VirtualPanel.tsx +237 -0
- package/src/ui/board-scroll.test.ts +63 -0
- package/src/ui/board-scroll.ts +49 -0
- package/src/ui/glyphs.ts +129 -0
- package/src/views/AgentsOnly.tsx +103 -0
- package/src/views/BoardOnly.tsx +35 -0
- package/src/views/Dashboard.tsx +106 -0
- package/src/views/TimelineOnly.tsx +12 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File watcher wrapping chokidar.
|
|
3
|
+
*
|
|
4
|
+
* Emits a debounced `change` event when any tracked board file is modified
|
|
5
|
+
* on disk. Use to refresh state when the user edits a board externally
|
|
6
|
+
* (Obsidian, vim, sync conflict resolution, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Self-writes are filtered: every `writeBoardFile` call should be followed
|
|
9
|
+
* by `markSelfWrite(path)` so the watcher ignores the resulting event.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import chokidar, { type FSWatcher } from "chokidar";
|
|
13
|
+
|
|
14
|
+
export type ChangeListener = (filepath: string) => void;
|
|
15
|
+
|
|
16
|
+
export interface BoardWatcher {
|
|
17
|
+
/** Start watching. Returns a stop function. */
|
|
18
|
+
start: () => void;
|
|
19
|
+
/** Stop watching and release file handles. */
|
|
20
|
+
stop: () => Promise<void>;
|
|
21
|
+
/** Subscribe to debounced change events. Returns an unsubscribe fn. */
|
|
22
|
+
onChange: (listener: ChangeListener) => () => void;
|
|
23
|
+
/** Mark the next change event for `filepath` as a self-write, to be ignored. */
|
|
24
|
+
markSelfWrite: (filepath: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WatcherOptions {
|
|
28
|
+
/** Debounce window in ms. Default 150. */
|
|
29
|
+
debounceMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createBoardWatcher(
|
|
33
|
+
filepaths: string[],
|
|
34
|
+
{ debounceMs = 150 }: WatcherOptions = {},
|
|
35
|
+
): BoardWatcher {
|
|
36
|
+
const listeners = new Set<ChangeListener>();
|
|
37
|
+
const pending = new Map<string, ReturnType<typeof setTimeout>>();
|
|
38
|
+
const selfWrites = new Set<string>();
|
|
39
|
+
let watcher: FSWatcher | null = null;
|
|
40
|
+
|
|
41
|
+
function emit(filepath: string) {
|
|
42
|
+
if (selfWrites.delete(filepath)) return; // ignore our own writes
|
|
43
|
+
for (const l of listeners) l(filepath);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function schedule(filepath: string) {
|
|
47
|
+
const existing = pending.get(filepath);
|
|
48
|
+
if (existing) clearTimeout(existing);
|
|
49
|
+
const t = setTimeout(() => {
|
|
50
|
+
pending.delete(filepath);
|
|
51
|
+
emit(filepath);
|
|
52
|
+
}, debounceMs);
|
|
53
|
+
pending.set(filepath, t);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
start() {
|
|
58
|
+
if (watcher) return;
|
|
59
|
+
watcher = chokidar.watch(filepaths, {
|
|
60
|
+
persistent: true,
|
|
61
|
+
ignoreInitial: true,
|
|
62
|
+
awaitWriteFinish: { stabilityThreshold: 80, pollInterval: 30 },
|
|
63
|
+
});
|
|
64
|
+
watcher.on("change", schedule);
|
|
65
|
+
watcher.on("add", schedule);
|
|
66
|
+
},
|
|
67
|
+
async stop() {
|
|
68
|
+
for (const t of pending.values()) clearTimeout(t);
|
|
69
|
+
pending.clear();
|
|
70
|
+
if (watcher) {
|
|
71
|
+
await watcher.close();
|
|
72
|
+
watcher = null;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
onChange(listener) {
|
|
76
|
+
listeners.add(listener);
|
|
77
|
+
return () => listeners.delete(listener);
|
|
78
|
+
},
|
|
79
|
+
markSelfWrite(filepath) {
|
|
80
|
+
selfWrites.add(filepath);
|
|
81
|
+
// Guard against the watcher missing the event — clear after a short delay.
|
|
82
|
+
setTimeout(() => selfWrites.delete(filepath), 1000);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
package/src/io/writer.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic, conflict-safe board file writer.
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* 1. Read current mtime of target file.
|
|
6
|
+
* 2. If `expectedMtimeMs` was provided and current ≠ expected → conflict.
|
|
7
|
+
* Caller should re-read and merge before retrying.
|
|
8
|
+
* 3. Write content to a sibling `.tmp` file with a unique suffix.
|
|
9
|
+
* 4. `rename` over the original (atomic on the same volume — POSIX +
|
|
10
|
+
* Windows ReplaceFile semantics).
|
|
11
|
+
* 5. Return the new mtime so the caller can update its watermark.
|
|
12
|
+
*
|
|
13
|
+
* Note on Windows: `fs.rename` is *not* atomic-overwrite by default. We use
|
|
14
|
+
* `fs.renameSync` which on modern Node/Bun maps to `MoveFileExW` with the
|
|
15
|
+
* `MOVEFILE_REPLACE_EXISTING` flag. If a third party has the target open
|
|
16
|
+
* for exclusive write at that exact moment, the rename can fail — we surface
|
|
17
|
+
* the error to the caller, which can retry.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
closeSync,
|
|
22
|
+
existsSync,
|
|
23
|
+
openSync,
|
|
24
|
+
renameSync,
|
|
25
|
+
statSync,
|
|
26
|
+
writeSync,
|
|
27
|
+
} from "node:fs";
|
|
28
|
+
import { dirname, join } from "node:path";
|
|
29
|
+
|
|
30
|
+
export class ConflictError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
readonly filepath: string,
|
|
33
|
+
readonly expectedMtimeMs: number,
|
|
34
|
+
readonly actualMtimeMs: number,
|
|
35
|
+
) {
|
|
36
|
+
super(
|
|
37
|
+
`File ${filepath} changed on disk since last read ` +
|
|
38
|
+
`(expected mtime ${expectedMtimeMs}, found ${actualMtimeMs}).`,
|
|
39
|
+
);
|
|
40
|
+
this.name = "ConflictError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface WriteResult {
|
|
45
|
+
/** New mtime in ms — caller should store this as the next watermark. */
|
|
46
|
+
mtimeMs: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WriteOptions {
|
|
50
|
+
/** Last known mtime in ms. If set and disk mtime differs, throws ConflictError. */
|
|
51
|
+
expectedMtimeMs?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function writeBoardFile(
|
|
55
|
+
filepath: string,
|
|
56
|
+
content: string,
|
|
57
|
+
{ expectedMtimeMs }: WriteOptions = {},
|
|
58
|
+
): WriteResult {
|
|
59
|
+
if (typeof expectedMtimeMs === "number" && existsSync(filepath)) {
|
|
60
|
+
const cur = statSync(filepath).mtimeMs;
|
|
61
|
+
// Allow a small tolerance (1ms) for filesystems with coarse mtime.
|
|
62
|
+
if (Math.abs(cur - expectedMtimeMs) > 1) {
|
|
63
|
+
throw new ConflictError(filepath, expectedMtimeMs, cur);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const dir = dirname(filepath);
|
|
68
|
+
const tmpName = `.${basename(filepath)}.tuiboard-${process.pid}-${Date.now()}.tmp`;
|
|
69
|
+
const tmpPath = join(dir, tmpName);
|
|
70
|
+
|
|
71
|
+
// Write tmp file. Open with O_CREAT|O_WRONLY|O_TRUNC.
|
|
72
|
+
const fd = openSync(tmpPath, "w");
|
|
73
|
+
try {
|
|
74
|
+
writeSync(fd, content);
|
|
75
|
+
} finally {
|
|
76
|
+
closeSync(fd);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Atomic-ish rename over target.
|
|
80
|
+
renameSync(tmpPath, filepath);
|
|
81
|
+
|
|
82
|
+
return { mtimeMs: statSync(filepath).mtimeMs };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function basename(p: string): string {
|
|
86
|
+
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
|
87
|
+
return i >= 0 ? p.slice(i + 1) : p;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function statMtime(filepath: string): number {
|
|
91
|
+
return statSync(filepath).mtimeMs;
|
|
92
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown board parser.
|
|
3
|
+
*
|
|
4
|
+
* Reads a board file (Obsidian-Kanban-plugin-compatible markdown) and produces
|
|
5
|
+
* a structured `Board`. Designed to be *lossless on round-trip*: anything we
|
|
6
|
+
* don't understand is preserved verbatim (frontmatter, trailing kanban-plugin
|
|
7
|
+
* settings, decorative emoji in task bodies, unusual whitespace).
|
|
8
|
+
*
|
|
9
|
+
* Supports both:
|
|
10
|
+
* - Legacy `HH:MM-HH:MM ` time block prefix (Python tools' format)
|
|
11
|
+
* - New canonical `⌚ HH:MM-HH:MM` anywhere (tuiboard's format)
|
|
12
|
+
*
|
|
13
|
+
* The serializer (Day 2) will always emit the new canonical form on write,
|
|
14
|
+
* so the format migrates organically through normal editing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { basename, extname } from "node:path";
|
|
18
|
+
import type {
|
|
19
|
+
BlankLine,
|
|
20
|
+
Board,
|
|
21
|
+
Column,
|
|
22
|
+
ColumnChild,
|
|
23
|
+
ParseDiagnostic,
|
|
24
|
+
ParseResult,
|
|
25
|
+
PriorityLevel,
|
|
26
|
+
RawOther,
|
|
27
|
+
SectionBreak,
|
|
28
|
+
Task,
|
|
29
|
+
TimeBlock,
|
|
30
|
+
TimeBlockSource,
|
|
31
|
+
} from "~/types";
|
|
32
|
+
|
|
33
|
+
// ─── Regexes ─────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const RE_FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
|
|
36
|
+
const RE_HEADING = /^(#{1,6})\s+(.+?)\s*$/;
|
|
37
|
+
const RE_TASK = /^- \[([ xX])\]\s?(.*)$/;
|
|
38
|
+
const RE_SECTION_BREAK = /^\*\*\*\s*$/;
|
|
39
|
+
const RE_KANBAN_SETTINGS_START = /^%%\s*kanban:settings\s*$/i;
|
|
40
|
+
const RE_KANBAN_SETTINGS_END = /^%%\s*$/;
|
|
41
|
+
|
|
42
|
+
// Metadata patterns scanned in task body
|
|
43
|
+
const RE_TIME_PREFIX = /^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s+/;
|
|
44
|
+
const RE_TIME_WATCH = /⌚\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/;
|
|
45
|
+
const RE_SCHED = /⏳\s*(\d{4}-\d{2}-\d{2})/;
|
|
46
|
+
const RE_DUE = /📅\s*(\d{4}-\d{2}-\d{2})/;
|
|
47
|
+
const RE_START = /🛫\s*(\d{4}-\d{2}-\d{2})/;
|
|
48
|
+
const RE_DONE_D = /✅\s*(\d{4}-\d{2}-\d{2})/;
|
|
49
|
+
const RE_ASSIGNEE = /@([A-Za-z][A-Za-z0-9_-]*)/;
|
|
50
|
+
const RE_TAG = /(?<![&\w])#([\w][\w-]*)/g; // avoid matching #fragments inside e.g. `&#x...;`
|
|
51
|
+
const RE_WIKILINK = /\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]/g;
|
|
52
|
+
|
|
53
|
+
// Priority emoji → level. Order matters: scan from highest to lowest.
|
|
54
|
+
const PRIORITY_EMOJI: Array<[string, PriorityLevel]> = [
|
|
55
|
+
["🔺", "highest"],
|
|
56
|
+
["⏫", "high"],
|
|
57
|
+
["🔼", "medium"],
|
|
58
|
+
["🔽", "low"],
|
|
59
|
+
["⏬", "lowest"],
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// Decorative emoji we strip from displayTitle but preserve in rawBody.
|
|
63
|
+
// 🔥 = "urgent" visual, ⚪ = "backlog" visual, 📋 = "list" visual.
|
|
64
|
+
const DECORATIVE_EMOJI = ["🔥", "⚪", "📋"];
|
|
65
|
+
|
|
66
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export interface ParseOptions {
|
|
69
|
+
filepath: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseBoard(
|
|
73
|
+
content: string,
|
|
74
|
+
{ filepath }: ParseOptions,
|
|
75
|
+
): ParseResult {
|
|
76
|
+
const diagnostics: ParseDiagnostic[] = [];
|
|
77
|
+
const lineEnding: "\n" | "\r\n" = content.includes("\r\n") ? "\r\n" : "\n";
|
|
78
|
+
|
|
79
|
+
// 1. Strip frontmatter (verbatim block to preserve).
|
|
80
|
+
const fmMatch = content.match(RE_FRONTMATTER);
|
|
81
|
+
let frontmatter = "";
|
|
82
|
+
let body = content;
|
|
83
|
+
let bodyStartLine = 1;
|
|
84
|
+
if (fmMatch) {
|
|
85
|
+
frontmatter = fmMatch[0];
|
|
86
|
+
body = content.slice(fmMatch[0].length);
|
|
87
|
+
bodyStartLine = 1 + frontmatter.split(/\r?\n/).length - 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. Identify trailer (everything from `%% kanban:settings %%` onwards).
|
|
91
|
+
// We split on lines but keep enough info to reassemble whitespace exactly.
|
|
92
|
+
const lines = body.split(/\r?\n/);
|
|
93
|
+
let trailerStart = lines.length;
|
|
94
|
+
for (let i = 0; i < lines.length; i++) {
|
|
95
|
+
if (RE_KANBAN_SETTINGS_START.test(lines[i]!)) {
|
|
96
|
+
trailerStart = i;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Trailer is verbatim; pop leading blank lines but remember them so they
|
|
101
|
+
// are emitted as part of the *last column's* trailing blanks. Actually
|
|
102
|
+
// simpler: just glue them back via the joining algorithm in `serialize`.
|
|
103
|
+
const trailer = lines.slice(trailerStart).join(lineEnding);
|
|
104
|
+
const bodyLines = lines.slice(0, trailerStart);
|
|
105
|
+
|
|
106
|
+
// 3. Walk lines top-down. Build columns; *every line* before the first
|
|
107
|
+
// column is preamble, every line after a heading is a child of that
|
|
108
|
+
// column (task / section-break / blank / raw). Nothing is silently
|
|
109
|
+
// dropped — round-trip fidelity depends on it.
|
|
110
|
+
const preambleLines: string[] = [];
|
|
111
|
+
const columns: Column[] = [];
|
|
112
|
+
let current: Column | undefined;
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
115
|
+
const raw = bodyLines[i]!;
|
|
116
|
+
const lineNo = bodyStartLine + i;
|
|
117
|
+
const trimmed = raw.trim();
|
|
118
|
+
|
|
119
|
+
const h = raw.match(RE_HEADING);
|
|
120
|
+
if (h) {
|
|
121
|
+
const headerLevel = h[1]!.length;
|
|
122
|
+
if (headerLevel !== 2) {
|
|
123
|
+
diagnostics.push({
|
|
124
|
+
line: lineNo,
|
|
125
|
+
level: "info",
|
|
126
|
+
message: `Heading level ${headerLevel} used as column "${h[2]}".`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
current = {
|
|
130
|
+
name: h[2]!,
|
|
131
|
+
headerLevel,
|
|
132
|
+
rawHeading: raw,
|
|
133
|
+
children: [],
|
|
134
|
+
};
|
|
135
|
+
columns.push(current);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Not a heading. Route to preamble or current column.
|
|
140
|
+
const target = current ? current.children : undefined;
|
|
141
|
+
|
|
142
|
+
if (RE_SECTION_BREAK.test(raw)) {
|
|
143
|
+
const sb: SectionBreak = { kind: "section-break", rawLine: raw };
|
|
144
|
+
if (target) target.push(sb);
|
|
145
|
+
else preambleLines.push(raw);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const tm = raw.match(RE_TASK);
|
|
150
|
+
if (tm) {
|
|
151
|
+
if (!current) {
|
|
152
|
+
diagnostics.push({
|
|
153
|
+
line: lineNo,
|
|
154
|
+
level: "warn",
|
|
155
|
+
message: "Task found outside any column — creating implicit '_' column.",
|
|
156
|
+
});
|
|
157
|
+
current = {
|
|
158
|
+
name: "_",
|
|
159
|
+
headerLevel: 2,
|
|
160
|
+
rawHeading: "## _",
|
|
161
|
+
children: [],
|
|
162
|
+
};
|
|
163
|
+
columns.push(current);
|
|
164
|
+
}
|
|
165
|
+
const task = parseTask({
|
|
166
|
+
rawLine: raw,
|
|
167
|
+
checkboxChar: tm[1]!,
|
|
168
|
+
body: tm[2] ?? "",
|
|
169
|
+
columnIndex: columns.length - 1,
|
|
170
|
+
indexInColumn: current.children.length,
|
|
171
|
+
});
|
|
172
|
+
current.children.push(task);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Blank or unrecognized line. Preserve verbatim.
|
|
177
|
+
if (trimmed === "") {
|
|
178
|
+
const bl: BlankLine = { kind: "blank", rawLine: raw };
|
|
179
|
+
if (target) target.push(bl);
|
|
180
|
+
else preambleLines.push(raw);
|
|
181
|
+
} else {
|
|
182
|
+
const other: RawOther = { kind: "raw", rawLine: raw };
|
|
183
|
+
if (target) target.push(other);
|
|
184
|
+
else preambleLines.push(raw);
|
|
185
|
+
diagnostics.push({
|
|
186
|
+
line: lineNo,
|
|
187
|
+
level: "info",
|
|
188
|
+
message: `Unrecognized line preserved verbatim: "${raw.slice(0, 60)}"`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Preamble: lines were split, rejoin with the original line ending. The
|
|
194
|
+
// very last line yielded by `.split(/\r?\n/)` is the empty tail after the
|
|
195
|
+
// final newline, but since we walked bodyLines wholly we always include a
|
|
196
|
+
// trailing ending if the section had one.
|
|
197
|
+
const preamble = preambleLines.length > 0
|
|
198
|
+
? preambleLines.join(lineEnding) + lineEnding
|
|
199
|
+
: "";
|
|
200
|
+
|
|
201
|
+
const board: Board = {
|
|
202
|
+
filepath,
|
|
203
|
+
name: extractBoardName(frontmatter, filepath),
|
|
204
|
+
frontmatter,
|
|
205
|
+
preamble,
|
|
206
|
+
columns,
|
|
207
|
+
trailer,
|
|
208
|
+
lineEnding,
|
|
209
|
+
originalContent: content,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return { board, diagnostics };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Task parsing ────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
interface ParseTaskInput {
|
|
218
|
+
rawLine: string;
|
|
219
|
+
checkboxChar: string;
|
|
220
|
+
body: string;
|
|
221
|
+
columnIndex: number;
|
|
222
|
+
indexInColumn: number;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function parseTask(input: ParseTaskInput): Task {
|
|
226
|
+
const { rawLine, checkboxChar, body, columnIndex, indexInColumn } = input;
|
|
227
|
+
const done = checkboxChar.toLowerCase() === "x";
|
|
228
|
+
|
|
229
|
+
// Time block — try new canonical first, then legacy prefix.
|
|
230
|
+
let timeBlock: TimeBlock | undefined;
|
|
231
|
+
let timeBlockSource: TimeBlockSource | undefined;
|
|
232
|
+
const watch = body.match(RE_TIME_WATCH);
|
|
233
|
+
if (watch) {
|
|
234
|
+
timeBlock = toTimeBlock(watch[1]!, watch[2]!, watch[3]!, watch[4]!);
|
|
235
|
+
if (timeBlock) timeBlockSource = "watch-emoji";
|
|
236
|
+
} else {
|
|
237
|
+
const prefix = body.match(RE_TIME_PREFIX);
|
|
238
|
+
if (prefix) {
|
|
239
|
+
timeBlock = toTimeBlock(prefix[1]!, prefix[2]!, prefix[3]!, prefix[4]!);
|
|
240
|
+
if (timeBlock) timeBlockSource = "legacy-prefix";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Dates
|
|
245
|
+
const sched = body.match(RE_SCHED);
|
|
246
|
+
const due = body.match(RE_DUE);
|
|
247
|
+
const start = body.match(RE_START);
|
|
248
|
+
const doneD = body.match(RE_DONE_D);
|
|
249
|
+
|
|
250
|
+
// Assignee
|
|
251
|
+
const am = body.match(RE_ASSIGNEE);
|
|
252
|
+
|
|
253
|
+
// Tags (global)
|
|
254
|
+
const tags = Array.from(body.matchAll(RE_TAG), (m) => m[1]!).filter(
|
|
255
|
+
(t): t is string => Boolean(t),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Wikilinks — capture displayed text (alias if present, target otherwise)
|
|
259
|
+
const wikilinks = Array.from(body.matchAll(RE_WIKILINK), (m) => m[2] ?? m[1]!);
|
|
260
|
+
|
|
261
|
+
// Priority
|
|
262
|
+
let priority: PriorityLevel = "none";
|
|
263
|
+
for (const [emoji, level] of PRIORITY_EMOJI) {
|
|
264
|
+
if (body.includes(emoji)) {
|
|
265
|
+
priority = level;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Display title — stripped of all metadata but preserving readable text.
|
|
271
|
+
const displayTitle = buildDisplayTitle(body, { hasTimeBlockPrefix: timeBlockSource === "legacy-prefix" });
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
id: `${columnIndex}:${indexInColumn}`,
|
|
275
|
+
done,
|
|
276
|
+
rawBody: body,
|
|
277
|
+
rawLine,
|
|
278
|
+
dirty: false,
|
|
279
|
+
displayTitle,
|
|
280
|
+
assignee: am?.[1],
|
|
281
|
+
tags,
|
|
282
|
+
wikilinks,
|
|
283
|
+
scheduled: sched?.[1],
|
|
284
|
+
due: due?.[1],
|
|
285
|
+
start: start?.[1],
|
|
286
|
+
doneDate: doneD?.[1],
|
|
287
|
+
priority,
|
|
288
|
+
timeBlock,
|
|
289
|
+
timeBlockSource,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function toTimeBlock(h1: string, m1: string, h2: string, m2: string): TimeBlock | undefined {
|
|
294
|
+
const startMin = Number(h1) * 60 + Number(m1);
|
|
295
|
+
const endMin = Number(h2) * 60 + Number(m2);
|
|
296
|
+
if (!Number.isFinite(startMin) || !Number.isFinite(endMin)) return undefined;
|
|
297
|
+
if (startMin < 0 || endMin < 0 || startMin >= 24 * 60 || endMin > 24 * 60) return undefined;
|
|
298
|
+
if (endMin <= startMin) return undefined;
|
|
299
|
+
return { startMin, endMin };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function buildDisplayTitle(
|
|
303
|
+
body: string,
|
|
304
|
+
opts: { hasTimeBlockPrefix: boolean },
|
|
305
|
+
): string {
|
|
306
|
+
let t = body;
|
|
307
|
+
if (opts.hasTimeBlockPrefix) t = t.replace(RE_TIME_PREFIX, "");
|
|
308
|
+
t = t.replace(RE_TIME_WATCH, "");
|
|
309
|
+
t = t.replace(RE_SCHED, "");
|
|
310
|
+
t = t.replace(RE_DUE, "");
|
|
311
|
+
t = t.replace(RE_START, "");
|
|
312
|
+
t = t.replace(RE_DONE_D, "");
|
|
313
|
+
t = t.replace(RE_ASSIGNEE, "");
|
|
314
|
+
t = t.replace(RE_TAG, "");
|
|
315
|
+
// Replace wikilinks with their displayed text (alias or target)
|
|
316
|
+
t = t.replace(RE_WIKILINK, (_m, target: string, alias?: string) => alias ?? target);
|
|
317
|
+
// Strip priority and decorative emoji
|
|
318
|
+
for (const [emoji] of PRIORITY_EMOJI) t = t.replaceAll(emoji, "");
|
|
319
|
+
for (const emoji of DECORATIVE_EMOJI) t = t.replaceAll(emoji, "");
|
|
320
|
+
// Collapse whitespace
|
|
321
|
+
t = t.replace(/\s+/g, " ").trim();
|
|
322
|
+
return t;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function extractBoardName(frontmatter: string, filepath: string): string {
|
|
326
|
+
const m = frontmatter.match(/^name:\s*(.+?)\s*$/m);
|
|
327
|
+
if (m) return m[1]!;
|
|
328
|
+
const file = basename(filepath, extname(filepath));
|
|
329
|
+
// "Tasks - R3PLICA" → "R3PLICA"; otherwise use as-is.
|
|
330
|
+
const dash = file.indexOf(" - ");
|
|
331
|
+
return dash >= 0 ? file.slice(dash + 3) : file;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── Type guards ─────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
export function isTask(child: ColumnChild): child is Task {
|
|
337
|
+
// Tasks are the only child type without a `kind` discriminator.
|
|
338
|
+
return !("kind" in child);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function isSectionBreak(child: ColumnChild): child is SectionBreak {
|
|
342
|
+
return "kind" in child && child.kind === "section-break";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function isBlankLine(child: ColumnChild): child is BlankLine {
|
|
346
|
+
return "kind" in child && child.kind === "blank";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function isRawOther(child: ColumnChild): child is RawOther {
|
|
350
|
+
return "kind" in child && child.kind === "raw";
|
|
351
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown board serializer.
|
|
3
|
+
*
|
|
4
|
+
* Design: each line of the original board (heading, task, section break,
|
|
5
|
+
* blank line, unrecognized) is preserved verbatim unless a task has been
|
|
6
|
+
* marked `dirty: true`. In that case the serializer rebuilds *only* that
|
|
7
|
+
* line from structured fields, emitting the new canonical metadata format
|
|
8
|
+
* (e.g. `⌚ HH:MM-HH:MM`). Everything else round-trips bit-for-bit.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Board, Task, TimeBlock } from "~/types";
|
|
12
|
+
import {
|
|
13
|
+
isBlankLine,
|
|
14
|
+
isRawOther,
|
|
15
|
+
isSectionBreak,
|
|
16
|
+
isTask,
|
|
17
|
+
} from "~/parser/markdown";
|
|
18
|
+
|
|
19
|
+
const PRIORITY_TO_EMOJI: Record<string, string> = {
|
|
20
|
+
highest: "🔺",
|
|
21
|
+
high: "⏫",
|
|
22
|
+
medium: "🔼",
|
|
23
|
+
low: "🔽",
|
|
24
|
+
lowest: "⏬",
|
|
25
|
+
none: "",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function serializeBoard(board: Board): string {
|
|
29
|
+
const eol = board.lineEnding;
|
|
30
|
+
const parts: string[] = [];
|
|
31
|
+
|
|
32
|
+
if (board.frontmatter) parts.push(board.frontmatter);
|
|
33
|
+
if (board.preamble) parts.push(board.preamble);
|
|
34
|
+
|
|
35
|
+
for (const col of board.columns) {
|
|
36
|
+
parts.push(col.rawHeading);
|
|
37
|
+
parts.push(eol);
|
|
38
|
+
for (const child of col.children) {
|
|
39
|
+
if (isTask(child)) {
|
|
40
|
+
parts.push(serializeTask(child));
|
|
41
|
+
} else if (isSectionBreak(child) || isBlankLine(child) || isRawOther(child)) {
|
|
42
|
+
parts.push(child.rawLine);
|
|
43
|
+
}
|
|
44
|
+
parts.push(eol);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (board.trailer) {
|
|
49
|
+
parts.push(board.trailer);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return parts.join("");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function serializeTask(task: Task): string {
|
|
56
|
+
if (!task.dirty) return task.rawLine;
|
|
57
|
+
return rebuildTaskLine(task);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Rebuild a task line from structured fields in canonical format:
|
|
62
|
+
*
|
|
63
|
+
* - [ ] <title> 🔺 @assignee #tag1 ⌚ HH:MM-HH:MM 🛫 D ⏳ D 📅 D ✅ D
|
|
64
|
+
*/
|
|
65
|
+
function rebuildTaskLine(task: Task): string {
|
|
66
|
+
const checkbox = task.done ? "x" : " ";
|
|
67
|
+
const parts: string[] = [`- [${checkbox}]`];
|
|
68
|
+
|
|
69
|
+
const title = task.displayTitle.trim();
|
|
70
|
+
if (title) parts.push(title);
|
|
71
|
+
|
|
72
|
+
if (task.priority !== "none") {
|
|
73
|
+
const glyph = PRIORITY_TO_EMOJI[task.priority];
|
|
74
|
+
if (glyph) parts.push(glyph);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (task.assignee) parts.push(`@${task.assignee}`);
|
|
78
|
+
for (const tag of task.tags) parts.push(`#${tag}`);
|
|
79
|
+
|
|
80
|
+
if (task.timeBlock) parts.push(`⌚ ${fmtTimeBlock(task.timeBlock)}`);
|
|
81
|
+
if (task.start) parts.push(`🛫 ${task.start}`);
|
|
82
|
+
if (task.scheduled) parts.push(`⏳ ${task.scheduled}`);
|
|
83
|
+
if (task.due) parts.push(`📅 ${task.due}`);
|
|
84
|
+
if (task.doneDate) parts.push(`✅ ${task.doneDate}`);
|
|
85
|
+
|
|
86
|
+
return parts.join(" ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function fmtTimeBlock(tb: TimeBlock): string {
|
|
90
|
+
return `${fmtMin(tb.startMin)}-${fmtMin(tb.endMin)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function fmtMin(m: number): string {
|
|
94
|
+
const h = Math.floor(m / 60).toString().padStart(2, "0");
|
|
95
|
+
const mm = (m % 60).toString().padStart(2, "0");
|
|
96
|
+
return `${h}:${mm}`;
|
|
97
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke check for the agents store. Lists the first 10 sessions
|
|
3
|
+
* discovered on this machine with status, display name, and short cwd.
|
|
4
|
+
*
|
|
5
|
+
* Usage: bun run agents:check
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createAgentsStore } from "~/store/agents";
|
|
9
|
+
|
|
10
|
+
const store = createAgentsStore();
|
|
11
|
+
const all = store.sessions();
|
|
12
|
+
const live = all.filter(
|
|
13
|
+
(s) => s.status === "live-busy" || s.status === "live-idle",
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
console.log(`Found ${all.length} sessions, ${live.length} live`);
|
|
17
|
+
console.log("");
|
|
18
|
+
for (const s of all.slice(0, 10)) {
|
|
19
|
+
console.log(
|
|
20
|
+
` ${s.status.padEnd(10)} ${s.displayName.padEnd(40)} ${s.cwdShort}`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await store.dispose();
|