@wonseok-han/confluence-sync 1.6.0 → 1.7.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
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
  }
@@ -115,12 +124,43 @@ export function createClient(cfg, opts) {
115
124
  if (existing?.pageId) {
116
125
  const changed = existing.hash !== hash || opts.force || forceUpdate;
117
126
  // 변경 없고 검증(--verify)도 안 하면 호출 없이 스킵
118
- if (!changed && !opts.verify)
127
+ if (!changed && !opts.verify && !existing.pendingMove)
119
128
  return 'skipped';
120
- const cur = await getPageOrNull(existing.pageId);
129
+ let cur = await getPageOrNull(existing.pageId);
130
+ if (!cur && existing.pendingMove)
131
+ throw new Error(`이동 대상 페이지 #${existing.pageId}가 없습니다. 자동 재생성하지 않습니다.`);
121
132
  if (cur) {
122
- if (!changed)
123
- return 'skipped'; // 페이지 존재 확인됨, 내용도 동일
133
+ let moved = false;
134
+ if (existing.pendingMove) {
135
+ if (String(cur.spaceId) !== spaceId)
136
+ throw new Error('이동 대상 페이지의 스페이스가 설정과 다릅니다.');
137
+ if ((cur.parentId ?? undefined) !== parentId) {
138
+ let targetId = parentId;
139
+ let position = 'append';
140
+ if (!targetId) {
141
+ // A sibling of the top-level homepage is also top-level.
142
+ const space = await api(`/api/v2/spaces/${spaceId}`);
143
+ targetId = space.homepageId;
144
+ if (!targetId)
145
+ throw new Error('최상위 이동 기준 홈페이지가 없습니다. CONFLUENCE_PARENT_ID를 지정하세요.');
146
+ const target = await getPageOrNull(targetId);
147
+ if (!target || target.parentId || String(target.spaceId) !== spaceId)
148
+ throw new Error('최상위 이동 기준 홈페이지를 확인할 수 없습니다.');
149
+ position = 'after';
150
+ }
151
+ if (targetId === existing.pageId)
152
+ throw new Error('페이지를 자기 자신 아래로 이동할 수 없습니다.');
153
+ await api(`/rest/api/content/${existing.pageId}/move/${position}/${targetId}`, { method: 'PUT' });
154
+ cur = await getPageOrNull(existing.pageId);
155
+ if (!cur || (cur.parentId ?? undefined) !== parentId)
156
+ throw new Error('이동 후 부모 위치 검증에 실패했습니다. 다시 sync하세요.');
157
+ moved = true;
158
+ }
159
+ }
160
+ if (!changed) {
161
+ delete existing.pendingMove;
162
+ return moved ? 'moved' : 'skipped';
163
+ }
124
164
  const nextVersion = (cur.version?.number ?? 1) + 1;
125
165
  await api(`/api/v2/pages/${existing.pageId}`, {
126
166
  method: 'PUT',
@@ -154,7 +194,7 @@ export function createClient(cfg, opts) {
154
194
  headers: { Authorization: authHeader(), Accept: 'application/json' },
155
195
  });
156
196
  if (!res.ok)
157
- throw new Error(`Confluence API ${res.status} ${res.statusText}\n${path}\n${await res.text()}`);
197
+ throw new ConfluenceApiError(res.status, path, await res.text(), res.statusText);
158
198
  return res.json();
159
199
  }
160
200
  /**
@@ -227,6 +267,47 @@ export function createClient(cfg, opts) {
227
267
  throw new Error(`Confluence API ${res.status} ${res.statusText}\n/rest/api/content/${id}\n${await res.text()}`);
228
268
  return res.json();
229
269
  }
270
+ /** 같은 스페이스·부모·제목의 폴더만 재사용한다. 검색 결과는 현재 메타로 재검증. */
271
+ async function findFolder(spaceId, spaceKey, title, parentId) {
272
+ const cql = `type = folder AND space = ${JSON.stringify(spaceKey)} AND title = ${JSON.stringify(title)}`;
273
+ let path = `/rest/api/content/search?cql=${encodeURIComponent(cql)}&limit=100`;
274
+ const matches = new Set();
275
+ const otherParents = new Set();
276
+ let homepageId;
277
+ while (path) {
278
+ const data = await getV1(path);
279
+ for (const candidate of data.results ?? []) {
280
+ if (candidate.type !== 'folder' || candidate.title !== title)
281
+ continue;
282
+ const folder = await getV1(`/rest/api/content/${encodeURIComponent(candidate.id)}?expand=ancestors,space`);
283
+ if (!Array.isArray(folder.ancestors) || !folder.space?.id) {
284
+ throw new Error(`폴더 위치 메타데이터를 확인할 수 없습니다: ${candidate.id}`);
285
+ }
286
+ if (folder.type !== 'folder' || String(folder.space.id) !== spaceId || folder.title !== title)
287
+ continue;
288
+ const actualParent = folder.ancestors.at(-1)?.id;
289
+ // The visible space root can be the homepage's children, not parentless content.
290
+ if (!parentId && actualParent && homepageId === undefined) {
291
+ homepageId = String((await api(`/api/v2/spaces/${spaceId}`)).homepageId ?? '');
292
+ }
293
+ if (actualParent === parentId || (!parentId && actualParent === homepageId)) {
294
+ matches.add(String(folder.id));
295
+ }
296
+ else {
297
+ otherParents.add(actualParent ?? 'ROOT');
298
+ }
299
+ }
300
+ const next = data._links?.next;
301
+ // Keep pagination on this configured API base (including gateway cloud IDs).
302
+ path = next ? new URL(next, 'https://pagination.invalid').pathname.replace(/^\/wiki(?=\/rest\/)/, '') + new URL(next, 'https://pagination.invalid').search : null;
303
+ }
304
+ if (matches.size > 1)
305
+ throw new Error(`같은 위치에 동명 폴더가 여러 개 있습니다: ${title}`);
306
+ if (!matches.size && otherParents.size) {
307
+ throw new Error(`동명 폴더 '${title}'가 다른 위치에 있습니다 (현재 부모: ${[...otherParents].join(', ')}, 요청 부모: ${parentId ?? 'ROOT/홈페이지'}). 부모 설정 또는 폴더 구조를 확인하세요.`);
308
+ }
309
+ return [...matches][0] ?? null;
310
+ }
230
311
  /** 폴더 생성(write:folder 필요). parentId 없으면 공간 최상위. 생성된 folder id 반환. */
231
312
  async function createFolder(spaceId, title, parentId) {
232
313
  const body = { spaceId, title };
@@ -235,6 +316,47 @@ export function createClient(cfg, opts) {
235
316
  const created = await api(`/api/v2/folders`, { method: 'POST', body: JSON.stringify(body) });
236
317
  return created.id;
237
318
  }
319
+ /** 조회→재사용→생성. 동시 생성/검색 지연으로 충돌하면 한 번 재조회한다. */
320
+ async function upsertFolder(spaceId, spaceKey, title, parentId, mappedId) {
321
+ parentId = parentId?.trim() || undefined;
322
+ if (mappedId) {
323
+ let folder;
324
+ try {
325
+ folder = await api(`/rest/api/content/${encodeURIComponent(mappedId)}?expand=ancestors,space`);
326
+ }
327
+ catch (error) {
328
+ if (!(error instanceof ConfluenceApiError) || error.status !== 404)
329
+ throw error;
330
+ }
331
+ if (folder) {
332
+ if (!Array.isArray(folder.ancestors) || !folder.space?.id)
333
+ throw new Error(`폴더 위치 메타데이터를 확인할 수 없습니다: ${mappedId}`);
334
+ const actualParent = folder.ancestors.at(-1)?.id;
335
+ const homepageId = !parentId && actualParent ? String((await api(`/api/v2/spaces/${spaceId}`)).homepageId ?? '') : undefined;
336
+ if (folder.type !== 'folder' || String(folder.space.id) !== spaceId || folder.title !== title ||
337
+ !(actualParent === parentId || (!parentId && actualParent === homepageId))) {
338
+ throw new Error(`매핑 폴더 #${mappedId}의 스페이스·제목·부모가 '${title}'의 요청 위치와 다릅니다.`);
339
+ }
340
+ return { id: mappedId, created: false };
341
+ }
342
+ }
343
+ const found = await findFolder(spaceId, spaceKey, title, parentId);
344
+ if (found)
345
+ return { id: found, created: false };
346
+ try {
347
+ return { id: await createFolder(spaceId, title, parentId), created: true };
348
+ }
349
+ catch (error) {
350
+ const conflict = error instanceof ConfluenceApiError &&
351
+ (error.status === 409 || (error.status === 400 && /folder exists with the same title/i.test(error.responseBody)));
352
+ if (!conflict)
353
+ throw error;
354
+ const raced = await findFolder(spaceId, spaceKey, title, parentId);
355
+ if (raced)
356
+ return { id: raced, created: false };
357
+ throw error;
358
+ }
359
+ }
238
360
  /** 폴더 삭제(--rebuild). */
239
361
  async function deleteFolder(id) {
240
362
  await api(`/api/v2/folders/${id}`, { method: 'DELETE' });
@@ -242,6 +364,6 @@ export function createClient(cfg, opts) {
242
364
  return {
243
365
  api, getPageOrNull, getSpaceId, getSpaceInfo, deleteAll, uploadImages, upsertPage,
244
366
  getNode, getChildPages, getChildFolders, listAttachments, downloadAttachment,
245
- getContentOrNull, createFolder, deleteFolder,
367
+ getContentOrNull, findFolder, createFolder, upsertFolder, deleteFolder,
246
368
  };
247
369
  }
@@ -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
  /** 동기화(생성/갱신)에 필요한 필수 환경변수 검증. 누락 시 종료. */
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,7 +140,8 @@ 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}` : '';
@@ -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,6 +258,11 @@ export async function runPush(argv) {
247
258
  skipped++;
248
259
  continue;
249
260
  }
261
+ if (result === 'moved') {
262
+ console.log(` ${cyan('→ 이동')} ${rel} (부모: ${pk ?? 'ROOT'})`);
263
+ moved++;
264
+ continue;
265
+ }
250
266
  const imgOk = await client.uploadImages(work[rel].pageId, r.images, BASE_DIR);
251
267
  const imgNote = r.images.length ? `, 이미지 ${imgOk}/${r.images.length}` : '';
252
268
  const label = result === 'created' ? green('+ 생성')
@@ -292,6 +308,7 @@ export async function runPush(argv) {
292
308
  console.log('\n' + bold('완료') +
293
309
  ` ${green(`생성 ${created}`)}` +
294
310
  ` ${yellow(`갱신 ${updated}`)}` +
311
+ ` ${cyan(`이동 ${moved}`)}` +
295
312
  ` ${magenta(`재생성 ${recreated}`)}` +
296
313
  ` ${cyan(`링크재연결 ${relinked}`)}` +
297
314
  ` ${gray(`변경없음 ${skipped}`)}` +
@@ -87,7 +87,10 @@ export function buildReferenceLookup(base, rels, roots, baseUrl) {
87
87
  return null;
88
88
  }
89
89
  linked(`${target} → pageId ${hit.pageId} (${join(hit.root, hit.rel)})`);
90
- return `${server}/pages/viewpage.action?pageId=${encodeURIComponent(hit.pageId)}`;
90
+ // The API gateway has no browser UI. References are already restricted to
91
+ // the same server, so Cloud links can use the viewing site's /wiki root.
92
+ const pageBase = new URL(server).hostname === 'api.atlassian.com' ? '/wiki' : server;
93
+ return `${pageBase}/pages/viewpage.action?pageId=${encodeURIComponent(hit.pageId)}`;
91
94
  },
92
95
  };
93
96
  }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonseok-han/confluence-sync",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Confluence Cloud ↔ Markdown 양방향 동기화 CLI — 마크다운을 Confluence 페이지로 발행(push)하고, Confluence 페이지·폴더·스페이스를 마크다운으로 가져옵니다(pull).",
6
6
  "keywords": [