@wonseok-han/confluence-sync 1.7.0 → 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
@@ -199,6 +199,7 @@ docs/ → [CONFLUENCE_PARENT_ID 또는 공간 최상위]
199
199
  - `mermaid` 코드블록 뒤에는 **Mermaid diagram** 매크로를 자동 삽입합니다. Confluence에 [Atlassian Labs Mermaid Diagrams Viewer](https://marketplace.atlassian.com/apps/1232887/mermaid-diagrams-viewer)가 설치되어 있어야 하며, 원본 코드도 함께 표시됩니다. 자동 연결은 등장 순서를 사용하므로 페이지의 모든 Mermaid 코드는 `mermaid` 코드블록으로 작성하세요.
200
200
  - **내부 `.md` 링크**와 **`[[wikilink]]`** → Confluence 페이지 링크로 자동 변환.
201
201
  - **섹션 링크** `[§1 개요](#1-개요)` · `[§6](./04-구현체.md#6-zkp)` · `[[문서#헤딩 텍스트]]` → Confluence 앵커 링크. [아래 참조](#섹션-링크-앵커)
202
+ - **일반 파일 링크** `[자료](<attachments/자료.pdf>)` → PDF·TXT·MMD·JSON 등 실제 로컬 파일을 페이지 첨부로 업로드하고 다운로드 링크로 변환합니다. 경로는 Markdown 문서 위치 기준이며, 한글·공백 경로와 반복 링크를 지원합니다. 문서 링크(`.md`)·외부 URL·없는 파일은 기존 링크를 유지합니다. 같은 페이지에서 서로 다른 파일의 파일명이 같으면 업로드 이름에 경로 기반 접미사를 붙여 구분합니다.
202
203
  - **로컬 이미지**와 **`![[embed]]`** → 페이지 첨부로 업로드 후 참조 (같은 파일명은 새 버전으로 갱신). 외부 URL 이미지는 그대로.
203
204
  - **YAML frontmatter** 는 메타데이터로만 쓰이고 본문에 새지 않습니다.
204
205
 
@@ -250,7 +251,7 @@ pull 은 반대로 Confluence 앵커를 마크다운 슬러그로 되돌립니
250
251
 
251
252
  ### 변경 감지와 매핑
252
253
 
253
- - 문서의 `제목 + 본문 + 참조 이미지 내용` 해시를 저장해두고, **바뀐 문서만** 새 버전으로 올립니다(`= 변경없음` 은 API 호출조차 하지 않음). `--force` 로 무시할 수 있습니다.
254
+ - 문서의 `제목 + 본문 + 참조 첨부 내용` 해시를 저장해두고, **바뀐 문서만** 새 버전으로 올립니다(`= 변경없음` 은 API 호출조차 하지 않음). `--force` 로 무시할 수 있습니다.
254
255
  - 매핑은 **`<base>/.confluence-sync.json`** 에 저장됩니다. **문서셋과 함께 커밋하세요** — 지우면 다음 실행에서 페이지가 중복 생성됩니다. (`--mapping <path>` 로 위치 변경 가능)
255
256
  - **pull 도 같은 매핑에 기록합니다** — 그래서 pull 직후 push 하면 `= 변경없음` 입니다. [Pull → 매핑 기록](#매핑-기록) 참고.
256
257
  - Confluence 에서 페이지가 삭제됐다면 자동으로 **재생성**하고, 그 페이지를 가리키던 문서들도 **링크를 다시 연결**합니다. 내용이 그대로라 스킵되는 경우까지 잡으려면 `--verify` 를 씁니다.
@@ -70,54 +70,61 @@ export function createClient(cfg, opts) {
70
70
  }
71
71
  /** 페이지의 기존 첨부 중 같은 파일명의 attachmentId 를 찾는다(없으면 null). */
72
72
  async function findAttachmentId(pageId, filename) {
73
- try {
74
- const data = await api(`/rest/api/content/${pageId}/child/attachment?filename=${encodeURIComponent(filename)}`);
75
- return data?.results?.[0]?.id ?? null;
76
- }
77
- catch {
78
- return null;
79
- }
73
+ const data = await api(`/rest/api/content/${pageId}/child/attachment?filename=${encodeURIComponent(filename)}`);
74
+ return data?.results?.[0]?.id ?? null;
80
75
  }
81
76
  /**
82
- * 로컬 이미지를 페이지 첨부로 업로드(v1, multipart).
77
+ * 로컬 파일을 페이지 첨부로 업로드(v1, multipart).
83
78
  * 같은 파일명 첨부가 이미 있으면 데이터만 새 버전으로 갱신(중복 생성 400 방지),
84
79
  * 없으면 신규 생성한다.
85
80
  */
86
81
  async function uploadAttachment(pageId, filename, abs) {
87
82
  const buf = readFileSync(abs);
88
- const form = new FormData();
89
- form.append('file', new Blob([buf]), filename);
90
- form.append('minorEdit', 'true');
91
- const attId = await findAttachmentId(pageId, filename);
92
- const path = attId
93
- ? `/rest/api/content/${pageId}/child/attachment/${attId}/data` // 기존 첨부 데이터 갱신
94
- : `/rest/api/content/${pageId}/child/attachment`; // 신규 첨부 생성
95
- const res = await fetch(`${cfg.baseUrl}${path}`, {
96
- method: 'POST',
97
- headers: { Authorization: authHeader(), 'X-Atlassian-Token': 'nocheck' },
98
- body: form,
99
- });
100
- if (!res.ok)
101
- 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
+ }
102
108
  }
103
- /** 페이지의 로컬 이미지들을 업로드. 성공 개수 반환. baseDir 은 로그 표시용. */
104
- async function uploadImages(pageId, images, baseDir) {
109
+ /** 페이지의 로컬 첨부를 업로드. 성공 개수 반환. */
110
+ async function uploadAttachments(pageId, attachments, baseDir) {
105
111
  let ok = 0;
106
- for (const img of images) {
107
- if (!existsSync(img.abs)) {
108
- 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)}`));
109
115
  continue;
110
116
  }
111
117
  try {
112
- await uploadAttachment(pageId, img.filename, img.abs);
118
+ await uploadAttachment(pageId, file.filename, file.abs);
113
119
  ok++;
114
120
  }
115
121
  catch (e) {
116
- console.error(red(` ✗ 이미지 업로드 실패 ${img.filename}`) + `\n${e.message}`);
122
+ console.error(red(` ✗ 첨부 업로드 실패 ${file.filename}`) + `\n${e.message}`);
117
123
  }
118
124
  }
119
125
  return ok;
120
126
  }
127
+ const uploadImages = uploadAttachments;
121
128
  /** 페이지 생성/갱신. 삭제 감지 시 재생성. 변경 없으면(opts.verify 아니면) 호출 없이 skip. */
122
129
  async function upsertPage(mapping, spaceId, key, title, storage, hash, parentId, forceUpdate = false) {
123
130
  const existing = mapping[key];
@@ -362,7 +369,7 @@ export function createClient(cfg, opts) {
362
369
  await api(`/api/v2/folders/${id}`, { method: 'DELETE' });
363
370
  }
364
371
  return {
365
- api, getPageOrNull, getSpaceId, getSpaceInfo, deleteAll, uploadImages, upsertPage,
372
+ api, getPageOrNull, getSpaceId, getSpaceInfo, deleteAll, uploadImages, uploadAttachments, upsertPage,
366
373
  getNode, getChildPages, getChildFolders, listAttachments, downloadAttachment,
367
374
  getContentOrNull, findFolder, createFolder, upsertFolder, deleteFolder,
368
375
  };
@@ -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
@@ -145,7 +145,7 @@ export async function runPush(argv) {
145
145
  const pk = parentKeyOf(rel, folderIndex);
146
146
  const anchorNote = r.anchorLinks || r.anchorsPlaced
147
147
  ? `, 섹션링크: ${r.anchorLinks}, 앵커: ${r.anchorsPlaced}` : '';
148
- 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})`)}`);
149
149
  if (r.deadAnchors.length) {
150
150
  console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${r.deadAnchors.length}개: `) + dim(r.deadAnchors.slice(0, 3).join(' ')));
151
151
  }
@@ -263,8 +263,12 @@ export async function runPush(argv) {
263
263
  moved++;
264
264
  continue;
265
265
  }
266
- const imgOk = await client.uploadImages(work[rel].pageId, r.images, BASE_DIR);
267
- const imgNote = r.images.length ? `, 이미지 ${imgOk}/${r.images.length}` : '';
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
+ }
268
272
  const label = result === 'created' ? green('+ 생성')
269
273
  : result === 'recreated' ? magenta('♻ 재생성(삭제 복구)')
270
274
  : yellow('↻ 갱신');
@@ -296,7 +300,11 @@ export async function runPush(argv) {
296
300
  try {
297
301
  await client.upsertPage(work, spaceId, rel, r.title, r.storage, r.hash, r.parentId, true);
298
302
  saveMapping(MAPPING_PATH, work);
299
- 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
+ }
300
308
  console.log(` ${cyan('🔗 링크 재연결')} ${rel} ${dim('→')} ${cyan(`"${r.title}"`)}`);
301
309
  relinked++;
302
310
  }
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.7.0",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "description": "Confluence Cloud ↔ Markdown 양방향 동기화 CLI — 마크다운을 Confluence 페이지로 발행(push)하고, Confluence 페이지·폴더·스페이스를 마크다운으로 가져옵니다(pull).",
6
6
  "keywords": [