@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.
- package/dist/cli/status.js +1 -1
- package/dist/conversion/convert.js +1 -1
- package/dist/documents/anchors.js +1 -1
- package/dist/documents/catalog.js +24 -9
- package/dist/documents/obsidian.js +1 -1
- package/dist/site/assets/app.js +4 -3
- package/dist/site/assets/workspace.worker.js +148 -148
- package/dist/site/index.html +2 -2
- package/dist/sync/diff-links.js +37 -0
- package/dist/sync/diff.js +6 -2
- package/dist/sync/fetch.js +61 -10
- package/dist/sync/local-state.js +38 -2
- package/dist/sync/prepare.js +2 -2
- package/dist/sync/pull.js +59 -8
- package/dist/sync/push.js +2 -2
- package/dist/sync/remote-folder-moves.js +66 -0
- package/dist/sync/render.js +1 -1
- package/dist/sync/staging.js +2 -2
- package/dist/web/assets/app.js +4 -3
- package/dist/web/assets/workspace.worker.js +148 -148
- package/dist/web/hosted-files.js +4 -3
- package/dist/web/hosted.js +1 -1
- package/dist/web/sync.js +2 -0
- package/docs/user/CLI.md +4 -0
- package/docs/user/WEB.md +2 -0
- package/package.json +1 -1
package/dist/site/index.html
CHANGED
|
@@ -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.
|
|
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
|
+
<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
|
-
|
|
39
|
-
|
|
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에 표시하지 않습니다.' };
|
package/dist/sync/fetch.js
CHANGED
|
@@ -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-
|
|
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
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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/local-state.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
+
}
|
package/dist/sync/prepare.js
CHANGED
|
@@ -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,
|
|
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
|
-
?
|
|
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 ?
|
|
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
|
+
}
|
package/dist/sync/render.js
CHANGED
|
@@ -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);
|
package/dist/sync/staging.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { green, cyan } from '../shared/colors.js';
|
|
2
2
|
import { reconcilePages } from './reconcile.js';
|
|
3
|
-
import {
|
|
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 ?
|
|
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'))
|