@wonseok-han/confluence-sync 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,11 @@ 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
+ - **로컬 이미지**와 **`![[embed]]`** → 페이지 첨부로 업로드 후 참조 (같은 파일명은 새 버전으로 갱신). 외부 URL 이미지는 그대로.
133
+ - **YAML frontmatter** 는 메타데이터로만 쓰이고 본문에 새지 않습니다.
129
134
 
130
135
  ### 변경 감지와 매핑
131
136
 
@@ -162,15 +167,113 @@ confluence-sync pull <pageId|url> # 한 페이지를 현재 폴
162
167
  confluence-sync pull <url> --out ./docs # 출력 디렉토리 지정
163
168
  confluence-sync pull <url> --out ./docs --children # 하위 페이지·폴더까지 트리로 복원
164
169
  confluence-sync pull --space --out ./docs # 스페이스 전체 (홈페이지부터 전부)
170
+ confluence-sync pull --space --obsidian --out ~/MyVault # 내부 링크를 [[wikilink]] 로
165
171
  ```
166
172
 
167
173
  - 대상은 **숫자 ID** 또는 **URL** (`.../pages/<ID>/...` 페이지, `.../folder/<ID>` 폴더) 둘 다 됩니다.
168
174
  - **`--children`** — 하위를 재귀 복원합니다. 자식이 있는 페이지는 `<제목>/README.md` 폴더로 펼쳐져 **push 계층 관례와 맞물립니다**.
169
175
  - **`--space`** — 대상 없이 스페이스 전체를 가져옵니다. 페이지가 많으면 시간이 걸립니다.
170
176
  - **이미지/첨부** 는 문서별로 `attachments/<문서명>/` 아래에 내려받고 링크를 그 경로로 씁니다 (문서 폴더가 이미지로 지저분해지지 않음). 다운로드에는 `read:attachment` 스코프가 필요합니다.
177
+ - **내부 링크** 는 같은 실행에서 함께 받은 페이지끼리 **상대 `.md` 링크로 이어집니다.** 받지 않은 페이지는 절대 URL 로 남습니다(깨진 링크를 만들지 않음).
178
+ - 각 문서 머리에 **frontmatter** 가 붙습니다 — `title` · `pageId` · `spaceKey` · `source` · `updated`. `pageId` 가 다시 push 할 때의 앵커입니다.
179
+ - 제목은 frontmatter 의 `title` 로만 들어가고 **본문에 `# 제목` 을 따로 넣지 않습니다** — 제목을 별도로 표시하는 뷰어(Obsidian 등)에서 두 번 보이기 때문입니다. push 는 `title` 을 먼저 읽으므로 왕복에 문제가 없습니다.
171
180
  - 본문은 `export_view`(HTML)를 [turndown](https://github.com/mixmark-io/turndown) 으로 변환합니다 — 코드블록(언어 포함, 미지정은 `plaintext`), GFM 표, 리스트 등.
172
181
 
173
- > **무손실 왕복은 아닙니다.** Confluence storage → Markdown 은 근사 변환이라 일부 매크로·레이아웃은 단순화됩니다. 내부 페이지 링크는 절대 URL로 유지됩니다.
182
+ > **무손실 왕복은 아닙니다.** Confluence storage → Markdown 은 근사 변환이라 일부 매크로·레이아웃은 단순화됩니다.
183
+
184
+ ---
185
+
186
+ ## Obsidian
187
+
188
+ pull 이 만드는 마크다운은 **vault 에 그대로 열립니다.** 별도 플러그인이 필요 없습니다.
189
+
190
+ ```bash
191
+ cd ~/MyVault
192
+ confluence-sync pull --space --out . # 스페이스 전체를 vault 로
193
+ confluence-sync pull --space --out . --obsidian # 내부 링크를 [[wikilink]] 로
194
+ ```
195
+
196
+ - 내부 링크가 이어져 있어 **그래프 뷰·백링크가 그대로 동작**합니다. 기본값인 상대 `.md` 링크도 Obsidian 에서 정상 동작하며 GitHub·VS Code 에서도 열립니다. vault 안에서만 쓸 거라면 `--obsidian` 으로 `[[wikilink]]` 를 받으세요.
197
+ - frontmatter 는 Obsidian 속성 패널에 표시되고, `source` 로 원본 페이지를 바로 열 수 있습니다.
198
+
199
+ ---
200
+
201
+ ## `convert` — 이미 받아둔 문서 손보기
202
+
203
+ **다시 pull 하지 않고** 로컬 `.md` 트리를 제자리에서 고칩니다. Confluence 를 호출하지 않으니 인증도 필요 없습니다.
204
+
205
+ ```bash
206
+ confluence-sync convert --to obsidian ./docs # 상대 .md 링크 → [[wikilink]]
207
+ confluence-sync convert --to markdown ./docs # 되돌리기
208
+ confluence-sync convert --fix ./docs # 옛 pull 결과 보정
209
+ confluence-sync convert --to obsidian --fix ./docs # 둘 다
210
+ confluence-sync convert --fix ./docs --dry-run # 바뀔 파일만 미리보기
211
+ ```
212
+
213
+ ### 대상 지정
214
+
215
+ 파일·폴더를 **여러 개** 줄 수 있고, 안 주면 base 전체가 대상입니다.
216
+
217
+ ```bash
218
+ confluence-sync convert --fix ./docs/가이드/설치.md # 파일 하나만
219
+ confluence-sync convert --fix ./docs/a.md ./docs/나머지/ # 파일 + 폴더 혼합
220
+ ```
221
+
222
+ `--base` 는 **링크를 해석하는 범위**입니다. 파일 하나만 고칠 때도 주변 문서를 알아야 이름이 유일한지, 링크 대상이 실재하는지 판정할 수 있기 때문입니다. 지정하지 않으면 준 경로들의 공통 상위 폴더가 되며, `--to` 로 링크를 바꿀 때는 **문서 트리 루트를 `--base` 로 알려주는 편이 정확합니다.**
223
+
224
+ ```bash
225
+ confluence-sync convert --to obsidian ./docs/가이드/설치.md --base ./docs
226
+ ```
227
+
228
+ ### 다른 곳에 내보내기 — `--out`
229
+
230
+ `--out` 없이 실행하면 **원본을 덮어씁니다.** 원본을 그대로 두고 결과만 따로 받으려면:
231
+
232
+ ```bash
233
+ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
234
+ ```
235
+
236
+ - base 기준 상대 경로를 그대로 재현하고, **본문이 참조하는 첨부(이미지)도 같이 복사**합니다 — 안 그러면 링크가 깨집니다.
237
+ - 안 바뀐 문서도 함께 내보내 **온전한 트리**가 나옵니다.
238
+ - `--out` 이 base 안이면 원본을 오염시키므로 거부합니다.
239
+
240
+ ### 링크 표기 (`--to`)
241
+
242
+ 두 방향은 서로의 역변환이라 **왕복해도 내용이 보존됩니다.** 안전하게 바꿀 수 있는 링크만 건드립니다.
243
+
244
+ - 외부 URL·이미지·깨진 링크(대상 파일 없음)는 **그대로 둡니다.**
245
+ - 파일명이 트리 안에서 겹치면(`README.md` 여러 개 등) `[[wikilink]]` 로 바꾸지 않고 상대 링크를 유지합니다 — 이름만으로는 어느 노트인지 확정할 수 없기 때문입니다.
246
+ - 코드블록·인라인 코드 안의 링크는 손대지 않습니다.
247
+
248
+ ### 보정 (`--fix`)
249
+
250
+ 변환기가 개선되기 전에 pull 한 문서에는 옛 결함의 흔적이 남아 있습니다. 스페이스를 통째로 다시 받지 않고 그 흔적만 되돌립니다.
251
+
252
+ | 증상 | 보정 |
253
+ | --- | --- |
254
+ | frontmatter `title` 과 본문 `# 제목` 이 중복 | 겹치는 H1 제거 (Obsidian 에서 제목이 두 번 보임) |
255
+ | 모든 코드블록이 ` ```java ` | 실제 Java 가 아니면 `plaintext` 로 (Confluence 가 언어 미지정 시 `brush: java` 를 붙이는 탓) |
256
+ | 제목에 `[data-colorid=…]{color:#…}` | 본문에 새어 나온 CSS 제거 |
257
+ | `:11\_eleven\_blue:` · `\[선택 가능\]` · `**7️⃣**\-**1️⃣**` | 불필요한 `\` 이스케이프 해제 |
258
+ | 리스트 항목마다 빈 줄 | 같은 종류 항목끼리 붙임 (번호↔글머리 경계는 유지) |
259
+ | 코드블록 안 줄 끝 공백 | 정리 |
260
+
261
+ **멱등적입니다** — 두 번 돌려도 더 바뀌지 않고, 최신 pull 결과에는 아무것도 하지 않습니다. 애매하면 손대지 않는 쪽을 택하므로 진짜 Java 코드블록은 그대로 남습니다. 파일을 덮어쓰니 `--dry-run` 으로 먼저 확인하세요.
262
+
263
+ 반대로 **vault 를 그대로 push** 할 수 있습니다. Obsidian 문법을 그대로 이해합니다.
264
+
265
+ | vault 에서 쓴 것 | Confluence 에 올라가는 것 |
266
+ | --- | --- |
267
+ | `[[설계]]` · `[[구조/설계\|설계 문서]]` | 페이지 링크 (짧은 이름·경로 모두 인식) |
268
+ | `![[diagram.png]]` | 첨부 이미지 |
269
+ | frontmatter `title:` | 페이지 제목 |
270
+ | frontmatter `pageId:` | **그 페이지를 갱신** (새로 만들지 않음) |
271
+
272
+ - 코드블록·인라인 코드 안의 `[[...]]` 는 건드리지 않고, **대상을 못 찾은 링크는 원문 그대로** 둡니다(조용히 사라지지 않습니다).
273
+ - `.obsidian/` `.trash/` 등 숨김 폴더는 동기화 대상에서 제외됩니다.
274
+ - `pageId` 덕분에 매핑 파일(`.confluence-sync.json`)을 잃어버려도 **중복 페이지가 생기지 않습니다** — 원본을 찾아가 갱신합니다(`[연결]` 로 표시).
275
+
276
+ > pull → 편집 → push 왕복이 목적이라면 frontmatter 를 지우지 마세요. `pageId` 가 원본 페이지를 가리키는 유일한 단서입니다.
174
277
 
175
278
  ---
176
279
 
@@ -202,7 +305,7 @@ git push && git push --tags
202
305
 
203
306
  워크플로는 `package.json` 버전과 태그가 일치하는지 검증한 뒤 배포합니다. 변경 이력은 [CHANGELOG.md](CHANGELOG.md) 를 참고하세요.
204
307
 
205
- > 사전 설정: npm Automation 토큰을 저장소 **Settings → Secrets and variables → Actions** 에 `NPM_TOKEN` 으로 등록해야 합니다.
308
+ > npm 발행은 **Trusted Publishing(OIDC)** 을 씁니다 — 장기 토큰 없이 GitHub Actions 가 단기 자격증명으로 배포합니다.
206
309
 
207
310
  ## 라이선스
208
311
 
@@ -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,242 @@
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 } from './obsidian.js';
16
+ import { repairMarkdown, totalFixes } from './repair.js';
17
+ import { bold, cyan, dim, gray, green, red, yellow } from './colors.js';
18
+ function optVal(argv, name) {
19
+ const i = argv.indexOf(name);
20
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
21
+ }
22
+ const decode = (s) => { try {
23
+ return decodeURIComponent(s);
24
+ }
25
+ catch {
26
+ return s;
27
+ } };
28
+ const posix = (s) => s.split('\\').join('/');
29
+ // ![alt](경로) — 본문이 참조하는 로컬 이미지(--out 으로 복사할 때 같이 옮긴다)
30
+ const IMAGE = /!\[[^\]]*\]\((<[^>]+>|[^()\s]+)\)/g;
31
+ /** md 본문이 참조하는 로컬 첨부의 절대경로들(외부 URL 제외, 실제 존재하는 것만). */
32
+ function referencedAssets(text, fileAbs) {
33
+ const out = [];
34
+ for (const m of text.matchAll(IMAGE)) {
35
+ const dest = decode(m[1].replace(/^<(.*)>$/, '$1')).split('#')[0];
36
+ if (/^(https?:|data:)/i.test(dest))
37
+ continue;
38
+ const abs = resolve(dirname(fileAbs), dest);
39
+ if (existsSync(abs))
40
+ out.push(abs);
41
+ }
42
+ return out;
43
+ }
44
+ /** 준 경로들을 모두 담는 가장 가까운 폴더(파일은 그 파일이 있는 폴더로 친다). 인자가 없으면 null. */
45
+ function commonAncestor(paths) {
46
+ if (!paths.length)
47
+ return null;
48
+ const dirs = paths.map((p) => {
49
+ const abs = resolve(p);
50
+ return statSync(abs).isDirectory() ? abs : dirname(abs);
51
+ });
52
+ const split = dirs.map((d) => d.split('/'));
53
+ const head = split[0];
54
+ let i = 0;
55
+ while (i < head.length && split.every((s) => s[i] === head[i]))
56
+ i++;
57
+ return split.length === 1 ? dirs[0] : head.slice(0, i).join('/') || '/';
58
+ }
59
+ /**
60
+ * 상대 .md 링크 → [[wikilink]].
61
+ * wikilink 는 vault 어디서든 "이름"으로 찾으므로, 파일명이 트리 안에서 유일할 때만 바꾼다
62
+ * (겹치면 엉뚱한 노트로 이어질 수 있어 상대 링크를 그대로 둔다).
63
+ */
64
+ function toObsidian(text, fileAbs, baseDir, nameCount) {
65
+ return linksToWikilinks(text, (dest, label) => {
66
+ if (/^(https?:|mailto:|#)/i.test(dest))
67
+ return null; // 외부 링크·앵커
68
+ const [pathPart, ...hash] = decode(dest).split('#');
69
+ if (!/\.md$/i.test(pathPart))
70
+ return null;
71
+ const targetAbs = resolve(dirname(fileAbs), pathPart);
72
+ if (!existsSync(targetAbs))
73
+ return null; // 깨진 링크는 손대지 않는다
74
+ if (relative(baseDir, targetAbs).startsWith('..'))
75
+ return null; // 트리 밖
76
+ const name = basename(targetAbs).replace(/\.md$/i, '');
77
+ if (nameCount.get(name) !== 1)
78
+ return null; // 이름이 겹치면 상대 링크 유지
79
+ const anchor = hash.length ? `#${hash.join('#')}` : '';
80
+ const l = label.trim();
81
+ return l && l !== name ? `[[${name}${anchor}|${l}]]` : `[[${name}${anchor}]]`;
82
+ });
83
+ }
84
+ export async function runConvert(argv) {
85
+ const toRaw = optVal(argv, '--to');
86
+ const fix = argv.includes('--fix');
87
+ if (toRaw !== undefined && toRaw !== 'obsidian' && toRaw !== 'markdown') {
88
+ console.error(red(`✗ 알 수 없는 변환 방향: ${toRaw}`) + '\n --to obsidian | --to markdown');
89
+ process.exit(1);
90
+ }
91
+ const to = toRaw;
92
+ if (!to && !fix) {
93
+ console.error(red('✗ 할 일을 지정하세요: --to <형식> 또는 --fix') +
94
+ `\n 예) ${cyan('confluence-sync convert --to obsidian ./docs')}` +
95
+ `\n ${cyan('confluence-sync convert --fix ./docs')} ${dim('(옛 pull 결과 보정)')}` +
96
+ `\n ${cyan('confluence-sync convert --to obsidian --fix ./docs')} ${dim('(둘 다)')}`);
97
+ process.exit(1);
98
+ }
99
+ const dryRun = argv.includes('--dry-run');
100
+ // --out: 원본을 두고 변환 결과를 다른 트리에 쓴다(base 기준 상대 경로를 그대로 재현)
101
+ const outOpt = optVal(argv, '--out');
102
+ const outDir = outOpt ? resolve(outOpt) : undefined;
103
+ // 위치 인자: ['convert', <파일|폴더>...] (옵션 값은 건너뛴다)
104
+ const positionals = [];
105
+ for (let i = 0; i < argv.length; i++) {
106
+ if (argv[i] === '--to' || argv[i] === '--base' || argv[i] === '--out') {
107
+ i++;
108
+ continue;
109
+ }
110
+ if (argv[i].startsWith('-'))
111
+ continue;
112
+ positionals.push(argv[i]);
113
+ }
114
+ const targets = positionals.slice(1); // [0] 은 'convert'
115
+ for (const t of targets) {
116
+ if (!existsSync(resolve(t))) {
117
+ console.error(red(`✗ 없는 경로: ${t}`));
118
+ process.exit(1);
119
+ }
120
+ }
121
+ /**
122
+ * baseDir 은 **링크를 해석하는 범위**다. 파일 하나만 고칠 때도 주변 문서를 알아야
123
+ * (이름이 유일한지·링크 대상이 실재하는지) 판정할 수 있다.
124
+ * 기본값은 준 경로들의 공통 상위 폴더 — 폴더를 주면 그 폴더, 파일을 주면 그 파일이 있는 폴더다.
125
+ * 링크 변환(--to)에서 트리 전체를 봐야 한다면 --base 로 루트를 지정한다.
126
+ */
127
+ const baseOpt = optVal(argv, '--base');
128
+ const baseDir = resolve(baseOpt ?? commonAncestor(targets) ?? process.cwd());
129
+ if (!existsSync(baseDir) || !statSync(baseDir).isDirectory()) {
130
+ console.error(red(`✗ --base 가 디렉토리가 아닙니다: ${baseDir}`));
131
+ process.exit(1);
132
+ }
133
+ const allMd = collectMarkdown(baseDir); // 링크 해석용(전체)
134
+ const mdRels = allMd.map((f) => posix(relative(baseDir, f)));
135
+ const vault = buildVault(mdRels, collectAssets(baseDir).map((f) => posix(relative(baseDir, f))));
136
+ // obsidian 방향의 이름 중복 검사도 트리 전체 기준이어야 한다
137
+ const nameCount = new Map();
138
+ for (const f of allMd) {
139
+ const n = basename(f).replace(/\.md$/i, '');
140
+ nameCount.set(n, (nameCount.get(n) ?? 0) + 1);
141
+ }
142
+ // 실제로 고쳐 쓸 파일(선택). 경로를 안 주면 base 전체.
143
+ const selected = targets.length
144
+ ? allMd.filter((f) => targets.some((t) => {
145
+ const abs = resolve(t);
146
+ return f === abs || f.startsWith(abs + '/');
147
+ }))
148
+ : allMd;
149
+ // base 밖의 .md 를 직접 지목한 경우(예: 이웃 폴더 파일) 는 위 필터에 안 걸린다
150
+ const outside = targets
151
+ .map((t) => resolve(t))
152
+ .filter((abs) => statSync(abs).isFile() && !allMd.includes(abs));
153
+ if (outside.length) {
154
+ console.error(yellow(`⚠ --base(${baseDir}) 밖의 파일은 건너뜁니다:`) + '\n ' + outside.join('\n '));
155
+ }
156
+ if (!selected.length) {
157
+ console.error(red('✗ 대상 .md 가 없습니다.') + dim('\n (파일을 직접 지목했다면 --base 로 문서 트리 루트를 알려주세요)'));
158
+ process.exit(1);
159
+ }
160
+ const mdAbs = selected;
161
+ const jobs = [
162
+ to === 'obsidian' ? '상대 .md 링크 → [[wikilink]]' : to === 'markdown' ? '[[wikilink]] → 상대 .md 링크' : '',
163
+ fix ? '변환 흔적 보정' : '',
164
+ ].filter(Boolean).join(' + ');
165
+ const scope = mdAbs.length === allMd.length
166
+ ? `${mdAbs.length}건`
167
+ : `선택 ${mdAbs.length}/${allMd.length}건`;
168
+ if (outDir && (outDir === baseDir || outDir.startsWith(baseDir + '/'))) {
169
+ console.error(red(`✗ --out 이 base 안에 있습니다: ${outDir}`) + dim('\n 원본 트리를 덮어쓰지 않도록 base 밖의 경로를 쓰세요.'));
170
+ process.exit(1);
171
+ }
172
+ console.log(`${dim('base:')} ${cyan(baseDir)}\n` +
173
+ `${dim('대상:')} ${scope} ${dim('—')} ${jobs}` +
174
+ (outDir ? `\n${dim('출력:')} ${cyan(outDir)} ${dim('(원본 유지)')}` : '') +
175
+ (dryRun ? dim(' (dry-run)') : ''));
176
+ let changed = 0;
177
+ const copiedAssets = new Set();
178
+ const total = { cssJunk: 0, codeLang: 0, trailingWs: 0, escapes: 0, tightList: 0, dupTitle: 0 };
179
+ for (const abs of mdAbs) {
180
+ const rel = posix(relative(baseDir, abs));
181
+ const before = readFileSync(abs, 'utf8');
182
+ let text = before;
183
+ const notes = [];
184
+ // 보정을 먼저 한다 — 이스케이프가 풀려야(\[…\] → […]) 링크 인식이 정확해진다
185
+ if (fix) {
186
+ const r = repairMarkdown(text);
187
+ text = r.text;
188
+ const n = totalFixes(r.stats);
189
+ if (n) {
190
+ for (const k of Object.keys(total))
191
+ total[k] += r.stats[k];
192
+ notes.push(dim(`보정 ${n}`));
193
+ }
194
+ }
195
+ if (to === 'obsidian')
196
+ text = toObsidian(text, abs, baseDir, nameCount);
197
+ else if (to === 'markdown')
198
+ text = resolveWikilinks(text, vaultResolver(rel, vault));
199
+ // 제자리 변환이면 바뀐 것만 쓰지만, --out 이면 대상 전부를 내보낸다(온전한 트리가 나와야 하므로)
200
+ if (!outDir && text === before)
201
+ continue;
202
+ if (outDir) {
203
+ const target = join(outDir, rel);
204
+ if (!dryRun) {
205
+ mkdirSync(dirname(target), { recursive: true });
206
+ writeFileSync(target, text);
207
+ // 이미지가 따라가지 않으면 링크가 깨진다 — 참조된 첨부를 같은 상대 위치로 복사
208
+ for (const asset of referencedAssets(text, abs)) {
209
+ const arel = posix(relative(baseDir, asset));
210
+ if (arel.startsWith('..') || copiedAssets.has(arel))
211
+ continue; // base 밖 첨부는 두고 온다
212
+ const dstAsset = join(outDir, arel);
213
+ mkdirSync(dirname(dstAsset), { recursive: true });
214
+ copyFileSync(asset, dstAsset);
215
+ copiedAssets.add(arel);
216
+ }
217
+ }
218
+ }
219
+ else if (!dryRun) {
220
+ writeFileSync(abs, text);
221
+ }
222
+ // --out 이면 안 바뀐 파일도 그대로 내보내지만, 로그는 실제로 손댄 것만 남긴다
223
+ if (text !== before) {
224
+ changed++;
225
+ console.log(` ${cyan('↻')} ${rel}${notes.length ? ' ' + notes.join(' ') : ''}`);
226
+ }
227
+ }
228
+ console.log('\n' + bold('완료') + ` ${changed ? green(`${changed}개 파일 변경`) : gray('고칠 것 없음')}` +
229
+ (outDir ? ` ${cyan(`${mdAbs.length}개 파일 출력`)}${copiedAssets.size ? dim(`, 첨부 ${copiedAssets.size}`) : ''}` : '') +
230
+ ` ${dim(`대상 ${mdAbs.length}`)}`);
231
+ if (fix && totalFixes(total)) {
232
+ console.log(dim(' 보정:') +
233
+ ` 중복 제목 ${total.dupTitle}` +
234
+ ` 코드블록 언어 ${total.codeLang}` +
235
+ ` CSS 잔해 ${total.cssJunk}` +
236
+ ` 이스케이프 ${total.escapes}` +
237
+ ` 리스트 빈 줄 ${total.tightList}` +
238
+ ` 줄끝 공백 ${total.trailingWs}`);
239
+ }
240
+ if (dryRun)
241
+ console.log(dim('--dry-run: 파일을 쓰지 않았습니다.'));
242
+ }
package/dist/docs.js CHANGED
@@ -3,29 +3,90 @@
3
3
  * 폴더의 README.md 가 대표 페이지가 되고 같은 폴더의 다른 문서는 그 자식이 된다.
4
4
  */
5
5
  import { readdirSync, readFileSync } from 'node:fs';
6
- import { join, resolve, relative } from 'node:path';
6
+ import { join, resolve, relative, basename, dirname } from 'node:path';
7
7
  import { splitTitleAndBody } from './markdown.js';
8
- /** dir 이하의 모든 .md 절대경로를 재귀 수집 */
9
- export function collectMarkdown(dir) {
8
+ import { splitFrontmatter } from './obsidian.js';
9
+ /**
10
+ * 숨김 디렉토리는 문서가 아니다. Obsidian vault 의 `.obsidian/`(설정)·`.trash/`(휴지통) 과
11
+ * `.git/`·`node_modules/` 가 여기서 걸러진다.
12
+ */
13
+ const skipDir = (name) => name.startsWith('.') || name === 'node_modules';
14
+ /** dir 이하의 파일 절대경로를 재귀 수집(pick 이 true 인 것만). */
15
+ function walk(dir, pick) {
10
16
  const out = [];
11
17
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
12
18
  const full = join(dir, entry.name);
13
- if (entry.isDirectory())
14
- out.push(...collectMarkdown(full));
15
- else if (entry.isFile() && entry.name.endsWith('.md'))
19
+ if (entry.isDirectory()) {
20
+ if (!skipDir(entry.name))
21
+ out.push(...walk(full, pick));
22
+ }
23
+ else if (entry.isFile() && pick(entry.name)) {
16
24
  out.push(full);
25
+ }
17
26
  }
18
27
  return out;
19
28
  }
20
- /** base 상대 경로 → 문서 제목(첫 H1) 인덱스 */
29
+ const isMd = (name) => name.toLowerCase().endsWith('.md');
30
+ /** dir 이하의 모든 .md 절대경로를 재귀 수집 */
31
+ export function collectMarkdown(dir) {
32
+ return walk(dir, isMd);
33
+ }
34
+ /** dir 이하의 .md 가 아닌 파일(이미지 등 첨부 후보) 절대경로를 재귀 수집 */
35
+ export function collectAssets(dir) {
36
+ return walk(dir, (n) => !isMd(n));
37
+ }
38
+ /**
39
+ * 문서 1건을 읽어 제목·본문·frontmatter 로 분해한다.
40
+ * 제목 우선순위: frontmatter `title` > 첫 H1 > 파일명. H1 은 어느 경우든 본문에서 제거된다
41
+ * (Confluence 는 페이지 제목을 따로 가지므로 본문에 남으면 제목이 두 번 보인다).
42
+ */
43
+ export function readDoc(baseDir, rel) {
44
+ const { data, body: afterFm } = splitFrontmatter(readFileSync(resolve(baseDir, rel), 'utf8'));
45
+ const { title, body } = splitTitleAndBody(afterFm, basename(rel).replace(/\.md$/i, ''));
46
+ return { title: (data.title || title).trim(), body, fm: data };
47
+ }
48
+ /** base 상대 경로 → 문서 제목 인덱스 */
21
49
  export function buildTitleIndex(rels, baseDir) {
22
50
  const idx = {};
23
- for (const rel of rels) {
24
- const { title } = splitTitleAndBody(readFileSync(resolve(baseDir, rel), 'utf8'), rel);
25
- idx[rel] = title;
26
- }
51
+ for (const rel of rels)
52
+ idx[rel] = readDoc(baseDir, rel).title;
27
53
  return idx;
28
54
  }
55
+ /**
56
+ * wikilink 대상 이름 → base 상대 경로 인덱스.
57
+ * Obsidian 은 `[[파일명]]`(짧은 이름)과 `[[폴더/파일명]]`(경로) 을 모두 허용하므로 둘 다 등록한다.
58
+ * 짧은 이름이 겹치면 경로가 사전순으로 앞서는 쪽이 이긴다(Obsidian 의 "가장 가까운 것" 규칙과는 다르지만 결정적이다).
59
+ */
60
+ export function buildVault(mdRels, assetRels) {
61
+ const index = (rels, stripExt) => {
62
+ const idx = {};
63
+ for (const rel of [...rels].sort()) {
64
+ const withoutExt = stripExt ? rel.replace(/\.md$/i, '') : rel;
65
+ for (const key of [withoutExt, basename(withoutExt)]) {
66
+ const k = key.toLowerCase();
67
+ if (!(k in idx))
68
+ idx[k] = rel; // 먼저 등록된(사전순 앞선) 것이 이긴다
69
+ }
70
+ }
71
+ return idx;
72
+ };
73
+ return { notes: index(mdRels, true), assets: index(assetRels, false) };
74
+ }
75
+ /** 파일(rel) 기준으로 wikilink 대상을 상대경로로 풀어주는 resolver. */
76
+ export function vaultResolver(rel, vault) {
77
+ const dir = dirname(rel) === '.' ? '' : dirname(rel);
78
+ return (target, embed) => {
79
+ const key = target.toLowerCase().replace(/\.md$/i, '');
80
+ // 임베드(![[...]])는 대개 이미지, 링크([[...]])는 대개 노트 → 각자 먼저 찾아본다
81
+ const hit = embed
82
+ ? (vault.assets[target.toLowerCase()] ?? vault.notes[key])
83
+ : (vault.notes[key] ?? vault.assets[target.toLowerCase()]);
84
+ if (!hit)
85
+ return null;
86
+ const r = relative(dir || '.', hit).split('\\').join('/');
87
+ return r || null;
88
+ };
89
+ }
29
90
  const isReadme = (base) => /README\.md$/.test(base);
30
91
  /** 폴더 경로 → 그 폴더의 대표 README(base 상대) 인덱스 */
31
92
  export function buildFolderIndex(rels) {
package/dist/help.js CHANGED
@@ -16,12 +16,14 @@ export function printHelp() {
16
16
  const h = (s) => bold(s); // 섹션 제목
17
17
  const o = (s) => cyan(s); // 옵션/명령
18
18
  console.log(`${bold(cyan(`confluence-sync v${readPkgVersion()}`))}
19
- ${dim('Markdown 디렉토리를 Confluence Cloud 페이지로 단방향 동기화합니다.')}
19
+ ${dim('Confluence Cloud ↔ Markdown 양방향 동기화 CLI.')}
20
+ ${dim('짧은 이름')} ${o('csync')} ${dim('로도 실행할 수 있습니다(아래 confluence-sync 를 csync 로 대체 가능).')}
20
21
 
21
22
  ${h('사용법:')}
22
23
  ${o('confluence-sync')} [옵션] [경로...] 문서를 동기화(md → Confluence)
23
24
  ${o('confluence-sync init')} 대화형으로 .env 설정 파일 생성
24
25
  ${o('confluence-sync pull <pageId|url>')} Confluence 페이지/폴더를 .md 로 가져오기(역방향)
26
+ ${o('confluence-sync convert --to <형식>')} 이미 받은 .md 의 링크 표기를 바꾸기(로컬 전용)
25
27
  ${o('confluence-sync --help | --version')}
26
28
 
27
29
  ${h('동기화 옵션:')}
@@ -42,13 +44,37 @@ ${h('경로 인자:')}
42
44
  예) ${o('confluence-sync 20-design')} ${o('confluence-sync 90-glossary.md')}
43
45
 
44
46
  ${h('pull(역방향) 옵션:')}
45
- ${o('--out <dir>')} .md 를 생성할 디렉토리(기본: 현재 폴더)
46
- ${o('--children')} 하위 페이지까지 재귀적으로 가져와 폴더 트리로 복원
47
- ${o('--space')} 대상 없이 스페이스(CONFLUENCE_SPACE_KEY) 홈페이지부터 전체를 가져옴
47
+ ${o('--out <dir>')} .md 를 생성할 디렉토리(기본: 현재 폴더)
48
+ ${o('--children')} 하위 페이지까지 재귀적으로 가져와 폴더 트리로 복원
49
+ ${o('--space')} 대상 없이 스페이스(CONFLUENCE_SPACE_KEY) 홈페이지부터 전체를 가져옴
50
+ ${o('--obsidian')} 내부 링크를 [[wikilink]] 로 출력(기본: 어디서나 열리는 상대 .md 링크)
48
51
  (이미지는 항상 attachments/<문서명>/ 하위에 저장됨)
49
52
  예) ${o('confluence-sync pull https://.../pages/12345/Title --out ./docs --children')}
50
53
  ${o('confluence-sync pull --space --out ./docs')}
51
54
 
55
+ ${h('convert(이미 받아둔 .md 손보기) 옵션:')} ${dim('Confluence 호출·인증 없음')}
56
+ ${o('--to obsidian')} 상대 .md 링크 → [[wikilink]]
57
+ ${o('--to markdown')} [[wikilink]] · ![[embed]] → 상대 .md 링크 (되돌리기)
58
+ ${o('--fix')} 옛 pull 결과 보정 — 코드블록 언어(java→plaintext)·CSS 잔해·
59
+ 불필요한 \\ 이스케이프·리스트 사이 빈 줄
60
+ ${o('--base <dir>')} 링크를 해석할 문서 트리 루트(기본: 준 경로들의 공통 상위 폴더)
61
+ ${o('--out <dir>')} 원본을 두고 결과를 다른 트리에 쓰기(참조된 첨부도 함께 복사)
62
+ ${o('--dry-run')} 바뀔 파일만 출력하고 쓰지 않음
63
+
64
+ 파일·폴더를 여러 개 줄 수 있고, 안 주면 base 전체가 대상입니다.
65
+ ${o('--out')} 없이 실행하면 ${dim('원본 파일을 덮어씁니다')}.
66
+ 예) ${o('confluence-sync convert --fix ./docs --dry-run')}
67
+ ${o('confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault')}
68
+ ${o('confluence-sync convert --fix ./docs/가이드/설치.md --base ./docs')} ${dim('(파일 하나만)')}
69
+
70
+ ${h('Obsidian:')}
71
+ pull 결과는 vault 에서 바로 열립니다. 함께 받은 페이지끼리의 내부 링크가 이어져 그래프·백링크가
72
+ 동작하고, 각 문서의 frontmatter 에 pageId 가 남아 원본 페이지를 가리킵니다.
73
+ [[wikilink]] 표기를 원하면 ${o('pull --obsidian')}, 이미 받아둔 문서라면 ${o('convert --to obsidian')}.
74
+
75
+ push 는 vault 문법을 그대로 이해합니다: ${o('[[wikilink]]')} · ${o('![[embed]]')} · YAML frontmatter
76
+ (frontmatter 의 ${o('title')} 이 페이지 제목이 되고, ${o('pageId')} 가 있으면 매핑이 없어도 그 페이지를 갱신합니다).
77
+
52
78
  ${h('설정(.env):')}
53
79
  실행 위치(cwd)의 .env 또는 셸 환경변수를 읽습니다.
54
80
  필수: CONFLUENCE_BASE_URL, CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN, CONFLUENCE_SPACE_KEY
package/dist/html2md.js CHANGED
@@ -22,7 +22,7 @@ const linkDest = (p) => (/\s/.test(p) ? `<${p}>` : p);
22
22
  * (예: `**7️⃣**\-**1️⃣**` — 두 굵은 글씨 사이 텍스트 노드가 "-" 하나라 노드 시작으로 취급)
23
23
  * 줄 맨 앞의 진짜 마커 이스케이프는 보존하고, 그 뒤(줄 중간)의 것만 되돌린다. 코드블록은 건드리지 않는다.
24
24
  */
25
- function relaxEscapes(md) {
25
+ export function relaxEscapes(md) {
26
26
  let inFence = false;
27
27
  return md
28
28
  .split('\n')
@@ -39,6 +39,8 @@ function relaxEscapes(md) {
39
39
  .slice(head.length)
40
40
  // 줄 중간의 - + . > 는 마커가 아니므로 이스케이프 불필요
41
41
  .replace(/\\([-+.>])/g, '$1')
42
+ // 단어 내부 밑줄은 CommonMark 에서 강조가 아니다 (:11_eleven_square_blue: 같은 이모지 코드)
43
+ .replace(/(\w)\\_(?=\w)/g, '$1_')
42
44
  // 링크가 아닌 대괄호 쌍(뒤에 ( 나 [ 가 오지 않음)은 이스케이프 불필요
43
45
  .replace(/\\\[([^[\]\n]*)\\\](?!\s*[([])/g, '[$1]');
44
46
  return head + rest;
package/dist/markdown.js CHANGED
@@ -9,6 +9,7 @@ import { readFileSync, existsSync } from 'node:fs';
9
9
  import { resolve, relative, dirname, basename } from 'node:path';
10
10
  import { createHash } from 'node:crypto';
11
11
  import MarkdownIt from 'markdown-it';
12
+ import { resolveWikilinks } from './obsidian.js';
12
13
  export const escapeXml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
13
14
  export const hashOf = (s) => createHash('sha256').update(s).digest('hex');
14
15
  /** markdown-it 이 퍼센트 인코딩한 링크/이미지 경로를 실제 경로로 디코드(실패 시 원본). */
@@ -83,11 +84,15 @@ export function splitTitleAndBody(markdown, fallback) {
83
84
  lines.splice(i, 1);
84
85
  return { title, body: lines.join('\n') };
85
86
  }
86
- /** 본문 markdown 을 storage format 으로 변환(base 기준 상대경로로 링크·이미지 해석). */
87
- export function toStorage(markdown, rel, titleIndex, base) {
87
+ /**
88
+ * 본문 markdown 을 storage format 으로 변환(base 기준 상대경로로 링크·이미지 해석).
89
+ * resolveLink 를 주면 Obsidian 의 [[wikilink]]·![[embed]] 를 먼저 표준 링크로 정규화한다.
90
+ */
91
+ export function toStorage(markdown, rel, titleIndex, base, resolveLink) {
88
92
  baseDir = base;
89
93
  ctx = { fileDir: dirname(rel) === '.' ? '' : dirname(rel), titleIndex, images: [], internalLinks: 0, linkStack: [], linkedTitles: new Set() };
90
- const storage = md.render(markdown);
94
+ const src = resolveLink ? resolveWikilinks(markdown, resolveLink) : markdown;
95
+ const storage = md.render(src);
91
96
  return { storage, images: ctx.images, internalLinks: ctx.internalLinks, linkedTitles: [...ctx.linkedTitles] };
92
97
  }
93
98
  /**
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Obsidian 브릿지 — vault 관례와 이 도구의 표준 마크다운 사이를 잇는다.
3
+ *
4
+ * 원칙: **표준을 내보내고, 방언을 받아들인다.**
5
+ * - push: vault 에서 쓴 YAML frontmatter · [[wikilink]] · ![[embed]] 를 이해해 그대로 발행한다.
6
+ * - pull: Obsidian·GitHub·VS Code 어디서나 열리는 표준 상대 링크로 내보낸다
7
+ * (frontmatter 의 pageId 가 왕복 동기화의 앵커가 된다).
8
+ *
9
+ * frontmatter 는 평면 `key: value` 만 다룬다. 중첩·리스트가 필요할 만큼 복잡한 메타데이터는
10
+ * 이 도구의 관심사가 아니며, 알 수 없는 키는 건드리지 않고 보존한다.
11
+ */
12
+ const FENCE = /^\s*(?:```|~~~)/;
13
+ // [[대상]] · [[대상|별칭]] · [[대상#헤딩]] · ![[임베드]]
14
+ const WIKILINK = /(!?)\[\[([^\]|#]+)(#[^\]|]+)?(?:\|([^\]]+))?\]\]/g;
15
+ /**
16
+ * 문서 맨 앞의 YAML frontmatter 를 분리한다. 없으면 data 는 빈 객체.
17
+ * `key: value` 한 줄짜리만 인식하고, 값의 감싼 따옴표는 벗긴다.
18
+ */
19
+ export function splitFrontmatter(raw) {
20
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
21
+ if (!m)
22
+ return { data: {}, body: raw };
23
+ const data = {};
24
+ for (const line of m[1].split(/\r?\n/)) {
25
+ const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
26
+ if (!kv)
27
+ continue; // 중첩/리스트 등 평면이 아닌 줄은 무시(보존은 하지 않음)
28
+ data[kv[1]] = kv[2].trim().replace(/^["'](.*)["']$/, '$1');
29
+ }
30
+ return { data, body: raw.slice(m[0].length) };
31
+ }
32
+ /** 평면 frontmatter 블록 생성. 값이 비면 그 키는 생략한다. */
33
+ export function buildFrontmatter(data) {
34
+ const lines = Object.entries(data)
35
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
36
+ // 콜론·따옴표·선행 특수문자가 있으면 YAML 이 깨지므로 인용한다
37
+ .map(([k, v]) => `${k}: ${/[:#"'\n]|^\s|\s$/.test(v) ? JSON.stringify(v) : v}`);
38
+ return lines.length ? `---\n${lines.join('\n')}\n---\n\n` : '';
39
+ }
40
+ /** 공백이 있으면 <...> 로 감싼다(markdown-it 이 목적지를 통째로 파싱하도록). */
41
+ const dest = (p) => (/\s/.test(p) ? `<${p}>` : p);
42
+ /**
43
+ * 마크다운 본문에서 "산문" 부분에만 변환을 적용한다.
44
+ * 코드블록(``` / ~~~)과 인라인 코드(`...`) 안은 그대로 둔다 — 링크처럼 생긴 예제 코드를
45
+ * 망가뜨리지 않기 위해서다.
46
+ */
47
+ function mapProse(md, fn) {
48
+ let inFence = false;
49
+ return md
50
+ .split('\n')
51
+ .map((line) => {
52
+ if (FENCE.test(line)) {
53
+ inFence = !inFence;
54
+ return line;
55
+ }
56
+ if (inFence)
57
+ return line;
58
+ // 홀수 인덱스 = 인라인 코드 스팬 → 원문 유지
59
+ return line
60
+ .split(/(`+[^`]*`+)/)
61
+ .map((seg, i) => (i % 2 ? seg : fn(seg)))
62
+ .join('');
63
+ })
64
+ .join('\n');
65
+ }
66
+ /**
67
+ * [[wikilink]] · ![[embed]] → 표준 마크다운 링크.
68
+ * 해석에 실패한 링크는 원문 그대로 남겨 (조용히 사라지지 않고) 눈에 띄게 한다.
69
+ */
70
+ export function resolveWikilinks(md, resolve) {
71
+ return mapProse(md, (s) => s.replace(WIKILINK, (whole, bang, target, hash, alias) => {
72
+ const embed = bang === '!';
73
+ const found = resolve(target.trim(), embed);
74
+ if (!found)
75
+ return whole; // 해석 실패 → 원문 보존
76
+ const label = (alias ?? target).trim();
77
+ return embed ? `![${label}](${dest(found)})` : `[${label}](${dest(found + (hash ?? ''))})`;
78
+ }));
79
+ }
80
+ // [text](dest) — dest 는 <...> 로 감싸였을 수 있다. 앞의 ! 는 이미지 임베드.
81
+ const MD_LINK = /(!?)\[([^\]]*)\]\((<[^>]+>|[^()\s]+)\)/g;
82
+ /**
83
+ * 표준 마크다운 링크 → [[wikilink]] (resolveWikilinks 의 역변환).
84
+ * toWikilink 가 null 을 주면(= 외부 URL·이미지·대상 불명) 그 링크는 그대로 둔다.
85
+ * 이미지 임베드(`![...]()`)는 건드리지 않는다 — 표준 문법이 Obsidian 에서도 그대로 렌더된다.
86
+ */
87
+ export function linksToWikilinks(md, toWikilink) {
88
+ return mapProse(md, (s) => s.replace(MD_LINK, (whole, bang, label, rawDest) => {
89
+ if (bang === '!')
90
+ return whole; // 이미지는 표준 링크 유지
91
+ const d = rawDest.replace(/^<(.*)>$/, '$1');
92
+ return toWikilink(d, label) ?? whole;
93
+ }));
94
+ }
package/dist/pull.js CHANGED
@@ -2,16 +2,22 @@
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 } from './obsidian.js';
12
16
  import { cyan, dim, green, red, yellow } from './colors.js';
13
17
  // 첨부 이미지는 항상 <md위치>/attachments/<문서명>/ 하위에 저장한다.
14
18
  const ASSETS_DIR = 'attachments';
19
+ /** 마크다운 링크 중 Confluence 페이지를 가리키는 것: [text](https://.../pages/<id>/...) */
20
+ const PAGE_LINK = /\[([^\]]*)\]\(<?(https?:\/\/[^\s)<>]*?\/pages\/(\d+)[^\s)<>]*?)>?\)/g;
15
21
  function optVal(argv, name) {
16
22
  const i = argv.indexOf(name);
17
23
  return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
@@ -28,8 +34,11 @@ function parseContentId(input) {
28
34
  function slug(title) {
29
35
  return title.replace(/[\/\\:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim() || 'untitled';
30
36
  }
37
+ /** 공백이 있으면 <...> 로 감싼 마크다운 링크 목적지 */
38
+ const dest = (p) => (/\s/.test(p) ? `<${p}>` : p);
31
39
  /** 페이지 1건을 .md 로 생성(자식 페이지/폴더가 있으면 <slug>/README.md 폴더로). */
32
- async function pullPage(client, node, destDir, withChildren) {
40
+ async function pullPage(ctx, node, destDir) {
41
+ const { client, withChildren } = ctx;
33
42
  const { id, title, html, storage } = node;
34
43
  // 페이지도 하위 페이지 + 하위 폴더를 모두 가질 수 있다(폴더가 페이지 밑에 올 수 있음).
35
44
  const childFolders = withChildren ? await client.getChildFolders(id) : [];
@@ -66,19 +75,33 @@ async function pullPage(client, node, destDir, withChildren) {
66
75
  }
67
76
  }
68
77
  }
69
- writeFileSync(filePath, `# ${title}\n\n${body}`);
78
+ // frontmatter 의 pageId 는 push 왕복의 앵커다(매핑 파일이 없어도 원본 페이지를 찾아간다).
79
+ // 취향 옵션이 아니라 중복 페이지 생성을 막는 장치이므로 항상 쓴다. Obsidian 은 이를 속성으로 보여준다.
80
+ const fm = buildFrontmatter({
81
+ title,
82
+ pageId: id,
83
+ spaceKey: ctx.spaceKey,
84
+ source: node.url ?? '',
85
+ updated: node.updated ?? '',
86
+ });
87
+ // 제목은 frontmatter 의 title 하나로 충분하다. 본문에 `# 제목` 을 또 넣으면
88
+ // 제목을 따로 표시하는 뷰어(Obsidian 등)에서 두 번 보인다. push 도 title 속성을 먼저 읽는다.
89
+ writeFileSync(filePath, fm ? `${fm}${body}` : `# ${title}\n\n${body}`);
90
+ ctx.written.push(filePath);
91
+ ctx.pathById.set(id, filePath);
70
92
  const imgNote = referenced.size ? `, 이미지 ${referenced.size}` : '';
71
93
  console.log(` ${green('+ 생성')} ${filePath} ${dim(`(#${id}${imgNote})`)}`);
72
94
  const counts = { pages: 1, folders: 0 };
73
95
  for (const f of childFolders)
74
- add(counts, await pullNode(client, f.id, folder, withChildren));
96
+ add(counts, await pullNode(ctx, f.id, folder));
75
97
  for (const c of childPages)
76
- add(counts, await pullNode(client, c.id, folder, withChildren));
98
+ add(counts, await pullNode(ctx, c.id, folder));
77
99
  return counts;
78
100
  }
79
101
  function add(acc, c) { acc.pages += c.pages; acc.folders += c.folders; }
80
102
  /** 노드(페이지/폴더)를 재귀적으로 가져온다. 폴더는 디렉토리(본문 없음)로 만든다. 노드 하나 실패는 스킵. */
81
- async function pullNode(client, id, destDir, withChildren) {
103
+ async function pullNode(ctx, id, destDir) {
104
+ const { client, withChildren } = ctx;
82
105
  try {
83
106
  const node = await client.getNode(id);
84
107
  if (node.type === 'folder') {
@@ -88,9 +111,9 @@ async function pullNode(client, id, destDir, withChildren) {
88
111
  const counts = { pages: 0, folders: 1 };
89
112
  if (withChildren) {
90
113
  for (const f of await client.getChildFolders(id))
91
- add(counts, await pullNode(client, f.id, dir, true));
114
+ add(counts, await pullNode(ctx, f.id, dir));
92
115
  for (const p of await client.getChildPages(id))
93
- add(counts, await pullNode(client, p.id, dir, true));
116
+ add(counts, await pullNode(ctx, p.id, dir));
94
117
  }
95
118
  else {
96
119
  console.log(dim(' (하위 내용은 --children 으로 가져옵니다)'));
@@ -98,13 +121,46 @@ async function pullNode(client, id, destDir, withChildren) {
98
121
  return counts;
99
122
  }
100
123
  // page: getNode 결과(html + storage) 그대로 사용
101
- return await pullPage(client, node, destDir, withChildren);
124
+ return await pullPage(ctx, node, destDir);
102
125
  }
103
126
  catch (e) {
104
127
  console.error(red(` ✗ 실패 #${id}`) + `\n${e.message}`);
105
128
  return { pages: 0, folders: 0 };
106
129
  }
107
130
  }
131
+ /**
132
+ * 함께 받은 페이지를 가리키는 절대 Confluence URL 을 상대 .md 링크(또는 [[wikilink]])로 바꾼다.
133
+ * 이번에 받지 않은 페이지의 링크는 절대 URL 그대로 둔다(깨진 링크를 만들지 않는다).
134
+ * 반환값은 재작성한 링크 수.
135
+ */
136
+ export function relinkPass(written, pathById, wikilinks) {
137
+ const noExt = (p) => basename(p).replace(/\.md$/i, '');
138
+ // wikilink 는 vault 어디서든 "이름"으로 찾으므로, 파일명이 유일할 때만 안전하게 쓸 수 있다.
139
+ const nameCount = new Map();
140
+ for (const p of pathById.values())
141
+ nameCount.set(noExt(p), (nameCount.get(noExt(p)) ?? 0) + 1);
142
+ let count = 0;
143
+ for (const file of written) {
144
+ const before = readFileSync(file, 'utf8');
145
+ const after = before.replace(PAGE_LINK, (whole, text, url, id) => {
146
+ const target = pathById.get(id);
147
+ if (!target || target === file)
148
+ return whole; // 못 받은 페이지·자기 자신 → 원본 URL 유지
149
+ count++;
150
+ const name = noExt(target);
151
+ const label = (text || name).trim();
152
+ if (wikilinks && nameCount.get(name) === 1) {
153
+ return label === name ? `[[${name}]]` : `[[${name}|${label}]]`;
154
+ }
155
+ const anchor = url.includes('#') ? '#' + url.split('#').slice(1).join('#') : '';
156
+ const rel = relative(dirname(file), target).split('\\').join('/');
157
+ return `[${label}](${dest(rel + anchor)})`;
158
+ });
159
+ if (after !== before)
160
+ writeFileSync(file, after);
161
+ }
162
+ return count;
163
+ }
108
164
  export async function runPull(argv) {
109
165
  const withChildren = argv.includes('--children');
110
166
  const wholeSpace = argv.includes('--space');
@@ -112,6 +168,19 @@ export async function runPull(argv) {
112
168
  const env = readEnv();
113
169
  requireEnv(env);
114
170
  const client = createClient({ baseUrl: env.baseUrl, email: env.email, token: env.token }, { force: false, verify: false });
171
+ const ctx = {
172
+ client,
173
+ withChildren,
174
+ obsidian: argv.includes('--obsidian'),
175
+ spaceKey: env.spaceKey,
176
+ written: [],
177
+ pathById: new Map(),
178
+ };
179
+ const done = (c) => {
180
+ const relinked = relinkPass(ctx.written, ctx.pathById, ctx.obsidian);
181
+ console.log(`\n${green('완료')} 페이지 ${c.pages}개${c.folders ? `, 폴더 ${c.folders}개` : ''} 생성` +
182
+ (relinked ? ` ${cyan(`내부링크 ${relinked}개 연결`)}` : ''));
183
+ };
115
184
  // --space: 스페이스 홈페이지(콘텐츠 트리 루트)부터 전체를 재귀적으로 가져옴
116
185
  if (wholeSpace) {
117
186
  const { homepageId } = await client.getSpaceInfo(env.spaceKey);
@@ -120,8 +189,8 @@ export async function runPull(argv) {
120
189
  process.exit(1);
121
190
  }
122
191
  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}개` : ''} 생성`);
192
+ ctx.withChildren = true;
193
+ done(await pullNode(ctx, homepageId, outDir));
125
194
  return;
126
195
  }
127
196
  // 위치 인자: ['pull', <pageId|url>, ...]
@@ -146,6 +215,5 @@ export async function runPull(argv) {
146
215
  process.exit(1);
147
216
  }
148
217
  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}개` : ''} 생성`);
218
+ done(await pullNode(ctx, contentId, outDir));
151
219
  }
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, buildTitleIndex, 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' +
@@ -103,6 +109,9 @@ async function main() {
103
109
  const ignoredCount = collected.length - allRel.length;
104
110
  const folderIndex = buildFolderIndex(allRel);
105
111
  const titleIndex = buildTitleIndex(allRel, BASE_DIR); // 링크 변환은 항상 전체 기준
112
+ // Obsidian [[wikilink]] 해석용 이름 인덱스. 제외된 문서는 링크 대상에서도 빠진다.
113
+ const vault = buildVault(allRel, collectAssets(BASE_DIR).map((f) => relative(BASE_DIR, f)));
114
+ const render = (rel, body) => toStorage(body, rel, titleIndex, BASE_DIR, vaultResolver(rel, vault));
106
115
  const selected = positionals.length
107
116
  ? withParents(resolveSelection(allRel, positionals, BASE_DIR), folderIndex)
108
117
  : allRel;
@@ -148,11 +157,14 @@ async function main() {
148
157
  const fex = mapping[`${d}/`];
149
158
  console.log(` [${fex?.pageId ? gray('폴더') : green('폴더+')}] 📁 ${d}/`);
150
159
  }
151
- const { title, body } = splitTitleAndBody(readFileSync(resolve(BASE_DIR, rel), 'utf8'), rel);
152
- const r = toStorage(body, rel, titleIndex, BASE_DIR);
160
+ const { title, body, fm } = readDoc(BASE_DIR, rel);
161
+ const r = render(rel, body);
153
162
  const hash = docHash(title, r);
154
163
  const ex = mapping[rel];
155
- const status = !ex?.pageId ? green('신규') : ex.hash === hash ? gray('동일') : yellow('변경');
164
+ // 매핑엔 없지만 frontmatter 에 pageId 가 있으면 신규 생성이 아니라 기존 페이지에 연결된다
165
+ const status = !ex?.pageId
166
+ ? (fm.pageId ? cyan('연결') : green('신규'))
167
+ : ex.hash === hash ? gray('동일') : yellow('변경');
156
168
  const pk = parentKeyOf(rel, folderIndex);
157
169
  console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}, 이미지: ${r.images.length})`)}`);
158
170
  }
@@ -166,7 +178,7 @@ async function main() {
166
178
  saveMapping(MAPPING_PATH, {});
167
179
  }
168
180
  const work = REBUILD ? {} : mapping;
169
- let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0;
181
+ let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0, linked = 0;
170
182
  const rendered = new Map();
171
183
  const recreatedTitles = new Set(); // 이번에 재생성된 문서의 제목
172
184
  const touched = new Set(); // 이번에 발행(생성/갱신/재생성)된 문서
@@ -216,8 +228,8 @@ async function main() {
216
228
  console.error(yellow(` ✗ 건너뜀 ${rel} (상위 폴더 미생성)`));
217
229
  continue;
218
230
  }
219
- const { title, body } = splitTitleAndBody(readFileSync(resolve(BASE_DIR, rel), 'utf8'), rel);
220
- const r = toStorage(body, rel, titleIndex, BASE_DIR);
231
+ const { title, body, fm } = readDoc(BASE_DIR, rel);
232
+ const r = render(rel, body);
221
233
  const hash = docHash(title, r);
222
234
  const pk = parentKeyOf(rel, folderIndex);
223
235
  const parentId = pk ? work[pk]?.pageId : env.parentId;
@@ -226,6 +238,24 @@ async function main() {
226
238
  console.error(yellow(` ✗ 건너뜀 ${rel} (부모 '${pk}' 가 아직 없음)`));
227
239
  continue;
228
240
  }
241
+ // pull 로 받은 문서(frontmatter 에 pageId)는 매핑이 없어도 원본 페이지에 다시 연결한다.
242
+ // 매핑 파일을 잃었거나 vault 를 다른 곳에 복제한 경우 중복 생성을 막아준다.
243
+ if (!work[rel]?.pageId && fm.pageId) {
244
+ try {
245
+ if (await client.getContentOrNull(fm.pageId)) {
246
+ work[rel] = { pageId: fm.pageId, title };
247
+ saveMapping(MAPPING_PATH, work);
248
+ console.log(` ${cyan('🔗 연결')} ${rel} ${dim(`→ 기존 페이지 #${fm.pageId}`)}`);
249
+ linked++;
250
+ }
251
+ else {
252
+ console.error(yellow(` ⚠ frontmatter 의 pageId #${fm.pageId} 를 찾을 수 없습니다 → 새로 생성 (${rel})`));
253
+ }
254
+ }
255
+ catch (e) {
256
+ console.error(yellow(` ⚠ pageId 확인 실패 #${fm.pageId}: ${e.message}`));
257
+ }
258
+ }
229
259
  try {
230
260
  const result = await client.upsertPage(work, spaceId, rel, title, r.storage, hash, parentId);
231
261
  saveMapping(MAPPING_PATH, work);
@@ -282,6 +312,7 @@ async function main() {
282
312
  ` ${magenta(`재생성 ${recreated}`)}` +
283
313
  ` ${cyan(`링크재연결 ${relinked}`)}` +
284
314
  ` ${gray(`변경없음 ${skipped}`)}` +
315
+ (linked ? ` ${cyan(`연결 ${linked}`)}` : '') +
285
316
  (foldersMade ? ` ${cyan(`폴더 ${foldersMade}`)}` : '') +
286
317
  ` ${dim(`대상 ${files.length}`)}`);
287
318
  }
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.1.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",