@vdfor/confluence-sync 0.0.1

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/License ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License
2
+
3
+ Copyright © 2017-present vdfor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@vdfor/confluence-sync",
3
+ "version": "0.0.1",
4
+ "author": "vdfor",
5
+ "license": "MIT",
6
+ "private": false,
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/vdfor/confluence-sync.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/vdfor/confluence-sync/issues"
13
+ },
14
+ "homepage": "https://github.com/vdfor/confluence-sync#readme",
15
+ "description": "Confluence 文档同步工具(Markdown ↔ Confluence)",
16
+ "type": "module",
17
+ "main": "src/confluence.mjs",
18
+ "bin": {
19
+ "confluence-sync": "./upload.mjs"
20
+ },
21
+ "files": [
22
+ "upload.mjs",
23
+ "src/"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "dependencies": {
29
+ "axios": "^1.7.0",
30
+ "form-data": "^4.0.0",
31
+ "marked": "^15.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "rimraf": "^6.1.3"
35
+ },
36
+ "scripts": {
37
+ "upload": "node upload.mjs upload",
38
+ "update": "node upload.mjs update",
39
+ "help": "node upload.mjs help",
40
+ "release:beta": "read -p '请输入版本号 (例如: patch, minor, major 或具体版本号): ' v && pnpm version $v && pnpm publish --registry https://registry.npmjs.org/ --access public --tag beta --no-git-checks",
41
+ "release": "read -p '请输入版本号 (例如: patch, minor, major 或具体版本号): ' v && pnpm version $v && pnpm publish --registry https://registry.npmjs.org/ --access public --no-git-checks",
42
+ "v": "read -p '请输入版本号 (例如: patch, minor, major 或具体版本号): ' v && pnpm version $v",
43
+ "clean": "rimraf dist node_modules"
44
+ }
45
+ }
@@ -0,0 +1,445 @@
1
+ import axios from 'axios';
2
+ import { marked } from 'marked';
3
+ import { readFileSync } from 'node:fs';
4
+ import { extname } from 'node:path';
5
+
6
+ // ── 配置 ──────────────────────────────────────────────────────────────────────
7
+ const config = JSON.parse(readFileSync(new URL('../config.json', import.meta.url), 'utf-8'));
8
+ const { spaceKey: defaultSpaceKey } = config;
9
+
10
+ let baseUrl = process.env.CONFLUENCE_BASE_URL;
11
+ let pat = process.env.CONFLUENCE_PAT;
12
+ let username = process.env.CONFLUENCE_USERNAME;
13
+ let apiToken = process.env.CONFLUENCE_API_TOKEN;
14
+ let api;
15
+
16
+ /**
17
+ * 用 CLI 参数覆盖环境变量配置,需在其他函数调用前执行
18
+ * @param {object} [opts]
19
+ * @param {string} [opts.baseUrl]
20
+ * @param {string} [opts.pat]
21
+ * @param {string} [opts.username]
22
+ * @param {string} [opts.apiToken]
23
+ */
24
+ export function configure(opts = {}) {
25
+ if (opts.baseUrl) baseUrl = opts.baseUrl;
26
+ if (opts.pat) pat = opts.pat;
27
+ if (opts.username) username = opts.username;
28
+ if (opts.apiToken) apiToken = opts.apiToken;
29
+ }
30
+
31
+ function ensureApi() {
32
+ if (api) return;
33
+
34
+ if (!baseUrl) {
35
+ console.error('请设置环境变量 CONFLUENCE_BASE_URL 或使用 --base-url 参数');
36
+ console.error(' export CONFLUENCE_BASE_URL="https://your-confluence-instance.com"');
37
+ process.exit(1);
38
+ }
39
+
40
+ if (!pat && (!username || !apiToken)) {
41
+ console.error('请设置认证信息(二选一):');
42
+ console.error(' 方式1 PAT: export CONFLUENCE_PAT="your-personal-access-token"');
43
+ console.error(' 方式2 Basic Auth: export CONFLUENCE_USERNAME="your-username" CONFLUENCE_API_TOKEN="your-token"');
44
+ process.exit(1);
45
+ }
46
+
47
+ const authHeaders = pat ? { Authorization: `Bearer ${pat}` } : {};
48
+ api = axios.create({
49
+ baseURL: `${baseUrl}/rest/api`,
50
+ ...(pat ? {} : { auth: { username, password: apiToken } }),
51
+ headers: { 'Content-Type': 'application/json', ...authHeaders },
52
+ timeout: 30000,
53
+ });
54
+ }
55
+
56
+ function logAxiosError(err) {
57
+ const status = err.response?.status;
58
+ const data = err.response?.data;
59
+ if (status) console.error(` HTTP ${status}:`, typeof data === 'string' ? data : JSON.stringify(data, null, 2));
60
+ }
61
+
62
+ // ── 页面查询 ──────────────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * 按 ID 获取页面元信息(含版本号)
66
+ */
67
+ export async function getPageById(id) {
68
+ ensureApi();
69
+ const { data } = await api.get(`/content/${id}`, {
70
+ params: { expand: 'version,ancestors' },
71
+ });
72
+ return data;
73
+ }
74
+
75
+ /**
76
+ * 按 spaceKey + title 获取页面(仅返回第一个匹配)
77
+ */
78
+ export async function getPageByTitle(spaceKey, title) {
79
+ ensureApi();
80
+ const { data } = await api.get('/content', {
81
+ params: {
82
+ spaceKey,
83
+ title,
84
+ expand: 'version',
85
+ limit: 1,
86
+ },
87
+ });
88
+ return data.results?.[0] || null;
89
+ }
90
+
91
+ /**
92
+ * 列出 space 下所有页面(分页)
93
+ */
94
+ export async function listPages(spaceKey, limit = 25) {
95
+ ensureApi();
96
+ const { data } = await api.get('/content', {
97
+ params: {
98
+ spaceKey,
99
+ type: 'page',
100
+ expand: 'version',
101
+ limit,
102
+ },
103
+ });
104
+ return data.results;
105
+ }
106
+
107
+ /**
108
+ * 获取页面 body.storage
109
+ */
110
+ export async function getPageContent(id) {
111
+ ensureApi();
112
+ const { data } = await api.get(`/content/${id}`, {
113
+ params: { expand: 'body.storage' },
114
+ });
115
+ return data;
116
+ }
117
+
118
+ // ── 页面创建 / 更新 ───────────────────────────────────────────────────────────
119
+
120
+ /**
121
+ * 创建新页面
122
+ */
123
+ export async function createPage(spaceKey, title, bodyHtml, parentId) {
124
+ ensureApi();
125
+ const payload = {
126
+ type: 'page',
127
+ title,
128
+ space: { key: spaceKey },
129
+ body: { storage: { value: bodyHtml, representation: 'storage' } },
130
+ };
131
+ if (parentId) {
132
+ payload.ancestors = [{ id: parentId }];
133
+ }
134
+ try {
135
+ const { data } = await api.post('/content', payload);
136
+ return data;
137
+ } catch (err) {
138
+ console.error('创建页面失败:');
139
+ logAxiosError(err);
140
+ throw err;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * 更新已有页面(需要传入当前版本号,自动 +1)
146
+ */
147
+ export async function updatePage(id, title, bodyHtml, currentVersion, minorEdit = false) {
148
+ ensureApi();
149
+ const payload = {
150
+ id,
151
+ type: 'page',
152
+ title,
153
+ body: { storage: { value: bodyHtml, representation: 'storage' } },
154
+ version: { number: currentVersion + 1, minorEdit },
155
+ };
156
+ try {
157
+ const { data } = await api.put(`/content/${id}`, payload);
158
+ return data;
159
+ } catch (err) {
160
+ console.error('更新页面失败:');
161
+ logAxiosError(err);
162
+ throw err;
163
+ }
164
+ }
165
+
166
+ // ── Markdown → Confluence Storage Format ───────────────────────────────────────
167
+
168
+ /**
169
+ * 将 Markdown 文本转换为 Confluence storage format(XHTML)
170
+ * 使用 marked 词法分析器遍历 token 树,支持嵌套列表
171
+ */
172
+ export function mdToConfluence(md) {
173
+ const tokens = marked.lexer(md);
174
+
175
+ function renderTokens(toks, listDepth = 0) {
176
+ let out = '';
177
+ for (const tok of toks) {
178
+ switch (tok.type) {
179
+ case 'heading':
180
+ out += `<h${tok.depth}>${renderInline(tok.tokens)}</h${tok.depth}>`;
181
+ break;
182
+ case 'paragraph':
183
+ out += `<p>${renderInline(tok.tokens)}</p>`;
184
+ break;
185
+ case 'code':
186
+ out += `<ac:structured-macro ac:name="code"><ac:parameter ac:name="language">${tok.lang || 'text'}</ac:parameter><ac:plain-text-body><![CDATA[${tok.text}]]></ac:plain-text-body></ac:structured-macro>`;
187
+ break;
188
+ case 'blockquote':
189
+ out += `<blockquote>${renderTokens(tok.tokens, listDepth)}</blockquote>`;
190
+ break;
191
+ case 'list': {
192
+ const tag = tok.ordered ? 'ol' : 'ul';
193
+ out += `<${tag}>`;
194
+ for (const item of tok.items) {
195
+ out += `<li>${renderTokens(item.tokens, listDepth + 1)}</li>`;
196
+ }
197
+ out += `</${tag}>`;
198
+ break;
199
+ }
200
+ case 'table': {
201
+ out += '<table>';
202
+ out += '<thead><tr>';
203
+ for (const cell of tok.header) {
204
+ const align = cell.align ? ` style="text-align:${cell.align}"` : '';
205
+ out += `<th${align}>${renderInline(cell.tokens)}</th>`;
206
+ }
207
+ out += '</tr></thead><tbody>';
208
+ for (const row of tok.rows) {
209
+ out += '<tr>';
210
+ for (const cell of row) {
211
+ const align = cell.align ? ` style="text-align:${cell.align}"` : '';
212
+ out += `<td${align}>${renderInline(cell.tokens)}</td>`;
213
+ }
214
+ out += '</tr>';
215
+ }
216
+ out += '</tbody></table>';
217
+ break;
218
+ }
219
+ case 'hr':
220
+ out += '<hr/>';
221
+ break;
222
+ case 'space':
223
+ break;
224
+ default:
225
+ if (tok.tokens) out += renderTokens(tok.tokens, listDepth);
226
+ else if (tok.text) out += tok.text;
227
+ }
228
+ }
229
+ return out;
230
+ }
231
+
232
+ function renderInline(toks) {
233
+ if (!toks) return '';
234
+ let out = '';
235
+ for (const tok of toks) {
236
+ switch (tok.type) {
237
+ case 'text':
238
+ out += tok.tokens ? renderInline(tok.tokens) : tok.text;
239
+ break;
240
+ case 'strong':
241
+ out += `<strong>${renderInline(tok.tokens)}</strong>`;
242
+ break;
243
+ case 'em':
244
+ out += `<em>${renderInline(tok.tokens)}</em>`;
245
+ break;
246
+ case 'codespan':
247
+ out += `<code>${tok.text}</code>`;
248
+ break;
249
+ case 'del':
250
+ out += `<s>${renderInline(tok.tokens)}</s>`;
251
+ break;
252
+ case 'link':
253
+ out += `<a href="${tok.href}">${renderInline(tok.tokens)}</a>`;
254
+ break;
255
+ case 'image':
256
+ out += `<ac:image><ri:attachment ri:filename="${tok.href}" /></ac:image>`;
257
+ break;
258
+ case 'br':
259
+ out += '<br/>';
260
+ break;
261
+ case 'escape':
262
+ out += tok.text;
263
+ break;
264
+ default:
265
+ out += tok.text || '';
266
+ }
267
+ }
268
+ return out;
269
+ }
270
+
271
+ return renderTokens(tokens);
272
+ }
273
+
274
+ // ── 文件上传(附件)────────────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * 上传文件为页面附件
278
+ * @param {string} pageId - 目标页面 ID
279
+ * @param {string} filePath - 本地文件路径
280
+ * @param {string} [fileName] - 可选覆盖文件名
281
+ */
282
+ export async function uploadAttachment(pageId, filePath, fileName) {
283
+ ensureApi();
284
+ const FormData = (await import('form-data')).default;
285
+ const fs = await import('node:fs');
286
+ const path = await import('node:path');
287
+
288
+ const name = fileName || path.basename(filePath);
289
+ const form = new FormData();
290
+ form.append('file', fs.createReadStream(filePath), name);
291
+
292
+ try {
293
+ const { data } = await api.post(`/content/${pageId}/child/attachment`, form, {
294
+ headers: {
295
+ ...form.getHeaders(),
296
+ 'X-Atlassian-Token': 'no-check',
297
+ },
298
+ maxContentLength: Infinity,
299
+ maxBodyLength: Infinity,
300
+ });
301
+ return data;
302
+ } catch (err) {
303
+ // 如果附件已存在,改为更新
304
+ if (err.response?.status === 400 || err.response?.status === 409) {
305
+ console.log(` 附件 ${name} 已存在,尝试更新…`);
306
+ return updateAttachment(pageId, filePath, name);
307
+ }
308
+ console.error('上传附件失败:');
309
+ logAxiosError(err);
310
+ throw err;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * 更新已有附件(按文件名匹配)
316
+ */
317
+ async function updateAttachment(pageId, filePath, fileName) {
318
+ ensureApi();
319
+ const FormData = (await import('form-data')).default;
320
+ const fs = await import('node:fs');
321
+ const { data: existing } = await api.get(`/content/${pageId}/child/attachment`, {
322
+ params: { filename: fileName },
323
+ });
324
+ const match = existing.results?.find(r => r.title === fileName);
325
+ if (!match) throw new Error(`未找到附件 ${fileName}`);
326
+ const form = new FormData();
327
+ form.append('file', fs.createReadStream(filePath), fileName);
328
+ const { data } = await api.post(
329
+ `/content/${pageId}/child/attachment/${match.id}/data`,
330
+ form,
331
+ {
332
+ headers: {
333
+ ...form.getHeaders(),
334
+ 'X-Atlassian-Token': 'no-check',
335
+ },
336
+ maxContentLength: Infinity,
337
+ maxBodyLength: Infinity,
338
+ },
339
+ );
340
+ return data;
341
+ }
342
+
343
+ /**
344
+ * 列出页面的所有附件
345
+ */
346
+ export async function listAttachments(pageId) {
347
+ ensureApi();
348
+ const { data } = await api.get(`/content/${pageId}/child/attachment`);
349
+ return data.results;
350
+ }
351
+
352
+ // ── 高级业务流程 ──────────────────────────────────────────────────────────────
353
+
354
+ /**
355
+ * 查找父页面 ID(支持按 title 或直接 ID)
356
+ */
357
+ async function resolveParentId(parentIdentifier, spaceKey) {
358
+ if (!parentIdentifier) return null;
359
+ // 如果是纯数字,当作 ID
360
+ if (/^\d+$/.test(parentIdentifier)) {
361
+ const page = await getPageById(parentIdentifier);
362
+ console.log(` 父页面:${page.title} (ID: ${page.id})`);
363
+ return page.id;
364
+ }
365
+ // 否则按 title 查找
366
+ const page = await getPageByTitle(spaceKey, parentIdentifier);
367
+ if (!page) {
368
+ throw new Error(`未找到父页面「${parentIdentifier}」(space: ${spaceKey})`);
369
+ }
370
+ console.log(` 父页面:${page.title} (ID: ${page.id})`);
371
+ return page.id;
372
+ }
373
+
374
+ /**
375
+ * 上传 Markdown 文件到 Confluence
376
+ * @param {object} opts
377
+ * @param {string} opts.filePath - 本地 .md 文件路径
378
+ * @param {string} [opts.title] - 页面标题(默认取文件名)
379
+ * @param {string} [opts.spaceKey] - space key
380
+ * @param {string} [opts.parentId] - 父页面 ID 或标题
381
+ * @param {boolean} [opts.dryRun] - 仅打印转换结果
382
+ */
383
+ export async function uploadMarkdown(opts) {
384
+ const { filePath, title: optTitle, spaceKey = defaultSpaceKey, parentId: parentRaw, dryRun = false } = opts;
385
+ const fs = await import('node:fs');
386
+ const path = await import('node:path');
387
+
388
+ const md = fs.readFileSync(filePath, 'utf-8');
389
+ const title = optTitle || path.basename(filePath, extname(filePath));
390
+ const bodyHtml = mdToConfluence(md);
391
+
392
+ if (dryRun) {
393
+ console.log('── 转换结果(dry-run)────────────────────────');
394
+ console.log(` 标题: ${title}`);
395
+ console.log(` Space: ${spaceKey}`);
396
+ console.log(' Body:\n' + bodyHtml);
397
+ return null;
398
+ }
399
+
400
+ // 查找父页面
401
+ const parentId = await resolveParentId(parentRaw, spaceKey);
402
+
403
+ // 检查是否已存在同名页面
404
+ const existing = await getPageByTitle(spaceKey, title);
405
+ if (existing) {
406
+ console.log(` 页面「${title}」已存在 (ID: ${existing.id}),将更新…`);
407
+ const updated = await updatePage(existing.id, title, bodyHtml, existing.version.number);
408
+ console.log(` ✅ 更新完成 → ${baseUrl}/pages/viewpage.action?pageId=${updated.id}`);
409
+ return updated;
410
+ }
411
+
412
+ const page = await createPage(spaceKey, title, bodyHtml, parentId);
413
+ console.log(` ✅ 创建完成 → ${baseUrl}/pages/viewpage.action?pageId=${page.id}`);
414
+ return page;
415
+ }
416
+
417
+ /**
418
+ * 更新指定页面(通过 ID 或 space+title 查找)
419
+ */
420
+ export async function updateMarkdown(opts) {
421
+ const { pageId, title: searchTitle, filePath, spaceKey = defaultSpaceKey, minorEdit = false } = opts;
422
+ const fs = await import('node:fs');
423
+
424
+ const md = fs.readFileSync(filePath, 'utf-8');
425
+ const bodyHtml = mdToConfluence(md);
426
+
427
+ let page;
428
+ if (pageId) {
429
+ page = await getPageById(pageId);
430
+ } else if (searchTitle) {
431
+ page = await getPageByTitle(spaceKey, searchTitle);
432
+ if (!page) throw new Error(`未找到页面「${searchTitle}」(space: ${spaceKey})`);
433
+ } else {
434
+ throw new Error('必须指定 --page-id 或 --title 来定位目标页面');
435
+ }
436
+
437
+ console.log(` 目标页面:${page.title} (ID: ${page.id}, 当前版本: ${page.version.number})`);
438
+ const updated = await updatePage(page.id, page.title, bodyHtml, page.version.number, minorEdit);
439
+ console.log(` ✅ 更新完成 → ${baseUrl}/pages/viewpage.action?pageId=${updated.id}`);
440
+ return updated;
441
+ }
442
+
443
+ // ── 导出配置 ──────────────────────────────────────────────────────────────────
444
+ export { defaultSpaceKey };
445
+ export function getBaseUrl() { return baseUrl; }
package/upload.mjs ADDED
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Confluence 文档同步 CLI
4
+ *
5
+ * 环境变量(或通过 CLI 参数配置):
6
+ * 必填: CONFLUENCE_BASE_URL - Confluence 服务地址
7
+ * 认证方式(二选一):
8
+ * 方式1 PAT: CONFLUENCE_PAT - Personal Access Token
9
+ * 方式2 Basic Auth: CONFLUENCE_USERNAME - Confluence 登录名(工号)
10
+ * CONFLUENCE_API_TOKEN - API Token 或密码
11
+ *
12
+ * 用法:
13
+ * confluence-sync upload --file <path> [--title <标题>] [--space <key>] [--parent <ID或标题>] [--dry-run]
14
+ * confluence-sync update --file <path> (--page-id <ID> | --title <标题>) [--space <key>] [--minor-edit]
15
+ * confluence-sync upload-file --page-id <ID> --file <path> [--name <文件名>]
16
+ * confluence-sync list-pages [--space <key>]
17
+ * confluence-sync list-files --page-id <ID>
18
+ * confluence-sync get-content --page-id <ID>
19
+ *
20
+ * 可选配置参数(优先于环境变量):
21
+ * --base-url Confluence 服务地址
22
+ * --pat Personal Access Token
23
+ * --username Confluence 登录名
24
+ * --api-token API Token 或密码
25
+ */
26
+
27
+ import {
28
+ uploadMarkdown,
29
+ updateMarkdown,
30
+ uploadAttachment,
31
+ listPages,
32
+ listAttachments,
33
+ getPageContent,
34
+ defaultSpaceKey,
35
+ getBaseUrl,
36
+ configure,
37
+ } from './src/confluence.mjs';
38
+
39
+ // ── 参数解析 ──────────────────────────────────────────────────────────────────
40
+
41
+ function parseArgs(argv) {
42
+ const args = argv.slice(2);
43
+ const command = args[0];
44
+ const opts = {};
45
+
46
+ for (let i = 1; i < args.length; i++) {
47
+ const arg = args[i];
48
+ if (arg.startsWith('--')) {
49
+ const key = arg.slice(2);
50
+ const next = args[i + 1];
51
+ if (!next || next.startsWith('--')) {
52
+ opts[key] = true;
53
+ } else {
54
+ opts[key] = next;
55
+ i++;
56
+ }
57
+ }
58
+ }
59
+ return { command, ...opts };
60
+ }
61
+
62
+ function printUsage() {
63
+ console.log(`
64
+ Confluence 文档同步工具
65
+
66
+ 用法:
67
+ confluence-sync <命令> [选项]
68
+
69
+ 命令:
70
+ upload 上传 Markdown 文件为新页面(已存在则更新)
71
+ update 更新已有页面内容
72
+ upload-file 上传文件作为页面附件
73
+ list-pages 列出 space 下所有页面
74
+ list-files 列出页面的所有附件
75
+ get-content 获取页面的 HTML 内容
76
+
77
+ upload 选项:
78
+ --file 本地 .md 文件路径(必填)
79
+ --title 页面标题(默认取文件名)
80
+ --space Space Key(默认读 config.json)
81
+ --parent 父页面 ID 或标题
82
+ --dry-run 仅打印转换结果,不实际上传
83
+
84
+ update 选项:
85
+ --file 本地 .md 文件路径(必填)
86
+ --page-id 目标页面 ID
87
+ --title 按标题查找目标页面(与 --page-id 二选一)
88
+ --space Space Key(默认读 config.json)
89
+ --minor-edit 标记为小修改(不通知关注者)
90
+
91
+ upload-file 选项:
92
+ --page-id 目标页面 ID(必填)
93
+ --file 本地文件路径(必填)
94
+ --name 覆盖文件名
95
+
96
+ list-pages 选项:
97
+ --space Space Key(默认读 config.json)
98
+
99
+ list-files 选项:
100
+ --page-id 页面 ID(必填)
101
+
102
+ get-content 选项:
103
+ --page-id 页面 ID(必填)
104
+
105
+ 配置参数(优先于环境变量):
106
+ --base-url Confluence 服务地址
107
+ --pat Personal Access Token
108
+ --username Confluence 登录名
109
+ --api-token API Token 或密码
110
+
111
+ 环境变量:
112
+ 必填:
113
+ CONFLUENCE_BASE_URL Confluence 服务地址
114
+
115
+ 认证方式(二选一):
116
+ 方式1 - Personal Access Token:
117
+ CONFLUENCE_PAT 个人访问令牌
118
+
119
+ 方式2 - Basic Auth:
120
+ CONFLUENCE_USERNAME Confluence 登录名(工号)
121
+ CONFLUENCE_API_TOKEN API Token 或密码
122
+
123
+ `);
124
+ }
125
+
126
+ // ── 命令处理 ──────────────────────────────────────────────────────────────────
127
+
128
+ async function main() {
129
+ const args = parseArgs(process.argv);
130
+ const { command } = args;
131
+
132
+ if (!command || command === 'help' || command === '--help') {
133
+ printUsage();
134
+ process.exit(0);
135
+ }
136
+
137
+ // 用 CLI 参数覆盖环境变量配置
138
+ configure({
139
+ baseUrl: args['base-url'],
140
+ pat: args['pat'],
141
+ username: args['username'],
142
+ apiToken: args['api-token'],
143
+ });
144
+ const baseUrl = getBaseUrl();
145
+
146
+ try {
147
+ switch (command) {
148
+ case 'upload': {
149
+ if (!args.file) {
150
+ console.error('错误:--file 必填');
151
+ process.exit(1);
152
+ }
153
+ const result = await uploadMarkdown({
154
+ filePath: args.file,
155
+ title: args.title,
156
+ spaceKey: args.space || defaultSpaceKey,
157
+ parentId: args.parent,
158
+ dryRun: args['dry-run'] === true,
159
+ });
160
+ if (result) {
161
+ console.log('\n页面信息:');
162
+ console.log(` ID: ${result.id}`);
163
+ console.log(` 标题: ${result.title}`);
164
+ console.log(` 版本: ${result.version?.number}`);
165
+ console.log(` 链接: ${baseUrl}/pages/viewpage.action?pageId=${result.id}`);
166
+ }
167
+ break;
168
+ }
169
+
170
+ case 'update': {
171
+ if (!args.file) {
172
+ console.error('错误:--file 必填');
173
+ process.exit(1);
174
+ }
175
+ if (!args['page-id'] && !args.title) {
176
+ console.error('错误:必须指定 --page-id 或 --title');
177
+ process.exit(1);
178
+ }
179
+ const result = await updateMarkdown({
180
+ pageId: args['page-id'],
181
+ title: args.title,
182
+ filePath: args.file,
183
+ spaceKey: args.space || defaultSpaceKey,
184
+ minorEdit: args['minor-edit'] === true,
185
+ });
186
+ console.log('\n页面信息:');
187
+ console.log(` ID: ${result.id}`);
188
+ console.log(` 标题: ${result.title}`);
189
+ console.log(` 版本: ${result.version?.number}`);
190
+ break;
191
+ }
192
+
193
+ case 'upload-file': {
194
+ if (!args['page-id'] || !args.file) {
195
+ console.error('错误:--page-id 和 --file 必填');
196
+ process.exit(1);
197
+ }
198
+ console.log(`正在上传附件到页面 ${args['page-id']}…`);
199
+ const result = await uploadAttachment(args['page-id'], args.file, args.name);
200
+ const attached = result.results?.[0];
201
+ console.log(` ✅ 上传完成`);
202
+ if (attached) {
203
+ console.log(` 文件名: ${attached.title}`);
204
+ console.log(` ID: ${attached.id}`);
205
+ console.log(` 链接: ${baseUrl}${attached._links?.download}`);
206
+ }
207
+ break;
208
+ }
209
+
210
+ case 'list-pages': {
211
+ const space = args.space || defaultSpaceKey;
212
+ console.log(`Space: ${space}\n`);
213
+ const pages = await listPages(space);
214
+ if (!pages.length) {
215
+ console.log(' (无页面)');
216
+ } else {
217
+ const maxId = Math.max(...pages.map(p => String(p.id).length));
218
+ for (const p of pages) {
219
+ const id = String(p.id).padEnd(maxId);
220
+ console.log(` ${id} v${p.version?.number ?? '?'} ${p.title}`);
221
+ }
222
+ console.log(`\n共 ${pages.length} 个页面`);
223
+ }
224
+ break;
225
+ }
226
+
227
+ case 'list-files': {
228
+ if (!args['page-id']) {
229
+ console.error('错误:--page-id 必填');
230
+ process.exit(1);
231
+ }
232
+ console.log(`页面 ${args['page-id']} 的附件:\n`);
233
+ const files = await listAttachments(args['page-id']);
234
+ if (!files.length) {
235
+ console.log(' (无附件)');
236
+ } else {
237
+ for (const f of files) {
238
+ const mediaType = f.metadata?.mediaType || 'unknown';
239
+ console.log(` ${f.title} (${mediaType}) ID: ${f.id}`);
240
+ }
241
+ console.log(`\n共 ${files.length} 个附件`);
242
+ }
243
+ break;
244
+ }
245
+
246
+ case 'get-content': {
247
+ if (!args['page-id']) {
248
+ console.error('错误:--page-id 必填');
249
+ process.exit(1);
250
+ }
251
+ const page = await getPageContent(args['page-id']);
252
+ console.log(`标题: ${page.title}`);
253
+ console.log(`Space: ${page.space?.key}`);
254
+ console.log(`版本: ${page.version?.number}`);
255
+ console.log(`\n── Storage HTML ──────────────────────────────\n`);
256
+ console.log(page.body.storage.value);
257
+ break;
258
+ }
259
+
260
+ default:
261
+ console.error(`未知命令:${command}`);
262
+ printUsage();
263
+ process.exit(1);
264
+ }
265
+ } catch (err) {
266
+ console.error(`\n❌ 执行失败:${err.message}`);
267
+ process.exit(1);
268
+ }
269
+ }
270
+
271
+ main();