@wonseok-han/confluence-sync 1.0.0 → 1.2.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/dist/pull.js CHANGED
@@ -2,16 +2,27 @@
2
2
  * `confluence-sync pull <pageId|url> [--out <dir>] [--children]`
3
3
  * Confluence 페이지를 읽어 Markdown(.md) 으로 생성한다(역방향).
4
4
  * 자식이 있는 페이지는 push 관례를 역으로 적용해 <slug>/README.md 폴더로 펼친다.
5
- * 첨부는 .md 옆에 내려받고 이미지 링크를 로컬 파일명으로 재작성한다.
5
+ * 첨부는 attachments/<문서명>/ 하위에 내려받고 이미지 링크를 그 경로로 재작성한다.
6
+ *
7
+ * 함께 받은 페이지끼리의 내부 링크는 마지막에 상대 .md 경로로 이어 붙인다(Obsidian 그래프·백링크가 살아난다).
8
+ * 각 문서 머리에 pageId 를 담은 frontmatter 를 남겨, 매핑 파일 없이도 push 가 원본 페이지를 다시 찾아간다.
6
9
  */
7
- import { mkdirSync, writeFileSync } from 'node:fs';
8
- import { resolve, join, dirname, basename } from 'node:path';
10
+ import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
11
+ import { resolve, join, dirname, basename, relative } from 'node:path';
9
12
  import { readEnv, requireEnv } from './config.js';
10
13
  import { createClient } from './confluence.js';
11
14
  import { htmlToMarkdown, codeLanguagesFromStorage } from './html2md.js';
15
+ import { buildFrontmatter, splitFrontmatter } from './obsidian.js';
16
+ import { collectHeadings, matchConfluenceAnchor } from './anchors.js';
12
17
  import { cyan, dim, green, red, yellow } from './colors.js';
13
18
  // 첨부 이미지는 항상 <md위치>/attachments/<문서명>/ 하위에 저장한다.
14
19
  const ASSETS_DIR = 'attachments';
20
+ /** 마크다운 링크 중 Confluence 페이지를 가리키는 것: [text](https://.../pages/<id>/...) */
21
+ const PAGE_LINK = /\[([^\]]*)\]\(<?(https?:\/\/[^\s)<>]*?\/pages\/(\d+)[^\s)<>]*?)>?\)/g;
22
+ /** 같은 문서 안의 앵커: [text](#앵커) */
23
+ const SELF_ANCHOR = /\[([^\]]*)\]\(#([^)\s]+)\)/g;
24
+ /** 다른 페이지의 섹션: [text](https://.../pages/<id>/제목#앵커) — 앵커만 따로 잡는다 */
25
+ const PAGE_ANCHOR = /(\[[^\]]*\]\(<?https?:\/\/[^\s)<>]*?\/pages\/(\d+)[^\s)<>#]*)#([^\s)<>]+?)(>?\))/g;
15
26
  function optVal(argv, name) {
16
27
  const i = argv.indexOf(name);
17
28
  return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
@@ -28,8 +39,11 @@ function parseContentId(input) {
28
39
  function slug(title) {
29
40
  return title.replace(/[\/\\:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim() || 'untitled';
30
41
  }
42
+ /** 공백이 있으면 <...> 로 감싼 마크다운 링크 목적지 */
43
+ const dest = (p) => (/\s/.test(p) ? `<${p}>` : p);
31
44
  /** 페이지 1건을 .md 로 생성(자식 페이지/폴더가 있으면 <slug>/README.md 폴더로). */
32
- async function pullPage(client, node, destDir, withChildren) {
45
+ async function pullPage(ctx, node, destDir) {
46
+ const { client, withChildren } = ctx;
33
47
  const { id, title, html, storage } = node;
34
48
  // 페이지도 하위 페이지 + 하위 폴더를 모두 가질 수 있다(폴더가 페이지 밑에 올 수 있음).
35
49
  const childFolders = withChildren ? await client.getChildFolders(id) : [];
@@ -66,19 +80,33 @@ async function pullPage(client, node, destDir, withChildren) {
66
80
  }
67
81
  }
68
82
  }
69
- writeFileSync(filePath, `# ${title}\n\n${body}`);
83
+ // frontmatter 의 pageId 는 push 왕복의 앵커다(매핑 파일이 없어도 원본 페이지를 찾아간다).
84
+ // 취향 옵션이 아니라 중복 페이지 생성을 막는 장치이므로 항상 쓴다. Obsidian 은 이를 속성으로 보여준다.
85
+ const fm = buildFrontmatter({
86
+ title,
87
+ pageId: id,
88
+ spaceKey: ctx.spaceKey,
89
+ source: node.url ?? '',
90
+ updated: node.updated ?? '',
91
+ });
92
+ // 제목은 frontmatter 의 title 하나로 충분하다. 본문에 `# 제목` 을 또 넣으면
93
+ // 제목을 따로 표시하는 뷰어(Obsidian 등)에서 두 번 보인다. push 도 title 속성을 먼저 읽는다.
94
+ writeFileSync(filePath, fm ? `${fm}${body}` : `# ${title}\n\n${body}`);
95
+ ctx.written.push(filePath);
96
+ ctx.pathById.set(id, filePath);
70
97
  const imgNote = referenced.size ? `, 이미지 ${referenced.size}` : '';
71
98
  console.log(` ${green('+ 생성')} ${filePath} ${dim(`(#${id}${imgNote})`)}`);
72
99
  const counts = { pages: 1, folders: 0 };
73
100
  for (const f of childFolders)
74
- add(counts, await pullNode(client, f.id, folder, withChildren));
101
+ add(counts, await pullNode(ctx, f.id, folder));
75
102
  for (const c of childPages)
76
- add(counts, await pullNode(client, c.id, folder, withChildren));
103
+ add(counts, await pullNode(ctx, c.id, folder));
77
104
  return counts;
78
105
  }
79
106
  function add(acc, c) { acc.pages += c.pages; acc.folders += c.folders; }
80
107
  /** 노드(페이지/폴더)를 재귀적으로 가져온다. 폴더는 디렉토리(본문 없음)로 만든다. 노드 하나 실패는 스킵. */
81
- async function pullNode(client, id, destDir, withChildren) {
108
+ async function pullNode(ctx, id, destDir) {
109
+ const { client, withChildren } = ctx;
82
110
  try {
83
111
  const node = await client.getNode(id);
84
112
  if (node.type === 'folder') {
@@ -88,9 +116,9 @@ async function pullNode(client, id, destDir, withChildren) {
88
116
  const counts = { pages: 0, folders: 1 };
89
117
  if (withChildren) {
90
118
  for (const f of await client.getChildFolders(id))
91
- add(counts, await pullNode(client, f.id, dir, true));
119
+ add(counts, await pullNode(ctx, f.id, dir));
92
120
  for (const p of await client.getChildPages(id))
93
- add(counts, await pullNode(client, p.id, dir, true));
121
+ add(counts, await pullNode(ctx, p.id, dir));
94
122
  }
95
123
  else {
96
124
  console.log(dim(' (하위 내용은 --children 으로 가져옵니다)'));
@@ -98,13 +126,104 @@ async function pullNode(client, id, destDir, withChildren) {
98
126
  return counts;
99
127
  }
100
128
  // page: getNode 결과(html + storage) 그대로 사용
101
- return await pullPage(client, node, destDir, withChildren);
129
+ return await pullPage(ctx, node, destDir);
102
130
  }
103
131
  catch (e) {
104
132
  console.error(red(` ✗ 실패 #${id}`) + `\n${e.message}`);
105
133
  return { pages: 0, folders: 0 };
106
134
  }
107
135
  }
136
+ function readDocInfo(file) {
137
+ const { data, body } = splitFrontmatter(readFileSync(file, 'utf8'));
138
+ const headings = collectHeadings(body);
139
+ return {
140
+ title: data.title ?? '',
141
+ headings,
142
+ textBySlug: new Map(headings.map((h) => [h.slug, h.text])),
143
+ };
144
+ }
145
+ /**
146
+ * Confluence 가 만든 헤딩 앵커를 마크다운 슬러그로 바꾼다.
147
+ *
148
+ * Confluence 의 앵커 id 는 `<페이지제목><헤딩텍스트>`(공백 제거) 라서 Obsidian·GitHub 에서는
149
+ * 아무 데도 가리키지 못한다. 실제 헤딩을 찾아 그 문서의 슬러그로 바꿔 줘야 링크가 산다.
150
+ * relinkPass 보다 **먼저** 돈다 — 아직 링크가 절대 URL 이라 어느 페이지인지 id 로 알 수 있기 때문이다.
151
+ */
152
+ export function anchorPass(written, pathById) {
153
+ const info = new Map();
154
+ for (const f of written)
155
+ info.set(f, readDocInfo(f));
156
+ let count = 0;
157
+ for (const file of written) {
158
+ const self = info.get(file);
159
+ const before = readFileSync(file, 'utf8');
160
+ // 우리가 올린 페이지는 Anchor 매크로 이름이 이미 마크다운 슬러그라 그대로 두면 된다.
161
+ // Confluence 에서 직접 쓴 페이지만 헤딩을 찾아 맞춘다.
162
+ const toSlug = (frag, d) => {
163
+ if (d.textBySlug.has(frag))
164
+ return null; // 이미 올바른 슬러그
165
+ return matchConfluenceAnchor(frag, d.headings, d.title)?.slug ?? null;
166
+ };
167
+ let after = before.replace(SELF_ANCHOR, (whole, label, frag) => {
168
+ const slug = toSlug(frag, self);
169
+ if (!slug)
170
+ return whole; // 이미 맞거나 못 찾음 → 손대지 않는다(엉뚱한 곳으로 보내지 않기 위해)
171
+ count++;
172
+ return `[${label}](#${slug})`;
173
+ });
174
+ after = after.replace(PAGE_ANCHOR, (whole, head, id, frag, tail) => {
175
+ const target = pathById.get(id);
176
+ const ti = target && info.get(target);
177
+ if (!ti)
178
+ return whole; // 이번에 안 받은 페이지 → 원본 URL 이 살아 있어야 한다
179
+ const slug = toSlug(frag, ti);
180
+ if (!slug)
181
+ return whole;
182
+ count++;
183
+ return `${head}#${slug}${tail}`;
184
+ });
185
+ if (after !== before)
186
+ writeFileSync(file, after);
187
+ }
188
+ return { count, info };
189
+ }
190
+ /**
191
+ * 함께 받은 페이지를 가리키는 절대 Confluence URL 을 상대 .md 링크(또는 [[wikilink]])로 바꾼다.
192
+ * 이번에 받지 않은 페이지의 링크는 절대 URL 그대로 둔다(깨진 링크를 만들지 않는다).
193
+ * 반환값은 재작성한 링크 수.
194
+ */
195
+ export function relinkPass(written, pathById, wikilinks, info) {
196
+ const noExt = (p) => basename(p).replace(/\.md$/i, '');
197
+ // wikilink 는 vault 어디서든 "이름"으로 찾으므로, 파일명이 유일할 때만 안전하게 쓸 수 있다.
198
+ const nameCount = new Map();
199
+ for (const p of pathById.values())
200
+ nameCount.set(noExt(p), (nameCount.get(noExt(p)) ?? 0) + 1);
201
+ let count = 0;
202
+ for (const file of written) {
203
+ const before = readFileSync(file, 'utf8');
204
+ const after = before.replace(PAGE_LINK, (whole, text, url, id) => {
205
+ const target = pathById.get(id);
206
+ if (!target || target === file)
207
+ return whole; // 못 받은 페이지·자기 자신 → 원본 URL 유지
208
+ count++;
209
+ const name = noExt(target);
210
+ const label = (text || name).trim();
211
+ const slug = url.includes('#') ? url.split('#').slice(1).join('#') : '';
212
+ if (wikilinks && nameCount.get(name) === 1) {
213
+ // Obsidian 은 슬러그가 아니라 헤딩 텍스트로 섹션을 가리킨다: [[문서#헤딩 텍스트]]
214
+ const heading = slug ? info?.get(target)?.textBySlug.get(slug) : undefined;
215
+ if (!heading)
216
+ return label === name ? `[[${name}]]` : `[[${name}|${label}]]`;
217
+ return `[[${name}#${heading}|${label}]]`;
218
+ }
219
+ const rel = relative(dirname(file), target).split('\\').join('/');
220
+ return `[${label}](${dest(rel + (slug ? `#${slug}` : ''))})`;
221
+ });
222
+ if (after !== before)
223
+ writeFileSync(file, after);
224
+ }
225
+ return count;
226
+ }
108
227
  export async function runPull(argv) {
109
228
  const withChildren = argv.includes('--children');
110
229
  const wholeSpace = argv.includes('--space');
@@ -112,6 +231,22 @@ export async function runPull(argv) {
112
231
  const env = readEnv();
113
232
  requireEnv(env);
114
233
  const client = createClient({ baseUrl: env.baseUrl, email: env.email, token: env.token }, { force: false, verify: false });
234
+ const ctx = {
235
+ client,
236
+ withChildren,
237
+ obsidian: argv.includes('--obsidian'),
238
+ spaceKey: env.spaceKey,
239
+ written: [],
240
+ pathById: new Map(),
241
+ };
242
+ const done = (c) => {
243
+ // 앵커를 먼저 슬러그로 바꾼 뒤 링크를 상대경로로 옮긴다(순서가 바뀌면 어느 페이지의 헤딩인지 알 수 없다).
244
+ const { count: anchors, info } = anchorPass(ctx.written, ctx.pathById);
245
+ const relinked = relinkPass(ctx.written, ctx.pathById, ctx.obsidian, info);
246
+ console.log(`\n${green('완료')} 페이지 ${c.pages}개${c.folders ? `, 폴더 ${c.folders}개` : ''} 생성` +
247
+ (relinked ? ` ${cyan(`내부링크 ${relinked}개 연결`)}` : '') +
248
+ (anchors ? ` ${cyan(`섹션링크 ${anchors}개 복원`)}` : ''));
249
+ };
115
250
  // --space: 스페이스 홈페이지(콘텐츠 트리 루트)부터 전체를 재귀적으로 가져옴
116
251
  if (wholeSpace) {
117
252
  const { homepageId } = await client.getSpaceInfo(env.spaceKey);
@@ -120,8 +255,8 @@ export async function runPull(argv) {
120
255
  process.exit(1);
121
256
  }
122
257
  console.log(`${dim('pull:')} space ${cyan(env.spaceKey)} ${dim('→')} ${cyan(outDir)} ${dim('(전체)')}`);
123
- const { pages, folders } = await pullNode(client, homepageId, outDir, true);
124
- console.log(`\n${green('완료')} 페이지 ${pages}개${folders ? `, 폴더 ${folders}개` : ''} 생성`);
258
+ ctx.withChildren = true;
259
+ done(await pullNode(ctx, homepageId, outDir));
125
260
  return;
126
261
  }
127
262
  // 위치 인자: ['pull', <pageId|url>, ...]
@@ -146,6 +281,5 @@ export async function runPull(argv) {
146
281
  process.exit(1);
147
282
  }
148
283
  console.log(`${dim('pull:')} #${contentId} ${dim('→')} ${cyan(outDir)}${withChildren ? dim(' (+children)') : ''}`);
149
- const { pages, folders } = await pullNode(client, contentId, outDir, withChildren);
150
- console.log(`\n${green('완료')} 페이지 ${pages}개${folders ? `, 폴더 ${folders}개` : ''} 생성`);
284
+ done(await pullNode(ctx, contentId, outDir));
151
285
  }
package/dist/repair.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * 이미 내려받아 둔 .md 를 고친다(사후 보정).
3
+ *
4
+ * html2md 가 개선되기 전에 pull 한 문서에는 변환기 결함의 흔적이 남아 있다.
5
+ * 스페이스를 통째로 다시 받지 않고도 같은 결과를 얻도록, 그 흔적들만 골라 되돌린다.
6
+ * 파일을 덮어쓰므로 **확실한 것만** 고치고, 애매하면 손대지 않는다.
7
+ */
8
+ import { relaxEscapes } from './html2md.js';
9
+ import { splitFrontmatter } from './obsidian.js';
10
+ const emptyStats = () => ({ cssJunk: 0, codeLang: 0, trailingWs: 0, escapes: 0, tightList: 0, dupTitle: 0 });
11
+ export const totalFixes = (s) => Object.values(s).reduce((a, b) => a + b, 0);
12
+ const FENCE = /^\s*(?:```|~~~)/;
13
+ /** 리스트 항목이면 종류('ul'|'ol'), 아니면 null. 종류가 다르면 별개의 리스트다. */
14
+ function listKind(line) {
15
+ if (/^[ \t]*[-*+]\s/.test(line))
16
+ return 'ul';
17
+ if (/^[ \t]*\d+[.)]\s/.test(line))
18
+ return 'ol';
19
+ return null;
20
+ }
21
+ /**
22
+ * Confluence export_view 는 색상 텍스트용 인라인 <style> 을 본문에(제목 안까지) 끼워 넣는데,
23
+ * 예전 변환기는 그 CSS 텍스트를 그대로 뱉었다. `[data-colorid=xxx]{color:#fff;}` 같은 잔해다.
24
+ * (turndown 이 이스케이프해 `\[data-colorid=...\]` 형태로 남아 있기도 하다.)
25
+ */
26
+ function stripCssJunk(line) {
27
+ return line
28
+ .replace(/\\?\[data-colorid=[^\]]*\\?\]\s*\{[^}]*\}/g, '') // 선택자 + 규칙 블록
29
+ .replace(/\\?\[data-colorid=[^\]]*\\?\]/g, '') // 선택자만 남은 것
30
+ .replace(/\{\s*color\s*:\s*[^}]*\}/g, ''); // 규칙만 남은 것
31
+ }
32
+ /**
33
+ * 실제 Java 코드로 보이는가?
34
+ * Confluence 는 언어를 지정하지 않은 code 매크로를 export 할 때 `brush: java` 를 기본으로 붙인다.
35
+ * 그래서 예전 변환기는 거의 모든 코드블록을 ```java 로 찍었다. 진짜 Java 만 남기고 plaintext 로 되돌린다.
36
+ */
37
+ function looksLikeJava(code) {
38
+ return (/^\s*(package|import)\s+(java|javax|com|org)\b/m.test(code) ||
39
+ /\b(public|private|protected)\s+(static\s+)?(final\s+)?(class|interface|enum|void)\b/.test(code) ||
40
+ /\bSystem\.(out|err)\.print/.test(code) ||
41
+ /@Override\b/.test(code));
42
+ }
43
+ /**
44
+ * 리스트 항목 사이의 빈 줄을 없앤다(Confluence 의 <li><p> 때문에 생긴 loose list).
45
+ * 앞뒤가 **같은 종류**의 항목일 때만 붙인다 — 번호 목록과 글머리 목록이 잇달아 나오는 경우
46
+ * 그 사이의 빈 줄은 두 리스트를 갈라놓는 구분자라 지우면 하나로 합쳐져 버린다.
47
+ */
48
+ function tightenLists(lines, stats) {
49
+ const out = [];
50
+ let inFence = false;
51
+ for (let i = 0; i < lines.length; i++) {
52
+ const line = lines[i];
53
+ if (FENCE.test(line))
54
+ inFence = !inFence;
55
+ if (!inFence && line.trim() === '' && out.length) {
56
+ const prev = listKind(out[out.length - 1]);
57
+ const next = listKind(lines[i + 1] ?? '');
58
+ if (prev && prev === next) {
59
+ stats.tightList++;
60
+ continue;
61
+ }
62
+ }
63
+ out.push(line);
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * frontmatter 에 title 이 있는데 본문 첫 헤딩이 같은 제목이면 그 H1 을 지운다.
69
+ * 제목을 따로 표시하는 뷰어(Obsidian 등)에서 제목이 두 번 보이기 때문이다.
70
+ * 제목이 다르면(본문이 실제로 다른 이야기로 시작하면) 손대지 않는다.
71
+ */
72
+ function dropDuplicateTitle(src, stats) {
73
+ const { data, body } = splitFrontmatter(src);
74
+ if (!data.title)
75
+ return src;
76
+ const lines = body.split('\n');
77
+ const i = lines.findIndex((l) => l.trim() !== '');
78
+ if (i === -1)
79
+ return src;
80
+ const h1 = lines[i].match(/^#\s+(.*)$/);
81
+ if (!h1 || h1[1].trim() !== data.title.trim())
82
+ return src;
83
+ lines.splice(i, 1);
84
+ while (lines[i]?.trim() === '')
85
+ lines.splice(i, 1); // H1 뒤에 붙어 있던 빈 줄까지
86
+ stats.dupTitle++;
87
+ const fmBlock = src.slice(0, src.length - body.length);
88
+ return fmBlock + lines.join('\n');
89
+ }
90
+ /** 문서 하나를 보정한다. 반환된 text 가 원본과 같으면 고칠 것이 없었다는 뜻. */
91
+ export function repairMarkdown(input) {
92
+ const stats = emptyStats();
93
+ const src = dropDuplicateTitle(input, stats);
94
+ const lines = src.split('\n');
95
+ const out = [];
96
+ let inFence = false;
97
+ let fenceStart = -1; // out 기준, 현재 코드블록 여는 줄의 인덱스
98
+ for (const raw of lines) {
99
+ if (FENCE.test(raw)) {
100
+ if (!inFence) {
101
+ inFence = true;
102
+ fenceStart = out.length;
103
+ out.push(raw);
104
+ }
105
+ else {
106
+ // 코드블록 종료 — 모아둔 내용으로 언어를 판정한다
107
+ inFence = false;
108
+ const opener = out[fenceStart];
109
+ if (/^\s*(?:```|~~~)java\s*$/.test(opener)) {
110
+ const code = out.slice(fenceStart + 1).join('\n');
111
+ if (!looksLikeJava(code)) {
112
+ out[fenceStart] = opener.replace(/java\s*$/, 'plaintext');
113
+ stats.codeLang++;
114
+ }
115
+ }
116
+ out.push(raw);
117
+ }
118
+ continue;
119
+ }
120
+ if (inFence) {
121
+ // 코드 안에서는 줄 끝 공백만 정리한다(코드 의미는 불변). 이스케이프·CSS 는 손대지 않는다.
122
+ const trimmed = raw.replace(/[ \t]+$/, '');
123
+ if (trimmed !== raw)
124
+ stats.trailingWs++;
125
+ out.push(trimmed);
126
+ continue;
127
+ }
128
+ let line = raw;
129
+ const noCss = stripCssJunk(raw);
130
+ if (noCss !== raw) {
131
+ stats.cssJunk++;
132
+ // 잔해를 걷어낸 자리에 공백이 남는다: "# {제거됨} 제목" → "# 제목"
133
+ line = noCss.replace(/[ \t]{2,}/g, ' ').replace(/[ \t]+$/, '');
134
+ }
135
+ out.push(line);
136
+ }
137
+ // 이스케이프 완화는 코드블록을 알아서 건너뛴다(html2md 와 같은 규칙을 재사용)
138
+ let text = relaxEscapes(tightenLists(out, stats).join('\n'));
139
+ // relaxEscapes 가 어디를 바꿨는지 역추적하긴 어려우니, 사라진 백슬래시 개수로 센다
140
+ const backslashes = (s) => (s.match(/\\/g) || []).length;
141
+ stats.escapes = Math.max(0, backslashes(src) - backslashes(text));
142
+ // CSS 잔해를 걷어내면 빈 껍데기 제목(`#` 만 남은 줄)이 생길 수 있다
143
+ text = text.replace(/^#{1,6}[ \t]*$/gm, '');
144
+ return { text, stats };
145
+ }
package/dist/sync.js CHANGED
@@ -14,15 +14,16 @@
14
14
  * 세부 로직은 ./markdown ./docs ./mapping ./confluence ./config ./init ./help 모듈에 있다.
15
15
  */
16
16
  import 'dotenv/config';
17
- import { existsSync, readFileSync, statSync } from 'node:fs';
17
+ import { existsSync, statSync } from 'node:fs';
18
18
  import { resolve, relative } from 'node:path';
19
19
  import { readEnv, requireEnv } from './config.js';
20
- import { toStorage, splitTitleAndBody, docHash } from './markdown.js';
21
- import { collectMarkdown, buildTitleIndex, buildFolderIndex, parentKeyOf, containerKeyOf, sortForSync, resolveSelection, withParents, } from './docs.js';
20
+ import { toStorage, docHash } from './markdown.js';
21
+ import { collectMarkdown, collectAssets, buildDocIndex, resolveAnchorTargets, buildFolderIndex, buildVault, vaultResolver, readDoc, parentKeyOf, containerKeyOf, sortForSync, resolveSelection, withParents, } from './docs.js';
22
22
  import { loadMapping, saveMapping } from './mapping.js';
23
23
  import { createClient } from './confluence.js';
24
24
  import { runInit } from './init.js';
25
25
  import { runPull } from './pull.js';
26
+ import { runConvert } from './convert.js';
26
27
  import { buildIgnorer } from './ignore.js';
27
28
  import { printHelp, readPkgVersion } from './help.js';
28
29
  import { bold, dim, red, green, yellow, magenta, cyan, gray } from './colors.js';
@@ -51,7 +52,8 @@ const EXCLUDES = optVals('--exclude');
51
52
  // 위치 인자(선택 경로/서브커맨드): 플래그·옵션값 제외
52
53
  const positionals = [];
53
54
  for (let i = 0; i < argv.length; i++) {
54
- if (argv[i] === '--base' || argv[i] === '--mapping' || argv[i] === '--exclude') {
55
+ if (argv[i] === '--base' || argv[i] === '--mapping' || argv[i] === '--exclude'
56
+ || argv[i] === '--to' || argv[i] === '--out') {
55
57
  i++;
56
58
  continue;
57
59
  }
@@ -76,6 +78,10 @@ if (positionals[0] === 'pull') {
76
78
  await runPull(argv);
77
79
  process.exit(0);
78
80
  }
81
+ if (positionals[0] === 'convert') {
82
+ await runConvert(argv);
83
+ process.exit(0);
84
+ }
79
85
  const baseInput = optVal('--base') ?? process.env.CONFLUENCE_SYNC_BASE;
80
86
  if (!baseInput) {
81
87
  console.error(red('✗ 동기화 루트가 지정되지 않았습니다.') + '\n' +
@@ -102,7 +108,20 @@ async function main() {
102
108
  const allRel = collected.filter((r) => !ignorer.ignores(r)); // 제외된 문서는 모든 단계에서 빠짐
103
109
  const ignoredCount = collected.length - allRel.length;
104
110
  const folderIndex = buildFolderIndex(allRel);
105
- const titleIndex = buildTitleIndex(allRel, BASE_DIR); // 링크 변환은 항상 전체 기준
111
+ // 링크·섹션 링크 변환은 항상 전체 기준(선택 동기화여도 대상 문서의 제목·헤딩을 알아야 한다)
112
+ const { titles: titleIndex, anchors: anchorIndex, docs } = buildDocIndex(allRel, BASE_DIR);
113
+ // Obsidian [[wikilink]] 해석용 이름 인덱스. 제외된 문서는 링크 대상에서도 빠진다.
114
+ const vault = buildVault(allRel, collectAssets(BASE_DIR).map((f) => relative(BASE_DIR, f)));
115
+ // 누가 어느 헤딩을 가리키는지 먼저 모은다 — 그 헤딩에만 Anchor 매크로를 심는다.
116
+ // 선택 동기화여도 트리 전체를 봐야 한다(다른 문서가 이 문서의 섹션을 가리킬 수 있으므로).
117
+ const anchorTargets = resolveAnchorTargets(docs, vault);
118
+ const render = (rel, body, title) => toStorage(body, rel, titleIndex, BASE_DIR, {
119
+ resolveLink: vaultResolver(rel, vault),
120
+ anchorIndex,
121
+ title,
122
+ anchorNames: anchorTargets[rel],
123
+ bodySlugs: docs[rel]?.bodySlugs,
124
+ });
106
125
  const selected = positionals.length
107
126
  ? withParents(resolveSelection(allRel, positionals, BASE_DIR), folderIndex)
108
127
  : allRel;
@@ -148,13 +167,21 @@ async function main() {
148
167
  const fex = mapping[`${d}/`];
149
168
  console.log(` [${fex?.pageId ? gray('폴더') : green('폴더+')}] 📁 ${d}/`);
150
169
  }
151
- const { title, body } = splitTitleAndBody(readFileSync(resolve(BASE_DIR, rel), 'utf8'), rel);
152
- const r = toStorage(body, rel, titleIndex, BASE_DIR);
170
+ const { title, body, fm } = readDoc(BASE_DIR, rel);
171
+ const r = render(rel, body, title);
153
172
  const hash = docHash(title, r);
154
173
  const ex = mapping[rel];
155
- const status = !ex?.pageId ? green('신규') : ex.hash === hash ? gray('동일') : yellow('변경');
174
+ // 매핑엔 없지만 frontmatter 에 pageId 가 있으면 신규 생성이 아니라 기존 페이지에 연결된다
175
+ const status = !ex?.pageId
176
+ ? (fm.pageId ? cyan('연결') : green('신규'))
177
+ : ex.hash === hash ? gray('동일') : yellow('변경');
156
178
  const pk = parentKeyOf(rel, folderIndex);
157
- console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}, 이미지: ${r.images.length})`)}`);
179
+ const anchorNote = r.anchorLinks || r.anchorsPlaced
180
+ ? `, 섹션링크: ${r.anchorLinks}, 앵커: ${r.anchorsPlaced}` : '';
181
+ console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}${anchorNote}, 이미지: ${r.images.length})`)}`);
182
+ if (r.deadAnchors.length) {
183
+ console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${r.deadAnchors.length}개: `) + dim(r.deadAnchors.slice(0, 3).join(' ')));
184
+ }
158
185
  }
159
186
  console.log('\n' + dim('--dry-run: 실제 호출 없음.'));
160
187
  return;
@@ -166,7 +193,7 @@ async function main() {
166
193
  saveMapping(MAPPING_PATH, {});
167
194
  }
168
195
  const work = REBUILD ? {} : mapping;
169
- let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0;
196
+ let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0, linked = 0;
170
197
  const rendered = new Map();
171
198
  const recreatedTitles = new Set(); // 이번에 재생성된 문서의 제목
172
199
  const touched = new Set(); // 이번에 발행(생성/갱신/재생성)된 문서
@@ -216,16 +243,37 @@ async function main() {
216
243
  console.error(yellow(` ✗ 건너뜀 ${rel} (상위 폴더 미생성)`));
217
244
  continue;
218
245
  }
219
- const { title, body } = splitTitleAndBody(readFileSync(resolve(BASE_DIR, rel), 'utf8'), rel);
220
- const r = toStorage(body, rel, titleIndex, BASE_DIR);
246
+ const { title, body, fm } = readDoc(BASE_DIR, rel);
247
+ const r = render(rel, body, title);
221
248
  const hash = docHash(title, r);
222
249
  const pk = parentKeyOf(rel, folderIndex);
250
+ if (r.deadAnchors.length) {
251
+ console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${rel}`) + dim(` ${r.deadAnchors.slice(0, 3).join(' ')}${r.deadAnchors.length > 3 ? ` 외 ${r.deadAnchors.length - 3}` : ''}`));
252
+ }
223
253
  const parentId = pk ? work[pk]?.pageId : env.parentId;
224
254
  rendered.set(rel, { ...r, title, hash, parentId });
225
255
  if (pk && !parentId) {
226
256
  console.error(yellow(` ✗ 건너뜀 ${rel} (부모 '${pk}' 가 아직 없음)`));
227
257
  continue;
228
258
  }
259
+ // pull 로 받은 문서(frontmatter 에 pageId)는 매핑이 없어도 원본 페이지에 다시 연결한다.
260
+ // 매핑 파일을 잃었거나 vault 를 다른 곳에 복제한 경우 중복 생성을 막아준다.
261
+ if (!work[rel]?.pageId && fm.pageId) {
262
+ try {
263
+ if (await client.getContentOrNull(fm.pageId)) {
264
+ work[rel] = { pageId: fm.pageId, title };
265
+ saveMapping(MAPPING_PATH, work);
266
+ console.log(` ${cyan('🔗 연결')} ${rel} ${dim(`→ 기존 페이지 #${fm.pageId}`)}`);
267
+ linked++;
268
+ }
269
+ else {
270
+ console.error(yellow(` ⚠ frontmatter 의 pageId #${fm.pageId} 를 찾을 수 없습니다 → 새로 생성 (${rel})`));
271
+ }
272
+ }
273
+ catch (e) {
274
+ console.error(yellow(` ⚠ pageId 확인 실패 #${fm.pageId}: ${e.message}`));
275
+ }
276
+ }
229
277
  try {
230
278
  const result = await client.upsertPage(work, spaceId, rel, title, r.storage, hash, parentId);
231
279
  saveMapping(MAPPING_PATH, work);
@@ -282,6 +330,7 @@ async function main() {
282
330
  ` ${magenta(`재생성 ${recreated}`)}` +
283
331
  ` ${cyan(`링크재연결 ${relinked}`)}` +
284
332
  ` ${gray(`변경없음 ${skipped}`)}` +
333
+ (linked ? ` ${cyan(`연결 ${linked}`)}` : '') +
285
334
  (foldersMade ? ` ${cyan(`폴더 ${foldersMade}`)}` : '') +
286
335
  ` ${dim(`대상 ${files.length}`)}`);
287
336
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonseok-han/confluence-sync",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Confluence Cloud ↔ Markdown 양방향 동기화 CLI — 마크다운을 Confluence 페이지로 발행(push)하고, Confluence 페이지·폴더·스페이스를 마크다운으로 가져옵니다(pull).",
6
6
  "keywords": [
@@ -26,7 +26,8 @@
26
26
  "node": ">=20"
27
27
  },
28
28
  "bin": {
29
- "confluence-sync": "dist/sync.js"
29
+ "confluence-sync": "dist/sync.js",
30
+ "csync": "dist/sync.js"
30
31
  },
31
32
  "files": [
32
33
  "dist",