@wonseok-han/confluence-sync 2.0.1 → 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.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>
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에 표시하지 않습니다.' };
@@ -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
@@ -2,7 +2,7 @@ import { progressStep } from '../shared/progress.js';
2
2
  import { folderMoves, relocated, folderStamp, checkFolderDestination, verifyRemoteFolderMoves } from './remote-folder-moves.js';
3
3
  import { connectWorkspace } from './scope.js';
4
4
  import { storeBaseline } from './baseline.js';
5
- import { localStateHash } from './local-state.js';
5
+ import { localStateHash, matchesLocalState } from './local-state.js';
6
6
  import { buildTreeRenderer } from './render.js';
7
7
  /**
8
8
  * `confluence-sync pull <pageId|url> [--out <dir>] (하위 항목 포함)`
@@ -215,7 +215,7 @@ async function pullNode(ctx, id, destDir) {
215
215
  throw new PullError(`pull 실패 #${id}: ${e.message}`);
216
216
  }
217
217
  }
218
- function readDocInfo(file) {
218
+ export function readDocInfo(file) {
219
219
  const { data, body } = splitFrontmatter(readFileSync(file, 'utf8'));
220
220
  const headings = collectHeadings(body);
221
221
  return {
@@ -620,7 +620,7 @@ export async function runPull(argv, options = {}) {
620
620
  for (const rel of scoped) {
621
621
  const doc = tree.docs[rel], rendered = tree.render(rel, doc.body, doc.title), entry = oldMapping[rel];
622
622
  const clean = entry?.localHash
623
- ? entry.localHash === localStateHash(baseDir, rel, rendered)
623
+ ? matchesLocalState(baseDir, rel, rendered, entry, rels)
624
624
  : !!entry?.hash && (entry.hash === docHash(doc.title, rendered)
625
625
  || entry.hash === docHash(legacy.docs[rel].title, legacy.render(rel, legacy.docs[rel].body, legacy.docs[rel].title))
626
626
  || entry.hash === docHash(doc.title, tree.render(rel, doc.body, doc.title, 'panels'))
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))
@@ -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'))