@wonseok-han/confluence-sync 1.6.1 → 1.8.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
@@ -24,6 +24,35 @@ Node.js 22.13 이상이 필요합니다.
24
24
 
25
25
  > 짧은 이름 **`csync`** 도 같이 설치됩니다 — 아래 예시의 `confluence-sync` 는 전부 `csync` 로 바꿔 쓸 수 있습니다.
26
26
 
27
+ ## 파일 이동 후 매핑 정리
28
+
29
+ `remap`은 로컬 문서의 `pageId`를 기준으로 `.confluence-sync.json`의 경로만 갱신합니다. 인증이나 네트워크 호출 없이 실행되며 문서 파일과 Confluence는 변경하지 않습니다.
30
+
31
+ ```bash
32
+ # 매핑 변경 미리보기 → 매핑만 저장
33
+ csync remap --base ./정리본 --dry-run
34
+ csync remap --base ./정리본
35
+
36
+ # 원본과 정리본이 다른 디렉토리라면 기존 해시도 가져오기
37
+ csync remap --base ./정리본 --from-mapping ./원본/.confluence-sync.json
38
+
39
+ # 업로드 없이 sync 예정 내역 확인
40
+ csync sync --base ./정리본 --dry-run
41
+ ```
42
+
43
+ 확인 후 사용자가 `csync sync --base ./정리본`을 실행하면 원격에 반영됩니다. `sync`는 기존 기본 명령(`csync --base ...`)의 별칭입니다.
44
+
45
+ - 기존 ID·제목·해시는 보존하고, 현재 경로에 따른 부모를 `pendingMove.parentKey`에 기록합니다. sync 시 실제 부모와 비교해 이동하고 성공 후 대기 기록을 제거합니다.
46
+ - 경로만 달라지고 렌더링 결과 해시가 같으면 본문·첨부 업로드 없이 이동합니다. 제목·본문·링크 변환 결과가 달라졌거나 기존 해시가 없으면 기존 sync 규칙대로 본문도 갱신합니다. `remap`은 새 해시를 만들어 변경을 숨기지 않습니다.
47
+ - 매핑에 없는 상위 폴더는 같은 스페이스·부모·제목의 원격 폴더를 먼저 찾아 연결하고, 없을 때만 sync 시 생성합니다. 부모 ID 미설정 시 홈페이지 바로 아래의 폴더도 화면상의 루트 폴더로 재사용합니다. 그 외 이름만 같고 부모가 다른 폴더는 재사용하지 않고 위치 충돌을 표시합니다. 폴더 검색과 메타데이터 조회 권한이 필요합니다. 생성 중 동명 충돌(400) 또는 충돌(409)이 나면 한 번 재조회해 같은 위치의 폴더를 재사용합니다. 매핑의 기존 폴더도 스페이스·제목·부모를 검증하며, 권한 오류나 위치 충돌은 자동 생성·이동으로 우회하지 않습니다. README가 있는 디렉토리는 해당 README 페이지가 부모입니다. 루트 문서는 `CONFLUENCE_PARENT_ID` 아래로 이동하며, 미설정 시 스페이스 최상위로 이동합니다. 최상위 이동은 부모 없는 홈페이지를 기준으로 검증하며, 기준을 확인할 수 없으면 실패합니다.
48
+ - 동일 ID를 가진 여러 문서나 매핑, 경로의 ID 충돌, 손상된 JSON은 저장 전에 거부합니다. 이동 대상 페이지 조회 실패 시 자동 재생성하지 않으며, 이동 실패 시 대기 기록을 유지합니다.
49
+ - `--mapping`은 출력 매핑 위치, `--from-mapping`은 읽기 전용 원본 매핑입니다. `.confluence-syncignore`와 반복 `--exclude`를 지원합니다. 원본과 정리본을 함께 스캔하지 말고 정리본만 `--base`로 지정하세요.
50
+ - ID가 없는 문서는 remap에서 미연결로 표시합니다. **이후 sync에서는 기존 규칙대로 새 페이지가 생성될 수 있으므로**, 관리용 문서는 ignore로 제외하세요.
51
+ - 대상에 없는 기존 매핑은 유지합니다. 폴더 자체에는 frontmatter ID가 없으므로 폴더 ID의 경로 변경은 추론하지 않으며, 새 위치에는 새 폴더가 생성될 수 있습니다. 원격의 빈 폴더를 자동 삭제하지 않습니다.
52
+ - 부모 페이지 이동은 원격 하위 페이지에도 영향을 줄 수 있습니다. `--dry-run`은 로컬 계획이며 원격 상태를 조회하지 않습니다. remap 이후 구조를 바꿨다면 다시 remap하세요.
53
+
54
+ 이동은 [Confluence의 페이지 이동 API](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content---children-and-descendants/#api-wiki-rest-api-content-pageid-move-position-targetid-put)를 사용합니다.
55
+
27
56
  ## 빠른 시작
28
57
 
29
58
  ```bash
@@ -170,6 +199,7 @@ docs/ → [CONFLUENCE_PARENT_ID 또는 공간 최상위]
170
199
  - `mermaid` 코드블록 뒤에는 **Mermaid diagram** 매크로를 자동 삽입합니다. Confluence에 [Atlassian Labs Mermaid Diagrams Viewer](https://marketplace.atlassian.com/apps/1232887/mermaid-diagrams-viewer)가 설치되어 있어야 하며, 원본 코드도 함께 표시됩니다. 자동 연결은 등장 순서를 사용하므로 페이지의 모든 Mermaid 코드는 `mermaid` 코드블록으로 작성하세요.
171
200
  - **내부 `.md` 링크**와 **`[[wikilink]]`** → Confluence 페이지 링크로 자동 변환.
172
201
  - **섹션 링크** `[§1 개요](#1-개요)` · `[§6](./04-구현체.md#6-zkp)` · `[[문서#헤딩 텍스트]]` → Confluence 앵커 링크. [아래 참조](#섹션-링크-앵커)
202
+ - **일반 파일 링크** `[자료](<attachments/자료.pdf>)` → PDF·TXT·MMD·JSON 등 실제 로컬 파일을 페이지 첨부로 업로드하고 다운로드 링크로 변환합니다. 경로는 Markdown 문서 위치 기준이며, 한글·공백 경로와 반복 링크를 지원합니다. 문서 링크(`.md`)·외부 URL·없는 파일은 기존 링크를 유지합니다. 같은 페이지에서 서로 다른 파일의 파일명이 같으면 업로드 이름에 경로 기반 접미사를 붙여 구분합니다.
173
203
  - **로컬 이미지**와 **`![[embed]]`** → 페이지 첨부로 업로드 후 참조 (같은 파일명은 새 버전으로 갱신). 외부 URL 이미지는 그대로.
174
204
  - **YAML frontmatter** 는 메타데이터로만 쓰이고 본문에 새지 않습니다.
175
205
 
@@ -221,7 +251,7 @@ pull 은 반대로 Confluence 앵커를 마크다운 슬러그로 되돌립니
221
251
 
222
252
  ### 변경 감지와 매핑
223
253
 
224
- - 문서의 `제목 + 본문 + 참조 이미지 내용` 해시를 저장해두고, **바뀐 문서만** 새 버전으로 올립니다(`= 변경없음` 은 API 호출조차 하지 않음). `--force` 로 무시할 수 있습니다.
254
+ - 문서의 `제목 + 본문 + 참조 첨부 내용` 해시를 저장해두고, **바뀐 문서만** 새 버전으로 올립니다(`= 변경없음` 은 API 호출조차 하지 않음). `--force` 로 무시할 수 있습니다.
225
255
  - 매핑은 **`<base>/.confluence-sync.json`** 에 저장됩니다. **문서셋과 함께 커밋하세요** — 지우면 다음 실행에서 페이지가 중복 생성됩니다. (`--mapping <path>` 로 위치 변경 가능)
226
256
  - **pull 도 같은 매핑에 기록합니다** — 그래서 pull 직후 push 하면 `= 변경없음` 입니다. [Pull → 매핑 기록](#매핑-기록) 참고.
227
257
  - Confluence 에서 페이지가 삭제됐다면 자동으로 **재생성**하고, 그 페이지를 가리키던 문서들도 **링크를 다시 연결**합니다. 내용이 그대로라 스킵되는 경우까지 잡으려면 `--verify` 를 씁니다.
package/dist/cli/args.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Positional arguments shared by command dispatch and push selection. */
2
2
  export function positionalArgs(argv) {
3
- const values = new Set(['--base', '--reference-root', '--mapping', '--exclude', '--to', '--out', '--port']);
3
+ const values = new Set(['--base', '--reference-root', '--mapping', '--from-mapping', '--exclude', '--to', '--out', '--port']);
4
4
  const result = [];
5
5
  for (let i = 0; i < argv.length; i++) {
6
6
  if (values.has(argv[i])) {
package/dist/cli/help.js CHANGED
@@ -27,6 +27,15 @@ ${h('사용법:')}
27
27
  ${o('confluence-sync convert --to <형식>')} 이미 받은 .md 의 링크 표기를 바꾸기(로컬 전용)
28
28
  ${o('confluence-sync --help | --version')}
29
29
 
30
+ ${h('remap 옵션(로컬 매핑만 갱신, Confluence 호출 없음):')}
31
+ ${o('csync remap --base <dir>')} pageId로 경로를 재연결하고 이동 대기 기록
32
+ ${o('--from-mapping <path>')} 원본 매핑에서 기존 ID·해시 가져오기(원본 유지)
33
+ ${o('--mapping <path>')} 저장할 매핑(기본: <base>/.confluence-sync.json)
34
+ ${o('--exclude <glob>')} 제외 패턴(반복 가능), .confluence-syncignore도 적용
35
+ ${o('--dry-run')} 변경 내역만 출력, 파일 저장 없음
36
+ 이후 ${o('csync sync --base <dir> --dry-run')}으로 확인하고, 명시적으로 sync할 때 이동 반영.
37
+ 기존 해시가 없으면 sync에서 본문도 갱신합니다. 폴더 ID의 경로 변경은 자동 추론하지 않습니다.
38
+
30
39
  ${h('web 옵션:')}
31
40
  ${o('--base <dir>')} 처음 열 폴더(기본: 홈 폴더). 화면에서 기준 폴더를 바꿀 수 있음
32
41
  ${o('--port <number>')} 웹 포트(기본: 4318)
package/dist/cli/main.js CHANGED
@@ -5,6 +5,7 @@ import { printHelp, readPkgVersion } from './help.js';
5
5
  import { runInit } from './init.js';
6
6
  import { runConvert } from '../conversion/convert.js';
7
7
  import { runPull } from '../sync/pull.js';
8
+ import { runRemap } from '../sync/remap.js';
8
9
  import { runPush } from '../sync/push.js';
9
10
  const argv = process.argv.slice(2);
10
11
  try {
@@ -20,6 +21,15 @@ try {
20
21
  case 'convert':
21
22
  await runConvert(argv);
22
23
  break;
24
+ case 'remap':
25
+ await runRemap(argv);
26
+ break;
27
+ case 'sync': {
28
+ const args = [...argv];
29
+ args.splice(args.indexOf('sync'), 1);
30
+ await runPush(args);
31
+ break;
32
+ }
23
33
  case 'pull':
24
34
  await runPull(argv);
25
35
  break;
@@ -5,6 +5,15 @@
5
5
  import { readFileSync, existsSync } from 'node:fs';
6
6
  import { relative } from 'node:path';
7
7
  import { red, yellow, dim } from '../shared/colors.js';
8
+ class ConfluenceApiError extends Error {
9
+ status;
10
+ responseBody;
11
+ constructor(status, path, responseBody, statusText) {
12
+ super(`Confluence API ${status} ${statusText}\n${path}\n${responseBody}`);
13
+ this.status = status;
14
+ this.responseBody = responseBody;
15
+ }
16
+ }
8
17
  export function createClient(cfg, opts) {
9
18
  const authHeader = () => 'Basic ' + Buffer.from(`${cfg.email}:${cfg.token}`).toString('base64');
10
19
  async function api(path, init) {
@@ -18,7 +27,7 @@ export function createClient(cfg, opts) {
18
27
  },
19
28
  });
20
29
  if (!res.ok) {
21
- throw new Error(`Confluence API ${res.status} ${res.statusText}\n${path}\n${await res.text()}`);
30
+ throw new ConfluenceApiError(res.status, path, await res.text(), res.statusText);
22
31
  }
23
32
  return res.status === 204 ? null : res.json();
24
33
  }
@@ -61,66 +70,104 @@ export function createClient(cfg, opts) {
61
70
  }
62
71
  /** 페이지의 기존 첨부 중 같은 파일명의 attachmentId 를 찾는다(없으면 null). */
63
72
  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
- }
73
+ const data = await api(`/rest/api/content/${pageId}/child/attachment?filename=${encodeURIComponent(filename)}`);
74
+ return data?.results?.[0]?.id ?? null;
71
75
  }
72
76
  /**
73
- * 로컬 이미지를 페이지 첨부로 업로드(v1, multipart).
77
+ * 로컬 파일을 페이지 첨부로 업로드(v1, multipart).
74
78
  * 같은 파일명 첨부가 이미 있으면 데이터만 새 버전으로 갱신(중복 생성 400 방지),
75
79
  * 없으면 신규 생성한다.
76
80
  */
77
81
  async function uploadAttachment(pageId, filename, abs) {
78
82
  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()}`);
83
+ // 롤백 오류에만 재시도한다. 매번 첨부를 재조회해 생성/갱신 경로를 다시 결정한다.
84
+ for (let attempt = 0; attempt < 3; attempt++) {
85
+ const attId = await findAttachmentId(pageId, filename);
86
+ const path = attId
87
+ ? `/rest/api/content/${pageId}/child/attachment/${attId}/data`
88
+ : `/rest/api/content/${pageId}/child/attachment`;
89
+ const form = new FormData();
90
+ form.append('file', new Blob([buf]), filename);
91
+ form.append('minorEdit', 'true');
92
+ const res = await fetch(`${cfg.baseUrl}${path}`, {
93
+ method: 'POST',
94
+ headers: { Authorization: authHeader(), 'X-Atlassian-Token': 'nocheck' },
95
+ body: form,
96
+ });
97
+ if (res.ok)
98
+ return;
99
+ const detail = await res.text();
100
+ if (res.status === 500 && /UnexpectedRollbackException|rollback-only/i.test(detail) && attempt < 2) {
101
+ console.error(yellow(` ↻ 첨부 트랜잭션 롤백, 재시도 ${attempt + 1}/2: ${filename}`));
102
+ await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt));
103
+ continue;
104
+ }
105
+ const traceId = res.headers.get('atl-traceid') ?? res.headers.get('x-arequestid');
106
+ throw new Error(`attachment ${res.status} ${res.statusText}\nPOST ${path}${traceId ? `\ntraceId: ${traceId}` : ''}\n${detail}`);
107
+ }
93
108
  }
94
- /** 페이지의 로컬 이미지들을 업로드. 성공 개수 반환. baseDir 은 로그 표시용. */
95
- async function uploadImages(pageId, images, baseDir) {
109
+ /** 페이지의 로컬 첨부를 업로드. 성공 개수 반환. */
110
+ async function uploadAttachments(pageId, attachments, baseDir) {
96
111
  let ok = 0;
97
- for (const img of images) {
98
- if (!existsSync(img.abs)) {
99
- console.error(yellow(` ⚠ 이미지 없음: ${relative(baseDir, img.abs)}`));
112
+ for (const file of attachments) {
113
+ if (!existsSync(file.abs)) {
114
+ console.error(yellow(` ⚠ 첨부 없음: ${relative(baseDir, file.abs)}`));
100
115
  continue;
101
116
  }
102
117
  try {
103
- await uploadAttachment(pageId, img.filename, img.abs);
118
+ await uploadAttachment(pageId, file.filename, file.abs);
104
119
  ok++;
105
120
  }
106
121
  catch (e) {
107
- console.error(red(` ✗ 이미지 업로드 실패 ${img.filename}`) + `\n${e.message}`);
122
+ console.error(red(` ✗ 첨부 업로드 실패 ${file.filename}`) + `\n${e.message}`);
108
123
  }
109
124
  }
110
125
  return ok;
111
126
  }
127
+ const uploadImages = uploadAttachments;
112
128
  /** 페이지 생성/갱신. 삭제 감지 시 재생성. 변경 없으면(opts.verify 아니면) 호출 없이 skip. */
113
129
  async function upsertPage(mapping, spaceId, key, title, storage, hash, parentId, forceUpdate = false) {
114
130
  const existing = mapping[key];
115
131
  if (existing?.pageId) {
116
132
  const changed = existing.hash !== hash || opts.force || forceUpdate;
117
133
  // 변경 없고 검증(--verify)도 안 하면 호출 없이 스킵
118
- if (!changed && !opts.verify)
134
+ if (!changed && !opts.verify && !existing.pendingMove)
119
135
  return 'skipped';
120
- const cur = await getPageOrNull(existing.pageId);
136
+ let cur = await getPageOrNull(existing.pageId);
137
+ if (!cur && existing.pendingMove)
138
+ throw new Error(`이동 대상 페이지 #${existing.pageId}가 없습니다. 자동 재생성하지 않습니다.`);
121
139
  if (cur) {
122
- if (!changed)
123
- return 'skipped'; // 페이지 존재 확인됨, 내용도 동일
140
+ let moved = false;
141
+ if (existing.pendingMove) {
142
+ if (String(cur.spaceId) !== spaceId)
143
+ throw new Error('이동 대상 페이지의 스페이스가 설정과 다릅니다.');
144
+ if ((cur.parentId ?? undefined) !== parentId) {
145
+ let targetId = parentId;
146
+ let position = 'append';
147
+ if (!targetId) {
148
+ // A sibling of the top-level homepage is also top-level.
149
+ const space = await api(`/api/v2/spaces/${spaceId}`);
150
+ targetId = space.homepageId;
151
+ if (!targetId)
152
+ throw new Error('최상위 이동 기준 홈페이지가 없습니다. CONFLUENCE_PARENT_ID를 지정하세요.');
153
+ const target = await getPageOrNull(targetId);
154
+ if (!target || target.parentId || String(target.spaceId) !== spaceId)
155
+ throw new Error('최상위 이동 기준 홈페이지를 확인할 수 없습니다.');
156
+ position = 'after';
157
+ }
158
+ if (targetId === existing.pageId)
159
+ throw new Error('페이지를 자기 자신 아래로 이동할 수 없습니다.');
160
+ await api(`/rest/api/content/${existing.pageId}/move/${position}/${targetId}`, { method: 'PUT' });
161
+ cur = await getPageOrNull(existing.pageId);
162
+ if (!cur || (cur.parentId ?? undefined) !== parentId)
163
+ throw new Error('이동 후 부모 위치 검증에 실패했습니다. 다시 sync하세요.');
164
+ moved = true;
165
+ }
166
+ }
167
+ if (!changed) {
168
+ delete existing.pendingMove;
169
+ return moved ? 'moved' : 'skipped';
170
+ }
124
171
  const nextVersion = (cur.version?.number ?? 1) + 1;
125
172
  await api(`/api/v2/pages/${existing.pageId}`, {
126
173
  method: 'PUT',
@@ -154,7 +201,7 @@ export function createClient(cfg, opts) {
154
201
  headers: { Authorization: authHeader(), Accept: 'application/json' },
155
202
  });
156
203
  if (!res.ok)
157
- throw new Error(`Confluence API ${res.status} ${res.statusText}\n${path}\n${await res.text()}`);
204
+ throw new ConfluenceApiError(res.status, path, await res.text(), res.statusText);
158
205
  return res.json();
159
206
  }
160
207
  /**
@@ -227,6 +274,47 @@ export function createClient(cfg, opts) {
227
274
  throw new Error(`Confluence API ${res.status} ${res.statusText}\n/rest/api/content/${id}\n${await res.text()}`);
228
275
  return res.json();
229
276
  }
277
+ /** 같은 스페이스·부모·제목의 폴더만 재사용한다. 검색 결과는 현재 메타로 재검증. */
278
+ async function findFolder(spaceId, spaceKey, title, parentId) {
279
+ const cql = `type = folder AND space = ${JSON.stringify(spaceKey)} AND title = ${JSON.stringify(title)}`;
280
+ let path = `/rest/api/content/search?cql=${encodeURIComponent(cql)}&limit=100`;
281
+ const matches = new Set();
282
+ const otherParents = new Set();
283
+ let homepageId;
284
+ while (path) {
285
+ const data = await getV1(path);
286
+ for (const candidate of data.results ?? []) {
287
+ if (candidate.type !== 'folder' || candidate.title !== title)
288
+ continue;
289
+ const folder = await getV1(`/rest/api/content/${encodeURIComponent(candidate.id)}?expand=ancestors,space`);
290
+ if (!Array.isArray(folder.ancestors) || !folder.space?.id) {
291
+ throw new Error(`폴더 위치 메타데이터를 확인할 수 없습니다: ${candidate.id}`);
292
+ }
293
+ if (folder.type !== 'folder' || String(folder.space.id) !== spaceId || folder.title !== title)
294
+ continue;
295
+ const actualParent = folder.ancestors.at(-1)?.id;
296
+ // The visible space root can be the homepage's children, not parentless content.
297
+ if (!parentId && actualParent && homepageId === undefined) {
298
+ homepageId = String((await api(`/api/v2/spaces/${spaceId}`)).homepageId ?? '');
299
+ }
300
+ if (actualParent === parentId || (!parentId && actualParent === homepageId)) {
301
+ matches.add(String(folder.id));
302
+ }
303
+ else {
304
+ otherParents.add(actualParent ?? 'ROOT');
305
+ }
306
+ }
307
+ const next = data._links?.next;
308
+ // Keep pagination on this configured API base (including gateway cloud IDs).
309
+ path = next ? new URL(next, 'https://pagination.invalid').pathname.replace(/^\/wiki(?=\/rest\/)/, '') + new URL(next, 'https://pagination.invalid').search : null;
310
+ }
311
+ if (matches.size > 1)
312
+ throw new Error(`같은 위치에 동명 폴더가 여러 개 있습니다: ${title}`);
313
+ if (!matches.size && otherParents.size) {
314
+ throw new Error(`동명 폴더 '${title}'가 다른 위치에 있습니다 (현재 부모: ${[...otherParents].join(', ')}, 요청 부모: ${parentId ?? 'ROOT/홈페이지'}). 부모 설정 또는 폴더 구조를 확인하세요.`);
315
+ }
316
+ return [...matches][0] ?? null;
317
+ }
230
318
  /** 폴더 생성(write:folder 필요). parentId 없으면 공간 최상위. 생성된 folder id 반환. */
231
319
  async function createFolder(spaceId, title, parentId) {
232
320
  const body = { spaceId, title };
@@ -235,13 +323,54 @@ export function createClient(cfg, opts) {
235
323
  const created = await api(`/api/v2/folders`, { method: 'POST', body: JSON.stringify(body) });
236
324
  return created.id;
237
325
  }
326
+ /** 조회→재사용→생성. 동시 생성/검색 지연으로 충돌하면 한 번 재조회한다. */
327
+ async function upsertFolder(spaceId, spaceKey, title, parentId, mappedId) {
328
+ parentId = parentId?.trim() || undefined;
329
+ if (mappedId) {
330
+ let folder;
331
+ try {
332
+ folder = await api(`/rest/api/content/${encodeURIComponent(mappedId)}?expand=ancestors,space`);
333
+ }
334
+ catch (error) {
335
+ if (!(error instanceof ConfluenceApiError) || error.status !== 404)
336
+ throw error;
337
+ }
338
+ if (folder) {
339
+ if (!Array.isArray(folder.ancestors) || !folder.space?.id)
340
+ throw new Error(`폴더 위치 메타데이터를 확인할 수 없습니다: ${mappedId}`);
341
+ const actualParent = folder.ancestors.at(-1)?.id;
342
+ const homepageId = !parentId && actualParent ? String((await api(`/api/v2/spaces/${spaceId}`)).homepageId ?? '') : undefined;
343
+ if (folder.type !== 'folder' || String(folder.space.id) !== spaceId || folder.title !== title ||
344
+ !(actualParent === parentId || (!parentId && actualParent === homepageId))) {
345
+ throw new Error(`매핑 폴더 #${mappedId}의 스페이스·제목·부모가 '${title}'의 요청 위치와 다릅니다.`);
346
+ }
347
+ return { id: mappedId, created: false };
348
+ }
349
+ }
350
+ const found = await findFolder(spaceId, spaceKey, title, parentId);
351
+ if (found)
352
+ return { id: found, created: false };
353
+ try {
354
+ return { id: await createFolder(spaceId, title, parentId), created: true };
355
+ }
356
+ catch (error) {
357
+ const conflict = error instanceof ConfluenceApiError &&
358
+ (error.status === 409 || (error.status === 400 && /folder exists with the same title/i.test(error.responseBody)));
359
+ if (!conflict)
360
+ throw error;
361
+ const raced = await findFolder(spaceId, spaceKey, title, parentId);
362
+ if (raced)
363
+ return { id: raced, created: false };
364
+ throw error;
365
+ }
366
+ }
238
367
  /** 폴더 삭제(--rebuild). */
239
368
  async function deleteFolder(id) {
240
369
  await api(`/api/v2/folders/${id}`, { method: 'DELETE' });
241
370
  }
242
371
  return {
243
- api, getPageOrNull, getSpaceId, getSpaceInfo, deleteAll, uploadImages, upsertPage,
372
+ api, getPageOrNull, getSpaceId, getSpaceInfo, deleteAll, uploadImages, uploadAttachments, upsertPage,
244
373
  getNode, getChildPages, getChildFolders, listAttachments, downloadAttachment,
245
- getContentOrNull, createFolder, deleteFolder,
374
+ getContentOrNull, findFolder, createFolder, upsertFolder, deleteFolder,
246
375
  };
247
376
  }
@@ -10,7 +10,7 @@ export function readEnv() {
10
10
  token: process.env.CONFLUENCE_API_TOKEN,
11
11
  spaceKey: process.env.CONFLUENCE_SPACE_KEY,
12
12
  // 신규 이름 우선, 없으면 구 이름(CONFLUENCE_PARENT_PAGE_ID) 하위호환
13
- parentId: process.env.CONFLUENCE_PARENT_ID ?? process.env.CONFLUENCE_PARENT_PAGE_ID,
13
+ parentId: (process.env.CONFLUENCE_PARENT_ID ?? process.env.CONFLUENCE_PARENT_PAGE_ID)?.trim() || undefined,
14
14
  };
15
15
  }
16
16
  /** 동기화(생성/갱신)에 필요한 필수 환경변수 검증. 누락 시 종료. */
@@ -5,8 +5,8 @@
5
5
  * - 로컬 이미지 → 첨부 참조(ri:attachment), 외부 URL 이미지는 그대로
6
6
  * 모든 경로는 baseDir 기준 상대경로로 해석한다.
7
7
  */
8
- import { readFileSync, existsSync } from 'node:fs';
9
- import { resolve, relative, dirname, basename } from 'node:path';
8
+ import { readFileSync, existsSync, statSync } from 'node:fs';
9
+ import { resolve, relative, dirname, basename, extname } from 'node:path';
10
10
  import { createHash } from 'node:crypto';
11
11
  import MarkdownIt from 'markdown-it';
12
12
  import { resolveWikilinks } from './obsidian.js';
@@ -27,7 +27,7 @@ const md = new MarkdownIt({ html: true, linkify: true, breaks: false });
27
27
  const emptyCtx = () => ({
28
28
  fileDir: '', titleIndex: {}, anchorIndex: {}, selfRel: '', selfTitle: '',
29
29
  anchorNames: new Set(), bodySlugs: [], headingSeq: 0, mermaidSeq: 0, anchorsPlaced: 0,
30
- images: [], internalLinks: 0, anchorLinks: 0, deadAnchors: [], linkStack: [], linkedTitles: new Set(),
30
+ images: [], attachments: [], internalLinks: 0, anchorLinks: 0, deadAnchors: [], linkStack: [], linkedTitles: new Set(),
31
31
  });
32
32
  // 렌더 규칙은 md 인스턴스에 한 번만 등록되므로, 현재 변환 컨텍스트를 모듈 변수로 공유한다.
33
33
  let ctx = emptyCtx();
@@ -91,6 +91,23 @@ md.renderer.rules.fence = (tokens, idx) => {
91
91
  const code = `<ac:structured-macro ac:name="code">${langParam}<ac:plain-text-body><![CDATA[${safe}]]></ac:plain-text-body></ac:structured-macro>\n`;
92
92
  return code + (lang === 'mermaid' ? mermaidMacro(ctx.selfRel, ctx.mermaidSeq++) : '');
93
93
  };
94
+ /** 페이지 첨부 이름은 유일해야 한다. 동일 경로는 한 번만 업로드한다. */
95
+ function addAttachment(abs) {
96
+ const existing = ctx.attachments.find(a => a.abs === abs);
97
+ if (existing)
98
+ return existing.filename;
99
+ let filename = basename(abs);
100
+ if (ctx.attachments.some(a => a.filename === filename)) {
101
+ const ext = extname(filename), stem = basename(filename, ext);
102
+ const suffix = hashOf(relative(baseDir, abs).split('\\').join('/')).slice(0, 12);
103
+ filename = `${stem}-${suffix}${ext}`;
104
+ let n = 1;
105
+ while (ctx.attachments.some(a => a.filename === filename))
106
+ filename = `${stem}-${suffix}-${n++}${ext}`;
107
+ }
108
+ ctx.attachments.push({ filename, abs });
109
+ return filename;
110
+ }
94
111
  md.renderer.rules.link_open = (tokens, idx, opts, _env, self) => {
95
112
  const originalHref = tokens[idx].attrGet('href') || '';
96
113
  const href = ctx.rewriteHref?.(originalHref) ?? originalHref;
@@ -109,6 +126,17 @@ md.renderer.rules.link_open = (tokens, idx, opts, _env, self) => {
109
126
  const page = t.title ? `<ri:page ri:content-title="${escapeXml(t.title)}" />` : '';
110
127
  return `<ac:link${anchor}>${page}<ac:link-body>`;
111
128
  }
129
+ // 일반 로컬 파일 링크도 첨부로 전환한다. 문서·외부 URL·없는 대상은 유지한다.
130
+ const path = href.split('#')[0];
131
+ if (path && !/^[a-z][a-z\d+.-]*:|^\/\//i.test(path) && !/\.md$/i.test(decodePath(path))) {
132
+ const abs = resolve(baseDir, ctx.fileDir, decodePath(path));
133
+ if (existsSync(abs) && statSync(abs).isFile()) {
134
+ const filename = addAttachment(abs);
135
+ ctx.linkStack.push(true);
136
+ const fragment = href.includes('#') ? ` ac:anchor="${escapeXml(decodePath(href.slice(href.indexOf('#') + 1)))}"` : '';
137
+ return `<ac:link${fragment}><ri:attachment ri:filename="${escapeXml(filename)}" /><ac:link-body>`;
138
+ }
139
+ }
112
140
  ctx.linkStack.push(false);
113
141
  return self.renderToken(tokens, idx, opts);
114
142
  };
@@ -128,7 +156,7 @@ md.renderer.rules.image = (tokens, idx) => {
128
156
  }
129
157
  // markdown-it 이 링크 경로를 퍼센트 인코딩하므로 디코드해서 실제 파일명/경로로 복원(한글·공백 대응)
130
158
  const abs = resolve(baseDir, ctx.fileDir, decodePath(src.split('#')[0]));
131
- const filename = basename(abs);
159
+ const filename = addAttachment(abs);
132
160
  ctx.images.push({ filename, abs });
133
161
  return `<ac:image${widthAttr}><ri:attachment ri:filename="${escapeXml(filename)}" /></ac:image>`;
134
162
  };
@@ -163,6 +191,7 @@ export function toStorage(markdown, rel, titleIndex, base, opts = {}) {
163
191
  return {
164
192
  storage,
165
193
  images: ctx.images,
194
+ attachments: ctx.attachments,
166
195
  internalLinks: ctx.internalLinks,
167
196
  anchorLinks: ctx.anchorLinks,
168
197
  anchorsPlaced: ctx.anchorsPlaced,
@@ -171,13 +200,13 @@ export function toStorage(markdown, rel, titleIndex, base, opts = {}) {
171
200
  };
172
201
  }
173
202
  /**
174
- * 변경 감지용 문서 해시: 제목 + storage + 참조 로컬 이미지의 내용 해시.
175
- * 이미지 내용을 포함하므로, 같은 파일명으로 이미지만 교체해도 해시가 바뀌어 재동기화 대상이 된다.
176
- * 이미지가 없으면 hashOf(title + '\0' + storage) 와 동일(이미지 없는 문서는 기존 해시 유지).
203
+ * 변경 감지용 문서 해시: 제목 + storage + 참조 로컬 첨부의 내용 해시.
204
+ * 첨부 내용을 포함하므로, 같은 파일명으로 파일만 교체해도 해시가 바뀌어 재동기화 대상이 된다.
205
+ * 첨부가 없으면 hashOf(title + '\0' + storage) 와 동일(첨부 없는 문서는 기존 해시 유지).
177
206
  */
178
207
  export function docHash(title, r) {
179
208
  const parts = [title, r.storage];
180
- for (const img of r.images) {
209
+ for (const img of r.attachments) {
181
210
  let digest = 'missing';
182
211
  try {
183
212
  if (existsSync(img.abs))
package/dist/sync/push.js CHANGED
@@ -101,6 +101,19 @@ export async function runPush(argv) {
101
101
  return;
102
102
  }
103
103
  const mapping = loadMapping(MAPPING_PATH);
104
+ // A remap plan is valid only for the tree it was prepared against.
105
+ for (const rel of files) {
106
+ const entry = mapping[rel];
107
+ if (entry?.pendingMove && entry.pendingMove.parentKey !== parentKeyOf(rel, folderIndex)) {
108
+ throw new Error(`구조가 remap 이후 변경되었습니다: ${rel}. remap을 다시 실행하세요.`);
109
+ }
110
+ const id = readDoc(BASE_DIR, rel).fm.pageId;
111
+ if (entry && id && entry.pageId !== id)
112
+ throw new Error(`pageId 충돌: ${rel}`);
113
+ if (!entry && id && Object.values(mapping).some(e => e.pageId === id && e.pendingMove)) {
114
+ throw new Error(`remap 이후 경로가 변경되었습니다: ${rel}. remap을 다시 실행하세요.`);
115
+ }
116
+ }
104
117
  if (DRY_RUN) {
105
118
  const seen = new Set();
106
119
  for (const rel of files) {
@@ -127,11 +140,12 @@ export async function runPush(argv) {
127
140
  // 매핑엔 없지만 frontmatter 에 pageId 가 있으면 신규 생성이 아니라 기존 페이지에 연결된다
128
141
  const status = !ex?.pageId
129
142
  ? (fm.pageId ? cyan('연결') : green('신규'))
130
- : ex.hash === hash ? gray('동일') : yellow('변경');
143
+ : ex.pendingMove ? cyan(ex.hash === hash ? '위치 확인/이동' : '위치 확인/이동 + 변경')
144
+ : ex.hash === hash ? gray('동일') : yellow('변경');
131
145
  const pk = parentKeyOf(rel, folderIndex);
132
146
  const anchorNote = r.anchorLinks || r.anchorsPlaced
133
147
  ? `, 섹션링크: ${r.anchorLinks}, 앵커: ${r.anchorsPlaced}` : '';
134
- console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}${anchorNote}, 이미지: ${r.images.length})`)}`);
148
+ console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}${anchorNote}, 이미지: ${r.images.length}, 첨부: ${r.attachments.length})`)}`);
135
149
  if (r.deadAnchors.length) {
136
150
  console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${r.deadAnchors.length}개: `) + dim(r.deadAnchors.slice(0, 3).join(' ')));
137
151
  }
@@ -146,11 +160,12 @@ export async function runPush(argv) {
146
160
  saveMapping(MAPPING_PATH, {});
147
161
  }
148
162
  const work = REBUILD ? {} : mapping;
163
+ let moved = 0;
149
164
  let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0, linked = 0, carried = 0;
150
165
  const rendered = new Map();
151
166
  const recreatedTitles = new Set(); // 이번에 재생성된 문서의 제목
152
167
  const touched = new Set(); // 이번에 발행(생성/갱신/재생성)된 문서
153
- const handledFolders = new Set(); // 이번 실행에서 처리한 폴더 dir
168
+ const handledFolders = new Map(); // 이번 실행에서 처리한 폴더 dir
154
169
  // README 없는 폴더를 Confluence 폴더로 생성(없으면)하고 work 에 등록. 성공 여부 반환.
155
170
  const ensureFolder = async (dir) => {
156
171
  const key = `${dir}/`;
@@ -161,20 +176,19 @@ export async function runPush(argv) {
161
176
  return false;
162
177
  }
163
178
  const ex = work[key];
164
- if (ex?.pageId && ex.type === 'folder' && (await client.getContentOrNull(ex.pageId))) {
165
- return true; // 이미 존재
166
- }
167
179
  try {
168
180
  const title = dir.split('/').pop();
169
- const fid = await client.createFolder(spaceId, title, parentId);
181
+ const result = await client.upsertFolder(spaceId, env.spaceKey, title, parentId, ex?.type === 'folder' ? ex.pageId : undefined);
182
+ const fid = result.id;
170
183
  work[key] = { pageId: fid, title, type: 'folder' };
171
184
  saveMapping(MAPPING_PATH, work);
172
- console.log(` ${cyan('📁 폴더')} ${dir}/ ${dim(`(부모: ${pcKey ?? 'ROOT'})`)}`);
173
- foldersMade++;
185
+ console.log(` ${cyan(!result.created ? '📁 폴더 연결' : '📁 폴더')} ${dir}/ ${dim(`(부모: ${pcKey ?? 'ROOT'})`)}`);
186
+ if (result.created)
187
+ foldersMade++;
174
188
  return true;
175
189
  }
176
190
  catch (e) {
177
- console.error(red(` ✗ 폴더 생성 실패 ${dir}/`) + `\n${e.message}`);
191
+ console.error(red(` ✗ 폴더 준비 실패 ${dir}/`) + `\n${e.message}`);
178
192
  return false;
179
193
  }
180
194
  };
@@ -182,12 +196,9 @@ export async function runPush(argv) {
182
196
  // 상위 README-less 폴더들 보장(shallow→deep)
183
197
  let folderOk = true;
184
198
  for (const d of ancestorFolderDirs(rel)) {
185
- if (!handledFolders.has(d)) {
186
- handledFolders.add(d);
187
- if (!(await ensureFolder(d)))
188
- folderOk = false;
189
- }
190
- else if (!work[`${d}/`]?.pageId)
199
+ if (!handledFolders.has(d))
200
+ handledFolders.set(d, await ensureFolder(d));
201
+ if (!handledFolders.get(d))
191
202
  folderOk = false;
192
203
  if (!folderOk)
193
204
  break;
@@ -247,8 +258,17 @@ export async function runPush(argv) {
247
258
  skipped++;
248
259
  continue;
249
260
  }
250
- const imgOk = await client.uploadImages(work[rel].pageId, r.images, BASE_DIR);
251
- const imgNote = r.images.length ? `, 이미지 ${imgOk}/${r.images.length}` : '';
261
+ if (result === 'moved') {
262
+ console.log(` ${cyan('→ 이동')} ${rel} (부모: ${pk ?? 'ROOT'})`);
263
+ moved++;
264
+ continue;
265
+ }
266
+ const imgOk = await client.uploadAttachments(work[rel].pageId, r.attachments, BASE_DIR);
267
+ const imgNote = r.attachments.length ? `, 첨부 ${imgOk}/${r.attachments.length}` : '';
268
+ if (imgOk !== r.attachments.length) {
269
+ delete work[rel].hash; // 부분 실패는 다음 동기화에서 재시도한다.
270
+ saveMapping(MAPPING_PATH, work);
271
+ }
252
272
  const label = result === 'created' ? green('+ 생성')
253
273
  : result === 'recreated' ? magenta('♻ 재생성(삭제 복구)')
254
274
  : yellow('↻ 갱신');
@@ -280,7 +300,11 @@ export async function runPush(argv) {
280
300
  try {
281
301
  await client.upsertPage(work, spaceId, rel, r.title, r.storage, r.hash, r.parentId, true);
282
302
  saveMapping(MAPPING_PATH, work);
283
- await client.uploadImages(work[rel].pageId, r.images, BASE_DIR);
303
+ const uploaded = await client.uploadAttachments(work[rel].pageId, r.attachments, BASE_DIR);
304
+ if (uploaded !== r.attachments.length) {
305
+ delete work[rel].hash;
306
+ saveMapping(MAPPING_PATH, work);
307
+ }
284
308
  console.log(` ${cyan('🔗 링크 재연결')} ${rel} ${dim('→')} ${cyan(`"${r.title}"`)}`);
285
309
  relinked++;
286
310
  }
@@ -292,6 +316,7 @@ export async function runPush(argv) {
292
316
  console.log('\n' + bold('완료') +
293
317
  ` ${green(`생성 ${created}`)}` +
294
318
  ` ${yellow(`갱신 ${updated}`)}` +
319
+ ` ${cyan(`이동 ${moved}`)}` +
295
320
  ` ${magenta(`재생성 ${recreated}`)}` +
296
321
  ` ${cyan(`링크재연결 ${relinked}`)}` +
297
322
  ` ${gray(`변경없음 ${skipped}`)}` +
@@ -0,0 +1,97 @@
1
+ /** Local-only pageId-based mapping reconciliation. Never contacts Confluence. */
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { relative, resolve } from 'node:path';
4
+ import { positionalArgs } from '../cli/args.js';
5
+ import { collectMarkdown, readDoc, buildFolderIndex, parentKeyOf } from '../documents/catalog.js';
6
+ import { buildIgnorer } from './ignore.js';
7
+ import { MAPPING_FILE, saveMapping } from './mapping.js';
8
+ function readMapping(path) {
9
+ if (!existsSync(path))
10
+ return {};
11
+ const data = JSON.parse(readFileSync(path, 'utf8'));
12
+ if (!data || typeof data !== 'object' || Array.isArray(data))
13
+ throw new Error(`잘못된 매핑: ${path}`);
14
+ const ids = new Set();
15
+ for (const [key, value] of Object.entries(data)) {
16
+ if (!value || typeof value !== 'object' || typeof value.pageId !== 'string') {
17
+ throw new Error(`잘못된 매핑 항목: ${key}`);
18
+ }
19
+ const id = value.pageId;
20
+ if (ids.has(id))
21
+ throw new Error(`매핑 pageId 중복: ${id}`);
22
+ ids.add(id);
23
+ }
24
+ return data;
25
+ }
26
+ export async function runRemap(argv) {
27
+ const values = new Set(['--base', '--mapping', '--from-mapping', '--exclude']);
28
+ const opts = {};
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const arg = argv[i];
31
+ if (values.has(arg)) {
32
+ const value = argv[++i];
33
+ if (!value || value.startsWith('--'))
34
+ throw new Error(`${arg} 값이 필요합니다.`);
35
+ (opts[arg] ??= []).push(value);
36
+ }
37
+ else if (arg !== 'remap' && arg !== '--dry-run')
38
+ throw new Error(`remap: 지원하지 않는 인자 ${arg}`);
39
+ }
40
+ if (positionalArgs(argv).length !== 1)
41
+ throw new Error('remap은 --base 전체를 대상으로 실행합니다.');
42
+ const baseInput = opts['--base']?.[0] ?? process.env.CONFLUENCE_SYNC_BASE;
43
+ if (!baseInput)
44
+ throw new Error('remap: --base <dir> 또는 CONFLUENCE_SYNC_BASE가 필요합니다.');
45
+ const base = resolve(baseInput);
46
+ const output = resolve(opts['--mapping']?.[0] ?? resolve(base, MAPPING_FILE));
47
+ const source = opts['--from-mapping']?.[0];
48
+ if (source && !existsSync(resolve(source)))
49
+ throw new Error(`원본 매핑이 없습니다: ${source}`);
50
+ const mapping = readMapping(output);
51
+ if (source)
52
+ for (const [key, entry] of Object.entries(readMapping(resolve(source)))) {
53
+ if (Object.values(mapping).some(e => e.pageId === entry.pageId))
54
+ continue;
55
+ if (mapping[key] && mapping[key].pageId !== entry.pageId)
56
+ throw new Error(`매핑 충돌: ${key}`);
57
+ mapping[key] ??= entry;
58
+ }
59
+ const ignorer = buildIgnorer(base, opts['--exclude'] ?? []);
60
+ const files = collectMarkdown(base).map(f => relative(base, f).split('\\').join('/')).filter(f => !ignorer.ignores(f)).sort();
61
+ const folders = buildFolderIndex(files);
62
+ const byId = new Map();
63
+ for (const [key, entry] of Object.entries(mapping)) {
64
+ if (byId.has(entry.pageId))
65
+ throw new Error(`매핑 pageId 중복: ${entry.pageId}`);
66
+ byId.set(entry.pageId, [key, entry]);
67
+ }
68
+ const docs = files.map(key => ({ key, doc: readDoc(base, key) }));
69
+ const seen = new Set();
70
+ const next = structuredClone(mapping);
71
+ const updates = [];
72
+ for (const { key, doc } of docs) {
73
+ const id = doc.fm.pageId ?? mapping[key]?.pageId;
74
+ if (!id) {
75
+ console.log(`미연결: ${key} (pageId 없음)`);
76
+ continue;
77
+ }
78
+ if (seen.has(id))
79
+ throw new Error(`문서 pageId 중복: ${id}`);
80
+ seen.add(id);
81
+ if (mapping[key] && mapping[key].pageId !== id)
82
+ throw new Error(`pageId 충돌: ${key}`);
83
+ const prior = byId.get(id);
84
+ if (prior?.[1].type === 'folder')
85
+ throw new Error(`폴더 ID를 문서에 사용할 수 없습니다: ${key}`);
86
+ if (prior)
87
+ delete next[prior[0]];
88
+ const entry = { ...(prior?.[1] ?? { pageId: id, title: doc.title }), pendingMove: { parentKey: parentKeyOf(key, folders) } };
89
+ updates.push([key, entry]);
90
+ console.log(`${prior?.[0] ?? `#${id}`} → ${key} (부모: ${entry.pendingMove.parentKey ?? 'ROOT'})${entry.hash ? '' : ' — 기존 해시 없음: sync 시 본문 갱신 대상'}`);
91
+ }
92
+ for (const [key, entry] of updates)
93
+ next[key] = entry;
94
+ if (!argv.includes('--dry-run'))
95
+ saveMapping(output, next);
96
+ console.log(`${argv.includes('--dry-run') ? '미리보기' : '매핑 저장'}: ${output} (${updates.length}건). Confluence 호출 없음.`);
97
+ }
package/dist/sync.js CHANGED
File without changes
package/dist/web/sync.js CHANGED
@@ -148,7 +148,7 @@ export function createWebSync(runner = runSyncCLI) {
148
148
  let reportedFailure = false;
149
149
  const secrets = [prepared.env.CONFLUENCE_API_TOKEN, Buffer.from(`${prepared.env.CONFLUENCE_EMAIL}:${prepared.env.CONFLUENCE_API_TOKEN}`).toString('base64')].filter(Boolean);
150
150
  const log = (text) => {
151
- if (text.includes('✗') || text.includes('⚠ 이미지 없음'))
151
+ if (text.includes('✗') || text.includes('⚠ 이미지 없음') || text.includes('⚠ 첨부 없음'))
152
152
  reportedFailure = true; // CLI reports per-document / attachment failures without exiting.
153
153
  for (const secret of secrets)
154
154
  text = text.split(secret).join('[비공개]');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonseok-han/confluence-sync",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "description": "Confluence Cloud ↔ Markdown 양방향 동기화 CLI — 마크다운을 Confluence 페이지로 발행(push)하고, Confluence 페이지·폴더·스페이스를 마크다운으로 가져옵니다(pull).",
6
6
  "keywords": [