@im-nobsidian/core 0.20.0 → 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
@@ -15184,12 +15184,56 @@ function folderFilesOf(markdown, namedOnly) {
15184
15184
  for (const span of notionMediaSpans(rest)) {
15185
15185
  if (!/^https?:\/\//.test(span.url)) return null;
15186
15186
  const name = uploadedFileNameFromUrl(span.url);
15187
- 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
+ }
15188
15191
  rest = rest.replace(span.full, "");
15189
15192
  files.push({ url: span.url, name });
15190
15193
  }
15191
15194
  return files.length > 0 && rest.trim() === "" ? files : null;
15192
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
+ }
15193
15237
  var WIKI_EMBED_RE = /!\[\[([^\]|#^\n]+)/g;
15194
15238
  var MD_EMBED_RE = /!\[[^\]\n]*\]\(<?([^)>\s]+)/g;
15195
15239
  var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
@@ -15217,8 +15261,8 @@ function embeddedNamesOf(notes) {
15217
15261
  function keyOf3(pageId) {
15218
15262
  return `folder_files:${compactNotionId(pageId)}`;
15219
15263
  }
15220
- function folderFileIdsOf(stateDb, pageId) {
15221
- const raw = stateDb.getMeta(keyOf3(pageId));
15264
+ function readPathMap(stateDb, key) {
15265
+ const raw = stateDb.getMeta(key);
15222
15266
  if (!raw) return /* @__PURE__ */ new Map();
15223
15267
  try {
15224
15268
  return new Map(Object.entries(JSON.parse(raw)));
@@ -15226,16 +15270,39 @@ function folderFileIdsOf(stateDb, pageId) {
15226
15270
  return /* @__PURE__ */ new Map();
15227
15271
  }
15228
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
+ }
15229
15279
  function forgetFolderFileId(stateDb, pageId, localPath) {
15280
+ rememberFolderFileStamp(stateDb, pageId, localPath, null);
15230
15281
  const ids = folderFileIdsOf(stateDb, pageId);
15231
15282
  if (!ids.delete(localPath)) return;
15232
- stateDb.setMeta(keyOf3(pageId), JSON.stringify(Object.fromEntries(ids)));
15283
+ writePathMap(stateDb, keyOf3(pageId), ids);
15233
15284
  }
15234
15285
  function rememberFolderFileId(stateDb, pageId, localPath, fileId) {
15235
15286
  const ids = folderFileIdsOf(stateDb, pageId);
15236
15287
  if (ids.get(localPath) === fileId) return;
15237
15288
  ids.set(localPath, fileId);
15238
- 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);
15239
15306
  }
15240
15307
 
15241
15308
  // src/sync/file-handler.ts
@@ -15246,6 +15313,12 @@ function getFolderPath(filePath) {
15246
15313
  const parts = filePath.split("/");
15247
15314
  return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
15248
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
+ }
15249
15322
  var LOGGED_NAMES = 5;
15250
15323
  var FileHandler = class {
15251
15324
  constructor(vaultFs, notionClient, stateDb, concurrency = DEFAULT_CONFIG.advanced.fileConcurrency, options) {
@@ -15325,21 +15398,55 @@ var FileHandler = class {
15325
15398
  this.sema.release();
15326
15399
  }
15327
15400
  }
15328
- /** 올린 뒤 바뀌지 않았나 — 크기가 같으면 읽지 않는다. */
15401
+ /**
15402
+ * 올리거나 받은 뒤 바뀌지 않았나 — 그때의 표(크기 · 수정 시각, {@link fileStampOf})와 같으면 읽지 않는다. 크기가 다르면 바뀌었다.
15403
+ * 크기만 같으면 내용으로 견주고, 같으면 지금 표를 적어 다음에는 읽지 않는다. 예전에는 크기가 같으면 바뀌지 않았다고 봐, 같은
15404
+ * 크기로 고친 파일을 올리지 않았다.
15405
+ */
15329
15406
  async isUnchanged(existing, file) {
15330
- 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;
15331
15412
  const buffer = await this.vaultFs.readBinary(file.path);
15332
- 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;
15333
15416
  }
15334
15417
  /** pull 이 받은 것을 두는 폴더 안인가. */
15335
15418
  inAttachmentsFolder(path) {
15336
15419
  return this.attachmentsFolder !== "" && path.startsWith(`${this.attachmentsFolder}/`);
15337
15420
  }
15338
- /** 폴더 첨부를 둘 Notion 페이지 — 폴더 노트 없는 폴더의 페이지(폴더 레코드)만. 맨 위는 루트 기록이 있을 때만. */
15421
+ /**
15422
+ * 폴더 첨부를 둘 Notion 페이지 — 그 폴더의 Notion 자리({@link folderContainer} 와 같은 차례). 폴더 노트(`X/X.md`)나 노트
15423
+ * 이름의 폴더(`X.md` 옆 `X/`)면 그 노트의 페이지 끝(ADR-056), 아니면 push 가 만든 폴더 페이지. 맨 위는 루트 기록이 있을 때만.
15424
+ * 행 · DB 폴더는 둘 곳이 없다 — 행의 본문은 행의 것이다.
15425
+ */
15339
15426
  folderPageOf(folder) {
15340
- const record = this.stateDb.getByPath(folder || "__root__");
15341
- if (!record?.notionPageId) return null;
15342
- 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);
15343
15450
  }
15344
15451
  /**
15345
15452
  * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 아예 빼고, Notion 이 받지 않는
@@ -15367,7 +15474,7 @@ var FileHandler = class {
15367
15474
  * @param failed 올리지 못한 첨부를 담을 곳 — push 결과의 `failed` 를 넘기면 CLI · 플러그인이 처리하지 못한 것으로
15368
15475
  * 보인다. 예전엔 경고 로그로만 남아 push 가 「실패 0」 으로 끝났다.
15369
15476
  */
15370
- async pushAllFiles(failed, folders) {
15477
+ async pushAllFiles(failed, folders, noteWrites) {
15371
15478
  const { uploadable: allFiles, unsupported: unsupported2 } = await this.listUploadableFiles();
15372
15479
  if (unsupported2.length > 0) {
15373
15480
  const names = unsupported2.slice(0, LOGGED_NAMES).map((f) => f.path);
@@ -15376,13 +15483,13 @@ var FileHandler = class {
15376
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` : ""}`
15377
15484
  );
15378
15485
  }
15379
- if (this.deleteSync) await this.removeDeletedFolderFiles();
15486
+ if (this.deleteSync) await this.removeDeletedFolderFiles(noteWrites);
15380
15487
  const pending = [];
15381
15488
  for (const file of allFiles) {
15382
15489
  if (this.inAttachmentsFolder(file.path) || isHiddenPath(file.path)) continue;
15383
15490
  const existing = this.stateDb.getFileRegistry(file.path);
15384
15491
  if (existing) {
15385
- if (existing.notionPageId !== this.folderPageOf(getFolderPath(file.path))) continue;
15492
+ if (!this.isFolderFileEntry(existing, getFolderPath(file.path))) continue;
15386
15493
  if (await this.isUnchanged(existing, file)) continue;
15387
15494
  }
15388
15495
  pending.push({ file, existing });
@@ -15404,10 +15511,23 @@ var FileHandler = class {
15404
15511
  for (const [folder, items] of byFolder) {
15405
15512
  const pageId = await this.provideFolderPage(folder, folders, items, stranded);
15406
15513
  if (!pageId) continue;
15407
- for (const { file, existing } of items) {
15408
- const result = await this.pushSingleFile(file, pageId, existing, failed);
15409
- if (result) results.push(result);
15410
- }
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
+ );
15411
15531
  }
15412
15532
  this.reportStranded(stranded);
15413
15533
  return results;
@@ -15416,25 +15536,37 @@ var FileHandler = class {
15416
15536
  * 볼트에서 지운 폴더 첨부를 Notion 에서도 지운다(deleteSync) — 폴더 페이지에 적힌 파일 가운데 볼트에 없는 것의 블록과 기록.
15417
15537
  * 포함 · 제외 패턴으로 빠진 파일은 볼트에 있어 지우지 않는다.
15418
15538
  */
15419
- async removeDeletedFolderFiles() {
15539
+ async removeDeletedFolderFiles(noteWrites) {
15420
15540
  const removed = [];
15421
15541
  for (const record of this.stateDb.getAll()) {
15422
- 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 = [];
15423
15548
  for (const entry of this.stateDb.getFilesByPageId(record.notionPageId)) {
15424
- if (getFolderPath(entry.localPath) !== record.obsidianPath) continue;
15425
- if (await this.vaultFs.exists(entry.localPath)) continue;
15426
- try {
15427
- const name = entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1);
15428
- await this.removeOlderBlocks(record.notionPageId, name, void 0);
15429
- this.stateDb.deleteFileRegistry(entry.localPath);
15430
- forgetFolderFileId(this.stateDb, record.notionPageId, entry.localPath);
15431
- removed.push(entry.localPath);
15432
- } catch (error) {
15433
- getLogger().warn(
15434
- `[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)}`
15435
- );
15436
- }
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);
15437
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
+ });
15438
15570
  }
15439
15571
  if (removed.length > 0) {
15440
15572
  getLogger().info(
@@ -15442,10 +15574,20 @@ var FileHandler = class {
15442
15574
  );
15443
15575
  }
15444
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
+ }
15445
15587
  /** 폴더 첨부를 둘 페이지 — 노트 없는 폴더면 마련한다. 둘 곳이 없으면 그 파일들을 `stranded` 에 담고 null. */
15446
15588
  async provideFolderPage(folder, folders, items, stranded) {
15447
15589
  let pageId = this.folderPageOf(folder);
15448
- 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";
15449
15591
  if (!pageId && folder !== "" && folders && !this.stateDb.getByPath(folder)) {
15450
15592
  try {
15451
15593
  await folders.ensureFolderPage(folder);
@@ -15594,6 +15736,8 @@ var FileHandler = class {
15594
15736
  fileHash: hash,
15595
15737
  fileSize: buffer.length
15596
15738
  });
15739
+ const stat2 = await this.vaultFs.getFileStat(localPath);
15740
+ if (stat2) rememberFolderFileStamp(this.stateDb, pageId, localPath, fileStampOf(stat2));
15597
15741
  return wrote;
15598
15742
  }
15599
15743
  async downloadFileBlock(url, filename, targetFolder) {
@@ -15635,7 +15779,9 @@ var FileHandler = class {
15635
15779
  const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
15636
15780
  const block = this.createFileBlock(blockType, fileUploadId, filename);
15637
15781
  const [created] = await this.notionClient.appendChildBlocks(parentPageId, [block]);
15638
- if (previous) await this.removeOlderBlocks(parentPageId, filename, created?.id);
15782
+ if (previous) {
15783
+ await this.removeOlderBlocks(parentPageId, file.path, filename, created?.id);
15784
+ }
15639
15785
  const url = created ? fileUrlOfBlock(created) : null;
15640
15786
  const fileId = url ? notionFileIdOf(url) : null;
15641
15787
  if (fileId) rememberFolderFileId(this.stateDb, parentPageId, file.path, fileId);
@@ -15647,28 +15793,78 @@ var FileHandler = class {
15647
15793
  fileHash: hash,
15648
15794
  fileSize: file.size
15649
15795
  });
15796
+ rememberFolderFileStamp(this.stateDb, parentPageId, file.path, fileStampOf(file));
15650
15797
  getLogger().info(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC: ${file.path} \u2192 ${blockType} \uBE14\uB85D`);
15651
15798
  return { localPath: file.path, fileUploadId, blockType };
15652
15799
  }
15653
- /** 이 페이지에서 같은 이름의 파일 블록 중 방금 붙인 것 말고를 지운다. */
15654
- 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);
15655
15843
  for (const block of await this.notionClient.fetchAllChildren(pageId)) {
15656
15844
  if (block.id === keep) continue;
15657
15845
  const media = block[block.type];
15658
15846
  const url = media?.type === "file" ? media.file?.url : void 0;
15659
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
+ }
15660
15855
  await this.notionClient.deleteBlock(block.id);
15661
15856
  }
15662
15857
  }
15663
15858
  /**
15664
- * 폴더 첨부의 파일 블록 — 캡션에 파일 이름을 적는다. 받는 쪽이 그 표시로 폴더 첨부를 알아본다({@link folderFilesOf}) —
15665
- * 자식 페이지가 없는 폴더 페이지는 PDF 하나만 붙인 노트와 모양이 같다.
15859
+ * 폴더 첨부의 파일 블록 — 캡션에 표시를 단 파일 이름을 적는다({@link folderFileCaption}). 받는 쪽이 그 표시로 폴더 첨부를
15860
+ * 알아본다 — 자식 페이지가 없는 폴더 페이지는 PDF 하나만 붙인 노트와 모양이 같고, 노트 페이지의 폴더 첨부는 그 노트의
15861
+ * 임베드와 같은 블록이다.
15666
15862
  */
15667
15863
  createFileBlock(blockType, fileUploadId, name) {
15668
15864
  const fileRef = {
15669
15865
  type: "file_upload",
15670
15866
  file_upload: { id: fileUploadId },
15671
- caption: [{ type: "text", text: { content: name } }]
15867
+ caption: [{ type: "text", text: { content: folderFileCaption(name) } }]
15672
15868
  };
15673
15869
  switch (blockType) {
15674
15870
  case "image":
@@ -19167,7 +19363,9 @@ var NoteRelocator = class {
19167
19363
  if (isFolderRecord(record)) return [record.obsidianPath];
19168
19364
  if (record.fileType === "folder-note") return [parentFolderOf(record.obsidianPath)];
19169
19365
  const children = stripMarkdownExtension(record.obsidianPath);
19170
- 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
+ );
19171
19369
  return hasChildren ? [record.obsidianPath, children] : [record.obsidianPath];
19172
19370
  }
19173
19371
  /** 옮기지 못할 까닭 — 없으면 null. */
@@ -19400,7 +19598,7 @@ async function notionBodyToObsidian(client, markdown, pageId) {
19400
19598
  const bookmarks = bookmarkIds.length > 0 ? await client.getBookmarks(bookmarkIds) : void 0;
19401
19599
  return notionEnhancedToObsidian(markdown, { userNames, codeTexts, bookmarks });
19402
19600
  }
19403
- async function pageBlocksToObsidian(client, pageId) {
19601
+ async function pageBlocksToObsidian(client, pageId, bodyOf5 = (markdown) => markdown) {
19404
19602
  const tree = await client.getBlockTree(pageId);
19405
19603
  const markdown = blocksToNfm(tree, { pageId });
19406
19604
  const content = await notionBodyToObsidian(
@@ -19409,7 +19607,7 @@ async function pageBlocksToObsidian(client, pageId) {
19409
19607
  getCodeBlockTexts: () => Promise.resolve(blocksOfType(tree, "code").map(plainTextOf)),
19410
19608
  getBookmarks: () => Promise.resolve(bookmarksOf(tree))
19411
19609
  },
19412
- markdown,
19610
+ bodyOf5(markdown),
19413
19611
  pageId
19414
19612
  );
19415
19613
  return { markdown, content };
@@ -23492,6 +23690,7 @@ function dropEmbeddedBaseMarkers(content, basePaths) {
23492
23690
 
23493
23691
  // src/sync/database-discovery.ts
23494
23692
  var DISCOVERY_RETRY_META_KEY = "discovery_retry";
23693
+ var PREVIEW_BODY_READ_BUDGET = 100;
23495
23694
  function parseDiscoveryRetry(raw) {
23496
23695
  if (!raw) return { parents: [], dbs: [] };
23497
23696
  try {
@@ -23889,6 +24088,27 @@ var DatabaseDiscovery = class {
23889
24088
  )
23890
24089
  };
23891
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
+ }
23892
24112
  /** 이번 pull 에서 본문을 받은 페이지인가 — 그 본문의 `<database>` 로 새 DB 를 찾았다. */
23893
24113
  bodyRead(pageId) {
23894
24114
  return this._bodyReadPages.has(normalizeNotionId(pageId));
@@ -24725,7 +24945,14 @@ var LocalPlanner = class {
24725
24945
  live.add(target);
24726
24946
  }
24727
24947
  }
24728
- 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
+ ];
24729
24956
  const folderMoves = [];
24730
24957
  const databaseFolderMoves = [];
24731
24958
  for (const move of deriveFolderMoves(tracked, live, pairs, hints)) {
@@ -24734,6 +24961,29 @@ var LocalPlanner = class {
24734
24961
  }
24735
24962
  return { folderMoves, databaseFolderMoves };
24736
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
+ }
24737
24987
  /** 레코드를 새 경로로 옮겨 적는다 — 이동 WAL · 위키링크 · 보존 마커도 함께. */
24738
24988
  moveRecord(record, to) {
24739
24989
  const from = record.obsidianPath;
@@ -27355,11 +27605,14 @@ var PagePusher = class {
27355
27605
  async pushUpdatePage(pageId, markdownContent, notePath) {
27356
27606
  if (this.config.conversion.preferMarkdownApi !== false) {
27357
27607
  const deferred = deferSyncedReferences(obsidianToNotionEnhanced(markdownContent));
27358
- const { markdown, kept } = await this.imageHandler.keepUploadedMedia(
27608
+ const { markdown: body, kept } = await this.imageHandler.keepUploadedMedia(
27359
27609
  pageId,
27360
27610
  deferred.nfm,
27361
27611
  notePath
27362
27612
  );
27613
+ const folderFiles = await this.folderFileLines(pageId);
27614
+ const markdown = folderFiles ? `${body}
27615
+ ${folderFiles}` : body;
27363
27616
  const written = await replacePageBody(this.notionClient, pageId, markdown, {
27364
27617
  databaseIdsOfBase: (basePath) => this.databaseIdsOfBase(basePath),
27365
27618
  unknownTagFallbacks: unknownTagFallbacks(markdownContent)
@@ -27379,6 +27632,7 @@ var PagePusher = class {
27379
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"
27380
27633
  );
27381
27634
  }
27635
+ this.forgetFolderFilesForBlockMode(pageId, notePath);
27382
27636
  const { nfm, blocks } = blockModeBody(markdownContent);
27383
27637
  if (blocks.length > 0) {
27384
27638
  await this.notionClient.appendBlockTree(pageId, blocks);
@@ -27396,6 +27650,32 @@ var PagePusher = class {
27396
27650
  );
27397
27651
  return { fingerprint: null, kept: [], sent: nfm, syncedRefs: [], syncedRefText: "" };
27398
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
+ }
27399
27679
  /**
27400
27680
  * 자리표시로 보낸 코드를 채우고(S-22) 동기화 블록 참조를 붙인다({@link deferSyncedReferences}) — 본문을
27401
27681
  * 바꾼 뒤(블록 방식이면 옛 블록을 지운 뒤) 부른다.
@@ -27773,7 +28053,10 @@ var PagePuller = class {
27773
28053
  const local = await readLocalNote(this.vaultFs, filePath);
27774
28054
  rendered = await render2(filePath, local);
27775
28055
  const relinked = local === void 0 ? null : this.relinkExistingNote(filePath, claim.existing, local, rendered, note);
27776
- if (relinked) return relinked;
28056
+ if (relinked) {
28057
+ await this.receiveNoteFiles(filePath, fileType, pageId, fetched);
28058
+ return relinked;
28059
+ }
27777
28060
  this.claimedPaths.delete(pathKey(filePath));
27778
28061
  filePath = (await this.resolveUniqueFilePath(noteDir, safeName, pageId)).path;
27779
28062
  rendered = await render2(filePath);
@@ -27786,8 +28069,19 @@ var PagePuller = class {
27786
28069
  properties: rendered.properties,
27787
28070
  status: "synced"
27788
28071
  });
28072
+ await this.receiveNoteFiles(filePath, fileType, pageId, fetched);
27789
28073
  return { path: filePath };
27790
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
+ }
27791
28085
  /**
27792
28086
  * 상태 기록에 없는 노트가 이 페이지가 놓일 자리에 이미 있다 — 상태 DB 를 잃었거나 처음 잇는 볼트다(P7).
27793
28087
  * 예전에는 남의 노트로 보고 id 를 붙인 이름으로 또 받아 모든 노트가 두 벌이 됐다(push 는 모든 페이지를 또 만들었다).
@@ -27946,6 +28240,14 @@ var PagePuller = class {
27946
28240
  }
27947
28241
  const rendered = await this.renderRemotePage(record, change.pageId, page);
27948
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
+ }
27949
28251
  let localContent;
27950
28252
  let localExists = true;
27951
28253
  try {
@@ -28089,7 +28391,10 @@ var PagePuller = class {
28089
28391
  if (record.fileType === "db-row") {
28090
28392
  return this.databaseSyncer.renderRow(page, record.obsidianPath, { downloadMedia });
28091
28393
  }
28092
- 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
+ });
28093
28398
  const title = this.notionClient.extractTitle(page);
28094
28399
  const { content, properties } = await this.renderPageNote({
28095
28400
  pageId,
@@ -28100,11 +28405,17 @@ var PagePuller = class {
28100
28405
  titleFollowsName: this.titleFollowsName(record, title),
28101
28406
  downloadMedia,
28102
28407
  local: {
28103
- content: await readLocalNote(this.vaultFs, record.obsidianPath),
28408
+ content: local,
28104
28409
  markers: this.stateDb.getPreserveMarkers(record.obsidianPath)
28105
28410
  }
28106
28411
  });
28107
- 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
+ };
28108
28419
  }
28109
28420
  /**
28110
28421
  * 받은 페이지 본문을 볼트에 쓸 노트로 — 새로 받기(`createPulledPage`)와 다시 받기({@link renderRemotePage})가
@@ -28172,19 +28483,27 @@ var PagePuller = class {
28172
28483
  /**
28173
28484
  * 페이지 본문을 볼트 쪽 markdown 으로 받는다.
28174
28485
  *
28486
+ * @param options.embedded 로컬 노트가 임베드한 파일 이름 — 폴더 첨부({@link folderFilesInNote})를 노트에서 떼어
28487
+ * `noteFiles` 로 돌려줄 때, 이 이름 · 이 볼트가 노트 첨부로 올린 파일은 노트의 것으로 둔다(ADR-056).
28175
28488
  * @returns `fingerprint` 는 받은 원격 본문의 지문({@link remoteBodyFingerprint}). 블록 API 로
28176
28489
  * 받았으면 null — 지문은 Markdown API 의 본문으로만 견준다.
28177
28490
  */
28178
- async fetchPageMarkdown(pageId) {
28491
+ async fetchPageMarkdown(pageId, options = {}) {
28492
+ const noteFilesOf = (markdown) => this.noteFolderFilesOf(pageId, markdown, options);
28179
28493
  if (this.config.conversion.preferMarkdownApi !== false) {
28180
28494
  try {
28181
28495
  const result = await this.notionClient.getPageMarkdown(pageId);
28182
28496
  this.discovery.collectInlineDbRefs(pageId, result.markdown);
28183
28497
  this.detector.noteBodyChildren(pageId, result.markdown);
28498
+ const found2 = noteFilesOf(result.markdown);
28184
28499
  return {
28185
28500
  content: await this.resolveBlockLinks(
28186
28501
  this.resolveNotionIdWikilinks(
28187
- await notionBodyToObsidian(this.notionClient, result.markdown, pageId)
28502
+ await notionBodyToObsidian(
28503
+ this.notionClient,
28504
+ found2?.markdown ?? result.markdown,
28505
+ pageId
28506
+ )
28188
28507
  ),
28189
28508
  pageId
28190
28509
  ),
@@ -28194,7 +28513,9 @@ var PagePuller = class {
28194
28513
  children: extractChildTags(result.markdown),
28195
28514
  fingerprint: remoteBodyFingerprint(result.markdown),
28196
28515
  hasBody: hasBodyBesidesChildren(result.markdown),
28197
- folderFiles: folderFilesInBody(result.markdown)
28516
+ folderFiles: folderFilesInBody(result.markdown),
28517
+ noteFiles: found2?.files ?? [],
28518
+ noteFilesComplete: !result.truncated && this.noteFilesHoldAll(pageId, found2?.markdown ?? result.markdown)
28198
28519
  };
28199
28520
  } catch (error) {
28200
28521
  getLogger().warn(
@@ -28202,7 +28523,14 @@ var PagePuller = class {
28202
28523
  );
28203
28524
  }
28204
28525
  }
28205
- 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
+ });
28206
28534
  this.discovery.collectInlineDbRefs(pageId, fallback.markdown);
28207
28535
  this.detector.noteBodyChildren(pageId, fallback.markdown);
28208
28536
  return {
@@ -28214,9 +28542,28 @@ var PagePuller = class {
28214
28542
  children: extractChildTags(fallback.markdown),
28215
28543
  fingerprint: null,
28216
28544
  hasBody: hasBodyBesidesChildren(fallback.markdown),
28217
- folderFiles: folderFilesInBody(fallback.markdown)
28545
+ folderFiles: folderFilesInBody(fallback.markdown),
28546
+ noteFiles,
28547
+ noteFilesComplete
28218
28548
  };
28219
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
+ }
28220
28567
  /** 폴더 첨부를 받아 받은 파일 수에 더한다. */
28221
28568
  async receiveFolderFiles(folder, pageId, files, complete2 = false) {
28222
28569
  this._pullFileCount += await this.fileHandler.receiveFolderFiles(
@@ -28688,6 +29035,10 @@ var PullPlanner = class {
28688
29035
  const relinkPaths = new Map(
28689
29036
  [...relinks.byPage].map(([pageId, relink]) => [compactNotionId(pageId), relink.path])
28690
29037
  );
29038
+ await this.discovery.readNewPageBodies(
29039
+ planned.filter((change) => change.type === "created").map((change) => change.pageId),
29040
+ options.signal
29041
+ );
28691
29042
  const newDatabases = await this.discovery.plannedNewDatabases(failed, {
28692
29043
  rescanRoot: this.detector.rootChanged(),
28693
29044
  notePathOf: (pageId) => relinkPaths.get(compactNotionId(pageId)) ?? this.stateDb.getByNotionId(pageId)?.obsidianPath ?? null
@@ -29880,7 +30231,11 @@ var SyncOrchestrator = class {
29880
30231
  async pushVaultFiles(failed) {
29881
30232
  if (this.config.sync.syncFiles === false) return 0;
29882
30233
  try {
29883
- 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
+ );
29884
30239
  if (fileResults.length > 0) {
29885
30240
  getLogger().info(`[Im-Nobsidian] ${fileResults.length}\uAC1C \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC`);
29886
30241
  }
@@ -29890,6 +30245,42 @@ var SyncOrchestrator = class {
29890
30245
  return 0;
29891
30246
  }
29892
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
+ }
29893
30284
  /**
29894
30285
  * @param mergedByPull sync dry-run 만 — 앞선 pull dry-run 이 합칠 노트. 실제 sync 는 합친 뒤 원격을 본 것으로
29895
30286
  * 적어 push 가 올린다 — dry-run 은 받지 않아 원격이 바뀐 그대로라, 원격을 보고 거절하지 않는다.
@@ -32468,7 +32859,7 @@ function hasColumnLayout(text) {
32468
32859
  var START_RE = new RegExp(`^[\\t ]*${COLUMN_LIST_START}[\\t ]*$`);
32469
32860
  var END_RE = new RegExp(`^[\\t ]*${COLUMN_LIST_END}[\\t ]*$`);
32470
32861
  var SEP_RE = new RegExp(`^[\\t ]*${COLUMN_SEP_SOURCE}[\\t ]*$`);
32471
- var FENCE_RE = /^[\t ]*(`{3,}|~{3,})/;
32862
+ var FENCE_RE2 = /^[\t ]*(`{3,}|~{3,})/;
32472
32863
  function readColumnLayout(lines) {
32473
32864
  const places = new Array(lines.length);
32474
32865
  let group2 = -1;
@@ -32492,7 +32883,7 @@ function readColumnLayout(lines) {
32492
32883
  open = null;
32493
32884
  };
32494
32885
  lines.forEach((line, i) => {
32495
- const fenceMatch = FENCE_RE.exec(line);
32886
+ const fenceMatch = FENCE_RE2.exec(line);
32496
32887
  if (fence) {
32497
32888
  if (fenceMatch && fenceMatch[1].startsWith(fence)) fence = null;
32498
32889
  return;