ultimate-brain 0.5.0 → 0.6.1

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/dist/lib/sync.js CHANGED
@@ -6,15 +6,16 @@ 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 { fullText, splitParts } from "./parts.js";
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";
13
14
  import { bodyHash, bodyOf } from "./proposals.js";
14
- const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "references", "review", "inbox"];
15
+ const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "documents", "review", "inbox"];
15
16
  const CREATABLE = new Set(["areas", "goals", "projects", "people", "orgs"]);
16
- const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", concepts: "concept", references: "reference", review: "proposal" };
17
- const DATABASE_FOR_TYPE = { area: "areas", goal: "goals", project: "projects", person: "people", org: "orgs", decision: "decisions", question: "questions", concept: "concepts", reference: "references", source: "inbox", meeting: "meetings", proposal: "review" };
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" };
18
19
  const PUBLISHED_TYPES = new Set(["area", "goal", "project", "person", "org", "decision", "question", "concept", "reference", "source", "meeting", "proposal"]);
19
20
  function property(value, key) {
20
21
  return Object.hasOwn(value, key) ? value[key] : undefined;
@@ -60,6 +61,8 @@ export function parseSnapshot(input) {
60
61
  if (!isRecord(value))
61
62
  return problems.push({ pointer: p, message: "must be an object" });
62
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)
63
66
  if (!DATABASES.includes(value.database))
64
67
  problems.push({ pointer: `${p}/database`, message: "is not a sync database" });
65
68
  if (typeof value.page_id !== "string" || value.page_id === "")
@@ -74,7 +77,7 @@ export function parseSnapshot(input) {
74
77
  }
75
78
  /** Compute the deterministic, no-network Notion pull plan (cli §5.14.2). */
76
79
  export function buildPlan(workspace, snapshot) {
77
- const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], full_text_requests: [], refresh_requests: [], report_fixes: [], conflicts: [], ignored: [] };
80
+ const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], document_requests: [], promote_requests: [], refresh_requests: [], report_fixes: [], conflicts: [], ignored: [] };
78
81
  const createsByPage = new Map();
79
82
  for (const row of snapshot.rows)
80
83
  if (CREATABLE.has(row.database) && blank(property(row.properties, "Brain ID"))) {
@@ -82,7 +85,14 @@ export function buildPlan(workspace, snapshot) {
82
85
  if (existing === undefined)
83
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] });
84
87
  }
88
+ const modules = modulesOf(workspace.config);
85
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
+ }
86
96
  if (row.database === "key_results") {
87
97
  planKeyResult(workspace, snapshot.as_of, row, result);
88
98
  continue;
@@ -114,6 +124,8 @@ export function buildPlan(workspace, snapshot) {
114
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).
115
125
  const known = typeof brainId === "string" && brainId !== "" ? row : { ...row, properties: { ...row.properties, "Brain ID": note.id } };
116
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);
117
129
  }
118
130
  sortPlan(result);
119
131
  return result;
@@ -199,8 +211,9 @@ function planExisting(workspace, asOf, row, note, out) {
199
211
  const published = isRecord(note.attributes.notion_published) ? note.attributes.notion_published : undefined;
200
212
  const rendered = renderedBrainValues(note, workspace);
201
213
  const publish = (name, value) => { updates.published = { ...(isRecord(updates.published) ? updates.published : {}), [name]: value }; };
214
+ const off = offRelations(workspace.config, row.database);
202
215
  for (const [name, brainField] of brainProperties(row.database)) {
203
- if (!Object.hasOwn(row.properties, name))
216
+ if (!Object.hasOwn(row.properties, name) || off.has(name))
204
217
  continue;
205
218
  if (name === "Key Facts") {
206
219
  planKeyFacts(row, note, published, updates, out);
@@ -329,33 +342,85 @@ function planDecision(row, proposal, out) {
329
342
  }
330
343
  out.decisions.push({ proposal: proposal.id, action: action, decided_at: row.last_edited, decided_by: "notion" });
331
344
  }
332
- /** An Inbox row is read for its Full text box (0043) and, on a Report row, Refresh and What to fix (cli §5.14.2, 0045). */
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 };
348
+ }
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
+ */
333
353
  function planInbox(workspace, row, out) {
354
+ const modules = modulesOf(workspace.config);
334
355
  const brainId = property(row.properties, "Brain ID");
335
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"));
336
359
  if (note !== undefined && isReport(note)) {
337
- if (property(row.properties, "Refresh") === true)
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)
338
365
  out.refresh_requests.push({ page_id: row.page_id, report: note.id });
339
366
  const text = property(row.properties, "What to fix");
340
367
  const last = (Array.isArray(note.attributes.fixes) ? note.attributes.fixes : []).filter(isRecord).at(-1);
341
368
  if (typeof text === "string" && text.trim() !== "" && text !== last?.text)
342
369
  out.report_fixes.push({ page_id: row.page_id, report: note.id, text });
343
370
  }
344
- if (property(row.properties, "Full text") !== true)
371
+ if (property(row.properties, "Document asked") !== true)
345
372
  return;
346
- const ignore = (id) => out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: id, property: "Full text", reason: "NO_FULL_TEXT" });
373
+ if (!modules.documents) {
374
+ out.ignored.push(ignoredRow(row, "MODULE_OFF", "Document asked"));
375
+ return;
376
+ }
347
377
  if (typeof brainId !== "string" || brainId === "") {
348
- ignore(null);
378
+ out.ignored.push(ignoredRow(row, "NOT_INGESTED", "Document asked"));
349
379
  return;
350
380
  }
351
381
  if (note === undefined) {
352
382
  out.conflicts.push(conflict(row, "UNKNOWN_BRAIN_ID", `No note has Brain ID ${brainId}`));
353
383
  return;
354
384
  }
355
- if (hasInboxBody(note))
356
- out.full_text_requests.push({ page_id: row.page_id, source: note.id });
357
- else
358
- ignore(note.id);
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;
359
424
  }
360
425
  /** A source whose Inbox row the brain writes: not minutes, a capture or an attached file (0042, 0043). */
361
426
  export function hasInboxBody(note) {
@@ -382,8 +447,8 @@ function planKeyResult(workspace, asOf, row, out) {
382
447
  out.updates.push({ id: goal, notion_fields: values });
383
448
  }
384
449
  function sortPlan(plan) {
385
- const order = (item) => [DATABASES.indexOf(String(item.database)), String(item.page_id ?? item.id), String(item.field ?? item.property ?? "")].join("\u0000");
386
- for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.full_text_requests, plan.refresh_requests, plan.report_fixes, plan.conflicts, plan.ignored])
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])
387
452
  values.sort((a, b) => order(a).localeCompare(order(b)));
388
453
  }
389
454
  function notionFields(database, p, asOf) {
@@ -396,7 +461,7 @@ function notionFields(database, p, asOf) {
396
461
  }
397
462
  function brainProperties(database) {
398
463
  const common = [["Name", "title"], ["Summary", "summary"], ["Lifecycle", "lifecycle"]];
399
- 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"]], references: [["Project", "reference_projects"], ["Area", "reference_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"]] };
400
465
  return [...common, ...(extras[database] ?? [])];
401
466
  }
402
467
  function convertedValue(workspace, row, name, value, conflicts) {
@@ -424,6 +489,28 @@ function linksUnpublished(workspace, note, brainField) {
424
489
  return ids.some((id) => typeof workspace.get(String(id))?.attributes.notion !== "string");
425
490
  }
426
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" } };
427
514
  function sameValue(a, b) {
428
515
  const norm = (value) => Array.isArray(value) ? [...value].map(String).sort(compareCodePoints) : value === "" || value === undefined ? null : value;
429
516
  return isDeepStrictEqual(norm(a), norm(b));
@@ -460,8 +547,8 @@ export function parsePlan(input) {
460
547
  if (!isRecord(input))
461
548
  assertNoProblems([{ pointer: "", message: "must be an object" }]);
462
549
  const p = input;
463
- const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "full_text_requests", "refresh_requests", "report_fixes", "conflicts", "ignored"], "");
464
- for (const key of ["full_text_requests", "refresh_requests", "report_fixes"])
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"])
465
552
  if (p[key] !== undefined && !Array.isArray(p[key]))
466
553
  problems.push({ pointer: `/${key}`, message: "must be a list" });
467
554
  if (!isTimestamp(p.as_of))
@@ -481,7 +568,7 @@ export function parsePlan(input) {
481
568
  problems.push({ pointer: `/create/${index}/lifecycle`, message: "must be a lifecycle string" });
482
569
  });
483
570
  assertNoProblems(problems);
484
- return { ...p, full_text_requests: p.full_text_requests ?? [], refresh_requests: p.refresh_requests ?? [], report_fixes: p.report_fixes ?? [], as_of: normalizeTimestamp(p.as_of) };
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) };
485
572
  }
486
573
  export function applyPlan(workspace, plan, now, recordOnly) {
487
574
  const create = [];
@@ -553,7 +640,7 @@ export function applyPlan(workspace, plan, now, recordOnly) {
553
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) })),
554
641
  ...(correctionApply?.log ?? []), ...(decisionApply.log ?? []),
555
642
  ];
556
- return { create, updates, corrections, decisions: decisionResult, full_text_requests: plan.full_text_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 };
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 };
557
644
  }
558
645
  function references(value) { if (Array.isArray(value))
559
646
  return value.flatMap(references); if (isRecord(value))
@@ -568,10 +655,17 @@ export function render(workspace, now, ids, held) {
568
655
  const items = [];
569
656
  const errors = [];
570
657
  const warnings = [];
658
+ const modules = modulesOf(workspace.config);
571
659
  for (const note of selected) {
572
660
  const database = isReport(note) ? "inbox" : DATABASE_FOR_TYPE[note.type];
573
661
  if (database === undefined || (note.type === "source" && note.attributes.kind === undefined))
574
662
  continue;
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" });
667
+ continue;
668
+ }
575
669
  // An attached source has no Inbox row: its notes go on the parent's (0042).
576
670
  if (note.type === "source" && note.attributes.attached_to !== undefined)
577
671
  continue;
@@ -602,6 +696,8 @@ export function render(workspace, now, ids, held) {
602
696
  }
603
697
  const rendered = renderedBrainValues(note, workspace);
604
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];
605
701
  const properties = {};
606
702
  const pending = [];
607
703
  const heldProperties = held?.get(note.id) ?? new Set();
@@ -610,12 +706,20 @@ export function render(workspace, now, ids, held) {
610
706
  if (name === "@content" || heldProperties.has(name) || (capturePage !== undefined && name === "Name"))
611
707
  continue;
612
708
  const encoded = notionProperty(name, value, note, workspace, pending);
613
- 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])))
614
710
  properties[name] = encoded;
615
711
  }
616
712
  const content = renderedContent(note, workspace);
617
713
  if (content.hash !== undefined)
618
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
+ }
619
723
  const contentChanged = content.hash !== undefined && !isDeepStrictEqual(content.hash, published["@content"]);
620
724
  // A report (0045): a refresh sends the body once and clears Refresh; a new fix clears What to fix.
621
725
  let reportDue = false;
@@ -623,7 +727,7 @@ export function render(workspace, now, ids, held) {
623
727
  const fixes = Array.isArray(note.attributes.fixes) ? note.attributes.fixes.length : 0;
624
728
  const stored = typeof published["@fixes"] === "number" ? published["@fixes"] : 0;
625
729
  if (!isCreate && contentChanged)
626
- properties.Refresh = { type: "checkbox", value: false };
730
+ properties["Refresh asked"] = { type: "checkbox", value: false };
627
731
  if (!isCreate && !contentChanged)
628
732
  content.body = null;
629
733
  if (fixes > stored) {
@@ -632,16 +736,8 @@ export function render(workspace, now, ids, held) {
632
736
  }
633
737
  rendered["@fixes"] = Math.max(fixes, stored);
634
738
  }
635
- // Full text (0043): the text goes out once per choice, and a ticked box is cleared once per request.
636
- const full = note.type === "source" && hasInboxBody(note) ? fullTextDue(note, published["@full_text"]) : undefined;
637
- if (full?.sections !== undefined)
638
- content.sections = { ...(content.sections ?? {}), ...full.sections };
639
- if (full?.clear === true)
640
- properties["Full text"] = { type: "checkbox", value: false };
641
- if (full !== undefined && (full.sections !== undefined || full.clear))
642
- rendered["@full_text"] = full.record;
643
- const fullTextDueNow = full !== undefined && (full.sections !== undefined || full.clear);
644
- if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !fullTextDueNow && !reportDue)
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)
645
741
  continue;
646
742
  if (!isCreate && note.type !== "meeting" && note.type !== "proposal")
647
743
  properties["Last Synced"] = { type: "date", value: { start: formatTimestamp(now), end: null } };
@@ -660,8 +756,7 @@ function trashDue(note, now) {
660
756
  const time = typeof decided === "string" ? Date.parse(decided) : Number.NaN;
661
757
  return Number.isFinite(time) && now.getTime() - time > TRASH_AFTER_MS;
662
758
  }
663
- function databaseIdFor(workspace, database) { const notion = workspace.config.notion; if (!isRecord(notion) || !isRecord(notion.databases))
664
- 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); }
665
760
  function renderedBrainValues(note, workspace) {
666
761
  const a = note.attributes;
667
762
  const values = {};
@@ -681,16 +776,16 @@ function renderedBrainValues(note, workspace) {
681
776
  const page = (source ?? target)?.attributes.notion;
682
777
  const meeting = typeof a.source === "string" ? workspace.get(a.source) : undefined;
683
778
  Object.assign(values, {
684
- Name: a.title, Kind: select(a.kind), About: targetText,
779
+ Name: reviewName(a), Kind: select(a.kind), About: targetText,
685
780
  "About Page": typeof page === "string" ? `https://www.notion.so/${page.replaceAll("-", "")}` : null,
686
781
  "Source Meeting": meeting?.type === "meeting" && typeof meeting.attributes.notion === "string" ? [meeting.attributes.notion] : [],
687
782
  Now: reviewText(proposalBefore(a, workspace)), Proposed: proposed !== undefined ? proposed : reviewText(after ?? proposalAfter(a, workspace)),
688
- Why: reviewText(a.why), State: select(proposalState(a.status)), "Brain ID": note.id,
783
+ Why: reviewText(a.why), Area: relationPages(reviewArea(a, workspace), workspace), State: select(proposalState(a.status)), "Brain ID": note.id,
689
784
  });
690
785
  return values;
691
786
  }
692
787
  if (isReport(note)) {
693
- 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": "Processed", "Brain ID": note.id });
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 });
694
789
  return values;
695
790
  }
696
791
  if (note.type === "meeting") {
@@ -723,8 +818,28 @@ function renderedBrainValues(note, workspace) {
723
818
  }
724
819
  if (note.type === "concept" || note.type === "reference")
725
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
+ }
726
825
  return values;
727
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
+ }
728
843
  /** Area on decisions and questions is a derived Notion relation (decision 0034). */
729
844
  function projectAreas(projects, workspace) {
730
845
  const areas = (Array.isArray(projects) ? projects : []).flatMap((project) => {
@@ -736,20 +851,23 @@ function projectAreas(projects, workspace) {
736
851
  /** The notes a source produced, with those of the sources attached to it (cli §5.14.4, 0042). */
737
852
  function producedBy(note, workspace) {
738
853
  const sources = new Set([note.id, ...workspace.notes.filter((candidate) => candidate.type === "source" && candidate.attributes.attached_to === note.id).map((candidate) => candidate.id)]);
739
- return workspace.notes.filter((candidate) => ["decision", "question", "concept", "reference", "person"].includes(candidate.type) && Array.isArray(candidate.attributes.sources) && candidate.attributes.sources.some((id) => sources.has(String(id))));
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))));
740
856
  }
741
857
  function sourceValues(note, workspace) {
742
858
  const produced = producedBy(note, workspace);
743
859
  const ids = (type) => produced.filter((candidate) => candidate.type === type).map((candidate) => candidate.id).sort(compareCodePoints);
744
860
  const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))].sort(compareCodePoints);
745
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);
746
- 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), ...(databaseIdFor(workspace, "references") === "" ? {} : { References: relationPages(ids("reference"), workspace) }), People: relationPages(ids("person"), workspace), Project: relationPages(projects, workspace), Area: relationPages(areas, workspace), "Brain Status": "Processed", "Brain ID": note.id };
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 };
747
863
  }
748
864
  function notionProperty(name, value, note, workspace, pending) {
749
- const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Concepts", "References", "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);
750
866
  if (relation) {
751
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" };
752
- const raw = 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] ?? ""];
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] ?? ""];
753
871
  const missing = (Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]).map(String).filter((id) => typeof workspace.get(id)?.attributes.notion !== "string");
754
872
  if (missing.length > 0)
755
873
  pending.push({ property: name, ids: missing });
@@ -766,6 +884,9 @@ function notionProperty(name, value, note, workspace, pending) {
766
884
  return { type: "relation", value: Array.isArray(value) ? value : [] };
767
885
  if (name === "About Page")
768
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" };
769
890
  if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status", "Status"].includes(name))
770
891
  return { type: "select", value };
771
892
  if (name === "Last Synced" || name === "Date" || name === "Deadline" || name === "Ingested At")
@@ -780,7 +901,7 @@ function sourceRelationIds(note, name, workspace) {
780
901
  return produced.filter((candidate) => candidate.type === "question").map((candidate) => candidate.id);
781
902
  if (name === "Concepts")
782
903
  return produced.filter((candidate) => candidate.type === "concept").map((candidate) => candidate.id);
783
- if (name === "References")
904
+ if (name === "Documents")
784
905
  return produced.filter((candidate) => candidate.type === "reference").map((candidate) => candidate.id);
785
906
  if (name === "People")
786
907
  return produced.filter((candidate) => candidate.type === "person").map((candidate) => candidate.id);
@@ -804,7 +925,7 @@ function renderedContent(note, workspace) {
804
925
  const sections = { Brief: notionMarkdown(generatedBlock(brief.body), brief, workspace) };
805
926
  return { body: null, sections, hash: hash(JSON.stringify(sections)) };
806
927
  }
807
- // A source's digest is written once; its Full text is tracked by @full_text, not @content (0043).
928
+ // A source's digest is written once (0043).
808
929
  if (note.type === "source") {
809
930
  if (!hasInboxBody(note))
810
931
  return { body: null, sections: null };
@@ -889,18 +1010,6 @@ export function letters(index) {
889
1010
  return index < 26 ? String.fromCharCode(97 + index) : `${letters(Math.floor(index / 26) - 1)}${letters(index % 26)}`;
890
1011
  }
891
1012
  function isReport(note) { return note.type === "view" && note.attributes.view_kind === "report"; }
892
- /**
893
- * Whether a source's Full text is due (cli §5.14.4): B of `@full_text` against S. The text goes out
894
- * when it is chosen and S doesn't hold the same choice; the box is cleared when a request is new.
895
- */
896
- function fullTextDue(note, stored) {
897
- const a = note.attributes;
898
- const record = { text: a.notion_text ?? "digest", parts: Array.isArray(a.notion_parts) ? a.notion_parts : null, requested: a.notion_text_requested ?? null };
899
- const s = isRecord(stored) ? stored : undefined;
900
- const send = record.text === "full" && (s === undefined || s.text !== record.text || !isDeepStrictEqual(s.parts ?? null, record.parts));
901
- const clear = record.requested !== null && record.requested !== (s?.requested ?? null);
902
- return { record, ...(send ? { sections: { "Full text": fullText(note.body, record.parts) } } : {}), clear };
903
- }
904
1013
  /** A brain-created Inbox row's body: a line about the source, its summary and its digest (cli §5.14.4). */
905
1014
  function digestBody(note) {
906
1015
  const a = note.attributes;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-brain",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "CLI for an AI-assisted Markdown and Git second brain",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -64,6 +64,37 @@
64
64
  }
65
65
  }
66
66
  },
67
+ "modules": {
68
+ "description": "Which optional modules are on (decision 0048, spec/schema.md §12). Absent: every module on (a brain made before 0.6.0). A key absent from a present block takes the module's default.",
69
+ "type": "object",
70
+ "additionalProperties": false,
71
+ "properties": {
72
+ "knowledge": {
73
+ "type": "boolean",
74
+ "default": true
75
+ },
76
+ "orgs": {
77
+ "type": "boolean",
78
+ "default": true
79
+ },
80
+ "documents": {
81
+ "type": "boolean",
82
+ "default": false
83
+ },
84
+ "reports": {
85
+ "type": "boolean",
86
+ "default": false
87
+ },
88
+ "tasks": {
89
+ "type": "boolean",
90
+ "default": false
91
+ },
92
+ "planning": {
93
+ "type": "boolean",
94
+ "default": false
95
+ }
96
+ }
97
+ },
67
98
  "repos": {
68
99
  "type": "array",
69
100
  "items": {
@@ -158,6 +189,11 @@
158
189
  "$ref": "#/$defs/dbId"
159
190
  },
160
191
  "references": {
192
+ "$ref": "#/$defs/dbId",
193
+ "deprecated": true,
194
+ "description": "Old name of documents (decision 0049); read as documents when documents is absent. brain upgrade renames it."
195
+ },
196
+ "documents": {
161
197
  "$ref": "#/$defs/dbId"
162
198
  }
163
199
  }