yuque-cookie-plugin 0.1.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/.env.example +5 -0
- package/README.md +253 -0
- package/bin/yuque-local.mjs +3 -0
- package/dist/auth.js +439 -0
- package/dist/cli.js +692 -0
- package/dist/client-cookie.js +544 -0
- package/dist/download-utils.js +579 -0
- package/dist/downloader.js +388 -0
- package/dist/editor-bridge.js +240 -0
- package/dist/fs-utils.js +9 -0
- package/dist/lake-diff.js +113 -0
- package/dist/lake-heading-numbering.js +29 -0
- package/dist/lake-insert.js +34 -0
- package/dist/lake-markdown.js +54 -0
- package/dist/lake-parser.js +79 -0
- package/dist/lake-transform.js +12 -0
- package/dist/reports.js +10 -0
- package/dist/serve-book.js +134 -0
- package/dist/types.js +1 -0
- package/docs/docker-quickstart.md +96 -0
- package/docs/native-lake-capability.md +101 -0
- package/docs/npm-release.md +91 -0
- package/docs/real-acceptance.md +146 -0
- package/docs/usage-zh.md +580 -0
- package/package.json +71 -0
- package/skills/yuque-cookie-plugin/SKILL.md +58 -0
- package/skills/yuque-cookie-plugin/commands.yaml +61 -0
- package/skills/yuque-cookie-plugin/reference/agent-spec.md +51 -0
- package/vendor/lake-editor/CodeMirror.js +1 -0
- package/vendor/lake-editor/antd.4.24.13.css +26886 -0
- package/vendor/lake-editor/doc.css +23603 -0
- package/vendor/lake-editor/doc.umd.js +2 -0
- package/vendor/lake-editor/katex.js +18829 -0
- package/vendor/lake-editor/lake-editor-icon.js +1 -0
- package/vendor/lake-editor/react-dom.production.min.js +267 -0
- package/vendor/lake-editor/react.production.min.js +31 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, extname } from 'node:path';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { openAsBlob } from 'node:fs';
|
|
5
|
+
import { timestampForFile } from "./fs-utils.js";
|
|
6
|
+
import { writeReport } from "./reports.js";
|
|
7
|
+
const DEFAULT_HOST = 'https://www.yuque.com';
|
|
8
|
+
export class YuqueCookieClient {
|
|
9
|
+
session;
|
|
10
|
+
ctoken;
|
|
11
|
+
extraCookies;
|
|
12
|
+
host;
|
|
13
|
+
constructor({ session, ctoken, extraCookies = {}, host = DEFAULT_HOST }) {
|
|
14
|
+
if (!session)
|
|
15
|
+
throw new Error('Missing YUQUE_SESSION.');
|
|
16
|
+
if (!ctoken)
|
|
17
|
+
throw new Error('Missing YUQUE_CTOKEN.');
|
|
18
|
+
this.session = session;
|
|
19
|
+
this.ctoken = ctoken;
|
|
20
|
+
this.extraCookies = extraCookies;
|
|
21
|
+
this.host = host;
|
|
22
|
+
}
|
|
23
|
+
cookieHeader() {
|
|
24
|
+
const cookies = {
|
|
25
|
+
_yuque_session: this.session,
|
|
26
|
+
yuque_ctoken: this.ctoken,
|
|
27
|
+
...this.extraCookies
|
|
28
|
+
};
|
|
29
|
+
return Object.entries(cookies)
|
|
30
|
+
.filter(([, value]) => value)
|
|
31
|
+
.map(([key, value]) => `${key}=${value};`)
|
|
32
|
+
.join(' ');
|
|
33
|
+
}
|
|
34
|
+
htmlHeaders(extra = {}) {
|
|
35
|
+
return {
|
|
36
|
+
cookie: this.cookieHeader(),
|
|
37
|
+
'user-agent': 'Mozilla/5.0',
|
|
38
|
+
accept: 'text/html,application/xhtml+xml',
|
|
39
|
+
...extra
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
jsonHeaders(extra = {}) {
|
|
43
|
+
return {
|
|
44
|
+
cookie: this.cookieHeader(),
|
|
45
|
+
'user-agent': 'Mozilla/5.0',
|
|
46
|
+
accept: 'application/json',
|
|
47
|
+
'content-type': 'application/json',
|
|
48
|
+
'x-csrf-token': this.ctoken,
|
|
49
|
+
'x-requested-with': 'XMLHttpRequest',
|
|
50
|
+
...extra
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async inspect(url) {
|
|
54
|
+
const appData = await this.fetchAppData(url);
|
|
55
|
+
return summarizeAppData(appData);
|
|
56
|
+
}
|
|
57
|
+
async getBookInfo(url) {
|
|
58
|
+
const appData = await this.fetchAppData(url);
|
|
59
|
+
if (!appData.book?.id)
|
|
60
|
+
throw new Error('No found book id');
|
|
61
|
+
return {
|
|
62
|
+
bookId: appData.book.id,
|
|
63
|
+
bookSlug: appData.book.slug,
|
|
64
|
+
tocList: appData.book.toc || [],
|
|
65
|
+
bookName: appData.book.name || '',
|
|
66
|
+
bookDesc: appData.book.description || '',
|
|
67
|
+
host: appData.space?.host || DEFAULT_HOST,
|
|
68
|
+
imageServiceDomains: appData.imageServiceDomains || []
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async getDocInfoFromUrl(url) {
|
|
72
|
+
const appData = await this.fetchAppData(url);
|
|
73
|
+
if (!appData.doc)
|
|
74
|
+
throw new Error('Failed to get document info from URL');
|
|
75
|
+
return {
|
|
76
|
+
docId: appData.doc.id,
|
|
77
|
+
docSlug: appData.doc.slug,
|
|
78
|
+
docTitle: appData.doc.title || '',
|
|
79
|
+
bookId: appData.doc.book_id,
|
|
80
|
+
bookSlug: appData.book?.slug || '',
|
|
81
|
+
bookName: appData.book?.name || '',
|
|
82
|
+
host: appData.space?.host || DEFAULT_HOST,
|
|
83
|
+
imageServiceDomains: appData.imageServiceDomains || []
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async getDocMarkdownData(params) {
|
|
87
|
+
const { articleUrl, bookId, host = DEFAULT_HOST, isMarkdown = true } = params;
|
|
88
|
+
const queryParams = {
|
|
89
|
+
book_id: String(bookId),
|
|
90
|
+
merge_dynamic_data: String(false)
|
|
91
|
+
};
|
|
92
|
+
if (isMarkdown)
|
|
93
|
+
queryParams.mode = 'markdown';
|
|
94
|
+
const query = new URLSearchParams(queryParams).toString();
|
|
95
|
+
const apiUrl = `${host}/api/docs/${articleUrl}?${query}`;
|
|
96
|
+
const res = await fetch(apiUrl, {
|
|
97
|
+
headers: this.jsonHeaders()
|
|
98
|
+
});
|
|
99
|
+
const text = await res.text();
|
|
100
|
+
return {
|
|
101
|
+
apiUrl,
|
|
102
|
+
httpStatus: res.status,
|
|
103
|
+
response: text ? JSON.parse(text) : undefined
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async getVideoInfo(videoId) {
|
|
107
|
+
const apiUrl = `${this.host}/api/video?${new URLSearchParams({ video_id: videoId }).toString()}`;
|
|
108
|
+
const res = await fetch(apiUrl, {
|
|
109
|
+
headers: this.jsonHeaders()
|
|
110
|
+
});
|
|
111
|
+
if (!res.ok)
|
|
112
|
+
return false;
|
|
113
|
+
const data = await res.json();
|
|
114
|
+
if (data?.data?.status === 'success')
|
|
115
|
+
return data.data.info || false;
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
async createBook(params) {
|
|
119
|
+
const referer = `${this.host}/dashboard`;
|
|
120
|
+
const payload = {
|
|
121
|
+
name: params.name,
|
|
122
|
+
slug: params.slug,
|
|
123
|
+
description: params.description || '',
|
|
124
|
+
public: params.public ? 1 : 0,
|
|
125
|
+
type: 'Book',
|
|
126
|
+
repo_type: 'Book'
|
|
127
|
+
};
|
|
128
|
+
const created = await this.requestJson('POST', '/api/books', payload, referer);
|
|
129
|
+
const book = created.data || created;
|
|
130
|
+
const url = book.user?.login && book.slug ? `${this.host}/${book.user.login}/${book.slug}` : `${this.host}/${book.slug || ''}`;
|
|
131
|
+
const report = await writeReport('create-book', {
|
|
132
|
+
ok: true,
|
|
133
|
+
book: {
|
|
134
|
+
id: book.id,
|
|
135
|
+
slug: book.slug,
|
|
136
|
+
name: book.name,
|
|
137
|
+
public: book.public
|
|
138
|
+
},
|
|
139
|
+
url
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
book_id: book.id,
|
|
144
|
+
slug: book.slug,
|
|
145
|
+
name: book.name,
|
|
146
|
+
public: book.public,
|
|
147
|
+
url,
|
|
148
|
+
report
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
async createMarkdownDoc(bookUrl, params) {
|
|
152
|
+
const appData = await this.fetchAppData(bookUrl);
|
|
153
|
+
const book = appData.book;
|
|
154
|
+
if (!book?.id)
|
|
155
|
+
throw new Error('URL is not a readable Yuque book page.');
|
|
156
|
+
if (!book.abilities?.create_doc)
|
|
157
|
+
throw new Error(`Current account cannot create docs in book "${book.name || book.slug || book.id}".`);
|
|
158
|
+
const payload = {
|
|
159
|
+
book_id: book.id,
|
|
160
|
+
title: params.title,
|
|
161
|
+
format: 'markdown',
|
|
162
|
+
body: params.body,
|
|
163
|
+
body_draft: params.body,
|
|
164
|
+
public: 0
|
|
165
|
+
};
|
|
166
|
+
if (params.slug)
|
|
167
|
+
payload.slug = params.slug;
|
|
168
|
+
if (params.attachToToc !== false) {
|
|
169
|
+
payload.insert_to_catalog = true;
|
|
170
|
+
payload.action = params.tocAction || 'appendByDocs';
|
|
171
|
+
payload.target_uuid = params.targetUuid || '';
|
|
172
|
+
}
|
|
173
|
+
const created = await this.requestJson('POST', '/api/docs', payload, bookUrl);
|
|
174
|
+
const doc = created.data || created;
|
|
175
|
+
const toc = Array.isArray(created.toc) ? created.toc : [];
|
|
176
|
+
const tocAttached = toc.some((item) => Number(item.doc_id || item.id) === Number(doc.id));
|
|
177
|
+
const docUrl = buildDocUrl(this.host, appData, book, doc);
|
|
178
|
+
const report = await writeReport('create-doc', {
|
|
179
|
+
ok: true,
|
|
180
|
+
book: {
|
|
181
|
+
id: book.id,
|
|
182
|
+
slug: book.slug,
|
|
183
|
+
name: book.name
|
|
184
|
+
},
|
|
185
|
+
doc: {
|
|
186
|
+
id: doc.id,
|
|
187
|
+
slug: doc.slug,
|
|
188
|
+
title: doc.title,
|
|
189
|
+
format: doc.format
|
|
190
|
+
},
|
|
191
|
+
url: docUrl,
|
|
192
|
+
toc_attached: tocAttached,
|
|
193
|
+
toc
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
ok: true,
|
|
197
|
+
book_id: book.id,
|
|
198
|
+
doc_id: doc.id,
|
|
199
|
+
slug: doc.slug,
|
|
200
|
+
title: doc.title,
|
|
201
|
+
format: doc.format,
|
|
202
|
+
url: docUrl,
|
|
203
|
+
toc_attached: tocAttached,
|
|
204
|
+
toc,
|
|
205
|
+
report
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
async uploadAttach(refererUrl, filePath) {
|
|
209
|
+
const appData = await this.fetchAppData(refererUrl);
|
|
210
|
+
const me = appData.me;
|
|
211
|
+
if (!me?.id)
|
|
212
|
+
throw new Error('Failed to resolve current Yuque user id for upload.');
|
|
213
|
+
const absolutePath = path.resolve(filePath);
|
|
214
|
+
const info = await stat(absolutePath);
|
|
215
|
+
if (!info.isFile())
|
|
216
|
+
throw new Error(`Upload path is not a file: ${filePath}`);
|
|
217
|
+
const fileName = basename(absolutePath);
|
|
218
|
+
const contentType = guessContentType(fileName);
|
|
219
|
+
const blob = await openAsBlob(absolutePath, { type: contentType });
|
|
220
|
+
const form = new FormData();
|
|
221
|
+
form.append('file', new File([blob], fileName, { type: contentType }));
|
|
222
|
+
const query = new URLSearchParams({
|
|
223
|
+
attachable_type: 'User',
|
|
224
|
+
attachable_id: String(me.id),
|
|
225
|
+
type: contentType.startsWith('image/') ? 'image' : 'attachment',
|
|
226
|
+
ctoken: this.ctoken
|
|
227
|
+
}).toString();
|
|
228
|
+
const endpoint = `/api/upload/attach?${query}`;
|
|
229
|
+
const safeEndpoint = `/api/upload/attach?${new URLSearchParams({
|
|
230
|
+
attachable_type: 'User',
|
|
231
|
+
attachable_id: String(me.id),
|
|
232
|
+
type: contentType.startsWith('image/') ? 'image' : 'attachment',
|
|
233
|
+
ctoken: '<redacted>'
|
|
234
|
+
}).toString()}`;
|
|
235
|
+
const res = await fetch(`${this.host}${endpoint}`, {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: {
|
|
238
|
+
cookie: this.cookieHeader(),
|
|
239
|
+
'user-agent': 'Mozilla/5.0',
|
|
240
|
+
accept: 'application/json',
|
|
241
|
+
'x-csrf-token': this.ctoken,
|
|
242
|
+
'x-requested-with': 'XMLHttpRequest',
|
|
243
|
+
referer: refererUrl
|
|
244
|
+
},
|
|
245
|
+
body: form
|
|
246
|
+
});
|
|
247
|
+
const text = await res.text();
|
|
248
|
+
const response = text ? JSON.parse(text) : {};
|
|
249
|
+
const upload = sanitizeUploadResponse(response.data || response);
|
|
250
|
+
const report = await writeReport('upload-attach', {
|
|
251
|
+
ok: res.ok,
|
|
252
|
+
url: refererUrl,
|
|
253
|
+
endpoint: safeEndpoint,
|
|
254
|
+
file: {
|
|
255
|
+
name: fileName,
|
|
256
|
+
size: info.size,
|
|
257
|
+
type: contentType
|
|
258
|
+
},
|
|
259
|
+
upload
|
|
260
|
+
});
|
|
261
|
+
if (!res.ok)
|
|
262
|
+
throw new Error(`POST ${safeEndpoint} failed: ${res.status} ${text.slice(0, 500)}. Report: ${report}`);
|
|
263
|
+
return {
|
|
264
|
+
ok: true,
|
|
265
|
+
file: {
|
|
266
|
+
name: fileName,
|
|
267
|
+
size: info.size,
|
|
268
|
+
type: contentType
|
|
269
|
+
},
|
|
270
|
+
upload,
|
|
271
|
+
report
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
async fetchAppData(url) {
|
|
275
|
+
const res = await fetch(url, { headers: this.htmlHeaders() });
|
|
276
|
+
const html = await res.text();
|
|
277
|
+
const match = /decodeURIComponent\("(.+)"\)\);/m.exec(html);
|
|
278
|
+
if (!match)
|
|
279
|
+
throw new Error(`Failed to parse appData from ${url}; HTTP ${res.status}`);
|
|
280
|
+
return JSON.parse(decodeURIComponent(match[1]));
|
|
281
|
+
}
|
|
282
|
+
async snapshotDoc(url) {
|
|
283
|
+
const appData = await this.fetchAppData(url);
|
|
284
|
+
const doc = appData.doc;
|
|
285
|
+
const book = appData.book;
|
|
286
|
+
if (!doc?.id || !book?.id)
|
|
287
|
+
throw new Error('URL is not a readable Yuque document page.');
|
|
288
|
+
const edit = await this.getDocById(doc.id, book.id, 'edit', url);
|
|
289
|
+
const markdown = await this.getDocById(doc.id, book.id, 'markdown', url);
|
|
290
|
+
const lake = await this.getDocById(doc.id, book.id, 'lake', url);
|
|
291
|
+
return {
|
|
292
|
+
captured_at: new Date().toISOString(),
|
|
293
|
+
url,
|
|
294
|
+
book: {
|
|
295
|
+
id: book.id,
|
|
296
|
+
slug: book.slug,
|
|
297
|
+
name: book.name
|
|
298
|
+
},
|
|
299
|
+
doc: {
|
|
300
|
+
id: doc.id,
|
|
301
|
+
slug: doc.slug,
|
|
302
|
+
title: doc.title,
|
|
303
|
+
format: doc.format
|
|
304
|
+
},
|
|
305
|
+
edit: pickDocFields(edit.data),
|
|
306
|
+
markdown: pickDocFields(markdown.data),
|
|
307
|
+
lake: pickDocFields(lake.data)
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
async getDocById(docId, bookId, mode, referer) {
|
|
311
|
+
const endpoint = `${this.host}/api/docs/${docId}?book_id=${bookId}&mode=${mode}`;
|
|
312
|
+
const res = await fetch(endpoint, {
|
|
313
|
+
headers: this.jsonHeaders({ referer })
|
|
314
|
+
});
|
|
315
|
+
const text = await res.text();
|
|
316
|
+
if (!res.ok)
|
|
317
|
+
throw new Error(`GET ${endpoint} failed: ${res.status} ${text.slice(0, 500)}`);
|
|
318
|
+
return JSON.parse(text);
|
|
319
|
+
}
|
|
320
|
+
async updateLakeDoc(url, bodyAsl) {
|
|
321
|
+
return this.applyLakeDoc(url, bodyAsl, { dryRun: false, command: 'update-lake' });
|
|
322
|
+
}
|
|
323
|
+
async planLakeApply(url, bodyAsl) {
|
|
324
|
+
const snapshot = await this.snapshotDoc(url);
|
|
325
|
+
const validation = validateLakeApply(snapshot, bodyAsl);
|
|
326
|
+
return buildApplyPlan(snapshot, bodyAsl, validation);
|
|
327
|
+
}
|
|
328
|
+
async applyLakeDoc(url, bodyAsl, { dryRun = false, command = 'apply-lake' } = {}) {
|
|
329
|
+
const snapshot = await this.snapshotDoc(url);
|
|
330
|
+
const validation = validateLakeApply(snapshot, bodyAsl);
|
|
331
|
+
if (!validation.ok) {
|
|
332
|
+
const report = await writeReport(command, {
|
|
333
|
+
ok: false,
|
|
334
|
+
dry_run: dryRun,
|
|
335
|
+
url,
|
|
336
|
+
validation
|
|
337
|
+
});
|
|
338
|
+
throw new Error(`Refusing to apply Lake document: ${validation.errors.join('; ')}. Report: ${report}`);
|
|
339
|
+
}
|
|
340
|
+
const plan = buildApplyPlan(snapshot, bodyAsl, validation);
|
|
341
|
+
if (dryRun) {
|
|
342
|
+
const report = await writeReport(command, {
|
|
343
|
+
ok: true,
|
|
344
|
+
dry_run: true,
|
|
345
|
+
...plan
|
|
346
|
+
});
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
dry_run: true,
|
|
350
|
+
report,
|
|
351
|
+
plan
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const backupPath = await writeSnapshotBackup(snapshot);
|
|
355
|
+
const { id } = snapshot.doc;
|
|
356
|
+
await this.requestJson('PUT', `/api/docs/${id}/content`, {
|
|
357
|
+
id,
|
|
358
|
+
body_asl: bodyAsl,
|
|
359
|
+
body_draft_asl: bodyAsl,
|
|
360
|
+
format: 'lake',
|
|
361
|
+
save_type: 'user',
|
|
362
|
+
draft_version: snapshot.edit.draft_version
|
|
363
|
+
}, url);
|
|
364
|
+
await this.requestJson('PUT', `/api/docs/${id}/publish`, { id }, url);
|
|
365
|
+
const report = await writeReport(command, {
|
|
366
|
+
ok: true,
|
|
367
|
+
dry_run: false,
|
|
368
|
+
backup: backupPath,
|
|
369
|
+
...plan
|
|
370
|
+
});
|
|
371
|
+
return {
|
|
372
|
+
ok: true,
|
|
373
|
+
dry_run: false,
|
|
374
|
+
doc_id: id,
|
|
375
|
+
book_id: snapshot.book.id,
|
|
376
|
+
backup: backupPath,
|
|
377
|
+
report
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
async requestJson(method, pathName, body, referer) {
|
|
381
|
+
const res = await fetch(`${this.host}${pathName}`, {
|
|
382
|
+
method,
|
|
383
|
+
headers: this.jsonHeaders({ referer }),
|
|
384
|
+
body: body ? JSON.stringify(body) : undefined
|
|
385
|
+
});
|
|
386
|
+
const text = await res.text();
|
|
387
|
+
if (!res.ok)
|
|
388
|
+
throw new Error(`${method} ${pathName} failed: ${res.status} ${text.slice(0, 500)}`);
|
|
389
|
+
return text ? JSON.parse(text) : {};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function validateLakeApply(snapshot, bodyAsl) {
|
|
393
|
+
const errors = [];
|
|
394
|
+
const warnings = [];
|
|
395
|
+
const current = snapshot.edit?.body_asl || '';
|
|
396
|
+
if (snapshot.doc?.format !== 'lake')
|
|
397
|
+
errors.push(`document format is "${snapshot.doc?.format}", expected "lake"`);
|
|
398
|
+
if (snapshot.edit?.draft_version === undefined || snapshot.edit?.draft_version === null)
|
|
399
|
+
errors.push('missing draft_version');
|
|
400
|
+
if (!bodyAsl?.trim())
|
|
401
|
+
errors.push('lake file is empty');
|
|
402
|
+
if (bodyAsl && bodyAsl.length < 20)
|
|
403
|
+
errors.push('lake file is suspiciously short');
|
|
404
|
+
if (current && bodyAsl) {
|
|
405
|
+
const ratio = bodyAsl.length / current.length;
|
|
406
|
+
if (ratio < 0.25 || ratio > 4)
|
|
407
|
+
warnings.push(`large body size change: ${current.length} -> ${bodyAsl.length}`);
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
ok: errors.length === 0,
|
|
411
|
+
errors,
|
|
412
|
+
warnings
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function buildApplyPlan(snapshot, bodyAsl, validation) {
|
|
416
|
+
const current = snapshot.edit?.body_asl || '';
|
|
417
|
+
return {
|
|
418
|
+
captured_at: new Date().toISOString(),
|
|
419
|
+
url: snapshot.url,
|
|
420
|
+
doc: snapshot.doc,
|
|
421
|
+
book: snapshot.book,
|
|
422
|
+
validation,
|
|
423
|
+
stats: {
|
|
424
|
+
current_lake_length: current.length,
|
|
425
|
+
next_lake_length: bodyAsl.length,
|
|
426
|
+
length_delta: bodyAsl.length - current.length,
|
|
427
|
+
changed: current !== bodyAsl
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
async function writeSnapshotBackup(snapshot) {
|
|
432
|
+
const backupDir = path.resolve('backups');
|
|
433
|
+
await mkdir(backupDir, { recursive: true });
|
|
434
|
+
const backupPath = path.join(backupDir, `${snapshot.doc.id}-${timestampForFile()}.snapshot.json`);
|
|
435
|
+
await writeFile(backupPath, `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
436
|
+
return backupPath;
|
|
437
|
+
}
|
|
438
|
+
function summarizeAppData(appData) {
|
|
439
|
+
return {
|
|
440
|
+
book: appData.book && {
|
|
441
|
+
id: appData.book.id,
|
|
442
|
+
slug: appData.book.slug,
|
|
443
|
+
name: appData.book.name,
|
|
444
|
+
toc_count: appData.book.toc?.length,
|
|
445
|
+
abilities: appData.book.abilities
|
|
446
|
+
},
|
|
447
|
+
doc: appData.doc && {
|
|
448
|
+
id: appData.doc.id,
|
|
449
|
+
slug: appData.doc.slug,
|
|
450
|
+
title: appData.doc.title,
|
|
451
|
+
book_id: appData.doc.book_id,
|
|
452
|
+
format: appData.doc.format
|
|
453
|
+
},
|
|
454
|
+
me: appData.me && {
|
|
455
|
+
id: appData.me.id,
|
|
456
|
+
login: appData.me.login,
|
|
457
|
+
name: appData.me.name
|
|
458
|
+
},
|
|
459
|
+
space: appData.space && {
|
|
460
|
+
host: appData.space.host,
|
|
461
|
+
login: appData.space.login
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function pickDocFields(doc = {}) {
|
|
466
|
+
return {
|
|
467
|
+
id: doc.id,
|
|
468
|
+
slug: doc.slug,
|
|
469
|
+
title: doc.title,
|
|
470
|
+
format: doc.format,
|
|
471
|
+
draft_version: doc.draft_version,
|
|
472
|
+
content_updated_at: doc.content_updated_at,
|
|
473
|
+
updated_at: doc.updated_at,
|
|
474
|
+
published_at: doc.published_at,
|
|
475
|
+
body: doc.body,
|
|
476
|
+
body_draft: doc.body_draft,
|
|
477
|
+
body_asl: doc.body_asl,
|
|
478
|
+
body_draft_asl: doc.body_draft_asl,
|
|
479
|
+
sourcecode: doc.sourcecode,
|
|
480
|
+
serializer: doc.serializer
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function buildDocUrl(host, appData, book, doc) {
|
|
484
|
+
const spaceLogin = appData.space?.login || appData.me?.login;
|
|
485
|
+
const bookSlug = book.slug;
|
|
486
|
+
const docSlug = doc.slug || doc.id;
|
|
487
|
+
if (spaceLogin && bookSlug && docSlug)
|
|
488
|
+
return `${host}/${spaceLogin}/${bookSlug}/${docSlug}`;
|
|
489
|
+
if (bookSlug && docSlug)
|
|
490
|
+
return `${host}/${bookSlug}/${docSlug}`;
|
|
491
|
+
return `${host}/api/docs/${doc.id || ''}`;
|
|
492
|
+
}
|
|
493
|
+
function guessContentType(fileName) {
|
|
494
|
+
const ext = extname(fileName).toLowerCase();
|
|
495
|
+
const types = {
|
|
496
|
+
'.png': 'image/png',
|
|
497
|
+
'.jpg': 'image/jpeg',
|
|
498
|
+
'.jpeg': 'image/jpeg',
|
|
499
|
+
'.webp': 'image/webp',
|
|
500
|
+
'.gif': 'image/gif',
|
|
501
|
+
'.svg': 'image/svg+xml',
|
|
502
|
+
'.pdf': 'application/pdf',
|
|
503
|
+
'.zip': 'application/zip',
|
|
504
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
505
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
506
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
507
|
+
};
|
|
508
|
+
return types[ext] || 'application/octet-stream';
|
|
509
|
+
}
|
|
510
|
+
function sanitizeUploadResponse(upload) {
|
|
511
|
+
const attachment = upload.attachment && typeof upload.attachment === 'object'
|
|
512
|
+
? {
|
|
513
|
+
id: upload.attachment.id,
|
|
514
|
+
filename: upload.attachment.filename,
|
|
515
|
+
ext: upload.attachment.ext,
|
|
516
|
+
filesize: upload.attachment.filesize,
|
|
517
|
+
filekey: upload.attachment.filekey,
|
|
518
|
+
filemd5: upload.attachment.filemd5,
|
|
519
|
+
mode: upload.attachment.mode,
|
|
520
|
+
attachable_type: upload.attachment.attachable_type,
|
|
521
|
+
attachable_id: upload.attachment.attachable_id,
|
|
522
|
+
user_id: upload.attachment.user_id,
|
|
523
|
+
created_at: upload.attachment.created_at,
|
|
524
|
+
updated_at: upload.attachment.updated_at
|
|
525
|
+
}
|
|
526
|
+
: undefined;
|
|
527
|
+
return {
|
|
528
|
+
filekey: upload.filekey,
|
|
529
|
+
extname: upload.extname,
|
|
530
|
+
mode: upload.mode,
|
|
531
|
+
url: upload.url,
|
|
532
|
+
etag: upload.etag,
|
|
533
|
+
size: upload.size,
|
|
534
|
+
filename: upload.filename,
|
|
535
|
+
filemd5: upload.filemd5,
|
|
536
|
+
element_id: upload.element_id,
|
|
537
|
+
attachment_id: upload.attachment_id,
|
|
538
|
+
attachable_type: upload.attachable_type,
|
|
539
|
+
attachable_id: upload.attachable_id,
|
|
540
|
+
...(upload.symlink === undefined ? {} : { symlink: upload.symlink }),
|
|
541
|
+
...(upload.isCopy === undefined ? {} : { isCopy: upload.isCopy }),
|
|
542
|
+
...(attachment ? { attachment } : {})
|
|
543
|
+
};
|
|
544
|
+
}
|