@im-nobsidian/core 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -597,10 +597,16 @@ 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;
605
+ /**
606
+ * dry-run 만 — 이번 pull 이 새로 찾을 DB(처음 훑는 볼트 · 새로 받을 페이지 안의 DB)의 행은 세지 않았다. 받아 봐야 그 DB 가
607
+ * 있는지 안다. 없으면 생략.
608
+ */
609
+ readonly undiscoveredDatabases?: true;
604
610
  /**
605
611
  * 볼트에서 고쳐 덮어쓰지 않은 사이드카(`.notion.json`) — push 하면 고친 속성을 Notion 에 올린다(ADR-045). 없으면 생략.
606
612
  */
@@ -2820,7 +2826,7 @@ declare class SyncOrchestrator {
2820
2826
  * 적어 push 가 올린다 — dry-run 은 받지 않아 원격이 바뀐 그대로라, 원격을 보고 거절하지 않는다.
2821
2827
  */
2822
2828
  /**
2823
- * @param relinkedByPull 같은 sync 의 dry-run pull 이 다시 이을 것이라고 미리 봤다 — 실제 sync 는 받으며 먼저 잇고 올리므로
2829
+ * @param relinkedByPull 같은 sync 의 dry-run pull 이 받거나 다시 이을 것이 있다 — 실제 sync 는 받으며 먼저 잇고 올리므로
2824
2830
  * 상태 기록이 비었어도 막지 않는다({@link assertVaultLinked}).
2825
2831
  */
2826
2832
  private executePush;
@@ -3643,6 +3649,8 @@ interface PlannedRow {
3643
3649
  readonly moves?: readonly RemoteMove[];
3644
3650
  /** 로컬도 고친 행을 실제 pull 이 받지 않는 까닭 — 없으면 받는다. */
3645
3651
  readonly resolution?: PlannedResolution;
3652
+ /** `create` 만 — 상태 기록에 없던 같은 자리의 행 노트를 다시 잇는다(P7). 새로 받는 것으로 세지 않는다. */
3653
+ readonly relink?: true;
3646
3654
  }
3647
3655
  /**
3648
3656
  * 로컬도 고친 노트를 실제 pull 이 어떻게 처리하나 — 충돌로 남김 · 합침(merge) · 받지 않음(원격이 그대로거나
@@ -3798,6 +3806,8 @@ declare class DatabaseSyncer {
3798
3806
  * 볼트에서 옮긴 레코드 — dry-run 은 옮겨 적지 않아 이동 WAL 이 없다. 실제 pull 처럼 볼트가 먼저다.
3799
3807
  */
3800
3808
  movingInVault?: ReadonlySet<string>;
3809
+ /** 이번 미리 보기가 다시 이을 것으로 고른 자리({@link pathKey}) — 고르면 더한다. */
3810
+ relinkTaken?: Set<string>;
3801
3811
  }): Promise<PlannedRow[]>;
3802
3812
  /**
3803
3813
  * 받은 행이 지난 사본 그대로인가 — 렌더한 글이 달라도 받을 것이 없으면 그 사이 로컬 편집을 충돌로 올리지 않는다
@@ -4110,7 +4120,8 @@ declare class WikilinkResolver implements Processor {
4110
4120
  process(input: ProcessorInput): ProcessorOutput;
4111
4121
  /**
4112
4122
  * `[[노트#제목]]` · `[[노트#^블록]]` · `[[#제목]]` → `[글자](https://www.notion.so/<페이지>#<블록>)`. 글자는 별칭,
4113
- * 없으면 Obsidian 이 보이는 「노트 > 제목」 — pull 이 그 글자면 별칭을 붙이지 않는다. 대상 페이지 · 블록을 못 찾으면 null.
4123
+ * 없으면 Obsidian 이 보이는 「노트 > 제목」 — pull 이 그 글자면 별칭을 붙이지 않는다. 앵커 링크가 아니면 null, 못 풀면
4124
+ * 그 까닭({@link AnchorMiss}).
4114
4125
  */
4115
4126
  private anchorLink;
4116
4127
  }
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;
@@ -16543,6 +16578,10 @@ function decodeId(id) {
16543
16578
  // src/sync/discovered-databases.ts
16544
16579
  var ROOT_DATABASES_FOLDER = "databases";
16545
16580
  var DISCOVERED_DBS_META_KEY = "discovered_dbs";
16581
+ var DISCOVERY_SCANNED_META_KEY = "discovery_scanned_at";
16582
+ function discoveryScanPending(stateDb) {
16583
+ return parseDiscoveredDbs(stateDb.getMeta(DISCOVERED_DBS_META_KEY)).length === 0 && !stateDb.getMeta(DISCOVERY_SCANNED_META_KEY);
16584
+ }
16546
16585
  var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
16547
16586
  var LINKED_DBS_META_KEY = "linked_dbs";
16548
16587
  function parseDiscoveredDbs(raw) {
@@ -19161,16 +19200,6 @@ function joinPath(folder, name) {
19161
19200
  return folder === "" ? name : `${folder}/${name}`;
19162
19201
  }
19163
19202
 
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
19203
  // src/converter/unknown-tag-fallback.ts
19175
19204
  var UNKNOWN_BLOCK_TAG_RE2 = /<unknown (url|id)="([^"]*)"[^>]*\/>/g;
19176
19205
  var UNKNOWN_MENTION_TAG_RE = /<unknown_mention\s+url="([^"]*)"[^>]*?\/>/g;
@@ -19846,7 +19875,13 @@ var DatabaseSyncer = class {
19846
19875
  const target = await this.queryRows(dbConfig, opts);
19847
19876
  if (target.kind !== "rows") return [];
19848
19877
  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);
19878
+ const planned = await this.planRows(
19879
+ dbConfig,
19880
+ target,
19881
+ opts?.paths,
19882
+ opts?.movingInVault,
19883
+ opts?.relinkTaken
19884
+ );
19850
19885
  if (!folderMove) return planned;
19851
19886
  const moved = (path) => within(path, folderMove.from) ? folderMove.to + path.slice(folderMove.from.length) : path;
19852
19887
  return [
@@ -19925,7 +19960,7 @@ var DatabaseSyncer = class {
19925
19960
  return false;
19926
19961
  }
19927
19962
  }
19928
- async planRows(dbConfig, target, paths, movingInVault = /* @__PURE__ */ new Set()) {
19963
+ async planRows(dbConfig, target, paths, movingInVault = /* @__PURE__ */ new Set(), relinkTaken = /* @__PURE__ */ new Set()) {
19929
19964
  const opts = { paths };
19930
19965
  const planned = [];
19931
19966
  const claimed = /* @__PURE__ */ new Map();
@@ -19946,6 +19981,24 @@ var DatabaseSyncer = class {
19946
19981
  if (action === "skip") continue;
19947
19982
  if (!record) {
19948
19983
  const name = sanitizeFileName(this.notionClient.extractTitle(page));
19984
+ const untracked = await findUntrackedNote(
19985
+ this.stateDb,
19986
+ this.vaultFs,
19987
+ dbConfig.localFolder,
19988
+ name,
19989
+ page.id,
19990
+ (key) => relinkTaken.has(key) || claimed.has(key)
19991
+ );
19992
+ if (untracked && relinksWithoutContent(untracked, this.unlinkedVault)) {
19993
+ relinkTaken.add(pathKey(untracked.path));
19994
+ planned.push({
19995
+ pageId: page.id,
19996
+ path: untracked.path,
19997
+ operation: "create",
19998
+ relink: true
19999
+ });
20000
+ continue;
20001
+ }
19949
20002
  const ownerOf = await this.rowPathOwners(dbConfig.localFolder, name, page.id);
19950
20003
  const path = resolveDbRowPath(dbConfig.localFolder, name, page.id, (p) => {
19951
20004
  const owner = claimed.get(pathKey(p));
@@ -20761,20 +20814,16 @@ var DatabaseSyncer = class {
20761
20814
  * `claimExistingNote` 와 같은 규칙이다.
20762
20815
  */
20763
20816
  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;
20817
+ const found2 = await findUntrackedNote(
20818
+ this.stateDb,
20819
+ this.vaultFs,
20820
+ localFolder,
20821
+ safeName,
20822
+ pageId,
20823
+ (key) => this.relinkClaims.has(key)
20824
+ );
20825
+ if (found2) this.relinkClaims.add(pathKey(found2.path));
20826
+ return found2;
20778
20827
  }
20779
20828
  /**
20780
20829
  * 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 페이지의 `relinkExistingNote` 와 같은 규칙. 글이 같으면 잇고, 다르면
@@ -23332,7 +23381,6 @@ function dropEmbeddedBaseMarkers(content, basePaths) {
23332
23381
 
23333
23382
  // src/sync/database-discovery.ts
23334
23383
  var DISCOVERY_RETRY_META_KEY = "discovery_retry";
23335
- var DISCOVERY_SCANNED_META_KEY = "discovery_scanned_at";
23336
23384
  function parseDiscoveryRetry(raw) {
23337
23385
  if (!raw) return { parents: [], dbs: [] };
23338
23386
  try {
@@ -23511,7 +23559,7 @@ var DatabaseDiscovery = class {
23511
23559
  retryLater(dbId, parentPageId, outcome.error);
23512
23560
  }
23513
23561
  };
23514
- const scanAll = forceRediscovery || dbConfigs.length === 0 && !this.stateDb.getMeta(DISCOVERY_SCANNED_META_KEY);
23562
+ const scanAll = forceRediscovery || discoveryScanPending(this.stateDb);
23515
23563
  if (signal?.aborted) {
23516
23564
  nextRetry.parents.push(...retry.parents);
23517
23565
  nextRetry.dbs.push(...retry.dbs);
@@ -26099,23 +26147,32 @@ var PagePusher = class {
26099
26147
  return uploads;
26100
26148
  }
26101
26149
  /**
26102
- * 노트의 제목 · 블록 링크(`[[노트#제목]]`)가 가리키는 페이지의 맨 위 블록을 읽어 둔다 — 변환은 기다리지 않는다
26103
- * (anchor-links). 그런 링크가 없으면 읽지 않는다. 읽지 못한 페이지의 링크는 예전처럼 간다.
26150
+ * 노트의 제목 · 블록 링크(`[[노트#제목]]`)가 가리키는 페이지의 블록을 읽어 둔다 — 변환은 기다리지 않는다
26151
+ * (anchor-links). 맨 위 블록에서 모두 찾으면 거기서 멈추고, 못 찾은 앵커가 있으면 콜아웃 · 토글 · 목록 자식까지 읽는다.
26152
+ * 그런 링크가 없으면 읽지 않는다. 읽지 못한 페이지의 링크는 예전처럼 간다.
26104
26153
  *
26105
- * @param selfPageId 이 노트의 페이지 — `[[#제목]]` 의 대상. 새로 만드는 노트는 아직 없다(그 링크는 다음 push 에 걸린다).
26154
+ * @param selfPageId 이 노트의 페이지 — `[[#제목]]` 의 대상. 새로 만드는 노트는 아직 없다(그 링크는 push 뒤 패스가 다시 보낸다 — `push-link-pass`).
26106
26155
  */
26107
26156
  async anchorLookup(content, selfPageId) {
26108
- const pages = /* @__PURE__ */ new Set();
26157
+ const pages = /* @__PURE__ */ new Map();
26109
26158
  for (const target of anchoredWikilinkTargets(content)) {
26110
- const { note } = splitAnchor(target);
26159
+ const { note, anchor } = splitAnchor(target);
26111
26160
  const pageId = note === "" ? selfPageId : this.stateDb.resolveWikilink(note)?.notionPageId ?? null;
26112
- if (pageId) pages.add(compactNotionId(pageId));
26161
+ if (!pageId) continue;
26162
+ const key = compactNotionId(pageId);
26163
+ pages.set(key, [...pages.get(key) ?? [], anchor]);
26113
26164
  }
26114
26165
  if (pages.size === 0) return void 0;
26115
26166
  const blocks = /* @__PURE__ */ new Map();
26116
- for (const pageId of pages) {
26167
+ for (const [pageId, anchors] of pages) {
26117
26168
  try {
26118
- blocks.set(pageId, (await this.notionClient.fetchAllChildren(pageId)).map(anchorBlockOf));
26169
+ const top = (await this.notionClient.fetchAllChildren(pageId)).map(anchorBlockOf);
26170
+ blocks.set(pageId, top);
26171
+ if (anchors.every((anchor) => anchorBlockId(top, anchor) !== null)) continue;
26172
+ blocks.set(
26173
+ pageId,
26174
+ flattenBlockTree(await this.notionClient.getBlockTree(pageId)).map(anchorBlockOf)
26175
+ );
26119
26176
  } catch (error) {
26120
26177
  getLogger().debug(
26121
26178
  `[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 +27453,36 @@ var PagePuller = class {
27396
27453
  sanitizeFileName(this.notionClient.extractTitle(page))
27397
27454
  );
27398
27455
  }
27456
+ /**
27457
+ * dry-run — 실제 pull 이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7). 다시 이을 노트(`note`) ·
27458
+ * 이미 있는 폴더(`folder` — 이은 노트가 없는 볼트에서 폴더로만 받으면 만들지 않는다), 아니면 null. 받기 전에는 폴더 노트가 될지
27459
+ * 모른다 — 노트 자리 · 폴더 노트 자리를 차례로 본다. `taken` 은 이번 미리 보기가 이미 이은 자리({@link pathKey}) — 고르면
27460
+ * 더한다. 볼트도 상태 DB 도 바꾸지 않는다.
27461
+ */
27462
+ async plannedRelink(pageId, taken) {
27463
+ const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
27464
+ const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
27465
+ const parentPath = await this.resolveParentPath(page);
27466
+ const folder = parentPath ? `${parentPath}/${safeName}` : safeName;
27467
+ for (const dir of [parentPath, folder]) {
27468
+ const found2 = await findUntrackedNote(
27469
+ this.stateDb,
27470
+ this.vaultFs,
27471
+ dir,
27472
+ safeName,
27473
+ pageId,
27474
+ (key) => taken.has(key)
27475
+ );
27476
+ if (found2 && relinksWithoutContent(found2, this.unlinkedVault)) {
27477
+ taken.add(pathKey(found2.path));
27478
+ return { path: found2.path, kind: "note" };
27479
+ }
27480
+ }
27481
+ const folderExists = this.unlinkedVault && !taken.has(pathKey(folder)) && this.stateDb.getByPath(folder) === null && await this.vaultFs.exists(folder);
27482
+ if (!folderExists) return null;
27483
+ taken.add(pathKey(folder));
27484
+ return { path: folder, kind: "folder" };
27485
+ }
27399
27486
  /**
27400
27487
  * 새 페이지가 놓일 자리가 뺀 자리인가 — 부모 폴더 · 노트 이름 · 폴더 노트가 될 때의 폴더를 본다. 받기 전에는 폴더 노트가
27401
27488
  * 될지 모른다(본문을 받아 봐야 안다) — 어느 쪽이든 빠지면 뺀다. 자연 이름으로 보고, 같은 이름이 있을 때 붙는 id 접미사는
@@ -28081,21 +28168,22 @@ var PagePuller = class {
28081
28168
  * 이름. 다시 이을지는 받은 글과 견줘 호출측이 정한다({@link relinkExistingNote}) — 잇지 않으면 장부에서 지운다.
28082
28169
  */
28083
28170
  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;
28171
+ const taken = (key) => this.claimedPaths.has(key);
28172
+ for (; ; ) {
28173
+ const found2 = await findUntrackedNote(
28174
+ this.stateDb,
28175
+ this.vaultFs,
28176
+ dir,
28177
+ safeName,
28178
+ pageId,
28179
+ taken
28180
+ );
28181
+ if (!found2) return null;
28182
+ const key = pathKey(found2.path);
28094
28183
  if (this.claimedPaths.has(key)) continue;
28095
- this.claimedPaths.set(key, candidate);
28096
- return { path: candidate, existing };
28184
+ this.claimedPaths.set(key, found2.path);
28185
+ return found2;
28097
28186
  }
28098
- return null;
28099
28187
  }
28100
28188
  async claimFilePath(dir, safeName, pageId) {
28101
28189
  const candidates = pagePathCandidates(dir, safeName, pageId);
@@ -28297,17 +28385,31 @@ async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle, blockAncho
28297
28385
  }
28298
28386
 
28299
28387
  // src/sync/push-link-pass.ts
28300
- function notesWithResolvableWikilinks(stateDb) {
28388
+ function notesWithResolvableWikilinks(stateDb, pushed = /* @__PURE__ */ new Set()) {
28301
28389
  const paths = [];
28302
28390
  for (const record of stateDb.getAll()) {
28303
28391
  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
- );
28392
+ const resolvable = stateDb.getPreserveMarkers(record.obsidianPath).some((marker) => marker.type === "wikilink" && resolvesNow(stateDb, record, marker, pushed));
28307
28393
  if (resolvable) paths.push(record.obsidianPath);
28308
28394
  }
28309
28395
  return paths.sort();
28310
28396
  }
28397
+ function resolvesNow(stateDb, record, marker, pushed) {
28398
+ const text = marker.params.text;
28399
+ if (text === void 0) return false;
28400
+ const split = splitAnchor(text);
28401
+ if (!split) return stateDb.resolveWikilink(text) !== null;
28402
+ const target = split.note === "" ? null : stateDb.resolveWikilink(split.note);
28403
+ if (split.note !== "" && target === null) return false;
28404
+ switch (marker.params.anchor) {
28405
+ case "page":
28406
+ return true;
28407
+ case "block":
28408
+ return target !== null && target.obsidianPath !== record.obsidianPath && pushed.has(target.obsidianPath);
28409
+ default:
28410
+ return true;
28411
+ }
28412
+ }
28311
28413
 
28312
28414
  // src/sync/pull-planner.ts
28313
28415
  var PullPlanner = class {
@@ -28356,12 +28458,19 @@ var PullPlanner = class {
28356
28458
  }
28357
28459
  planned.push(change);
28358
28460
  }
28461
+ const relinks = await this.plannedRelinks(planned);
28359
28462
  const queued = new Set(
28360
28463
  [...filtered, ...restoreChanges].map((change) => compactNotionId(change.pageId))
28361
28464
  );
28362
28465
  const movingInVault = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
28363
28466
  const rows = [];
28364
- for (const row of await this.planDatabaseRows(options.paths, failed, ledger, movingInVault)) {
28467
+ for (const row of await this.planDatabaseRows(
28468
+ options.paths,
28469
+ failed,
28470
+ ledger,
28471
+ movingInVault,
28472
+ relinks.taken
28473
+ )) {
28365
28474
  if (queued.has(compactNotionId(row.pageId))) continue;
28366
28475
  if (row.resolution?.action === "conflict") conflicts.push(row.resolution.conflict);
28367
28476
  else if (row.resolution?.action === "merge") mergedPaths.push(row.path);
@@ -28371,16 +28480,27 @@ var PullPlanner = class {
28371
28480
  const items = [];
28372
28481
  const plannedPath = this.planner.plannedPaths(localPlan);
28373
28482
  for (const change of [...planned, ...restoreChanges]) {
28483
+ const relink = relinks.byPage.get(change.pageId);
28484
+ if (relink) {
28485
+ items.push({ path: relink.path, operation: "create", unchanged: true });
28486
+ continue;
28487
+ }
28374
28488
  const record = this.stateDb.getByNotionId(change.pageId);
28375
28489
  items.push({
28376
28490
  path: (record && plannedPath.get(record.id)) ?? record?.obsidianPath ?? change.title ?? change.pageId,
28377
28491
  operation: change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update"
28378
28492
  });
28379
28493
  }
28380
- for (const row of rows) items.push({ path: row.path, operation: row.operation });
28494
+ for (const row of rows) {
28495
+ items.push({
28496
+ path: row.path,
28497
+ operation: row.operation,
28498
+ ...row.relink ? { unchanged: true } : {}
28499
+ });
28500
+ }
28381
28501
  const movedRows = rows.filter((row) => row.moves !== void 0);
28382
28502
  items.forEach((item, index) => options.onProgress?.(index + 1, items.length, item));
28383
- const count = (type) => planned.filter((c) => c.type === type).length;
28503
+ const count = (type) => planned.filter((c) => c.type === type && !relinks.byPage.has(c.pageId)).length;
28384
28504
  const restoredModified = planned.filter(
28385
28505
  (c) => c.type === "modified" && restoreIds.has(c.pageId)
28386
28506
  ).length;
@@ -28391,7 +28511,14 @@ var PullPlanner = class {
28391
28511
  ].map((change) => this.stateDb.getByNotionId(change.pageId)?.obsidianPath).filter((path) => path !== void 0),
28392
28512
  ...rows.filter((row) => row.operation === "update" && row.restore).map((row) => row.path)
28393
28513
  ];
28394
- const rowCount = (operation, restore = false) => rows.filter((row) => row.operation === operation && (row.restore ?? false) === restore).length;
28514
+ const rowCount = (operation, restore = false) => rows.filter(
28515
+ (row) => row.operation === operation && (row.restore ?? false) === restore && !row.relink
28516
+ ).length;
28517
+ const undiscoveredDatabases = !isDatabaseMode(this.config) && (discoveryScanPending(this.stateDb) || planned.some((c) => c.type === "created"));
28518
+ const relinkedPaths = [
28519
+ ...[...relinks.byPage.values()].filter((relink) => relink.kind === "note").map((relink) => relink.path),
28520
+ ...rows.filter((row) => row.relink).map((row) => row.path)
28521
+ ];
28395
28522
  return {
28396
28523
  created: count("created") + rowCount("create"),
28397
28524
  updated: count("modified") - restoredModified + rowCount("update"),
@@ -28403,6 +28530,8 @@ var PullPlanner = class {
28403
28530
  ...movedRows.length > 0 ? { moved: movedRows.flatMap((row) => row.moves) } : {},
28404
28531
  conflicts,
28405
28532
  ...mergedPaths.length > 0 ? { mergedPaths } : {},
28533
+ ...relinkedPaths.length > 0 ? { relinkedPaths, relinkPending: true } : {},
28534
+ ...undiscoveredDatabases ? { undiscoveredDatabases: true } : {},
28406
28535
  writtenPaths: [],
28407
28536
  failed,
28408
28537
  duration: Date.now() - startTime,
@@ -28419,6 +28548,27 @@ var PullPlanner = class {
28419
28548
  }
28420
28549
  };
28421
28550
  }
28551
+ /**
28552
+ * 새 페이지 가운데 실제 pull 이 상태 기록에 없던 노트 · 폴더와 다시 이을 것({@link PagePuller.plannedRelink}) — 페이지 id →
28553
+ * 자리. `taken` 은 고른 자리라 행도 피한다.
28554
+ */
28555
+ async plannedRelinks(planned) {
28556
+ const byPage = /* @__PURE__ */ new Map();
28557
+ const taken = /* @__PURE__ */ new Set();
28558
+ for (const change of planned) {
28559
+ if (change.type !== "created") continue;
28560
+ try {
28561
+ const relink = await this.puller.plannedRelink(change.pageId, taken);
28562
+ if (relink) byPage.set(change.pageId, relink);
28563
+ } catch (error) {
28564
+ getLogger().debug(
28565
+ `[Im-Nobsidian] \uB2E4\uC2DC \uC774\uC744 \uC790\uB9AC\uB97C \uBBF8\uB9AC \uBCF4\uC9C0 \uBABB\uD568 (${change.pageId}):`,
28566
+ error
28567
+ );
28568
+ }
28569
+ }
28570
+ return { byPage, taken };
28571
+ }
28422
28572
  /**
28423
28573
  * 고친 페이지를 실제 pull 이 받는가 — 폴더로만 추적하는 페이지는 본문이 생겼을 때만 폴더 노트로 받는다. 하위 페이지 ·
28424
28574
  * DB 를 옮기기만 해도 그 페이지의 수정 시각이 바뀌어 고친 것으로 보였다. 확인하지 못하면 센다(실제 pull 이 받아 본다).
@@ -28467,7 +28617,7 @@ var PullPlanner = class {
28467
28617
  * (`DatabaseDiscovery.pullDiscoveredDatabases`)를 같은 규칙으로 고른다. 세지 못한 DB 는 이유와
28468
28618
  * 함께 `failed` 에 싣는다 — 세지 못한 것을 없다고 하지 않는다.
28469
28619
  */
28470
- async planDatabaseRows(paths, failed, ledger, movingInVault) {
28620
+ async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken) {
28471
28621
  const configured = this.config.notion.databases ?? [];
28472
28622
  const rows = [];
28473
28623
  const plan = async (dbConfig, resolveDbFolder2) => {
@@ -28481,7 +28631,8 @@ var PullPlanner = class {
28481
28631
  paths,
28482
28632
  resolveDbFolder: resolveDbFolder2,
28483
28633
  followPlacement: resolveDbFolder2 !== void 0,
28484
- movingInVault
28634
+ movingInVault,
28635
+ relinkTaken
28485
28636
  })
28486
28637
  );
28487
28638
  } catch (error) {
@@ -29518,7 +29669,7 @@ var SyncOrchestrator = class {
29518
29669
  * 적어 push 가 올린다 — dry-run 은 받지 않아 원격이 바뀐 그대로라, 원격을 보고 거절하지 않는다.
29519
29670
  */
29520
29671
  /**
29521
- * @param relinkedByPull 같은 sync 의 dry-run pull 이 다시 이을 것이라고 미리 봤다 — 실제 sync 는 받으며 먼저 잇고 올리므로
29672
+ * @param relinkedByPull 같은 sync 의 dry-run pull 이 받거나 다시 이을 것이 있다 — 실제 sync 는 받으며 먼저 잇고 올리므로
29522
29673
  * 상태 기록이 비었어도 막지 않는다({@link assertVaultLinked}).
29523
29674
  */
29524
29675
  async executePush(options, mergedByPull = /* @__PURE__ */ new Set(), relinkedByPull = false) {
@@ -29876,7 +30027,7 @@ var SyncOrchestrator = class {
29876
30027
  );
29877
30028
  }
29878
30029
  };
29879
- const relinkable = notesWithResolvableWikilinks(this.stateDb).filter(
30030
+ const relinkable = notesWithResolvableWikilinks(this.stateDb, pushedBodies).filter(
29880
30031
  (path) => inAnyPathScope(path, options?.paths) && !excludeSet?.has(path)
29881
30032
  );
29882
30033
  await runPool(relinkable, relink, {
@@ -30190,8 +30341,7 @@ var SyncOrchestrator = class {
30190
30341
  },
30191
30342
  moved: [...relocated.moved, ...plan.moved ?? []],
30192
30343
  moveSkipped: relocated.skipped,
30193
- excluded: [...exclusions.values()],
30194
- ...this.puller.unlinkedVaultAtStart && plan.created > 0 ? { relinkPending: true } : {}
30344
+ excluded: [...exclusions.values()]
30195
30345
  };
30196
30346
  }
30197
30347
  let pullTotal = workItems.length;
@@ -30403,15 +30553,16 @@ var SyncOrchestrator = class {
30403
30553
  const tagged = (direction) => onProgress && ((current, total, item) => onProgress(current, total, { ...item, direction }));
30404
30554
  const pullResult = await this.executePull({ ...options, onProgress: tagged("pull") });
30405
30555
  const conflicted = options?.dryRun ? pullResult.conflicts.map((conflict) => conflict.syncRecord.obsidianPath) : [];
30556
+ const relinked = options?.dryRun ? pullResult.relinkedPaths ?? [] : [];
30406
30557
  const pushResult = await this.executePush(
30407
30558
  {
30408
30559
  ...options,
30409
30560
  onProgress: tagged("push"),
30410
30561
  paths: options?.paths,
30411
- excludePaths: [...pullResult.writtenPaths, ...conflicted]
30562
+ excludePaths: [...pullResult.writtenPaths, ...conflicted, ...relinked]
30412
30563
  },
30413
30564
  new Set(options?.dryRun ? pullResult.mergedPaths ?? [] : []),
30414
- pullResult.relinkPending === true
30565
+ options?.dryRun === true && (pullResult.created > 0 || relinked.length > 0)
30415
30566
  );
30416
30567
  return {
30417
30568
  pull: pullResult,