@wonseok-han/confluence-sync 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wonseok-han
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # confluence-sync
2
+
3
+ **Confluence Cloud ↔ Markdown 양방향 동기화 CLI**
4
+
5
+ - **push** — 로컬 `.md` 디렉토리를 Confluence 페이지 트리로 발행. 폴더 구조가 그대로 페이지 계층이 됩니다.
6
+ - **pull** — Confluence 페이지·폴더·**스페이스 전체**를 읽어 `.md` 로 가져옵니다.
7
+
8
+ 문서를 git으로 관리하면서(docs-as-code) Confluence를 미러로 두거나, 반대로 Confluence에 쌓인 문서를 마크다운으로 내려받아 쓸 수 있습니다. 내려받은 마크다운은 Obsidian·VS Code 등 어디서든 그대로 열립니다.
9
+
10
+ ---
11
+
12
+ ## 설치
13
+
14
+ ```bash
15
+ npm i -g @wonseok-han/confluence-sync
16
+
17
+ confluence-sync --version
18
+ confluence-sync --help
19
+ ```
20
+
21
+ 공개 npm 패키지라 **토큰이나 `.npmrc` 설정이 필요 없습니다.** (Node 20+)
22
+
23
+ ## 빠른 시작
24
+
25
+ ```bash
26
+ cd /path/to/docs # 동기화할 .md 들이 있는 폴더
27
+
28
+ confluence-sync init # 대화형으로 .env 생성 (토큰 입력은 가려짐)
29
+ confluence-sync --dry-run # 무엇이 올라갈지 미리보기
30
+ confluence-sync # 실제 발행
31
+ ```
32
+
33
+ 반대로 Confluence에서 가져오려면:
34
+
35
+ ```bash
36
+ confluence-sync pull --space --out ./docs # 스페이스 전체를 .md 로
37
+ ```
38
+
39
+ ---
40
+
41
+ ## 설정
42
+
43
+ Confluence 접속 정보를 담은 `.env` 가 **실행 위치(cwd)** 에 있어야 합니다(셸 환경변수도 가능).
44
+
45
+ **대화형 생성 (권장)**
46
+
47
+ ```bash
48
+ confluence-sync init # 항목별 안내에 따라 입력 → cwd 에 .env 생성
49
+ ```
50
+
51
+ **직접 작성**
52
+
53
+ | 변수 | 설명 |
54
+ | --- | --- |
55
+ | `CONFLUENCE_BASE_URL` | `https://api.atlassian.com/ex/confluence/<CLOUD_ID>/wiki` |
56
+ | `CONFLUENCE_EMAIL` | Atlassian 계정 이메일 |
57
+ | `CONFLUENCE_API_TOKEN` | Scoped API 토큰 (아래 참고) |
58
+ | `CONFLUENCE_SPACE_KEY` | URL `.../wiki/spaces/<KEY>/...` 의 KEY |
59
+ | `CONFLUENCE_PARENT_ID` | (선택) 루트 앵커 — 부모 **페이지 또는 폴더** ID. 비우면 공간 최상위 |
60
+ | `CONFLUENCE_SYNC_BASE` | push 할 루트 디렉토리 (`--base` 로도 지정 가능) |
61
+
62
+ ### API 토큰 발급
63
+
64
+ 1. https://id.atlassian.com/manage-profile/security/api-tokens → **Create API token with scopes**
65
+ 2. 앱 **Confluence** 선택
66
+ 3. 쓰려는 기능에 맞춰 스코프 선택:
67
+
68
+ | 기능 | 필요 스코프 |
69
+ | --- | --- |
70
+ | **push 기본** (조회·생성·갱신) | `read:space` · `read:page` · `write:page` |
71
+ | 이미지/첨부 업로드 | `read:content-details` + `write:attachment` (둘 다) |
72
+ | README 없는 폴더를 Confluence 폴더로 생성 | `write:folder` |
73
+ | `--rebuild` (삭제 후 재생성) | `delete:page` (+ 폴더 있으면 `delete:folder`) |
74
+ | **pull** (읽기) | `read:page` + `read:content-details` |
75
+ | pull 시 첨부 이미지 다운로드 | `read:attachment` |
76
+
77
+ > 모두 `:confluence` 접미사가 붙습니다 (예: `read:page:confluence`).
78
+
79
+ 4. 토큰 계정은 대상 Space에 해당 권한이 있어야 합니다.
80
+
81
+ ### cloudId 확인
82
+
83
+ `BASE_URL` 의 `<CLOUD_ID>` 는 아래에서 확인합니다.
84
+
85
+ ```
86
+ https://<your-domain>.atlassian.net/_edge/tenant_info
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Push — Markdown → Confluence
92
+
93
+ ```bash
94
+ confluence-sync --list # 인식된 문서·제목·계층만 출력 (호출·인증 없음)
95
+ confluence-sync --dry-run # 대상·상태(신규/변경/동일) 미리보기
96
+ confluence-sync # 변경된 문서만 갱신 (+ 신규 생성)
97
+
98
+ confluence-sync --base /abs/path/docs # 동기화 루트 지정 (env 미설정 시 필수)
99
+ confluence-sync guide/ # 지정 폴더만 (부모 README 자동 포함)
100
+ confluence-sync guide/setup.md # 단일 문서만
101
+ confluence-sync --force # 변경 감지 무시, 전체 강제 갱신
102
+ confluence-sync --verify # 변경 없는 문서도 페이지 존재 확인
103
+ confluence-sync --rebuild # 매핑된 페이지 전부 삭제 후 재생성
104
+ confluence-sync --exclude '*-draft.md' # 제외 패턴 (반복 가능)
105
+ ```
106
+
107
+ ### 폴더 = 페이지 계층
108
+
109
+ - 폴더의 **`README.md`** 가 그 폴더의 **대표 페이지**가 되고, 같은 폴더의 다른 문서는 그 자식이 됩니다.
110
+ - README 가 **없는** 폴더는 **Confluence 폴더**로 생성되고 그 아래로 문서가 들어갑니다 (`write:folder` 스코프 필요).
111
+ - `README.md` 는 폴더 맨 앞, 나머지는 파일명순(숫자 prefix `01-`, `10-` … 로 순서 제어)으로 정렬되어 부모가 자식보다 먼저 생성됩니다.
112
+
113
+ ```
114
+ docs/ → [CONFLUENCE_PARENT_ID 또는 공간 최상위]
115
+ ├── README.md → 루트 페이지
116
+ ├── 10-guide/
117
+ │ ├── README.md → "10-guide" 대표 페이지
118
+ │ └── 01-setup.md → 그 자식 페이지
119
+ └── 20-notes/ → README 없음 → Confluence 폴더
120
+ └── memo.md → 폴더 안 페이지
121
+ ```
122
+
123
+ ### 변환 규칙
124
+
125
+ - 각 문서의 **첫 `# 제목`** 이 페이지 제목이 되고, 본문은 storage format 으로 변환됩니다.
126
+ - 코드블록 → **code 매크로**, 표·리스트·헤딩은 그대로.
127
+ - **내부 `.md` 링크** → Confluence 페이지 링크로 자동 변환.
128
+ - **로컬 이미지** → 페이지 첨부로 업로드 후 참조 (같은 파일명은 새 버전으로 갱신). 외부 URL 이미지는 그대로.
129
+
130
+ ### 변경 감지와 매핑
131
+
132
+ - 문서의 `제목 + 본문 + 참조 이미지 내용` 해시를 저장해두고, **바뀐 문서만** 새 버전으로 올립니다(`= 변경없음` 은 API 호출조차 하지 않음). `--force` 로 무시할 수 있습니다.
133
+ - 매핑은 **`<base>/.confluence-sync.json`** 에 저장됩니다. **문서셋과 함께 커밋하세요** — 지우면 다음 실행에서 페이지가 중복 생성됩니다. (`--mapping <path>` 로 위치 변경 가능)
134
+ - Confluence 에서 페이지가 삭제됐다면 자동으로 **재생성**하고, 그 페이지를 가리키던 문서들도 **링크를 다시 연결**합니다. 내용이 그대로라 스킵되는 경우까지 잡으려면 `--verify` 를 씁니다.
135
+
136
+ ### 동기화 제외 (로컬 전용 문서 유지)
137
+
138
+ 같은 폴더에 올릴 문서와 로컬에만 둘 문서가 섞여 있을 때 제외할 수 있습니다.
139
+
140
+ - **`<base>/.confluence-syncignore`** — `.gitignore` 방식 패턴 파일 (문서셋과 함께 커밋)
141
+
142
+ ```gitignore
143
+ *-draft.md # 초안 전부 제외
144
+ notes/local-only.md # 특정 파일만 (notes/ 의 나머지는 동기화)
145
+ secret/ # 폴더 통째로
146
+ ```
147
+
148
+ - **`--exclude <glob>`** — 일회성 제외 (반복 가능)
149
+
150
+ 제외된 문서는 list·dry-run·sync 모든 모드에서 빠지고, 그 문서만 있던 폴더는 생성되지 않습니다. 부정(`!`), `**` 등 `.gitignore` 규칙을 그대로 따릅니다.
151
+
152
+ ### `--rebuild`
153
+
154
+ 부모 관계는 **생성 시점에만** 지정되어 사후 이동이 어렵습니다. 계층을 갈아엎으려면 `--rebuild` 로 매핑된 페이지·폴더를 모두 삭제(휴지통) 후 재생성합니다. 매핑에 기록된 것만 지우므로 수동으로 만든 페이지는 건드리지 않습니다.
155
+
156
+ ---
157
+
158
+ ## Pull — Confluence → Markdown
159
+
160
+ ```bash
161
+ confluence-sync pull <pageId|url> # 한 페이지를 현재 폴더에 .md 로
162
+ confluence-sync pull <url> --out ./docs # 출력 디렉토리 지정
163
+ confluence-sync pull <url> --out ./docs --children # 하위 페이지·폴더까지 트리로 복원
164
+ confluence-sync pull --space --out ./docs # 스페이스 전체 (홈페이지부터 전부)
165
+ ```
166
+
167
+ - 대상은 **숫자 ID** 또는 **URL** (`.../pages/<ID>/...` 페이지, `.../folder/<ID>` 폴더) 둘 다 됩니다.
168
+ - **`--children`** — 하위를 재귀 복원합니다. 자식이 있는 페이지는 `<제목>/README.md` 폴더로 펼쳐져 **push 계층 관례와 맞물립니다**.
169
+ - **`--space`** — 대상 없이 스페이스 전체를 가져옵니다. 페이지가 많으면 시간이 걸립니다.
170
+ - **이미지/첨부** 는 문서별로 `attachments/<문서명>/` 아래에 내려받고 링크를 그 경로로 씁니다 (문서 폴더가 이미지로 지저분해지지 않음). 다운로드에는 `read:attachment` 스코프가 필요합니다.
171
+ - 본문은 `export_view`(HTML)를 [turndown](https://github.com/mixmark-io/turndown) 으로 변환합니다 — 코드블록(언어 포함, 미지정은 `plaintext`), GFM 표, 리스트 등.
172
+
173
+ > **무손실 왕복은 아닙니다.** Confluence storage → Markdown 은 근사 변환이라 일부 매크로·레이아웃은 단순화됩니다. 내부 페이지 링크는 절대 URL로 유지됩니다.
174
+
175
+ ---
176
+
177
+ ## 개발
178
+
179
+ ```bash
180
+ git clone https://github.com/wonseok-han/confluence-sync.git
181
+ cd confluence-sync
182
+ npm install
183
+ npm run build
184
+ ```
185
+
186
+ 소스에서 바로 실행할 때는 `npm run` 스크립트를 쓰고, 인자는 **`--` 뒤에** 둡니다.
187
+
188
+ | 글로벌 | 로컬 |
189
+ | --- | --- |
190
+ | `confluence-sync` | `npm run sync` |
191
+ | `confluence-sync --dry-run` | `npm run sync:dry` |
192
+ | `confluence-sync --list` | `npm run list -- --base ./docs` |
193
+
194
+ ## 릴리스
195
+
196
+ `v*` 태그를 푸시하면 GitHub Actions 가 빌드 → npm 배포 → GitHub Release 까지 자동 처리합니다.
197
+
198
+ ```bash
199
+ npm version patch # 또는 minor / major
200
+ git push && git push --tags
201
+ ```
202
+
203
+ 워크플로는 `package.json` 버전과 태그가 일치하는지 검증한 뒤 배포합니다. 변경 이력은 [CHANGELOG.md](CHANGELOG.md) 를 참고하세요.
204
+
205
+ > 사전 설정: npm Automation 토큰을 저장소 **Settings → Secrets and variables → Actions** 에 `NPM_TOKEN` 으로 등록해야 합니다.
206
+
207
+ ## 라이선스
208
+
209
+ [MIT](LICENSE)
package/dist/colors.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 의존성 없는 ANSI 컬러 헬퍼.
3
+ * 비활성 조건: NO_COLOR / TERM=dumb / --no-color / 출력이 TTY 가 아님(파이프·리다이렉트).
4
+ * FORCE_COLOR 가 있으면 강제 활성.
5
+ */
6
+ const enabled = !process.env.NO_COLOR &&
7
+ process.env.TERM !== 'dumb' &&
8
+ !process.argv.includes('--no-color') &&
9
+ (process.env.FORCE_COLOR ? true : process.stdout.isTTY === true);
10
+ const wrap = (open, close) => (s) => enabled ? `\x1b[${open}m${s}\x1b[${close}m` : String(s);
11
+ export const bold = wrap(1, 22);
12
+ export const dim = wrap(2, 22);
13
+ export const red = wrap(31, 39);
14
+ export const green = wrap(32, 39);
15
+ export const yellow = wrap(33, 39);
16
+ export const blue = wrap(34, 39);
17
+ export const magenta = wrap(35, 39);
18
+ export const cyan = wrap(36, 39);
19
+ export const gray = wrap(90, 39);
20
+ export const colorEnabled = () => enabled;
package/dist/config.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 환경변수(.env / 셸) 로딩과 필수값 검증.
3
+ * dotenv 로딩은 진입점(sync.ts)에서 `import 'dotenv/config'` 로 먼저 수행된다.
4
+ */
5
+ import { red, cyan } from './colors.js';
6
+ export function readEnv() {
7
+ return {
8
+ baseUrl: process.env.CONFLUENCE_BASE_URL,
9
+ email: process.env.CONFLUENCE_EMAIL,
10
+ token: process.env.CONFLUENCE_API_TOKEN,
11
+ spaceKey: process.env.CONFLUENCE_SPACE_KEY,
12
+ // 신규 이름 우선, 없으면 구 이름(CONFLUENCE_PARENT_PAGE_ID) 하위호환
13
+ parentId: process.env.CONFLUENCE_PARENT_ID ?? process.env.CONFLUENCE_PARENT_PAGE_ID,
14
+ };
15
+ }
16
+ /** 동기화(생성/갱신)에 필요한 필수 환경변수 검증. 누락 시 종료. */
17
+ export function requireEnv(env) {
18
+ const missing = [
19
+ ['CONFLUENCE_BASE_URL', env.baseUrl],
20
+ ['CONFLUENCE_EMAIL', env.email],
21
+ ['CONFLUENCE_API_TOKEN', env.token],
22
+ ['CONFLUENCE_SPACE_KEY', env.spaceKey],
23
+ ].filter(([, v]) => !v).map(([k]) => k);
24
+ if (missing.length) {
25
+ console.error(red(`환경변수 누락: ${missing.join(', ')}`) + `\n${cyan('confluence-sync init')} 으로 .env 를 만들 수 있습니다.`);
26
+ process.exit(1);
27
+ }
28
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Confluence Cloud API 클라이언트. createClient(config, opts) 로 생성하면
3
+ * baseUrl·인증·force/verify 옵션을 클로저로 묶은 메서드 집합을 돌려준다.
4
+ */
5
+ import { readFileSync, existsSync } from 'node:fs';
6
+ import { relative } from 'node:path';
7
+ import { red, yellow, dim } from './colors.js';
8
+ export function createClient(cfg, opts) {
9
+ const authHeader = () => 'Basic ' + Buffer.from(`${cfg.email}:${cfg.token}`).toString('base64');
10
+ async function api(path, init) {
11
+ const res = await fetch(`${cfg.baseUrl}${path}`, {
12
+ ...init,
13
+ headers: {
14
+ Authorization: authHeader(),
15
+ 'Content-Type': 'application/json',
16
+ Accept: 'application/json',
17
+ ...(init?.headers ?? {}),
18
+ },
19
+ });
20
+ if (!res.ok) {
21
+ throw new Error(`Confluence API ${res.status} ${res.statusText}\n${path}\n${await res.text()}`);
22
+ }
23
+ return res.status === 204 ? null : res.json();
24
+ }
25
+ /** 페이지 조회. 삭제(휴지통)·부재면 404 → null 반환(그 외 오류는 throw) */
26
+ async function getPageOrNull(pageId) {
27
+ const res = await fetch(`${cfg.baseUrl}/api/v2/pages/${pageId}`, {
28
+ headers: { Authorization: authHeader(), Accept: 'application/json' },
29
+ });
30
+ if (res.status === 404)
31
+ return null;
32
+ if (!res.ok)
33
+ throw new Error(`Confluence API ${res.status} ${res.statusText}\n/api/v2/pages/${pageId}\n${await res.text()}`);
34
+ return res.json();
35
+ }
36
+ async function getSpaceId(key) {
37
+ return (await getSpaceInfo(key)).id;
38
+ }
39
+ /** 스페이스 id + 홈페이지 id(=콘텐츠 트리 루트). */
40
+ async function getSpaceInfo(key) {
41
+ const data = await api(`/api/v2/spaces?keys=${encodeURIComponent(key)}`);
42
+ const space = data.results?.[0];
43
+ if (!space)
44
+ throw new Error(`Space key '${key}' 를 찾을 수 없습니다.`);
45
+ return { id: space.id, homepageId: space.homepageId };
46
+ }
47
+ /** 매핑에 기록된 페이지를 모두 삭제(휴지통). 매핑 파일 영속화는 호출자 책임. */
48
+ async function deleteAll(mapping) {
49
+ // 페이지(자식) 먼저, 폴더(컨테이너) 나중에 삭제
50
+ const entries = Object.entries(mapping).sort(([, a], [, b]) => (a.type === 'folder' ? 1 : 0) - (b.type === 'folder' ? 1 : 0));
51
+ console.log(yellow(`--rebuild: 기존 ${entries.length}건 삭제`));
52
+ for (const [key, { pageId, type }] of entries) {
53
+ try {
54
+ await api(type === 'folder' ? `/api/v2/folders/${pageId}` : `/api/v2/pages/${pageId}`, { method: 'DELETE' });
55
+ console.log(` ${yellow('🗑 삭제')} ${key} ${dim(`(#${pageId})`)}`);
56
+ }
57
+ catch (e) {
58
+ console.error(red(` ✗ 삭제 실패 ${key}`) + `\n${e.message}`);
59
+ }
60
+ }
61
+ }
62
+ /** 페이지의 기존 첨부 중 같은 파일명의 attachmentId 를 찾는다(없으면 null). */
63
+ async function findAttachmentId(pageId, filename) {
64
+ try {
65
+ const data = await api(`/rest/api/content/${pageId}/child/attachment?filename=${encodeURIComponent(filename)}`);
66
+ return data?.results?.[0]?.id ?? null;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ /**
73
+ * 로컬 이미지를 페이지 첨부로 업로드(v1, multipart).
74
+ * 같은 파일명 첨부가 이미 있으면 데이터만 새 버전으로 갱신(중복 생성 400 방지),
75
+ * 없으면 신규 생성한다.
76
+ */
77
+ async function uploadAttachment(pageId, filename, abs) {
78
+ const buf = readFileSync(abs);
79
+ const form = new FormData();
80
+ form.append('file', new Blob([buf]), filename);
81
+ form.append('minorEdit', 'true');
82
+ const attId = await findAttachmentId(pageId, filename);
83
+ const path = attId
84
+ ? `/rest/api/content/${pageId}/child/attachment/${attId}/data` // 기존 첨부 데이터 갱신
85
+ : `/rest/api/content/${pageId}/child/attachment`; // 신규 첨부 생성
86
+ const res = await fetch(`${cfg.baseUrl}${path}`, {
87
+ method: 'POST',
88
+ headers: { Authorization: authHeader(), 'X-Atlassian-Token': 'nocheck' },
89
+ body: form,
90
+ });
91
+ if (!res.ok)
92
+ throw new Error(`attachment ${res.status} ${res.statusText}\n${await res.text()}`);
93
+ }
94
+ /** 페이지의 로컬 이미지들을 업로드. 성공 개수 반환. baseDir 은 로그 표시용. */
95
+ async function uploadImages(pageId, images, baseDir) {
96
+ let ok = 0;
97
+ for (const img of images) {
98
+ if (!existsSync(img.abs)) {
99
+ console.error(yellow(` ⚠ 이미지 없음: ${relative(baseDir, img.abs)}`));
100
+ continue;
101
+ }
102
+ try {
103
+ await uploadAttachment(pageId, img.filename, img.abs);
104
+ ok++;
105
+ }
106
+ catch (e) {
107
+ console.error(red(` ✗ 이미지 업로드 실패 ${img.filename}`) + `\n${e.message}`);
108
+ }
109
+ }
110
+ return ok;
111
+ }
112
+ /** 페이지 생성/갱신. 삭제 감지 시 재생성. 변경 없으면(opts.verify 아니면) 호출 없이 skip. */
113
+ async function upsertPage(mapping, spaceId, key, title, storage, hash, parentId, forceUpdate = false) {
114
+ const existing = mapping[key];
115
+ if (existing?.pageId) {
116
+ const changed = existing.hash !== hash || opts.force || forceUpdate;
117
+ // 변경 없고 검증(--verify)도 안 하면 호출 없이 스킵
118
+ if (!changed && !opts.verify)
119
+ return 'skipped';
120
+ const cur = await getPageOrNull(existing.pageId);
121
+ if (cur) {
122
+ if (!changed)
123
+ return 'skipped'; // 페이지 존재 확인됨, 내용도 동일
124
+ const nextVersion = (cur.version?.number ?? 1) + 1;
125
+ await api(`/api/v2/pages/${existing.pageId}`, {
126
+ method: 'PUT',
127
+ body: JSON.stringify({
128
+ id: existing.pageId,
129
+ status: 'current',
130
+ title,
131
+ body: { representation: 'storage', value: storage },
132
+ version: { number: nextVersion, message: `sync from git: ${key}` },
133
+ }),
134
+ });
135
+ mapping[key] = { pageId: existing.pageId, title, hash };
136
+ return 'updated';
137
+ }
138
+ // cur === null: Confluence 에서 삭제됨 → 아래에서 신규 생성으로 복구
139
+ }
140
+ const body = {
141
+ spaceId, status: 'current', title,
142
+ body: { representation: 'storage', value: storage },
143
+ };
144
+ if (parentId)
145
+ body.parentId = parentId;
146
+ const created = await api(`/api/v2/pages`, { method: 'POST', body: JSON.stringify(body) });
147
+ mapping[key] = { pageId: created.id, title, hash };
148
+ return existing?.pageId ? 'recreated' : 'created';
149
+ }
150
+ // ---- pull(역방향) 전용 ----
151
+ /** v1 GET. api() 와 달리 /rest/api 계열 + 임의 expand 응답을 그대로 돌려준다. */
152
+ async function getV1(path) {
153
+ const res = await fetch(`${cfg.baseUrl}${path}`, {
154
+ headers: { Authorization: authHeader(), Accept: 'application/json' },
155
+ });
156
+ if (!res.ok)
157
+ throw new Error(`Confluence API ${res.status} ${res.statusText}\n${path}\n${await res.text()}`);
158
+ return res.json();
159
+ }
160
+ /**
161
+ * 콘텐츠 노드 조회. type + 제목 + export_view(HTML) + storage(원본 XHTML).
162
+ * storage 는 코드블록의 "진짜" 언어를 얻는 데 쓴다(export_view 는 언어 미지정 시 brush:java 기본값이 붙어 신뢰 불가).
163
+ */
164
+ async function getNode(id) {
165
+ const data = await getV1(`/rest/api/content/${id}?expand=body.export_view,body.storage`);
166
+ return {
167
+ id: data.id,
168
+ type: data.type ?? 'page',
169
+ title: data.title ?? id,
170
+ html: data.body?.export_view?.value ?? '',
171
+ storage: data.body?.storage?.value ?? '',
172
+ };
173
+ }
174
+ /** 자식 노드 목록(kind = page|folder, 전체 페이지네이션). */
175
+ async function listChildren(id, kind) {
176
+ const out = [];
177
+ let path = `/rest/api/content/${id}/child/${kind}?limit=100`;
178
+ while (path) {
179
+ const data = await getV1(path);
180
+ for (const c of data.results ?? [])
181
+ out.push({ id: c.id, title: c.title });
182
+ const next = data._links?.next;
183
+ path = next ? (next.startsWith('/') ? next : `/${next}`) : null;
184
+ }
185
+ return out;
186
+ }
187
+ const getChildPages = (id) => listChildren(id, 'page');
188
+ const getChildFolders = (id) => listChildren(id, 'folder');
189
+ /** 페이지 첨부 목록(파일명 + 다운로드 경로). */
190
+ async function listAttachments(pageId) {
191
+ const out = [];
192
+ let path = `/rest/api/content/${pageId}/child/attachment?limit=100`;
193
+ while (path) {
194
+ const data = await getV1(path);
195
+ for (const a of data.results ?? []) {
196
+ const downloadPath = a._links?.download;
197
+ if (downloadPath)
198
+ out.push({ filename: a.title, downloadPath });
199
+ }
200
+ const next = data._links?.next;
201
+ path = next ? (next.startsWith('/') ? next : `/${next}`) : null;
202
+ }
203
+ return out;
204
+ }
205
+ /** 첨부 바이너리 다운로드. */
206
+ async function downloadAttachment(downloadPath) {
207
+ const url = downloadPath.startsWith('http') ? downloadPath : `${cfg.baseUrl}${downloadPath}`;
208
+ const res = await fetch(url, { headers: { Authorization: authHeader() } });
209
+ if (!res.ok)
210
+ throw new Error(`download ${res.status} ${res.statusText}\n${downloadPath}`);
211
+ return Buffer.from(await res.arrayBuffer());
212
+ }
213
+ // ---- 폴더(README 없는 디렉토리 컨테이너) ----
214
+ /** 콘텐츠(페이지/폴더) 존재 확인. 404 → null (v1, 폴더 read 스코프 없이도 동작). */
215
+ async function getContentOrNull(id) {
216
+ const res = await fetch(`${cfg.baseUrl}/rest/api/content/${id}`, {
217
+ headers: { Authorization: authHeader(), Accept: 'application/json' },
218
+ });
219
+ if (res.status === 404)
220
+ return null;
221
+ if (!res.ok)
222
+ throw new Error(`Confluence API ${res.status} ${res.statusText}\n/rest/api/content/${id}\n${await res.text()}`);
223
+ return res.json();
224
+ }
225
+ /** 폴더 생성(write:folder 필요). parentId 없으면 공간 최상위. 생성된 folder id 반환. */
226
+ async function createFolder(spaceId, title, parentId) {
227
+ const body = { spaceId, title };
228
+ if (parentId)
229
+ body.parentId = parentId;
230
+ const created = await api(`/api/v2/folders`, { method: 'POST', body: JSON.stringify(body) });
231
+ return created.id;
232
+ }
233
+ /** 폴더 삭제(--rebuild). */
234
+ async function deleteFolder(id) {
235
+ await api(`/api/v2/folders/${id}`, { method: 'DELETE' });
236
+ }
237
+ return {
238
+ api, getPageOrNull, getSpaceId, getSpaceInfo, deleteAll, uploadImages, upsertPage,
239
+ getNode, getChildPages, getChildFolders, listAttachments, downloadAttachment,
240
+ getContentOrNull, createFolder, deleteFolder,
241
+ };
242
+ }
package/dist/docs.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * 문서 수집과 폴더=계층 구조 계산. 모든 키는 baseDir 기준 상대경로다.
3
+ * 폴더의 README.md 가 대표 페이지가 되고 같은 폴더의 다른 문서는 그 자식이 된다.
4
+ */
5
+ import { readdirSync, readFileSync } from 'node:fs';
6
+ import { join, resolve, relative } from 'node:path';
7
+ import { splitTitleAndBody } from './markdown.js';
8
+ /** dir 이하의 모든 .md 절대경로를 재귀 수집 */
9
+ export function collectMarkdown(dir) {
10
+ const out = [];
11
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
12
+ const full = join(dir, entry.name);
13
+ if (entry.isDirectory())
14
+ out.push(...collectMarkdown(full));
15
+ else if (entry.isFile() && entry.name.endsWith('.md'))
16
+ out.push(full);
17
+ }
18
+ return out;
19
+ }
20
+ /** base 상대 경로 → 문서 제목(첫 H1) 인덱스 */
21
+ export function buildTitleIndex(rels, baseDir) {
22
+ const idx = {};
23
+ for (const rel of rels) {
24
+ const { title } = splitTitleAndBody(readFileSync(resolve(baseDir, rel), 'utf8'), rel);
25
+ idx[rel] = title;
26
+ }
27
+ return idx;
28
+ }
29
+ const isReadme = (base) => /README\.md$/.test(base);
30
+ /** 폴더 경로 → 그 폴더의 대표 README(base 상대) 인덱스 */
31
+ export function buildFolderIndex(rels) {
32
+ const idx = {};
33
+ for (const p of rels) {
34
+ const parts = p.split('/');
35
+ if (isReadme(parts[parts.length - 1]))
36
+ idx[parts.slice(0, -1).join('/')] = p;
37
+ }
38
+ return idx;
39
+ }
40
+ const parentDirOf = (dir) => (dir.includes('/') ? dir.slice(0, dir.lastIndexOf('/')) : '');
41
+ /**
42
+ * dir 의 직계 자식을 담는 "컨테이너"의 mapping 키.
43
+ * - 루트('') → null (CONFLUENCE_PARENT_ID)
44
+ * - README 가 있는 폴더 → 그 README 페이지 키
45
+ * - README 가 없는 폴더 → 폴더 노드 키(`<dir>/`, 끝에 슬래시로 구분)
46
+ */
47
+ export function containerKeyOf(dir, folderIndex) {
48
+ if (dir === '')
49
+ return null;
50
+ return folderIndex[dir] ?? `${dir}/`;
51
+ }
52
+ /**
53
+ * 부모 결정(base 상대): 컨테이너 모델.
54
+ * - 일반 파일 → 자신이 속한 dir 의 컨테이너
55
+ * - 폴더 대표 README → 그 폴더의 "부모 dir" 컨테이너(README 는 자기 폴더를 대표하므로)
56
+ */
57
+ export function parentKeyOf(rel, folderIndex) {
58
+ const parts = rel.split('/');
59
+ const dir = parts.slice(0, -1).join('/');
60
+ return isReadme(parts[parts.length - 1])
61
+ ? containerKeyOf(parentDirOf(dir), folderIndex)
62
+ : containerKeyOf(dir, folderIndex);
63
+ }
64
+ /** README 가 없어 Confluence 폴더로 만들어야 하는 dir 들(파일들의 모든 상위 dir 중 folderIndex 에 없는 것). */
65
+ export function neededFolderDirs(rels, folderIndex) {
66
+ const set = new Set();
67
+ for (const rel of rels) {
68
+ let dir = parentDirOf(rel);
69
+ while (dir !== '') {
70
+ if (!folderIndex[dir])
71
+ set.add(dir);
72
+ dir = parentDirOf(dir);
73
+ }
74
+ }
75
+ return [...set];
76
+ }
77
+ /** README(폴더 대표)를 폴더 맨 앞에, 나머지는 파일명순 → 부모가 자식보다 먼저 */
78
+ function sortKey(rel) {
79
+ const parts = rel.split('/');
80
+ const base = parts[parts.length - 1];
81
+ const dir = parts.slice(0, -1).join('/');
82
+ const leaf = isReadme(base) ? '' : base;
83
+ return dir === '' ? leaf : dir + '/' + leaf;
84
+ }
85
+ export function sortForSync(rels) {
86
+ return [...rels].sort((a, b) => {
87
+ const ka = sortKey(a), kb = sortKey(b);
88
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
89
+ });
90
+ }
91
+ /** 선택 경로(positionals)를 base 상대 키 집합으로 확장(디렉토리는 하위 .md 전부) */
92
+ export function resolveSelection(rels, positionals, baseDir) {
93
+ const set = new Set();
94
+ for (const p of positionals) {
95
+ const abs = resolve(baseDir, p);
96
+ const relToBase = relative(baseDir, abs);
97
+ if (relToBase.startsWith('..')) {
98
+ console.error(` ⚠ base 밖 경로 무시: ${p}`);
99
+ continue;
100
+ }
101
+ const matched = rels.filter((r) => r === relToBase || r.startsWith(relToBase + '/'));
102
+ if (matched.length === 0)
103
+ console.error(` ⚠ 매칭되는 문서 없음: ${p}`);
104
+ matched.forEach((m) => set.add(m));
105
+ }
106
+ return [...set];
107
+ }
108
+ /** 선택된 문서의 상위 폴더 대표 README 들을 포함(부모 pageId 확보용). 폴더 노드는 neededFolderDirs 로 따로 생성. */
109
+ export function withParents(selected, folderIndex) {
110
+ const out = new Set(selected);
111
+ for (const rel of selected) {
112
+ let dir = parentDirOf(rel);
113
+ while (dir !== '') {
114
+ if (folderIndex[dir])
115
+ out.add(folderIndex[dir]);
116
+ dir = parentDirOf(dir);
117
+ }
118
+ }
119
+ return [...out];
120
+ }