@im-nobsidian/core 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -597,8 +597,9 @@ interface PullResult {
597
597
  */
598
598
  readonly relinkedPaths?: readonly string[];
599
599
  /**
600
- * dry-run 만 — 상태 기록에 Notion 과 이은 노트가 없어 실제 pull 은 같은 자리의 노트를 먼저 다시 잇는다(P7). 미리 보기는
601
- * 노트 글을 받아 견주지 않아 그 노트들도 {@link created} 로 센다. 이을 노트가 없으면 생략.
600
+ * dry-run 만 — {@link relinkedPaths} 는 이은 것이 아니라 실제 pull 이 다시 이을 것이다(P7). 미리 보기는 노트 글을 받아
601
+ * 견주지 않아 글이 다른 노트는 실제 pull 에서 충돌로 남는다. 자연 이름의 노트는 이은 노트가 없는 볼트에서만 이을 것으로
602
+ * 센다 — 이은 노트가 있는 볼트는 글이 같아야 잇는다. 이을 노트가 없으면 생략.
602
603
  */
603
604
  readonly relinkPending?: true;
604
605
  /**
@@ -2820,7 +2821,7 @@ declare class SyncOrchestrator {
2820
2821
  * 적어 push 가 올린다 — dry-run 은 받지 않아 원격이 바뀐 그대로라, 원격을 보고 거절하지 않는다.
2821
2822
  */
2822
2823
  /**
2823
- * @param relinkedByPull 같은 sync 의 dry-run pull 이 다시 이을 것이라고 미리 봤다 — 실제 sync 는 받으며 먼저 잇고 올리므로
2824
+ * @param relinkedByPull 같은 sync 의 dry-run pull 이 받거나 다시 이을 것이 있다 — 실제 sync 는 받으며 먼저 잇고 올리므로
2824
2825
  * 상태 기록이 비었어도 막지 않는다({@link assertVaultLinked}).
2825
2826
  */
2826
2827
  private executePush;
@@ -3643,6 +3644,8 @@ interface PlannedRow {
3643
3644
  readonly moves?: readonly RemoteMove[];
3644
3645
  /** 로컬도 고친 행을 실제 pull 이 받지 않는 까닭 — 없으면 받는다. */
3645
3646
  readonly resolution?: PlannedResolution;
3647
+ /** `create` 만 — 상태 기록에 없던 같은 자리의 행 노트를 다시 잇는다(P7). 새로 받는 것으로 세지 않는다. */
3648
+ readonly relink?: true;
3646
3649
  }
3647
3650
  /**
3648
3651
  * 로컬도 고친 노트를 실제 pull 이 어떻게 처리하나 — 충돌로 남김 · 합침(merge) · 받지 않음(원격이 그대로거나
@@ -3798,6 +3801,8 @@ declare class DatabaseSyncer {
3798
3801
  * 볼트에서 옮긴 레코드 — dry-run 은 옮겨 적지 않아 이동 WAL 이 없다. 실제 pull 처럼 볼트가 먼저다.
3799
3802
  */
3800
3803
  movingInVault?: ReadonlySet<string>;
3804
+ /** 이번 미리 보기가 다시 이을 것으로 고른 자리({@link pathKey}) — 고르면 더한다. */
3805
+ relinkTaken?: Set<string>;
3801
3806
  }): Promise<PlannedRow[]>;
3802
3807
  /**
3803
3808
  * 받은 행이 지난 사본 그대로인가 — 렌더한 글이 달라도 받을 것이 없으면 그 사이 로컬 편집을 충돌로 올리지 않는다
@@ -4110,7 +4115,8 @@ declare class WikilinkResolver implements Processor {
4110
4115
  process(input: ProcessorInput): ProcessorOutput;
4111
4116
  /**
4112
4117
  * `[[노트#제목]]` · `[[노트#^블록]]` · `[[#제목]]` → `[글자](https://www.notion.so/<페이지>#<블록>)`. 글자는 별칭,
4113
- * 없으면 Obsidian 이 보이는 「노트 > 제목」 — pull 이 그 글자면 별칭을 붙이지 않는다. 대상 페이지 · 블록을 못 찾으면 null.
4118
+ * 없으면 Obsidian 이 보이는 「노트 > 제목」 — pull 이 그 글자면 별칭을 붙이지 않는다. 앵커 링크가 아니면 null, 못 풀면
4119
+ * 그 까닭({@link AnchorMiss}).
4114
4120
  */
4115
4121
  private anchorLink;
4116
4122
  }
package/dist/index.js CHANGED
@@ -5994,6 +5994,9 @@ function anchorBlockOf(block) {
5994
5994
  text: (body?.rich_text ?? []).map((t) => t.plain_text ?? "").join("")
5995
5995
  };
5996
5996
  }
5997
+ function flattenBlockTree(blocks) {
5998
+ return blocks.flatMap((block) => [block, ...flattenBlockTree(block.children ?? [])]);
5999
+ }
5997
6000
  var ANCHORED_WIKILINK_RE = new LookbehindRegExp(
5998
6001
  /\[\[([^[\]|]*#[^[\]|]*?)\\?(?:\|[^[\]]+)?\]\]/g,
5999
6002
  void 0,
@@ -6037,7 +6040,7 @@ var WikilinkResolver = class {
6037
6040
  const target = display === void 0 ? written : withoutTableAliasEscape(written);
6038
6041
  const label = display ?? target;
6039
6042
  const anchored = this.anchorLink(target, display, input.context.anchors);
6040
- if (anchored) return anchored;
6043
+ if (anchored?.link) return anchored.link;
6041
6044
  if (this.resolve) {
6042
6045
  const entry = this.resolve(target);
6043
6046
  if (entry) {
@@ -6048,6 +6051,7 @@ var WikilinkResolver = class {
6048
6051
  }
6049
6052
  }
6050
6053
  const params = display ? { text: target, display } : { text: target };
6054
+ if (anchored?.miss) params.anchor = anchored.miss;
6051
6055
  params.__anchor = computeAnchor(input.content, base + offset);
6052
6056
  preserveMarkers.push({
6053
6057
  type: "wikilink",
@@ -6065,16 +6069,19 @@ var WikilinkResolver = class {
6065
6069
  }
6066
6070
  /**
6067
6071
  * `[[노트#제목]]` · `[[노트#^블록]]` · `[[#제목]]` → `[글자](https://www.notion.so/<페이지>#<블록>)`. 글자는 별칭,
6068
- * 없으면 Obsidian 이 보이는 「노트 > 제목」 — pull 이 그 글자면 별칭을 붙이지 않는다. 대상 페이지 · 블록을 못 찾으면 null.
6072
+ * 없으면 Obsidian 이 보이는 「노트 > 제목」 — pull 이 그 글자면 별칭을 붙이지 않는다. 앵커 링크가 아니면 null, 못 풀면
6073
+ * 그 까닭({@link AnchorMiss}).
6069
6074
  */
6070
6075
  anchorLink(target, display, anchors) {
6071
6076
  const split = anchors ? splitAnchor(target) : null;
6072
6077
  if (!anchors || !split) return null;
6073
6078
  const pageId = split.note === "" ? anchors.selfPageId : this.resolve?.(split.note)?.notionPageId ?? null;
6074
- if (!pageId) return null;
6079
+ if (!pageId) return { miss: "page" };
6075
6080
  const blockId = anchors.blockOf(pageId, split.anchor);
6076
- if (!blockId) return null;
6077
- return `[${display ?? anchorDisplay(split.note, split.anchor)}](${notionBlockUrl(pageId, blockId)})`;
6081
+ if (!blockId) return { miss: "block" };
6082
+ return {
6083
+ link: `[${display ?? anchorDisplay(split.note, split.anchor)}](${notionBlockUrl(pageId, blockId)})`
6084
+ };
6078
6085
  }
6079
6086
  };
6080
6087
 
@@ -15686,6 +15693,77 @@ async function readLocalNote(vaultFs, path) {
15686
15693
  }
15687
15694
  }
15688
15695
 
15696
+ // src/utils/db-row-path.ts
15697
+ function selectDbRowFiles(files, localFolder) {
15698
+ const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
15699
+ return files.filter((file) => {
15700
+ if (!file.path.startsWith(prefix)) return false;
15701
+ const rel = file.path.slice(prefix.length);
15702
+ return rel.length > 0 && !rel.includes("/");
15703
+ });
15704
+ }
15705
+ var PAGE_ID_SUFFIX_LENGTHS = [8, 16, 32];
15706
+ var PAGE_ID_SUFFIX = new RegExp(
15707
+ ` \\((?:${PAGE_ID_SUFFIX_LENGTHS.map((length) => `[0-9a-f]{${length}}`).join("|")})\\)$`,
15708
+ "i"
15709
+ );
15710
+ function withoutPageIdSuffix(name) {
15711
+ return name.replace(PAGE_ID_SUFFIX, "");
15712
+ }
15713
+ function pagePathCandidates(dir, safeName, pageId) {
15714
+ const base = dir ? `${dir}/${safeName}` : safeName;
15715
+ const raw = pageId.replace(/-/g, "");
15716
+ return [
15717
+ `${base}.md`,
15718
+ ...PAGE_ID_SUFFIX_LENGTHS.map((len) => `${base} (${raw.slice(0, len)}).md`)
15719
+ ];
15720
+ }
15721
+ function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
15722
+ const ownedBySelf = (candidate) => {
15723
+ const owner = lookupByPath(candidate);
15724
+ if (!owner) return true;
15725
+ return owner.notionPageId != null && notionIdsEqual(owner.notionPageId, pageId);
15726
+ };
15727
+ const candidates = pagePathCandidates(localFolder, safeName, pageId);
15728
+ return candidates.find(ownedBySelf) ?? candidates[candidates.length - 1];
15729
+ }
15730
+ function inClaimOrder(items, chosen, idOf2) {
15731
+ const slots = items.flatMap((item, index) => chosen(item) ? [index] : []);
15732
+ const key = (item) => compactNotionId(idOf2(item));
15733
+ const members = slots.map((index) => items[index]).sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
15734
+ const ordered = [...items];
15735
+ slots.forEach((slot, k) => ordered[slot] = members[k]);
15736
+ return ordered;
15737
+ }
15738
+
15739
+ // src/utils/path-key.ts
15740
+ function pathKey(path) {
15741
+ return path.normalize("NFC").toLowerCase();
15742
+ }
15743
+ function foldedOwnerOf(stateDb, candidate, pageId) {
15744
+ return stateDb.getByPathKey(candidate).some(
15745
+ (record) => record.obsidianPath !== candidate && !(record.notionPageId != null && notionIdsEqual(record.notionPageId, pageId))
15746
+ );
15747
+ }
15748
+
15749
+ // src/sync/untracked-note.ts
15750
+ async function findUntrackedNote(stateDb, vaultFs, dir, safeName, pageId, taken) {
15751
+ const [natural, ...suffixed] = pagePathCandidates(dir, safeName, pageId);
15752
+ const order = [
15753
+ ...suffixed.map((path) => ({ path, existing: "own" })),
15754
+ { path: natural, existing: "name" }
15755
+ ];
15756
+ for (const candidate of order) {
15757
+ if (taken(pathKey(candidate.path)) || stateDb.getByPath(candidate.path)) continue;
15758
+ if (foldedOwnerOf(stateDb, candidate.path, pageId)) continue;
15759
+ if (await vaultFs.exists(candidate.path)) return candidate;
15760
+ }
15761
+ return null;
15762
+ }
15763
+ function relinksWithoutContent(note, unlinkedVault) {
15764
+ return note.existing === "own" || unlinkedVault;
15765
+ }
15766
+
15689
15767
  // src/conflict/merger.ts
15690
15768
  function threeWayMerge(base, local, remote) {
15691
15769
  const baseLines = base.split("\n");
@@ -16151,49 +16229,6 @@ function withUnsentProperties(content, keys, before) {
16151
16229
  return (lines && replaceFrontmatterLines(content, lines)) ?? content;
16152
16230
  }
16153
16231
 
16154
- // src/utils/db-row-path.ts
16155
- function selectDbRowFiles(files, localFolder) {
16156
- const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
16157
- return files.filter((file) => {
16158
- if (!file.path.startsWith(prefix)) return false;
16159
- const rel = file.path.slice(prefix.length);
16160
- return rel.length > 0 && !rel.includes("/");
16161
- });
16162
- }
16163
- var PAGE_ID_SUFFIX_LENGTHS = [8, 16, 32];
16164
- var PAGE_ID_SUFFIX = new RegExp(
16165
- ` \\((?:${PAGE_ID_SUFFIX_LENGTHS.map((length) => `[0-9a-f]{${length}}`).join("|")})\\)$`,
16166
- "i"
16167
- );
16168
- function withoutPageIdSuffix(name) {
16169
- return name.replace(PAGE_ID_SUFFIX, "");
16170
- }
16171
- function pagePathCandidates(dir, safeName, pageId) {
16172
- const base = dir ? `${dir}/${safeName}` : safeName;
16173
- const raw = pageId.replace(/-/g, "");
16174
- return [
16175
- `${base}.md`,
16176
- ...PAGE_ID_SUFFIX_LENGTHS.map((len) => `${base} (${raw.slice(0, len)}).md`)
16177
- ];
16178
- }
16179
- function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
16180
- const ownedBySelf = (candidate) => {
16181
- const owner = lookupByPath(candidate);
16182
- if (!owner) return true;
16183
- return owner.notionPageId != null && notionIdsEqual(owner.notionPageId, pageId);
16184
- };
16185
- const candidates = pagePathCandidates(localFolder, safeName, pageId);
16186
- return candidates.find(ownedBySelf) ?? candidates[candidates.length - 1];
16187
- }
16188
- function inClaimOrder(items, chosen, idOf2) {
16189
- const slots = items.flatMap((item, index) => chosen(item) ? [index] : []);
16190
- const key = (item) => compactNotionId(idOf2(item));
16191
- const members = slots.map((index) => items[index]).sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
16192
- const ordered = [...items];
16193
- slots.forEach((slot, k) => ordered[slot] = members[k]);
16194
- return ordered;
16195
- }
16196
-
16197
16232
  // src/sync/note-title.ts
16198
16233
  function explicitTitle(frontmatter) {
16199
16234
  const title = frontmatter.title;
@@ -19161,16 +19196,6 @@ function joinPath(folder, name) {
19161
19196
  return folder === "" ? name : `${folder}/${name}`;
19162
19197
  }
19163
19198
 
19164
- // src/utils/path-key.ts
19165
- function pathKey(path) {
19166
- return path.normalize("NFC").toLowerCase();
19167
- }
19168
- function foldedOwnerOf(stateDb, candidate, pageId) {
19169
- return stateDb.getByPathKey(candidate).some(
19170
- (record) => record.obsidianPath !== candidate && !(record.notionPageId != null && notionIdsEqual(record.notionPageId, pageId))
19171
- );
19172
- }
19173
-
19174
19199
  // src/converter/unknown-tag-fallback.ts
19175
19200
  var UNKNOWN_BLOCK_TAG_RE2 = /<unknown (url|id)="([^"]*)"[^>]*\/>/g;
19176
19201
  var UNKNOWN_MENTION_TAG_RE = /<unknown_mention\s+url="([^"]*)"[^>]*?\/>/g;
@@ -19846,7 +19871,13 @@ var DatabaseSyncer = class {
19846
19871
  const target = await this.queryRows(dbConfig, opts);
19847
19872
  if (target.kind !== "rows") return [];
19848
19873
  const folderMove = opts?.followPlacement && !opts.paths ? await this.unblocked(this.placementMoveOf(dbConfig, target.meta, opts.movingInVault)) : null;
19849
- const planned = await this.planRows(dbConfig, target, opts?.paths, opts?.movingInVault);
19874
+ const planned = await this.planRows(
19875
+ dbConfig,
19876
+ target,
19877
+ opts?.paths,
19878
+ opts?.movingInVault,
19879
+ opts?.relinkTaken
19880
+ );
19850
19881
  if (!folderMove) return planned;
19851
19882
  const moved = (path) => within(path, folderMove.from) ? folderMove.to + path.slice(folderMove.from.length) : path;
19852
19883
  return [
@@ -19925,7 +19956,7 @@ var DatabaseSyncer = class {
19925
19956
  return false;
19926
19957
  }
19927
19958
  }
19928
- async planRows(dbConfig, target, paths, movingInVault = /* @__PURE__ */ new Set()) {
19959
+ async planRows(dbConfig, target, paths, movingInVault = /* @__PURE__ */ new Set(), relinkTaken = /* @__PURE__ */ new Set()) {
19929
19960
  const opts = { paths };
19930
19961
  const planned = [];
19931
19962
  const claimed = /* @__PURE__ */ new Map();
@@ -19946,6 +19977,24 @@ var DatabaseSyncer = class {
19946
19977
  if (action === "skip") continue;
19947
19978
  if (!record) {
19948
19979
  const name = sanitizeFileName(this.notionClient.extractTitle(page));
19980
+ const untracked = await findUntrackedNote(
19981
+ this.stateDb,
19982
+ this.vaultFs,
19983
+ dbConfig.localFolder,
19984
+ name,
19985
+ page.id,
19986
+ (key) => relinkTaken.has(key) || claimed.has(key)
19987
+ );
19988
+ if (untracked && relinksWithoutContent(untracked, this.unlinkedVault)) {
19989
+ relinkTaken.add(pathKey(untracked.path));
19990
+ planned.push({
19991
+ pageId: page.id,
19992
+ path: untracked.path,
19993
+ operation: "create",
19994
+ relink: true
19995
+ });
19996
+ continue;
19997
+ }
19949
19998
  const ownerOf = await this.rowPathOwners(dbConfig.localFolder, name, page.id);
19950
19999
  const path = resolveDbRowPath(dbConfig.localFolder, name, page.id, (p) => {
19951
20000
  const owner = claimed.get(pathKey(p));
@@ -20761,20 +20810,16 @@ var DatabaseSyncer = class {
20761
20810
  * `claimExistingNote` 와 같은 규칙이다.
20762
20811
  */
20763
20812
  async claimExistingRow(localFolder, safeName, pageId) {
20764
- const [natural, ...suffixed] = pagePathCandidates(localFolder, safeName, pageId);
20765
- const order = [
20766
- ...suffixed.map((path) => [path, "own"]),
20767
- [natural, "name"]
20768
- ];
20769
- for (const [candidate, existing] of order) {
20770
- const key = pathKey(candidate);
20771
- if (this.relinkClaims.has(key) || this.stateDb.getByPath(candidate)) continue;
20772
- if (foldedOwnerOf(this.stateDb, candidate, pageId)) continue;
20773
- if (!await this.vaultFs.exists(candidate)) continue;
20774
- this.relinkClaims.add(key);
20775
- return { path: candidate, existing };
20776
- }
20777
- return null;
20813
+ const found2 = await findUntrackedNote(
20814
+ this.stateDb,
20815
+ this.vaultFs,
20816
+ localFolder,
20817
+ safeName,
20818
+ pageId,
20819
+ (key) => this.relinkClaims.has(key)
20820
+ );
20821
+ if (found2) this.relinkClaims.add(pathKey(found2.path));
20822
+ return found2;
20778
20823
  }
20779
20824
  /**
20780
20825
  * 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 페이지의 `relinkExistingNote` 와 같은 규칙. 글이 같으면 잇고, 다르면
@@ -26099,23 +26144,32 @@ var PagePusher = class {
26099
26144
  return uploads;
26100
26145
  }
26101
26146
  /**
26102
- * 노트의 제목 · 블록 링크(`[[노트#제목]]`)가 가리키는 페이지의 맨 위 블록을 읽어 둔다 — 변환은 기다리지 않는다
26103
- * (anchor-links). 그런 링크가 없으면 읽지 않는다. 읽지 못한 페이지의 링크는 예전처럼 간다.
26147
+ * 노트의 제목 · 블록 링크(`[[노트#제목]]`)가 가리키는 페이지의 블록을 읽어 둔다 — 변환은 기다리지 않는다
26148
+ * (anchor-links). 맨 위 블록에서 모두 찾으면 거기서 멈추고, 못 찾은 앵커가 있으면 콜아웃 · 토글 · 목록 자식까지 읽는다.
26149
+ * 그런 링크가 없으면 읽지 않는다. 읽지 못한 페이지의 링크는 예전처럼 간다.
26104
26150
  *
26105
- * @param selfPageId 이 노트의 페이지 — `[[#제목]]` 의 대상. 새로 만드는 노트는 아직 없다(그 링크는 다음 push 에 걸린다).
26151
+ * @param selfPageId 이 노트의 페이지 — `[[#제목]]` 의 대상. 새로 만드는 노트는 아직 없다(그 링크는 push 뒤 패스가 다시 보낸다 — `push-link-pass`).
26106
26152
  */
26107
26153
  async anchorLookup(content, selfPageId) {
26108
- const pages = /* @__PURE__ */ new Set();
26154
+ const pages = /* @__PURE__ */ new Map();
26109
26155
  for (const target of anchoredWikilinkTargets(content)) {
26110
- const { note } = splitAnchor(target);
26156
+ const { note, anchor } = splitAnchor(target);
26111
26157
  const pageId = note === "" ? selfPageId : this.stateDb.resolveWikilink(note)?.notionPageId ?? null;
26112
- if (pageId) pages.add(compactNotionId(pageId));
26158
+ if (!pageId) continue;
26159
+ const key = compactNotionId(pageId);
26160
+ pages.set(key, [...pages.get(key) ?? [], anchor]);
26113
26161
  }
26114
26162
  if (pages.size === 0) return void 0;
26115
26163
  const blocks = /* @__PURE__ */ new Map();
26116
- for (const pageId of pages) {
26164
+ for (const [pageId, anchors] of pages) {
26117
26165
  try {
26118
- blocks.set(pageId, (await this.notionClient.fetchAllChildren(pageId)).map(anchorBlockOf));
26166
+ const top = (await this.notionClient.fetchAllChildren(pageId)).map(anchorBlockOf);
26167
+ blocks.set(pageId, top);
26168
+ if (anchors.every((anchor) => anchorBlockId(top, anchor) !== null)) continue;
26169
+ blocks.set(
26170
+ pageId,
26171
+ flattenBlockTree(await this.notionClient.getBlockTree(pageId)).map(anchorBlockOf)
26172
+ );
26119
26173
  } catch (error) {
26120
26174
  getLogger().debug(
26121
26175
  `[Im-Nobsidian] \uC81C\uBAA9 \uB9C1\uD06C\uC758 \uB300\uC0C1 \uD398\uC774\uC9C0 \uBE14\uB85D\uC744 \uC77D\uC9C0 \uBABB\uD568 (${pageId}): ${error instanceof Error ? error.message : String(error)}`
@@ -27396,6 +27450,36 @@ var PagePuller = class {
27396
27450
  sanitizeFileName(this.notionClient.extractTitle(page))
27397
27451
  );
27398
27452
  }
27453
+ /**
27454
+ * dry-run — 실제 pull 이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7). 다시 이을 노트(`note`) ·
27455
+ * 이미 있는 폴더(`folder` — 이은 노트가 없는 볼트에서 폴더로만 받으면 만들지 않는다), 아니면 null. 받기 전에는 폴더 노트가 될지
27456
+ * 모른다 — 노트 자리 · 폴더 노트 자리를 차례로 본다. `taken` 은 이번 미리 보기가 이미 이은 자리({@link pathKey}) — 고르면
27457
+ * 더한다. 볼트도 상태 DB 도 바꾸지 않는다.
27458
+ */
27459
+ async plannedRelink(pageId, taken) {
27460
+ const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
27461
+ const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
27462
+ const parentPath = await this.resolveParentPath(page);
27463
+ const folder = parentPath ? `${parentPath}/${safeName}` : safeName;
27464
+ for (const dir of [parentPath, folder]) {
27465
+ const found2 = await findUntrackedNote(
27466
+ this.stateDb,
27467
+ this.vaultFs,
27468
+ dir,
27469
+ safeName,
27470
+ pageId,
27471
+ (key) => taken.has(key)
27472
+ );
27473
+ if (found2 && relinksWithoutContent(found2, this.unlinkedVault)) {
27474
+ taken.add(pathKey(found2.path));
27475
+ return { path: found2.path, kind: "note" };
27476
+ }
27477
+ }
27478
+ const folderExists = this.unlinkedVault && !taken.has(pathKey(folder)) && this.stateDb.getByPath(folder) === null && await this.vaultFs.exists(folder);
27479
+ if (!folderExists) return null;
27480
+ taken.add(pathKey(folder));
27481
+ return { path: folder, kind: "folder" };
27482
+ }
27399
27483
  /**
27400
27484
  * 새 페이지가 놓일 자리가 뺀 자리인가 — 부모 폴더 · 노트 이름 · 폴더 노트가 될 때의 폴더를 본다. 받기 전에는 폴더 노트가
27401
27485
  * 될지 모른다(본문을 받아 봐야 안다) — 어느 쪽이든 빠지면 뺀다. 자연 이름으로 보고, 같은 이름이 있을 때 붙는 id 접미사는
@@ -28081,21 +28165,22 @@ var PagePuller = class {
28081
28165
  * 이름. 다시 이을지는 받은 글과 견줘 호출측이 정한다({@link relinkExistingNote}) — 잇지 않으면 장부에서 지운다.
28082
28166
  */
28083
28167
  async claimExistingNote(dir, safeName, pageId) {
28084
- const [natural, ...suffixed] = pagePathCandidates(dir, safeName, pageId);
28085
- const order = [
28086
- ...suffixed.map((path) => [path, "own"]),
28087
- [natural, "name"]
28088
- ];
28089
- for (const [candidate, existing] of order) {
28090
- const key = pathKey(candidate);
28091
- if (this.claimedPaths.has(key) || this.stateDb.getByPath(candidate)) continue;
28092
- if (this.foldedOwner(candidate, pageId)) continue;
28093
- if (!await this.vaultFs.exists(candidate)) continue;
28168
+ const taken = (key) => this.claimedPaths.has(key);
28169
+ for (; ; ) {
28170
+ const found2 = await findUntrackedNote(
28171
+ this.stateDb,
28172
+ this.vaultFs,
28173
+ dir,
28174
+ safeName,
28175
+ pageId,
28176
+ taken
28177
+ );
28178
+ if (!found2) return null;
28179
+ const key = pathKey(found2.path);
28094
28180
  if (this.claimedPaths.has(key)) continue;
28095
- this.claimedPaths.set(key, candidate);
28096
- return { path: candidate, existing };
28181
+ this.claimedPaths.set(key, found2.path);
28182
+ return found2;
28097
28183
  }
28098
- return null;
28099
28184
  }
28100
28185
  async claimFilePath(dir, safeName, pageId) {
28101
28186
  const candidates = pagePathCandidates(dir, safeName, pageId);
@@ -28297,17 +28382,31 @@ async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle, blockAncho
28297
28382
  }
28298
28383
 
28299
28384
  // src/sync/push-link-pass.ts
28300
- function notesWithResolvableWikilinks(stateDb) {
28385
+ function notesWithResolvableWikilinks(stateDb, pushed = /* @__PURE__ */ new Set()) {
28301
28386
  const paths = [];
28302
28387
  for (const record of stateDb.getAll()) {
28303
28388
  if (!record.notionPageId || record.status !== "synced") continue;
28304
- const resolvable = stateDb.getPreserveMarkers(record.obsidianPath).some(
28305
- (marker) => marker.type === "wikilink" && marker.params.text !== void 0 && stateDb.resolveWikilink(marker.params.text) !== null
28306
- );
28389
+ const resolvable = stateDb.getPreserveMarkers(record.obsidianPath).some((marker) => marker.type === "wikilink" && resolvesNow(stateDb, record, marker, pushed));
28307
28390
  if (resolvable) paths.push(record.obsidianPath);
28308
28391
  }
28309
28392
  return paths.sort();
28310
28393
  }
28394
+ function resolvesNow(stateDb, record, marker, pushed) {
28395
+ const text = marker.params.text;
28396
+ if (text === void 0) return false;
28397
+ const split = splitAnchor(text);
28398
+ if (!split) return stateDb.resolveWikilink(text) !== null;
28399
+ const target = split.note === "" ? null : stateDb.resolveWikilink(split.note);
28400
+ if (split.note !== "" && target === null) return false;
28401
+ switch (marker.params.anchor) {
28402
+ case "page":
28403
+ return true;
28404
+ case "block":
28405
+ return target !== null && target.obsidianPath !== record.obsidianPath && pushed.has(target.obsidianPath);
28406
+ default:
28407
+ return true;
28408
+ }
28409
+ }
28311
28410
 
28312
28411
  // src/sync/pull-planner.ts
28313
28412
  var PullPlanner = class {
@@ -28356,12 +28455,19 @@ var PullPlanner = class {
28356
28455
  }
28357
28456
  planned.push(change);
28358
28457
  }
28458
+ const relinks = await this.plannedRelinks(planned);
28359
28459
  const queued = new Set(
28360
28460
  [...filtered, ...restoreChanges].map((change) => compactNotionId(change.pageId))
28361
28461
  );
28362
28462
  const movingInVault = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
28363
28463
  const rows = [];
28364
- for (const row of await this.planDatabaseRows(options.paths, failed, ledger, movingInVault)) {
28464
+ for (const row of await this.planDatabaseRows(
28465
+ options.paths,
28466
+ failed,
28467
+ ledger,
28468
+ movingInVault,
28469
+ relinks.taken
28470
+ )) {
28365
28471
  if (queued.has(compactNotionId(row.pageId))) continue;
28366
28472
  if (row.resolution?.action === "conflict") conflicts.push(row.resolution.conflict);
28367
28473
  else if (row.resolution?.action === "merge") mergedPaths.push(row.path);
@@ -28371,16 +28477,27 @@ var PullPlanner = class {
28371
28477
  const items = [];
28372
28478
  const plannedPath = this.planner.plannedPaths(localPlan);
28373
28479
  for (const change of [...planned, ...restoreChanges]) {
28480
+ const relink = relinks.byPage.get(change.pageId);
28481
+ if (relink) {
28482
+ items.push({ path: relink.path, operation: "create", unchanged: true });
28483
+ continue;
28484
+ }
28374
28485
  const record = this.stateDb.getByNotionId(change.pageId);
28375
28486
  items.push({
28376
28487
  path: (record && plannedPath.get(record.id)) ?? record?.obsidianPath ?? change.title ?? change.pageId,
28377
28488
  operation: change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update"
28378
28489
  });
28379
28490
  }
28380
- for (const row of rows) items.push({ path: row.path, operation: row.operation });
28491
+ for (const row of rows) {
28492
+ items.push({
28493
+ path: row.path,
28494
+ operation: row.operation,
28495
+ ...row.relink ? { unchanged: true } : {}
28496
+ });
28497
+ }
28381
28498
  const movedRows = rows.filter((row) => row.moves !== void 0);
28382
28499
  items.forEach((item, index) => options.onProgress?.(index + 1, items.length, item));
28383
- const count = (type) => planned.filter((c) => c.type === type).length;
28500
+ const count = (type) => planned.filter((c) => c.type === type && !relinks.byPage.has(c.pageId)).length;
28384
28501
  const restoredModified = planned.filter(
28385
28502
  (c) => c.type === "modified" && restoreIds.has(c.pageId)
28386
28503
  ).length;
@@ -28391,7 +28508,13 @@ var PullPlanner = class {
28391
28508
  ].map((change) => this.stateDb.getByNotionId(change.pageId)?.obsidianPath).filter((path) => path !== void 0),
28392
28509
  ...rows.filter((row) => row.operation === "update" && row.restore).map((row) => row.path)
28393
28510
  ];
28394
- const rowCount = (operation, restore = false) => rows.filter((row) => row.operation === operation && (row.restore ?? false) === restore).length;
28511
+ const rowCount = (operation, restore = false) => rows.filter(
28512
+ (row) => row.operation === operation && (row.restore ?? false) === restore && !row.relink
28513
+ ).length;
28514
+ const relinkedPaths = [
28515
+ ...[...relinks.byPage.values()].filter((relink) => relink.kind === "note").map((relink) => relink.path),
28516
+ ...rows.filter((row) => row.relink).map((row) => row.path)
28517
+ ];
28395
28518
  return {
28396
28519
  created: count("created") + rowCount("create"),
28397
28520
  updated: count("modified") - restoredModified + rowCount("update"),
@@ -28403,6 +28526,7 @@ var PullPlanner = class {
28403
28526
  ...movedRows.length > 0 ? { moved: movedRows.flatMap((row) => row.moves) } : {},
28404
28527
  conflicts,
28405
28528
  ...mergedPaths.length > 0 ? { mergedPaths } : {},
28529
+ ...relinkedPaths.length > 0 ? { relinkedPaths, relinkPending: true } : {},
28406
28530
  writtenPaths: [],
28407
28531
  failed,
28408
28532
  duration: Date.now() - startTime,
@@ -28419,6 +28543,27 @@ var PullPlanner = class {
28419
28543
  }
28420
28544
  };
28421
28545
  }
28546
+ /**
28547
+ * 새 페이지 가운데 실제 pull 이 상태 기록에 없던 노트 · 폴더와 다시 이을 것({@link PagePuller.plannedRelink}) — 페이지 id →
28548
+ * 자리. `taken` 은 고른 자리라 행도 피한다.
28549
+ */
28550
+ async plannedRelinks(planned) {
28551
+ const byPage = /* @__PURE__ */ new Map();
28552
+ const taken = /* @__PURE__ */ new Set();
28553
+ for (const change of planned) {
28554
+ if (change.type !== "created") continue;
28555
+ try {
28556
+ const relink = await this.puller.plannedRelink(change.pageId, taken);
28557
+ if (relink) byPage.set(change.pageId, relink);
28558
+ } catch (error) {
28559
+ getLogger().debug(
28560
+ `[Im-Nobsidian] \uB2E4\uC2DC \uC774\uC744 \uC790\uB9AC\uB97C \uBBF8\uB9AC \uBCF4\uC9C0 \uBABB\uD568 (${change.pageId}):`,
28561
+ error
28562
+ );
28563
+ }
28564
+ }
28565
+ return { byPage, taken };
28566
+ }
28422
28567
  /**
28423
28568
  * 고친 페이지를 실제 pull 이 받는가 — 폴더로만 추적하는 페이지는 본문이 생겼을 때만 폴더 노트로 받는다. 하위 페이지 ·
28424
28569
  * DB 를 옮기기만 해도 그 페이지의 수정 시각이 바뀌어 고친 것으로 보였다. 확인하지 못하면 센다(실제 pull 이 받아 본다).
@@ -28467,7 +28612,7 @@ var PullPlanner = class {
28467
28612
  * (`DatabaseDiscovery.pullDiscoveredDatabases`)를 같은 규칙으로 고른다. 세지 못한 DB 는 이유와
28468
28613
  * 함께 `failed` 에 싣는다 — 세지 못한 것을 없다고 하지 않는다.
28469
28614
  */
28470
- async planDatabaseRows(paths, failed, ledger, movingInVault) {
28615
+ async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken) {
28471
28616
  const configured = this.config.notion.databases ?? [];
28472
28617
  const rows = [];
28473
28618
  const plan = async (dbConfig, resolveDbFolder2) => {
@@ -28481,7 +28626,8 @@ var PullPlanner = class {
28481
28626
  paths,
28482
28627
  resolveDbFolder: resolveDbFolder2,
28483
28628
  followPlacement: resolveDbFolder2 !== void 0,
28484
- movingInVault
28629
+ movingInVault,
28630
+ relinkTaken
28485
28631
  })
28486
28632
  );
28487
28633
  } catch (error) {
@@ -29518,7 +29664,7 @@ var SyncOrchestrator = class {
29518
29664
  * 적어 push 가 올린다 — dry-run 은 받지 않아 원격이 바뀐 그대로라, 원격을 보고 거절하지 않는다.
29519
29665
  */
29520
29666
  /**
29521
- * @param relinkedByPull 같은 sync 의 dry-run pull 이 다시 이을 것이라고 미리 봤다 — 실제 sync 는 받으며 먼저 잇고 올리므로
29667
+ * @param relinkedByPull 같은 sync 의 dry-run pull 이 받거나 다시 이을 것이 있다 — 실제 sync 는 받으며 먼저 잇고 올리므로
29522
29668
  * 상태 기록이 비었어도 막지 않는다({@link assertVaultLinked}).
29523
29669
  */
29524
29670
  async executePush(options, mergedByPull = /* @__PURE__ */ new Set(), relinkedByPull = false) {
@@ -29876,7 +30022,7 @@ var SyncOrchestrator = class {
29876
30022
  );
29877
30023
  }
29878
30024
  };
29879
- const relinkable = notesWithResolvableWikilinks(this.stateDb).filter(
30025
+ const relinkable = notesWithResolvableWikilinks(this.stateDb, pushedBodies).filter(
29880
30026
  (path) => inAnyPathScope(path, options?.paths) && !excludeSet?.has(path)
29881
30027
  );
29882
30028
  await runPool(relinkable, relink, {
@@ -30190,8 +30336,7 @@ var SyncOrchestrator = class {
30190
30336
  },
30191
30337
  moved: [...relocated.moved, ...plan.moved ?? []],
30192
30338
  moveSkipped: relocated.skipped,
30193
- excluded: [...exclusions.values()],
30194
- ...this.puller.unlinkedVaultAtStart && plan.created > 0 ? { relinkPending: true } : {}
30339
+ excluded: [...exclusions.values()]
30195
30340
  };
30196
30341
  }
30197
30342
  let pullTotal = workItems.length;
@@ -30403,15 +30548,16 @@ var SyncOrchestrator = class {
30403
30548
  const tagged = (direction) => onProgress && ((current, total, item) => onProgress(current, total, { ...item, direction }));
30404
30549
  const pullResult = await this.executePull({ ...options, onProgress: tagged("pull") });
30405
30550
  const conflicted = options?.dryRun ? pullResult.conflicts.map((conflict) => conflict.syncRecord.obsidianPath) : [];
30551
+ const relinked = options?.dryRun ? pullResult.relinkedPaths ?? [] : [];
30406
30552
  const pushResult = await this.executePush(
30407
30553
  {
30408
30554
  ...options,
30409
30555
  onProgress: tagged("push"),
30410
30556
  paths: options?.paths,
30411
- excludePaths: [...pullResult.writtenPaths, ...conflicted]
30557
+ excludePaths: [...pullResult.writtenPaths, ...conflicted, ...relinked]
30412
30558
  },
30413
30559
  new Set(options?.dryRun ? pullResult.mergedPaths ?? [] : []),
30414
- pullResult.relinkPending === true
30560
+ options?.dryRun === true && (pullResult.created > 0 || relinked.length > 0)
30415
30561
  );
30416
30562
  return {
30417
30563
  pull: pullResult,