@im-nobsidian/core 0.19.1 → 0.21.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));
@@ -15163,12 +15184,56 @@ function folderFilesOf(markdown, namedOnly) {
15163
15184
  for (const span of notionMediaSpans(rest)) {
15164
15185
  if (!/^https?:\/\//.test(span.url)) return null;
15165
15186
  const name = uploadedFileNameFromUrl(span.url);
15166
- if (!name || namedOnly && unescapeCaption(span.label) !== name) return null;
15187
+ if (!name) return null;
15188
+ if (namedOnly && unescapeCaption(span.label) !== name && !isMarkedCaption(span.label, name)) {
15189
+ return null;
15190
+ }
15167
15191
  rest = rest.replace(span.full, "");
15168
15192
  files.push({ url: span.url, name });
15169
15193
  }
15170
15194
  return files.length > 0 && rest.trim() === "" ? files : null;
15171
15195
  }
15196
+ var FOLDER_FILE_MARK = "\u{1F4C1}";
15197
+ function folderFileCaption(name) {
15198
+ return `${FOLDER_FILE_MARK} ${name}`;
15199
+ }
15200
+ function isMarkedCaption(label, name) {
15201
+ return unescapeCaption(label) === folderFileCaption(name);
15202
+ }
15203
+ var FENCE_RE = /^(`{3,}|~{3,})/;
15204
+ function folderFilesInNote(markdown, options = {}) {
15205
+ const all = markdown.split("\n");
15206
+ const drop = /* @__PURE__ */ new Set();
15207
+ const files = [];
15208
+ const lines = [];
15209
+ let fence = null;
15210
+ all.forEach((line, i) => {
15211
+ const opener = FENCE_RE.exec(line)?.[1];
15212
+ if (opener !== void 0 && (fence === null || opener.startsWith(fence))) {
15213
+ fence = fence === null ? opener : null;
15214
+ return;
15215
+ }
15216
+ if (fence !== null) return;
15217
+ const full = line.trimEnd();
15218
+ const [span, ...more] = notionMediaSpans(full);
15219
+ if (!span || more.length > 0 || span.full !== full || !/^https?:\/\//.test(span.url)) return;
15220
+ const name = uploadedFileNameFromUrl(span.url);
15221
+ if (!name) return;
15222
+ const fileId = notionFileIdOf(span.url);
15223
+ const marked = isMarkedCaption(span.label, name) || fileId !== null && options.knownIds?.has(fileId) === true;
15224
+ const file = { url: span.url, name };
15225
+ if (!marked || options.exclude?.(file)) return;
15226
+ drop.add(i);
15227
+ files.push(file);
15228
+ lines.push(full);
15229
+ });
15230
+ if (files.length === 0) return null;
15231
+ return {
15232
+ markdown: all.filter((_, i) => !drop.has(i)).join("\n"),
15233
+ files,
15234
+ lines
15235
+ };
15236
+ }
15172
15237
  var WIKI_EMBED_RE = /!\[\[([^\]|#^\n]+)/g;
15173
15238
  var MD_EMBED_RE = /!\[[^\]\n]*\]\(<?([^)>\s]+)/g;
15174
15239
  var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
@@ -15196,8 +15261,8 @@ function embeddedNamesOf(notes) {
15196
15261
  function keyOf3(pageId) {
15197
15262
  return `folder_files:${compactNotionId(pageId)}`;
15198
15263
  }
15199
- function folderFileIdsOf(stateDb, pageId) {
15200
- const raw = stateDb.getMeta(keyOf3(pageId));
15264
+ function readPathMap(stateDb, key) {
15265
+ const raw = stateDb.getMeta(key);
15201
15266
  if (!raw) return /* @__PURE__ */ new Map();
15202
15267
  try {
15203
15268
  return new Map(Object.entries(JSON.parse(raw)));
@@ -15205,16 +15270,39 @@ function folderFileIdsOf(stateDb, pageId) {
15205
15270
  return /* @__PURE__ */ new Map();
15206
15271
  }
15207
15272
  }
15273
+ function writePathMap(stateDb, key, map) {
15274
+ stateDb.setMeta(key, JSON.stringify(Object.fromEntries(map)));
15275
+ }
15276
+ function folderFileIdsOf(stateDb, pageId) {
15277
+ return readPathMap(stateDb, keyOf3(pageId));
15278
+ }
15208
15279
  function forgetFolderFileId(stateDb, pageId, localPath) {
15280
+ rememberFolderFileStamp(stateDb, pageId, localPath, null);
15209
15281
  const ids = folderFileIdsOf(stateDb, pageId);
15210
15282
  if (!ids.delete(localPath)) return;
15211
- stateDb.setMeta(keyOf3(pageId), JSON.stringify(Object.fromEntries(ids)));
15283
+ writePathMap(stateDb, keyOf3(pageId), ids);
15212
15284
  }
15213
15285
  function rememberFolderFileId(stateDb, pageId, localPath, fileId) {
15214
15286
  const ids = folderFileIdsOf(stateDb, pageId);
15215
15287
  if (ids.get(localPath) === fileId) return;
15216
15288
  ids.set(localPath, fileId);
15217
- stateDb.setMeta(keyOf3(pageId), JSON.stringify(Object.fromEntries(ids)));
15289
+ writePathMap(stateDb, keyOf3(pageId), ids);
15290
+ }
15291
+ function fileStampOf(file) {
15292
+ return `${file.size}:${file.mtime}`;
15293
+ }
15294
+ function stampKeyOf(pageId) {
15295
+ return `folder_file_stamps:${compactNotionId(pageId)}`;
15296
+ }
15297
+ function folderFileStampsOf(stateDb, pageId) {
15298
+ return readPathMap(stateDb, stampKeyOf(pageId));
15299
+ }
15300
+ function rememberFolderFileStamp(stateDb, pageId, localPath, stamp) {
15301
+ const stamps = folderFileStampsOf(stateDb, pageId);
15302
+ if ((stamps.get(localPath) ?? null) === stamp) return;
15303
+ if (stamp === null) stamps.delete(localPath);
15304
+ else stamps.set(localPath, stamp);
15305
+ writePathMap(stateDb, stampKeyOf(pageId), stamps);
15218
15306
  }
15219
15307
 
15220
15308
  // src/sync/file-handler.ts
@@ -15225,6 +15313,12 @@ function getFolderPath(filePath) {
15225
15313
  const parts = filePath.split("/");
15226
15314
  return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
15227
15315
  }
15316
+ function filesFolderOfNote(record) {
15317
+ if (record.fileType === "folder-note" && isFolderNotePath(record.obsidianPath)) {
15318
+ return getFolderPath(record.obsidianPath);
15319
+ }
15320
+ return record.fileType === "file" ? stripMarkdownExtension(record.obsidianPath) : null;
15321
+ }
15228
15322
  var LOGGED_NAMES = 5;
15229
15323
  var FileHandler = class {
15230
15324
  constructor(vaultFs, notionClient, stateDb, concurrency = DEFAULT_CONFIG.advanced.fileConcurrency, options) {
@@ -15304,21 +15398,55 @@ var FileHandler = class {
15304
15398
  this.sema.release();
15305
15399
  }
15306
15400
  }
15307
- /** 올린 뒤 바뀌지 않았나 — 크기가 같으면 읽지 않는다. */
15401
+ /**
15402
+ * 올리거나 받은 뒤 바뀌지 않았나 — 그때의 표(크기 · 수정 시각, {@link fileStampOf})와 같으면 읽지 않는다. 크기가 다르면 바뀌었다.
15403
+ * 크기만 같으면 내용으로 견주고, 같으면 지금 표를 적어 다음에는 읽지 않는다. 예전에는 크기가 같으면 바뀌지 않았다고 봐, 같은
15404
+ * 크기로 고친 파일을 올리지 않았다.
15405
+ */
15308
15406
  async isUnchanged(existing, file) {
15309
- if (existing.fileSize === file.size) return true;
15407
+ const stamp = fileStampOf(file);
15408
+ if (folderFileStampsOf(this.stateDb, existing.notionPageId).get(file.path) === stamp) {
15409
+ return true;
15410
+ }
15411
+ if (existing.fileSize !== file.size) return false;
15310
15412
  const buffer = await this.vaultFs.readBinary(file.path);
15311
- return existing.fileHash === createHash("sha256").update(buffer).digest("hex");
15413
+ const same = existing.fileHash === createHash("sha256").update(buffer).digest("hex");
15414
+ if (same) rememberFolderFileStamp(this.stateDb, existing.notionPageId, file.path, stamp);
15415
+ return same;
15312
15416
  }
15313
15417
  /** pull 이 받은 것을 두는 폴더 안인가. */
15314
15418
  inAttachmentsFolder(path) {
15315
15419
  return this.attachmentsFolder !== "" && path.startsWith(`${this.attachmentsFolder}/`);
15316
15420
  }
15317
- /** 폴더 첨부를 둘 Notion 페이지 — 폴더 노트 없는 폴더의 페이지(폴더 레코드)만. 맨 위는 루트 기록이 있을 때만. */
15421
+ /**
15422
+ * 폴더 첨부를 둘 Notion 페이지 — 그 폴더의 Notion 자리({@link folderContainer} 와 같은 차례). 폴더 노트(`X/X.md`)나 노트
15423
+ * 이름의 폴더(`X.md` 옆 `X/`)면 그 노트의 페이지 끝(ADR-056), 아니면 push 가 만든 폴더 페이지. 맨 위는 루트 기록이 있을 때만.
15424
+ * 행 · DB 폴더는 둘 곳이 없다 — 행의 본문은 행의 것이다.
15425
+ */
15318
15426
  folderPageOf(folder) {
15319
- const record = this.stateDb.getByPath(folder || "__root__");
15320
- if (!record?.notionPageId) return null;
15321
- return folder === "" || isFolderRecord(record) ? record.notionPageId : null;
15427
+ if (folder === "") return this.stateDb.getByPath("__root__")?.notionPageId ?? null;
15428
+ return this.notePageOf(folder) ?? this.folderRecordPageOf(folder);
15429
+ }
15430
+ /** 이 폴더를 품은 노트의 페이지 — 폴더 노트 · 노트 이름의 폴더. 없거나 행이면 null. */
15431
+ notePageOf(folder) {
15432
+ const note = this.stateDb.getByPath(folderNoteOf(folder));
15433
+ if (note?.notionPageId && note.fileType === "folder-note") return note.notionPageId;
15434
+ const named = this.stateDb.getByPath(`${folder}.md`);
15435
+ return named?.notionPageId && named.fileType === "file" ? named.notionPageId : null;
15436
+ }
15437
+ folderRecordPageOf(folder) {
15438
+ const record = this.stateDb.getByPath(folder);
15439
+ return record?.notionPageId && isFolderRecord(record) ? record.notionPageId : null;
15440
+ }
15441
+ /**
15442
+ * 이 기록이 그 폴더의 폴더 첨부인가. 노트의 페이지에는 그 노트가 임베드해 올린 파일도 같은 페이지로 적혀 있다 — 폴더
15443
+ * 첨부로 적어 둔 것({@link folderFileIdsOf})만이다.
15444
+ */
15445
+ isFolderFileEntry(entry, folder) {
15446
+ const pageId = this.folderPageOf(folder);
15447
+ if (!pageId || entry.notionPageId !== pageId) return false;
15448
+ if (pageId !== this.notePageOf(folder)) return true;
15449
+ return folderFileIdsOf(this.stateDb, pageId).has(entry.localPath);
15322
15450
  }
15323
15451
  /**
15324
15452
  * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 아예 빼고, Notion 이 받지 않는
@@ -15346,7 +15474,7 @@ var FileHandler = class {
15346
15474
  * @param failed 올리지 못한 첨부를 담을 곳 — push 결과의 `failed` 를 넘기면 CLI · 플러그인이 처리하지 못한 것으로
15347
15475
  * 보인다. 예전엔 경고 로그로만 남아 push 가 「실패 0」 으로 끝났다.
15348
15476
  */
15349
- async pushAllFiles(failed, folders) {
15477
+ async pushAllFiles(failed, folders, noteWrites) {
15350
15478
  const { uploadable: allFiles, unsupported: unsupported2 } = await this.listUploadableFiles();
15351
15479
  if (unsupported2.length > 0) {
15352
15480
  const names = unsupported2.slice(0, LOGGED_NAMES).map((f) => f.path);
@@ -15355,13 +15483,13 @@ var FileHandler = class {
15355
15483
  `[Im-Nobsidian] Notion \uC774 \uBC1B\uC9C0 \uC54A\uB294 \uD615\uC2DD\uC774\uB77C \uC62C\uB9AC\uC9C0 \uC54A\uC740 \uCCA8\uBD80 ${unsupported2.length}\uAC1C: ${names.join(", ")}${rest > 0 ? ` \uC678 ${rest}\uAC1C` : ""}`
15356
15484
  );
15357
15485
  }
15358
- if (this.deleteSync) await this.removeDeletedFolderFiles();
15486
+ if (this.deleteSync) await this.removeDeletedFolderFiles(noteWrites);
15359
15487
  const pending = [];
15360
15488
  for (const file of allFiles) {
15361
15489
  if (this.inAttachmentsFolder(file.path) || isHiddenPath(file.path)) continue;
15362
15490
  const existing = this.stateDb.getFileRegistry(file.path);
15363
15491
  if (existing) {
15364
- if (existing.notionPageId !== this.folderPageOf(getFolderPath(file.path))) continue;
15492
+ if (!this.isFolderFileEntry(existing, getFolderPath(file.path))) continue;
15365
15493
  if (await this.isUnchanged(existing, file)) continue;
15366
15494
  }
15367
15495
  pending.push({ file, existing });
@@ -15383,10 +15511,23 @@ var FileHandler = class {
15383
15511
  for (const [folder, items] of byFolder) {
15384
15512
  const pageId = await this.provideFolderPage(folder, folders, items, stranded);
15385
15513
  if (!pageId) continue;
15386
- for (const { file, existing } of items) {
15387
- const result = await this.pushSingleFile(file, pageId, existing, failed);
15388
- if (result) results.push(result);
15389
- }
15514
+ await this.writingNotePage(
15515
+ pageId === this.notePageOf(folder),
15516
+ pageId,
15517
+ noteWrites,
15518
+ async () => {
15519
+ for (const { file, existing } of items) {
15520
+ let previous = existing;
15521
+ if (!previous) {
15522
+ const moved = await this.movedHere(file, pageId);
15523
+ if (moved?.same) continue;
15524
+ previous = moved?.entry ?? null;
15525
+ }
15526
+ const result = await this.pushSingleFile(file, pageId, previous, failed);
15527
+ if (result) results.push(result);
15528
+ }
15529
+ }
15530
+ );
15390
15531
  }
15391
15532
  this.reportStranded(stranded);
15392
15533
  return results;
@@ -15395,25 +15536,37 @@ var FileHandler = class {
15395
15536
  * 볼트에서 지운 폴더 첨부를 Notion 에서도 지운다(deleteSync) — 폴더 페이지에 적힌 파일 가운데 볼트에 없는 것의 블록과 기록.
15396
15537
  * 포함 · 제외 패턴으로 빠진 파일은 볼트에 있어 지우지 않는다.
15397
15538
  */
15398
- async removeDeletedFolderFiles() {
15539
+ async removeDeletedFolderFiles(noteWrites) {
15399
15540
  const removed = [];
15400
15541
  for (const record of this.stateDb.getAll()) {
15401
- if (!record.notionPageId || !isFolderRecord(record)) continue;
15542
+ if (!record.notionPageId) continue;
15543
+ const folder = isFolderRecord(record) ? record.obsidianPath : filesFolderOfNote(record);
15544
+ if (folder === null) continue;
15545
+ const ids = folderFileIdsOf(this.stateDb, record.notionPageId);
15546
+ if (!isFolderRecord(record) && ids.size === 0) continue;
15547
+ const gone = [];
15402
15548
  for (const entry of this.stateDb.getFilesByPageId(record.notionPageId)) {
15403
- if (getFolderPath(entry.localPath) !== record.obsidianPath) continue;
15404
- if (await this.vaultFs.exists(entry.localPath)) continue;
15405
- try {
15406
- const name = entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1);
15407
- await this.removeOlderBlocks(record.notionPageId, name, void 0);
15408
- this.stateDb.deleteFileRegistry(entry.localPath);
15409
- forgetFolderFileId(this.stateDb, record.notionPageId, entry.localPath);
15410
- removed.push(entry.localPath);
15411
- } catch (error) {
15412
- getLogger().warn(
15413
- `[Im-Nobsidian] \uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80\uB97C Notion \uC5D0\uC11C \uC9C0\uC6B0\uC9C0 \uBABB\uD568 (${entry.localPath}): ${error instanceof Error ? error.message : String(error)}`
15414
- );
15415
- }
15549
+ if (getFolderPath(entry.localPath) !== folder) continue;
15550
+ if (!isFolderRecord(record) && !ids.has(entry.localPath)) continue;
15551
+ if (!await this.vaultFs.exists(entry.localPath)) gone.push(entry);
15416
15552
  }
15553
+ if (gone.length === 0) continue;
15554
+ const pageId = record.notionPageId;
15555
+ await this.writingNotePage(!isFolderRecord(record), pageId, noteWrites, async () => {
15556
+ for (const entry of gone) {
15557
+ try {
15558
+ const name = entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1);
15559
+ await this.removeOlderBlocks(pageId, entry.localPath, name, void 0);
15560
+ this.stateDb.deleteFileRegistry(entry.localPath);
15561
+ forgetFolderFileId(this.stateDb, pageId, entry.localPath);
15562
+ removed.push(entry.localPath);
15563
+ } catch (error) {
15564
+ getLogger().warn(
15565
+ `[Im-Nobsidian] \uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80\uB97C Notion \uC5D0\uC11C \uC9C0\uC6B0\uC9C0 \uBABB\uD568 (${entry.localPath}): ${error instanceof Error ? error.message : String(error)}`
15566
+ );
15567
+ }
15568
+ }
15569
+ });
15417
15570
  }
15418
15571
  if (removed.length > 0) {
15419
15572
  getLogger().info(
@@ -15421,10 +15574,20 @@ var FileHandler = class {
15421
15574
  );
15422
15575
  }
15423
15576
  }
15577
+ /** 노트의 페이지에 쓰는 동안을 알린다({@link NotePageWrites}) — 폴더 페이지는 노트가 없어 알릴 것이 없다. */
15578
+ async writingNotePage(isNotePage, pageId, noteWrites, write) {
15579
+ if (!isNotePage || !noteWrites) return write();
15580
+ await noteWrites.begin(pageId);
15581
+ try {
15582
+ await write();
15583
+ } finally {
15584
+ await noteWrites.end(pageId);
15585
+ }
15586
+ }
15424
15587
  /** 폴더 첨부를 둘 페이지 — 노트 없는 폴더면 마련한다. 둘 곳이 없으면 그 파일들을 `stranded` 에 담고 null. */
15425
15588
  async provideFolderPage(folder, folders, items, stranded) {
15426
15589
  let pageId = this.folderPageOf(folder);
15427
- let reason = folder === "" ? "\uBCFC\uD2B8 \uB9E8 \uC704" : "\uD3F4\uB354 \uB178\uD2B8\uAC00 \uC788\uB294 \uD3F4\uB354 \xB7 DB \uD3F4\uB354";
15590
+ let reason = folder === "" ? "\uBCFC\uD2B8 \uB9E8 \uC704" : "DB \uD3F4\uB354 \xB7 \uC544\uC9C1 \uC62C\uB9AC\uC9C0 \uC54A\uC740 \uD3F4\uB354 \uB178\uD2B8";
15428
15591
  if (!pageId && folder !== "" && folders && !this.stateDb.getByPath(folder)) {
15429
15592
  try {
15430
15593
  await folders.ensureFolderPage(folder);
@@ -15573,6 +15736,8 @@ var FileHandler = class {
15573
15736
  fileHash: hash,
15574
15737
  fileSize: buffer.length
15575
15738
  });
15739
+ const stat2 = await this.vaultFs.getFileStat(localPath);
15740
+ if (stat2) rememberFolderFileStamp(this.stateDb, pageId, localPath, fileStampOf(stat2));
15576
15741
  return wrote;
15577
15742
  }
15578
15743
  async downloadFileBlock(url, filename, targetFolder) {
@@ -15614,7 +15779,9 @@ var FileHandler = class {
15614
15779
  const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
15615
15780
  const block = this.createFileBlock(blockType, fileUploadId, filename);
15616
15781
  const [created] = await this.notionClient.appendChildBlocks(parentPageId, [block]);
15617
- if (previous) await this.removeOlderBlocks(parentPageId, filename, created?.id);
15782
+ if (previous) {
15783
+ await this.removeOlderBlocks(parentPageId, file.path, filename, created?.id);
15784
+ }
15618
15785
  const url = created ? fileUrlOfBlock(created) : null;
15619
15786
  const fileId = url ? notionFileIdOf(url) : null;
15620
15787
  if (fileId) rememberFolderFileId(this.stateDb, parentPageId, file.path, fileId);
@@ -15626,28 +15793,78 @@ var FileHandler = class {
15626
15793
  fileHash: hash,
15627
15794
  fileSize: file.size
15628
15795
  });
15796
+ rememberFolderFileStamp(this.stateDb, parentPageId, file.path, fileStampOf(file));
15629
15797
  getLogger().info(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC: ${file.path} \u2192 ${blockType} \uBE14\uB85D`);
15630
15798
  return { localPath: file.path, fileUploadId, blockType };
15631
15799
  }
15632
- /** 이 페이지에서 같은 이름의 파일 블록 중 방금 붙인 것 말고를 지운다. */
15633
- async removeOlderBlocks(pageId, name, keep) {
15800
+ /**
15801
+ * 처음 보는 파일이 이 페이지에 적어 둔 같은 이름의 폴더 첨부를 옮긴 것인가 — 그 기록의 경로가 볼트에서 사라졌으면 옮겼다(폴더 ·
15802
+ * 폴더 노트 · 노트의 이름 바꾸기). 기록 · 파일 id · 표를 새 경로로 옮긴다. `same` 이면 내용이 같아 올리지 않는다. 다르면 옮긴
15803
+ * 기록을 옛 것으로 올려 옛 블록을 지운다. 예전에는 새 파일로 올려 노트 페이지에 같은 파일의 블록이 둘 남았다.
15804
+ */
15805
+ async movedHere(file, pageId) {
15806
+ const name = file.path.slice(file.path.lastIndexOf("/") + 1);
15807
+ const record = this.stateDb.getByNotionId(pageId);
15808
+ const ids = folderFileIdsOf(this.stateDb, pageId);
15809
+ for (const entry of this.stateDb.getFilesByPageId(pageId)) {
15810
+ if (entry.localPath === file.path) continue;
15811
+ if (entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1) !== name) continue;
15812
+ if (record && !isFolderRecord(record) && !ids.has(entry.localPath)) continue;
15813
+ if (await this.vaultFs.exists(entry.localPath)) continue;
15814
+ const buffer = await this.vaultFs.readBinary(file.path);
15815
+ const same = entry.fileSize === file.size && entry.fileHash === createHash("sha256").update(buffer).digest("hex");
15816
+ const fileId = ids.get(entry.localPath);
15817
+ this.stateDb.deleteFileRegistry(entry.localPath);
15818
+ forgetFolderFileId(this.stateDb, pageId, entry.localPath);
15819
+ this.stateDb.registerFile({
15820
+ localPath: file.path,
15821
+ notionPageId: pageId,
15822
+ fileUploadId: entry.fileUploadId,
15823
+ fileType: entry.fileType,
15824
+ fileHash: entry.fileHash,
15825
+ fileSize: entry.fileSize
15826
+ });
15827
+ if (fileId) rememberFolderFileId(this.stateDb, pageId, file.path, fileId);
15828
+ if (same) rememberFolderFileStamp(this.stateDb, pageId, file.path, fileStampOf(file));
15829
+ return { entry: { ...entry, localPath: file.path }, same };
15830
+ }
15831
+ return null;
15832
+ }
15833
+ /**
15834
+ * 이 페이지에서 그 폴더 첨부의 블록 중 방금 붙인 것(`keep`) 말고를 지운다. 폴더 페이지는 같은 이름의 파일 블록 모두(id 를
15835
+ * 적기 전에 올린 블록도). 노트의 페이지는 노트가 임베드한 같은 이름의 파일이 있을 수 있어, 적어 둔 Notion 파일 id 의 블록과
15836
+ * 표시를 단 캡션({@link folderFileCaption})의 블록만 — 다른 볼트가 같은 파일을 먼저 고쳐 올린 블록은 id 를 모르지만 표시가 있다.
15837
+ * 예전에는 id 로만 지워, 두 볼트가 따로 고쳐 올리면 블록이 둘 남았다.
15838
+ */
15839
+ async removeOlderBlocks(pageId, localPath, name, keep) {
15840
+ const record = this.stateDb.getByNotionId(pageId);
15841
+ const onNote = record !== null && !isFolderRecord(record);
15842
+ const fileId = folderFileIdsOf(this.stateDb, pageId).get(localPath);
15634
15843
  for (const block of await this.notionClient.fetchAllChildren(pageId)) {
15635
15844
  if (block.id === keep) continue;
15636
15845
  const media = block[block.type];
15637
15846
  const url = media?.type === "file" ? media.file?.url : void 0;
15638
15847
  if (!url || uploadedFileNameFromUrl(url) !== name) continue;
15848
+ if (keep === void 0 && fileId !== void 0) {
15849
+ if (notionFileIdOf(url) !== fileId) continue;
15850
+ } else if (onNote) {
15851
+ const caption = (media?.caption ?? []).map((part) => part.plain_text ?? "").join("");
15852
+ const ours = fileId !== void 0 && notionFileIdOf(url) === fileId || caption === folderFileCaption(name);
15853
+ if (!ours) continue;
15854
+ }
15639
15855
  await this.notionClient.deleteBlock(block.id);
15640
15856
  }
15641
15857
  }
15642
15858
  /**
15643
- * 폴더 첨부의 파일 블록 — 캡션에 파일 이름을 적는다. 받는 쪽이 그 표시로 폴더 첨부를 알아본다({@link folderFilesOf}) —
15644
- * 자식 페이지가 없는 폴더 페이지는 PDF 하나만 붙인 노트와 모양이 같다.
15859
+ * 폴더 첨부의 파일 블록 — 캡션에 표시를 단 파일 이름을 적는다({@link folderFileCaption}). 받는 쪽이 그 표시로 폴더 첨부를
15860
+ * 알아본다 — 자식 페이지가 없는 폴더 페이지는 PDF 하나만 붙인 노트와 모양이 같고, 노트 페이지의 폴더 첨부는 그 노트의
15861
+ * 임베드와 같은 블록이다.
15645
15862
  */
15646
15863
  createFileBlock(blockType, fileUploadId, name) {
15647
15864
  const fileRef = {
15648
15865
  type: "file_upload",
15649
15866
  file_upload: { id: fileUploadId },
15650
- caption: [{ type: "text", text: { content: name } }]
15867
+ caption: [{ type: "text", text: { content: folderFileCaption(name) } }]
15651
15868
  };
15652
15869
  switch (blockType) {
15653
15870
  case "image":
@@ -15760,8 +15977,59 @@ async function findUntrackedNote(stateDb, vaultFs, dir, safeName, pageId, taken)
15760
15977
  }
15761
15978
  return null;
15762
15979
  }
15763
- function relinksWithoutContent(note, unlinkedVault) {
15764
- return note.existing === "own" || unlinkedVault;
15980
+ function relinkOutcome(existing, same, unlinkedVault, strategy) {
15981
+ if (same) return "relink";
15982
+ if (existing === "name" && !unlinkedVault) return null;
15983
+ const chosen = existing === "own" ? strategy : "manual";
15984
+ if (chosen === "local-first") return "relink";
15985
+ if (chosen === "remote-first") return "overwrite";
15986
+ return "conflict";
15987
+ }
15988
+ function relinkConflict(input) {
15989
+ return {
15990
+ syncRecord: input.record,
15991
+ localChange: {
15992
+ path: input.path,
15993
+ type: "modified",
15994
+ currentHash: computeHash(input.local),
15995
+ previousHash: input.record.contentHash
15996
+ },
15997
+ remoteChange: {
15998
+ pageId: input.pageId,
15999
+ type: "modified",
16000
+ lastEdited: input.lastEdited,
16001
+ previousEdited: null,
16002
+ path: input.path,
16003
+ title: input.title
16004
+ },
16005
+ baseContent: null,
16006
+ localContent: input.local,
16007
+ remoteContent: input.remote
16008
+ };
16009
+ }
16010
+ function plannedRelinkRecord(input) {
16011
+ const now = (/* @__PURE__ */ new Date()).toISOString();
16012
+ return {
16013
+ id: `planned:${input.pageId}`,
16014
+ obsidianPath: input.path,
16015
+ notionPageId: input.pageId,
16016
+ notionParentId: input.parentId,
16017
+ contentHash: computeHash(input.remote),
16018
+ notionLastEdited: input.lastEdited,
16019
+ notionLastEditedBy: null,
16020
+ notionSeenAt: null,
16021
+ notionBodyFingerprint: null,
16022
+ localLastModified: now,
16023
+ syncDirection: "both",
16024
+ fileType: input.fileType,
16025
+ status: "conflict",
16026
+ baseSnapshot: null,
16027
+ localMtime: null,
16028
+ localFileSize: null,
16029
+ version: 0,
16030
+ createdAt: now,
16031
+ updatedAt: now
16032
+ };
15765
16033
  }
15766
16034
 
15767
16035
  // src/conflict/merger.ts
@@ -15949,20 +16217,27 @@ var PENDING_LINK_RE = new RegExp(
15949
16217
  "g"
15950
16218
  );
15951
16219
  function sameAsPendingLinks(local, remote) {
15952
- if (!PENDING_LINK_RE.test(remote)) return false;
15953
- PENDING_LINK_RE.lastIndex = 0;
16220
+ const pending = new RegExp(`${NOTION_BLOCK_LINK_RE.source}|${PENDING_LINK_RE.source}`, "g");
16221
+ if (!pending.test(remote)) return false;
16222
+ pending.lastIndex = 0;
15954
16223
  const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15955
16224
  let pattern = "";
15956
16225
  let last = 0;
15957
- for (const match of remote.matchAll(PENDING_LINK_RE)) {
16226
+ for (const match of remote.matchAll(pending)) {
15958
16227
  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]+\\))`;
16228
+ const blockLabel = match[1];
16229
+ const alias = match[6];
16230
+ pattern += blockLabel ? pendingBlockLinkPattern(blockLabel, escape) : match[7] ? `"?\\[\\[[^\\]\\n]+\\]\\]"?` : alias ? `(?:\\[\\[(?:[^\\]\\n|]*/)?${escape(alias)}(?:\\\\?\\|[^\\]\\n]*)?\\]\\]|\\[\\[[^\\]\\n|]+\\\\?\\|${escape(alias)}\\]\\]|\\[${escape(alias)}\\]\\([^)\\n]+\\))` : `(?:\\[\\[[^\\]\\n]+\\]\\]|\\[[^\\]\\n]+\\]\\([^)\\n]+\\))`;
15961
16231
  last = match.index + match[0].length;
15962
16232
  }
15963
16233
  pattern += escape(remote.slice(last).replace(/\n+$/, ""));
15964
16234
  return new RegExp(`^${pattern}\\n*$`).test(local.replace(/\n+$/, ""));
15965
16235
  }
16236
+ function pendingBlockLinkPattern(label, escape) {
16237
+ const text = label.replace(/\\([>^])/g, "$1");
16238
+ const written = escape(text.split(" > ").join("#"));
16239
+ return `(?:\\[\\[[^\\]\\n|]*#[^\\]\\n|]*\\\\?\\|${escape(text)}\\]\\]|\\[\\[(?:[^\\]\\n|#]*/)?${written}\\]\\]|\\[\\[#${written}\\]\\]|\\[(?:${escape(text)}|${escape(label)})\\]\\([^)\\n]+\\))`;
16240
+ }
15966
16241
  function resolveNotionIdWikilinks(markdown, lookup) {
15967
16242
  let resolved = 0;
15968
16243
  const out = markdown.replace(
@@ -19088,7 +19363,9 @@ var NoteRelocator = class {
19088
19363
  if (isFolderRecord(record)) return [record.obsidianPath];
19089
19364
  if (record.fileType === "folder-note") return [parentFolderOf(record.obsidianPath)];
19090
19365
  const children = stripMarkdownExtension(record.obsidianPath);
19091
- const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children));
19366
+ const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children)) || record.notionPageId !== null && [...folderFileIdsOf(this.stateDb, record.notionPageId).keys()].some(
19367
+ (path) => within(path, children)
19368
+ );
19092
19369
  return hasChildren ? [record.obsidianPath, children] : [record.obsidianPath];
19093
19370
  }
19094
19371
  /** 옮기지 못할 까닭 — 없으면 null. */
@@ -19321,7 +19598,7 @@ async function notionBodyToObsidian(client, markdown, pageId) {
19321
19598
  const bookmarks = bookmarkIds.length > 0 ? await client.getBookmarks(bookmarkIds) : void 0;
19322
19599
  return notionEnhancedToObsidian(markdown, { userNames, codeTexts, bookmarks });
19323
19600
  }
19324
- async function pageBlocksToObsidian(client, pageId) {
19601
+ async function pageBlocksToObsidian(client, pageId, bodyOf5 = (markdown) => markdown) {
19325
19602
  const tree = await client.getBlockTree(pageId);
19326
19603
  const markdown = blocksToNfm(tree, { pageId });
19327
19604
  const content = await notionBodyToObsidian(
@@ -19330,7 +19607,7 @@ async function pageBlocksToObsidian(client, pageId) {
19330
19607
  getCodeBlockTexts: () => Promise.resolve(blocksOfType(tree, "code").map(plainTextOf)),
19331
19608
  getBookmarks: () => Promise.resolve(bookmarksOf(tree))
19332
19609
  },
19333
- markdown,
19610
+ bodyOf5(markdown),
19334
19611
  pageId
19335
19612
  );
19336
19613
  return { markdown, content };
@@ -19989,13 +20266,15 @@ var DatabaseSyncer = class {
19989
20266
  page.id,
19990
20267
  (key) => relinkTaken.has(key) || claimed.has(key)
19991
20268
  );
19992
- if (untracked && relinksWithoutContent(untracked, this.unlinkedVault)) {
20269
+ const relink = untracked && await this.plannedRowRelink(page, dbConfig, untracked);
20270
+ if (untracked && relink) {
19993
20271
  relinkTaken.add(pathKey(untracked.path));
19994
20272
  planned.push({
19995
20273
  pageId: page.id,
19996
20274
  path: untracked.path,
19997
20275
  operation: "create",
19998
- relink: true
20276
+ relink: true,
20277
+ ...relink.conflict ? { relinkConflict: relink.conflict } : {}
19999
20278
  });
20000
20279
  continue;
20001
20280
  }
@@ -20826,16 +21105,20 @@ var DatabaseSyncer = class {
20826
21105
  return found2;
20827
21106
  }
20828
21107
  /**
20829
- * 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 페이지의 `relinkExistingNote` 와 같은 규칙. 글이 같으면 잇고, 다르면
20830
- * 이 행 id 를 단 이름은 충돌 전략을 따르고 · 자연 이름은 이은 노트가 없는 볼트에서만 충돌로 잇는다. 잇지 않으면 null.
20831
- * remote-first 는 `"write"` — 호출측이 받은 행으로 덮는다.
21108
+ * 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 받은 글과 견줘 {@link relinkOutcome} 대로. 잇지 않으면 null.
21109
+ * 원격으로 덮을 것은 `"write"` — 호출측이 받은 행으로 덮는다.
20832
21110
  */
20833
21111
  async relinkExistingRow(filePath, existing, local, finalContent, page, dbConfig, rendered, bodyFingerprint) {
20834
21112
  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";
21113
+ const outcome = relinkOutcome(
21114
+ existing,
21115
+ same,
21116
+ this.unlinkedVault,
21117
+ this.config.sync.conflictStrategy
21118
+ );
21119
+ if (outcome === null) return null;
21120
+ if (outcome === "overwrite") return "write";
21121
+ const conflicted = outcome === "conflict";
20839
21122
  const stat2 = same ? await this.vaultFs.getFileStat(filePath) : null;
20840
21123
  this.stateDb.transaction(() => {
20841
21124
  this.stateDb.upsert({
@@ -20862,31 +21145,57 @@ var DatabaseSyncer = class {
20862
21145
  });
20863
21146
  });
20864
21147
  if (!conflicted) return { action: "unchanged", path: filePath, relinked: true };
20865
- const record = this.stateDb.getByPath(filePath);
20866
21148
  return {
20867
21149
  action: "conflict",
20868
21150
  path: filePath,
20869
21151
  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: {
21152
+ conflict: relinkConflict({
21153
+ record: this.stateDb.getByPath(filePath),
21154
+ path: filePath,
21155
+ local,
21156
+ remote: finalContent,
21157
+ pageId: page.id,
21158
+ lastEdited: page.last_edited_time,
21159
+ title: rendered.title
21160
+ })
21161
+ };
21162
+ }
21163
+ /**
21164
+ * dry-run — 실제 pull 이 상태 기록에 없던 이 행 노트를 다시 잇나(P7). 받은 행을 실제 pull 처럼 렌더하되 볼트 · 상태 DB 는
21165
+ * 바꾸지 않고({@link ImageHandler.preview}) 견준다. 잇지 않으면 null(새 이름으로 받는다), 충돌로 남길 것이면 그 충돌.
21166
+ */
21167
+ async plannedRowRelink(page, dbConfig, untracked) {
21168
+ const local = await readLocalNote(this.vaultFs, untracked.path);
21169
+ if (local === void 0) return null;
21170
+ const rendered = await this.imageHandler.preview(
21171
+ () => this.renderRow(page, untracked.path, { localPath: untracked.path, adoptFrom: local })
21172
+ );
21173
+ const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
21174
+ const outcome = relinkOutcome(
21175
+ untracked.existing,
21176
+ same,
21177
+ this.unlinkedVault,
21178
+ this.config.sync.conflictStrategy
21179
+ );
21180
+ if (outcome === null) return null;
21181
+ if (outcome !== "conflict") return {};
21182
+ return {
21183
+ conflict: relinkConflict({
21184
+ record: plannedRelinkRecord({
21185
+ path: untracked.path,
20879
21186
  pageId: page.id,
20880
- type: "modified",
21187
+ parentId: dbConfig.databaseId,
21188
+ remote: rendered.content,
20881
21189
  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
- }
21190
+ fileType: "db-row"
21191
+ }),
21192
+ path: untracked.path,
21193
+ local,
21194
+ remote: rendered.content,
21195
+ pageId: page.id,
21196
+ lastEdited: page.last_edited_time,
21197
+ title: rendered.title
21198
+ })
20890
21199
  };
20891
21200
  }
20892
21201
  /** 이번 받기에서 로컬 편집을 합친 행의 경로를 가져가고 비운다. */
@@ -23381,6 +23690,7 @@ function dropEmbeddedBaseMarkers(content, basePaths) {
23381
23690
 
23382
23691
  // src/sync/database-discovery.ts
23383
23692
  var DISCOVERY_RETRY_META_KEY = "discovery_retry";
23693
+ var PREVIEW_BODY_READ_BUDGET = 100;
23384
23694
  function parseDiscoveryRetry(raw) {
23385
23695
  if (!raw) return { parents: [], dbs: [] };
23386
23696
  try {
@@ -23446,144 +23756,25 @@ var DatabaseDiscovery = class {
23446
23756
  let deleted = 0;
23447
23757
  let restored = 0;
23448
23758
  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
- }
23759
+ const registry = await this.openRegistry(failed, {
23760
+ forceRediscovery,
23761
+ ...options.rescanRoot !== void 0 ? { rescanRoot: options.rescanRoot } : {},
23762
+ ...signal ? { signal } : {},
23763
+ markScanned: true
23764
+ });
23765
+ const {
23766
+ dbConfigs,
23767
+ configuredIds,
23768
+ registeredNow,
23769
+ inaccessibleIds,
23770
+ linkedMap,
23771
+ linkedNow,
23772
+ retry,
23773
+ nextRetry,
23774
+ dirty,
23775
+ register,
23776
+ degrade
23777
+ } = registry;
23587
23778
  const stillSyncable = [];
23588
23779
  const pulledWhole = /* @__PURE__ */ new Set();
23589
23780
  let queue = [...dbConfigs];
@@ -23621,14 +23812,14 @@ var DatabaseDiscovery = class {
23621
23812
  config.localFolder = move.to + config.localFolder.slice(move.from.length);
23622
23813
  }
23623
23814
  }
23624
- changed = true;
23815
+ dirty.list = true;
23625
23816
  }
23626
23817
  });
23627
23818
  if (dbResult.linkedOriginalDbId) {
23628
23819
  linkedMap.set(nohyph, dbResult.linkedOriginalDbId.replace(/-/g, ""));
23629
23820
  linkedNow.add(nohyph);
23630
- linkedChanged = true;
23631
- changed = true;
23821
+ dirty.linked = true;
23822
+ dirty.list = true;
23632
23823
  ledger?.settle(dbConfig.databaseId);
23633
23824
  getLogger().info(
23634
23825
  `[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 +23874,18 @@ var DatabaseDiscovery = class {
23683
23874
  }
23684
23875
  }
23685
23876
  if (!signal?.aborted) {
23686
- linkedChanged = await this.pullLinkedViews(linkedMap, stillSyncable, {
23877
+ dirty.linked = await this.pullLinkedViews(linkedMap, stillSyncable, {
23687
23878
  pulled: (container, original) => forceRediscovery || linkedNow.has(container) || pulledWhole.has(original)
23688
- }) || linkedChanged;
23879
+ }) || dirty.linked;
23689
23880
  }
23690
23881
  ledger?.keepOnly([...configuredIds, ...stillSyncable.map((c) => c.databaseId)]);
23691
- if (changed || inaccessibleChanged || stillSyncable.length !== dbConfigs.length) {
23882
+ if (dirty.list || dirty.inaccessible || stillSyncable.length !== dbConfigs.length) {
23692
23883
  this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(stillSyncable));
23693
23884
  }
23694
- if (inaccessibleChanged) {
23885
+ if (dirty.inaccessible) {
23695
23886
  this.stateDb.setMeta(INACCESSIBLE_DBS_META_KEY, JSON.stringify([...inaccessibleIds]));
23696
23887
  }
23697
- if (linkedChanged) {
23888
+ if (dirty.linked) {
23698
23889
  this.stateDb.setMeta(LINKED_DBS_META_KEY, JSON.stringify(Object.fromEntries(linkedMap)));
23699
23890
  }
23700
23891
  if (retry.parents.length + retry.dbs.length + nextRetry.parents.length + nextRetry.dbs.length > 0) {
@@ -23711,6 +23902,217 @@ var DatabaseDiscovery = class {
23711
23902
  }
23712
23903
  return { created, updated, deleted, restored };
23713
23904
  }
23905
+ /**
23906
+ * 이번 pull 이 찾을 DB 를 등록부에 올린다 — 블록을 훑고({@link discoverChildDatabases}) 받은 본문의 `<database>` 를
23907
+ * 더한다. 상태 DB 에는 쓰지 않는다 — 등록부를 적는 것은 실제 pull 의 마감이고 dry-run 은 버린다. 다 훑은 표시만
23908
+ * `markScanned` 일 때 적는다.
23909
+ *
23910
+ * @param failed 읽지 못한 페이지 · DB — 이유와 함께 싣는다.
23911
+ * @param options.notePathOf 페이지 → 볼트 경로. 새 DB 폴더를 부모 노트 옆에 둔다 — dry-run 은 다시 이을 노트의 자리를 준다.
23912
+ */
23913
+ async openRegistry(failed, options) {
23914
+ const { forceRediscovery, signal } = options;
23915
+ const { notePathOf } = options;
23916
+ const dbConfigs = parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY));
23917
+ const configuredIds = new Set(
23918
+ (this.config.notion.databases ?? []).map((d) => d.databaseId.replace(/-/g, ""))
23919
+ );
23920
+ const knownIds = /* @__PURE__ */ new Set([
23921
+ ...configuredIds,
23922
+ ...dbConfigs.map((c) => c.databaseId.replace(/-/g, ""))
23923
+ ]);
23924
+ const dirty = { list: false, inaccessible: false, linked: false };
23925
+ const registeredNow = /* @__PURE__ */ new Set();
23926
+ const folderOwner = new Map(
23927
+ (this.config.notion.databases ?? []).map((d) => [d.localFolder, d.databaseId])
23928
+ );
23929
+ const repairedCount = repairDbFolderCollisions(dbConfigs, folderOwner);
23930
+ for (const c of dbConfigs) folderOwner.set(c.localFolder, c.databaseId);
23931
+ if (repairedCount > 0) {
23932
+ dirty.list = true;
23933
+ getLogger().info(`[Im-Nobsidian] \uB3D9\uBA85 DB \uD3F4\uB354 \uCDA9\uB3CC ${repairedCount}\uAC74 \uBD84\uB9AC(F24)`);
23934
+ }
23935
+ const folderKeys = new Map([...folderOwner].map(([folder, id]) => [pathKey(folder), id]));
23936
+ const claimFolder = (config) => {
23937
+ config.localFolder = resolveDbFolderPath(
23938
+ config.localFolder,
23939
+ config.databaseId,
23940
+ (f) => folderOwner.get(f) ?? folderKeys.get(pathKey(f)) ?? null
23941
+ );
23942
+ folderOwner.set(config.localFolder, config.databaseId);
23943
+ folderKeys.set(pathKey(config.localFolder), config.databaseId);
23944
+ };
23945
+ const inaccessibleIds = loadInaccessibleDbIds(this.stateDb);
23946
+ const degrade = (rawId) => {
23947
+ const nohyph = rawId.replace(/-/g, "");
23948
+ if (!inaccessibleIds.has(nohyph)) {
23949
+ inaccessibleIds.add(nohyph);
23950
+ dirty.inaccessible = true;
23951
+ }
23952
+ getLogger().warn(
23953
+ `[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)`
23954
+ );
23955
+ };
23956
+ if (forceRediscovery && inaccessibleIds.size > 0) {
23957
+ getLogger().info(
23958
+ `[Im-Nobsidian] --force: \uC811\uADFC \uBD88\uAC00 DB ${inaccessibleIds.size}\uAC1C denylist \uC7AC\uAC80\uC99D`
23959
+ );
23960
+ inaccessibleIds.clear();
23961
+ dirty.inaccessible = true;
23962
+ }
23963
+ const linkedMap = loadLinkedDbMap(this.stateDb);
23964
+ const linkedNow = /* @__PURE__ */ new Set();
23965
+ const retry = parseDiscoveryRetry(this.stateDb.getMeta(DISCOVERY_RETRY_META_KEY));
23966
+ const nextRetry = { parents: [], dbs: [] };
23967
+ const pathOf = (pageId) => notionIdsEqual(pageId, this.config.notion.rootPageId) ? "" : this.stateDb.getByNotionId(pageId)?.obsidianPath ?? pageId;
23968
+ const unreadParent = (pageId, error) => {
23969
+ nextRetry.parents.push(pageId);
23970
+ failed.push({
23971
+ path: pathOf(pageId),
23972
+ operation: "update",
23973
+ error: `\uD558\uC704 DB \uB97C \uD655\uC778\uD558\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uD655\uC778\uD55C\uB2E4: ${error}`
23974
+ });
23975
+ };
23976
+ const retryLater = (dbId, parentPageId, error) => {
23977
+ nextRetry.dbs.push({ dbId, parentPageId });
23978
+ failed.push({
23979
+ path: pathOf(parentPageId),
23980
+ operation: "create",
23981
+ error: `DB ${shortNotionId(dbId)} \uB97C \uC77D\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uBCF8\uB2E4: ${error}`
23982
+ });
23983
+ };
23984
+ const register = async (dbId, parentPageId) => {
23985
+ const nohyph = dbId.replace(/-/g, "");
23986
+ if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) return;
23987
+ const registerOriginal = async (origNohyph) => {
23988
+ if (knownIds.has(origNohyph) || inaccessibleIds.has(origNohyph)) return;
23989
+ const origId = normalizeNotionId(origNohyph);
23990
+ const outcome2 = await this.buildDiscoveredDbConfig(origId, parentPageId, notePathOf);
23991
+ knownIds.add(origNohyph);
23992
+ if (outcome2.kind === "ok") {
23993
+ claimFolder(outcome2.config);
23994
+ dbConfigs.push(outcome2.config);
23995
+ registeredNow.add(origNohyph);
23996
+ dirty.list = true;
23997
+ } else if (outcome2.kind === "error") {
23998
+ retryLater(origId, parentPageId, outcome2.error);
23999
+ } else {
24000
+ degrade(origId);
24001
+ }
24002
+ };
24003
+ const knownOriginal = linkedMap.get(nohyph);
24004
+ if (knownOriginal) {
24005
+ await registerOriginal(knownOriginal);
24006
+ return;
24007
+ }
24008
+ const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId, notePathOf);
24009
+ knownIds.add(nohyph);
24010
+ if (outcome.kind === "ok") {
24011
+ claimFolder(outcome.config);
24012
+ dbConfigs.push(outcome.config);
24013
+ registeredNow.add(nohyph);
24014
+ dirty.list = true;
24015
+ } else if (outcome.kind === "linked") {
24016
+ const origNohyph = outcome.originalDbId.replace(/-/g, "");
24017
+ linkedMap.set(nohyph, origNohyph);
24018
+ linkedNow.add(nohyph);
24019
+ dirty.linked = true;
24020
+ await registerOriginal(origNohyph);
24021
+ } else if (outcome.kind === "inaccessible") {
24022
+ degrade(dbId);
24023
+ } else if (outcome.kind === "error") {
24024
+ retryLater(dbId, parentPageId, outcome.error);
24025
+ }
24026
+ };
24027
+ const scanAll = forceRediscovery || discoveryScanPending(this.stateDb);
24028
+ if (signal?.aborted) {
24029
+ nextRetry.parents.push(...retry.parents);
24030
+ nextRetry.dbs.push(...retry.dbs);
24031
+ } else {
24032
+ const root = this.config.notion.rootPageId;
24033
+ const scanIds = scanAll ? [
24034
+ root,
24035
+ ...this.stateDb.getAll().filter((r) => r.notionPageId).map((r) => r.notionPageId)
24036
+ ].filter((id) => !this._bodyReadPages.has(normalizeNotionId(id))) : options.rescanRoot && !retry.parents.some((id) => notionIdsEqual(id, root)) ? [root, ...retry.parents] : retry.parents;
24037
+ if (scanIds.length > 0) {
24038
+ const { found: found2, unread } = await this.discoverChildDatabases(scanIds);
24039
+ for (const { dbId, parentPageId } of found2) {
24040
+ await register(dbId, parentPageId);
24041
+ }
24042
+ for (const { pageId, error } of unread) unreadParent(pageId, error);
24043
+ }
24044
+ for (const { dbId, parentPageId } of retry.dbs) {
24045
+ await register(dbId, parentPageId);
24046
+ }
24047
+ if (scanAll && options.markScanned) {
24048
+ this.stateDb.setMeta(DISCOVERY_SCANNED_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
24049
+ }
24050
+ }
24051
+ for (const [nohyph, parentPageId] of this._inlineDbRefs) {
24052
+ await register(normalizeNotionId(nohyph), parentPageId);
24053
+ }
24054
+ return {
24055
+ dbConfigs,
24056
+ configuredIds,
24057
+ registeredNow,
24058
+ inaccessibleIds,
24059
+ linkedMap,
24060
+ linkedNow,
24061
+ retry,
24062
+ nextRetry,
24063
+ dirty,
24064
+ register,
24065
+ degrade
24066
+ };
24067
+ }
24068
+ /**
24069
+ * dry-run — 이번 pull 이 새로 찾을 DB(실제 pull 의 {@link openRegistry} 와 같은 규칙). 볼트 · 상태 DB 를 바꾸지 않는다. 받은
24070
+ * 본문은 dry-run 이 이미 읽은 것(다시 이을 노트 · 로컬도 고친 노트)만 본다 — {@link bodyRead}.
24071
+ *
24072
+ * @returns 새로 찾을 DB 와 그 폴더를 가를 전체 목록(발견해 둔 DB 포함).
24073
+ */
24074
+ async plannedNewDatabases(failed, options) {
24075
+ if (isDatabaseMode(this.config)) return { found: [], all: [] };
24076
+ const registry = await this.openRegistry(failed, {
24077
+ forceRediscovery: false,
24078
+ rescanRoot: options.rescanRoot,
24079
+ markScanned: false,
24080
+ notePathOf: options.notePathOf
24081
+ });
24082
+ return {
24083
+ found: registry.dbConfigs.filter(
24084
+ (config) => registry.registeredNow.has(config.databaseId.replace(/-/g, ""))
24085
+ ),
24086
+ all: registry.dbConfigs.filter(
24087
+ (config) => !registry.inaccessibleIds.has(config.databaseId.replace(/-/g, ""))
24088
+ )
24089
+ };
24090
+ }
24091
+ /**
24092
+ * dry-run — 새로 받을 페이지의 본문을 읽어 그 안의 DB 를 찾는다({@link plannedNewDatabases} 가 그 행을 센다). 실제 pull 은
24093
+ * 받으며 찾는다. 예전에는 읽지 않고 «미리 세지 않았습니다» 만 알렸다. {@link PREVIEW_BODY_READ_BUDGET} 개까지만 읽는다 —
24094
+ * 넘거나 읽지 못한(다 받지 못한) 페이지는 {@link bodyRead} 가 아니어서 알림으로 남는다.
24095
+ */
24096
+ async readNewPageBodies(pageIds, signal) {
24097
+ if (isDatabaseMode(this.config)) return;
24098
+ const unread = pageIds.filter((pageId) => !this.bodyRead(pageId));
24099
+ for (const pageId of unread.slice(0, PREVIEW_BODY_READ_BUDGET)) {
24100
+ if (signal?.aborted) return;
24101
+ try {
24102
+ const result = await this.notionClient.getPageMarkdown(pageId);
24103
+ if (result.truncated || (result.unknown_block_ids ?? []).length > 0) continue;
24104
+ this.collectInlineDbRefs(pageId, result.markdown);
24105
+ } catch (error) {
24106
+ getLogger().debug(
24107
+ `[Im-Nobsidian] \uBBF8\uB9AC \uBCF4\uAE30: \uC0C8 \uD398\uC774\uC9C0 \uBCF8\uBB38\uC744 \uC77D\uC9C0 \uBABB\uD574 \uC548\uC758 DB \uB294 \uBC1B\uC73C\uBA70 \uCC3E\uC74C (${pageId}): ${error instanceof Error ? error.message : String(error)}`
24108
+ );
24109
+ }
24110
+ }
24111
+ }
24112
+ /** 이번 pull 에서 본문을 받은 페이지인가 — 그 본문의 `<database>` 로 새 DB 를 찾았다. */
24113
+ bodyRead(pageId) {
24114
+ return this._bodyReadPages.has(normalizeNotionId(pageId));
24115
+ }
23714
24116
  /**
23715
24117
  * 링크드 뷰 컨테이너마다 그 보기로 `.base` 를 쓴다({@link DatabaseSyncer.pullLinkedView}). 원본 DB 를 받은 pull ·
23716
24118
  * 이번에 해소한 컨테이너 · 아직 `.base` 가 없는 컨테이너만 — 보기만 고친 것은 DB 수정 시각을 바꾸지 않아 원본처럼
@@ -23763,7 +24165,7 @@ var DatabaseDiscovery = class {
23763
24165
  * (예: 부모 "AI Engineer (1).md" + DB "Minirecord Project" → "AI Engineer (1)/Minirecord-Project")
23764
24166
  * - 제목 취득 실패 시 부모 폴더명 기반 안전 이름으로 폴백
23765
24167
  */
23766
- async buildDiscoveredDbConfig(dbId, parentPageId) {
24168
+ async buildDiscoveredDbConfig(dbId, parentPageId, notePathOf = (pageId) => this.stateDb.getByNotionId(pageId)?.obsidianPath ?? null) {
23767
24169
  let dbTitle;
23768
24170
  try {
23769
24171
  const info = await this.notionClient.getDatabaseSyncability(dbId);
@@ -23789,10 +24191,9 @@ var DatabaseDiscovery = class {
23789
24191
  getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uC124\uC815 \uC0DD\uC131 \uC2E4\uD328:`, error);
23790
24192
  return { kind: "error", error: error instanceof Error ? error.message : String(error) };
23791
24193
  }
23792
- const parentEntry = parentPageId ? this.stateDb.getByNotionId(parentPageId) : null;
24194
+ const obsPath = parentPageId ? notePathOf(parentPageId) : null;
23793
24195
  let parentFolder = "";
23794
- if (parentEntry?.obsidianPath) {
23795
- const obsPath = parentEntry.obsidianPath;
24196
+ if (obsPath) {
23796
24197
  if (isMarkdownPath(obsPath)) {
23797
24198
  const parts = obsPath.split("/");
23798
24199
  const fileBase = stripMarkdownExtension(parts.pop() ?? "");
@@ -24544,7 +24945,14 @@ var LocalPlanner = class {
24544
24945
  live.add(target);
24545
24946
  }
24546
24947
  }
24547
- const pairs = scan.adoptions.map((a) => ({ from: a.record.obsidianPath, to: a.to }));
24948
+ const filePairs = await this.movedFolderFilePairs(
24949
+ [...folderRecords].filter((folder) => !live.has(folder))
24950
+ );
24951
+ for (const { to } of filePairs) for (const folder of foldersOf([to])) live.add(folder);
24952
+ const pairs = [
24953
+ ...scan.adoptions.map((a) => ({ from: a.record.obsidianPath, to: a.to })),
24954
+ ...filePairs
24955
+ ];
24548
24956
  const folderMoves = [];
24549
24957
  const databaseFolderMoves = [];
24550
24958
  for (const move of deriveFolderMoves(tracked, live, pairs, hints)) {
@@ -24553,6 +24961,29 @@ var LocalPlanner = class {
24553
24961
  }
24554
24962
  return { folderMoves, databaseFolderMoves };
24555
24963
  }
24964
+ /**
24965
+ * 사라진 폴더 페이지에 올린 폴더 첨부의 짝 — 노트 없이 파일만 든 폴더는 노트의 짝이 없어 이름을 바꿔도 옮긴 줄 몰랐다. 그
24966
+ * 폴더 페이지에 올린 파일과 이름 · 크기가 같은 파일이 볼트에 하나뿐이면 그리로 옮겼다. 예전에는 새 폴더 페이지를 만들어 다시
24967
+ * 올렸고, 삭제 동기화가 꺼져 있으면 옛 폴더 페이지가 남아 다른 볼트가 두 폴더를 받았다.
24968
+ */
24969
+ async movedFolderFilePairs(missing) {
24970
+ const entries = missing.flatMap((folder) => {
24971
+ const pageId = this.stateDb.getByPath(folder)?.notionPageId;
24972
+ if (!pageId) return [];
24973
+ return this.stateDb.getFilesByPageId(pageId).filter((entry) => entry.localPath.startsWith(`${folder}/`));
24974
+ });
24975
+ if (entries.length === 0) return [];
24976
+ const files = await this.vaultFs.listNonMarkdownFiles();
24977
+ const nameOf = (path) => path.slice(path.lastIndexOf("/") + 1);
24978
+ const pairs = [];
24979
+ for (const entry of entries) {
24980
+ const found2 = files.filter(
24981
+ (file) => nameOf(file.path) === nameOf(entry.localPath) && file.size === entry.fileSize
24982
+ );
24983
+ if (found2.length === 1) pairs.push({ from: entry.localPath, to: found2[0].path });
24984
+ }
24985
+ return pairs;
24986
+ }
24556
24987
  /** 레코드를 새 경로로 옮겨 적는다 — 이동 WAL · 위키링크 · 보존 마커도 함께. */
24557
24988
  moveRecord(record, to) {
24558
24989
  const from = record.obsidianPath;
@@ -27174,11 +27605,14 @@ var PagePusher = class {
27174
27605
  async pushUpdatePage(pageId, markdownContent, notePath) {
27175
27606
  if (this.config.conversion.preferMarkdownApi !== false) {
27176
27607
  const deferred = deferSyncedReferences(obsidianToNotionEnhanced(markdownContent));
27177
- const { markdown, kept } = await this.imageHandler.keepUploadedMedia(
27608
+ const { markdown: body, kept } = await this.imageHandler.keepUploadedMedia(
27178
27609
  pageId,
27179
27610
  deferred.nfm,
27180
27611
  notePath
27181
27612
  );
27613
+ const folderFiles = await this.folderFileLines(pageId);
27614
+ const markdown = folderFiles ? `${body}
27615
+ ${folderFiles}` : body;
27182
27616
  const written = await replacePageBody(this.notionClient, pageId, markdown, {
27183
27617
  databaseIdsOfBase: (basePath) => this.databaseIdsOfBase(basePath),
27184
27618
  unknownTagFallbacks: unknownTagFallbacks(markdownContent)
@@ -27198,6 +27632,7 @@ var PagePusher = class {
27198
27632
  "\uC790\uC2DD \uD398\uC774\uC9C0 \xB7 DB \uAC00 \uC788\uB294 \uD398\uC774\uC9C0\uB294 \uBE14\uB85D \uBC29\uC2DD\uC73C\uB85C \uBCF8\uBB38\uC744 \uBCF4\uB0B4\uBA74 \uC790\uC2DD\uAE4C\uC9C0 \uC9C0\uC6CC\uC838 \uBCF4\uB0B4\uC9C0 \uC54A\uC74C \u2014 \uC124\uC815 conversion.preferMarkdownApi \uB97C \uAE30\uBCF8\uAC12(true)\uC73C\uB85C \uB450\uACE0 \uB2E4\uC2DC push \uD558\uC138\uC694"
27199
27633
  );
27200
27634
  }
27635
+ this.forgetFolderFilesForBlockMode(pageId, notePath);
27201
27636
  const { nfm, blocks } = blockModeBody(markdownContent);
27202
27637
  if (blocks.length > 0) {
27203
27638
  await this.notionClient.appendBlockTree(pageId, blocks);
@@ -27215,6 +27650,32 @@ var PagePusher = class {
27215
27650
  );
27216
27651
  return { fingerprint: null, kept: [], sent: nfm, syncedRefs: [], syncedRefText: "" };
27217
27652
  }
27653
+ /**
27654
+ * 노트 페이지의 폴더 첨부 줄(ADR-056) — 본문을 통째로 바꿀 때 받은 줄 그대로 끝에 되실어야 Notion 이 그 블록을 둔다
27655
+ * ({@link ImageHandler.keepUploadedMedia} 와 같은 실측). 이 페이지에 적어 둔 폴더 첨부가 없으면 읽지 않는다. 읽지 못하면
27656
+ * 던진다 — 모르고 바꾸면 폴더 첨부가 지워진다.
27657
+ */
27658
+ async folderFileLines(pageId) {
27659
+ const ids = new Set(folderFileIdsOf(this.stateDb, pageId).values());
27660
+ if (ids.size === 0) return "";
27661
+ const remote = (await this.notionClient.getPageMarkdown(pageId)).markdown;
27662
+ return (folderFilesInNote(remote, { knownIds: ids })?.lines ?? []).join("\n");
27663
+ }
27664
+ /**
27665
+ * 블록 방식은 본문 블록을 모두 지우고 새로 붙인다 — 폴더 첨부 블록을 남기면 새 본문이 그 뒤에 붙는다.
27666
+ * 함께 지우고 기록을 놓는다 — 이어지는 첨부 push 가 볼트의 파일을 다시 끝에 올린다.
27667
+ */
27668
+ forgetFolderFilesForBlockMode(pageId, notePath) {
27669
+ const ids = folderFileIdsOf(this.stateDb, pageId);
27670
+ if (ids.size === 0) return;
27671
+ for (const localPath of ids.keys()) {
27672
+ this.stateDb.deleteFileRegistry(localPath);
27673
+ forgetFolderFileId(this.stateDb, pageId, localPath);
27674
+ }
27675
+ getLogger().info(
27676
+ `[Im-Nobsidian] \uBE14\uB85D \uBC29\uC2DD\uC73C\uB85C \uBCF8\uBB38\uC744 \uBC14\uAFD4 \uD3F4\uB354 \uCCA8\uBD80 ${ids.size}\uAC1C\uB97C \uB2E4\uC2DC \uC62C\uB9BC (${notePath})`
27677
+ );
27678
+ }
27218
27679
  /**
27219
27680
  * 자리표시로 보낸 코드를 채우고(S-22) 동기화 블록 참조를 붙인다({@link deferSyncedReferences}) — 본문을
27220
27681
  * 바꾼 뒤(블록 방식이면 옛 블록을 지운 뒤) 부른다.
@@ -27454,34 +27915,80 @@ var PagePuller = class {
27454
27915
  );
27455
27916
  }
27456
27917
  /**
27457
- * dry-run — 실제 pull 이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7). 다시 이을 노트(`note`) ·
27458
- * 이미 있는 폴더(`folder` — 이은 노트가 없는 볼트에서 폴더로만 받으면 만들지 않는다), 아니면 null. 받기 전에는 폴더 노트가 될지
27459
- * 모른다 — 노트 자리 · 폴더 노트 자리를 차례로 본다. `taken` 은 이번 미리 보기가 이미 이은 자리({@link pathKey}) — 고르면
27460
- * 더한다. 볼트도 상태 DB 도 바꾸지 않는다.
27918
+ * dry-run — 실제 pull({@link createPulledPage})이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7).
27919
+ * 다시 이을 노트(`note` — 충돌로 남길 것이면 `conflict`) · 이미 있는 폴더(`folder` — 폴더로만 받는 페이지), 아니면 null.
27920
+ * 그 자리에 무엇이 있을 때만 본문을 받아 실제 pull 처럼 렌더해 견준다 — 미디어는 받되 볼트에 쓰지 않는다
27921
+ * ({@link ImageHandler.preview}). `taken` 은 이번 미리 보기가 이미 이은 자리({@link pathKey}) — 고르면 더한다. 볼트도 상태 DB 도
27922
+ * 바꾸지 않는다.
27461
27923
  */
27462
27924
  async plannedRelink(pageId, taken) {
27463
27925
  const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
27464
- const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
27926
+ const title = this.notionClient.extractTitle(page);
27927
+ const safeName = sanitizeFileName(title);
27465
27928
  const parentPath = await this.resolveParentPath(page);
27929
+ if (this.excludedCreatePath(parentPath, safeName) !== null) return null;
27466
27930
  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
- }
27931
+ const untrackedIn = (dir) => findUntrackedNote(this.stateDb, this.vaultFs, dir, safeName, pageId, (key) => taken.has(key));
27932
+ const folderExists = async () => !taken.has(pathKey(folder)) && this.stateDb.getByPath(folder) === null && await this.vaultFs.exists(folder);
27933
+ if (!await untrackedIn(parentPath) && !await untrackedIn(folder) && !await folderExists()) {
27934
+ return null;
27480
27935
  }
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" };
27936
+ const fetched = await this.fetchPageMarkdown(pageId);
27937
+ const hasChildPages = this.hasChildContainers(pageId, fetched.children);
27938
+ const folderOnly = hasChildPages && !fetched.hasBody || fetched.folderFiles !== null && (hasChildPages || fetched.folderFiles.named);
27939
+ if (folderOnly) {
27940
+ if (!await folderExists()) return null;
27941
+ taken.add(pathKey(folder));
27942
+ return { path: folder, kind: "folder" };
27943
+ }
27944
+ const found2 = await untrackedIn(hasChildPages ? folder : parentPath);
27945
+ if (!found2) return null;
27946
+ const local = await readLocalNote(this.vaultFs, found2.path);
27947
+ if (local === void 0) return null;
27948
+ const rendered = await this.imageHandler.preview(
27949
+ () => this.renderPageNote({
27950
+ pageId,
27951
+ page,
27952
+ fetched,
27953
+ filePath: found2.path,
27954
+ title,
27955
+ titleFollowsName: extractTitle(found2.path) === title,
27956
+ downloadMedia: true,
27957
+ local: { content: local, markers: [] },
27958
+ adoptLocalMedia: true
27959
+ })
27960
+ );
27961
+ const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
27962
+ const outcome = relinkOutcome(
27963
+ found2.existing,
27964
+ same,
27965
+ this.unlinkedVault,
27966
+ this.config.sync.conflictStrategy
27967
+ );
27968
+ if (outcome === null) return null;
27969
+ taken.add(pathKey(found2.path));
27970
+ if (outcome !== "conflict") return { path: found2.path, kind: "note" };
27971
+ const parentId = await extractParentId(this.notionClient, page);
27972
+ return {
27973
+ path: found2.path,
27974
+ kind: "note",
27975
+ conflict: relinkConflict({
27976
+ record: plannedRelinkRecord({
27977
+ path: found2.path,
27978
+ pageId,
27979
+ parentId,
27980
+ remote: rendered.content,
27981
+ lastEdited: page.last_edited_time,
27982
+ fileType: hasChildPages ? "folder-note" : "file"
27983
+ }),
27984
+ path: found2.path,
27985
+ local,
27986
+ remote: rendered.content,
27987
+ pageId,
27988
+ lastEdited: page.last_edited_time,
27989
+ title
27990
+ })
27991
+ };
27485
27992
  }
27486
27993
  /**
27487
27994
  * 새 페이지가 놓일 자리가 뺀 자리인가 — 부모 폴더 · 노트 이름 · 폴더 노트가 될 때의 폴더를 본다. 받기 전에는 폴더 노트가
@@ -27546,7 +28053,10 @@ var PagePuller = class {
27546
28053
  const local = await readLocalNote(this.vaultFs, filePath);
27547
28054
  rendered = await render2(filePath, local);
27548
28055
  const relinked = local === void 0 ? null : this.relinkExistingNote(filePath, claim.existing, local, rendered, note);
27549
- if (relinked) return relinked;
28056
+ if (relinked) {
28057
+ await this.receiveNoteFiles(filePath, fileType, pageId, fetched);
28058
+ return relinked;
28059
+ }
27550
28060
  this.claimedPaths.delete(pathKey(filePath));
27551
28061
  filePath = (await this.resolveUniqueFilePath(noteDir, safeName, pageId)).path;
27552
28062
  rendered = await render2(filePath);
@@ -27559,24 +28069,35 @@ var PagePuller = class {
27559
28069
  properties: rendered.properties,
27560
28070
  status: "synced"
27561
28071
  });
28072
+ await this.receiveNoteFiles(filePath, fileType, pageId, fetched);
27562
28073
  return { path: filePath };
27563
28074
  }
28075
+ /** 새로 받은 노트 페이지의 폴더 첨부를 그 노트의 폴더에 받는다(ADR-056) — 폴더 노트는 그 폴더, 노트는 이름의 폴더. */
28076
+ async receiveNoteFiles(filePath, fileType, pageId, fetched) {
28077
+ if (fetched.noteFiles.length === 0) return;
28078
+ await this.receiveFolderFiles(
28079
+ childFolderOf({ fileType, obsidianPath: filePath }, this.placement.folderLookup()),
28080
+ pageId,
28081
+ fetched.noteFiles,
28082
+ fetched.noteFilesComplete
28083
+ );
28084
+ }
27564
28085
  /**
27565
28086
  * 상태 기록에 없는 노트가 이 페이지가 놓일 자리에 이미 있다 — 상태 DB 를 잃었거나 처음 잇는 볼트다(P7).
27566
28087
  * 예전에는 남의 노트로 보고 id 를 붙인 이름으로 또 받아 모든 노트가 두 벌이 됐다(push 는 모든 페이지를 또 만들었다).
27567
- *
27568
- * - 글이 같으면(끝 개행만 달라도) 그 노트를 이 페이지와 다시 잇는다 — 쓰지 않는다.
27569
- * - 다르면 이 페이지 id 를 단 이름(지난번 이 페이지를 받은 이름)이면 충돌 전략을 따른다: remote-first 는 원격으로 덮고 ·
27570
- * local-first 는 로컬을 두어 다음 push 가 올리고 · 그 밖은 충돌로 남긴다(지난 사본이 없어 merge 도 합칠 수 없다).
27571
- * - 자연 이름에 있는 다른 글은 이은 노트가 하나도 없는 볼트에서만 잇고 전략과 상관없이 충돌로 남긴다 — 다른 노트일 수 있어
27572
- * 덮거나 올리면 잃는다. 이은 노트가 있는 볼트에서는 잇지 않는다(null) — 그 볼트의 새 노트일 수 있다.
28088
+ * 받은 글과 견줘 {@link relinkOutcome} 대로 잇는다 — 잇지 않으면 null(id 를 단 새 이름으로 받는다).
27573
28089
  */
27574
28090
  relinkExistingNote(filePath, existing, local, rendered, note) {
27575
28091
  const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
27576
- if (!same && existing === "name" && !this.unlinkedVault) return null;
28092
+ const outcome = relinkOutcome(
28093
+ existing,
28094
+ same,
28095
+ this.unlinkedVault,
28096
+ this.config.sync.conflictStrategy
28097
+ );
28098
+ if (outcome === null) return null;
27577
28099
  return (async () => {
27578
- const strategy = existing === "own" ? this.config.sync.conflictStrategy : "manual";
27579
- if (same || strategy === "local-first") {
28100
+ if (outcome === "relink") {
27580
28101
  await this.recordPulledNote(filePath, note, {
27581
28102
  content: rendered.content,
27582
28103
  properties: rendered.properties,
@@ -27587,7 +28108,7 @@ var PagePuller = class {
27587
28108
  });
27588
28109
  return { path: filePath, relinked: true };
27589
28110
  }
27590
- if (strategy === "remote-first") {
28111
+ if (outcome === "overwrite") {
27591
28112
  await this.vaultFs.writeFile(filePath, rendered.content);
27592
28113
  await this.recordPulledNote(filePath, note, {
27593
28114
  content: rendered.content,
@@ -27603,27 +28124,15 @@ var PagePuller = class {
27603
28124
  snapshot: null,
27604
28125
  differsOnDisk: true
27605
28126
  });
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
- };
28127
+ const conflict = relinkConflict({
28128
+ record: this.stateDb.getByPath(filePath),
28129
+ path: filePath,
28130
+ local,
28131
+ remote: rendered.content,
28132
+ pageId: note.pageId,
28133
+ lastEdited: note.page.last_edited_time,
28134
+ title: note.title
28135
+ });
27627
28136
  return { path: filePath, relinked: true, conflict };
27628
28137
  })();
27629
28138
  }
@@ -27731,6 +28240,14 @@ var PagePuller = class {
27731
28240
  }
27732
28241
  const rendered = await this.renderRemotePage(record, change.pageId, page);
27733
28242
  const remoteContent = rendered.content;
28243
+ if (rendered.folderFiles && !remoteGone) {
28244
+ await this.receiveFolderFiles(
28245
+ childFolderOf(record, this.placement.folderLookup()),
28246
+ change.pageId,
28247
+ rendered.folderFiles.files,
28248
+ rendered.folderFiles.complete
28249
+ );
28250
+ }
27734
28251
  let localContent;
27735
28252
  let localExists = true;
27736
28253
  try {
@@ -27874,7 +28391,10 @@ var PagePuller = class {
27874
28391
  if (record.fileType === "db-row") {
27875
28392
  return this.databaseSyncer.renderRow(page, record.obsidianPath, { downloadMedia });
27876
28393
  }
27877
- const fetched = await this.fetchPageMarkdown(pageId);
28394
+ const local = await readLocalNote(this.vaultFs, record.obsidianPath);
28395
+ const fetched = await this.fetchPageMarkdown(pageId, {
28396
+ embedded: embeddedNamesOf(local === void 0 ? [] : [local])
28397
+ });
27878
28398
  const title = this.notionClient.extractTitle(page);
27879
28399
  const { content, properties } = await this.renderPageNote({
27880
28400
  pageId,
@@ -27885,11 +28405,17 @@ var PagePuller = class {
27885
28405
  titleFollowsName: this.titleFollowsName(record, title),
27886
28406
  downloadMedia,
27887
28407
  local: {
27888
- content: await readLocalNote(this.vaultFs, record.obsidianPath),
28408
+ content: local,
27889
28409
  markers: this.stateDb.getPreserveMarkers(record.obsidianPath)
27890
28410
  }
27891
28411
  });
27892
- return { content, title, properties, bodyFingerprint: fetched.fingerprint };
28412
+ return {
28413
+ content,
28414
+ title,
28415
+ properties,
28416
+ bodyFingerprint: fetched.fingerprint,
28417
+ folderFiles: { files: fetched.noteFiles, complete: fetched.noteFilesComplete }
28418
+ };
27893
28419
  }
27894
28420
  /**
27895
28421
  * 받은 페이지 본문을 볼트에 쓸 노트로 — 새로 받기(`createPulledPage`)와 다시 받기({@link renderRemotePage})가
@@ -27957,19 +28483,27 @@ var PagePuller = class {
27957
28483
  /**
27958
28484
  * 페이지 본문을 볼트 쪽 markdown 으로 받는다.
27959
28485
  *
28486
+ * @param options.embedded 로컬 노트가 임베드한 파일 이름 — 폴더 첨부({@link folderFilesInNote})를 노트에서 떼어
28487
+ * `noteFiles` 로 돌려줄 때, 이 이름 · 이 볼트가 노트 첨부로 올린 파일은 노트의 것으로 둔다(ADR-056).
27960
28488
  * @returns `fingerprint` 는 받은 원격 본문의 지문({@link remoteBodyFingerprint}). 블록 API 로
27961
28489
  * 받았으면 null — 지문은 Markdown API 의 본문으로만 견준다.
27962
28490
  */
27963
- async fetchPageMarkdown(pageId) {
28491
+ async fetchPageMarkdown(pageId, options = {}) {
28492
+ const noteFilesOf = (markdown) => this.noteFolderFilesOf(pageId, markdown, options);
27964
28493
  if (this.config.conversion.preferMarkdownApi !== false) {
27965
28494
  try {
27966
28495
  const result = await this.notionClient.getPageMarkdown(pageId);
27967
28496
  this.discovery.collectInlineDbRefs(pageId, result.markdown);
27968
28497
  this.detector.noteBodyChildren(pageId, result.markdown);
28498
+ const found2 = noteFilesOf(result.markdown);
27969
28499
  return {
27970
28500
  content: await this.resolveBlockLinks(
27971
28501
  this.resolveNotionIdWikilinks(
27972
- await notionBodyToObsidian(this.notionClient, result.markdown, pageId)
28502
+ await notionBodyToObsidian(
28503
+ this.notionClient,
28504
+ found2?.markdown ?? result.markdown,
28505
+ pageId
28506
+ )
27973
28507
  ),
27974
28508
  pageId
27975
28509
  ),
@@ -27979,7 +28513,9 @@ var PagePuller = class {
27979
28513
  children: extractChildTags(result.markdown),
27980
28514
  fingerprint: remoteBodyFingerprint(result.markdown),
27981
28515
  hasBody: hasBodyBesidesChildren(result.markdown),
27982
- folderFiles: folderFilesInBody(result.markdown)
28516
+ folderFiles: folderFilesInBody(result.markdown),
28517
+ noteFiles: found2?.files ?? [],
28518
+ noteFilesComplete: !result.truncated && this.noteFilesHoldAll(pageId, found2?.markdown ?? result.markdown)
27983
28519
  };
27984
28520
  } catch (error) {
27985
28521
  getLogger().warn(
@@ -27987,7 +28523,14 @@ var PagePuller = class {
27987
28523
  );
27988
28524
  }
27989
28525
  }
27990
- const fallback = await pageBlocksToObsidian(this.notionClient, pageId);
28526
+ let noteFiles = [];
28527
+ let noteFilesComplete = true;
28528
+ const fallback = await pageBlocksToObsidian(this.notionClient, pageId, (markdown) => {
28529
+ const found2 = noteFilesOf(markdown);
28530
+ noteFiles = found2?.files ?? [];
28531
+ noteFilesComplete = this.noteFilesHoldAll(pageId, found2?.markdown ?? markdown);
28532
+ return found2?.markdown ?? markdown;
28533
+ });
27991
28534
  this.discovery.collectInlineDbRefs(pageId, fallback.markdown);
27992
28535
  this.detector.noteBodyChildren(pageId, fallback.markdown);
27993
28536
  return {
@@ -27999,9 +28542,28 @@ var PagePuller = class {
27999
28542
  children: extractChildTags(fallback.markdown),
28000
28543
  fingerprint: null,
28001
28544
  hasBody: hasBodyBesidesChildren(fallback.markdown),
28002
- folderFiles: folderFilesInBody(fallback.markdown)
28545
+ folderFiles: folderFilesInBody(fallback.markdown),
28546
+ noteFiles,
28547
+ noteFilesComplete
28003
28548
  };
28004
28549
  }
28550
+ /** 노트 페이지의 폴더 첨부({@link folderFilesInNote}) — 없으면 null. */
28551
+ noteFolderFilesOf(pageId, markdown, options) {
28552
+ const media = recallUploadedMedia(this.stateDb, pageId);
28553
+ return folderFilesInNote(markdown, {
28554
+ knownIds: new Set(folderFileIdsOf(this.stateDb, pageId).values()),
28555
+ exclude: (file) => {
28556
+ const fileId = notionFileIdOf(file.url);
28557
+ return options.embedded?.has(file.name) === true || fileId !== null && media.has(fileId);
28558
+ }
28559
+ });
28560
+ }
28561
+ /** 폴더 첨부를 뗀 본문에 적어 둔 폴더 첨부가 남지 않았나 — {@link fetchPageMarkdown} 의 `noteFilesComplete`. */
28562
+ noteFilesHoldAll(pageId, rest) {
28563
+ const ids = new Set(folderFileIdsOf(this.stateDb, pageId).values());
28564
+ if (ids.size === 0) return true;
28565
+ return !notionMediaSpans(rest).some((span) => ids.has(notionFileIdOf(span.url) ?? ""));
28566
+ }
28005
28567
  /** 폴더 첨부를 받아 받은 파일 수에 더한다. */
28006
28568
  async receiveFolderFiles(folder, pageId, files, complete2 = false) {
28007
28569
  this._pullFileCount += await this.fileHandler.receiveFolderFiles(
@@ -28413,7 +28975,7 @@ function resolvesNow(stateDb, record, marker, pushed) {
28413
28975
 
28414
28976
  // src/sync/pull-planner.ts
28415
28977
  var PullPlanner = class {
28416
- constructor(config, stateDb, vaultFs, databaseSyncer, drift, planner, puller) {
28978
+ constructor(config, stateDb, vaultFs, databaseSyncer, drift, planner, puller, discovery, detector) {
28417
28979
  this.config = config;
28418
28980
  this.stateDb = stateDb;
28419
28981
  this.vaultFs = vaultFs;
@@ -28421,6 +28983,8 @@ var PullPlanner = class {
28421
28983
  this.drift = drift;
28422
28984
  this.planner = planner;
28423
28985
  this.puller = puller;
28986
+ this.discovery = discovery;
28987
+ this.detector = detector;
28424
28988
  }
28425
28989
  config;
28426
28990
  stateDb;
@@ -28429,11 +28993,13 @@ var PullPlanner = class {
28429
28993
  drift;
28430
28994
  planner;
28431
28995
  puller;
28996
+ discovery;
28997
+ detector;
28432
28998
  /**
28433
28999
  * pull 할 것을 세기만 한다(dry-run) — 볼트 · 상태 DB · Notion 을 바꾸지 않는다. 페이지와 DB 행을
28434
- * 같이 센다. DB 는 설정한 DB 와 이미 발견해 둔 DB 가운데 실제 pull 이 조회할 것(`ledger`)만 센다 —
28435
- * 이번 pull 이 새로 발견할 DB 는 받아 봐야 안다. 세지 못한 DB 는 이유와 함께 `failed` 에 싣는다
28436
- * (세지 못한 것을 없다고 하지 않는다).
29000
+ * 같이 센다. DB 는 설정한 DB 와 이미 발견해 둔 DB 가운데 실제 pull 이 조회할 것(`ledger`)과, 이번 pull 이 새로 찾을
29001
+ * DB(루트 · 미리 보며 읽은 본문 안)를 센다. 세지 못한 DB 는 이유와 함께 `failed` 에 싣는다(세지 못한 것을 없다고 하지
29002
+ * 않는다).
28437
29003
  */
28438
29004
  async planPull(filtered, restoreChanges, restoreIds, localPlan, options, startTime, scan, ledger) {
28439
29005
  const unchangedDropped = await this.drift.withoutUnchangedRemotes(filtered, options.signal);
@@ -28459,20 +29025,36 @@ var PullPlanner = class {
28459
29025
  planned.push(change);
28460
29026
  }
28461
29027
  const relinks = await this.plannedRelinks(planned);
29028
+ for (const relink of relinks.byPage.values()) {
29029
+ if (relink.conflict) conflicts.push(relink.conflict);
29030
+ }
28462
29031
  const queued = new Set(
28463
29032
  [...filtered, ...restoreChanges].map((change) => compactNotionId(change.pageId))
28464
29033
  );
28465
29034
  const movingInVault = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
29035
+ const relinkPaths = new Map(
29036
+ [...relinks.byPage].map(([pageId, relink]) => [compactNotionId(pageId), relink.path])
29037
+ );
29038
+ await this.discovery.readNewPageBodies(
29039
+ planned.filter((change) => change.type === "created").map((change) => change.pageId),
29040
+ options.signal
29041
+ );
29042
+ const newDatabases = await this.discovery.plannedNewDatabases(failed, {
29043
+ rescanRoot: this.detector.rootChanged(),
29044
+ notePathOf: (pageId) => relinkPaths.get(compactNotionId(pageId)) ?? this.stateDb.getByNotionId(pageId)?.obsidianPath ?? null
29045
+ });
28466
29046
  const rows = [];
28467
29047
  for (const row of await this.planDatabaseRows(
28468
29048
  options.paths,
28469
29049
  failed,
28470
29050
  ledger,
28471
29051
  movingInVault,
28472
- relinks.taken
29052
+ relinks.taken,
29053
+ newDatabases
28473
29054
  )) {
28474
29055
  if (queued.has(compactNotionId(row.pageId))) continue;
28475
29056
  if (row.resolution?.action === "conflict") conflicts.push(row.resolution.conflict);
29057
+ if (row.relinkConflict) conflicts.push(row.relinkConflict);
28476
29058
  else if (row.resolution?.action === "merge") mergedPaths.push(row.path);
28477
29059
  if (row.resolution?.action === "conflict" || row.resolution?.action === "skip") continue;
28478
29060
  rows.push(row);
@@ -28514,7 +29096,7 @@ var PullPlanner = class {
28514
29096
  const rowCount = (operation, restore = false) => rows.filter(
28515
29097
  (row) => row.operation === operation && (row.restore ?? false) === restore && !row.relink
28516
29098
  ).length;
28517
- const undiscoveredDatabases = !isDatabaseMode(this.config) && (discoveryScanPending(this.stateDb) || planned.some((c) => c.type === "created"));
29099
+ const undiscoveredDatabases = !isDatabaseMode(this.config) && planned.some((c) => c.type === "created" && !this.discovery.bodyRead(c.pageId));
28518
29100
  const relinkedPaths = [
28519
29101
  ...[...relinks.byPage.values()].filter((relink) => relink.kind === "note").map((relink) => relink.path),
28520
29102
  ...rows.filter((row) => row.relink).map((row) => row.path)
@@ -28617,11 +29199,12 @@ var PullPlanner = class {
28617
29199
  * (`DatabaseDiscovery.pullDiscoveredDatabases`)를 같은 규칙으로 고른다. 세지 못한 DB 는 이유와
28618
29200
  * 함께 `failed` 에 싣는다 — 세지 못한 것을 없다고 하지 않는다.
28619
29201
  */
28620
- async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken) {
29202
+ async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken, databases) {
28621
29203
  const configured = this.config.notion.databases ?? [];
28622
29204
  const rows = [];
29205
+ const found2 = new Set(databases.found);
28623
29206
  const plan = async (dbConfig, resolveDbFolder2) => {
28624
- if (!ledger.selects(dbConfig.databaseId)) {
29207
+ if (!found2.has(dbConfig) && !ledger.selects(dbConfig.databaseId)) {
28625
29208
  ledger.skip();
28626
29209
  return;
28627
29210
  }
@@ -28646,17 +29229,9 @@ var PullPlanner = class {
28646
29229
  };
28647
29230
  for (const dbConfig of configured) await plan(dbConfig);
28648
29231
  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);
29232
+ const discovered = databases.all;
28655
29233
  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
- }
29234
+ for (const dbConfig of discovered) await plan(dbConfig, resolveDbFolder);
28660
29235
  return rows;
28661
29236
  }
28662
29237
  };
@@ -29541,7 +30116,9 @@ var SyncOrchestrator = class {
29541
30116
  this.databaseSyncer,
29542
30117
  this.drift,
29543
30118
  this.planner,
29544
- this.puller
30119
+ this.puller,
30120
+ this.discovery,
30121
+ this.detector
29545
30122
  );
29546
30123
  this.pusher = new PagePusher(
29547
30124
  config,
@@ -29654,7 +30231,11 @@ var SyncOrchestrator = class {
29654
30231
  async pushVaultFiles(failed) {
29655
30232
  if (this.config.sync.syncFiles === false) return 0;
29656
30233
  try {
29657
- const fileResults = await this.fileHandler.pushAllFiles(failed, this.placement);
30234
+ const fileResults = await this.fileHandler.pushAllFiles(
30235
+ failed,
30236
+ this.placement,
30237
+ this.notePageWrites()
30238
+ );
29658
30239
  if (fileResults.length > 0) {
29659
30240
  getLogger().info(`[Im-Nobsidian] ${fileResults.length}\uAC1C \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC`);
29660
30241
  }
@@ -29664,6 +30245,42 @@ var SyncOrchestrator = class {
29664
30245
  return 0;
29665
30246
  }
29666
30247
  }
30248
+ /**
30249
+ * 폴더 첨부를 노트의 페이지에 붙이거나 지울 때({@link NotePageWrites}) — 쓰기 전에 원격과 맞던 노트만 쓴 뒤의 원격을 본
30250
+ * 것으로 적는다. 받지 않은 Notion 편집이 걸린 노트를 적으면 다음 pull 이 그 편집을 받지 않는다. 읽지 못하면 적지 않는다 —
30251
+ * 다음 push 가 그 노트를 올리지 않고 pull 을 먼저 하라고 알릴 뿐이다.
30252
+ */
30253
+ notePageWrites() {
30254
+ const inSync = /* @__PURE__ */ new Map();
30255
+ return {
30256
+ begin: async (pageId) => {
30257
+ const record = this.stateDb.getByNotionId(pageId);
30258
+ if (!record || isFolderRecord(record)) return;
30259
+ try {
30260
+ const page = await this.notionClient.getPage(pageId);
30261
+ if (await this.drift.remoteDrift(record, page) === "none")
30262
+ inSync.set(pageId, record.id);
30263
+ } catch (error) {
30264
+ getLogger().warn(
30265
+ `[Im-Nobsidian] \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBD99\uC77C \uB178\uD2B8\uC758 \uC6D0\uACA9\uC744 \uC77D\uC9C0 \uBABB\uD568 (${record.obsidianPath}): ${error instanceof Error ? error.message : String(error)}`
30266
+ );
30267
+ }
30268
+ },
30269
+ end: async (pageId) => {
30270
+ const recordId = inSync.get(pageId);
30271
+ if (recordId === void 0) return;
30272
+ inSync.delete(pageId);
30273
+ try {
30274
+ const page = await this.notionClient.getPage(pageId);
30275
+ this.observation.record(recordId, page, await this.drift.remoteBodyFingerprintOf(pageId));
30276
+ } catch (error) {
30277
+ getLogger().warn(
30278
+ `[Im-Nobsidian] \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBD99\uC778 \uB178\uD2B8\uC758 \uC6D0\uACA9\uC744 \uC801\uC9C0 \uBABB\uD568 (${pageId}): ${error instanceof Error ? error.message : String(error)}`
30279
+ );
30280
+ }
30281
+ }
30282
+ };
30283
+ }
29667
30284
  /**
29668
30285
  * @param mergedByPull sync dry-run 만 — 앞선 pull dry-run 이 합칠 노트. 실제 sync 는 합친 뒤 원격을 본 것으로
29669
30286
  * 적어 push 가 올린다 — dry-run 은 받지 않아 원격이 바뀐 그대로라, 원격을 보고 거절하지 않는다.
@@ -32242,7 +32859,7 @@ function hasColumnLayout(text) {
32242
32859
  var START_RE = new RegExp(`^[\\t ]*${COLUMN_LIST_START}[\\t ]*$`);
32243
32860
  var END_RE = new RegExp(`^[\\t ]*${COLUMN_LIST_END}[\\t ]*$`);
32244
32861
  var SEP_RE = new RegExp(`^[\\t ]*${COLUMN_SEP_SOURCE}[\\t ]*$`);
32245
- var FENCE_RE = /^[\t ]*(`{3,}|~{3,})/;
32862
+ var FENCE_RE2 = /^[\t ]*(`{3,}|~{3,})/;
32246
32863
  function readColumnLayout(lines) {
32247
32864
  const places = new Array(lines.length);
32248
32865
  let group2 = -1;
@@ -32266,7 +32883,7 @@ function readColumnLayout(lines) {
32266
32883
  open = null;
32267
32884
  };
32268
32885
  lines.forEach((line, i) => {
32269
- const fenceMatch = FENCE_RE.exec(line);
32886
+ const fenceMatch = FENCE_RE2.exec(line);
32270
32887
  if (fence) {
32271
32888
  if (fenceMatch && fenceMatch[1].startsWith(fence)) fence = null;
32272
32889
  return;