@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/README.md CHANGED
@@ -5,7 +5,9 @@
5
5
  - **push** — 로컬 `.md` 디렉토리를 Confluence 페이지 트리로 발행. 폴더 구조가 그대로 페이지 계층이 됩니다.
6
6
  - **pull** — Confluence 페이지·폴더·**스페이스 전체**를 읽어 `.md` 로 가져옵니다.
7
7
 
8
- 문서를 git으로 관리하면서(docs-as-code) Confluence를 미러로 두거나, 반대로 Confluence에 쌓인 문서를 마크다운으로 내려받아 쓸 수 있습니다. 내려받은 마크다운은 Obsidian·VS Code 등 어디서든 그대로 열립니다.
8
+ 문서를 git으로 관리하면서(docs-as-code) Confluence를 미러로 두거나, 반대로 Confluence에 쌓인 문서를 마크다운으로 내려받아 쓸 수 있습니다.
9
+
10
+ **[Obsidian](#obsidian) vault 와 그대로 맞물립니다** — pull 한 문서는 내부 링크가 이어져 그래프 뷰가 동작하고, vault 에서 쓴 `[[wikilink]]`·`![[embed]]`·frontmatter 를 push 가 그대로 이해합니다.
9
11
 
10
12
  ---
11
13
 
@@ -20,6 +22,8 @@ confluence-sync --help
20
22
 
21
23
  공개 npm 패키지라 **토큰이나 `.npmrc` 설정이 필요 없습니다.** (Node 20+)
22
24
 
25
+ > 짧은 이름 **`csync`** 도 같이 설치됩니다 — 아래 예시의 `confluence-sync` 는 전부 `csync` 로 바꿔 쓸 수 있습니다.
26
+
23
27
  ## 빠른 시작
24
28
 
25
29
  ```bash
@@ -122,10 +126,38 @@ docs/ → [CONFLUENCE_PARENT_ID 또는 공간 최상위]
122
126
 
123
127
  ### 변환 규칙
124
128
 
125
- - 각 문서의 **첫 `# 제목`** 이 페이지 제목이 되고, 본문은 storage format 으로 변환됩니다.
129
+ - 페이지 제목은 **frontmatter `title` → 첫 `# 제목` → 파일명** 순으로 정해지고, 본문은 storage format 으로 변환됩니다.
126
130
  - 코드블록 → **code 매크로**, 표·리스트·헤딩은 그대로.
127
- - **내부 `.md` 링크** → Confluence 페이지 링크로 자동 변환.
128
- - **로컬 이미지** → 페이지 첨부로 업로드 후 참조 (같은 파일명은 새 버전으로 갱신). 외부 URL 이미지는 그대로.
131
+ - **내부 `.md` 링크**와 **`[[wikilink]]`** → Confluence 페이지 링크로 자동 변환.
132
+ - **섹션 링크** `[§1 개요](#1-개요)` · `[§6](./04-구현체.md#6-zkp)` · `[[문서#헤딩 텍스트]]` → Confluence 앵커 링크. [아래 참조](#섹션-링크-앵커)
133
+ - **로컬 이미지**와 **`![[embed]]`** → 페이지 첨부로 업로드 후 참조 (같은 파일명은 새 버전으로 갱신). 외부 URL 이미지는 그대로.
134
+ - **YAML frontmatter** 는 메타데이터로만 쓰이고 본문에 새지 않습니다.
135
+
136
+ ### 섹션 링크 (앵커)
137
+
138
+ 문서는 표준 마크다운 그대로 두면 됩니다. 이 도구가 Confluence 쪽 앵커를 만들어 맞춰 줍니다.
139
+
140
+ **Confluence 가 헤딩에 자동으로 붙이는 id 에는 기대지 않습니다.** 그 규칙은 Cloud 와 Data Center 가 다르고(Cloud 는 공백만 하이픈으로 바꾸고, DC 는 `페이지제목-헤딩` 형태) 예고 없이 바뀌기 때문입니다. 대신 **링크 대상이 되는 헤딩 앞에 [Anchor 매크로](https://confluence.atlassian.com/doc/anchors-139442.html)로 이름(= 마크다운 슬러그)을 직접 심고** 링크가 그 이름을 가리키게 합니다 — 양쪽 다 이 도구가 정한 값이라 어느 배포판에서도 맞습니다. 실제로 가리켜지는 헤딩에만 심으므로 페이지가 지저분해지지 않습니다.
141
+
142
+ | 쓰는 것 | Confluence 에서 |
143
+ | --- | --- |
144
+ | `[§1 개요](#1-개요)` | 같은 페이지의 그 헤딩으로 이동 |
145
+ | `[§6 ZKP](./04-구현체.md#6-zkp)` | 그 페이지의 그 헤딩으로 이동 |
146
+ | `[[04-구현체#6. ZKP]]` | 위와 동일 (Obsidian 표기) |
147
+
148
+ - 슬러그 규칙은 **GitHub 과 동일**합니다 — 소문자화 → 문자·숫자 아닌 것 제거 → 공백 하나당 하이픈 하나. 공백을 접지 않으므로 `제목 · 블록` 의 `--` 도 그대로 맞습니다. 겹치는 헤딩에는 `-1`·`-2` 가 붙습니다.
149
+ - 문서의 첫 `# H1` 을 가리키는 링크는 **페이지 최상단**이 됩니다 (H1 은 Confluence 페이지 제목이 되므로).
150
+ - **대응하는 헤딩을 못 찾으면 링크를 바꾸지 않고 경고합니다.** 엉뚱한 섹션으로 보내는 대신 알려주는 쪽을 택했습니다 — 대개 오타나 깨진 상대경로입니다.
151
+
152
+ ```
153
+ $ confluence-sync --base ./docs --dry-run
154
+ [변경] core-tech/03-standards.md → "표준 참조" (…, 내부링크: 36, 섹션링크: 30, 앵커: 15, 이미지: 0)
155
+ ⚠ 대응 헤딩 없는 앵커 poc/02-zkp/legacy/README.md #부록-a-개념-다리
156
+ ```
157
+
158
+ `섹션링크` 는 이 문서가 **가리키는** 링크 수, `앵커` 는 이 문서에 **심긴** 앵커 수입니다(다른 문서가 가리키는 것 포함).
159
+
160
+ pull 은 반대로 Confluence 앵커를 마크다운 슬러그로 되돌립니다(`--obsidian` 이면 `[[문서#헤딩 텍스트]]`).
129
161
 
130
162
  ### 변경 감지와 매핑
131
163
 
@@ -162,15 +194,115 @@ confluence-sync pull <pageId|url> # 한 페이지를 현재 폴
162
194
  confluence-sync pull <url> --out ./docs # 출력 디렉토리 지정
163
195
  confluence-sync pull <url> --out ./docs --children # 하위 페이지·폴더까지 트리로 복원
164
196
  confluence-sync pull --space --out ./docs # 스페이스 전체 (홈페이지부터 전부)
197
+ confluence-sync pull --space --obsidian --out ~/MyVault # 내부 링크를 [[wikilink]] 로
165
198
  ```
166
199
 
167
200
  - 대상은 **숫자 ID** 또는 **URL** (`.../pages/<ID>/...` 페이지, `.../folder/<ID>` 폴더) 둘 다 됩니다.
168
201
  - **`--children`** — 하위를 재귀 복원합니다. 자식이 있는 페이지는 `<제목>/README.md` 폴더로 펼쳐져 **push 계층 관례와 맞물립니다**.
169
202
  - **`--space`** — 대상 없이 스페이스 전체를 가져옵니다. 페이지가 많으면 시간이 걸립니다.
170
203
  - **이미지/첨부** 는 문서별로 `attachments/<문서명>/` 아래에 내려받고 링크를 그 경로로 씁니다 (문서 폴더가 이미지로 지저분해지지 않음). 다운로드에는 `read:attachment` 스코프가 필요합니다.
204
+ - **내부 링크** 는 같은 실행에서 함께 받은 페이지끼리 **상대 `.md` 링크로 이어집니다.** 받지 않은 페이지는 절대 URL 로 남습니다(깨진 링크를 만들지 않음).
205
+ - **섹션 링크** 는 Confluence 앵커(`#페이지제목헤딩텍스트`)를 그 문서의 **마크다운 슬러그로 되돌립니다** — 그대로 두면 Obsidian·GitHub 어디서도 걸리지 않기 때문입니다. `--obsidian` 이면 `[[문서#헤딩 텍스트]]` 로 씁니다.
206
+ - 각 문서 머리에 **frontmatter** 가 붙습니다 — `title` · `pageId` · `spaceKey` · `source` · `updated`. `pageId` 가 다시 push 할 때의 앵커입니다.
207
+ - 제목은 frontmatter 의 `title` 로만 들어가고 **본문에 `# 제목` 을 따로 넣지 않습니다** — 제목을 별도로 표시하는 뷰어(Obsidian 등)에서 두 번 보이기 때문입니다. push 는 `title` 을 먼저 읽으므로 왕복에 문제가 없습니다.
171
208
  - 본문은 `export_view`(HTML)를 [turndown](https://github.com/mixmark-io/turndown) 으로 변환합니다 — 코드블록(언어 포함, 미지정은 `plaintext`), GFM 표, 리스트 등.
172
209
 
173
- > **무손실 왕복은 아닙니다.** Confluence storage → Markdown 은 근사 변환이라 일부 매크로·레이아웃은 단순화됩니다. 내부 페이지 링크는 절대 URL로 유지됩니다.
210
+ > **무손실 왕복은 아닙니다.** Confluence storage → Markdown 은 근사 변환이라 일부 매크로·레이아웃은 단순화됩니다.
211
+
212
+ ---
213
+
214
+ ## Obsidian
215
+
216
+ pull 이 만드는 마크다운은 **vault 에 그대로 열립니다.** 별도 플러그인이 필요 없습니다.
217
+
218
+ ```bash
219
+ cd ~/MyVault
220
+ confluence-sync pull --space --out . # 스페이스 전체를 vault 로
221
+ confluence-sync pull --space --out . --obsidian # 내부 링크를 [[wikilink]] 로
222
+ ```
223
+
224
+ - 내부 링크가 이어져 있어 **그래프 뷰·백링크가 그대로 동작**합니다. 기본값인 상대 `.md` 링크도 Obsidian 에서 정상 동작하며 GitHub·VS Code 에서도 열립니다. vault 안에서만 쓸 거라면 `--obsidian` 으로 `[[wikilink]]` 를 받으세요.
225
+ - frontmatter 는 Obsidian 속성 패널에 표시되고, `source` 로 원본 페이지를 바로 열 수 있습니다.
226
+
227
+ ---
228
+
229
+ ## `convert` — 이미 받아둔 문서 손보기
230
+
231
+ **다시 pull 하지 않고** 로컬 `.md` 트리를 제자리에서 고칩니다. Confluence 를 호출하지 않으니 인증도 필요 없습니다.
232
+
233
+ ```bash
234
+ confluence-sync convert --to obsidian ./docs # 상대 .md 링크 → [[wikilink]]
235
+ confluence-sync convert --to markdown ./docs # 되돌리기
236
+ confluence-sync convert --fix ./docs # 옛 pull 결과 보정
237
+ confluence-sync convert --to obsidian --fix ./docs # 둘 다
238
+ confluence-sync convert --fix ./docs --dry-run # 바뀔 파일만 미리보기
239
+ ```
240
+
241
+ ### 대상 지정
242
+
243
+ 파일·폴더를 **여러 개** 줄 수 있고, 안 주면 base 전체가 대상입니다.
244
+
245
+ ```bash
246
+ confluence-sync convert --fix ./docs/가이드/설치.md # 파일 하나만
247
+ confluence-sync convert --fix ./docs/a.md ./docs/나머지/ # 파일 + 폴더 혼합
248
+ ```
249
+
250
+ `--base` 는 **링크를 해석하는 범위**입니다. 파일 하나만 고칠 때도 주변 문서를 알아야 이름이 유일한지, 링크 대상이 실재하는지 판정할 수 있기 때문입니다. 지정하지 않으면 준 경로들의 공통 상위 폴더가 되며, `--to` 로 링크를 바꿀 때는 **문서 트리 루트를 `--base` 로 알려주는 편이 정확합니다.**
251
+
252
+ ```bash
253
+ confluence-sync convert --to obsidian ./docs/가이드/설치.md --base ./docs
254
+ ```
255
+
256
+ ### 다른 곳에 내보내기 — `--out`
257
+
258
+ `--out` 없이 실행하면 **원본을 덮어씁니다.** 원본을 그대로 두고 결과만 따로 받으려면:
259
+
260
+ ```bash
261
+ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
262
+ ```
263
+
264
+ - base 기준 상대 경로를 그대로 재현하고, **본문이 참조하는 첨부(이미지)도 같이 복사**합니다 — 안 그러면 링크가 깨집니다.
265
+ - 안 바뀐 문서도 함께 내보내 **온전한 트리**가 나옵니다.
266
+ - `--out` 이 base 안이면 원본을 오염시키므로 거부합니다.
267
+
268
+ ### 링크 표기 (`--to`)
269
+
270
+ 두 방향은 서로의 역변환이라 **왕복해도 내용이 보존됩니다.** 안전하게 바꿀 수 있는 링크만 건드립니다.
271
+
272
+ - 외부 URL·이미지·깨진 링크(대상 파일 없음)는 **그대로 둡니다.**
273
+ - 파일명이 트리 안에서 겹치면(`README.md` 여러 개 등) `[[wikilink]]` 로 바꾸지 않고 상대 링크를 유지합니다 — 이름만으로는 어느 노트인지 확정할 수 없기 때문입니다.
274
+ - 코드블록·인라인 코드 안의 링크는 손대지 않습니다.
275
+ - **섹션 링크는 표기가 다릅니다** — 표준 마크다운은 슬러그(`문서.md#6-zkp`), Obsidian 은 헤딩 텍스트(`[[문서#6. ZKP]]`)를 씁니다. 대상 문서의 헤딩을 찾아 옮기고, 못 찾으면 섹션 없이 문서 링크로 둡니다. Obsidian 블록 참조(`#^id`)는 마크다운에 대응물이 없어 같은 방식으로 처리합니다.
276
+
277
+ ### 보정 (`--fix`)
278
+
279
+ 변환기가 개선되기 전에 pull 한 문서에는 옛 결함의 흔적이 남아 있습니다. 스페이스를 통째로 다시 받지 않고 그 흔적만 되돌립니다.
280
+
281
+ | 증상 | 보정 |
282
+ | --- | --- |
283
+ | frontmatter `title` 과 본문 `# 제목` 이 중복 | 겹치는 H1 제거 (Obsidian 에서 제목이 두 번 보임) |
284
+ | 모든 코드블록이 ` ```java ` | 실제 Java 가 아니면 `plaintext` 로 (Confluence 가 언어 미지정 시 `brush: java` 를 붙이는 탓) |
285
+ | 제목에 `[data-colorid=…]{color:#…}` | 본문에 새어 나온 CSS 제거 |
286
+ | `:11\_eleven\_blue:` · `\[선택 가능\]` · `**7️⃣**\-**1️⃣**` | 불필요한 `\` 이스케이프 해제 |
287
+ | 리스트 항목마다 빈 줄 | 같은 종류 항목끼리 붙임 (번호↔글머리 경계는 유지) |
288
+ | 코드블록 안 줄 끝 공백 | 정리 |
289
+
290
+ **멱등적입니다** — 두 번 돌려도 더 바뀌지 않고, 최신 pull 결과에는 아무것도 하지 않습니다. 애매하면 손대지 않는 쪽을 택하므로 진짜 Java 코드블록은 그대로 남습니다. 파일을 덮어쓰니 `--dry-run` 으로 먼저 확인하세요.
291
+
292
+ 반대로 **vault 를 그대로 push** 할 수 있습니다. Obsidian 문법을 그대로 이해합니다.
293
+
294
+ | vault 에서 쓴 것 | Confluence 에 올라가는 것 |
295
+ | --- | --- |
296
+ | `[[설계]]` · `[[구조/설계\|설계 문서]]` | 페이지 링크 (짧은 이름·경로 모두 인식) |
297
+ | `![[diagram.png]]` | 첨부 이미지 |
298
+ | frontmatter `title:` | 페이지 제목 |
299
+ | frontmatter `pageId:` | **그 페이지를 갱신** (새로 만들지 않음) |
300
+
301
+ - 코드블록·인라인 코드 안의 `[[...]]` 는 건드리지 않고, **대상을 못 찾은 링크는 원문 그대로** 둡니다(조용히 사라지지 않습니다).
302
+ - `.obsidian/` `.trash/` 등 숨김 폴더는 동기화 대상에서 제외됩니다.
303
+ - `pageId` 덕분에 매핑 파일(`.confluence-sync.json`)을 잃어버려도 **중복 페이지가 생기지 않습니다** — 원본을 찾아가 갱신합니다(`[연결]` 로 표시).
304
+
305
+ > pull → 편집 → push 왕복이 목적이라면 frontmatter 를 지우지 마세요. `pageId` 가 원본 페이지를 가리키는 유일한 단서입니다.
174
306
 
175
307
  ---
176
308
 
@@ -202,7 +334,7 @@ git push && git push --tags
202
334
 
203
335
  워크플로는 `package.json` 버전과 태그가 일치하는지 검증한 뒤 배포합니다. 변경 이력은 [CHANGELOG.md](CHANGELOG.md) 를 참고하세요.
204
336
 
205
- > 사전 설정: npm Automation 토큰을 저장소 **Settings → Secrets and variables → Actions** 에 `NPM_TOKEN` 으로 등록해야 합니다.
337
+ > npm 발행은 **Trusted Publishing(OIDC)** 을 씁니다 — 장기 토큰 없이 GitHub Actions 가 단기 자격증명으로 배포합니다.
206
338
 
207
339
  ## 라이선스
208
340
 
@@ -0,0 +1,157 @@
1
+ /**
2
+ * 헤딩 앵커 — 마크다운과 Confluence 의 서로 다른 "섹션 가리키기" 표기를 잇는다.
3
+ *
4
+ * 마크다운(GitHub·Obsidian·VS Code) [§1](#1-신뢰--trust-registry----블록-01) ← 슬러그
5
+ * Obsidian wikilink [[문서#1. 신뢰 — Trust Registry · 블록 01]] ← 헤딩 텍스트
6
+ *
7
+ * **Confluence 가 헤딩에 자동으로 붙이는 id 에는 기대지 않는다.** 그 규칙은 Cloud 와 Data Center 가
8
+ * 서로 다르고(‑ Cloud 는 공백만 하이픈으로 바꾸고, DC 는 `페이지제목-헤딩` 형태다) 예고 없이 바뀐다.
9
+ * 대신 링크 대상이 되는 헤딩 앞에 **Anchor 매크로로 이름을 직접 심고**, 링크는 그 이름을 가리킨다.
10
+ * 이름은 마크다운 슬러그를 그대로 쓰므로 왕복도 정확해진다 — 양쪽 다 우리가 정한 값이다.
11
+ */
12
+ const FENCE = /^\s*(?:```|~~~)/;
13
+ /** 인라인 마크다운 표기를 벗긴다 — 슬러그는 렌더된 텍스트 기준이다. */
14
+ function plainText(s) {
15
+ return s
16
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // [label](url) → label
17
+ .replace(/\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]/g, (_m, t, a) => a ?? t)
18
+ .replace(/`+/g, '')
19
+ .replace(/\*\*|~~|\*/g, '') // _ 는 남긴다: 슬러그가 밑줄을 보존하므로(foo_bar → foo_bar)
20
+ .trim();
21
+ }
22
+ /**
23
+ * GitHub 호환 헤딩 슬러그: 소문자 → 문자·숫자·공백·하이픈·밑줄 외 제거 → 공백 하나당 하이픈 하나.
24
+ * 공백을 **접지 않는** 것이 핵심이다 — `제목 · 블록` 처럼 구두점이 빠지면 하이픈이 여러 개 남는다.
25
+ */
26
+ export function slugifyHeading(text) {
27
+ return plainText(text)
28
+ .toLowerCase()
29
+ .replace(/[^\p{L}\p{N}\s_-]/gu, '')
30
+ .replace(/\s/g, '-');
31
+ }
32
+ /** 본문의 ATX 헤딩 목록(코드블록 안은 제외). 슬러그가 겹치면 GitHub 처럼 -1·-2 를 붙인다. */
33
+ export function collectHeadings(md) {
34
+ const out = [];
35
+ const used = new Map();
36
+ let inFence = false;
37
+ for (const line of md.split('\n')) {
38
+ if (FENCE.test(line)) {
39
+ inFence = !inFence;
40
+ continue;
41
+ }
42
+ if (inFence)
43
+ continue;
44
+ // 닫는 # 는 앞에 공백이 있을 때만 장식이다(`## C#` 의 # 를 지우지 않도록)
45
+ const m = line.match(/^(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/);
46
+ if (!m)
47
+ continue;
48
+ const text = m[2].trim();
49
+ if (!text)
50
+ continue;
51
+ const base = slugifyHeading(text);
52
+ const n = used.get(base) ?? 0;
53
+ used.set(base, n + 1);
54
+ out.push({ text, slug: n ? `${base}-${n}` : base, level: m[1].length });
55
+ }
56
+ return out;
57
+ }
58
+ /** 슬러그 → 헤딩 텍스트. 먼저 나온 헤딩이 이긴다. */
59
+ export function buildAnchorIndex(md) {
60
+ const idx = new Map();
61
+ for (const h of collectHeadings(md))
62
+ if (!idx.has(h.slug))
63
+ idx.set(h.slug, h.text);
64
+ return idx;
65
+ }
66
+ /** 퍼센트 인코딩된 앵커를 되돌린다(실패하면 원문). */
67
+ export const decodeAnchor = (s) => {
68
+ try {
69
+ return decodeURIComponent(s);
70
+ }
71
+ catch {
72
+ return s;
73
+ }
74
+ };
75
+ /**
76
+ * Confluence 앵커 비교용 키 — 공백·구두점을 모두 지운 소문자.
77
+ * Confluence 는 헤딩 앵커 id 를 만들 때 공백을 지우고 구두점 처리도 버전마다 달라서,
78
+ * 남는 글자(문자·숫자)만으로 맞춰야 안정적이다.
79
+ */
80
+ export const anchorKey = (s) => s.normalize('NFC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
81
+ /**
82
+ * Confluence 가 만든 앵커 fragment 를 이 문서의 헤딩에 맞춘다.
83
+ * id 는 보통 `<페이지제목><헤딩텍스트>`(공백 제거) 형태라 제목 접두사를 허용하고,
84
+ * 그래도 안 맞으면 "헤딩으로 끝나는가"로 한 번 더 시도한다(가장 긴 헤딩이 이긴다).
85
+ */
86
+ export function matchConfluenceAnchor(fragment, headings, pageTitle) {
87
+ const frag = anchorKey(decodeAnchor(fragment));
88
+ if (!frag)
89
+ return null;
90
+ const titleKey = anchorKey(pageTitle);
91
+ let loose = null;
92
+ let looseLen = 0;
93
+ for (const h of headings) {
94
+ const hk = anchorKey(h.text);
95
+ if (!hk)
96
+ continue;
97
+ if (frag === hk || frag === titleKey + hk)
98
+ return h; // 정확히 맞으면 바로
99
+ if (frag.endsWith(hk) && hk.length > looseLen) {
100
+ loose = h;
101
+ looseLen = hk.length;
102
+ }
103
+ }
104
+ return loose;
105
+ }
106
+ /**
107
+ * Obsidian wikilink 의 앵커(`[[문서#헤딩 텍스트]]`)를 마크다운 슬러그로.
108
+ * 중첩 헤딩(`#상위#하위`)은 마지막 것이 실제 목적지고, 블록 참조(`#^id`)는 마크다운에 대응물이 없다.
109
+ */
110
+ export function wikilinkAnchorToSlug(anchor) {
111
+ const last = anchor.split('#').filter(Boolean).pop();
112
+ if (!last || last.startsWith('^'))
113
+ return null;
114
+ return slugifyHeading(last) || null;
115
+ }
116
+ const MD_LINK = /\[([^\]]*)\]\((<[^>]+>|[^()\s]+)\)/g;
117
+ const WIKILINK = /(!?)\[\[([^\]|#]+)(#[^\]|]+)?(?:\|([^\]]+))?\]\]/g;
118
+ /**
119
+ * 본문에서 **앵커가 붙은 링크만** 훑는다 — 어느 문서의 어느 헤딩에 앵커를 심어야 하는지 알아내기 위해서다.
120
+ * 전체 렌더보다 훨씬 싸고, 코드블록·인라인 코드·이미지는 제외한다.
121
+ */
122
+ export function collectLinkAnchors(md) {
123
+ const out = [];
124
+ let inFence = false;
125
+ for (const line of md.split('\n')) {
126
+ if (FENCE.test(line)) {
127
+ inFence = !inFence;
128
+ continue;
129
+ }
130
+ if (inFence)
131
+ continue;
132
+ // 홀수 인덱스 = 인라인 코드 → 링크로 치지 않는다
133
+ const prose = line.split(/(`+[^`]*`+)/).filter((_, i) => i % 2 === 0).join('');
134
+ for (const m of prose.matchAll(MD_LINK)) {
135
+ if (prose[m.index - 1] === '!')
136
+ continue; // 이미지
137
+ const d = decodeAnchor(m[2].replace(/^<(.*)>$/, '$1'));
138
+ const i = d.indexOf('#');
139
+ if (i < 0 || i === d.length - 1)
140
+ continue;
141
+ if (/^(https?:|mailto:)/i.test(d))
142
+ continue;
143
+ out.push({ dest: d.slice(0, i), fragment: d.slice(i + 1), wiki: false });
144
+ }
145
+ for (const m of prose.matchAll(WIKILINK)) {
146
+ if (m[1] === '!' || !m[3])
147
+ continue; // 임베드·앵커 없는 wikilink
148
+ out.push({ dest: m[2].trim(), fragment: m[3].slice(1), wiki: true });
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+ /** Anchor 매크로 — 링크 대상 헤딩 바로 앞에 심어 이름을 고정한다. */
154
+ export function anchorMacro(name) {
155
+ const safe = name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
156
+ return `<ac:structured-macro ac:name="anchor"><ac:parameter ac:name="">${safe}</ac:parameter></ac:structured-macro>`;
157
+ }
@@ -158,17 +158,22 @@ export function createClient(cfg, opts) {
158
158
  return res.json();
159
159
  }
160
160
  /**
161
- * 콘텐츠 노드 조회. type + 제목 + export_view(HTML) + storage(원본 XHTML).
161
+ * 콘텐츠 노드 조회. type + 제목 + export_view(HTML) + storage(원본 XHTML) + 출처 메타.
162
162
  * storage 는 코드블록의 "진짜" 언어를 얻는 데 쓴다(export_view 는 언어 미지정 시 brush:java 기본값이 붙어 신뢰 불가).
163
+ * url/updated 는 frontmatter 의 출처 정보로 쓰이며, 응답에 없으면 undefined.
163
164
  */
164
165
  async function getNode(id) {
165
- const data = await getV1(`/rest/api/content/${id}?expand=body.export_view,body.storage`);
166
+ const data = await getV1(`/rest/api/content/${id}?expand=body.export_view,body.storage,version`);
167
+ const base = data._links?.base;
168
+ const webui = data._links?.webui;
166
169
  return {
167
170
  id: data.id,
168
171
  type: data.type ?? 'page',
169
172
  title: data.title ?? id,
170
173
  html: data.body?.export_view?.value ?? '',
171
174
  storage: data.body?.storage?.value ?? '',
175
+ url: base && webui ? `${base}${webui}` : undefined,
176
+ updated: data.version?.when,
172
177
  };
173
178
  }
174
179
  /** 자식 노드 목록(kind = page|folder, 전체 페이지네이션). */
@@ -0,0 +1,257 @@
1
+ /**
2
+ * `confluence-sync convert [--to obsidian|markdown] [--fix] [파일|폴더...] [--base <dir>] [--dry-run]`
3
+ * 이미 내려받은 .md 를 제자리에서 손본다. Confluence 호출도 인증도 없다.
4
+ *
5
+ * --to obsidian — 상대 .md 링크 → [[wikilink]]
6
+ * --to markdown — [[wikilink]] · ![[embed]] → 상대 .md 링크 (되돌리기)
7
+ * --fix — 옛 변환기가 남긴 흔적 보정(코드블록 언어·CSS 잔해·이스케이프·빈 줄)
8
+ *
9
+ * 스페이스를 통째로 다시 받지 않고도 최신 변환 품질을 얻기 위한 것이다.
10
+ * 링크는 대상이 트리 안에 실제로 있을 때만 바꾼다(외부 URL·이미지·깨진 링크는 손대지 않는다).
11
+ */
12
+ import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, copyFileSync } from 'node:fs';
13
+ import { resolve, relative, dirname, basename, join } from 'node:path';
14
+ import { collectMarkdown, collectAssets, buildVault, vaultResolver } from './docs.js';
15
+ import { linksToWikilinks, resolveWikilinks, splitFrontmatter } from './obsidian.js';
16
+ import { buildAnchorIndex } from './anchors.js';
17
+ import { repairMarkdown, totalFixes } from './repair.js';
18
+ import { bold, cyan, dim, gray, green, red, yellow } from './colors.js';
19
+ function optVal(argv, name) {
20
+ const i = argv.indexOf(name);
21
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
22
+ }
23
+ const decode = (s) => { try {
24
+ return decodeURIComponent(s);
25
+ }
26
+ catch {
27
+ return s;
28
+ } };
29
+ const posix = (s) => s.split('\\').join('/');
30
+ // ![alt](경로) — 본문이 참조하는 로컬 이미지(--out 으로 복사할 때 같이 옮긴다)
31
+ const IMAGE = /!\[[^\]]*\]\((<[^>]+>|[^()\s]+)\)/g;
32
+ /** md 본문이 참조하는 로컬 첨부의 절대경로들(외부 URL 제외, 실제 존재하는 것만). */
33
+ function referencedAssets(text, fileAbs) {
34
+ const out = [];
35
+ for (const m of text.matchAll(IMAGE)) {
36
+ const dest = decode(m[1].replace(/^<(.*)>$/, '$1')).split('#')[0];
37
+ if (/^(https?:|data:)/i.test(dest))
38
+ continue;
39
+ const abs = resolve(dirname(fileAbs), dest);
40
+ if (existsSync(abs))
41
+ out.push(abs);
42
+ }
43
+ return out;
44
+ }
45
+ /** 준 경로들을 모두 담는 가장 가까운 폴더(파일은 그 파일이 있는 폴더로 친다). 인자가 없으면 null. */
46
+ function commonAncestor(paths) {
47
+ if (!paths.length)
48
+ return null;
49
+ const dirs = paths.map((p) => {
50
+ const abs = resolve(p);
51
+ return statSync(abs).isDirectory() ? abs : dirname(abs);
52
+ });
53
+ const split = dirs.map((d) => d.split('/'));
54
+ const head = split[0];
55
+ let i = 0;
56
+ while (i < head.length && split.every((s) => s[i] === head[i]))
57
+ i++;
58
+ return split.length === 1 ? dirs[0] : head.slice(0, i).join('/') || '/';
59
+ }
60
+ /**
61
+ * 상대 .md 링크 → [[wikilink]].
62
+ * wikilink 는 vault 어디서든 "이름"으로 찾으므로, 파일명이 트리 안에서 유일할 때만 바꾼다
63
+ * (겹치면 엉뚱한 노트로 이어질 수 있어 상대 링크를 그대로 둔다).
64
+ */
65
+ function toObsidian(text, fileAbs, baseDir, nameCount, headingOf) {
66
+ return linksToWikilinks(text, (dest, label) => {
67
+ if (/^(https?:|mailto:|#)/i.test(dest))
68
+ return null; // 외부 링크·같은 문서 앵커
69
+ const [pathPart, ...hash] = decode(dest).split('#');
70
+ if (!/\.md$/i.test(pathPart))
71
+ return null;
72
+ const targetAbs = resolve(dirname(fileAbs), pathPart);
73
+ if (!existsSync(targetAbs))
74
+ return null; // 깨진 링크는 손대지 않는다
75
+ if (relative(baseDir, targetAbs).startsWith('..'))
76
+ return null; // 트리 밖
77
+ const name = basename(targetAbs).replace(/\.md$/i, '');
78
+ if (nameCount.get(name) !== 1)
79
+ return null; // 이름이 겹치면 상대 링크 유지
80
+ // Obsidian 의 섹션 링크는 슬러그가 아니라 헤딩 텍스트다. 대상 문서에서 헤딩을 못 찾으면
81
+ // 슬러그를 그대로 넣어봤자 안 걸리므로, 링크는 살리되 섹션은 떼고 문서 최상단으로 보낸다.
82
+ const slug = hash.join('#');
83
+ const heading = slug ? headingOf(targetAbs, slug) : undefined;
84
+ const anchor = heading ? `#${heading}` : '';
85
+ const l = label.trim();
86
+ return l && l !== name ? `[[${name}${anchor}|${l}]]` : anchor ? `[[${name}${anchor}|${name}]]` : `[[${name}]]`;
87
+ });
88
+ }
89
+ export async function runConvert(argv) {
90
+ const toRaw = optVal(argv, '--to');
91
+ const fix = argv.includes('--fix');
92
+ if (toRaw !== undefined && toRaw !== 'obsidian' && toRaw !== 'markdown') {
93
+ console.error(red(`✗ 알 수 없는 변환 방향: ${toRaw}`) + '\n --to obsidian | --to markdown');
94
+ process.exit(1);
95
+ }
96
+ const to = toRaw;
97
+ if (!to && !fix) {
98
+ console.error(red('✗ 할 일을 지정하세요: --to <형식> 또는 --fix') +
99
+ `\n 예) ${cyan('confluence-sync convert --to obsidian ./docs')}` +
100
+ `\n ${cyan('confluence-sync convert --fix ./docs')} ${dim('(옛 pull 결과 보정)')}` +
101
+ `\n ${cyan('confluence-sync convert --to obsidian --fix ./docs')} ${dim('(둘 다)')}`);
102
+ process.exit(1);
103
+ }
104
+ const dryRun = argv.includes('--dry-run');
105
+ // --out: 원본을 두고 변환 결과를 다른 트리에 쓴다(base 기준 상대 경로를 그대로 재현)
106
+ const outOpt = optVal(argv, '--out');
107
+ const outDir = outOpt ? resolve(outOpt) : undefined;
108
+ // 위치 인자: ['convert', <파일|폴더>...] (옵션 값은 건너뛴다)
109
+ const positionals = [];
110
+ for (let i = 0; i < argv.length; i++) {
111
+ if (argv[i] === '--to' || argv[i] === '--base' || argv[i] === '--out') {
112
+ i++;
113
+ continue;
114
+ }
115
+ if (argv[i].startsWith('-'))
116
+ continue;
117
+ positionals.push(argv[i]);
118
+ }
119
+ const targets = positionals.slice(1); // [0] 은 'convert'
120
+ for (const t of targets) {
121
+ if (!existsSync(resolve(t))) {
122
+ console.error(red(`✗ 없는 경로: ${t}`));
123
+ process.exit(1);
124
+ }
125
+ }
126
+ /**
127
+ * baseDir 은 **링크를 해석하는 범위**다. 파일 하나만 고칠 때도 주변 문서를 알아야
128
+ * (이름이 유일한지·링크 대상이 실재하는지) 판정할 수 있다.
129
+ * 기본값은 준 경로들의 공통 상위 폴더 — 폴더를 주면 그 폴더, 파일을 주면 그 파일이 있는 폴더다.
130
+ * 링크 변환(--to)에서 트리 전체를 봐야 한다면 --base 로 루트를 지정한다.
131
+ */
132
+ const baseOpt = optVal(argv, '--base');
133
+ const baseDir = resolve(baseOpt ?? commonAncestor(targets) ?? process.cwd());
134
+ if (!existsSync(baseDir) || !statSync(baseDir).isDirectory()) {
135
+ console.error(red(`✗ --base 가 디렉토리가 아닙니다: ${baseDir}`));
136
+ process.exit(1);
137
+ }
138
+ const allMd = collectMarkdown(baseDir); // 링크 해석용(전체)
139
+ const mdRels = allMd.map((f) => posix(relative(baseDir, f)));
140
+ const vault = buildVault(mdRels, collectAssets(baseDir).map((f) => posix(relative(baseDir, f))));
141
+ // obsidian 방향의 이름 중복 검사도 트리 전체 기준이어야 한다
142
+ const nameCount = new Map();
143
+ for (const f of allMd) {
144
+ const n = basename(f).replace(/\.md$/i, '');
145
+ nameCount.set(n, (nameCount.get(n) ?? 0) + 1);
146
+ }
147
+ // 섹션 링크(#슬러그 ↔ #헤딩 텍스트)를 옮기려면 대상 문서의 헤딩을 알아야 한다. 필요할 때만 읽는다.
148
+ const anchorCache = new Map();
149
+ const headingOf = (targetAbs, slug) => {
150
+ let idx = anchorCache.get(targetAbs);
151
+ if (!idx) {
152
+ idx = buildAnchorIndex(splitFrontmatter(readFileSync(targetAbs, 'utf8')).body);
153
+ anchorCache.set(targetAbs, idx);
154
+ }
155
+ return idx.get(slug.toLowerCase());
156
+ };
157
+ // 실제로 고쳐 쓸 파일(선택). 경로를 안 주면 base 전체.
158
+ const selected = targets.length
159
+ ? allMd.filter((f) => targets.some((t) => {
160
+ const abs = resolve(t);
161
+ return f === abs || f.startsWith(abs + '/');
162
+ }))
163
+ : allMd;
164
+ // base 밖의 .md 를 직접 지목한 경우(예: 이웃 폴더 파일) 는 위 필터에 안 걸린다
165
+ const outside = targets
166
+ .map((t) => resolve(t))
167
+ .filter((abs) => statSync(abs).isFile() && !allMd.includes(abs));
168
+ if (outside.length) {
169
+ console.error(yellow(`⚠ --base(${baseDir}) 밖의 파일은 건너뜁니다:`) + '\n ' + outside.join('\n '));
170
+ }
171
+ if (!selected.length) {
172
+ console.error(red('✗ 대상 .md 가 없습니다.') + dim('\n (파일을 직접 지목했다면 --base 로 문서 트리 루트를 알려주세요)'));
173
+ process.exit(1);
174
+ }
175
+ const mdAbs = selected;
176
+ const jobs = [
177
+ to === 'obsidian' ? '상대 .md 링크 → [[wikilink]]' : to === 'markdown' ? '[[wikilink]] → 상대 .md 링크' : '',
178
+ fix ? '변환 흔적 보정' : '',
179
+ ].filter(Boolean).join(' + ');
180
+ const scope = mdAbs.length === allMd.length
181
+ ? `${mdAbs.length}건`
182
+ : `선택 ${mdAbs.length}/${allMd.length}건`;
183
+ if (outDir && (outDir === baseDir || outDir.startsWith(baseDir + '/'))) {
184
+ console.error(red(`✗ --out 이 base 안에 있습니다: ${outDir}`) + dim('\n 원본 트리를 덮어쓰지 않도록 base 밖의 경로를 쓰세요.'));
185
+ process.exit(1);
186
+ }
187
+ console.log(`${dim('base:')} ${cyan(baseDir)}\n` +
188
+ `${dim('대상:')} ${scope} ${dim('—')} ${jobs}` +
189
+ (outDir ? `\n${dim('출력:')} ${cyan(outDir)} ${dim('(원본 유지)')}` : '') +
190
+ (dryRun ? dim(' (dry-run)') : ''));
191
+ let changed = 0;
192
+ const copiedAssets = new Set();
193
+ const total = { cssJunk: 0, codeLang: 0, trailingWs: 0, escapes: 0, tightList: 0, dupTitle: 0 };
194
+ for (const abs of mdAbs) {
195
+ const rel = posix(relative(baseDir, abs));
196
+ const before = readFileSync(abs, 'utf8');
197
+ let text = before;
198
+ const notes = [];
199
+ // 보정을 먼저 한다 — 이스케이프가 풀려야(\[…\] → […]) 링크 인식이 정확해진다
200
+ if (fix) {
201
+ const r = repairMarkdown(text);
202
+ text = r.text;
203
+ const n = totalFixes(r.stats);
204
+ if (n) {
205
+ for (const k of Object.keys(total))
206
+ total[k] += r.stats[k];
207
+ notes.push(dim(`보정 ${n}`));
208
+ }
209
+ }
210
+ if (to === 'obsidian')
211
+ text = toObsidian(text, abs, baseDir, nameCount, headingOf);
212
+ else if (to === 'markdown')
213
+ text = resolveWikilinks(text, vaultResolver(rel, vault));
214
+ // 제자리 변환이면 바뀐 것만 쓰지만, --out 이면 대상 전부를 내보낸다(온전한 트리가 나와야 하므로)
215
+ if (!outDir && text === before)
216
+ continue;
217
+ if (outDir) {
218
+ const target = join(outDir, rel);
219
+ if (!dryRun) {
220
+ mkdirSync(dirname(target), { recursive: true });
221
+ writeFileSync(target, text);
222
+ // 이미지가 따라가지 않으면 링크가 깨진다 — 참조된 첨부를 같은 상대 위치로 복사
223
+ for (const asset of referencedAssets(text, abs)) {
224
+ const arel = posix(relative(baseDir, asset));
225
+ if (arel.startsWith('..') || copiedAssets.has(arel))
226
+ continue; // base 밖 첨부는 두고 온다
227
+ const dstAsset = join(outDir, arel);
228
+ mkdirSync(dirname(dstAsset), { recursive: true });
229
+ copyFileSync(asset, dstAsset);
230
+ copiedAssets.add(arel);
231
+ }
232
+ }
233
+ }
234
+ else if (!dryRun) {
235
+ writeFileSync(abs, text);
236
+ }
237
+ // --out 이면 안 바뀐 파일도 그대로 내보내지만, 로그는 실제로 손댄 것만 남긴다
238
+ if (text !== before) {
239
+ changed++;
240
+ console.log(` ${cyan('↻')} ${rel}${notes.length ? ' ' + notes.join(' ') : ''}`);
241
+ }
242
+ }
243
+ console.log('\n' + bold('완료') + ` ${changed ? green(`${changed}개 파일 변경`) : gray('고칠 것 없음')}` +
244
+ (outDir ? ` ${cyan(`${mdAbs.length}개 파일 출력`)}${copiedAssets.size ? dim(`, 첨부 ${copiedAssets.size}`) : ''}` : '') +
245
+ ` ${dim(`대상 ${mdAbs.length}`)}`);
246
+ if (fix && totalFixes(total)) {
247
+ console.log(dim(' 보정:') +
248
+ ` 중복 제목 ${total.dupTitle}` +
249
+ ` 코드블록 언어 ${total.codeLang}` +
250
+ ` CSS 잔해 ${total.cssJunk}` +
251
+ ` 이스케이프 ${total.escapes}` +
252
+ ` 리스트 빈 줄 ${total.tightList}` +
253
+ ` 줄끝 공백 ${total.trailingWs}`);
254
+ }
255
+ if (dryRun)
256
+ console.log(dim('--dry-run: 파일을 쓰지 않았습니다.'));
257
+ }