@im-nobsidian/core 0.11.0 → 0.12.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
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { LogLevel, Client } from '@notionhq/client';
3
- import { readFile, mkdir, writeFile, unlink, rename, stat, readdir } from 'fs/promises';
3
+ import { readFile, mkdir, writeFile, unlink, rmdir, rename, stat, readdir } from 'fs/promises';
4
4
  import { join, dirname, sep, relative } from 'path';
5
5
  import { mkdirSync, unlinkSync, utimesSync, openSync, writeSync, closeSync, readFileSync, statSync, existsSync, readlinkSync } from 'fs';
6
6
  import Database from 'better-sqlite3';
@@ -1476,6 +1476,13 @@ function normalizeAnnotations(annotations) {
1476
1476
  };
1477
1477
  }
1478
1478
  var DEFAULT_STYLE = JSON.stringify(normalizeAnnotations(void 0));
1479
+ function titleRunsOf(properties) {
1480
+ for (const prop of Object.values(properties)) {
1481
+ const value = prop;
1482
+ if (value?.type === "title") return Array.isArray(value.title) ? value.title : null;
1483
+ }
1484
+ return null;
1485
+ }
1479
1486
  function stringifyFrontmatter(content, data) {
1480
1487
  const astral = maskAstralCharacters(data);
1481
1488
  const out = matter.stringify(
@@ -1660,6 +1667,7 @@ function notionFileIdOf(url) {
1660
1667
  }
1661
1668
  function fileUrlOfBlock(block) {
1662
1669
  const media = block.type ? block[block.type] : void 0;
1670
+ if (block.type === "embed") return media?.url ?? null;
1663
1671
  return media?.file?.url ?? media?.external?.url ?? null;
1664
1672
  }
1665
1673
 
@@ -1879,6 +1887,13 @@ var PropertyMapper = class _PropertyMapper {
1879
1887
  relationKeys(keys) {
1880
1888
  return keys.filter((key) => this.schema.get(key)?.type === "relation");
1881
1889
  }
1890
+ /** 이 속성들 가운데 Notion 이 값을 정하는 것(수식 · 롤업 · ID · 생성일 …) — 볼트에서 고쳐도 보낼 수 없다. */
1891
+ readOnlyKeys(keys) {
1892
+ return keys.filter((key) => {
1893
+ const type = this.schema.get(key)?.type;
1894
+ return type !== void 0 && READ_ONLY_TYPES.has(type);
1895
+ });
1896
+ }
1882
1897
  /**
1883
1898
  * 값을 보낼 수 있는 속성만, pull 이 적는 모양으로 — 원격 값과 로컬 값을 견줄 때 쓴다.
1884
1899
  *
@@ -2307,13 +2322,6 @@ function dateText(value, zone) {
2307
2322
  if (!DATE_REGEX.test(text)) return null;
2308
2323
  return isWallTime(text) ? toZonedIso(text, zone) : text;
2309
2324
  }
2310
- function titleRunsOf(remote) {
2311
- for (const prop of Object.values(remote)) {
2312
- const value = prop;
2313
- if (value?.type === "title") return Array.isArray(value.title) ? value.title : null;
2314
- }
2315
- return null;
2316
- }
2317
2325
  function richTextRunsOf(remote, key) {
2318
2326
  const value = remote?.[key];
2319
2327
  return value?.type === "rich_text" && Array.isArray(value.rich_text) ? value.rich_text : null;
@@ -2762,8 +2770,7 @@ function isNotionAccessDenied(error) {
2762
2770
  return e.code === "restricted_resource" || e.status === 403;
2763
2771
  }
2764
2772
  function isTrashedOrArchived(page) {
2765
- const inTrash = page.in_trash === true;
2766
- return inTrash || page.archived === true;
2773
+ return page.in_trash === true || page.archived === true;
2767
2774
  }
2768
2775
  function isNotionBadRequest(error) {
2769
2776
  return notionStatusOf(error) === 400;
@@ -2796,6 +2803,7 @@ var DiscoveryTooLargeError = class extends Error {
2796
2803
  partial;
2797
2804
  };
2798
2805
  var NOT_IDEMPOTENT = { idempotent: false };
2806
+ var CREATES_BLOCKS = { idempotent: false, createsBlocks: true };
2799
2807
  function parentDatabaseIdOf(page) {
2800
2808
  const parent = page.parent;
2801
2809
  if (parent?.type !== "data_source_id" && parent?.type !== "database_id") return null;
@@ -2841,8 +2849,8 @@ var NotionClient = class _NotionClient {
2841
2849
  // 전역 쿨다운 밖에서, 로그 없이 흐른다.
2842
2850
  retry: false,
2843
2851
  // SDK 5.27+ 는 브라우저 환경(Obsidian 렌더러)에서 토큰을 받으면 던진다 — 남이 여는 페이지에 토큰이 실리는
2844
- // 것을 막는 검사다. 플러그인의 토큰은 사용자 기기의 볼트 설정에 있다. 5.23 은 이 옵션을 모른다(무시).
2845
- ...{ dangerouslyAllowBrowser: true },
2852
+ // 것을 막는 검사다. 플러그인의 토큰은 사용자 기기의 볼트 설정에 있다.
2853
+ dangerouslyAllowBrowser: true,
2846
2854
  ...options.fetch ? { fetch: options.fetch } : {}
2847
2855
  },
2848
2856
  options.notifications ?? DEFAULT_CONFIG.sync.notifications
@@ -2953,7 +2961,7 @@ var NotionClient = class _NotionClient {
2953
2961
  properties,
2954
2962
  children: params.children
2955
2963
  }),
2956
- NOT_IDEMPOTENT
2964
+ CREATES_BLOCKS
2957
2965
  );
2958
2966
  }
2959
2967
  /**
@@ -2978,7 +2986,7 @@ var NotionClient = class _NotionClient {
2978
2986
  }
2979
2987
  })
2980
2988
  ),
2981
- NOT_IDEMPOTENT
2989
+ CREATES_BLOCKS
2982
2990
  );
2983
2991
  const dataSourceId = db.data_sources?.[0]?.id ?? await this.primaryDataSourceId(db.id);
2984
2992
  if (!dataSourceId) throw new Error(`\uB9CC\uB4E0 DB(${db.id})\uC758 data source \uB97C \uC54C \uC218 \uC5C6\uC74C`);
@@ -3023,7 +3031,7 @@ var NotionClient = class _NotionClient {
3023
3031
  properties,
3024
3032
  markdown: params.markdown
3025
3033
  }),
3026
- NOT_IDEMPOTENT
3034
+ CREATES_BLOCKS
3027
3035
  );
3028
3036
  }
3029
3037
  /**
@@ -3438,7 +3446,7 @@ var NotionClient = class _NotionClient {
3438
3446
  });
3439
3447
  }
3440
3448
  async archivePage(pageId) {
3441
- await this.withRateLimit(() => this.client.pages.update({ page_id: pageId, archived: true }));
3449
+ await this.withRateLimit(() => this.client.pages.update({ page_id: pageId, in_trash: true }));
3442
3450
  }
3443
3451
  /**
3444
3452
  * 파일 블록의 지금 받을 수 있는 주소. 블록이 없거나 볼 수 없으면 null 이다.
@@ -3582,7 +3590,7 @@ var NotionClient = class _NotionClient {
3582
3590
  children: batch,
3583
3591
  ...after ? { position: { type: "after_block", after_block: { id: after } } } : {}
3584
3592
  }),
3585
- NOT_IDEMPOTENT
3593
+ CREATES_BLOCKS
3586
3594
  );
3587
3595
  const blocks = response.results.slice(0, batch.length);
3588
3596
  created.push(...blocks);
@@ -4086,6 +4094,12 @@ var NotionClient = class _NotionClient {
4086
4094
  * 슬롯을 붙잡아 남을 막는 대신, 다 같이 보는 이 게이트로 쉬게 한다.
4087
4095
  */
4088
4096
  cooldownUntil = 0;
4097
+ /**
4098
+ * Free 워크스페이스 블록 한도에 막힌 거절과 그 시각. 막힌 동안 블록을 만드는 요청은 보내지 않고 이 거절을 그대로
4099
+ * 돌려준다 — 플랜을 올리기 전에는 풀리지 않으므로 노트마다 같은 거절을 받으러 가지 않는다. 그 사이 플랜을
4100
+ * 올렸을 수 있어 {@link BLOCK_LIMIT_RECHECK_MS} 가 지나면 다시 보내 본다.
4101
+ */
4102
+ blockLimit = null;
4089
4103
  /**
4090
4104
  * 요청 1건을 rate limit 슬롯 안에서 실행하고, 재시도 가능한 실패는 백오프 후 다시 시도한다.
4091
4105
  *
@@ -4106,6 +4120,10 @@ var NotionClient = class _NotionClient {
4106
4120
  */
4107
4121
  async withRateLimit(fn, options) {
4108
4122
  const idempotent = options?.idempotent ?? true;
4123
+ if (options?.createsBlocks && this.blockLimit) {
4124
+ if (Date.now() - this.blockLimit.at < BLOCK_LIMIT_RECHECK_MS) throw this.blockLimit.error;
4125
+ this.blockLimit = null;
4126
+ }
4109
4127
  this.requestsSent++;
4110
4128
  let backoffMs = 0;
4111
4129
  for (let attempt = 0; ; attempt++) {
@@ -4115,6 +4133,12 @@ var NotionClient = class _NotionClient {
4115
4133
  this.lastRequestTime = Date.now();
4116
4134
  return await fn();
4117
4135
  } catch (error) {
4136
+ if (isWorkspaceBlockLimit(error)) {
4137
+ const limited = new NotionBlockLimitError(error);
4138
+ this.blockLimit = { error: limited, at: Date.now() };
4139
+ getLogger().warn(`[Im-Nobsidian] ${limited.message}`);
4140
+ throw limited;
4141
+ }
4118
4142
  if (!isRetryable(error, idempotent) || attempt >= this.maxRetries) throw error;
4119
4143
  const retryAfterMs = extractRetryAfter(error);
4120
4144
  backoffMs = retryAfterMs !== null ? retryAfterMs * (1 + Math.random() * 0.25) : this.retryBaseDelayMs * Math.pow(this.retryBackoffFactor, attempt) * (0.5 + Math.random() * 0.5);
@@ -4179,10 +4203,31 @@ function isAmbiguousFailure(error) {
4179
4203
  return status === 500 || status === 502 || status === 503 || status === 504;
4180
4204
  }
4181
4205
  function isRequestBlocked(error) {
4182
- if (notionStatusOf(error) !== 429) return false;
4206
+ return rateLimitReasonOf(error) === "public_api_request_blocked";
4207
+ }
4208
+ function rateLimitReasonOf(error) {
4209
+ if (notionStatusOf(error) !== 429) return null;
4183
4210
  const data = error.additional_data;
4184
- return data?.rate_limit_reason === "public_api_request_blocked";
4211
+ return typeof data?.rate_limit_reason === "string" ? data.rate_limit_reason : null;
4185
4212
  }
4213
+ function isWorkspaceBlockLimit(error) {
4214
+ if (notionStatusOf(error) !== 403) return false;
4215
+ const data = error.additional_data;
4216
+ return data?.block_limit !== void 0 && data.block_limit !== null;
4217
+ }
4218
+ var BLOCK_LIMIT_RECHECK_MS = 5 * 6e4;
4219
+ var NotionBlockLimitError = class extends Error {
4220
+ status = 403;
4221
+ code = "restricted_resource";
4222
+ constructor(cause) {
4223
+ const reason = cause instanceof Error ? cause.message : String(cause);
4224
+ super(
4225
+ `Notion \uC6CC\uD06C\uC2A4\uD398\uC774\uC2A4\uAC00 \uBB34\uB8CC \uBE14\uB85D \uD55C\uB3C4\uB97C \uB2E4 \uC368\uC11C \uC0C8 \uB0B4\uC6A9\uC744 \uB9CC\uB4E4 \uC218 \uC5C6\uC74C \u2014 Notion \uC5D0\uC11C \uD50C\uB79C\uC744 \uC62C\uB9AC\uBA74 \uD480\uB9B0\uB2E4 (Notion: ${reason})`,
4226
+ { cause }
4227
+ );
4228
+ this.name = "NotionBlockLimitError";
4229
+ }
4230
+ };
4186
4231
  function isRetryable(error, idempotent) {
4187
4232
  if (isRequestBlocked(error)) return false;
4188
4233
  return isExplicitRejection(error) || idempotent && isAmbiguousFailure(error);
@@ -4193,10 +4238,13 @@ function describeRetryCause(error) {
4193
4238
  const parts = [];
4194
4239
  if (typeof e.status === "number") parts.push(`status ${e.status}`);
4195
4240
  if (typeof e.code === "string") parts.push(e.code);
4241
+ const reason = rateLimitReasonOf(error);
4242
+ if (reason) parts.push(reason);
4196
4243
  if (typeof e.message === "string" && e.message) parts.push(e.message.slice(0, 120));
4197
4244
  return parts.length > 0 ? parts.join(" \xB7 ") : "\uC54C \uC218 \uC5C6\uB294 \uC624\uB958";
4198
4245
  }
4199
4246
  var MAX_RETRY_AFTER_MS = 6e4;
4247
+ var MAX_SHARED_RETRY_AFTER_MS = 5 * 6e4;
4200
4248
  function readHeader(headers, name) {
4201
4249
  if (typeof headers !== "object" || headers === null) return null;
4202
4250
  const get = headers.get;
@@ -4223,7 +4271,8 @@ function extractRetryAfter(error) {
4223
4271
  ms = Number.isNaN(at) ? Number.NaN : at - Date.now();
4224
4272
  }
4225
4273
  if (!Number.isFinite(ms) || ms <= 0) return null;
4226
- return Math.min(ms, MAX_RETRY_AFTER_MS);
4274
+ const cap = rateLimitReasonOf(error) === "public_api_space_request_rate_limit" ? MAX_SHARED_RETRY_AFTER_MS : MAX_RETRY_AFTER_MS;
4275
+ return Math.min(ms, cap);
4227
4276
  }
4228
4277
  function sleep(ms) {
4229
4278
  return new Promise((resolve2) => setTimeout(resolve2, ms));
@@ -5061,6 +5110,7 @@ function deferredSyncedRefMarker(index) {
5061
5110
  }
5062
5111
  var TOGGLE_START = compactMarker("toggle:start");
5063
5112
  var TOGGLE_END = compactMarker("toggle:end");
5113
+ var LARGE_FILE_MARKER = compactMarker("large-file");
5064
5114
  var TOGGLE_HEADING_START = compactMarker("toggle-heading");
5065
5115
  var TOGGLE_HEADING_END = compactMarker("toggle-heading:end");
5066
5116
  function calloutIndentMarker(depth2) {
@@ -5143,7 +5193,6 @@ var MARKER_TOKEN_RE = new RegExp(
5143
5193
 
5144
5194
  // src/converter/pipeline.ts
5145
5195
  var INLINE_DB_PROBE = new RegExp(`%% ${MARKER_BRAND_RE}:inline-db`);
5146
- var TOGGLE_HEADING_PROBE = new RegExp(`%% ${MARKER_BRAND_RE}:toggle-heading`);
5147
5196
  var ConversionPipeline = class {
5148
5197
  preProcessors = [];
5149
5198
  postProcessors = [];
@@ -5155,11 +5204,14 @@ var ConversionPipeline = class {
5155
5204
  this.postProcessors.push(processor);
5156
5205
  this.postProcessors.sort((a, b) => a.order - b.order);
5157
5206
  }
5207
+ /**
5208
+ * 토글 헤딩은 Markdown API 경로가 변환한다(`toggle-heading.ts`) — 블록 경로로 보내면 콜아웃이 옛 이모지 꼴로
5209
+ * 바뀐다(`CalloutTransformer`). 예전에 여기 있던 토글 헤딩 탐지는 띄어 쓴 마커를 찾아 한 번도 맞지 않았다.
5210
+ */
5158
5211
  selectPath(content) {
5159
5212
  const hasInlineDb = INLINE_DB_PROBE.test(content);
5160
5213
  const hasColumnLayout2 = />\s*\[!col\]/.test(content);
5161
- const hasToggleHeading = TOGGLE_HEADING_PROBE.test(content);
5162
- if (hasInlineDb || hasColumnLayout2 || hasToggleHeading) {
5214
+ if (hasInlineDb || hasColumnLayout2) {
5163
5215
  return "block-api";
5164
5216
  }
5165
5217
  return "markdown-api";
@@ -5788,6 +5840,27 @@ function obsidianMathCloseAt(line, open) {
5788
5840
  return close;
5789
5841
  }
5790
5842
 
5843
+ // src/converter/media-labels.ts
5844
+ var MEDIA_LABELS = {
5845
+ audio: "\u{1F50A}",
5846
+ video: "\u{1F3AC}",
5847
+ pdf: "\u{1F4C4}",
5848
+ file: "\u{1F4CE}",
5849
+ html: "\u{1F310}"
5850
+ };
5851
+ var LEGACY_MEDIA_ICONS = ["\u{1F39E}"];
5852
+ var MEDIA_PLACEHOLDER_CAPTIONS = new Set(Object.keys(MEDIA_LABELS));
5853
+ function mediaLabel(kind, caption) {
5854
+ return `${MEDIA_LABELS[kind]} ${caption || kind}`;
5855
+ }
5856
+ function mediaLinkRe(kind) {
5857
+ return new RegExp(`\\[${MEDIA_LABELS[kind]}\\s*([^\\]]*)\\]\\(([^)]+)\\)`, "g");
5858
+ }
5859
+ var MEDIA_LABEL_PREFIX_RE = new RegExp(
5860
+ `^(?:${[...Object.values(MEDIA_LABELS), ...LEGACY_MEDIA_ICONS].join("|")})\\s*`,
5861
+ "u"
5862
+ );
5863
+
5791
5864
  // src/converter/container-head.ts
5792
5865
  var CALLOUT_HEAD_LINE_RE = /^([ \t]{0,3})> \[!(\w+)\]([-+])?[ \t]*(.*)$/;
5793
5866
  var TOGGLE_HEAD_SOURCE = String.raw`^([ \t]{0,3})> \[!toggle\]-`;
@@ -5862,7 +5935,7 @@ var EmbedResolver = class {
5862
5935
  (match, alt, url, offset) => {
5863
5936
  if (code.some(([start, end]) => offset >= start && offset < end)) return match;
5864
5937
  if (isVideoUrl(url)) {
5865
- return `[\u{1F3AC} ${alt || "video"}](${url})`;
5938
+ return `[${mediaLabel("video", alt)}](${url})`;
5866
5939
  }
5867
5940
  if (isExternalUrl(url)) {
5868
5941
  images.push({ url, isExternal: true });
@@ -7426,6 +7499,112 @@ var BlankLineCollapser = class {
7426
7499
  }
7427
7500
  };
7428
7501
 
7502
+ // src/converter/heading-markers.ts
7503
+ var MARKER = `%%${MARKER_BRAND_RE}:(?:block-color:[a-z_]+|toggle-heading)%%`;
7504
+ var MARKERS = `(?:${MARKER}[ \\t]*)+`;
7505
+ var HEADING_WITH_MARKERS_RE = new RegExp(`^([\\t ]*)(#{1,6}[ \\t].*?)[ \\t]+(${MARKERS})$`, "gm");
7506
+ var MARKER_LINE_AFTER_HEADING_RE = new RegExp(
7507
+ `^([\\t ]*#{1,6}[ \\t].*?)[ \\t]*\\n[\\t ]*(${MARKERS})$`,
7508
+ "gm"
7509
+ );
7510
+ var HEADING_LINE_RE = /^[\t ]*#{1,6}[ \t]/;
7511
+ var MARKER_LINE_RE = new RegExp(`^[\\t ]*${MARKERS}$`);
7512
+ function placeHeadingMarkersBelow(content) {
7513
+ return mapOutsideCodeFences(
7514
+ content,
7515
+ (segment) => segment.replace(
7516
+ HEADING_WITH_MARKERS_RE,
7517
+ (_m, indent, heading, markers) => `${indent}${heading}
7518
+ ${indent}${markers.trim()}`
7519
+ )
7520
+ );
7521
+ }
7522
+ function liftHeadingMarkers(content) {
7523
+ return mapOutsideCodeFences(
7524
+ content,
7525
+ (segment) => segment.replace(
7526
+ MARKER_LINE_AFTER_HEADING_RE,
7527
+ (_m, heading, markers) => `${heading} ${markers.trim()}`
7528
+ )
7529
+ );
7530
+ }
7531
+ function isHeadingLine(line) {
7532
+ return HEADING_LINE_RE.test(line);
7533
+ }
7534
+ function isHeadingMarkerLine(line) {
7535
+ return MARKER_LINE_RE.test(line);
7536
+ }
7537
+ function sectionLinesWithHeadingMarkers(all, start, end) {
7538
+ const lines = all.slice(start, end + 1);
7539
+ const last = lines.length - 1;
7540
+ const next = all[end + 1];
7541
+ if (last >= 0 && next !== void 0 && isHeadingLine(lines[last]) && isHeadingMarkerLine(next)) {
7542
+ lines[last] = `${lines[last]} ${next.trim()}`;
7543
+ }
7544
+ return lines;
7545
+ }
7546
+
7547
+ // src/converter/pre-processors/heading-marker-lifter.ts
7548
+ var HeadingMarkerLifter = class {
7549
+ name = "HeadingMarkerLifter";
7550
+ order = 0;
7551
+ process(input) {
7552
+ if (input.context.direction !== "push") {
7553
+ return { content: input.content, metadata: input.metadata };
7554
+ }
7555
+ return { content: liftHeadingMarkers(input.content), metadata: input.metadata };
7556
+ }
7557
+ };
7558
+
7559
+ // src/converter/degrade-link.ts
7560
+ function degradeLink(label, url, marker) {
7561
+ return `[${label}](${url.replace(/\(/g, "%28").replace(/\)/g, "%29")})${marker}`;
7562
+ }
7563
+ var DEGRADE_LINK_SOURCE = "(?:\\[(?:\\\\.|[^\\\\\\]])*\\]\\([^)]*\\))?";
7564
+ function embedVaultLink(src, caption = "") {
7565
+ const label = caption.replace(/<[^>]*>/g, "").replace(/[[\]\\]/g, "").trim();
7566
+ return degradeLink(label === "" ? "\u{1F517} Embed" : `\u{1F517} ${label}`, src, embedMarker(src, caption));
7567
+ }
7568
+
7569
+ // src/converter/large-file-link.ts
7570
+ var LARGE_FILE_LINK_RE = new RegExp(` ?${DEGRADE_LINK_SOURCE}${LARGE_FILE_MARKER}`, "g");
7571
+ function notionFileLocation(pageId, blockId) {
7572
+ const page = `https://www.notion.so/${compactNotionId(pageId)}`;
7573
+ return blockId ? `${page}#${compactNotionId(blockId)}` : page;
7574
+ }
7575
+ function fileSizeLabel(bytes) {
7576
+ const mb = bytes / 1024 / 1024;
7577
+ return mb >= 1 ? `${Math.round(mb)}MB` : `${Math.max(1, Math.round(bytes / 1024))}KB`;
7578
+ }
7579
+ function largeFileLink(location, bytes) {
7580
+ return ` ${degradeLink(`\u2197 Notion \uC5D0\uC11C \uC5F4\uAE30 \xB7 ${fileSizeLabel(bytes)}`, location, LARGE_FILE_MARKER)}`;
7581
+ }
7582
+ function withLargeFileLink(content, fileLink, link) {
7583
+ const at = content.indexOf(fileLink);
7584
+ if (at < 0) return content;
7585
+ const end = at + fileLink.length;
7586
+ const sticky = new RegExp(LARGE_FILE_LINK_RE.source, "y");
7587
+ sticky.lastIndex = end;
7588
+ const existing = sticky.exec(content);
7589
+ const rest = existing ? end + existing[0].length : end;
7590
+ return `${content.slice(0, end)}${link}${content.slice(rest)}`;
7591
+ }
7592
+ function stripLargeFileLinks(content) {
7593
+ return content.includes(LARGE_FILE_MARKER) ? content.replace(LARGE_FILE_LINK_RE, "") : content;
7594
+ }
7595
+
7596
+ // src/converter/pre-processors/large-file-link-stripper.ts
7597
+ var LargeFileLinkStripper = class {
7598
+ name = "LargeFileLinkStripper";
7599
+ order = 0;
7600
+ process(input) {
7601
+ if (input.context.direction !== "push") {
7602
+ return { content: input.content, metadata: input.metadata };
7603
+ }
7604
+ return { content: stripLargeFileLinks(input.content), metadata: input.metadata };
7605
+ }
7606
+ };
7607
+
7429
7608
  // src/utils/wikilink-title.ts
7430
7609
  function wikilinkTitleFromPath(obsidianPath) {
7431
7610
  return noteNameFromPath(obsidianPath);
@@ -7436,7 +7615,7 @@ function formatWikilink(target, label) {
7436
7615
 
7437
7616
  // src/converter/link-restore.ts
7438
7617
  var NOTION_LINK_REGEX = new RegExp(
7439
- `\\[${MARKER_LABEL_CAPTURE}\\]\\((?:https:\\/\\/(?:www\\.)?notion\\.so\\/|notion:\\/\\/)([a-f0-9-]+)\\)(?!%%${MARKER_BRAND_RE}:(?:unknown|embed):)`,
7618
+ `\\[${MARKER_LABEL_CAPTURE}\\]\\((?:https:\\/\\/(?:www\\.)?notion\\.so\\/|notion:\\/\\/)([a-f0-9-]+)\\)(?!%%${MARKER_BRAND_RE}:(?:unknown|embed):|${LARGE_FILE_MARKER})`,
7440
7619
  "g"
7441
7620
  );
7442
7621
  var WIKILINK_PRESERVE_REGEX = new RegExp(
@@ -7701,7 +7880,7 @@ var PreserveMarkerInjector = class {
7701
7880
  const rendered = this.renderMarker(m);
7702
7881
  if (rendered === null) return false;
7703
7882
  if (content.includes(rendered)) return false;
7704
- return !this.isSemanticallyRestored(m, content);
7883
+ return !this.restoredElsewhere(m);
7705
7884
  });
7706
7885
  if (missing.length === 0) {
7707
7886
  return { content, metadata: input.metadata };
@@ -7736,15 +7915,11 @@ var PreserveMarkerInjector = class {
7736
7915
  const body = entries.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
7737
7916
  return spacedMarker(`${marker.type}:${body}`);
7738
7917
  }
7739
- /** 마커가 이미 원래 문법으로 복원돼 있으면 재삽입은 중복·오염이다. */
7740
- isSemanticallyRestored(marker, content) {
7741
- const params = marker.params ?? {};
7918
+ /** 다른 복원기가 맡는 마커 — 본문에 다시 끼우면 중복 · 오염이다. */
7919
+ restoredElsewhere(marker) {
7742
7920
  switch (marker.type) {
7743
- case "wikilink": {
7744
- const text = params.text;
7745
- if (!text) return false;
7746
- return content.includes(`[[${text}]]`) || content.includes(`[[${text}|`);
7747
- }
7921
+ case "wikilink":
7922
+ return true;
7748
7923
  case "local-image":
7749
7924
  case "local-file":
7750
7925
  return true;
@@ -8604,7 +8779,10 @@ var CommentRestorer = class {
8604
8779
  if (input.context.direction !== "pull" || local === void 0) {
8605
8780
  return { content: input.content, metadata: input.metadata };
8606
8781
  }
8607
- return { content: restoreComments(input.content, local), metadata: input.metadata };
8782
+ return {
8783
+ content: restoreComments(input.content, liftHeadingMarkers(local)),
8784
+ metadata: input.metadata
8785
+ };
8608
8786
  }
8609
8787
  };
8610
8788
 
@@ -8953,9 +9131,10 @@ function pieces(local, pulled) {
8953
9131
  let j = 0;
8954
9132
  const zip = (to) => {
8955
9133
  const theirs = pulled.slice(j, to);
8956
- for (let k = 0; k < Math.max(pending.length, theirs.length); k++) {
8957
- out.push([pending.slice(k, k + 1), theirs.slice(k, k + 1)]);
8958
- }
9134
+ const paired = Math.min(pending.length, theirs.length);
9135
+ for (let k = 0; k < paired; k++) out.push([[pending[k]], [theirs[k]]]);
9136
+ if (pending.length > paired) out.push([pending.slice(paired), []]);
9137
+ for (let k = paired; k < theirs.length; k++) out.push([[], [theirs[k]]]);
8959
9138
  pending = [];
8960
9139
  j = to;
8961
9140
  };
@@ -9172,6 +9351,18 @@ function readable(note) {
9172
9351
  }
9173
9352
  }
9174
9353
 
9354
+ // src/converter/post-processors/heading-marker-placer.ts
9355
+ var HeadingMarkerPlacer = class {
9356
+ name = "HeadingMarkerPlacer";
9357
+ order = 140;
9358
+ process(input) {
9359
+ if (input.context.direction !== "pull") {
9360
+ return { content: input.content, metadata: input.metadata };
9361
+ }
9362
+ return { content: placeHeadingMarkersBelow(input.content), metadata: input.metadata };
9363
+ }
9364
+ };
9365
+
9175
9366
  // src/converter/literal-escapes.ts
9176
9367
  var ASCII_PUNCT_RE = /[!-/:-@[-`{-~]/;
9177
9368
  var MATH_FENCE_LINE_RE = /^(?:[ \t]*>)*[ \t]*\$\$[ \t]*$/;
@@ -9752,6 +9943,8 @@ function findBodyEnd(lines, from, level) {
9752
9943
  // src/utils/sanitize.ts
9753
9944
  var INVALID_CHARS = /[<>:"/\\|?*\x00-\x1f]/g;
9754
9945
  var WIKILINK_UNSAFE_CHARS = /[[\]]/g;
9946
+ var LINK_SUBPATH_CHARS = /[#^]/g;
9947
+ var TRAILING_DOTS_AND_SPACES = /[.\s]+$/;
9755
9948
  var TRAILING_DOTS = /\.+$/;
9756
9949
  var RESERVED_NAMES = /* @__PURE__ */ new Set([
9757
9950
  "CON",
@@ -9777,15 +9970,38 @@ var RESERVED_NAMES = /* @__PURE__ */ new Set([
9777
9970
  "LPT8",
9778
9971
  "LPT9"
9779
9972
  ]);
9780
- function sanitizeFileName(name) {
9781
- let sanitized = name.replace(INVALID_CHARS, "_").replace(WIKILINK_UNSAFE_CHARS, "_").replace(TRAILING_DOTS, "").trim().slice(0, 200);
9782
- if (RESERVED_NAMES.has(sanitized.toUpperCase())) {
9783
- sanitized = `_${sanitized}`;
9784
- }
9785
- if (sanitized.length === 0) {
9786
- sanitized = "_untitled";
9973
+ var FILE_NAME_MAX_BYTES = 200;
9974
+ var FILE_SYSTEM_NAME_MAX_BYTES = 255;
9975
+ var utf8 = new TextEncoder();
9976
+ function utf8Bytes(text) {
9977
+ return utf8.encode(text).length;
9978
+ }
9979
+ function truncateBytes(name, maxBytes) {
9980
+ if (utf8.encode(name).length <= maxBytes) return name;
9981
+ let out = "";
9982
+ let bytes = 0;
9983
+ for (const { segment } of new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(
9984
+ name
9985
+ )) {
9986
+ const size = utf8.encode(segment).length;
9987
+ if (bytes + size > maxBytes) break;
9988
+ out += segment;
9989
+ bytes += size;
9787
9990
  }
9788
- return sanitized;
9991
+ return out;
9992
+ }
9993
+ function guarded(name) {
9994
+ const safe = RESERVED_NAMES.has(name.toUpperCase()) ? `_${name}` : name;
9995
+ return safe.length === 0 ? "_untitled" : safe;
9996
+ }
9997
+ function sanitizeFileName(name) {
9998
+ const cleaned = name.replace(INVALID_CHARS, "_").replace(WIKILINK_UNSAFE_CHARS, "_").replace(LINK_SUBPATH_CHARS, "_").trim();
9999
+ return guarded(truncateBytes(cleaned, FILE_NAME_MAX_BYTES).replace(TRAILING_DOTS_AND_SPACES, ""));
10000
+ }
10001
+ function legacySanitizeFileName(name) {
10002
+ return guarded(
10003
+ name.replace(INVALID_CHARS, "_").replace(WIKILINK_UNSAFE_CHARS, "_").replace(TRAILING_DOTS, "").trim().slice(0, 200)
10004
+ );
9789
10005
  }
9790
10006
 
9791
10007
  // src/converter/nfm-attrs.ts
@@ -10289,16 +10505,6 @@ function breaksToEmptyBlocks(content) {
10289
10505
  });
10290
10506
  }
10291
10507
 
10292
- // src/converter/degrade-link.ts
10293
- function degradeLink(label, url, marker) {
10294
- return `[${label}](${url.replace(/\(/g, "%28").replace(/\)/g, "%29")})${marker}`;
10295
- }
10296
- var DEGRADE_LINK_SOURCE = "(?:\\[(?:\\\\.|[^\\\\\\]])*\\]\\([^)]*\\))?";
10297
- function embedVaultLink(src, caption = "") {
10298
- const label = caption.replace(/<[^>]*>/g, "").replace(/[[\]\\]/g, "").trim();
10299
- return degradeLink(label === "" ? "\u{1F517} Embed" : `\u{1F517} ${label}`, src, embedMarker(src, caption));
10300
- }
10301
-
10302
10508
  // src/converter/placeholder-indent.ts
10303
10509
  var PLACEHOLDER_LINE_RE = new RegExp(`^([ \\t]*)${MEDIA_PLACEHOLDER_HEAD}`);
10304
10510
  var LEADING_SPACE_RE3 = /^[ \t]*/;
@@ -11240,6 +11446,7 @@ var NOTION_AUDIO_RE = /<audio src="([^"]*)">([\s\S]*?)<\/audio>/g;
11240
11446
  var NOTION_VIDEO_RE = /<video src="([^"]*)">([\s\S]*?)<\/video>/g;
11241
11447
  var NOTION_PDF_RE = /<pdf src="([^"]*)">([\s\S]*?)<\/pdf>/g;
11242
11448
  var NOTION_FILE_RE = /<file src="([^"]*)">([\s\S]*?)<\/file>/g;
11449
+ var NOTION_HOSTED_EMBED_RE = /<embed\s+src="([^"]*)"[^>]*>([^\n]*?)\s*<\/embed>/g;
11243
11450
  var NOTION_TAB_RE = /<tab title="([^"]*)">([\s\S]*?)<\/tab>/g;
11244
11451
  var NOTION_LABELED_PAGE_LINK_RE = new RegExp(
11245
11452
  `\\[${MARKER_LABEL_CAPTURE}\\]\\(https?:\\/\\/(?:[a-z]+\\.)?notion\\.(?:so|com)\\/(?:p\\/)?([a-f0-9]{32}|[a-f0-9-]{36})\\)`,
@@ -11294,7 +11501,7 @@ function unescapeBrackets(content) {
11294
11501
  );
11295
11502
  }
11296
11503
  function obsidianToNotionEnhanced(obsidian) {
11297
- let result = obsidian;
11504
+ let result = liftHeadingMarkers(stripLargeFileLinks(obsidian));
11298
11505
  result = quotedCodeToChildren(result);
11299
11506
  result = breaksToEmptyBlocks(result);
11300
11507
  result = pipeTablesToNfmTables(result);
@@ -11556,20 +11763,20 @@ ${indented}
11556
11763
  function convertMediaTags(content) {
11557
11764
  let result = content;
11558
11765
  result = result.replace(NOTION_AUDIO_RE, (_match, src, caption) => {
11559
- const cap = caption.trim();
11560
- return cap ? `[\u{1F50A} ${cap}](${src})` : `[\u{1F50A} audio](${src})`;
11766
+ return `[${mediaLabel("audio", caption.trim())}](${src})`;
11561
11767
  });
11562
11768
  result = result.replace(NOTION_VIDEO_RE, (_match, src, caption) => {
11563
- const cap = caption.trim();
11564
- return cap ? `[\u{1F3AC} ${cap}](${src})` : `[\u{1F3AC} video](${src})`;
11769
+ return `[${mediaLabel("video", caption.trim())}](${src})`;
11565
11770
  });
11566
11771
  result = result.replace(NOTION_PDF_RE, (_match, src, caption) => {
11567
- const cap = caption.trim();
11568
- return cap ? `[\u{1F4C4} ${cap}](${src})` : `[\u{1F4C4} pdf](${src})`;
11772
+ return `[${mediaLabel("pdf", caption.trim())}](${src})`;
11569
11773
  });
11570
11774
  result = result.replace(NOTION_FILE_RE, (_match, src, caption) => {
11571
- const cap = caption.trim();
11572
- return cap ? `[\u{1F4CE} ${cap}](${src})` : `[\u{1F4CE} file](${src})`;
11775
+ return `[${mediaLabel("file", caption.trim())}](${src})`;
11776
+ });
11777
+ result = result.replace(NOTION_HOSTED_EMBED_RE, (match, src, caption) => {
11778
+ if (!isNotionHostedFileUrl(src)) return match;
11779
+ return `[${mediaLabel("html", caption.trim())}](${src})`;
11573
11780
  });
11574
11781
  return result;
11575
11782
  }
@@ -11806,10 +12013,11 @@ $$`;
11806
12013
  function unescapeNotionChars(content) {
11807
12014
  return content.replace(/\\~/g, "~").replace(/\\\^/g, "^");
11808
12015
  }
11809
- var OBSIDIAN_MEDIA_AUDIO_RE = /\[🔊\s*([^\]]*)\]\(([^)]+)\)/g;
11810
- var OBSIDIAN_MEDIA_VIDEO_RE = /\[🎬\s*([^\]]*)\]\(([^)]+)\)/g;
11811
- var OBSIDIAN_MEDIA_PDF_RE = /\[📄\s*([^\]]*)\]\(([^)]+)\)/g;
11812
- var OBSIDIAN_MEDIA_FILE_RE = /\[📎\s*([^\]]*)\]\(([^)]+)\)/g;
12016
+ var OBSIDIAN_MEDIA_AUDIO_RE = mediaLinkRe("audio");
12017
+ var OBSIDIAN_MEDIA_VIDEO_RE = mediaLinkRe("video");
12018
+ var OBSIDIAN_MEDIA_PDF_RE = mediaLinkRe("pdf");
12019
+ var OBSIDIAN_MEDIA_FILE_RE = mediaLinkRe("file");
12020
+ var OBSIDIAN_MEDIA_HTML_RE = mediaLinkRe("html");
11813
12021
  function restoreMediaTags(content) {
11814
12022
  let result = content;
11815
12023
  const restore = (tag) => (_match, caption, src) => {
@@ -11820,6 +12028,10 @@ function restoreMediaTags(content) {
11820
12028
  result = result.replace(OBSIDIAN_MEDIA_VIDEO_RE, restore("video"));
11821
12029
  result = result.replace(OBSIDIAN_MEDIA_PDF_RE, restore("pdf"));
11822
12030
  result = result.replace(OBSIDIAN_MEDIA_FILE_RE, restore("file"));
12031
+ result = result.replace(OBSIDIAN_MEDIA_HTML_RE, (_match, caption, src) => {
12032
+ const cap = caption.trim() === "html" ? "" : caption;
12033
+ return `<embed src="${src}">${cap}</embed>`;
12034
+ });
11823
12035
  return result;
11824
12036
  }
11825
12037
  var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)((?:\n> .*)*)/gm;
@@ -12090,6 +12302,8 @@ function sortKeys(value) {
12090
12302
  // src/converter/pipeline-factory.ts
12091
12303
  function createDefaultPipeline(options) {
12092
12304
  const pipeline = new ConversionPipeline();
12305
+ pipeline.registerPreProcessor(new HeadingMarkerLifter());
12306
+ pipeline.registerPreProcessor(new LargeFileLinkStripper());
12093
12307
  pipeline.registerPreProcessor(new InlineAnnotationPreserver());
12094
12308
  pipeline.registerPreProcessor(new UnsupportedBlockStripper());
12095
12309
  pipeline.registerPreProcessor(new CommentStripper());
@@ -12120,6 +12334,7 @@ function createDefaultPipeline(options) {
12120
12334
  pipeline.registerPostProcessor(
12121
12335
  new LocalFormRestorer((note, context, children) => sentForm(pipeline, note, context, children))
12122
12336
  );
12337
+ pipeline.registerPostProcessor(new HeadingMarkerPlacer());
12123
12338
  return pipeline;
12124
12339
  }
12125
12340
 
@@ -12227,6 +12442,11 @@ function isNotionUploadable(path) {
12227
12442
  const ext = /\.[^.]+$/.exec(name)?.[0].toLowerCase();
12228
12443
  return ext !== void 0 && NOTION_UPLOADABLE_EXTENSIONS.has(ext);
12229
12444
  }
12445
+ var HTML_BLOCK_EXTENSIONS = /* @__PURE__ */ new Set([".html", ".htm"]);
12446
+ function isHtmlBlockFile(path) {
12447
+ const ext = /\.[^./]+$/.exec(path)?.[0].toLowerCase();
12448
+ return ext !== void 0 && HTML_BLOCK_EXTENSIONS.has(ext);
12449
+ }
12230
12450
  function getBlockType(filename) {
12231
12451
  const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
12232
12452
  if (!ext) return "file";
@@ -12354,10 +12574,15 @@ function toWikilinkAlias(label) {
12354
12574
  return label.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, "$1").replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, t, d) => d ?? t).replace(/[\r\n]+/g, " ").replace(/\\([!-/:-@[-`{-~])/g, "$1").replace(/\]\]/g, ")").replace(/\|/g, "-").trim();
12355
12575
  }
12356
12576
  var INTERNAL_FILE_URL_RE = /^file:\/\/%7B.*%7D%7D$/;
12357
- var FILE_LABEL_PREFIX_RE = /^(?:📎|🎬|🎞|📄|🔊)\s*/u;
12358
- var MEDIA_TAG_RE = /<(?:file|video|audio|pdf)\s+src="([^"]+)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
12359
- var MEDIA_TAG_NAME_RE = /^<(file|video|audio|pdf)\s/;
12360
- var MEDIA_PLACEHOLDER_CAPTIONS = /* @__PURE__ */ new Set(["file", "pdf", "video", "audio"]);
12577
+ var MEDIA_TAG_NAMES = "file|video|audio|pdf|embed";
12578
+ function mediaTagRe(src) {
12579
+ return new RegExp(
12580
+ `<(?:${MEDIA_TAG_NAMES})\\s+src="(${src})">([\\s\\S]*?)<\\/(?:${MEDIA_TAG_NAMES})>`,
12581
+ "g"
12582
+ );
12583
+ }
12584
+ var MEDIA_TAG_RE = mediaTagRe('[^"]+');
12585
+ var MEDIA_TAG_NAME_RE = new RegExp(`^<(${MEDIA_TAG_NAMES})\\s`);
12361
12586
  var PLACEHOLDER_IMAGE_STEMS = /* @__PURE__ */ new Set(["image", "untitled"]);
12362
12587
  var EMBED_SIZE_RE = /^\d+(?:x\d+)?$/;
12363
12588
  function splitEmbedTarget(target) {
@@ -12381,8 +12606,8 @@ function embedTargetFromCaption(target, caption, uploadedName) {
12381
12606
  return description ? sized(`${path}|${alias}`) : `${path}|${alias}`;
12382
12607
  }
12383
12608
  function captionOfLabel(label) {
12384
- const caption = label.replace(FILE_LABEL_PREFIX_RE, "").replace(/\\(.)/g, "$1").trim();
12385
- return FILE_LABEL_PREFIX_RE.test(label) && MEDIA_PLACEHOLDER_CAPTIONS.has(caption) ? "" : caption;
12609
+ const caption = label.replace(MEDIA_LABEL_PREFIX_RE, "").replace(/\\(.)/g, "$1").trim();
12610
+ return MEDIA_LABEL_PREFIX_RE.test(label) && MEDIA_PLACEHOLDER_CAPTIONS.has(caption) ? "" : caption;
12386
12611
  }
12387
12612
  function fileNameOf2(caption, url) {
12388
12613
  return savedNameOf(caption, uploadedFileNameFromUrl(url));
@@ -12409,7 +12634,7 @@ function isNotionMediaUrl(url) {
12409
12634
  function notionMediaSpans(markdown) {
12410
12635
  const spans = [];
12411
12636
  for (const link of scanMarkdownLinks(markdown)) {
12412
- if (!link.isEmbed && !FILE_LABEL_PREFIX_RE.test(link.label)) continue;
12637
+ if (!link.isEmbed && !MEDIA_LABEL_PREFIX_RE.test(link.label)) continue;
12413
12638
  if (isNotionMediaUrl(link.url)) spans.push(link);
12414
12639
  }
12415
12640
  for (const tag of markdown.matchAll(MEDIA_TAG_RE)) {
@@ -12481,6 +12706,7 @@ function mediaBlock2(kind, fileUploadId, vaultTarget, target) {
12481
12706
  const description = captionOfEmbed(target);
12482
12707
  const caption = description ? [{ type: "text", text: { content: description } }] : [];
12483
12708
  const file = { type: "file_upload", file_upload: { id: fileUploadId }, caption };
12709
+ if (kind !== "image" && isHtmlBlockFile(vaultTarget)) return { type: "embed", embed: file };
12484
12710
  const type = kind === "image" ? "image" : getBlockType(baseName(vaultTarget));
12485
12711
  return type === "file" ? { type, file: { ...file, name: baseName(vaultTarget) } } : { type, [type]: file };
12486
12712
  }
@@ -13125,17 +13351,15 @@ var ImageHandler = class {
13125
13351
  }
13126
13352
  async downloadAllFiles(markdown, pageTitle, pageId) {
13127
13353
  const fileLinks = scanMarkdownLinks(markdown).filter(
13128
- (s) => !s.isEmbed && FILE_LABEL_PREFIX_RE.test(s.label)
13354
+ (s) => !s.isEmbed && MEDIA_LABEL_PREFIX_RE.test(s.label)
13129
13355
  );
13130
13356
  const notionHttpMatches = fileLinks.filter(
13131
13357
  (s) => /^https?:\/\//.test(s.url) && this.isNotionFileUrl(s.url)
13132
13358
  );
13133
13359
  const mdInternalMatches = fileLinks.filter((s) => INTERNAL_FILE_URL_RE.test(s.url));
13134
- const xmlHttpRegex = /<(?:file|video|audio|pdf)\s+src="(https?:\/\/[^"]+)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
13135
- const xmlHttpMatches = [...markdown.matchAll(xmlHttpRegex)];
13360
+ const xmlHttpMatches = [...markdown.matchAll(mediaTagRe('https?:\\/\\/[^"]+'))];
13136
13361
  const notionXmlHttpMatches = xmlHttpMatches.filter((m) => m[1] && this.isNotionFileUrl(m[1]));
13137
- const xmlInternalRegex = /<(?:file|video|audio|pdf)\s+src="(file:\/\/%7B.*?%7D%7D)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
13138
- const xmlInternalMatches = [...markdown.matchAll(xmlInternalRegex)];
13362
+ const xmlInternalMatches = [...markdown.matchAll(mediaTagRe("file:\\/\\/%7B.*?%7D%7D"))];
13139
13363
  const totalMatches = notionHttpMatches.length + mdInternalMatches.length + notionXmlHttpMatches.length + xmlInternalMatches.length;
13140
13364
  if (totalMatches === 0) {
13141
13365
  return { content: markdown, downloads: [] };
@@ -13147,9 +13371,12 @@ var ImageHandler = class {
13147
13371
  for (const span of notionHttpMatches) {
13148
13372
  await sema.acquire();
13149
13373
  try {
13150
- const caption = fileNameOf2(span.label.replace(FILE_LABEL_PREFIX_RE, ""), span.url);
13374
+ const caption = fileNameOf2(span.label.replace(MEDIA_LABEL_PREFIX_RE, ""), span.url);
13151
13375
  const download = await this.downloadFile(span.url, pageTitle, caption);
13152
- if (!download.localPath) continue;
13376
+ if (!download.localPath) {
13377
+ result = keepLargeFile(result, span.full, download, pageId);
13378
+ continue;
13379
+ }
13153
13380
  downloads.push(download);
13154
13381
  const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
13155
13382
  result = result.replace(span.full, () => obsidianLink);
@@ -13178,9 +13405,12 @@ var ImageHandler = class {
13178
13405
  getLogger().warn(`[Im-Nobsidian] \uD30C\uC77C Block URL \uC870\uD68C \uC2E4\uD328: ${fileName2}`);
13179
13406
  continue;
13180
13407
  }
13181
- const caption = savedNameOf(span.label.replace(FILE_LABEL_PREFIX_RE, ""), fileName2);
13408
+ const caption = savedNameOf(span.label.replace(MEDIA_LABEL_PREFIX_RE, ""), fileName2);
13182
13409
  const download = await this.downloadFile(realUrl, pageTitle, caption);
13183
- if (!download.localPath) continue;
13410
+ if (!download.localPath) {
13411
+ result = keepLargeFile(result, span.full, download, pageId, blockId);
13412
+ continue;
13413
+ }
13184
13414
  downloads.push(download);
13185
13415
  const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
13186
13416
  result = result.replace(span.full, () => obsidianLink);
@@ -13203,7 +13433,10 @@ var ImageHandler = class {
13203
13433
  const url = match[1];
13204
13434
  const caption = fileNameOf2(match[2] ?? "", url);
13205
13435
  const download = await this.downloadFile(url, pageTitle, caption);
13206
- if (!download.localPath) continue;
13436
+ if (!download.localPath) {
13437
+ result = keepLargeFile(result, match[0], download, pageId);
13438
+ continue;
13439
+ }
13207
13440
  downloads.push(download);
13208
13441
  const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
13209
13442
  result = result.replace(match[0], () => obsidianLink);
@@ -13235,7 +13468,10 @@ var ImageHandler = class {
13235
13468
  }
13236
13469
  const caption = savedNameOf(match[2] ?? "", fileName2);
13237
13470
  const download = await this.downloadFile(realUrl, pageTitle, caption);
13238
- if (!download.localPath) continue;
13471
+ if (!download.localPath) {
13472
+ result = keepLargeFile(result, match[0], download, pageId, blockId);
13473
+ continue;
13474
+ }
13239
13475
  downloads.push(download);
13240
13476
  const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
13241
13477
  result = result.replace(match[0], () => obsidianLink);
@@ -13286,18 +13522,21 @@ var ImageHandler = class {
13286
13522
  const contentLength = response.headers.get("content-length");
13287
13523
  const MAX_FILE_SIZE = this.maxFileSizeBytes;
13288
13524
  if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
13289
- const sizeMB = Math.round(parseInt(contentLength, 10) / 1024 / 1024);
13525
+ const bytes = parseInt(contentLength, 10);
13290
13526
  getLogger().info(
13291
- `[Im-Nobsidian] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uAC74\uB108\uB700 \u2014 \uD06C\uAE30 \uC0C1\uD55C \uCD08\uACFC (${sizeMB}MB > ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB), Notion \uC6D0\uBCF8 \uB9C1\uD06C \uC720\uC9C0: ${caption}`
13527
+ `[Im-Nobsidian] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uAC74\uB108\uB700 \u2014 \uD06C\uAE30 \uC0C1\uD55C \uCD08\uACFC (${fileSizeLabel(bytes)} > ${fileSizeLabel(MAX_FILE_SIZE)}), Notion \uC5D0\uC11C \uC5F4\uAE30 \uB9C1\uD06C\uB97C \uB460: ${caption}`
13292
13528
  );
13293
- return { originalUrl: url, localPath: "", hash: "", size: 0 };
13529
+ return { originalUrl: url, localPath: "", hash: "", size: 0, skippedBytes: bytes };
13294
13530
  }
13295
13531
  const buffer = Buffer.from(await response.arrayBuffer());
13296
13532
  const fileHash = createHash("sha256").update(buffer).digest("hex");
13297
13533
  const hash = nameHashOf(fileHash);
13298
13534
  const extension = this.guessFileExtension(url, response.headers.get("content-type"));
13299
13535
  const name = withoutOwnHash(caption, hash);
13300
- const safeName = name.replace(/[^a-zA-Z0-9가-힣\s._-]/g, "").replace(/\s+/g, "-").slice(0, 80);
13536
+ const safeName = truncateBytes(
13537
+ name.replace(/[^a-zA-Z0-9가-힣\s._-]/g, "").replace(/\s+/g, "-").slice(0, 80),
13538
+ FILE_SYSTEM_NAME_MAX_BYTES - utf8Bytes(`-${hash}${extension}`)
13539
+ );
13301
13540
  const fileName2 = `${safeName}-${hash}${extension}`;
13302
13541
  const localPath = `${this.attachmentFolder}/${fileName2}`;
13303
13542
  await this.vaultFs.ensureFolder(this.attachmentFolder);
@@ -13359,6 +13598,11 @@ var ImageHandler = class {
13359
13598
  return "";
13360
13599
  }
13361
13600
  };
13601
+ function keepLargeFile(content, fileLink, download, pageId, blockId) {
13602
+ if (download.skippedBytes === void 0 || !pageId) return content;
13603
+ const link = largeFileLink(notionFileLocation(pageId, blockId), download.skippedBytes);
13604
+ return withLargeFileLink(content, fileLink, link);
13605
+ }
13362
13606
 
13363
13607
  // src/view/notion-filter-translator.ts
13364
13608
  function translateNotionFilter(filter, schema, resolveName) {
@@ -13688,6 +13932,10 @@ function filterGroup(node) {
13688
13932
  return ["not", node.not];
13689
13933
  }
13690
13934
  var BASE_FILE_HEADER = "# Auto-generated by Im-Nobsidian";
13935
+ function inFolderFilter(folderPath) {
13936
+ return `file.inFolder("${folderPath}")`;
13937
+ }
13938
+ var IN_FOLDER_FILTER_RE = /file\.inFolder\("([^"\n]*)"\)/g;
13691
13939
  var BaseFileGenerator = class {
13692
13940
  generate(options) {
13693
13941
  const lines = [];
@@ -13703,7 +13951,7 @@ var BaseFileGenerator = class {
13703
13951
  writeFilters(lines, folderPath) {
13704
13952
  lines.push("filters:");
13705
13953
  lines.push(" and:");
13706
- lines.push(` - file.inFolder("${folderPath}")`);
13954
+ lines.push(` - ${inFolderFilter(folderPath)}`);
13707
13955
  lines.push(` - file.ext == "md"`);
13708
13956
  lines.push("");
13709
13957
  }
@@ -14541,9 +14789,9 @@ function noteTitle(frontmatter, path) {
14541
14789
  return explicitTitle(frontmatter) ?? wikilinkTitleFromPath(path);
14542
14790
  }
14543
14791
  function isFileNameOf(title, fileName2) {
14544
- const safe = sanitizeFileName(title);
14545
- if (fileName2 === safe) return true;
14546
- return fileName2.startsWith(safe) && /^ \([0-9a-f]{8,32}\)$/i.test(fileName2.slice(safe.length));
14792
+ return [sanitizeFileName(title), legacySanitizeFileName(title)].some(
14793
+ (safe) => fileName2 === safe || fileName2.startsWith(safe) && /^ \([0-9a-f]{8,32}\)$/i.test(fileName2.slice(safe.length))
14794
+ );
14547
14795
  }
14548
14796
  function followsFileName(title, path) {
14549
14797
  return isFileNameOf(title, wikilinkTitleFromPath(path));
@@ -14571,8 +14819,11 @@ function titleMayChange(base, current, from, to) {
14571
14819
  function extractTitle(filePath) {
14572
14820
  return noteNameFromPath(filePath);
14573
14821
  }
14574
- function titleProperty(title) {
14575
- return { title: { title: [{ text: { content: title } }] } };
14822
+ function titleProperty(title, remote) {
14823
+ const runs = remote ? titleRunsOf(remote) : null;
14824
+ return {
14825
+ title: { title: (runs && patchRichText(runs, title)) ?? [{ text: { content: title } }] }
14826
+ };
14576
14827
  }
14577
14828
  function extractAliases(properties) {
14578
14829
  const raw = properties.aliases ?? properties.alias;
@@ -14599,10 +14850,14 @@ function selectDbRowFiles(files, localFolder) {
14599
14850
  return rel.length > 0 && !rel.includes("/");
14600
14851
  });
14601
14852
  }
14853
+ var PAGE_ID_SUFFIX_LENGTHS = [8, 16, 32];
14602
14854
  function pagePathCandidates(dir, safeName, pageId) {
14603
14855
  const base = dir ? `${dir}/${safeName}` : safeName;
14604
14856
  const raw = pageId.replace(/-/g, "");
14605
- return [`${base}.md`, ...[8, 16, 32].map((len) => `${base} (${raw.slice(0, len)}).md`)];
14857
+ return [
14858
+ `${base}.md`,
14859
+ ...PAGE_ID_SUFFIX_LENGTHS.map((len) => `${base} (${raw.slice(0, len)}).md`)
14860
+ ];
14606
14861
  }
14607
14862
  function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
14608
14863
  const ownedBySelf = (candidate) => {
@@ -14613,9 +14868,9 @@ function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
14613
14868
  const candidates = pagePathCandidates(localFolder, safeName, pageId);
14614
14869
  return candidates.find(ownedBySelf) ?? candidates[candidates.length - 1];
14615
14870
  }
14616
- function inClaimOrder(items, chosen, idOf) {
14871
+ function inClaimOrder(items, chosen, idOf2) {
14617
14872
  const slots = items.flatMap((item, index) => chosen(item) ? [index] : []);
14618
- const key = (item) => compactNotionId(idOf(item));
14873
+ const key = (item) => compactNotionId(idOf2(item));
14619
14874
  const members = slots.map((index) => items[index]).sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
14620
14875
  const ordered = [...items];
14621
14876
  slots.forEach((slot, k) => ordered[slot] = members[k]);
@@ -14979,8 +15234,14 @@ var DbBaseFiles = class {
14979
15234
  const entries = this.load();
14980
15235
  const key = compactNotionId(databaseId);
14981
15236
  const known = entries.get(key);
14982
- if (known?.basePath === info.basePath && known.title === info.title) return;
14983
- entries.set(key, { basePath: info.basePath, title: info.title });
15237
+ if (known?.basePath === info.basePath && known.title === info.title && known.hash === info.hash) {
15238
+ return;
15239
+ }
15240
+ entries.set(key, {
15241
+ basePath: info.basePath,
15242
+ title: info.title,
15243
+ ...info.hash ? { hash: info.hash } : {}
15244
+ });
14984
15245
  this.loadedRaw = JSON.stringify(Object.fromEntries(entries));
14985
15246
  this.stateDb.setMeta(DB_BASE_FILES_META_KEY, this.loadedRaw);
14986
15247
  }
@@ -14997,7 +15258,11 @@ var DbBaseFiles = class {
14997
15258
  for (const [key, value] of Object.entries(parsed)) {
14998
15259
  const entry = value;
14999
15260
  if (typeof entry?.basePath === "string" && typeof entry.title === "string") {
15000
- this.entries.set(compactNotionId(key), { basePath: entry.basePath, title: entry.title });
15261
+ this.entries.set(compactNotionId(key), {
15262
+ basePath: entry.basePath,
15263
+ title: entry.title,
15264
+ ...typeof entry.hash === "string" ? { hash: entry.hash } : {}
15265
+ });
15001
15266
  }
15002
15267
  }
15003
15268
  } catch {
@@ -15082,9 +15347,9 @@ async function retargetBaseFiles(vaultFs, moves) {
15082
15347
  if (!file.path.endsWith(BASE_SUFFIX)) continue;
15083
15348
  const content = await vaultFs.readFile(file.path);
15084
15349
  if (!isGeneratedDbArtifact(file.path, content)) continue;
15085
- const next = content.replace(IN_FOLDER, (whole, folder) => {
15350
+ const next = content.replace(IN_FOLDER_FILTER_RE, (whole, folder) => {
15086
15351
  const to = finalPath(folder, folders);
15087
- return to === folder ? whole : `file.inFolder("${to}")`;
15352
+ return to === folder ? whole : inFolderFilter(to);
15088
15353
  });
15089
15354
  if (next !== content) await vaultFs.writeFile(file.path, next);
15090
15355
  }
@@ -15094,7 +15359,6 @@ async function retargetBaseFiles(vaultFs, moves) {
15094
15359
  );
15095
15360
  }
15096
15361
  }
15097
- var IN_FOLDER = /file\.inFolder\("([^"\n]*)"\)/g;
15098
15362
  function finalPath(path, moves) {
15099
15363
  let current = path;
15100
15364
  for (const move of moves) {
@@ -15660,7 +15924,7 @@ var DatabaseSyncer = class {
15660
15924
  */
15661
15925
  appearances = /* @__PURE__ */ new WeakMap();
15662
15926
  baseFileGenerator = new BaseFileGenerator();
15663
- /** 사용자가 만든 `.base` 라 덮어쓰지 않았다고 이미 알린 경로 — 한 pull 이 같은 DB 를 여러 번 다시 쓴다. */
15927
+ /** 사용자가 만들거나 고친 `.base` 라 덮어쓰지 않았다고 이미 알린 경로 — 한 pull 이 같은 DB 를 여러 번 다시 쓴다. */
15664
15928
  keptUserBases = /* @__PURE__ */ new Set();
15665
15929
  sidecarGenerator = new SidecarGenerator();
15666
15930
  /** Notion 에서 바꾼 제목으로 이름을 바꾼 행과 실제로 옮긴 차례 — pull 마감이 가져가 다른 노트의 링크를 고친다. */
@@ -15669,6 +15933,8 @@ var DatabaseSyncer = class {
15669
15933
  renamed: [],
15670
15934
  applied: []
15671
15935
  };
15936
+ /** 볼트에서 사라져 되살린 행의 경로 — pull 마감이 가져가 화면에 알린다(D8). */
15937
+ restoredRows = [];
15672
15938
  /**
15673
15939
  * 실제로 기록한 .base 경로/DB 제목 — placeholder 임베드 재작성(F22)의 SSOT. 이번 pull 이 조회하지
15674
15940
  * 않은 DB 의 것도 남아 있다({@link DbBaseFiles}).
@@ -15786,8 +16052,10 @@ var DatabaseSyncer = class {
15786
16052
  created++;
15787
16053
  if (outcome.action === "written") writtenPaths.push(outcome.path);
15788
16054
  } else if (outcome.action === "written") {
15789
- if (action === "restore") restored++;
15790
- else updated++;
16055
+ if (action === "restore") {
16056
+ restored++;
16057
+ this.restoredRows.push(outcome.path);
16058
+ } else updated++;
15791
16059
  writtenPaths.push(outcome.path);
15792
16060
  } else if (outcome.action === "conflict") {
15793
16061
  conflicts.push(outcome.conflict);
@@ -16136,17 +16404,23 @@ var DatabaseSyncer = class {
16136
16404
  });
16137
16405
  const safeName = sanitizeFileName(dbName);
16138
16406
  const basePath = `${dbConfig.localFolder}/${safeName}${BASE_SUFFIX}`;
16139
- if (await this.isUserFile(basePath)) {
16407
+ const previous = this.baseFileInfo.get(dbConfig.databaseId);
16408
+ const existing = await this.vaultFs.readFile(basePath).catch(() => null);
16409
+ let hash = computeHash(baseContent);
16410
+ if (existing !== null && !isGeneratedDbArtifact(basePath, existing)) {
16140
16411
  if (!this.keptUserBases.has(basePath)) {
16141
16412
  this.keptUserBases.add(basePath);
16142
16413
  getLogger().info(
16143
16414
  `[DB Sync] \uC9C1\uC811 \uB9CC\uB4E0 .base \uB77C Notion \uBCF4\uAE30\uB85C \uB36E\uC5B4\uC4F0\uC9C0 \uC54A\uC74C: ${basePath} \u2014 Notion \uBCF4\uAE30\uB97C \uBC1B\uC73C\uB824\uBA74 \uC774 \uD30C\uC77C\uC758 \uC774\uB984\uC744 \uBC14\uAFB8\uC138\uC694`
16144
16415
  );
16145
16416
  }
16146
- } else {
16417
+ } else if (existing !== null && existing !== baseContent && editedSince(previous, basePath, existing)) {
16418
+ hash = previous.hash;
16419
+ this.warnEditedBase(basePath);
16420
+ } else if (existing !== baseContent) {
16147
16421
  await this.vaultFs.writeFile(basePath, baseContent);
16148
16422
  }
16149
- this.baseFileInfo.set(dbConfig.databaseId, { basePath, title: dbName });
16423
+ this.baseFileInfo.set(dbConfig.databaseId, { basePath, title: dbName, hash });
16150
16424
  getLogger().debug(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131: ${basePath}`);
16151
16425
  await this.generateSidecar(
16152
16426
  dbConfig,
@@ -16157,7 +16431,7 @@ var DatabaseSyncer = class {
16157
16431
  meta.isInline,
16158
16432
  await this.appearanceOf(meta, safeName)
16159
16433
  );
16160
- await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName);
16434
+ await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName, previous);
16161
16435
  } catch (error) {
16162
16436
  getLogger().warn(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
16163
16437
  }
@@ -16189,17 +16463,20 @@ var DatabaseSyncer = class {
16189
16463
  ...a.showTitle ? {} : { showTitle: false }
16190
16464
  };
16191
16465
  }
16192
- /** pull 이 쓰지 않은 `.base` · 사이드카가 이미 있는가 — 없거나 읽지 못하면 false. */
16193
- async isUserFile(path) {
16194
- const content = await this.vaultFs.readFile(path).catch(() => null);
16195
- return content !== null && !isGeneratedDbArtifact(path, content);
16466
+ /** 볼트에서 고친 `.base` 를 덮어쓰거나 지우지 않았다고 한 번 알린다. */
16467
+ warnEditedBase(path) {
16468
+ if (this.keptUserBases.has(path)) return;
16469
+ this.keptUserBases.add(path);
16470
+ getLogger().warn(
16471
+ `[DB Sync] \uBCFC\uD2B8\uC5D0\uC11C \uACE0\uCE5C .base \uB77C Notion \uBCF4\uAE30\uB85C \uB36E\uC5B4\uC4F0\uC9C0 \uC54A\uC74C: ${path} \u2014 Notion \uC758 \uBCF4\uAE30\uB97C \uB2E4\uC2DC \uBC1B\uC73C\uB824\uBA74 \uC774 \uD30C\uC77C\uC744 \uC9C0\uC6B0\uC138\uC694`
16472
+ );
16196
16473
  }
16197
16474
  /**
16198
16475
  * DB 제목이 바뀌면 `.base`/`.notion.json` 파일명(safeName)이 바뀌어 옛 이름의 산출물이
16199
16476
  * 같은 폴더에 고아로 잔존한다(drift·부활 원인). DB 폴더 **직속**의 산출물 중 현재 이름이
16200
16477
  * 아닌 것을 삭제해 멱등을 보장한다. 하위 폴더(중첩 DB)의 산출물은 보호한다.
16201
16478
  */
16202
- async cleanupStaleDbArtifacts(localFolder, safeName) {
16479
+ async cleanupStaleDbArtifacts(localFolder, safeName, previous) {
16203
16480
  try {
16204
16481
  const keep = /* @__PURE__ */ new Set([
16205
16482
  `${localFolder}/${safeName}${BASE_SUFFIX}`,
@@ -16208,7 +16485,12 @@ var DatabaseSyncer = class {
16208
16485
  const files = await this.vaultFs.listNonMarkdownFiles();
16209
16486
  const stale = selectStaleDbArtifacts(files, localFolder, keep);
16210
16487
  for (const path of stale) {
16211
- if (await this.isUserFile(path)) continue;
16488
+ const content = await this.vaultFs.readFile(path).catch(() => null);
16489
+ if (content === null || !isGeneratedDbArtifact(path, content)) continue;
16490
+ if (editedSince(previous, path, content)) {
16491
+ this.warnEditedBase(path);
16492
+ continue;
16493
+ }
16212
16494
  await this.vaultFs.deleteFile(path);
16213
16495
  getLogger().info(`[DB Sync] \uACE0\uC544 DB \uC0B0\uCD9C\uBB3C \uC0AD\uC81C(rename): ${path}`);
16214
16496
  }
@@ -16386,6 +16668,12 @@ var DatabaseSyncer = class {
16386
16668
  }
16387
16669
  return (path) => this.stateDb.getByPath(path) ?? (untracked.has(path) ? { notionPageId: null } : null);
16388
16670
  }
16671
+ /** 되살린 행의 경로를 가져가고 비운다. */
16672
+ takeRestoredRows() {
16673
+ const rows = this.restoredRows;
16674
+ this.restoredRows = [];
16675
+ return rows;
16676
+ }
16389
16677
  /** 이름을 바꾼 행을 가져가고 비운다. */
16390
16678
  takeRenamedRows() {
16391
16679
  const rows = this.renamedRows;
@@ -16558,6 +16846,9 @@ var DatabaseSyncer = class {
16558
16846
  function rowProperties(page) {
16559
16847
  return page.properties ?? {};
16560
16848
  }
16849
+ function editedSince(previous, path, content) {
16850
+ return previous?.basePath === path && previous.hash !== void 0 && computeHash(content) !== previous.hash;
16851
+ }
16561
16852
 
16562
16853
  // src/audit/completeness.ts
16563
16854
  function groupLocalRowsByDatabase(local) {
@@ -16654,6 +16945,40 @@ async function verifyPageCompleteness(remote, local, rootPageId) {
16654
16945
  };
16655
16946
  }
16656
16947
 
16948
+ // src/audit/nested-folder-pages.ts
16949
+ var ID_SUFFIX = new RegExp(
16950
+ ` \\((?:${PAGE_ID_SUFFIX_LENGTHS.map((len) => `[0-9a-f]{${len}}`).join("|")})\\)$`
16951
+ );
16952
+ function folderOf3(record) {
16953
+ if (record.fileType === "db-row") return null;
16954
+ if (isFolderRecord(record)) return record.obsidianPath;
16955
+ const slash = record.obsidianPath.lastIndexOf("/");
16956
+ if (slash < 0) return null;
16957
+ const folder = record.obsidianPath.slice(0, slash);
16958
+ const stem = stripMarkdownExtension(record.obsidianPath.slice(slash + 1)).replace(ID_SUFFIX, "");
16959
+ return stem === folder.slice(folder.lastIndexOf("/") + 1) ? folder : null;
16960
+ }
16961
+ function findNestedFolderPages(records) {
16962
+ const byPage = /* @__PURE__ */ new Map();
16963
+ for (const record of records) {
16964
+ if (record.notionPageId !== null) byPage.set(compactNotionId(record.notionPageId), record);
16965
+ }
16966
+ const found2 = [];
16967
+ for (const inner of records) {
16968
+ if (inner.notionPageId === null || inner.notionParentId === null) continue;
16969
+ const folder = folderOf3(inner);
16970
+ if (folder === null) continue;
16971
+ const outer = byPage.get(compactNotionId(inner.notionParentId));
16972
+ if (!outer || outer === inner || folderOf3(outer) !== folder) continue;
16973
+ found2.push({
16974
+ folder,
16975
+ outer: { path: outer.obsidianPath, notionPageId: outer.notionPageId },
16976
+ inner: { path: inner.obsidianPath, notionPageId: inner.notionPageId }
16977
+ });
16978
+ }
16979
+ return found2.sort((a, b) => a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0);
16980
+ }
16981
+
16657
16982
  // src/conflict/merger.ts
16658
16983
  function threeWayMerge(base, local, remote) {
16659
16984
  const baseLines = base.split("\n");
@@ -17068,6 +17393,9 @@ var ScopedVaultFS = class {
17068
17393
  async ensureFolder(path) {
17069
17394
  return await this.base.ensureFolder(this.toBase(path, true));
17070
17395
  }
17396
+ async removeEmptyFolder(path) {
17397
+ return await this.base.removeEmptyFolder?.(this.toBase(path, true)) ?? false;
17398
+ }
17071
17399
  /** 맡은 노트만 읽는다 — 볼트 전체의 본문을 읽지 않는다. */
17072
17400
  async listMarkdownFiles() {
17073
17401
  const stats = await this.listMarkdownFileStats();
@@ -17145,7 +17473,7 @@ function nextFullReconcileAt(lastFullPullAt, intervalSec) {
17145
17473
  const last = lastFullPullAt === null ? NaN : Date.parse(lastFullPullAt);
17146
17474
  return Number.isFinite(last) ? new Date(last + intervalSec * 1e3).toISOString() : null;
17147
17475
  }
17148
- function remoteScanInfo(scan, lastFullPullAt, deleteSync) {
17476
+ function remoteScanInfo(scan, lastFullPullAt, deleteSync, deletionsChecked = false) {
17149
17477
  return scan.kind === "full" ? {
17150
17478
  kind: "full",
17151
17479
  reason: scan.reason,
@@ -17156,7 +17484,7 @@ function remoteScanInfo(scan, lastFullPullAt, deleteSync) {
17156
17484
  kind: "incremental",
17157
17485
  lastFullAt: scan.lastFullAt,
17158
17486
  nextFullAt: scan.nextFullAt,
17159
- deletionsDeferred: deleteSync
17487
+ deletionsDeferred: deleteSync && !deletionsChecked
17160
17488
  };
17161
17489
  }
17162
17490
  var ALL_DATABASES = { kind: "all" };
@@ -17178,6 +17506,14 @@ function parsePendingDatabases(raw) {
17178
17506
  function serializePendingDatabases(ids) {
17179
17507
  return JSON.stringify([...ids].sort());
17180
17508
  }
17509
+ function addPendingDatabases(stateDb, databaseIds) {
17510
+ const pending = parsePendingDatabases(stateDb.getMeta(PENDING_DATABASES_META_KEY));
17511
+ const before = pending.size;
17512
+ for (const id of databaseIds) pending.add(compactNotionId(id));
17513
+ if (pending.size !== before) {
17514
+ stateDb.setMeta(PENDING_DATABASES_META_KEY, serializePendingDatabases(pending));
17515
+ }
17516
+ }
17181
17517
  var DatabasePullLedger = class {
17182
17518
  constructor(selection, pending) {
17183
17519
  this.selection = selection;
@@ -17539,8 +17875,7 @@ var InterruptedSyncRecovery = class {
17539
17875
  if (isNotionObjectNotFound(error)) continue;
17540
17876
  throw error;
17541
17877
  }
17542
- const inTrash = page.in_trash === true;
17543
- if (inTrash || page.archived) continue;
17878
+ if (isTrashedOrArchived(page)) continue;
17544
17879
  return page;
17545
17880
  }
17546
17881
  return null;
@@ -17559,8 +17894,7 @@ var InterruptedSyncRecovery = class {
17559
17894
  });
17560
17895
  for (const row of rows) {
17561
17896
  if (this.stateDb.getByNotionId(row.id)) continue;
17562
- const inTrash = row.in_trash === true;
17563
- if (inTrash || row.archived) continue;
17897
+ if (isTrashedOrArchived(row)) continue;
17564
17898
  return row;
17565
17899
  }
17566
17900
  return null;
@@ -18059,7 +18393,7 @@ var DatabaseDiscovery = class {
18059
18393
  }
18060
18394
  let safeName;
18061
18395
  if (dbTitle) {
18062
- safeName = dbTitle.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-");
18396
+ safeName = databaseFolderName(dbTitle);
18063
18397
  } else {
18064
18398
  const parentName = parentFolder.split("/").pop() || "";
18065
18399
  safeName = parentName ? `${parentName}-DB` : `db-${dbId.replace(/-/g, "").slice(0, 8)}`;
@@ -18152,6 +18486,12 @@ var DatabaseDiscovery = class {
18152
18486
  return refs;
18153
18487
  }
18154
18488
  };
18489
+ function databaseFolderName(title) {
18490
+ return truncateBytes(
18491
+ title.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-"),
18492
+ FILE_SYSTEM_NAME_MAX_BYTES
18493
+ );
18494
+ }
18155
18495
 
18156
18496
  // src/sync/folder-placement.ts
18157
18497
  var FolderPlacement = class {
@@ -18975,7 +19315,7 @@ var RemoteRenameFollower = class {
18975
19315
  /** 바꿀 것이 없으면 null. */
18976
19316
  async followOne(record, dryRun) {
18977
19317
  const page = await this.pageOf(record.notionPageId);
18978
- if (page.in_trash || page.archived) return null;
19318
+ if (isTrashedOrArchived(page)) return null;
18979
19319
  const title = this.notionClient.extractTitle(page);
18980
19320
  if (!retitledInNotion(...await this.frontmatters(record), record.obsidianPath, title)) {
18981
19321
  return null;
@@ -19116,7 +19456,7 @@ function rewriteTarget(target, source, context, kind) {
19116
19456
  const shown = keepExtension ? destination : destination.slice(0, -".md".length);
19117
19457
  let next;
19118
19458
  if (resolved.relative) {
19119
- next = relativePath(folderOf3(source.to), shown);
19459
+ next = relativePath(folderOf4(source.to), shown);
19120
19460
  if (!linkpath.startsWith("./") && next.startsWith("./")) next = next.slice(2);
19121
19461
  } else if (style === "name") {
19122
19462
  const name = baseName3(shown);
@@ -19138,14 +19478,14 @@ function resolveLinkpath(linkpath, sourcePath, files) {
19138
19478
  const matches = byName(files, baseName3(candidate)).filter((file) => file.endsWith(suffix));
19139
19479
  if (matches.length === 1) return found(matches[0], linkpath);
19140
19480
  if (matches.length > 1) {
19141
- const local = `${folderPrefix(folderOf3(sourcePath))}${candidate}`;
19481
+ const local = `${folderPrefix(folderOf4(sourcePath))}${candidate}`;
19142
19482
  return matches.includes(local) ? found(local, linkpath) : null;
19143
19483
  }
19144
19484
  }
19145
19485
  return null;
19146
19486
  }
19147
19487
  function resolveRelative(linkpath, sourcePath, files) {
19148
- const joined = normalizeSegments(`${folderPrefix(folderOf3(sourcePath))}${linkpath}`);
19488
+ const joined = normalizeSegments(`${folderPrefix(folderOf4(sourcePath))}${linkpath}`);
19149
19489
  if (joined === null) return null;
19150
19490
  for (const candidate of withExtension(joined)) {
19151
19491
  if (files.has(candidate)) return { ...found(candidate, linkpath), relative: true };
@@ -19204,7 +19544,7 @@ function relativePath(from, to) {
19204
19544
  function encodeMarkdownTarget(next, original) {
19205
19545
  return original.includes("%") || next.includes(" ") ? next.replace(/ /g, "%20") : next;
19206
19546
  }
19207
- function folderOf3(path) {
19547
+ function folderOf4(path) {
19208
19548
  const slash = path.lastIndexOf("/");
19209
19549
  return slash === -1 ? "" : path.slice(0, slash);
19210
19550
  }
@@ -20473,9 +20813,7 @@ var DatabaseCreator = class {
20473
20813
  discovered.push({ databaseId, localFolder: folder, titleProperty: titleProperty2 });
20474
20814
  this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(discovered));
20475
20815
  }
20476
- const pending = parsePendingDatabases(this.stateDb.getMeta(PENDING_DATABASES_META_KEY));
20477
- pending.add(compactNotionId(databaseId));
20478
- this.stateDb.setMeta(PENDING_DATABASES_META_KEY, serializePendingDatabases(pending));
20816
+ addPendingDatabases(this.stateDb, [databaseId]);
20479
20817
  }
20480
20818
  async readSidecar(path) {
20481
20819
  try {
@@ -20780,6 +21118,7 @@ var PagePusher = class {
20780
21118
  * (ADR-017). relation 을 보내면 가리킨 행의 반대쪽 relation 이, 행을 만들면 그 행의 수식 · ID 가 바뀐다.
20781
21119
  */
20782
21120
  markRemoteUnverified(pageIds) {
21121
+ const databases = [];
20783
21122
  for (const pageId of new Set(pageIds)) {
20784
21123
  const target = this.stateDb.getByNotionId(pageId);
20785
21124
  if (!target?.notionLastEdited) continue;
@@ -20788,6 +21127,76 @@ var PagePusher = class {
20788
21127
  lastEditedBy: null,
20789
21128
  seenAt: null
20790
21129
  });
21130
+ if (target.fileType === "db-row" && target.notionParentId) {
21131
+ databases.push(target.notionParentId);
21132
+ }
21133
+ }
21134
+ addPendingDatabases(this.stateDb, databases);
21135
+ }
21136
+ /** 이번 실행에서 볼트의 값을 올리지 않은 읽기 전용 속성 — 노트 경로별. */
21137
+ readOnlySkips = /* @__PURE__ */ new Map();
21138
+ /** 올리지 않은 읽기 전용 속성을 꺼낸다(목록은 비운다). */
21139
+ takeReadOnlySkips() {
21140
+ const skips = [...this.readOnlySkips].sort(([a], [b]) => a.localeCompare(b)).map(([path, properties]) => ({ path, properties }));
21141
+ this.readOnlySkips.clear();
21142
+ return skips;
21143
+ }
21144
+ /**
21145
+ * dry-run 이 알릴 읽기 전용 속성 — 실제 push 처럼 볼트에서 바꾼 행 속성(새 행은 모든 속성) 가운데 Notion 이 값을
21146
+ * 정하는 것. 원격은 읽지 않고 지난 동기화 사본과 견준다 — 원격도 바뀌었으면 실제 push 와 다를 수 있다. 사본이
21147
+ * 없거나 frontmatter · 스키마를 읽지 못하면 그 노트는 건너뛴다(실제 push 가 이유와 함께 알린다).
21148
+ */
21149
+ async previewReadOnlySkips(changes) {
21150
+ const skips = [];
21151
+ for (const change of changes) {
21152
+ const row = this.previewRow(change);
21153
+ if (!row) continue;
21154
+ try {
21155
+ const current = parseFrontmatter(await this.vaultFs.readFile(change.path));
21156
+ const { changed, cleared } = diffRowProperties(row.base, current.data);
21157
+ const mapper = await this.rowSchemas.mapperFor(row.databaseId);
21158
+ const readOnly = mapper.readOnlyKeys(
21159
+ withoutDecorations([...Object.keys(changed), ...cleared])
21160
+ );
21161
+ if (readOnly.length > 0) skips.push({ path: change.path, properties: readOnly });
21162
+ } catch {
21163
+ continue;
21164
+ }
21165
+ }
21166
+ return skips.sort((a, b) => a.path.localeCompare(b.path));
21167
+ }
21168
+ /** dry-run 이 견줄 행 — 새 행은 기준 없이, 고친 행은 지난 동기화 사본과. 행이 아니거나 사본이 없으면 null. */
21169
+ previewRow(change) {
21170
+ if (change.type === "created") {
21171
+ const databaseId2 = this.placement.newRowDatabaseOf(change.path);
21172
+ return databaseId2 ? { databaseId: databaseId2, base: null } : null;
21173
+ }
21174
+ if (change.type !== "modified") return null;
21175
+ const record = this.stateDb.getByPath(change.path);
21176
+ const databaseId = record ? rowDatabaseOf(this.config, record) : null;
21177
+ const base = record && databaseId ? this.baseRowState(record) : null;
21178
+ return databaseId && base ? { databaseId, base: base.properties } : null;
21179
+ }
21180
+ /**
21181
+ * 보내지 않은 속성을 알린다. 읽기 전용 속성은 경고로 따로 모은다 — 다음 pull 이 볼트의 값을 Notion 값으로 되돌리니
21182
+ * 고친 사람이 알아야 한다. 나머지(DB 에 없는 속성 · 변환 불가 …)는 예전처럼 정보 로그다.
21183
+ */
21184
+ reportUnsent(path, mapper, skipped, what) {
21185
+ const unsent = withoutDecorations(skipped);
21186
+ const readOnly = mapper.readOnlyKeys(unsent);
21187
+ if (readOnly.length > 0) {
21188
+ this.readOnlySkips.set(path, readOnly);
21189
+ getLogger().warn(
21190
+ `[Im-Nobsidian] \uC77D\uAE30 \uC804\uC6A9 \uC18D\uC131 ${readOnly.length}\uAC1C\uB294 Notion \uC774 \uAC12\uC744 \uC815\uD574 \uC62C\uB9AC\uC9C0 \uC54A\uC74C \u2014 \uB2E4\uC74C pull \uC774 Notion \uAC12\uC73C\uB85C \uB418\uB3CC\uB9B0\uB2E4: ${path} \u2014 ${readOnly.join(", ")}`
21191
+ );
21192
+ } else {
21193
+ this.readOnlySkips.delete(path);
21194
+ }
21195
+ const others = unsent.filter((key) => !readOnly.includes(key));
21196
+ if (others.length > 0) {
21197
+ getLogger().info(
21198
+ `[Im-Nobsidian] ${what} ${others.length}\uAC1C\uB294 \uBCF4\uB0B4\uC9C0 \uC54A\uC74C(DB \uC5D0 \uC5C6\uB294 \uC18D\uC131 \xB7 \uBE44\uC6B8 \uC218 \uC5C6\uB294 \uC18D\uC131 \xB7 \uBCC0\uD658 \uBD88\uAC00): ${path} \u2014 ${others.join(", ")}`
21199
+ );
20791
21200
  }
20792
21201
  }
20793
21202
  /** relation 을 보내지 못한 행을 꺼낸다(목록은 비운다). */
@@ -20812,7 +21221,7 @@ var PagePusher = class {
20812
21221
  const selectedPath = this.pipeline.selectPath(content);
20813
21222
  if (selectedPath === "block-api") {
20814
21223
  getLogger().warn(
20815
- `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column/toggle) \u2014 converted with reduced fidelity in v0.1.0`
21224
+ `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column) \u2014 converted with reduced fidelity in v0.1.0`
20816
21225
  );
20817
21226
  }
20818
21227
  const conversionResult = this.pipeline.convertToNotion(content, {
@@ -20956,7 +21365,7 @@ var PagePusher = class {
20956
21365
  const updatePath = this.pipeline.selectPath(content);
20957
21366
  if (updatePath === "block-api") {
20958
21367
  getLogger().warn(
20959
- `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column/toggle) \u2014 converted with reduced fidelity in v0.1.0`
21368
+ `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column) \u2014 converted with reduced fidelity in v0.1.0`
20960
21369
  );
20961
21370
  }
20962
21371
  const conversionResult = this.pipeline.convertToNotion(content, {
@@ -20987,7 +21396,7 @@ var PagePusher = class {
20987
21396
  bodyFingerprint = await this.drift.remoteBodyFingerprintOf(record.notionPageId);
20988
21397
  }
20989
21398
  const newTitle = this.changedPageTitle(record, content, path);
20990
- const propsToUpdate = newTitle === null ? void 0 : titleProperty(newTitle);
21399
+ const propsToUpdate = newTitle === null ? void 0 : titleProperty(newTitle, pagePropertiesOf(remote));
20991
21400
  const decorated = await this.decorations.push(
20992
21401
  record.notionPageId,
20993
21402
  path,
@@ -21170,16 +21579,11 @@ var PagePusher = class {
21170
21579
  current: current.data,
21171
21580
  uploads: await this.uploadsFor(mapper, changes.changed, path)
21172
21581
  });
21173
- const unsentProperties = withoutDecorations(skipped);
21174
- if (unsentProperties.length > 0) {
21175
- getLogger().info(
21176
- `[Im-Nobsidian] \uC0C8 \uD589 \uC18D\uC131 ${unsentProperties.length}\uAC1C\uB294 \uBCF4\uB0B4\uC9C0 \uC54A\uC74C(DB \uC5D0 \uC5C6\uB294 \uC18D\uC131 \xB7 \uC77D\uAE30 \uC804\uC6A9 \xB7 \uBCC0\uD658 \uBD88\uAC00): ${path} \u2014 ${unsentProperties.join(", ")}`
21177
- );
21178
- }
21582
+ this.reportUnsent(path, mapper, skipped, "\uC0C8 \uD589 \uC18D\uC131");
21179
21583
  const selectedPath = this.pipeline.selectPath(content);
21180
21584
  if (selectedPath === "block-api") {
21181
21585
  getLogger().warn(
21182
- `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column/toggle) \u2014 converted with reduced fidelity in v0.1.0`
21586
+ `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column) \u2014 converted with reduced fidelity in v0.1.0`
21183
21587
  );
21184
21588
  }
21185
21589
  const conversionResult = this.pipeline.convertToNotion(content, {
@@ -21378,12 +21782,7 @@ var PagePusher = class {
21378
21782
  ...against === null || options.overwriteRemote ? {} : { uploads: await this.uploadsFor(mapper, changes.changed, path) }
21379
21783
  }
21380
21784
  );
21381
- const unsentProperties = withoutDecorations(skipped);
21382
- if (unsentProperties.length > 0) {
21383
- getLogger().info(
21384
- `[Im-Nobsidian] \uD589 \uC18D\uC131 ${unsentProperties.length}\uAC1C\uB294 \uBCF4\uB0B4\uC9C0 \uC54A\uC74C(DB \uC5D0 \uC5C6\uB294 \uC18D\uC131 \xB7 \uC77D\uAE30 \uC804\uC6A9 \xB7 \uBE44\uC6B8 \uC218 \uC5C6\uB294 \uC18D\uC131 \xB7 \uBCC0\uD658 \uBD88\uAC00): ${path} \u2014 ${unsentProperties.join(", ")}`
21385
- );
21386
- }
21785
+ this.reportUnsent(path, mapper, skipped, "\uD589 \uC18D\uC131");
21387
21786
  const bodyChanged = against?.body == null || against.body !== current.body;
21388
21787
  const settles = drift === "none";
21389
21788
  if (bodyChanged && !this.drift.overwritesRemote(record, options.overwriteRemote)) {
@@ -21395,7 +21794,7 @@ var PagePusher = class {
21395
21794
  const selectedPath = this.pipeline.selectPath(content);
21396
21795
  if (selectedPath === "block-api") {
21397
21796
  getLogger().warn(
21398
- `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column/toggle) \u2014 converted with reduced fidelity in v0.1.0`
21797
+ `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column) \u2014 converted with reduced fidelity in v0.1.0`
21399
21798
  );
21400
21799
  }
21401
21800
  conversionResult = this.pipeline.convertToNotion(content, {
@@ -21473,6 +21872,8 @@ var PagePusher = class {
21473
21872
  aliases: extractAliases(current.data)
21474
21873
  });
21475
21874
  });
21875
+ if (mapper.readOnlyKeys(withoutDecorations(skipped)).length > 0)
21876
+ this.markRemoteUnverified([pageId]);
21476
21877
  }
21477
21878
  /**
21478
21879
  * 지난 동기화 시점의 행. 사본이 없거나 읽지 못하면 null — 호출측은 비교할 기준이 없다고
@@ -21517,7 +21918,10 @@ var PagePusher = class {
21517
21918
  const title = change.title ? change.title(this.notionClient.extractTitle(remote)) : null;
21518
21919
  if (newParent !== null) await this.notionClient.movePage(pageId, newParent);
21519
21920
  if (title !== null) {
21520
- written = await this.notionClient.updatePageProperties(pageId, titleProperty(title));
21921
+ written = await this.notionClient.updatePageProperties(
21922
+ pageId,
21923
+ titleProperty(title, pagePropertiesOf(remote))
21924
+ );
21521
21925
  } else if (newParent !== null) {
21522
21926
  written = await this.notionClient.getPage(pageId);
21523
21927
  }
@@ -21863,6 +22267,8 @@ var PagePuller = class {
21863
22267
  // 제목이 같은 새 페이지끼리 경로를 고르는 차례({@link orderCreates}) — 페이지 → 앞 차례가 경로를 고르면
21864
22268
  // 풀리는 약속과 내 차례를 끝내는 함수.
21865
22269
  claimTurns = /* @__PURE__ */ new Map();
22270
+ // Notion 에서 사라져 볼트에서 지운 페이지의 하위 폴더 — pull 이 마칠 때 비었으면 지운다({@link removeVacatedFolders}).
22271
+ vacatedFolders = /* @__PURE__ */ new Set();
21866
22272
  decorationsCache;
21867
22273
  get decorations() {
21868
22274
  return this.decorationsCache ??= new PageDecorations(this.notionClient, this.imageHandler);
@@ -21877,6 +22283,35 @@ var PagePuller = class {
21877
22283
  this.claimTurns.clear();
21878
22284
  this._pullImageCount = 0;
21879
22285
  this._pullFileCount = 0;
22286
+ this.vacatedFolders.clear();
22287
+ }
22288
+ /**
22289
+ * 이번 pull 이 지운 페이지의 폴더가 비었으면 지운다 — 안쪽부터, 그래야 바깥이 빈다. 예전에는 노트만 지우고
22290
+ * 폴더를 남겨 볼트에 빈 폴더가 쌓였다. 사용자 파일 · 숨김 파일이 하나라도 남은 폴더는 둔다
22291
+ * ({@link VaultFS.removeEmptyFolder}).
22292
+ */
22293
+ async removeVacatedFolders() {
22294
+ const folders = [...this.vacatedFolders].sort(
22295
+ (a, b) => b.split("/").length - a.split("/").length
22296
+ );
22297
+ this.vacatedFolders.clear();
22298
+ for (const folder of folders) {
22299
+ try {
22300
+ await this.vaultFs.removeEmptyFolder?.(folder);
22301
+ } catch (error) {
22302
+ getLogger().warn(`[Im-Nobsidian] \uBE48 \uD3F4\uB354 ${folder} \uB97C \uC9C0\uC6B0\uC9C0 \uBABB\uD568:`, error);
22303
+ }
22304
+ }
22305
+ }
22306
+ /**
22307
+ * 원격에서 사라진 페이지의 하위 폴더를 적어 둔다 — 그 아래 페이지도 함께 사라졌으면 pull 끝에 빈다. 볼트 맨 위와
22308
+ * DB 폴더는 적지 않는다 — DB 는 페이지가 아니다.
22309
+ */
22310
+ noteVacatedFolder(record) {
22311
+ const lookup = this.placement.folderLookup();
22312
+ if (isFolderRecord(record) && lookup.databaseAt(record.obsidianPath)) return;
22313
+ const folder = childFolderOf(record, lookup);
22314
+ if (folder !== "" && !lookup.databaseAt(folder)) this.vacatedFolders.add(folder);
21880
22315
  }
21881
22316
  /** 이번 pull 이 내려받은 이미지 수. */
21882
22317
  get imageCount() {
@@ -22056,7 +22491,7 @@ var PagePuller = class {
22056
22491
  if (!folderNote) return { action: "unchanged", path: record.obsidianPath };
22057
22492
  record = folderNote;
22058
22493
  }
22059
- const remoteGone = page.in_trash === true || page.archived === true;
22494
+ const remoteGone = isTrashedOrArchived(page);
22060
22495
  if (remoteGone && !await this.vaultFs.exists(record.obsidianPath)) {
22061
22496
  return { action: "unchanged", path: record.obsidianPath };
22062
22497
  }
@@ -22152,6 +22587,7 @@ var PagePuller = class {
22152
22587
  this.stateDb.delete(record.id);
22153
22588
  this.stateDb.deleteWikilink(record.obsidianPath);
22154
22589
  });
22590
+ if (this.config.sync.deleteSync) this.noteVacatedFolder(record);
22155
22591
  return { action: "untracked", path: record.obsidianPath };
22156
22592
  }
22157
22593
  const outcome = await applyRemoteDeletion(this.stateDb, this.vaultFs, record, {
@@ -22159,6 +22595,7 @@ var PagePuller = class {
22159
22595
  strategy: this.config.sync.conflictStrategy,
22160
22596
  remoteChange: change
22161
22597
  });
22598
+ if (outcome.action === "deleted" && this.config.sync.deleteSync) this.noteVacatedFolder(record);
22162
22599
  return { ...outcome, path: record.obsidianPath };
22163
22600
  }
22164
22601
  /**
@@ -22314,8 +22751,7 @@ var PagePuller = class {
22314
22751
  */
22315
22752
  async pullFolderRecord(record, page) {
22316
22753
  const folder = record.obsidianPath;
22317
- const remoteGone = page.in_trash === true || page.archived === true;
22318
- if (remoteGone) return null;
22754
+ if (isTrashedOrArchived(page)) return null;
22319
22755
  const { markdown } = await this.notionClient.getPageMarkdown(page.id);
22320
22756
  if (!hasBodyBesidesChildren(markdown)) {
22321
22757
  this.observation.record(record.id, page, remoteBodyFingerprint(markdown));
@@ -22647,6 +23083,13 @@ var PullPlanner = class {
22647
23083
  const restoredModified = planned.filter(
22648
23084
  (c) => c.type === "modified" && restoreIds.has(c.pageId)
22649
23085
  ).length;
23086
+ const restoredPaths = [
23087
+ ...[
23088
+ ...restoreChanges,
23089
+ ...planned.filter((c) => c.type === "modified" && restoreIds.has(c.pageId))
23090
+ ].map((change) => this.stateDb.getByNotionId(change.pageId)?.obsidianPath).filter((path) => path !== void 0),
23091
+ ...rows.filter((row) => row.operation === "update" && row.restore).map((row) => row.path)
23092
+ ];
22650
23093
  const rowCount = (operation, restore = false) => rows.filter((row) => row.operation === operation && (row.restore ?? false) === restore).length;
22651
23094
  return {
22652
23095
  created: count("created") + rowCount("create"),
@@ -22655,6 +23098,7 @@ var PullPlanner = class {
22655
23098
  // 복원은 updated 에 섞지 않는다 — dry-run 이 "수정 N건" 이라고만 말하면
22656
23099
  // 사용자가 사라진 파일이 되살아난다는 사실을 미리 알 수 없다.
22657
23100
  restored: restoreChanges.length + restoredModified + rowCount("update", true),
23101
+ ...restoredPaths.length > 0 ? { restoredPaths } : {},
22658
23102
  conflicts: [],
22659
23103
  writtenPaths: [],
22660
23104
  failed,
@@ -22729,6 +23173,42 @@ var PullPlanner = class {
22729
23173
  }
22730
23174
  };
22731
23175
 
23176
+ // src/sync/deletion-sweep.ts
23177
+ var DELETION_SWEEP_CURSOR_META_KEY = "deletion_sweep_cursor";
23178
+ var DELETION_SWEEP_BUDGET = 15;
23179
+ function sweepable(records, skip) {
23180
+ return records.filter((r) => r.notionPageId !== null && !skip.has(idOf(r))).sort((a, b) => idOf(a) < idOf(b) ? -1 : idOf(a) > idOf(b) ? 1 : 0);
23181
+ }
23182
+ function sweepBatch(records, cursor, skip, budget = DELETION_SWEEP_BUDGET) {
23183
+ const ordered = sweepable(records, skip);
23184
+ if (ordered.length === 0) return { batch: [], next: null, complete: true };
23185
+ if (ordered.length <= budget) {
23186
+ return { batch: ordered, next: idOf(ordered[ordered.length - 1]), complete: true };
23187
+ }
23188
+ const start = Math.max(0, cursor === null ? 0 : ordered.findIndex((r) => idOf(r) > cursor));
23189
+ const batch = Array.from({ length: budget }, (_, i) => ordered[(start + i) % ordered.length]);
23190
+ return { batch, next: idOf(batch[batch.length - 1]), complete: false };
23191
+ }
23192
+ function trackedDescendants(records, goneIds) {
23193
+ const gone = new Set(goneIds);
23194
+ const out = [];
23195
+ for (let grew = true; grew; ) {
23196
+ grew = false;
23197
+ for (const record of records) {
23198
+ if (record.notionPageId === null || record.notionParentId === null) continue;
23199
+ const id = idOf(record);
23200
+ if (gone.has(id) || !gone.has(compactNotionId(record.notionParentId))) continue;
23201
+ gone.add(id);
23202
+ out.push(record);
23203
+ grew = true;
23204
+ }
23205
+ }
23206
+ return out;
23207
+ }
23208
+ function idOf(record) {
23209
+ return compactNotionId(record.notionPageId);
23210
+ }
23211
+
22732
23212
  // src/sync/remote-detector.ts
22733
23213
  var RemoteDetector = class _RemoteDetector {
22734
23214
  constructor(config, stateDb, notionClient, observation) {
@@ -22764,6 +23244,12 @@ var RemoteDetector = class _RemoteDetector {
22764
23244
  // Notion 에서 부모가 바뀐 추적 페이지(정규화 id → 새 부모 페이지 id) — pull 이 볼트에서도 옮긴다(ADR-039).
22765
23245
  // 옮겨도 수정 시각이 바뀌지 않아(실측) 변경으로는 보이지 않는다. 감지마다 다시 채운다.
22766
23246
  _moved = /* @__PURE__ */ new Map();
23247
+ // 증분 감지가 search 로 살아 있다고 본 페이지(압축 id) — 삭제 확인이 다시 묻지 않는다.
23248
+ _seenAlive = /* @__PURE__ */ new Set();
23249
+ // 이번 감지가 삭제를 물은 뒤의 커서 — pull 이 마칠 때 적는다({@link saveDeletionSweep}). undefined 면 묻지 않았다.
23250
+ _sweepCursor;
23251
+ // 이번 감지가 추적 중인 것을 모두 물었나 — 그러면 증분이어도 미룬 삭제가 없다.
23252
+ _sweptAll = false;
22767
23253
  /** 이번 감지에서 자식 페이지를 가진 것으로 본 페이지인가 — 폴더노트 판정의 1차 근거. */
22768
23254
  hasChildPages(pageId) {
22769
23255
  return this._childParentIds.has(normalizeNotionId(pageId));
@@ -22805,7 +23291,7 @@ var RemoteDetector = class _RemoteDetector {
22805
23291
  throwIfAborted(signal);
22806
23292
  try {
22807
23293
  const page = await this.notionClient.getPage(childId);
22808
- if (page.in_trash || page.archived) continue;
23294
+ if (isTrashedOrArchived(page)) continue;
22809
23295
  const actualParent = await extractParentId(this.notionClient, page);
22810
23296
  if (!actualParent || !notionIdsEqual(actualParent, parentId)) continue;
22811
23297
  this._childParentIds.add(normalizeNotionId(parentId));
@@ -22868,10 +23354,83 @@ var RemoteDetector = class _RemoteDetector {
22868
23354
  * @param options.databases 바뀐 DB 도 찾는다(pull · dry-run). 상태 확인은 DB 를 조회하지 않아 찾지 않는다.
22869
23355
  */
22870
23356
  async detectRemote(scan, options) {
23357
+ this._seenAlive.clear();
23358
+ this._sweepCursor = void 0;
23359
+ this._sweptAll = false;
22871
23360
  const detection = scan.kind === "full" ? { changes: await this.detectRemoteChanges(options.signal), databaseIds: null } : await this.detectRemoteChangesIncremental(scan.since, options);
23361
+ if (options.databases) {
23362
+ detection.changes.push(
23363
+ ...await this.sweepDeletions(scan, detection.changes, options.signal)
23364
+ );
23365
+ }
22872
23366
  this.beginDetection(detection.changes);
22873
23367
  return detection;
22874
23368
  }
23369
+ /**
23370
+ * 이번 감지가 보지 못하는 삭제를 차례로 몇 개씩 원격에 묻는다({@link sweepBatch}). Notion 은 지운 것을
23371
+ * 목록으로 주지 않는다 — search · DB 조회 모두 휴지통을 돌려주지 않고 거르지도 못한다(ADR-044).
23372
+ *
23373
+ * - 증분 → 추적 중인 페이지 · 행 모두가 차례에 든다. search 가 살아 있다고 보인 것은 뺀다.
23374
+ * - 전체 대조 → 조회 필터를 단 DB 의 행만. 나머지는 순회 · DB 조회가 가른다. 필터를 단 DB 는 조회에 없는
23375
+ * 행이 필터 밖인지 지워진 것인지 몰라 가르지 않는다(DatabaseSyncer) — 여기서 하나씩 묻는다.
23376
+ *
23377
+ * 지워진 페이지 아래의 추적 페이지는 차례와 상관없이 함께 묻는다 — 부모를 휴지통에 넣으면 그 아래도 모두
23378
+ * 휴지통이다(실측 2026-10-28). 삭제는 deleteSync 일 때만, 첫 pull 이 아닐 때만 가른다 — 전체 대조와 같다.
23379
+ */
23380
+ async sweepDeletions(scan, found2, signal) {
23381
+ if (!this.config.sync.deleteSync || !this.stateDb.getMeta(LAST_PULL_META_KEY)) return [];
23382
+ const records = this.stateDb.getAll();
23383
+ const candidates = scan.kind === "incremental" ? records : records.filter((r) => this.inFilteredDatabase(r));
23384
+ const skip = /* @__PURE__ */ new Set([...this._seenAlive, ...found2.map((c) => compactNotionId(c.pageId))]);
23385
+ const { batch, next, complete: complete2 } = sweepBatch(
23386
+ candidates,
23387
+ this.stateDb.getMeta(DELETION_SWEEP_CURSOR_META_KEY) || null,
23388
+ skip
23389
+ );
23390
+ this._sweepCursor = next;
23391
+ this._sweptAll = scan.kind === "incremental" && complete2;
23392
+ const deletions = [];
23393
+ const gone = /* @__PURE__ */ new Set();
23394
+ const ask = async (record) => {
23395
+ throwIfAborted(signal);
23396
+ const id = compactNotionId(record.notionPageId);
23397
+ try {
23398
+ if ((await remotePresence(this.notionClient, record.notionPageId)).kind !== "gone") return;
23399
+ } catch (error) {
23400
+ getLogger().warn(
23401
+ `[Im-Nobsidian] ${record.obsidianPath} \u2014 \uC6D0\uACA9\uC744 \uD655\uC778\uD558\uC9C0 \uBABB\uD574 \uC774\uBC88\uC5D0\uB294 \uC0AD\uC81C\uB97C \uAC00\uB974\uC9C0 \uC54A\uC74C:`,
23402
+ error
23403
+ );
23404
+ return;
23405
+ }
23406
+ gone.add(id);
23407
+ deletions.push(remoteDeletionChange(record));
23408
+ };
23409
+ for (const record of batch) await ask(record);
23410
+ const asked = new Set(batch.map((r) => compactNotionId(r.notionPageId)));
23411
+ for (const record of trackedDescendants(records, gone)) {
23412
+ const id = compactNotionId(record.notionPageId);
23413
+ if (!asked.has(id) && !skip.has(id)) await ask(record);
23414
+ }
23415
+ return deletions;
23416
+ }
23417
+ /** 조회 필터(`pullFilter`)를 단 설정 DB 의 행인가. */
23418
+ inFilteredDatabase(record) {
23419
+ if (record.fileType !== "db-row" || record.notionParentId === null) return false;
23420
+ return (this.config.notion.databases ?? []).some(
23421
+ (db) => db.pullFilter !== void 0 && notionIdsEqual(db.databaseId, record.notionParentId)
23422
+ );
23423
+ }
23424
+ /** 이번 감지가 추적 중인 페이지 · 행을 모두 원격에 물었나 — 결과의 `deletionsDeferred` 를 가른다. */
23425
+ deletionsChecked() {
23426
+ return this._sweptAll;
23427
+ }
23428
+ /** 삭제를 물은 자리를 적는다 — 다음 pull 이 이어서 묻는다. 경로를 좁힌 pull · dry-run 은 부르지 않는다. */
23429
+ saveDeletionSweep() {
23430
+ if (this._sweepCursor !== void 0) {
23431
+ this.stateDb.setMeta(DELETION_SWEEP_CURSOR_META_KEY, this._sweepCursor ?? "");
23432
+ }
23433
+ }
22875
23434
  /**
22876
23435
  * 전체 대조 — 루트 아래 페이지를 모두 훑고 추적 중인데 목록에 없는 페이지를 원격에 물어 삭제를
22877
23436
  * 가른다. 취소하면 {@link OperationAbortedError} 를 던진다 — 다 훑지 못한 목록으로 삭제를 가르지
@@ -23012,9 +23571,8 @@ var RemoteDetector = class _RemoteDetector {
23012
23571
  }
23013
23572
  /**
23014
23573
  * 증분 원격 변경 감지 — `since` 이후 수정된 페이지만 search 로 받아 created/modified 만
23015
- * 만든다. **삭제는 감지하지 않는다**: search API 는 in_trash/archived 페이지를 반환하지 않아
23016
- * (=사라진 것을 증분만으로는 구분 불가) 삭제는 전체 대조(`detectRemoteChanges`)가 가른다.
23017
- * 전체 대조는 주기마다 돈다(ADR-027 — 예전에는 deleteSync 가 켜져 있으면 매번 돌았다. I10).
23574
+ * 만든다. search API 는 in_trash/archived 페이지를 반환하지 않아 삭제는 여기서 보이지 않는다 —
23575
+ * 차례로 묻는 삭제 확인({@link sweepDeletions})과 주기마다 도는 전체 대조(ADR-027)가 가른다.
23018
23576
  *
23019
23577
  * 바뀐 행 · 새 행 · 스키마를 고친 DB 도 모은다 — pull 은 그 DB 만 조회한다. 새 행은 페이지로 받지
23020
23578
  * 않는다: DB 조회가 행 속성과 함께 받는다. 그래서 부모를 묻지 않는다(행마다 요청 1회 이상 아낀다).
@@ -23032,6 +23590,7 @@ var RemoteDetector = class _RemoteDetector {
23032
23590
  const recentPages = await this.notionClient.searchRecentPages(searchSince, options.signal);
23033
23591
  const untracked = [];
23034
23592
  for (const page of recentPages) {
23593
+ this._seenAlive.add(compactNotionId(page.id));
23035
23594
  const record = this.stateDb.getByNotionId(page.id);
23036
23595
  if (!record) {
23037
23596
  if (page.parentDatabaseId) {
@@ -23185,6 +23744,9 @@ function isRerenderable(record) {
23185
23744
  }
23186
23745
 
23187
23746
  // src/sync/missing-local-files.ts
23747
+ function restoredWhenMissing(record) {
23748
+ return record.notionPageId !== null && !isFolderRecord(record) && (record.fileType === "file" || record.fileType === "folder-note" || record.fileType === "db-row");
23749
+ }
23188
23750
  async function detectMissingLocalFiles(config, stateDb, vaultFs, paths) {
23189
23751
  if (config.sync.deleteSync) return [];
23190
23752
  const records = stateDb.getAll();
@@ -23463,11 +24025,11 @@ ${text}
23463
24025
  skipped.push({ what: "\uBCF4\uAE30", reason: ".base \uB97C \uC77D\uC9C0 \uBABB\uD588\uB2E4" });
23464
24026
  return [];
23465
24027
  }
23466
- const idOf = /* @__PURE__ */ new Map();
23467
- for (const p of properties) idOf.set(p.type === "title" ? "file.name" : p.name, p.id);
24028
+ const idOf2 = /* @__PURE__ */ new Map();
24029
+ for (const p of properties) idOf2.set(p.type === "title" ? "file.name" : p.name, p.id);
23468
24030
  const columnId = (raw) => {
23469
24031
  if (typeof raw !== "string") return void 0;
23470
- return idOf.get(raw) ?? idOf.get(raw.replace(/^note\./, ""));
24032
+ return idOf2.get(raw) ?? idOf2.get(raw.replace(/^note\./, ""));
23471
24033
  };
23472
24034
  const views = [];
23473
24035
  const list = Array.isArray(parsed.views) ? parsed.views : [];
@@ -23762,6 +24324,7 @@ var SyncOrchestrator = class {
23762
24324
  this.rowSchemas.clear();
23763
24325
  this.placement.resetUnpreparedFolders();
23764
24326
  this.databaseCreator.reset();
24327
+ this.pusher.takeReadOnlySkips();
23765
24328
  if (!options?.dryRun) await this.recovery.recoverInterruptedPushOps();
23766
24329
  const plan = await this.planner.planLocalChanges(await this.vaultFs.listMarkdownFileStats());
23767
24330
  const view = this.planner.localView(plan);
@@ -23815,6 +24378,8 @@ var SyncOrchestrator = class {
23815
24378
  view.lookup,
23816
24379
  !options?.paths
23817
24380
  );
24381
+ const unsentDeletions2 = applicable.filter((change) => this.unsentDeletion(change)).map((change) => change.path);
24382
+ const readOnlySkipped = await this.pusher.previewReadOnlySkips(applicable);
23818
24383
  return {
23819
24384
  createdDatabases: newDatabases.size,
23820
24385
  created: count("create"),
@@ -23822,13 +24387,16 @@ var SyncOrchestrator = class {
23822
24387
  deleted: count("delete"),
23823
24388
  moved: count("move"),
23824
24389
  failed: refusedFailures,
23825
- duration: Date.now() - startTime
24390
+ duration: Date.now() - startTime,
24391
+ ...unsentDeletions2.length > 0 ? { unsentDeletions: unsentDeletions2 } : {},
24392
+ ...readOnlySkipped.length > 0 ? { readOnlySkipped } : {}
23826
24393
  };
23827
24394
  }
23828
24395
  this.stateDb.setMeta(PUSH_IN_PROGRESS_META_KEY, "true");
23829
24396
  await this.observation.resolveBotUserId();
23830
24397
  const counts = { created: 0, updated: 0, deleted: 0, moved: 0 };
23831
24398
  const failed = [...refusedFailures];
24399
+ const unsentDeletions = [];
23832
24400
  let completed = 0;
23833
24401
  const total = movableFolders.length + applicable.length;
23834
24402
  counts.moved += await this.pusher.pushFolderMoves(
@@ -23856,6 +24424,7 @@ var SyncOrchestrator = class {
23856
24424
  case "deleted": {
23857
24425
  const propagated = await this.pusher.pushDelete(change.path);
23858
24426
  if (propagated) counts.deleted++;
24427
+ else if (this.unsentDeletion(change)) unsentDeletions.push(change.path);
23859
24428
  break;
23860
24429
  }
23861
24430
  }
@@ -24011,9 +24580,20 @@ var SyncOrchestrator = class {
24011
24580
  deleted: counts.deleted,
24012
24581
  moved: counts.moved,
24013
24582
  failed,
24014
- duration: Date.now() - startTime
24583
+ duration: Date.now() - startTime,
24584
+ readOnlySkipped: this.pusher.takeReadOnlySkips(),
24585
+ ...unsentDeletions.length > 0 ? { unsentDeletions } : {}
24015
24586
  };
24016
24587
  }
24588
+ /**
24589
+ * 볼트에서 지운 이 노트를 Notion 에서 지우지 않아 다음 pull 이 되살리나 — deleteSync 가 꺼져 있고 pull 이
24590
+ * 되살리는 레코드일 때. 예전에는 대기로 적기만 하고 알리지 않아, 지운 파일이 말없이 돌아왔다(D8).
24591
+ */
24592
+ unsentDeletion(change) {
24593
+ if (change.type !== "deleted" || this.config.sync.deleteSync) return false;
24594
+ const record = this.stateDb.getByPath(change.path);
24595
+ return !!record && restoredWhenMissing(record);
24596
+ }
24017
24597
  /**
24018
24598
  * 만든 DB 에 relation · rollup · formula 를 붙이고, 행 스키마를 다시 읽게 한 뒤 보기를 만든다. 실패는
24019
24599
  * 다음 push 가 이어 붙인다 — 행은 그대로 올린다.
@@ -24086,6 +24666,7 @@ var SyncOrchestrator = class {
24086
24666
  this.assertConnectionScope();
24087
24667
  this.puller.beginPull();
24088
24668
  this.rowSchemas.clear();
24669
+ this.databaseSyncer.takeRestoredRows();
24089
24670
  const emptyResult = {
24090
24671
  created: 0,
24091
24672
  updated: 0,
@@ -24118,6 +24699,7 @@ var SyncOrchestrator = class {
24118
24699
  this.discovery.clearInlineRefs();
24119
24700
  const conflicts = [];
24120
24701
  const writtenPaths = [];
24702
+ const restoredPaths = [];
24121
24703
  const excluded = [];
24122
24704
  const failed = [];
24123
24705
  let detected = [];
@@ -24161,7 +24743,9 @@ var SyncOrchestrator = class {
24161
24743
  pendingDatabases
24162
24744
  );
24163
24745
  const finalize = async (created, updated, deleted, restored = 0) => {
24746
+ await this.puller.removeVacatedFolders();
24164
24747
  const renamedRows = this.databaseSyncer.takeRenamedRows();
24748
+ restoredPaths.push(...this.databaseSyncer.takeRestoredRows());
24165
24749
  const relinked = await this.linkUpdater.apply([...relocated.applied, ...renamedRows.applied]);
24166
24750
  const linkCount = writtenPaths.length > 0 ? await this.resolveNotionLinks(writtenPaths) : 0;
24167
24751
  if (!options?.paths) {
@@ -24177,6 +24761,7 @@ var SyncOrchestrator = class {
24177
24761
  this.stateDb.setMeta(LAST_FULL_PULL_META_KEY, startedAt);
24178
24762
  }
24179
24763
  this.detector.savePendingDatabases(ledger);
24764
+ this.detector.saveDeletionSweep();
24180
24765
  }
24181
24766
  this.stateDb.setMeta(LAST_SYNC_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
24182
24767
  this.stateDb.setMeta(PULL_IN_PROGRESS_META_KEY, "");
@@ -24185,6 +24770,7 @@ var SyncOrchestrator = class {
24185
24770
  updated,
24186
24771
  deleted,
24187
24772
  restored,
24773
+ ...restoredPaths.length > 0 ? { restoredPaths } : {},
24188
24774
  conflicts,
24189
24775
  writtenPaths,
24190
24776
  failed,
@@ -24196,7 +24782,8 @@ var SyncOrchestrator = class {
24196
24782
  ...remoteScanInfo(
24197
24783
  scan,
24198
24784
  this.stateDb.getMeta(LAST_FULL_PULL_META_KEY),
24199
- this.config.sync.deleteSync
24785
+ this.config.sync.deleteSync,
24786
+ this.detector.deletionsChecked()
24200
24787
  ),
24201
24788
  skippedDatabases: ledger.skipped
24202
24789
  },
@@ -24247,6 +24834,10 @@ var SyncOrchestrator = class {
24247
24834
  );
24248
24835
  return {
24249
24836
  ...plan,
24837
+ remoteScan: plan.remoteScan && {
24838
+ ...plan.remoteScan,
24839
+ deletionsDeferred: plan.remoteScan.deletionsDeferred && !this.detector.deletionsChecked()
24840
+ },
24250
24841
  moved: relocated.moved,
24251
24842
  moveSkipped: relocated.skipped,
24252
24843
  excluded: [...exclusions.values()]
@@ -24340,8 +24931,10 @@ var SyncOrchestrator = class {
24340
24931
  conflicts.push(outcome.conflict);
24341
24932
  } else if (outcome.action === "written") {
24342
24933
  writtenPaths.push(outcome.path);
24343
- if (restoreIds.has(change.pageId)) counts.restored++;
24344
- else counts.updated++;
24934
+ if (restoreIds.has(change.pageId)) {
24935
+ counts.restored++;
24936
+ restoredPaths.push(outcome.path);
24937
+ } else counts.updated++;
24345
24938
  }
24346
24939
  return {
24347
24940
  path: outcome.path,
@@ -24569,7 +25162,12 @@ var SyncOrchestrator = class {
24569
25162
  this.stateDb,
24570
25163
  this.config.notion.rootPageId
24571
25164
  );
24572
- return { databases, pages, complete: databases.complete && (pages?.complete ?? true) };
25165
+ return {
25166
+ databases,
25167
+ pages,
25168
+ nestedFolderPages: findNestedFolderPages(this.stateDb.getAll()),
25169
+ complete: databases.complete && (pages?.complete ?? true)
25170
+ };
24573
25171
  }
24574
25172
  async fetch() {
24575
25173
  return this.gate.run("fetch", () => this.executeFetch());
@@ -24814,6 +25412,19 @@ var NodeVaultFS = class {
24814
25412
  const fullPath = join(this.rootPath, path);
24815
25413
  await unlink(fullPath);
24816
25414
  }
25415
+ /** `rmdir` 는 비지 않은 폴더를 지우지 않는다(ENOTEMPTY) — 숨김 파일도 그대로 둔다. */
25416
+ async removeEmptyFolder(path) {
25417
+ try {
25418
+ await rmdir(join(this.rootPath, path));
25419
+ return true;
25420
+ } catch (error) {
25421
+ const code = error.code;
25422
+ if (code === "ENOTEMPTY" || code === "EEXIST" || code === "ENOENT" || code === "ENOTDIR") {
25423
+ return false;
25424
+ }
25425
+ throw error;
25426
+ }
25427
+ }
24817
25428
  async moveFile(from, to) {
24818
25429
  const fromPath = join(this.rootPath, from);
24819
25430
  const toPath = join(this.rootPath, to);
@@ -25772,6 +26383,9 @@ var FilteredVaultFS = class {
25772
26383
  ensureFolder(path) {
25773
26384
  return this.inner.ensureFolder(path);
25774
26385
  }
26386
+ async removeEmptyFolder(path) {
26387
+ return await this.inner.removeEmptyFolder?.(path) ?? false;
26388
+ }
25775
26389
  getFileStat(path) {
25776
26390
  return this.inner.getFileStat(path);
25777
26391
  }
@@ -25998,6 +26612,6 @@ function formatRatio(ratio) {
25998
26612
  return String(Math.round(ratio * 100) / 100);
25999
26613
  }
26000
26614
 
26001
- export { BaseFileGenerator, BlankLineCollapser, CONFIG_FILE, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConnectionBoundaryError, ConnectionScopeError, ConversionPipeline, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DEFAULT_TITLE_PROPERTY, DEFAULT_WATCH_DEBOUNCE_MS, DIFF_SIGN, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FilteredVaultFS, FrontmatterExtractor, FrontmatterGenerator, GITIGNORE_ENTRY, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, ImageHandler, InlineAnnotationPreserver, InlineDBParser, LocalImageRestorer, MARKER_BRAND, MathNormalizer, MentionToWikilink, NOTION_COLOR_NAMES, NOTION_TOKEN_PREFIX, NOTION_TO_BASES_VIEW, NodeVaultFS, NotionBlockBuilder, NotionClient, OBSIDIAN_CONFIG_DIR, PathFilter, PendingWalError, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, RENDER_LINE_RULES, SAVED_STATE_TABLES_QUERY, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_MIGRATIONS, STATE_DB_PATH, SavedStateDbError, ScopedVaultFS, SidecarGenerator, StateDB, StateDbLockedError, StateDbUnavailableError, StateLock, SyncBusyError, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WIKILINK_LOOKUPS, WatchSyncService, WikilinkResolver, applicableChoices, assertNoPendingWal, basesViewMappingOf, basesViewTypeOf, choiceForStrategy, classifyBodyFidelity, columnLabel, compactNotionId, computeBufferHash, computeHash, configDirFrom, connectionFolderOf, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, findConnectionFolders, formatUnifiedDiff, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, hasColumnLayout, hasDesignMarker, inAnyPathScope, isInternalPath, isMarkdownPath, isRemoteDeletion, lineDiff, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, normalizeVaultPath, noteNameFromPath, notionIdFromInput, notionIdsEqual, operationLabel, parseDesignColor, parseIgnorePatterns, pendingWalBytes, readColumnLayout, readDesignLine, resolvePropertyName, sanitizeFileName, setLogger, sortEntries, stateDbLockPath, stateDbWalPath, stripMarkdownExtension, summarizeFidelity, systemTimeZone, threeWayMerge, toVaultPath, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness, wikilinkLookupsIn };
26615
+ export { BaseFileGenerator, BlankLineCollapser, CONFIG_FILE, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConnectionBoundaryError, ConnectionScopeError, ConversionPipeline, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DEFAULT_TITLE_PROPERTY, DEFAULT_WATCH_DEBOUNCE_MS, DIFF_SIGN, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FilteredVaultFS, FrontmatterExtractor, FrontmatterGenerator, GITIGNORE_ENTRY, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, ImageHandler, InlineAnnotationPreserver, InlineDBParser, LocalImageRestorer, MARKER_BRAND, MathNormalizer, MentionToWikilink, NOTION_COLOR_NAMES, NOTION_TOKEN_PREFIX, NOTION_TO_BASES_VIEW, NodeVaultFS, NotionBlockBuilder, NotionClient, OBSIDIAN_CONFIG_DIR, PathFilter, PendingWalError, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, RENDER_LINE_RULES, SAVED_STATE_TABLES_QUERY, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_MIGRATIONS, STATE_DB_PATH, SavedStateDbError, ScopedVaultFS, SidecarGenerator, StateDB, StateDbLockedError, StateDbUnavailableError, StateLock, SyncBusyError, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WIKILINK_LOOKUPS, WatchSyncService, WikilinkResolver, applicableChoices, assertNoPendingWal, basesViewMappingOf, basesViewTypeOf, choiceForStrategy, classifyBodyFidelity, columnLabel, compactNotionId, computeBufferHash, computeHash, configDirFrom, connectionFolderOf, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, findConnectionFolders, findNestedFolderPages, formatUnifiedDiff, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, hasColumnLayout, hasDesignMarker, inAnyPathScope, isHeadingLine, isHeadingMarkerLine, isInternalPath, isMarkdownPath, isRemoteDeletion, lineDiff, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, normalizeVaultPath, noteNameFromPath, notionIdFromInput, notionIdsEqual, operationLabel, parseDesignColor, parseIgnorePatterns, pendingWalBytes, readColumnLayout, readDesignLine, resolvePropertyName, sanitizeFileName, sectionLinesWithHeadingMarkers, setLogger, sortEntries, stateDbLockPath, stateDbWalPath, stripMarkdownExtension, summarizeFidelity, systemTimeZone, threeWayMerge, toVaultPath, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness, wikilinkLookupsIn };
26002
26616
  //# sourceMappingURL=index.js.map
26003
26617
  //# sourceMappingURL=index.js.map