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
*/7
import type { Mapping, Work } from "./mapping.ts";8
import { replaceWork } from "./mapping.ts";9
10
const API = "https://api.notion.com/v1";11
const NOTION_VERSION = "2022-06-28";12
const MAX_BLOCK = 2000;13
14
export 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
25
export type Transport = (method: string, url: string, headers: Record<string, string>, body?: string) => Promise<any>;26
27
export class NotionError extends Error {}28
29
async 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
35
export 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
90
export 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
96
function richText(content: string) {97
return { rich_text: [{ type: "text", text: { content } }] };98
}99
100
function 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
110
export 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
127
export async function loadWorkFromNotion(mapping: Mapping, databaseId: string, client: NotionClient): Promise<Mapping> {128
return replaceWork(mapping, await client.queryWork(databaseId));129
}130
131
export 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
164
export function clientFromEnv(transport?: Transport): NotionClient {165
return new NotionClient(process.env.NOTION_TOKEN ?? "", transport);166
}167