@patimweb/pi-outline-wiki 1.0.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.
@@ -0,0 +1,632 @@
1
+ /**
2
+ * Outline API client.
3
+ *
4
+ * The Outline API is RPC over HTTP: every method is a POST to
5
+ * `<workspace>/api/<method>` with a JSON body and a Bearer API key, and every
6
+ * response is `{ ok, data, pagination? }`. `callOutline` is the single place
7
+ * that owns auth, timeouts and error translation; everything above it is a
8
+ * thin, typed wrapper that returns plain data and never mutates state.
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import type {
14
+ CollectionSummary,
15
+ CommentEntry,
16
+ DocumentDetail,
17
+ DocumentSummary,
18
+ NavigationNode,
19
+ OutlineConfig,
20
+ OutlineResponse,
21
+ Pagination,
22
+ SearchHit,
23
+ UserRef,
24
+ WorkspaceInfo,
25
+ } from "../types.ts";
26
+ import {
27
+ OutlineApiError,
28
+ OutlineNotFoundError,
29
+ UnsafeExportPathError,
30
+ } from "../types.ts";
31
+
32
+ const DEFAULT_TIMEOUT_MS = 30_000;
33
+
34
+ let cachedVersion: string | undefined;
35
+ let tlsWarningIssued = false;
36
+
37
+ function getClientVersion(): string {
38
+ if (!cachedVersion) {
39
+ try {
40
+ const pkg = JSON.parse(
41
+ fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
42
+ );
43
+ cachedVersion = typeof pkg.version === "string" ? pkg.version : "unknown";
44
+ } catch {
45
+ cachedVersion = "unknown";
46
+ }
47
+ }
48
+ return cachedVersion;
49
+ }
50
+
51
+ /**
52
+ * Certificate validation can only be turned off process-wide in Node, so it
53
+ * happens once, loudly, and only when a profile explicitly opts in.
54
+ */
55
+ function applyTlsOption(config: OutlineConfig): void {
56
+ if (!config.insecureTls) return;
57
+ if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0") return;
58
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
59
+ if (!tlsWarningIssued) {
60
+ tlsWarningIssued = true;
61
+ process.emitWarning(
62
+ "pi-outline-wiki: TLS certificate validation disabled for this process (profile has insecureTls).",
63
+ );
64
+ }
65
+ }
66
+
67
+ /** True once a profile in this process has disabled certificate validation. */
68
+ export function isTlsValidationDisabled(): boolean {
69
+ return process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0";
70
+ }
71
+
72
+ // Core request
73
+
74
+ function combineSignals(signal?: AbortSignal, timeoutMs = DEFAULT_TIMEOUT_MS) {
75
+ const timeout = AbortSignal.timeout(timeoutMs);
76
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
77
+ }
78
+
79
+ function describeFailure(method: string, status: number, payload: unknown): OutlineApiError {
80
+ const body = (payload ?? {}) as Record<string, unknown>;
81
+ const code = typeof body.error === "string" ? body.error : undefined;
82
+ const detail =
83
+ (typeof body.message === "string" && body.message) ||
84
+ (typeof body.error === "string" && body.error) ||
85
+ "";
86
+
87
+ if (status === 401) {
88
+ return new OutlineApiError(
89
+ method,
90
+ status,
91
+ "Outline rejected the API key (401). Create a new key under Settings -> API & Apps and re-run outline_setup.",
92
+ code,
93
+ );
94
+ }
95
+ if (status === 403) {
96
+ return new OutlineApiError(
97
+ method,
98
+ status,
99
+ `Outline denied access (403)${detail ? `: ${detail}` : ""}. The API key's user may lack permission for this collection or document.`,
100
+ code,
101
+ );
102
+ }
103
+ if (status === 429) {
104
+ return new OutlineApiError(
105
+ method,
106
+ status,
107
+ "Outline rate limit reached (429). Wait a moment and retry.",
108
+ code,
109
+ );
110
+ }
111
+ return new OutlineApiError(
112
+ method,
113
+ status,
114
+ `Outline API ${method} failed (${status})${detail ? `: ${detail}` : ""}`,
115
+ code,
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Call one Outline API method. Throws OutlineApiError for any non-ok response.
121
+ */
122
+ export async function callOutline<T>(
123
+ config: OutlineConfig,
124
+ method: string,
125
+ body: Record<string, unknown> = {},
126
+ signal?: AbortSignal,
127
+ timeoutMs = DEFAULT_TIMEOUT_MS,
128
+ ): Promise<OutlineResponse<T>> {
129
+ applyTlsOption(config);
130
+
131
+ // Undefined values would be serialised away anyway, but dropping them here
132
+ // keeps request bodies readable in errors and tests.
133
+ const payload: Record<string, unknown> = {};
134
+ for (const [key, value] of Object.entries(body)) {
135
+ if (value !== undefined) payload[key] = value;
136
+ }
137
+
138
+ const endpoint = `${config.url}/api/${method}`;
139
+ let response: Response;
140
+
141
+ try {
142
+ response = await fetch(endpoint, {
143
+ method: "POST",
144
+ headers: {
145
+ "Content-Type": "application/json",
146
+ Accept: "application/json",
147
+ Authorization: `Bearer ${config.apiKey}`,
148
+ "User-Agent": `pi-outline-wiki/${getClientVersion()}`,
149
+ },
150
+ body: JSON.stringify(payload),
151
+ signal: combineSignals(signal, timeoutMs),
152
+ });
153
+ } catch (err) {
154
+ const cause = err as Error;
155
+ if (cause?.name === "TimeoutError" || cause?.name === "AbortError") {
156
+ throw new OutlineApiError(
157
+ method,
158
+ 0,
159
+ `Outline did not respond within ${Math.round(timeoutMs / 1000)}s (${endpoint}).`,
160
+ );
161
+ }
162
+ throw new OutlineApiError(
163
+ method,
164
+ 0,
165
+ `Could not reach Outline at ${config.url}: ${cause?.message ?? String(err)}`,
166
+ );
167
+ }
168
+
169
+ const raw = await response.text();
170
+ let parsed: unknown = undefined;
171
+ if (raw) {
172
+ try {
173
+ parsed = JSON.parse(raw);
174
+ } catch {
175
+ parsed = undefined;
176
+ }
177
+ }
178
+
179
+ if (!response.ok) {
180
+ if (parsed === undefined && raw.trim().startsWith("<")) {
181
+ throw new OutlineApiError(
182
+ method,
183
+ response.status,
184
+ `Outline returned HTML instead of JSON (${response.status}). Is ${config.url} really an Outline workspace?`,
185
+ );
186
+ }
187
+ throw describeFailure(method, response.status, parsed);
188
+ }
189
+
190
+ const envelope = parsed as OutlineResponse<T> | undefined;
191
+ if (!envelope || envelope.ok === false) {
192
+ throw describeFailure(method, response.status, envelope);
193
+ }
194
+ return envelope;
195
+ }
196
+
197
+ // Mapping -- API payloads to domain data
198
+
199
+ function toUserRef(raw: unknown): UserRef | undefined {
200
+ if (!raw || typeof raw !== "object") return undefined;
201
+ const user = raw as Record<string, unknown>;
202
+ return {
203
+ id: typeof user.id === "string" ? user.id : undefined,
204
+ name: typeof user.name === "string" ? user.name : undefined,
205
+ email: typeof user.email === "string" ? user.email : undefined,
206
+ };
207
+ }
208
+
209
+ export function toDocumentSummary(raw: unknown): DocumentSummary {
210
+ const doc = (raw ?? {}) as Record<string, any>;
211
+ return {
212
+ id: String(doc.id ?? ""),
213
+ urlId: doc.urlId,
214
+ title: typeof doc.title === "string" && doc.title.length > 0 ? doc.title : "Untitled",
215
+ url: doc.url,
216
+ icon: doc.icon ?? null,
217
+ collectionId: doc.collectionId ?? null,
218
+ parentDocumentId: doc.parentDocumentId ?? null,
219
+ template: Boolean(doc.template),
220
+ fullWidth: Boolean(doc.fullWidth),
221
+ createdAt: doc.createdAt,
222
+ updatedAt: doc.updatedAt,
223
+ archivedAt: doc.archivedAt ?? null,
224
+ deletedAt: doc.deletedAt ?? null,
225
+ publishedAt: doc.publishedAt ?? null,
226
+ createdBy: toUserRef(doc.createdBy),
227
+ updatedBy: toUserRef(doc.updatedBy),
228
+ };
229
+ }
230
+
231
+ export function toDocumentDetail(raw: unknown): DocumentDetail {
232
+ const doc = (raw ?? {}) as Record<string, any>;
233
+ return {
234
+ ...toDocumentSummary(doc),
235
+ text: typeof doc.text === "string" ? doc.text : "",
236
+ };
237
+ }
238
+
239
+ export function toCollectionSummary(raw: unknown): CollectionSummary {
240
+ const col = (raw ?? {}) as Record<string, any>;
241
+ return {
242
+ id: String(col.id ?? ""),
243
+ name: typeof col.name === "string" ? col.name : "Untitled",
244
+ description: col.description ?? null,
245
+ icon: col.icon ?? null,
246
+ color: col.color ?? null,
247
+ permission: col.permission ?? null,
248
+ updatedAt: col.updatedAt,
249
+ };
250
+ }
251
+
252
+ export function toNavigationNode(raw: unknown): NavigationNode {
253
+ const node = (raw ?? {}) as Record<string, any>;
254
+ const children = Array.isArray(node.children) ? node.children : [];
255
+ return {
256
+ id: String(node.id ?? ""),
257
+ title: typeof node.title === "string" && node.title.length > 0 ? node.title : "Untitled",
258
+ url: node.url,
259
+ children: children.map(toNavigationNode),
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Comments are stored as a ProseMirror document. Flatten it to plain text so
265
+ * the agent reads prose instead of a node tree. Falls back to `text` when the
266
+ * server already provides it.
267
+ */
268
+ export function flattenRichText(raw: unknown): string {
269
+ if (typeof raw === "string") return raw;
270
+ if (!raw || typeof raw !== "object") return "";
271
+
272
+ const node = raw as Record<string, any>;
273
+ if (node.type === "text" && typeof node.text === "string") return node.text;
274
+
275
+ const children = Array.isArray(node.content) ? node.content : [];
276
+ const parts = children.map(flattenRichText).filter((part) => part.length > 0);
277
+
278
+ const blockTypes = new Set([
279
+ "paragraph",
280
+ "heading",
281
+ "blockquote",
282
+ "code_block",
283
+ "code_fence",
284
+ "list_item",
285
+ "checkbox_item",
286
+ ]);
287
+ const joined = parts.join("");
288
+ return blockTypes.has(String(node.type)) ? `${joined}\n` : joined;
289
+ }
290
+
291
+ export function toComment(raw: unknown): CommentEntry {
292
+ const comment = (raw ?? {}) as Record<string, any>;
293
+ const text =
294
+ typeof comment.text === "string" && comment.text.length > 0
295
+ ? comment.text
296
+ : flattenRichText(comment.data).trim();
297
+ return {
298
+ id: String(comment.id ?? ""),
299
+ documentId: comment.documentId,
300
+ parentCommentId: comment.parentCommentId ?? null,
301
+ text,
302
+ createdAt: comment.createdAt,
303
+ updatedAt: comment.updatedAt,
304
+ resolvedAt: comment.resolvedAt ?? null,
305
+ createdBy: toUserRef(comment.createdBy),
306
+ };
307
+ }
308
+
309
+ // Operations -- workspace
310
+
311
+ export async function getWorkspaceInfo(
312
+ config: OutlineConfig,
313
+ signal?: AbortSignal,
314
+ ): Promise<WorkspaceInfo> {
315
+ const res = await callOutline<any>(config, "auth.info", {}, signal, 15_000);
316
+ const data = res.data ?? {};
317
+ return {
318
+ userName: data.user?.name,
319
+ userEmail: data.user?.email,
320
+ teamName: data.team?.name,
321
+ teamUrl: data.team?.url,
322
+ };
323
+ }
324
+
325
+ // Operations -- collections
326
+
327
+ export async function listCollections(
328
+ config: OutlineConfig,
329
+ params: { limit?: number; offset?: number } = {},
330
+ signal?: AbortSignal,
331
+ ): Promise<{ collections: CollectionSummary[]; pagination?: Pagination }> {
332
+ const res = await callOutline<any[]>(
333
+ config,
334
+ "collections.list",
335
+ { limit: params.limit ?? 50, offset: params.offset ?? 0 },
336
+ signal,
337
+ );
338
+ return {
339
+ collections: (res.data ?? []).map(toCollectionSummary),
340
+ pagination: res.pagination,
341
+ };
342
+ }
343
+
344
+ export async function getCollectionStructure(
345
+ config: OutlineConfig,
346
+ collectionId: string,
347
+ signal?: AbortSignal,
348
+ ): Promise<NavigationNode[]> {
349
+ const res = await callOutline<any[]>(
350
+ config,
351
+ "collections.documents",
352
+ { id: collectionId },
353
+ signal,
354
+ );
355
+ return (res.data ?? []).map(toNavigationNode);
356
+ }
357
+
358
+ // Operations -- documents
359
+
360
+ export async function searchDocuments(
361
+ config: OutlineConfig,
362
+ params: {
363
+ query: string;
364
+ limit?: number;
365
+ offset?: number;
366
+ collectionId?: string;
367
+ documentId?: string;
368
+ statusFilter?: string[];
369
+ dateFilter?: string;
370
+ },
371
+ signal?: AbortSignal,
372
+ ): Promise<{ hits: SearchHit[]; pagination?: Pagination }> {
373
+ const res = await callOutline<any[]>(
374
+ config,
375
+ "documents.search",
376
+ {
377
+ query: params.query,
378
+ limit: params.limit ?? 15,
379
+ offset: params.offset ?? 0,
380
+ collectionId: params.collectionId,
381
+ documentId: params.documentId,
382
+ statusFilter: params.statusFilter,
383
+ dateFilter: params.dateFilter,
384
+ },
385
+ signal,
386
+ );
387
+ const hits = (res.data ?? []).map((hit: any) => ({
388
+ ranking: typeof hit?.ranking === "number" ? hit.ranking : undefined,
389
+ context: typeof hit?.context === "string" ? hit.context : "",
390
+ document: toDocumentSummary(hit?.document),
391
+ }));
392
+ return { hits, pagination: res.pagination };
393
+ }
394
+
395
+ export async function listDocuments(
396
+ config: OutlineConfig,
397
+ params: {
398
+ limit?: number;
399
+ offset?: number;
400
+ collectionId?: string;
401
+ parentDocumentId?: string;
402
+ userId?: string;
403
+ sort?: string;
404
+ direction?: string;
405
+ statusFilter?: string[];
406
+ drafts?: boolean;
407
+ } = {},
408
+ signal?: AbortSignal,
409
+ ): Promise<{ documents: DocumentSummary[]; pagination?: Pagination }> {
410
+ const method = params.drafts ? "documents.drafts" : "documents.list";
411
+ const res = await callOutline<any[]>(
412
+ config,
413
+ method,
414
+ {
415
+ limit: params.limit ?? 25,
416
+ offset: params.offset ?? 0,
417
+ collectionId: params.collectionId,
418
+ parentDocumentId: params.drafts ? undefined : params.parentDocumentId,
419
+ userId: params.drafts ? undefined : params.userId,
420
+ sort: params.sort ?? "updatedAt",
421
+ direction: params.direction ?? "DESC",
422
+ statusFilter: params.drafts ? undefined : params.statusFilter,
423
+ },
424
+ signal,
425
+ );
426
+ return {
427
+ documents: (res.data ?? []).map(toDocumentSummary),
428
+ pagination: res.pagination,
429
+ };
430
+ }
431
+
432
+ export async function getDocument(
433
+ config: OutlineConfig,
434
+ id: string,
435
+ signal?: AbortSignal,
436
+ ): Promise<DocumentDetail> {
437
+ try {
438
+ const res = await callOutline<any>(config, "documents.info", { id }, signal);
439
+ if (!res.data) throw new OutlineNotFoundError(`Document "${id}"`);
440
+ return toDocumentDetail(res.data);
441
+ } catch (err) {
442
+ if (err instanceof OutlineApiError && err.status === 404) {
443
+ throw new OutlineNotFoundError(`Document "${id}"`);
444
+ }
445
+ throw err;
446
+ }
447
+ }
448
+
449
+ export async function createDocument(
450
+ config: OutlineConfig,
451
+ params: {
452
+ title: string;
453
+ text?: string;
454
+ collectionId?: string;
455
+ parentDocumentId?: string;
456
+ templateId?: string;
457
+ icon?: string;
458
+ publish?: boolean;
459
+ },
460
+ signal?: AbortSignal,
461
+ ): Promise<DocumentDetail> {
462
+ const res = await callOutline<any>(
463
+ config,
464
+ "documents.create",
465
+ {
466
+ title: params.title,
467
+ text: params.text,
468
+ collectionId: params.collectionId,
469
+ parentDocumentId: params.parentDocumentId,
470
+ templateId: params.templateId,
471
+ icon: params.icon,
472
+ publish: params.publish ?? true,
473
+ },
474
+ signal,
475
+ );
476
+ return toDocumentDetail(res.data);
477
+ }
478
+
479
+ export async function updateDocument(
480
+ config: OutlineConfig,
481
+ params: {
482
+ id: string;
483
+ title?: string;
484
+ text?: string;
485
+ icon?: string;
486
+ fullWidth?: boolean;
487
+ publish?: boolean;
488
+ },
489
+ signal?: AbortSignal,
490
+ ): Promise<DocumentDetail> {
491
+ const res = await callOutline<any>(
492
+ config,
493
+ "documents.update",
494
+ {
495
+ id: params.id,
496
+ title: params.title,
497
+ text: params.text,
498
+ icon: params.icon,
499
+ fullWidth: params.fullWidth,
500
+ publish: params.publish,
501
+ },
502
+ signal,
503
+ );
504
+ return toDocumentDetail(res.data);
505
+ }
506
+
507
+ export async function moveDocument(
508
+ config: OutlineConfig,
509
+ params: {
510
+ id: string;
511
+ collectionId?: string;
512
+ parentDocumentId?: string | null;
513
+ index?: number;
514
+ },
515
+ signal?: AbortSignal,
516
+ ): Promise<{ documentCount: number }> {
517
+ const res = await callOutline<any>(
518
+ config,
519
+ "documents.move",
520
+ {
521
+ id: params.id,
522
+ collectionId: params.collectionId,
523
+ parentDocumentId: params.parentDocumentId,
524
+ index: params.index,
525
+ },
526
+ signal,
527
+ );
528
+ const documents = res.data?.documents;
529
+ return { documentCount: Array.isArray(documents) ? documents.length : 0 };
530
+ }
531
+
532
+ export async function documentLifecycle(
533
+ config: OutlineConfig,
534
+ action: "archive" | "restore" | "unpublish" | "delete",
535
+ params: { id: string; permanent?: boolean },
536
+ signal?: AbortSignal,
537
+ ): Promise<DocumentSummary | null> {
538
+ const method = `documents.${action}`;
539
+ const body: Record<string, unknown> = { id: params.id };
540
+ if (action === "delete" && params.permanent) body.permanent = true;
541
+
542
+ const res = await callOutline<any>(config, method, body, signal);
543
+ return res.data ? toDocumentSummary(res.data) : null;
544
+ }
545
+
546
+ /** Fetch a document as markdown via documents.export. */
547
+ export async function exportDocument(
548
+ config: OutlineConfig,
549
+ id: string,
550
+ signal?: AbortSignal,
551
+ ): Promise<string> {
552
+ const res = await callOutline<string>(config, "documents.export", { id }, signal);
553
+ return typeof res.data === "string" ? res.data : "";
554
+ }
555
+
556
+ // Operations -- comments
557
+
558
+ export async function listComments(
559
+ config: OutlineConfig,
560
+ params: { documentId: string; limit?: number; offset?: number },
561
+ signal?: AbortSignal,
562
+ ): Promise<{ comments: CommentEntry[]; pagination?: Pagination }> {
563
+ const res = await callOutline<any[]>(
564
+ config,
565
+ "comments.list",
566
+ {
567
+ documentId: params.documentId,
568
+ limit: params.limit ?? 50,
569
+ offset: params.offset ?? 0,
570
+ },
571
+ signal,
572
+ );
573
+ return {
574
+ comments: (res.data ?? []).map(toComment),
575
+ pagination: res.pagination,
576
+ };
577
+ }
578
+
579
+ export async function createComment(
580
+ config: OutlineConfig,
581
+ params: { documentId: string; text: string; parentCommentId?: string },
582
+ signal?: AbortSignal,
583
+ ): Promise<CommentEntry> {
584
+ const res = await callOutline<any>(
585
+ config,
586
+ "comments.create",
587
+ {
588
+ documentId: params.documentId,
589
+ parentCommentId: params.parentCommentId,
590
+ text: params.text,
591
+ // Older Outline releases validate `data` (ProseMirror) rather than
592
+ // `text`; sending both keeps one call working across versions.
593
+ data: {
594
+ type: "doc",
595
+ content: [
596
+ { type: "paragraph", content: [{ type: "text", text: params.text }] },
597
+ ],
598
+ },
599
+ },
600
+ signal,
601
+ );
602
+ return toComment(res.data);
603
+ }
604
+
605
+ // Local file output
606
+
607
+ /** Slugify a document title into a safe file name stem. */
608
+ export function slugifyTitle(title: string): string {
609
+ const slug = title
610
+ .normalize("NFKD")
611
+ .replace(/[\u0300-\u036f]/g, "")
612
+ .toLowerCase()
613
+ .replace(/[^a-z0-9]+/g, "-")
614
+ .replace(/^-+|-+$/g, "")
615
+ .slice(0, 80);
616
+ return slug || "document";
617
+ }
618
+
619
+ /**
620
+ * Write markdown into `dir`, refusing any path that escapes it. Returns the
621
+ * absolute path written.
622
+ */
623
+ export function writeMarkdownFile(dir: string, fileName: string, content: string): string {
624
+ const targetDir = path.resolve(dir);
625
+ const target = path.resolve(targetDir, fileName);
626
+ if (target !== targetDir && !target.startsWith(targetDir + path.sep)) {
627
+ throw new UnsafeExportPathError(fileName);
628
+ }
629
+ fs.mkdirSync(targetDir, { recursive: true });
630
+ fs.writeFileSync(target, content, "utf-8");
631
+ return target;
632
+ }