@im-nobsidian/core 0.19.1 → 0.20.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.js CHANGED
@@ -13397,6 +13397,29 @@ var ImageHandler = class {
13397
13397
  get fetchFn() {
13398
13398
  return this.customFetch ?? globalThis.fetch;
13399
13399
  }
13400
+ /** {@link preview} 안에서 도는 중인 렌더 수 — 0 보다 크면 받은 미디어를 볼트에 쓰지 않고 상태 DB 에 적지 않는다. */
13401
+ previewDepth = 0;
13402
+ /**
13403
+ * 받기를 미리 본다(dry-run) — 미디어를 실제 pull 처럼 받아 이름(내용 해시)과 임베드를 정하되 볼트에 쓰지 않고 상태 DB 에
13404
+ * 적지 않는다. 받지 않고 렌더하면 미디어가 원격 주소로 남아, 받은 글을 볼트의 노트와 견줄 수 없다.
13405
+ */
13406
+ async preview(run) {
13407
+ this.previewDepth++;
13408
+ try {
13409
+ return await run();
13410
+ } finally {
13411
+ this.previewDepth--;
13412
+ }
13413
+ }
13414
+ get previewing() {
13415
+ return this.previewDepth > 0;
13416
+ }
13417
+ /** 받은 파일을 볼트에 쓴다 — 미리 보기에서는 쓰지 않는다({@link preview}). */
13418
+ async writeDownload(localPath, buffer) {
13419
+ if (this.previewing) return;
13420
+ await this.vaultFs.ensureFolder(this.attachmentFolder);
13421
+ await this.vaultFs.writeBinary(localPath, buffer);
13422
+ }
13400
13423
  /** 상한 로직은 첨부 경로와 공유한다 — 한쪽만 빠뜨리지 않도록(utils/download-fetch). */
13401
13424
  fetchForDownload(url) {
13402
13425
  return fetchForDownload(this.fetchFn, url, this.downloadTimeoutMs);
@@ -13460,8 +13483,7 @@ var ImageHandler = class {
13460
13483
  return null;
13461
13484
  }
13462
13485
  async persistImage(fetched) {
13463
- await this.vaultFs.ensureFolder(this.attachmentFolder);
13464
- await this.vaultFs.writeBinary(fetched.localPath, fetched.buffer);
13486
+ await this.writeDownload(fetched.localPath, fetched.buffer);
13465
13487
  return {
13466
13488
  originalUrl: fetched.originalUrl,
13467
13489
  localPath: fetched.localPath,
@@ -13530,7 +13552,7 @@ var ImageHandler = class {
13530
13552
  }
13531
13553
  if (localHash !== remoteHash) continue;
13532
13554
  const fileId = notionFileIdOf(span.url);
13533
- if (fileId && this.stateDb) {
13555
+ if (fileId && this.stateDb && !this.previewing) {
13534
13556
  rememberReceivedMedia(this.stateDb, pageId, [
13535
13557
  { fileId, target, localPath: candidate, fileHash: remoteHash }
13536
13558
  ]);
@@ -14001,7 +14023,7 @@ var ImageHandler = class {
14001
14023
  */
14002
14024
  rememberReceived(pageId, received) {
14003
14025
  const media = received.filter((item) => item !== null);
14004
- if (!pageId || !this.stateDb || media.length === 0) return;
14026
+ if (!pageId || !this.stateDb || media.length === 0 || this.previewing) return;
14005
14027
  try {
14006
14028
  rememberReceivedMedia(this.stateDb, pageId, media);
14007
14029
  } catch (error) {
@@ -14250,8 +14272,7 @@ var ImageHandler = class {
14250
14272
  name
14251
14273
  };
14252
14274
  }
14253
- await this.vaultFs.ensureFolder(this.attachmentFolder);
14254
- await this.vaultFs.writeBinary(localPath, buffer);
14275
+ await this.writeDownload(localPath, buffer);
14255
14276
  return { originalUrl: url, localPath, hash, size: buffer.length, fileHash, name };
14256
14277
  } catch (error) {
14257
14278
  lastError = error instanceof Error ? error : new Error(String(error));
@@ -15760,8 +15781,59 @@ async function findUntrackedNote(stateDb, vaultFs, dir, safeName, pageId, taken)
15760
15781
  }
15761
15782
  return null;
15762
15783
  }
15763
- function relinksWithoutContent(note, unlinkedVault) {
15764
- return note.existing === "own" || unlinkedVault;
15784
+ function relinkOutcome(existing, same, unlinkedVault, strategy) {
15785
+ if (same) return "relink";
15786
+ if (existing === "name" && !unlinkedVault) return null;
15787
+ const chosen = existing === "own" ? strategy : "manual";
15788
+ if (chosen === "local-first") return "relink";
15789
+ if (chosen === "remote-first") return "overwrite";
15790
+ return "conflict";
15791
+ }
15792
+ function relinkConflict(input) {
15793
+ return {
15794
+ syncRecord: input.record,
15795
+ localChange: {
15796
+ path: input.path,
15797
+ type: "modified",
15798
+ currentHash: computeHash(input.local),
15799
+ previousHash: input.record.contentHash
15800
+ },
15801
+ remoteChange: {
15802
+ pageId: input.pageId,
15803
+ type: "modified",
15804
+ lastEdited: input.lastEdited,
15805
+ previousEdited: null,
15806
+ path: input.path,
15807
+ title: input.title
15808
+ },
15809
+ baseContent: null,
15810
+ localContent: input.local,
15811
+ remoteContent: input.remote
15812
+ };
15813
+ }
15814
+ function plannedRelinkRecord(input) {
15815
+ const now = (/* @__PURE__ */ new Date()).toISOString();
15816
+ return {
15817
+ id: `planned:${input.pageId}`,
15818
+ obsidianPath: input.path,
15819
+ notionPageId: input.pageId,
15820
+ notionParentId: input.parentId,
15821
+ contentHash: computeHash(input.remote),
15822
+ notionLastEdited: input.lastEdited,
15823
+ notionLastEditedBy: null,
15824
+ notionSeenAt: null,
15825
+ notionBodyFingerprint: null,
15826
+ localLastModified: now,
15827
+ syncDirection: "both",
15828
+ fileType: input.fileType,
15829
+ status: "conflict",
15830
+ baseSnapshot: null,
15831
+ localMtime: null,
15832
+ localFileSize: null,
15833
+ version: 0,
15834
+ createdAt: now,
15835
+ updatedAt: now
15836
+ };
15765
15837
  }
15766
15838
 
15767
15839
  // src/conflict/merger.ts
@@ -15949,20 +16021,27 @@ var PENDING_LINK_RE = new RegExp(
15949
16021
  "g"
15950
16022
  );
15951
16023
  function sameAsPendingLinks(local, remote) {
15952
- if (!PENDING_LINK_RE.test(remote)) return false;
15953
- PENDING_LINK_RE.lastIndex = 0;
16024
+ const pending = new RegExp(`${NOTION_BLOCK_LINK_RE.source}|${PENDING_LINK_RE.source}`, "g");
16025
+ if (!pending.test(remote)) return false;
16026
+ pending.lastIndex = 0;
15954
16027
  const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15955
16028
  let pattern = "";
15956
16029
  let last = 0;
15957
- for (const match of remote.matchAll(PENDING_LINK_RE)) {
16030
+ for (const match of remote.matchAll(pending)) {
15958
16031
  pattern += escape(remote.slice(last, match.index));
15959
- const alias = match[3];
15960
- pattern += match[4] ? `"?\\[\\[[^\\]\\n]+\\]\\]"?` : alias ? `(?:\\[\\[(?:[^\\]\\n|]*/)?${escape(alias)}(?:\\\\?\\|[^\\]\\n]*)?\\]\\]|\\[\\[[^\\]\\n|]+\\\\?\\|${escape(alias)}\\]\\]|\\[${escape(alias)}\\]\\([^)\\n]+\\))` : `(?:\\[\\[[^\\]\\n]+\\]\\]|\\[[^\\]\\n]+\\]\\([^)\\n]+\\))`;
16032
+ const blockLabel = match[1];
16033
+ const alias = match[6];
16034
+ pattern += blockLabel ? pendingBlockLinkPattern(blockLabel, escape) : match[7] ? `"?\\[\\[[^\\]\\n]+\\]\\]"?` : alias ? `(?:\\[\\[(?:[^\\]\\n|]*/)?${escape(alias)}(?:\\\\?\\|[^\\]\\n]*)?\\]\\]|\\[\\[[^\\]\\n|]+\\\\?\\|${escape(alias)}\\]\\]|\\[${escape(alias)}\\]\\([^)\\n]+\\))` : `(?:\\[\\[[^\\]\\n]+\\]\\]|\\[[^\\]\\n]+\\]\\([^)\\n]+\\))`;
15961
16035
  last = match.index + match[0].length;
15962
16036
  }
15963
16037
  pattern += escape(remote.slice(last).replace(/\n+$/, ""));
15964
16038
  return new RegExp(`^${pattern}\\n*$`).test(local.replace(/\n+$/, ""));
15965
16039
  }
16040
+ function pendingBlockLinkPattern(label, escape) {
16041
+ const text = label.replace(/\\([>^])/g, "$1");
16042
+ const written = escape(text.split(" > ").join("#"));
16043
+ return `(?:\\[\\[[^\\]\\n|]*#[^\\]\\n|]*\\\\?\\|${escape(text)}\\]\\]|\\[\\[(?:[^\\]\\n|#]*/)?${written}\\]\\]|\\[\\[#${written}\\]\\]|\\[(?:${escape(text)}|${escape(label)})\\]\\([^)\\n]+\\))`;
16044
+ }
15966
16045
  function resolveNotionIdWikilinks(markdown, lookup) {
15967
16046
  let resolved = 0;
15968
16047
  const out = markdown.replace(
@@ -19989,13 +20068,15 @@ var DatabaseSyncer = class {
19989
20068
  page.id,
19990
20069
  (key) => relinkTaken.has(key) || claimed.has(key)
19991
20070
  );
19992
- if (untracked && relinksWithoutContent(untracked, this.unlinkedVault)) {
20071
+ const relink = untracked && await this.plannedRowRelink(page, dbConfig, untracked);
20072
+ if (untracked && relink) {
19993
20073
  relinkTaken.add(pathKey(untracked.path));
19994
20074
  planned.push({
19995
20075
  pageId: page.id,
19996
20076
  path: untracked.path,
19997
20077
  operation: "create",
19998
- relink: true
20078
+ relink: true,
20079
+ ...relink.conflict ? { relinkConflict: relink.conflict } : {}
19999
20080
  });
20000
20081
  continue;
20001
20082
  }
@@ -20826,16 +20907,20 @@ var DatabaseSyncer = class {
20826
20907
  return found2;
20827
20908
  }
20828
20909
  /**
20829
- * 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 페이지의 `relinkExistingNote` 와 같은 규칙. 글이 같으면 잇고, 다르면
20830
- * 이 행 id 를 단 이름은 충돌 전략을 따르고 · 자연 이름은 이은 노트가 없는 볼트에서만 충돌로 잇는다. 잇지 않으면 null.
20831
- * remote-first 는 `"write"` — 호출측이 받은 행으로 덮는다.
20910
+ * 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 받은 글과 견줘 {@link relinkOutcome} 대로. 잇지 않으면 null.
20911
+ * 원격으로 덮을 것은 `"write"` — 호출측이 받은 행으로 덮는다.
20832
20912
  */
20833
20913
  async relinkExistingRow(filePath, existing, local, finalContent, page, dbConfig, rendered, bodyFingerprint) {
20834
20914
  const same = sameNoteContent(local, finalContent) || sameAsPendingLinks(local, finalContent);
20835
- if (!same && existing === "name" && !this.unlinkedVault) return null;
20836
- const strategy = existing === "own" ? this.config.sync.conflictStrategy : "manual";
20837
- if (!same && strategy === "remote-first") return "write";
20838
- const conflicted = !same && strategy !== "local-first";
20915
+ const outcome = relinkOutcome(
20916
+ existing,
20917
+ same,
20918
+ this.unlinkedVault,
20919
+ this.config.sync.conflictStrategy
20920
+ );
20921
+ if (outcome === null) return null;
20922
+ if (outcome === "overwrite") return "write";
20923
+ const conflicted = outcome === "conflict";
20839
20924
  const stat2 = same ? await this.vaultFs.getFileStat(filePath) : null;
20840
20925
  this.stateDb.transaction(() => {
20841
20926
  this.stateDb.upsert({
@@ -20862,31 +20947,57 @@ var DatabaseSyncer = class {
20862
20947
  });
20863
20948
  });
20864
20949
  if (!conflicted) return { action: "unchanged", path: filePath, relinked: true };
20865
- const record = this.stateDb.getByPath(filePath);
20866
20950
  return {
20867
20951
  action: "conflict",
20868
20952
  path: filePath,
20869
20953
  relinked: true,
20870
- conflict: {
20871
- syncRecord: record,
20872
- localChange: {
20873
- path: filePath,
20874
- type: "modified",
20875
- currentHash: computeHash(local),
20876
- previousHash: record.contentHash
20877
- },
20878
- remoteChange: {
20954
+ conflict: relinkConflict({
20955
+ record: this.stateDb.getByPath(filePath),
20956
+ path: filePath,
20957
+ local,
20958
+ remote: finalContent,
20959
+ pageId: page.id,
20960
+ lastEdited: page.last_edited_time,
20961
+ title: rendered.title
20962
+ })
20963
+ };
20964
+ }
20965
+ /**
20966
+ * dry-run — 실제 pull 이 상태 기록에 없던 이 행 노트를 다시 잇나(P7). 받은 행을 실제 pull 처럼 렌더하되 볼트 · 상태 DB 는
20967
+ * 바꾸지 않고({@link ImageHandler.preview}) 견준다. 잇지 않으면 null(새 이름으로 받는다), 충돌로 남길 것이면 그 충돌.
20968
+ */
20969
+ async plannedRowRelink(page, dbConfig, untracked) {
20970
+ const local = await readLocalNote(this.vaultFs, untracked.path);
20971
+ if (local === void 0) return null;
20972
+ const rendered = await this.imageHandler.preview(
20973
+ () => this.renderRow(page, untracked.path, { localPath: untracked.path, adoptFrom: local })
20974
+ );
20975
+ const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
20976
+ const outcome = relinkOutcome(
20977
+ untracked.existing,
20978
+ same,
20979
+ this.unlinkedVault,
20980
+ this.config.sync.conflictStrategy
20981
+ );
20982
+ if (outcome === null) return null;
20983
+ if (outcome !== "conflict") return {};
20984
+ return {
20985
+ conflict: relinkConflict({
20986
+ record: plannedRelinkRecord({
20987
+ path: untracked.path,
20879
20988
  pageId: page.id,
20880
- type: "modified",
20989
+ parentId: dbConfig.databaseId,
20990
+ remote: rendered.content,
20881
20991
  lastEdited: page.last_edited_time,
20882
- previousEdited: null,
20883
- path: filePath,
20884
- title: rendered.title
20885
- },
20886
- baseContent: null,
20887
- localContent: local,
20888
- remoteContent: finalContent
20889
- }
20992
+ fileType: "db-row"
20993
+ }),
20994
+ path: untracked.path,
20995
+ local,
20996
+ remote: rendered.content,
20997
+ pageId: page.id,
20998
+ lastEdited: page.last_edited_time,
20999
+ title: rendered.title
21000
+ })
20890
21001
  };
20891
21002
  }
20892
21003
  /** 이번 받기에서 로컬 편집을 합친 행의 경로를 가져가고 비운다. */
@@ -23446,144 +23557,25 @@ var DatabaseDiscovery = class {
23446
23557
  let deleted = 0;
23447
23558
  let restored = 0;
23448
23559
  try {
23449
- const dbConfigs = parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY));
23450
- const configuredIds = new Set(
23451
- (this.config.notion.databases ?? []).map((d) => d.databaseId.replace(/-/g, ""))
23452
- );
23453
- const knownIds = /* @__PURE__ */ new Set([
23454
- ...configuredIds,
23455
- ...dbConfigs.map((c) => c.databaseId.replace(/-/g, ""))
23456
- ]);
23457
- let changed = false;
23458
- const registeredNow = /* @__PURE__ */ new Set();
23459
- const folderOwner = new Map(
23460
- (this.config.notion.databases ?? []).map((d) => [d.localFolder, d.databaseId])
23461
- );
23462
- const repairedCount = repairDbFolderCollisions(dbConfigs, folderOwner);
23463
- for (const c of dbConfigs) folderOwner.set(c.localFolder, c.databaseId);
23464
- if (repairedCount > 0) {
23465
- changed = true;
23466
- getLogger().info(`[Im-Nobsidian] \uB3D9\uBA85 DB \uD3F4\uB354 \uCDA9\uB3CC ${repairedCount}\uAC74 \uBD84\uB9AC(F24)`);
23467
- }
23468
- const folderKeys = new Map([...folderOwner].map(([folder, id]) => [pathKey(folder), id]));
23469
- const claimFolder = (config) => {
23470
- config.localFolder = resolveDbFolderPath(
23471
- config.localFolder,
23472
- config.databaseId,
23473
- (f) => folderOwner.get(f) ?? folderKeys.get(pathKey(f)) ?? null
23474
- );
23475
- folderOwner.set(config.localFolder, config.databaseId);
23476
- folderKeys.set(pathKey(config.localFolder), config.databaseId);
23477
- };
23478
- const inaccessibleIds = loadInaccessibleDbIds(this.stateDb);
23479
- let inaccessibleChanged = false;
23480
- const degrade = (rawId) => {
23481
- const nohyph = rawId.replace(/-/g, "");
23482
- if (!inaccessibleIds.has(nohyph)) {
23483
- inaccessibleIds.add(nohyph);
23484
- inaccessibleChanged = true;
23485
- }
23486
- getLogger().warn(
23487
- `[Im-Nobsidian] DB ${rawId}: \uC811\uADFC \uAC00\uB2A5\uD55C data source \uC5C6\uC74C(\uB9C1\uD06C\uB4DC/\uBBF8\uACF5\uC720/\uC0AD\uC81C \uCD94\uC815) \u2014 \uB3D9\uAE30\uD654 \uB300\uC0C1\uC5D0\uC11C \uC81C\uC678(\uC81C\uBAA9\uC740 \uBD80\uBAA8 \uD398\uC774\uC9C0\uC5D0 \uBCF4\uC874)`
23488
- );
23489
- };
23490
- if (forceRediscovery && inaccessibleIds.size > 0) {
23491
- getLogger().info(
23492
- `[Im-Nobsidian] --force: \uC811\uADFC \uBD88\uAC00 DB ${inaccessibleIds.size}\uAC1C denylist \uC7AC\uAC80\uC99D`
23493
- );
23494
- inaccessibleIds.clear();
23495
- inaccessibleChanged = true;
23496
- }
23497
- const linkedMap = loadLinkedDbMap(this.stateDb);
23498
- let linkedChanged = false;
23499
- const linkedNow = /* @__PURE__ */ new Set();
23500
- const retry = parseDiscoveryRetry(this.stateDb.getMeta(DISCOVERY_RETRY_META_KEY));
23501
- const nextRetry = { parents: [], dbs: [] };
23502
- const pathOf = (pageId) => notionIdsEqual(pageId, this.config.notion.rootPageId) ? "" : this.stateDb.getByNotionId(pageId)?.obsidianPath ?? pageId;
23503
- const unreadParent = (pageId, error) => {
23504
- nextRetry.parents.push(pageId);
23505
- failed.push({
23506
- path: pathOf(pageId),
23507
- operation: "update",
23508
- error: `\uD558\uC704 DB \uB97C \uD655\uC778\uD558\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uD655\uC778\uD55C\uB2E4: ${error}`
23509
- });
23510
- };
23511
- const retryLater = (dbId, parentPageId, error) => {
23512
- nextRetry.dbs.push({ dbId, parentPageId });
23513
- failed.push({
23514
- path: pathOf(parentPageId),
23515
- operation: "create",
23516
- error: `DB ${shortNotionId(dbId)} \uB97C \uC77D\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uBCF8\uB2E4: ${error}`
23517
- });
23518
- };
23519
- const register = async (dbId, parentPageId) => {
23520
- const nohyph = dbId.replace(/-/g, "");
23521
- if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) return;
23522
- const registerOriginal = async (origNohyph) => {
23523
- if (knownIds.has(origNohyph) || inaccessibleIds.has(origNohyph)) return;
23524
- const origId = normalizeNotionId(origNohyph);
23525
- const outcome2 = await this.buildDiscoveredDbConfig(origId, parentPageId);
23526
- knownIds.add(origNohyph);
23527
- if (outcome2.kind === "ok") {
23528
- claimFolder(outcome2.config);
23529
- dbConfigs.push(outcome2.config);
23530
- registeredNow.add(origNohyph);
23531
- changed = true;
23532
- } else if (outcome2.kind === "error") {
23533
- retryLater(origId, parentPageId, outcome2.error);
23534
- } else {
23535
- degrade(origId);
23536
- }
23537
- };
23538
- const knownOriginal = linkedMap.get(nohyph);
23539
- if (knownOriginal) {
23540
- await registerOriginal(knownOriginal);
23541
- return;
23542
- }
23543
- const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
23544
- knownIds.add(nohyph);
23545
- if (outcome.kind === "ok") {
23546
- claimFolder(outcome.config);
23547
- dbConfigs.push(outcome.config);
23548
- registeredNow.add(nohyph);
23549
- changed = true;
23550
- } else if (outcome.kind === "linked") {
23551
- const origNohyph = outcome.originalDbId.replace(/-/g, "");
23552
- linkedMap.set(nohyph, origNohyph);
23553
- linkedNow.add(nohyph);
23554
- linkedChanged = true;
23555
- await registerOriginal(origNohyph);
23556
- } else if (outcome.kind === "inaccessible") {
23557
- degrade(dbId);
23558
- } else if (outcome.kind === "error") {
23559
- retryLater(dbId, parentPageId, outcome.error);
23560
- }
23561
- };
23562
- const scanAll = forceRediscovery || discoveryScanPending(this.stateDb);
23563
- if (signal?.aborted) {
23564
- nextRetry.parents.push(...retry.parents);
23565
- nextRetry.dbs.push(...retry.dbs);
23566
- } else {
23567
- const root = this.config.notion.rootPageId;
23568
- const scanIds = scanAll ? [
23569
- root,
23570
- ...this.stateDb.getAll().filter((r) => r.notionPageId).map((r) => r.notionPageId)
23571
- ].filter((id) => !this._bodyReadPages.has(normalizeNotionId(id))) : options.rescanRoot && !retry.parents.some((id) => notionIdsEqual(id, root)) ? [root, ...retry.parents] : retry.parents;
23572
- if (scanIds.length > 0) {
23573
- const { found: found2, unread } = await this.discoverChildDatabases(scanIds);
23574
- for (const { dbId, parentPageId } of found2) {
23575
- await register(dbId, parentPageId);
23576
- }
23577
- for (const { pageId, error } of unread) unreadParent(pageId, error);
23578
- }
23579
- for (const { dbId, parentPageId } of retry.dbs) {
23580
- await register(dbId, parentPageId);
23581
- }
23582
- if (scanAll) this.stateDb.setMeta(DISCOVERY_SCANNED_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
23583
- }
23584
- for (const [nohyph, parentPageId] of this._inlineDbRefs) {
23585
- await register(normalizeNotionId(nohyph), parentPageId);
23586
- }
23560
+ const registry = await this.openRegistry(failed, {
23561
+ forceRediscovery,
23562
+ ...options.rescanRoot !== void 0 ? { rescanRoot: options.rescanRoot } : {},
23563
+ ...signal ? { signal } : {},
23564
+ markScanned: true
23565
+ });
23566
+ const {
23567
+ dbConfigs,
23568
+ configuredIds,
23569
+ registeredNow,
23570
+ inaccessibleIds,
23571
+ linkedMap,
23572
+ linkedNow,
23573
+ retry,
23574
+ nextRetry,
23575
+ dirty,
23576
+ register,
23577
+ degrade
23578
+ } = registry;
23587
23579
  const stillSyncable = [];
23588
23580
  const pulledWhole = /* @__PURE__ */ new Set();
23589
23581
  let queue = [...dbConfigs];
@@ -23621,14 +23613,14 @@ var DatabaseDiscovery = class {
23621
23613
  config.localFolder = move.to + config.localFolder.slice(move.from.length);
23622
23614
  }
23623
23615
  }
23624
- changed = true;
23616
+ dirty.list = true;
23625
23617
  }
23626
23618
  });
23627
23619
  if (dbResult.linkedOriginalDbId) {
23628
23620
  linkedMap.set(nohyph, dbResult.linkedOriginalDbId.replace(/-/g, ""));
23629
23621
  linkedNow.add(nohyph);
23630
- linkedChanged = true;
23631
- changed = true;
23622
+ dirty.linked = true;
23623
+ dirty.list = true;
23632
23624
  ledger?.settle(dbConfig.databaseId);
23633
23625
  getLogger().info(
23634
23626
  `[Im-Nobsidian] DB ${dbConfig.databaseId}: linked view \uCEE8\uD14C\uC774\uB108 \uAC10\uC9C0 \u2192 \uD589\uC740 \uC6D0\uBCF8 ${dbResult.linkedOriginalDbId} \uD3F4\uB354\uAC00 \uB2E8\uC77C \uC18C\uC720(F25)`
@@ -23683,18 +23675,18 @@ var DatabaseDiscovery = class {
23683
23675
  }
23684
23676
  }
23685
23677
  if (!signal?.aborted) {
23686
- linkedChanged = await this.pullLinkedViews(linkedMap, stillSyncable, {
23678
+ dirty.linked = await this.pullLinkedViews(linkedMap, stillSyncable, {
23687
23679
  pulled: (container, original) => forceRediscovery || linkedNow.has(container) || pulledWhole.has(original)
23688
- }) || linkedChanged;
23680
+ }) || dirty.linked;
23689
23681
  }
23690
23682
  ledger?.keepOnly([...configuredIds, ...stillSyncable.map((c) => c.databaseId)]);
23691
- if (changed || inaccessibleChanged || stillSyncable.length !== dbConfigs.length) {
23683
+ if (dirty.list || dirty.inaccessible || stillSyncable.length !== dbConfigs.length) {
23692
23684
  this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(stillSyncable));
23693
23685
  }
23694
- if (inaccessibleChanged) {
23686
+ if (dirty.inaccessible) {
23695
23687
  this.stateDb.setMeta(INACCESSIBLE_DBS_META_KEY, JSON.stringify([...inaccessibleIds]));
23696
23688
  }
23697
- if (linkedChanged) {
23689
+ if (dirty.linked) {
23698
23690
  this.stateDb.setMeta(LINKED_DBS_META_KEY, JSON.stringify(Object.fromEntries(linkedMap)));
23699
23691
  }
23700
23692
  if (retry.parents.length + retry.dbs.length + nextRetry.parents.length + nextRetry.dbs.length > 0) {
@@ -23711,6 +23703,196 @@ var DatabaseDiscovery = class {
23711
23703
  }
23712
23704
  return { created, updated, deleted, restored };
23713
23705
  }
23706
+ /**
23707
+ * 이번 pull 이 찾을 DB 를 등록부에 올린다 — 블록을 훑고({@link discoverChildDatabases}) 받은 본문의 `<database>` 를
23708
+ * 더한다. 상태 DB 에는 쓰지 않는다 — 등록부를 적는 것은 실제 pull 의 마감이고 dry-run 은 버린다. 다 훑은 표시만
23709
+ * `markScanned` 일 때 적는다.
23710
+ *
23711
+ * @param failed 읽지 못한 페이지 · DB — 이유와 함께 싣는다.
23712
+ * @param options.notePathOf 페이지 → 볼트 경로. 새 DB 폴더를 부모 노트 옆에 둔다 — dry-run 은 다시 이을 노트의 자리를 준다.
23713
+ */
23714
+ async openRegistry(failed, options) {
23715
+ const { forceRediscovery, signal } = options;
23716
+ const { notePathOf } = options;
23717
+ const dbConfigs = parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY));
23718
+ const configuredIds = new Set(
23719
+ (this.config.notion.databases ?? []).map((d) => d.databaseId.replace(/-/g, ""))
23720
+ );
23721
+ const knownIds = /* @__PURE__ */ new Set([
23722
+ ...configuredIds,
23723
+ ...dbConfigs.map((c) => c.databaseId.replace(/-/g, ""))
23724
+ ]);
23725
+ const dirty = { list: false, inaccessible: false, linked: false };
23726
+ const registeredNow = /* @__PURE__ */ new Set();
23727
+ const folderOwner = new Map(
23728
+ (this.config.notion.databases ?? []).map((d) => [d.localFolder, d.databaseId])
23729
+ );
23730
+ const repairedCount = repairDbFolderCollisions(dbConfigs, folderOwner);
23731
+ for (const c of dbConfigs) folderOwner.set(c.localFolder, c.databaseId);
23732
+ if (repairedCount > 0) {
23733
+ dirty.list = true;
23734
+ getLogger().info(`[Im-Nobsidian] \uB3D9\uBA85 DB \uD3F4\uB354 \uCDA9\uB3CC ${repairedCount}\uAC74 \uBD84\uB9AC(F24)`);
23735
+ }
23736
+ const folderKeys = new Map([...folderOwner].map(([folder, id]) => [pathKey(folder), id]));
23737
+ const claimFolder = (config) => {
23738
+ config.localFolder = resolveDbFolderPath(
23739
+ config.localFolder,
23740
+ config.databaseId,
23741
+ (f) => folderOwner.get(f) ?? folderKeys.get(pathKey(f)) ?? null
23742
+ );
23743
+ folderOwner.set(config.localFolder, config.databaseId);
23744
+ folderKeys.set(pathKey(config.localFolder), config.databaseId);
23745
+ };
23746
+ const inaccessibleIds = loadInaccessibleDbIds(this.stateDb);
23747
+ const degrade = (rawId) => {
23748
+ const nohyph = rawId.replace(/-/g, "");
23749
+ if (!inaccessibleIds.has(nohyph)) {
23750
+ inaccessibleIds.add(nohyph);
23751
+ dirty.inaccessible = true;
23752
+ }
23753
+ getLogger().warn(
23754
+ `[Im-Nobsidian] DB ${rawId}: \uC811\uADFC \uAC00\uB2A5\uD55C data source \uC5C6\uC74C(\uB9C1\uD06C\uB4DC/\uBBF8\uACF5\uC720/\uC0AD\uC81C \uCD94\uC815) \u2014 \uB3D9\uAE30\uD654 \uB300\uC0C1\uC5D0\uC11C \uC81C\uC678(\uC81C\uBAA9\uC740 \uBD80\uBAA8 \uD398\uC774\uC9C0\uC5D0 \uBCF4\uC874)`
23755
+ );
23756
+ };
23757
+ if (forceRediscovery && inaccessibleIds.size > 0) {
23758
+ getLogger().info(
23759
+ `[Im-Nobsidian] --force: \uC811\uADFC \uBD88\uAC00 DB ${inaccessibleIds.size}\uAC1C denylist \uC7AC\uAC80\uC99D`
23760
+ );
23761
+ inaccessibleIds.clear();
23762
+ dirty.inaccessible = true;
23763
+ }
23764
+ const linkedMap = loadLinkedDbMap(this.stateDb);
23765
+ const linkedNow = /* @__PURE__ */ new Set();
23766
+ const retry = parseDiscoveryRetry(this.stateDb.getMeta(DISCOVERY_RETRY_META_KEY));
23767
+ const nextRetry = { parents: [], dbs: [] };
23768
+ const pathOf = (pageId) => notionIdsEqual(pageId, this.config.notion.rootPageId) ? "" : this.stateDb.getByNotionId(pageId)?.obsidianPath ?? pageId;
23769
+ const unreadParent = (pageId, error) => {
23770
+ nextRetry.parents.push(pageId);
23771
+ failed.push({
23772
+ path: pathOf(pageId),
23773
+ operation: "update",
23774
+ error: `\uD558\uC704 DB \uB97C \uD655\uC778\uD558\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uD655\uC778\uD55C\uB2E4: ${error}`
23775
+ });
23776
+ };
23777
+ const retryLater = (dbId, parentPageId, error) => {
23778
+ nextRetry.dbs.push({ dbId, parentPageId });
23779
+ failed.push({
23780
+ path: pathOf(parentPageId),
23781
+ operation: "create",
23782
+ error: `DB ${shortNotionId(dbId)} \uB97C \uC77D\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uBCF8\uB2E4: ${error}`
23783
+ });
23784
+ };
23785
+ const register = async (dbId, parentPageId) => {
23786
+ const nohyph = dbId.replace(/-/g, "");
23787
+ if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) return;
23788
+ const registerOriginal = async (origNohyph) => {
23789
+ if (knownIds.has(origNohyph) || inaccessibleIds.has(origNohyph)) return;
23790
+ const origId = normalizeNotionId(origNohyph);
23791
+ const outcome2 = await this.buildDiscoveredDbConfig(origId, parentPageId, notePathOf);
23792
+ knownIds.add(origNohyph);
23793
+ if (outcome2.kind === "ok") {
23794
+ claimFolder(outcome2.config);
23795
+ dbConfigs.push(outcome2.config);
23796
+ registeredNow.add(origNohyph);
23797
+ dirty.list = true;
23798
+ } else if (outcome2.kind === "error") {
23799
+ retryLater(origId, parentPageId, outcome2.error);
23800
+ } else {
23801
+ degrade(origId);
23802
+ }
23803
+ };
23804
+ const knownOriginal = linkedMap.get(nohyph);
23805
+ if (knownOriginal) {
23806
+ await registerOriginal(knownOriginal);
23807
+ return;
23808
+ }
23809
+ const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId, notePathOf);
23810
+ knownIds.add(nohyph);
23811
+ if (outcome.kind === "ok") {
23812
+ claimFolder(outcome.config);
23813
+ dbConfigs.push(outcome.config);
23814
+ registeredNow.add(nohyph);
23815
+ dirty.list = true;
23816
+ } else if (outcome.kind === "linked") {
23817
+ const origNohyph = outcome.originalDbId.replace(/-/g, "");
23818
+ linkedMap.set(nohyph, origNohyph);
23819
+ linkedNow.add(nohyph);
23820
+ dirty.linked = true;
23821
+ await registerOriginal(origNohyph);
23822
+ } else if (outcome.kind === "inaccessible") {
23823
+ degrade(dbId);
23824
+ } else if (outcome.kind === "error") {
23825
+ retryLater(dbId, parentPageId, outcome.error);
23826
+ }
23827
+ };
23828
+ const scanAll = forceRediscovery || discoveryScanPending(this.stateDb);
23829
+ if (signal?.aborted) {
23830
+ nextRetry.parents.push(...retry.parents);
23831
+ nextRetry.dbs.push(...retry.dbs);
23832
+ } else {
23833
+ const root = this.config.notion.rootPageId;
23834
+ const scanIds = scanAll ? [
23835
+ root,
23836
+ ...this.stateDb.getAll().filter((r) => r.notionPageId).map((r) => r.notionPageId)
23837
+ ].filter((id) => !this._bodyReadPages.has(normalizeNotionId(id))) : options.rescanRoot && !retry.parents.some((id) => notionIdsEqual(id, root)) ? [root, ...retry.parents] : retry.parents;
23838
+ if (scanIds.length > 0) {
23839
+ const { found: found2, unread } = await this.discoverChildDatabases(scanIds);
23840
+ for (const { dbId, parentPageId } of found2) {
23841
+ await register(dbId, parentPageId);
23842
+ }
23843
+ for (const { pageId, error } of unread) unreadParent(pageId, error);
23844
+ }
23845
+ for (const { dbId, parentPageId } of retry.dbs) {
23846
+ await register(dbId, parentPageId);
23847
+ }
23848
+ if (scanAll && options.markScanned) {
23849
+ this.stateDb.setMeta(DISCOVERY_SCANNED_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
23850
+ }
23851
+ }
23852
+ for (const [nohyph, parentPageId] of this._inlineDbRefs) {
23853
+ await register(normalizeNotionId(nohyph), parentPageId);
23854
+ }
23855
+ return {
23856
+ dbConfigs,
23857
+ configuredIds,
23858
+ registeredNow,
23859
+ inaccessibleIds,
23860
+ linkedMap,
23861
+ linkedNow,
23862
+ retry,
23863
+ nextRetry,
23864
+ dirty,
23865
+ register,
23866
+ degrade
23867
+ };
23868
+ }
23869
+ /**
23870
+ * dry-run — 이번 pull 이 새로 찾을 DB(실제 pull 의 {@link openRegistry} 와 같은 규칙). 볼트 · 상태 DB 를 바꾸지 않는다. 받은
23871
+ * 본문은 dry-run 이 이미 읽은 것(다시 이을 노트 · 로컬도 고친 노트)만 본다 — {@link bodyRead}.
23872
+ *
23873
+ * @returns 새로 찾을 DB 와 그 폴더를 가를 전체 목록(발견해 둔 DB 포함).
23874
+ */
23875
+ async plannedNewDatabases(failed, options) {
23876
+ if (isDatabaseMode(this.config)) return { found: [], all: [] };
23877
+ const registry = await this.openRegistry(failed, {
23878
+ forceRediscovery: false,
23879
+ rescanRoot: options.rescanRoot,
23880
+ markScanned: false,
23881
+ notePathOf: options.notePathOf
23882
+ });
23883
+ return {
23884
+ found: registry.dbConfigs.filter(
23885
+ (config) => registry.registeredNow.has(config.databaseId.replace(/-/g, ""))
23886
+ ),
23887
+ all: registry.dbConfigs.filter(
23888
+ (config) => !registry.inaccessibleIds.has(config.databaseId.replace(/-/g, ""))
23889
+ )
23890
+ };
23891
+ }
23892
+ /** 이번 pull 에서 본문을 받은 페이지인가 — 그 본문의 `<database>` 로 새 DB 를 찾았다. */
23893
+ bodyRead(pageId) {
23894
+ return this._bodyReadPages.has(normalizeNotionId(pageId));
23895
+ }
23714
23896
  /**
23715
23897
  * 링크드 뷰 컨테이너마다 그 보기로 `.base` 를 쓴다({@link DatabaseSyncer.pullLinkedView}). 원본 DB 를 받은 pull ·
23716
23898
  * 이번에 해소한 컨테이너 · 아직 `.base` 가 없는 컨테이너만 — 보기만 고친 것은 DB 수정 시각을 바꾸지 않아 원본처럼
@@ -23763,7 +23945,7 @@ var DatabaseDiscovery = class {
23763
23945
  * (예: 부모 "AI Engineer (1).md" + DB "Minirecord Project" → "AI Engineer (1)/Minirecord-Project")
23764
23946
  * - 제목 취득 실패 시 부모 폴더명 기반 안전 이름으로 폴백
23765
23947
  */
23766
- async buildDiscoveredDbConfig(dbId, parentPageId) {
23948
+ async buildDiscoveredDbConfig(dbId, parentPageId, notePathOf = (pageId) => this.stateDb.getByNotionId(pageId)?.obsidianPath ?? null) {
23767
23949
  let dbTitle;
23768
23950
  try {
23769
23951
  const info = await this.notionClient.getDatabaseSyncability(dbId);
@@ -23789,10 +23971,9 @@ var DatabaseDiscovery = class {
23789
23971
  getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uC124\uC815 \uC0DD\uC131 \uC2E4\uD328:`, error);
23790
23972
  return { kind: "error", error: error instanceof Error ? error.message : String(error) };
23791
23973
  }
23792
- const parentEntry = parentPageId ? this.stateDb.getByNotionId(parentPageId) : null;
23974
+ const obsPath = parentPageId ? notePathOf(parentPageId) : null;
23793
23975
  let parentFolder = "";
23794
- if (parentEntry?.obsidianPath) {
23795
- const obsPath = parentEntry.obsidianPath;
23976
+ if (obsPath) {
23796
23977
  if (isMarkdownPath(obsPath)) {
23797
23978
  const parts = obsPath.split("/");
23798
23979
  const fileBase = stripMarkdownExtension(parts.pop() ?? "");
@@ -27454,34 +27635,80 @@ var PagePuller = class {
27454
27635
  );
27455
27636
  }
27456
27637
  /**
27457
- * dry-run — 실제 pull 이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7). 다시 이을 노트(`note`) ·
27458
- * 이미 있는 폴더(`folder` — 이은 노트가 없는 볼트에서 폴더로만 받으면 만들지 않는다), 아니면 null. 받기 전에는 폴더 노트가 될지
27459
- * 모른다 — 노트 자리 · 폴더 노트 자리를 차례로 본다. `taken` 은 이번 미리 보기가 이미 이은 자리({@link pathKey}) — 고르면
27460
- * 더한다. 볼트도 상태 DB 도 바꾸지 않는다.
27638
+ * dry-run — 실제 pull({@link createPulledPage})이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7).
27639
+ * 다시 이을 노트(`note` — 충돌로 남길 것이면 `conflict`) · 이미 있는 폴더(`folder` — 폴더로만 받는 페이지), 아니면 null.
27640
+ * 그 자리에 무엇이 있을 때만 본문을 받아 실제 pull 처럼 렌더해 견준다 — 미디어는 받되 볼트에 쓰지 않는다
27641
+ * ({@link ImageHandler.preview}). `taken` 은 이번 미리 보기가 이미 이은 자리({@link pathKey}) — 고르면 더한다. 볼트도 상태 DB 도
27642
+ * 바꾸지 않는다.
27461
27643
  */
27462
27644
  async plannedRelink(pageId, taken) {
27463
27645
  const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
27464
- const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
27646
+ const title = this.notionClient.extractTitle(page);
27647
+ const safeName = sanitizeFileName(title);
27465
27648
  const parentPath = await this.resolveParentPath(page);
27649
+ if (this.excludedCreatePath(parentPath, safeName) !== null) return null;
27466
27650
  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
- }
27651
+ const untrackedIn = (dir) => findUntrackedNote(this.stateDb, this.vaultFs, dir, safeName, pageId, (key) => taken.has(key));
27652
+ const folderExists = async () => !taken.has(pathKey(folder)) && this.stateDb.getByPath(folder) === null && await this.vaultFs.exists(folder);
27653
+ if (!await untrackedIn(parentPath) && !await untrackedIn(folder) && !await folderExists()) {
27654
+ return null;
27480
27655
  }
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" };
27656
+ const fetched = await this.fetchPageMarkdown(pageId);
27657
+ const hasChildPages = this.hasChildContainers(pageId, fetched.children);
27658
+ const folderOnly = hasChildPages && !fetched.hasBody || fetched.folderFiles !== null && (hasChildPages || fetched.folderFiles.named);
27659
+ if (folderOnly) {
27660
+ if (!await folderExists()) return null;
27661
+ taken.add(pathKey(folder));
27662
+ return { path: folder, kind: "folder" };
27663
+ }
27664
+ const found2 = await untrackedIn(hasChildPages ? folder : parentPath);
27665
+ if (!found2) return null;
27666
+ const local = await readLocalNote(this.vaultFs, found2.path);
27667
+ if (local === void 0) return null;
27668
+ const rendered = await this.imageHandler.preview(
27669
+ () => this.renderPageNote({
27670
+ pageId,
27671
+ page,
27672
+ fetched,
27673
+ filePath: found2.path,
27674
+ title,
27675
+ titleFollowsName: extractTitle(found2.path) === title,
27676
+ downloadMedia: true,
27677
+ local: { content: local, markers: [] },
27678
+ adoptLocalMedia: true
27679
+ })
27680
+ );
27681
+ const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
27682
+ const outcome = relinkOutcome(
27683
+ found2.existing,
27684
+ same,
27685
+ this.unlinkedVault,
27686
+ this.config.sync.conflictStrategy
27687
+ );
27688
+ if (outcome === null) return null;
27689
+ taken.add(pathKey(found2.path));
27690
+ if (outcome !== "conflict") return { path: found2.path, kind: "note" };
27691
+ const parentId = await extractParentId(this.notionClient, page);
27692
+ return {
27693
+ path: found2.path,
27694
+ kind: "note",
27695
+ conflict: relinkConflict({
27696
+ record: plannedRelinkRecord({
27697
+ path: found2.path,
27698
+ pageId,
27699
+ parentId,
27700
+ remote: rendered.content,
27701
+ lastEdited: page.last_edited_time,
27702
+ fileType: hasChildPages ? "folder-note" : "file"
27703
+ }),
27704
+ path: found2.path,
27705
+ local,
27706
+ remote: rendered.content,
27707
+ pageId,
27708
+ lastEdited: page.last_edited_time,
27709
+ title
27710
+ })
27711
+ };
27485
27712
  }
27486
27713
  /**
27487
27714
  * 새 페이지가 놓일 자리가 뺀 자리인가 — 부모 폴더 · 노트 이름 · 폴더 노트가 될 때의 폴더를 본다. 받기 전에는 폴더 노트가
@@ -27564,19 +27791,19 @@ var PagePuller = class {
27564
27791
  /**
27565
27792
  * 상태 기록에 없는 노트가 이 페이지가 놓일 자리에 이미 있다 — 상태 DB 를 잃었거나 처음 잇는 볼트다(P7).
27566
27793
  * 예전에는 남의 노트로 보고 id 를 붙인 이름으로 또 받아 모든 노트가 두 벌이 됐다(push 는 모든 페이지를 또 만들었다).
27567
- *
27568
- * - 글이 같으면(끝 개행만 달라도) 그 노트를 이 페이지와 다시 잇는다 — 쓰지 않는다.
27569
- * - 다르면 이 페이지 id 를 단 이름(지난번 이 페이지를 받은 이름)이면 충돌 전략을 따른다: remote-first 는 원격으로 덮고 ·
27570
- * local-first 는 로컬을 두어 다음 push 가 올리고 · 그 밖은 충돌로 남긴다(지난 사본이 없어 merge 도 합칠 수 없다).
27571
- * - 자연 이름에 있는 다른 글은 이은 노트가 하나도 없는 볼트에서만 잇고 전략과 상관없이 충돌로 남긴다 — 다른 노트일 수 있어
27572
- * 덮거나 올리면 잃는다. 이은 노트가 있는 볼트에서는 잇지 않는다(null) — 그 볼트의 새 노트일 수 있다.
27794
+ * 받은 글과 견줘 {@link relinkOutcome} 대로 잇는다 — 잇지 않으면 null(id 를 단 새 이름으로 받는다).
27573
27795
  */
27574
27796
  relinkExistingNote(filePath, existing, local, rendered, note) {
27575
27797
  const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
27576
- if (!same && existing === "name" && !this.unlinkedVault) return null;
27798
+ const outcome = relinkOutcome(
27799
+ existing,
27800
+ same,
27801
+ this.unlinkedVault,
27802
+ this.config.sync.conflictStrategy
27803
+ );
27804
+ if (outcome === null) return null;
27577
27805
  return (async () => {
27578
- const strategy = existing === "own" ? this.config.sync.conflictStrategy : "manual";
27579
- if (same || strategy === "local-first") {
27806
+ if (outcome === "relink") {
27580
27807
  await this.recordPulledNote(filePath, note, {
27581
27808
  content: rendered.content,
27582
27809
  properties: rendered.properties,
@@ -27587,7 +27814,7 @@ var PagePuller = class {
27587
27814
  });
27588
27815
  return { path: filePath, relinked: true };
27589
27816
  }
27590
- if (strategy === "remote-first") {
27817
+ if (outcome === "overwrite") {
27591
27818
  await this.vaultFs.writeFile(filePath, rendered.content);
27592
27819
  await this.recordPulledNote(filePath, note, {
27593
27820
  content: rendered.content,
@@ -27603,27 +27830,15 @@ var PagePuller = class {
27603
27830
  snapshot: null,
27604
27831
  differsOnDisk: true
27605
27832
  });
27606
- const record = this.stateDb.getByPath(filePath);
27607
- const conflict = {
27608
- syncRecord: record,
27609
- localChange: {
27610
- path: filePath,
27611
- type: "modified",
27612
- currentHash: computeHash(local),
27613
- previousHash: record.contentHash
27614
- },
27615
- remoteChange: {
27616
- pageId: note.pageId,
27617
- type: "modified",
27618
- lastEdited: note.page.last_edited_time,
27619
- previousEdited: null,
27620
- path: filePath,
27621
- title: note.title
27622
- },
27623
- baseContent: null,
27624
- localContent: local,
27625
- remoteContent: rendered.content
27626
- };
27833
+ const conflict = relinkConflict({
27834
+ record: this.stateDb.getByPath(filePath),
27835
+ path: filePath,
27836
+ local,
27837
+ remote: rendered.content,
27838
+ pageId: note.pageId,
27839
+ lastEdited: note.page.last_edited_time,
27840
+ title: note.title
27841
+ });
27627
27842
  return { path: filePath, relinked: true, conflict };
27628
27843
  })();
27629
27844
  }
@@ -28413,7 +28628,7 @@ function resolvesNow(stateDb, record, marker, pushed) {
28413
28628
 
28414
28629
  // src/sync/pull-planner.ts
28415
28630
  var PullPlanner = class {
28416
- constructor(config, stateDb, vaultFs, databaseSyncer, drift, planner, puller) {
28631
+ constructor(config, stateDb, vaultFs, databaseSyncer, drift, planner, puller, discovery, detector) {
28417
28632
  this.config = config;
28418
28633
  this.stateDb = stateDb;
28419
28634
  this.vaultFs = vaultFs;
@@ -28421,6 +28636,8 @@ var PullPlanner = class {
28421
28636
  this.drift = drift;
28422
28637
  this.planner = planner;
28423
28638
  this.puller = puller;
28639
+ this.discovery = discovery;
28640
+ this.detector = detector;
28424
28641
  }
28425
28642
  config;
28426
28643
  stateDb;
@@ -28429,11 +28646,13 @@ var PullPlanner = class {
28429
28646
  drift;
28430
28647
  planner;
28431
28648
  puller;
28649
+ discovery;
28650
+ detector;
28432
28651
  /**
28433
28652
  * pull 할 것을 세기만 한다(dry-run) — 볼트 · 상태 DB · Notion 을 바꾸지 않는다. 페이지와 DB 행을
28434
- * 같이 센다. DB 는 설정한 DB 와 이미 발견해 둔 DB 가운데 실제 pull 이 조회할 것(`ledger`)만 센다 —
28435
- * 이번 pull 이 새로 발견할 DB 는 받아 봐야 안다. 세지 못한 DB 는 이유와 함께 `failed` 에 싣는다
28436
- * (세지 못한 것을 없다고 하지 않는다).
28653
+ * 같이 센다. DB 는 설정한 DB 와 이미 발견해 둔 DB 가운데 실제 pull 이 조회할 것(`ledger`)과, 이번 pull 이 새로 찾을
28654
+ * DB(루트 · 미리 보며 읽은 본문 안)를 센다. 세지 못한 DB 는 이유와 함께 `failed` 에 싣는다(세지 못한 것을 없다고 하지
28655
+ * 않는다).
28437
28656
  */
28438
28657
  async planPull(filtered, restoreChanges, restoreIds, localPlan, options, startTime, scan, ledger) {
28439
28658
  const unchangedDropped = await this.drift.withoutUnchangedRemotes(filtered, options.signal);
@@ -28459,20 +28678,32 @@ var PullPlanner = class {
28459
28678
  planned.push(change);
28460
28679
  }
28461
28680
  const relinks = await this.plannedRelinks(planned);
28681
+ for (const relink of relinks.byPage.values()) {
28682
+ if (relink.conflict) conflicts.push(relink.conflict);
28683
+ }
28462
28684
  const queued = new Set(
28463
28685
  [...filtered, ...restoreChanges].map((change) => compactNotionId(change.pageId))
28464
28686
  );
28465
28687
  const movingInVault = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
28688
+ const relinkPaths = new Map(
28689
+ [...relinks.byPage].map(([pageId, relink]) => [compactNotionId(pageId), relink.path])
28690
+ );
28691
+ const newDatabases = await this.discovery.plannedNewDatabases(failed, {
28692
+ rescanRoot: this.detector.rootChanged(),
28693
+ notePathOf: (pageId) => relinkPaths.get(compactNotionId(pageId)) ?? this.stateDb.getByNotionId(pageId)?.obsidianPath ?? null
28694
+ });
28466
28695
  const rows = [];
28467
28696
  for (const row of await this.planDatabaseRows(
28468
28697
  options.paths,
28469
28698
  failed,
28470
28699
  ledger,
28471
28700
  movingInVault,
28472
- relinks.taken
28701
+ relinks.taken,
28702
+ newDatabases
28473
28703
  )) {
28474
28704
  if (queued.has(compactNotionId(row.pageId))) continue;
28475
28705
  if (row.resolution?.action === "conflict") conflicts.push(row.resolution.conflict);
28706
+ if (row.relinkConflict) conflicts.push(row.relinkConflict);
28476
28707
  else if (row.resolution?.action === "merge") mergedPaths.push(row.path);
28477
28708
  if (row.resolution?.action === "conflict" || row.resolution?.action === "skip") continue;
28478
28709
  rows.push(row);
@@ -28514,7 +28745,7 @@ var PullPlanner = class {
28514
28745
  const rowCount = (operation, restore = false) => rows.filter(
28515
28746
  (row) => row.operation === operation && (row.restore ?? false) === restore && !row.relink
28516
28747
  ).length;
28517
- const undiscoveredDatabases = !isDatabaseMode(this.config) && (discoveryScanPending(this.stateDb) || planned.some((c) => c.type === "created"));
28748
+ const undiscoveredDatabases = !isDatabaseMode(this.config) && planned.some((c) => c.type === "created" && !this.discovery.bodyRead(c.pageId));
28518
28749
  const relinkedPaths = [
28519
28750
  ...[...relinks.byPage.values()].filter((relink) => relink.kind === "note").map((relink) => relink.path),
28520
28751
  ...rows.filter((row) => row.relink).map((row) => row.path)
@@ -28617,11 +28848,12 @@ var PullPlanner = class {
28617
28848
  * (`DatabaseDiscovery.pullDiscoveredDatabases`)를 같은 규칙으로 고른다. 세지 못한 DB 는 이유와
28618
28849
  * 함께 `failed` 에 싣는다 — 세지 못한 것을 없다고 하지 않는다.
28619
28850
  */
28620
- async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken) {
28851
+ async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken, databases) {
28621
28852
  const configured = this.config.notion.databases ?? [];
28622
28853
  const rows = [];
28854
+ const found2 = new Set(databases.found);
28623
28855
  const plan = async (dbConfig, resolveDbFolder2) => {
28624
- if (!ledger.selects(dbConfig.databaseId)) {
28856
+ if (!found2.has(dbConfig) && !ledger.selects(dbConfig.databaseId)) {
28625
28857
  ledger.skip();
28626
28858
  return;
28627
28859
  }
@@ -28646,17 +28878,9 @@ var PullPlanner = class {
28646
28878
  };
28647
28879
  for (const dbConfig of configured) await plan(dbConfig);
28648
28880
  if (isDatabaseMode(this.config)) return rows;
28649
- const discovered = parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY));
28650
- repairDbFolderCollisions(
28651
- discovered,
28652
- new Map(configured.map((d) => [d.localFolder, d.databaseId]))
28653
- );
28654
- const inaccessible = loadInaccessibleDbIds(this.stateDb);
28881
+ const discovered = databases.all;
28655
28882
  const resolveDbFolder = (dbId) => [...discovered, ...configured].find((c) => notionIdsEqual(c.databaseId, dbId))?.localFolder ?? null;
28656
- for (const dbConfig of discovered) {
28657
- if (inaccessible.has(dbConfig.databaseId.replace(/-/g, ""))) continue;
28658
- await plan(dbConfig, resolveDbFolder);
28659
- }
28883
+ for (const dbConfig of discovered) await plan(dbConfig, resolveDbFolder);
28660
28884
  return rows;
28661
28885
  }
28662
28886
  };
@@ -29541,7 +29765,9 @@ var SyncOrchestrator = class {
29541
29765
  this.databaseSyncer,
29542
29766
  this.drift,
29543
29767
  this.planner,
29544
- this.puller
29768
+ this.puller,
29769
+ this.discovery,
29770
+ this.detector
29545
29771
  );
29546
29772
  this.pusher = new PagePusher(
29547
29773
  config,