@im-nobsidian/core 0.10.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';
@@ -195,6 +195,7 @@ var DEFAULTS = {
195
195
  autoSyncInterval: 300,
196
196
  fullReconcileInterval: 3600,
197
197
  deleteSync: false,
198
+ renameOnTitleChange: false,
198
199
  syncFiles: true,
199
200
  createDatabases: true,
200
201
  notifications: "silent"
@@ -250,6 +251,8 @@ var ConfigSchema = z.object({
250
251
  // 반영한다. 0 이면 pull 마다 전체 대조(ADR-027). 최대 7일.
251
252
  fullReconcileInterval: z.number().int().min(0).max(604800).default(D.sync.fullReconcileInterval),
252
253
  deleteSync: z.boolean().default(D.sync.deleteSync),
254
+ // Notion 에서 바꾼 제목으로 노트 이름을 바꾼다 — 끄면 이름은 두고 새 제목을 frontmatter `title` 에 적는다.
255
+ renameOnTitleChange: z.boolean().default(D.sync.renameOnTitleChange),
253
256
  syncFiles: z.boolean().default(D.sync.syncFiles),
254
257
  // 사이드카가 있는 새 DB 폴더를 push 가 Notion DB 로 만든다(ADR-036). 끄면 폴더 · 페이지로 올린다.
255
258
  createDatabases: z.boolean().default(D.sync.createDatabases),
@@ -1473,6 +1476,13 @@ function normalizeAnnotations(annotations) {
1473
1476
  };
1474
1477
  }
1475
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
+ }
1476
1486
  function stringifyFrontmatter(content, data) {
1477
1487
  const astral = maskAstralCharacters(data);
1478
1488
  const out = matter.stringify(
@@ -1657,6 +1667,7 @@ function notionFileIdOf(url) {
1657
1667
  }
1658
1668
  function fileUrlOfBlock(block) {
1659
1669
  const media = block.type ? block[block.type] : void 0;
1670
+ if (block.type === "embed") return media?.url ?? null;
1660
1671
  return media?.file?.url ?? media?.external?.url ?? null;
1661
1672
  }
1662
1673
 
@@ -1876,6 +1887,13 @@ var PropertyMapper = class _PropertyMapper {
1876
1887
  relationKeys(keys) {
1877
1888
  return keys.filter((key) => this.schema.get(key)?.type === "relation");
1878
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
+ }
1879
1897
  /**
1880
1898
  * 값을 보낼 수 있는 속성만, pull 이 적는 모양으로 — 원격 값과 로컬 값을 견줄 때 쓴다.
1881
1899
  *
@@ -2304,13 +2322,6 @@ function dateText(value, zone) {
2304
2322
  if (!DATE_REGEX.test(text)) return null;
2305
2323
  return isWallTime(text) ? toZonedIso(text, zone) : text;
2306
2324
  }
2307
- function titleRunsOf(remote) {
2308
- for (const prop of Object.values(remote)) {
2309
- const value = prop;
2310
- if (value?.type === "title") return Array.isArray(value.title) ? value.title : null;
2311
- }
2312
- return null;
2313
- }
2314
2325
  function richTextRunsOf(remote, key) {
2315
2326
  const value = remote?.[key];
2316
2327
  return value?.type === "rich_text" && Array.isArray(value.rich_text) ? value.rich_text : null;
@@ -2759,8 +2770,7 @@ function isNotionAccessDenied(error) {
2759
2770
  return e.code === "restricted_resource" || e.status === 403;
2760
2771
  }
2761
2772
  function isTrashedOrArchived(page) {
2762
- const inTrash = page.in_trash === true;
2763
- return inTrash || page.archived === true;
2773
+ return page.in_trash === true || page.archived === true;
2764
2774
  }
2765
2775
  function isNotionBadRequest(error) {
2766
2776
  return notionStatusOf(error) === 400;
@@ -2793,6 +2803,7 @@ var DiscoveryTooLargeError = class extends Error {
2793
2803
  partial;
2794
2804
  };
2795
2805
  var NOT_IDEMPOTENT = { idempotent: false };
2806
+ var CREATES_BLOCKS = { idempotent: false, createsBlocks: true };
2796
2807
  function parentDatabaseIdOf(page) {
2797
2808
  const parent = page.parent;
2798
2809
  if (parent?.type !== "data_source_id" && parent?.type !== "database_id") return null;
@@ -2838,8 +2849,8 @@ var NotionClient = class _NotionClient {
2838
2849
  // 전역 쿨다운 밖에서, 로그 없이 흐른다.
2839
2850
  retry: false,
2840
2851
  // SDK 5.27+ 는 브라우저 환경(Obsidian 렌더러)에서 토큰을 받으면 던진다 — 남이 여는 페이지에 토큰이 실리는
2841
- // 것을 막는 검사다. 플러그인의 토큰은 사용자 기기의 볼트 설정에 있다. 5.23 은 이 옵션을 모른다(무시).
2842
- ...{ dangerouslyAllowBrowser: true },
2852
+ // 것을 막는 검사다. 플러그인의 토큰은 사용자 기기의 볼트 설정에 있다.
2853
+ dangerouslyAllowBrowser: true,
2843
2854
  ...options.fetch ? { fetch: options.fetch } : {}
2844
2855
  },
2845
2856
  options.notifications ?? DEFAULT_CONFIG.sync.notifications
@@ -2950,7 +2961,7 @@ var NotionClient = class _NotionClient {
2950
2961
  properties,
2951
2962
  children: params.children
2952
2963
  }),
2953
- NOT_IDEMPOTENT
2964
+ CREATES_BLOCKS
2954
2965
  );
2955
2966
  }
2956
2967
  /**
@@ -2975,7 +2986,7 @@ var NotionClient = class _NotionClient {
2975
2986
  }
2976
2987
  })
2977
2988
  ),
2978
- NOT_IDEMPOTENT
2989
+ CREATES_BLOCKS
2979
2990
  );
2980
2991
  const dataSourceId = db.data_sources?.[0]?.id ?? await this.primaryDataSourceId(db.id);
2981
2992
  if (!dataSourceId) throw new Error(`\uB9CC\uB4E0 DB(${db.id})\uC758 data source \uB97C \uC54C \uC218 \uC5C6\uC74C`);
@@ -3020,7 +3031,7 @@ var NotionClient = class _NotionClient {
3020
3031
  properties,
3021
3032
  markdown: params.markdown
3022
3033
  }),
3023
- NOT_IDEMPOTENT
3034
+ CREATES_BLOCKS
3024
3035
  );
3025
3036
  }
3026
3037
  /**
@@ -3060,10 +3071,10 @@ var NotionClient = class _NotionClient {
3060
3071
  /** 워크스페이스 사용자 이모지의 id — 이름이 꼭 같은 것. 없으면 null. */
3061
3072
  async findCustomEmojiId(name) {
3062
3073
  const response = await this.withRateLimit(() => this.client.customEmojis.list({ name }));
3063
- const found = response.results.find(
3074
+ const found2 = response.results.find(
3064
3075
  (emoji) => emoji.name === name
3065
3076
  );
3066
- return found?.id ?? null;
3077
+ return found2?.id ?? null;
3067
3078
  }
3068
3079
  // ─── Markdown API ───
3069
3080
  /**
@@ -3435,7 +3446,7 @@ var NotionClient = class _NotionClient {
3435
3446
  });
3436
3447
  }
3437
3448
  async archivePage(pageId) {
3438
- 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 }));
3439
3450
  }
3440
3451
  /**
3441
3452
  * 파일 블록의 지금 받을 수 있는 주소. 블록이 없거나 볼 수 없으면 null 이다.
@@ -3579,7 +3590,7 @@ var NotionClient = class _NotionClient {
3579
3590
  children: batch,
3580
3591
  ...after ? { position: { type: "after_block", after_block: { id: after } } } : {}
3581
3592
  }),
3582
- NOT_IDEMPOTENT
3593
+ CREATES_BLOCKS
3583
3594
  );
3584
3595
  const blocks = response.results.slice(0, batch.length);
3585
3596
  created.push(...blocks);
@@ -4083,6 +4094,12 @@ var NotionClient = class _NotionClient {
4083
4094
  * 슬롯을 붙잡아 남을 막는 대신, 다 같이 보는 이 게이트로 쉬게 한다.
4084
4095
  */
4085
4096
  cooldownUntil = 0;
4097
+ /**
4098
+ * Free 워크스페이스 블록 한도에 막힌 거절과 그 시각. 막힌 동안 블록을 만드는 요청은 보내지 않고 이 거절을 그대로
4099
+ * 돌려준다 — 플랜을 올리기 전에는 풀리지 않으므로 노트마다 같은 거절을 받으러 가지 않는다. 그 사이 플랜을
4100
+ * 올렸을 수 있어 {@link BLOCK_LIMIT_RECHECK_MS} 가 지나면 다시 보내 본다.
4101
+ */
4102
+ blockLimit = null;
4086
4103
  /**
4087
4104
  * 요청 1건을 rate limit 슬롯 안에서 실행하고, 재시도 가능한 실패는 백오프 후 다시 시도한다.
4088
4105
  *
@@ -4103,6 +4120,10 @@ var NotionClient = class _NotionClient {
4103
4120
  */
4104
4121
  async withRateLimit(fn, options) {
4105
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
+ }
4106
4127
  this.requestsSent++;
4107
4128
  let backoffMs = 0;
4108
4129
  for (let attempt = 0; ; attempt++) {
@@ -4112,6 +4133,12 @@ var NotionClient = class _NotionClient {
4112
4133
  this.lastRequestTime = Date.now();
4113
4134
  return await fn();
4114
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
+ }
4115
4142
  if (!isRetryable(error, idempotent) || attempt >= this.maxRetries) throw error;
4116
4143
  const retryAfterMs = extractRetryAfter(error);
4117
4144
  backoffMs = retryAfterMs !== null ? retryAfterMs * (1 + Math.random() * 0.25) : this.retryBaseDelayMs * Math.pow(this.retryBackoffFactor, attempt) * (0.5 + Math.random() * 0.5);
@@ -4176,10 +4203,31 @@ function isAmbiguousFailure(error) {
4176
4203
  return status === 500 || status === 502 || status === 503 || status === 504;
4177
4204
  }
4178
4205
  function isRequestBlocked(error) {
4179
- 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;
4180
4210
  const data = error.additional_data;
4181
- return data?.rate_limit_reason === "public_api_request_blocked";
4211
+ return typeof data?.rate_limit_reason === "string" ? data.rate_limit_reason : null;
4182
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
+ };
4183
4231
  function isRetryable(error, idempotent) {
4184
4232
  if (isRequestBlocked(error)) return false;
4185
4233
  return isExplicitRejection(error) || idempotent && isAmbiguousFailure(error);
@@ -4190,10 +4238,13 @@ function describeRetryCause(error) {
4190
4238
  const parts = [];
4191
4239
  if (typeof e.status === "number") parts.push(`status ${e.status}`);
4192
4240
  if (typeof e.code === "string") parts.push(e.code);
4241
+ const reason = rateLimitReasonOf(error);
4242
+ if (reason) parts.push(reason);
4193
4243
  if (typeof e.message === "string" && e.message) parts.push(e.message.slice(0, 120));
4194
4244
  return parts.length > 0 ? parts.join(" \xB7 ") : "\uC54C \uC218 \uC5C6\uB294 \uC624\uB958";
4195
4245
  }
4196
4246
  var MAX_RETRY_AFTER_MS = 6e4;
4247
+ var MAX_SHARED_RETRY_AFTER_MS = 5 * 6e4;
4197
4248
  function readHeader(headers, name) {
4198
4249
  if (typeof headers !== "object" || headers === null) return null;
4199
4250
  const get = headers.get;
@@ -4220,7 +4271,8 @@ function extractRetryAfter(error) {
4220
4271
  ms = Number.isNaN(at) ? Number.NaN : at - Date.now();
4221
4272
  }
4222
4273
  if (!Number.isFinite(ms) || ms <= 0) return null;
4223
- 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);
4224
4276
  }
4225
4277
  function sleep(ms) {
4226
4278
  return new Promise((resolve2) => setTimeout(resolve2, ms));
@@ -5058,6 +5110,7 @@ function deferredSyncedRefMarker(index) {
5058
5110
  }
5059
5111
  var TOGGLE_START = compactMarker("toggle:start");
5060
5112
  var TOGGLE_END = compactMarker("toggle:end");
5113
+ var LARGE_FILE_MARKER = compactMarker("large-file");
5061
5114
  var TOGGLE_HEADING_START = compactMarker("toggle-heading");
5062
5115
  var TOGGLE_HEADING_END = compactMarker("toggle-heading:end");
5063
5116
  function calloutIndentMarker(depth2) {
@@ -5140,7 +5193,6 @@ var MARKER_TOKEN_RE = new RegExp(
5140
5193
 
5141
5194
  // src/converter/pipeline.ts
5142
5195
  var INLINE_DB_PROBE = new RegExp(`%% ${MARKER_BRAND_RE}:inline-db`);
5143
- var TOGGLE_HEADING_PROBE = new RegExp(`%% ${MARKER_BRAND_RE}:toggle-heading`);
5144
5196
  var ConversionPipeline = class {
5145
5197
  preProcessors = [];
5146
5198
  postProcessors = [];
@@ -5152,11 +5204,14 @@ var ConversionPipeline = class {
5152
5204
  this.postProcessors.push(processor);
5153
5205
  this.postProcessors.sort((a, b) => a.order - b.order);
5154
5206
  }
5207
+ /**
5208
+ * 토글 헤딩은 Markdown API 경로가 변환한다(`toggle-heading.ts`) — 블록 경로로 보내면 콜아웃이 옛 이모지 꼴로
5209
+ * 바뀐다(`CalloutTransformer`). 예전에 여기 있던 토글 헤딩 탐지는 띄어 쓴 마커를 찾아 한 번도 맞지 않았다.
5210
+ */
5155
5211
  selectPath(content) {
5156
5212
  const hasInlineDb = INLINE_DB_PROBE.test(content);
5157
5213
  const hasColumnLayout2 = />\s*\[!col\]/.test(content);
5158
- const hasToggleHeading = TOGGLE_HEADING_PROBE.test(content);
5159
- if (hasInlineDb || hasColumnLayout2 || hasToggleHeading) {
5214
+ if (hasInlineDb || hasColumnLayout2) {
5160
5215
  return "block-api";
5161
5216
  }
5162
5217
  return "markdown-api";
@@ -5785,6 +5840,27 @@ function obsidianMathCloseAt(line, open) {
5785
5840
  return close;
5786
5841
  }
5787
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
+
5788
5864
  // src/converter/container-head.ts
5789
5865
  var CALLOUT_HEAD_LINE_RE = /^([ \t]{0,3})> \[!(\w+)\]([-+])?[ \t]*(.*)$/;
5790
5866
  var TOGGLE_HEAD_SOURCE = String.raw`^([ \t]{0,3})> \[!toggle\]-`;
@@ -5859,7 +5935,7 @@ var EmbedResolver = class {
5859
5935
  (match, alt, url, offset) => {
5860
5936
  if (code.some(([start, end]) => offset >= start && offset < end)) return match;
5861
5937
  if (isVideoUrl(url)) {
5862
- return `[\u{1F3AC} ${alt || "video"}](${url})`;
5938
+ return `[${mediaLabel("video", alt)}](${url})`;
5863
5939
  }
5864
5940
  if (isExternalUrl(url)) {
5865
5941
  images.push({ url, isExternal: true });
@@ -7423,6 +7499,112 @@ var BlankLineCollapser = class {
7423
7499
  }
7424
7500
  };
7425
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
+
7426
7608
  // src/utils/wikilink-title.ts
7427
7609
  function wikilinkTitleFromPath(obsidianPath) {
7428
7610
  return noteNameFromPath(obsidianPath);
@@ -7433,7 +7615,7 @@ function formatWikilink(target, label) {
7433
7615
 
7434
7616
  // src/converter/link-restore.ts
7435
7617
  var NOTION_LINK_REGEX = new RegExp(
7436
- `\\[${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})`,
7437
7619
  "g"
7438
7620
  );
7439
7621
  var WIKILINK_PRESERVE_REGEX = new RegExp(
@@ -7698,7 +7880,7 @@ var PreserveMarkerInjector = class {
7698
7880
  const rendered = this.renderMarker(m);
7699
7881
  if (rendered === null) return false;
7700
7882
  if (content.includes(rendered)) return false;
7701
- return !this.isSemanticallyRestored(m, content);
7883
+ return !this.restoredElsewhere(m);
7702
7884
  });
7703
7885
  if (missing.length === 0) {
7704
7886
  return { content, metadata: input.metadata };
@@ -7733,15 +7915,11 @@ var PreserveMarkerInjector = class {
7733
7915
  const body = entries.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
7734
7916
  return spacedMarker(`${marker.type}:${body}`);
7735
7917
  }
7736
- /** 마커가 이미 원래 문법으로 복원돼 있으면 재삽입은 중복·오염이다. */
7737
- isSemanticallyRestored(marker, content) {
7738
- const params = marker.params ?? {};
7918
+ /** 다른 복원기가 맡는 마커 — 본문에 다시 끼우면 중복 · 오염이다. */
7919
+ restoredElsewhere(marker) {
7739
7920
  switch (marker.type) {
7740
- case "wikilink": {
7741
- const text = params.text;
7742
- if (!text) return false;
7743
- return content.includes(`[[${text}]]`) || content.includes(`[[${text}|`);
7744
- }
7921
+ case "wikilink":
7922
+ return true;
7745
7923
  case "local-image":
7746
7924
  case "local-file":
7747
7925
  return true;
@@ -8601,7 +8779,10 @@ var CommentRestorer = class {
8601
8779
  if (input.context.direction !== "pull" || local === void 0) {
8602
8780
  return { content: input.content, metadata: input.metadata };
8603
8781
  }
8604
- return { content: restoreComments(input.content, local), metadata: input.metadata };
8782
+ return {
8783
+ content: restoreComments(input.content, liftHeadingMarkers(local)),
8784
+ metadata: input.metadata
8785
+ };
8605
8786
  }
8606
8787
  };
8607
8788
 
@@ -8950,9 +9131,10 @@ function pieces(local, pulled) {
8950
9131
  let j = 0;
8951
9132
  const zip = (to) => {
8952
9133
  const theirs = pulled.slice(j, to);
8953
- for (let k = 0; k < Math.max(pending.length, theirs.length); k++) {
8954
- out.push([pending.slice(k, k + 1), theirs.slice(k, k + 1)]);
8955
- }
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]]]);
8956
9138
  pending = [];
8957
9139
  j = to;
8958
9140
  };
@@ -9169,6 +9351,18 @@ function readable(note) {
9169
9351
  }
9170
9352
  }
9171
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
+
9172
9366
  // src/converter/literal-escapes.ts
9173
9367
  var ASCII_PUNCT_RE = /[!-/:-@[-`{-~]/;
9174
9368
  var MATH_FENCE_LINE_RE = /^(?:[ \t]*>)*[ \t]*\$\$[ \t]*$/;
@@ -9391,17 +9585,17 @@ var CHILD_PAGE_TAG_RE = /<page url="([^"]*)">([\s\S]*?)<\/page>/g;
9391
9585
  var CHILD_DATABASE_TAG_RE = /<database\b([^>]*)>([\s\S]*?)<\/database>/g;
9392
9586
  var ID32_RE = /[a-f0-9]{32}/;
9393
9587
  function extractChildTags(markdown) {
9394
- const found = [];
9588
+ const found2 = [];
9395
9589
  for (const m of markdown.matchAll(CHILD_PAGE_TAG_RE)) {
9396
9590
  const id = ID32_RE.exec(m[1] ?? "")?.[0];
9397
9591
  if (id) {
9398
- found.push({ at: m.index ?? 0, child: { kind: "page", id, title: m[2] ?? "", tag: m[0] } });
9592
+ found2.push({ at: m.index ?? 0, child: { kind: "page", id, title: m[2] ?? "", tag: m[0] } });
9399
9593
  }
9400
9594
  }
9401
9595
  for (const m of markdown.matchAll(CHILD_DATABASE_TAG_RE)) {
9402
9596
  const id = databaseTagId(m[1] ?? "");
9403
9597
  if (id) {
9404
- found.push({
9598
+ found2.push({
9405
9599
  at: m.index ?? 0,
9406
9600
  child: { kind: "database", id, title: m[2] ?? "", tag: m[0] }
9407
9601
  });
@@ -9409,7 +9603,7 @@ function extractChildTags(markdown) {
9409
9603
  }
9410
9604
  const seen = /* @__PURE__ */ new Set();
9411
9605
  const children = [];
9412
- for (const { child } of found.sort((a, b) => a.at - b.at)) {
9606
+ for (const { child } of found2.sort((a, b) => a.at - b.at)) {
9413
9607
  if (seen.has(child.id)) continue;
9414
9608
  seen.add(child.id);
9415
9609
  children.push(child);
@@ -9749,6 +9943,8 @@ function findBodyEnd(lines, from, level) {
9749
9943
  // src/utils/sanitize.ts
9750
9944
  var INVALID_CHARS = /[<>:"/\\|?*\x00-\x1f]/g;
9751
9945
  var WIKILINK_UNSAFE_CHARS = /[[\]]/g;
9946
+ var LINK_SUBPATH_CHARS = /[#^]/g;
9947
+ var TRAILING_DOTS_AND_SPACES = /[.\s]+$/;
9752
9948
  var TRAILING_DOTS = /\.+$/;
9753
9949
  var RESERVED_NAMES = /* @__PURE__ */ new Set([
9754
9950
  "CON",
@@ -9774,15 +9970,38 @@ var RESERVED_NAMES = /* @__PURE__ */ new Set([
9774
9970
  "LPT8",
9775
9971
  "LPT9"
9776
9972
  ]);
9777
- function sanitizeFileName(name) {
9778
- let sanitized = name.replace(INVALID_CHARS, "_").replace(WIKILINK_UNSAFE_CHARS, "_").replace(TRAILING_DOTS, "").trim().slice(0, 200);
9779
- if (RESERVED_NAMES.has(sanitized.toUpperCase())) {
9780
- sanitized = `_${sanitized}`;
9781
- }
9782
- if (sanitized.length === 0) {
9783
- 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;
9784
9990
  }
9785
- 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
+ );
9786
10005
  }
9787
10006
 
9788
10007
  // src/converter/nfm-attrs.ts
@@ -10142,16 +10361,16 @@ var TableAttrs = class {
10142
10361
  cols = [];
10143
10362
  /** 셀 하나의 마커를 걷는다 — 행 · 셀 속성은 돌려주고 표 · 열 속성은 모아 둔다. */
10144
10363
  take(cell, col) {
10145
- const found = {};
10364
+ const found2 = {};
10146
10365
  const text = cell.replace(TABLE_MARKER_RE, (_m, part, payload) => {
10147
10366
  const attrs = parseAttrsPayload(payload);
10148
10367
  if (part === "table") this.table ??= attrs;
10149
10368
  else if (part === "table-col") this.cols[col] ??= attrs;
10150
- else if (part === "table-row") found.row ??= attrs;
10151
- else found.cell ??= attrs;
10369
+ else if (part === "table-row") found2.row ??= attrs;
10370
+ else found2.cell ??= attrs;
10152
10371
  return "";
10153
10372
  });
10154
- return { text: text.trim(), ...found };
10373
+ return { text: text.trim(), ...found2 };
10155
10374
  }
10156
10375
  };
10157
10376
  function toNfmTable(prefix, bodies) {
@@ -10286,16 +10505,6 @@ function breaksToEmptyBlocks(content) {
10286
10505
  });
10287
10506
  }
10288
10507
 
10289
- // src/converter/degrade-link.ts
10290
- function degradeLink(label, url, marker) {
10291
- return `[${label}](${url.replace(/\(/g, "%28").replace(/\)/g, "%29")})${marker}`;
10292
- }
10293
- var DEGRADE_LINK_SOURCE = "(?:\\[(?:\\\\.|[^\\\\\\]])*\\]\\([^)]*\\))?";
10294
- function embedVaultLink(src, caption = "") {
10295
- const label = caption.replace(/<[^>]*>/g, "").replace(/[[\]\\]/g, "").trim();
10296
- return degradeLink(label === "" ? "\u{1F517} Embed" : `\u{1F517} ${label}`, src, embedMarker(src, caption));
10297
- }
10298
-
10299
10508
  // src/converter/placeholder-indent.ts
10300
10509
  var PLACEHOLDER_LINE_RE = new RegExp(`^([ \\t]*)${MEDIA_PLACEHOLDER_HEAD}`);
10301
10510
  var LEADING_SPACE_RE3 = /^[ \t]*/;
@@ -11237,6 +11446,7 @@ var NOTION_AUDIO_RE = /<audio src="([^"]*)">([\s\S]*?)<\/audio>/g;
11237
11446
  var NOTION_VIDEO_RE = /<video src="([^"]*)">([\s\S]*?)<\/video>/g;
11238
11447
  var NOTION_PDF_RE = /<pdf src="([^"]*)">([\s\S]*?)<\/pdf>/g;
11239
11448
  var NOTION_FILE_RE = /<file src="([^"]*)">([\s\S]*?)<\/file>/g;
11449
+ var NOTION_HOSTED_EMBED_RE = /<embed\s+src="([^"]*)"[^>]*>([^\n]*?)\s*<\/embed>/g;
11240
11450
  var NOTION_TAB_RE = /<tab title="([^"]*)">([\s\S]*?)<\/tab>/g;
11241
11451
  var NOTION_LABELED_PAGE_LINK_RE = new RegExp(
11242
11452
  `\\[${MARKER_LABEL_CAPTURE}\\]\\(https?:\\/\\/(?:[a-z]+\\.)?notion\\.(?:so|com)\\/(?:p\\/)?([a-f0-9]{32}|[a-f0-9-]{36})\\)`,
@@ -11291,7 +11501,7 @@ function unescapeBrackets(content) {
11291
11501
  );
11292
11502
  }
11293
11503
  function obsidianToNotionEnhanced(obsidian) {
11294
- let result = obsidian;
11504
+ let result = liftHeadingMarkers(stripLargeFileLinks(obsidian));
11295
11505
  result = quotedCodeToChildren(result);
11296
11506
  result = breaksToEmptyBlocks(result);
11297
11507
  result = pipeTablesToNfmTables(result);
@@ -11553,20 +11763,20 @@ ${indented}
11553
11763
  function convertMediaTags(content) {
11554
11764
  let result = content;
11555
11765
  result = result.replace(NOTION_AUDIO_RE, (_match, src, caption) => {
11556
- const cap = caption.trim();
11557
- return cap ? `[\u{1F50A} ${cap}](${src})` : `[\u{1F50A} audio](${src})`;
11766
+ return `[${mediaLabel("audio", caption.trim())}](${src})`;
11558
11767
  });
11559
11768
  result = result.replace(NOTION_VIDEO_RE, (_match, src, caption) => {
11560
- const cap = caption.trim();
11561
- return cap ? `[\u{1F3AC} ${cap}](${src})` : `[\u{1F3AC} video](${src})`;
11769
+ return `[${mediaLabel("video", caption.trim())}](${src})`;
11562
11770
  });
11563
11771
  result = result.replace(NOTION_PDF_RE, (_match, src, caption) => {
11564
- const cap = caption.trim();
11565
- return cap ? `[\u{1F4C4} ${cap}](${src})` : `[\u{1F4C4} pdf](${src})`;
11772
+ return `[${mediaLabel("pdf", caption.trim())}](${src})`;
11566
11773
  });
11567
11774
  result = result.replace(NOTION_FILE_RE, (_match, src, caption) => {
11568
- const cap = caption.trim();
11569
- 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})`;
11570
11780
  });
11571
11781
  return result;
11572
11782
  }
@@ -11803,10 +12013,11 @@ $$`;
11803
12013
  function unescapeNotionChars(content) {
11804
12014
  return content.replace(/\\~/g, "~").replace(/\\\^/g, "^");
11805
12015
  }
11806
- var OBSIDIAN_MEDIA_AUDIO_RE = /\[🔊\s*([^\]]*)\]\(([^)]+)\)/g;
11807
- var OBSIDIAN_MEDIA_VIDEO_RE = /\[🎬\s*([^\]]*)\]\(([^)]+)\)/g;
11808
- var OBSIDIAN_MEDIA_PDF_RE = /\[📄\s*([^\]]*)\]\(([^)]+)\)/g;
11809
- 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");
11810
12021
  function restoreMediaTags(content) {
11811
12022
  let result = content;
11812
12023
  const restore = (tag) => (_match, caption, src) => {
@@ -11817,6 +12028,10 @@ function restoreMediaTags(content) {
11817
12028
  result = result.replace(OBSIDIAN_MEDIA_VIDEO_RE, restore("video"));
11818
12029
  result = result.replace(OBSIDIAN_MEDIA_PDF_RE, restore("pdf"));
11819
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
+ });
11820
12035
  return result;
11821
12036
  }
11822
12037
  var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)((?:\n> .*)*)/gm;
@@ -12087,6 +12302,8 @@ function sortKeys(value) {
12087
12302
  // src/converter/pipeline-factory.ts
12088
12303
  function createDefaultPipeline(options) {
12089
12304
  const pipeline = new ConversionPipeline();
12305
+ pipeline.registerPreProcessor(new HeadingMarkerLifter());
12306
+ pipeline.registerPreProcessor(new LargeFileLinkStripper());
12090
12307
  pipeline.registerPreProcessor(new InlineAnnotationPreserver());
12091
12308
  pipeline.registerPreProcessor(new UnsupportedBlockStripper());
12092
12309
  pipeline.registerPreProcessor(new CommentStripper());
@@ -12117,6 +12334,7 @@ function createDefaultPipeline(options) {
12117
12334
  pipeline.registerPostProcessor(
12118
12335
  new LocalFormRestorer((note, context, children) => sentForm(pipeline, note, context, children))
12119
12336
  );
12337
+ pipeline.registerPostProcessor(new HeadingMarkerPlacer());
12120
12338
  return pipeline;
12121
12339
  }
12122
12340
 
@@ -12224,6 +12442,11 @@ function isNotionUploadable(path) {
12224
12442
  const ext = /\.[^.]+$/.exec(name)?.[0].toLowerCase();
12225
12443
  return ext !== void 0 && NOTION_UPLOADABLE_EXTENSIONS.has(ext);
12226
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
+ }
12227
12450
  function getBlockType(filename) {
12228
12451
  const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
12229
12452
  if (!ext) return "file";
@@ -12351,10 +12574,15 @@ function toWikilinkAlias(label) {
12351
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();
12352
12575
  }
12353
12576
  var INTERNAL_FILE_URL_RE = /^file:\/\/%7B.*%7D%7D$/;
12354
- var FILE_LABEL_PREFIX_RE = /^(?:📎|🎬|🎞|📄|🔊)\s*/u;
12355
- var MEDIA_TAG_RE = /<(?:file|video|audio|pdf)\s+src="([^"]+)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
12356
- var MEDIA_TAG_NAME_RE = /^<(file|video|audio|pdf)\s/;
12357
- 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`);
12358
12586
  var PLACEHOLDER_IMAGE_STEMS = /* @__PURE__ */ new Set(["image", "untitled"]);
12359
12587
  var EMBED_SIZE_RE = /^\d+(?:x\d+)?$/;
12360
12588
  function splitEmbedTarget(target) {
@@ -12378,8 +12606,8 @@ function embedTargetFromCaption(target, caption, uploadedName) {
12378
12606
  return description ? sized(`${path}|${alias}`) : `${path}|${alias}`;
12379
12607
  }
12380
12608
  function captionOfLabel(label) {
12381
- const caption = label.replace(FILE_LABEL_PREFIX_RE, "").replace(/\\(.)/g, "$1").trim();
12382
- 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;
12383
12611
  }
12384
12612
  function fileNameOf2(caption, url) {
12385
12613
  return savedNameOf(caption, uploadedFileNameFromUrl(url));
@@ -12406,7 +12634,7 @@ function isNotionMediaUrl(url) {
12406
12634
  function notionMediaSpans(markdown) {
12407
12635
  const spans = [];
12408
12636
  for (const link of scanMarkdownLinks(markdown)) {
12409
- if (!link.isEmbed && !FILE_LABEL_PREFIX_RE.test(link.label)) continue;
12637
+ if (!link.isEmbed && !MEDIA_LABEL_PREFIX_RE.test(link.label)) continue;
12410
12638
  if (isNotionMediaUrl(link.url)) spans.push(link);
12411
12639
  }
12412
12640
  for (const tag of markdown.matchAll(MEDIA_TAG_RE)) {
@@ -12478,6 +12706,7 @@ function mediaBlock2(kind, fileUploadId, vaultTarget, target) {
12478
12706
  const description = captionOfEmbed(target);
12479
12707
  const caption = description ? [{ type: "text", text: { content: description } }] : [];
12480
12708
  const file = { type: "file_upload", file_upload: { id: fileUploadId }, caption };
12709
+ if (kind !== "image" && isHtmlBlockFile(vaultTarget)) return { type: "embed", embed: file };
12481
12710
  const type = kind === "image" ? "image" : getBlockType(baseName(vaultTarget));
12482
12711
  return type === "file" ? { type, file: { ...file, name: baseName(vaultTarget) } } : { type, [type]: file };
12483
12712
  }
@@ -12845,12 +13074,12 @@ var ImageHandler = class {
12845
13074
  let result = "";
12846
13075
  let cursor = 0;
12847
13076
  for (const placeholder2 of placeholders) {
12848
- const found = onPage.get(stripAlias2(placeholder2.target))?.shift();
12849
- const line = found && await this.keptMediaLine(placeholder2, found, noteFolder);
13077
+ const found2 = onPage.get(stripAlias2(placeholder2.target))?.shift();
13078
+ const line = found2 && await this.keptMediaLine(placeholder2, found2, noteFolder);
12850
13079
  if (!line) continue;
12851
13080
  result += markdown.slice(cursor, placeholder2.start) + line;
12852
13081
  cursor = placeholder2.end;
12853
- kept.push({ ...found.media, target: placeholder2.target });
13082
+ kept.push({ ...found2.media, target: placeholder2.target });
12854
13083
  }
12855
13084
  if (kept.length === 0) return unchanged;
12856
13085
  return { markdown: result + markdown.slice(cursor), kept };
@@ -12859,21 +13088,21 @@ var ImageHandler = class {
12859
13088
  * 자리표시자 자리에 실을 Notion 미디어 한 줄. 종류 · 캡션 · URL 을 그대로 실을 수 없거나 볼트의 파일이
12860
13089
  * 올린 것과 다르면 null — 그 자리표시자는 다시 올린다.
12861
13090
  */
12862
- async keptMediaLine(placeholder2, found, noteFolder) {
13091
+ async keptMediaLine(placeholder2, found2, noteFolder) {
12863
13092
  const isImage = placeholder2.kind === "image";
12864
- if (isImage !== found.span.full.startsWith("![")) return null;
12865
- const tag = MEDIA_TAG_NAME_RE.exec(found.span.full)?.[1];
13093
+ if (isImage !== found2.span.full.startsWith("![")) return null;
13094
+ const tag = MEDIA_TAG_NAME_RE.exec(found2.span.full)?.[1];
12866
13095
  if (!isImage && !tag) return null;
12867
- const caption = this.keptCaptionOf(placeholder2.target, found.span);
12868
- const url = found.span.url.split("?")[0];
13096
+ const caption = this.keptCaptionOf(placeholder2.target, found2.span);
13097
+ const url = found2.span.url.split("?")[0];
12869
13098
  if (!PLAIN_CAPTION_RE.test(caption) || !PLAIN_URL_RE.test(url)) return null;
12870
13099
  try {
12871
13100
  const { buffer, resolvedPath } = await this.readImageFromVault(
12872
13101
  stripAlias2(placeholder2.target),
12873
13102
  noteFolder
12874
13103
  );
12875
- if (resolvedPath !== found.media.localPath) return null;
12876
- if (createHash("sha256").update(buffer).digest("hex") !== found.media.fileHash) return null;
13104
+ if (resolvedPath !== found2.media.localPath) return null;
13105
+ if (createHash("sha256").update(buffer).digest("hex") !== found2.media.fileHash) return null;
12877
13106
  } catch {
12878
13107
  return null;
12879
13108
  }
@@ -13122,17 +13351,15 @@ var ImageHandler = class {
13122
13351
  }
13123
13352
  async downloadAllFiles(markdown, pageTitle, pageId) {
13124
13353
  const fileLinks = scanMarkdownLinks(markdown).filter(
13125
- (s) => !s.isEmbed && FILE_LABEL_PREFIX_RE.test(s.label)
13354
+ (s) => !s.isEmbed && MEDIA_LABEL_PREFIX_RE.test(s.label)
13126
13355
  );
13127
13356
  const notionHttpMatches = fileLinks.filter(
13128
13357
  (s) => /^https?:\/\//.test(s.url) && this.isNotionFileUrl(s.url)
13129
13358
  );
13130
13359
  const mdInternalMatches = fileLinks.filter((s) => INTERNAL_FILE_URL_RE.test(s.url));
13131
- const xmlHttpRegex = /<(?:file|video|audio|pdf)\s+src="(https?:\/\/[^"]+)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
13132
- const xmlHttpMatches = [...markdown.matchAll(xmlHttpRegex)];
13360
+ const xmlHttpMatches = [...markdown.matchAll(mediaTagRe('https?:\\/\\/[^"]+'))];
13133
13361
  const notionXmlHttpMatches = xmlHttpMatches.filter((m) => m[1] && this.isNotionFileUrl(m[1]));
13134
- const xmlInternalRegex = /<(?:file|video|audio|pdf)\s+src="(file:\/\/%7B.*?%7D%7D)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
13135
- const xmlInternalMatches = [...markdown.matchAll(xmlInternalRegex)];
13362
+ const xmlInternalMatches = [...markdown.matchAll(mediaTagRe("file:\\/\\/%7B.*?%7D%7D"))];
13136
13363
  const totalMatches = notionHttpMatches.length + mdInternalMatches.length + notionXmlHttpMatches.length + xmlInternalMatches.length;
13137
13364
  if (totalMatches === 0) {
13138
13365
  return { content: markdown, downloads: [] };
@@ -13144,9 +13371,12 @@ var ImageHandler = class {
13144
13371
  for (const span of notionHttpMatches) {
13145
13372
  await sema.acquire();
13146
13373
  try {
13147
- 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);
13148
13375
  const download = await this.downloadFile(span.url, pageTitle, caption);
13149
- if (!download.localPath) continue;
13376
+ if (!download.localPath) {
13377
+ result = keepLargeFile(result, span.full, download, pageId);
13378
+ continue;
13379
+ }
13150
13380
  downloads.push(download);
13151
13381
  const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
13152
13382
  result = result.replace(span.full, () => obsidianLink);
@@ -13175,9 +13405,12 @@ var ImageHandler = class {
13175
13405
  getLogger().warn(`[Im-Nobsidian] \uD30C\uC77C Block URL \uC870\uD68C \uC2E4\uD328: ${fileName2}`);
13176
13406
  continue;
13177
13407
  }
13178
- const caption = savedNameOf(span.label.replace(FILE_LABEL_PREFIX_RE, ""), fileName2);
13408
+ const caption = savedNameOf(span.label.replace(MEDIA_LABEL_PREFIX_RE, ""), fileName2);
13179
13409
  const download = await this.downloadFile(realUrl, pageTitle, caption);
13180
- if (!download.localPath) continue;
13410
+ if (!download.localPath) {
13411
+ result = keepLargeFile(result, span.full, download, pageId, blockId);
13412
+ continue;
13413
+ }
13181
13414
  downloads.push(download);
13182
13415
  const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
13183
13416
  result = result.replace(span.full, () => obsidianLink);
@@ -13200,7 +13433,10 @@ var ImageHandler = class {
13200
13433
  const url = match[1];
13201
13434
  const caption = fileNameOf2(match[2] ?? "", url);
13202
13435
  const download = await this.downloadFile(url, pageTitle, caption);
13203
- if (!download.localPath) continue;
13436
+ if (!download.localPath) {
13437
+ result = keepLargeFile(result, match[0], download, pageId);
13438
+ continue;
13439
+ }
13204
13440
  downloads.push(download);
13205
13441
  const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
13206
13442
  result = result.replace(match[0], () => obsidianLink);
@@ -13232,7 +13468,10 @@ var ImageHandler = class {
13232
13468
  }
13233
13469
  const caption = savedNameOf(match[2] ?? "", fileName2);
13234
13470
  const download = await this.downloadFile(realUrl, pageTitle, caption);
13235
- if (!download.localPath) continue;
13471
+ if (!download.localPath) {
13472
+ result = keepLargeFile(result, match[0], download, pageId, blockId);
13473
+ continue;
13474
+ }
13236
13475
  downloads.push(download);
13237
13476
  const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
13238
13477
  result = result.replace(match[0], () => obsidianLink);
@@ -13283,18 +13522,21 @@ var ImageHandler = class {
13283
13522
  const contentLength = response.headers.get("content-length");
13284
13523
  const MAX_FILE_SIZE = this.maxFileSizeBytes;
13285
13524
  if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
13286
- const sizeMB = Math.round(parseInt(contentLength, 10) / 1024 / 1024);
13525
+ const bytes = parseInt(contentLength, 10);
13287
13526
  getLogger().info(
13288
- `[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}`
13289
13528
  );
13290
- return { originalUrl: url, localPath: "", hash: "", size: 0 };
13529
+ return { originalUrl: url, localPath: "", hash: "", size: 0, skippedBytes: bytes };
13291
13530
  }
13292
13531
  const buffer = Buffer.from(await response.arrayBuffer());
13293
13532
  const fileHash = createHash("sha256").update(buffer).digest("hex");
13294
13533
  const hash = nameHashOf(fileHash);
13295
13534
  const extension = this.guessFileExtension(url, response.headers.get("content-type"));
13296
13535
  const name = withoutOwnHash(caption, hash);
13297
- 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
+ );
13298
13540
  const fileName2 = `${safeName}-${hash}${extension}`;
13299
13541
  const localPath = `${this.attachmentFolder}/${fileName2}`;
13300
13542
  await this.vaultFs.ensureFolder(this.attachmentFolder);
@@ -13356,6 +13598,11 @@ var ImageHandler = class {
13356
13598
  return "";
13357
13599
  }
13358
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
+ }
13359
13606
 
13360
13607
  // src/view/notion-filter-translator.ts
13361
13608
  function translateNotionFilter(filter, schema, resolveName) {
@@ -13685,6 +13932,10 @@ function filterGroup(node) {
13685
13932
  return ["not", node.not];
13686
13933
  }
13687
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;
13688
13939
  var BaseFileGenerator = class {
13689
13940
  generate(options) {
13690
13941
  const lines = [];
@@ -13700,7 +13951,7 @@ var BaseFileGenerator = class {
13700
13951
  writeFilters(lines, folderPath) {
13701
13952
  lines.push("filters:");
13702
13953
  lines.push(" and:");
13703
- lines.push(` - file.inFolder("${folderPath}")`);
13954
+ lines.push(` - ${inFolderFilter(folderPath)}`);
13704
13955
  lines.push(` - file.ext == "md"`);
13705
13956
  lines.push("");
13706
13957
  }
@@ -14538,9 +14789,9 @@ function noteTitle(frontmatter, path) {
14538
14789
  return explicitTitle(frontmatter) ?? wikilinkTitleFromPath(path);
14539
14790
  }
14540
14791
  function isFileNameOf(title, fileName2) {
14541
- const safe = sanitizeFileName(title);
14542
- if (fileName2 === safe) return true;
14543
- 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
+ );
14544
14795
  }
14545
14796
  function followsFileName(title, path) {
14546
14797
  return isFileNameOf(title, wikilinkTitleFromPath(path));
@@ -14568,8 +14819,11 @@ function titleMayChange(base, current, from, to) {
14568
14819
  function extractTitle(filePath) {
14569
14820
  return noteNameFromPath(filePath);
14570
14821
  }
14571
- function titleProperty(title) {
14572
- 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
+ };
14573
14827
  }
14574
14828
  function extractAliases(properties) {
14575
14829
  const raw = properties.aliases ?? properties.alias;
@@ -14579,6 +14833,13 @@ function extractAliases(properties) {
14579
14833
  return raw.split(",").map((s) => s.trim()).filter(Boolean);
14580
14834
  return [];
14581
14835
  }
14836
+ function retitledInNotion(base, local, path, remoteTitle) {
14837
+ if (followsFileName(remoteTitle, path)) return false;
14838
+ const previous = base ? explicitTitle(base) : null;
14839
+ if (previous !== null && !followsFileName(previous, path)) return false;
14840
+ if (local && explicitTitle(local) !== previous) return false;
14841
+ return true;
14842
+ }
14582
14843
 
14583
14844
  // src/utils/db-row-path.ts
14584
14845
  function selectDbRowFiles(files, localFolder) {
@@ -14589,10 +14850,14 @@ function selectDbRowFiles(files, localFolder) {
14589
14850
  return rel.length > 0 && !rel.includes("/");
14590
14851
  });
14591
14852
  }
14853
+ var PAGE_ID_SUFFIX_LENGTHS = [8, 16, 32];
14592
14854
  function pagePathCandidates(dir, safeName, pageId) {
14593
14855
  const base = dir ? `${dir}/${safeName}` : safeName;
14594
14856
  const raw = pageId.replace(/-/g, "");
14595
- 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
+ ];
14596
14861
  }
14597
14862
  function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
14598
14863
  const ownedBySelf = (candidate) => {
@@ -14603,9 +14868,9 @@ function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
14603
14868
  const candidates = pagePathCandidates(localFolder, safeName, pageId);
14604
14869
  return candidates.find(ownedBySelf) ?? candidates[candidates.length - 1];
14605
14870
  }
14606
- function inClaimOrder(items, chosen, idOf) {
14871
+ function inClaimOrder(items, chosen, idOf2) {
14607
14872
  const slots = items.flatMap((item, index) => chosen(item) ? [index] : []);
14608
- const key = (item) => compactNotionId(idOf(item));
14873
+ const key = (item) => compactNotionId(idOf2(item));
14609
14874
  const members = slots.map((index) => items[index]).sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
14610
14875
  const ordered = [...items];
14611
14876
  slots.forEach((slot, k) => ordered[slot] = members[k]);
@@ -14960,6 +15225,8 @@ var DbBaseFiles = class {
14960
15225
  }
14961
15226
  stateDb;
14962
15227
  entries = null;
15228
+ /** 마지막으로 읽거나 쓴 메타 값 — 그 사이 다른 쪽(폴더를 옮긴 NoteRelocator)이 고쳐 적었으면 다시 읽는다. */
15229
+ loadedRaw = null;
14963
15230
  get(databaseId) {
14964
15231
  return this.load().get(compactNotionId(databaseId));
14965
15232
  }
@@ -14967,15 +15234,23 @@ var DbBaseFiles = class {
14967
15234
  const entries = this.load();
14968
15235
  const key = compactNotionId(databaseId);
14969
15236
  const known = entries.get(key);
14970
- if (known?.basePath === info.basePath && known.title === info.title) return;
14971
- entries.set(key, { basePath: info.basePath, title: info.title });
14972
- this.stateDb.setMeta(DB_BASE_FILES_META_KEY, JSON.stringify(Object.fromEntries(entries)));
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
+ });
15245
+ this.loadedRaw = JSON.stringify(Object.fromEntries(entries));
15246
+ this.stateDb.setMeta(DB_BASE_FILES_META_KEY, this.loadedRaw);
14973
15247
  }
14974
- /** 상태 메타에서 한 번 읽는다. 없거나 깨졌으면 비어 있다 — 이번 실행이 기록하는 것부터 다시 쌓는다. */
15248
+ /** 상태 메타에서 읽는다 — 값이 그대로면 읽어 둔 것을 쓴다. 없거나 깨졌으면 비어 있다 — 이번 실행이 기록하는 것부터 다시 쌓는다. */
14975
15249
  load() {
14976
- if (this.entries) return this.entries;
14977
- this.entries = /* @__PURE__ */ new Map();
14978
15250
  const raw = this.stateDb.getMeta(DB_BASE_FILES_META_KEY);
15251
+ if (this.entries && (raw === null || raw === this.loadedRaw)) return this.entries;
15252
+ this.entries = /* @__PURE__ */ new Map();
15253
+ this.loadedRaw = raw;
14979
15254
  if (!raw) return this.entries;
14980
15255
  try {
14981
15256
  const parsed = JSON.parse(raw);
@@ -14983,7 +15258,11 @@ var DbBaseFiles = class {
14983
15258
  for (const [key, value] of Object.entries(parsed)) {
14984
15259
  const entry = value;
14985
15260
  if (typeof entry?.basePath === "string" && typeof entry.title === "string") {
14986
- 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
+ });
14987
15266
  }
14988
15267
  }
14989
15268
  } catch {
@@ -14992,83 +15271,301 @@ var DbBaseFiles = class {
14992
15271
  }
14993
15272
  };
14994
15273
 
14995
- // src/converter/unknown-tag-fallback.ts
14996
- var UNKNOWN_BLOCK_TAG_RE2 = /<unknown (url|id)="([^"]*)"[^>]*\/>/g;
14997
- var UNKNOWN_MENTION_TAG_RE = /<unknown_mention\s+url="([^"]*)"[^>]*?\/>/g;
14998
- var VAULT_UNKNOWN_PAIR_RE = new RegExp(
14999
- `(?:\\[((?:\\\\.|[^\\\\\\]])*)\\]\\(([^)]*)\\))?%%${MARKER_BRAND_RE}:unknown:id=([^&]+)&type=([^%]+)%%`,
15000
- "g"
15001
- );
15002
- var CAPTION_ESCAPED_CHAR_RE = /[\\*_<>`~[\]|${}]/g;
15003
- function blockKey(attr, value) {
15004
- return (attr === "url" ? bookmarkBlockId(value) : void 0) ?? `${attr}:${value}`;
15005
- }
15006
- function mentionKey(url) {
15007
- return `mention:${url}`;
15008
- }
15009
- function unknownTagKeys(nfm) {
15010
- const keys = /* @__PURE__ */ new Set();
15011
- mapOutsideCode(nfm, (segment) => {
15012
- for (const m of segment.matchAll(UNKNOWN_BLOCK_TAG_RE2)) keys.add(blockKey(m[1], m[2]));
15013
- for (const m of segment.matchAll(UNKNOWN_MENTION_TAG_RE)) keys.add(mentionKey(m[1]));
15014
- return segment;
15015
- });
15016
- return keys;
15017
- }
15018
- function unknownTagFallbacks(vaultMarkdown) {
15019
- const fallbacks = /* @__PURE__ */ new Map();
15020
- mapOutsideCode(vaultMarkdown, (segment) => {
15021
- for (const m of segment.matchAll(VAULT_UNKNOWN_PAIR_RE)) {
15022
- const [, label, url, encodedId, type] = m;
15023
- const decoded = decodeURIComponent(encodedId);
15024
- const key = decoded.startsWith("http") ? blockKey("url", decoded) : blockKey("id", encodedId);
15025
- fallbacks.set(key, fallbackOf(label, url, decoded, type));
15026
- }
15027
- return segment;
15028
- });
15029
- return fallbacks;
15030
- }
15031
- function fallbackOf(label, url, placeholder2, type) {
15032
- if (label === void 0 || url === void 0) return "";
15033
- const bookmark = type === "bookmark" ? bookmarkOfVaultLink(label, url, placeholder2) : null;
15034
- if (bookmark === null) return `[${label}](${url})`;
15035
- const caption = bookmark.caption.replace(CAPTION_ESCAPED_CHAR_RE, "\\$&");
15036
- return `<bookmark url="${escapeAttr(bookmark.url)}">${caption}</bookmark>`;
15274
+ // src/sync/discovered-databases.ts
15275
+ var ROOT_DATABASES_FOLDER = "databases";
15276
+ var DISCOVERED_DBS_META_KEY = "discovered_dbs";
15277
+ var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
15278
+ var LINKED_DBS_META_KEY = "linked_dbs";
15279
+ function parseDiscoveredDbs(raw) {
15280
+ if (!raw) return [];
15281
+ try {
15282
+ const parsed = JSON.parse(raw);
15283
+ return Array.isArray(parsed) ? parsed : [];
15284
+ } catch {
15285
+ return [];
15286
+ }
15037
15287
  }
15038
- function escapeAttr(value) {
15039
- return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
15288
+ function loadInaccessibleDbIds(stateDb) {
15289
+ const raw = stateDb.getMeta(INACCESSIBLE_DBS_META_KEY);
15290
+ if (!raw) return /* @__PURE__ */ new Set();
15291
+ try {
15292
+ const arr = JSON.parse(raw);
15293
+ return new Set(arr.map((id) => id.replace(/-/g, "")));
15294
+ } catch {
15295
+ return /* @__PURE__ */ new Set();
15296
+ }
15040
15297
  }
15041
- function replaceMissingUnknownTags(nfm, present, fallbacks) {
15042
- let replaced = 0;
15043
- const swap = (tag, key) => {
15044
- if (present.has(key)) return tag;
15045
- replaced += 1;
15046
- return fallbacks.get(key) ?? "";
15047
- };
15048
- const out = mapOutsideCode(
15049
- nfm,
15050
- (segment) => segment.replace(
15051
- UNKNOWN_BLOCK_TAG_RE2,
15052
- (tag, attr, value) => swap(tag, blockKey(attr, value))
15053
- ).replace(UNKNOWN_MENTION_TAG_RE, (tag, url) => swap(tag, mentionKey(url)))
15054
- );
15055
- return { nfm: out, replaced };
15298
+ function loadLinkedDbMap(stateDb) {
15299
+ const raw = stateDb.getMeta(LINKED_DBS_META_KEY);
15300
+ if (!raw) return /* @__PURE__ */ new Map();
15301
+ try {
15302
+ const obj = JSON.parse(raw);
15303
+ return new Map(Object.entries(obj).map(([k, v]) => [k.replace(/-/g, ""), v.replace(/-/g, "")]));
15304
+ } catch {
15305
+ return /* @__PURE__ */ new Map();
15306
+ }
15056
15307
  }
15057
15308
 
15058
- // src/sync/page-body.ts
15059
- async function replacePageBody(client, pageId, markdown, options = {}) {
15309
+ // src/sync/db-folder-moves.ts
15310
+ function rewriteDatabaseFolders(stateDb, moves) {
15311
+ const folders = moves.filter((move) => !isMarkdownPath(move.from));
15312
+ if (folders.length === 0) return;
15313
+ const databases = parseDiscoveredDbs(stateDb.getMeta(DISCOVERED_DBS_META_KEY));
15314
+ let changed = false;
15315
+ for (const db of databases) {
15316
+ const folder = db.localFolder.replace(/\/+$/, "");
15317
+ const to = finalPath(folder, folders);
15318
+ if (to === folder) continue;
15319
+ db.localFolder = to;
15320
+ changed = true;
15321
+ }
15322
+ if (changed) stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(databases));
15323
+ const raw = stateDb.getMeta(DB_BASE_FILES_META_KEY);
15324
+ if (!raw) return;
15325
+ let entries;
15060
15326
  try {
15061
- return await client.replacePageMarkdown(pageId, markdown);
15327
+ entries = JSON.parse(raw);
15328
+ } catch {
15329
+ return;
15330
+ }
15331
+ if (!entries || typeof entries !== "object") return;
15332
+ let moved = false;
15333
+ for (const entry of Object.values(entries)) {
15334
+ if (typeof entry?.basePath !== "string") continue;
15335
+ const to = finalPath(entry.basePath, folders);
15336
+ if (to === entry.basePath) continue;
15337
+ entry.basePath = to;
15338
+ moved = true;
15339
+ }
15340
+ if (moved) stateDb.setMeta(DB_BASE_FILES_META_KEY, JSON.stringify(entries));
15341
+ }
15342
+ async function retargetBaseFiles(vaultFs, moves) {
15343
+ const folders = moves.filter((move) => !isMarkdownPath(move.from));
15344
+ if (folders.length === 0) return;
15345
+ try {
15346
+ for (const file of await vaultFs.listNonMarkdownFiles()) {
15347
+ if (!file.path.endsWith(BASE_SUFFIX)) continue;
15348
+ const content = await vaultFs.readFile(file.path);
15349
+ if (!isGeneratedDbArtifact(file.path, content)) continue;
15350
+ const next = content.replace(IN_FOLDER_FILTER_RE, (whole, folder) => {
15351
+ const to = finalPath(folder, folders);
15352
+ return to === folder ? whole : inFolderFilter(to);
15353
+ });
15354
+ if (next !== content) await vaultFs.writeFile(file.path, next);
15355
+ }
15062
15356
  } catch (error) {
15063
- if (!isNotionValidationError(error)) throw error;
15064
- const limit = pageRecordLimitOf(error);
15065
- if (limit !== null) {
15066
- throw new Error(
15067
- `Notion \uD398\uC774\uC9C0\uAC00 \uB108\uBB34 \uCEE4\uC11C \uBCF8\uBB38\uC744 \uBC14\uAFB8\uC9C0 \uBABB\uD568(\uB808\uCF54\uB4DC ${Number.isNaN(limit) ? "\uD55C\uB3C4" : `${limit}\uAC1C`} \uB118\uC74C) \u2014 Notion \uC5D0\uC11C \uD398\uC774\uC9C0\uB97C \uB098\uB204\uC138\uC694`
15068
- );
15357
+ getLogger().warn(
15358
+ `[Im-Nobsidian] \uC62E\uAE34 \uD3F4\uB354\uB97C \uAC00\uB9AC\uD0A4\uB358 .base \uBCF4\uAE30\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568: ${error instanceof Error ? error.message : String(error)}`
15359
+ );
15360
+ }
15361
+ }
15362
+ function finalPath(path, moves) {
15363
+ let current = path;
15364
+ for (const move of moves) {
15365
+ if (current === move.from || current.startsWith(`${move.from}/`)) {
15366
+ current = move.to + current.slice(move.from.length);
15069
15367
  }
15070
- const remote = (await client.getPageMarkdown(pageId)).markdown;
15071
- const sendable = options.unknownTagFallbacks ? replaceMissingUnknownTags(markdown, unknownTagKeys(remote), options.unknownTagFallbacks) : { nfm: markdown, replaced: 0 };
15368
+ }
15369
+ return current;
15370
+ }
15371
+
15372
+ // src/sync/note-relocation.ts
15373
+ var NoteRelocator = class {
15374
+ constructor(stateDb, vaultFs, configuredDatabases = []) {
15375
+ this.stateDb = stateDb;
15376
+ this.vaultFs = vaultFs;
15377
+ this.configuredDatabases = configuredDatabases;
15378
+ }
15379
+ stateDb;
15380
+ vaultFs;
15381
+ configuredDatabases;
15382
+ /**
15383
+ * 함께 옮길 것 — 폴더로 추적하는 페이지는 그 폴더, 폴더 노트(`X/X.md`)는 든 폴더, 노트는 그 파일과 옆의 자식
15384
+ * 폴더(`X/`, 있으면).
15385
+ */
15386
+ unitsOf(record) {
15387
+ if (isFolderRecord(record)) return [record.obsidianPath];
15388
+ if (record.fileType !== "file") return [parentFolderOf(record.obsidianPath)];
15389
+ const children = stripMarkdownExtension(record.obsidianPath);
15390
+ const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children));
15391
+ return hasChildren ? [record.obsidianPath, children] : [record.obsidianPath];
15392
+ }
15393
+ /** 옮기지 못할 까닭 — 없으면 null. */
15394
+ async blocked(move) {
15395
+ if (within(move.to, move.from)) return "\uC81C \uC548\uC73C\uB85C \uC62E\uAE38 \uC218 \uC5C6\uB2E4";
15396
+ if (!await this.vaultFs.exists(move.from)) return "\uBCFC\uD2B8\uC5D0 \uB178\uD2B8\uAC00 \uC5C6\uB2E4";
15397
+ if (await this.taken(move.to)) return `\uC62E\uAE38 \uC790\uB9AC(${move.to})\uC5D0 \uAC19\uC740 \uC774\uB984\uC774 \uC788\uB2E4`;
15398
+ const isFolder = !isMarkdownPath(move.from);
15399
+ if (this.vaultFs.excludesPath?.(move.to, isFolder ? "folder" : "file")) {
15400
+ return `\uC62E\uAE38 \uC790\uB9AC(${move.to})\uAC00 \uB3D9\uAE30\uD654\uC5D0\uC11C \uBE60\uC9C4\uB2E4(paths \xB7 .im-nobsidian-ignore)`;
15401
+ }
15402
+ if (isFolder && this.holdsConfiguredDatabase(move.from)) {
15403
+ return "\uD3F4\uB354 \uC548\uC5D0 \uC124\uC815 \uD30C\uC77C\uB85C \uC815\uD55C DB \uD3F4\uB354(notion.databases)\uAC00 \uC788\uB2E4 \u2014 \uC124\uC815\uC758 localFolder \uB97C \uACE0\uCE5C \uB4A4 Obsidian \uC5D0\uC11C \uC62E\uAE30\uBA74 \uB2E4\uC74C push \uAC00 Notion \uC744 \uB9DE\uCD98\uB2E4";
15404
+ }
15405
+ if (isFolder && (this.vaultFs.holdsOtherConnection?.(move.from) ?? false)) {
15406
+ return "\uD3F4\uB354 \uC548\uC5D0 \uB2E4\uB978 \uC5F0\uACB0 \uD3F4\uB354\uAC00 \uC788\uB2E4 \u2014 Obsidian \uC5D0\uC11C \uC62E\uAE30\uBA74 \uB2E4\uC74C push \uAC00 Notion \uC744 \uB9DE\uCD98\uB2E4";
15407
+ }
15408
+ return null;
15409
+ }
15410
+ /** 이 경로에 파일 · 폴더가 있거나 추적하는 것이 있다. */
15411
+ async taken(path) {
15412
+ return await this.vaultFs.exists(path) || this.stateDb.getAll().some((r) => within(r.obsidianPath, path));
15413
+ }
15414
+ /**
15415
+ * 차례로 옮기고 상태 DB 를 새 경로로 적는다. 하나라도 실패하면 옮긴 것을 되돌리고 던진다 — 상태 DB 는 옛 경로 그대로다.
15416
+ * @param alsoInTransaction 경로를 옮겨 적는 트랜잭션 안에서 함께 할 것.
15417
+ */
15418
+ async relocate(moves, alsoInTransaction) {
15419
+ const done = [];
15420
+ try {
15421
+ for (const move of moves) {
15422
+ await this.vaultFs.moveFile(move.from, move.to);
15423
+ done.push(move);
15424
+ }
15425
+ } catch (error) {
15426
+ for (const move of done.reverse())
15427
+ await this.vaultFs.moveFile(move.to, move.from).catch(() => {
15428
+ });
15429
+ throw error;
15430
+ }
15431
+ this.stateDb.transaction(() => {
15432
+ for (const move of moves) this.rewritePaths(move);
15433
+ rewriteDatabaseFolders(this.stateDb, moves);
15434
+ alsoInTransaction?.();
15435
+ });
15436
+ await retargetBaseFiles(this.vaultFs, moves);
15437
+ }
15438
+ holdsConfiguredDatabase(folder) {
15439
+ return this.configuredDatabases.some(
15440
+ (db) => within(db.localFolder.replace(/\/+$/, ""), folder)
15441
+ );
15442
+ }
15443
+ /** 옮긴 것 아래의 레코드 · 위키링크 · 보존 마커 · 첨부 기록을 새 경로로 옮겨 적는다. */
15444
+ rewritePaths(move) {
15445
+ for (const record of this.stateDb.getAll()) {
15446
+ if (!within(record.obsidianPath, move.from)) continue;
15447
+ const from = record.obsidianPath;
15448
+ const to = move.to + from.slice(move.from.length);
15449
+ this.stateDb.updatePath(record.id, to);
15450
+ const entry = record.notionPageId ? this.stateDb.resolvePageId(record.notionPageId) : null;
15451
+ if (entry && entry.obsidianPath === from) {
15452
+ this.stateDb.deleteWikilink(from);
15453
+ this.stateDb.upsertWikilink({
15454
+ obsidianPath: to,
15455
+ notionPageId: entry.notionPageId,
15456
+ title: wikilinkTitleFromPath(to),
15457
+ aliases: entry.aliases
15458
+ });
15459
+ }
15460
+ const markers = this.stateDb.getPreserveMarkers(from);
15461
+ if (markers.length > 0) {
15462
+ this.stateDb.storePreserveMarkers(to, markers);
15463
+ this.stateDb.storePreserveMarkers(from, []);
15464
+ }
15465
+ if (record.notionPageId) {
15466
+ for (const file of this.stateDb.getFilesByPageId(record.notionPageId)) {
15467
+ if (!within(file.localPath, move.from)) continue;
15468
+ this.stateDb.registerFile({
15469
+ localPath: move.to + file.localPath.slice(move.from.length),
15470
+ notionPageId: file.notionPageId,
15471
+ fileUploadId: file.fileUploadId,
15472
+ fileType: file.fileType,
15473
+ fileHash: file.fileHash,
15474
+ fileSize: file.fileSize
15475
+ });
15476
+ this.stateDb.deleteFileRegistry(file.localPath);
15477
+ }
15478
+ }
15479
+ }
15480
+ }
15481
+ };
15482
+ function within(path, unit) {
15483
+ return path === unit || path.startsWith(`${unit}/`);
15484
+ }
15485
+ function baseName2(path) {
15486
+ return path.slice(path.lastIndexOf("/") + 1);
15487
+ }
15488
+ function joinPath(folder, name) {
15489
+ return folder === "" ? name : `${folder}/${name}`;
15490
+ }
15491
+
15492
+ // src/converter/unknown-tag-fallback.ts
15493
+ var UNKNOWN_BLOCK_TAG_RE2 = /<unknown (url|id)="([^"]*)"[^>]*\/>/g;
15494
+ var UNKNOWN_MENTION_TAG_RE = /<unknown_mention\s+url="([^"]*)"[^>]*?\/>/g;
15495
+ var VAULT_UNKNOWN_PAIR_RE = new RegExp(
15496
+ `(?:\\[((?:\\\\.|[^\\\\\\]])*)\\]\\(([^)]*)\\))?%%${MARKER_BRAND_RE}:unknown:id=([^&]+)&type=([^%]+)%%`,
15497
+ "g"
15498
+ );
15499
+ var CAPTION_ESCAPED_CHAR_RE = /[\\*_<>`~[\]|${}]/g;
15500
+ function blockKey(attr, value) {
15501
+ return (attr === "url" ? bookmarkBlockId(value) : void 0) ?? `${attr}:${value}`;
15502
+ }
15503
+ function mentionKey(url) {
15504
+ return `mention:${url}`;
15505
+ }
15506
+ function unknownTagKeys(nfm) {
15507
+ const keys = /* @__PURE__ */ new Set();
15508
+ mapOutsideCode(nfm, (segment) => {
15509
+ for (const m of segment.matchAll(UNKNOWN_BLOCK_TAG_RE2)) keys.add(blockKey(m[1], m[2]));
15510
+ for (const m of segment.matchAll(UNKNOWN_MENTION_TAG_RE)) keys.add(mentionKey(m[1]));
15511
+ return segment;
15512
+ });
15513
+ return keys;
15514
+ }
15515
+ function unknownTagFallbacks(vaultMarkdown) {
15516
+ const fallbacks = /* @__PURE__ */ new Map();
15517
+ mapOutsideCode(vaultMarkdown, (segment) => {
15518
+ for (const m of segment.matchAll(VAULT_UNKNOWN_PAIR_RE)) {
15519
+ const [, label, url, encodedId, type] = m;
15520
+ const decoded = decodeURIComponent(encodedId);
15521
+ const key = decoded.startsWith("http") ? blockKey("url", decoded) : blockKey("id", encodedId);
15522
+ fallbacks.set(key, fallbackOf(label, url, decoded, type));
15523
+ }
15524
+ return segment;
15525
+ });
15526
+ return fallbacks;
15527
+ }
15528
+ function fallbackOf(label, url, placeholder2, type) {
15529
+ if (label === void 0 || url === void 0) return "";
15530
+ const bookmark = type === "bookmark" ? bookmarkOfVaultLink(label, url, placeholder2) : null;
15531
+ if (bookmark === null) return `[${label}](${url})`;
15532
+ const caption = bookmark.caption.replace(CAPTION_ESCAPED_CHAR_RE, "\\$&");
15533
+ return `<bookmark url="${escapeAttr(bookmark.url)}">${caption}</bookmark>`;
15534
+ }
15535
+ function escapeAttr(value) {
15536
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
15537
+ }
15538
+ function replaceMissingUnknownTags(nfm, present, fallbacks) {
15539
+ let replaced = 0;
15540
+ const swap = (tag, key) => {
15541
+ if (present.has(key)) return tag;
15542
+ replaced += 1;
15543
+ return fallbacks.get(key) ?? "";
15544
+ };
15545
+ const out = mapOutsideCode(
15546
+ nfm,
15547
+ (segment) => segment.replace(
15548
+ UNKNOWN_BLOCK_TAG_RE2,
15549
+ (tag, attr, value) => swap(tag, blockKey(attr, value))
15550
+ ).replace(UNKNOWN_MENTION_TAG_RE, (tag, url) => swap(tag, mentionKey(url)))
15551
+ );
15552
+ return { nfm: out, replaced };
15553
+ }
15554
+
15555
+ // src/sync/page-body.ts
15556
+ async function replacePageBody(client, pageId, markdown, options = {}) {
15557
+ try {
15558
+ return await client.replacePageMarkdown(pageId, markdown);
15559
+ } catch (error) {
15560
+ if (!isNotionValidationError(error)) throw error;
15561
+ const limit = pageRecordLimitOf(error);
15562
+ if (limit !== null) {
15563
+ throw new Error(
15564
+ `Notion \uD398\uC774\uC9C0\uAC00 \uB108\uBB34 \uCEE4\uC11C \uBCF8\uBB38\uC744 \uBC14\uAFB8\uC9C0 \uBABB\uD568(\uB808\uCF54\uB4DC ${Number.isNaN(limit) ? "\uD55C\uB3C4" : `${limit}\uAC1C`} \uB118\uC74C) \u2014 Notion \uC5D0\uC11C \uD398\uC774\uC9C0\uB97C \uB098\uB204\uC138\uC694`
15565
+ );
15566
+ }
15567
+ const remote = (await client.getPageMarkdown(pageId)).markdown;
15568
+ const sendable = options.unknownTagFallbacks ? replaceMissingUnknownTags(markdown, unknownTagKeys(remote), options.unknownTagFallbacks) : { nfm: markdown, replaced: 0 };
15072
15569
  if (sendable.replaced > 0) {
15073
15570
  getLogger().warn(
15074
15571
  `[Im-Nobsidian] Notion \uD398\uC774\uC9C0\uC5D0 \uC5C6\uB294 \uBE14\uB85D\uC744 \uAC00\uB9AC\uD0A4\uB294 \uC790\uB9AC ${sendable.replaced}\uAC1C\uB97C \uBCFC\uD2B8\uC758 \uB9C1\uD06C \xB7 \uBD81\uB9C8\uD06C\uB85C \uBCF4\uB0C4 (${pageId})`
@@ -15223,16 +15720,16 @@ var PageDecorations = class {
15223
15720
  *
15224
15721
  * @param baseName 내려받은 파일 이름의 앞부분 — 보통 노트 이름
15225
15722
  */
15226
- async pulled(page, baseName3, download) {
15723
+ async pulled(page, baseName4, download) {
15227
15724
  const out = {};
15228
15725
  const cover = this.notionClient.extractCover(page);
15229
15726
  if (cover) {
15230
- const local = download ? await this.imageHandler.localizeNotionFileUrl(cover.url, `${baseName3}-cover`) : null;
15727
+ const local = download ? await this.imageHandler.localizeNotionFileUrl(cover.url, `${baseName4}-cover`) : null;
15231
15728
  out.cover = local ? `[[${local}]]` : cover.url;
15232
15729
  }
15233
15730
  const icon = this.notionClient.extractIcon(page);
15234
15731
  if (icon) {
15235
- const local = download && icon.type === "file" ? await this.imageHandler.localizeNotionFileUrl(icon.value, `${baseName3}-icon`) : null;
15732
+ const local = download && icon.type === "file" ? await this.imageHandler.localizeNotionFileUrl(icon.value, `${baseName4}-icon`) : null;
15236
15733
  out.icon = local ? `[[${local}]]` : icon.value;
15237
15734
  }
15238
15735
  return out;
@@ -15399,6 +15896,7 @@ var DatabaseSyncer = class {
15399
15896
  this.observation = observation;
15400
15897
  this.noteRowBody = noteRowBody;
15401
15898
  this.baseFileInfo = new DbBaseFiles(stateDb);
15899
+ this.relocator = new NoteRelocator(stateDb, vaultFs, config.notion.databases ?? []);
15402
15900
  this.propertyMapper = PropertyMapper.fromConfig(config);
15403
15901
  this.propertyMapper.setWikilinkResolver({
15404
15902
  resolve: (title) => stateDb.resolveWikilink(title)?.notionPageId ?? null,
@@ -15426,9 +15924,17 @@ var DatabaseSyncer = class {
15426
15924
  */
15427
15925
  appearances = /* @__PURE__ */ new WeakMap();
15428
15926
  baseFileGenerator = new BaseFileGenerator();
15429
- /** 사용자가 만든 `.base` 라 덮어쓰지 않았다고 이미 알린 경로 — 한 pull 이 같은 DB 를 여러 번 다시 쓴다. */
15927
+ /** 사용자가 만들거나 고친 `.base` 라 덮어쓰지 않았다고 이미 알린 경로 — 한 pull 이 같은 DB 를 여러 번 다시 쓴다. */
15430
15928
  keptUserBases = /* @__PURE__ */ new Set();
15431
15929
  sidecarGenerator = new SidecarGenerator();
15930
+ /** Notion 에서 바꾼 제목으로 이름을 바꾼 행과 실제로 옮긴 차례 — pull 마감이 가져가 다른 노트의 링크를 고친다. */
15931
+ relocator;
15932
+ renamedRows = {
15933
+ renamed: [],
15934
+ applied: []
15935
+ };
15936
+ /** 볼트에서 사라져 되살린 행의 경로 — pull 마감이 가져가 화면에 알린다(D8). */
15937
+ restoredRows = [];
15432
15938
  /**
15433
15939
  * 실제로 기록한 .base 경로/DB 제목 — placeholder 임베드 재작성(F22)의 SSOT. 이번 pull 이 조회하지
15434
15940
  * 않은 DB 의 것도 남아 있다({@link DbBaseFiles}).
@@ -15546,8 +16052,10 @@ var DatabaseSyncer = class {
15546
16052
  created++;
15547
16053
  if (outcome.action === "written") writtenPaths.push(outcome.path);
15548
16054
  } else if (outcome.action === "written") {
15549
- if (action === "restore") restored++;
15550
- else updated++;
16055
+ if (action === "restore") {
16056
+ restored++;
16057
+ this.restoredRows.push(outcome.path);
16058
+ } else updated++;
15551
16059
  writtenPaths.push(outcome.path);
15552
16060
  } else if (outcome.action === "conflict") {
15553
16061
  conflicts.push(outcome.conflict);
@@ -15896,17 +16404,23 @@ var DatabaseSyncer = class {
15896
16404
  });
15897
16405
  const safeName = sanitizeFileName(dbName);
15898
16406
  const basePath = `${dbConfig.localFolder}/${safeName}${BASE_SUFFIX}`;
15899
- 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)) {
15900
16411
  if (!this.keptUserBases.has(basePath)) {
15901
16412
  this.keptUserBases.add(basePath);
15902
16413
  getLogger().info(
15903
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`
15904
16415
  );
15905
16416
  }
15906
- } 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) {
15907
16421
  await this.vaultFs.writeFile(basePath, baseContent);
15908
16422
  }
15909
- this.baseFileInfo.set(dbConfig.databaseId, { basePath, title: dbName });
16423
+ this.baseFileInfo.set(dbConfig.databaseId, { basePath, title: dbName, hash });
15910
16424
  getLogger().debug(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131: ${basePath}`);
15911
16425
  await this.generateSidecar(
15912
16426
  dbConfig,
@@ -15917,7 +16431,7 @@ var DatabaseSyncer = class {
15917
16431
  meta.isInline,
15918
16432
  await this.appearanceOf(meta, safeName)
15919
16433
  );
15920
- await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName);
16434
+ await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName, previous);
15921
16435
  } catch (error) {
15922
16436
  getLogger().warn(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
15923
16437
  }
@@ -15927,12 +16441,12 @@ var DatabaseSyncer = class {
15927
16441
  * 같은 꼴로 적고 올린 파일은 볼트로 내려받는다 — 서명 주소를 적으면 pull 마다 사이드카가 바뀐다.
15928
16442
  */
15929
16443
  appearanceOf(meta, safeName) {
15930
- let found = this.appearances.get(meta);
15931
- if (!found) {
15932
- found = this.readAppearance(meta, safeName);
15933
- this.appearances.set(meta, found);
16444
+ let found2 = this.appearances.get(meta);
16445
+ if (!found2) {
16446
+ found2 = this.readAppearance(meta, safeName);
16447
+ this.appearances.set(meta, found2);
15934
16448
  }
15935
- return found;
16449
+ return found2;
15936
16450
  }
15937
16451
  async readAppearance(meta, safeName) {
15938
16452
  const a = meta.appearance;
@@ -15949,17 +16463,20 @@ var DatabaseSyncer = class {
15949
16463
  ...a.showTitle ? {} : { showTitle: false }
15950
16464
  };
15951
16465
  }
15952
- /** pull 이 쓰지 않은 `.base` · 사이드카가 이미 있는가 — 없거나 읽지 못하면 false. */
15953
- async isUserFile(path) {
15954
- const content = await this.vaultFs.readFile(path).catch(() => null);
15955
- 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
+ );
15956
16473
  }
15957
16474
  /**
15958
16475
  * DB 제목이 바뀌면 `.base`/`.notion.json` 파일명(safeName)이 바뀌어 옛 이름의 산출물이
15959
16476
  * 같은 폴더에 고아로 잔존한다(drift·부활 원인). DB 폴더 **직속**의 산출물 중 현재 이름이
15960
16477
  * 아닌 것을 삭제해 멱등을 보장한다. 하위 폴더(중첩 DB)의 산출물은 보호한다.
15961
16478
  */
15962
- async cleanupStaleDbArtifacts(localFolder, safeName) {
16479
+ async cleanupStaleDbArtifacts(localFolder, safeName, previous) {
15963
16480
  try {
15964
16481
  const keep = /* @__PURE__ */ new Set([
15965
16482
  `${localFolder}/${safeName}${BASE_SUFFIX}`,
@@ -15968,7 +16485,12 @@ var DatabaseSyncer = class {
15968
16485
  const files = await this.vaultFs.listNonMarkdownFiles();
15969
16486
  const stale = selectStaleDbArtifacts(files, localFolder, keep);
15970
16487
  for (const path of stale) {
15971
- 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
+ }
15972
16494
  await this.vaultFs.deleteFile(path);
15973
16495
  getLogger().info(`[DB Sync] \uACE0\uC544 DB \uC0B0\uCD9C\uBB3C \uC0AD\uC81C(rename): ${path}`);
15974
16496
  }
@@ -16146,12 +16668,30 @@ var DatabaseSyncer = class {
16146
16668
  }
16147
16669
  return (path) => this.stateDb.getByPath(path) ?? (untracked.has(path) ? { notionPageId: null } : null);
16148
16670
  }
16671
+ /** 되살린 행의 경로를 가져가고 비운다. */
16672
+ takeRestoredRows() {
16673
+ const rows = this.restoredRows;
16674
+ this.restoredRows = [];
16675
+ return rows;
16676
+ }
16677
+ /** 이름을 바꾼 행을 가져가고 비운다. */
16678
+ takeRenamedRows() {
16679
+ const rows = this.renamedRows;
16680
+ this.renamedRows = { renamed: [], applied: [] };
16681
+ return rows;
16682
+ }
16149
16683
  async pullDatabasePage(page, dbConfig) {
16150
16684
  const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
16151
16685
  const existingRecord = this.stateDb.getByNotionId(page.id);
16152
16686
  const recordPath = existingRecord?.obsidianPath;
16153
16687
  let filePath;
16154
- if (recordPath && isDirectDbRowPath(dbConfig.localFolder, recordPath)) {
16688
+ const retitled = !!existingRecord && !!recordPath && this.config.sync.renameOnTitleChange && isDirectDbRowPath(dbConfig.localFolder, recordPath) && retitledInNotion(
16689
+ snapshotFrontmatter(existingRecord.baseSnapshot),
16690
+ null,
16691
+ recordPath,
16692
+ this.notionClient.extractTitle(page)
16693
+ ) && !this.stateDb.getIncompleteOpByState(existingRecord.id, "move");
16694
+ if (recordPath && isDirectDbRowPath(dbConfig.localFolder, recordPath) && !retitled) {
16155
16695
  filePath = recordPath;
16156
16696
  } else {
16157
16697
  filePath = resolveDbRowPath(
@@ -16161,6 +16701,22 @@ var DatabaseSyncer = class {
16161
16701
  await this.rowPathOwners(dbConfig.localFolder, safeName, page.id)
16162
16702
  );
16163
16703
  }
16704
+ let childFolderMove = null;
16705
+ if (retitled) {
16706
+ const from = stripMarkdownExtension(recordPath);
16707
+ if (this.stateDb.getAll().some((r) => within(r.obsidianPath, from))) {
16708
+ childFolderMove = { from, to: stripMarkdownExtension(filePath) };
16709
+ const blocked = await this.relocator.blocked(childFolderMove);
16710
+ if (blocked) {
16711
+ getLogger().warn(
16712
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uBC14\uAFBC \uC81C\uBAA9\uC73C\uB85C \uD589 \uC774\uB984\uC744 \uBC14\uAFB8\uC9C0 \uC54A\uC74C (${recordPath}): \uD558\uC704 \uD398\uC774\uC9C0 \uD3F4\uB354 \u2014 ${blocked}`
16713
+ );
16714
+ filePath = recordPath;
16715
+ childFolderMove = null;
16716
+ }
16717
+ }
16718
+ }
16719
+ const renaming = retitled && filePath !== recordPath;
16164
16720
  const {
16165
16721
  content: finalContent,
16166
16722
  title,
@@ -16240,6 +16796,11 @@ var DatabaseSyncer = class {
16240
16796
  if (rehomed) {
16241
16797
  this.stateDb.updatePath(existingRecord.id, filePath);
16242
16798
  this.stateDb.deleteWikilink(recordPath);
16799
+ const markers = this.stateDb.getPreserveMarkers(recordPath);
16800
+ if (markers.length > 0) {
16801
+ this.stateDb.storePreserveMarkers(filePath, markers);
16802
+ this.stateDb.storePreserveMarkers(recordPath, []);
16803
+ }
16243
16804
  }
16244
16805
  this.stateDb.upsert({
16245
16806
  obsidianPath: filePath,
@@ -16267,6 +16828,17 @@ var DatabaseSyncer = class {
16267
16828
  await this.vaultFs.deleteFile(recordPath);
16268
16829
  } catch {
16269
16830
  }
16831
+ if (renaming) {
16832
+ if (childFolderMove) await this.relocator.relocate([childFolderMove]);
16833
+ this.renamedRows.renamed.push({ from: recordPath, to: filePath, renamed: true });
16834
+ this.renamedRows.applied.push(
16835
+ { from: recordPath, to: filePath },
16836
+ ...childFolderMove ? [childFolderMove] : []
16837
+ );
16838
+ getLogger().info(
16839
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uBC14\uAFBC \uC81C\uBAA9\uC73C\uB85C \uD589 \uC774\uB984\uC744 \uBC14\uAFC8: ${recordPath} \u2192 ${filePath}`
16840
+ );
16841
+ }
16270
16842
  }
16271
16843
  return { action: "written", path: filePath };
16272
16844
  }
@@ -16274,6 +16846,9 @@ var DatabaseSyncer = class {
16274
16846
  function rowProperties(page) {
16275
16847
  return page.properties ?? {};
16276
16848
  }
16849
+ function editedSince(previous, path, content) {
16850
+ return previous?.basePath === path && previous.hash !== void 0 && computeHash(content) !== previous.hash;
16851
+ }
16277
16852
 
16278
16853
  // src/audit/completeness.ts
16279
16854
  function groupLocalRowsByDatabase(local) {
@@ -16370,6 +16945,40 @@ async function verifyPageCompleteness(remote, local, rootPageId) {
16370
16945
  };
16371
16946
  }
16372
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
+
16373
16982
  // src/conflict/merger.ts
16374
16983
  function threeWayMerge(base, local, remote) {
16375
16984
  const baseLines = base.split("\n");
@@ -16784,6 +17393,9 @@ var ScopedVaultFS = class {
16784
17393
  async ensureFolder(path) {
16785
17394
  return await this.base.ensureFolder(this.toBase(path, true));
16786
17395
  }
17396
+ async removeEmptyFolder(path) {
17397
+ return await this.base.removeEmptyFolder?.(this.toBase(path, true)) ?? false;
17398
+ }
16787
17399
  /** 맡은 노트만 읽는다 — 볼트 전체의 본문을 읽지 않는다. */
16788
17400
  async listMarkdownFiles() {
16789
17401
  const stats = await this.listMarkdownFileStats();
@@ -16861,7 +17473,7 @@ function nextFullReconcileAt(lastFullPullAt, intervalSec) {
16861
17473
  const last = lastFullPullAt === null ? NaN : Date.parse(lastFullPullAt);
16862
17474
  return Number.isFinite(last) ? new Date(last + intervalSec * 1e3).toISOString() : null;
16863
17475
  }
16864
- function remoteScanInfo(scan, lastFullPullAt, deleteSync) {
17476
+ function remoteScanInfo(scan, lastFullPullAt, deleteSync, deletionsChecked = false) {
16865
17477
  return scan.kind === "full" ? {
16866
17478
  kind: "full",
16867
17479
  reason: scan.reason,
@@ -16872,7 +17484,7 @@ function remoteScanInfo(scan, lastFullPullAt, deleteSync) {
16872
17484
  kind: "incremental",
16873
17485
  lastFullAt: scan.lastFullAt,
16874
17486
  nextFullAt: scan.nextFullAt,
16875
- deletionsDeferred: deleteSync
17487
+ deletionsDeferred: deleteSync && !deletionsChecked
16876
17488
  };
16877
17489
  }
16878
17490
  var ALL_DATABASES = { kind: "all" };
@@ -16894,6 +17506,14 @@ function parsePendingDatabases(raw) {
16894
17506
  function serializePendingDatabases(ids) {
16895
17507
  return JSON.stringify([...ids].sort());
16896
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
+ }
16897
17517
  var DatabasePullLedger = class {
16898
17518
  constructor(selection, pending) {
16899
17519
  this.selection = selection;
@@ -17255,8 +17875,7 @@ var InterruptedSyncRecovery = class {
17255
17875
  if (isNotionObjectNotFound(error)) continue;
17256
17876
  throw error;
17257
17877
  }
17258
- const inTrash = page.in_trash === true;
17259
- if (inTrash || page.archived) continue;
17878
+ if (isTrashedOrArchived(page)) continue;
17260
17879
  return page;
17261
17880
  }
17262
17881
  return null;
@@ -17275,8 +17894,7 @@ var InterruptedSyncRecovery = class {
17275
17894
  });
17276
17895
  for (const row of rows) {
17277
17896
  if (this.stateDb.getByNotionId(row.id)) continue;
17278
- const inTrash = row.in_trash === true;
17279
- if (inTrash || row.archived) continue;
17897
+ if (isTrashedOrArchived(row)) continue;
17280
17898
  return row;
17281
17899
  }
17282
17900
  return null;
@@ -17391,41 +18009,6 @@ function dropEmbeddedBaseMarkers(content, basePaths) {
17391
18009
  return kept.length === lines.length ? content : kept.join("\n");
17392
18010
  }
17393
18011
 
17394
- // src/sync/discovered-databases.ts
17395
- var ROOT_DATABASES_FOLDER = "databases";
17396
- var DISCOVERED_DBS_META_KEY = "discovered_dbs";
17397
- var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
17398
- var LINKED_DBS_META_KEY = "linked_dbs";
17399
- function parseDiscoveredDbs(raw) {
17400
- if (!raw) return [];
17401
- try {
17402
- const parsed = JSON.parse(raw);
17403
- return Array.isArray(parsed) ? parsed : [];
17404
- } catch {
17405
- return [];
17406
- }
17407
- }
17408
- function loadInaccessibleDbIds(stateDb) {
17409
- const raw = stateDb.getMeta(INACCESSIBLE_DBS_META_KEY);
17410
- if (!raw) return /* @__PURE__ */ new Set();
17411
- try {
17412
- const arr = JSON.parse(raw);
17413
- return new Set(arr.map((id) => id.replace(/-/g, "")));
17414
- } catch {
17415
- return /* @__PURE__ */ new Set();
17416
- }
17417
- }
17418
- function loadLinkedDbMap(stateDb) {
17419
- const raw = stateDb.getMeta(LINKED_DBS_META_KEY);
17420
- if (!raw) return /* @__PURE__ */ new Map();
17421
- try {
17422
- const obj = JSON.parse(raw);
17423
- return new Map(Object.entries(obj).map(([k, v]) => [k.replace(/-/g, ""), v.replace(/-/g, "")]));
17424
- } catch {
17425
- return /* @__PURE__ */ new Map();
17426
- }
17427
- }
17428
-
17429
18012
  // src/sync/database-discovery.ts
17430
18013
  var DISCOVERY_RETRY_META_KEY = "discovery_retry";
17431
18014
  var DISCOVERY_SCANNED_META_KEY = "discovery_scanned_at";
@@ -17613,8 +18196,8 @@ var DatabaseDiscovery = class {
17613
18196
  ...this.stateDb.getAll().filter((r) => r.notionPageId).map((r) => r.notionPageId)
17614
18197
  ].filter((id) => !this._bodyReadPages.has(normalizeNotionId(id))) : retry.parents;
17615
18198
  if (scanIds.length > 0) {
17616
- const { found, unread } = await this.discoverChildDatabases(scanIds);
17617
- for (const { dbId, parentPageId } of found) {
18199
+ const { found: found2, unread } = await this.discoverChildDatabases(scanIds);
18200
+ for (const { dbId, parentPageId } of found2) {
17618
18201
  await register(dbId, parentPageId);
17619
18202
  }
17620
18203
  for (const { pageId, error } of unread) unreadParent(pageId, error);
@@ -17741,7 +18324,7 @@ var DatabaseDiscovery = class {
17741
18324
  return { created, updated, deleted, restored };
17742
18325
  }
17743
18326
  async discoverChildDatabases(pageIds) {
17744
- const found = [];
18327
+ const found2 = [];
17745
18328
  const unread = [];
17746
18329
  const seen = /* @__PURE__ */ new Set();
17747
18330
  for (const parentId of pageIds) {
@@ -17750,7 +18333,7 @@ var DatabaseDiscovery = class {
17750
18333
  try {
17751
18334
  const dbIds = await this.notionClient.getChildDatabaseIds(parentId);
17752
18335
  for (const dbId of dbIds) {
17753
- found.push({ dbId, parentPageId: parentId });
18336
+ found2.push({ dbId, parentPageId: parentId });
17754
18337
  }
17755
18338
  } catch (error) {
17756
18339
  if (isNotionObjectNotFound(error) || isNotionAccessDenied(error)) continue;
@@ -17760,7 +18343,7 @@ var DatabaseDiscovery = class {
17760
18343
  });
17761
18344
  }
17762
18345
  }
17763
- return { found, unread };
18346
+ return { found: found2, unread };
17764
18347
  }
17765
18348
  /**
17766
18349
  * 자동 발견된 child_database 1개를 DatabaseSyncer 설정으로 변환한다.
@@ -17810,7 +18393,7 @@ var DatabaseDiscovery = class {
17810
18393
  }
17811
18394
  let safeName;
17812
18395
  if (dbTitle) {
17813
- safeName = dbTitle.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-");
18396
+ safeName = databaseFolderName(dbTitle);
17814
18397
  } else {
17815
18398
  const parentName = parentFolder.split("/").pop() || "";
17816
18399
  safeName = parentName ? `${parentName}-DB` : `db-${dbId.replace(/-/g, "").slice(0, 8)}`;
@@ -17903,6 +18486,12 @@ var DatabaseDiscovery = class {
17903
18486
  return refs;
17904
18487
  }
17905
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
+ }
17906
18495
 
17907
18496
  // src/sync/folder-placement.ts
17908
18497
  var FolderPlacement = class {
@@ -18134,13 +18723,13 @@ var FolderPlacement = class {
18134
18723
  if (parent?.kind !== "page") throw new Error(this.folderNotReady(parentPath, "\uB9CC\uB4E4\uC9C0"));
18135
18724
  parentId = parent.pageId;
18136
18725
  }
18137
- const found = await this.recovery.findChildPageByTitle(parentId, folderName);
18138
- const folderPage = found ?? await this.notionClient.createPage({
18726
+ const found2 = await this.recovery.findChildPageByTitle(parentId, folderName);
18727
+ const folderPage = found2 ?? await this.notionClient.createPage({
18139
18728
  parentId,
18140
18729
  parentType: "page",
18141
18730
  title: folderName
18142
18731
  });
18143
- const bodyFingerprint = found ? await this.drift.remoteBodyFingerprintOf(found.id) : remoteBodyFingerprint("");
18732
+ const bodyFingerprint = found2 ? await this.drift.remoteBodyFingerprintOf(found2.id) : remoteBodyFingerprint("");
18144
18733
  this.stateDb.upsert({
18145
18734
  obsidianPath: folderPath,
18146
18735
  notionPageId: folderPage.id,
@@ -18414,7 +19003,7 @@ var LocalPlanner = class {
18414
19003
  * 쓴다. Notion 에 반영할 것은 이동 WAL 이 «마지막으로 반영한 경로» 로 적고, 반영을 마치면
18415
19004
  * 지운다(pushMove). 반영하기 전에 그 자리로 되돌아오면 반영할 것이 없어 바로 지운다.
18416
19005
  */
18417
- adoptLocalMoves(plan) {
19006
+ async adoptLocalMoves(plan) {
18418
19007
  const { adoptions } = plan.scan;
18419
19008
  const moved = adoptions.length + plan.folderMoves.length + plan.databaseFolderMoves.length;
18420
19009
  if (moved === 0 && plan.settledMoveOps.length === 0 && isEmptyRenameHints(plan.hints)) return;
@@ -18447,9 +19036,7 @@ var LocalPlanner = class {
18447
19036
  const record = this.stateDb.getByPath(move.from);
18448
19037
  if (record) this.moveRecord(record, move.to);
18449
19038
  }
18450
- if (plan.databaseFolderMoves.length > 0) {
18451
- this.remapDiscoveredDbFolders(plan.databaseFolderMoves);
18452
- }
19039
+ rewriteDatabaseFolders(this.stateDb, [...plan.folderMoves, ...plan.databaseFolderMoves]);
18453
19040
  if (!isEmptyRenameHints(plan.hints)) {
18454
19041
  this.writeRenameHints(pruneRenameHints(this.renameHints(), plan.hints));
18455
19042
  }
@@ -18459,6 +19046,7 @@ var LocalPlanner = class {
18459
19046
  `[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8 ${adoptions.length}\uAC74 \xB7 \uD3F4\uB354 ${plan.folderMoves.length + plan.databaseFolderMoves.length}\uAC74\uC744 \uC0C1\uD0DC\uC5D0 \uC62E\uACA8 \uC801\uC74C`
18460
19047
  );
18461
19048
  }
19049
+ await retargetBaseFiles(this.vaultFs, [...plan.folderMoves, ...plan.databaseFolderMoves]);
18462
19050
  for (const move of plan.databaseFolderMoves) {
18463
19051
  getLogger().info(
18464
19052
  `[Im-Nobsidian] DB \uD3F4\uB354\uB97C \uC62E\uAE40: ${move.from} \u2192 ${move.to} \u2014 \uBCFC\uD2B8 \uCABD \uC790\uB9AC\uB9CC \uBC14\uB010\uB2E4. Notion \uC758 DB \uB294 \uC62E\uAE30\uAC70\uB098 \uC774\uB984\uC744 \uBC14\uAFB8\uC9C0 \uC54A\uB294\uB2E4`
@@ -18590,19 +19178,6 @@ var LocalPlanner = class {
18590
19178
  this.stateDb.storePreserveMarkers(from, []);
18591
19179
  }
18592
19180
  }
18593
- /** 옮겨진 자동 발견 DB 폴더를 설정에 옮겨 적는다 — pull 이 행을 새 폴더에 쓴다. */
18594
- remapDiscoveredDbFolders(moves) {
18595
- const target = new Map(moves.map((move) => [move.from, move.to]));
18596
- const configs = parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY));
18597
- let changed = false;
18598
- for (const config of configs) {
18599
- const to = target.get(config.localFolder.replace(/\/+$/, ""));
18600
- if (to === void 0) continue;
18601
- config.localFolder = to;
18602
- changed = true;
18603
- }
18604
- if (changed) this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(configs));
18605
- }
18606
19181
  };
18607
19182
 
18608
19183
  // src/sync/remote-moves.ts
@@ -18620,11 +19195,13 @@ var RemoteMoveFollower = class {
18620
19195
  this.stateDb = stateDb;
18621
19196
  this.vaultFs = vaultFs;
18622
19197
  this.folderLookup = folderLookup;
19198
+ this.relocator = new NoteRelocator(stateDb, vaultFs, config.notion.databases ?? []);
18623
19199
  }
18624
19200
  config;
18625
19201
  stateDb;
18626
19202
  vaultFs;
18627
19203
  folderLookup;
19204
+ relocator;
18628
19205
  /**
18629
19206
  * @param moved 페이지 id → Notion 의 새 부모 페이지 id.
18630
19207
  * @param options.dryRun 옮길 자리만 정하고 쓰지 않는다.
@@ -18664,33 +19241,18 @@ var RemoteMoveFollower = class {
18664
19241
  }
18665
19242
  const folder = this.parentFolder(parentId);
18666
19243
  if (folder === null) return skip("Notion \uC758 \uC0C8 \uBD80\uBAA8\uAC00 \uBCFC\uD2B8\uC5D0 \uC5C6\uB2E4");
18667
- const units = this.unitsOf(record);
19244
+ const units = this.relocator.unitsOf(record);
18668
19245
  const moves = units.map((from) => ({ from, to: joinPath(folder, baseName2(from)) }));
18669
19246
  if (moves.every((move) => move.from === move.to)) {
18670
19247
  if (!dryRun) this.stateDb.setNotionParentId(record.id, parentId);
18671
19248
  return { moves: [] };
18672
19249
  }
18673
19250
  for (const move of moves) {
18674
- const blocked = await this.blocked(move);
19251
+ const blocked = await this.relocator.blocked(move);
18675
19252
  if (blocked) return skip(blocked);
18676
19253
  }
18677
19254
  if (dryRun) return { moves };
18678
- const done = [];
18679
- try {
18680
- for (const move of moves) {
18681
- await this.vaultFs.moveFile(move.from, move.to);
18682
- done.push(move);
18683
- }
18684
- } catch (error) {
18685
- for (const move of done.reverse())
18686
- await this.vaultFs.moveFile(move.to, move.from).catch(() => {
18687
- });
18688
- throw error;
18689
- }
18690
- this.stateDb.transaction(() => {
18691
- for (const move of moves) this.rewritePaths(move);
18692
- this.stateDb.setNotionParentId(record.id, parentId);
18693
- });
19255
+ await this.relocator.relocate(moves, () => this.stateDb.setNotionParentId(record.id, parentId));
18694
19256
  for (const move of moves) {
18695
19257
  getLogger().info(
18696
19258
  `[Im-Nobsidian] Notion \uC5D0\uC11C \uC62E\uAE34 \uD398\uC774\uC9C0\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE40: ${move.from} \u2192 ${move.to}`
@@ -18704,98 +19266,356 @@ var RemoteMoveFollower = class {
18704
19266
  const parent = this.stateDb.getByNotionId(parentId);
18705
19267
  return parent ? childFolderOf(parent, this.folderLookup()) : null;
18706
19268
  }
18707
- /**
18708
- * 함께 옮길 것 — 폴더로 추적하는 페이지는 그 폴더, 폴더 노트(`X/X.md`)는 든 폴더, 노트는 그 파일과 옆의 자식
18709
- * 폴더(`X/`, 있으면).
18710
- */
18711
- unitsOf(record) {
18712
- if (isFolderRecord(record)) return [record.obsidianPath];
18713
- if (record.fileType !== "file") return [parentFolderOf(record.obsidianPath)];
18714
- const children = stripMarkdownExtension(record.obsidianPath);
18715
- const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children));
18716
- return hasChildren ? [record.obsidianPath, children] : [record.obsidianPath];
19269
+ };
19270
+
19271
+ // src/sync/remote-renames.ts
19272
+ var RemoteRenameFollower = class {
19273
+ constructor(stateDb, vaultFs, notionClient, pageOf, configuredDatabases = []) {
19274
+ this.stateDb = stateDb;
19275
+ this.vaultFs = vaultFs;
19276
+ this.notionClient = notionClient;
19277
+ this.pageOf = pageOf;
19278
+ this.relocator = new NoteRelocator(stateDb, vaultFs, configuredDatabases);
19279
+ }
19280
+ stateDb;
19281
+ vaultFs;
19282
+ notionClient;
19283
+ pageOf;
19284
+ relocator;
19285
+ async follow(changes, options = {}) {
19286
+ const renamed = [];
19287
+ const skipped = [];
19288
+ const applied = [];
19289
+ for (const change of changes) {
19290
+ if (change.type !== "modified") continue;
19291
+ const record = this.stateDb.getByNotionId(change.pageId);
19292
+ if (!record || !RENAMABLE.has(record.fileType)) continue;
19293
+ if (this.vaultFs.excludesPath?.(record.obsidianPath)) continue;
19294
+ try {
19295
+ const outcome = await this.followOne(record, options.dryRun === true);
19296
+ if (outcome === null) continue;
19297
+ if ("reason" in outcome) {
19298
+ skipped.push(outcome);
19299
+ getLogger().warn(
19300
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uBC14\uAFBC \uC81C\uBAA9\uC73C\uB85C \uB178\uD2B8 \uC774\uB984\uC744 \uBC14\uAFB8\uC9C0 \uC54A\uC74C (${outcome.path}): ${outcome.reason}`
19301
+ );
19302
+ continue;
19303
+ }
19304
+ renamed.push(outcome.renamed);
19305
+ applied.push(...outcome.moves);
19306
+ } catch (error) {
19307
+ skipped.push({
19308
+ path: record.obsidianPath,
19309
+ reason: `\uC774\uB984\uC744 \uBC14\uAFB8\uC9C0 \uBABB\uD568: ${error instanceof Error ? error.message : String(error)}`
19310
+ });
19311
+ }
19312
+ }
19313
+ return { renamed, skipped, applied };
19314
+ }
19315
+ /** 바꿀 것이 없으면 null. */
19316
+ async followOne(record, dryRun) {
19317
+ const page = await this.pageOf(record.notionPageId);
19318
+ if (isTrashedOrArchived(page)) return null;
19319
+ const title = this.notionClient.extractTitle(page);
19320
+ if (!retitledInNotion(...await this.frontmatters(record), record.obsidianPath, title)) {
19321
+ return null;
19322
+ }
19323
+ const skip = (reason) => ({ path: record.obsidianPath, reason });
19324
+ if (this.stateDb.getIncompleteOpByState(record.id, "move")) {
19325
+ return skip(
19326
+ "\uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE34 \uAC83\uC744 \uC544\uC9C1 \uC62C\uB9AC\uC9C0 \uC54A\uC558\uB2E4 \u2014 \uB2E4\uC74C push \uAC00 Notion \uC744 \uBCFC\uD2B8\uC758 \uC790\uB9AC\uB85C \uC62E\uAE34\uB2E4"
19327
+ );
19328
+ }
19329
+ const moves = await this.freeMoves(record, sanitizeFileName(title));
19330
+ if (moves === null) return skip("\uC0C8 \uC774\uB984\uC758 \uC790\uB9AC\uAC00 \uBAA8\uB450 \uCC28 \uC788\uB2E4");
19331
+ for (const move of moves) {
19332
+ const blocked = await this.relocator.blocked(move);
19333
+ if (blocked) return skip(blocked);
19334
+ }
19335
+ const renamed = { from: record.obsidianPath, to: finalPath2(record.obsidianPath, moves) };
19336
+ if (dryRun) return { renamed: { ...renamed, renamed: true }, moves: [] };
19337
+ await this.relocator.relocate(moves, () => {
19338
+ const entry = this.stateDb.resolvePageId(record.notionPageId);
19339
+ if (entry) this.stateDb.upsertWikilink({ ...entry, obsidianPath: renamed.to, title });
19340
+ });
19341
+ getLogger().info(
19342
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uBC14\uAFBC \uC81C\uBAA9\uC73C\uB85C \uB178\uD2B8 \uC774\uB984\uC744 \uBC14\uAFC8: ${renamed.from} \u2192 ${renamed.to}`
19343
+ );
19344
+ return { renamed: { ...renamed, renamed: true }, moves };
19345
+ }
19346
+ /** 지난 동기화 때와 지금 노트의 frontmatter — 폴더는 둘 다 없다. */
19347
+ async frontmatters(record) {
19348
+ if (isFolderRecord(record)) return [null, null];
19349
+ const base = snapshotFrontmatter(record.baseSnapshot);
19350
+ try {
19351
+ return [base, parseFrontmatter(await this.vaultFs.readFile(record.obsidianPath)).data];
19352
+ } catch {
19353
+ return [base, null];
19354
+ }
19355
+ }
19356
+ /**
19357
+ * 새 이름으로 옮길 차례 — 새 페이지를 받을 때처럼 자연 이름부터, 차 있으면 id 접미사를 붙인 이름. 모두 차 있으면 null.
19358
+ * 폴더 노트는 노트 이름을 먼저 바꾸고 폴더를 바꾼다 — 거꾸로 하면 두 번째 이동의 출발점이 아직 없다.
19359
+ */
19360
+ async freeMoves(record, safeName) {
19361
+ for (const candidate of pagePathCandidates("", safeName, record.notionPageId)) {
19362
+ const name = stripMarkdownExtension(candidate);
19363
+ const moves = this.movesTo(record, name);
19364
+ let free = true;
19365
+ for (const move of moves) {
19366
+ if (move.from !== move.to && await this.relocator.taken(move.to)) free = false;
19367
+ }
19368
+ if (free) return moves.filter((move) => move.from !== move.to);
19369
+ }
19370
+ return null;
19371
+ }
19372
+ movesTo(record, name) {
19373
+ const path = record.obsidianPath;
19374
+ if (isFolderRecord(record)) {
19375
+ return [{ from: path, to: joinPath(parentFolderOf(path), name) }];
19376
+ }
19377
+ if (record.fileType === "folder-note") {
19378
+ const folder2 = parentFolderOf(path);
19379
+ return [
19380
+ { from: path, to: joinPath(folder2, `${name}.md`) },
19381
+ { from: folder2, to: joinPath(parentFolderOf(folder2), name) }
19382
+ ];
19383
+ }
19384
+ const [note, children] = this.relocator.unitsOf(record);
19385
+ const folder = parentFolderOf(path);
19386
+ const moves = [{ from: note, to: joinPath(folder, `${name}.md`) }];
19387
+ if (children) moves.push({ from: children, to: joinPath(folder, name) });
19388
+ return moves;
19389
+ }
19390
+ };
19391
+ var RENAMABLE = /* @__PURE__ */ new Set([
19392
+ "file",
19393
+ "folder-note",
19394
+ "folder-only"
19395
+ ]);
19396
+ function finalPath2(path, moves) {
19397
+ let current = path;
19398
+ for (const move of moves) {
19399
+ if (current === move.from || current.startsWith(`${move.from}/`)) {
19400
+ current = move.to + current.slice(move.from.length);
19401
+ }
19402
+ }
19403
+ return current;
19404
+ }
19405
+
19406
+ // src/utils/link-rewrite.ts
19407
+ var WIKILINK = /(!?)\[\[([^[\]\n]+?)\]\]/g;
19408
+ var MARKDOWN_LINK = /(!?\[[^\]\n]*\]\()(<[^>\n]+>|[^)\s<>]+)((?:\s+"[^"\n]*")?\))/g;
19409
+ var URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
19410
+ function rewriteLinks(content, source, context) {
19411
+ if (!content.includes("[[") && !content.includes("](")) return content;
19412
+ return mapOutsideCodeFences(
19413
+ content,
19414
+ (fenceFree) => mapOutsideInlineCode(
19415
+ fenceFree,
19416
+ (text) => text.replace(WIKILINK, (whole, bang, inner) => {
19417
+ const [target, rest] = splitWikiTarget(inner);
19418
+ const next = rewriteTarget(target, source, context, "wiki");
19419
+ return next === null ? whole : `${bang}[[${next}${rest}]]`;
19420
+ }).replace(MARKDOWN_LINK, (whole, head, raw, tail) => {
19421
+ const bracketed = raw.startsWith("<");
19422
+ const url = bracketed ? raw.slice(1, -1) : raw;
19423
+ if (URL_SCHEME.test(url) || url.startsWith("#")) return whole;
19424
+ const hash = url.indexOf("#");
19425
+ const target = hash === -1 ? url : url.slice(0, hash);
19426
+ let decoded;
19427
+ try {
19428
+ decoded = decodeURI(target);
19429
+ } catch {
19430
+ return whole;
19431
+ }
19432
+ const next = rewriteTarget(decoded, source, context, "markdown");
19433
+ if (next === null) return whole;
19434
+ const encoded = bracketed ? next : encodeMarkdownTarget(next, target);
19435
+ const fragment = hash === -1 ? "" : url.slice(hash);
19436
+ return `${head}${bracketed ? `<${encoded}${fragment}>` : `${encoded}${fragment}`}${tail}`;
19437
+ })
19438
+ )
19439
+ );
19440
+ }
19441
+ function splitWikiTarget(inner) {
19442
+ const cut = inner.search(/\\?\||#/);
19443
+ return cut === -1 ? [inner, ""] : [inner.slice(0, cut), inner.slice(cut)];
19444
+ }
19445
+ function rewriteTarget(target, source, context, kind) {
19446
+ const linkpath = target.trim();
19447
+ if (linkpath === "") return null;
19448
+ const style = /^\.\.?\//.test(linkpath) ? "relative" : linkpath.includes("/") ? "path" : "name";
19449
+ const resolved = style === "path" && kind === "markdown" ? resolveRelative(linkpath, source.from, context.before) ?? resolveLinkpath(linkpath, source.from, context.before) : resolveLinkpath(linkpath, source.from, context.before);
19450
+ if (resolved === null) return null;
19451
+ const moved = context.renames.get(resolved.path);
19452
+ const sourceMoved = source.from !== source.to;
19453
+ if (moved === void 0 && !(sourceMoved && resolved.relative)) return null;
19454
+ const destination = moved ?? resolved.path;
19455
+ const keepExtension = resolved.explicitExtension || !destination.endsWith(".md");
19456
+ const shown = keepExtension ? destination : destination.slice(0, -".md".length);
19457
+ let next;
19458
+ if (resolved.relative) {
19459
+ next = relativePath(folderOf4(source.to), shown);
19460
+ if (!linkpath.startsWith("./") && next.startsWith("./")) next = next.slice(2);
19461
+ } else if (style === "name") {
19462
+ const name = baseName3(shown);
19463
+ next = uniqueName(baseName3(destination), context.after) ? name : shown;
19464
+ } else {
19465
+ next = shown;
19466
+ }
19467
+ return next === linkpath ? null : next;
19468
+ }
19469
+ function resolveLinkpath(linkpath, sourcePath, files) {
19470
+ if (/^\.\.?\//.test(linkpath)) return resolveRelative(linkpath, sourcePath, files);
19471
+ const clean = linkpath.replace(/^\/+/, "");
19472
+ const candidates = withExtension(clean);
19473
+ for (const candidate of candidates) {
19474
+ if (files.has(candidate)) return found(candidate, linkpath);
18717
19475
  }
18718
- /** 옮기지 못할 까닭 — 없으면 null. */
18719
- async blocked(move) {
18720
- if (within(move.to, move.from)) return "\uC81C \uC548\uC73C\uB85C \uC62E\uAE38 \uC218 \uC5C6\uB2E4";
18721
- if (!await this.vaultFs.exists(move.from)) return "\uBCFC\uD2B8\uC5D0 \uB178\uD2B8\uAC00 \uC5C6\uB2E4";
18722
- if (await this.vaultFs.exists(move.to) || this.stateDb.getAll().some((r) => within(r.obsidianPath, move.to))) {
18723
- return `\uC62E\uAE38 \uC790\uB9AC(${move.to})\uC5D0 \uAC19\uC740 \uC774\uB984\uC774 \uC788\uB2E4`;
19476
+ for (const candidate of candidates) {
19477
+ const suffix = `/${candidate}`;
19478
+ const matches = byName(files, baseName3(candidate)).filter((file) => file.endsWith(suffix));
19479
+ if (matches.length === 1) return found(matches[0], linkpath);
19480
+ if (matches.length > 1) {
19481
+ const local = `${folderPrefix(folderOf4(sourcePath))}${candidate}`;
19482
+ return matches.includes(local) ? found(local, linkpath) : null;
18724
19483
  }
18725
- const isFolder = !isMarkdownPath(move.from);
18726
- if (this.vaultFs.excludesPath?.(move.to, isFolder ? "folder" : "file")) {
18727
- return `\uC62E\uAE38 \uC790\uB9AC(${move.to})\uAC00 \uB3D9\uAE30\uD654\uC5D0\uC11C \uBE60\uC9C4\uB2E4(paths \xB7 .im-nobsidian-ignore)`;
18728
- }
18729
- if (isFolder && this.holdsDatabaseOrConnection(move.from)) {
18730
- return "\uD3F4\uB354 \uC548\uC5D0 DB \uB098 \uB2E4\uB978 \uC5F0\uACB0\uC774 \uC788\uB2E4 \u2014 DB \uBCF4\uAE30(.base)\uAC00 \uD3F4\uB354 \uACBD\uB85C\uB97C \uD488\uB294\uB2E4. Obsidian \uC5D0\uC11C \uC62E\uAE30\uBA74 \uB2E4\uC74C push \uAC00 Notion \uC744 \uB9DE\uCD98\uB2E4";
19484
+ }
19485
+ return null;
19486
+ }
19487
+ function resolveRelative(linkpath, sourcePath, files) {
19488
+ const joined = normalizeSegments(`${folderPrefix(folderOf4(sourcePath))}${linkpath}`);
19489
+ if (joined === null) return null;
19490
+ for (const candidate of withExtension(joined)) {
19491
+ if (files.has(candidate)) return { ...found(candidate, linkpath), relative: true };
19492
+ }
19493
+ return null;
19494
+ }
19495
+ function found(path, linkpath) {
19496
+ return { path, explicitExtension: hasExtension(linkpath), relative: false };
19497
+ }
19498
+ function withExtension(linkpath) {
19499
+ return hasExtension(linkpath) ? [linkpath, `${linkpath}.md`] : [`${linkpath}.md`, linkpath];
19500
+ }
19501
+ function hasExtension(linkpath) {
19502
+ return /\.[^./]+$/.test(baseName3(linkpath));
19503
+ }
19504
+ function uniqueName(name, files) {
19505
+ return byName(files, name).length <= 1;
19506
+ }
19507
+ var nameIndexes = /* @__PURE__ */ new WeakMap();
19508
+ function byName(files, name) {
19509
+ let index = nameIndexes.get(files);
19510
+ if (!index) {
19511
+ index = /* @__PURE__ */ new Map();
19512
+ for (const file of files) {
19513
+ const key = baseName3(file);
19514
+ const list = index.get(key);
19515
+ if (list) list.push(file);
19516
+ else index.set(key, [file]);
18731
19517
  }
18732
- return null;
19518
+ nameIndexes.set(files, index);
19519
+ }
19520
+ return index.get(name) ?? [];
19521
+ }
19522
+ function normalizeSegments(path) {
19523
+ const out = [];
19524
+ for (const segment of path.split("/")) {
19525
+ if (segment === "" || segment === ".") continue;
19526
+ if (segment === "..") {
19527
+ if (out.length === 0) return null;
19528
+ out.pop();
19529
+ } else out.push(segment);
19530
+ }
19531
+ return out.join("/");
19532
+ }
19533
+ function relativePath(from, to) {
19534
+ const fromParts = from === "" ? [] : from.split("/");
19535
+ const toParts = to.split("/");
19536
+ let common = 0;
19537
+ while (common < fromParts.length && common < toParts.length - 1 && fromParts[common] === toParts[common]) {
19538
+ common++;
19539
+ }
19540
+ const up = fromParts.length - common;
19541
+ const rest = toParts.slice(common).join("/");
19542
+ return up === 0 ? `./${rest}` : `${"../".repeat(up)}${rest}`;
19543
+ }
19544
+ function encodeMarkdownTarget(next, original) {
19545
+ return original.includes("%") || next.includes(" ") ? next.replace(/ /g, "%20") : next;
19546
+ }
19547
+ function folderOf4(path) {
19548
+ const slash = path.lastIndexOf("/");
19549
+ return slash === -1 ? "" : path.slice(0, slash);
19550
+ }
19551
+ function folderPrefix(folder) {
19552
+ return folder === "" ? "" : `${folder}/`;
19553
+ }
19554
+ function baseName3(path) {
19555
+ return path.slice(path.lastIndexOf("/") + 1);
19556
+ }
19557
+
19558
+ // src/sync/link-updater.ts
19559
+ var LinkUpdater = class {
19560
+ constructor(stateDb, vaultFs) {
19561
+ this.stateDb = stateDb;
19562
+ this.vaultFs = vaultFs;
18733
19563
  }
18734
- holdsDatabaseOrConnection(folder) {
18735
- const databases = parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY));
18736
- if (databases.some((db) => within(db.localFolder.replace(/\/+$/, ""), folder))) return true;
18737
- const baseFiles = this.stateDb.getMeta(DB_BASE_FILES_META_KEY);
18738
- if (baseFiles) {
19564
+ stateDb;
19565
+ vaultFs;
19566
+ /**
19567
+ * @param moves 옮긴 차례 그대로의 이동 — 노트 하나거나 폴더 하나.
19568
+ * @returns 링크를 고친 노트.
19569
+ */
19570
+ async apply(moves) {
19571
+ if (moves.length === 0) return [];
19572
+ const notes = await this.vaultFs.listMarkdownFiles();
19573
+ const others = await this.vaultFs.listNonMarkdownFiles();
19574
+ const after = new Set([...notes, ...others].map((file) => file.path));
19575
+ const renames = /* @__PURE__ */ new Map();
19576
+ for (const path of after) {
19577
+ const before2 = undo(path, moves);
19578
+ if (before2 !== path) renames.set(before2, path);
19579
+ }
19580
+ if (renames.size === 0) return [];
19581
+ const before = new Set([...after].map((path) => undo(path, moves)));
19582
+ const context = { before, after, renames };
19583
+ const relinked = [];
19584
+ for (const note of notes) {
19585
+ const source = { from: undo(note.path, moves), to: note.path };
19586
+ const next = rewriteLinks(note.content, source, context);
19587
+ if (next === note.content) continue;
18739
19588
  try {
18740
- const entries = Object.values(
18741
- JSON.parse(baseFiles)
19589
+ await this.vaultFs.writeFile(note.path, next);
19590
+ } catch (error) {
19591
+ getLogger().warn(
19592
+ `[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568 (${note.path}): ${error instanceof Error ? error.message : String(error)}`
18742
19593
  );
18743
- if (entries.some((entry) => entry.basePath && within(entry.basePath, folder))) return true;
18744
- } catch {
19594
+ continue;
19595
+ }
19596
+ const record = this.stateDb.getByPath(note.path);
19597
+ if (record && record.contentHash === computeHash(note.content)) {
19598
+ this.stateDb.updateHash(record.id, computeHash(next), Buffer.from(next, "utf-8"));
19599
+ const stat2 = await this.vaultFs.getFileStat(note.path);
19600
+ if (stat2) this.stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
18745
19601
  }
19602
+ relinked.push(note.path);
18746
19603
  }
18747
- if (this.stateDb.getAll().some((r) => r.fileType === "db-row" && within(r.obsidianPath, folder))) {
18748
- return true;
19604
+ if (relinked.length > 0) {
19605
+ getLogger().info(`[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE68: \uB178\uD2B8 ${relinked.length}`);
18749
19606
  }
18750
- return this.vaultFs.holdsOtherConnection?.(folder) ?? false;
19607
+ return relinked;
18751
19608
  }
18752
- /** 옮긴 것 아래의 레코드 · 위키링크 · 보존 마커 · 첨부 기록을 새 경로로 옮겨 적는다. */
18753
- rewritePaths(move) {
18754
- for (const record of this.stateDb.getAll()) {
18755
- if (!within(record.obsidianPath, move.from)) continue;
18756
- const from = record.obsidianPath;
18757
- const to = move.to + from.slice(move.from.length);
18758
- this.stateDb.updatePath(record.id, to);
18759
- const entry = record.notionPageId ? this.stateDb.resolvePageId(record.notionPageId) : null;
18760
- if (entry && entry.obsidianPath === from) {
18761
- this.stateDb.deleteWikilink(from);
18762
- this.stateDb.upsertWikilink({
18763
- obsidianPath: to,
18764
- notionPageId: entry.notionPageId,
18765
- title: wikilinkTitleFromPath(to),
18766
- aliases: entry.aliases
18767
- });
18768
- }
18769
- const markers = this.stateDb.getPreserveMarkers(from);
18770
- if (markers.length > 0) {
18771
- this.stateDb.storePreserveMarkers(to, markers);
18772
- this.stateDb.storePreserveMarkers(from, []);
18773
- }
18774
- if (record.notionPageId) {
18775
- for (const file of this.stateDb.getFilesByPageId(record.notionPageId)) {
18776
- if (!within(file.localPath, move.from)) continue;
18777
- this.stateDb.registerFile({
18778
- localPath: move.to + file.localPath.slice(move.from.length),
18779
- notionPageId: file.notionPageId,
18780
- fileUploadId: file.fileUploadId,
18781
- fileType: file.fileType,
18782
- fileHash: file.fileHash,
18783
- fileSize: file.fileSize
18784
- });
18785
- this.stateDb.deleteFileRegistry(file.localPath);
18786
- }
18787
- }
19609
+ };
19610
+ function undo(path, moves) {
19611
+ let current = path;
19612
+ for (let i = moves.length - 1; i >= 0; i--) {
19613
+ const move = moves[i];
19614
+ if (current === move.to || current.startsWith(`${move.to}/`)) {
19615
+ current = move.from + current.slice(move.to.length);
18788
19616
  }
18789
19617
  }
18790
- };
18791
- function within(path, unit) {
18792
- return path === unit || path.startsWith(`${unit}/`);
18793
- }
18794
- function baseName2(path) {
18795
- return path.slice(path.lastIndexOf("/") + 1);
18796
- }
18797
- function joinPath(folder, name) {
18798
- return folder === "" ? name : `${folder}/${name}`;
19618
+ return current;
18799
19619
  }
18800
19620
 
18801
19621
  // src/sync/change-inspector.ts
@@ -19518,8 +20338,8 @@ var DatabaseCreator = class {
19518
20338
  * @param includeEmpty 새 노트가 없는 DB 폴더(행이 없는 DB)도 — 범위를 좁힌 push 는 범위 밖을 만들지 않는다.
19519
20339
  */
19520
20340
  async newDatabaseFolders(changes, lookup, includeEmpty) {
19521
- const found = /* @__PURE__ */ new Map();
19522
- if (!this.enabled) return found;
20341
+ const found2 = /* @__PURE__ */ new Map();
20342
+ if (!this.enabled) return found2;
19523
20343
  const candidates = /* @__PURE__ */ new Set();
19524
20344
  for (const change of changes) {
19525
20345
  if (change.type !== "created" && change.type !== "moved") continue;
@@ -19527,22 +20347,22 @@ var DatabaseCreator = class {
19527
20347
  if (!folderContainer(folder, lookup)) candidates.add(folder);
19528
20348
  }
19529
20349
  }
19530
- if (candidates.size === 0 && !includeEmpty) return found;
20350
+ if (candidates.size === 0 && !includeEmpty) return found2;
19531
20351
  const known = this.knownDatabaseIds();
19532
20352
  const created = loadCreatedDatabases(this.stateDb);
19533
20353
  for (const file of await this.vaultFs.listNonMarkdownFiles()) {
19534
20354
  if (!isSidecarPath(file.path)) continue;
19535
20355
  const folder = parentFolderOf(file.path);
19536
- if (found.has(folder)) continue;
20356
+ if (found2.has(folder)) continue;
19537
20357
  if (!candidates.has(folder) && !(includeEmpty && !folderContainer(folder, lookup))) continue;
19538
20358
  const schema = await this.readSidecar(file.path);
19539
20359
  if (!schema) continue;
19540
20360
  const entry = created.get(schema.databaseId);
19541
20361
  if (entry && entry.databaseId !== null) continue;
19542
20362
  if (!entry && known.has(schema.databaseId)) continue;
19543
- found.set(folder, { folder, schema });
20363
+ found2.set(folder, { folder, schema });
19544
20364
  }
19545
- return found;
20365
+ return found2;
19546
20366
  }
19547
20367
  /**
19548
20368
  * 폴더의 DB 를 부모 페이지 아래에 만들고 자동 발견 목록에 적는다. 만드는 요청은 다시 보내지 않는다 —
@@ -19993,9 +20813,7 @@ var DatabaseCreator = class {
19993
20813
  discovered.push({ databaseId, localFolder: folder, titleProperty: titleProperty2 });
19994
20814
  this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(discovered));
19995
20815
  }
19996
- const pending = parsePendingDatabases(this.stateDb.getMeta(PENDING_DATABASES_META_KEY));
19997
- pending.add(compactNotionId(databaseId));
19998
- this.stateDb.setMeta(PENDING_DATABASES_META_KEY, serializePendingDatabases(pending));
20816
+ addPendingDatabases(this.stateDb, [databaseId]);
19999
20817
  }
20000
20818
  async readSidecar(path) {
20001
20819
  try {
@@ -20300,6 +21118,7 @@ var PagePusher = class {
20300
21118
  * (ADR-017). relation 을 보내면 가리킨 행의 반대쪽 relation 이, 행을 만들면 그 행의 수식 · ID 가 바뀐다.
20301
21119
  */
20302
21120
  markRemoteUnverified(pageIds) {
21121
+ const databases = [];
20303
21122
  for (const pageId of new Set(pageIds)) {
20304
21123
  const target = this.stateDb.getByNotionId(pageId);
20305
21124
  if (!target?.notionLastEdited) continue;
@@ -20308,6 +21127,76 @@ var PagePusher = class {
20308
21127
  lastEditedBy: null,
20309
21128
  seenAt: null
20310
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
+ );
20311
21200
  }
20312
21201
  }
20313
21202
  /** relation 을 보내지 못한 행을 꺼낸다(목록은 비운다). */
@@ -20332,7 +21221,7 @@ var PagePusher = class {
20332
21221
  const selectedPath = this.pipeline.selectPath(content);
20333
21222
  if (selectedPath === "block-api") {
20334
21223
  getLogger().warn(
20335
- `[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`
20336
21225
  );
20337
21226
  }
20338
21227
  const conversionResult = this.pipeline.convertToNotion(content, {
@@ -20476,7 +21365,7 @@ var PagePusher = class {
20476
21365
  const updatePath = this.pipeline.selectPath(content);
20477
21366
  if (updatePath === "block-api") {
20478
21367
  getLogger().warn(
20479
- `[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`
20480
21369
  );
20481
21370
  }
20482
21371
  const conversionResult = this.pipeline.convertToNotion(content, {
@@ -20507,7 +21396,7 @@ var PagePusher = class {
20507
21396
  bodyFingerprint = await this.drift.remoteBodyFingerprintOf(record.notionPageId);
20508
21397
  }
20509
21398
  const newTitle = this.changedPageTitle(record, content, path);
20510
- const propsToUpdate = newTitle === null ? void 0 : titleProperty(newTitle);
21399
+ const propsToUpdate = newTitle === null ? void 0 : titleProperty(newTitle, pagePropertiesOf(remote));
20511
21400
  const decorated = await this.decorations.push(
20512
21401
  record.notionPageId,
20513
21402
  path,
@@ -20690,16 +21579,11 @@ var PagePusher = class {
20690
21579
  current: current.data,
20691
21580
  uploads: await this.uploadsFor(mapper, changes.changed, path)
20692
21581
  });
20693
- const unsentProperties = withoutDecorations(skipped);
20694
- if (unsentProperties.length > 0) {
20695
- getLogger().info(
20696
- `[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(", ")}`
20697
- );
20698
- }
21582
+ this.reportUnsent(path, mapper, skipped, "\uC0C8 \uD589 \uC18D\uC131");
20699
21583
  const selectedPath = this.pipeline.selectPath(content);
20700
21584
  if (selectedPath === "block-api") {
20701
21585
  getLogger().warn(
20702
- `[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`
20703
21587
  );
20704
21588
  }
20705
21589
  const conversionResult = this.pipeline.convertToNotion(content, {
@@ -20898,12 +21782,7 @@ var PagePusher = class {
20898
21782
  ...against === null || options.overwriteRemote ? {} : { uploads: await this.uploadsFor(mapper, changes.changed, path) }
20899
21783
  }
20900
21784
  );
20901
- const unsentProperties = withoutDecorations(skipped);
20902
- if (unsentProperties.length > 0) {
20903
- getLogger().info(
20904
- `[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(", ")}`
20905
- );
20906
- }
21785
+ this.reportUnsent(path, mapper, skipped, "\uD589 \uC18D\uC131");
20907
21786
  const bodyChanged = against?.body == null || against.body !== current.body;
20908
21787
  const settles = drift === "none";
20909
21788
  if (bodyChanged && !this.drift.overwritesRemote(record, options.overwriteRemote)) {
@@ -20915,7 +21794,7 @@ var PagePusher = class {
20915
21794
  const selectedPath = this.pipeline.selectPath(content);
20916
21795
  if (selectedPath === "block-api") {
20917
21796
  getLogger().warn(
20918
- `[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`
20919
21798
  );
20920
21799
  }
20921
21800
  conversionResult = this.pipeline.convertToNotion(content, {
@@ -20993,6 +21872,8 @@ var PagePusher = class {
20993
21872
  aliases: extractAliases(current.data)
20994
21873
  });
20995
21874
  });
21875
+ if (mapper.readOnlyKeys(withoutDecorations(skipped)).length > 0)
21876
+ this.markRemoteUnverified([pageId]);
20996
21877
  }
20997
21878
  /**
20998
21879
  * 지난 동기화 시점의 행. 사본이 없거나 읽지 못하면 null — 호출측은 비교할 기준이 없다고
@@ -21037,7 +21918,10 @@ var PagePusher = class {
21037
21918
  const title = change.title ? change.title(this.notionClient.extractTitle(remote)) : null;
21038
21919
  if (newParent !== null) await this.notionClient.movePage(pageId, newParent);
21039
21920
  if (title !== null) {
21040
- written = await this.notionClient.updatePageProperties(pageId, titleProperty(title));
21921
+ written = await this.notionClient.updatePageProperties(
21922
+ pageId,
21923
+ titleProperty(title, pagePropertiesOf(remote))
21924
+ );
21041
21925
  } else if (newParent !== null) {
21042
21926
  written = await this.notionClient.getPage(pageId);
21043
21927
  }
@@ -21383,6 +22267,8 @@ var PagePuller = class {
21383
22267
  // 제목이 같은 새 페이지끼리 경로를 고르는 차례({@link orderCreates}) — 페이지 → 앞 차례가 경로를 고르면
21384
22268
  // 풀리는 약속과 내 차례를 끝내는 함수.
21385
22269
  claimTurns = /* @__PURE__ */ new Map();
22270
+ // Notion 에서 사라져 볼트에서 지운 페이지의 하위 폴더 — pull 이 마칠 때 비었으면 지운다({@link removeVacatedFolders}).
22271
+ vacatedFolders = /* @__PURE__ */ new Set();
21386
22272
  decorationsCache;
21387
22273
  get decorations() {
21388
22274
  return this.decorationsCache ??= new PageDecorations(this.notionClient, this.imageHandler);
@@ -21397,6 +22283,35 @@ var PagePuller = class {
21397
22283
  this.claimTurns.clear();
21398
22284
  this._pullImageCount = 0;
21399
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);
21400
22315
  }
21401
22316
  /** 이번 pull 이 내려받은 이미지 수. */
21402
22317
  get imageCount() {
@@ -21443,6 +22358,9 @@ var PagePuller = class {
21443
22358
  }
21444
22359
  return ordered;
21445
22360
  }
22361
+ /**
22362
+ * @returns 쓴 노트(폴더) 경로. 동기화에서 뺀 자리에 놓일 페이지면 `excluded` — 받지 않았다.
22363
+ */
21446
22364
  async pullCreate(pageId) {
21447
22365
  try {
21448
22366
  return await this.createPulledPage(pageId);
@@ -21450,15 +22368,44 @@ var PagePuller = class {
21450
22368
  this.claimTurns.get(normalizeNotionId(pageId))?.release();
21451
22369
  }
21452
22370
  }
22371
+ /**
22372
+ * 새 원격 페이지가 동기화에서 뺀 자리(paths · `.im-nobsidian-ignore`)에 놓일 것이면 그 자리 — 아니면 null.
22373
+ * dry-run 이 받지 않을 것을 미리 알리는 데 쓴다. 볼트도 상태 DB 도 바꾸지 않는다.
22374
+ */
22375
+ async plannedExclusion(pageId) {
22376
+ if (!this.vaultFs.excludesPath) return null;
22377
+ const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
22378
+ return this.excludedCreatePath(
22379
+ await this.resolveParentPath(page),
22380
+ sanitizeFileName(this.notionClient.extractTitle(page))
22381
+ );
22382
+ }
22383
+ /**
22384
+ * 새 페이지가 놓일 자리가 뺀 자리인가 — 부모 폴더 · 노트 이름 · 폴더 노트가 될 때의 폴더를 본다. 받기 전에는 폴더 노트가
22385
+ * 될지 모른다(본문을 받아 봐야 안다) — 어느 쪽이든 빠지면 뺀다. 자연 이름으로 보고, 같은 이름이 있을 때 붙는 id 접미사는
22386
+ * 보지 않는다.
22387
+ */
22388
+ excludedCreatePath(parentPath, safeName) {
22389
+ const excludes = this.vaultFs.excludesPath?.bind(this.vaultFs);
22390
+ if (!excludes) return null;
22391
+ const folder = parentPath ? `${parentPath}/${safeName}` : safeName;
22392
+ const note = `${folder}.md`;
22393
+ const blocked = parentPath !== "" && excludes(parentPath, "folder") || excludes(note) || excludes(folder, "folder");
22394
+ return blocked ? note : null;
22395
+ }
21453
22396
  async createPulledPage(pageId) {
21454
22397
  const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
21455
22398
  const title = this.notionClient.extractTitle(page);
21456
22399
  const safeName = sanitizeFileName(title);
21457
22400
  const parentPath = await this.resolveParentPath(page);
22401
+ const excluded = this.excludedCreatePath(parentPath, safeName);
22402
+ if (excluded !== null) return { path: excluded, excluded: true };
21458
22403
  const fetched = await this.fetchPageMarkdown(pageId);
21459
22404
  const hasChildPages = this.hasChildContainers(pageId, fetched.children);
21460
22405
  if (hasChildPages && !fetched.hasBody) {
21461
- return this.createFolderOnly(pageId, page, fetched.fingerprint, parentPath, safeName);
22406
+ return {
22407
+ path: await this.createFolderOnly(pageId, page, fetched.fingerprint, parentPath, safeName)
22408
+ };
21462
22409
  }
21463
22410
  let filePath;
21464
22411
  let fileType;
@@ -21507,7 +22454,7 @@ var PagePuller = class {
21507
22454
  aliases
21508
22455
  });
21509
22456
  });
21510
- return filePath;
22457
+ return { path: filePath };
21511
22458
  }
21512
22459
  /**
21513
22460
  * 자식 말고 본문이 없는 페이지는 폴더로만 받는다 — 노트를 쓰지 않고 폴더 경로를 추적한다(폴더 레코드).
@@ -21544,7 +22491,7 @@ var PagePuller = class {
21544
22491
  if (!folderNote) return { action: "unchanged", path: record.obsidianPath };
21545
22492
  record = folderNote;
21546
22493
  }
21547
- const remoteGone = page.in_trash === true || page.archived === true;
22494
+ const remoteGone = isTrashedOrArchived(page);
21548
22495
  if (remoteGone && !await this.vaultFs.exists(record.obsidianPath)) {
21549
22496
  return { action: "unchanged", path: record.obsidianPath };
21550
22497
  }
@@ -21640,6 +22587,7 @@ var PagePuller = class {
21640
22587
  this.stateDb.delete(record.id);
21641
22588
  this.stateDb.deleteWikilink(record.obsidianPath);
21642
22589
  });
22590
+ if (this.config.sync.deleteSync) this.noteVacatedFolder(record);
21643
22591
  return { action: "untracked", path: record.obsidianPath };
21644
22592
  }
21645
22593
  const outcome = await applyRemoteDeletion(this.stateDb, this.vaultFs, record, {
@@ -21647,6 +22595,7 @@ var PagePuller = class {
21647
22595
  strategy: this.config.sync.conflictStrategy,
21648
22596
  remoteChange: change
21649
22597
  });
22598
+ if (outcome.action === "deleted" && this.config.sync.deleteSync) this.noteVacatedFolder(record);
21650
22599
  return { ...outcome, path: record.obsidianPath };
21651
22600
  }
21652
22601
  /**
@@ -21802,8 +22751,7 @@ var PagePuller = class {
21802
22751
  */
21803
22752
  async pullFolderRecord(record, page) {
21804
22753
  const folder = record.obsidianPath;
21805
- const remoteGone = page.in_trash === true || page.archived === true;
21806
- if (remoteGone) return null;
22754
+ if (isTrashedOrArchived(page)) return null;
21807
22755
  const { markdown } = await this.notionClient.getPageMarkdown(page.id);
21808
22756
  if (!hasBodyBesidesChildren(markdown)) {
21809
22757
  this.observation.record(record.id, page, remoteBodyFingerprint(markdown));
@@ -22135,6 +23083,13 @@ var PullPlanner = class {
22135
23083
  const restoredModified = planned.filter(
22136
23084
  (c) => c.type === "modified" && restoreIds.has(c.pageId)
22137
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
+ ];
22138
23093
  const rowCount = (operation, restore = false) => rows.filter((row) => row.operation === operation && (row.restore ?? false) === restore).length;
22139
23094
  return {
22140
23095
  created: count("created") + rowCount("create"),
@@ -22143,6 +23098,7 @@ var PullPlanner = class {
22143
23098
  // 복원은 updated 에 섞지 않는다 — dry-run 이 "수정 N건" 이라고만 말하면
22144
23099
  // 사용자가 사라진 파일이 되살아난다는 사실을 미리 알 수 없다.
22145
23100
  restored: restoreChanges.length + restoredModified + rowCount("update", true),
23101
+ ...restoredPaths.length > 0 ? { restoredPaths } : {},
22146
23102
  conflicts: [],
22147
23103
  writtenPaths: [],
22148
23104
  failed,
@@ -22217,6 +23173,42 @@ var PullPlanner = class {
22217
23173
  }
22218
23174
  };
22219
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
+
22220
23212
  // src/sync/remote-detector.ts
22221
23213
  var RemoteDetector = class _RemoteDetector {
22222
23214
  constructor(config, stateDb, notionClient, observation) {
@@ -22252,6 +23244,12 @@ var RemoteDetector = class _RemoteDetector {
22252
23244
  // Notion 에서 부모가 바뀐 추적 페이지(정규화 id → 새 부모 페이지 id) — pull 이 볼트에서도 옮긴다(ADR-039).
22253
23245
  // 옮겨도 수정 시각이 바뀌지 않아(실측) 변경으로는 보이지 않는다. 감지마다 다시 채운다.
22254
23246
  _moved = /* @__PURE__ */ new Map();
23247
+ // 증분 감지가 search 로 살아 있다고 본 페이지(압축 id) — 삭제 확인이 다시 묻지 않는다.
23248
+ _seenAlive = /* @__PURE__ */ new Set();
23249
+ // 이번 감지가 삭제를 물은 뒤의 커서 — pull 이 마칠 때 적는다({@link saveDeletionSweep}). undefined 면 묻지 않았다.
23250
+ _sweepCursor;
23251
+ // 이번 감지가 추적 중인 것을 모두 물었나 — 그러면 증분이어도 미룬 삭제가 없다.
23252
+ _sweptAll = false;
22255
23253
  /** 이번 감지에서 자식 페이지를 가진 것으로 본 페이지인가 — 폴더노트 판정의 1차 근거. */
22256
23254
  hasChildPages(pageId) {
22257
23255
  return this._childParentIds.has(normalizeNotionId(pageId));
@@ -22293,7 +23291,7 @@ var RemoteDetector = class _RemoteDetector {
22293
23291
  throwIfAborted(signal);
22294
23292
  try {
22295
23293
  const page = await this.notionClient.getPage(childId);
22296
- if (page.in_trash || page.archived) continue;
23294
+ if (isTrashedOrArchived(page)) continue;
22297
23295
  const actualParent = await extractParentId(this.notionClient, page);
22298
23296
  if (!actualParent || !notionIdsEqual(actualParent, parentId)) continue;
22299
23297
  this._childParentIds.add(normalizeNotionId(parentId));
@@ -22356,10 +23354,83 @@ var RemoteDetector = class _RemoteDetector {
22356
23354
  * @param options.databases 바뀐 DB 도 찾는다(pull · dry-run). 상태 확인은 DB 를 조회하지 않아 찾지 않는다.
22357
23355
  */
22358
23356
  async detectRemote(scan, options) {
23357
+ this._seenAlive.clear();
23358
+ this._sweepCursor = void 0;
23359
+ this._sweptAll = false;
22359
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
+ }
22360
23366
  this.beginDetection(detection.changes);
22361
23367
  return detection;
22362
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
+ }
22363
23434
  /**
22364
23435
  * 전체 대조 — 루트 아래 페이지를 모두 훑고 추적 중인데 목록에 없는 페이지를 원격에 물어 삭제를
22365
23436
  * 가른다. 취소하면 {@link OperationAbortedError} 를 던진다 — 다 훑지 못한 목록으로 삭제를 가르지
@@ -22500,9 +23571,8 @@ var RemoteDetector = class _RemoteDetector {
22500
23571
  }
22501
23572
  /**
22502
23573
  * 증분 원격 변경 감지 — `since` 이후 수정된 페이지만 search 로 받아 created/modified 만
22503
- * 만든다. **삭제는 감지하지 않는다**: search API 는 in_trash/archived 페이지를 반환하지 않아
22504
- * (=사라진 것을 증분만으로는 구분 불가) 삭제는 전체 대조(`detectRemoteChanges`)가 가른다.
22505
- * 전체 대조는 주기마다 돈다(ADR-027 — 예전에는 deleteSync 가 켜져 있으면 매번 돌았다. I10).
23574
+ * 만든다. search API 는 in_trash/archived 페이지를 반환하지 않아 삭제는 여기서 보이지 않는다 —
23575
+ * 차례로 묻는 삭제 확인({@link sweepDeletions})과 주기마다 도는 전체 대조(ADR-027)가 가른다.
22506
23576
  *
22507
23577
  * 바뀐 행 · 새 행 · 스키마를 고친 DB 도 모은다 — pull 은 그 DB 만 조회한다. 새 행은 페이지로 받지
22508
23578
  * 않는다: DB 조회가 행 속성과 함께 받는다. 그래서 부모를 묻지 않는다(행마다 요청 1회 이상 아낀다).
@@ -22520,6 +23590,7 @@ var RemoteDetector = class _RemoteDetector {
22520
23590
  const recentPages = await this.notionClient.searchRecentPages(searchSince, options.signal);
22521
23591
  const untracked = [];
22522
23592
  for (const page of recentPages) {
23593
+ this._seenAlive.add(compactNotionId(page.id));
22523
23594
  const record = this.stateDb.getByNotionId(page.id);
22524
23595
  if (!record) {
22525
23596
  if (page.parentDatabaseId) {
@@ -22673,6 +23744,9 @@ function isRerenderable(record) {
22673
23744
  }
22674
23745
 
22675
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
+ }
22676
23750
  async function detectMissingLocalFiles(config, stateDb, vaultFs, paths) {
22677
23751
  if (config.sync.deleteSync) return [];
22678
23752
  const records = stateDb.getAll();
@@ -22951,11 +24025,11 @@ ${text}
22951
24025
  skipped.push({ what: "\uBCF4\uAE30", reason: ".base \uB97C \uC77D\uC9C0 \uBABB\uD588\uB2E4" });
22952
24026
  return [];
22953
24027
  }
22954
- const idOf = /* @__PURE__ */ new Map();
22955
- 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);
22956
24030
  const columnId = (raw) => {
22957
24031
  if (typeof raw !== "string") return void 0;
22958
- return idOf.get(raw) ?? idOf.get(raw.replace(/^note\./, ""));
24032
+ return idOf2.get(raw) ?? idOf2.get(raw.replace(/^note\./, ""));
22959
24033
  };
22960
24034
  const views = [];
22961
24035
  const list = Array.isArray(parsed.views) ? parsed.views : [];
@@ -23114,6 +24188,14 @@ var SyncOrchestrator = class {
23114
24188
  vaultFs,
23115
24189
  () => this.placement.folderLookup()
23116
24190
  );
24191
+ this.remoteRenames = new RemoteRenameFollower(
24192
+ stateDb,
24193
+ vaultFs,
24194
+ notionClient,
24195
+ async (pageId) => this.detector.discoveredPage(pageId) ?? await notionClient.getPage(pageId),
24196
+ config.notion.databases ?? []
24197
+ );
24198
+ this.linkUpdater = new LinkUpdater(stateDb, vaultFs);
23117
24199
  this.puller = new PagePuller(
23118
24200
  config,
23119
24201
  stateDb,
@@ -23182,6 +24264,8 @@ var SyncOrchestrator = class {
23182
24264
  // 이번 실행의 로컬 변경 — 옮긴 노트 · 폴더를 짝짓고 상태 DB 에 옮겨 적는다(S-11).
23183
24265
  planner;
23184
24266
  remoteMoves;
24267
+ remoteRenames;
24268
+ linkUpdater;
23185
24269
  // 원격에서 바뀐 것 — 전체 대조 · 증분 감지(ADR-027).
23186
24270
  detector;
23187
24271
  // 지난번에 본 뒤로 원격이 바뀌었나(N-05) — 덮어쓰기 · 지우기 전에, 변경을 보이기 전에 본다.
@@ -23240,10 +24324,11 @@ var SyncOrchestrator = class {
23240
24324
  this.rowSchemas.clear();
23241
24325
  this.placement.resetUnpreparedFolders();
23242
24326
  this.databaseCreator.reset();
24327
+ this.pusher.takeReadOnlySkips();
23243
24328
  if (!options?.dryRun) await this.recovery.recoverInterruptedPushOps();
23244
24329
  const plan = await this.planner.planLocalChanges(await this.vaultFs.listMarkdownFileStats());
23245
24330
  const view = this.planner.localView(plan);
23246
- if (!options?.dryRun) this.planner.adoptLocalMoves(plan);
24331
+ if (!options?.dryRun) await this.planner.adoptLocalMoves(plan);
23247
24332
  const changes = plan.scan.changes;
23248
24333
  const conflictPaths = new Set(this.stateDb.getByStatus("conflict").map((r) => r.obsidianPath));
23249
24334
  const eligible = options?.force ? changes : changes.filter((c) => !conflictPaths.has(c.path));
@@ -23293,6 +24378,8 @@ var SyncOrchestrator = class {
23293
24378
  view.lookup,
23294
24379
  !options?.paths
23295
24380
  );
24381
+ const unsentDeletions2 = applicable.filter((change) => this.unsentDeletion(change)).map((change) => change.path);
24382
+ const readOnlySkipped = await this.pusher.previewReadOnlySkips(applicable);
23296
24383
  return {
23297
24384
  createdDatabases: newDatabases.size,
23298
24385
  created: count("create"),
@@ -23300,13 +24387,16 @@ var SyncOrchestrator = class {
23300
24387
  deleted: count("delete"),
23301
24388
  moved: count("move"),
23302
24389
  failed: refusedFailures,
23303
- duration: Date.now() - startTime
24390
+ duration: Date.now() - startTime,
24391
+ ...unsentDeletions2.length > 0 ? { unsentDeletions: unsentDeletions2 } : {},
24392
+ ...readOnlySkipped.length > 0 ? { readOnlySkipped } : {}
23304
24393
  };
23305
24394
  }
23306
24395
  this.stateDb.setMeta(PUSH_IN_PROGRESS_META_KEY, "true");
23307
24396
  await this.observation.resolveBotUserId();
23308
24397
  const counts = { created: 0, updated: 0, deleted: 0, moved: 0 };
23309
24398
  const failed = [...refusedFailures];
24399
+ const unsentDeletions = [];
23310
24400
  let completed = 0;
23311
24401
  const total = movableFolders.length + applicable.length;
23312
24402
  counts.moved += await this.pusher.pushFolderMoves(
@@ -23334,6 +24424,7 @@ var SyncOrchestrator = class {
23334
24424
  case "deleted": {
23335
24425
  const propagated = await this.pusher.pushDelete(change.path);
23336
24426
  if (propagated) counts.deleted++;
24427
+ else if (this.unsentDeletion(change)) unsentDeletions.push(change.path);
23337
24428
  break;
23338
24429
  }
23339
24430
  }
@@ -23489,9 +24580,20 @@ var SyncOrchestrator = class {
23489
24580
  deleted: counts.deleted,
23490
24581
  moved: counts.moved,
23491
24582
  failed,
23492
- duration: Date.now() - startTime
24583
+ duration: Date.now() - startTime,
24584
+ readOnlySkipped: this.pusher.takeReadOnlySkips(),
24585
+ ...unsentDeletions.length > 0 ? { unsentDeletions } : {}
23493
24586
  };
23494
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
+ }
23495
24597
  /**
23496
24598
  * 만든 DB 에 relation · rollup · formula 를 붙이고, 행 스키마를 다시 읽게 한 뒤 보기를 만든다. 실패는
23497
24599
  * 다음 push 가 이어 붙인다 — 행은 그대로 올린다.
@@ -23526,6 +24628,16 @@ var SyncOrchestrator = class {
23526
24628
  async pull(options) {
23527
24629
  return this.gate.run("pull", () => this.executePull(options));
23528
24630
  }
24631
+ /** 새 원격 페이지 가운데 동기화에서 뺀 자리에 놓일 것 — 페이지 id → 그 자리. dry-run 이 받지 않을 것으로 알린다. */
24632
+ async plannedExclusions(changes) {
24633
+ const exclusions = /* @__PURE__ */ new Map();
24634
+ for (const change of changes) {
24635
+ if (change.type !== "created") continue;
24636
+ const path = await this.puller.plannedExclusion(change.pageId);
24637
+ if (path !== null) exclusions.set(change.pageId, path);
24638
+ }
24639
+ return exclusions;
24640
+ }
23529
24641
  /** 감지가 본, Notion 에서 옮긴 페이지를 볼트에서 옮긴다 — 경로를 좁힌 pull 은 그 범위의 것만. */
23530
24642
  async followRemoteMoves(options) {
23531
24643
  const moved = new Map(
@@ -23537,11 +24649,24 @@ var SyncOrchestrator = class {
23537
24649
  if (moved.size === 0) return { moved: [], skipped: [] };
23538
24650
  return await this.remoteMoves.follow(moved, { dryRun: options?.dryRun });
23539
24651
  }
24652
+ /**
24653
+ * Notion 에서 제목을 바꾼 페이지의 노트 이름을 바꾼다 — `sync.renameOnTitleChange` 를 켰을 때만, 경로를 좁힌 pull 은
24654
+ * 그 범위의 것만. 설정이 뺀 노트는 두지 않는다.
24655
+ */
24656
+ async followRemoteRenames(changes, options) {
24657
+ if (!this.config.sync.renameOnTitleChange) return { renamed: [], skipped: [], applied: [] };
24658
+ const scoped = changes.filter((change) => {
24659
+ const record = this.stateDb.getByNotionId(change.pageId);
24660
+ return !!record && (!options?.paths || inAnyPathScope(record.obsidianPath, options.paths));
24661
+ });
24662
+ return await this.remoteRenames.follow(scoped, { dryRun: options?.dryRun });
24663
+ }
23540
24664
  async executePull(options) {
23541
24665
  const startTime = Date.now();
23542
24666
  this.assertConnectionScope();
23543
24667
  this.puller.beginPull();
23544
24668
  this.rowSchemas.clear();
24669
+ this.databaseSyncer.takeRestoredRows();
23545
24670
  const emptyResult = {
23546
24671
  created: 0,
23547
24672
  updated: 0,
@@ -23568,12 +24693,14 @@ var SyncOrchestrator = class {
23568
24693
  `[Im-Nobsidian] \uBCFC\uD2B8\uB97C \uC77D\uC9C0 \uBABB\uD574 \uC62E\uAE34 \uB178\uD2B8\uB97C \uD655\uC778\uD558\uC9C0 \uBABB\uD568 \u2014 \uC774\uBC88 pull \uC740 \uC0AC\uB77C\uC9C4 \uB178\uD2B8\uB97C \uB418\uC0B4\uB9AC\uC9C0 \uC54A\uB294\uB2E4: ${error instanceof Error ? error.message : String(error)}`
23569
24694
  );
23570
24695
  }
23571
- if (localPlan && !options?.dryRun) this.planner.adoptLocalMoves(localPlan);
24696
+ if (localPlan && !options?.dryRun) await this.planner.adoptLocalMoves(localPlan);
23572
24697
  const adopting = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
23573
24698
  const counts = { created: 0, updated: 0, deleted: 0, restored: 0 };
23574
24699
  this.discovery.clearInlineRefs();
23575
24700
  const conflicts = [];
23576
24701
  const writtenPaths = [];
24702
+ const restoredPaths = [];
24703
+ const excluded = [];
23577
24704
  const failed = [];
23578
24705
  let detected = [];
23579
24706
  const applied = /* @__PURE__ */ new Set();
@@ -23597,6 +24724,12 @@ var SyncOrchestrator = class {
23597
24724
  const remoteChanges = detection.changes;
23598
24725
  detected = remoteChanges;
23599
24726
  const remoteMoves = await this.followRemoteMoves(options);
24727
+ const remoteRenames = await this.followRemoteRenames(remoteChanges, options);
24728
+ const relocated = {
24729
+ moved: [...remoteMoves.moved, ...remoteRenames.renamed],
24730
+ skipped: [...remoteMoves.skipped, ...remoteRenames.skipped],
24731
+ applied: [...remoteMoves.moved, ...remoteRenames.applied]
24732
+ };
23600
24733
  const pendingDatabases = parsePendingDatabases(
23601
24734
  this.stateDb.getMeta(PENDING_DATABASES_META_KEY)
23602
24735
  );
@@ -23610,6 +24743,10 @@ var SyncOrchestrator = class {
23610
24743
  pendingDatabases
23611
24744
  );
23612
24745
  const finalize = async (created, updated, deleted, restored = 0) => {
24746
+ await this.puller.removeVacatedFolders();
24747
+ const renamedRows = this.databaseSyncer.takeRenamedRows();
24748
+ restoredPaths.push(...this.databaseSyncer.takeRestoredRows());
24749
+ const relinked = await this.linkUpdater.apply([...relocated.applied, ...renamedRows.applied]);
23613
24750
  const linkCount = writtenPaths.length > 0 ? await this.resolveNotionLinks(writtenPaths) : 0;
23614
24751
  if (!options?.paths) {
23615
24752
  const startedAt = new Date(startTime).toISOString();
@@ -23624,6 +24761,7 @@ var SyncOrchestrator = class {
23624
24761
  this.stateDb.setMeta(LAST_FULL_PULL_META_KEY, startedAt);
23625
24762
  }
23626
24763
  this.detector.savePendingDatabases(ledger);
24764
+ this.detector.saveDeletionSweep();
23627
24765
  }
23628
24766
  this.stateDb.setMeta(LAST_SYNC_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
23629
24767
  this.stateDb.setMeta(PULL_IN_PROGRESS_META_KEY, "");
@@ -23632,6 +24770,7 @@ var SyncOrchestrator = class {
23632
24770
  updated,
23633
24771
  deleted,
23634
24772
  restored,
24773
+ ...restoredPaths.length > 0 ? { restoredPaths } : {},
23635
24774
  conflicts,
23636
24775
  writtenPaths,
23637
24776
  failed,
@@ -23643,12 +24782,15 @@ var SyncOrchestrator = class {
23643
24782
  ...remoteScanInfo(
23644
24783
  scan,
23645
24784
  this.stateDb.getMeta(LAST_FULL_PULL_META_KEY),
23646
- this.config.sync.deleteSync
24785
+ this.config.sync.deleteSync,
24786
+ this.detector.deletionsChecked()
23647
24787
  ),
23648
24788
  skippedDatabases: ledger.skipped
23649
24789
  },
23650
- moved: remoteMoves.moved,
23651
- moveSkipped: remoteMoves.skipped
24790
+ moved: [...relocated.moved, ...renamedRows.renamed],
24791
+ moveSkipped: relocated.skipped,
24792
+ relinked,
24793
+ excluded
23652
24794
  };
23653
24795
  };
23654
24796
  const excludes = (path) => this.vaultFs.excludesPath?.(path) ?? false;
@@ -23679,8 +24821,9 @@ var SyncOrchestrator = class {
23679
24821
  }) : [];
23680
24822
  const workItems = [...filtered, ...restoreChanges, ...rerenderChanges];
23681
24823
  if (options?.dryRun) {
24824
+ const exclusions = await this.plannedExclusions(filtered);
23682
24825
  const plan = await this.pullPlanner.planPull(
23683
- filtered,
24826
+ filtered.filter((change) => !exclusions.has(change.pageId)),
23684
24827
  restoreChanges,
23685
24828
  restoreIds,
23686
24829
  localPlan,
@@ -23689,7 +24832,16 @@ var SyncOrchestrator = class {
23689
24832
  scan,
23690
24833
  ledger
23691
24834
  );
23692
- return { ...plan, moved: remoteMoves.moved, moveSkipped: remoteMoves.skipped };
24835
+ return {
24836
+ ...plan,
24837
+ remoteScan: plan.remoteScan && {
24838
+ ...plan.remoteScan,
24839
+ deletionsDeferred: plan.remoteScan.deletionsDeferred && !this.detector.deletionsChecked()
24840
+ },
24841
+ moved: relocated.moved,
24842
+ moveSkipped: relocated.skipped,
24843
+ excluded: [...exclusions.values()]
24844
+ };
23693
24845
  }
23694
24846
  let pullTotal = workItems.length;
23695
24847
  let pullCompleted = 0;
@@ -23764,10 +24916,14 @@ var SyncOrchestrator = class {
23764
24916
  const applyChange = async (change) => {
23765
24917
  switch (change.type) {
23766
24918
  case "created": {
23767
- const path = await this.puller.pullCreate(change.pageId);
23768
- writtenPaths.push(path);
24919
+ const created = await this.puller.pullCreate(change.pageId);
24920
+ if (created.excluded) {
24921
+ excluded.push(created.path);
24922
+ return { path: created.path, unchanged: true };
24923
+ }
24924
+ writtenPaths.push(created.path);
23769
24925
  counts.created++;
23770
- return { path, unchanged: false };
24926
+ return { path: created.path, unchanged: false };
23771
24927
  }
23772
24928
  case "modified": {
23773
24929
  const outcome = await this.puller.pullUpdate(change);
@@ -23775,8 +24931,10 @@ var SyncOrchestrator = class {
23775
24931
  conflicts.push(outcome.conflict);
23776
24932
  } else if (outcome.action === "written") {
23777
24933
  writtenPaths.push(outcome.path);
23778
- if (restoreIds.has(change.pageId)) counts.restored++;
23779
- else counts.updated++;
24934
+ if (restoreIds.has(change.pageId)) {
24935
+ counts.restored++;
24936
+ restoredPaths.push(outcome.path);
24937
+ } else counts.updated++;
23780
24938
  }
23781
24939
  return {
23782
24940
  path: outcome.path,
@@ -23823,14 +24981,14 @@ var SyncOrchestrator = class {
23823
24981
  if (options?.paths) return;
23824
24982
  for (; ; ) {
23825
24983
  if (options?.signal?.aborted) return;
23826
- const found = await this.detector.takeUndiscoveredChildren(options?.signal);
23827
- if (found.length === 0) return;
24984
+ const found2 = await this.detector.takeUndiscoveredChildren(options?.signal);
24985
+ if (found2.length === 0) return;
23828
24986
  getLogger().info(
23829
- `[Im-Nobsidian] \uAC10\uC9C0\uAC00 \uBE60\uB728\uB9B0 \uC790\uC2DD \uD398\uC774\uC9C0 ${found.length}\uAC74\uC744 \uBD80\uBAA8 \uBCF8\uBB38\uC5D0\uC11C \uCC3E\uC544 \uBC1B\uC2B5\uB2C8\uB2E4`
24987
+ `[Im-Nobsidian] \uAC10\uC9C0\uAC00 \uBE60\uB728\uB9B0 \uC790\uC2DD \uD398\uC774\uC9C0 ${found2.length}\uAC74\uC744 \uBD80\uBAA8 \uBCF8\uBB38\uC5D0\uC11C \uCC3E\uC544 \uBC1B\uC2B5\uB2C8\uB2E4`
23830
24988
  );
23831
- detected = [...detected, ...found];
23832
- pullTotal += found.length;
23833
- await runPool(this.puller.orderCreates(found), handle, {
24989
+ detected = [...detected, ...found2];
24990
+ pullTotal += found2.length;
24991
+ await runPool(this.puller.orderCreates(found2), handle, {
23834
24992
  concurrency: this.config.advanced.concurrency,
23835
24993
  signal: options?.signal
23836
24994
  });
@@ -23911,7 +25069,11 @@ var SyncOrchestrator = class {
23911
25069
  await this.observation.resolveBotUserId();
23912
25070
  const scan = this.detector.remoteScan({ force: false, deferDue: true });
23913
25071
  const detection = await this.detector.detectRemote(scan, { signal, databases: false });
23914
- const remoteChanges = await this.drift.withoutUnchangedRemotes(detection.changes, signal);
25072
+ const detected = await this.drift.withoutUnchangedRemotes(detection.changes, signal);
25073
+ const moves = await this.followRemoteMoves({ dryRun: true });
25074
+ const renames = await this.followRemoteRenames(detected, { dryRun: true });
25075
+ const exclusions = await this.plannedExclusions(detected);
25076
+ const remoteChanges = detected.filter((change) => !exclusions.has(change.pageId));
23915
25077
  const conflictRecords = this.stateDb.getByStatus("conflict");
23916
25078
  throwIfAborted(signal);
23917
25079
  const conflicts = await this.conflictWorkflow.buildConflictsFromRecords(
@@ -23929,7 +25091,10 @@ var SyncOrchestrator = class {
23929
25091
  pendingOperations: conflictRecords.length,
23930
25092
  lastSyncAt,
23931
25093
  lastFullScanAt,
23932
- remoteScan: remoteScanInfo(scan, lastFullScanAt, this.config.sync.deleteSync)
25094
+ remoteScan: remoteScanInfo(scan, lastFullScanAt, this.config.sync.deleteSync),
25095
+ remoteMoves: [...moves.moved, ...renames.renamed],
25096
+ remoteMoveSkipped: [...moves.skipped, ...renames.skipped],
25097
+ remoteExcluded: [...exclusions.values()]
23933
25098
  };
23934
25099
  }
23935
25100
  /**
@@ -23997,7 +25162,12 @@ var SyncOrchestrator = class {
23997
25162
  this.stateDb,
23998
25163
  this.config.notion.rootPageId
23999
25164
  );
24000
- 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
+ };
24001
25171
  }
24002
25172
  async fetch() {
24003
25173
  return this.gate.run("fetch", () => this.executeFetch());
@@ -24115,6 +25285,10 @@ var PathFilter = class {
24115
25285
  this.include = compileAll(include);
24116
25286
  this.exclude = compileAll(exclude);
24117
25287
  }
25288
+ /** 아무것도 빼지 않는다 — 포함 패턴이 없거나 전부(`**\/*`)이고 제외 패턴이 없다. */
25289
+ isEmpty() {
25290
+ return this.exclude.length === 0 && this.include.every((pattern) => pattern.kind === "all");
25291
+ }
24118
25292
  /** 동기화하지 않는 노트 · 첨부 — 포함 패턴 어느 것에도 맞지 않거나 제외 패턴에 맞는다. */
24119
25293
  excludesFile(path) {
24120
25294
  if (this.include.length > 0 && !this.include.some((p) => matchesWithin(path, p))) return true;
@@ -24128,8 +25302,11 @@ var PathFilter = class {
24128
25302
  return this.exclude.some((p) => matchesWithin(path, p));
24129
25303
  }
24130
25304
  };
24131
- function toVaultPath(relativePath, separator) {
24132
- return separator === "/" ? relativePath : relativePath.split(separator).join("/");
25305
+ function parseIgnorePatterns(content) {
25306
+ return content.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#"));
25307
+ }
25308
+ function toVaultPath(relativePath2, separator) {
25309
+ return separator === "/" ? relativePath2 : relativePath2.split(separator).join("/");
24133
25310
  }
24134
25311
  function compileAll(patterns) {
24135
25312
  return patterns.flatMap((raw) => {
@@ -24207,8 +25384,7 @@ var NodeVaultFS = class {
24207
25384
  loadImNobsidianIgnore() {
24208
25385
  try {
24209
25386
  const ignorePath = join(this.rootPath, IGNORE_FILE);
24210
- const content = readFileSync(ignorePath, "utf-8");
24211
- return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
25387
+ return parseIgnorePatterns(readFileSync(ignorePath, "utf-8"));
24212
25388
  } catch (error) {
24213
25389
  if (error.code === "ENOENT") return [];
24214
25390
  throw error;
@@ -24236,6 +25412,19 @@ var NodeVaultFS = class {
24236
25412
  const fullPath = join(this.rootPath, path);
24237
25413
  await unlink(fullPath);
24238
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
+ }
24239
25428
  async moveFile(from, to) {
24240
25429
  const fromPath = join(this.rootPath, from);
24241
25430
  const toPath = join(this.rootPath, to);
@@ -24286,14 +25475,14 @@ var NodeVaultFS = class {
24286
25475
  for (const entry of entries) {
24287
25476
  const fullPath = join(dir, entry.name);
24288
25477
  if (entry.name.startsWith(".")) continue;
24289
- const relativePath = this.vaultPath(fullPath);
25478
+ const relativePath2 = this.vaultPath(fullPath);
24290
25479
  if (entry.isDirectory()) {
24291
- if (this.filter.excludesFolder(relativePath)) continue;
25480
+ if (this.filter.excludesFolder(relativePath2)) continue;
24292
25481
  await this.walkDirNonMd(fullPath, result);
24293
- } else if (entry.isFile() && !isMarkdownPath(entry.name) && !this.filter.excludesFile(relativePath)) {
25482
+ } else if (entry.isFile() && !isMarkdownPath(entry.name) && !this.filter.excludesFile(relativePath2)) {
24294
25483
  const fileStat = await stat(fullPath);
24295
25484
  result.push({
24296
- path: relativePath,
25485
+ path: relativePath2,
24297
25486
  size: fileStat.size,
24298
25487
  mtime: fileStat.mtime.toISOString()
24299
25488
  });
@@ -24305,14 +25494,14 @@ var NodeVaultFS = class {
24305
25494
  for (const entry of entries) {
24306
25495
  const fullPath = join(dir, entry.name);
24307
25496
  if (entry.name.startsWith(".")) continue;
24308
- const relativePath = this.vaultPath(fullPath);
25497
+ const relativePath2 = this.vaultPath(fullPath);
24309
25498
  if (entry.isDirectory()) {
24310
- if (this.filter.excludesFolder(relativePath)) continue;
25499
+ if (this.filter.excludesFolder(relativePath2)) continue;
24311
25500
  await this.walkDirStats(fullPath, result);
24312
- } else if (entry.isFile() && isMarkdownPath(entry.name) && !this.filter.excludesFile(relativePath)) {
25501
+ } else if (entry.isFile() && isMarkdownPath(entry.name) && !this.filter.excludesFile(relativePath2)) {
24313
25502
  const fileStat = await stat(fullPath);
24314
25503
  result.push({
24315
- path: relativePath,
25504
+ path: relativePath2,
24316
25505
  mtime: fileStat.mtime.toISOString(),
24317
25506
  size: fileStat.size
24318
25507
  });
@@ -24324,15 +25513,15 @@ var NodeVaultFS = class {
24324
25513
  for (const entry of entries) {
24325
25514
  const fullPath = join(dir, entry.name);
24326
25515
  if (entry.name.startsWith(".")) continue;
24327
- const relativePath = this.vaultPath(fullPath);
25516
+ const relativePath2 = this.vaultPath(fullPath);
24328
25517
  if (entry.isDirectory()) {
24329
- if (this.filter.excludesFolder(relativePath)) continue;
25518
+ if (this.filter.excludesFolder(relativePath2)) continue;
24330
25519
  await this.walkDir(fullPath, result);
24331
- } else if (entry.isFile() && isMarkdownPath(entry.name) && !this.filter.excludesFile(relativePath)) {
25520
+ } else if (entry.isFile() && isMarkdownPath(entry.name) && !this.filter.excludesFile(relativePath2)) {
24332
25521
  const content = await readFile(fullPath, "utf-8");
24333
25522
  const fileStat = await stat(fullPath);
24334
25523
  result.push({
24335
- path: relativePath,
25524
+ path: relativePath2,
24336
25525
  content,
24337
25526
  mtime: fileStat.mtime.toISOString()
24338
25527
  });
@@ -25138,18 +26327,81 @@ function summarizeFidelity(perFile) {
25138
26327
  }
25139
26328
  return summary;
25140
26329
  }
26330
+
26331
+ // src/sync/filtered-vault-fs.ts
26332
+ var FilteredVaultFS = class {
26333
+ constructor(inner, filter) {
26334
+ this.inner = inner;
26335
+ this.filter = filter;
26336
+ if (inner.ownsPath) this.ownsPath = (path) => inner.ownsPath(path);
26337
+ if (inner.holdsOtherConnection) {
26338
+ this.holdsOtherConnection = (folder) => inner.holdsOtherConnection(folder);
26339
+ }
26340
+ }
26341
+ inner;
26342
+ filter;
26343
+ ownsPath;
26344
+ holdsOtherConnection;
26345
+ excludesPath(path, kind = "file") {
26346
+ const excluded = kind === "folder" ? this.filter.excludesFolder(path) : this.filter.excludesFile(path);
26347
+ return excluded || (this.inner.excludesPath?.(path, kind) ?? false);
26348
+ }
26349
+ async listMarkdownFiles() {
26350
+ return (await this.inner.listMarkdownFiles()).filter((f) => !this.filter.excludesFile(f.path));
26351
+ }
26352
+ async listMarkdownFileStats() {
26353
+ return (await this.inner.listMarkdownFileStats()).filter(
26354
+ (f) => !this.filter.excludesFile(f.path)
26355
+ );
26356
+ }
26357
+ async listNonMarkdownFiles() {
26358
+ return (await this.inner.listNonMarkdownFiles()).filter(
26359
+ (f) => !this.filter.excludesFile(f.path)
26360
+ );
26361
+ }
26362
+ readFile(path) {
26363
+ return this.inner.readFile(path);
26364
+ }
26365
+ readBinary(path) {
26366
+ return this.inner.readBinary(path);
26367
+ }
26368
+ writeFile(path, content) {
26369
+ return this.inner.writeFile(path, content);
26370
+ }
26371
+ writeBinary(path, data) {
26372
+ return this.inner.writeBinary(path, data);
26373
+ }
26374
+ deleteFile(path) {
26375
+ return this.inner.deleteFile(path);
26376
+ }
26377
+ moveFile(from, to) {
26378
+ return this.inner.moveFile(from, to);
26379
+ }
26380
+ exists(path) {
26381
+ return this.inner.exists(path);
26382
+ }
26383
+ ensureFolder(path) {
26384
+ return this.inner.ensureFolder(path);
26385
+ }
26386
+ async removeEmptyFolder(path) {
26387
+ return await this.inner.removeEmptyFolder?.(path) ?? false;
26388
+ }
26389
+ getFileStat(path) {
26390
+ return this.inner.getFileStat(path);
26391
+ }
26392
+ };
25141
26393
  async function findConnectionFolders(vaultRoot) {
25142
- const found = [];
26394
+ const found2 = [];
25143
26395
  const walk = async (dir, rel) => {
25144
26396
  const entries = await readdir(dir, { withFileTypes: true });
25145
- if (entries.some((e) => e.isDirectory() && e.name === INTERNAL_DIR)) found.push(rel);
26397
+ if (entries.some((e) => e.isDirectory() && e.name === INTERNAL_DIR)) found2.push(rel);
25146
26398
  for (const entry of entries) {
25147
26399
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
25148
26400
  await walk(join(dir, entry.name), rel === "" ? entry.name : `${rel}/${entry.name}`);
25149
26401
  }
25150
26402
  };
25151
26403
  await walk(vaultRoot, "");
25152
- return found;
26404
+ return found2;
25153
26405
  }
25154
26406
 
25155
26407
  // src/converter/design-markers.ts
@@ -25360,6 +26612,6 @@ function formatRatio(ratio) {
25360
26612
  return String(Math.round(ratio * 100) / 100);
25361
26613
  }
25362
26614
 
25363
- 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, 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, 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 };
25364
26616
  //# sourceMappingURL=index.js.map
25365
26617
  //# sourceMappingURL=index.js.map