ultimate-brain 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/brain-apply/SKILL.md +35 -22
- package/.agents/skills/brain-catch-up/SKILL.md +30 -22
- package/.agents/skills/brain-howto/SKILL.md +1 -1
- package/.agents/skills/brain-howto/references/guide.md +70 -43
- package/.agents/skills/brain-ingest/SKILL.md +10 -6
- package/.agents/skills/brain-ingest/references/cli-inputs.md +19 -3
- package/.agents/skills/brain-ingest/references/extraction.md +23 -1
- package/.agents/skills/brain-ingest/references/notion.md +4 -3
- package/.agents/skills/brain-ingest/references/sources.md +25 -5
- package/.agents/skills/brain-notion-change/SKILL.md +2 -0
- package/.agents/skills/brain-query/SKILL.md +17 -7
- package/.agents/skills/brain-sync/SKILL.md +16 -10
- package/.agents/skills/brain-sync/references/formats.md +1 -1
- package/dist/cli.js +3 -3
- package/dist/commands/report.js +81 -4
- package/dist/commands/source.js +34 -0
- package/dist/commands/status.js +2 -2
- package/dist/lib/apply.js +237 -33
- package/dist/lib/diff.js +31 -0
- package/dist/lib/ids.js +2 -0
- package/dist/lib/index-builder.js +6 -1
- package/dist/lib/lint.js +39 -8
- package/dist/lib/note-new.js +21 -2
- package/dist/lib/paths.js +1 -0
- package/dist/lib/proposals.js +72 -18
- package/dist/lib/query.js +1 -1
- package/dist/lib/schemas.js +1 -1
- package/dist/lib/sync.js +129 -53
- package/package.json +1 -1
- package/spec/schemas/brain.schema.json +3 -0
- package/spec/schemas/note.schema.json +130 -2
- package/spec/schemas/proposal.schema.json +461 -14
- package/templates/brain/brain.json +2 -1
- package/templates/brain/knowledge/references/.gitkeep +0 -0
package/dist/lib/sync.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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";
|
|
@@ -9,11 +9,13 @@ import { compareCodePoints } from "./notes.js";
|
|
|
9
9
|
import { fullText, 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 { bodyHash, bodyOf } from "./proposals.js";
|
|
14
|
+
const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "references", "review", "inbox"];
|
|
13
15
|
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"]);
|
|
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" };
|
|
18
|
+
const PUBLISHED_TYPES = new Set(["area", "goal", "project", "person", "org", "decision", "question", "concept", "reference", "source", "meeting", "proposal"]);
|
|
17
19
|
function property(value, key) {
|
|
18
20
|
return Object.hasOwn(value, key) ? value[key] : undefined;
|
|
19
21
|
}
|
|
@@ -72,7 +74,7 @@ export function parseSnapshot(input) {
|
|
|
72
74
|
}
|
|
73
75
|
/** Compute the deterministic, no-network Notion pull plan (cli §5.14.2). */
|
|
74
76
|
export function buildPlan(workspace, snapshot) {
|
|
75
|
-
const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], full_text_requests: [], conflicts: [], ignored: [] };
|
|
77
|
+
const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], full_text_requests: [], refresh_requests: [], report_fixes: [], conflicts: [], ignored: [] };
|
|
76
78
|
const createsByPage = new Map();
|
|
77
79
|
for (const row of snapshot.rows)
|
|
78
80
|
if (CREATABLE.has(row.database) && blank(property(row.properties, "Brain ID"))) {
|
|
@@ -296,44 +298,56 @@ function planKeyFacts(row, note, published, updates, out) {
|
|
|
296
298
|
updates.published = { ...(isRecord(updates.published) ? updates.published : {}), "Key Facts": text };
|
|
297
299
|
}
|
|
298
300
|
function planDecision(row, proposal, out) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
+
// A snapshot taken before migration 0009 has Decision and Note; Edit there is read as Fix (cli §5.14.2, 0045).
|
|
302
|
+
const call = property(row.properties, "Your call") ?? property(row.properties, "Decision");
|
|
303
|
+
const label = Object.hasOwn(row.properties, "Your call") ? "Your call" : "Decision";
|
|
304
|
+
const textLabel = Object.hasOwn(row.properties, "What to fix") || !Object.hasOwn(row.properties, "Note") ? "What to fix" : "Note";
|
|
305
|
+
if (call === undefined || call === null || property(row.properties, "State") !== "Pending")
|
|
301
306
|
return;
|
|
302
307
|
if (["applied", "closed"].includes(String(proposal.attributes.status))) {
|
|
303
308
|
out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, reason: "ALREADY_TERMINAL" });
|
|
304
309
|
return;
|
|
305
310
|
}
|
|
306
|
-
const action = String(
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
311
|
+
const action = String(call).toLowerCase() === "edit" ? "fix" : String(call).toLowerCase();
|
|
312
|
+
const text = property(row.properties, textLabel);
|
|
313
|
+
const written = typeof text === "string" && text.trim() !== "";
|
|
314
|
+
if (!["approve", "reject", "fix"].includes(action)) {
|
|
315
|
+
out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, property: label, reason: "UNKNOWN_OPTION" });
|
|
310
316
|
return;
|
|
311
317
|
}
|
|
312
|
-
if (action === "
|
|
313
|
-
out.conflicts.push(conflict(row, "
|
|
318
|
+
if (action === "fix" && !written) {
|
|
319
|
+
out.conflicts.push(conflict(row, "FIX_WITHOUT_TEXT", "Fix needs What to fix: what to correct", "What to fix"));
|
|
314
320
|
return;
|
|
315
321
|
}
|
|
316
|
-
if (action === "
|
|
317
|
-
out.conflicts.push(conflict(row, "
|
|
322
|
+
if (action === "fix") {
|
|
323
|
+
out.conflicts.push(conflict(row, "FIX_TO_READ", "What to fix needs reading by the skill", "What to fix", text, "Fix"));
|
|
318
324
|
return;
|
|
319
325
|
}
|
|
320
|
-
if (
|
|
321
|
-
out.conflicts.push(conflict(row, "NOTE_TO_READ", "
|
|
326
|
+
if (written) {
|
|
327
|
+
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
328
|
return;
|
|
323
329
|
}
|
|
324
|
-
out.decisions.push({ proposal: proposal.id, action: action,
|
|
330
|
+
out.decisions.push({ proposal: proposal.id, action: action, decided_at: row.last_edited, decided_by: "notion" });
|
|
325
331
|
}
|
|
326
|
-
/** An Inbox row is read
|
|
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). */
|
|
327
333
|
function planInbox(workspace, row, out) {
|
|
334
|
+
const brainId = property(row.properties, "Brain ID");
|
|
335
|
+
const note = typeof brainId === "string" && brainId !== "" ? workspace.get(brainId) : undefined;
|
|
336
|
+
if (note !== undefined && isReport(note)) {
|
|
337
|
+
if (property(row.properties, "Refresh") === true)
|
|
338
|
+
out.refresh_requests.push({ page_id: row.page_id, report: note.id });
|
|
339
|
+
const text = property(row.properties, "What to fix");
|
|
340
|
+
const last = (Array.isArray(note.attributes.fixes) ? note.attributes.fixes : []).filter(isRecord).at(-1);
|
|
341
|
+
if (typeof text === "string" && text.trim() !== "" && text !== last?.text)
|
|
342
|
+
out.report_fixes.push({ page_id: row.page_id, report: note.id, text });
|
|
343
|
+
}
|
|
328
344
|
if (property(row.properties, "Full text") !== true)
|
|
329
345
|
return;
|
|
330
|
-
const brainId = property(row.properties, "Brain ID");
|
|
331
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" });
|
|
332
347
|
if (typeof brainId !== "string" || brainId === "") {
|
|
333
348
|
ignore(null);
|
|
334
349
|
return;
|
|
335
350
|
}
|
|
336
|
-
const note = workspace.get(brainId);
|
|
337
351
|
if (note === undefined) {
|
|
338
352
|
out.conflicts.push(conflict(row, "UNKNOWN_BRAIN_ID", `No note has Brain ID ${brainId}`));
|
|
339
353
|
return;
|
|
@@ -369,7 +383,7 @@ function planKeyResult(workspace, asOf, row, out) {
|
|
|
369
383
|
}
|
|
370
384
|
function sortPlan(plan) {
|
|
371
385
|
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.full_text_requests, plan.conflicts, plan.ignored])
|
|
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])
|
|
373
387
|
values.sort((a, b) => order(a).localeCompare(order(b)));
|
|
374
388
|
}
|
|
375
389
|
function notionFields(database, p, asOf) {
|
|
@@ -382,7 +396,7 @@ function notionFields(database, p, asOf) {
|
|
|
382
396
|
}
|
|
383
397
|
function brainProperties(database) {
|
|
384
398
|
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"]] };
|
|
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"]] };
|
|
386
400
|
return [...common, ...(extras[database] ?? [])];
|
|
387
401
|
}
|
|
388
402
|
function convertedValue(workspace, row, name, value, conflicts) {
|
|
@@ -446,9 +460,10 @@ export function parsePlan(input) {
|
|
|
446
460
|
if (!isRecord(input))
|
|
447
461
|
assertNoProblems([{ pointer: "", message: "must be an object" }]);
|
|
448
462
|
const p = input;
|
|
449
|
-
const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "full_text_requests", "conflicts", "ignored"], "");
|
|
450
|
-
|
|
451
|
-
|
|
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"])
|
|
465
|
+
if (p[key] !== undefined && !Array.isArray(p[key]))
|
|
466
|
+
problems.push({ pointer: `/${key}`, message: "must be a list" });
|
|
452
467
|
if (!isTimestamp(p.as_of))
|
|
453
468
|
problems.push({ pointer: "/as_of", message: "must be an ISO 8601 UTC timestamp" });
|
|
454
469
|
for (const key of ["create", "updates", "corrections", "decisions", "conflicts", "ignored"])
|
|
@@ -466,7 +481,7 @@ export function parsePlan(input) {
|
|
|
466
481
|
problems.push({ pointer: `/create/${index}/lifecycle`, message: "must be a lifecycle string" });
|
|
467
482
|
});
|
|
468
483
|
assertNoProblems(problems);
|
|
469
|
-
return { ...p, full_text_requests: p.full_text_requests ?? [], as_of: normalizeTimestamp(p.as_of) };
|
|
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) };
|
|
470
485
|
}
|
|
471
486
|
export function applyPlan(workspace, plan, now, recordOnly) {
|
|
472
487
|
const create = [];
|
|
@@ -538,17 +553,17 @@ export function applyPlan(workspace, plan, now, recordOnly) {
|
|
|
538
553
|
...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
554
|
...(correctionApply?.log ?? []), ...(decisionApply.log ?? []),
|
|
540
555
|
];
|
|
541
|
-
return { create, updates, corrections, decisions: decisionResult, full_text_requests: plan.full_text_requests, conflicts: plan.conflicts, ignored: plan.ignored, summary: summarize([...create, ...updates, ...corrections, ...decisionResult].map((item) => ({ ...item }))), log };
|
|
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 };
|
|
542
557
|
}
|
|
543
558
|
function references(value) { if (Array.isArray(value))
|
|
544
559
|
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] : []; }
|
|
560
|
+
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
561
|
function summarize(items) { const result = { applied: 0, closed: 0, recorded: 0, corrected: 0, skipped: 0, failed: 0 }; for (const item of items)
|
|
547
562
|
if (typeof item.result === "string" && Object.hasOwn(result, item.result))
|
|
548
563
|
result[item.result] += 1; return result; }
|
|
549
564
|
export function render(workspace, now, ids, held) {
|
|
550
565
|
const selected = ids === undefined
|
|
551
|
-
? workspace.notes.filter((note) => (PUBLISHED_TYPES.has(note.type) && !(note.type === "source" && note.attributes.kind === undefined)) ||
|
|
566
|
+
? workspace.notes.filter((note) => (PUBLISHED_TYPES.has(note.type) && !(note.type === "source" && note.attributes.kind === undefined)) || isReport(note))
|
|
552
567
|
: ids.map((id) => workspace.get(id)).filter(Boolean);
|
|
553
568
|
const items = [];
|
|
554
569
|
const errors = [];
|
|
@@ -557,17 +572,21 @@ export function render(workspace, now, ids, held) {
|
|
|
557
572
|
const database = isReport(note) ? "inbox" : DATABASE_FOR_TYPE[note.type];
|
|
558
573
|
if (database === undefined || (note.type === "source" && note.attributes.kind === undefined))
|
|
559
574
|
continue;
|
|
560
|
-
// A published report is never rendered again (0043).
|
|
561
|
-
if (isReport(note) && note.attributes.notion !== undefined)
|
|
562
|
-
continue;
|
|
563
575
|
// An attached source has no Inbox row: its notes go on the parent's (0042).
|
|
564
576
|
if (note.type === "source" && note.attributes.attached_to !== undefined)
|
|
565
577
|
continue;
|
|
578
|
+
// A decided Review row leaves Notion 30 days after its decision, once (0045).
|
|
579
|
+
if (note.type === "proposal" && isRecord(note.attributes.notion_published) && note.attributes.notion_published["@trashed"] !== undefined)
|
|
580
|
+
continue;
|
|
566
581
|
const databaseId = databaseIdFor(workspace, database);
|
|
567
582
|
if (databaseId === "") {
|
|
568
583
|
warnings.push({ code: "DATABASE_NOT_CONFIGURED", message: `${database} database is not configured`, id: note.id });
|
|
569
584
|
continue;
|
|
570
585
|
}
|
|
586
|
+
if (note.type === "proposal" && trashDue(note, now)) {
|
|
587
|
+
items.push({ id: note.id, type: note.type, database, database_id: databaseId, page_id: String(note.attributes.notion), action: "trash", properties: {}, body: null, sections: null, pending_relations: [], published: { "@trashed": formatTimestamp(now) } });
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
571
590
|
if (note.type === "meeting" && typeof note.attributes.notion !== "string") {
|
|
572
591
|
errors.push({ id: note.id, reason: "MEETING_NOT_PUBLISHED" });
|
|
573
592
|
continue;
|
|
@@ -586,7 +605,7 @@ export function render(workspace, now, ids, held) {
|
|
|
586
605
|
const properties = {};
|
|
587
606
|
const pending = [];
|
|
588
607
|
const heldProperties = held?.get(note.id) ?? new Set();
|
|
589
|
-
const isCreate =
|
|
608
|
+
const isCreate = note.attributes.notion === undefined && capturePage === undefined;
|
|
590
609
|
for (const [name, value] of Object.entries(rendered)) {
|
|
591
610
|
if (name === "@content" || heldProperties.has(name) || (capturePage !== undefined && name === "Name"))
|
|
592
611
|
continue;
|
|
@@ -598,6 +617,21 @@ export function render(workspace, now, ids, held) {
|
|
|
598
617
|
if (content.hash !== undefined)
|
|
599
618
|
rendered["@content"] = content.hash;
|
|
600
619
|
const contentChanged = content.hash !== undefined && !isDeepStrictEqual(content.hash, published["@content"]);
|
|
620
|
+
// A report (0045): a refresh sends the body once and clears Refresh; a new fix clears What to fix.
|
|
621
|
+
let reportDue = false;
|
|
622
|
+
if (isReport(note)) {
|
|
623
|
+
const fixes = Array.isArray(note.attributes.fixes) ? note.attributes.fixes.length : 0;
|
|
624
|
+
const stored = typeof published["@fixes"] === "number" ? published["@fixes"] : 0;
|
|
625
|
+
if (!isCreate && contentChanged)
|
|
626
|
+
properties.Refresh = { type: "checkbox", value: false };
|
|
627
|
+
if (!isCreate && !contentChanged)
|
|
628
|
+
content.body = null;
|
|
629
|
+
if (fixes > stored) {
|
|
630
|
+
properties["What to fix"] = { type: "rich_text", value: null };
|
|
631
|
+
reportDue = true;
|
|
632
|
+
}
|
|
633
|
+
rendered["@fixes"] = Math.max(fixes, stored);
|
|
634
|
+
}
|
|
601
635
|
// Full text (0043): the text goes out once per choice, and a ticked box is cleared once per request.
|
|
602
636
|
const full = note.type === "source" && hasInboxBody(note) ? fullTextDue(note, published["@full_text"]) : undefined;
|
|
603
637
|
if (full?.sections !== undefined)
|
|
@@ -607,7 +641,7 @@ export function render(workspace, now, ids, held) {
|
|
|
607
641
|
if (full !== undefined && (full.sections !== undefined || full.clear))
|
|
608
642
|
rendered["@full_text"] = full.record;
|
|
609
643
|
const fullTextDueNow = full !== undefined && (full.sections !== undefined || full.clear);
|
|
610
|
-
if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !fullTextDueNow)
|
|
644
|
+
if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !fullTextDueNow && !reportDue)
|
|
611
645
|
continue;
|
|
612
646
|
if (!isCreate && note.type !== "meeting" && note.type !== "proposal")
|
|
613
647
|
properties["Last Synced"] = { type: "date", value: { start: formatTimestamp(now), end: null } };
|
|
@@ -616,6 +650,16 @@ export function render(workspace, now, ids, held) {
|
|
|
616
650
|
items.sort((a, b) => DATABASES.concat(["meetings"]).indexOf(a.database) - DATABASES.concat(["meetings"]).indexOf(b.database) || compareCodePoints(a.id, b.id));
|
|
617
651
|
return { items, errors, warnings };
|
|
618
652
|
}
|
|
653
|
+
const TRASH_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
|
654
|
+
/** A published Review row whose proposal was applied or closed more than 30 days before `now` (0045). */
|
|
655
|
+
function trashDue(note, now) {
|
|
656
|
+
const a = note.attributes;
|
|
657
|
+
if (typeof a.notion !== "string" || (a.status !== "applied" && a.status !== "closed"))
|
|
658
|
+
return false;
|
|
659
|
+
const decided = isRecord(a.decision) && typeof a.decision.decided_at === "string" ? a.decision.decided_at : a.updated;
|
|
660
|
+
const time = typeof decided === "string" ? Date.parse(decided) : Number.NaN;
|
|
661
|
+
return Number.isFinite(time) && now.getTime() - time > TRASH_AFTER_MS;
|
|
662
|
+
}
|
|
619
663
|
function databaseIdFor(workspace, database) { const notion = workspace.config.notion; if (!isRecord(notion) || !isRecord(notion.databases))
|
|
620
664
|
return ""; return typeof notion.databases[database] === "string" ? notion.databases[database] : database === "inbox" && typeof notion.databases.capture_inbox === "string" ? notion.databases.capture_inbox : ""; }
|
|
621
665
|
function renderedBrainValues(note, workspace) {
|
|
@@ -627,7 +671,9 @@ function renderedBrainValues(note, workspace) {
|
|
|
627
671
|
const addLink = a.kind === "add_link";
|
|
628
672
|
const after = addLink
|
|
629
673
|
? proposalLinks(a).map((link) => `${fieldLabel(String(link.field))}: ${noteTitle(link.value, workspace)}`).join("\n")
|
|
630
|
-
: a.kind === "review_notes" ? reviewNotesAfter(a, workspace) : undefined;
|
|
674
|
+
: a.kind === "review_notes" ? reviewNotesAfter(a, workspace) : a.kind === "update_body" ? bodyChange(a, workspace)?.join("\n") ?? null : undefined;
|
|
675
|
+
// A body diff can only be shown while the target's body is the one proposed against; then S keeps it (0045).
|
|
676
|
+
const proposed = staleBody(a, workspace) && isRecord(a.notion_published) && Object.hasOwn(a.notion_published, "Proposed") ? a.notion_published.Proposed : undefined;
|
|
631
677
|
const reviewText = (value) => truncate(typeof value === "string" ? reviewMarkdown(value, target) : value);
|
|
632
678
|
// A Review notes row names its source: the meeting for minutes, else the source (0041).
|
|
633
679
|
const source = a.kind === "review_notes" && typeof a.source === "string" ? workspace.get(a.source) : undefined;
|
|
@@ -635,16 +681,16 @@ function renderedBrainValues(note, workspace) {
|
|
|
635
681
|
const page = (source ?? target)?.attributes.notion;
|
|
636
682
|
const meeting = typeof a.source === "string" ? workspace.get(a.source) : undefined;
|
|
637
683
|
Object.assign(values, {
|
|
638
|
-
Name: a.title, Kind: select(a.kind),
|
|
639
|
-
"
|
|
684
|
+
Name: a.title, Kind: select(a.kind), About: targetText,
|
|
685
|
+
"About Page": typeof page === "string" ? `https://www.notion.so/${page.replaceAll("-", "")}` : null,
|
|
640
686
|
"Source Meeting": meeting?.type === "meeting" && typeof meeting.attributes.notion === "string" ? [meeting.attributes.notion] : [],
|
|
641
|
-
|
|
687
|
+
Now: reviewText(proposalBefore(a, workspace)), Proposed: proposed !== undefined ? proposed : reviewText(after ?? proposalAfter(a, workspace)),
|
|
642
688
|
Why: reviewText(a.why), State: select(proposalState(a.status)), "Brain ID": note.id,
|
|
643
689
|
});
|
|
644
690
|
return values;
|
|
645
691
|
}
|
|
646
692
|
if (isReport(note)) {
|
|
647
|
-
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": "Processed", "Brain ID": note.id });
|
|
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 });
|
|
648
694
|
return values;
|
|
649
695
|
}
|
|
650
696
|
if (note.type === "meeting") {
|
|
@@ -664,10 +710,18 @@ function renderedBrainValues(note, workspace) {
|
|
|
664
710
|
Object.assign(values, { Date: dateOutput(a.date), Assessment: select(a.assessment), Area: relationPages(projectAreas(a.projects, workspace), workspace), 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) });
|
|
665
711
|
if (note.type === "question")
|
|
666
712
|
Object.assign(values, { Kind: select(a.kind), Area: relationPages(projectAreas(a.projects, workspace), workspace), Project: relationPages(a.projects, workspace), "Raised In": relationPages(a.raised_in === undefined ? [] : [a.raised_in], workspace) });
|
|
667
|
-
//
|
|
668
|
-
if (note.type === "question"
|
|
669
|
-
|
|
670
|
-
|
|
713
|
+
// Status goes out once as Open while you never set one, and once as Answered after an approved answer; it stays yours otherwise (0041, 0045).
|
|
714
|
+
if (note.type === "question") {
|
|
715
|
+
const published = isRecord(a.notion_published) ? a.notion_published : {};
|
|
716
|
+
const yours = isRecord(a.notion_fields) && a.notion_fields.status !== undefined;
|
|
717
|
+
if (a.answer !== undefined) {
|
|
718
|
+
if (published.Status !== "Answered")
|
|
719
|
+
values.Status = "Answered";
|
|
720
|
+
}
|
|
721
|
+
else if (published.Status === undefined && !yours)
|
|
722
|
+
values.Status = "Open";
|
|
723
|
+
}
|
|
724
|
+
if (note.type === "concept" || note.type === "reference")
|
|
671
725
|
Object.assign(values, { Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace) });
|
|
672
726
|
return values;
|
|
673
727
|
}
|
|
@@ -682,17 +736,17 @@ function projectAreas(projects, workspace) {
|
|
|
682
736
|
/** The notes a source produced, with those of the sources attached to it (cli §5.14.4, 0042). */
|
|
683
737
|
function producedBy(note, workspace) {
|
|
684
738
|
const sources = new Set([note.id, ...workspace.notes.filter((candidate) => candidate.type === "source" && candidate.attributes.attached_to === note.id).map((candidate) => candidate.id)]);
|
|
685
|
-
return workspace.notes.filter((candidate) => ["decision", "question", "concept", "person"].includes(candidate.type) && Array.isArray(candidate.attributes.sources) && candidate.attributes.sources.some((id) => sources.has(String(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))));
|
|
686
740
|
}
|
|
687
741
|
function sourceValues(note, workspace) {
|
|
688
742
|
const produced = producedBy(note, workspace);
|
|
689
743
|
const ids = (type) => produced.filter((candidate) => candidate.type === type).map((candidate) => candidate.id).sort(compareCodePoints);
|
|
690
744
|
const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))].sort(compareCodePoints);
|
|
691
745
|
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);
|
|
692
|
-
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": "Processed", "Brain ID": note.id };
|
|
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 };
|
|
693
747
|
}
|
|
694
748
|
function notionProperty(name, value, note, workspace, pending) {
|
|
695
|
-
const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Concepts", "Attendees"].includes(name);
|
|
749
|
+
const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Concepts", "References", "Attendees"].includes(name);
|
|
696
750
|
if (relation) {
|
|
697
751
|
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" };
|
|
698
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] ?? ""];
|
|
@@ -710,7 +764,7 @@ function notionProperty(name, value, note, workspace, pending) {
|
|
|
710
764
|
}
|
|
711
765
|
if (name === "Ask")
|
|
712
766
|
return { type: "relation", value: Array.isArray(value) ? value : [] };
|
|
713
|
-
if (name === "
|
|
767
|
+
if (name === "About Page")
|
|
714
768
|
return { type: "url", value };
|
|
715
769
|
if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status", "Status"].includes(name))
|
|
716
770
|
return { type: "select", value };
|
|
@@ -726,6 +780,8 @@ function sourceRelationIds(note, name, workspace) {
|
|
|
726
780
|
return produced.filter((candidate) => candidate.type === "question").map((candidate) => candidate.id);
|
|
727
781
|
if (name === "Concepts")
|
|
728
782
|
return produced.filter((candidate) => candidate.type === "concept").map((candidate) => candidate.id);
|
|
783
|
+
if (name === "References")
|
|
784
|
+
return produced.filter((candidate) => candidate.type === "reference").map((candidate) => candidate.id);
|
|
729
785
|
if (name === "People")
|
|
730
786
|
return produced.filter((candidate) => candidate.type === "person").map((candidate) => candidate.id);
|
|
731
787
|
const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))];
|
|
@@ -759,7 +815,7 @@ function renderedContent(note, workspace) {
|
|
|
759
815
|
const body = notionMarkdown(note.body.replace(/^\n+/, ""), note, workspace);
|
|
760
816
|
return { body, sections: null, hash: hash(body) };
|
|
761
817
|
}
|
|
762
|
-
if (note.type === "decision" || note.type === "question" || note.type === "concept") {
|
|
818
|
+
if (note.type === "decision" || note.type === "question" || note.type === "concept" || note.type === "reference") {
|
|
763
819
|
const body = notionMarkdown(note.body, note, workspace);
|
|
764
820
|
return { body, sections: null, hash: hash(body) };
|
|
765
821
|
}
|
|
@@ -799,14 +855,34 @@ function personTitle(value, workspace) { return typeof value === "string" && wor
|
|
|
799
855
|
function noteTitle(value, workspace) { return typeof value === "string" ? workspace.get(value)?.attributes.title ?? value : value ?? null; }
|
|
800
856
|
function fieldLabel(field) { return field[0]?.toUpperCase() + field.slice(1).replaceAll("_", " "); }
|
|
801
857
|
function truncate(value) { return typeof value === "string" ? value.slice(0, 2000) : value; }
|
|
802
|
-
/** The lettered lines of a Review notes row: its notes, then its links (cli §5.14.4). */
|
|
858
|
+
/** The lettered lines of a Review notes row: its changes, then its notes, then its links (cli §5.14.4, 0045). */
|
|
803
859
|
function reviewNotesAfter(attributes, workspace) {
|
|
860
|
+
const changes = (Array.isArray(attributes.changes) ? attributes.changes.filter(isRecord) : []).map((change) => {
|
|
861
|
+
const target = String(noteTitle(change.target, workspace));
|
|
862
|
+
if (change.kind === "update_fact")
|
|
863
|
+
return `⚠ ${fieldLabel(String(change.field))} on ${target}: ${change.before === undefined ? "(new)" : String(noteTitle(change.before, workspace))} → ${String(noteTitle(change.after, workspace))}`;
|
|
864
|
+
if (change.kind === "update_body")
|
|
865
|
+
return `⚠ ${target}: ${bodyChange(change, workspace)?.length ?? 0} lines changed`;
|
|
866
|
+
return `Add to ${target}: ${String(change.content ?? "").split("\n")[0].slice(0, 80)}`;
|
|
867
|
+
});
|
|
804
868
|
const notes = (Array.isArray(attributes.notes) ? attributes.notes : []).map((id) => {
|
|
805
869
|
const note = workspace.get(String(id));
|
|
806
870
|
return `${note === undefined ? "Note" : fieldLabel(note.type)}: ${String(noteTitle(id, workspace))}`;
|
|
807
871
|
});
|
|
808
872
|
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))}`);
|
|
809
|
-
return [...notes, ...links].map((line, index) => `${letters(index)}) ${line}`).join("\n");
|
|
873
|
+
return [...changes, ...notes, ...links].map((line, index) => `${letters(index)}) ${line}`).join("\n");
|
|
874
|
+
}
|
|
875
|
+
/** The changed lines an update_body proposes against its target's body (cli §5.19), or undefined once that body changed. */
|
|
876
|
+
function bodyChange(change, workspace) {
|
|
877
|
+
const target = typeof change.target === "string" ? workspace.get(change.target) : undefined;
|
|
878
|
+
if (target === undefined || bodyHash(target.body) !== change.before_hash)
|
|
879
|
+
return undefined;
|
|
880
|
+
return lineDiff(target.body, bodyOf(String(change.content ?? "")));
|
|
881
|
+
}
|
|
882
|
+
/** Whether a proposal holds an update_body whose target's body is no longer the one it was proposed against. */
|
|
883
|
+
function staleBody(attributes, workspace) {
|
|
884
|
+
const bodies = attributes.kind === "update_body" ? [attributes] : (Array.isArray(attributes.changes) ? attributes.changes.filter(isRecord) : []).filter((change) => change.kind === "update_body");
|
|
885
|
+
return bodies.some((change) => bodyChange(change, workspace) === undefined);
|
|
810
886
|
}
|
|
811
887
|
/** a, b, … z, then aa, ab, … */
|
|
812
888
|
export function letters(index) {
|
|
@@ -837,7 +913,7 @@ function digestBody(note) {
|
|
|
837
913
|
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");
|
|
838
914
|
}
|
|
839
915
|
function proposalBefore(attributes, workspace) {
|
|
840
|
-
if (attributes.kind === "add_link" || attributes.kind === "review_notes" || attributes.kind === "answer")
|
|
916
|
+
if (attributes.kind === "add_link" || attributes.kind === "review_notes" || attributes.kind === "answer" || attributes.kind === "update_body")
|
|
841
917
|
return null;
|
|
842
918
|
if (attributes.kind === "supersede")
|
|
843
919
|
return truncate(noteTitle(attributes.supersedes, workspace));
|
package/package.json
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"decision",
|
|
27
27
|
"question",
|
|
28
28
|
"concept",
|
|
29
|
+
"reference",
|
|
29
30
|
"source",
|
|
30
31
|
"view",
|
|
31
32
|
"memory"
|
|
@@ -152,6 +153,18 @@
|
|
|
152
153
|
"$ref": "#/$defs/concept"
|
|
153
154
|
}
|
|
154
155
|
},
|
|
156
|
+
{
|
|
157
|
+
"if": {
|
|
158
|
+
"properties": {
|
|
159
|
+
"type": {
|
|
160
|
+
"const": "reference"
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
"then": {
|
|
165
|
+
"$ref": "#/$defs/reference"
|
|
166
|
+
}
|
|
167
|
+
},
|
|
155
168
|
{
|
|
156
169
|
"if": {
|
|
157
170
|
"properties": {
|
|
@@ -196,7 +209,7 @@
|
|
|
196
209
|
},
|
|
197
210
|
"id": {
|
|
198
211
|
"type": "string",
|
|
199
|
-
"pattern": "^(area|gol|prj|per|org|mtg|dec|que|con|src|mem|prp|vw)-[0-9abcdefghjkmnpqrstvwxyz]{8}$"
|
|
212
|
+
"pattern": "^(area|gol|prj|per|org|mtg|dec|que|con|ref|src|mem|prp|vw)-[0-9abcdefghjkmnpqrstvwxyz]{8}$"
|
|
200
213
|
},
|
|
201
214
|
"areaId": {
|
|
202
215
|
"type": "string",
|
|
@@ -795,6 +808,24 @@
|
|
|
795
808
|
}
|
|
796
809
|
]
|
|
797
810
|
},
|
|
811
|
+
"reference": {
|
|
812
|
+
"description": "What the user kept from a document (decision 0045, spec/schema.md §4 reference).",
|
|
813
|
+
"allOf": [
|
|
814
|
+
{
|
|
815
|
+
"$ref": "#/$defs/knowledge"
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
"required": [
|
|
819
|
+
"sources"
|
|
820
|
+
],
|
|
821
|
+
"properties": {
|
|
822
|
+
"sources": {
|
|
823
|
+
"minItems": 1
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
]
|
|
828
|
+
},
|
|
798
829
|
"source": {
|
|
799
830
|
"required": [
|
|
800
831
|
"source_key",
|
|
@@ -850,6 +881,13 @@
|
|
|
850
881
|
"attached_to": {
|
|
851
882
|
"$ref": "#/$defs/sourceId"
|
|
852
883
|
},
|
|
884
|
+
"replaces": {
|
|
885
|
+
"$ref": "#/$defs/sourceId"
|
|
886
|
+
},
|
|
887
|
+
"keep": {
|
|
888
|
+
"type": "string",
|
|
889
|
+
"minLength": 1
|
|
890
|
+
},
|
|
853
891
|
"summary": {
|
|
854
892
|
"type": "string",
|
|
855
893
|
"minLength": 1,
|
|
@@ -1098,6 +1136,45 @@
|
|
|
1098
1136
|
]
|
|
1099
1137
|
}
|
|
1100
1138
|
}
|
|
1139
|
+
},
|
|
1140
|
+
{
|
|
1141
|
+
"description": "keep only on a source that is read for its content (0045).",
|
|
1142
|
+
"if": {
|
|
1143
|
+
"anyOf": [
|
|
1144
|
+
{
|
|
1145
|
+
"required": [
|
|
1146
|
+
"attached_to"
|
|
1147
|
+
]
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
"required": [
|
|
1151
|
+
"kind"
|
|
1152
|
+
],
|
|
1153
|
+
"properties": {
|
|
1154
|
+
"kind": {
|
|
1155
|
+
"enum": [
|
|
1156
|
+
"minutes",
|
|
1157
|
+
"capture"
|
|
1158
|
+
]
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
{
|
|
1163
|
+
"not": {
|
|
1164
|
+
"required": [
|
|
1165
|
+
"kind"
|
|
1166
|
+
]
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
]
|
|
1170
|
+
},
|
|
1171
|
+
"then": {
|
|
1172
|
+
"not": {
|
|
1173
|
+
"required": [
|
|
1174
|
+
"keep"
|
|
1175
|
+
]
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1101
1178
|
}
|
|
1102
1179
|
]
|
|
1103
1180
|
},
|
|
@@ -1133,7 +1210,48 @@
|
|
|
1133
1210
|
"minLength": 1
|
|
1134
1211
|
},
|
|
1135
1212
|
"asked_in": {
|
|
1136
|
-
"
|
|
1213
|
+
"oneOf": [
|
|
1214
|
+
{
|
|
1215
|
+
"$ref": "#/$defs/notionPageId"
|
|
1216
|
+
},
|
|
1217
|
+
{
|
|
1218
|
+
"type": "array",
|
|
1219
|
+
"minItems": 1,
|
|
1220
|
+
"uniqueItems": true,
|
|
1221
|
+
"items": {
|
|
1222
|
+
"$ref": "#/$defs/notionPageId"
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
]
|
|
1226
|
+
},
|
|
1227
|
+
"fixes": {
|
|
1228
|
+
"type": "array",
|
|
1229
|
+
"items": {
|
|
1230
|
+
"type": "object",
|
|
1231
|
+
"required": [
|
|
1232
|
+
"text",
|
|
1233
|
+
"received",
|
|
1234
|
+
"proposals"
|
|
1235
|
+
],
|
|
1236
|
+
"additionalProperties": false,
|
|
1237
|
+
"properties": {
|
|
1238
|
+
"text": {
|
|
1239
|
+
"type": "string",
|
|
1240
|
+
"minLength": 1
|
|
1241
|
+
},
|
|
1242
|
+
"received": {
|
|
1243
|
+
"$ref": "#/$defs/timestamp"
|
|
1244
|
+
},
|
|
1245
|
+
"proposals": {
|
|
1246
|
+
"type": "array",
|
|
1247
|
+
"uniqueItems": true,
|
|
1248
|
+
"items": {
|
|
1249
|
+
"type": "string",
|
|
1250
|
+
"pattern": "^prp-[0-9abcdefghjkmnpqrstvwxyz]{8}$"
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1137
1255
|
},
|
|
1138
1256
|
"generated_from": {
|
|
1139
1257
|
"type": "array",
|
|
@@ -1215,6 +1333,11 @@
|
|
|
1215
1333
|
"asked_in"
|
|
1216
1334
|
]
|
|
1217
1335
|
},
|
|
1336
|
+
{
|
|
1337
|
+
"required": [
|
|
1338
|
+
"fixes"
|
|
1339
|
+
]
|
|
1340
|
+
},
|
|
1218
1341
|
{
|
|
1219
1342
|
"required": [
|
|
1220
1343
|
"notion"
|
|
@@ -1274,6 +1397,11 @@
|
|
|
1274
1397
|
"asked_in"
|
|
1275
1398
|
]
|
|
1276
1399
|
},
|
|
1400
|
+
{
|
|
1401
|
+
"required": [
|
|
1402
|
+
"fixes"
|
|
1403
|
+
]
|
|
1404
|
+
},
|
|
1277
1405
|
{
|
|
1278
1406
|
"required": [
|
|
1279
1407
|
"notion"
|