ultimate-brain 0.4.1 → 0.6.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/brain-apply/SKILL.md +35 -22
- package/.agents/skills/brain-catch-up/SKILL.md +51 -36
- package/.agents/skills/brain-connect-notion/SKILL.md +29 -2
- package/.agents/skills/brain-howto/SKILL.md +1 -1
- package/.agents/skills/brain-howto/references/guide.md +117 -48
- package/.agents/skills/brain-ingest/SKILL.md +14 -6
- package/.agents/skills/brain-ingest/references/cli-inputs.md +31 -5
- package/.agents/skills/brain-ingest/references/extraction.md +36 -1
- package/.agents/skills/brain-ingest/references/notion.md +8 -6
- package/.agents/skills/brain-ingest/references/sources.md +36 -14
- package/.agents/skills/brain-notion-change/SKILL.md +2 -0
- package/.agents/skills/brain-query/SKILL.md +18 -8
- package/.agents/skills/brain-sync/SKILL.md +31 -12
- package/.agents/skills/brain-sync/references/formats.md +28 -1
- package/dist/cli.js +5 -4
- package/dist/commands/modules.js +54 -0
- package/dist/commands/report.js +81 -4
- package/dist/commands/source.js +27 -35
- package/dist/commands/status.js +5 -5
- package/dist/commands/sync.js +3 -1
- package/dist/commands/upgrade.js +33 -18
- package/dist/lib/apply.js +237 -33
- package/dist/lib/diff.js +31 -0
- package/dist/lib/errors.js +2 -0
- package/dist/lib/ids.js +2 -0
- package/dist/lib/index-builder.js +6 -1
- package/dist/lib/lint.js +46 -8
- package/dist/lib/modules.js +91 -0
- package/dist/lib/note-new.js +111 -18
- package/dist/lib/paths.js +1 -0
- package/dist/lib/proposals.js +74 -18
- package/dist/lib/query.js +1 -1
- package/dist/lib/schemas.js +1 -1
- package/dist/lib/sync.js +246 -86
- package/package.json +1 -1
- package/spec/schemas/brain.schema.json +36 -0
- package/spec/schemas/note.schema.json +105 -18
- package/templates/brain/brain.json +3 -1
- package/templates/brain/knowledge/references/.gitkeep +0 -0
package/dist/lib/sync.js
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
|
-
import { applyAll
|
|
3
|
+
import { applyAll } from "./apply.js";
|
|
4
4
|
import { BrainError } from "./errors.js";
|
|
5
5
|
import { assertNoProblems, isRecord, unknownKeys } from "./input.js";
|
|
6
6
|
import { generateId, TYPE_PREFIX } from "./ids.js";
|
|
7
7
|
import { assertStagedValid, checkNoteInput, stageNotes } from "./note-new.js";
|
|
8
8
|
import { compareCodePoints } from "./notes.js";
|
|
9
|
-
import {
|
|
9
|
+
import { splitParts } from "./parts.js";
|
|
10
10
|
import { loadSchemas } from "./schemas.js";
|
|
11
11
|
import { formatDate, formatTimestamp, normalizeTimestamp } from "./time.js";
|
|
12
|
-
|
|
12
|
+
import { lineDiff } from "./diff.js";
|
|
13
|
+
import { databaseId, moduleForDatabase, modulesOf, typeIsOn } from "./modules.js";
|
|
14
|
+
import { bodyHash, bodyOf } from "./proposals.js";
|
|
15
|
+
const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "documents", "review", "inbox"];
|
|
13
16
|
const CREATABLE = new Set(["areas", "goals", "projects", "people", "orgs"]);
|
|
14
|
-
const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", concepts: "concept", review: "proposal" };
|
|
15
|
-
const DATABASE_FOR_TYPE = { area: "areas", goal: "goals", project: "projects", person: "people", org: "orgs", decision: "decisions", question: "questions", concept: "concepts", source: "inbox", meeting: "meetings", proposal: "review" };
|
|
16
|
-
const PUBLISHED_TYPES = new Set(["area", "goal", "project", "person", "org", "decision", "question", "concept", "source", "meeting", "proposal"]);
|
|
17
|
+
const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", concepts: "concept", documents: "reference", review: "proposal" };
|
|
18
|
+
const DATABASE_FOR_TYPE = { area: "areas", goal: "goals", project: "projects", person: "people", org: "orgs", decision: "decisions", question: "questions", concept: "concepts", reference: "documents", source: "inbox", meeting: "meetings", proposal: "review" };
|
|
19
|
+
const PUBLISHED_TYPES = new Set(["area", "goal", "project", "person", "org", "decision", "question", "concept", "reference", "source", "meeting", "proposal"]);
|
|
17
20
|
function property(value, key) {
|
|
18
21
|
return Object.hasOwn(value, key) ? value[key] : undefined;
|
|
19
22
|
}
|
|
@@ -58,6 +61,8 @@ export function parseSnapshot(input) {
|
|
|
58
61
|
if (!isRecord(value))
|
|
59
62
|
return problems.push({ pointer: p, message: "must be an object" });
|
|
60
63
|
problems.push(...unknownKeys(value, ["database", "page_id", "last_edited", "properties"], p));
|
|
64
|
+
if (value.database === "references")
|
|
65
|
+
value.database = "documents"; // read as documents (0049)
|
|
61
66
|
if (!DATABASES.includes(value.database))
|
|
62
67
|
problems.push({ pointer: `${p}/database`, message: "is not a sync database" });
|
|
63
68
|
if (typeof value.page_id !== "string" || value.page_id === "")
|
|
@@ -72,7 +77,7 @@ export function parseSnapshot(input) {
|
|
|
72
77
|
}
|
|
73
78
|
/** Compute the deterministic, no-network Notion pull plan (cli §5.14.2). */
|
|
74
79
|
export function buildPlan(workspace, snapshot) {
|
|
75
|
-
const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [],
|
|
80
|
+
const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], document_requests: [], promote_requests: [], refresh_requests: [], report_fixes: [], conflicts: [], ignored: [] };
|
|
76
81
|
const createsByPage = new Map();
|
|
77
82
|
for (const row of snapshot.rows)
|
|
78
83
|
if (CREATABLE.has(row.database) && blank(property(row.properties, "Brain ID"))) {
|
|
@@ -80,7 +85,14 @@ export function buildPlan(workspace, snapshot) {
|
|
|
80
85
|
if (existing === undefined)
|
|
81
86
|
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
87
|
}
|
|
88
|
+
const modules = modulesOf(workspace.config);
|
|
83
89
|
for (const row of snapshot.rows) {
|
|
90
|
+
// A database whose module is off is not read at all (cli §5.14.2, 0048).
|
|
91
|
+
const module = moduleForDatabase(row.database);
|
|
92
|
+
if (module !== null && !modules[module]) {
|
|
93
|
+
result.ignored.push(ignoredRow(row, "MODULE_OFF"));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
84
96
|
if (row.database === "key_results") {
|
|
85
97
|
planKeyResult(workspace, snapshot.as_of, row, result);
|
|
86
98
|
continue;
|
|
@@ -112,6 +124,8 @@ export function buildPlan(workspace, snapshot) {
|
|
|
112
124
|
// A create whose publish failed has no Brain ID in Notion yet; treat the row as if it had it (cli §5.14.2).
|
|
113
125
|
const known = typeof brainId === "string" && brainId !== "" ? row : { ...row, properties: { ...row.properties, "Brain ID": note.id } };
|
|
114
126
|
planExisting(workspace, snapshot.as_of, known, note, result);
|
|
127
|
+
if ((row.database === "decisions" || row.database === "concepts") && property(row.properties, "Promote asked") === true)
|
|
128
|
+
planPromote(workspace, row, note, modules.documents, result);
|
|
115
129
|
}
|
|
116
130
|
sortPlan(result);
|
|
117
131
|
return result;
|
|
@@ -197,8 +211,9 @@ function planExisting(workspace, asOf, row, note, out) {
|
|
|
197
211
|
const published = isRecord(note.attributes.notion_published) ? note.attributes.notion_published : undefined;
|
|
198
212
|
const rendered = renderedBrainValues(note, workspace);
|
|
199
213
|
const publish = (name, value) => { updates.published = { ...(isRecord(updates.published) ? updates.published : {}), [name]: value }; };
|
|
214
|
+
const off = offRelations(workspace.config, row.database);
|
|
200
215
|
for (const [name, brainField] of brainProperties(row.database)) {
|
|
201
|
-
if (!Object.hasOwn(row.properties, name))
|
|
216
|
+
if (!Object.hasOwn(row.properties, name) || off.has(name))
|
|
202
217
|
continue;
|
|
203
218
|
if (name === "Key Facts") {
|
|
204
219
|
planKeyFacts(row, note, published, updates, out);
|
|
@@ -296,52 +311,116 @@ function planKeyFacts(row, note, published, updates, out) {
|
|
|
296
311
|
updates.published = { ...(isRecord(updates.published) ? updates.published : {}), "Key Facts": text };
|
|
297
312
|
}
|
|
298
313
|
function planDecision(row, proposal, out) {
|
|
299
|
-
|
|
300
|
-
|
|
314
|
+
// A snapshot taken before migration 0009 has Decision and Note; Edit there is read as Fix (cli §5.14.2, 0045).
|
|
315
|
+
const call = property(row.properties, "Your call") ?? property(row.properties, "Decision");
|
|
316
|
+
const label = Object.hasOwn(row.properties, "Your call") ? "Your call" : "Decision";
|
|
317
|
+
const textLabel = Object.hasOwn(row.properties, "What to fix") || !Object.hasOwn(row.properties, "Note") ? "What to fix" : "Note";
|
|
318
|
+
if (call === undefined || call === null || property(row.properties, "State") !== "Pending")
|
|
301
319
|
return;
|
|
302
320
|
if (["applied", "closed"].includes(String(proposal.attributes.status))) {
|
|
303
321
|
out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, reason: "ALREADY_TERMINAL" });
|
|
304
322
|
return;
|
|
305
323
|
}
|
|
306
|
-
const action = String(
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
324
|
+
const action = String(call).toLowerCase() === "edit" ? "fix" : String(call).toLowerCase();
|
|
325
|
+
const text = property(row.properties, textLabel);
|
|
326
|
+
const written = typeof text === "string" && text.trim() !== "";
|
|
327
|
+
if (!["approve", "reject", "fix"].includes(action)) {
|
|
328
|
+
out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, property: label, reason: "UNKNOWN_OPTION" });
|
|
310
329
|
return;
|
|
311
330
|
}
|
|
312
|
-
if (action === "
|
|
313
|
-
out.conflicts.push(conflict(row, "
|
|
331
|
+
if (action === "fix" && !written) {
|
|
332
|
+
out.conflicts.push(conflict(row, "FIX_WITHOUT_TEXT", "Fix needs What to fix: what to correct", "What to fix"));
|
|
314
333
|
return;
|
|
315
334
|
}
|
|
316
|
-
if (action === "
|
|
317
|
-
out.conflicts.push(conflict(row, "
|
|
335
|
+
if (action === "fix") {
|
|
336
|
+
out.conflicts.push(conflict(row, "FIX_TO_READ", "What to fix needs reading by the skill", "What to fix", text, "Fix"));
|
|
318
337
|
return;
|
|
319
338
|
}
|
|
320
|
-
if (
|
|
321
|
-
out.conflicts.push(conflict(row, "NOTE_TO_READ", "
|
|
339
|
+
if (written) {
|
|
340
|
+
out.conflicts.push(conflict(row, "NOTE_TO_READ", "What to fix needs reading by the skill", "What to fix", text, action === "approve" ? "Approve" : "Reject"));
|
|
322
341
|
return;
|
|
323
342
|
}
|
|
324
|
-
out.decisions.push({ proposal: proposal.id, action: action,
|
|
343
|
+
out.decisions.push({ proposal: proposal.id, action: action, decided_at: row.last_edited, decided_by: "notion" });
|
|
344
|
+
}
|
|
345
|
+
function ignoredRow(row, reason, propertyName) {
|
|
346
|
+
const brainId = property(row.properties, "Brain ID");
|
|
347
|
+
return { database: row.database, page_id: row.page_id, brain_id: typeof brainId === "string" && brainId !== "" ? brainId : null, ...(propertyName === undefined ? {} : { property: propertyName }), reason };
|
|
325
348
|
}
|
|
326
|
-
/**
|
|
349
|
+
/**
|
|
350
|
+
* An Inbox row is read for Document asked (0049) and, on a Report row, Refresh and What to fix
|
|
351
|
+
* (0045); a Full text box is retired (cli §5.14.2).
|
|
352
|
+
*/
|
|
327
353
|
function planInbox(workspace, row, out) {
|
|
328
|
-
|
|
329
|
-
return;
|
|
354
|
+
const modules = modulesOf(workspace.config);
|
|
330
355
|
const brainId = property(row.properties, "Brain ID");
|
|
331
|
-
const
|
|
356
|
+
const note = typeof brainId === "string" && brainId !== "" ? workspace.get(brainId) : undefined;
|
|
357
|
+
if (Object.hasOwn(row.properties, "Full text"))
|
|
358
|
+
out.ignored.push(ignoredRow(row, "RETIRED", "Full text"));
|
|
359
|
+
if (note !== undefined && isReport(note)) {
|
|
360
|
+
if (!modules.reports) {
|
|
361
|
+
out.ignored.push(ignoredRow(row, "MODULE_OFF"));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (property(row.properties, "Refresh asked") === true || property(row.properties, "Refresh") === true)
|
|
365
|
+
out.refresh_requests.push({ page_id: row.page_id, report: note.id });
|
|
366
|
+
const text = property(row.properties, "What to fix");
|
|
367
|
+
const last = (Array.isArray(note.attributes.fixes) ? note.attributes.fixes : []).filter(isRecord).at(-1);
|
|
368
|
+
if (typeof text === "string" && text.trim() !== "" && text !== last?.text)
|
|
369
|
+
out.report_fixes.push({ page_id: row.page_id, report: note.id, text });
|
|
370
|
+
}
|
|
371
|
+
if (property(row.properties, "Document asked") !== true)
|
|
372
|
+
return;
|
|
373
|
+
if (!modules.documents) {
|
|
374
|
+
out.ignored.push(ignoredRow(row, "MODULE_OFF", "Document asked"));
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
332
377
|
if (typeof brainId !== "string" || brainId === "") {
|
|
333
|
-
|
|
378
|
+
out.ignored.push(ignoredRow(row, "NOT_INGESTED", "Document asked"));
|
|
334
379
|
return;
|
|
335
380
|
}
|
|
336
|
-
const note = workspace.get(brainId);
|
|
337
381
|
if (note === undefined) {
|
|
338
382
|
out.conflicts.push(conflict(row, "UNKNOWN_BRAIN_ID", `No note has Brain ID ${brainId}`));
|
|
339
383
|
return;
|
|
340
384
|
}
|
|
341
|
-
if (
|
|
342
|
-
out.
|
|
343
|
-
|
|
344
|
-
|
|
385
|
+
if (note.type !== "source" || note.attributes.kind === undefined || note.attributes.kind === "minutes") {
|
|
386
|
+
out.ignored.push(ignoredRow(row, "NOT_A_SOURCE", "Document asked"));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const source = latestVersion(workspace, note);
|
|
390
|
+
const files = workspace.notes.filter((candidate) => candidate.type === "source" && candidate.attributes.attached_to === source.id).map((file) => file.id).sort(compareCodePoints);
|
|
391
|
+
const onlyKeep = property(row.properties, "Only keep");
|
|
392
|
+
const only = typeof onlyKeep === "string" && onlyKeep.trim() !== "" ? onlyKeep : null;
|
|
393
|
+
const request = { page_id: row.page_id, source: source.id, files, only_keep: only };
|
|
394
|
+
if (only === null) {
|
|
395
|
+
const wholes = (files.length > 0 ? files : [source.id]).map((id) => wholeDocumentOf(workspace, id));
|
|
396
|
+
if (wholes.every((id) => id !== undefined))
|
|
397
|
+
request.existing = wholes;
|
|
398
|
+
}
|
|
399
|
+
out.document_requests.push(request);
|
|
400
|
+
}
|
|
401
|
+
/** A Promote asked box on a decision or concept row (cli §5.14.2, 0049). */
|
|
402
|
+
function planPromote(workspace, row, note, documentsOn, out) {
|
|
403
|
+
if (!documentsOn) {
|
|
404
|
+
out.ignored.push(ignoredRow(row, "MODULE_OFF", "Promote asked"));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const existing = workspace.notes.find((candidate) => candidate.type === "reference" && candidate.attributes.origin === "promoted" && candidate.attributes.promoted_from === note.id);
|
|
408
|
+
out.promote_requests.push({ page_id: row.page_id, note: note.id, ...(existing === undefined ? {} : { existing: existing.id }) });
|
|
409
|
+
}
|
|
410
|
+
/** The latest version of a source, following `replaces` forward (schema §4, 0045). */
|
|
411
|
+
function latestVersion(workspace, note) {
|
|
412
|
+
let current = note;
|
|
413
|
+
const seen = new Set([note.id]);
|
|
414
|
+
for (;;) {
|
|
415
|
+
const next = workspace.notes.find((candidate) => candidate.type === "source" && candidate.attributes.replaces === current.id);
|
|
416
|
+
if (next === undefined || seen.has(next.id))
|
|
417
|
+
return current;
|
|
418
|
+
seen.add(next.id);
|
|
419
|
+
current = next;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function wholeDocumentOf(workspace, source) {
|
|
423
|
+
return workspace.notes.find((note) => note.type === "reference" && note.attributes.origin === "whole" && Array.isArray(note.attributes.sources) && note.attributes.sources[0] === source)?.id;
|
|
345
424
|
}
|
|
346
425
|
/** A source whose Inbox row the brain writes: not minutes, a capture or an attached file (0042, 0043). */
|
|
347
426
|
export function hasInboxBody(note) {
|
|
@@ -368,8 +447,8 @@ function planKeyResult(workspace, asOf, row, out) {
|
|
|
368
447
|
out.updates.push({ id: goal, notion_fields: values });
|
|
369
448
|
}
|
|
370
449
|
function sortPlan(plan) {
|
|
371
|
-
const order = (item) => [DATABASES.indexOf(String(item.database)), String(item.page_id ?? item.id), String(item.field ?? item.property ?? "")].join("\u0000");
|
|
372
|
-
for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.
|
|
450
|
+
const order = (item) => [String(DATABASES.indexOf(String(item.database))).padStart(2, "0"), String(item.page_id ?? item.id), String(item.field ?? item.property ?? "")].join("\u0000");
|
|
451
|
+
for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.document_requests, plan.promote_requests, plan.refresh_requests, plan.report_fixes, plan.conflicts, plan.ignored])
|
|
373
452
|
values.sort((a, b) => order(a).localeCompare(order(b)));
|
|
374
453
|
}
|
|
375
454
|
function notionFields(database, p, asOf) {
|
|
@@ -382,7 +461,7 @@ function notionFields(database, p, asOf) {
|
|
|
382
461
|
}
|
|
383
462
|
function brainProperties(database) {
|
|
384
463
|
const common = [["Name", "title"], ["Summary", "summary"], ["Lifecycle", "lifecycle"]];
|
|
385
|
-
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"], ["Project", "projects"], ["Area", "derived_area"]], questions: [["Kind", "kind"], ["Project", "projects"], ["Area", "derived_area"]], concepts: [["Project", "concept_projects"], ["Area", "concept_area"]] };
|
|
464
|
+
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"], ["Project", "projects"], ["Area", "derived_area"]], questions: [["Kind", "kind"], ["Project", "projects"], ["Area", "derived_area"]], concepts: [["Project", "concept_projects"], ["Area", "concept_area"]], documents: [["Project", "reference_projects"], ["Area", "reference_area"]] };
|
|
386
465
|
return [...common, ...(extras[database] ?? [])];
|
|
387
466
|
}
|
|
388
467
|
function convertedValue(workspace, row, name, value, conflicts) {
|
|
@@ -410,6 +489,28 @@ function linksUnpublished(workspace, note, brainField) {
|
|
|
410
489
|
return ids.some((id) => typeof workspace.get(String(id))?.attributes.notion !== "string");
|
|
411
490
|
}
|
|
412
491
|
/** Equal in Notion's normalized form; relations compare as sets, and an empty text equals null. */
|
|
492
|
+
/** Relation properties left out while their module is off (cli §5.14.4, 0048), per database. */
|
|
493
|
+
function offRelations(config, database) {
|
|
494
|
+
const on = modulesOf(config);
|
|
495
|
+
const off = new Set();
|
|
496
|
+
if (!on.orgs && database === "projects")
|
|
497
|
+
off.add("Client");
|
|
498
|
+
if (!on.orgs && database === "people")
|
|
499
|
+
off.add("Org");
|
|
500
|
+
if (!on.planning && database === "projects")
|
|
501
|
+
off.add("Goal");
|
|
502
|
+
if (!on.knowledge && (database === "inbox" || database === "meetings")) {
|
|
503
|
+
off.add("Decisions");
|
|
504
|
+
off.add("Questions");
|
|
505
|
+
}
|
|
506
|
+
if (!on.knowledge && database === "inbox")
|
|
507
|
+
off.add("Concepts");
|
|
508
|
+
if (!on.documents && database === "inbox")
|
|
509
|
+
off.add("Documents");
|
|
510
|
+
return off;
|
|
511
|
+
}
|
|
512
|
+
/** Brain values that were renamed (0048, 0049): an S of the old one compares equal to the new one. */
|
|
513
|
+
const RENAMED_VALUES = { "Brain Status": { Processed: "Done" }, Kind: { Document: "File" } };
|
|
413
514
|
function sameValue(a, b) {
|
|
414
515
|
const norm = (value) => Array.isArray(value) ? [...value].map(String).sort(compareCodePoints) : value === "" || value === undefined ? null : value;
|
|
415
516
|
return isDeepStrictEqual(norm(a), norm(b));
|
|
@@ -446,9 +547,10 @@ export function parsePlan(input) {
|
|
|
446
547
|
if (!isRecord(input))
|
|
447
548
|
assertNoProblems([{ pointer: "", message: "must be an object" }]);
|
|
448
549
|
const p = input;
|
|
449
|
-
const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "
|
|
450
|
-
|
|
451
|
-
|
|
550
|
+
const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "document_requests", "promote_requests", "refresh_requests", "report_fixes", "conflicts", "ignored"], "");
|
|
551
|
+
for (const key of ["document_requests", "promote_requests", "refresh_requests", "report_fixes"])
|
|
552
|
+
if (p[key] !== undefined && !Array.isArray(p[key]))
|
|
553
|
+
problems.push({ pointer: `/${key}`, message: "must be a list" });
|
|
452
554
|
if (!isTimestamp(p.as_of))
|
|
453
555
|
problems.push({ pointer: "/as_of", message: "must be an ISO 8601 UTC timestamp" });
|
|
454
556
|
for (const key of ["create", "updates", "corrections", "decisions", "conflicts", "ignored"])
|
|
@@ -466,7 +568,7 @@ export function parsePlan(input) {
|
|
|
466
568
|
problems.push({ pointer: `/create/${index}/lifecycle`, message: "must be a lifecycle string" });
|
|
467
569
|
});
|
|
468
570
|
assertNoProblems(problems);
|
|
469
|
-
return { ...p,
|
|
571
|
+
return { ...p, document_requests: p.document_requests ?? [], promote_requests: p.promote_requests ?? [], refresh_requests: p.refresh_requests ?? [], report_fixes: p.report_fixes ?? [], as_of: normalizeTimestamp(p.as_of) };
|
|
470
572
|
}
|
|
471
573
|
export function applyPlan(workspace, plan, now, recordOnly) {
|
|
472
574
|
const create = [];
|
|
@@ -538,28 +640,32 @@ export function applyPlan(workspace, plan, now, recordOnly) {
|
|
|
538
640
|
...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) })),
|
|
539
641
|
...(correctionApply?.log ?? []), ...(decisionApply.log ?? []),
|
|
540
642
|
];
|
|
541
|
-
return { create, updates, corrections, decisions: decisionResult,
|
|
643
|
+
return { create, updates, corrections, decisions: decisionResult, document_requests: plan.document_requests, promote_requests: plan.promote_requests, refresh_requests: plan.refresh_requests, report_fixes: plan.report_fixes, conflicts: plan.conflicts, ignored: plan.ignored, summary: summarize([...create, ...updates, ...corrections, ...decisionResult].map((item) => ({ ...item }))), log };
|
|
542
644
|
}
|
|
543
645
|
function references(value) { if (Array.isArray(value))
|
|
544
646
|
return value.flatMap(references); if (isRecord(value))
|
|
545
|
-
return Object.values(value).flatMap(references); return typeof value === "string" && /^(area|gol|prj|per|org|mtg|dec|que|con|src|mem)-/.test(value) ? [value] : []; }
|
|
647
|
+
return Object.values(value).flatMap(references); return typeof value === "string" && /^(area|gol|prj|per|org|mtg|dec|que|con|ref|src|mem)-/.test(value) ? [value] : []; }
|
|
546
648
|
function summarize(items) { const result = { applied: 0, closed: 0, recorded: 0, corrected: 0, skipped: 0, failed: 0 }; for (const item of items)
|
|
547
649
|
if (typeof item.result === "string" && Object.hasOwn(result, item.result))
|
|
548
650
|
result[item.result] += 1; return result; }
|
|
549
651
|
export function render(workspace, now, ids, held) {
|
|
550
652
|
const selected = ids === undefined
|
|
551
|
-
? workspace.notes.filter((note) => (PUBLISHED_TYPES.has(note.type) && !(note.type === "source" && note.attributes.kind === undefined)) ||
|
|
653
|
+
? workspace.notes.filter((note) => (PUBLISHED_TYPES.has(note.type) && !(note.type === "source" && note.attributes.kind === undefined)) || isReport(note))
|
|
552
654
|
: ids.map((id) => workspace.get(id)).filter(Boolean);
|
|
553
655
|
const items = [];
|
|
554
656
|
const errors = [];
|
|
555
657
|
const warnings = [];
|
|
658
|
+
const modules = modulesOf(workspace.config);
|
|
556
659
|
for (const note of selected) {
|
|
557
660
|
const database = isReport(note) ? "inbox" : DATABASE_FOR_TYPE[note.type];
|
|
558
661
|
if (database === undefined || (note.type === "source" && note.attributes.kind === undefined))
|
|
559
662
|
continue;
|
|
560
|
-
// A published
|
|
561
|
-
if (isReport(note) &&
|
|
663
|
+
// A note of a module that is off isn't published; naming one is an error (0048).
|
|
664
|
+
if ((isReport(note) && !modules.reports) || !typeIsOn(workspace.config, note.type)) {
|
|
665
|
+
if (ids !== undefined)
|
|
666
|
+
errors.push({ id: note.id, reason: "MODULE_OFF" });
|
|
562
667
|
continue;
|
|
668
|
+
}
|
|
563
669
|
// An attached source has no Inbox row: its notes go on the parent's (0042).
|
|
564
670
|
if (note.type === "source" && note.attributes.attached_to !== undefined)
|
|
565
671
|
continue;
|
|
@@ -590,31 +696,48 @@ export function render(workspace, now, ids, held) {
|
|
|
590
696
|
}
|
|
591
697
|
const rendered = renderedBrainValues(note, workspace);
|
|
592
698
|
const published = isRecord(note.attributes.notion_published) ? note.attributes.notion_published : {};
|
|
699
|
+
for (const name of offRelations(workspace.config, database))
|
|
700
|
+
delete rendered[name];
|
|
593
701
|
const properties = {};
|
|
594
702
|
const pending = [];
|
|
595
703
|
const heldProperties = held?.get(note.id) ?? new Set();
|
|
596
|
-
const isCreate =
|
|
704
|
+
const isCreate = note.attributes.notion === undefined && capturePage === undefined;
|
|
597
705
|
for (const [name, value] of Object.entries(rendered)) {
|
|
598
706
|
if (name === "@content" || heldProperties.has(name) || (capturePage !== undefined && name === "Name"))
|
|
599
707
|
continue;
|
|
600
708
|
const encoded = notionProperty(name, value, note, workspace, pending);
|
|
601
|
-
if (encoded !== undefined && (isCreate || !Object.hasOwn(published, name) || !sameValue(value, published[name])))
|
|
709
|
+
if (encoded !== undefined && (isCreate || !Object.hasOwn(published, name) || !sameValue(value, RENAMED_VALUES[name]?.[String(published[name])] ?? published[name])))
|
|
602
710
|
properties[name] = encoded;
|
|
603
711
|
}
|
|
604
712
|
const content = renderedContent(note, workspace);
|
|
605
713
|
if (content.hash !== undefined)
|
|
606
714
|
rendered["@content"] = content.hash;
|
|
715
|
+
// A promoted Document's body is yours once its page exists (0049): sent on create only.
|
|
716
|
+
if (note.type === "reference" && note.attributes.origin === "promoted" && !isCreate) {
|
|
717
|
+
content.body = null;
|
|
718
|
+
delete rendered["@content"];
|
|
719
|
+
if (Object.hasOwn(published, "@content"))
|
|
720
|
+
rendered["@content"] = published["@content"];
|
|
721
|
+
content.hash = undefined;
|
|
722
|
+
}
|
|
607
723
|
const contentChanged = content.hash !== undefined && !isDeepStrictEqual(content.hash, published["@content"]);
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
if (
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
724
|
+
// A report (0045): a refresh sends the body once and clears Refresh; a new fix clears What to fix.
|
|
725
|
+
let reportDue = false;
|
|
726
|
+
if (isReport(note)) {
|
|
727
|
+
const fixes = Array.isArray(note.attributes.fixes) ? note.attributes.fixes.length : 0;
|
|
728
|
+
const stored = typeof published["@fixes"] === "number" ? published["@fixes"] : 0;
|
|
729
|
+
if (!isCreate && contentChanged)
|
|
730
|
+
properties["Refresh asked"] = { type: "checkbox", value: false };
|
|
731
|
+
if (!isCreate && !contentChanged)
|
|
732
|
+
content.body = null;
|
|
733
|
+
if (fixes > stored) {
|
|
734
|
+
properties["What to fix"] = { type: "rich_text", value: null };
|
|
735
|
+
reportDue = true;
|
|
736
|
+
}
|
|
737
|
+
rendered["@fixes"] = Math.max(fixes, stored);
|
|
738
|
+
}
|
|
739
|
+
// Full text (0043) is retired (0049): nothing is sent, and `@full_text` in S stays as it is.
|
|
740
|
+
if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !reportDue)
|
|
618
741
|
continue;
|
|
619
742
|
if (!isCreate && note.type !== "meeting" && note.type !== "proposal")
|
|
620
743
|
properties["Last Synced"] = { type: "date", value: { start: formatTimestamp(now), end: null } };
|
|
@@ -633,8 +756,7 @@ function trashDue(note, now) {
|
|
|
633
756
|
const time = typeof decided === "string" ? Date.parse(decided) : Number.NaN;
|
|
634
757
|
return Number.isFinite(time) && now.getTime() - time > TRASH_AFTER_MS;
|
|
635
758
|
}
|
|
636
|
-
function databaseIdFor(workspace, database) {
|
|
637
|
-
return ""; return typeof notion.databases[database] === "string" ? notion.databases[database] : database === "inbox" && typeof notion.databases.capture_inbox === "string" ? notion.databases.capture_inbox : ""; }
|
|
759
|
+
function databaseIdFor(workspace, database) { return databaseId(workspace.config, database); }
|
|
638
760
|
function renderedBrainValues(note, workspace) {
|
|
639
761
|
const a = note.attributes;
|
|
640
762
|
const values = {};
|
|
@@ -644,7 +766,9 @@ function renderedBrainValues(note, workspace) {
|
|
|
644
766
|
const addLink = a.kind === "add_link";
|
|
645
767
|
const after = addLink
|
|
646
768
|
? proposalLinks(a).map((link) => `${fieldLabel(String(link.field))}: ${noteTitle(link.value, workspace)}`).join("\n")
|
|
647
|
-
: a.kind === "review_notes" ? reviewNotesAfter(a, workspace) : undefined;
|
|
769
|
+
: a.kind === "review_notes" ? reviewNotesAfter(a, workspace) : a.kind === "update_body" ? bodyChange(a, workspace)?.join("\n") ?? null : undefined;
|
|
770
|
+
// A body diff can only be shown while the target's body is the one proposed against; then S keeps it (0045).
|
|
771
|
+
const proposed = staleBody(a, workspace) && isRecord(a.notion_published) && Object.hasOwn(a.notion_published, "Proposed") ? a.notion_published.Proposed : undefined;
|
|
648
772
|
const reviewText = (value) => truncate(typeof value === "string" ? reviewMarkdown(value, target) : value);
|
|
649
773
|
// A Review notes row names its source: the meeting for minutes, else the source (0041).
|
|
650
774
|
const source = a.kind === "review_notes" && typeof a.source === "string" ? workspace.get(a.source) : undefined;
|
|
@@ -652,16 +776,16 @@ function renderedBrainValues(note, workspace) {
|
|
|
652
776
|
const page = (source ?? target)?.attributes.notion;
|
|
653
777
|
const meeting = typeof a.source === "string" ? workspace.get(a.source) : undefined;
|
|
654
778
|
Object.assign(values, {
|
|
655
|
-
Name: a
|
|
656
|
-
"
|
|
779
|
+
Name: reviewName(a), Kind: select(a.kind), About: targetText,
|
|
780
|
+
"About Page": typeof page === "string" ? `https://www.notion.so/${page.replaceAll("-", "")}` : null,
|
|
657
781
|
"Source Meeting": meeting?.type === "meeting" && typeof meeting.attributes.notion === "string" ? [meeting.attributes.notion] : [],
|
|
658
|
-
|
|
659
|
-
Why: reviewText(a.why), State: select(proposalState(a.status)), "Brain ID": note.id,
|
|
782
|
+
Now: reviewText(proposalBefore(a, workspace)), Proposed: proposed !== undefined ? proposed : reviewText(after ?? proposalAfter(a, workspace)),
|
|
783
|
+
Why: reviewText(a.why), Area: relationPages(reviewArea(a, workspace), workspace), State: select(proposalState(a.status)), "Brain ID": note.id,
|
|
660
784
|
});
|
|
661
785
|
return values;
|
|
662
786
|
}
|
|
663
787
|
if (isReport(note)) {
|
|
664
|
-
Object.assign(values, { Name: a.title, Kind: "Report", Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace), Ask: typeof a.asked_in === "string" ? [a.asked_in] : [], "Brain Status": "
|
|
788
|
+
Object.assign(values, { Name: a.title, Kind: "Report", Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace), Ask: typeof a.asked_in === "string" ? [a.asked_in] : Array.isArray(a.asked_in) ? a.asked_in : [], "Brain Status": "Done", "Brain ID": note.id });
|
|
665
789
|
return values;
|
|
666
790
|
}
|
|
667
791
|
if (note.type === "meeting") {
|
|
@@ -692,10 +816,30 @@ function renderedBrainValues(note, workspace) {
|
|
|
692
816
|
else if (published.Status === undefined && !yours)
|
|
693
817
|
values.Status = "Open";
|
|
694
818
|
}
|
|
695
|
-
if (note.type === "concept")
|
|
819
|
+
if (note.type === "concept" || note.type === "reference")
|
|
696
820
|
Object.assign(values, { Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace) });
|
|
821
|
+
if (note.type === "reference") {
|
|
822
|
+
const from = a.origin === "promoted" && typeof a.promoted_from === "string" ? a.promoted_from : undefined;
|
|
823
|
+
Object.assign(values, { "From decision": relationPages(from?.startsWith("dec-") ? [from] : [], workspace), "From concept": relationPages(from?.startsWith("con-") ? [from] : [], workspace) });
|
|
824
|
+
}
|
|
697
825
|
return values;
|
|
698
826
|
}
|
|
827
|
+
/** A Review row's Name says which kind of row it is (cli §5.14.4, 0048). */
|
|
828
|
+
function reviewName(a) {
|
|
829
|
+
const prefix = ["contradiction", "supersede", "merge", "new_item"].includes(String(a.kind)) ? "Conflict: " : a.kind === "answer" ? "Answer: " : "";
|
|
830
|
+
return typeof a.title === "string" && prefix !== "" && !a.title.startsWith(prefix) ? `${prefix}${a.title}` : a.title;
|
|
831
|
+
}
|
|
832
|
+
/** A Review row's one area: its source note's own area, else the area of its first project (cli §5.14.4, 0050). */
|
|
833
|
+
function reviewArea(a, workspace) {
|
|
834
|
+
const source = typeof a.source === "string" ? workspace.get(a.source) : undefined;
|
|
835
|
+
if (source === undefined)
|
|
836
|
+
return [];
|
|
837
|
+
if (typeof source.attributes.area === "string")
|
|
838
|
+
return [source.attributes.area];
|
|
839
|
+
const first = Array.isArray(source.attributes.projects) ? source.attributes.projects[0] : undefined;
|
|
840
|
+
const area = typeof first === "string" ? workspace.get(first)?.attributes.area : undefined;
|
|
841
|
+
return typeof area === "string" ? [area] : [];
|
|
842
|
+
}
|
|
699
843
|
/** Area on decisions and questions is a derived Notion relation (decision 0034). */
|
|
700
844
|
function projectAreas(projects, workspace) {
|
|
701
845
|
const areas = (Array.isArray(projects) ? projects : []).flatMap((project) => {
|
|
@@ -707,20 +851,23 @@ function projectAreas(projects, workspace) {
|
|
|
707
851
|
/** The notes a source produced, with those of the sources attached to it (cli §5.14.4, 0042). */
|
|
708
852
|
function producedBy(note, workspace) {
|
|
709
853
|
const sources = new Set([note.id, ...workspace.notes.filter((candidate) => candidate.type === "source" && candidate.attributes.attached_to === note.id).map((candidate) => candidate.id)]);
|
|
710
|
-
|
|
854
|
+
// A promoted Document comes from its note, not the source (0049).
|
|
855
|
+
return workspace.notes.filter((candidate) => ["decision", "question", "concept", "reference", "person"].includes(candidate.type) && !(candidate.type === "reference" && candidate.attributes.origin === "promoted") && Array.isArray(candidate.attributes.sources) && candidate.attributes.sources.some((id) => sources.has(String(id))));
|
|
711
856
|
}
|
|
712
857
|
function sourceValues(note, workspace) {
|
|
713
858
|
const produced = producedBy(note, workspace);
|
|
714
859
|
const ids = (type) => produced.filter((candidate) => candidate.type === type).map((candidate) => candidate.id).sort(compareCodePoints);
|
|
715
860
|
const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))].sort(compareCodePoints);
|
|
716
861
|
const areas = [...new Set([...produced.map((candidate) => candidate.attributes.area).filter((id) => typeof id === "string"), ...projects.flatMap((id) => { const area = workspace.get(id)?.attributes.area; return typeof area === "string" ? [area] : []; })])].sort(compareCodePoints);
|
|
717
|
-
return { Name: note.attributes.title, Kind: select(note.attributes.kind), Summary: note.attributes.summary ?? null, Decisions: relationPages(ids("decision"), workspace), Questions: relationPages(ids("question"), workspace), Concepts: relationPages(ids("concept"), workspace), People: relationPages(ids("person"), workspace), Project: relationPages(projects, workspace), Area: relationPages(areas, workspace), "Brain Status": "
|
|
862
|
+
return { Name: note.attributes.title, Kind: note.attributes.kind === "document" ? "File" : select(note.attributes.kind), Summary: note.attributes.summary ?? null, Decisions: relationPages(ids("decision"), workspace), Questions: relationPages(ids("question"), workspace), Concepts: relationPages(ids("concept"), workspace), ...(databaseIdFor(workspace, "documents") === "" ? {} : { Documents: relationPages(ids("reference"), workspace) }), People: relationPages(ids("person"), workspace), Project: relationPages(projects, workspace), Area: relationPages(areas, workspace), "Brain Status": "Done", "Brain ID": note.id };
|
|
718
863
|
}
|
|
719
864
|
function notionProperty(name, value, note, workspace, pending) {
|
|
720
|
-
const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Concepts", "Attendees"].includes(name);
|
|
865
|
+
const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Concepts", "Documents", "Attendees", "From decision", "From concept"].includes(name);
|
|
721
866
|
if (relation) {
|
|
722
867
|
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" };
|
|
723
|
-
const raw = name === "Area" &&
|
|
868
|
+
const raw = name === "Area" && note.type === "proposal" ? reviewArea(note.attributes, workspace)
|
|
869
|
+
: name === "From decision" || name === "From concept" ? (Array.isArray(value) && note.attributes.promoted_from !== undefined && String(note.attributes.promoted_from).startsWith(name === "From decision" ? "dec-" : "con-") ? [note.attributes.promoted_from] : [])
|
|
870
|
+
: name === "Area" && (note.type === "decision" || note.type === "question") ? projectAreas(note.attributes.projects, workspace) : name === "Supersedes" ? (Array.isArray(note.attributes.relations) ? note.attributes.relations.filter(isRecord).filter((item) => item.type === "supersedes").map((item) => item.target) : []) : note.type === "source" ? sourceRelationIds(note, name, workspace) : note.attributes[sourceField[name] ?? ""];
|
|
724
871
|
const missing = (Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]).map(String).filter((id) => typeof workspace.get(id)?.attributes.notion !== "string");
|
|
725
872
|
if (missing.length > 0)
|
|
726
873
|
pending.push({ property: name, ids: missing });
|
|
@@ -735,8 +882,11 @@ function notionProperty(name, value, note, workspace, pending) {
|
|
|
735
882
|
}
|
|
736
883
|
if (name === "Ask")
|
|
737
884
|
return { type: "relation", value: Array.isArray(value) ? value : [] };
|
|
738
|
-
if (name === "
|
|
885
|
+
if (name === "About Page")
|
|
739
886
|
return { type: "url", value };
|
|
887
|
+
// An Inbox row's Kind is written only when empty in Notion, so a Kind you chose stands (0048).
|
|
888
|
+
if (name === "Kind" && note.type === "source")
|
|
889
|
+
return { type: "select", value, mode: "if_empty" };
|
|
740
890
|
if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status", "Status"].includes(name))
|
|
741
891
|
return { type: "select", value };
|
|
742
892
|
if (name === "Last Synced" || name === "Date" || name === "Deadline" || name === "Ingested At")
|
|
@@ -751,6 +901,8 @@ function sourceRelationIds(note, name, workspace) {
|
|
|
751
901
|
return produced.filter((candidate) => candidate.type === "question").map((candidate) => candidate.id);
|
|
752
902
|
if (name === "Concepts")
|
|
753
903
|
return produced.filter((candidate) => candidate.type === "concept").map((candidate) => candidate.id);
|
|
904
|
+
if (name === "Documents")
|
|
905
|
+
return produced.filter((candidate) => candidate.type === "reference").map((candidate) => candidate.id);
|
|
754
906
|
if (name === "People")
|
|
755
907
|
return produced.filter((candidate) => candidate.type === "person").map((candidate) => candidate.id);
|
|
756
908
|
const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))];
|
|
@@ -773,7 +925,7 @@ function renderedContent(note, workspace) {
|
|
|
773
925
|
const sections = { Brief: notionMarkdown(generatedBlock(brief.body), brief, workspace) };
|
|
774
926
|
return { body: null, sections, hash: hash(JSON.stringify(sections)) };
|
|
775
927
|
}
|
|
776
|
-
// A source's digest is written once
|
|
928
|
+
// A source's digest is written once (0043).
|
|
777
929
|
if (note.type === "source") {
|
|
778
930
|
if (!hasInboxBody(note))
|
|
779
931
|
return { body: null, sections: null };
|
|
@@ -784,7 +936,7 @@ function renderedContent(note, workspace) {
|
|
|
784
936
|
const body = notionMarkdown(note.body.replace(/^\n+/, ""), note, workspace);
|
|
785
937
|
return { body, sections: null, hash: hash(body) };
|
|
786
938
|
}
|
|
787
|
-
if (note.type === "decision" || note.type === "question" || note.type === "concept") {
|
|
939
|
+
if (note.type === "decision" || note.type === "question" || note.type === "concept" || note.type === "reference") {
|
|
788
940
|
const body = notionMarkdown(note.body, note, workspace);
|
|
789
941
|
return { body, sections: null, hash: hash(body) };
|
|
790
942
|
}
|
|
@@ -824,32 +976,40 @@ function personTitle(value, workspace) { return typeof value === "string" && wor
|
|
|
824
976
|
function noteTitle(value, workspace) { return typeof value === "string" ? workspace.get(value)?.attributes.title ?? value : value ?? null; }
|
|
825
977
|
function fieldLabel(field) { return field[0]?.toUpperCase() + field.slice(1).replaceAll("_", " "); }
|
|
826
978
|
function truncate(value) { return typeof value === "string" ? value.slice(0, 2000) : value; }
|
|
827
|
-
/** The lettered lines of a Review notes row: its notes, then its links (cli §5.14.4). */
|
|
979
|
+
/** The lettered lines of a Review notes row: its changes, then its notes, then its links (cli §5.14.4, 0045). */
|
|
828
980
|
function reviewNotesAfter(attributes, workspace) {
|
|
981
|
+
const changes = (Array.isArray(attributes.changes) ? attributes.changes.filter(isRecord) : []).map((change) => {
|
|
982
|
+
const target = String(noteTitle(change.target, workspace));
|
|
983
|
+
if (change.kind === "update_fact")
|
|
984
|
+
return `⚠ ${fieldLabel(String(change.field))} on ${target}: ${change.before === undefined ? "(new)" : String(noteTitle(change.before, workspace))} → ${String(noteTitle(change.after, workspace))}`;
|
|
985
|
+
if (change.kind === "update_body")
|
|
986
|
+
return `⚠ ${target}: ${bodyChange(change, workspace)?.length ?? 0} lines changed`;
|
|
987
|
+
return `Add to ${target}: ${String(change.content ?? "").split("\n")[0].slice(0, 80)}`;
|
|
988
|
+
});
|
|
829
989
|
const notes = (Array.isArray(attributes.notes) ? attributes.notes : []).map((id) => {
|
|
830
990
|
const note = workspace.get(String(id));
|
|
831
991
|
return `${note === undefined ? "Note" : fieldLabel(note.type)}: ${String(noteTitle(id, workspace))}`;
|
|
832
992
|
});
|
|
833
993
|
const links = (Array.isArray(attributes.links) ? attributes.links.filter(isRecord) : []).map((link) => `${fieldLabel(String(link.field))} on ${String(noteTitle(link.target, workspace))}: ${String(noteTitle(link.value, workspace))}`);
|
|
834
|
-
return [...notes, ...links].map((line, index) => `${letters(index)}) ${line}`).join("\n");
|
|
994
|
+
return [...changes, ...notes, ...links].map((line, index) => `${letters(index)}) ${line}`).join("\n");
|
|
995
|
+
}
|
|
996
|
+
/** The changed lines an update_body proposes against its target's body (cli §5.19), or undefined once that body changed. */
|
|
997
|
+
function bodyChange(change, workspace) {
|
|
998
|
+
const target = typeof change.target === "string" ? workspace.get(change.target) : undefined;
|
|
999
|
+
if (target === undefined || bodyHash(target.body) !== change.before_hash)
|
|
1000
|
+
return undefined;
|
|
1001
|
+
return lineDiff(target.body, bodyOf(String(change.content ?? "")));
|
|
1002
|
+
}
|
|
1003
|
+
/** Whether a proposal holds an update_body whose target's body is no longer the one it was proposed against. */
|
|
1004
|
+
function staleBody(attributes, workspace) {
|
|
1005
|
+
const bodies = attributes.kind === "update_body" ? [attributes] : (Array.isArray(attributes.changes) ? attributes.changes.filter(isRecord) : []).filter((change) => change.kind === "update_body");
|
|
1006
|
+
return bodies.some((change) => bodyChange(change, workspace) === undefined);
|
|
835
1007
|
}
|
|
836
1008
|
/** a, b, … z, then aa, ab, … */
|
|
837
1009
|
export function letters(index) {
|
|
838
1010
|
return index < 26 ? String.fromCharCode(97 + index) : `${letters(Math.floor(index / 26) - 1)}${letters(index % 26)}`;
|
|
839
1011
|
}
|
|
840
1012
|
function isReport(note) { return note.type === "view" && note.attributes.view_kind === "report"; }
|
|
841
|
-
/**
|
|
842
|
-
* Whether a source's Full text is due (cli §5.14.4): B of `@full_text` against S. The text goes out
|
|
843
|
-
* when it is chosen and S doesn't hold the same choice; the box is cleared when a request is new.
|
|
844
|
-
*/
|
|
845
|
-
function fullTextDue(note, stored) {
|
|
846
|
-
const a = note.attributes;
|
|
847
|
-
const record = { text: a.notion_text ?? "digest", parts: Array.isArray(a.notion_parts) ? a.notion_parts : null, requested: a.notion_text_requested ?? null };
|
|
848
|
-
const s = isRecord(stored) ? stored : undefined;
|
|
849
|
-
const send = record.text === "full" && (s === undefined || s.text !== record.text || !isDeepStrictEqual(s.parts ?? null, record.parts));
|
|
850
|
-
const clear = record.requested !== null && record.requested !== (s?.requested ?? null);
|
|
851
|
-
return { record, ...(send ? { sections: { "Full text": fullText(note.body, record.parts) } } : {}), clear };
|
|
852
|
-
}
|
|
853
1013
|
/** A brain-created Inbox row's body: a line about the source, its summary and its digest (cli §5.14.4). */
|
|
854
1014
|
function digestBody(note) {
|
|
855
1015
|
const a = note.attributes;
|
|
@@ -862,7 +1022,7 @@ function digestBody(note) {
|
|
|
862
1022
|
return [line, typeof a.summary === "string" ? a.summary : undefined, typeof a.digest === "string" ? a.digest.replace(/\n+$/, "") : undefined].filter((part) => part !== undefined).join("\n\n");
|
|
863
1023
|
}
|
|
864
1024
|
function proposalBefore(attributes, workspace) {
|
|
865
|
-
if (attributes.kind === "add_link" || attributes.kind === "review_notes" || attributes.kind === "answer")
|
|
1025
|
+
if (attributes.kind === "add_link" || attributes.kind === "review_notes" || attributes.kind === "answer" || attributes.kind === "update_body")
|
|
866
1026
|
return null;
|
|
867
1027
|
if (attributes.kind === "supersede")
|
|
868
1028
|
return truncate(noteTitle(attributes.supersedes, workspace));
|