@anchrd/intel-ui 0.37.0 → 0.38.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.
@@ -0,0 +1,74 @@
1
+ import type { BoardTaskFilter } from "@anchrd/intel-contract/board";
2
+ import type { Node } from "@anchrd/intel-contract/node";
3
+ import { useState } from "react";
4
+ import { useBoard } from "@/board/board-data/board-data.ts";
5
+ import { BoardKanban } from "@/board/board-kanban/board-kanban.tsx";
6
+ import type { GroupKey, Sort } from "@/board/board-table/board-table.ts";
7
+ import { BoardTable } from "@/board/board-table/board-table.tsx";
8
+ import { useI18n } from "@/i18n/i18n-context.tsx";
9
+
10
+ type View = "kanban" | "table";
11
+
12
+ /**
13
+ * A board on the node screen, beside the folder, the table and the editor (D66, #376).
14
+ *
15
+ * ⚠️ The whole board is ONE answer. `board_get` returns the columns and every card in them, and the
16
+ * views below read that one object — no view fetches per card, per column, or on scroll. A board
17
+ * that loaded its cards one by one would look exactly the same on screen and be a different thing
18
+ * over the wire, which is why `board-panel.unit.tsx` counts the calls rather than the cards.
19
+ */
20
+ export function BoardPanel({ node }: { node: Node }) {
21
+ const i18n = useI18n();
22
+ const [view, setView] = useState<View>("kanban");
23
+ return (
24
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
25
+ {/* No wrapper role: the house switch (`view-toggle.tsx`) carries none either, and a `group`
26
+ on a plain `div` is the shape a linter rightly asks to be a `fieldset`. What names the
27
+ control is each button — its own text, plus `aria-pressed` for which view is showing. */}
28
+ <div className="flex shrink-0 items-center gap-1 border-b px-4 py-2">
29
+ {(["kanban", "table"] as const).map((name) => (
30
+ <button
31
+ key={name}
32
+ type="button"
33
+ aria-pressed={view === name}
34
+ onClick={() => setView(name)}
35
+ className="rounded-md px-3 py-1 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-muted aria-pressed:font-medium"
36
+ >
37
+ {i18n.t(`board.view.${name}`)}
38
+ </button>
39
+ ))}
40
+ </div>
41
+ {view === "kanban" ? <KanbanView nodeId={node.id} /> : <TableView nodeId={node.id} />}
42
+ </div>
43
+ );
44
+ }
45
+
46
+ function KanbanView({ nodeId }: { nodeId: string }) {
47
+ const board = useBoard(nodeId);
48
+ return <BoardKanban board={board} />;
49
+ }
50
+
51
+ /**
52
+ * ⚠️ **The filter lives HERE, inside the table's own branch, not beside both views.** Held one
53
+ * level up it would survive a switch to the kanban board — which draws whatever the query answered
54
+ * and would then show a column full and the rest empty, with nothing on screen saying why. That is
55
+ * the quiet failure: cards that look deleted. Unmounting the table takes its filter with it, and
56
+ * the kanban asks for the whole board every time.
57
+ */
58
+ function TableView({ nodeId }: { nodeId: string }) {
59
+ const [filter, setFilter] = useState<Partial<BoardTaskFilter>>({});
60
+ const [sort, setSort] = useState<Sort | null>(null);
61
+ const [group, setGroup] = useState<GroupKey>("none");
62
+ const board = useBoard(nodeId, filter);
63
+ return (
64
+ <BoardTable
65
+ board={board}
66
+ filter={filter}
67
+ onFilter={setFilter}
68
+ sort={sort}
69
+ onSort={setSort}
70
+ group={group}
71
+ onGroup={setGroup}
72
+ />
73
+ );
74
+ }
@@ -0,0 +1,135 @@
1
+ import type { BoardTask } from "@anchrd/intel-contract/board";
2
+ import type { ColumnWithCards } from "@/board/board-data/board-data.types.ts";
3
+
4
+ export type SortKey = "title" | "status" | "assignee" | "due" | "dependsOn";
5
+ export type GroupKey = "none" | "status" | "assignee";
6
+
7
+ export interface Sort {
8
+ key: SortKey;
9
+ direction: "asc" | "desc";
10
+ }
11
+
12
+ /**
13
+ * One line of the table: the card, plus the two things the card cannot answer about itself.
14
+ *
15
+ * `columnTitle` is its status resolved against the board's configuration, and `blocking` is what
16
+ * `dependsOn` points at — the blocking card's title when it is on this board, its bare id when it
17
+ * is not. ⚠️ **The id is shown rather than hidden**: a dependency on something outside this board
18
+ * is exactly the case somebody needs to see, and an empty cell claims there is no dependency.
19
+ */
20
+ export interface Row {
21
+ task: BoardTask;
22
+ columnTitle: string;
23
+ blocking: string | null;
24
+ }
25
+
26
+ export function rowsOf(columns: ColumnWithCards[]): Row[] {
27
+ const titles = new Map<string, string>();
28
+ for (const entry of columns) {
29
+ for (const card of entry.cards) titles.set(card.id, card.title);
30
+ }
31
+ return columns.flatMap((entry) =>
32
+ entry.cards.map((task) => ({
33
+ task,
34
+ columnTitle: entry.column.title,
35
+ blocking: task.dependsOn === null ? null : (titles.get(task.dependsOn) ?? task.dependsOn),
36
+ })),
37
+ );
38
+ }
39
+
40
+ function cellOf(row: Row, key: SortKey): string | null {
41
+ switch (key) {
42
+ case "title":
43
+ return row.task.title;
44
+ case "status":
45
+ return row.columnTitle;
46
+ case "assignee":
47
+ return row.task.assigneeId;
48
+ case "due":
49
+ return row.task.dueDate;
50
+ case "dependsOn":
51
+ return row.blocking;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * The table in one order.
57
+ *
58
+ * ⚠️ **An empty cell sorts last in BOTH directions, and that is not a preference.** A card without
59
+ * a due date is not the earliest one — it is one whose date nobody knows, and putting it first is a
60
+ * table that answers "what is due next" with the rows that have no answer. Comparing `null` with a
61
+ * string instead is worse than wrong: `null < "2026-08-19"` and `null > "2026-08-19"` are BOTH
62
+ * false, so the comparator claims equality between rows that are not equal, and the order then
63
+ * depends on where the dateless rows happened to sit.
64
+ *
65
+ * ⚠️ **The sort is stable and the incoming order is the tiebreaker.** Rows arrive in the board's
66
+ * own order — configured columns first, cards by position — so two cards with the same due date
67
+ * keep the order the board gives them rather than swapping on every render.
68
+ */
69
+ export function sortRows(rows: Row[], sort: Sort | null): Row[] {
70
+ if (sort === null) return rows;
71
+ const factor = sort.direction === "asc" ? 1 : -1;
72
+ return [...rows].sort((left, right) => {
73
+ const a = cellOf(left, sort.key);
74
+ const b = cellOf(right, sort.key);
75
+ if (a === null && b === null) return 0;
76
+ if (a === null) return 1;
77
+ if (b === null) return -1;
78
+ // An ISO timestamp compares correctly as a string, so one comparison serves every column.
79
+ return factor * a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
80
+ });
81
+ }
82
+
83
+ export interface Group {
84
+ /**
85
+ * The VALUE the rows were grouped by, not the text to draw.
86
+ *
87
+ * ⚠️ The difference matters for the assignee: the value is a Gate principal id, and an id must
88
+ * never reach the screen (`user-name.ts`, #258). The view resolves it to a name or to the word
89
+ * for somebody it cannot name; this module has no business deciding that, and returning a
90
+ * ready-made label would have made the id the obvious thing to put there.
91
+ *
92
+ * `null` is the group the key does not answer for — unassigned, mostly.
93
+ */
94
+ key: string | null;
95
+ rows: Row[];
96
+ }
97
+
98
+ /**
99
+ * The rows in groups, in the order the rows themselves arrive.
100
+ *
101
+ * ⚠️ **Not alphabetically.** Grouped by status, the groups are the board's columns, and their order
102
+ * carries meaning the alphabet destroys: `todo, doing, done` would be drawn `doing, done, todo`.
103
+ * Walking the rows and appending each new group as it first appears keeps the board's order for
104
+ * free — and keeps whatever `sortRows` decided when the grouping key is something else.
105
+ */
106
+ export function groupRows(rows: Row[], key: GroupKey): Group[] {
107
+ if (key === "none") return [{ key: null, rows }];
108
+ const groups: Group[] = [];
109
+ for (const row of rows) {
110
+ const value = key === "status" ? row.columnTitle : row.task.assigneeId;
111
+ const existing = groups.find((group) => group.key === value);
112
+ if (existing === undefined) groups.push({ key: value, rows: [row] });
113
+ else existing.rows.push(row);
114
+ }
115
+ return groups;
116
+ }
117
+
118
+ /** What the header of a sortable column has to say for a reader who cannot see the arrow. */
119
+ export function ariaSortOf(sort: Sort | null, key: SortKey): "ascending" | "descending" | "none" {
120
+ if (sort === null || sort.key !== key) return "none";
121
+ return sort.direction === "asc" ? "ascending" : "descending";
122
+ }
123
+
124
+ /**
125
+ * What a click on a header means.
126
+ *
127
+ * ⚠️ Three states, not two: ascending, descending, and back to the board's own order. Without the
128
+ * third there is no way back — once a table is sorted it stays sorted, and the order the board was
129
+ * arranged in by hand is unreachable for the rest of the session.
130
+ */
131
+ export function nextSort(sort: Sort | null, key: SortKey): Sort | null {
132
+ if (sort === null || sort.key !== key) return { key, direction: "asc" };
133
+ if (sort.direction === "asc") return { key, direction: "desc" };
134
+ return null;
135
+ }
@@ -0,0 +1,199 @@
1
+ import type { BoardColumn, BoardTaskFilter } from "@anchrd/intel-contract/board";
2
+ import { isFiltering } from "@/board/board-data/board-data.ts";
3
+ import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
4
+ import { useI18n } from "@/i18n/i18n-context.tsx";
5
+ import { useUserName } from "@/user-name/user-name.ts";
6
+ import {
7
+ ariaSortOf,
8
+ type GroupKey,
9
+ groupRows,
10
+ nextSort,
11
+ type Row,
12
+ rowsOf,
13
+ type Sort,
14
+ type SortKey,
15
+ sortRows,
16
+ } from "./board-table.ts";
17
+
18
+ const headers: { key: SortKey; labelKey: string }[] = [
19
+ { key: "title", labelKey: "board.column.title" },
20
+ { key: "status", labelKey: "board.column.status" },
21
+ { key: "assignee", labelKey: "board.column.assignee" },
22
+ { key: "due", labelKey: "board.column.due" },
23
+ // ⚠️ `dependsOn` is a column of its own, and that is the condition under which the board graph
24
+ // stays parked (#651, #653). Stored but undrawn, a dependency is a card that waits for a reason
25
+ // nobody can see.
26
+ { key: "dependsOn", labelKey: "board.column.dependsOn" },
27
+ ];
28
+
29
+ export function BoardTable({
30
+ board,
31
+ filter,
32
+ onFilter,
33
+ sort,
34
+ onSort,
35
+ group,
36
+ onGroup,
37
+ }: {
38
+ board: BoardHandle;
39
+ filter: Partial<BoardTaskFilter>;
40
+ onFilter(next: Partial<BoardTaskFilter>): void;
41
+ sort: Sort | null;
42
+ onSort(next: Sort | null): void;
43
+ group: GroupKey;
44
+ onGroup(next: GroupKey): void;
45
+ }) {
46
+ const i18n = useI18n();
47
+
48
+ if (board.isPending) {
49
+ return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
50
+ }
51
+ if (board.isError) {
52
+ return (
53
+ <p role="alert" className="p-6 text-sm text-destructive">
54
+ {i18n.t("board.failed")}
55
+ </p>
56
+ );
57
+ }
58
+
59
+ const groups = groupRows(sortRows(rowsOf(board.columns), sort), group);
60
+ const configured: BoardColumn[] = board.columns
61
+ .filter((entry) => entry.unknown !== true)
62
+ .map((entry) => entry.column);
63
+ // ⚠️ The answer this is measured against is the FILTERED one. A board full of cards, narrowed to
64
+ // a column that happens to be empty, would otherwise be told it has no cards at all — and the
65
+ // reader would go looking for the cards they just watched disappear.
66
+ const empty = board.columns.every((entry) => entry.cards.length === 0);
67
+ const emptyMessage = isFiltering(filter) ? "board.filterEmpty" : "board.tableEmpty";
68
+
69
+ return (
70
+ <div className="flex min-h-0 flex-1 flex-col gap-3 p-4" aria-busy={board.isWriting}>
71
+ <div className="flex flex-wrap items-center gap-4 text-sm">
72
+ <label className="flex items-center gap-2">
73
+ {i18n.t("board.filter.status")}
74
+ {/* ⚠️ This one TRAVELS. `useBoard` carries the filter in the query key and sends it, so
75
+ the server answers with the cards that match. A view that fetched everything and hid
76
+ rows in the browser would look identical on screen and be a different thing over the
77
+ wire — which is why the test asserts the argument of the second request. */}
78
+ <select
79
+ className="rounded-md border bg-background px-2 py-1"
80
+ value={filter.status ?? ""}
81
+ onChange={(event) =>
82
+ onFilter(
83
+ event.target.value === ""
84
+ ? { ...filter, status: undefined }
85
+ : { ...filter, status: event.target.value },
86
+ )
87
+ }
88
+ >
89
+ <option value="">{i18n.t("board.filter.all")}</option>
90
+ {configured.map((column) => (
91
+ <option key={column.id} value={column.id}>
92
+ {column.title}
93
+ </option>
94
+ ))}
95
+ </select>
96
+ </label>
97
+ <label className="flex items-center gap-2">
98
+ {i18n.t("board.groupBy")}
99
+ <select
100
+ className="rounded-md border bg-background px-2 py-1"
101
+ value={group}
102
+ onChange={(event) => onGroup(event.target.value as GroupKey)}
103
+ >
104
+ <option value="none">{i18n.t("board.group.none")}</option>
105
+ <option value="status">{i18n.t("board.group.status")}</option>
106
+ <option value="assignee">{i18n.t("board.group.assignee")}</option>
107
+ </select>
108
+ </label>
109
+ </div>
110
+
111
+ <div className="min-h-0 flex-1 overflow-auto">
112
+ <table className="w-full border-collapse text-left text-sm">
113
+ <thead>
114
+ <tr className="border-b">
115
+ {headers.map((header) => (
116
+ <th
117
+ key={header.key}
118
+ scope="col"
119
+ aria-sort={ariaSortOf(sort, header.key)}
120
+ className="p-2 font-medium"
121
+ >
122
+ <button
123
+ type="button"
124
+ onClick={() => onSort(nextSort(sort, header.key))}
125
+ className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
126
+ >
127
+ {i18n.t(header.labelKey)}
128
+ <span aria-hidden="true" className="text-xs text-muted-foreground">
129
+ {sort?.key === header.key ? (sort.direction === "asc" ? "▲" : "▼") : ""}
130
+ </span>
131
+ </button>
132
+ </th>
133
+ ))}
134
+ <th scope="col" className="p-2 font-medium">
135
+ {i18n.t("board.column.labels")}
136
+ </th>
137
+ </tr>
138
+ </thead>
139
+ {groups.map((entry) => (
140
+ <tbody key={entry.key ?? "-none-"}>
141
+ {group === "none" ? null : (
142
+ <tr className="border-b bg-muted/40">
143
+ <th scope="rowgroup" colSpan={headers.length + 1} className="p-2 text-left">
144
+ {group === "assignee" ? (
145
+ <Assignee id={entry.key} />
146
+ ) : (
147
+ (entry.key ?? i18n.t("board.unassigned"))
148
+ )}
149
+ <span className="ml-2 text-xs font-normal text-muted-foreground tabular-nums">
150
+ {entry.rows.length}
151
+ </span>
152
+ </th>
153
+ </tr>
154
+ )}
155
+ {entry.rows.map((row) => (
156
+ <Line key={row.task.id} row={row} />
157
+ ))}
158
+ </tbody>
159
+ ))}
160
+ </table>
161
+ {empty ? <p className="p-6 text-sm text-muted-foreground">{i18n.t(emptyMessage)}</p> : null}
162
+ </div>
163
+ </div>
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Who a card belongs to, by the one rule this application has for saying so.
169
+ *
170
+ * ⚠️ **Never the raw id.** Intel has no user directory — `useUserName` can resolve exactly one id,
171
+ * the signed-in person's — and an id under the heading "assigned to" is not an answer to "who", it
172
+ * is the question again in smaller type. It would also put a Gate principal identifier on a screen
173
+ * that has no reason to carry one (`user-name.ts`, #258). Somebody this browser cannot name is
174
+ * drawn as somebody, which is true, and the word changes in exactly one place when Intel gains a
175
+ * lookup (#261).
176
+ */
177
+ function Assignee({ id }: { id: string | null }) {
178
+ const i18n = useI18n();
179
+ const name = useUserName(id);
180
+ if (id === null) return <>{i18n.t("board.unassigned")}</>;
181
+ return <>{name ?? i18n.t("node.someUser")}</>;
182
+ }
183
+
184
+ function Line({ row }: { row: Row }) {
185
+ return (
186
+ <tr className="border-b last:border-0">
187
+ <td className="p-2">{row.task.title}</td>
188
+ <td className="p-2">{row.columnTitle}</td>
189
+ <td className="p-2 text-muted-foreground">
190
+ <Assignee id={row.task.assigneeId} />
191
+ </td>
192
+ {/* The date, not the time: a board deals in days, and a timestamp in a cell is noise that
193
+ pushes the columns beside it off the screen. */}
194
+ <td className="p-2 tabular-nums">{row.task.dueDate?.slice(0, 10) ?? ""}</td>
195
+ <td className="p-2">{row.blocking ?? ""}</td>
196
+ <td className="p-2 text-muted-foreground">{row.task.labels.join(", ")}</td>
197
+ </tr>
198
+ );
199
+ }
@@ -1,4 +1,11 @@
1
1
  import { type NamedOrCounted, ProblemDetails, SessionUser } from "@anchrd/intel-contract";
2
+ import {
3
+ BoardGetInput,
4
+ BoardTaskCreateInput,
5
+ BoardTaskUpdateInput,
6
+ BoardUpdateInput,
7
+ BoardView,
8
+ } from "@anchrd/intel-contract/board";
2
9
  import { BundleImportResult } from "@anchrd/intel-contract/bundle";
3
10
  import {
4
11
  ArchiveFlowInput,
@@ -344,6 +351,49 @@ export function createIntelDataProvider(
344
351
  async listNodeVersions(nodeId) {
345
352
  return await request(`/nodes/${encodeURIComponent(nodeId)}/versions`, NodeVersionList);
346
353
  },
354
+ async getBoard(input) {
355
+ const parsed = BoardGetInput.parse(input);
356
+ // ⚠️ Only the filters the caller actually named travel. Sending an empty `status` would ask
357
+ // the server for the column called "" rather than for every column — the same rule the flow
358
+ // list follows above, and for the same reason.
359
+ const params = new URLSearchParams();
360
+ if (parsed.status !== undefined) params.set("status", parsed.status);
361
+ if (parsed.assigneeId !== undefined) params.set("assigneeId", parsed.assigneeId);
362
+ if (parsed.dueBefore !== undefined) params.set("dueBefore", parsed.dueBefore);
363
+ if (parsed.dependsOn !== undefined) params.set("dependsOn", parsed.dependsOn);
364
+ if (parsed.includeArchived) params.set("includeArchived", "true");
365
+ const query = params.toString();
366
+ return await request(
367
+ `/boards/${encodeURIComponent(parsed.boardId)}${query === "" ? "" : `?${query}`}`,
368
+ BoardView,
369
+ );
370
+ },
371
+ async updateBoard(input) {
372
+ const parsed = BoardUpdateInput.parse(input);
373
+ return await request(`/boards/${encodeURIComponent(parsed.boardId)}`, BoardView, {
374
+ method: "PATCH",
375
+ body: JSON.stringify(parsed),
376
+ });
377
+ },
378
+ async createBoardTask(input) {
379
+ const parsed = BoardTaskCreateInput.parse(input);
380
+ return await request(`/boards/${encodeURIComponent(parsed.boardId)}/tasks`, BoardView, {
381
+ method: "POST",
382
+ body: JSON.stringify(parsed),
383
+ });
384
+ },
385
+ // ⚠️ The board id is in the path although the call identifies the card by its own id: the route
386
+ // mirrors the tool name segment for segment (`board_task_update`), and a card that could be
387
+ // addressed without its board would invite a caller to move one between boards — which the
388
+ // service refuses on purpose (#377).
389
+ async updateBoardTask(boardId, input) {
390
+ const parsed = BoardTaskUpdateInput.parse(input);
391
+ return await request(
392
+ `/boards/${encodeURIComponent(boardId)}/tasks/${encodeURIComponent(parsed.taskId)}`,
393
+ BoardView,
394
+ { method: "PATCH", body: JSON.stringify(parsed) },
395
+ );
396
+ },
347
397
  async updateNode(input) {
348
398
  const parsed = UpdateNodeInput.parse(input);
349
399
  return await request(`/nodes/${encodeURIComponent(parsed.nodeId)}`, Node, {
@@ -1,4 +1,11 @@
1
1
  import type { SessionUser } from "@anchrd/intel-contract";
2
+ import type {
3
+ BoardGetInput,
4
+ BoardTaskCreateInput,
5
+ BoardTaskUpdateInput,
6
+ BoardUpdateInput,
7
+ BoardView,
8
+ } from "@anchrd/intel-contract/board";
2
9
  import type { BundleImportResult } from "@anchrd/intel-contract/bundle";
3
10
  import type {
4
11
  ArchiveFlowInput,
@@ -95,6 +102,21 @@ export interface IntelDataProvider {
95
102
  * would be reporting the wrong thing.
96
103
  */
97
104
  reindexNodes(): Promise<ReindexResult>;
105
+ /**
106
+ * One board, in one answer: its columns and every card the caller may see, without the card
107
+ * bodies (#648).
108
+ *
109
+ * ⚠️ **One call, not one per card.** A screen that fetched each card separately would look
110
+ * identical and is the reason this endpoint exists — the test that guards it counts requests
111
+ * rather than pixels.
112
+ */
113
+ getBoard(input: BoardGetInput): Promise<BoardView>;
114
+ updateBoard(input: BoardUpdateInput): Promise<BoardView>;
115
+ createBoardTask(input: BoardTaskCreateInput): Promise<BoardView>;
116
+ // Every write answers with the WHOLE board rather than the row it changed: a drag moves one card
117
+ // and shifts nothing else, but a column's contents are what the screen draws, and re-deriving
118
+ // them from a single row is where a second, quietly different truth would start.
119
+ updateBoardTask(boardId: string, input: BoardTaskUpdateInput): Promise<BoardView>;
98
120
  listNodes(input?: Partial<ListNodesInput>): Promise<NodeList>;
99
121
  // One level of the shared tree: the documents and the flows filed in the same folder, in one
100
122
  // sorted list. Per level rather than recursive, so opening a folder is what costs a request.
@@ -1,5 +1,4 @@
1
1
  import { DocumentLinkInlineType } from "@anchrd/intel-contract/node";
2
- import { BlockNoteSchema } from "@blocknote/core";
3
2
  import { createReactInlineContentSpec } from "@blocknote/react";
4
3
  import { FileText, Link2Off } from "lucide-react";
5
4
  import { createContext, useContext } from "react";
@@ -87,23 +86,6 @@ export const documentLinkSpec = createReactInlineContentSpec(
87
86
  },
88
87
  );
89
88
 
90
- // One schema for the editor and for everything that reads its documents back: BlockNote's defaults
91
- // plus the one inline element #41 adds.
92
- //
93
- // ⚠️ `extend` rather than `create({ inlineContentSpecs: { ...defaults, documentLink } })`. Both
94
- // build the same schema at run time, but only `extend` keeps the added type in the schema's own
95
- // generics under this repo's `exactOptionalPropertyTypes` — with the spread form the editor infers
96
- // BlockNote's default schema, typechecks, and then refuses to insert a document link.
97
- export const intelEditorSchema = BlockNoteSchema.create().extend({
98
- inlineContentSpecs: { documentLink: documentLinkSpec },
99
- });
100
-
101
- // The schema carries its own editor and block types as declaration-only properties, which is the
102
- // only way to name them here: writing `BlockNoteEditor<typeof schema.blockSchema, ...>` by hand
103
- // fails BlockNote's own constraint under `exactOptionalPropertyTypes`.
104
- export type IntelEditor = (typeof intelEditorSchema)["BlockNoteEditor"];
105
- export type IntelEditorPartialBlock = (typeof intelEditorSchema)["PartialBlock"];
106
-
107
89
  /**
108
90
  * The IDs a stored BlockNote document links to.
109
91
  *
@@ -0,0 +1,21 @@
1
+ import { BlockNoteSchema } from "@blocknote/core";
2
+ import { documentLinkSpec } from "@/document-link/document-link.tsx";
3
+ import { frontmatterSpec } from "@/frontmatter/frontmatter.tsx";
4
+
5
+ // One schema for the editor and for everything that reads its documents back: BlockNote's defaults
6
+ // plus what Intel adds — the inline element of #41 and the head block of #657.
7
+ //
8
+ // ⚠️ `extend` rather than `create({ inlineContentSpecs: { ...defaults, documentLink } })`. Both
9
+ // build the same schema at run time, but only `extend` keeps the added types in the schema's own
10
+ // generics under this repo's `exactOptionalPropertyTypes` — with the spread form the editor infers
11
+ // BlockNote's default schema, typechecks, and then refuses to insert a document link.
12
+ export const intelEditorSchema = BlockNoteSchema.create().extend({
13
+ inlineContentSpecs: { documentLink: documentLinkSpec },
14
+ blockSpecs: { frontmatter: frontmatterSpec() },
15
+ });
16
+
17
+ // The schema carries its own editor and block types as declaration-only properties, which is the
18
+ // only way to name them here: writing `BlockNoteEditor<typeof schema.blockSchema, ...>` by hand
19
+ // fails BlockNote's own constraint under `exactOptionalPropertyTypes`.
20
+ export type IntelEditor = (typeof intelEditorSchema)["BlockNoteEditor"];
21
+ export type IntelEditorPartialBlock = (typeof intelEditorSchema)["PartialBlock"];
@@ -0,0 +1,63 @@
1
+ import type { IntelEditor, IntelEditorPartialBlock } from "@/editor-schema/editor-schema.ts";
2
+ import { FrontmatterBlockType, splitFrontmatter } from "@/frontmatter/frontmatter.tsx";
3
+
4
+ /**
5
+ * The two halves of #657, and they only work as a pair.
6
+ *
7
+ * Before them the editor had no idea what a head was, and remark had no reason to invent one: it
8
+ * read `---` as a thematic break, the keys as a paragraph, and the LAST key plus the closing `---`
9
+ * as a setext heading — so `## autor: Jack` stood above the document's own title. That was the
10
+ * visible half.
11
+ *
12
+ * ⚠️ The invisible half is the one that cost data. `blocksToMarkdownLossy` is what the editor
13
+ * writes into `payload.markdown`, and that field is not decoration: `indexing.ts` pulls it — and
14
+ * nothing else — into full text and vectors, so it is the whole of what a search can match.
15
+ * Serialising those blocks back gave `***`, `\`-hard-breaks and `## autor: Jack`, which is no
16
+ * longer a head at all. Opening a document and saving it left its metadata unfindable, and nothing
17
+ * anywhere went red.
18
+ *
19
+ * Therefore: whatever is parsed out here is what is written back out there, byte for byte. The two
20
+ * functions are each other's inverse over the head, and the test that matters asserts exactly that.
21
+ */
22
+
23
+ /**
24
+ * Markdown to blocks, with the head lifted out of remark's way.
25
+ *
26
+ * ⚠️ Synchronous, because `tryParseMarkdownToBlocks` is. Making it `async` would put the load path
27
+ * behind a tick, and the effect in `node-editor.tsx` moves `savedSnapshot` immediately after it —
28
+ * a gap there is a document that reports itself unsaved the moment it opens (#115).
29
+ */
30
+ export function markdownToBlocks(editor: IntelEditor, markdown: string): IntelEditorPartialBlock[] {
31
+ const { raw, body } = splitFrontmatter(markdown);
32
+ const blocks = editor.tryParseMarkdownToBlocks(body) as IntelEditorPartialBlock[];
33
+ if (raw === null) return blocks;
34
+ return [{ type: FrontmatterBlockType, props: { raw } } as IntelEditorPartialBlock, ...blocks];
35
+ }
36
+
37
+ /**
38
+ * Blocks to markdown, with the head written back as a head.
39
+ *
40
+ * ⚠️ The head is emitted from `raw`, never re-serialised from anything parsed. A head that is not
41
+ * valid YAML comes back exactly as it went in, and the roundtrip has no gap for a parser to fall
42
+ * into.
43
+ */
44
+ export async function blocksToMarkdown(editor: IntelEditor, blocks: unknown[]): Promise<string> {
45
+ const all = blocks as { type?: string; props?: { raw?: unknown } }[];
46
+ // ⚠️ Found by type, NOT taken from position 0. The head is a block like any other: BlockNote's
47
+ // drag handle moves it, and typing above the first block pushes it down. A positional guard falls
48
+ // through to `blocksToMarkdownLossy` for exactly those documents, and that path has no head in it
49
+ // — it serialises the block's rendered DOM, so `payload.markdown` would carry the word "Metadata"
50
+ // where the metadata used to be. That is the same silent loss #657 is about, one gesture away.
51
+ const at = all.findIndex((block) => block?.type === FrontmatterBlockType);
52
+ if (at < 0) return editor.blocksToMarkdownLossy(blocks as never);
53
+ const head = all[at];
54
+ const raw = typeof head?.props?.raw === "string" ? head.props.raw : "";
55
+ // A document has ONE head, and it belongs at the top wherever the block ended up. Any further
56
+ // head block stays in the body and is serialised by the spec's own `toExternalHTML` — as a code
57
+ // block, which is visible and lossless without claiming to be a second head.
58
+ const body = await editor.blocksToMarkdownLossy(all.filter((_, index) => index !== at) as never);
59
+ // ⚠️ The blank line after the closing fence is part of the contract, not tidiness: without it the
60
+ // first line of the body sits against the fence, and a `# Title` there would be read back as part
61
+ // of nothing in particular the next time round.
62
+ return `---\n${raw}\n---\n\n${body}`;
63
+ }