@im-nobsidian/core 0.20.0 → 0.22.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
@@ -3753,7 +3753,7 @@ var NotionClient2 = class _NotionClient {
3753
3753
  return blocks;
3754
3754
  }
3755
3755
  /**
3756
- * 자식 블록을 덧붙인다. `options.after` 를 주면 그 블록 **바로 뒤**에 삽입한다
3756
+ * 자식 블록을 덧붙인다. `options.after` 를 주면 그 블록 **바로 뒤**에, `options.atStart` 면 맨 앞에 삽입한다
3757
3757
  * (기본은 맨 끝). 자리표시자를 실제 미디어 블록으로 제자리 교체할 때 필요하다.
3758
3758
  *
3759
3759
  * 실측(2025-09-03): position 삽입 응답의 `results` 는 방금 넣은 블록뿐 아니라
@@ -3771,19 +3771,24 @@ var NotionClient2 = class _NotionClient {
3771
3771
  const batchSize = this.batchSize;
3772
3772
  const created = [];
3773
3773
  let after = options?.after;
3774
+ let atStart = after === void 0 && options?.atStart === true;
3774
3775
  for (let i = 0; i < children.length; i += batchSize) {
3775
3776
  const batch = children.slice(i, i + batchSize);
3777
+ const position = after ? { position: { type: "after_block", after_block: { id: after } } } : atStart ? { position: { type: "start" } } : {};
3776
3778
  const response = await this.withRateLimit(
3777
3779
  () => this.client.blocks.children.append({
3778
3780
  block_id: blockId,
3779
3781
  children: batch,
3780
- ...after ? { position: { type: "after_block", after_block: { id: after } } } : {}
3782
+ ...position
3781
3783
  }),
3782
3784
  CREATES_BLOCKS
3783
3785
  );
3784
3786
  const blocks = response.results.slice(0, batch.length);
3785
3787
  created.push(...blocks);
3786
- if (after && blocks.length > 0) after = blocks[blocks.length - 1].id;
3788
+ if ((after || atStart) && blocks.length > 0) {
3789
+ after = blocks[blocks.length - 1].id;
3790
+ atStart = false;
3791
+ }
3787
3792
  }
3788
3793
  return created;
3789
3794
  }
@@ -3792,9 +3797,11 @@ var NotionClient2 = class _NotionClient {
3792
3797
  * 자식은 부모를 만든 뒤 그 아래에 마저 붙인다({@link shapeAppendRequests}). 더 깊은 블록의 id 는
3793
3798
  * 응답에 없어 부모의 자식 목록을 읽어 찾는다.
3794
3799
  *
3800
+ * `options.atStart` 면 맨 위 블록을 맨 앞에 넣는다 — 본문 뒤에 둘 블록(폴더 첨부)을 남기고 본문만 바꿀 때.
3801
+ *
3795
3802
  * 중간에 실패하면 그때까지 만든 블록은 남는다 — 호출측이 실패로 알린다.
3796
3803
  */
3797
- async appendBlockTree(parentId, blocks) {
3804
+ async appendBlockTree(parentId, blocks, options) {
3798
3805
  const listed = /* @__PURE__ */ new Map();
3799
3806
  const childIds = async (id) => {
3800
3807
  let ids = listed.get(id);
@@ -3805,15 +3812,19 @@ var NotionClient2 = class _NotionClient {
3805
3812
  return ids;
3806
3813
  };
3807
3814
  const queue = [
3808
- { parentId, blocks }
3815
+ { parentId, blocks, top: true }
3809
3816
  ];
3817
+ let topAfter;
3810
3818
  while (queue.length > 0) {
3811
3819
  const next = queue.shift();
3812
3820
  for (const request of shapeAppendRequests(next.blocks)) {
3821
+ const position = next.top && options?.atStart ? topAfter ? { after: topAfter } : { atStart: true } : void 0;
3813
3822
  const created = await this.appendChildren(
3814
3823
  next.parentId,
3815
- request.map((shaped) => shaped.body)
3824
+ request.map((shaped) => shaped.body),
3825
+ position
3816
3826
  );
3827
+ if (position) topAfter = created[created.length - 1] ?? topAfter;
3817
3828
  for (const [k, shaped] of request.entries()) {
3818
3829
  for (const pending of shaped.pending) {
3819
3830
  let id = created[k];
@@ -15174,6 +15185,21 @@ function selectStaleDbArtifacts(files, localFolder, keepPaths) {
15174
15185
  return stale;
15175
15186
  }
15176
15187
 
15188
+ // src/sync/parent-mode.ts
15189
+ function isDatabaseMode(config) {
15190
+ return config.notion.parentMode === "database" && !!config.notion.databaseId;
15191
+ }
15192
+ function rowDatabaseOf(config, record) {
15193
+ if (record.fileType === "db-row") {
15194
+ if (!record.notionParentId) {
15195
+ throw new Error(`DB \uD589\uC778\uB370 \uC18C\uC18D DB \uB97C \uC54C \uC218 \uC5C6\uC74C: ${record.obsidianPath}`);
15196
+ }
15197
+ return record.notionParentId;
15198
+ }
15199
+ if (isDatabaseMode(config)) return config.notion.databaseId;
15200
+ return null;
15201
+ }
15202
+
15177
15203
  // src/sync/folder-files.ts
15178
15204
  function unescapeCaption(caption) {
15179
15205
  return caption.replace(/\\([\\`*_{}[\]()#+\-.!|~<>$&])/g, "$1").trim();
@@ -15184,12 +15210,89 @@ function folderFilesOf(markdown, namedOnly) {
15184
15210
  for (const span of notionMediaSpans(rest)) {
15185
15211
  if (!/^https?:\/\//.test(span.url)) return null;
15186
15212
  const name = uploadedFileNameFromUrl(span.url);
15187
- if (!name || namedOnly && unescapeCaption(span.label) !== name) return null;
15213
+ if (!name) return null;
15214
+ if (namedOnly && unescapeCaption(span.label) !== name && !isMarkedCaption(span.label, name)) {
15215
+ return null;
15216
+ }
15188
15217
  rest = rest.replace(span.full, "");
15189
15218
  files.push({ url: span.url, name });
15190
15219
  }
15191
15220
  return files.length > 0 && rest.trim() === "" ? files : null;
15192
15221
  }
15222
+ var FOLDER_FILE_MARK = "\u{1F4C1}";
15223
+ function folderFileCaption(name) {
15224
+ return `${FOLDER_FILE_MARK} ${name}`;
15225
+ }
15226
+ function isMarkedCaption(label, name) {
15227
+ return unescapeCaption(label) === folderFileCaption(name);
15228
+ }
15229
+ function uploadedFileOfBlock(block) {
15230
+ const media = block[block.type];
15231
+ const url = media?.type === "file" ? media.file?.url : void 0;
15232
+ if (!url) return null;
15233
+ return { url, caption: (media?.caption ?? []).map((part) => part.plain_text ?? "").join("") };
15234
+ }
15235
+ function isFolderFileBlock(block, knownIds) {
15236
+ const file = uploadedFileOfBlock(block);
15237
+ if (!file) return false;
15238
+ const name = uploadedFileNameFromUrl(file.url);
15239
+ const fileId = notionFileIdOf(file.url);
15240
+ return fileId !== null && knownIds.has(fileId) || name !== null && file.caption === folderFileCaption(name);
15241
+ }
15242
+ var FENCE_RE = /^\s*(`{3,}|~{3,})/;
15243
+ function folderFilesInNote(markdown, options = {}) {
15244
+ const all = markdown.split("\n");
15245
+ const drop = /* @__PURE__ */ new Set();
15246
+ const files = [];
15247
+ const lines = [];
15248
+ let fence = null;
15249
+ all.forEach((line, i) => {
15250
+ const opener = FENCE_RE.exec(line)?.[1];
15251
+ if (opener !== void 0 && (fence === null || opener.startsWith(fence))) {
15252
+ fence = fence === null ? opener : null;
15253
+ return;
15254
+ }
15255
+ if (fence !== null) return;
15256
+ const full = line.trim();
15257
+ const [span, ...more] = notionMediaSpans(full);
15258
+ if (!span || more.length > 0 || span.full !== full || !/^https?:\/\//.test(span.url)) return;
15259
+ const name = uploadedFileNameFromUrl(span.url);
15260
+ if (!name) return;
15261
+ const fileId = notionFileIdOf(span.url);
15262
+ const marked = isMarkedCaption(span.label, name) || fileId !== null && options.knownIds?.has(fileId) === true;
15263
+ const file = { url: span.url, name };
15264
+ if (!marked || options.exclude?.(file)) return;
15265
+ drop.add(i);
15266
+ files.push(file);
15267
+ lines.push(full);
15268
+ });
15269
+ if (files.length === 0) return null;
15270
+ return {
15271
+ markdown: all.filter((_, i) => !drop.has(i)).join("\n"),
15272
+ files,
15273
+ lines
15274
+ };
15275
+ }
15276
+ function noteFolderFilesOfPage(stateDb, pageId, markdown, embedded) {
15277
+ const ids = new Set(folderFileIdsOf(stateDb, pageId).values());
15278
+ const media = recallUploadedMedia(stateDb, pageId);
15279
+ const found2 = folderFilesInNote(markdown, {
15280
+ knownIds: ids,
15281
+ exclude: (file) => {
15282
+ const fileId = notionFileIdOf(file.url);
15283
+ return embedded?.has(file.name) === true || fileId !== null && media.has(fileId);
15284
+ }
15285
+ });
15286
+ const rest = found2?.markdown ?? markdown;
15287
+ return {
15288
+ found: found2,
15289
+ holdsAll: ids.size === 0 || !notionMediaSpans(rest).some((span) => ids.has(notionFileIdOf(span.url) ?? ""))
15290
+ };
15291
+ }
15292
+ function rootFilesPageOf(config) {
15293
+ if (isDatabaseMode(config) || !config.notion.rootPageId) return null;
15294
+ return normalizeNotionId(config.notion.rootPageId);
15295
+ }
15193
15296
  var WIKI_EMBED_RE = /!\[\[([^\]|#^\n]+)/g;
15194
15297
  var MD_EMBED_RE = /!\[[^\]\n]*\]\(<?([^)>\s]+)/g;
15195
15298
  var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
@@ -15217,8 +15320,8 @@ function embeddedNamesOf(notes) {
15217
15320
  function keyOf3(pageId) {
15218
15321
  return `folder_files:${compactNotionId(pageId)}`;
15219
15322
  }
15220
- function folderFileIdsOf(stateDb, pageId) {
15221
- const raw = stateDb.getMeta(keyOf3(pageId));
15323
+ function readPathMap(stateDb, key) {
15324
+ const raw = stateDb.getMeta(key);
15222
15325
  if (!raw) return /* @__PURE__ */ new Map();
15223
15326
  try {
15224
15327
  return new Map(Object.entries(JSON.parse(raw)));
@@ -15226,19 +15329,45 @@ function folderFileIdsOf(stateDb, pageId) {
15226
15329
  return /* @__PURE__ */ new Map();
15227
15330
  }
15228
15331
  }
15332
+ function writePathMap(stateDb, key, map) {
15333
+ stateDb.setMeta(key, JSON.stringify(Object.fromEntries(map)));
15334
+ }
15335
+ function folderFileIdsOf(stateDb, pageId) {
15336
+ return readPathMap(stateDb, keyOf3(pageId));
15337
+ }
15229
15338
  function forgetFolderFileId(stateDb, pageId, localPath) {
15339
+ rememberFolderFileStamp(stateDb, pageId, localPath, null);
15230
15340
  const ids = folderFileIdsOf(stateDb, pageId);
15231
15341
  if (!ids.delete(localPath)) return;
15232
- stateDb.setMeta(keyOf3(pageId), JSON.stringify(Object.fromEntries(ids)));
15342
+ writePathMap(stateDb, keyOf3(pageId), ids);
15233
15343
  }
15234
15344
  function rememberFolderFileId(stateDb, pageId, localPath, fileId) {
15235
15345
  const ids = folderFileIdsOf(stateDb, pageId);
15236
15346
  if (ids.get(localPath) === fileId) return;
15237
15347
  ids.set(localPath, fileId);
15238
- stateDb.setMeta(keyOf3(pageId), JSON.stringify(Object.fromEntries(ids)));
15348
+ writePathMap(stateDb, keyOf3(pageId), ids);
15349
+ }
15350
+ function fileStampOf(file) {
15351
+ return `${file.size}:${file.mtime}`;
15352
+ }
15353
+ function stampKeyOf(pageId) {
15354
+ return `folder_file_stamps:${compactNotionId(pageId)}`;
15355
+ }
15356
+ function folderFileStampsOf(stateDb, pageId) {
15357
+ return readPathMap(stateDb, stampKeyOf(pageId));
15358
+ }
15359
+ function rememberFolderFileStamp(stateDb, pageId, localPath, stamp) {
15360
+ const stamps = folderFileStampsOf(stateDb, pageId);
15361
+ if ((stamps.get(localPath) ?? null) === stamp) return;
15362
+ if (stamp === null) stamps.delete(localPath);
15363
+ else stamps.set(localPath, stamp);
15364
+ writePathMap(stateDb, stampKeyOf(pageId), stamps);
15239
15365
  }
15240
15366
 
15241
15367
  // src/sync/file-handler.ts
15368
+ function flattenBlockTree2(blocks) {
15369
+ return blocks.flatMap((block) => [block, ...flattenBlockTree2(block.children ?? [])]);
15370
+ }
15242
15371
  function isHiddenPath(path) {
15243
15372
  return path.split("/").some((part) => part.startsWith("."));
15244
15373
  }
@@ -15246,6 +15375,15 @@ function getFolderPath(filePath) {
15246
15375
  const parts = filePath.split("/");
15247
15376
  return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
15248
15377
  }
15378
+ function filesFolderOfNote(record) {
15379
+ if (record.fileType === "folder-note" && isFolderNotePath(record.obsidianPath)) {
15380
+ return getFolderPath(record.obsidianPath);
15381
+ }
15382
+ return isNamedNote(record) ? stripMarkdownExtension(record.obsidianPath) : null;
15383
+ }
15384
+ function isNamedNote(record) {
15385
+ return record.fileType === "file" || record.fileType === "db-row";
15386
+ }
15249
15387
  var LOGGED_NAMES = 5;
15250
15388
  var FileHandler = class {
15251
15389
  constructor(vaultFs, notionClient, stateDb, concurrency = DEFAULT_CONFIG.advanced.fileConcurrency, options) {
@@ -15257,6 +15395,7 @@ var FileHandler = class {
15257
15395
  this.downloadTimeoutMs = options?.downloadTimeoutMs ?? DEFAULT_CONFIG.advanced.mediaDownloadTimeoutMs;
15258
15396
  this.itemTimeoutMs = options?.itemTimeoutMs ?? DEFAULT_CONFIG.advanced.itemTimeoutMs;
15259
15397
  this.deleteSync = options?.deleteSync ?? false;
15398
+ this.rootPageId = options?.rootPageId ?? null;
15260
15399
  this.attachmentsFolder = (options?.attachmentsFolder ?? DEFAULT_CONFIG.paths.attachments).replace(/^\/+|\/+$/g, "");
15261
15400
  }
15262
15401
  vaultFs;
@@ -15268,6 +15407,7 @@ var FileHandler = class {
15268
15407
  itemTimeoutMs;
15269
15408
  attachmentsFolder;
15270
15409
  deleteSync;
15410
+ rootPageId;
15271
15411
  get fetchFn() {
15272
15412
  return this.customFetch ?? globalThis.fetch;
15273
15413
  }
@@ -15325,21 +15465,58 @@ var FileHandler = class {
15325
15465
  this.sema.release();
15326
15466
  }
15327
15467
  }
15328
- /** 올린 뒤 바뀌지 않았나 — 크기가 같으면 읽지 않는다. */
15468
+ /**
15469
+ * 올리거나 받은 뒤 바뀌지 않았나 — 그때의 표(크기 · 수정 시각, {@link fileStampOf})와 같으면 읽지 않는다. 크기가 다르면 바뀌었다.
15470
+ * 크기만 같으면 내용으로 견주고, 같으면 지금 표를 적어 다음에는 읽지 않는다. 예전에는 크기가 같으면 바뀌지 않았다고 봐, 같은
15471
+ * 크기로 고친 파일을 올리지 않았다.
15472
+ */
15329
15473
  async isUnchanged(existing, file) {
15330
- if (existing.fileSize === file.size) return true;
15474
+ const stamp = fileStampOf(file);
15475
+ if (folderFileStampsOf(this.stateDb, existing.notionPageId).get(file.path) === stamp) {
15476
+ return true;
15477
+ }
15478
+ if (existing.fileSize !== file.size) return false;
15331
15479
  const buffer = await this.vaultFs.readBinary(file.path);
15332
- return existing.fileHash === createHash("sha256").update(buffer).digest("hex");
15480
+ const same = existing.fileHash === createHash("sha256").update(buffer).digest("hex");
15481
+ if (same) rememberFolderFileStamp(this.stateDb, existing.notionPageId, file.path, stamp);
15482
+ return same;
15333
15483
  }
15334
15484
  /** pull 이 받은 것을 두는 폴더 안인가. */
15335
15485
  inAttachmentsFolder(path) {
15336
15486
  return this.attachmentsFolder !== "" && path.startsWith(`${this.attachmentsFolder}/`);
15337
15487
  }
15338
- /** 폴더 첨부를 둘 Notion 페이지 — 폴더 노트 없는 폴더의 페이지(폴더 레코드)만. 맨 위는 루트 기록이 있을 때만. */
15488
+ /**
15489
+ * 폴더 첨부를 둘 Notion 페이지 — 그 폴더의 Notion 자리({@link folderContainer} 와 같은 차례). 폴더 노트(`X/X.md`)나 노트
15490
+ * 이름의 폴더(`X.md` 옆 `X/`)면 그 노트의 페이지 끝(ADR-056), 아니면 push 가 만든 폴더 페이지. 맨 위는 루트 페이지 끝.
15491
+ * 행 이름의 폴더(`DB/행/`)는 그 행의 페이지 끝. DB 폴더는 둘 곳이 없다 — Notion 의 DB 는 본문이 없다.
15492
+ */
15339
15493
  folderPageOf(folder) {
15340
- const record = this.stateDb.getByPath(folder || "__root__");
15341
- if (!record?.notionPageId) return null;
15342
- return folder === "" || isFolderRecord(record) ? record.notionPageId : null;
15494
+ return this.notePageOf(folder) ?? this.folderRecordPageOf(folder);
15495
+ }
15496
+ /**
15497
+ * 이 폴더를 품은 노트의 페이지 — 폴더 노트 · 노트(행 포함) 이름의 폴더 · 볼트 맨 위는 루트 페이지. 본문이 이 도구의 것이
15498
+ * 아니거나(루트) 노트의 것이라, 적어 둔 폴더 첨부만 제 것으로 본다. 없으면 null.
15499
+ */
15500
+ notePageOf(folder) {
15501
+ if (folder === "") return this.rootPageId;
15502
+ const note = this.stateDb.getByPath(folderNoteOf(folder));
15503
+ if (note?.notionPageId && note.fileType === "folder-note") return note.notionPageId;
15504
+ const named = this.stateDb.getByPath(`${folder}.md`);
15505
+ return named?.notionPageId && isNamedNote(named) ? named.notionPageId : null;
15506
+ }
15507
+ folderRecordPageOf(folder) {
15508
+ const record = this.stateDb.getByPath(folder);
15509
+ return record?.notionPageId && isFolderRecord(record) ? record.notionPageId : null;
15510
+ }
15511
+ /**
15512
+ * 이 기록이 그 폴더의 폴더 첨부인가. 노트의 페이지에는 그 노트가 임베드해 올린 파일도 같은 페이지로 적혀 있다 — 폴더
15513
+ * 첨부로 적어 둔 것({@link folderFileIdsOf})만이다.
15514
+ */
15515
+ isFolderFileEntry(entry, folder) {
15516
+ const pageId = this.folderPageOf(folder);
15517
+ if (!pageId || entry.notionPageId !== pageId) return false;
15518
+ if (pageId !== this.notePageOf(folder)) return true;
15519
+ return folderFileIdsOf(this.stateDb, pageId).has(entry.localPath);
15343
15520
  }
15344
15521
  /**
15345
15522
  * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 아예 빼고, Notion 이 받지 않는
@@ -15367,7 +15544,7 @@ var FileHandler = class {
15367
15544
  * @param failed 올리지 못한 첨부를 담을 곳 — push 결과의 `failed` 를 넘기면 CLI · 플러그인이 처리하지 못한 것으로
15368
15545
  * 보인다. 예전엔 경고 로그로만 남아 push 가 「실패 0」 으로 끝났다.
15369
15546
  */
15370
- async pushAllFiles(failed, folders) {
15547
+ async pushAllFiles(failed, folders, noteWrites) {
15371
15548
  const { uploadable: allFiles, unsupported: unsupported2 } = await this.listUploadableFiles();
15372
15549
  if (unsupported2.length > 0) {
15373
15550
  const names = unsupported2.slice(0, LOGGED_NAMES).map((f) => f.path);
@@ -15376,13 +15553,13 @@ var FileHandler = class {
15376
15553
  `[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
15554
  );
15378
15555
  }
15379
- if (this.deleteSync) await this.removeDeletedFolderFiles();
15556
+ if (this.deleteSync) await this.removeDeletedFolderFiles(noteWrites);
15380
15557
  const pending = [];
15381
15558
  for (const file of allFiles) {
15382
15559
  if (this.inAttachmentsFolder(file.path) || isHiddenPath(file.path)) continue;
15383
15560
  const existing = this.stateDb.getFileRegistry(file.path);
15384
15561
  if (existing) {
15385
- if (existing.notionPageId !== this.folderPageOf(getFolderPath(file.path))) continue;
15562
+ if (!this.isFolderFileEntry(existing, getFolderPath(file.path))) continue;
15386
15563
  if (await this.isUnchanged(existing, file)) continue;
15387
15564
  }
15388
15565
  pending.push({ file, existing });
@@ -15404,10 +15581,23 @@ var FileHandler = class {
15404
15581
  for (const [folder, items] of byFolder) {
15405
15582
  const pageId = await this.provideFolderPage(folder, folders, items, stranded);
15406
15583
  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
- }
15584
+ await this.writingNotePage(
15585
+ pageId === this.notePageOf(folder),
15586
+ pageId,
15587
+ noteWrites,
15588
+ async () => {
15589
+ for (const { file, existing } of items) {
15590
+ let previous = existing;
15591
+ if (!previous) {
15592
+ const moved = await this.movedHere(file, pageId);
15593
+ if (moved?.same) continue;
15594
+ previous = moved?.entry ?? null;
15595
+ }
15596
+ const result = await this.pushSingleFile(file, pageId, previous, failed);
15597
+ if (result) results.push(result);
15598
+ }
15599
+ }
15600
+ );
15411
15601
  }
15412
15602
  this.reportStranded(stranded);
15413
15603
  return results;
@@ -15416,25 +15606,42 @@ var FileHandler = class {
15416
15606
  * 볼트에서 지운 폴더 첨부를 Notion 에서도 지운다(deleteSync) — 폴더 페이지에 적힌 파일 가운데 볼트에 없는 것의 블록과 기록.
15417
15607
  * 포함 · 제외 패턴으로 빠진 파일은 볼트에 있어 지우지 않는다.
15418
15608
  */
15419
- async removeDeletedFolderFiles() {
15609
+ async removeDeletedFolderFiles(noteWrites) {
15420
15610
  const removed = [];
15611
+ const pages = [];
15421
15612
  for (const record of this.stateDb.getAll()) {
15422
- if (!record.notionPageId || !isFolderRecord(record)) continue;
15423
- 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
- }
15613
+ if (!record.notionPageId) continue;
15614
+ const folder = isFolderRecord(record) ? record.obsidianPath : filesFolderOfNote(record);
15615
+ if (folder !== null) {
15616
+ pages.push({ pageId: record.notionPageId, folder, onNote: !isFolderRecord(record) });
15617
+ }
15618
+ }
15619
+ if (this.rootPageId) pages.push({ pageId: this.rootPageId, folder: "", onNote: true });
15620
+ for (const { pageId, folder, onNote } of pages) {
15621
+ const ids = folderFileIdsOf(this.stateDb, pageId);
15622
+ if (onNote && ids.size === 0) continue;
15623
+ const gone = [];
15624
+ for (const entry of this.stateDb.getFilesByPageId(pageId)) {
15625
+ if (getFolderPath(entry.localPath) !== folder) continue;
15626
+ if (onNote && !ids.has(entry.localPath)) continue;
15627
+ if (!await this.vaultFs.exists(entry.localPath)) gone.push(entry);
15437
15628
  }
15629
+ if (gone.length === 0) continue;
15630
+ await this.writingNotePage(onNote, pageId, noteWrites, async () => {
15631
+ for (const entry of gone) {
15632
+ try {
15633
+ const name = entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1);
15634
+ await this.removeOlderBlocks(pageId, entry.localPath, name, void 0);
15635
+ this.stateDb.deleteFileRegistry(entry.localPath);
15636
+ forgetFolderFileId(this.stateDb, pageId, entry.localPath);
15637
+ removed.push(entry.localPath);
15638
+ } catch (error) {
15639
+ getLogger().warn(
15640
+ `[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)}`
15641
+ );
15642
+ }
15643
+ }
15644
+ });
15438
15645
  }
15439
15646
  if (removed.length > 0) {
15440
15647
  getLogger().info(
@@ -15442,10 +15649,20 @@ var FileHandler = class {
15442
15649
  );
15443
15650
  }
15444
15651
  }
15652
+ /** 노트의 페이지에 쓰는 동안을 알린다({@link NotePageWrites}) — 폴더 페이지는 노트가 없어 알릴 것이 없다. */
15653
+ async writingNotePage(isNotePage, pageId, noteWrites, write) {
15654
+ if (!isNotePage || !noteWrites) return write();
15655
+ await noteWrites.begin(pageId);
15656
+ try {
15657
+ await write();
15658
+ } finally {
15659
+ await noteWrites.end(pageId);
15660
+ }
15661
+ }
15445
15662
  /** 폴더 첨부를 둘 페이지 — 노트 없는 폴더면 마련한다. 둘 곳이 없으면 그 파일들을 `stranded` 에 담고 null. */
15446
15663
  async provideFolderPage(folder, folders, items, stranded) {
15447
15664
  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";
15665
+ let reason = folder === "" ? "\uBCFC\uD2B8 \uB9E8 \uC704 \u2014 DB \uBAA8\uB4DC\uC758 \uB8E8\uD2B8\uB294 DB \uB77C \uBCF8\uBB38\uC774 \uC5C6\uB2E4" : "DB \uD3F4\uB354 \xB7 \uC544\uC9C1 \uC62C\uB9AC\uC9C0 \uC54A\uC740 \uD3F4\uB354 \uB178\uD2B8";
15449
15666
  if (!pageId && folder !== "" && folders && !this.stateDb.getByPath(folder)) {
15450
15667
  try {
15451
15668
  await folders.ensureFolderPage(folder);
@@ -15594,6 +15811,8 @@ var FileHandler = class {
15594
15811
  fileHash: hash,
15595
15812
  fileSize: buffer.length
15596
15813
  });
15814
+ const stat2 = await this.vaultFs.getFileStat(localPath);
15815
+ if (stat2) rememberFolderFileStamp(this.stateDb, pageId, localPath, fileStampOf(stat2));
15597
15816
  return wrote;
15598
15817
  }
15599
15818
  async downloadFileBlock(url, filename, targetFolder) {
@@ -15635,7 +15854,9 @@ var FileHandler = class {
15635
15854
  const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
15636
15855
  const block = this.createFileBlock(blockType, fileUploadId, filename);
15637
15856
  const [created] = await this.notionClient.appendChildBlocks(parentPageId, [block]);
15638
- if (previous) await this.removeOlderBlocks(parentPageId, filename, created?.id);
15857
+ if (previous) {
15858
+ await this.removeOlderBlocks(parentPageId, file.path, filename, created?.id);
15859
+ }
15639
15860
  const url = created ? fileUrlOfBlock(created) : null;
15640
15861
  const fileId = url ? notionFileIdOf(url) : null;
15641
15862
  if (fileId) rememberFolderFileId(this.stateDb, parentPageId, file.path, fileId);
@@ -15647,28 +15868,79 @@ var FileHandler = class {
15647
15868
  fileHash: hash,
15648
15869
  fileSize: file.size
15649
15870
  });
15871
+ rememberFolderFileStamp(this.stateDb, parentPageId, file.path, fileStampOf(file));
15650
15872
  getLogger().info(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC: ${file.path} \u2192 ${blockType} \uBE14\uB85D`);
15651
15873
  return { localPath: file.path, fileUploadId, blockType };
15652
15874
  }
15653
- /** 이 페이지에서 같은 이름의 파일 블록 중 방금 붙인 것 말고를 지운다. */
15654
- async removeOlderBlocks(pageId, name, keep) {
15655
- for (const block of await this.notionClient.fetchAllChildren(pageId)) {
15656
- if (block.id === keep) continue;
15657
- const media = block[block.type];
15658
- const url = media?.type === "file" ? media.file?.url : void 0;
15659
- if (!url || uploadedFileNameFromUrl(url) !== name) continue;
15660
- await this.notionClient.deleteBlock(block.id);
15875
+ /**
15876
+ * 처음 보는 파일이 이 페이지에 적어 둔 같은 이름의 폴더 첨부를 옮긴 것인가 — 그 기록의 경로가 볼트에서 사라졌으면 옮겼다(폴더 ·
15877
+ * 폴더 노트 · 노트의 이름 바꾸기). 기록 · 파일 id · 표를 새 경로로 옮긴다. `same` 이면 내용이 같아 올리지 않는다. 다르면 옮긴
15878
+ * 기록을 옛 것으로 올려 옛 블록을 지운다. 예전에는 새 파일로 올려 노트 페이지에 같은 파일의 블록이 둘 남았다.
15879
+ */
15880
+ async movedHere(file, pageId) {
15881
+ const name = file.path.slice(file.path.lastIndexOf("/") + 1);
15882
+ const record = this.stateDb.getByNotionId(pageId);
15883
+ const ids = folderFileIdsOf(this.stateDb, pageId);
15884
+ for (const entry of this.stateDb.getFilesByPageId(pageId)) {
15885
+ if (entry.localPath === file.path) continue;
15886
+ if (entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1) !== name) continue;
15887
+ if (record && !isFolderRecord(record) && !ids.has(entry.localPath)) continue;
15888
+ if (await this.vaultFs.exists(entry.localPath)) continue;
15889
+ const buffer = await this.vaultFs.readBinary(file.path);
15890
+ const same = entry.fileSize === file.size && entry.fileHash === createHash("sha256").update(buffer).digest("hex");
15891
+ const fileId = ids.get(entry.localPath);
15892
+ this.stateDb.deleteFileRegistry(entry.localPath);
15893
+ forgetFolderFileId(this.stateDb, pageId, entry.localPath);
15894
+ this.stateDb.registerFile({
15895
+ localPath: file.path,
15896
+ notionPageId: pageId,
15897
+ fileUploadId: entry.fileUploadId,
15898
+ fileType: entry.fileType,
15899
+ fileHash: entry.fileHash,
15900
+ fileSize: entry.fileSize
15901
+ });
15902
+ if (fileId) rememberFolderFileId(this.stateDb, pageId, file.path, fileId);
15903
+ if (same) rememberFolderFileStamp(this.stateDb, pageId, file.path, fileStampOf(file));
15904
+ return { entry: { ...entry, localPath: file.path }, same };
15661
15905
  }
15906
+ return null;
15662
15907
  }
15663
15908
  /**
15664
- * 폴더 첨부의 파일 블록 — 캡션에 파일 이름을 적는다. 받는 쪽이 그 표시로 폴더 첨부를 알아본다({@link folderFilesOf}) —
15665
- * 자식 페이지가 없는 폴더 페이지는 PDF 하나만 붙인 노트와 모양이 같다.
15909
+ * 이 페이지에서 그 폴더 첨부의 블록 중 방금 붙인 것(`keep`) 말고를 지운다. 폴더 페이지는 같은 이름의 파일 블록 모두(id 를
15910
+ * 적기 전에 올린 블록도). 노트의 페이지는 노트가 임베드한 같은 이름의 파일이 있을 수 있어, 적어 둔 Notion 파일 id 의 블록과
15911
+ * 표시를 단 캡션({@link folderFileCaption})의 블록만 — 다른 볼트가 같은 파일을 먼저 고쳐 올린 블록은 id 를 모르지만 표시가 있다.
15912
+ * 예전에는 id 로만 지워, 두 볼트가 따로 고쳐 올리면 블록이 둘 남았다.
15913
+ */
15914
+ async removeOlderBlocks(pageId, localPath, name, keep) {
15915
+ const record = this.stateDb.getByNotionId(pageId);
15916
+ const onNote = record !== null && !isFolderRecord(record) || this.rootPageId !== null && notionIdsEqual(pageId, this.rootPageId);
15917
+ const fileId = folderFileIdsOf(this.stateDb, pageId).get(localPath);
15918
+ const isOlder = (block) => {
15919
+ if (block.id === keep) return false;
15920
+ const media = uploadedFileOfBlock(block);
15921
+ if (!media || uploadedFileNameFromUrl(media.url) !== name) return false;
15922
+ if (keep === void 0 && fileId !== void 0) {
15923
+ return notionFileIdOf(media.url) === fileId;
15924
+ }
15925
+ if (!onNote) return true;
15926
+ return fileId !== void 0 && notionFileIdOf(media.url) === fileId || media.caption === folderFileCaption(name);
15927
+ };
15928
+ let older = (await this.notionClient.fetchAllChildren(pageId)).filter(isOlder);
15929
+ if (older.length === 0 && onNote) {
15930
+ older = flattenBlockTree2(await this.notionClient.getBlockTree(pageId)).filter(isOlder);
15931
+ }
15932
+ for (const block of older) await this.notionClient.deleteBlock(block.id);
15933
+ }
15934
+ /**
15935
+ * 폴더 첨부의 파일 블록 — 캡션에 표시를 단 파일 이름을 적는다({@link folderFileCaption}). 받는 쪽이 그 표시로 폴더 첨부를
15936
+ * 알아본다 — 자식 페이지가 없는 폴더 페이지는 PDF 하나만 붙인 노트와 모양이 같고, 노트 페이지의 폴더 첨부는 그 노트의
15937
+ * 임베드와 같은 블록이다.
15666
15938
  */
15667
15939
  createFileBlock(blockType, fileUploadId, name) {
15668
15940
  const fileRef = {
15669
15941
  type: "file_upload",
15670
15942
  file_upload: { id: fileUploadId },
15671
- caption: [{ type: "text", text: { content: name } }]
15943
+ caption: [{ type: "text", text: { content: folderFileCaption(name) } }]
15672
15944
  };
15673
15945
  switch (blockType) {
15674
15946
  case "image":
@@ -16693,21 +16965,6 @@ function loadLinkedDbMap(stateDb) {
16693
16965
  }
16694
16966
  }
16695
16967
 
16696
- // src/sync/parent-mode.ts
16697
- function isDatabaseMode(config) {
16698
- return config.notion.parentMode === "database" && !!config.notion.databaseId;
16699
- }
16700
- function rowDatabaseOf(config, record) {
16701
- if (record.fileType === "db-row") {
16702
- if (!record.notionParentId) {
16703
- throw new Error(`DB \uD589\uC778\uB370 \uC18C\uC18D DB \uB97C \uC54C \uC218 \uC5C6\uC74C: ${record.obsidianPath}`);
16704
- }
16705
- return record.notionParentId;
16706
- }
16707
- if (isDatabaseMode(config)) return config.notion.databaseId;
16708
- return null;
16709
- }
16710
-
16711
16968
  // src/sync/remote-scan.ts
16712
16969
  var LAST_FULL_PULL_META_KEY = "last_full_pull_at";
16713
16970
  var LAST_PULL_META_KEY = "last_pull_at";
@@ -19167,7 +19424,9 @@ var NoteRelocator = class {
19167
19424
  if (isFolderRecord(record)) return [record.obsidianPath];
19168
19425
  if (record.fileType === "folder-note") return [parentFolderOf(record.obsidianPath)];
19169
19426
  const children = stripMarkdownExtension(record.obsidianPath);
19170
- const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children));
19427
+ const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children)) || record.notionPageId !== null && [...folderFileIdsOf(this.stateDb, record.notionPageId).keys()].some(
19428
+ (path) => within(path, children)
19429
+ );
19171
19430
  return hasChildren ? [record.obsidianPath, children] : [record.obsidianPath];
19172
19431
  }
19173
19432
  /** 옮기지 못할 까닭 — 없으면 null. */
@@ -19400,7 +19659,7 @@ async function notionBodyToObsidian(client, markdown, pageId) {
19400
19659
  const bookmarks = bookmarkIds.length > 0 ? await client.getBookmarks(bookmarkIds) : void 0;
19401
19660
  return notionEnhancedToObsidian(markdown, { userNames, codeTexts, bookmarks });
19402
19661
  }
19403
- async function pageBlocksToObsidian(client, pageId) {
19662
+ async function pageBlocksToObsidian(client, pageId, bodyOf5 = (markdown) => markdown) {
19404
19663
  const tree = await client.getBlockTree(pageId);
19405
19664
  const markdown = blocksToNfm(tree, { pageId });
19406
19665
  const content = await notionBodyToObsidian(
@@ -19409,7 +19668,7 @@ async function pageBlocksToObsidian(client, pageId) {
19409
19668
  getCodeBlockTexts: () => Promise.resolve(blocksOfType(tree, "code").map(plainTextOf)),
19410
19669
  getBookmarks: () => Promise.resolve(bookmarksOf(tree))
19411
19670
  },
19412
- markdown,
19671
+ bodyOf5(markdown),
19413
19672
  pageId
19414
19673
  );
19415
19674
  return { markdown, content };
@@ -19676,6 +19935,7 @@ function emptyDatabaseSyncResult() {
19676
19935
  }
19677
19936
  var DatabaseSyncer = class {
19678
19937
  constructor(config, stateDb, notionClient, vaultFs, pipeline, imageHandler, observation = () => NO_OBSERVATION, noteRowBody = () => {
19938
+ }, receiveRowFiles = async () => {
19679
19939
  }) {
19680
19940
  this.config = config;
19681
19941
  this.stateDb = stateDb;
@@ -19685,6 +19945,7 @@ var DatabaseSyncer = class {
19685
19945
  this.imageHandler = imageHandler;
19686
19946
  this.observation = observation;
19687
19947
  this.noteRowBody = noteRowBody;
19948
+ this.receiveRowFiles = receiveRowFiles;
19688
19949
  this.baseFileInfo = new DbBaseFiles(stateDb);
19689
19950
  this.journal = new UpdateJournal(stateDb);
19690
19951
  this.sidecarBases = new SidecarBases(stateDb);
@@ -19709,6 +19970,7 @@ var DatabaseSyncer = class {
19709
19970
  imageHandler;
19710
19971
  observation;
19711
19972
  noteRowBody;
19973
+ receiveRowFiles;
19712
19974
  propertyMapper;
19713
19975
  decorationsCache;
19714
19976
  /**
@@ -20791,7 +21053,18 @@ var DatabaseSyncer = class {
20791
21053
  this.noteRowBody(page.id, mdResult.markdown);
20792
21054
  const bodyFingerprint = remoteBodyFingerprint(mdResult.markdown);
20793
21055
  const exportCompact = isCompactExport(mdResult.markdown);
20794
- let markdown = await notionBodyToObsidian(this.notionClient, mdResult.markdown, page.id);
21056
+ const localContent = await readLocalNote(this.vaultFs, options?.localPath ?? filePath);
21057
+ const noteFiles = noteFolderFilesOfPage(
21058
+ this.stateDb,
21059
+ page.id,
21060
+ mdResult.markdown,
21061
+ embeddedNamesOf(localContent === void 0 ? [] : [localContent])
21062
+ );
21063
+ let markdown = await notionBodyToObsidian(
21064
+ this.notionClient,
21065
+ noteFiles.found?.markdown ?? mdResult.markdown,
21066
+ page.id
21067
+ );
20795
21068
  markdown = await this.imageHandler.restoreUploadedMedia(
20796
21069
  markdown,
20797
21070
  page.id,
@@ -20825,7 +21098,7 @@ var DatabaseSyncer = class {
20825
21098
  {
20826
21099
  properties,
20827
21100
  notionExportCompact: exportCompact,
20828
- localContent: await readLocalNote(this.vaultFs, options?.localPath ?? filePath),
21101
+ localContent,
20829
21102
  // 이 렌더러가 적는 키 — 속성과 제목 · 커버 · 아이콘. 나머지는 로컬 키다(F-08).
20830
21103
  notionKeys: [...this.propertyMapper.ownedKeys(rawProperties), "title", ...DECORATION_KEYS]
20831
21104
  }
@@ -20835,7 +21108,11 @@ var DatabaseSyncer = class {
20835
21108
  title,
20836
21109
  properties,
20837
21110
  bodyFingerprint,
20838
- propertiesBlock: hasPropertiesBlock(mdResult.markdown)
21111
+ propertiesBlock: hasPropertiesBlock(mdResult.markdown),
21112
+ folderFiles: {
21113
+ files: noteFiles.found?.files ?? [],
21114
+ complete: !mdResult.truncated && noteFiles.holdsAll
21115
+ }
20839
21116
  };
20840
21117
  }
20841
21118
  /**
@@ -21132,8 +21409,19 @@ var DatabaseSyncer = class {
21132
21409
  */
21133
21410
  adoptedChildrenOf(record, rowPath) {
21134
21411
  const from = record.fileType === "folder-note" ? parentFolderOf(record.obsidianPath) : stripMarkdownExtension(record.obsidianPath);
21135
- const hasChildren = this.stateDb.getAll().some((r) => r.id !== record.id && within(r.obsidianPath, from));
21136
- return hasChildren ? { from, to: stripMarkdownExtension(rowPath) } : null;
21412
+ return this.holdsChildren(record, from) ? { from, to: stripMarkdownExtension(rowPath) } : null;
21413
+ }
21414
+ /**
21415
+ * 이 노트 · 행 옆의 폴더(`from`)가 그것의 것인가 — 하위 페이지가 있거나, 그 페이지에 올린 폴더 첨부(ADR-056)가 든 폴더.
21416
+ * 예전에는 하위 페이지만 보아 행의 이름을 따라 옮길 때 폴더 첨부만 든 폴더가 옛 이름에 남았다.
21417
+ */
21418
+ holdsChildren(record, from) {
21419
+ if (this.stateDb.getAll().some((r) => r.id !== record.id && within(r.obsidianPath, from))) {
21420
+ return true;
21421
+ }
21422
+ return record.notionPageId !== null && [...folderFileIdsOf(this.stateDb, record.notionPageId).keys()].some(
21423
+ (path) => within(path, from)
21424
+ );
21137
21425
  }
21138
21426
  /**
21139
21427
  * Notion 에서 바꾼 제목으로 행 이름을 바꿀 자리(`sync.renameOnTitleChange`) — 바꿀 것이 없으면 null.
@@ -21155,14 +21443,32 @@ var DatabaseSyncer = class {
21155
21443
  );
21156
21444
  if (to === recordPath) return null;
21157
21445
  const from = stripMarkdownExtension(recordPath);
21158
- if (!this.stateDb.getAll().some((r) => within(r.obsidianPath, from))) {
21446
+ if (!this.holdsChildren(record, from)) {
21159
21447
  return { to, children: null };
21160
21448
  }
21161
21449
  const children = { from, to: stripMarkdownExtension(to) };
21162
21450
  const blocked = await this.relocator.blocked(children);
21163
21451
  return blocked ? { reason: `\uD558\uC704 \uD398\uC774\uC9C0 \uD3F4\uB354 \u2014 ${blocked}` } : { to, children };
21164
21452
  }
21453
+ /**
21454
+ * 행 하나를 받고 그 행 페이지의 폴더 첨부를 행 이름의 폴더에 받는다(ADR-056) — 노트가 충돌로 남거나 로컬을 지켜도 파일은
21455
+ * 제 규칙대로 받는다(페이지와 같다). 행을 옮겼으면 옮긴 자리로.
21456
+ */
21165
21457
  async pullDatabasePage(page, dbConfig) {
21458
+ const rendered = {};
21459
+ const outcome = await this.pullDatabaseRow(page, dbConfig, rendered);
21460
+ const path = outcome.path ?? this.stateDb.getByNotionId(page.id)?.obsidianPath;
21461
+ if (rendered.folderFiles && path) {
21462
+ await this.receiveRowFiles(
21463
+ stripMarkdownExtension(path),
21464
+ page.id,
21465
+ rendered.folderFiles.files,
21466
+ rendered.folderFiles.complete
21467
+ );
21468
+ }
21469
+ return outcome;
21470
+ }
21471
+ async pullDatabaseRow(page, dbConfig, seen) {
21166
21472
  const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
21167
21473
  const existingRecord = this.stateDb.getByNotionId(page.id);
21168
21474
  const recordPath = existingRecord?.obsidianPath;
@@ -21216,6 +21522,7 @@ var DatabaseSyncer = class {
21216
21522
  localPath: recordPath ?? filePath,
21217
21523
  ...local !== void 0 ? { adoptFrom: local } : {}
21218
21524
  });
21525
+ seen.folderFiles = rendered.folderFiles;
21219
21526
  let relinked = false;
21220
21527
  if (adopt) {
21221
21528
  const outcome = local === void 0 ? null : await this.relinkExistingRow(
@@ -21238,6 +21545,7 @@ var DatabaseSyncer = class {
21238
21545
  await this.rowPathOwners(dbConfig.localFolder, safeName, page.id)
21239
21546
  );
21240
21547
  rendered = await this.renderRow(page, filePath, { localPath: filePath });
21548
+ seen.folderFiles = rendered.folderFiles;
21241
21549
  }
21242
21550
  }
21243
21551
  const { title, properties, bodyFingerprint } = rendered;
@@ -23709,7 +24017,8 @@ var DatabaseDiscovery = class {
23709
24017
  * `markScanned` 일 때 적는다.
23710
24018
  *
23711
24019
  * @param failed 읽지 못한 페이지 · DB — 이유와 함께 싣는다.
23712
- * @param options.notePathOf 페이지 → 볼트 경로. 새 DB 폴더를 부모 노트 옆에 둔다 — dry-run 은 다시 이을 노트의 자리를 준다.
24020
+ * @param options.notePathOf 페이지 → 볼트 경로. 새 DB 폴더를 부모 노트 옆에 둔다 — dry-run 은 다시 이을 노트 · 새로 받을 페이지의
24021
+ * 자리를 준다.
23713
24022
  */
23714
24023
  async openRegistry(failed, options) {
23715
24024
  const { forceRediscovery, signal } = options;
@@ -23889,6 +24198,41 @@ var DatabaseDiscovery = class {
23889
24198
  )
23890
24199
  };
23891
24200
  }
24201
+ /**
24202
+ * dry-run — 새로 받을 페이지의 본문을 읽어 그 안의 DB 를 찾는다({@link plannedNewDatabases} 가 그 행을 센다). 실제 pull 은
24203
+ * 받으며 찾는다 — 같은 길로 읽는다: Markdown API(잘린 본문은 채운 것 · 권한 없는 자리표시는 그대로), 실패하거나 블록 방식이면
24204
+ * 블록 트리. 예전에는 100개까지만 읽고 넘는 페이지 안의 DB 를 «미리 세지 않았습니다» 로만 알렸다 — 미리 보기는 사용자가
24205
+ * 부르고 멈출 수 있어(`signal`) 모두 읽는다. 둘 다 읽지 못한 페이지만 {@link bodyRead} 가 아니어서 알림으로 남는다.
24206
+ */
24207
+ async readNewPageBodies(pageIds, signal) {
24208
+ if (isDatabaseMode(this.config)) return;
24209
+ const unread = pageIds.filter((pageId) => !this.bodyRead(pageId));
24210
+ if (unread.length > 0) {
24211
+ getLogger().info(
24212
+ `[Im-Nobsidian] \uBBF8\uB9AC \uBCF4\uAE30: \uC0C8 \uD398\uC774\uC9C0 ${unread.length}\uAC1C\uC758 \uBCF8\uBB38\uC5D0\uC11C DB \uB97C \uCC3E\uC74C`
24213
+ );
24214
+ }
24215
+ for (const pageId of unread) {
24216
+ if (signal?.aborted) return;
24217
+ try {
24218
+ this.collectInlineDbRefs(pageId, await this.previewBodyOf(pageId));
24219
+ } catch (error) {
24220
+ getLogger().debug(
24221
+ `[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)}`
24222
+ );
24223
+ }
24224
+ }
24225
+ }
24226
+ /** 미리 보기가 읽는 새 페이지 본문 — 실제 pull 의 `PagePuller.fetchPageMarkdown` 과 같은 차례. */
24227
+ async previewBodyOf(pageId) {
24228
+ if (this.config.conversion.preferMarkdownApi !== false) {
24229
+ try {
24230
+ return (await this.notionClient.getPageMarkdown(pageId)).markdown;
24231
+ } catch {
24232
+ }
24233
+ }
24234
+ return blocksToNfm(await this.notionClient.getBlockTree(pageId), { pageId });
24235
+ }
23892
24236
  /** 이번 pull 에서 본문을 받은 페이지인가 — 그 본문의 `<database>` 로 새 DB 를 찾았다. */
23893
24237
  bodyRead(pageId) {
23894
24238
  return this._bodyReadPages.has(normalizeNotionId(pageId));
@@ -23971,7 +24315,7 @@ var DatabaseDiscovery = class {
23971
24315
  getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uC124\uC815 \uC0DD\uC131 \uC2E4\uD328:`, error);
23972
24316
  return { kind: "error", error: error instanceof Error ? error.message : String(error) };
23973
24317
  }
23974
- const obsPath = parentPageId ? notePathOf(parentPageId) : null;
24318
+ const obsPath = parentPageId ? await notePathOf(parentPageId) : null;
23975
24319
  let parentFolder = "";
23976
24320
  if (obsPath) {
23977
24321
  if (isMarkdownPath(obsPath)) {
@@ -24725,7 +25069,14 @@ var LocalPlanner = class {
24725
25069
  live.add(target);
24726
25070
  }
24727
25071
  }
24728
- const pairs = scan.adoptions.map((a) => ({ from: a.record.obsidianPath, to: a.to }));
25072
+ const filePairs = await this.movedFolderFilePairs(
25073
+ [...folderRecords].filter((folder) => !live.has(folder))
25074
+ );
25075
+ for (const { to } of filePairs) for (const folder of foldersOf([to])) live.add(folder);
25076
+ const pairs = [
25077
+ ...scan.adoptions.map((a) => ({ from: a.record.obsidianPath, to: a.to })),
25078
+ ...filePairs
25079
+ ];
24729
25080
  const folderMoves = [];
24730
25081
  const databaseFolderMoves = [];
24731
25082
  for (const move of deriveFolderMoves(tracked, live, pairs, hints)) {
@@ -24734,6 +25085,29 @@ var LocalPlanner = class {
24734
25085
  }
24735
25086
  return { folderMoves, databaseFolderMoves };
24736
25087
  }
25088
+ /**
25089
+ * 사라진 폴더 페이지에 올린 폴더 첨부의 짝 — 노트 없이 파일만 든 폴더는 노트의 짝이 없어 이름을 바꿔도 옮긴 줄 몰랐다. 그
25090
+ * 폴더 페이지에 올린 파일과 이름 · 크기가 같은 파일이 볼트에 하나뿐이면 그리로 옮겼다. 예전에는 새 폴더 페이지를 만들어 다시
25091
+ * 올렸고, 삭제 동기화가 꺼져 있으면 옛 폴더 페이지가 남아 다른 볼트가 두 폴더를 받았다.
25092
+ */
25093
+ async movedFolderFilePairs(missing) {
25094
+ const entries = missing.flatMap((folder) => {
25095
+ const pageId = this.stateDb.getByPath(folder)?.notionPageId;
25096
+ if (!pageId) return [];
25097
+ return this.stateDb.getFilesByPageId(pageId).filter((entry) => entry.localPath.startsWith(`${folder}/`));
25098
+ });
25099
+ if (entries.length === 0) return [];
25100
+ const files = await this.vaultFs.listNonMarkdownFiles();
25101
+ const nameOf = (path) => path.slice(path.lastIndexOf("/") + 1);
25102
+ const pairs = [];
25103
+ for (const entry of entries) {
25104
+ const found2 = files.filter(
25105
+ (file) => nameOf(file.path) === nameOf(entry.localPath) && file.size === entry.fileSize
25106
+ );
25107
+ if (found2.length === 1) pairs.push({ from: entry.localPath, to: found2[0].path });
25108
+ }
25109
+ return pairs;
25110
+ }
24737
25111
  /** 레코드를 새 경로로 옮겨 적는다 — 이동 WAL · 위키링크 · 보존 마커도 함께. */
24738
25112
  moveRecord(record, to) {
24739
25113
  const from = record.obsidianPath;
@@ -27348,18 +27722,22 @@ var PagePusher = class {
27348
27722
  * 실패로 알린다. 예전처럼 조용히 건너뛰면 동기화됨으로 기록돼 편집이 영영 가지 않는다.
27349
27723
  *
27350
27724
  * 바뀌지 않은 미디어는 다시 올리지 않고 Notion 의 그 블록을 둔다({@link ImageHandler.keepUploadedMedia}).
27351
- * 블록 방식은 블록을 모두 지우므로 둘 수 없다 — 예전처럼 모두 다시 올린다.
27725
+ * 블록 방식은 노트의 미디어를 둘 수 없다 — 예전처럼 모두 다시 올린다. 페이지 끝의 폴더 첨부(ADR-056)는 노트 밖의 것이라
27726
+ * 블록째 두고 새 본문을 그 앞(맨 앞)에 넣는다.
27352
27727
  *
27353
27728
  * @returns 보낸 본문 · 그 지문 · 그대로 둔 미디어({@link PushedBody})
27354
27729
  */
27355
27730
  async pushUpdatePage(pageId, markdownContent, notePath) {
27356
27731
  if (this.config.conversion.preferMarkdownApi !== false) {
27357
27732
  const deferred = deferSyncedReferences(obsidianToNotionEnhanced(markdownContent));
27358
- const { markdown, kept } = await this.imageHandler.keepUploadedMedia(
27733
+ const { markdown: body, kept } = await this.imageHandler.keepUploadedMedia(
27359
27734
  pageId,
27360
27735
  deferred.nfm,
27361
27736
  notePath
27362
27737
  );
27738
+ const folderFiles2 = await this.folderFileLines(pageId);
27739
+ const markdown = folderFiles2 ? `${body}
27740
+ ${folderFiles2}` : body;
27363
27741
  const written = await replacePageBody(this.notionClient, pageId, markdown, {
27364
27742
  databaseIdsOfBase: (basePath) => this.databaseIdsOfBase(basePath),
27365
27743
  unknownTagFallbacks: unknownTagFallbacks(markdownContent)
@@ -27379,13 +27757,19 @@ var PagePusher = class {
27379
27757
  "\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
27758
  );
27381
27759
  }
27760
+ const folderFiles = this.folderFileBlocks(existingBlocks, pageId, notePath);
27382
27761
  const { nfm, blocks } = blockModeBody(markdownContent);
27383
27762
  if (blocks.length > 0) {
27384
- await this.notionClient.appendBlockTree(pageId, blocks);
27763
+ await this.notionClient.appendBlockTree(
27764
+ pageId,
27765
+ blocks,
27766
+ folderFiles.size > 0 ? { atStart: true } : void 0
27767
+ );
27385
27768
  }
27386
27769
  const deleteSema = new Sema(this.config.advanced.concurrency);
27387
27770
  await Promise.all(
27388
27771
  existingBlocks.map(async (block) => {
27772
+ if (folderFiles.has(block.id)) return;
27389
27773
  await deleteSema.acquire();
27390
27774
  try {
27391
27775
  await this.notionClient.deleteBlock(block.id);
@@ -27396,6 +27780,41 @@ var PagePusher = class {
27396
27780
  );
27397
27781
  return { fingerprint: null, kept: [], sent: nfm, syncedRefs: [], syncedRefText: "" };
27398
27782
  }
27783
+ /**
27784
+ * 노트 페이지의 폴더 첨부 줄(ADR-056) — 본문을 통째로 바꿀 때 받은 줄 그대로 끝에 되실어야 Notion 이 그 블록을 둔다
27785
+ * ({@link ImageHandler.keepUploadedMedia} 와 같은 실측). 이 페이지에 적어 둔 폴더 첨부가 없으면 읽지 않는다. 읽지 못하면
27786
+ * 던진다 — 모르고 바꾸면 폴더 첨부가 지워진다.
27787
+ */
27788
+ async folderFileLines(pageId) {
27789
+ const ids = new Set(folderFileIdsOf(this.stateDb, pageId).values());
27790
+ if (ids.size === 0) return "";
27791
+ const remote = (await this.notionClient.getPageMarkdown(pageId)).markdown;
27792
+ return (folderFilesInNote(remote, { knownIds: ids })?.lines ?? []).join("\n");
27793
+ }
27794
+ /**
27795
+ * 블록 방식으로 바꿀 본문에서 남길 폴더 첨부 블록(ADR-056) — 맨 위 블록 가운데 {@link isFolderFileBlock}. 예전에는 함께 지우고
27796
+ * 기록을 놓아 노트를 고칠 때마다 폴더 첨부를 다시 올렸다.
27797
+ *
27798
+ * 맨 위에 없는 폴더 첨부(Notion 에서 토글 안으로 옮긴 것)는 그 토글과 함께 지워진다 — 기록을 놓아 이어지는 첨부 push 가
27799
+ * 페이지 끝에 다시 올리게 한다. 두면 볼트의 기록만 남아 다음 pull 이 Notion 에서 지운 파일로 본다.
27800
+ */
27801
+ folderFileBlocks(blocks, pageId, notePath) {
27802
+ const recorded = folderFileIdsOf(this.stateDb, pageId);
27803
+ const ids = new Set(recorded.values());
27804
+ const kept = blocks.filter((block) => isFolderFileBlock(block, ids));
27805
+ const keptIds = new Set(kept.map((block) => notionFileIdOf(fileUrlOfBlock(block) ?? "")));
27806
+ const lost = [...recorded].filter(([, fileId]) => !keptIds.has(fileId));
27807
+ for (const [localPath] of lost) {
27808
+ this.stateDb.deleteFileRegistry(localPath);
27809
+ forgetFolderFileId(this.stateDb, pageId, localPath);
27810
+ }
27811
+ if (lost.length > 0) {
27812
+ getLogger().info(
27813
+ `[Im-Nobsidian] \uBE14\uB85D \uBC29\uC2DD\uC73C\uB85C \uBCF8\uBB38\uC744 \uBC14\uAFB8\uBA70 \uB9E8 \uC704\uC5D0 \uC5C6\uB358 \uD3F4\uB354 \uCCA8\uBD80 ${lost.length}\uAC1C\uB97C \uB2E4\uC2DC \uC62C\uB9BC (${notePath})`
27814
+ );
27815
+ }
27816
+ return new Set(kept.map((block) => block.id));
27817
+ }
27399
27818
  /**
27400
27819
  * 자리표시로 보낸 코드를 채우고(S-22) 동기화 블록 참조를 붙인다({@link deferSyncedReferences}) — 본문을
27401
27820
  * 바꾼 뒤(블록 방식이면 옛 블록을 지운 뒤) 부른다.
@@ -27634,6 +28053,17 @@ var PagePuller = class {
27634
28053
  sanitizeFileName(this.notionClient.extractTitle(page))
27635
28054
  );
27636
28055
  }
28056
+ /**
28057
+ * dry-run — 새 페이지를 실제 pull 이 받을 자리(부모의 자리 + 제목). 그 안에서 찾은 DB 의 폴더를 실제처럼 그 옆에 둔다 — 예전에는
28058
+ * 자리를 몰라 `databases/` 아래로 보였다. 같은 이름이 있으면 실제 pull 은 접미사를 붙이지만 여기서는 붙이지 않는다(선점하지
28059
+ * 않는다). 볼트도 상태 DB 도 바꾸지 않는다.
28060
+ */
28061
+ async plannedNotePath(pageId) {
28062
+ const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
28063
+ const parent = await this.resolveParentPath(page);
28064
+ const name = `${sanitizeFileName(this.notionClient.extractTitle(page))}.md`;
28065
+ return parent ? `${parent}/${name}` : name;
28066
+ }
27637
28067
  /**
27638
28068
  * dry-run — 실제 pull({@link createPulledPage})이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7).
27639
28069
  * 다시 이을 노트(`note` — 충돌로 남길 것이면 `conflict`) · 이미 있는 폴더(`folder` — 폴더로만 받는 페이지), 아니면 null.
@@ -27773,7 +28203,10 @@ var PagePuller = class {
27773
28203
  const local = await readLocalNote(this.vaultFs, filePath);
27774
28204
  rendered = await render2(filePath, local);
27775
28205
  const relinked = local === void 0 ? null : this.relinkExistingNote(filePath, claim.existing, local, rendered, note);
27776
- if (relinked) return relinked;
28206
+ if (relinked) {
28207
+ await this.receiveNoteFiles(filePath, fileType, pageId, fetched);
28208
+ return relinked;
28209
+ }
27777
28210
  this.claimedPaths.delete(pathKey(filePath));
27778
28211
  filePath = (await this.resolveUniqueFilePath(noteDir, safeName, pageId)).path;
27779
28212
  rendered = await render2(filePath);
@@ -27786,8 +28219,19 @@ var PagePuller = class {
27786
28219
  properties: rendered.properties,
27787
28220
  status: "synced"
27788
28221
  });
28222
+ await this.receiveNoteFiles(filePath, fileType, pageId, fetched);
27789
28223
  return { path: filePath };
27790
28224
  }
28225
+ /** 새로 받은 노트 페이지의 폴더 첨부를 그 노트의 폴더에 받는다(ADR-056) — 폴더 노트는 그 폴더, 노트는 이름의 폴더. */
28226
+ async receiveNoteFiles(filePath, fileType, pageId, fetched) {
28227
+ if (fetched.noteFiles.length === 0) return;
28228
+ await this.receiveFolderFiles(
28229
+ childFolderOf({ fileType, obsidianPath: filePath }, this.placement.folderLookup()),
28230
+ pageId,
28231
+ fetched.noteFiles,
28232
+ fetched.noteFilesComplete
28233
+ );
28234
+ }
27791
28235
  /**
27792
28236
  * 상태 기록에 없는 노트가 이 페이지가 놓일 자리에 이미 있다 — 상태 DB 를 잃었거나 처음 잇는 볼트다(P7).
27793
28237
  * 예전에는 남의 노트로 보고 id 를 붙인 이름으로 또 받아 모든 노트가 두 벌이 됐다(push 는 모든 페이지를 또 만들었다).
@@ -27946,6 +28390,14 @@ var PagePuller = class {
27946
28390
  }
27947
28391
  const rendered = await this.renderRemotePage(record, change.pageId, page);
27948
28392
  const remoteContent = rendered.content;
28393
+ if (rendered.folderFiles && !remoteGone) {
28394
+ await this.receiveFolderFiles(
28395
+ childFolderOf(record, this.placement.folderLookup()),
28396
+ change.pageId,
28397
+ rendered.folderFiles.files,
28398
+ rendered.folderFiles.complete
28399
+ );
28400
+ }
27949
28401
  let localContent;
27950
28402
  let localExists = true;
27951
28403
  try {
@@ -28089,7 +28541,10 @@ var PagePuller = class {
28089
28541
  if (record.fileType === "db-row") {
28090
28542
  return this.databaseSyncer.renderRow(page, record.obsidianPath, { downloadMedia });
28091
28543
  }
28092
- const fetched = await this.fetchPageMarkdown(pageId);
28544
+ const local = await readLocalNote(this.vaultFs, record.obsidianPath);
28545
+ const fetched = await this.fetchPageMarkdown(pageId, {
28546
+ embedded: embeddedNamesOf(local === void 0 ? [] : [local])
28547
+ });
28093
28548
  const title = this.notionClient.extractTitle(page);
28094
28549
  const { content, properties } = await this.renderPageNote({
28095
28550
  pageId,
@@ -28100,11 +28555,17 @@ var PagePuller = class {
28100
28555
  titleFollowsName: this.titleFollowsName(record, title),
28101
28556
  downloadMedia,
28102
28557
  local: {
28103
- content: await readLocalNote(this.vaultFs, record.obsidianPath),
28558
+ content: local,
28104
28559
  markers: this.stateDb.getPreserveMarkers(record.obsidianPath)
28105
28560
  }
28106
28561
  });
28107
- return { content, title, properties, bodyFingerprint: fetched.fingerprint };
28562
+ return {
28563
+ content,
28564
+ title,
28565
+ properties,
28566
+ bodyFingerprint: fetched.fingerprint,
28567
+ folderFiles: { files: fetched.noteFiles, complete: fetched.noteFilesComplete }
28568
+ };
28108
28569
  }
28109
28570
  /**
28110
28571
  * 받은 페이지 본문을 볼트에 쓸 노트로 — 새로 받기(`createPulledPage`)와 다시 받기({@link renderRemotePage})가
@@ -28172,19 +28633,27 @@ var PagePuller = class {
28172
28633
  /**
28173
28634
  * 페이지 본문을 볼트 쪽 markdown 으로 받는다.
28174
28635
  *
28636
+ * @param options.embedded 로컬 노트가 임베드한 파일 이름 — 폴더 첨부({@link folderFilesInNote})를 노트에서 떼어
28637
+ * `noteFiles` 로 돌려줄 때, 이 이름 · 이 볼트가 노트 첨부로 올린 파일은 노트의 것으로 둔다(ADR-056).
28175
28638
  * @returns `fingerprint` 는 받은 원격 본문의 지문({@link remoteBodyFingerprint}). 블록 API 로
28176
28639
  * 받았으면 null — 지문은 Markdown API 의 본문으로만 견준다.
28177
28640
  */
28178
- async fetchPageMarkdown(pageId) {
28641
+ async fetchPageMarkdown(pageId, options = {}) {
28642
+ const noteFilesOf = (markdown) => noteFolderFilesOfPage(this.stateDb, pageId, markdown, options.embedded);
28179
28643
  if (this.config.conversion.preferMarkdownApi !== false) {
28180
28644
  try {
28181
28645
  const result = await this.notionClient.getPageMarkdown(pageId);
28182
28646
  this.discovery.collectInlineDbRefs(pageId, result.markdown);
28183
28647
  this.detector.noteBodyChildren(pageId, result.markdown);
28648
+ const { found: found2, holdsAll } = noteFilesOf(result.markdown);
28184
28649
  return {
28185
28650
  content: await this.resolveBlockLinks(
28186
28651
  this.resolveNotionIdWikilinks(
28187
- await notionBodyToObsidian(this.notionClient, result.markdown, pageId)
28652
+ await notionBodyToObsidian(
28653
+ this.notionClient,
28654
+ found2?.markdown ?? result.markdown,
28655
+ pageId
28656
+ )
28188
28657
  ),
28189
28658
  pageId
28190
28659
  ),
@@ -28194,7 +28663,9 @@ var PagePuller = class {
28194
28663
  children: extractChildTags(result.markdown),
28195
28664
  fingerprint: remoteBodyFingerprint(result.markdown),
28196
28665
  hasBody: hasBodyBesidesChildren(result.markdown),
28197
- folderFiles: folderFilesInBody(result.markdown)
28666
+ folderFiles: folderFilesInBody(result.markdown),
28667
+ noteFiles: found2?.files ?? [],
28668
+ noteFilesComplete: !result.truncated && holdsAll
28198
28669
  };
28199
28670
  } catch (error) {
28200
28671
  getLogger().warn(
@@ -28202,7 +28673,14 @@ var PagePuller = class {
28202
28673
  );
28203
28674
  }
28204
28675
  }
28205
- const fallback = await pageBlocksToObsidian(this.notionClient, pageId);
28676
+ let noteFiles = [];
28677
+ let noteFilesComplete = true;
28678
+ const fallback = await pageBlocksToObsidian(this.notionClient, pageId, (markdown) => {
28679
+ const { found: found2, holdsAll } = noteFilesOf(markdown);
28680
+ noteFiles = found2?.files ?? [];
28681
+ noteFilesComplete = holdsAll;
28682
+ return found2?.markdown ?? markdown;
28683
+ });
28206
28684
  this.discovery.collectInlineDbRefs(pageId, fallback.markdown);
28207
28685
  this.detector.noteBodyChildren(pageId, fallback.markdown);
28208
28686
  return {
@@ -28214,10 +28692,44 @@ var PagePuller = class {
28214
28692
  children: extractChildTags(fallback.markdown),
28215
28693
  fingerprint: null,
28216
28694
  hasBody: hasBodyBesidesChildren(fallback.markdown),
28217
- folderFiles: folderFilesInBody(fallback.markdown)
28695
+ folderFiles: folderFilesInBody(fallback.markdown),
28696
+ noteFiles,
28697
+ noteFilesComplete
28218
28698
  };
28219
28699
  }
28220
- /** 폴더 첨부를 받아 받은 파일 수에 더한다. */
28700
+ /**
28701
+ * 볼트 맨 위 파일 — 루트 페이지 끝의 폴더 첨부(「📁 이름」 · 적어 둔 것)를 볼트 맨 위에 받는다({@link rootFilesPageOf}).
28702
+ * 루트의 다른 블록(사용자가 붙인 파일 포함)은 받지 않는다. Markdown API 로 읽지 못하면 맨 위 블록에서 찾되 빠진 파일을
28703
+ * 지운 것으로 보지 않는다 — 토글 안으로 옮긴 것은 보이지 않는다. 읽지 못하면 경고만 남긴다(다음 pull 이 다시 읽는다).
28704
+ */
28705
+ async receiveRootFiles() {
28706
+ const rootId = rootFilesPageOf(this.config);
28707
+ if (!rootId) return;
28708
+ try {
28709
+ let files;
28710
+ let complete2;
28711
+ try {
28712
+ const result = await this.notionClient.getPageMarkdown(rootId);
28713
+ const { found: found2, holdsAll } = noteFolderFilesOfPage(this.stateDb, rootId, result.markdown);
28714
+ files = found2?.files ?? [];
28715
+ complete2 = !result.truncated && holdsAll;
28716
+ } catch {
28717
+ const ids = new Set(folderFileIdsOf(this.stateDb, rootId).values());
28718
+ files = (await this.notionClient.fetchAllChildren(rootId)).flatMap((block) => {
28719
+ const url = isFolderFileBlock(block, ids) ? fileUrlOfBlock(block) : null;
28720
+ const name = url ? uploadedFileNameFromUrl(url) : null;
28721
+ return url && name ? [{ url, name }] : [];
28722
+ });
28723
+ complete2 = false;
28724
+ }
28725
+ await this.receiveFolderFiles("", rootId, files, complete2);
28726
+ } catch (error) {
28727
+ getLogger().warn(
28728
+ `[Im-Nobsidian] \uBCFC\uD2B8 \uB9E8 \uC704 \uD30C\uC77C\uC744 \uB8E8\uD2B8 \uD398\uC774\uC9C0\uC5D0\uC11C \uBC1B\uC9C0 \uBABB\uD568: ${error instanceof Error ? error.message : String(error)}`
28729
+ );
28730
+ }
28731
+ }
28732
+ /** 폴더 첨부를 받아 받은 파일 수에 더한다 — 행의 폴더 첨부도 여기로 받는다(`DatabaseSyncer`). */
28221
28733
  async receiveFolderFiles(folder, pageId, files, complete2 = false) {
28222
28734
  this._pullFileCount += await this.fileHandler.receiveFolderFiles(
28223
28735
  folder,
@@ -28688,9 +29200,12 @@ var PullPlanner = class {
28688
29200
  const relinkPaths = new Map(
28689
29201
  [...relinks.byPage].map(([pageId, relink]) => [compactNotionId(pageId), relink.path])
28690
29202
  );
29203
+ const created = planned.filter((change) => change.type === "created").map((change) => change.pageId);
29204
+ await this.discovery.readNewPageBodies(created, options.signal);
29205
+ const createdIds = new Set(created.map(compactNotionId));
28691
29206
  const newDatabases = await this.discovery.plannedNewDatabases(failed, {
28692
29207
  rescanRoot: this.detector.rootChanged(),
28693
- notePathOf: (pageId) => relinkPaths.get(compactNotionId(pageId)) ?? this.stateDb.getByNotionId(pageId)?.obsidianPath ?? null
29208
+ notePathOf: async (pageId) => relinkPaths.get(compactNotionId(pageId)) ?? this.stateDb.getByNotionId(pageId)?.obsidianPath ?? (createdIds.has(compactNotionId(pageId)) ? await this.puller.plannedNotePath(pageId) : null)
28694
29209
  });
28695
29210
  const rows = [];
28696
29211
  for (const row of await this.planDatabaseRows(
@@ -29645,7 +30160,8 @@ var SyncOrchestrator = class {
29645
30160
  downloadTimeoutMs: config.advanced.mediaDownloadTimeoutMs,
29646
30161
  itemTimeoutMs: config.advanced.itemTimeoutMs,
29647
30162
  attachmentsFolder: config.paths.attachments,
29648
- deleteSync: config.sync.deleteSync
30163
+ deleteSync: config.sync.deleteSync,
30164
+ rootPageId: rootFilesPageOf(config)
29649
30165
  }
29650
30166
  );
29651
30167
  const propertyMapper = PropertyMapper.fromConfig(config);
@@ -29657,7 +30173,8 @@ var SyncOrchestrator = class {
29657
30173
  pipeline,
29658
30174
  imageHandler,
29659
30175
  () => this.observation.context,
29660
- (pageId, rawMarkdown) => this.detector.noteBodyChildren(pageId, rawMarkdown)
30176
+ (pageId, rawMarkdown) => this.detector.noteBodyChildren(pageId, rawMarkdown),
30177
+ (folder, pageId, files, complete2) => this.puller.receiveFolderFiles(folder, pageId, files, complete2)
29661
30178
  );
29662
30179
  this.discovery = new DatabaseDiscovery(
29663
30180
  config,
@@ -29880,7 +30397,11 @@ var SyncOrchestrator = class {
29880
30397
  async pushVaultFiles(failed) {
29881
30398
  if (this.config.sync.syncFiles === false) return 0;
29882
30399
  try {
29883
- const fileResults = await this.fileHandler.pushAllFiles(failed, this.placement);
30400
+ const fileResults = await this.fileHandler.pushAllFiles(
30401
+ failed,
30402
+ this.placement,
30403
+ this.notePageWrites()
30404
+ );
29884
30405
  if (fileResults.length > 0) {
29885
30406
  getLogger().info(`[Im-Nobsidian] ${fileResults.length}\uAC1C \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC`);
29886
30407
  }
@@ -29890,6 +30411,42 @@ var SyncOrchestrator = class {
29890
30411
  return 0;
29891
30412
  }
29892
30413
  }
30414
+ /**
30415
+ * 폴더 첨부를 노트의 페이지에 붙이거나 지울 때({@link NotePageWrites}) — 쓰기 전에 원격과 맞던 노트만 쓴 뒤의 원격을 본
30416
+ * 것으로 적는다. 받지 않은 Notion 편집이 걸린 노트를 적으면 다음 pull 이 그 편집을 받지 않는다. 읽지 못하면 적지 않는다 —
30417
+ * 다음 push 가 그 노트를 올리지 않고 pull 을 먼저 하라고 알릴 뿐이다.
30418
+ */
30419
+ notePageWrites() {
30420
+ const inSync = /* @__PURE__ */ new Map();
30421
+ return {
30422
+ begin: async (pageId) => {
30423
+ const record = this.stateDb.getByNotionId(pageId);
30424
+ if (!record || isFolderRecord(record)) return;
30425
+ try {
30426
+ const page = await this.notionClient.getPage(pageId);
30427
+ if (await this.drift.remoteDrift(record, page) === "none")
30428
+ inSync.set(pageId, record.id);
30429
+ } catch (error) {
30430
+ getLogger().warn(
30431
+ `[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)}`
30432
+ );
30433
+ }
30434
+ },
30435
+ end: async (pageId) => {
30436
+ const recordId = inSync.get(pageId);
30437
+ if (recordId === void 0) return;
30438
+ inSync.delete(pageId);
30439
+ try {
30440
+ const page = await this.notionClient.getPage(pageId);
30441
+ this.observation.record(recordId, page, await this.drift.remoteBodyFingerprintOf(pageId));
30442
+ } catch (error) {
30443
+ getLogger().warn(
30444
+ `[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)}`
30445
+ );
30446
+ }
30447
+ }
30448
+ };
30449
+ }
29893
30450
  /**
29894
30451
  * @param mergedByPull sync dry-run 만 — 앞선 pull dry-run 이 합칠 노트. 실제 sync 는 합친 뒤 원격을 본 것으로
29895
30452
  * 적어 push 가 올린다 — dry-run 은 받지 않아 원격이 바뀐 그대로라, 원격을 보고 거절하지 않는다.
@@ -30457,6 +31014,7 @@ var SyncOrchestrator = class {
30457
31014
  pendingDatabases
30458
31015
  );
30459
31016
  const finalize = async (created, updated, deleted, restored = 0) => {
31017
+ if (!options?.dryRun && this.detector.rootChanged()) await this.puller.receiveRootFiles();
30460
31018
  await this.puller.removeVacatedFolders();
30461
31019
  const renamedRows = this.databaseSyncer.takeRenamedRows();
30462
31020
  restoredPaths.push(...this.databaseSyncer.takeRestoredRows());
@@ -32468,7 +33026,7 @@ function hasColumnLayout(text) {
32468
33026
  var START_RE = new RegExp(`^[\\t ]*${COLUMN_LIST_START}[\\t ]*$`);
32469
33027
  var END_RE = new RegExp(`^[\\t ]*${COLUMN_LIST_END}[\\t ]*$`);
32470
33028
  var SEP_RE = new RegExp(`^[\\t ]*${COLUMN_SEP_SOURCE}[\\t ]*$`);
32471
- var FENCE_RE = /^[\t ]*(`{3,}|~{3,})/;
33029
+ var FENCE_RE2 = /^[\t ]*(`{3,}|~{3,})/;
32472
33030
  function readColumnLayout(lines) {
32473
33031
  const places = new Array(lines.length);
32474
33032
  let group2 = -1;
@@ -32492,7 +33050,7 @@ function readColumnLayout(lines) {
32492
33050
  open = null;
32493
33051
  };
32494
33052
  lines.forEach((line, i) => {
32495
- const fenceMatch = FENCE_RE.exec(line);
33053
+ const fenceMatch = FENCE_RE2.exec(line);
32496
33054
  if (fence) {
32497
33055
  if (fenceMatch && fenceMatch[1].startsWith(fence)) fence = null;
32498
33056
  return;