typescript167 lines · 5.5 KB
1/** Notion is the live work ledger. Channels stay the markdown wall.
2 *
3 * Reads the Projects database and writes Broadcast TS, Broadcast Permalink,
4 * and Last Status Update. Stdlib fetch. Token from NOTION_TOKEN.
5 * Dry-run queries and prints writes; it does not PATCH.
6 */
7import type { Mapping, Work } from "./mapping.ts";
8import { replaceWork } from "./mapping.ts";
9
10const API = "https://api.notion.com/v1";
11const NOTION_VERSION = "2022-06-28";
12const MAX_BLOCK = 2000;
13
14export const PROPS = {
15 name: "Name",
16 id: "Scope key",
17 room: "Admin Channel",
18 state: "Status",
19 anchor: "Broadcast TS",
20 owner: "Owner",
21 permalink: "Broadcast Permalink",
22 statusUpdate: "Last Status Update",
23} as const;
24
25export type Transport = (method: string, url: string, headers: Record<string, string>, body?: string) => Promise<any>;
26
27export class NotionError extends Error {}
28
29async function defaultTransport(method: string, url: string, headers: Record<string, string>, body?: string) {
30 const res = await fetch(url, { method, headers, body });
31 if (!res.ok) throw new NotionError(`notion ${method} ${res.status}`);
32 return await res.json();
33}
34
35export class NotionClient {
36 constructor(
37 private token: string,
38 private transport: Transport = defaultTransport,
39 ) {
40 if (!token) throw new NotionError("NOTION_TOKEN is required");
41 }
42
43 private headers() {
44 return {
45 Authorization: `Bearer ${this.token}`,
46 "Notion-Version": NOTION_VERSION,
47 "Content-Type": "application/json",
48 "User-Agent": "axiom-relay/0.3",
49 };
50 }
51
52 private call(method: string, path: string, payload?: unknown) {
53 return this.transport(method, API + path, this.headers(), payload ? JSON.stringify(payload) : undefined);
54 }
55
56 async queryWork(databaseId: string): Promise<Work[]> {
57 const rows: Work[] = [];
58 let cursor: string | undefined;
59 while (true) {
60 const payload: Record<string, unknown> = { page_size: 100 };
61 if (cursor) payload.start_cursor = cursor;
62 const data = await this.call("POST", `/databases/${databaseId}/query`, payload);
63 for (const page of data.results ?? []) {
64 const w = workFromPage(page);
65 if (w) rows.push(w);
66 }
67 if (!data.has_more) break;
68 cursor = data.next_cursor;
69 }
70 return rows;
71 }
72
73 writeAnchor(pageId: string, ts: string, permalink: string) {
74 if (!ts) throw new NotionError("refusing empty Broadcast TS");
75 return this.call("PATCH", `/pages/${pageId}`, {
76 properties: {
77 [PROPS.anchor]: richText(ts),
78 [PROPS.permalink]: richText(permalink),
79 },
80 });
81 }
82
83 writeTrail(pageId: string, trail: string) {
84 return this.call("PATCH", `/pages/${pageId}`, {
85 properties: { [PROPS.statusUpdate]: richText(clip(trail)) },
86 });
87 }
88}
89
90export function clip(text: string): string {
91 if (text.length <= MAX_BLOCK) return text;
92 const suffix = "\n(truncated)";
93 return text.slice(0, MAX_BLOCK - suffix.length) + suffix;
94}
95
96function richText(content: string) {
97 return { rich_text: [{ type: "text", text: { content } }] };
98}
99
100function plain(prop: any): string {
101 if (!prop) return "";
102 if (prop.type === "title") return (prop.title ?? []).map((t: any) => t.plain_text ?? "").join("");
103 if (prop.type === "rich_text") return (prop.rich_text ?? []).map((t: any) => t.plain_text ?? "").join("");
104 if (prop.type === "select" || prop.type === "status") return prop[prop.type]?.name ?? "";
105 if (prop.type === "url") return prop.url ?? "";
106 if (prop.type === "number" && prop.number != null) return String(prop.number);
107 return "";
108}
109
110export function workFromPage(page: any): Work | null {
111 const props = page.properties ?? {};
112 const name = plain(props[PROPS.name]);
113 const key = plain(props[PROPS.id]) || name.toLowerCase().replace(/ /g, "-");
114 if (!key) return null;
115 const raw = plain(props[PROPS.state]).trim().toLowerCase();
116 return {
117 id: key,
118 name: name || key,
119 room: plain(props[PROPS.room]),
120 state: raw,
121 anchor: plain(props[PROPS.anchor]),
122 owner: plain(props[PROPS.owner]),
123 pageId: page.id ?? "",
124 };
125}
126
127export async function loadWorkFromNotion(mapping: Mapping, databaseId: string, client: NotionClient): Promise<Mapping> {
128 return replaceWork(mapping, await client.queryWork(databaseId));
129}
130
131export async function applyWrites(
132 client: NotionClient,
133 plan: { work: Work; action: string }[],
134 posts: Record<string, { ts: string; permalink: string }>,
135 trails: Record<string, string>,
136 dryRun: boolean,
137): Promise<string[]> {
138 const lines: string[] = [];
139 for (const { work: w, action } of plan) {
140 if (!w.pageId) {
141 lines.push(`notion ${w.id.padEnd(16)} skip no page id`);
142 continue;
143 }
144 if (action === "post") {
145 const posted = posts[w.id];
146 if (!posted?.ts) {
147 lines.push(`notion ${w.id.padEnd(16)} POST no ts — leave empty, retry next run`);
148 continue;
149 }
150 lines.push(`notion ${w.id.padEnd(16)} POST write Broadcast TS + Permalink`);
151 if (!dryRun) await client.writeAnchor(w.pageId, posted.ts, posted.permalink);
152 } else if (action === "gather") {
153 lines.push(`notion ${w.id.padEnd(16)} GATHER write Last Status Update`);
154 if (!dryRun) await client.writeTrail(w.pageId, trails[w.id] ?? "silent");
155 } else if (action === "gap") {
156 lines.push(`notion ${w.id.padEnd(16)} GAP no write`);
157 } else {
158 lines.push(`notion ${w.id.padEnd(16)} SKIP closed`);
159 }
160 }
161 return lines;
162}
163
164export function clientFromEnv(transport?: Transport): NotionClient {
165 return new NotionClient(process.env.NOTION_TOKEN ?? "", transport);
166}
167