ultimate-brain 0.0.0-stage → 0.2.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/.agents/skills/README.md +3 -0
- package/.agents/skills/brain-apply/SKILL.md +80 -0
- package/.agents/skills/brain-connect-notion/SKILL.md +55 -0
- package/.agents/skills/brain-howto/SKILL.md +36 -0
- package/.agents/skills/brain-howto/references/guide.md +176 -0
- package/.agents/skills/brain-ingest/SKILL.md +105 -0
- package/.agents/skills/brain-ingest/references/cli-inputs.md +129 -0
- package/.agents/skills/brain-ingest/references/example-batch.json +165 -0
- package/.agents/skills/brain-ingest/references/extraction.md +118 -0
- package/.agents/skills/brain-ingest/references/notion.md +65 -0
- package/.agents/skills/brain-ingest/references/sources.md +70 -0
- package/.agents/skills/brain-notion-change/SKILL.md +57 -0
- package/.agents/skills/brain-query/SKILL.md +62 -0
- package/.agents/skills/brain-sync/SKILL.md +66 -0
- package/.agents/skills/brain-sync/references/formats.md +53 -0
- package/LICENSE +21 -0
- package/README.md +40 -2
- package/bin/brain.js +5 -0
- package/dist/cli.js +83 -0
- package/dist/commands/apply.js +34 -0
- package/dist/commands/config.js +96 -0
- package/dist/commands/id.js +32 -0
- package/dist/commands/index.js +58 -0
- package/dist/commands/init.js +233 -0
- package/dist/commands/link.js +285 -0
- package/dist/commands/lint.js +47 -0
- package/dist/commands/log.js +48 -0
- package/dist/commands/note.js +36 -0
- package/dist/commands/proposal.js +57 -0
- package/dist/commands/query.js +38 -0
- package/dist/commands/status.js +98 -0
- package/dist/commands/sync.js +53 -0
- package/dist/commands/upgrade.js +143 -0
- package/dist/commands/views.js +111 -0
- package/dist/lib/apply.js +524 -0
- package/dist/lib/args.js +63 -0
- package/dist/lib/brain-path.js +91 -0
- package/dist/lib/brain.js +36 -0
- package/dist/lib/command.js +61 -0
- package/dist/lib/errors.js +33 -0
- package/dist/lib/frontmatter.js +171 -0
- package/dist/lib/fs-safe.js +140 -0
- package/dist/lib/ids.js +51 -0
- package/dist/lib/index-builder.js +216 -0
- package/dist/lib/input.js +45 -0
- package/dist/lib/lint.js +340 -0
- package/dist/lib/log.js +44 -0
- package/dist/lib/note-new.js +121 -0
- package/dist/lib/notes.js +95 -0
- package/dist/lib/paths.js +59 -0
- package/dist/lib/proposals.js +229 -0
- package/dist/lib/query.js +163 -0
- package/dist/lib/schemas.js +51 -0
- package/dist/lib/skills.js +60 -0
- package/dist/lib/sync.js +760 -0
- package/dist/lib/time.js +21 -0
- package/dist/lib/views.js +139 -0
- package/dist/lib/warnings.js +9 -0
- package/dist/lib/workspace.js +183 -0
- package/package.json +45 -4
- package/spec/schemas/brain.schema.json +200 -0
- package/spec/schemas/note.schema.json +1102 -0
- package/spec/schemas/proposal.schema.json +559 -0
- package/templates/README.md +44 -0
- package/templates/brain/AGENTS.md +55 -0
- package/templates/brain/CLAUDE.md +1 -0
- package/templates/brain/_proposals/.gitkeep +0 -0
- package/templates/brain/archive/goals/.gitkeep +0 -0
- package/templates/brain/archive/projects/.gitkeep +0 -0
- package/templates/brain/areas/.gitkeep +0 -0
- package/templates/brain/brain.json +35 -0
- package/templates/brain/gitignore +6 -0
- package/templates/brain/goals/.gitkeep +0 -0
- package/templates/brain/index.md +3 -0
- package/templates/brain/knowledge/concepts/.gitkeep +0 -0
- package/templates/brain/knowledge/decisions/.gitkeep +0 -0
- package/templates/brain/knowledge/meetings/.gitkeep +0 -0
- package/templates/brain/knowledge/orgs/.gitkeep +0 -0
- package/templates/brain/knowledge/people/.gitkeep +0 -0
- package/templates/brain/knowledge/questions/.gitkeep +0 -0
- package/templates/brain/memories/.gitkeep +0 -0
- package/templates/brain/projects/.gitkeep +0 -0
- package/templates/brain/sources/notion/.gitkeep +0 -0
package/dist/lib/sync.js
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { applyAll } from "./apply.js";
|
|
4
|
+
import { BrainError } from "./errors.js";
|
|
5
|
+
import { assertNoProblems, isRecord, unknownKeys } from "./input.js";
|
|
6
|
+
import { generateId, TYPE_PREFIX } from "./ids.js";
|
|
7
|
+
import { assertStagedValid, checkNoteInput, stageNotes } from "./note-new.js";
|
|
8
|
+
import { compareCodePoints } from "./notes.js";
|
|
9
|
+
import { loadSchemas } from "./schemas.js";
|
|
10
|
+
import { formatDate, formatTimestamp } from "./time.js";
|
|
11
|
+
const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "review"];
|
|
12
|
+
const CREATABLE = new Set(["areas", "goals", "projects", "people", "orgs"]);
|
|
13
|
+
const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", review: "proposal" };
|
|
14
|
+
const DATABASE_FOR_TYPE = { area: "areas", goal: "goals", project: "projects", person: "people", org: "orgs", decision: "decisions", question: "questions", meeting: "meetings", proposal: "review" };
|
|
15
|
+
const PUBLISHED_TYPES = new Set(["area", "goal", "project", "person", "org", "decision", "question", "meeting", "proposal"]);
|
|
16
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
17
|
+
function property(value, key) {
|
|
18
|
+
return Object.hasOwn(value, key) ? value[key] : undefined;
|
|
19
|
+
}
|
|
20
|
+
function noteByNotion(workspace, pageId) {
|
|
21
|
+
return workspace.notes.find((note) => note.attributes.notion === pageId);
|
|
22
|
+
}
|
|
23
|
+
function relationIds(workspace, value, conflicts, row, name) {
|
|
24
|
+
if (!Array.isArray(value))
|
|
25
|
+
return undefined;
|
|
26
|
+
const resolved = [];
|
|
27
|
+
for (const page of value) {
|
|
28
|
+
if (typeof page !== "string")
|
|
29
|
+
continue;
|
|
30
|
+
const note = noteByNotion(workspace, page);
|
|
31
|
+
if (note === undefined) {
|
|
32
|
+
conflicts.push(conflict(row, "UNRESOLVED_RELATION", `Cannot resolve ${name} relation ${page}`, name, value));
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
resolved.push(note.id);
|
|
36
|
+
}
|
|
37
|
+
return [...new Set(resolved)].sort(compareCodePoints);
|
|
38
|
+
}
|
|
39
|
+
function conflict(row, code, message, propertyName, notionValue, brainValue) {
|
|
40
|
+
return {
|
|
41
|
+
database: row.database, page_id: row.page_id, brain_id: typeof row.properties["Brain ID"] === "string" ? row.properties["Brain ID"] : null,
|
|
42
|
+
code, message, ...(propertyName === undefined ? {} : { property: propertyName }),
|
|
43
|
+
...(notionValue === undefined ? {} : { notion_value: notionValue }), ...(brainValue === undefined ? {} : { brain_value: brainValue }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Validates the skill-normalized snapshot without accepting arbitrary MCP-shaped input. */
|
|
47
|
+
export function parseSnapshot(input) {
|
|
48
|
+
if (!isRecord(input))
|
|
49
|
+
assertNoProblems([{ pointer: "", message: "must be an object" }]);
|
|
50
|
+
const record = input;
|
|
51
|
+
const problems = unknownKeys(record, ["as_of", "rows"], "");
|
|
52
|
+
if (!isTimestamp(record.as_of))
|
|
53
|
+
problems.push({ pointer: "/as_of", message: "must be an ISO 8601 UTC timestamp" });
|
|
54
|
+
if (!Array.isArray(record.rows))
|
|
55
|
+
problems.push({ pointer: "/rows", message: "must be a list" });
|
|
56
|
+
(Array.isArray(record.rows) ? record.rows : []).forEach((value, index) => {
|
|
57
|
+
const p = `/rows/${index}`;
|
|
58
|
+
if (!isRecord(value))
|
|
59
|
+
return problems.push({ pointer: p, message: "must be an object" });
|
|
60
|
+
problems.push(...unknownKeys(value, ["database", "page_id", "last_edited", "properties"], p));
|
|
61
|
+
if (!DATABASES.includes(value.database))
|
|
62
|
+
problems.push({ pointer: `${p}/database`, message: "is not a sync database" });
|
|
63
|
+
if (typeof value.page_id !== "string" || value.page_id === "")
|
|
64
|
+
problems.push({ pointer: `${p}/page_id`, message: "must be a non-empty string" });
|
|
65
|
+
if (!isTimestamp(value.last_edited))
|
|
66
|
+
problems.push({ pointer: `${p}/last_edited`, message: "must be an ISO 8601 UTC timestamp" });
|
|
67
|
+
if (!isRecord(value.properties))
|
|
68
|
+
problems.push({ pointer: `${p}/properties`, message: "must be an object" });
|
|
69
|
+
});
|
|
70
|
+
assertNoProblems(problems);
|
|
71
|
+
return { as_of: record.as_of, rows: record.rows };
|
|
72
|
+
}
|
|
73
|
+
/** Compute the deterministic, no-network Notion pull plan (cli §5.14.2). */
|
|
74
|
+
export function buildPlan(workspace, snapshot) {
|
|
75
|
+
const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], conflicts: [], ignored: [] };
|
|
76
|
+
const createsByPage = new Map();
|
|
77
|
+
for (const row of snapshot.rows)
|
|
78
|
+
if (CREATABLE.has(row.database) && blank(property(row.properties, "Brain ID"))) {
|
|
79
|
+
const existing = noteByNotion(workspace, row.page_id);
|
|
80
|
+
if (existing === undefined)
|
|
81
|
+
createsByPage.set(row.page_id, { id: generateId(TYPE_PREFIX[TYPE_FOR_DATABASE[row.database]], new Set([...workspace.ids(), ...[...createsByPage.values()].map((x) => x.id)])), type: TYPE_FOR_DATABASE[row.database] });
|
|
82
|
+
}
|
|
83
|
+
for (const row of snapshot.rows) {
|
|
84
|
+
if (row.database === "key_results") {
|
|
85
|
+
planKeyResult(workspace, snapshot.as_of, row, result);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const brainId = property(row.properties, "Brain ID");
|
|
89
|
+
let note = typeof brainId === "string" && brainId !== "" ? workspace.get(brainId) : noteByNotion(workspace, row.page_id);
|
|
90
|
+
if (typeof brainId === "string" && brainId !== "" && note === undefined) {
|
|
91
|
+
result.conflicts.push(conflict(row, "UNKNOWN_BRAIN_ID", `No note has Brain ID ${brainId}`));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (note !== undefined && note.attributes.notion !== undefined && note.attributes.notion !== row.page_id) {
|
|
95
|
+
result.conflicts.push(conflict(row, "NOTION_ID_MISMATCH", `Note ${note.id} belongs to another Notion page`));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (note === undefined) {
|
|
99
|
+
if (!CREATABLE.has(row.database)) {
|
|
100
|
+
result.ignored.push({ database: row.database, page_id: row.page_id, brain_id: null, reason: "NOT_CREATED_IN_NOTION" });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const item = createFromRow(workspace, snapshot.as_of, row, createsByPage, result.conflicts);
|
|
104
|
+
if (item !== undefined)
|
|
105
|
+
result.create.push(item);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// A create whose publish failed has no Brain ID in Notion yet; treat the row as if it had it (cli §5.14.2).
|
|
109
|
+
const known = typeof brainId === "string" && brainId !== "" ? row : { ...row, properties: { ...row.properties, "Brain ID": note.id } };
|
|
110
|
+
planExisting(workspace, snapshot.as_of, known, note, result);
|
|
111
|
+
}
|
|
112
|
+
sortPlan(result);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
function createFromRow(workspace, asOf, row, creates, conflicts) {
|
|
116
|
+
const type = TYPE_FOR_DATABASE[row.database];
|
|
117
|
+
const title = property(row.properties, "Name");
|
|
118
|
+
if (typeof title !== "string" || title === "") {
|
|
119
|
+
conflicts.push(conflict(row, "REQUIRED_EMPTY", "Name is required", "Name", title));
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const valueOfPage = (value, field, many = false) => {
|
|
123
|
+
if (!Array.isArray(value))
|
|
124
|
+
return many ? [] : undefined;
|
|
125
|
+
const ids = [];
|
|
126
|
+
for (const page of value) {
|
|
127
|
+
if (typeof page !== "string")
|
|
128
|
+
continue;
|
|
129
|
+
const note = noteByNotion(workspace, page);
|
|
130
|
+
const created = creates.get(page);
|
|
131
|
+
if (note !== undefined)
|
|
132
|
+
ids.push(note.id);
|
|
133
|
+
else if (created !== undefined)
|
|
134
|
+
ids.push(created.id);
|
|
135
|
+
else {
|
|
136
|
+
conflicts.push(conflict(row, "UNRESOLVED_RELATION", `Cannot resolve ${field} relation ${page}`, field, value));
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return many ? [...new Set(ids)].sort(compareCodePoints) : ids[0];
|
|
141
|
+
};
|
|
142
|
+
const frontmatter = {
|
|
143
|
+
summary: typeof property(row.properties, "Summary") === "string" && property(row.properties, "Summary") !== "" ? property(row.properties, "Summary") : title,
|
|
144
|
+
notion: row.page_id, sources: [`notion:${row.page_id}`], notion_fields: notionFields(row.database, row.properties, asOf),
|
|
145
|
+
notion_published: publishedBaseline(row, renderedBrainValues({ path: "", type, id: "", body: "", raw: "", attributes: { type, title, summary: property(row.properties, "Summary") ?? title, lifecycle: normalizeLifecycle(property(row.properties, "Lifecycle")) ?? "draft", notion: row.page_id } }, workspace)),
|
|
146
|
+
};
|
|
147
|
+
const facts = {};
|
|
148
|
+
const timeline = [];
|
|
149
|
+
const fact = (name, value) => { if (value !== undefined && value !== null) {
|
|
150
|
+
facts[name] = value;
|
|
151
|
+
timeline.push({ field: name, value, from: row.last_edited.slice(0, 10), learned: asOf.slice(0, 10), source: `notion:${row.page_id}`, assessment: "accepted" });
|
|
152
|
+
} };
|
|
153
|
+
if (type === "goal") {
|
|
154
|
+
frontmatter.why = property(row.properties, "Why");
|
|
155
|
+
frontmatter.area = valueOfPage(property(row.properties, "Area"), "Area");
|
|
156
|
+
fact("deadline", dateValue(property(row.properties, "Deadline")));
|
|
157
|
+
}
|
|
158
|
+
if (type === "project") {
|
|
159
|
+
frontmatter.area = valueOfPage(property(row.properties, "Area"), "Area");
|
|
160
|
+
frontmatter.goal = valueOfPage(property(row.properties, "Goal"), "Goal");
|
|
161
|
+
frontmatter.client = valueOfPage(property(row.properties, "Client"), "Client");
|
|
162
|
+
frontmatter.people = valueOfPage(property(row.properties, "People"), "People", true);
|
|
163
|
+
Object.assign(facts, parseKeyFacts(property(row.properties, "Key Facts")));
|
|
164
|
+
}
|
|
165
|
+
if (type === "person") {
|
|
166
|
+
frontmatter.org = valueOfPage(property(row.properties, "Org"), "Org");
|
|
167
|
+
fact("role", property(row.properties, "Role"));
|
|
168
|
+
}
|
|
169
|
+
if (Object.keys(facts).length > 0) {
|
|
170
|
+
frontmatter.facts = facts;
|
|
171
|
+
for (const [field, value] of Object.entries(facts))
|
|
172
|
+
fact(field, value);
|
|
173
|
+
frontmatter.timeline = timeline;
|
|
174
|
+
}
|
|
175
|
+
if ((type === "goal" && (typeof frontmatter.why !== "string" || typeof frontmatter.area !== "string")) || (type === "project" && typeof frontmatter.area !== "string")) {
|
|
176
|
+
conflicts.push(conflict(row, "MISSING_REQUIRED", `${type} "${title}" is missing required properties`));
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
const id = creates.get(row.page_id).id;
|
|
180
|
+
return { type, id, title, lifecycle: normalizeLifecycle(property(row.properties, "Lifecycle")) ?? "draft", frontmatter, body: "" };
|
|
181
|
+
}
|
|
182
|
+
/** The baseline is what Notion shows (cli §5.14.2), empty values included, so render publishes the brain's values over them. */
|
|
183
|
+
function publishedBaseline(row, rendered) {
|
|
184
|
+
return Object.fromEntries(Object.keys(rendered).filter((name) => Object.hasOwn(row.properties, name)).map((name) => [name, row.properties[name]]));
|
|
185
|
+
}
|
|
186
|
+
function planExisting(workspace, asOf, row, note, out) {
|
|
187
|
+
const updates = { id: note.id };
|
|
188
|
+
if (note.attributes.notion === undefined)
|
|
189
|
+
updates.notion = row.page_id;
|
|
190
|
+
const fields = notionFields(row.database, row.properties, asOf);
|
|
191
|
+
if (Object.keys(fields).length > 1 && !isDeepStrictEqual(withoutAsOf(note.attributes.notion_fields), withoutAsOf(fields)))
|
|
192
|
+
updates.notion_fields = fields;
|
|
193
|
+
const published = isRecord(note.attributes.notion_published) ? note.attributes.notion_published : undefined;
|
|
194
|
+
const rendered = renderedBrainValues(note, workspace);
|
|
195
|
+
const publish = (name, value) => { updates.published = { ...(isRecord(updates.published) ? updates.published : {}), [name]: value }; };
|
|
196
|
+
for (const [name, brainField] of brainProperties(row.database)) {
|
|
197
|
+
if (!Object.hasOwn(row.properties, name))
|
|
198
|
+
continue;
|
|
199
|
+
if (name === "Key Facts") {
|
|
200
|
+
planKeyFacts(row, note, published, updates, out);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
// N, S and B are compared in Notion's normalized form (cli §5.14.1): page ids, {start,end} dates, select labels.
|
|
204
|
+
// Lifecycle compares case-insensitively; a link to a note not yet in Notion counts as a brain change.
|
|
205
|
+
const fold = (value) => name === "Lifecycle" && typeof value === "string" ? value.toLowerCase() : value;
|
|
206
|
+
const n = fold(row.properties[name]);
|
|
207
|
+
const s = fold(published?.[name]);
|
|
208
|
+
const b = fold(rendered[name] ?? null);
|
|
209
|
+
const brainChanged = !sameValue(b, s) || linksUnpublished(workspace, note, brainField);
|
|
210
|
+
if (s === undefined) {
|
|
211
|
+
if (sameValue(n, b) && !brainChanged)
|
|
212
|
+
publish(name, row.properties[name]);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (sameValue(n, s))
|
|
216
|
+
continue;
|
|
217
|
+
const value = brainValue(workspace, row, name, brainField, out.conflicts);
|
|
218
|
+
if (value === undefined && !blank(n))
|
|
219
|
+
continue;
|
|
220
|
+
if (name === "Lifecycle" && blank(n)) {
|
|
221
|
+
out.conflicts.push(conflict(row, "REQUIRED_EMPTY", `${name} cannot be empty`, name, null, note.attributes.lifecycle));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (name === "Lifecycle" && !LIFECYCLES.includes(String(value))) {
|
|
225
|
+
out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: note.id, property: name, reason: "UNKNOWN_OPTION" });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (brainChanged) {
|
|
229
|
+
if (sameValue(n, b) && !linksUnpublished(workspace, note, brainField))
|
|
230
|
+
publish(name, row.properties[name]);
|
|
231
|
+
else
|
|
232
|
+
out.conflicts.push(conflict(row, "BOTH_CHANGED", `${name} changed in both Notion and brain`, name, value ?? null, readBrainField(note, brainField)));
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
// Only Notion changed: your edit.
|
|
236
|
+
if (name === "Lifecycle") {
|
|
237
|
+
updates.lifecycle = value;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!correctable(brainField)) {
|
|
241
|
+
out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: note.id, property: name, reason: "NOT_CORRECTABLE" });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (blank(n) && !optionalReference(brainField)) {
|
|
245
|
+
if (name === "Name" || name === "Summary")
|
|
246
|
+
out.conflicts.push(conflict(row, "REQUIRED_EMPTY", `${name} cannot be empty`, name, null, readBrainField(note, brainField)));
|
|
247
|
+
else
|
|
248
|
+
out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: note.id, property: name, reason: "FACT_REMOVAL_NOT_SUPPORTED" });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
out.corrections.push({ target: note.id, field: brainField, value: value ?? null, observed_at: row.last_edited, property: name, notion_value: value ?? null });
|
|
252
|
+
}
|
|
253
|
+
if (Object.keys(updates).length > 1)
|
|
254
|
+
out.updates.push(updates);
|
|
255
|
+
if (row.database === "review")
|
|
256
|
+
planDecision(row, note, out);
|
|
257
|
+
}
|
|
258
|
+
/** Key Facts are one Notion text property but independently-owned facts (cli §5.14.2). */
|
|
259
|
+
function planKeyFacts(row, note, published, updates, out) {
|
|
260
|
+
const text = property(row.properties, "Key Facts");
|
|
261
|
+
const notion = parseKeyFacts(text);
|
|
262
|
+
const brainFacts = isRecord(note.attributes.facts) ? note.attributes.facts : {};
|
|
263
|
+
const stored = parseKeyFacts(published?.["Key Facts"]);
|
|
264
|
+
let converged = true;
|
|
265
|
+
for (const [key, value] of Object.entries(notion)) {
|
|
266
|
+
const n = typeof brainFacts[key] === "number" && typeof value === "string" && Number.isFinite(Number(value)) ? Number(value) : value;
|
|
267
|
+
const b = brainFacts[key];
|
|
268
|
+
const s = stored[key];
|
|
269
|
+
if (s === undefined) {
|
|
270
|
+
if (!isDeepStrictEqual(n, b))
|
|
271
|
+
converged = false;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (isDeepStrictEqual(n, s))
|
|
275
|
+
continue;
|
|
276
|
+
if (isDeepStrictEqual(b, s)) {
|
|
277
|
+
out.corrections.push({ target: note.id, field: `facts.${key}`, value: n, observed_at: row.last_edited, property: `Key Facts/${key}`, notion_value: text });
|
|
278
|
+
converged = false;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (isDeepStrictEqual(n, b))
|
|
282
|
+
continue;
|
|
283
|
+
out.conflicts.push(conflict(row, "BOTH_CHANGED", `Key Facts/${key} changed in both Notion and brain`, `Key Facts/${key}`, n, b));
|
|
284
|
+
converged = false;
|
|
285
|
+
}
|
|
286
|
+
for (const key of Object.keys(stored))
|
|
287
|
+
if (!Object.hasOwn(notion, key)) {
|
|
288
|
+
out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: note.id, property: `Key Facts/${key}`, reason: "FACT_REMOVAL_NOT_SUPPORTED" });
|
|
289
|
+
converged = false;
|
|
290
|
+
}
|
|
291
|
+
if (converged && !sameValue(text, published?.["Key Facts"]))
|
|
292
|
+
updates.published = { ...(isRecord(updates.published) ? updates.published : {}), "Key Facts": text };
|
|
293
|
+
}
|
|
294
|
+
function planDecision(row, proposal, out) {
|
|
295
|
+
const decision = property(row.properties, "Decision");
|
|
296
|
+
if (decision === undefined || property(row.properties, "State") !== "Pending")
|
|
297
|
+
return;
|
|
298
|
+
if (["applied", "closed"].includes(String(proposal.attributes.status))) {
|
|
299
|
+
out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, reason: "ALREADY_TERMINAL" });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const action = String(decision).toLowerCase();
|
|
303
|
+
const value = property(row.properties, "Edited Value");
|
|
304
|
+
if (!["approve", "reject", "edit"].includes(action)) {
|
|
305
|
+
out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, reason: "NOT_CORRECTABLE" });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (action === "edit" && (value === null || value === undefined || value === "")) {
|
|
309
|
+
out.conflicts.push(conflict(row, "EDIT_WITHOUT_VALUE", "Edit needs an Edited Value"));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
out.decisions.push({ proposal: proposal.id, action: action, ...(action === "edit" ? { value } : {}), decided_at: row.last_edited, decided_by: "notion" });
|
|
313
|
+
}
|
|
314
|
+
function planKeyResult(workspace, asOf, row, out) {
|
|
315
|
+
const goals = relationIds(workspace, property(row.properties, "Goal"), out.conflicts, row, "Goal");
|
|
316
|
+
const goal = goals?.[0];
|
|
317
|
+
if (goal === undefined) {
|
|
318
|
+
out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: null, reason: "UNKNOWN_GOAL" });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const note = workspace.get(goal);
|
|
322
|
+
if (note === undefined)
|
|
323
|
+
return;
|
|
324
|
+
const values = isRecord(note.attributes.notion_fields) ? { ...note.attributes.notion_fields } : {};
|
|
325
|
+
const existing = Array.isArray(values.key_results) ? values.key_results.filter(isRecord) : [];
|
|
326
|
+
const current = { page_id: row.page_id, name: property(row.properties, "Name") ?? null, ...row.properties };
|
|
327
|
+
const keyResults = [...existing.filter((item) => item.page_id !== row.page_id), current];
|
|
328
|
+
values.key_results = keyResults.sort((a, b) => String(a["Name"] ?? a.name).localeCompare(String(b["Name"] ?? b.name)) || String(a.page_id).localeCompare(String(b.page_id)));
|
|
329
|
+
values.as_of = asOf;
|
|
330
|
+
if (!isDeepStrictEqual(withoutAsOf(note.attributes.notion_fields), withoutAsOf(values)))
|
|
331
|
+
out.updates.push({ id: goal, notion_fields: values });
|
|
332
|
+
}
|
|
333
|
+
function sortPlan(plan) {
|
|
334
|
+
const order = (item) => [DATABASES.indexOf(String(item.database)), String(item.page_id ?? item.id), String(item.field ?? item.property ?? "")].join("\u0000");
|
|
335
|
+
for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.conflicts, plan.ignored])
|
|
336
|
+
values.sort((a, b) => order(a).localeCompare(order(b)));
|
|
337
|
+
}
|
|
338
|
+
function notionFields(database, p, asOf) {
|
|
339
|
+
const mapping = { areas: { Type: "type", Archived: "archived" }, goals: { Status: "status", Progress: "progress", Archived: "archived" }, projects: { Status: "status", Priority: "priority", Dates: "dates", Archived: "archived" }, people: { Email: "email" }, orgs: { Relationship: "relationship", Website: "website" }, questions: { Status: "status" } };
|
|
340
|
+
const fields = { as_of: asOf };
|
|
341
|
+
for (const [name, key] of Object.entries(mapping[database] ?? {}))
|
|
342
|
+
if (Object.hasOwn(p, name) && p[name] !== null)
|
|
343
|
+
fields[key] = p[name];
|
|
344
|
+
return fields;
|
|
345
|
+
}
|
|
346
|
+
function brainProperties(database) {
|
|
347
|
+
const common = [["Name", "title"], ["Summary", "summary"], ["Lifecycle", "lifecycle"]];
|
|
348
|
+
const extras = { goals: [["Why", "why"], ["Deadline", "facts.deadline"], ["Area", "area"]], projects: [["Key Facts", "facts"], ["Area", "area"], ["Goal", "goal"], ["Client", "client"], ["People", "people"]], people: [["Role", "facts.role"], ["Org", "org"]], decisions: [["Assessment", "assessment"]], questions: [["Kind", "kind"]] };
|
|
349
|
+
return [...common, ...(extras[database] ?? [])];
|
|
350
|
+
}
|
|
351
|
+
function convertedValue(workspace, row, name, value, conflicts) {
|
|
352
|
+
if (name === "Lifecycle")
|
|
353
|
+
return normalizeLifecycle(value);
|
|
354
|
+
if (name === "Deadline")
|
|
355
|
+
return dateValue(value);
|
|
356
|
+
if (["Area", "Goal", "Client", "Org", "People"].includes(name))
|
|
357
|
+
return relationIds(workspace, value, conflicts, row, name);
|
|
358
|
+
if (name === "Key Facts")
|
|
359
|
+
return parseKeyFacts(value);
|
|
360
|
+
return value;
|
|
361
|
+
}
|
|
362
|
+
/** A Notion value in the brain's form: brain ids for relations (one id for single references), YYYY-MM-DD dates, lowercase lifecycle. */
|
|
363
|
+
function brainValue(workspace, row, name, brainField, conflicts) {
|
|
364
|
+
const value = convertedValue(workspace, row, name, row.properties[name], conflicts);
|
|
365
|
+
return Array.isArray(value) && ["area", "goal", "client", "org"].includes(brainField) ? value[0] ?? null : value;
|
|
366
|
+
}
|
|
367
|
+
/** True when a relation field links a note that has no Notion page yet: render leaves it out, so B alone would hide it. */
|
|
368
|
+
function linksUnpublished(workspace, note, brainField) {
|
|
369
|
+
if (!["area", "goal", "client", "org", "people"].includes(brainField))
|
|
370
|
+
return false;
|
|
371
|
+
const raw = note.attributes[brainField];
|
|
372
|
+
const ids = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
|
|
373
|
+
return ids.some((id) => typeof workspace.get(String(id))?.attributes.notion !== "string");
|
|
374
|
+
}
|
|
375
|
+
/** Equal in Notion's normalized form; relations compare as sets, and an empty text equals null. */
|
|
376
|
+
function sameValue(a, b) {
|
|
377
|
+
const norm = (value) => Array.isArray(value) ? [...value].map(String).sort(compareCodePoints) : value === "" || value === undefined ? null : value;
|
|
378
|
+
return isDeepStrictEqual(norm(a), norm(b));
|
|
379
|
+
}
|
|
380
|
+
function readBrainField(note, field) {
|
|
381
|
+
if (field === "facts")
|
|
382
|
+
return note.attributes.facts ?? {};
|
|
383
|
+
if (field.startsWith("facts."))
|
|
384
|
+
return isRecord(note.attributes.facts) ? note.attributes.facts[field.slice(6)] ?? null : null;
|
|
385
|
+
return note.attributes[field] ?? null;
|
|
386
|
+
}
|
|
387
|
+
function correctable(field) { return ["title", "summary", "why", "area", "goal", "client", "org", "people"].includes(field) || field.startsWith("facts."); }
|
|
388
|
+
function optionalReference(field) { return ["goal", "client", "org"].includes(field); }
|
|
389
|
+
const LIFECYCLES = ["draft", "reviewed", "disputed", "archived"];
|
|
390
|
+
function normalizeLifecycle(value) { return typeof value === "string" ? value.toLowerCase() : value; }
|
|
391
|
+
function dateValue(value) { return isRecord(value) && typeof value.start === "string" ? value.start.slice(0, 10) : value; }
|
|
392
|
+
function blank(value) { return value === undefined || value === null || value === ""; }
|
|
393
|
+
function withoutAsOf(value) { if (!isRecord(value))
|
|
394
|
+
return value ?? {}; const { as_of: _asOf, ...rest } = value; return rest; }
|
|
395
|
+
function isTimestamp(value) { return typeof value === "string" && TIMESTAMP.test(value); }
|
|
396
|
+
export function parseKeyFacts(value) {
|
|
397
|
+
if (typeof value !== "string" || value === "")
|
|
398
|
+
return {};
|
|
399
|
+
const result = {};
|
|
400
|
+
for (const segment of value.split(" · ")) {
|
|
401
|
+
const separator = segment.indexOf(": ");
|
|
402
|
+
if (separator > 0)
|
|
403
|
+
result[segment.slice(0, separator).toLowerCase().replaceAll(" ", "_")] = segment.slice(separator + 2);
|
|
404
|
+
}
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
/** Input validation and execution for `sync apply`. */
|
|
408
|
+
export function parsePlan(input) {
|
|
409
|
+
if (!isRecord(input))
|
|
410
|
+
assertNoProblems([{ pointer: "", message: "must be an object" }]);
|
|
411
|
+
const p = input;
|
|
412
|
+
const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "conflicts", "ignored"], "");
|
|
413
|
+
if (!isTimestamp(p.as_of))
|
|
414
|
+
problems.push({ pointer: "/as_of", message: "must be an ISO 8601 UTC timestamp" });
|
|
415
|
+
for (const key of ["create", "updates", "corrections", "decisions", "conflicts", "ignored"])
|
|
416
|
+
if (!Array.isArray(p[key]))
|
|
417
|
+
problems.push({ pointer: `/${key}`, message: "must be a list" });
|
|
418
|
+
(Array.isArray(p.create) ? p.create : []).forEach((item, index) => {
|
|
419
|
+
if (!isRecord(item)) {
|
|
420
|
+
problems.push({ pointer: `/create/${index}`, message: "must be an object" });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
problems.push(...unknownKeys(item, ["type", "id", "title", "file_date", "frontmatter", "body", "lifecycle"], `/create/${index}`));
|
|
424
|
+
const { lifecycle: _lifecycle, ...note } = item;
|
|
425
|
+
problems.push(...checkNoteInput(note, `/create/${index}`));
|
|
426
|
+
if (item.lifecycle !== undefined && typeof item.lifecycle !== "string")
|
|
427
|
+
problems.push({ pointer: `/create/${index}/lifecycle`, message: "must be a lifecycle string" });
|
|
428
|
+
});
|
|
429
|
+
assertNoProblems(problems);
|
|
430
|
+
return p;
|
|
431
|
+
}
|
|
432
|
+
export function applyPlan(workspace, plan, now, recordOnly) {
|
|
433
|
+
const create = [];
|
|
434
|
+
const updates = [];
|
|
435
|
+
const dependencyFailures = new Set();
|
|
436
|
+
for (let index = 0; index < plan.create.length; index += 1) {
|
|
437
|
+
const item = plan.create[index];
|
|
438
|
+
const checkpoint = workspace.checkpoint();
|
|
439
|
+
try {
|
|
440
|
+
if (item.id !== undefined && workspace.get(item.id) !== undefined)
|
|
441
|
+
throw new Error("ID_IN_USE");
|
|
442
|
+
const depends = references(item.frontmatter).some((id) => dependencyFailures.has(id));
|
|
443
|
+
if (depends)
|
|
444
|
+
throw new Error("DEPENDENCY_FAILED");
|
|
445
|
+
const staged = stageNotes(workspace, [item], () => `/create/${index}`, { now, lifecycle: String(item.lifecycle ?? "draft") });
|
|
446
|
+
assertStagedValid(workspace, staged.pointers);
|
|
447
|
+
create.push({ id: staged.created[0].id, result: "applied", changed: staged.created.map((note) => note.path) });
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
workspace.restore(checkpoint);
|
|
451
|
+
const reason = error instanceof Error && error.message === "DEPENDENCY_FAILED" ? "DEPENDENCY_FAILED" : error instanceof Error && error.message === "ID_IN_USE" ? "ID_IN_USE" : "INVALID_RESULT";
|
|
452
|
+
if (item.id !== undefined)
|
|
453
|
+
dependencyFailures.add(item.id);
|
|
454
|
+
create.push({ id: item.id, result: "failed", reason, changed: [] });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
for (const update of plan.updates) {
|
|
458
|
+
const checkpoint = workspace.checkpoint();
|
|
459
|
+
const id = String(update.id);
|
|
460
|
+
const note = workspace.get(id);
|
|
461
|
+
if (note === undefined) {
|
|
462
|
+
updates.push({ id, result: "failed", reason: "UNKNOWN_ID", changed: [] });
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
const doc = workspace.edit(note);
|
|
467
|
+
for (const key of ["notion", "lifecycle", "notion_fields"])
|
|
468
|
+
if (Object.hasOwn(update, key))
|
|
469
|
+
doc.attributes[key] = update[key];
|
|
470
|
+
if (isRecord(update.published))
|
|
471
|
+
doc.attributes.notion_published = { ...(isRecord(doc.attributes.notion_published) ? doc.attributes.notion_published : {}), ...update.published };
|
|
472
|
+
workspace.save(note.path, doc);
|
|
473
|
+
const errors = workspace.newErrors();
|
|
474
|
+
if (errors.length > 0)
|
|
475
|
+
throw new Error("INVALID_RESULT");
|
|
476
|
+
updates.push({ id, result: "applied", changed: workspace.changedSince(checkpoint) });
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
workspace.restore(checkpoint);
|
|
480
|
+
updates.push({ id, result: "failed", reason: "INVALID_RESULT", changed: [] });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const correctionApply = recordOnly ? undefined : applyAll(workspace, { corrections: plan.corrections, decisions: [] }, { now, recordOnly: false });
|
|
484
|
+
const corrections = recordOnly ? plan.corrections.map((item) => ({ target: item.target, field: item.field, result: "skipped", reason: "RECORD_ONLY", changed: [] })) : correctionApply.corrections;
|
|
485
|
+
const decisionApply = applyAll(workspace, { corrections: [], decisions: plan.decisions }, { now, recordOnly });
|
|
486
|
+
const decisionResult = decisionApply.decisions;
|
|
487
|
+
for (const correction of corrections)
|
|
488
|
+
if (correction.result === "applied") {
|
|
489
|
+
const note = workspace.get(correction.target);
|
|
490
|
+
const input = plan.corrections.find((item) => item.target === correction.target && item.field === correction.field);
|
|
491
|
+
if (note !== undefined && input !== undefined && "property" in input) {
|
|
492
|
+
const doc = workspace.edit(note);
|
|
493
|
+
doc.attributes.notion_published = { ...(isRecord(doc.attributes.notion_published) ? doc.attributes.notion_published : {}), [String(input.property)]: input.notion_value };
|
|
494
|
+
workspace.save(note.path, doc);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const log = [
|
|
498
|
+
...create.filter((item) => item.result === "applied").map((item) => ({ op: "sync-create", id: String(item.id), title: String(workspace.get(String(item.id))?.attributes.title ?? item.id) })),
|
|
499
|
+
...updates.filter((item) => item.result === "applied").map((item) => ({ op: "sync-update", id: String(item.id), title: String(workspace.get(String(item.id))?.attributes.title ?? item.id) })),
|
|
500
|
+
...(correctionApply?.log ?? []), ...(decisionApply.log ?? []),
|
|
501
|
+
];
|
|
502
|
+
return { create, updates, corrections, decisions: decisionResult, conflicts: plan.conflicts, ignored: plan.ignored, summary: summarize([...create, ...updates, ...corrections, ...decisionResult].map((item) => ({ ...item }))), log };
|
|
503
|
+
}
|
|
504
|
+
function references(value) { if (Array.isArray(value))
|
|
505
|
+
return value.flatMap(references); if (isRecord(value))
|
|
506
|
+
return Object.values(value).flatMap(references); return typeof value === "string" && /^(area|gol|prj|per|org|mtg|dec|que|con|src|mem)-/.test(value) ? [value] : []; }
|
|
507
|
+
function summarize(items) { const result = { applied: 0, closed: 0, recorded: 0, corrected: 0, skipped: 0, failed: 0 }; for (const item of items)
|
|
508
|
+
if (typeof item.result === "string" && Object.hasOwn(result, item.result))
|
|
509
|
+
result[item.result] += 1; return result; }
|
|
510
|
+
export function render(workspace, now, ids, held) {
|
|
511
|
+
const selected = ids === undefined ? workspace.notes.filter((note) => PUBLISHED_TYPES.has(note.type)) : ids.map((id) => workspace.get(id)).filter(Boolean);
|
|
512
|
+
const items = [];
|
|
513
|
+
const errors = [];
|
|
514
|
+
const warnings = [];
|
|
515
|
+
for (const note of selected) {
|
|
516
|
+
const database = DATABASE_FOR_TYPE[note.type];
|
|
517
|
+
if (database === undefined)
|
|
518
|
+
continue;
|
|
519
|
+
const databaseId = databaseIdFor(workspace, database);
|
|
520
|
+
if (databaseId === "") {
|
|
521
|
+
warnings.push({ code: "DATABASE_NOT_CONFIGURED", message: `${database} database is not configured`, id: note.id });
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (note.type === "meeting" && typeof note.attributes.notion !== "string") {
|
|
525
|
+
errors.push({ id: note.id, reason: "MEETING_NOT_PUBLISHED" });
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const rendered = renderedBrainValues(note, workspace);
|
|
529
|
+
const published = isRecord(note.attributes.notion_published) ? note.attributes.notion_published : {};
|
|
530
|
+
const properties = {};
|
|
531
|
+
const pending = [];
|
|
532
|
+
const heldProperties = held?.get(note.id) ?? new Set();
|
|
533
|
+
for (const [name, value] of Object.entries(rendered)) {
|
|
534
|
+
if (name === "@content" || heldProperties.has(name))
|
|
535
|
+
continue;
|
|
536
|
+
const encoded = notionProperty(name, value, note, workspace, pending);
|
|
537
|
+
if (encoded !== undefined && (note.attributes.notion === undefined || !Object.hasOwn(published, name) || !sameValue(value, published[name])))
|
|
538
|
+
properties[name] = encoded;
|
|
539
|
+
}
|
|
540
|
+
const content = renderedContent(note, workspace);
|
|
541
|
+
if (content.hash !== undefined)
|
|
542
|
+
rendered["@content"] = content.hash;
|
|
543
|
+
const contentChanged = content.hash !== undefined && !isDeepStrictEqual(content.hash, published["@content"]);
|
|
544
|
+
const isCreate = note.attributes.notion === undefined;
|
|
545
|
+
if (!isCreate && Object.keys(properties).length === 0 && !contentChanged)
|
|
546
|
+
continue;
|
|
547
|
+
if (!isCreate && note.type !== "meeting" && note.type !== "proposal")
|
|
548
|
+
properties["Last Synced"] = { type: "date", value: { start: formatTimestamp(now), end: null } };
|
|
549
|
+
items.push({ id: note.id, type: note.type, database, database_id: databaseId, page_id: typeof note.attributes.notion === "string" ? note.attributes.notion : null, action: isCreate ? "create" : "update", properties, body: content.body, sections: content.sections, pending_relations: pending, published: rendered });
|
|
550
|
+
}
|
|
551
|
+
items.sort((a, b) => DATABASES.concat(["meetings"]).indexOf(a.database) - DATABASES.concat(["meetings"]).indexOf(b.database) || compareCodePoints(a.id, b.id));
|
|
552
|
+
return { items, errors, warnings };
|
|
553
|
+
}
|
|
554
|
+
function databaseIdFor(workspace, database) { const notion = workspace.config.notion; return isRecord(notion) && isRecord(notion.databases) && typeof notion.databases[database] === "string" ? notion.databases[database] : ""; }
|
|
555
|
+
function renderedBrainValues(note, workspace) {
|
|
556
|
+
const a = note.attributes;
|
|
557
|
+
const values = {};
|
|
558
|
+
const common = () => Object.assign(values, { Name: a.title, Summary: a.summary ?? null, Lifecycle: select(a.lifecycle), "Brain ID": note.id });
|
|
559
|
+
if (note.type === "proposal") {
|
|
560
|
+
const target = typeof a.target === "string" ? workspace.get(a.target) : undefined;
|
|
561
|
+
const addLink = a.kind === "add_link";
|
|
562
|
+
const after = addLink && typeof a.field === "string"
|
|
563
|
+
? `${fieldLabel(a.field)}: ${noteTitle(a.after, workspace)}`
|
|
564
|
+
: undefined;
|
|
565
|
+
Object.assign(values, {
|
|
566
|
+
Name: a.title, Kind: select(a.kind), Target: target === undefined ? null : `${target.attributes.title} (${target.id})`,
|
|
567
|
+
Before: proposalBefore(a, workspace), After: truncate(after ?? proposalAfter(a, workspace)),
|
|
568
|
+
Why: a.why ?? null, State: select(proposalState(a.status)), "Brain ID": note.id,
|
|
569
|
+
});
|
|
570
|
+
return values;
|
|
571
|
+
}
|
|
572
|
+
if (note.type === "meeting") {
|
|
573
|
+
Object.assign(values, { Decisions: relationPages(a.decisions, workspace), Questions: relationPages(a.questions, workspace), Attendees: relationPages(a.attendees, workspace), Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace), "Brain Status": "Ingested", "Ingested At": dateOutput(a.created), "Brain ID": note.id });
|
|
574
|
+
return values;
|
|
575
|
+
}
|
|
576
|
+
common();
|
|
577
|
+
if (note.type === "goal")
|
|
578
|
+
Object.assign(values, { Why: a.why ?? null, Deadline: dateOutput(isRecord(a.facts) ? a.facts.deadline : undefined), Area: relationPages(a.area === undefined ? [] : [a.area], workspace) });
|
|
579
|
+
if (note.type === "project")
|
|
580
|
+
Object.assign(values, { "Key Facts": keyFactsText(a.facts, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace), Goal: relationPages(a.goal === undefined ? [] : [a.goal], workspace), Client: relationPages(a.client === undefined ? [] : [a.client], workspace), People: relationPages(a.people, workspace) });
|
|
581
|
+
if (note.type === "person")
|
|
582
|
+
Object.assign(values, { Role: isRecord(a.facts) ? a.facts.role ?? null : null, Org: relationPages(a.org === undefined ? [] : [a.org], workspace) });
|
|
583
|
+
if (note.type === "decision")
|
|
584
|
+
Object.assign(values, { Date: dateOutput(a.date), Assessment: select(a.assessment), Project: relationPages(a.projects, workspace), "Decided In": relationPages(a.decided_in === undefined ? [] : [a.decided_in], workspace), Supersedes: relationPages((Array.isArray(a.relations) ? a.relations.filter(isRecord).filter((x) => x.type === "supersedes").map((x) => x.target) : []), workspace) });
|
|
585
|
+
if (note.type === "question")
|
|
586
|
+
Object.assign(values, { Kind: select(a.kind), Project: relationPages(a.projects, workspace), "Raised In": relationPages(a.raised_in === undefined ? [] : [a.raised_in], workspace) });
|
|
587
|
+
return values;
|
|
588
|
+
}
|
|
589
|
+
function notionProperty(name, value, note, workspace, pending) {
|
|
590
|
+
const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Attendees"].includes(name);
|
|
591
|
+
if (relation) {
|
|
592
|
+
const sourceField = { Area: "area", Goal: "goal", Client: "client", People: "people", Org: "org", Project: "projects", "Decided In": "decided_in", "Raised In": "raised_in", Decisions: "decisions", Questions: "questions", Attendees: "attendees" };
|
|
593
|
+
const raw = name === "Supersedes" ? (Array.isArray(note.attributes.relations) ? note.attributes.relations.filter(isRecord).filter((item) => item.type === "supersedes").map((item) => item.target) : []) : note.attributes[sourceField[name] ?? ""];
|
|
594
|
+
const missing = (Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]).map(String).filter((id) => typeof workspace.get(id)?.attributes.notion !== "string");
|
|
595
|
+
if (missing.length > 0)
|
|
596
|
+
pending.push({ property: name, ids: missing });
|
|
597
|
+
return { type: "relation", value: Array.isArray(value) ? value : [], ...(note.type === "meeting" ? { mode: ["Project", "Area"].includes(name) ? "if_empty" : "add" } : {}) };
|
|
598
|
+
}
|
|
599
|
+
if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status"].includes(name))
|
|
600
|
+
return { type: "select", value };
|
|
601
|
+
if (name === "Last Synced" || name === "Date" || name === "Deadline" || name === "Ingested At")
|
|
602
|
+
return { type: "date", value };
|
|
603
|
+
return { type: name === "Brain ID" ? "rich_text" : "rich_text", value };
|
|
604
|
+
}
|
|
605
|
+
function renderedContent(note, workspace) { if (note.type === "decision" || note.type === "question") {
|
|
606
|
+
const body = notionMarkdown(note.body, note, workspace);
|
|
607
|
+
return { body, sections: null, hash: hash(body) };
|
|
608
|
+
} if (note.type === "project") {
|
|
609
|
+
const brief = workspace.notes.find((x) => x.path === `${note.path.slice(0, note.path.lastIndexOf("/"))}/brief.md`);
|
|
610
|
+
const log = workspace.notes.find((x) => x.path === `${note.path.slice(0, note.path.lastIndexOf("/"))}/decision-log.md`);
|
|
611
|
+
const sections = { Brief: notionMarkdown(generatedBlock(brief?.body ?? ""), note, workspace), "Decision log": notionMarkdown(generatedBlock(log?.body ?? ""), note, workspace) };
|
|
612
|
+
return { body: null, sections, hash: hash(JSON.stringify(sections)) };
|
|
613
|
+
} return { body: null, sections: null }; }
|
|
614
|
+
function generatedBlock(body) { const start = body.indexOf("<!-- @generated:start -->"); const end = body.indexOf("<!-- @generated:end -->"); return start >= 0 && end > start ? body.slice(start + "<!-- @generated:start -->".length, end).trim() : body.trim(); }
|
|
615
|
+
function notionMarkdown(body, source, workspace) { const fromFolder = source.path.includes("/") ? source.path.slice(0, source.path.lastIndexOf("/")) : ""; return body.replace(/\^\[(inferred|ambiguous)\]/g, (_, kind) => kind === "inferred" ? " (AI inference)" : " (ambiguous)").replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_all, title, link) => { const targetPath = normalizeRelative(fromFolder, link); const target = workspace.notes.find((note) => note.path === targetPath); return typeof target?.attributes.notion === "string" ? `[${title}](https://www.notion.so/${target.attributes.notion.replaceAll("-", "")})` : title; }); }
|
|
616
|
+
function normalizeRelative(folder, link) { const parts = `${folder}/${link}`.split("/"); const out = []; for (const part of parts) {
|
|
617
|
+
if (part === "" || part === ".")
|
|
618
|
+
continue;
|
|
619
|
+
if (part === "..")
|
|
620
|
+
out.pop();
|
|
621
|
+
else
|
|
622
|
+
out.push(part);
|
|
623
|
+
} return out.join("/"); }
|
|
624
|
+
function hash(value) { return `sha256:${createHash("sha256").update(value).digest("hex")}`; }
|
|
625
|
+
function relationPages(value, workspace) { return publishedPages(value, workspace); }
|
|
626
|
+
function select(value) { return typeof value === "string" ? value.split("_").map((word) => word[0]?.toUpperCase() + word.slice(1)).join(" ") : value ?? null; }
|
|
627
|
+
function proposalState(value) { return ["pending", "approved", "edited", "rejected"].includes(String(value)) ? "Pending" : String(value) === "applied" ? "Applied" : String(value) === "closed" ? "Closed" : value; }
|
|
628
|
+
function dateOutput(value) { return typeof value === "string" && /^\d{4}-\d{2}$/.test(value) ? { start: `${value}-01`, end: null } : typeof value === "string" ? { start: value, end: null } : null; }
|
|
629
|
+
function keyFactsText(value, workspace) { if (!isRecord(value))
|
|
630
|
+
return ""; return Object.keys(value).sort(compareCodePoints).map((key) => `${key[0].toUpperCase()}${key.slice(1).replaceAll("_", " ")}: ${personTitle(value[key], workspace)}`).join(" · "); }
|
|
631
|
+
function personTitle(value, workspace) { return typeof value === "string" && workspace.get(value)?.type === "person" ? workspace.get(value).attributes.title : value; }
|
|
632
|
+
function noteTitle(value, workspace) { return typeof value === "string" ? workspace.get(value)?.attributes.title ?? value : value ?? null; }
|
|
633
|
+
function fieldLabel(field) { return field[0]?.toUpperCase() + field.slice(1).replaceAll("_", " "); }
|
|
634
|
+
function truncate(value) { return typeof value === "string" ? value.slice(0, 2000) : value; }
|
|
635
|
+
function proposalBefore(attributes, workspace) {
|
|
636
|
+
if (attributes.kind === "add_link")
|
|
637
|
+
return null;
|
|
638
|
+
if (attributes.kind === "supersede")
|
|
639
|
+
return truncate(noteTitle(attributes.supersedes, workspace));
|
|
640
|
+
if (attributes.kind === "merge")
|
|
641
|
+
return truncate(noteTitle(attributes.target, workspace));
|
|
642
|
+
return truncate(attributes.before ?? null);
|
|
643
|
+
}
|
|
644
|
+
function proposalAfter(attributes, workspace) {
|
|
645
|
+
if (attributes.kind === "add_to_note")
|
|
646
|
+
return attributes.content ?? null;
|
|
647
|
+
if (attributes.kind === "new_item" && isRecord(attributes.note))
|
|
648
|
+
return `${attributes.note.title ?? ""} (${attributes.note.type ?? ""})`;
|
|
649
|
+
if (attributes.kind === "supersede")
|
|
650
|
+
return noteTitle(attributes.target, workspace);
|
|
651
|
+
if (attributes.kind === "merge")
|
|
652
|
+
return noteTitle(attributes.merge_into, workspace);
|
|
653
|
+
return attributes.after ?? null;
|
|
654
|
+
}
|
|
655
|
+
export function renderTasks(workspace, meetingId, input) {
|
|
656
|
+
if (!isRecord(input) || !Array.isArray(input.items))
|
|
657
|
+
throw new BrainError("INPUT_INVALID", "tasks input needs an items list");
|
|
658
|
+
const config = tasksConfig(workspace);
|
|
659
|
+
const meeting = workspace.get(meetingId);
|
|
660
|
+
if (meeting?.type !== "meeting")
|
|
661
|
+
throw new BrainError("UNKNOWN_ID", `No meeting with id ${meetingId}`);
|
|
662
|
+
const tasks = input.items.map((item, index) => {
|
|
663
|
+
if (!isRecord(item) || typeof item.title !== "string" || item.title === "")
|
|
664
|
+
throw new BrainError("INPUT_INVALID", "each task needs a title");
|
|
665
|
+
const projects = taskProjects(workspace, meeting, item.projects, index);
|
|
666
|
+
const properties = {};
|
|
667
|
+
const pending_relations = [];
|
|
668
|
+
const put = (key, type, value) => { const name = config.properties[key]; if (typeof name === "string" && name !== "")
|
|
669
|
+
properties[name] = { type, value }; };
|
|
670
|
+
put("title", "title", item.title);
|
|
671
|
+
put("status", "select", config.new_task_status);
|
|
672
|
+
if (item.due !== undefined)
|
|
673
|
+
put("due", "date", dateOutput(item.due));
|
|
674
|
+
if (item.owner !== undefined)
|
|
675
|
+
put("owner", "person_name", item.owner);
|
|
676
|
+
const projectProperty = config.properties.project;
|
|
677
|
+
if (typeof projectProperty === "string" && projectProperty !== "") {
|
|
678
|
+
const unpublished = projects.filter((id) => typeof workspace.get(id)?.attributes.notion !== "string");
|
|
679
|
+
if (unpublished.length > 0)
|
|
680
|
+
pending_relations.push({ property: projectProperty, ids: unpublished });
|
|
681
|
+
put("project", "relation", publishedPages(projects, workspace));
|
|
682
|
+
}
|
|
683
|
+
put("area", "relation", publishedPages(meeting.attributes.area === undefined ? [] : [meeting.attributes.area], workspace));
|
|
684
|
+
if (config.properties.meeting !== "")
|
|
685
|
+
put("meeting", "relation", [meeting.attributes.notion]);
|
|
686
|
+
else
|
|
687
|
+
put("meeting_url", "url", meeting.attributes.notion === undefined ? undefined : `https://www.notion.so/${String(meeting.attributes.notion).replaceAll("-", "")}`);
|
|
688
|
+
put("brain_id", "rich_text", meeting.id);
|
|
689
|
+
return { database_id: config.database, properties, pending_relations };
|
|
690
|
+
});
|
|
691
|
+
return { items: tasks };
|
|
692
|
+
}
|
|
693
|
+
function taskProjects(workspace, meeting, value, itemIndex) {
|
|
694
|
+
if (value === undefined || (Array.isArray(value) && value.length === 0))
|
|
695
|
+
return Array.isArray(meeting.attributes.projects) ? meeting.attributes.projects.filter((id) => typeof id === "string") : [];
|
|
696
|
+
if (!Array.isArray(value))
|
|
697
|
+
throw new BrainError("INPUT_INVALID", "task projects must be an array", [{ pointer: `/items/${itemIndex}/projects`, message: "must be an array of project ids" }]);
|
|
698
|
+
const problems = [];
|
|
699
|
+
const projects = [];
|
|
700
|
+
value.forEach((id, projectIndex) => {
|
|
701
|
+
const pointer = `/items/${itemIndex}/projects/${projectIndex}`;
|
|
702
|
+
if (typeof id !== "string") {
|
|
703
|
+
problems.push({ pointer, message: "must be a project id" });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const note = workspace.get(id);
|
|
707
|
+
if (note === undefined)
|
|
708
|
+
throw new BrainError("UNKNOWN_ID", `No note with id ${id}`);
|
|
709
|
+
if (note.type !== "project") {
|
|
710
|
+
problems.push({ pointer, message: `${id} is not a project` });
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
projects.push(id);
|
|
714
|
+
});
|
|
715
|
+
assertNoProblems(problems);
|
|
716
|
+
return projects;
|
|
717
|
+
}
|
|
718
|
+
function publishedPages(value, workspace) { return (Array.isArray(value) ? value : []).map((id) => workspace.get(String(id))?.attributes.notion).filter((id) => typeof id === "string"); }
|
|
719
|
+
function tasksConfig(workspace) { if (!loadSchemas().brain(workspace.config))
|
|
720
|
+
throw new BrainError("BRAIN_CONFIG_INVALID", "brain.json fails its schema"); const notion = workspace.config.notion; const tasks = notion.tasks; if (typeof tasks.database !== "string" || tasks.database === "")
|
|
721
|
+
throw new BrainError("BRAIN_CONFIG_INVALID", "Tasks database is not configured"); return { database: tasks.database, properties: tasks.properties, new_task_status: tasks.new_task_status }; }
|
|
722
|
+
export function record(workspace, input) {
|
|
723
|
+
if (!isRecord(input) || !Array.isArray(input.records))
|
|
724
|
+
throw new BrainError("INPUT_INVALID", "record input needs a records list");
|
|
725
|
+
const records = [];
|
|
726
|
+
for (const entry of input.records) {
|
|
727
|
+
if (!isRecord(entry) || typeof entry.id !== "string" || !isTimestamp(entry.synced_at))
|
|
728
|
+
throw new BrainError("INPUT_INVALID", "each record needs id and synced_at");
|
|
729
|
+
const note = workspace.get(entry.id);
|
|
730
|
+
if (note === undefined) {
|
|
731
|
+
records.push({ id: entry.id, result: "failed", reason: "UNKNOWN_ID", changed: [] });
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const checkpoint = workspace.checkpoint();
|
|
735
|
+
try {
|
|
736
|
+
const doc = workspace.edit(note);
|
|
737
|
+
if (typeof entry.notion === "string") {
|
|
738
|
+
if (doc.attributes.notion !== undefined && doc.attributes.notion !== entry.notion)
|
|
739
|
+
throw new Error("NOTION_ID_MISMATCH");
|
|
740
|
+
doc.attributes.notion = entry.notion;
|
|
741
|
+
}
|
|
742
|
+
if (isRecord(entry.published))
|
|
743
|
+
doc.attributes.notion_published = { ...(isRecord(doc.attributes.notion_published) ? doc.attributes.notion_published : {}), ...entry.published };
|
|
744
|
+
if (note.type !== "source" && note.type !== "view" && note.type !== "proposal")
|
|
745
|
+
doc.attributes.last_synced = entry.synced_at;
|
|
746
|
+
if (note.type === "meeting" && Array.isArray(entry.tasks))
|
|
747
|
+
doc.attributes.tasks = [...new Set([...(Array.isArray(doc.attributes.tasks) ? doc.attributes.tasks : []), ...entry.tasks.filter((id) => typeof id === "string")])];
|
|
748
|
+
workspace.save(note.path, doc);
|
|
749
|
+
const errors = workspace.newErrors();
|
|
750
|
+
if (errors.length > 0)
|
|
751
|
+
throw new Error("INVALID_RESULT");
|
|
752
|
+
records.push({ id: note.id, result: "recorded", changed: workspace.changedSince(checkpoint) });
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
workspace.restore(checkpoint);
|
|
756
|
+
records.push({ id: entry.id, result: "failed", reason: error instanceof Error && error.message === "NOTION_ID_MISMATCH" ? "NOTION_ID_MISMATCH" : "INVALID_RESULT", changed: [] });
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return { records };
|
|
760
|
+
}
|