@wonseok-han/confluence-sync 2.0.0 → 2.0.2

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.2"><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.2</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>
@@ -0,0 +1,37 @@
1
+ /** Reuse Pull link/anchor conversion in a disposable tree; user files stay read-only. */
2
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, resolve, dirname, relative } from 'node:path';
5
+ import { collectMarkdown, buildVault, vaultResolver } from '../documents/catalog.js';
6
+ import { buildFrontmatter, splitFrontmatter, resolveWikilinks } from '../documents/obsidian.js';
7
+ import { anchorPass, relinkPass, readDocInfo } from './pull.js';
8
+ import { normalizeWikiPaths } from './local-state.js';
9
+ import { buildIgnorer } from './ignore.js';
10
+ import { inNestedWorkspace } from './workspace.js';
11
+ export function diffLinks(base, path, title, before, after, wiki, mapping) {
12
+ const ignore = buildIgnorer(base, []), files = collectMarkdown(base).map(file => relative(base, file).split('\\').join('/')).filter(file => !ignore.ignores(file) && !inNestedWorkspace(base, file));
13
+ const stage = mkdtempSync(join(tmpdir(), 'csync-diff-'));
14
+ try {
15
+ const file = resolve(stage, path), paths = new Map(), refs = new Map();
16
+ mkdirSync(dirname(file), { recursive: true });
17
+ writeFileSync(file, buildFrontmatter({ title }) + before);
18
+ for (const rel of files) {
19
+ const entry = mapping[rel];
20
+ if (!entry || entry.type === 'folder')
21
+ continue;
22
+ const target = resolve(stage, rel);
23
+ paths.set(entry.pageId, target);
24
+ if (rel !== path)
25
+ refs.set(target, readDocInfo(resolve(base, rel)));
26
+ }
27
+ const pass = anchorPass([file], paths, refs);
28
+ relinkPass([file], paths, wiki, pass.info);
29
+ const body = splitFrontmatter(readFileSync(file, 'utf8')).body.replace(/^\r?\n/, '');
30
+ const vault = buildVault(files, [], base), lookup = vaultResolver(path, vault);
31
+ const canonical = (text) => resolveWikilinks(normalizeWikiPaths(base, path, text, files), lookup);
32
+ return { before: canonical(body), after: canonical(after) };
33
+ }
34
+ finally {
35
+ rmSync(stage, { recursive: true, force: true });
36
+ }
37
+ }
package/dist/sync/diff.js CHANGED
@@ -3,6 +3,7 @@ import { bold, cyan, red, green, dim } from '../shared/colors.js';
3
3
  import { readFileSync, statSync } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
  import { readBaseline } from './baseline.js';
6
+ import { diffLinks } from './diff-links.js';
6
7
  import { loadMapping } from './mapping.js';
7
8
  import { mappingPath } from './metadata.js';
8
9
  import { loadWorkspace, requireScope, inScope, inNestedWorkspace, scopePath, assertWorkspaceConnection, resolveSyncBase } from './workspace.js';
@@ -35,8 +36,11 @@ export async function documentDiff(base, input, options = {}) {
35
36
  const localBody = splitFrontmatter(after).body;
36
37
  // buildFrontmatter adds one blank separator. Keep intentional body whitespace.
37
38
  const comparisonBody = localBody !== after ? localBody.replace(/^\r?\n/, '') : localBody;
38
- return { path, before: htmlToMarkdown(node.html, { obsidian: /^ {0,3}(?:> ?)+\[![\w-]+\]|\[\^[^\]\s]+\]:|(?<!\\)\^\[/m.test(comparisonBody), pageId: entry.pageId, storage: node.storage, codeLangs: codeLanguagesFromStorage(node.storage) }), after: comparisonBody,
39
- source: 'remote', canRemote: true, note: `현재 원격 v${node.version ?? '?'} → 로컬 본문 비교입니다. 원격 HTML의 근사 변환이며 frontmatter는 제외합니다. 과거 동기화 기준과의 비교는 아닙니다. 변경 목록에는 본문 외 제목·원격 위치 변경도 포함됩니다.` };
39
+ const obsidian = /^ {0,3}(?:> ?)+\[![\w-]+\]|\[\^[^\]\s]+\]:|(?<!\\)\^\[|\[\[/m.test(comparisonBody);
40
+ const remote = htmlToMarkdown(node.html, { obsidian, pageId: entry.pageId, storage: node.storage, codeLangs: codeLanguagesFromStorage(node.storage) });
41
+ const comparison = diffLinks(base, path, node.title, remote, comparisonBody, obsidian, mapping);
42
+ return { path, ...comparison,
43
+ source: 'remote', canRemote: true, note: `현재 원격 v${node.version ?? '?'} → 로컬 본문 비교입니다. 원격 HTML을 근사 변환하고 연결된 문서 링크를 로컬 경로로 맞췄으며 frontmatter는 제외합니다. 과거 동기화 기준과의 비교는 아닙니다. 변경 목록에는 본문 외 제목·원격 위치 변경도 포함됩니다.` };
40
44
  }
41
45
  if (entry?.sourceObject)
42
46
  return { path, before: readBaseline(base, entry.sourceObject), after, source: 'baseline', canRemote: true, note: '마지막 Pull·Push 성공 시 저장한 Markdown → 현재 로컬 문서입니다. 첨부 파일의 바이트 차이는 본문 diff에 표시하지 않습니다.' };
@@ -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)
@@ -2,11 +2,47 @@
2
2
  import { createHash } from 'node:crypto';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
- export function localStateHash(base, rel, rendered) {
6
- const hash = createHash('sha256').update(readFileSync(resolve(base, rel)));
5
+ import { readBaseline } from './baseline.js';
6
+ import { buildVault, vaultPath } from '../documents/catalog.js';
7
+ import { WIKILINK_RE } from '../documents/anchors.js';
8
+ import { mapProse, splitFrontmatter } from '../documents/obsidian.js';
9
+ export function localStateHash(base, rel, rendered, source) {
10
+ const hash = createHash('sha256').update(source ?? readFileSync(resolve(base, rel)));
7
11
  for (const asset of [...rendered.attachments].sort((a, b) => a.filename.localeCompare(b.filename))) {
8
12
  hash.update('\0' + asset.filename + '\0');
9
13
  hash.update(createHash('sha256').update(readFileSync(asset.abs)).digest());
10
14
  }
11
15
  return hash.digest('hex');
12
16
  }
17
+ /** Ignore only rewritten note paths; labels, fragments, code and assets remain exact. */
18
+ export function matchesLocalState(base, rel, rendered, entry, files, actual = localStateHash(base, rel, rendered)) {
19
+ if (entry.localHash === actual)
20
+ return true;
21
+ if (!entry.localHash || !entry.sourceObject)
22
+ return false;
23
+ const before = readBaseline(base, entry.sourceObject), after = readFileSync(resolve(base, rel), 'utf8');
24
+ // Reusing the old source with current attachments must reproduce the stored hash.
25
+ if (localStateHash(base, rel, rendered, before) !== entry.localHash)
26
+ return false;
27
+ return normalizeWikiPaths(base, rel, before, files) === normalizeWikiPaths(base, rel, after, files);
28
+ }
29
+ export function normalizeWikiPaths(base, rel, text, files) {
30
+ const vault = buildVault(files, [], base);
31
+ const { body } = splitFrontmatter(text), prefix = text.slice(0, text.length - body.length);
32
+ return prefix + mapProse(body, part => part.replace(WIKILINK_RE, (whole, bang, target, hash, alias) => {
33
+ if (bang)
34
+ return whole;
35
+ const name = target.replace(/\\$/, '').trim(), path = vaultPath(vault, name, false, rel);
36
+ if (!path)
37
+ return whole;
38
+ // A short ambiguous name is safe only when the target is in this directory.
39
+ if (!name.includes('/')) {
40
+ const key = name.replace(/\.md$/i, '').normalize('NFC').toLowerCase();
41
+ const candidates = files.filter(file => file.split('/').at(-1).replace(/\.md$/i, '').normalize('NFC').toLowerCase() === key);
42
+ const nearby = (rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/') + 1) : '') + name.replace(/\.md$/i, '') + '.md';
43
+ if (candidates.length > 1 && path.normalize('NFC').toLowerCase() !== nearby.normalize('NFC').toLowerCase())
44
+ return whole;
45
+ }
46
+ return `[[${path}${hash ?? ''}|${alias ?? name}]]`;
47
+ }));
48
+ }
@@ -12,7 +12,7 @@ export async function preparePushTree(base, rels, selected, options) {
12
12
  try {
13
13
  const chosen = new Set(selected);
14
14
  const sourceRenderer = buildTreeRenderer(base, rels);
15
- const vault = buildVault(rels, collectAssets(base).map(file => relative(base, file).split('\\').join('/')));
15
+ const vault = buildVault(rels, collectAssets(base).map(file => relative(base, file).split('\\').join('/')), base);
16
16
  for (const rel of rels) {
17
17
  const original = resolve(base, rel), destination = resolve(stage, rel);
18
18
  let text = readFileSync(original, 'utf8');
@@ -43,7 +43,7 @@ export async function preparePushTree(base, rels, selected, options) {
43
43
  mkdirSync(dirname(destination), { recursive: true });
44
44
  writeFileSync(destination, text);
45
45
  }
46
- return { ...buildTreeRenderer(stage, rels, options ? { ...options, referenceBase: base } : undefined), dispose };
46
+ return { ...buildTreeRenderer(stage, rels, { referenceRoots: [], ...options, referenceBase: base }), dispose };
47
47
  }
48
48
  catch (error) {
49
49
  dispose();
package/dist/sync/pull.js CHANGED
@@ -1,7 +1,8 @@
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
- import { localStateHash } from './local-state.js';
5
+ import { localStateHash, matchesLocalState } from './local-state.js';
5
6
  import { buildTreeRenderer } from './render.js';
6
7
  /**
7
8
  * `confluence-sync pull <pageId|url> [--out <dir>] (하위 항목 포함)`
@@ -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';
@@ -214,7 +215,7 @@ async function pullNode(ctx, id, destDir) {
214
215
  throw new PullError(`pull 실패 #${id}: ${e.message}`);
215
216
  }
216
217
  }
217
- function readDocInfo(file) {
218
+ export function readDocInfo(file) {
218
219
  const { data, body } = splitFrontmatter(readFileSync(file, 'utf8'));
219
220
  const headings = collectHeadings(body);
220
221
  return {
@@ -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?.();
@@ -619,7 +620,7 @@ export async function runPull(argv, options = {}) {
619
620
  for (const rel of scoped) {
620
621
  const doc = tree.docs[rel], rendered = tree.render(rel, doc.body, doc.title), entry = oldMapping[rel];
621
622
  const clean = entry?.localHash
622
- ? entry.localHash === localStateHash(baseDir, rel, rendered)
623
+ ? matchesLocalState(baseDir, rel, rendered, entry, rels)
623
624
  : !!entry?.hash && (entry.hash === docHash(doc.title, rendered)
624
625
  || entry.hash === docHash(legacy.docs[rel].title, legacy.render(rel, legacy.docs[rel].body, legacy.docs[rel].title))
625
626
  || entry.hash === docHash(doc.title, tree.render(rel, doc.body, doc.title, 'panels'))
@@ -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);
package/dist/sync/push.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { progressStep } from '../shared/progress.js';
2
2
  import { storeBaseline } from './baseline.js';
3
3
  import { buildTreeRenderer } from './render.js';
4
- import { localStateHash } from './local-state.js';
4
+ import { localStateHash, matchesLocalState } from './local-state.js';
5
5
  import { loadIndex, stagedPaths, assertStaged, completeStaged } from './staging.js';
6
6
  /** Incremental Confluence push and local preview orchestration. */
7
7
  import { mappingPath } from './metadata.js';
@@ -144,7 +144,7 @@ export async function runPush(argv) {
144
144
  const legacy = buildTreeRenderer(BASE_DIR, allRel);
145
145
  const locallyClean = (rel) => {
146
146
  const entry = mapping[rel];
147
- return entry?.localHash ? entry.localHash === localHashes[rel] : !!entry?.hash && (entry.hash === hashes[rel]
147
+ return entry?.localHash ? matchesLocalState(BASE_DIR, rel, render(rel, docs[rel].body, docs[rel].title), entry, allRel, localHashes[rel]) : !!entry?.hash && (entry.hash === hashes[rel]
148
148
  || entry.hash === docHash(legacy.docs[rel].title, legacy.render(rel, legacy.docs[rel].body, legacy.docs[rel].title))
149
149
  || entry.hash === docHash(docs[rel].title, render(rel, docs[rel].body, docs[rel].title, 'panels'))
150
150
  || entry.hash === docHash(docs[rel].title, render(rel, docs[rel].body, docs[rel].title, true))
@@ -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
+ }
@@ -14,7 +14,7 @@ import { buildDocIndex, buildVault, collectAssets, vaultResolver, resolveAnchorT
14
14
  export function buildTreeRenderer(baseDir, rels, options) {
15
15
  const { titles, anchors, docs } = buildDocIndex(rels, baseDir);
16
16
  // Obsidian [[wikilink]] 해석용 이름 인덱스
17
- const vault = buildVault(rels, collectAssets(baseDir).map((f) => relative(baseDir, f)));
17
+ const vault = buildVault(rels, collectAssets(baseDir).map((f) => relative(baseDir, f)), options?.referenceBase ?? baseDir);
18
18
  // 누가 어느 헤딩을 가리키는지 먼저 모은다 — 그 헤딩에만 Anchor 매크로를 심는다.
19
19
  const lookup = options ? buildReferenceLookup(options.referenceBase ?? baseDir, rels, options.referenceRoots, options.baseUrl) : null;
20
20
  const anchorTargets = resolveAnchorTargets(docs, vault, lookup?.localTarget);
@@ -1,6 +1,6 @@
1
1
  import { green, cyan } from '../shared/colors.js';
2
2
  import { reconcilePages } from './reconcile.js';
3
- import { localStateHash } from './local-state.js';
3
+ import { matchesLocalState } from './local-state.js';
4
4
  import { buildTreeRenderer } from './render.js';
5
5
  /** Document-level upload selection. Source files are never copied into the index. */
6
6
  import { createHash, randomUUID } from 'node:crypto';
@@ -110,7 +110,7 @@ export async function inspectStaging(base, options = {}, only) {
110
110
  const oldPath = doc.fm.pageId && Object.keys(originalMapping).find(key => originalMapping[key].pageId === doc.fm.pageId && !key.endsWith('/'));
111
111
  const prior = originalMapping[path] ?? (oldPath ? originalMapping[oldPath] : undefined);
112
112
  const reconciled = mapping[path];
113
- const clean = prior?.localHash ? prior.localHash === localStateHash(base, path, rendered) : !!prior?.hash && (prior.hash === hash || prior.hash === docHash(legacy.docs[path].title, legacy.render(path, legacy.docs[path].body, legacy.docs[path].title))
113
+ const clean = prior?.localHash ? matchesLocalState(base, path, rendered, prior, all) : !!prior?.hash && (prior.hash === hash || prior.hash === docHash(legacy.docs[path].title, legacy.render(path, legacy.docs[path].body, legacy.docs[path].title))
114
114
  || prior.hash === docHash(doc.title, prepared.render(path, doc.body, doc.title, 'panels'))
115
115
  || prior.hash === docHash(doc.title, prepared.render(path, doc.body, doc.title, true))
116
116
  || prior.hash === docHash(legacy.docs[path].title, legacy.render(path, legacy.docs[path].body, legacy.docs[path].title, 'panels'))