@im-nobsidian/core 0.19.0 → 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(
@@ -16578,6 +16657,10 @@ function decodeId(id) {
16578
16657
  // src/sync/discovered-databases.ts
16579
16658
  var ROOT_DATABASES_FOLDER = "databases";
16580
16659
  var DISCOVERED_DBS_META_KEY = "discovered_dbs";
16660
+ var DISCOVERY_SCANNED_META_KEY = "discovery_scanned_at";
16661
+ function discoveryScanPending(stateDb) {
16662
+ return parseDiscoveredDbs(stateDb.getMeta(DISCOVERED_DBS_META_KEY)).length === 0 && !stateDb.getMeta(DISCOVERY_SCANNED_META_KEY);
16663
+ }
16581
16664
  var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
16582
16665
  var LINKED_DBS_META_KEY = "linked_dbs";
16583
16666
  function parseDiscoveredDbs(raw) {
@@ -19985,13 +20068,15 @@ var DatabaseSyncer = class {
19985
20068
  page.id,
19986
20069
  (key) => relinkTaken.has(key) || claimed.has(key)
19987
20070
  );
19988
- if (untracked && relinksWithoutContent(untracked, this.unlinkedVault)) {
20071
+ const relink = untracked && await this.plannedRowRelink(page, dbConfig, untracked);
20072
+ if (untracked && relink) {
19989
20073
  relinkTaken.add(pathKey(untracked.path));
19990
20074
  planned.push({
19991
20075
  pageId: page.id,
19992
20076
  path: untracked.path,
19993
20077
  operation: "create",
19994
- relink: true
20078
+ relink: true,
20079
+ ...relink.conflict ? { relinkConflict: relink.conflict } : {}
19995
20080
  });
19996
20081
  continue;
19997
20082
  }
@@ -20822,16 +20907,20 @@ var DatabaseSyncer = class {
20822
20907
  return found2;
20823
20908
  }
20824
20909
  /**
20825
- * 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 페이지의 `relinkExistingNote` 와 같은 규칙. 글이 같으면 잇고, 다르면
20826
- * 이 행 id 를 단 이름은 충돌 전략을 따르고 · 자연 이름은 이은 노트가 없는 볼트에서만 충돌로 잇는다. 잇지 않으면 null.
20827
- * remote-first 는 `"write"` — 호출측이 받은 행으로 덮는다.
20910
+ * 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 받은 글과 견줘 {@link relinkOutcome} 대로. 잇지 않으면 null.
20911
+ * 원격으로 덮을 것은 `"write"` — 호출측이 받은 행으로 덮는다.
20828
20912
  */
20829
20913
  async relinkExistingRow(filePath, existing, local, finalContent, page, dbConfig, rendered, bodyFingerprint) {
20830
20914
  const same = sameNoteContent(local, finalContent) || sameAsPendingLinks(local, finalContent);
20831
- if (!same && existing === "name" && !this.unlinkedVault) return null;
20832
- const strategy = existing === "own" ? this.config.sync.conflictStrategy : "manual";
20833
- if (!same && strategy === "remote-first") return "write";
20834
- 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";
20835
20924
  const stat2 = same ? await this.vaultFs.getFileStat(filePath) : null;
20836
20925
  this.stateDb.transaction(() => {
20837
20926
  this.stateDb.upsert({
@@ -20858,31 +20947,57 @@ var DatabaseSyncer = class {
20858
20947
  });
20859
20948
  });
20860
20949
  if (!conflicted) return { action: "unchanged", path: filePath, relinked: true };
20861
- const record = this.stateDb.getByPath(filePath);
20862
20950
  return {
20863
20951
  action: "conflict",
20864
20952
  path: filePath,
20865
20953
  relinked: true,
20866
- conflict: {
20867
- syncRecord: record,
20868
- localChange: {
20869
- path: filePath,
20870
- type: "modified",
20871
- currentHash: computeHash(local),
20872
- previousHash: record.contentHash
20873
- },
20874
- 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,
20875
20988
  pageId: page.id,
20876
- type: "modified",
20989
+ parentId: dbConfig.databaseId,
20990
+ remote: rendered.content,
20877
20991
  lastEdited: page.last_edited_time,
20878
- previousEdited: null,
20879
- path: filePath,
20880
- title: rendered.title
20881
- },
20882
- baseContent: null,
20883
- localContent: local,
20884
- remoteContent: finalContent
20885
- }
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
+ })
20886
21001
  };
20887
21002
  }
20888
21003
  /** 이번 받기에서 로컬 편집을 합친 행의 경로를 가져가고 비운다. */
@@ -23377,7 +23492,6 @@ function dropEmbeddedBaseMarkers(content, basePaths) {
23377
23492
 
23378
23493
  // src/sync/database-discovery.ts
23379
23494
  var DISCOVERY_RETRY_META_KEY = "discovery_retry";
23380
- var DISCOVERY_SCANNED_META_KEY = "discovery_scanned_at";
23381
23495
  function parseDiscoveryRetry(raw) {
23382
23496
  if (!raw) return { parents: [], dbs: [] };
23383
23497
  try {
@@ -23443,144 +23557,25 @@ var DatabaseDiscovery = class {
23443
23557
  let deleted = 0;
23444
23558
  let restored = 0;
23445
23559
  try {
23446
- const dbConfigs = parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY));
23447
- const configuredIds = new Set(
23448
- (this.config.notion.databases ?? []).map((d) => d.databaseId.replace(/-/g, ""))
23449
- );
23450
- const knownIds = /* @__PURE__ */ new Set([
23451
- ...configuredIds,
23452
- ...dbConfigs.map((c) => c.databaseId.replace(/-/g, ""))
23453
- ]);
23454
- let changed = false;
23455
- const registeredNow = /* @__PURE__ */ new Set();
23456
- const folderOwner = new Map(
23457
- (this.config.notion.databases ?? []).map((d) => [d.localFolder, d.databaseId])
23458
- );
23459
- const repairedCount = repairDbFolderCollisions(dbConfigs, folderOwner);
23460
- for (const c of dbConfigs) folderOwner.set(c.localFolder, c.databaseId);
23461
- if (repairedCount > 0) {
23462
- changed = true;
23463
- getLogger().info(`[Im-Nobsidian] \uB3D9\uBA85 DB \uD3F4\uB354 \uCDA9\uB3CC ${repairedCount}\uAC74 \uBD84\uB9AC(F24)`);
23464
- }
23465
- const folderKeys = new Map([...folderOwner].map(([folder, id]) => [pathKey(folder), id]));
23466
- const claimFolder = (config) => {
23467
- config.localFolder = resolveDbFolderPath(
23468
- config.localFolder,
23469
- config.databaseId,
23470
- (f) => folderOwner.get(f) ?? folderKeys.get(pathKey(f)) ?? null
23471
- );
23472
- folderOwner.set(config.localFolder, config.databaseId);
23473
- folderKeys.set(pathKey(config.localFolder), config.databaseId);
23474
- };
23475
- const inaccessibleIds = loadInaccessibleDbIds(this.stateDb);
23476
- let inaccessibleChanged = false;
23477
- const degrade = (rawId) => {
23478
- const nohyph = rawId.replace(/-/g, "");
23479
- if (!inaccessibleIds.has(nohyph)) {
23480
- inaccessibleIds.add(nohyph);
23481
- inaccessibleChanged = true;
23482
- }
23483
- getLogger().warn(
23484
- `[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)`
23485
- );
23486
- };
23487
- if (forceRediscovery && inaccessibleIds.size > 0) {
23488
- getLogger().info(
23489
- `[Im-Nobsidian] --force: \uC811\uADFC \uBD88\uAC00 DB ${inaccessibleIds.size}\uAC1C denylist \uC7AC\uAC80\uC99D`
23490
- );
23491
- inaccessibleIds.clear();
23492
- inaccessibleChanged = true;
23493
- }
23494
- const linkedMap = loadLinkedDbMap(this.stateDb);
23495
- let linkedChanged = false;
23496
- const linkedNow = /* @__PURE__ */ new Set();
23497
- const retry = parseDiscoveryRetry(this.stateDb.getMeta(DISCOVERY_RETRY_META_KEY));
23498
- const nextRetry = { parents: [], dbs: [] };
23499
- const pathOf = (pageId) => notionIdsEqual(pageId, this.config.notion.rootPageId) ? "" : this.stateDb.getByNotionId(pageId)?.obsidianPath ?? pageId;
23500
- const unreadParent = (pageId, error) => {
23501
- nextRetry.parents.push(pageId);
23502
- failed.push({
23503
- path: pathOf(pageId),
23504
- operation: "update",
23505
- error: `\uD558\uC704 DB \uB97C \uD655\uC778\uD558\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uD655\uC778\uD55C\uB2E4: ${error}`
23506
- });
23507
- };
23508
- const retryLater = (dbId, parentPageId, error) => {
23509
- nextRetry.dbs.push({ dbId, parentPageId });
23510
- failed.push({
23511
- path: pathOf(parentPageId),
23512
- operation: "create",
23513
- error: `DB ${shortNotionId(dbId)} \uB97C \uC77D\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uBCF8\uB2E4: ${error}`
23514
- });
23515
- };
23516
- const register = async (dbId, parentPageId) => {
23517
- const nohyph = dbId.replace(/-/g, "");
23518
- if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) return;
23519
- const registerOriginal = async (origNohyph) => {
23520
- if (knownIds.has(origNohyph) || inaccessibleIds.has(origNohyph)) return;
23521
- const origId = normalizeNotionId(origNohyph);
23522
- const outcome2 = await this.buildDiscoveredDbConfig(origId, parentPageId);
23523
- knownIds.add(origNohyph);
23524
- if (outcome2.kind === "ok") {
23525
- claimFolder(outcome2.config);
23526
- dbConfigs.push(outcome2.config);
23527
- registeredNow.add(origNohyph);
23528
- changed = true;
23529
- } else if (outcome2.kind === "error") {
23530
- retryLater(origId, parentPageId, outcome2.error);
23531
- } else {
23532
- degrade(origId);
23533
- }
23534
- };
23535
- const knownOriginal = linkedMap.get(nohyph);
23536
- if (knownOriginal) {
23537
- await registerOriginal(knownOriginal);
23538
- return;
23539
- }
23540
- const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
23541
- knownIds.add(nohyph);
23542
- if (outcome.kind === "ok") {
23543
- claimFolder(outcome.config);
23544
- dbConfigs.push(outcome.config);
23545
- registeredNow.add(nohyph);
23546
- changed = true;
23547
- } else if (outcome.kind === "linked") {
23548
- const origNohyph = outcome.originalDbId.replace(/-/g, "");
23549
- linkedMap.set(nohyph, origNohyph);
23550
- linkedNow.add(nohyph);
23551
- linkedChanged = true;
23552
- await registerOriginal(origNohyph);
23553
- } else if (outcome.kind === "inaccessible") {
23554
- degrade(dbId);
23555
- } else if (outcome.kind === "error") {
23556
- retryLater(dbId, parentPageId, outcome.error);
23557
- }
23558
- };
23559
- const scanAll = forceRediscovery || dbConfigs.length === 0 && !this.stateDb.getMeta(DISCOVERY_SCANNED_META_KEY);
23560
- if (signal?.aborted) {
23561
- nextRetry.parents.push(...retry.parents);
23562
- nextRetry.dbs.push(...retry.dbs);
23563
- } else {
23564
- const root = this.config.notion.rootPageId;
23565
- const scanIds = scanAll ? [
23566
- root,
23567
- ...this.stateDb.getAll().filter((r) => r.notionPageId).map((r) => r.notionPageId)
23568
- ].filter((id) => !this._bodyReadPages.has(normalizeNotionId(id))) : options.rescanRoot && !retry.parents.some((id) => notionIdsEqual(id, root)) ? [root, ...retry.parents] : retry.parents;
23569
- if (scanIds.length > 0) {
23570
- const { found: found2, unread } = await this.discoverChildDatabases(scanIds);
23571
- for (const { dbId, parentPageId } of found2) {
23572
- await register(dbId, parentPageId);
23573
- }
23574
- for (const { pageId, error } of unread) unreadParent(pageId, error);
23575
- }
23576
- for (const { dbId, parentPageId } of retry.dbs) {
23577
- await register(dbId, parentPageId);
23578
- }
23579
- if (scanAll) this.stateDb.setMeta(DISCOVERY_SCANNED_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
23580
- }
23581
- for (const [nohyph, parentPageId] of this._inlineDbRefs) {
23582
- await register(normalizeNotionId(nohyph), parentPageId);
23583
- }
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;
23584
23579
  const stillSyncable = [];
23585
23580
  const pulledWhole = /* @__PURE__ */ new Set();
23586
23581
  let queue = [...dbConfigs];
@@ -23618,14 +23613,14 @@ var DatabaseDiscovery = class {
23618
23613
  config.localFolder = move.to + config.localFolder.slice(move.from.length);
23619
23614
  }
23620
23615
  }
23621
- changed = true;
23616
+ dirty.list = true;
23622
23617
  }
23623
23618
  });
23624
23619
  if (dbResult.linkedOriginalDbId) {
23625
23620
  linkedMap.set(nohyph, dbResult.linkedOriginalDbId.replace(/-/g, ""));
23626
23621
  linkedNow.add(nohyph);
23627
- linkedChanged = true;
23628
- changed = true;
23622
+ dirty.linked = true;
23623
+ dirty.list = true;
23629
23624
  ledger?.settle(dbConfig.databaseId);
23630
23625
  getLogger().info(
23631
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)`
@@ -23680,18 +23675,18 @@ var DatabaseDiscovery = class {
23680
23675
  }
23681
23676
  }
23682
23677
  if (!signal?.aborted) {
23683
- linkedChanged = await this.pullLinkedViews(linkedMap, stillSyncable, {
23678
+ dirty.linked = await this.pullLinkedViews(linkedMap, stillSyncable, {
23684
23679
  pulled: (container, original) => forceRediscovery || linkedNow.has(container) || pulledWhole.has(original)
23685
- }) || linkedChanged;
23680
+ }) || dirty.linked;
23686
23681
  }
23687
23682
  ledger?.keepOnly([...configuredIds, ...stillSyncable.map((c) => c.databaseId)]);
23688
- if (changed || inaccessibleChanged || stillSyncable.length !== dbConfigs.length) {
23683
+ if (dirty.list || dirty.inaccessible || stillSyncable.length !== dbConfigs.length) {
23689
23684
  this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(stillSyncable));
23690
23685
  }
23691
- if (inaccessibleChanged) {
23686
+ if (dirty.inaccessible) {
23692
23687
  this.stateDb.setMeta(INACCESSIBLE_DBS_META_KEY, JSON.stringify([...inaccessibleIds]));
23693
23688
  }
23694
- if (linkedChanged) {
23689
+ if (dirty.linked) {
23695
23690
  this.stateDb.setMeta(LINKED_DBS_META_KEY, JSON.stringify(Object.fromEntries(linkedMap)));
23696
23691
  }
23697
23692
  if (retry.parents.length + retry.dbs.length + nextRetry.parents.length + nextRetry.dbs.length > 0) {
@@ -23708,6 +23703,196 @@ var DatabaseDiscovery = class {
23708
23703
  }
23709
23704
  return { created, updated, deleted, restored };
23710
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
+ }
23711
23896
  /**
23712
23897
  * 링크드 뷰 컨테이너마다 그 보기로 `.base` 를 쓴다({@link DatabaseSyncer.pullLinkedView}). 원본 DB 를 받은 pull ·
23713
23898
  * 이번에 해소한 컨테이너 · 아직 `.base` 가 없는 컨테이너만 — 보기만 고친 것은 DB 수정 시각을 바꾸지 않아 원본처럼
@@ -23760,7 +23945,7 @@ var DatabaseDiscovery = class {
23760
23945
  * (예: 부모 "AI Engineer (1).md" + DB "Minirecord Project" → "AI Engineer (1)/Minirecord-Project")
23761
23946
  * - 제목 취득 실패 시 부모 폴더명 기반 안전 이름으로 폴백
23762
23947
  */
23763
- async buildDiscoveredDbConfig(dbId, parentPageId) {
23948
+ async buildDiscoveredDbConfig(dbId, parentPageId, notePathOf = (pageId) => this.stateDb.getByNotionId(pageId)?.obsidianPath ?? null) {
23764
23949
  let dbTitle;
23765
23950
  try {
23766
23951
  const info = await this.notionClient.getDatabaseSyncability(dbId);
@@ -23786,10 +23971,9 @@ var DatabaseDiscovery = class {
23786
23971
  getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uC124\uC815 \uC0DD\uC131 \uC2E4\uD328:`, error);
23787
23972
  return { kind: "error", error: error instanceof Error ? error.message : String(error) };
23788
23973
  }
23789
- const parentEntry = parentPageId ? this.stateDb.getByNotionId(parentPageId) : null;
23974
+ const obsPath = parentPageId ? notePathOf(parentPageId) : null;
23790
23975
  let parentFolder = "";
23791
- if (parentEntry?.obsidianPath) {
23792
- const obsPath = parentEntry.obsidianPath;
23976
+ if (obsPath) {
23793
23977
  if (isMarkdownPath(obsPath)) {
23794
23978
  const parts = obsPath.split("/");
23795
23979
  const fileBase = stripMarkdownExtension(parts.pop() ?? "");
@@ -27451,34 +27635,80 @@ var PagePuller = class {
27451
27635
  );
27452
27636
  }
27453
27637
  /**
27454
- * dry-run — 실제 pull 이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7). 다시 이을 노트(`note`) ·
27455
- * 이미 있는 폴더(`folder` — 이은 노트가 없는 볼트에서 폴더로만 받으면 만들지 않는다), 아니면 null. 받기 전에는 폴더 노트가 될지
27456
- * 모른다 — 노트 자리 · 폴더 노트 자리를 차례로 본다. `taken` 은 이번 미리 보기가 이미 이은 자리({@link pathKey}) — 고르면
27457
- * 더한다. 볼트도 상태 DB 도 바꾸지 않는다.
27638
+ * dry-run — 실제 pull({@link createPulledPage})이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7).
27639
+ * 다시 이을 노트(`note` — 충돌로 남길 것이면 `conflict`) · 이미 있는 폴더(`folder` — 폴더로만 받는 페이지), 아니면 null.
27640
+ * 그 자리에 무엇이 있을 때만 본문을 받아 실제 pull 처럼 렌더해 견준다 — 미디어는 받되 볼트에 쓰지 않는다
27641
+ * ({@link ImageHandler.preview}). `taken` 은 이번 미리 보기가 이미 이은 자리({@link pathKey}) — 고르면 더한다. 볼트도 상태 DB 도
27642
+ * 바꾸지 않는다.
27458
27643
  */
27459
27644
  async plannedRelink(pageId, taken) {
27460
27645
  const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
27461
- const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
27646
+ const title = this.notionClient.extractTitle(page);
27647
+ const safeName = sanitizeFileName(title);
27462
27648
  const parentPath = await this.resolveParentPath(page);
27649
+ if (this.excludedCreatePath(parentPath, safeName) !== null) return null;
27463
27650
  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
- }
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;
27477
27655
  }
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" };
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
+ };
27482
27712
  }
27483
27713
  /**
27484
27714
  * 새 페이지가 놓일 자리가 뺀 자리인가 — 부모 폴더 · 노트 이름 · 폴더 노트가 될 때의 폴더를 본다. 받기 전에는 폴더 노트가
@@ -27561,19 +27791,19 @@ var PagePuller = class {
27561
27791
  /**
27562
27792
  * 상태 기록에 없는 노트가 이 페이지가 놓일 자리에 이미 있다 — 상태 DB 를 잃었거나 처음 잇는 볼트다(P7).
27563
27793
  * 예전에는 남의 노트로 보고 id 를 붙인 이름으로 또 받아 모든 노트가 두 벌이 됐다(push 는 모든 페이지를 또 만들었다).
27564
- *
27565
- * - 글이 같으면(끝 개행만 달라도) 그 노트를 이 페이지와 다시 잇는다 — 쓰지 않는다.
27566
- * - 다르면 이 페이지 id 를 단 이름(지난번 이 페이지를 받은 이름)이면 충돌 전략을 따른다: remote-first 는 원격으로 덮고 ·
27567
- * local-first 는 로컬을 두어 다음 push 가 올리고 · 그 밖은 충돌로 남긴다(지난 사본이 없어 merge 도 합칠 수 없다).
27568
- * - 자연 이름에 있는 다른 글은 이은 노트가 하나도 없는 볼트에서만 잇고 전략과 상관없이 충돌로 남긴다 — 다른 노트일 수 있어
27569
- * 덮거나 올리면 잃는다. 이은 노트가 있는 볼트에서는 잇지 않는다(null) — 그 볼트의 새 노트일 수 있다.
27794
+ * 받은 글과 견줘 {@link relinkOutcome} 대로 잇는다 — 잇지 않으면 null(id 를 단 새 이름으로 받는다).
27570
27795
  */
27571
27796
  relinkExistingNote(filePath, existing, local, rendered, note) {
27572
27797
  const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
27573
- 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;
27574
27805
  return (async () => {
27575
- const strategy = existing === "own" ? this.config.sync.conflictStrategy : "manual";
27576
- if (same || strategy === "local-first") {
27806
+ if (outcome === "relink") {
27577
27807
  await this.recordPulledNote(filePath, note, {
27578
27808
  content: rendered.content,
27579
27809
  properties: rendered.properties,
@@ -27584,7 +27814,7 @@ var PagePuller = class {
27584
27814
  });
27585
27815
  return { path: filePath, relinked: true };
27586
27816
  }
27587
- if (strategy === "remote-first") {
27817
+ if (outcome === "overwrite") {
27588
27818
  await this.vaultFs.writeFile(filePath, rendered.content);
27589
27819
  await this.recordPulledNote(filePath, note, {
27590
27820
  content: rendered.content,
@@ -27600,27 +27830,15 @@ var PagePuller = class {
27600
27830
  snapshot: null,
27601
27831
  differsOnDisk: true
27602
27832
  });
27603
- const record = this.stateDb.getByPath(filePath);
27604
- const conflict = {
27605
- syncRecord: record,
27606
- localChange: {
27607
- path: filePath,
27608
- type: "modified",
27609
- currentHash: computeHash(local),
27610
- previousHash: record.contentHash
27611
- },
27612
- remoteChange: {
27613
- pageId: note.pageId,
27614
- type: "modified",
27615
- lastEdited: note.page.last_edited_time,
27616
- previousEdited: null,
27617
- path: filePath,
27618
- title: note.title
27619
- },
27620
- baseContent: null,
27621
- localContent: local,
27622
- remoteContent: rendered.content
27623
- };
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
+ });
27624
27842
  return { path: filePath, relinked: true, conflict };
27625
27843
  })();
27626
27844
  }
@@ -28410,7 +28628,7 @@ function resolvesNow(stateDb, record, marker, pushed) {
28410
28628
 
28411
28629
  // src/sync/pull-planner.ts
28412
28630
  var PullPlanner = class {
28413
- constructor(config, stateDb, vaultFs, databaseSyncer, drift, planner, puller) {
28631
+ constructor(config, stateDb, vaultFs, databaseSyncer, drift, planner, puller, discovery, detector) {
28414
28632
  this.config = config;
28415
28633
  this.stateDb = stateDb;
28416
28634
  this.vaultFs = vaultFs;
@@ -28418,6 +28636,8 @@ var PullPlanner = class {
28418
28636
  this.drift = drift;
28419
28637
  this.planner = planner;
28420
28638
  this.puller = puller;
28639
+ this.discovery = discovery;
28640
+ this.detector = detector;
28421
28641
  }
28422
28642
  config;
28423
28643
  stateDb;
@@ -28426,11 +28646,13 @@ var PullPlanner = class {
28426
28646
  drift;
28427
28647
  planner;
28428
28648
  puller;
28649
+ discovery;
28650
+ detector;
28429
28651
  /**
28430
28652
  * pull 할 것을 세기만 한다(dry-run) — 볼트 · 상태 DB · Notion 을 바꾸지 않는다. 페이지와 DB 행을
28431
- * 같이 센다. DB 는 설정한 DB 와 이미 발견해 둔 DB 가운데 실제 pull 이 조회할 것(`ledger`)만 센다 —
28432
- * 이번 pull 이 새로 발견할 DB 는 받아 봐야 안다. 세지 못한 DB 는 이유와 함께 `failed` 에 싣는다
28433
- * (세지 못한 것을 없다고 하지 않는다).
28653
+ * 같이 센다. DB 는 설정한 DB 와 이미 발견해 둔 DB 가운데 실제 pull 이 조회할 것(`ledger`)과, 이번 pull 이 새로 찾을
28654
+ * DB(루트 · 미리 보며 읽은 본문 안)를 센다. 세지 못한 DB 는 이유와 함께 `failed` 에 싣는다(세지 못한 것을 없다고 하지
28655
+ * 않는다).
28434
28656
  */
28435
28657
  async planPull(filtered, restoreChanges, restoreIds, localPlan, options, startTime, scan, ledger) {
28436
28658
  const unchangedDropped = await this.drift.withoutUnchangedRemotes(filtered, options.signal);
@@ -28456,20 +28678,32 @@ var PullPlanner = class {
28456
28678
  planned.push(change);
28457
28679
  }
28458
28680
  const relinks = await this.plannedRelinks(planned);
28681
+ for (const relink of relinks.byPage.values()) {
28682
+ if (relink.conflict) conflicts.push(relink.conflict);
28683
+ }
28459
28684
  const queued = new Set(
28460
28685
  [...filtered, ...restoreChanges].map((change) => compactNotionId(change.pageId))
28461
28686
  );
28462
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
+ });
28463
28695
  const rows = [];
28464
28696
  for (const row of await this.planDatabaseRows(
28465
28697
  options.paths,
28466
28698
  failed,
28467
28699
  ledger,
28468
28700
  movingInVault,
28469
- relinks.taken
28701
+ relinks.taken,
28702
+ newDatabases
28470
28703
  )) {
28471
28704
  if (queued.has(compactNotionId(row.pageId))) continue;
28472
28705
  if (row.resolution?.action === "conflict") conflicts.push(row.resolution.conflict);
28706
+ if (row.relinkConflict) conflicts.push(row.relinkConflict);
28473
28707
  else if (row.resolution?.action === "merge") mergedPaths.push(row.path);
28474
28708
  if (row.resolution?.action === "conflict" || row.resolution?.action === "skip") continue;
28475
28709
  rows.push(row);
@@ -28511,6 +28745,7 @@ var PullPlanner = class {
28511
28745
  const rowCount = (operation, restore = false) => rows.filter(
28512
28746
  (row) => row.operation === operation && (row.restore ?? false) === restore && !row.relink
28513
28747
  ).length;
28748
+ const undiscoveredDatabases = !isDatabaseMode(this.config) && planned.some((c) => c.type === "created" && !this.discovery.bodyRead(c.pageId));
28514
28749
  const relinkedPaths = [
28515
28750
  ...[...relinks.byPage.values()].filter((relink) => relink.kind === "note").map((relink) => relink.path),
28516
28751
  ...rows.filter((row) => row.relink).map((row) => row.path)
@@ -28527,6 +28762,7 @@ var PullPlanner = class {
28527
28762
  conflicts,
28528
28763
  ...mergedPaths.length > 0 ? { mergedPaths } : {},
28529
28764
  ...relinkedPaths.length > 0 ? { relinkedPaths, relinkPending: true } : {},
28765
+ ...undiscoveredDatabases ? { undiscoveredDatabases: true } : {},
28530
28766
  writtenPaths: [],
28531
28767
  failed,
28532
28768
  duration: Date.now() - startTime,
@@ -28612,11 +28848,12 @@ var PullPlanner = class {
28612
28848
  * (`DatabaseDiscovery.pullDiscoveredDatabases`)를 같은 규칙으로 고른다. 세지 못한 DB 는 이유와
28613
28849
  * 함께 `failed` 에 싣는다 — 세지 못한 것을 없다고 하지 않는다.
28614
28850
  */
28615
- async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken) {
28851
+ async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken, databases) {
28616
28852
  const configured = this.config.notion.databases ?? [];
28617
28853
  const rows = [];
28854
+ const found2 = new Set(databases.found);
28618
28855
  const plan = async (dbConfig, resolveDbFolder2) => {
28619
- if (!ledger.selects(dbConfig.databaseId)) {
28856
+ if (!found2.has(dbConfig) && !ledger.selects(dbConfig.databaseId)) {
28620
28857
  ledger.skip();
28621
28858
  return;
28622
28859
  }
@@ -28641,17 +28878,9 @@ var PullPlanner = class {
28641
28878
  };
28642
28879
  for (const dbConfig of configured) await plan(dbConfig);
28643
28880
  if (isDatabaseMode(this.config)) return rows;
28644
- const discovered = parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY));
28645
- repairDbFolderCollisions(
28646
- discovered,
28647
- new Map(configured.map((d) => [d.localFolder, d.databaseId]))
28648
- );
28649
- const inaccessible = loadInaccessibleDbIds(this.stateDb);
28881
+ const discovered = databases.all;
28650
28882
  const resolveDbFolder = (dbId) => [...discovered, ...configured].find((c) => notionIdsEqual(c.databaseId, dbId))?.localFolder ?? null;
28651
- for (const dbConfig of discovered) {
28652
- if (inaccessible.has(dbConfig.databaseId.replace(/-/g, ""))) continue;
28653
- await plan(dbConfig, resolveDbFolder);
28654
- }
28883
+ for (const dbConfig of discovered) await plan(dbConfig, resolveDbFolder);
28655
28884
  return rows;
28656
28885
  }
28657
28886
  };
@@ -29536,7 +29765,9 @@ var SyncOrchestrator = class {
29536
29765
  this.databaseSyncer,
29537
29766
  this.drift,
29538
29767
  this.planner,
29539
- this.puller
29768
+ this.puller,
29769
+ this.discovery,
29770
+ this.detector
29540
29771
  );
29541
29772
  this.pusher = new PagePusher(
29542
29773
  config,