@wonseok-han/confluence-sync 2.0.0 → 2.0.1

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.
@@ -1,6 +1,6 @@
1
1
  <!doctype html>
2
- <html lang="ko"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-gC1xP5ikAuS9OEjIyx77wCJoX4h4jhP+0cQnKC4FHD4='; connect-src 'self' http://127.0.0.1:* http://localhost:* https://*.blob.vercel-storage.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'"><meta name="referrer" content="no-referrer"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="csync-token" content=""><meta name="csync-runtime" content="standalone"><meta name="csync-version" content="2.0.0"><title>문서 워크스페이스 · confluence-sync</title><link rel="icon" type="image/svg+xml" href="/assets/favicon.svg?v=paperlink"><link rel="stylesheet" href="/assets/app.css"></head><body>
3
- <header class="app-header"><a class="brand" href="/" aria-label="confluence-sync 홈"><img class="brand-symbol" src="/assets/favicon.svg?v=paperlink" alt="" aria-hidden="true" width="30" height="30">confluence<span class="brand-light">sync</span></a><span class="app-version" aria-label="현재 버전">v2.0.0</span><span class="local-status"><i></i><span id="local-connection-status">브라우저 폴더 선택 필요</span></span><button id="local-disconnect" class="quiet" type="button" hidden>연결 해제</button></header>
2
+ <html lang="ko"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-gC1xP5ikAuS9OEjIyx77wCJoX4h4jhP+0cQnKC4FHD4='; connect-src 'self' http://127.0.0.1:* http://localhost:* https://*.blob.vercel-storage.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'"><meta name="referrer" content="no-referrer"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="csync-token" content=""><meta name="csync-runtime" content="standalone"><meta name="csync-version" content="2.0.1"><title>문서 워크스페이스 · confluence-sync</title><link rel="icon" type="image/svg+xml" href="/assets/favicon.svg?v=paperlink"><link rel="stylesheet" href="/assets/app.css"></head><body>
3
+ <header class="app-header"><a class="brand" href="/" aria-label="confluence-sync 홈"><img class="brand-symbol" src="/assets/favicon.svg?v=paperlink" alt="" aria-hidden="true" width="30" height="30">confluence<span class="brand-light">sync</span></a><span class="app-version" aria-label="현재 버전">v2.0.1</span><span class="local-status"><i></i><span id="local-connection-status">브라우저 폴더 선택 필요</span></span><button id="local-disconnect" class="quiet" type="button" hidden>연결 해제</button></header>
4
4
  <main id="workspace-interface" class="app-main">
5
5
  <div class="page-heading"><div><div class="eyebrow">DOCUMENT WORKSPACE</div><h1>문서 워크스페이스<span class="title-dot">.</span></h1></div><div class="page-heading-actions"><p>문서를 변환하고, Confluence에서 가져오거나 업로드하세요.</p><button id="open-help" aria-haspopup="dialog" aria-controls="help-dialog">사용 도움말</button></div></div>
6
6
  <div class="mode-tabs" role="tablist" aria-label="문서 작업"><button id="tab-convert" role="tab" aria-selected="true" aria-controls="convert-view">문서 변환</button><button id="tab-sync" role="tab" aria-selected="false" aria-controls="sync-view" tabindex="-1">Confluence</button></div>
@@ -3,7 +3,7 @@ import { progressStep } from '../shared/progress.js';
3
3
  import { bold, cyan, green, yellow, red, dim } from '../shared/colors.js';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
- import { dirname, join, resolve } from 'node:path';
6
+ import { basename, dirname, join, resolve } from 'node:path';
7
7
  import { readEnv } from '../confluence/config.js';
8
8
  import { buildFolderIndex, parentKeyOf } from '../documents/catalog.js';
9
9
  import { buildIgnorer } from './ignore.js';
@@ -13,6 +13,8 @@ import { scopeClient } from './scope.js';
13
13
  import { assertWorkspaceConnection, inNestedWorkspace, inScope, loadWorkspace, requireScope, resolveSyncBase, within } from './workspace.js';
14
14
  export const FETCH_TTL_MS = 120_000;
15
15
  const BATCH_SIZE = 100, MAX_REQUESTS = 20;
16
+ const folderUnavailableReason = '원격 상위 폴더를 조회할 수 없습니다. 삭제·권한 변경 여부를 확인하세요.';
17
+ const folderMoveReason = (folder) => `원격 상위 폴더 위치 변경${basename(folder.previousPath.replace(/\/$/, '')) !== basename(folder.path.replace(/\/$/, '')) ? ' (폴더 이름 변경 포함)' : ''}: ${folder.previousPath} → ${folder.path}`;
16
18
  const states = new Set(['unchanged', 'changed', 'unavailable', 'unknown', 'new']);
17
19
  const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
18
20
  function cachePath(base) {
@@ -32,7 +34,7 @@ function fetchContext(baseInput, env, options = {}) {
32
34
  throw new Error('매핑의 페이지 ID가 숫자가 아닙니다. 매핑을 확인하세요.');
33
35
  const folderIndex = buildFolderIndex(Object.keys(mapping).filter(path => /\.md$/i.test(path)));
34
36
  const expectedParents = new Map(pages.map(([path]) => { const parent = parentKeyOf(path, folderIndex); return [path, path === 'README.md' && mapping[path].pageId === root.id ? undefined : mapping[path].remoteParentId ?? (parent ? mapping[parent]?.pageId : root.id)]; }));
35
- const key = digest(['discovery-v2', base, workspace, env.baseUrl, env.spaceKey, digest([env.email, env.token]), pages, [...expectedParents], scoped.filter(([, e]) => e.type === 'folder')]);
37
+ const key = digest(['discovery-v3', base, workspace, env.baseUrl, env.spaceKey, digest([env.email, env.token]), pages, [...expectedParents], scoped.filter(([, e]) => e.type === 'folder')]);
36
38
  const binding = digest([base, workspace, env.baseUrl, env.spaceKey, digest([env.email, env.token])]);
37
39
  const path = cachePath(base), now = Date.now();
38
40
  return { base, workspace, root, mapping, pages, scoped, expectedParents, key, path, now, binding };
@@ -43,7 +45,7 @@ function cachedSnapshot(context, allowExpired = false) {
43
45
  const age = context.now - Date.parse(value?.checkedAt);
44
46
  if (value?.version !== 1 || value.key !== context.key || age < 0 || !Number.isFinite(age) || !allowExpired && age >= FETCH_TTL_MS || !Array.isArray(value.items))
45
47
  return null;
46
- if (value.folders !== undefined && (!Array.isArray(value.folders) || value.folders.some(folder => !folder || typeof folder.path !== 'string' || !folder.path.endsWith('/') || !/^\d+$/.test(folder.pageId) || typeof folder.title !== 'string' || !within(context.base, resolve(context.base, folder.path)))))
48
+ if (value.folders !== undefined && (!Array.isArray(value.folders) || value.folders.some(folder => !folder || typeof folder.path !== 'string' || !folder.path.endsWith('/') || !/^\d+$/.test(folder.pageId) || typeof folder.title !== 'string' || folder.previousPath !== undefined && (typeof folder.previousPath !== 'string' || !folder.previousPath.endsWith('/') || !within(context.base, resolve(context.base, folder.previousPath))) || !within(context.base, resolve(context.base, folder.path)))))
47
49
  return null;
48
50
  const ids = new Set();
49
51
  for (const item of value.items) {
@@ -83,7 +85,7 @@ export function readFetchSnapshot(base, env) {
83
85
  if (!mapped)
84
86
  return item;
85
87
  const { path, entry } = mapped, reasons = [];
86
- if (item.state === 'unavailable')
88
+ if (item.state === 'unavailable' || item.state === 'unknown' && item.reasons.includes(folderUnavailableReason))
87
89
  return { ...item, path, baselineVersion: entry.remoteVersion };
88
90
  if (!entry.remoteVersion || !item.remoteVersion || item.remoteVersion < entry.remoteVersion)
89
91
  return { ...item, path, baselineVersion: entry.remoteVersion, state: 'unknown', reasons: ['원격 버전 기준을 확인할 수 없습니다. Fetch로 갱신하세요.'] };
@@ -94,6 +96,9 @@ export function readFetchSnapshot(base, env) {
94
96
  const parent = context.expectedParents.get(path);
95
97
  if (parent && item.parentId && parent !== item.parentId)
96
98
  reasons.push('원격 부모 위치 변경');
99
+ for (const folder of prior.folders ?? [])
100
+ if (folder.previousPath && path.startsWith(folder.previousPath) && context.mapping[folder.previousPath]?.pageId === folder.pageId)
101
+ reasons.push(folderMoveReason(folder));
97
102
  return { ...item, path, baselineVersion: entry.remoteVersion, state: reasons.length ? 'changed' : 'unchanged', reasons };
98
103
  }) };
99
104
  }
@@ -123,7 +128,7 @@ export async function fetchRemote(baseInput, env, options = {}) {
123
128
  for (const page of data.results) {
124
129
  if (!page || !/^\d+$/.test(String(page.id)) || page.parentId != null && !/^\d+$/.test(String(page.parentId)) || String(page.spaceId) !== workspace.connection.spaceId || remote.has(String(page.id)))
125
130
  throw new Error('원격 페이지 목록의 ID·스페이스 또는 중복 응답을 확인하세요.');
126
- remote.set(String(page.id), page);
131
+ remote.set(String(page.id), { ...page, id: String(page.id), parentId: page.parentId == null ? undefined : String(page.parentId) });
127
132
  }
128
133
  const next = data._links?.next || link.split(',').find(part => /rel\s*=\s*"?next"?/i.test(part))?.match(/<([^>]+)>/)?.[1];
129
134
  cursor = '';
@@ -168,7 +173,9 @@ export async function fetchRemote(baseInput, env, options = {}) {
168
173
  const folders = new Map();
169
174
  const slug = (title) => { const value = title.replace(/[\/\\:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim(); return !value || /^\.+$/.test(value) ? 'untitled' : value.startsWith('.') || ['attachments', 'node_modules'].includes(value) ? '_' + value : value; };
170
175
  const nodes = new Map(remote);
171
- const missingParents = [...new Set([...remote].filter(([id]) => !mappingById.has(id)).map(([, page]) => String(page.parentId ?? '')).filter(id => id && id !== context.root.id && !nodes.has(id) && !mappingById.has(id)))];
176
+ const unavailableFolders = new Set();
177
+ const trackedFolders = context.scoped.filter(([path, entry]) => entry.type === 'folder' && pages.some(([page]) => page.startsWith(path))).map(([, entry]) => entry.pageId);
178
+ const missingParents = [...new Set([...trackedFolders, ...[...remote].filter(([id]) => !mappingById.has(id)).map(([, page]) => String(page.parentId ?? '')).filter(id => id && id !== context.root.id && !nodes.has(id) && !mappingById.has(id))])];
172
179
  for (let offset = 0; offset < missingParents.length; offset += BATCH_SIZE) {
173
180
  const ids = missingParents.slice(offset, offset + BATCH_SIZE), remaining = new Set(ids), seenStarts = new Set();
174
181
  let start = '';
@@ -179,7 +186,7 @@ export async function fetchRemote(baseInput, env, options = {}) {
179
186
  if (start)
180
187
  params.set('start', start);
181
188
  requests++;
182
- progressStep(`신규 문서의 부모 폴더 메타데이터 조회 · ${offset + 1}–${offset + ids.length}`);
189
+ progressStep(`원격 폴더 메타데이터 조회 · ${offset + 1}–${offset + ids.length}`);
183
190
  const data = await client.api('/rest/api/content/search?' + params);
184
191
  if (!Array.isArray(data?.results))
185
192
  throw new Error('원격 부모 폴더 목록을 확인할 수 없습니다.');
@@ -208,8 +215,10 @@ export async function fetchRemote(baseInput, env, options = {}) {
208
215
  seenStarts.add(start);
209
216
  }
210
217
  } while (start);
211
- if (remaining.size)
218
+ if ([...remaining].some(id => !trackedFolders.includes(id)))
212
219
  throw new Error('신규 원격 문서의 부모 폴더를 찾을 수 없습니다. 기존 캐시는 유지합니다.');
220
+ for (const id of remaining)
221
+ unavailableFolders.add(id);
213
222
  }
214
223
  async function container(id, visiting = new Set()) {
215
224
  if (id === context.root.id)
@@ -218,8 +227,21 @@ export async function fetchRemote(baseInput, env, options = {}) {
218
227
  throw new Error('원격 부모 관계가 순환합니다.');
219
228
  visiting.add(id);
220
229
  const mapped = mappingById.get(id);
221
- if (mapped)
222
- return mapped.entry.type === 'folder' ? mapped.path.replace(/\/$/, '') : dirname(mapped.path) === '.' ? '' : dirname(mapped.path);
230
+ if (mapped) {
231
+ if (mapped.entry.type !== 'folder')
232
+ return dirname(mapped.path) === '.' ? '' : dirname(mapped.path);
233
+ const prior = mapped.path.replace(/\/$/, ''), node = nodes.get(id);
234
+ if (!node)
235
+ return prior;
236
+ if (typeof node.title !== 'string' || !node.title.trim())
237
+ throw new Error(`원격 폴더 제목을 확인할 수 없습니다: #${id}`);
238
+ const parent = node.parentId ? await container(String(node.parentId), visiting) : '';
239
+ const renamed = slug(node.title) !== slug(mapped.entry.title ?? basename(prior));
240
+ const path = [parent, renamed ? slug(node.title) : basename(prior)].filter(Boolean).join('/');
241
+ if (path !== prior)
242
+ folders.set(id, { path: path + '/', previousPath: prior + '/', pageId: id, title: node.title, parentId: node.parentId ? String(node.parentId) : undefined });
243
+ return path;
244
+ }
223
245
  const node = nodes.get(id);
224
246
  if (!node)
225
247
  throw new Error(`원격 부모 정보를 확인할 수 없습니다: #${id}`);
@@ -231,6 +253,35 @@ export async function fetchRemote(baseInput, env, options = {}) {
231
253
  folders.set(id, { path: path + '/', pageId: id, title: node.title });
232
254
  return path;
233
255
  }
256
+ for (const id of trackedFolders)
257
+ await container(id);
258
+ for (const item of items) {
259
+ if (item.state === 'unavailable' || item.state === 'unknown')
260
+ continue;
261
+ // A deleted historical local folder is not necessarily a current ancestor:
262
+ // its documents may already have moved elsewhere without changing local paths.
263
+ let parent = item.parentId, missingAncestor = false;
264
+ const seenParents = new Set();
265
+ while (parent && parent !== context.root.id) {
266
+ if (unavailableFolders.has(parent) || seenParents.has(parent)) {
267
+ missingAncestor = true;
268
+ break;
269
+ }
270
+ seenParents.add(parent);
271
+ const node = nodes.get(parent);
272
+ parent = node?.parentId ? String(node.parentId) : undefined;
273
+ }
274
+ if (missingAncestor) {
275
+ item.state = 'unknown';
276
+ item.reasons.push(folderUnavailableReason);
277
+ continue;
278
+ }
279
+ for (const folder of folders.values())
280
+ if (folder.previousPath && item.path.startsWith(folder.previousPath)) {
281
+ item.state = 'changed';
282
+ item.reasons.push(folderMoveReason(folder));
283
+ }
284
+ }
234
285
  const mappedIds = new Set(Object.values(context.mapping).map(entry => entry.pageId));
235
286
  for (const [id, page] of remote)
236
287
  if (!mappedIds.has(id) && page.parentId)
package/dist/sync/pull.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { progressStep } from '../shared/progress.js';
2
+ import { folderMoves, relocated, folderStamp, checkFolderDestination, verifyRemoteFolderMoves } from './remote-folder-moves.js';
2
3
  import { connectWorkspace } from './scope.js';
3
4
  import { storeBaseline } from './baseline.js';
4
5
  import { localStateHash } from './local-state.js';
@@ -12,7 +13,7 @@ import { buildTreeRenderer } from './render.js';
12
13
  * 함께 받은 페이지끼리의 내부 링크는 마지막에 상대 .md 경로로 이어 붙인다(Obsidian 그래프·백링크가 살아난다).
13
14
  * 각 문서 머리에 pageId 를 담은 frontmatter 를 남겨, 매핑 파일 없이도 push 가 원본 페이지를 다시 찾아간다.
14
15
  */
15
- import { mkdirSync, writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, readdirSync, copyFileSync, lstatSync, realpathSync, rmdirSync, constants } from 'node:fs';
16
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, readdirSync, copyFileSync, lstatSync, realpathSync, rmdirSync, renameSync, constants } from 'node:fs';
16
17
  import { resolve, join, dirname, basename, relative } from 'node:path';
17
18
  import { fetchRemote } from './fetch.js';
18
19
  import { verifyScope } from './scope.js';
@@ -379,7 +380,7 @@ export async function mappingPass(baseDir, mappingPath, pathById, folderIds, ver
379
380
  const rel = posix(relative(baseDir, dir));
380
381
  if (!rel || rel.startsWith('..'))
381
382
  continue;
382
- m[`${rel}/`] = { pageId: id, title: basename(dir), type: 'folder' };
383
+ m[`${rel}/`] = { pageId: id, title: basename(dir), type: 'folder', ...(parents.has(id) ? { remoteParentId: parents.get(id) } : {}) };
383
384
  n++;
384
385
  }
385
386
  validate?.();
@@ -644,18 +645,43 @@ export async function runPull(argv, options = {}) {
644
645
  return;
645
646
  }
646
647
  }
648
+ const moves = [];
649
+ for (const move of folderMoves(discovered?.folders)) {
650
+ const descendants = Object.entries(oldMapping).filter(([path, entry]) => entry.type !== 'folder' && path.startsWith(move.from) && /\.md$/i.test(path));
651
+ if (!descendants.some(([, entry]) => ids.includes(entry.pageId)))
652
+ continue;
653
+ const protectedFolder = descendants.some(([path, entry]) => !localClean.has(path) || entry.pendingMove || !ids.includes(entry.pageId));
654
+ if (protectedFolder) {
655
+ if (!remoteOnly)
656
+ throw Error(`로컬 변경·누락 또는 일부 문서 선택으로 폴더 이동을 보류합니다: ${move.from}`);
657
+ const blocked = new Set([...descendants.map(([, entry]) => entry.pageId), ...(discovered?.items ?? []).filter(item => item.state === 'new' && item.path.startsWith(move.to)).map(item => item.pageId)]);
658
+ ids = ids.filter(id => !blocked.has(id));
659
+ console.log(`로컬 변경·누락이 있어 폴더와 하위 문서를 보존: ${move.from}`);
660
+ continue;
661
+ }
662
+ if (moves.some(prior => move.from.startsWith(prior.from) || prior.from.startsWith(move.from)))
663
+ throw Error('서로 다른 위치로 이동한 중첩 폴더는 한 번에 적용할 수 없습니다. 로컬 파일은 유지합니다.');
664
+ checkFolderDestination(baseDir, move);
665
+ moves.push(move);
666
+ }
667
+ if (incremental && !ids.length) {
668
+ console.log('완료 · 폴더 이동을 포함해 안전하게 가져올 문서가 없습니다. 로컬 변경은 유지합니다.');
669
+ return;
670
+ }
671
+ const moveStamps = new Map(moves.map(move => [move.from, folderStamp(baseDir, move.from)]));
672
+ const destination = (path) => relocated(path, moves);
647
673
  const stage = mkdtempSync(join(tmpdir(), 'csync-pull-'));
648
674
  const stageOut = join(stage, relative(baseDir, outDir));
649
675
  const ctx = {
650
676
  client, remoteBaseUrl: env.baseUrl, missingAttachments: 0, incremental, expectedVersions,
651
677
  obsidian: argv.includes('--obsidian'), spaceKey: env.spaceKey, written: [], pathById: new Map(), folderIds: new Map(),
652
- versions: new Map(), parents: new Map(), assetOwners: new Map(), knownPaths: new Map([...knownById].map(([id, rel]) => [id, resolve(stage, rel)])),
678
+ versions: new Map(), parents: new Map(), assetOwners: new Map(), knownPaths: new Map([...knownById].map(([id, rel]) => [id, resolve(stage, destination(rel))])),
653
679
  stageBase: stage, baseDir, workspace, rootId, claimed: new Map(), visited: new Set(),
654
680
  };
655
681
  try {
656
682
  if (discovered?.folders)
657
683
  for (const folder of discovered.folders) {
658
- if (![...newPaths, ...recoveringPaths].some(path => path.startsWith(folder.path)))
684
+ if (![...newPaths, ...recoveringPaths, ...ids.map(id => destination(knownById.get(id) ?? ''))].some(path => path.startsWith(folder.path)))
659
685
  continue;
660
686
  const prior = oldMapping[folder.path];
661
687
  if (prior && prior.pageId !== folder.pageId)
@@ -664,6 +690,8 @@ export async function runPull(argv, options = {}) {
664
690
  claim(ctx, path, folder.pageId, true);
665
691
  mkdirSync(path, { recursive: true });
666
692
  ctx.folderIds.set(path, folder.pageId);
693
+ if (folder.parentId)
694
+ ctx.parents.set(folder.pageId, folder.parentId);
667
695
  }
668
696
  // Reuse page IDs and local paths even when a remote title has changed.
669
697
  if (rootId)
@@ -752,8 +780,9 @@ export async function runPull(argv, options = {}) {
752
780
  throw new Error('pull 도중 설정·매핑이 변경되었습니다.');
753
781
  if (restorePaths)
754
782
  validateRestorePaths(baseDir, restorePaths, restoreOverwrite);
783
+ await verifyRemoteFolderMoves(client, moves, discovered?.folders ?? []);
755
784
  const backup = mkdtempSync(join(tmpdir(), 'csync-pull-backup-'));
756
- const applied = [], createdDirs = [];
785
+ const applied = [], createdDirs = [], moved = [];
757
786
  let mapped, keepBackup = false;
758
787
  const ensureDirectory = (dir) => {
759
788
  if (existsSync(dir))
@@ -766,6 +795,21 @@ export async function runPull(argv, options = {}) {
766
795
  };
767
796
  try {
768
797
  ensureDirectory(baseDir);
798
+ for (const move of moves) {
799
+ checkFolderDestination(baseDir, move);
800
+ if (folderStamp(baseDir, move.from) !== moveStamps.get(move.from))
801
+ throw Error(`pull 도중 이동할 폴더가 변경되었습니다: ${move.from}`);
802
+ }
803
+ for (const move of moves) {
804
+ const from = resolve(baseDir, move.from), to = resolve(baseDir, move.to);
805
+ ensureDirectory(dirname(to));
806
+ renameSync(from, to);
807
+ moved.push(move);
808
+ for (const [path, hash] of [...originals])
809
+ if (within(from, path))
810
+ originals.set(resolve(to, relative(from, path)), hash);
811
+ console.log(`로컬 폴더 이동: ${move.from} → ${move.to}`);
812
+ }
769
813
  for (const dir of stagedDirs)
770
814
  ensureDirectory(join(baseDir, relative(stage, dir)));
771
815
  for (const file of stagedFiles) {
@@ -823,6 +867,13 @@ export async function runPull(argv, options = {}) {
823
867
  keepBackup = true;
824
868
  }
825
869
  }
870
+ for (const move of moved.reverse())
871
+ try {
872
+ renameSync(resolve(baseDir, move.to), resolve(baseDir, move.from));
873
+ }
874
+ catch {
875
+ keepBackup = true;
876
+ }
826
877
  for (const dir of [...new Set(createdDirs)].reverse())
827
878
  try {
828
879
  rmdirSync(dir);
@@ -0,0 +1,66 @@
1
+ /** Filesystem part of a remote folder move. Pull owns commit/rollback and baselines. */
2
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { resolve, relative, dirname, join } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { within } from './workspace.js';
6
+ export function folderMoves(folders = []) {
7
+ const all = folders.filter(f => f.previousPath && f.previousPath !== f.path).map(f => ({ from: f.previousPath, to: f.path, pageId: f.pageId })).sort((a, b) => a.from.length - b.from.length);
8
+ return all.filter(move => !all.some(parent => parent !== move && move.from.startsWith(parent.from) && move.to === parent.to + move.from.slice(parent.from.length)));
9
+ }
10
+ export function relocated(path, moves) {
11
+ const move = moves.find(m => path.startsWith(m.from));
12
+ return move ? move.to + path.slice(move.from.length) : path;
13
+ }
14
+ export function folderStamp(base, rel) {
15
+ const root = resolve(base, rel), entries = [];
16
+ const walk = (path) => {
17
+ const info = lstatSync(path);
18
+ if (info.isSymbolicLink())
19
+ throw Error(`폴더 이동 중 심볼릭 링크는 지원하지 않습니다: ${rel}`);
20
+ if (info.isDirectory()) {
21
+ entries.push([relative(root, path), 'directory']);
22
+ for (const entry of readdirSync(path).sort()) {
23
+ if (['.csync', '.git', '.confluence-workspace.json', '.confluence-sync.json'].includes(entry))
24
+ throw Error(`별도 작업공간이 있는 폴더는 자동 이동하지 않습니다: ${rel}`);
25
+ walk(join(path, entry));
26
+ }
27
+ }
28
+ else if (info.isFile())
29
+ entries.push([relative(root, path), createHash('sha256').update(readFileSync(path)).digest('hex')]);
30
+ else
31
+ throw Error(`일반 파일이 아닌 항목이 있어 폴더를 이동할 수 없습니다: ${rel}`);
32
+ };
33
+ walk(root);
34
+ return createHash('sha256').update(JSON.stringify(entries)).digest('hex');
35
+ }
36
+ export function checkFolderDestination(base, move) {
37
+ const from = resolve(base, move.from), to = resolve(base, move.to);
38
+ if (!within(base, from) || !within(base, to) || from === resolve(base) || to === resolve(base) || within(from, to) || within(to, from))
39
+ throw Error(`안전하지 않은 폴더 이동: ${move.from} → ${move.to}`);
40
+ if (!existsSync(from) || !lstatSync(from).isDirectory() || lstatSync(from).isSymbolicLink())
41
+ throw Error(`이동할 로컬 폴더를 확인하세요: ${move.from}`);
42
+ if (existsSync(to))
43
+ throw Error(`폴더 이동 대상 경로가 이미 존재합니다: ${move.to}`);
44
+ for (let path = dirname(to); within(base, path); path = dirname(path)) {
45
+ if (existsSync(path) && (!lstatSync(path).isDirectory() || lstatSync(path).isSymbolicLink()))
46
+ throw Error(`폴더 이동 대상의 상위 경로를 확인하세요: ${move.to}`);
47
+ if (path === resolve(base))
48
+ break;
49
+ }
50
+ }
51
+ export async function verifyRemoteFolderMoves(client, moves, folders) {
52
+ for (let offset = 0; offset < moves.length; offset += 100) {
53
+ const batch = moves.slice(offset, offset + 100), expected = new Map(batch.map(move => [move.pageId, folders.find(folder => folder.pageId === move.pageId)]));
54
+ const query = new URLSearchParams({ cql: `id in (${batch.map(move => move.pageId).join(',')})`, expand: 'ancestors,space', limit: '100' });
55
+ const response = await client.api('/rest/api/content/search?' + query);
56
+ if (!Array.isArray(response?.results) || response.results.length !== expected.size)
57
+ throw Error('이동할 원격 폴더를 다시 확인할 수 없습니다. 로컬 폴더는 유지합니다.');
58
+ const seen = new Set();
59
+ for (const folder of response.results) {
60
+ const id = String(folder?.id), parent = folder?.ancestors?.at(-1)?.id;
61
+ if (!expected.has(id) || seen.has(id) || folder.type !== 'folder' || parent == null || String(parent) !== expected.get(id)?.parentId || folder.title !== expected.get(id)?.title)
62
+ throw Error('가져오기 도중 원격 폴더 위치가 바뀌었거나 이름이 변경되었습니다. Fetch 후 다시 확인하세요.');
63
+ seen.add(id);
64
+ }
65
+ }
66
+ }