@wonseok-han/confluence-sync 1.4.0 → 1.6.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.
Files changed (42) hide show
  1. package/README.md +64 -33
  2. package/dist/cli/args.js +14 -0
  3. package/dist/{help.js → cli/help.js} +11 -3
  4. package/dist/{init.js → cli/init.js} +1 -1
  5. package/dist/cli/main.js +38 -0
  6. package/dist/{confluence.js → confluence/client.js} +1 -1
  7. package/dist/{config.js → confluence/config.js} +1 -1
  8. package/dist/{convert.js → conversion/convert.js} +4 -4
  9. package/dist/{footnotes.js → conversion/footnotes.js} +1 -1
  10. package/dist/{pdf-pages.js → conversion/pdf-pages.js} +1 -1
  11. package/dist/{repair.js → conversion/repair.js} +2 -2
  12. package/dist/conversion/worker.js +9 -0
  13. package/dist/{docs.js → documents/catalog.js} +3 -1
  14. package/dist/{markdown.js → documents/markdown.js} +11 -5
  15. package/dist/documents/mermaid.js +13 -0
  16. package/dist/{pull.js → sync/pull.js} +8 -8
  17. package/dist/sync/push.js +302 -0
  18. package/dist/sync/references.js +93 -0
  19. package/dist/sync/render.js +47 -0
  20. package/dist/sync.js +2 -348
  21. package/dist/web/assets/Monaco-LICENSE.txt +21 -0
  22. package/dist/web/assets/Monaco-ThirdPartyNotices.txt +448 -0
  23. package/dist/web/assets/app.css +1 -0
  24. package/dist/web/assets/app.js +939 -0
  25. package/dist/web/assets/editor.worker.js +30 -0
  26. package/dist/web/folder-dialog.js +44 -0
  27. package/dist/web/output.js +139 -0
  28. package/dist/web/server.js +346 -0
  29. package/dist/web/sync.js +226 -0
  30. package/dist/web/views/help.js +33 -0
  31. package/dist/web/views/sync-panel.js +26 -0
  32. package/dist/web/views/workspace.js +39 -0
  33. package/package.json +5 -2
  34. package/dist/render.js +0 -27
  35. /package/dist/{attachments.js → conversion/attachments.js} +0 -0
  36. /package/dist/{pdf-renderer.js → conversion/pdf-renderer.js} +0 -0
  37. /package/dist/{anchors.js → documents/anchors.js} +0 -0
  38. /package/dist/{html2md.js → documents/html-to-markdown.js} +0 -0
  39. /package/dist/{obsidian.js → documents/obsidian.js} +0 -0
  40. /package/dist/{colors.js → shared/colors.js} +0 -0
  41. /package/dist/{ignore.js → sync/ignore.js} +0 -0
  42. /package/dist/{mapping.js → sync/mapping.js} +0 -0
package/dist/sync.js CHANGED
@@ -1,349 +1,3 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * confluence-sync — Markdown 디렉토리 → Confluence Cloud 단방향 동기화 (진입점)
4
- *
5
- * confluence-sync [경로...] base 전체(또는 지정 파일/폴더)에서 변경된 문서만 갱신(+신규)
6
- * confluence-sync --base <dir> 동기화 루트 지정(필수, 또는 env CONFLUENCE_SYNC_BASE)
7
- * confluence-sync --dry-run 호출 없이 대상·상태 출력
8
- * confluence-sync --list 인식된 문서·제목·계층만 출력(인증 불필요)
9
- * confluence-sync --rebuild 매핑된 페이지 전부 삭제 후 재생성
10
- * confluence-sync init 대화형으로 .env 설정 파일 생성
11
- * confluence-sync --help | --version
12
- *
13
- * git 이 원천(SoT)이며 Confluence 는 미러다. 양방향 동기화는 하지 않는다.
14
- * 세부 로직은 ./markdown ./docs ./mapping ./confluence ./config ./init ./help 모듈에 있다.
15
- */
16
- import 'dotenv/config';
17
- import { existsSync, statSync } from 'node:fs';
18
- import { resolve, relative } from 'node:path';
19
- import { readEnv, requireEnv } from './config.js';
20
- import { docHash } from './markdown.js';
21
- import { buildTreeRenderer } from './render.js';
22
- import { collectMarkdown, buildFolderIndex, readDoc, parentKeyOf, containerKeyOf, sortForSync, resolveSelection, withParents, } from './docs.js';
23
- import { loadMapping, saveMapping } from './mapping.js';
24
- import { createClient } from './confluence.js';
25
- import { runInit } from './init.js';
26
- import { runPull } from './pull.js';
27
- import { runConvert } from './convert.js';
28
- import { buildIgnorer } from './ignore.js';
29
- import { printHelp, readPkgVersion } from './help.js';
30
- import { bold, dim, red, green, yellow, magenta, cyan, gray } from './colors.js';
31
- // ---- CLI 인자 ----
32
- const argv = process.argv.slice(2);
33
- const DRY_RUN = argv.includes('--dry-run');
34
- const LIST = argv.includes('--list');
35
- const REBUILD = argv.includes('--rebuild');
36
- const FORCE = argv.includes('--force');
37
- const VERIFY = argv.includes('--verify');
38
- const HELP = argv.includes('--help') || argv.includes('-h');
39
- const VERSION = argv.includes('--version') || argv.includes('-v');
40
- function optVal(name) {
41
- const i = argv.indexOf(name);
42
- return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
43
- }
44
- /** 반복 가능한 옵션의 모든 값 수집(예: --exclude a --exclude b) */
45
- function optVals(name) {
46
- const out = [];
47
- for (let i = 0; i < argv.length; i++)
48
- if (argv[i] === name && i + 1 < argv.length)
49
- out.push(argv[++i]);
50
- return out;
51
- }
52
- const EXCLUDES = optVals('--exclude');
53
- // 위치 인자(선택 경로/서브커맨드): 플래그·옵션값 제외
54
- const positionals = [];
55
- for (let i = 0; i < argv.length; i++) {
56
- if (argv[i] === '--base' || argv[i] === '--mapping' || argv[i] === '--exclude'
57
- || argv[i] === '--to' || argv[i] === '--out') {
58
- i++;
59
- continue;
60
- }
61
- if (argv[i].startsWith('-'))
62
- continue;
63
- positionals.push(argv[i]);
64
- }
65
- // 인증·동기화 루트가 필요 없는 즉시 종료 모드 (base 검사보다 먼저)
66
- if (HELP) {
67
- printHelp();
68
- process.exit(0);
69
- }
70
- if (VERSION) {
71
- console.log(readPkgVersion());
72
- process.exit(0);
73
- }
74
- if (positionals[0] === 'init') {
75
- await runInit(argv);
76
- process.exit(0);
77
- }
78
- if (positionals[0] === 'pull') {
79
- await runPull(argv);
80
- process.exit(0);
81
- }
82
- if (positionals[0] === 'convert') {
83
- await runConvert(argv);
84
- process.exit(0);
85
- }
86
- const baseInput = optVal('--base') ?? process.env.CONFLUENCE_SYNC_BASE;
87
- if (!baseInput) {
88
- console.error(red('✗ 동기화 루트가 지정되지 않았습니다.') + '\n' +
89
- ' --base <dir> 인자 또는 env CONFLUENCE_SYNC_BASE 로 동기화할 디렉토리를 지정하세요.\n' +
90
- ` 예) ${cyan('confluence-sync --base ./docs')}\n` +
91
- ` 설정(.env)을 만들려면: ${cyan('confluence-sync init')}\n` +
92
- ` 전체 도움말: ${cyan('confluence-sync --help')}`);
93
- process.exit(1);
94
- }
95
- const BASE_DIR = resolve(baseInput);
96
- // mapping 은 base 루트에 둔다(문서셋에 종속). --mapping 으로 위치 override 가능.
97
- const mappingOpt = optVal('--mapping');
98
- const MAPPING_PATH = mappingOpt ? resolve(mappingOpt) : resolve(BASE_DIR, '.confluence-sync.json');
99
- const env = readEnv();
100
- async function main() {
101
- if (!existsSync(BASE_DIR) || !statSync(BASE_DIR).isDirectory()) {
102
- console.error(red(`동기화 루트가 디렉토리가 아닙니다: ${BASE_DIR}`));
103
- process.exit(1);
104
- }
105
- if (!DRY_RUN && !LIST)
106
- requireEnv(env); // list/dry 는 Confluence 호출이 없어 인증 불필요
107
- const ignorer = buildIgnorer(BASE_DIR, EXCLUDES);
108
- const collected = collectMarkdown(BASE_DIR).map((f) => relative(BASE_DIR, f).split('\\').join('/'));
109
- const allRel = collected.filter((r) => !ignorer.ignores(r)); // 제외된 문서는 모든 단계에서 빠짐
110
- const ignoredCount = collected.length - allRel.length;
111
- const folderIndex = buildFolderIndex(allRel);
112
- // 링크·섹션 링크 변환은 항상 트리 전체 기준이다. 선택 동기화여도 대상 문서의 제목·헤딩을 알아야 하고,
113
- // 다른 문서가 이 문서의 섹션을 가리킬 수 있다. 제외된 문서는 링크 대상에서도 빠진다.
114
- const { titles: titleIndex, render } = buildTreeRenderer(BASE_DIR, allRel);
115
- // 사용자가 실제로 고른 문서. 경로 인자가 없으면 전체가 대상이다.
116
- const picked = positionals.length ? resolveSelection(allRel, positionals, BASE_DIR) : allRel;
117
- const explicit = new Set(picked);
118
- // 상위 README 를 함께 넣는 것은 **자식의 부모 pageId 를 얻기 위해서**다(발행 대상이 아니다).
119
- const selected = positionals.length ? withParents(picked, folderIndex) : allRel;
120
- const files = sortForSync(selected);
121
- /** 딸려온 상위 README 인가 — 이미 페이지가 있으면 내용은 건드리지 않는다. */
122
- const isCarriedParent = (rel) => !explicit.has(rel);
123
- const scope = positionals.length
124
- ? `선택 ${explicit.size}/${allRel.length}건` +
125
- (files.length > explicit.size ? dim(` (+상위 ${files.length - explicit.size})`) : '')
126
- : `${files.length}건`;
127
- const ignoredNote = ignoredCount ? dim(` (제외 ${ignoredCount})`) : '';
128
- console.log(`${dim('base:')} ${cyan(BASE_DIR)}\n${dim('대상:')} ${scope}${ignoredNote}`);
129
- // ---- 경로 헬퍼 ----
130
- const isReadmeFile = (p) => /README\.md$/.test(p);
131
- const segOf = (s) => (s === '' ? 0 : s.split('/').length);
132
- const dirOf = (p) => p.split('/').slice(0, -1).join('/');
133
- const parentDirOf = (d) => (d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '');
134
- const fileDepth = (rel) => (isReadmeFile(rel) ? Math.max(0, segOf(dirOf(rel)) - 1) : segOf(dirOf(rel)));
135
- const folderDepth = (d) => Math.max(0, segOf(d) - 1);
136
- // 파일의 README 없는(=폴더로 만들) 조상 dir들, shallow→deep
137
- const ancestorFolderDirs = (rel) => {
138
- const out = [];
139
- for (let d = dirOf(rel); d !== ''; d = parentDirOf(d))
140
- if (!folderIndex[d])
141
- out.unshift(d);
142
- return out;
143
- };
144
- // --list: base에서 인식된 문서·제목·계층만 출력(Confluence 호출 없음)
145
- if (LIST) {
146
- const seen = new Set();
147
- for (const rel of files) {
148
- for (const d of ancestorFolderDirs(rel))
149
- if (!seen.has(d)) {
150
- seen.add(d);
151
- console.log(` ${' '.repeat(folderDepth(d))}${cyan(`📁 ${d}/`)}`);
152
- }
153
- console.log(` ${' '.repeat(fileDepth(rel))}${rel} ${dim('—')} ${cyan(`"${titleIndex[rel]}"`)}`);
154
- }
155
- return;
156
- }
157
- const mapping = loadMapping(MAPPING_PATH);
158
- if (DRY_RUN) {
159
- const seen = new Set();
160
- for (const rel of files) {
161
- for (const d of ancestorFolderDirs(rel))
162
- if (!seen.has(d)) {
163
- seen.add(d);
164
- const fex = mapping[`${d}/`];
165
- console.log(` [${fex?.pageId ? gray('폴더') : green('폴더+')}] 📁 ${d}/`);
166
- }
167
- const ex0 = mapping[rel];
168
- // 딸려온 상위 README 는 pageId 만 쓰고 지나간다 — 고르지 않은 문서를 발행하지 않는다
169
- if (isCarriedParent(rel) && ex0?.pageId) {
170
- console.log(` [${gray('상위')}] ${rel} ${dim(`(pageId #${ex0.pageId} 만 사용)`)}`);
171
- continue;
172
- }
173
- const { title, body, fm } = readDoc(BASE_DIR, rel);
174
- const r = render(rel, body, title);
175
- const hash = docHash(title, r);
176
- const ex = mapping[rel];
177
- // 매핑엔 없지만 frontmatter 에 pageId 가 있으면 신규 생성이 아니라 기존 페이지에 연결된다
178
- const status = !ex?.pageId
179
- ? (fm.pageId ? cyan('연결') : green('신규'))
180
- : ex.hash === hash ? gray('동일') : yellow('변경');
181
- const pk = parentKeyOf(rel, folderIndex);
182
- const anchorNote = r.anchorLinks || r.anchorsPlaced
183
- ? `, 섹션링크: ${r.anchorLinks}, 앵커: ${r.anchorsPlaced}` : '';
184
- console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}${anchorNote}, 이미지: ${r.images.length})`)}`);
185
- if (r.deadAnchors.length) {
186
- console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${r.deadAnchors.length}개: `) + dim(r.deadAnchors.slice(0, 3).join(' ')));
187
- }
188
- }
189
- console.log('\n' + dim('--dry-run: 실제 호출 없음.'));
190
- return;
191
- }
192
- const client = createClient({ baseUrl: env.baseUrl, email: env.email, token: env.token }, { force: FORCE, verify: VERIFY });
193
- const spaceId = await client.getSpaceId(env.spaceKey);
194
- if (REBUILD) {
195
- await client.deleteAll(mapping);
196
- saveMapping(MAPPING_PATH, {});
197
- }
198
- const work = REBUILD ? {} : mapping;
199
- let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0, linked = 0, carried = 0;
200
- const rendered = new Map();
201
- const recreatedTitles = new Set(); // 이번에 재생성된 문서의 제목
202
- const touched = new Set(); // 이번에 발행(생성/갱신/재생성)된 문서
203
- const handledFolders = new Set(); // 이번 실행에서 처리한 폴더 dir
204
- // README 없는 폴더를 Confluence 폴더로 생성(없으면)하고 work 에 등록. 성공 여부 반환.
205
- const ensureFolder = async (dir) => {
206
- const key = `${dir}/`;
207
- const pcKey = containerKeyOf(parentDirOf(dir), folderIndex);
208
- const parentId = pcKey ? work[pcKey]?.pageId : env.parentId;
209
- if (pcKey && !parentId) {
210
- console.error(yellow(` ✗ 폴더 건너뜀 ${dir}/ (부모 '${pcKey}' 가 아직 없음)`));
211
- return false;
212
- }
213
- const ex = work[key];
214
- if (ex?.pageId && ex.type === 'folder' && (await client.getContentOrNull(ex.pageId))) {
215
- return true; // 이미 존재
216
- }
217
- try {
218
- const title = dir.split('/').pop();
219
- const fid = await client.createFolder(spaceId, title, parentId);
220
- work[key] = { pageId: fid, title, type: 'folder' };
221
- saveMapping(MAPPING_PATH, work);
222
- console.log(` ${cyan('📁 폴더')} ${dir}/ ${dim(`(부모: ${pcKey ?? 'ROOT'})`)}`);
223
- foldersMade++;
224
- return true;
225
- }
226
- catch (e) {
227
- console.error(red(` ✗ 폴더 생성 실패 ${dir}/`) + `\n${e.message}`);
228
- return false;
229
- }
230
- };
231
- for (const rel of files) {
232
- // 상위 README-less 폴더들 보장(shallow→deep)
233
- let folderOk = true;
234
- for (const d of ancestorFolderDirs(rel)) {
235
- if (!handledFolders.has(d)) {
236
- handledFolders.add(d);
237
- if (!(await ensureFolder(d)))
238
- folderOk = false;
239
- }
240
- else if (!work[`${d}/`]?.pageId)
241
- folderOk = false;
242
- if (!folderOk)
243
- break;
244
- }
245
- if (!folderOk) {
246
- console.error(yellow(` ✗ 건너뜀 ${rel} (상위 폴더 미생성)`));
247
- continue;
248
- }
249
- // 딸려온 상위 README 는 자식의 부모 pageId 를 얻으려고 들어온 것이다.
250
- // 이미 페이지가 있으면 내용은 건드리지 않는다 — `csync <파일>` 이 고르지 않은 문서를 발행하면 안 된다.
251
- // (페이지가 아직 없으면 자식을 붙일 곳이 없으므로 아래에서 생성한다)
252
- if (isCarriedParent(rel) && work[rel]?.pageId) {
253
- console.log(` ${gray('· 상위')} ${rel} ${dim(`(pageId #${work[rel].pageId} 만 사용)`)}`);
254
- carried++;
255
- continue;
256
- }
257
- const { title, body, fm } = readDoc(BASE_DIR, rel);
258
- const r = render(rel, body, title);
259
- const hash = docHash(title, r);
260
- const pk = parentKeyOf(rel, folderIndex);
261
- if (r.deadAnchors.length) {
262
- console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${rel}`) + dim(` ${r.deadAnchors.slice(0, 3).join(' ')}${r.deadAnchors.length > 3 ? ` 외 ${r.deadAnchors.length - 3}` : ''}`));
263
- }
264
- const parentId = pk ? work[pk]?.pageId : env.parentId;
265
- rendered.set(rel, { ...r, title, hash, parentId });
266
- if (pk && !parentId) {
267
- console.error(yellow(` ✗ 건너뜀 ${rel} (부모 '${pk}' 가 아직 없음)`));
268
- continue;
269
- }
270
- // pull 로 받은 문서(frontmatter 에 pageId)는 매핑이 없어도 원본 페이지에 다시 연결한다.
271
- // 매핑 파일을 잃었거나 vault 를 다른 곳에 복제한 경우 중복 생성을 막아준다.
272
- if (!work[rel]?.pageId && fm.pageId) {
273
- try {
274
- if (await client.getContentOrNull(fm.pageId)) {
275
- work[rel] = { pageId: fm.pageId, title };
276
- saveMapping(MAPPING_PATH, work);
277
- console.log(` ${cyan('🔗 연결')} ${rel} ${dim(`→ 기존 페이지 #${fm.pageId}`)}`);
278
- linked++;
279
- }
280
- else {
281
- console.error(yellow(` ⚠ frontmatter 의 pageId #${fm.pageId} 를 찾을 수 없습니다 → 새로 생성 (${rel})`));
282
- }
283
- }
284
- catch (e) {
285
- console.error(yellow(` ⚠ pageId 확인 실패 #${fm.pageId}: ${e.message}`));
286
- }
287
- }
288
- try {
289
- const result = await client.upsertPage(work, spaceId, rel, title, r.storage, hash, parentId);
290
- saveMapping(MAPPING_PATH, work);
291
- if (result === 'skipped') {
292
- console.log(` ${gray('= 변경없음')} ${rel}`);
293
- skipped++;
294
- continue;
295
- }
296
- const imgOk = await client.uploadImages(work[rel].pageId, r.images, BASE_DIR);
297
- const imgNote = r.images.length ? `, 이미지 ${imgOk}/${r.images.length}` : '';
298
- const label = result === 'created' ? green('+ 생성')
299
- : result === 'recreated' ? magenta('♻ 재생성(삭제 복구)')
300
- : yellow('↻ 갱신');
301
- console.log(` ${label} ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}${imgNote})`)}`);
302
- if (result === 'created')
303
- created++;
304
- else if (result === 'recreated') {
305
- recreated++;
306
- recreatedTitles.add(title);
307
- }
308
- else
309
- updated++;
310
- touched.add(rel);
311
- }
312
- catch (e) {
313
- console.error(red(` ✗ 실패 ${rel}`) + `\n${e.message}`);
314
- }
315
- }
316
- // 링크 정합성: 재생성된 문서를 가리키던, 이번에 발행 안 된(스킵된) 문서를 강제 재발행해 링크를 새 페이지로 재연결
317
- if (recreatedTitles.size) {
318
- for (const rel of files) {
319
- if (touched.has(rel))
320
- continue;
321
- const r = rendered.get(rel);
322
- if (!r || !work[rel]?.pageId)
323
- continue;
324
- if (!r.linkedTitles.some((t) => recreatedTitles.has(t)))
325
- continue;
326
- try {
327
- await client.upsertPage(work, spaceId, rel, r.title, r.storage, r.hash, r.parentId, true);
328
- saveMapping(MAPPING_PATH, work);
329
- await client.uploadImages(work[rel].pageId, r.images, BASE_DIR);
330
- console.log(` ${cyan('🔗 링크 재연결')} ${rel} ${dim('→')} ${cyan(`"${r.title}"`)}`);
331
- relinked++;
332
- }
333
- catch (e) {
334
- console.error(red(` ✗ 링크 재연결 실패 ${rel}`) + `\n${e.message}`);
335
- }
336
- }
337
- }
338
- console.log('\n' + bold('완료') +
339
- ` ${green(`생성 ${created}`)}` +
340
- ` ${yellow(`갱신 ${updated}`)}` +
341
- ` ${magenta(`재생성 ${recreated}`)}` +
342
- ` ${cyan(`링크재연결 ${relinked}`)}` +
343
- ` ${gray(`변경없음 ${skipped}`)}` +
344
- (linked ? ` ${cyan(`연결 ${linked}`)}` : '') +
345
- (foldersMade ? ` ${cyan(`폴더 ${foldersMade}`)}` : '') +
346
- (carried ? ` ${gray(`상위 ${carried}`)}` : '') +
347
- ` ${dim(`대상 ${files.length - carried}`)}`);
348
- }
349
- main().catch((e) => { console.error(e); process.exit(1); });
2
+ // Stable executable entry for installed packages and source development.
3
+ import './cli/main.js';
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 - present Microsoft Corporation
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 all
13
+ 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 THE
21
+ SOFTWARE.