@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.
- package/.node-version +1 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/index.ts +128 -0
- package/package.json +51 -0
- package/release.config.cjs +16 -0
- package/screenshot.png +0 -0
- package/skills/outline-doc-writing/SKILL.md +78 -0
- package/skills/outline-knowledge-capture/SKILL.md +92 -0
- package/skills/outline-wiki/SKILL.md +70 -0
- package/src/clients/outline-client.ts +632 -0
- package/src/config.ts +218 -0
- package/src/formatting/formatters.ts +258 -0
- package/src/tools/outline-archive.ts +70 -0
- package/src/tools/outline-collections.ts +52 -0
- package/src/tools/outline-comment.ts +65 -0
- package/src/tools/outline-comments.ts +52 -0
- package/src/tools/outline-create.ts +99 -0
- package/src/tools/outline-delete.ts +60 -0
- package/src/tools/outline-export.ts +146 -0
- package/src/tools/outline-list.ts +110 -0
- package/src/tools/outline-move.ts +99 -0
- package/src/tools/outline-profile.ts +82 -0
- package/src/tools/outline-read.ts +104 -0
- package/src/tools/outline-search.ts +114 -0
- package/src/tools/outline-setup.ts +71 -0
- package/src/tools/outline-status.ts +64 -0
- package/src/tools/outline-structure.ts +63 -0
- package/src/tools/outline-update.ts +106 -0
- package/src/types.ts +169 -0
|
@@ -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
|
+
}
|