@im-nobsidian/core 0.2.1 → 0.3.1

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
@@ -659,6 +659,16 @@ var StateDB = class _StateDB {
659
659
  }
660
660
  };
661
661
 
662
+ // src/utils/notion-file-url.ts
663
+ function isNotionHostedFileUrl(url) {
664
+ return url.includes("secure.notion-static.com") || url.includes("prod-files-secure") || // 신형 파일 URL (2024+): https://file.notion.so/f/f/<space>/<file>/<name>?table=block&...
665
+ url.includes("file.notion.so");
666
+ }
667
+ var NOTION_ATTACHMENT_URI_RE = /^attachment:([0-9a-f-]+):(.+)$/;
668
+ function isNotionAttachmentUri(url) {
669
+ return NOTION_ATTACHMENT_URI_RE.test(url);
670
+ }
671
+
662
672
  // src/notion/property-mapper.ts
663
673
  var DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
664
674
  var WIKILINK_REGEX = /^\[\[(.+?)(?:\|.+?)?\]\]$/;
@@ -773,6 +783,16 @@ var PropertyMapper = class {
773
783
  }
774
784
  case "files": {
775
785
  const fileList = Array.isArray(value) ? value : [value];
786
+ const urls = fileList.map((f) => {
787
+ if (typeof f === "object" && f !== null && "url" in f) {
788
+ return String(f.url ?? "");
789
+ }
790
+ return String(f);
791
+ });
792
+ const hasProtected = urls.some(
793
+ (u) => WIKILINK_REGEX.test(u.trim()) || isNotionHostedFileUrl(u)
794
+ );
795
+ if (hasProtected) return null;
776
796
  const filesArr = fileList.map((f) => {
777
797
  if (typeof f === "object" && f !== null && "url" in f) {
778
798
  const obj = f;
@@ -1108,18 +1128,60 @@ var NotionClient = class _NotionClient {
1108
1128
  return titleArr?.[0]?.plain_text ?? "";
1109
1129
  }
1110
1130
  /**
1111
- * 발견된 DB 가 **동기화 가능한지**(접근 가능한 data source 가 있는지) 1회 호출로 판별한다.
1131
+ * 발견된 DB 가 **동기화 가능한지**(접근 가능한 data source 가 있는지) 판별한다.
1112
1132
  * 신 모델(2025-09-03)에서 링크드 DB·미공유 데이터 소스·삭제 DB 는 `data_sources` 가
1113
1133
  * 비어, 행 조회(`dataSources.query`)가 404 로 실패하고 그 전에 생성된 빈 폴더/`.base` 만
1114
1134
  * 남긴다. 발견 단계에서 미리 걸러 **빈 폴더/.base 오염 + 매 pull 의 404 노이즈**를 차단한다.
1115
1135
  * 제목은 함께 반환해 호출처가 추가 조회 없이 폴더명을 잡게 한다.
1136
+ *
1137
+ * F25: 원본이 같은 공유 범위에 있으면 linked view 컨테이너도 `data_sources` 가 **채워져**
1138
+ * 온다(실측 — 워크스페이스 8건). 이때 data source 의 parent database 는 원본이므로,
1139
+ * 첫 소스의 parent 가 자신이 아니면 `linkedOriginalDbId` 로 알려 컨테이너가 원본과 같은
1140
+ * 행 집합을 이중 pull(폴더 릴레이 재배치 churn)하는 것을 발견 단계에서 차단한다.
1116
1141
  */
1117
1142
  async getDatabaseSyncability(databaseId) {
1118
1143
  const db = await this.withRateLimit(
1119
1144
  () => this.client.databases.retrieve({ database_id: databaseId })
1120
1145
  );
1121
1146
  const title = Array.isArray(db.title) ? db.title[0]?.plain_text ?? "" : "";
1122
- return { title, queryable: (db.data_sources?.length ?? 0) > 0 };
1147
+ const firstDsId = db.data_sources?.[0]?.id;
1148
+ if (!firstDsId) return { title, queryable: false };
1149
+ try {
1150
+ const ds = await this.withRateLimit(
1151
+ () => this.client.dataSources.retrieve({ data_source_id: firstDsId })
1152
+ );
1153
+ const ownerDbId = ds.parent?.database_id;
1154
+ if (ownerDbId && normalizeNotionId(ownerDbId) !== normalizeNotionId(databaseId)) {
1155
+ return { title, queryable: true, linkedOriginalDbId: ownerDbId };
1156
+ }
1157
+ } catch {
1158
+ }
1159
+ return { title, queryable: true };
1160
+ }
1161
+ /**
1162
+ * linked database view 컨테이너를 원본 DB 로 해소한다.
1163
+ * 컨테이너 자체는 `data_sources` 가 비어 행 조회가 불가능하지만(getDatabaseSyncability
1164
+ * queryable=false), Views API 의 뷰 상세에는 원본 `data_source_id` 가 실리고 그 data source
1165
+ * 의 parent 가 원본 database 다. 해소 실패(뷰 없음/권한 없음/자기참조)는 null — 호출처가
1166
+ * 기존대로 접근 불가 처리한다.
1167
+ */
1168
+ async resolveLinkedDatabase(databaseId) {
1169
+ try {
1170
+ const views = await this.listDatabaseViews(databaseId);
1171
+ for (const view of views) {
1172
+ if (!view.dataSourceId) continue;
1173
+ const ds = await this.withRateLimit(
1174
+ () => this.client.dataSources.retrieve({ data_source_id: view.dataSourceId })
1175
+ );
1176
+ const originalDbId = ds.parent?.database_id;
1177
+ if (!originalDbId) continue;
1178
+ if (originalDbId.replace(/-/g, "") === databaseId.replace(/-/g, "")) continue;
1179
+ return { originalDbId, viewName: view.name ?? "" };
1180
+ }
1181
+ return null;
1182
+ } catch {
1183
+ return null;
1184
+ }
1123
1185
  }
1124
1186
  async getDatabaseSchema(databaseId) {
1125
1187
  const db = await this.fetchDatabaseModern(databaseId);
@@ -1329,7 +1391,17 @@ var NotionClient = class _NotionClient {
1329
1391
  await this.withRateLimit(() => this.client.blocks.delete({ block_id: blockId }));
1330
1392
  }
1331
1393
  // ─── File Upload ───
1394
+ /** single_part 업로드 상한 (Notion API 규격 20MB) */
1395
+ static SINGLE_PART_MAX_BYTES = 20 * 1024 * 1024;
1396
+ /** multi_part 파트 크기 — 규격상 마지막 파트 제외 5~20MB, 10MB 고정 사용 */
1397
+ static MULTI_PART_CHUNK_BYTES = 10 * 1024 * 1024;
1332
1398
  async uploadFile(fileData, filename, contentType) {
1399
+ if (fileData.type !== contentType) {
1400
+ fileData = new Blob([fileData], { type: contentType });
1401
+ }
1402
+ if (fileData.size > _NotionClient.SINGLE_PART_MAX_BYTES) {
1403
+ return this.uploadFileMultiPart(fileData, filename, contentType);
1404
+ }
1333
1405
  const upload = await this.withRateLimit(
1334
1406
  () => this.client.fileUploads.create({ filename, content_type: contentType })
1335
1407
  );
@@ -1348,6 +1420,34 @@ var NotionClient = class _NotionClient {
1348
1420
  }
1349
1421
  return fileUploadId;
1350
1422
  }
1423
+ /** 20MB 초과 파일의 multi_part 업로드 — part_number 는 API 규격상 문자열("1"부터) */
1424
+ async uploadFileMultiPart(fileData, filename, contentType) {
1425
+ const chunkSize = _NotionClient.MULTI_PART_CHUNK_BYTES;
1426
+ const numberOfParts = Math.ceil(fileData.size / chunkSize);
1427
+ const upload = await this.withRateLimit(
1428
+ () => this.client.fileUploads.create({
1429
+ mode: "multi_part",
1430
+ number_of_parts: numberOfParts,
1431
+ filename,
1432
+ content_type: contentType
1433
+ })
1434
+ );
1435
+ const fileUploadId = upload.id;
1436
+ for (let part = 1; part <= numberOfParts; part++) {
1437
+ const chunk = fileData.slice((part - 1) * chunkSize, part * chunkSize, contentType);
1438
+ await this.withRateLimit(
1439
+ () => this.client.fileUploads.send({
1440
+ file_upload_id: fileUploadId,
1441
+ part_number: String(part),
1442
+ file: { data: chunk, filename }
1443
+ })
1444
+ );
1445
+ }
1446
+ await this.withRateLimit(
1447
+ () => this.client.fileUploads.complete({ file_upload_id: fileUploadId })
1448
+ );
1449
+ return fileUploadId;
1450
+ }
1351
1451
  // ─── Search & Navigation ───
1352
1452
  async search(params) {
1353
1453
  const response = await this.withRateLimit(
@@ -2224,7 +2324,23 @@ var COLUMN_LIST_START = compactMarker("column-list:start");
2224
2324
  var COLUMN_LIST_END = compactMarker("column-list:end");
2225
2325
  var COLUMN_SEP = compactMarker("column");
2226
2326
  var TOC_MARKER = compactMarker("toc");
2327
+ function syncedStartMarker(kind, url) {
2328
+ return compactMarker(`synced:start:kind=${kind}&url=${encodeURIComponent(url)}`);
2329
+ }
2330
+ var SYNCED_END = compactMarker("synced:end");
2227
2331
  var BREADCRUMB_MARKER = compactMarker("breadcrumb");
2332
+ function calloutStyleMarker(params) {
2333
+ const kv = [];
2334
+ if (params.icon) kv.push(`icon=${encodeURIComponent(params.icon)}`);
2335
+ if (params.color) kv.push(`color=${encodeURIComponent(params.color)}`);
2336
+ return compactMarker(`callout-style:${kv.join("&")}`);
2337
+ }
2338
+ function toggleColorMarker(color) {
2339
+ return compactMarker(`toggle-color:${color}`);
2340
+ }
2341
+ function blockColorMarker(color) {
2342
+ return compactMarker(`block-color:${color}`);
2343
+ }
2228
2344
  var PROPERTIES_TAG = `# ${MARKER_BRAND}:properties`;
2229
2345
  var MARKER_BRAND_RE = MARKER_BRAND;
2230
2346
 
@@ -2312,6 +2428,50 @@ var FrontmatterExtractor = class {
2312
2428
  }
2313
2429
  };
2314
2430
 
2431
+ // src/utils/md-regions.ts
2432
+ function computeAnchor(content, idx) {
2433
+ const before = content.slice(Math.max(0, idx - 32), idx);
2434
+ const sameLine = before.split("\n").pop() ?? "";
2435
+ if (sameLine.trim().length >= 4) return sameLine;
2436
+ const prevLines = content.slice(0, idx).split("\n");
2437
+ for (let i = prevLines.length - 2; i >= 0; i--) {
2438
+ const line = prevLines[i].trim();
2439
+ if (line.length > 0) return line.slice(0, 48);
2440
+ }
2441
+ return "";
2442
+ }
2443
+ function mapOutsideCodeFences(content, fn) {
2444
+ const lines = content.split("\n");
2445
+ const out = [];
2446
+ let buffer = [];
2447
+ let fence = null;
2448
+ const flush = () => {
2449
+ if (buffer.length > 0) {
2450
+ out.push(fn(buffer.join("\n")));
2451
+ buffer = [];
2452
+ }
2453
+ };
2454
+ for (const line of lines) {
2455
+ const fenceMatch = line.match(/^\s*(```+|~~~+)/);
2456
+ if (fence === null && fenceMatch) {
2457
+ flush();
2458
+ fence = fenceMatch[1][0].repeat(3);
2459
+ out.push(line);
2460
+ continue;
2461
+ }
2462
+ if (fence !== null) {
2463
+ out.push(line);
2464
+ if (fenceMatch && fenceMatch[1].startsWith(fence)) {
2465
+ fence = null;
2466
+ }
2467
+ continue;
2468
+ }
2469
+ buffer.push(line);
2470
+ }
2471
+ flush();
2472
+ return out.join("\n");
2473
+ }
2474
+
2315
2475
  // src/converter/pre-processors/wikilink.ts
2316
2476
  var WIKILINK_REGEX2 = /(?<!!)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
2317
2477
  var WikilinkResolver = class {
@@ -2328,7 +2488,7 @@ var WikilinkResolver = class {
2328
2488
  const preserveMarkers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
2329
2489
  const resolved = input.content.replace(
2330
2490
  WIKILINK_REGEX2,
2331
- (_match, target, display) => {
2491
+ (_match, target, display, offset) => {
2332
2492
  const label = display ?? target;
2333
2493
  if (this.resolve) {
2334
2494
  const entry = this.resolve(target);
@@ -2336,10 +2496,12 @@ var WikilinkResolver = class {
2336
2496
  return `<mention-page id="${entry.notionPageId}">${label}</mention-page>`;
2337
2497
  }
2338
2498
  }
2499
+ const params = display ? { text: target, display } : { text: target };
2500
+ params.__anchor = computeAnchor(input.content, offset);
2339
2501
  preserveMarkers.push({
2340
2502
  type: "wikilink",
2341
- params: display ? { text: target, display } : { text: target },
2342
- startIndex: 0
2503
+ params,
2504
+ startIndex: offset
2343
2505
  });
2344
2506
  const encodedTarget = encodeURIComponent(target);
2345
2507
  return `[${label}](${WIKILINK_PROTOCOL}${encodedTarget})`;
@@ -2444,6 +2606,11 @@ var EmbedResolver = class {
2444
2606
  }
2445
2607
  return `![${target}](${encodeURI(target)})`;
2446
2608
  }
2609
+ if (isPush && !isExternalUrl(target) && isAttachmentFile(target)) {
2610
+ const fileName = target.split("/").pop() ?? target;
2611
+ return `> \u{1F4CE} ${fileName}
2612
+ > ${spacedMarker(`local-file:${target}`)}`;
2613
+ }
2447
2614
  const encoded = encodeURIComponent(target);
2448
2615
  return `[${target}](${EMBED_PROTOCOL}${encoded})`;
2449
2616
  });
@@ -2466,6 +2633,11 @@ var EmbedResolver = class {
2466
2633
  function isImageFile(path) {
2467
2634
  return /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(path);
2468
2635
  }
2636
+ function isAttachmentFile(target) {
2637
+ const path = target.split("|")[0];
2638
+ const ext = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase();
2639
+ return ext !== void 0 && ext !== "md" && ext !== "canvas";
2640
+ }
2469
2641
  function isVideoUrl(url) {
2470
2642
  return VIDEO_HOSTS.some((host) => url.includes(host));
2471
2643
  }
@@ -2484,7 +2656,7 @@ var PreserveMarkerCollector = class {
2484
2656
  for (const match of matches) {
2485
2657
  markers.push({
2486
2658
  type: match[1],
2487
- params: parseMarkerParams(match[2]),
2659
+ params: parseMarkerParams(match[2], input.content, match.index),
2488
2660
  startIndex: match.index
2489
2661
  });
2490
2662
  }
@@ -2494,14 +2666,19 @@ var PreserveMarkerCollector = class {
2494
2666
  };
2495
2667
  }
2496
2668
  };
2497
- function parseMarkerParams(paramString) {
2669
+ function parseMarkerParams(paramString, content, index) {
2498
2670
  const params = {};
2499
- for (const pair of paramString.split("&")) {
2500
- const eqIdx = pair.indexOf("=");
2501
- if (eqIdx > 0) {
2502
- params[pair.slice(0, eqIdx)] = decodeURIComponent(pair.slice(eqIdx + 1));
2671
+ if (paramString.includes("=")) {
2672
+ for (const pair of paramString.split("&")) {
2673
+ const eqIdx = pair.indexOf("=");
2674
+ if (eqIdx > 0) {
2675
+ params[pair.slice(0, eqIdx)] = decodeURIComponent(pair.slice(eqIdx + 1));
2676
+ }
2503
2677
  }
2678
+ } else {
2679
+ params.__raw = paramString;
2504
2680
  }
2681
+ params.__anchor = computeAnchor(content, index);
2505
2682
  return params;
2506
2683
  }
2507
2684
 
@@ -2522,7 +2699,20 @@ var UnsupportedBlockStripper = class {
2522
2699
  }
2523
2700
  };
2524
2701
  function stringifyFrontmatter(content, data) {
2525
- return matter.stringify({ content }, data);
2702
+ const out = matter.stringify(
2703
+ { content },
2704
+ data
2705
+ );
2706
+ return unquoteFrontmatterDates(out);
2707
+ }
2708
+ var QUOTED_DATE_LINE_RE = /^([ \t]*[^:\n]+:[ \t]*)'(\d{4}-\d{2}-\d{2})'([ \t]*)$/gm;
2709
+ function unquoteFrontmatterDates(out) {
2710
+ if (!out.startsWith("---\n")) return out;
2711
+ const close = out.indexOf("\n---\n", 3);
2712
+ if (close === -1) return out;
2713
+ const fmEnd = close + "\n---\n".length;
2714
+ const fm = out.slice(0, fmEnd).replace(QUOTED_DATE_LINE_RE, "$1$2$3");
2715
+ return fm + out.slice(fmEnd);
2526
2716
  }
2527
2717
 
2528
2718
  // src/converter/pre-processors/properties-table.ts
@@ -2557,6 +2747,7 @@ var PropertiesTableInjector = class {
2557
2747
 
2558
2748
  // src/converter/pre-processors/html-annotation.ts
2559
2749
  var UNDERLINE_REGEX = /<u>([\s\S]*?)<\/u>/g;
2750
+ var HIGHLIGHT_REGEX = /(?<!=)==([^=\n]+?)==(?!=)/g;
2560
2751
  var COLOR_SPAN_REGEX = /<span class="notion-(\w+)(-bg)?">([\s\S]*?)<\/span>/g;
2561
2752
  var SPACED_COLOR_REGEX = new RegExp(
2562
2753
  `%% ${MARKER_BRAND_RE}:color:(\\w+(?:_background)?) %%([\\s\\S]*?)%% ${MARKER_BRAND_RE}:end %%`,
@@ -2585,10 +2776,147 @@ var InlineAnnotationPreserver = class {
2585
2776
  return `${compactMarker(`color:${color}`)}${text}%%/color%%`;
2586
2777
  }
2587
2778
  );
2779
+ content = mapOutsideCodeFences(
2780
+ content,
2781
+ (segment) => segment.replace(
2782
+ HIGHLIGHT_REGEX,
2783
+ (_m, text) => `${compactMarker("color:yellow_bg")}${text}%%/color%%`
2784
+ )
2785
+ );
2588
2786
  return { content, metadata: input.metadata };
2589
2787
  }
2590
2788
  };
2591
2789
 
2790
+ // src/converter/pre-processors/comment-stripper.ts
2791
+ var COMMENT_REGEX = /%%([\s\S]*?)%%/g;
2792
+ var HTML_COMMENT_REGEX = /<!--([\s\S]*?)-->/g;
2793
+ var CommentStripper = class {
2794
+ name = "CommentStripper";
2795
+ // FrontmatterExtractor(10) 뒤 — YAML 값 속 `%%` 를 본문 주석으로 오인하지 않는다.
2796
+ order = 11;
2797
+ process(input) {
2798
+ if (input.context.direction !== "push") {
2799
+ return { content: input.content, metadata: input.metadata };
2800
+ }
2801
+ const markers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
2802
+ const content = mapOutsideCodeFences(input.content, (segment) => {
2803
+ const afterObsidian = segment.replace(
2804
+ COMMENT_REGEX,
2805
+ (match, body, offset) => {
2806
+ const trimmed = body.trimStart();
2807
+ if (trimmed.startsWith(`${MARKER_BRAND}:`) || trimmed.startsWith("/")) {
2808
+ return match;
2809
+ }
2810
+ markers.push({
2811
+ type: "comment",
2812
+ params: { text: body, __anchor: computeAnchor(segment, offset) },
2813
+ startIndex: offset
2814
+ });
2815
+ return "";
2816
+ }
2817
+ );
2818
+ return afterObsidian.replace(HTML_COMMENT_REGEX, (_match, body, offset) => {
2819
+ markers.push({
2820
+ type: "comment",
2821
+ params: { text: body, style: "html", __anchor: computeAnchor(afterObsidian, offset) },
2822
+ startIndex: offset
2823
+ });
2824
+ return "";
2825
+ });
2826
+ });
2827
+ return {
2828
+ content,
2829
+ metadata: { ...input.metadata, preserveMarkers: markers }
2830
+ };
2831
+ }
2832
+ };
2833
+
2834
+ // src/converter/pre-processors/footnote-guard.ts
2835
+ var FOOTNOTE_TOKEN_REGEX = /(?<!\\)\[\^([^\]\s]+)\]/g;
2836
+ var FootnoteGuard = class {
2837
+ name = "FootnoteGuard";
2838
+ order = 12;
2839
+ process(input) {
2840
+ if (input.context.direction !== "push") {
2841
+ return { content: input.content, metadata: input.metadata };
2842
+ }
2843
+ const content = mapOutsideCodeFences(
2844
+ input.content,
2845
+ (segment) => segment.replace(FOOTNOTE_TOKEN_REGEX, "\\[^$1]")
2846
+ );
2847
+ return { content, metadata: input.metadata };
2848
+ }
2849
+ };
2850
+
2851
+ // src/converter/pre-processors/table-alignment.ts
2852
+ var DELIMITER_CELL_RE = /^:?-{1,}:?$/;
2853
+ function parseDelimiterRow(line) {
2854
+ const trimmed = line.trim();
2855
+ if (!trimmed.includes("-") || !trimmed.includes("|")) return null;
2856
+ const inner = trimmed.replace(/^\|/, "").replace(/\|$/, "");
2857
+ const cells = inner.split("|").map((c) => c.trim());
2858
+ if (cells.length === 0) return null;
2859
+ return cells.every((c) => DELIMITER_CELL_RE.test(c)) ? cells : null;
2860
+ }
2861
+ function cellAlignment(cell) {
2862
+ const l = cell.startsWith(":");
2863
+ const r = cell.endsWith(":");
2864
+ if (l && r) return "center";
2865
+ if (r) return "right";
2866
+ if (l) return "left";
2867
+ return "none";
2868
+ }
2869
+ function alignmentsToDelimiter(alignments) {
2870
+ const cells = alignments.map((a) => {
2871
+ switch (a) {
2872
+ case "center":
2873
+ return ":---:";
2874
+ case "right":
2875
+ return "---:";
2876
+ case "left":
2877
+ return ":---";
2878
+ default:
2879
+ return "---";
2880
+ }
2881
+ });
2882
+ return `| ${cells.join(" | ")} |`;
2883
+ }
2884
+ var TableAlignmentGuard = class {
2885
+ name = "TableAlignmentGuard";
2886
+ order = 45;
2887
+ process(input) {
2888
+ if (input.context.direction !== "push") {
2889
+ return { content: input.content, metadata: input.metadata };
2890
+ }
2891
+ const markers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
2892
+ const lines = input.content.split("\n");
2893
+ let changed = false;
2894
+ for (let i = 1; i < lines.length; i++) {
2895
+ const cells = parseDelimiterRow(lines[i]);
2896
+ if (!cells || !lines[i].includes(":")) continue;
2897
+ const prev = lines[i - 1];
2898
+ if (!prev.includes("|")) continue;
2899
+ markers.push({
2900
+ type: "table-align",
2901
+ params: {
2902
+ align: cells.map(cellAlignment).join(","),
2903
+ __anchor: prev.trim().slice(0, 64),
2904
+ // 원본 구분행 원문. 복원 시 정렬뿐 아니라 셀 폭 패딩까지 그대로 되살려
2905
+ // 라운드트립 delta-0 을 만족시킨다(__ 접두라 마커 렌더링에서 제외).
2906
+ __line: lines[i]
2907
+ },
2908
+ startIndex: 0
2909
+ });
2910
+ lines[i] = alignmentsToDelimiter(cells.map(() => "none"));
2911
+ changed = true;
2912
+ }
2913
+ return {
2914
+ content: changed ? lines.join("\n") : input.content,
2915
+ metadata: { ...input.metadata, preserveMarkers: markers }
2916
+ };
2917
+ }
2918
+ };
2919
+
2592
2920
  // src/converter/link-restore.ts
2593
2921
  var NOTION_LINK_REGEX = /\[([^\]]+)\]\((?:https:\/\/(?:www\.)?notion\.so\/|notion:\/\/)([a-f0-9-]+)\)/g;
2594
2922
  var WIKILINK_PRESERVE_REGEX = new RegExp(`\\[([^\\]]+)\\]\\(${WIKILINK_PROTOCOL}([^)]+)\\)`, "g");
@@ -2620,6 +2948,91 @@ var MentionToWikilink = class {
2620
2948
  }
2621
2949
  };
2622
2950
 
2951
+ // src/converter/post-processors/escape-normalizer.ts
2952
+ var EscapeNormalizer = class {
2953
+ name = "EscapeNormalizer";
2954
+ order = 3;
2955
+ process(input) {
2956
+ if (input.context.direction !== "pull") {
2957
+ return { content: input.content, metadata: input.metadata };
2958
+ }
2959
+ const content = mapOutsideCodeFences(
2960
+ input.content,
2961
+ (segment) => segment.replace(/\\\[\^([^\]\\\n]+)\\\]/g, "[^$1]").replace(/\\\[\^/g, "[^").replace(/\\:/g, ":")
2962
+ );
2963
+ return { content, metadata: input.metadata };
2964
+ }
2965
+ };
2966
+
2967
+ // src/converter/post-processors/highlight-restorer.ts
2968
+ var COMPACT_YELLOW_RE = new RegExp(
2969
+ `%%${MARKER_BRAND_RE}:color:yellow(?:_background|_bg)%%([^\\n]*?)%%\\/color%%`,
2970
+ "g"
2971
+ );
2972
+ var SPACED_YELLOW_RE = new RegExp(
2973
+ `%% ${MARKER_BRAND_RE}:color:yellow(?:_background|_bg) %%([^\\n]*?)%% ${MARKER_BRAND_RE}:end %%`,
2974
+ "g"
2975
+ );
2976
+ var HighlightRestorer = class {
2977
+ name = "HighlightRestorer";
2978
+ order = 25;
2979
+ process(input) {
2980
+ if (input.context.direction !== "pull") {
2981
+ return { content: input.content, metadata: input.metadata };
2982
+ }
2983
+ const content = input.content.replace(COMPACT_YELLOW_RE, (_m, text) => text ? `==${text}==` : "").replace(SPACED_YELLOW_RE, (_m, text) => text ? `==${text}==` : "");
2984
+ return { content, metadata: input.metadata };
2985
+ }
2986
+ };
2987
+
2988
+ // src/converter/post-processors/table-alignment-restorer.ts
2989
+ var TableAlignmentRestorer = class {
2990
+ name = "TableAlignmentRestorer";
2991
+ order = 35;
2992
+ process(input) {
2993
+ if (input.context.direction !== "pull") {
2994
+ return { content: input.content, metadata: input.metadata };
2995
+ }
2996
+ const lines = input.content.split("\n");
2997
+ for (let i = 1; i < lines.length - 1; i++) {
2998
+ const delim = parseDelimiterRow(lines[i]);
2999
+ if (!delim || lines[i].includes(":")) continue;
3000
+ const phantom = parseDelimiterRow(lines[i + 1]);
3001
+ if (!phantom || !lines[i + 1].includes(":") || phantom.length !== delim.length) continue;
3002
+ if (!lines[i - 1].includes("|")) continue;
3003
+ lines[i] = alignmentsToDelimiter(phantom.map(cellAlignment));
3004
+ lines.splice(i + 1, 1);
3005
+ }
3006
+ const markers = input.metadata.preserveMarkers ?? [];
3007
+ const remaining = [];
3008
+ for (const marker of markers) {
3009
+ if (marker.type !== "table-align" || !marker.params.align || !marker.params.__anchor) {
3010
+ remaining.push(marker);
3011
+ continue;
3012
+ }
3013
+ const anchor = marker.params.__anchor;
3014
+ const idx = lines.findIndex(
3015
+ (line, i) => i < lines.length - 1 && line.trim().startsWith(anchor) && parseDelimiterRow(lines[i + 1]) !== null
3016
+ );
3017
+ if (idx === -1) continue;
3018
+ const delimCells = parseDelimiterRow(lines[idx + 1]);
3019
+ if (lines[idx + 1].includes(":")) continue;
3020
+ const originalLine = marker.params.__line;
3021
+ if (originalLine && parseDelimiterRow(originalLine)?.length === delimCells.length) {
3022
+ lines[idx + 1] = originalLine;
3023
+ continue;
3024
+ }
3025
+ const alignments = marker.params.align.split(",");
3026
+ while (alignments.length < delimCells.length) alignments.push("none");
3027
+ lines[idx + 1] = alignmentsToDelimiter(alignments.slice(0, delimCells.length));
3028
+ }
3029
+ return {
3030
+ content: lines.join("\n"),
3031
+ metadata: { ...input.metadata, preserveMarkers: remaining }
3032
+ };
3033
+ }
3034
+ };
3035
+
2623
3036
  // src/converter/post-processors/preserve-marker-injector.ts
2624
3037
  var PreserveMarkerInjector = class {
2625
3038
  name = "PreserveMarkerInjector";
@@ -2633,23 +3046,90 @@ var PreserveMarkerInjector = class {
2633
3046
  return { content: input.content, metadata: input.metadata };
2634
3047
  }
2635
3048
  let content = input.content;
2636
- const missingMarkers = markers.filter((m) => !content.includes(this.formatMarker(m)));
2637
- if (missingMarkers.length === 0) {
3049
+ const missing = markers.filter((m) => {
3050
+ const rendered = this.renderMarker(m);
3051
+ if (rendered === null) return false;
3052
+ if (content.includes(rendered)) return false;
3053
+ return !this.isSemanticallyRestored(m, content);
3054
+ });
3055
+ if (missing.length === 0) {
2638
3056
  return { content, metadata: input.metadata };
2639
3057
  }
2640
- const sorted = [...missingMarkers].sort((a, b) => a.startIndex - b.startIndex);
2641
- for (const m of sorted) {
2642
- const markerText = this.formatMarker(m);
2643
- const at = Math.max(0, Math.min(m.startIndex, content.length));
2644
- content = content.slice(0, at) + markerText + content.slice(at);
3058
+ const sorted = [...missing].sort((a, b) => a.startIndex - b.startIndex);
3059
+ for (const marker of sorted) {
3060
+ const rendered = this.renderMarker(marker);
3061
+ if (marker.params?.__anchor === void 0) {
3062
+ const at2 = Math.max(frontmatterEnd(content), Math.min(marker.startIndex, content.length));
3063
+ content = content.slice(0, at2) + rendered + content.slice(at2);
3064
+ continue;
3065
+ }
3066
+ const at = this.findInsertionPoint(marker, content);
3067
+ const before = content.slice(0, at);
3068
+ const needsNewline = before.length > 0 && !before.endsWith("\n");
3069
+ content = before + (needsNewline ? "\n" : "") + rendered + "\n" + content.slice(at);
2645
3070
  }
2646
3071
  return { content, metadata: input.metadata };
2647
3072
  }
2648
- formatMarker(marker) {
2649
- const params = Object.entries(marker.params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
2650
- return spacedMarker(`${marker.type}:${params}`);
3073
+ /** 마커의 본문 표현. 복원 불가능한(빈) 마커는 null — 삽입하지 않고 폐기한다. */
3074
+ renderMarker(marker) {
3075
+ const params = marker.params ?? {};
3076
+ if (marker.type === "comment") {
3077
+ if (!params.text) return null;
3078
+ return params.style === "html" ? `<!--${params.text}-->` : `%%${params.text}%%`;
3079
+ }
3080
+ if (params.__raw !== void 0) {
3081
+ return spacedMarker(`${marker.type}:${params.__raw}`);
3082
+ }
3083
+ const entries = Object.entries(params).filter(([key]) => !key.startsWith("__"));
3084
+ if (entries.length === 0) return null;
3085
+ const body = entries.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
3086
+ return spacedMarker(`${marker.type}:${body}`);
3087
+ }
3088
+ /** 마커가 이미 원래 문법으로 복원돼 있으면 재삽입은 중복·오염이다. */
3089
+ isSemanticallyRestored(marker, content) {
3090
+ const params = marker.params ?? {};
3091
+ switch (marker.type) {
3092
+ case "wikilink": {
3093
+ const text = params.text;
3094
+ if (!text) return false;
3095
+ return content.includes(`[[${text}]]`) || content.includes(`[[${text}|`);
3096
+ }
3097
+ case "local-image":
3098
+ case "local-file": {
3099
+ const target = params.__raw ?? params.path;
3100
+ return target ? content.includes(`![[${target}]]`) : false;
3101
+ }
3102
+ case "table-align":
3103
+ return true;
3104
+ default:
3105
+ return false;
3106
+ }
3107
+ }
3108
+ /** 삽입 위치: 앵커 → startIndex(줄 경계 보정) 순. 항상 프론트매터 뒤. */
3109
+ findInsertionPoint(marker, content) {
3110
+ const fmEnd = frontmatterEnd(content);
3111
+ const anchor = marker.params?.__anchor;
3112
+ if (anchor && anchor.trim().length >= 4) {
3113
+ const idx = content.indexOf(anchor, fmEnd);
3114
+ if (idx !== -1) {
3115
+ return nextLineStart(content, idx + anchor.length);
3116
+ }
3117
+ }
3118
+ const clamped = Math.max(fmEnd, Math.min(marker.startIndex, content.length));
3119
+ return nextLineStart(content, clamped);
2651
3120
  }
2652
3121
  };
3122
+ function frontmatterEnd(content) {
3123
+ if (!content.startsWith("---\n")) return 0;
3124
+ const close = content.indexOf("\n---", 3);
3125
+ if (close === -1) return 0;
3126
+ const lineEnd = content.indexOf("\n", close + 4);
3127
+ return lineEnd === -1 ? content.length : lineEnd + 1;
3128
+ }
3129
+ function nextLineStart(content, pos) {
3130
+ const nl = content.indexOf("\n", pos);
3131
+ return nl === -1 ? content.length : nl + 1;
3132
+ }
2653
3133
 
2654
3134
  // src/converter/post-processors/callout-restorer.ts
2655
3135
  var EMOJI_TYPE_MAP = {
@@ -2785,7 +3265,7 @@ function normalizeDate(dateStr) {
2785
3265
 
2786
3266
  // src/converter/post-processors/local-image-restorer.ts
2787
3267
  var LOCAL_IMAGE_MARKER_REGEX = new RegExp(
2788
- `>\\s*\u{1F4CE}\\s*[^\\n]*\\n>\\s*%%\\s*${MARKER_BRAND_RE}:local-image:([^\\s]+)\\s*%%`,
3268
+ `>\\s*\u{1F4CE}\\s*[^\\n]*\\n>\\s*%%\\s*${MARKER_BRAND_RE}:local-(?:image|file):([^\\s]+)\\s*%%`,
2789
3269
  "g"
2790
3270
  );
2791
3271
  var LocalImageRestorer = class {
@@ -2897,25 +3377,178 @@ function stripQuotes(s) {
2897
3377
  return s;
2898
3378
  }
2899
3379
 
3380
+ // src/converter/post-processors/block-spacer.ts
3381
+ var BlockSpacer = class {
3382
+ name = "BlockSpacer";
3383
+ order = 110;
3384
+ process(input) {
3385
+ if (input.context.direction !== "pull") {
3386
+ return { content: input.content, metadata: input.metadata };
3387
+ }
3388
+ return {
3389
+ content: respace(input.content, input.metadata.notionExportCompact),
3390
+ metadata: input.metadata
3391
+ };
3392
+ }
3393
+ };
3394
+ var LIST_ITEM_RE = /^[ \t]*(?:[-*+]|\d+[.)])\s/;
3395
+ var QUOTE_RE = /^[ \t]*>/;
3396
+ var CALLOUT_START_RE = /^[ \t]*>\s*\[![^\]]*\]/;
3397
+ var TABLE_ROW_RE = /^[ \t]*\|/;
3398
+ var FOOTNOTE_DEF_RE = /^\[\^[^\]]+\]:/;
3399
+ var FENCE_RE = /^[ \t]*(```+|~~~+)/;
3400
+ var MATH_FENCE_RE = /^[ \t]*\$\$\s*$/;
3401
+ var BRAND_MARKER_LINE_RE = new RegExp(`^[ \\t]*%%\\s*${MARKER_BRAND}:`);
3402
+ var INDENTED_CONTINUATION_RE = /^(?:\t| {2,})\S/;
3403
+ function respace(content, sourceCompact) {
3404
+ if (sourceCompact === false) return content;
3405
+ const lines = content.split("\n");
3406
+ const head = [];
3407
+ let start = 0;
3408
+ if (lines[0]?.trim() === "---") {
3409
+ for (let i = 1; i < lines.length; i++) {
3410
+ if (lines[i].trim() === "---") {
3411
+ for (let j = 0; j <= i; j++) head.push(lines[j]);
3412
+ start = i + 1;
3413
+ break;
3414
+ }
3415
+ }
3416
+ }
3417
+ if (sourceCompact === void 0 && hasBlankOutsideFences(lines, start)) return content;
3418
+ const blocks = [];
3419
+ let fence = null;
3420
+ let fenceToken = "";
3421
+ let boundary = false;
3422
+ const push = (kind, line) => {
3423
+ blocks.push({ kind, lines: [line] });
3424
+ };
3425
+ const appendToLast = (line) => {
3426
+ blocks[blocks.length - 1].lines.push(line);
3427
+ };
3428
+ const last = () => blocks[blocks.length - 1];
3429
+ for (let i = start; i < lines.length; i++) {
3430
+ const raw = lines[i];
3431
+ if (fence !== null) {
3432
+ appendToLast(raw);
3433
+ const closed = fence === "code" ? FENCE_RE.test(raw) && raw.trim().startsWith(fenceToken) : MATH_FENCE_RE.test(raw);
3434
+ if (closed) fence = null;
3435
+ continue;
3436
+ }
3437
+ if (raw.trim() === "") {
3438
+ boundary = true;
3439
+ continue;
3440
+ }
3441
+ const atBoundary = boundary;
3442
+ boundary = false;
3443
+ const fenceMatch = raw.match(FENCE_RE);
3444
+ if (fenceMatch) {
3445
+ push("fence", raw);
3446
+ fence = "code";
3447
+ fenceToken = fenceMatch[1][0].repeat(3);
3448
+ continue;
3449
+ }
3450
+ if (MATH_FENCE_RE.test(raw)) {
3451
+ push("math", raw);
3452
+ fence = "math";
3453
+ continue;
3454
+ }
3455
+ const line = LIST_ITEM_RE.test(raw) || INDENTED_CONTINUATION_RE.test(raw) ? raw.replace(/^\t+/, (t) => " ".repeat(t.length)) : raw;
3456
+ if (BRAND_MARKER_LINE_RE.test(line) && blocks.length > 0) {
3457
+ appendToLast(line);
3458
+ continue;
3459
+ }
3460
+ if (LIST_ITEM_RE.test(line)) {
3461
+ if (last()?.kind === "list" && !atBoundary) appendToLast(line);
3462
+ else push("list", line);
3463
+ continue;
3464
+ }
3465
+ if (last()?.kind === "list" && INDENTED_CONTINUATION_RE.test(line)) {
3466
+ appendToLast(line);
3467
+ continue;
3468
+ }
3469
+ if (QUOTE_RE.test(line)) {
3470
+ if (last()?.kind === "quote" && !CALLOUT_START_RE.test(line) && !atBoundary) {
3471
+ appendToLast(line);
3472
+ } else {
3473
+ push("quote", line);
3474
+ }
3475
+ continue;
3476
+ }
3477
+ if (TABLE_ROW_RE.test(line)) {
3478
+ if (last()?.kind === "table" && !atBoundary) appendToLast(line);
3479
+ else push("table", line);
3480
+ continue;
3481
+ }
3482
+ if (FOOTNOTE_DEF_RE.test(line)) {
3483
+ if (last()?.kind === "footnote" && !atBoundary) appendToLast(line);
3484
+ else push("footnote", line);
3485
+ continue;
3486
+ }
3487
+ push("other", line);
3488
+ }
3489
+ const body = blocks.map((b) => b.lines.join("\n")).join("\n\n");
3490
+ const tail = content.endsWith("\n") ? "\n" : "";
3491
+ if (head.length === 0) return body + tail;
3492
+ return `${head.join("\n")}
3493
+
3494
+ ${body}${tail}`;
3495
+ }
3496
+ function isCompactExport(rawMarkdown) {
3497
+ return !hasBlankOutsideFences(rawMarkdown.split("\n"), 0);
3498
+ }
3499
+ function hasBlankOutsideFences(lines, start) {
3500
+ let end = lines.length - 1;
3501
+ while (end >= start && lines[end].trim() === "") end--;
3502
+ let fence = null;
3503
+ let fenceToken = "";
3504
+ for (let i = start; i <= end; i++) {
3505
+ const raw = lines[i];
3506
+ if (fence !== null) {
3507
+ const closed = fence === "code" ? FENCE_RE.test(raw) && raw.trim().startsWith(fenceToken) : MATH_FENCE_RE.test(raw);
3508
+ if (closed) fence = null;
3509
+ continue;
3510
+ }
3511
+ const fenceMatch = raw.match(FENCE_RE);
3512
+ if (fenceMatch) {
3513
+ fence = "code";
3514
+ fenceToken = fenceMatch[1][0].repeat(3);
3515
+ continue;
3516
+ }
3517
+ if (MATH_FENCE_RE.test(raw)) {
3518
+ fence = "math";
3519
+ continue;
3520
+ }
3521
+ if (raw.trim() === "") return true;
3522
+ }
3523
+ return false;
3524
+ }
3525
+
2900
3526
  // src/converter/pipeline-factory.ts
2901
3527
  function createDefaultPipeline(options) {
2902
3528
  const pipeline = new ConversionPipeline();
2903
3529
  pipeline.registerPreProcessor(new InlineAnnotationPreserver());
2904
3530
  pipeline.registerPreProcessor(new UnsupportedBlockStripper());
3531
+ pipeline.registerPreProcessor(new CommentStripper());
2905
3532
  pipeline.registerPreProcessor(new FrontmatterExtractor());
3533
+ pipeline.registerPreProcessor(new FootnoteGuard());
2906
3534
  pipeline.registerPreProcessor(new PropertiesTableInjector());
2907
3535
  pipeline.registerPreProcessor(new WikilinkResolver(options?.wikilinkResolver));
2908
3536
  pipeline.registerPreProcessor(new CalloutTransformer());
3537
+ pipeline.registerPreProcessor(new TableAlignmentGuard());
2909
3538
  pipeline.registerPreProcessor(new MathNormalizer());
2910
3539
  pipeline.registerPreProcessor(new EmbedResolver());
2911
3540
  pipeline.registerPreProcessor(new PreserveMarkerCollector());
3541
+ pipeline.registerPostProcessor(new EscapeNormalizer());
2912
3542
  pipeline.registerPostProcessor(new PropertiesTableRestorer());
2913
3543
  pipeline.registerPostProcessor(new LocalImageRestorer());
3544
+ pipeline.registerPostProcessor(new HighlightRestorer());
3545
+ pipeline.registerPostProcessor(new TableAlignmentRestorer());
2914
3546
  pipeline.registerPostProcessor(new PreserveMarkerInjector());
2915
3547
  pipeline.registerPostProcessor(new MentionToWikilink());
2916
3548
  pipeline.registerPostProcessor(new CalloutRestorer());
2917
3549
  pipeline.registerPostProcessor(new ColorAnnotator());
2918
3550
  pipeline.registerPostProcessor(new FrontmatterGenerator());
3551
+ pipeline.registerPostProcessor(new BlockSpacer());
2919
3552
  return pipeline;
2920
3553
  }
2921
3554
 
@@ -3728,6 +4361,87 @@ var EXTENSION_TO_MIME = {
3728
4361
  ".ico": "image/x-icon",
3729
4362
  ".tiff": "image/tiff"
3730
4363
  };
4364
+ function scanMarkdownLinks(markdown) {
4365
+ const spans = [];
4366
+ let i = 0;
4367
+ while (i < markdown.length) {
4368
+ const open = markdown.indexOf("[", i);
4369
+ if (open === -1) break;
4370
+ if (markdown[open - 1] === "\\") {
4371
+ i = open + 1;
4372
+ continue;
4373
+ }
4374
+ const isEmbed = markdown[open - 1] === "!" && markdown[open - 2] !== "\\";
4375
+ let depth = 1;
4376
+ let j = open + 1;
4377
+ let broken = false;
4378
+ while (j < markdown.length && depth > 0) {
4379
+ const ch = markdown[j];
4380
+ if (ch === "\\") {
4381
+ j += 2;
4382
+ continue;
4383
+ }
4384
+ if (ch === "\n" && markdown[j + 1] === "\n") {
4385
+ broken = true;
4386
+ break;
4387
+ }
4388
+ if (ch === "[") depth++;
4389
+ else if (ch === "]") depth--;
4390
+ j++;
4391
+ }
4392
+ if (broken || depth !== 0 || markdown[j] !== "(") {
4393
+ i = open + 1;
4394
+ continue;
4395
+ }
4396
+ const label = markdown.slice(open + 1, j - 1);
4397
+ let parenDepth = 1;
4398
+ let k = j + 1;
4399
+ while (k < markdown.length && parenDepth > 0) {
4400
+ const ch = markdown[k];
4401
+ if (ch === "\\") {
4402
+ k += 2;
4403
+ continue;
4404
+ }
4405
+ if (ch === "\n") {
4406
+ broken = true;
4407
+ break;
4408
+ }
4409
+ if (ch === "(") parenDepth++;
4410
+ else if (ch === ")") parenDepth--;
4411
+ k++;
4412
+ }
4413
+ if (broken || parenDepth !== 0) {
4414
+ i = open + 1;
4415
+ continue;
4416
+ }
4417
+ const start = isEmbed ? open - 1 : open;
4418
+ spans.push({
4419
+ full: markdown.slice(start, k),
4420
+ label,
4421
+ url: markdown.slice(j + 1, k - 1),
4422
+ isEmbed
4423
+ });
4424
+ i = k;
4425
+ }
4426
+ return spans;
4427
+ }
4428
+ function toWikilinkAlias(label) {
4429
+ return label.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, "$1").replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, t, d) => d ?? t).replace(/[\r\n]+/g, " ").replace(/\]\]/g, ")").replace(/\|/g, "-").trim();
4430
+ }
4431
+ var INTERNAL_FILE_URL_RE = /^file:\/\/%7B.*%7D%7D$/;
4432
+ var FILE_LABEL_PREFIX_RE = /^(?:📎|🎬|🎞|📄|🔊)\s*/u;
4433
+ var LOCAL_MARKER_RE = new RegExp(`${MARKER_BRAND_RE}:local-(?:image|file):([^\\s%]+)`, "g");
4434
+ function collectLocalMarkerBasenames(markdown) {
4435
+ const names = /* @__PURE__ */ new Set();
4436
+ const unescaped = markdown.replace(/\\/g, "");
4437
+ for (const m of unescaped.matchAll(LOCAL_MARKER_RE)) {
4438
+ names.add(baseName(m[1]));
4439
+ }
4440
+ return names;
4441
+ }
4442
+ function baseName(path) {
4443
+ return path.split("/").pop() ?? path;
4444
+ }
3731
4445
  var ImageHandler = class {
3732
4446
  constructor(vaultFs, attachmentFolder = "attachments", notionClient, customFetch, options, stateDb) {
3733
4447
  this.vaultFs = vaultFs;
@@ -3753,6 +4467,9 @@ var ImageHandler = class {
3753
4467
  return this.customFetch ?? globalThis.fetch;
3754
4468
  }
3755
4469
  async downloadImage(url, pageTitle) {
4470
+ return this.persistImage(await this.fetchImage(url, pageTitle));
4471
+ }
4472
+ async fetchImage(url, pageTitle) {
3756
4473
  const maxRetries = this.maxRetries;
3757
4474
  let lastError = null;
3758
4475
  for (let attempt = 0; attempt < maxRetries; attempt++) {
@@ -3770,38 +4487,67 @@ var ImageHandler = class {
3770
4487
  const contentType = response.headers.get("content-type") ?? "image/png";
3771
4488
  const extension = IMAGE_EXTENSIONS[contentType] ?? this.guessExtension(url) ?? ".png";
3772
4489
  const buffer = Buffer.from(await response.arrayBuffer());
3773
- const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 12);
4490
+ const fullHash = createHash("sha256").update(buffer).digest("hex");
4491
+ const hash = fullHash.slice(0, 12);
3774
4492
  const safeName = pageTitle.replace(/[^a-zA-Z0-9가-힣\s-]/g, "").replace(/\s+/g, "-").slice(0, 50);
3775
4493
  const fileName = `${safeName}-${hash}${extension}`;
3776
4494
  const localPath = `${this.attachmentFolder}/${fileName}`;
3777
- await this.vaultFs.ensureFolder(this.attachmentFolder);
3778
- await this.vaultFs.writeBinary(localPath, buffer);
3779
- return { originalUrl: url, localPath, hash, size: buffer.length };
4495
+ return { originalUrl: url, localPath, hash, fullHash, buffer };
3780
4496
  } catch (error) {
3781
4497
  lastError = error instanceof Error ? error : new Error(String(error));
3782
4498
  }
3783
4499
  }
3784
4500
  throw lastError;
3785
4501
  }
3786
- async downloadAllImages(markdown, pageTitle) {
3787
- const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
3788
- const matches = [...markdown.matchAll(imageRegex)];
3789
- const notionMatches = matches.filter((m) => m[2] && this.isNotionImageUrl(m[2]));
3790
- const internalImageRegex = /!\[([^\]]*)\]\((file:\/\/%7B.*?%7D%7D)\)/g;
3791
- const internalMatches = [...markdown.matchAll(internalImageRegex)];
4502
+ async persistImage(fetched) {
4503
+ await this.vaultFs.ensureFolder(this.attachmentFolder);
4504
+ await this.vaultFs.writeBinary(fetched.localPath, fetched.buffer);
4505
+ return {
4506
+ originalUrl: fetched.originalUrl,
4507
+ localPath: fetched.localPath,
4508
+ hash: fetched.hash,
4509
+ size: fetched.buffer.length
4510
+ };
4511
+ }
4512
+ async downloadAllImages(markdown, pageTitle, pageId) {
4513
+ const embeds = scanMarkdownLinks(markdown).filter((s) => s.isEmbed);
4514
+ const notionMatches = embeds.filter(
4515
+ (s) => /^https?:\/\//.test(s.url) && this.isNotionImageUrl(s.url)
4516
+ );
4517
+ const internalMatches = embeds.filter((s) => INTERNAL_FILE_URL_RE.test(s.url));
4518
+ for (const span of embeds) {
4519
+ if (isNotionAttachmentUri(span.url)) {
4520
+ getLogger().warn(
4521
+ `[Im-Nobsidian] \uC811\uADFC \uBD88\uAC00 \uCCA8\uBD80(attachment:) \uAC10\uC9C0 \u2014 Notion \uD1B5\uD569\uC5D0 \uACF5\uC720\uB418\uC9C0 \uC54A\uC740 \uC6D0\uBCF8\uC758 \uD30C\uC77C\uC774\uB77C \uB2E4\uC6B4\uB85C\uB4DC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uC6D0\uBB38\uC744 \uBCF4\uC874\uD569\uB2C8\uB2E4: ${pageTitle}`
4522
+ );
4523
+ }
4524
+ }
3792
4525
  if (notionMatches.length === 0 && internalMatches.length === 0) {
3793
4526
  return { content: markdown, downloads: [] };
3794
4527
  }
4528
+ const registryByHash = /* @__PURE__ */ new Map();
4529
+ if (pageId && this.stateDb) {
4530
+ for (const entry of this.stateDb.getFilesByPageId(pageId)) {
4531
+ const list = registryByHash.get(entry.fileHash) ?? [];
4532
+ list.push(entry.localPath);
4533
+ registryByHash.set(entry.fileHash, list);
4534
+ }
4535
+ }
4536
+ const markerBasenames = collectLocalMarkerBasenames(markdown);
4537
+ const isAppendedCopy = (fetched) => {
4538
+ const paths = registryByHash.get(fetched.fullHash);
4539
+ return paths !== void 0 && paths.some((p) => markerBasenames.has(baseName(p)));
4540
+ };
3795
4541
  const sema = new Sema(this.concurrency);
3796
4542
  const downloads = [];
3797
4543
  let result = markdown;
3798
4544
  const httpResults = await Promise.allSettled(
3799
- notionMatches.map(async (match) => {
4545
+ notionMatches.map(async (span) => {
3800
4546
  await sema.acquire();
3801
4547
  try {
3802
4548
  return {
3803
- match,
3804
- download: await this.downloadImage(match[2], pageTitle)
4549
+ span,
4550
+ fetched: await this.fetchImage(span.url, pageTitle)
3805
4551
  };
3806
4552
  } finally {
3807
4553
  sema.release();
@@ -3810,26 +4556,37 @@ var ImageHandler = class {
3810
4556
  );
3811
4557
  for (const settled of httpResults) {
3812
4558
  if (settled.status !== "fulfilled") continue;
3813
- const { match, download } = settled.value;
4559
+ const { span, fetched } = settled.value;
4560
+ if (isAppendedCopy(fetched)) {
4561
+ result = result.replace(span.full, "");
4562
+ continue;
4563
+ }
4564
+ const download = await this.persistImage(fetched);
3814
4565
  downloads.push(download);
3815
- const obsidianEmbed = `![[${download.localPath}${match[1] ? `|${match[1]}` : ""}]]`;
3816
- result = result.replace(match[0], obsidianEmbed);
4566
+ const alias = toWikilinkAlias(span.label);
4567
+ const obsidianEmbed = `![[${download.localPath}${alias ? `|${alias}` : ""}]]`;
4568
+ result = result.replace(span.full, obsidianEmbed);
3817
4569
  }
3818
- for (const match of internalMatches) {
4570
+ for (const span of internalMatches) {
3819
4571
  await sema.acquire();
3820
4572
  try {
3821
- const fileUrl = match[2];
3822
- const parsed = this.parseNotionFileUrl(fileUrl);
4573
+ const parsed = this.parseNotionFileUrl(span.url);
3823
4574
  if (!parsed || !this.notionClient) continue;
3824
4575
  const realUrl = await this.notionClient.getFileBlockUrl(parsed.blockId);
3825
4576
  if (!realUrl) {
3826
4577
  getLogger().warn(`[Im-Nobsidian] \uC774\uBBF8\uC9C0 Block URL \uC870\uD68C \uC2E4\uD328: ${parsed.fileName}`);
3827
4578
  continue;
3828
4579
  }
3829
- const download = await this.downloadImage(realUrl, pageTitle);
4580
+ const fetched = await this.fetchImage(realUrl, pageTitle);
4581
+ if (isAppendedCopy(fetched)) {
4582
+ result = result.replace(span.full, "");
4583
+ continue;
4584
+ }
4585
+ const download = await this.persistImage(fetched);
3830
4586
  downloads.push(download);
3831
- const obsidianEmbed = `![[${download.localPath}${match[1] ? `|${match[1]}` : ""}]]`;
3832
- result = result.replace(match[0], obsidianEmbed);
4587
+ const alias = toWikilinkAlias(span.label);
4588
+ const obsidianEmbed = `![[${download.localPath}${alias ? `|${alias}` : ""}]]`;
4589
+ result = result.replace(span.full, obsidianEmbed);
3833
4590
  } catch (error) {
3834
4591
  getLogger().warn(`\uB0B4\uBD80 \uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
3835
4592
  } finally {
@@ -3838,27 +4595,28 @@ var ImageHandler = class {
3838
4595
  }
3839
4596
  return { content: result, downloads };
3840
4597
  }
3841
- async uploadLocalImage(localPath) {
4598
+ async uploadLocalImage(localPath, noteFolder) {
3842
4599
  if (!this.notionClient) {
3843
4600
  throw new Error("NotionClient required for image upload");
3844
4601
  }
3845
- const buffer = await this.readImageFromVault(localPath);
4602
+ const { buffer, resolvedPath } = await this.readImageFromVault(localPath, noteFolder);
3846
4603
  const filename = localPath.split("/").pop() ?? "image.png";
3847
4604
  const contentType = this.getContentTypeFromPath(filename);
3848
4605
  const blob = new Blob([buffer], { type: contentType });
3849
4606
  const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
3850
4607
  const fileHash = createHash("sha256").update(buffer).digest("hex");
3851
- return { localPath, fileUploadId, fileHash, fileSize: buffer.length };
4608
+ return { localPath: resolvedPath, fileUploadId, fileHash, fileSize: buffer.length };
3852
4609
  }
3853
- async uploadAndAppendImages(pageId, images) {
4610
+ async uploadAndAppendImages(pageId, images, notePath) {
3854
4611
  if (!this.notionClient) return [];
3855
4612
  const localImages = images.filter((img) => !img.isExternal && img.localPath);
3856
4613
  if (localImages.length === 0) return [];
4614
+ const noteFolder = notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
3857
4615
  const results = [];
3858
4616
  const imageBlocks = [];
3859
4617
  for (const img of localImages) {
3860
4618
  try {
3861
- const result = await this.uploadLocalImage(img.localPath);
4619
+ const result = await this.uploadLocalImage(img.localPath, noteFolder);
3862
4620
  results.push(result);
3863
4621
  imageBlocks.push({
3864
4622
  type: "image",
@@ -3868,7 +4626,11 @@ var ImageHandler = class {
3868
4626
  }
3869
4627
  });
3870
4628
  } catch (error) {
3871
- getLogger().warn(`\uC774\uBBF8\uC9C0 \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${img.localPath}):`, error);
4629
+ if (error?.code === "ENOENT") {
4630
+ getLogger().warn(`\uC774\uBBF8\uC9C0 \uC5C5\uB85C\uB4DC \uAC74\uB108\uB700 \u2014 \uBCFC\uD2B8\uC5D0\uC11C \uD30C\uC77C\uC744 \uCC3E\uC9C0 \uBABB\uD568: ${img.localPath}`);
4631
+ } else {
4632
+ getLogger().warn(`\uC774\uBBF8\uC9C0 \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${img.localPath}):`, error);
4633
+ }
3872
4634
  }
3873
4635
  }
3874
4636
  if (imageBlocks.length > 0) {
@@ -3892,23 +4654,39 @@ var ImageHandler = class {
3892
4654
  }
3893
4655
  return results;
3894
4656
  }
3895
- async readImageFromVault(localPath) {
3896
- try {
3897
- return await this.vaultFs.readBinary(localPath);
3898
- } catch {
3899
- return await this.vaultFs.readBinary(`${this.attachmentFolder}/${localPath}`);
4657
+ /**
4658
+ * 임베드 경로를 볼트 실제 파일로 해석한다(F23). Obsidian 위키링크 임베드는 노트 옆
4659
+ * 파일을 파일명만으로 참조하는 게 관례라, 노트 폴더 → 볼트 루트 → attachments/ 순으로
4660
+ * 시도한다. 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
4661
+ */
4662
+ async readImageFromVault(localPath, noteFolder) {
4663
+ const candidates = [];
4664
+ if (noteFolder && !localPath.includes("/")) {
4665
+ candidates.push(`${noteFolder}/${localPath}`);
4666
+ }
4667
+ candidates.push(localPath, `${this.attachmentFolder}/${localPath}`);
4668
+ let lastError;
4669
+ for (const candidate of candidates) {
4670
+ try {
4671
+ return { buffer: await this.vaultFs.readBinary(candidate), resolvedPath: candidate };
4672
+ } catch (error) {
4673
+ lastError = error;
4674
+ }
3900
4675
  }
4676
+ throw lastError;
3901
4677
  }
3902
4678
  getContentTypeFromPath(filename) {
3903
4679
  const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
3904
4680
  return (ext && EXTENSION_TO_MIME[ext]) ?? "application/octet-stream";
3905
4681
  }
3906
4682
  async downloadAllFiles(markdown, pageTitle) {
3907
- const mdLinkRegex = /\[(?:📎|🎬|🎞|📄|🔊)\s*([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
3908
- const mdHttpMatches = [...markdown.matchAll(mdLinkRegex)];
3909
- const notionHttpMatches = mdHttpMatches.filter((m) => m[2] && this.isNotionFileUrl(m[2]));
3910
- const mdInternalRegex = /\[(?:📎|🎬|🎞|📄|🔊)\s*([^\]]*)\]\((file:\/\/%7B.*?%7D%7D)\)/g;
3911
- const mdInternalMatches = [...markdown.matchAll(mdInternalRegex)];
4683
+ const fileLinks = scanMarkdownLinks(markdown).filter(
4684
+ (s) => !s.isEmbed && FILE_LABEL_PREFIX_RE.test(s.label)
4685
+ );
4686
+ const notionHttpMatches = fileLinks.filter(
4687
+ (s) => /^https?:\/\//.test(s.url) && this.isNotionFileUrl(s.url)
4688
+ );
4689
+ const mdInternalMatches = fileLinks.filter((s) => INTERNAL_FILE_URL_RE.test(s.url));
3912
4690
  const xmlHttpRegex = /<(?:file|video|audio|pdf)\s+src="(https?:\/\/[^"]+)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
3913
4691
  const xmlHttpMatches = [...markdown.matchAll(xmlHttpRegex)];
3914
4692
  const notionXmlHttpMatches = xmlHttpMatches.filter((m) => m[1] && this.isNotionFileUrl(m[1]));
@@ -3921,38 +4699,36 @@ var ImageHandler = class {
3921
4699
  const sema = new Sema(this.concurrency);
3922
4700
  const downloads = [];
3923
4701
  let result = markdown;
3924
- for (const match of notionHttpMatches) {
4702
+ for (const span of notionHttpMatches) {
3925
4703
  await sema.acquire();
3926
4704
  try {
3927
- const url = match[2];
3928
- const caption = match[1]?.trim() || "file";
3929
- const download = await this.downloadFile(url, pageTitle, caption);
4705
+ const caption = toWikilinkAlias(span.label.replace(FILE_LABEL_PREFIX_RE, "")) || "file";
4706
+ const download = await this.downloadFile(span.url, pageTitle, caption);
3930
4707
  if (!download.localPath) continue;
3931
4708
  downloads.push(download);
3932
- const obsidianLink = `[[${download.localPath}|${caption}]]`;
3933
- result = result.replace(match[0], obsidianLink);
4709
+ const obsidianLink = `![[${download.localPath}|${caption}]]`;
4710
+ result = result.replace(span.full, obsidianLink);
3934
4711
  } catch (error) {
3935
4712
  getLogger().warn(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
3936
4713
  } finally {
3937
4714
  sema.release();
3938
4715
  }
3939
4716
  }
3940
- for (const match of mdInternalMatches) {
4717
+ for (const span of mdInternalMatches) {
3941
4718
  await sema.acquire();
3942
4719
  try {
3943
- const fileUrl = match[2];
3944
- const parsed = this.parseNotionFileUrl(fileUrl);
4720
+ const parsed = this.parseNotionFileUrl(span.url);
3945
4721
  if (!parsed || !this.notionClient) continue;
3946
4722
  const blockId = parsed.blockId;
3947
4723
  const fileName = parsed.fileName;
3948
4724
  const realUrl = await this.notionClient.getFileBlockUrl(blockId);
3949
4725
  if (!realUrl) continue;
3950
- const caption = fileName || match[1]?.trim() || "file";
4726
+ const caption = fileName || toWikilinkAlias(span.label.replace(FILE_LABEL_PREFIX_RE, "")) || "file";
3951
4727
  const download = await this.downloadFile(realUrl, pageTitle, caption);
3952
4728
  if (!download.localPath) continue;
3953
4729
  downloads.push(download);
3954
- const obsidianLink = `[[${download.localPath}|${caption}]]`;
3955
- result = result.replace(match[0], obsidianLink);
4730
+ const obsidianLink = `![[${download.localPath}|${caption}]]`;
4731
+ result = result.replace(span.full, obsidianLink);
3956
4732
  } catch (error) {
3957
4733
  getLogger().warn(`\uB0B4\uBD80 \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
3958
4734
  } finally {
@@ -3963,11 +4739,11 @@ var ImageHandler = class {
3963
4739
  await sema.acquire();
3964
4740
  try {
3965
4741
  const url = match[1];
3966
- const caption = match[2]?.trim() || "file";
4742
+ const caption = toWikilinkAlias(match[2] ?? "") || "file";
3967
4743
  const download = await this.downloadFile(url, pageTitle, caption);
3968
4744
  if (!download.localPath) continue;
3969
4745
  downloads.push(download);
3970
- const obsidianLink = `[[${download.localPath}|${caption}]]`;
4746
+ const obsidianLink = `![[${download.localPath}|${caption}]]`;
3971
4747
  result = result.replace(match[0], obsidianLink);
3972
4748
  } catch (error) {
3973
4749
  getLogger().warn(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
@@ -3985,11 +4761,11 @@ var ImageHandler = class {
3985
4761
  const fileName = parsed.fileName;
3986
4762
  const realUrl = await this.notionClient.getFileBlockUrl(blockId);
3987
4763
  if (!realUrl) continue;
3988
- const caption = fileName || match[2]?.trim() || "file";
4764
+ const caption = fileName || toWikilinkAlias(match[2] ?? "") || "file";
3989
4765
  const download = await this.downloadFile(realUrl, pageTitle, caption);
3990
4766
  if (!download.localPath) continue;
3991
4767
  downloads.push(download);
3992
- const obsidianLink = `[[${download.localPath}|${caption}]]`;
4768
+ const obsidianLink = `![[${download.localPath}|${caption}]]`;
3993
4769
  result = result.replace(match[0], obsidianLink);
3994
4770
  } catch (error) {
3995
4771
  getLogger().warn(`\uB0B4\uBD80 \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
@@ -4031,7 +4807,9 @@ var ImageHandler = class {
4031
4807
  const MAX_FILE_SIZE = this.maxFileSizeBytes;
4032
4808
  if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
4033
4809
  const sizeMB = Math.round(parseInt(contentLength, 10) / 1024 / 1024);
4034
- getLogger().debug(`[Im-Nobsidian] \uD30C\uC77C \uC2A4\uD0B5 \u2014 \uB108\uBB34 \uD07C (${sizeMB}MB): ${caption}`);
4810
+ getLogger().info(
4811
+ `[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}`
4812
+ );
4035
4813
  return { originalUrl: url, localPath: "", hash: "", size: 0 };
4036
4814
  }
4037
4815
  const buffer = Buffer.from(await response.arrayBuffer());
@@ -4053,7 +4831,26 @@ var ImageHandler = class {
4053
4831
  return this.isNotionFileUrl(url);
4054
4832
  }
4055
4833
  isNotionFileUrl(url) {
4056
- return url.includes("secure.notion-static.com") || url.includes("prod-files-secure") || url.includes("s3.us-west-2.amazonaws.com/secure.notion-static.com");
4834
+ return isNotionHostedFileUrl(url);
4835
+ }
4836
+ /**
4837
+ * notion-hosted 파일 URL 하나를 로컬 첨부로 로컬라이즈한다(P3-A).
4838
+ * DB 행의 `files` 속성처럼 본문 밖에 있는 서명 URL 용 — 서명 URL 은 약 1시간 뒤
4839
+ * 만료되어 frontmatter 에 남으면 깨진 링크가 되고, push 로 되밀면 Notion 원본이
4840
+ * external(만료 URL)로 오염된다(실측). notion-hosted 가 아닌 URL(사용자가 넣은
4841
+ * 진짜 외부 링크)은 다운로드 대상이 아니므로 null 을 반환한다.
4842
+ *
4843
+ * @returns 로컬 첨부 경로(`attachments/...`), 다운로드 대상이 아니거나 실패 시 null
4844
+ */
4845
+ async localizeNotionFileUrl(url, caption) {
4846
+ if (!/^https?:\/\//.test(url) || !this.isNotionFileUrl(url)) return null;
4847
+ try {
4848
+ const download = await this.downloadFile(url, caption, caption);
4849
+ return download.localPath || null;
4850
+ } catch (error) {
4851
+ getLogger().warn(`[Im-Nobsidian] \uC18D\uC131 \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328 (${caption}): ${error}`);
4852
+ return null;
4853
+ }
4057
4854
  }
4058
4855
  guessExtension(url) {
4059
4856
  const pathname = new URL(url).pathname;
@@ -4080,6 +4877,28 @@ var ImageHandler = class {
4080
4877
  return "";
4081
4878
  }
4082
4879
  };
4880
+
4881
+ // src/sync/stale-db-artifacts.ts
4882
+ var ARTIFACT_SUFFIXES = [".base", ".notion.json"];
4883
+ function isDbArtifactPath(path) {
4884
+ return ARTIFACT_SUFFIXES.some((suffix) => path.endsWith(suffix));
4885
+ }
4886
+ function selectStaleDbArtifacts(files, localFolder, keepPaths) {
4887
+ const prefix = `${localFolder}/`;
4888
+ const stale = [];
4889
+ for (const file of files) {
4890
+ const path = file.path;
4891
+ if (!path.startsWith(prefix)) continue;
4892
+ const rel = path.slice(prefix.length);
4893
+ if (rel.includes("/")) continue;
4894
+ if (!ARTIFACT_SUFFIXES.some((suffix) => rel.endsWith(suffix))) continue;
4895
+ if (keepPaths.has(path)) continue;
4896
+ stale.push(path);
4897
+ }
4898
+ return stale;
4899
+ }
4900
+
4901
+ // src/sync/file-handler.ts
4083
4902
  var EXTENSION_TO_BLOCK_TYPE = {
4084
4903
  ".png": "image",
4085
4904
  ".jpg": "image",
@@ -4188,7 +5007,7 @@ var FileHandler = class {
4188
5007
  stateDb;
4189
5008
  sema;
4190
5009
  async pushFilesForFolder(folderPageId, folderPath) {
4191
- const allFiles = await this.vaultFs.listNonMarkdownFiles();
5010
+ const allFiles = await this.listUploadableFiles();
4192
5011
  const folderFiles = allFiles.filter((f) => getFolderPath(f.path) === folderPath);
4193
5012
  if (folderFiles.length === 0) return [];
4194
5013
  const results = [];
@@ -4212,8 +5031,18 @@ var FileHandler = class {
4212
5031
  }
4213
5032
  return results;
4214
5033
  }
4215
- async pushAllFiles() {
5034
+ /**
5035
+ * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 제외한다.
5036
+ * node 측 VaultFS 는 과거 `.base` 를 워커에서 빼는 식으로 우회했지만, Obsidian
5037
+ * 어댑터는 모든 파일을 반환하므로 플러그인 push 가 사이드카를 Notion 첨부로
5038
+ * 오염시켰다. 소비자인 여기서 일괄 차단해 구현체 간 동작을 통일한다.
5039
+ */
5040
+ async listUploadableFiles() {
4216
5041
  const allFiles = await this.vaultFs.listNonMarkdownFiles();
5042
+ return allFiles.filter((f) => !isDbArtifactPath(f.path));
5043
+ }
5044
+ async pushAllFiles() {
5045
+ const allFiles = await this.listUploadableFiles();
4217
5046
  if (allFiles.length === 0) return [];
4218
5047
  const filesByFolder = /* @__PURE__ */ new Map();
4219
5048
  for (const file of allFiles) {
@@ -4442,6 +5271,44 @@ function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
4442
5271
  return `${localFolder}/${safeName} (${raw}).md`;
4443
5272
  }
4444
5273
 
5274
+ // src/utils/db-folder-path.ts
5275
+ function resolveDbFolderPath(folder, dbId, ownerOf) {
5276
+ const usable = (candidate) => {
5277
+ const owner = ownerOf(candidate);
5278
+ return owner === null || notionIdsEqual(owner, dbId);
5279
+ };
5280
+ if (usable(folder)) return folder;
5281
+ const raw = dbId.replace(/-/g, "");
5282
+ for (const len of [8, 16, 32]) {
5283
+ const candidate = `${folder} (${raw.slice(0, len)})`;
5284
+ if (usable(candidate)) return candidate;
5285
+ }
5286
+ return `${folder} (${raw})`;
5287
+ }
5288
+ function repairDbFolderCollisions(configs, takenFolders) {
5289
+ const owner = new Map(takenFolders ?? []);
5290
+ let repaired = 0;
5291
+ for (const config of configs) {
5292
+ const resolved = resolveDbFolderPath(
5293
+ config.localFolder,
5294
+ config.databaseId,
5295
+ (f) => owner.get(f) ?? null
5296
+ );
5297
+ if (resolved !== config.localFolder) {
5298
+ config.localFolder = resolved;
5299
+ repaired++;
5300
+ }
5301
+ owner.set(resolved, config.databaseId);
5302
+ }
5303
+ return repaired;
5304
+ }
5305
+ function isDirectDbRowPath(localFolder, path) {
5306
+ const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
5307
+ if (!path.startsWith(prefix)) return false;
5308
+ const rel = path.slice(prefix.length);
5309
+ return rel.length > 0 && !rel.includes("/");
5310
+ }
5311
+
4445
5312
  // src/utils/wikilink-title.ts
4446
5313
  function wikilinkTitleFromPath(obsidianPath) {
4447
5314
  return obsidianPath.replace(/\.md$/i, "").split("/").pop() ?? "";
@@ -4781,23 +5648,6 @@ function sortKeys(value) {
4781
5648
  return value;
4782
5649
  }
4783
5650
 
4784
- // src/sync/stale-db-artifacts.ts
4785
- var ARTIFACT_SUFFIXES = [".base", ".notion.json"];
4786
- function selectStaleDbArtifacts(files, localFolder, keepPaths) {
4787
- const prefix = `${localFolder}/`;
4788
- const stale = [];
4789
- for (const file of files) {
4790
- const path = file.path;
4791
- if (!path.startsWith(prefix)) continue;
4792
- const rel = path.slice(prefix.length);
4793
- if (rel.includes("/")) continue;
4794
- if (!ARTIFACT_SUFFIXES.some((suffix) => rel.endsWith(suffix))) continue;
4795
- if (keepPaths.has(path)) continue;
4796
- stale.push(path);
4797
- }
4798
- return stale;
4799
- }
4800
-
4801
5651
  // src/converter/enhanced-md-converter.ts
4802
5652
  var NOTION_CALLOUT_RE = /^::: callout\n([\s\S]*?)\n:::/gm;
4803
5653
  var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
@@ -4827,12 +5677,13 @@ function notionEnhancedToObsidian(enhanced) {
4827
5677
  result = convertNotionTables(result);
4828
5678
  result = convertSpans(result);
4829
5679
  result = convertDatabaseBlocks(result);
4830
- result = cleanInlineColorAttrs(result);
5680
+ result = convertBlockColorAttrs(result);
4831
5681
  result = removeEmptyBlocks(result);
4832
5682
  result = unescapePipes(result);
4833
5683
  result = unescapeNotionChars(result);
4834
5684
  result = unescapeWikilinkBrackets(result);
4835
5685
  result = ensureCalloutContinuity(result);
5686
+ result = separateAdjacentCallouts(result);
4836
5687
  return result;
4837
5688
  }
4838
5689
  function unescapeWikilinkBrackets(content) {
@@ -4846,9 +5697,12 @@ function obsidianToNotionEnhanced(obsidian) {
4846
5697
  result = restoreMediaTags(result);
4847
5698
  result = restoreUnknownBlocks(result);
4848
5699
  result = restoreColorSpans(result);
5700
+ result = restoreBlockColorMarkers(result);
4849
5701
  result = restoreUnderlineSpans(result);
4850
5702
  result = convertMentionPageIdToUrl(result);
4851
5703
  result = restoreWikilinkPreserveLinks(result);
5704
+ result = restoreSyncedBlocks(result);
5705
+ result = reassembleColumns(result);
4852
5706
  return result;
4853
5707
  }
4854
5708
  var MENTION_PAGE_ID_RE = /<mention-page id="([^"]+)">[\s\S]*?<\/mention-page>/g;
@@ -4941,29 +5795,36 @@ ${fence}
4941
5795
  }
4942
5796
  );
4943
5797
  }
4944
- var NO_CONTAINER_BODY = "(?:(?!<details>|<callout|<columns>)[\\s\\S])*?";
4945
- var SUMMARY_TITLE = "((?:(?!<\\/summary>|<details>|<summary>)[\\s\\S])*?)";
5798
+ var NO_CONTAINER_BODY = "(?:(?!<details|<callout|<columns)[\\s\\S])*?";
5799
+ var SUMMARY_TITLE = "((?:(?!<\\/summary>|<details|<summary>)[\\s\\S])*?)";
4946
5800
  var INNERMOST_DETAILS_RE = new RegExp(
4947
- `([\\t ]*)<details>\\s*<summary>${SUMMARY_TITLE}<\\/summary>(${NO_CONTAINER_BODY})<\\/details>`,
5801
+ `([\\t ]*)<details([^>]*)>[ \\t]*\\n?(?:\\s*<summary>${SUMMARY_TITLE}<\\/summary>)?(${NO_CONTAINER_BODY})<\\/details>`,
4948
5802
  "g"
4949
5803
  );
4950
5804
  var INNERMOST_CALLOUT_RE = new RegExp(
4951
- `([\\t ]*)<callout[^>]*>\\n?(${NO_CONTAINER_BODY})<\\/callout>`,
5805
+ `([\\t ]*)<callout([^>]*)>\\n?(${NO_CONTAINER_BODY})<\\/callout>`,
4952
5806
  "g"
4953
5807
  );
4954
5808
  var INNERMOST_COLUMNS_RE = new RegExp(
4955
- `([\\t ]*)<columns>\\n?(${NO_CONTAINER_BODY})<\\/columns>`,
5809
+ `([\\t ]*)<columns[^>]*>\\n?(${NO_CONTAINER_BODY})<\\/columns>`,
4956
5810
  "g"
4957
5811
  );
4958
- var COLUMN_WRAP_RE = /[\t ]*<column>\n?([\s\S]*?)[\t ]*<\/column>\n?/g;
5812
+ var COLUMN_WRAP_RE = /[\t ]*<column(?!s)[^>]*>\n?([\s\S]*?)[\t ]*<\/column>\n?/g;
4959
5813
  function reindentLines(text, indent) {
4960
5814
  if (!indent) return text;
4961
5815
  return text.split("\n").map((line) => line === "" ? line : indent + line).join("\n");
4962
5816
  }
4963
- function toggleToCallout(title, rawBody) {
5817
+ function parseDesignAttrs(attrs) {
5818
+ const icon = /\bicon="([^"]*)"/.exec(attrs)?.[1];
5819
+ const color = /\bcolor="([^"]*)"/.exec(attrs)?.[1];
5820
+ return { icon: icon || void 0, color: color || void 0 };
5821
+ }
5822
+ function toggleToCallout(title, rawBody, color) {
4964
5823
  const body = dedentContainerBody(rawBody);
5824
+ const head = ["> [!toggle]-", title.trim(), color ? toggleColorMarker(color) : ""].filter((part) => part !== "").join(" ");
5825
+ if (body.trim() === "") return head;
4965
5826
  const calloutBody = body.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n");
4966
- return `> [!toggle]- ${title.trim()}
5827
+ return `${head}
4967
5828
  ${calloutBody}`;
4968
5829
  }
4969
5830
  function flattenColumns(body) {
@@ -4977,20 +5838,22 @@ function flattenColumns(body) {
4977
5838
  if (col.trim() !== "") cols.push(col);
4978
5839
  }
4979
5840
  if (!matched) return dedentContainerBody(body);
4980
- return cols.join("\n\n");
5841
+ if (cols.length === 0) return "";
5842
+ return [COLUMN_LIST_START, ...cols.map((c) => `${COLUMN_SEP}
5843
+ ${c}`), COLUMN_LIST_END].join("\n");
4981
5844
  }
4982
5845
  function convertContainers(content) {
4983
5846
  let result = content;
4984
5847
  let safety = 0;
4985
- while (safety++ < 2e3 && (result.includes("<details>") || result.includes("<callout") || result.includes("<columns>"))) {
5848
+ while (safety++ < 2e3 && (result.includes("<details") || result.includes("<callout") || result.includes("<columns"))) {
4986
5849
  const before = result;
4987
5850
  result = result.replace(
4988
5851
  INNERMOST_DETAILS_RE,
4989
- (_m, indent, title, body) => reindentLines(toggleToCallout(title, body), indent)
5852
+ (_m, indent, attrs, title, body) => reindentLines(toggleToCallout(title ?? "", body, parseDesignAttrs(attrs).color), indent)
4990
5853
  );
4991
5854
  result = result.replace(
4992
5855
  INNERMOST_CALLOUT_RE,
4993
- (_m, indent, body) => reindentLines(calloutBodyToObsidian(body), indent)
5856
+ (_m, indent, attrs, body) => reindentLines(calloutBodyToObsidian(body, parseDesignAttrs(attrs)), indent)
4994
5857
  );
4995
5858
  result = result.replace(
4996
5859
  INNERMOST_COLUMNS_RE,
@@ -4998,16 +5861,38 @@ function convertContainers(content) {
4998
5861
  );
4999
5862
  if (result === before) break;
5000
5863
  }
5001
- return result;
5864
+ return stripQuotedColumnMarkers(result);
5865
+ }
5866
+ var QUOTED_COLUMN_EDGE_RE = new RegExp(
5867
+ `^(?:>[ \\t]?)+[ \\t]*%%${MARKER_BRAND_RE}:column-list:(?:start|end)%%[ \\t]*\\n?`,
5868
+ "gm"
5869
+ );
5870
+ var QUOTED_COLUMN_SEP_RE = new RegExp(
5871
+ `^((?:>[ \\t]?)+)[ \\t]*%%${MARKER_BRAND_RE}:column%%[ \\t]*$`,
5872
+ "gm"
5873
+ );
5874
+ var QUOTED_COLUMN_INLINE_RE = new RegExp(
5875
+ `[ \\t]*%%${MARKER_BRAND_RE}:column(?:-list:(?:start|end))?%%`,
5876
+ "g"
5877
+ );
5878
+ function stripQuotedColumnMarkers(content) {
5879
+ return content.replace(QUOTED_COLUMN_EDGE_RE, "").replace(QUOTED_COLUMN_SEP_RE, (_m, prefix) => prefix.trimEnd()).replace(
5880
+ /^(?:>[ \t]?).*%%.*$/gm,
5881
+ (line) => line.replace(QUOTED_COLUMN_INLINE_RE, "").trimEnd()
5882
+ );
5002
5883
  }
5003
- function calloutBodyToObsidian(body) {
5884
+ function calloutBodyToObsidian(body, style) {
5004
5885
  const lines = dedentContainerBody(body).split("\n").filter((l, i, arr) => !(i === 0 && l === "") && !(i === arr.length - 1 && l === ""));
5005
5886
  const firstLine = lines[0] ?? "";
5006
- const emojiMatch = /^([\p{Emoji}️‍]+)\s*(.*)/u.exec(firstLine);
5007
- const type = emojiMatch ? emojiToCalloutType(emojiMatch[1]) : "note";
5887
+ const emojiMatch = style?.icon || style?.color ? null : /^([\p{Emoji}️‍]+)\s*(.*)/u.exec(firstLine);
5888
+ const rawIcon = style?.icon ?? (emojiMatch ? emojiMatch[1] : void 0);
5889
+ const icon = rawIcon !== void 0 && /^https?:\/\//i.test(rawIcon) ? void 0 : rawIcon;
5890
+ const type = icon ? emojiToCalloutType(icon) : "note";
5008
5891
  const title = emojiMatch ? emojiMatch[2] : firstLine;
5009
5892
  const rest = lines.slice(1).join("\n").trim();
5010
- const calloutTitle = title ? `> [!${type}] ${title}` : `> [!${type}]`;
5893
+ const needsMarker = Boolean(style?.color) || icon !== void 0 && calloutTypeToEmoji(type) !== icon;
5894
+ const styleTail = needsMarker ? ` ${calloutStyleMarker({ icon, color: style?.color })}` : "";
5895
+ const calloutTitle = title ? `> [!${type}] ${title}${styleTail}` : `> [!${type}]${styleTail}`;
5011
5896
  const calloutBody = rest ? "\n" + rest.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n") : "";
5012
5897
  return calloutTitle + calloutBody;
5013
5898
  }
@@ -5020,7 +5905,7 @@ function convertPageMentions(content) {
5020
5905
  return `[[${cleaned}]]`;
5021
5906
  });
5022
5907
  result = result.replace(
5023
- /<mention-page\s+url="https?:\/\/(?:www\.)?notion\.so\/([a-f0-9]{32})"[^>]*?(?:\/>|>[\s\S]*?<\/mention-page>)/g,
5908
+ /<mention-page\s+url="https?:\/\/(?:[a-z]+\.)?notion\.(?:so|com)\/(?:p\/)?([a-f0-9]{32})"[^>]*?(?:\/>|>[\s\S]*?<\/mention-page>)/g,
5024
5909
  (_match, id) => `[[notion:${id}]]`
5025
5910
  );
5026
5911
  return result;
@@ -5050,19 +5935,52 @@ function preserveUnknownBlocks(content) {
5050
5935
  return result;
5051
5936
  }
5052
5937
  function convertSyncedBlockRef(content) {
5938
+ const unwrap = (match, openRe, closeRe) => match.replace(openRe, "").replace(closeRe, "").split("\n").map((l) => l.replace(/^\t/, "")).join("\n").trim();
5053
5939
  let result = content.replace(
5054
- /<synced_block_reference[^>]*>[\s\S]*?<\/synced_block_reference>/g,
5055
- (match) => {
5056
- const inner = match.replace(/<synced_block_reference[^>]*>\n?/, "").replace(/<\/synced_block_reference>/, "").split("\n").map((l) => l.replace(/^\t/, "")).join("\n").trim();
5057
- return inner;
5940
+ /<synced_block_reference([^>]*)>[\s\S]*?<\/synced_block_reference>/g,
5941
+ (match, attrs) => {
5942
+ const inner = unwrap(match, /<synced_block_reference[^>]*>\n?/, /<\/synced_block_reference>/);
5943
+ const url = /url="([^"]*)"/.exec(attrs)?.[1];
5944
+ if (!url) return inner;
5945
+ return `${syncedStartMarker("ref", url)}
5946
+ ${inner}
5947
+ ${SYNCED_END}`;
5058
5948
  }
5059
5949
  );
5060
5950
  result = result.replace(
5061
- /<synced_block[^>]*>\n?([\s\S]*?)<\/synced_block>/g,
5062
- (_match, inner) => inner.trim()
5951
+ /<synced_block([^>]*)>[\s\S]*?<\/synced_block>/g,
5952
+ (match, attrs) => {
5953
+ const inner = unwrap(match, /<synced_block[^>]*>\n?/, /<\/synced_block>/);
5954
+ const url = /url="([^"]*)"/.exec(attrs)?.[1];
5955
+ if (!url) return inner;
5956
+ return `${syncedStartMarker("orig", url)}
5957
+ ${inner}
5958
+ ${SYNCED_END}`;
5959
+ }
5063
5960
  );
5064
5961
  return result;
5065
5962
  }
5963
+ var SYNCED_MARKER_PAIR_RE = new RegExp(
5964
+ `%%${MARKER_BRAND_RE}:synced:start:(.*?)%%\\n?([\\s\\S]*?)%%${MARKER_BRAND_RE}:synced:end%%`,
5965
+ "g"
5966
+ );
5967
+ function restoreSyncedBlocks(content) {
5968
+ return content.replace(SYNCED_MARKER_PAIR_RE, (_match, params, body) => {
5969
+ const kind = /kind=(ref|orig)/.exec(params)?.[1] ?? "orig";
5970
+ const encoded = /url=([^&]*)/.exec(params)?.[1] ?? "";
5971
+ let url = encoded;
5972
+ try {
5973
+ url = decodeURIComponent(encoded);
5974
+ } catch {
5975
+ }
5976
+ if (!url) return body.trim();
5977
+ const tag = kind === "ref" ? "synced_block_reference" : "synced_block";
5978
+ const indented = body.trim().split("\n").map((l) => l.length > 0 ? ` ${l}` : l).join("\n");
5979
+ return `<${tag} url="${url}">
5980
+ ${indented}
5981
+ </${tag}>`;
5982
+ });
5983
+ }
5066
5984
  function convertMediaTags(content) {
5067
5985
  let result = content;
5068
5986
  result = result.replace(NOTION_AUDIO_RE, (_match, src, caption) => {
@@ -5105,17 +6023,21 @@ function convertSpans(content) {
5105
6023
  }
5106
6024
  return result;
5107
6025
  }
6026
+ var TOGGLE_COLOR_MARKER_RE = new RegExp(`\\s*%%${MARKER_BRAND_RE}:toggle-color:([a-z_]+)%%`);
5108
6027
  function convertTogglesToHtml(content) {
5109
- const calloutToggleRe = /^> \[!toggle\]-\s*(.+)\n((?:>.*\n?)*)/gm;
6028
+ const calloutToggleRe = /^> \[!toggle\]-[ \t]*(.*)\n?((?:>.*\n?)*)/gm;
5110
6029
  let result = content;
5111
6030
  let prev = "";
5112
6031
  let safety = 0;
5113
6032
  while (result !== prev && safety++ < 100) {
5114
6033
  prev = result;
5115
6034
  result = result.replace(calloutToggleRe, (_match, title, body) => {
6035
+ const colorMatch = TOGGLE_COLOR_MARKER_RE.exec(title);
6036
+ const cleanTitle = colorMatch ? title.replace(TOGGLE_COLOR_MARKER_RE, "") : title;
6037
+ const attrs = colorMatch ? ` color="${colorMatch[1]}"` : "";
5116
6038
  const bodyText = body.split("\n").map((line) => line.replace(/^>\s?/, "")).join("\n").trim();
5117
- return `<details>
5118
- <summary>${title.trim()}</summary>
6039
+ return `<details${attrs}>
6040
+ <summary>${cleanTitle.trim()}</summary>
5119
6041
 
5120
6042
  ${bodyText}
5121
6043
 
@@ -5142,6 +6064,7 @@ ${body}
5142
6064
  result = result.slice(0, startIdx) + html + result.slice(endIdx + TOGGLE_END.length);
5143
6065
  }
5144
6066
  result = result.replace(startRe, "").replace(endRe, "");
6067
+ result = result.replace(/^(<\/details>)(?!\n|$)/gm, "$1\n");
5145
6068
  return result;
5146
6069
  }
5147
6070
  function findMatchingEnd(content, startFrom) {
@@ -5162,29 +6085,53 @@ function findMatchingEnd(content, startFrom) {
5162
6085
  }
5163
6086
  return -1;
5164
6087
  }
6088
+ var CALLOUT_STYLE_MARKER_RE = new RegExp(`\\s*%%${MARKER_BRAND_RE}:callout-style:(.*?)%%`);
6089
+ function parseCalloutStyleParams(params) {
6090
+ const style = {};
6091
+ for (const kv of params.split("&")) {
6092
+ const eq = kv.indexOf("=");
6093
+ if (eq <= 0) continue;
6094
+ const key = kv.slice(0, eq);
6095
+ const value = decodeURIComponent(kv.slice(eq + 1));
6096
+ if (key === "icon") style.icon = value;
6097
+ else if (key === "color") style.color = value;
6098
+ }
6099
+ return style;
6100
+ }
5165
6101
  function convertObsidianCallouts(content) {
5166
6102
  const lines = content.split("\n");
5167
6103
  const result = [];
5168
6104
  let i = 0;
5169
6105
  while (i < lines.length) {
5170
- const headerMatch = /^> \[!(\w+)\]([-+])?\s*(.*)/.exec(lines[i]);
5171
- if (headerMatch) {
5172
- const type = headerMatch[1];
5173
- const title = headerMatch[3];
5174
- const emoji = calloutTypeToEmoji(type) ?? "\u{1F4A1}";
5175
- const calloutTitle = title ? `${emoji} ${title}` : emoji;
6106
+ const headerMatch = /^> \[!(\w+)\]([-+])?[ \t]*(.*)$/.exec(lines[i]);
6107
+ const type = headerMatch?.[1]?.toLowerCase();
6108
+ if (headerMatch && type !== "toggle" && type !== "tab") {
6109
+ let title = headerMatch[3];
6110
+ let style = {};
6111
+ const styleMatch = CALLOUT_STYLE_MARKER_RE.exec(title);
6112
+ if (styleMatch) {
6113
+ style = parseCalloutStyleParams(styleMatch[1]);
6114
+ title = title.replace(CALLOUT_STYLE_MARKER_RE, "");
6115
+ } else {
6116
+ style.icon = calloutTypeToEmoji(type);
6117
+ }
5176
6118
  const bodyLines = [];
5177
6119
  i++;
5178
- while (i < lines.length && lines[i].startsWith("> ")) {
5179
- bodyLines.push(lines[i].slice(2));
6120
+ while (i < lines.length && (lines[i] === ">" || lines[i].startsWith("> "))) {
6121
+ bodyLines.push(lines[i] === ">" ? "" : lines[i].slice(2));
5180
6122
  i++;
5181
6123
  }
5182
- result.push("::: callout");
5183
- result.push(calloutTitle);
5184
- if (bodyLines.length > 0) {
5185
- result.push(...bodyLines);
6124
+ const innerConverted = convertObsidianCallouts(convertTogglesToHtml(bodyLines.join("\n")));
6125
+ const attrs = `${style.icon ? ` icon="${style.icon}"` : ""}${style.color ? ` color="${style.color}"` : ""}`;
6126
+ result.push(`<callout${attrs}>`);
6127
+ const cleanTitle = title.trim();
6128
+ if (cleanTitle) result.push(` ${cleanTitle}`);
6129
+ if (innerConverted.trim() !== "") {
6130
+ for (const line of innerConverted.split("\n")) {
6131
+ result.push(line === "" ? "" : ` ${line}`);
6132
+ }
5186
6133
  }
5187
- result.push(":::");
6134
+ result.push("</callout>");
5188
6135
  } else {
5189
6136
  result.push(lines[i]);
5190
6137
  i++;
@@ -5228,12 +6175,38 @@ function convertPageLinks(content) {
5228
6175
  }
5229
6176
  function convertDatabaseBlocks(content) {
5230
6177
  return content.replace(
5231
- /<database[^>]*>([\s\S]*?)<\/database>/g,
5232
- (_match, title) => `**${title.trim()}** *(Notion DB)*`
6178
+ /<database\b([^>]*)>([\s\S]*?)<\/database>/g,
6179
+ (_match, attrs, title) => {
6180
+ const clean = title.trim();
6181
+ const placeholder = `**${clean}** *(Notion DB)*`;
6182
+ const idMatch = /\burl="[^"]*?([a-f0-9]{32})[^"]*"/.exec(attrs);
6183
+ if (!idMatch?.[1]) return placeholder;
6184
+ return `${placeholder}${compactMarker(
6185
+ `child-database:id=${idMatch[1]}&title=${encodeURIComponent(clean)}`
6186
+ )}`;
6187
+ }
5233
6188
  );
5234
6189
  }
5235
- function cleanInlineColorAttrs(content) {
5236
- return content.replace(/\s*\{color="[^"]*"\}/g, "");
6190
+ var TRAILING_BLOCK_COLOR_RE = /[ \t]*\{color="([a-z_]+)"\}(?=[ \t]*$)/gm;
6191
+ function convertBlockColorAttrs(content) {
6192
+ return content.replace(TRAILING_BLOCK_COLOR_RE, (_m, color) => ` ${blockColorMarker(color)}`).replace(/\s*\{color="[^"]*"\}/g, "");
6193
+ }
6194
+ var CALLOUT_HEAD_RE = /^((?:> )*)> \[!\w+\][-+]?/;
6195
+ function separateAdjacentCallouts(content) {
6196
+ const lines = content.split("\n");
6197
+ const out = [];
6198
+ for (const line of lines) {
6199
+ const head = CALLOUT_HEAD_RE.exec(line);
6200
+ if (head && out.length > 0) {
6201
+ const parentPrefix = head[1];
6202
+ const prev = out[out.length - 1];
6203
+ if (prev.startsWith(`${parentPrefix}>`)) {
6204
+ out.push(parentPrefix.trimEnd());
6205
+ }
6206
+ }
6207
+ out.push(line);
6208
+ }
6209
+ return out.join("\n");
5237
6210
  }
5238
6211
  function removeEmptyBlocks(content) {
5239
6212
  return content.replace(NOTION_EMPTY_BLOCK_RE, "\n");
@@ -5257,7 +6230,7 @@ function unescapeNotionChars(content) {
5257
6230
  return content.replace(/\\~/g, "~").replace(/\\\^/g, "^");
5258
6231
  }
5259
6232
  var NOTION_TABLE_RE = /<table[^>]*>([\s\S]*?)<\/table>/g;
5260
- var TABLE_ROW_RE = /<tr>([\s\S]*?)<\/tr>/g;
6233
+ var TABLE_ROW_RE2 = /<tr>([\s\S]*?)<\/tr>/g;
5261
6234
  var TABLE_CELL_RE = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/g;
5262
6235
  function isAlignmentRow(cells) {
5263
6236
  return cells.every((c) => /^:?-{2,}:?$/.test(c.trim()));
@@ -5266,7 +6239,7 @@ function convertNotionTables(content) {
5266
6239
  return content.replace(NOTION_TABLE_RE, (_match, tableBody) => {
5267
6240
  const rows = [];
5268
6241
  let rowMatch;
5269
- const rowRe = new RegExp(TABLE_ROW_RE.source, TABLE_ROW_RE.flags);
6242
+ const rowRe = new RegExp(TABLE_ROW_RE2.source, TABLE_ROW_RE2.flags);
5270
6243
  while ((rowMatch = rowRe.exec(tableBody)) !== null) {
5271
6244
  const cells = [];
5272
6245
  let cellMatch;
@@ -5298,26 +6271,23 @@ var OBSIDIAN_MEDIA_PDF_RE = /\[📄\s*([^\]]*)\]\(([^)]+)\)/g;
5298
6271
  var OBSIDIAN_MEDIA_FILE_RE = /\[📎\s*([^\]]*)\]\(([^)]+)\)/g;
5299
6272
  function restoreMediaTags(content) {
5300
6273
  let result = content;
5301
- result = result.replace(OBSIDIAN_MEDIA_AUDIO_RE, (_match, caption, src) => {
5302
- return `<audio src="${src}">${caption}</audio>`;
5303
- });
5304
- result = result.replace(OBSIDIAN_MEDIA_VIDEO_RE, (_match, caption, src) => {
5305
- return `<video src="${src}">${caption}</video>`;
5306
- });
5307
- result = result.replace(OBSIDIAN_MEDIA_PDF_RE, (_match, caption, src) => {
5308
- return `<pdf src="${src}">${caption}</pdf>`;
5309
- });
5310
- result = result.replace(OBSIDIAN_MEDIA_FILE_RE, (_match, caption, src) => {
5311
- return `<file src="${src}">${caption}</file>`;
5312
- });
6274
+ const restore = (tag) => (_match, caption, src) => {
6275
+ const cap = caption.trim() === tag ? "" : caption;
6276
+ return `<${tag} src="${src}">${cap}</${tag}>`;
6277
+ };
6278
+ result = result.replace(OBSIDIAN_MEDIA_AUDIO_RE, restore("audio"));
6279
+ result = result.replace(OBSIDIAN_MEDIA_VIDEO_RE, restore("video"));
6280
+ result = result.replace(OBSIDIAN_MEDIA_PDF_RE, restore("pdf"));
6281
+ result = result.replace(OBSIDIAN_MEDIA_FILE_RE, restore("file"));
5313
6282
  return result;
5314
6283
  }
5315
6284
  var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)\n((?:> .*\n?)*)/gm;
5316
6285
  function restoreTabBlocks(content) {
5317
- return content.replace(OBSIDIAN_TAB_RE, (_match, title, body) => {
6286
+ const result = content.replace(OBSIDIAN_TAB_RE, (_match, title, body) => {
5318
6287
  const unquoted = body.split("\n").map((line) => line.replace(/^> /, "")).join("\n").trim();
5319
6288
  return `<tab title="${title}">${unquoted}</tab>`;
5320
6289
  });
6290
+ return result.replace(/(<\/tab>)(?!\n|$)/g, "$1\n");
5321
6291
  }
5322
6292
  var OBSIDIAN_UNKNOWN_RE = new RegExp(
5323
6293
  `(?:\\[[^\\]]*\\]\\([^)]*\\))?%%${MARKER_BRAND_RE}:unknown:id=([^&]+)&type=([^%]+)%%`,
@@ -5350,6 +6320,42 @@ function restoreUnderlineSpans(content) {
5350
6320
  return `<span underline="true">${text}</span>`;
5351
6321
  });
5352
6322
  }
6323
+ var OBSIDIAN_BLOCK_COLOR_RE = new RegExp(
6324
+ `[ \\t]*%%${MARKER_BRAND_RE}:block-color:([a-z_]+)%%`,
6325
+ "g"
6326
+ );
6327
+ function restoreBlockColorMarkers(content) {
6328
+ return content.replace(OBSIDIAN_BLOCK_COLOR_RE, (_m, color) => ` {color="${color}"}`);
6329
+ }
6330
+ var COLUMN_REGION_RE = new RegExp(
6331
+ `^${escapeRegex3(COLUMN_LIST_START)}[ \\t]*\\n([\\s\\S]*?)\\n?^${escapeRegex3(COLUMN_LIST_END)}[ \\t]*$\\n?`,
6332
+ "gm"
6333
+ );
6334
+ function indentColumnLines(text) {
6335
+ return text.split("\n").map((line) => line === "" ? "" : ` ${line}`).join("\n");
6336
+ }
6337
+ function reassembleColumns(content) {
6338
+ const sepRe = new RegExp(`^${escapeRegex3(COLUMN_SEP)}[ \\t]*$`, "m");
6339
+ let result = content.replace(COLUMN_REGION_RE, (_m, inner) => {
6340
+ const cols = inner.split(new RegExp(sepRe.source, "gm")).map((c) => c.replace(/^\n/, "").replace(/\n$/, "")).filter((c) => c.trim() !== "");
6341
+ if (cols.length === 0) return "";
6342
+ const parts = cols.map((c) => `<column>
6343
+ ${indentColumnLines(c)}
6344
+ </column>`).join("\n");
6345
+ return `<columns>
6346
+ ${indentColumnLines(parts)}
6347
+ </columns>
6348
+ `;
6349
+ });
6350
+ result = result.replace(
6351
+ new RegExp(
6352
+ `^[>\\t ]*%%${MARKER_BRAND_RE}:(?:column-list:start|column-list:end|column)%%[ \\t]*\\n?`,
6353
+ "gm"
6354
+ ),
6355
+ ""
6356
+ );
6357
+ return result;
6358
+ }
5353
6359
  function unescapePipes(content) {
5354
6360
  const lines = content.split("\n");
5355
6361
  const result = [];
@@ -5413,6 +6419,14 @@ var DatabaseSyncer = class {
5413
6419
  propertyMapper = new PropertyMapper();
5414
6420
  baseFileGenerator = new BaseFileGenerator();
5415
6421
  sidecarGenerator = new SidecarGenerator();
6422
+ /**
6423
+ * 이번 프로세스가 실제로 기록한 .base 경로/DB 제목 (databaseId nohyph → info).
6424
+ * placeholder 임베드 재작성(F22)의 SSOT — 폴더명(하이픈 새니타이즈)과 .base 파일명
6425
+ * (sanitizeFileName: 공백·점 보존)은 규칙이 달라 localFolder 로 추측한 경로는 깨진
6426
+ * 임베드가 된다. generateBaseFile 은 매 pull 모든 DB 에 대해 실행되므로 pull 종료
6427
+ * 시점에는 성공한 DB 전체가 채워져 있다.
6428
+ */
6429
+ baseFileInfo = /* @__PURE__ */ new Map();
5416
6430
  async pullAll() {
5417
6431
  const databases = this.config.notion.databases;
5418
6432
  if (!databases || databases.length === 0) {
@@ -5467,16 +6481,33 @@ var DatabaseSyncer = class {
5467
6481
  }
5468
6482
  return { created, updated, conflicts: [], failed, writtenPaths: [] };
5469
6483
  }
5470
- async pullDatabase(dbConfig) {
6484
+ async pullDatabase(dbConfig, opts) {
6485
+ const pages = await this.notionClient.queryAllDatabasePages(
6486
+ dbConfig.databaseId,
6487
+ dbConfig.pullFilter
6488
+ );
6489
+ const parent = pages[0]?.parent;
6490
+ const ownerDbId = parent?.database_id;
6491
+ if (ownerDbId && !notionIdsEqual(ownerDbId, dbConfig.databaseId)) {
6492
+ const ownerFolder = opts?.resolveDbFolder?.(ownerDbId) ?? null;
6493
+ if (ownerFolder !== null) {
6494
+ const viewsConfig2 = await this.pullDatabaseViews(dbConfig);
6495
+ await this.generateBaseFile(dbConfig, viewsConfig2, ownerFolder);
6496
+ return {
6497
+ created: 0,
6498
+ updated: 0,
6499
+ conflicts: [],
6500
+ failed: [],
6501
+ writtenPaths: [],
6502
+ linkedOriginalDbId: ownerDbId
6503
+ };
6504
+ }
6505
+ }
5471
6506
  const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
5472
6507
  this.propertyMapper.loadSchema(schema);
5473
6508
  await this.vaultFs.ensureFolder(dbConfig.localFolder);
5474
6509
  const viewsConfig = await this.pullDatabaseViews(dbConfig);
5475
6510
  await this.generateBaseFile(dbConfig, viewsConfig);
5476
- const pages = await this.notionClient.queryAllDatabasePages(
5477
- dbConfig.databaseId,
5478
- dbConfig.pullFilter
5479
- );
5480
6511
  let created = 0;
5481
6512
  let updated = 0;
5482
6513
  const conflicts = [];
@@ -5486,7 +6517,8 @@ var DatabaseSyncer = class {
5486
6517
  try {
5487
6518
  const record = this.stateDb.getByNotionId(page.id);
5488
6519
  if (record) {
5489
- if (page.last_edited_time === record.notionLastEdited) continue;
6520
+ const misplaced = !isDirectDbRowPath(dbConfig.localFolder, record.obsidianPath);
6521
+ if (page.last_edited_time === record.notionLastEdited && !misplaced) continue;
5490
6522
  const outcome = await this.pullDatabasePage(page, dbConfig);
5491
6523
  if (outcome.action === "written") {
5492
6524
  updated++;
@@ -5544,7 +6576,7 @@ var DatabaseSyncer = class {
5544
6576
  return null;
5545
6577
  }
5546
6578
  }
5547
- async generateBaseFile(dbConfig, viewsConfig) {
6579
+ async generateBaseFile(dbConfig, viewsConfig, rowsFolder) {
5548
6580
  try {
5549
6581
  const schemaFull = await this.notionClient.getDatabaseSchemaFull(dbConfig.databaseId);
5550
6582
  const dbName = viewsConfig?.databaseName || await this.notionClient.getDatabaseTitle(dbConfig.databaseId) || dbConfig.localFolder.split("/").pop() || "Database";
@@ -5559,11 +6591,12 @@ var DatabaseSyncer = class {
5559
6591
  databaseName: dbName,
5560
6592
  schema: schemaFull,
5561
6593
  viewsConfig: resolvedViews,
5562
- folderPath: dbConfig.localFolder
6594
+ folderPath: rowsFolder ?? dbConfig.localFolder
5563
6595
  });
5564
6596
  const safeName = sanitizeFileName(dbName);
5565
6597
  const basePath = `${dbConfig.localFolder}/${safeName}.base`;
5566
6598
  await this.vaultFs.writeFile(basePath, baseContent);
6599
+ this.baseFileInfo.set(dbConfig.databaseId.replace(/-/g, ""), { basePath, title: dbName });
5567
6600
  getLogger().debug(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131: ${basePath}`);
5568
6601
  await this.generateSidecar(dbConfig, dbName, safeName, schemaFull, resolvedViews);
5569
6602
  await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName);
@@ -5606,7 +6639,10 @@ var DatabaseSyncer = class {
5606
6639
  viewsConfig: resolvedViews
5607
6640
  });
5608
6641
  const sidecarPath = `${dbConfig.localFolder}/${safeName}.notion.json`;
5609
- await this.vaultFs.writeFile(sidecarPath, this.sidecarGenerator.serialize(sidecar));
6642
+ const serialized = this.sidecarGenerator.serialize(sidecar);
6643
+ const existing = await this.vaultFs.readFile(sidecarPath).catch(() => null);
6644
+ if (existing === serialized) return;
6645
+ await this.vaultFs.writeFile(sidecarPath, serialized);
5610
6646
  if (sidecar.degraded.length > 0) {
5611
6647
  const unrep = sidecar.degraded.filter((d) => d.kind === "view-unrepresentable").length;
5612
6648
  const dropped = sidecar.degraded.length - unrep;
@@ -5618,6 +6654,42 @@ var DatabaseSyncer = class {
5618
6654
  getLogger().warn(`[DB Sync] \uC0AC\uC774\uB4DC\uCE74 \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
5619
6655
  }
5620
6656
  }
6657
+ /**
6658
+ * DB 행 `files` 속성의 notion-hosted 서명 URL 을 로컬 첨부로 로컬라이즈한다(P3-A).
6659
+ * 서명 URL 은 약 1시간 뒤 만료되어 frontmatter 에 남으면 깨진 링크가 되고, push 로
6660
+ * 되밀면 Notion 원본이 만료 URL 의 external 파일로 오염된다(실측). 다운로드 성공분은
6661
+ * `[[attachments/..]]` 위키링크로 대체한다 — Bases 카드 `image:` 는 위키링크 스칼라를
6662
+ * 렌더하므로 갤러리 커버도 유지된다. 원본 파일명은 raw 속성의 `name` 에서 취한다
6663
+ * (extractValue 는 URL 만 남긴다). 사용자가 넣은 진짜 외부 URL 은 그대로 둔다.
6664
+ */
6665
+ async localizeFileProperties(rawProps, properties, safeName) {
6666
+ for (const [key, rawProp] of Object.entries(rawProps)) {
6667
+ const prop = rawProp;
6668
+ if (prop?.type !== "files" || !Array.isArray(prop.files) || prop.files.length === 0) {
6669
+ continue;
6670
+ }
6671
+ const localized = [];
6672
+ let changed = false;
6673
+ for (const f of prop.files) {
6674
+ const url = f.type === "file" ? f.file?.url : f.external?.url;
6675
+ if (!url) continue;
6676
+ const caption = f.name?.trim() || `${safeName}-${key}`;
6677
+ try {
6678
+ const localPath = await this.imageHandler.localizeNotionFileUrl(url, caption);
6679
+ if (localPath) {
6680
+ localized.push(`[[${localPath}]]`);
6681
+ changed = true;
6682
+ } else {
6683
+ localized.push(url);
6684
+ }
6685
+ } catch {
6686
+ localized.push(url);
6687
+ }
6688
+ }
6689
+ if (!changed || localized.length === 0) continue;
6690
+ properties[key] = localized.length === 1 ? localized[0] : localized;
6691
+ }
6692
+ }
5621
6693
  async pullDatabasePage(page, dbConfig) {
5622
6694
  const title = this.notionClient.extractTitle(page);
5623
6695
  const safeName = sanitizeFileName(title);
@@ -5625,30 +6697,25 @@ var DatabaseSyncer = class {
5625
6697
  page.properties
5626
6698
  );
5627
6699
  properties.title = title;
6700
+ await this.localizeFileProperties(
6701
+ page.properties,
6702
+ properties,
6703
+ safeName
6704
+ );
5628
6705
  const cover = this.notionClient.extractCover(page);
5629
6706
  const icon = this.notionClient.extractIcon(page);
5630
6707
  if (cover) {
5631
- try {
5632
- const coverResult = await this.imageHandler.downloadAllImages(
5633
- `![cover](${cover.url})`,
5634
- `${safeName}-cover`
5635
- );
5636
- const resolved = coverResult.content.match(/!\[cover\]\((.+?)\)/)?.[1];
5637
- if (resolved && !/^https?:\/\//i.test(resolved)) {
5638
- properties.cover = `[[${resolved}]]`;
5639
- } else {
5640
- properties.cover = cover.url;
5641
- }
5642
- } catch {
5643
- properties.cover = cover.url;
5644
- }
6708
+ const local = await this.imageHandler.localizeNotionFileUrl(cover.url, `${safeName}-cover`);
6709
+ properties.cover = local ? `[[${local}]]` : cover.url;
5645
6710
  }
5646
6711
  if (icon) {
5647
6712
  properties.icon = icon.value;
5648
6713
  }
5649
6714
  let markdown = "";
6715
+ let exportCompact = false;
5650
6716
  try {
5651
6717
  const mdResult = await this.notionClient.getPageMarkdown(page.id);
6718
+ exportCompact = isCompactExport(mdResult.markdown);
5652
6719
  markdown = notionEnhancedToObsidian(mdResult.markdown);
5653
6720
  } catch {
5654
6721
  }
@@ -5669,9 +6736,10 @@ var DatabaseSyncer = class {
5669
6736
  }
5670
6737
  }
5671
6738
  const existingRecord = this.stateDb.getByNotionId(page.id);
6739
+ const recordPath = existingRecord?.obsidianPath;
5672
6740
  let filePath;
5673
- if (existingRecord?.obsidianPath) {
5674
- filePath = existingRecord.obsidianPath;
6741
+ if (recordPath && isDirectDbRowPath(dbConfig.localFolder, recordPath)) {
6742
+ filePath = recordPath;
5675
6743
  } else {
5676
6744
  filePath = resolveDbRowPath(
5677
6745
  dbConfig.localFolder,
@@ -5688,12 +6756,12 @@ var DatabaseSyncer = class {
5688
6756
  filePath,
5689
6757
  parentMode: "database"
5690
6758
  },
5691
- { properties }
6759
+ { properties, notionExportCompact: exportCompact }
5692
6760
  );
5693
6761
  if (existingRecord) {
5694
6762
  let localContent = "";
5695
6763
  try {
5696
- localContent = await this.vaultFs.readFile(filePath);
6764
+ localContent = await this.vaultFs.readFile(recordPath ?? filePath);
5697
6765
  } catch {
5698
6766
  localContent = "";
5699
6767
  }
@@ -5710,17 +6778,26 @@ var DatabaseSyncer = class {
5710
6778
  strategy: this.config.sync.conflictStrategy
5711
6779
  });
5712
6780
  if (resolution.action === "skip") {
5713
- return { action: "skipped", path: filePath };
6781
+ return { action: "skipped", path: recordPath ?? filePath };
5714
6782
  }
5715
6783
  if (resolution.action === "conflict") {
5716
6784
  this.stateDb.updateStatus(existingRecord.id, "conflict");
5717
- return { action: "conflict", path: filePath, conflict: resolution.conflict };
6785
+ return {
6786
+ action: "conflict",
6787
+ path: recordPath ?? filePath,
6788
+ conflict: resolution.conflict
6789
+ };
5718
6790
  }
5719
6791
  }
5720
6792
  await this.vaultFs.ensureFolder(dbConfig.localFolder);
5721
6793
  await this.vaultFs.writeFile(filePath, finalContent);
6794
+ const rehomed = existingRecord && recordPath && recordPath !== filePath;
5722
6795
  const hash = computeHash(finalContent);
5723
6796
  this.stateDb.transaction(() => {
6797
+ if (rehomed) {
6798
+ this.stateDb.updatePath(existingRecord.id, filePath);
6799
+ this.stateDb.deleteWikilink(recordPath);
6800
+ }
5724
6801
  this.stateDb.upsert({
5725
6802
  obsidianPath: filePath,
5726
6803
  notionPageId: page.id,
@@ -5740,6 +6817,12 @@ var DatabaseSyncer = class {
5740
6817
  aliases: extractAliases(properties)
5741
6818
  });
5742
6819
  });
6820
+ if (rehomed) {
6821
+ try {
6822
+ await this.vaultFs.deleteFile(recordPath);
6823
+ } catch {
6824
+ }
6825
+ }
5743
6826
  return { action: "written", path: filePath };
5744
6827
  }
5745
6828
  async pushDatabase(dbConfig) {
@@ -6089,8 +7172,8 @@ var ConflictResolver = class {
6089
7172
  async resolveDuplicate(conflict) {
6090
7173
  const originalPath = conflict.syncRecord.obsidianPath;
6091
7174
  const ext = originalPath.endsWith(".md") ? ".md" : "";
6092
- const baseName = originalPath.replace(/\.md$/, "");
6093
- const conflictPath = `${baseName}.conflict${ext}`;
7175
+ const baseName2 = originalPath.replace(/\.md$/, "");
7176
+ const conflictPath = `${baseName2}.conflict${ext}`;
6094
7177
  await this.vaultFs.writeFile(conflictPath, conflict.remoteContent);
6095
7178
  const localHash = computeHash(conflict.localContent);
6096
7179
  this.stateDb.updateHash(
@@ -6177,8 +7260,77 @@ function resolveFrontmatterRelations(content, idToTitle) {
6177
7260
  return { content: stringifyFrontmatter(parsed.content, resolved), count };
6178
7261
  }
6179
7262
 
7263
+ // src/utils/inline-db-refs.ts
7264
+ function extractInlineDbIds(markdown) {
7265
+ const ids = /* @__PURE__ */ new Set();
7266
+ const tagRe = /<database\b[^>]*\burl="[^"]*?([a-f0-9]{32})[^"]*"[^>]*>/g;
7267
+ let m;
7268
+ while ((m = tagRe.exec(markdown)) !== null) {
7269
+ if (m[1]) ids.add(m[1]);
7270
+ }
7271
+ const markerRe = new RegExp(`${MARKER_BRAND_RE}:child-database:id=([a-f0-9-]{32,36})`, "g");
7272
+ while ((m = markerRe.exec(markdown)) !== null) {
7273
+ if (m[1]) ids.add(m[1].replace(/-/g, ""));
7274
+ }
7275
+ return [...ids];
7276
+ }
7277
+
7278
+ // src/sync/db-placeholder-rewriter.ts
7279
+ var ID32 = "[a-f0-9]{32}";
7280
+ var IDUUID = "[a-f0-9-]{32,36}";
7281
+ var ENC_TITLE = "(?:&title=(.*?))?";
7282
+ var INLINE_PLACEHOLDER_RE = new RegExp(
7283
+ `\\*\\*([^\\n*]*)\\*\\*\\s*\\*\\(Notion DB\\)\\*\\s*%%\\s*${MARKER_BRAND_RE}:child-database:id=(${ID32})${ENC_TITLE}\\s*%%`,
7284
+ "g"
7285
+ );
7286
+ var CALLOUT_PLACEHOLDER_RE = new RegExp(
7287
+ `^((?:[ \\t]*>)+[ \\t]*)\\[!database\\][ \\t]*([^\\n]*)\\n(?:[ \\t]*>)+[ \\t]*%%\\s*${MARKER_BRAND_RE}:child-database:id=(${IDUUID})${ENC_TITLE}\\s*%%[ \\t]*$`,
7288
+ "gm"
7289
+ );
7290
+ function decodeTitle(encoded) {
7291
+ if (!encoded) return "";
7292
+ try {
7293
+ return decodeURIComponent(encoded);
7294
+ } catch {
7295
+ return encoded;
7296
+ }
7297
+ }
7298
+ function toAlias(...candidates) {
7299
+ for (const c of candidates) {
7300
+ const cleaned = (c ?? "").replace(/[|[\]\n]/g, "").trim();
7301
+ if (cleaned) return cleaned;
7302
+ }
7303
+ return "";
7304
+ }
7305
+ function rewriteDbPlaceholders(content, resolve) {
7306
+ let rewrites = 0;
7307
+ let result = content.replace(
7308
+ INLINE_PLACEHOLDER_RE,
7309
+ (match, inlineTitle, id, encTitle) => {
7310
+ const target = resolve(id);
7311
+ if (!target) return match;
7312
+ const alias = toAlias(decodeTitle(encTitle), inlineTitle, target.title, "Database");
7313
+ rewrites++;
7314
+ return `![[${target.basePath}|${alias}]]`;
7315
+ }
7316
+ );
7317
+ result = result.replace(
7318
+ CALLOUT_PLACEHOLDER_RE,
7319
+ (match, prefix, calloutTitle, id, encTitle) => {
7320
+ const target = resolve(id.replace(/-/g, ""));
7321
+ if (!target) return match;
7322
+ const alias = toAlias(decodeTitle(encTitle), calloutTitle, target.title, "Database");
7323
+ rewrites++;
7324
+ return `${prefix}![[${target.basePath}|${alias}]]`;
7325
+ }
7326
+ );
7327
+ return { content: result, rewrites };
7328
+ }
7329
+
6180
7330
  // src/sync/orchestrator.ts
6181
7331
  var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
7332
+ var LINKED_DBS_META_KEY = "linked_dbs";
7333
+ var INCREMENTAL_SAFETY_WINDOW_MS = 15 * 6e4;
6182
7334
  function strategyToChoice(strategy) {
6183
7335
  switch (strategy) {
6184
7336
  case "local-first":
@@ -6479,7 +7631,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
6479
7631
  return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
6480
7632
  }) : remoteChanges;
6481
7633
  if (filtered.length === 0 && (this.config.notion.databases?.length ?? 0) === 0) {
6482
- const dbDiscovery = await this.pullDiscoveredDatabases(writtenPaths, failed, conflicts);
7634
+ const dbDiscovery = await this.pullDiscoveredDatabases(
7635
+ writtenPaths,
7636
+ failed,
7637
+ conflicts,
7638
+ options?.force === true
7639
+ );
6483
7640
  return finalize(dbDiscovery.created, dbDiscovery.updated, 0);
6484
7641
  }
6485
7642
  if (filtered.length === 0) {
@@ -6606,7 +7763,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
6606
7763
  }
6607
7764
  }
6608
7765
  {
6609
- const dbDiscovery = await this.pullDiscoveredDatabases(writtenPaths, failed, conflicts);
7766
+ const dbDiscovery = await this.pullDiscoveredDatabases(
7767
+ writtenPaths,
7768
+ failed,
7769
+ conflicts,
7770
+ options?.force === true
7771
+ );
6610
7772
  counts.created += dbDiscovery.created;
6611
7773
  counts.updated += dbDiscovery.updated;
6612
7774
  }
@@ -6698,7 +7860,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
6698
7860
  let remoteContent = "";
6699
7861
  if (record.notionPageId) {
6700
7862
  try {
6701
- remoteContent = await this.fetchPageMarkdown(record.notionPageId);
7863
+ remoteContent = (await this.fetchPageMarkdown(record.notionPageId)).content;
6702
7864
  } catch {
6703
7865
  }
6704
7866
  }
@@ -6745,7 +7907,20 @@ var SyncOrchestrator = class _SyncOrchestrator {
6745
7907
  let dbTitle;
6746
7908
  try {
6747
7909
  const info = await this.notionClient.getDatabaseSyncability(dbId);
7910
+ if (info.linkedOriginalDbId) {
7911
+ getLogger().info(
7912
+ `[Im-Nobsidian] DB ${dbId}: linked view \uCEE8\uD14C\uC774\uB108(\uC6D0\uBCF8 ${info.linkedOriginalDbId}) \u2192 \uC6D0\uBCF8\uC73C\uB85C \uD574\uC18C(F25)`
7913
+ );
7914
+ return { kind: "linked", originalDbId: info.linkedOriginalDbId };
7915
+ }
6748
7916
  if (!info.queryable) {
7917
+ const linked = await this.notionClient.resolveLinkedDatabase(dbId);
7918
+ if (linked) {
7919
+ getLogger().info(
7920
+ `[Im-Nobsidian] DB ${dbId}: linked view("${linked.viewName}") \u2192 \uC6D0\uBCF8 ${linked.originalDbId} \uB85C \uD574\uC18C`
7921
+ );
7922
+ return { kind: "linked", originalDbId: linked.originalDbId };
7923
+ }
6749
7924
  return { kind: "inaccessible" };
6750
7925
  }
6751
7926
  dbTitle = info.title;
@@ -6760,8 +7935,9 @@ var SyncOrchestrator = class _SyncOrchestrator {
6760
7935
  const obsPath = parentEntry.obsidianPath;
6761
7936
  if (obsPath.endsWith(".md")) {
6762
7937
  const parts = obsPath.split("/");
6763
- parts.pop();
6764
- parentFolder = parts.length > 0 ? parts.join("/") : obsPath.replace(/\.md$/, "");
7938
+ const fileBase = (parts.pop() ?? "").replace(/\.md$/, "");
7939
+ const dirName = parts[parts.length - 1] ?? "";
7940
+ parentFolder = fileBase === dirName ? parts.join("/") : [...parts, fileBase].filter(Boolean).join("/");
6765
7941
  } else {
6766
7942
  parentFolder = obsPath;
6767
7943
  }
@@ -6795,7 +7971,20 @@ var SyncOrchestrator = class _SyncOrchestrator {
6795
7971
  return /* @__PURE__ */ new Set();
6796
7972
  }
6797
7973
  }
6798
- async pullDiscoveredDatabases(writtenPaths, failed, conflicts) {
7974
+ /** linked view 컨테이너 → 원본 DB 매핑을 상태 메타에서 로드한다(nohyph → nohyph). */
7975
+ loadLinkedDbMap() {
7976
+ const raw = this.stateDb.getMeta(LINKED_DBS_META_KEY);
7977
+ if (!raw) return /* @__PURE__ */ new Map();
7978
+ try {
7979
+ const obj = JSON.parse(raw);
7980
+ return new Map(
7981
+ Object.entries(obj).map(([k, v]) => [k.replace(/-/g, ""), v.replace(/-/g, "")])
7982
+ );
7983
+ } catch {
7984
+ return /* @__PURE__ */ new Map();
7985
+ }
7986
+ }
7987
+ async pullDiscoveredDatabases(writtenPaths, failed, conflicts, forceRediscovery = false) {
6799
7988
  if (this.isDatabaseMode) return { created: 0, updated: 0 };
6800
7989
  let created = 0;
6801
7990
  let updated = 0;
@@ -6817,6 +8006,23 @@ var SyncOrchestrator = class _SyncOrchestrator {
6817
8006
  ...dbConfigs.map((c) => c.databaseId.replace(/-/g, ""))
6818
8007
  ]);
6819
8008
  let changed = false;
8009
+ const folderOwner = new Map(
8010
+ (this.config.notion.databases ?? []).map((d) => [d.localFolder, d.databaseId])
8011
+ );
8012
+ const repairedCount = repairDbFolderCollisions(dbConfigs, folderOwner);
8013
+ for (const c of dbConfigs) folderOwner.set(c.localFolder, c.databaseId);
8014
+ if (repairedCount > 0) {
8015
+ changed = true;
8016
+ getLogger().info(`[Im-Nobsidian] \uB3D9\uBA85 DB \uD3F4\uB354 \uCDA9\uB3CC ${repairedCount}\uAC74 \uBD84\uB9AC(F24)`);
8017
+ }
8018
+ const claimFolder = (config) => {
8019
+ config.localFolder = resolveDbFolderPath(
8020
+ config.localFolder,
8021
+ config.databaseId,
8022
+ (f) => folderOwner.get(f) ?? null
8023
+ );
8024
+ folderOwner.set(config.localFolder, config.databaseId);
8025
+ };
6820
8026
  const inaccessibleIds = this.loadInaccessibleDbIds();
6821
8027
  let inaccessibleChanged = false;
6822
8028
  const degrade = (rawId) => {
@@ -6829,52 +8035,119 @@ var SyncOrchestrator = class _SyncOrchestrator {
6829
8035
  `[Im-Nobsidian] DB ${rawId}: \uC811\uADFC \uAC00\uB2A5\uD55C data source \uC5C6\uC74C(\uB9C1\uD06C\uB4DC/\uBBF8\uACF5\uC720/\uC0AD\uC81C \uCD94\uC815) \u2014 \uB3D9\uAE30\uD654 \uB300\uC0C1\uC5D0\uC11C \uC81C\uC678(\uC81C\uBAA9\uC740 \uBD80\uBAA8 \uD398\uC774\uC9C0\uC5D0 \uBCF4\uC874)`
6830
8036
  );
6831
8037
  };
6832
- if (dbConfigs.length === 0) {
6833
- const discovered = await this.discoverChildDatabases();
6834
- for (const { dbId, parentPageId } of discovered) {
6835
- const nohyph = dbId.replace(/-/g, "");
6836
- if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) continue;
6837
- const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
6838
- knownIds.add(nohyph);
6839
- if (outcome.kind === "ok") {
6840
- dbConfigs.push(outcome.config);
8038
+ if (forceRediscovery && inaccessibleIds.size > 0) {
8039
+ getLogger().info(
8040
+ `[Im-Nobsidian] --force: \uC811\uADFC \uBD88\uAC00 DB ${inaccessibleIds.size}\uAC1C denylist \uC7AC\uAC80\uC99D`
8041
+ );
8042
+ inaccessibleIds.clear();
8043
+ inaccessibleChanged = true;
8044
+ }
8045
+ const linkedMap = this.loadLinkedDbMap();
8046
+ let linkedChanged = false;
8047
+ const register = async (dbId, parentPageId) => {
8048
+ const nohyph = dbId.replace(/-/g, "");
8049
+ if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) return;
8050
+ const registerOriginal = async (origNohyph) => {
8051
+ if (knownIds.has(origNohyph) || inaccessibleIds.has(origNohyph)) return;
8052
+ const origId = normalizeNotionId(origNohyph);
8053
+ const outcome2 = await this.buildDiscoveredDbConfig(origId, parentPageId);
8054
+ knownIds.add(origNohyph);
8055
+ if (outcome2.kind === "ok") {
8056
+ claimFolder(outcome2.config);
8057
+ dbConfigs.push(outcome2.config);
6841
8058
  changed = true;
6842
- } else if (outcome.kind === "inaccessible") {
6843
- degrade(dbId);
8059
+ } else if (outcome2.kind !== "error") {
8060
+ degrade(origId);
6844
8061
  }
8062
+ };
8063
+ const knownOriginal = linkedMap.get(nohyph);
8064
+ if (knownOriginal) {
8065
+ await registerOriginal(knownOriginal);
8066
+ return;
6845
8067
  }
6846
- }
6847
- for (const [nohyph, parentPageId] of this._inlineDbRefs) {
6848
- if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) continue;
6849
- const dbId = normalizeNotionId(nohyph);
6850
8068
  const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
6851
8069
  knownIds.add(nohyph);
6852
8070
  if (outcome.kind === "ok") {
8071
+ claimFolder(outcome.config);
6853
8072
  dbConfigs.push(outcome.config);
6854
8073
  changed = true;
8074
+ } else if (outcome.kind === "linked") {
8075
+ const origNohyph = outcome.originalDbId.replace(/-/g, "");
8076
+ linkedMap.set(nohyph, origNohyph);
8077
+ linkedChanged = true;
8078
+ await registerOriginal(origNohyph);
6855
8079
  } else if (outcome.kind === "inaccessible") {
6856
8080
  degrade(dbId);
6857
8081
  }
8082
+ };
8083
+ if (dbConfigs.length === 0 || forceRediscovery) {
8084
+ const discovered = await this.discoverChildDatabases();
8085
+ for (const { dbId, parentPageId } of discovered) {
8086
+ await register(dbId, parentPageId);
8087
+ }
8088
+ }
8089
+ for (const [nohyph, parentPageId] of this._inlineDbRefs) {
8090
+ await register(normalizeNotionId(nohyph), parentPageId);
6858
8091
  }
6859
8092
  const stillSyncable = [];
6860
- for (const dbConfig of dbConfigs) {
6861
- if (inaccessibleIds.has(dbConfig.databaseId.replace(/-/g, ""))) continue;
6862
- try {
6863
- const dbResult = await this.databaseSyncer.pullDatabase(dbConfig);
6864
- created += dbResult.created;
6865
- updated += dbResult.updated;
6866
- conflicts.push(...dbResult.conflicts);
6867
- failed.push(...dbResult.failed);
6868
- writtenPaths.push(...dbResult.writtenPaths);
6869
- stillSyncable.push(dbConfig);
6870
- } catch (error) {
6871
- if (isNotionObjectNotFound(error)) {
6872
- degrade(dbConfig.databaseId);
6873
- } else {
6874
- getLogger().warn(`[Im-Nobsidian] DB ${dbConfig.databaseId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
8093
+ let queue = [...dbConfigs];
8094
+ const MAX_DISCOVERY_ROUNDS = 4;
8095
+ const resolveDbFolder = (dbId) => {
8096
+ const key = dbId.replace(/-/g, "");
8097
+ const hit = dbConfigs.find((c) => c.databaseId.replace(/-/g, "") === key) ?? (this.config.notion.databases ?? []).find((c) => c.databaseId.replace(/-/g, "") === key);
8098
+ return hit?.localFolder ?? null;
8099
+ };
8100
+ for (let round = 1; queue.length > 0; round++) {
8101
+ const roundRowPaths = [];
8102
+ for (const dbConfig of queue) {
8103
+ if (inaccessibleIds.has(dbConfig.databaseId.replace(/-/g, ""))) continue;
8104
+ try {
8105
+ const dbResult = await this.databaseSyncer.pullDatabase(dbConfig, { resolveDbFolder });
8106
+ if (dbResult.linkedOriginalDbId) {
8107
+ linkedMap.set(
8108
+ dbConfig.databaseId.replace(/-/g, ""),
8109
+ dbResult.linkedOriginalDbId.replace(/-/g, "")
8110
+ );
8111
+ linkedChanged = true;
8112
+ changed = true;
8113
+ getLogger().info(
8114
+ `[Im-Nobsidian] DB ${dbConfig.databaseId}: linked view \uCEE8\uD14C\uC774\uB108 \uAC10\uC9C0 \u2192 \uD589\uC740 \uC6D0\uBCF8 ${dbResult.linkedOriginalDbId} \uD3F4\uB354\uAC00 \uB2E8\uC77C \uC18C\uC720(F25)`
8115
+ );
8116
+ continue;
8117
+ }
8118
+ created += dbResult.created;
8119
+ updated += dbResult.updated;
8120
+ conflicts.push(...dbResult.conflicts);
8121
+ failed.push(...dbResult.failed);
8122
+ writtenPaths.push(...dbResult.writtenPaths);
8123
+ roundRowPaths.push(...dbResult.writtenPaths);
6875
8124
  stillSyncable.push(dbConfig);
8125
+ } catch (error) {
8126
+ if (isNotionObjectNotFound(error)) {
8127
+ degrade(dbConfig.databaseId);
8128
+ } else {
8129
+ getLogger().warn(`[Im-Nobsidian] DB ${dbConfig.databaseId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
8130
+ stillSyncable.push(dbConfig);
8131
+ }
6876
8132
  }
6877
8133
  }
8134
+ const before = dbConfigs.length;
8135
+ for (const ref of await this.collectRowInlineRefs(roundRowPaths)) {
8136
+ await register(ref.dbId, ref.parentPageId);
8137
+ }
8138
+ queue = dbConfigs.slice(before);
8139
+ if (queue.length > 0 && round >= MAX_DISCOVERY_ROUNDS) {
8140
+ getLogger().warn(
8141
+ `[Im-Nobsidian] \uC911\uCCA9 DB \uBC1C\uACAC \uB77C\uC6B4\uB4DC \uD55C\uB3C4(${MAX_DISCOVERY_ROUNDS}) \uB3C4\uB2EC \u2014 ${queue.length}\uAC1C\uB294 \uB2E4\uC74C pull \uC5D0\uC11C \uB3D9\uAE30\uD654`
8142
+ );
8143
+ stillSyncable.push(...queue);
8144
+ break;
8145
+ }
8146
+ if (queue.length > 0) {
8147
+ getLogger().info(
8148
+ `[Im-Nobsidian] DB row \uBCF8\uBB38\uC5D0\uC11C \uC911\uCCA9 DB ${queue.length}\uAC1C \uCD94\uAC00 \uBC1C\uACAC(\uB77C\uC6B4\uB4DC ${round + 1})`
8149
+ );
8150
+ }
6878
8151
  }
6879
8152
  if (changed || inaccessibleChanged || stillSyncable.length !== dbConfigs.length) {
6880
8153
  this.stateDb.setMeta("discovered_dbs", JSON.stringify(stillSyncable));
@@ -6882,11 +8155,68 @@ var SyncOrchestrator = class _SyncOrchestrator {
6882
8155
  if (inaccessibleChanged) {
6883
8156
  this.stateDb.setMeta(INACCESSIBLE_DBS_META_KEY, JSON.stringify([...inaccessibleIds]));
6884
8157
  }
8158
+ if (linkedChanged) {
8159
+ this.stateDb.setMeta(LINKED_DBS_META_KEY, JSON.stringify(Object.fromEntries(linkedMap)));
8160
+ }
8161
+ await this.rewriteDbPlaceholderEmbeds(writtenPaths, stillSyncable, linkedMap);
6885
8162
  } catch (error) {
6886
8163
  getLogger().warn("[Im-Nobsidian] child_database \uC790\uB3D9 \uBC1C\uACAC \uC2E4\uD328:", error);
6887
8164
  }
6888
8165
  return { created, updated };
6889
8166
  }
8167
+ /**
8168
+ * pull 산출 md 의 인라인 DB placeholder(`**제목** *(Notion DB)*` + 보존 마커)를
8169
+ * `![[<실제 .base 경로>|제목]]` 임베드로 재작성한다(F22) — folder note 본문에서
8170
+ * DB 가 텍스트 한 줄이 아니라 실제 Bases 뷰로 보이게 하는 마지막 조각. linked view
8171
+ * 컨테이너는 linkedMap 을 거쳐 원본 DB 의 .base 로 향한다. .base 경로는 DatabaseSyncer
8172
+ * 가 실제 기록한 경로(baseFileInfo)가 SSOT — 폴더명과 .base 파일명은 새니타이즈 규칙이
8173
+ * 달라(공백→하이픈 vs 공백 보존) localFolder 로 추측하면 깨진 임베드가 된다(E2E 실측
8174
+ * 91/158건). 최종적으로 .base 실존까지 확인해, 없으면 placeholder 를 마커째 보존한다.
8175
+ */
8176
+ async rewriteDbPlaceholderEmbeds(paths, dbConfigs, linkedMap) {
8177
+ const targets = /* @__PURE__ */ new Map();
8178
+ const addTarget = (databaseId, localFolder) => {
8179
+ const nohyph = databaseId.replace(/-/g, "");
8180
+ if (targets.has(nohyph)) return;
8181
+ const info = this.databaseSyncer.baseFileInfo.get(nohyph);
8182
+ if (info) {
8183
+ targets.set(nohyph, { basePath: info.basePath, title: info.title });
8184
+ return;
8185
+ }
8186
+ const safeName = localFolder.split("/").pop() ?? localFolder;
8187
+ targets.set(nohyph, { basePath: `${localFolder}/${safeName}.base`, title: safeName });
8188
+ };
8189
+ for (const cfg of dbConfigs) addTarget(cfg.databaseId, cfg.localFolder);
8190
+ for (const d of this.config.notion.databases ?? []) addTarget(d.databaseId, d.localFolder);
8191
+ for (const [key, t] of [...targets]) {
8192
+ if (!await this.vaultFs.exists(t.basePath)) targets.delete(key);
8193
+ }
8194
+ if (targets.size === 0) return;
8195
+ const resolve = (nohyph) => targets.get(nohyph) ?? targets.get(linkedMap.get(nohyph) ?? "") ?? null;
8196
+ let total = 0;
8197
+ for (const filePath of paths) {
8198
+ if (!filePath.endsWith(".md")) continue;
8199
+ try {
8200
+ const content = await this.vaultFs.readFile(filePath);
8201
+ const { content: next, rewrites } = rewriteDbPlaceholders(content, resolve);
8202
+ if (rewrites === 0) continue;
8203
+ await this.vaultFs.writeFile(filePath, next);
8204
+ total += rewrites;
8205
+ const record = this.stateDb.getByPath(filePath);
8206
+ if (record) {
8207
+ const stat2 = await this.vaultFs.getFileStat(filePath);
8208
+ this.stateDb.transaction(() => {
8209
+ this.stateDb.updateHash(record.id, computeHash(next), Buffer.from(next, "utf-8"));
8210
+ if (stat2) this.stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
8211
+ });
8212
+ }
8213
+ } catch {
8214
+ }
8215
+ }
8216
+ if (total > 0) {
8217
+ getLogger().info(`[Im-Nobsidian] \uC778\uB77C\uC778 DB \uC784\uBCA0\uB4DC \uC7AC\uC791\uC131: ${total}\uAC74`);
8218
+ }
8219
+ }
6890
8220
  async resolveNotionLinks(paths) {
6891
8221
  let totalResolved = 0;
6892
8222
  const allRecords = this.stateDb.getAll();
@@ -7035,7 +8365,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
7035
8365
  localMtime: null,
7036
8366
  localFileSize: null
7037
8367
  });
7038
- await this.imageHandler.uploadAndAppendImages(page.id, conversionResult.images);
8368
+ await this.imageHandler.uploadAndAppendImages(page.id, conversionResult.images, path);
7039
8369
  const hash = computeHash(content);
7040
8370
  const fileStat = await this.vaultFs.getFileStat(path);
7041
8371
  this.stateDb.transaction(() => {
@@ -7081,7 +8411,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
7081
8411
  parentMode: this.config.notion.parentMode
7082
8412
  });
7083
8413
  await this.pushUpdatePage(record.notionPageId, conversionResult.content, record.baseSnapshot);
7084
- await this.imageHandler.uploadAndAppendImages(record.notionPageId, conversionResult.images);
8414
+ await this.imageHandler.uploadAndAppendImages(
8415
+ record.notionPageId,
8416
+ conversionResult.images,
8417
+ path
8418
+ );
7085
8419
  let propsToUpdate;
7086
8420
  if (this.isDatabaseMode && conversionResult.properties && Object.keys(conversionResult.properties).length > 0) {
7087
8421
  await this.ensureDbSchema();
@@ -7305,7 +8639,9 @@ var SyncOrchestrator = class _SyncOrchestrator {
7305
8639
  async detectRemoteChangesIncremental(since) {
7306
8640
  const changes = [];
7307
8641
  this._childParentIds.clear();
7308
- const recentPages = await this.notionClient.searchRecentPages(since);
8642
+ const sinceMs = Date.parse(since);
8643
+ const safeSince = Number.isFinite(sinceMs) ? new Date(sinceMs - INCREMENTAL_SAFETY_WINDOW_MS).toISOString() : since;
8644
+ const recentPages = await this.notionClient.searchRecentPages(safeSince);
7309
8645
  for (const page of recentPages) {
7310
8646
  const record = this.stateDb.getByNotionId(page.id);
7311
8647
  if (!record) {
@@ -7346,7 +8682,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
7346
8682
  const safeName = sanitizeFileName(title);
7347
8683
  const parentPath = await this.resolveParentPath(page);
7348
8684
  const hasChildPages = await this.pageHasChildContainers(pageId);
7349
- const markdown = await this.fetchPageMarkdown(pageId);
8685
+ const { content: markdown, compact: exportCompact } = await this.fetchPageMarkdown(pageId);
7350
8686
  const hasContent = markdown.trim().length > 0;
7351
8687
  let filePath;
7352
8688
  let fileType;
@@ -7374,10 +8710,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
7374
8710
  } else {
7375
8711
  properties = this.notionClient.extractProperties(page);
7376
8712
  }
7377
- properties.title = title;
8713
+ if (this.isDatabaseMode || extractTitle(filePath) !== title) {
8714
+ properties.title = title;
8715
+ }
7378
8716
  let processedMarkdown = markdown;
7379
8717
  if (this.config.conversion.imageDownload === "immediate") {
7380
- const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
8718
+ const imageResult = await this.imageHandler.downloadAllImages(markdown, title, pageId);
7381
8719
  processedMarkdown = imageResult.content;
7382
8720
  this._pullImageCount += imageResult.downloads.length;
7383
8721
  }
@@ -7392,7 +8730,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
7392
8730
  filePath,
7393
8731
  parentMode: this.config.notion.parentMode
7394
8732
  },
7395
- { properties }
8733
+ { properties, notionExportCompact: exportCompact }
7396
8734
  );
7397
8735
  const resolvedParentId = await this.extractParentId(page);
7398
8736
  await this.vaultFs.writeFile(filePath, finalContent);
@@ -7449,7 +8787,8 @@ var SyncOrchestrator = class _SyncOrchestrator {
7449
8787
  const record = this.stateDb.getByNotionId(change.pageId);
7450
8788
  if (!record) return {};
7451
8789
  const page = await this.notionClient.getPage(change.pageId);
7452
- let markdown = await this.fetchPageMarkdown(change.pageId);
8790
+ const fetched = await this.fetchPageMarkdown(change.pageId);
8791
+ let markdown = fetched.content;
7453
8792
  const title = this.notionClient.extractTitle(page);
7454
8793
  let properties;
7455
8794
  if (this.isDatabaseMode) {
@@ -7460,9 +8799,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
7460
8799
  } else {
7461
8800
  properties = this.notionClient.extractProperties(page);
7462
8801
  }
7463
- properties.title = title;
8802
+ if (this.isDatabaseMode || extractTitle(record.obsidianPath) !== title) {
8803
+ properties.title = title;
8804
+ }
7464
8805
  if (this.config.conversion.imageDownload === "immediate") {
7465
- const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
8806
+ const imageResult = await this.imageHandler.downloadAllImages(markdown, title, change.pageId);
7466
8807
  markdown = imageResult.content;
7467
8808
  this._pullImageCount += imageResult.downloads.length;
7468
8809
  }
@@ -7478,7 +8819,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
7478
8819
  filePath: record.obsidianPath,
7479
8820
  parentMode: this.config.notion.parentMode
7480
8821
  },
7481
- { properties, preserveMarkers: savedMarkers.length > 0 ? savedMarkers : void 0 }
8822
+ {
8823
+ properties,
8824
+ preserveMarkers: savedMarkers.length > 0 ? savedMarkers : void 0,
8825
+ notionExportCompact: fetched.compact
8826
+ }
7482
8827
  );
7483
8828
  let localContent;
7484
8829
  try {
@@ -7739,24 +9084,51 @@ var SyncOrchestrator = class _SyncOrchestrator {
7739
9084
  try {
7740
9085
  const result = await this.notionClient.getPageMarkdown(pageId);
7741
9086
  this.collectInlineDbRefs(pageId, result.markdown);
7742
- return this.resolveNotionIdWikilinks(notionEnhancedToObsidian(result.markdown));
9087
+ return {
9088
+ content: this.resolveNotionIdWikilinks(notionEnhancedToObsidian(result.markdown)),
9089
+ // 압축형 판정은 반드시 원시 export 기준 — enhanced 변환이 <empty-block/> 을
9090
+ // 빈 줄로 바꾼 뒤에는 BlockSpacer 가 저작형과 구분할 수 없다(D1).
9091
+ compact: isCompactExport(result.markdown)
9092
+ };
7743
9093
  } catch {
7744
9094
  }
7745
9095
  }
7746
- return this.blockConverter.notionBlocksToMarkdown(pageId);
9096
+ const fallback = await this.blockConverter.notionBlocksToMarkdown(pageId);
9097
+ this.collectInlineDbRefs(pageId, fallback);
9098
+ return { content: fallback, compact: false };
7747
9099
  }
7748
- // Markdown API는 인라인 데이터베이스를 다음처럼 렌더한다(컬럼/synced_block 내부 포함):
7749
- // <database url="https://www.notion.so/<id-nohyph>" inline="true"
7750
- // data-source-url="collection://<ds-id>">Title</database>
7751
- // url 안의 32자리 hex 가 databaseId 이므로, 별도 블록 트리 재귀 없이 이 태그만 파싱해
7752
- // 깊이 중첩된 child_database 까지 폴더+.base 동기화 대상으로 등록한다.
9100
+ // Markdown API는 인라인 데이터베이스를 <database url="..." ...>Title</database> 로 렌더한다
9101
+ // (컬럼/콜아웃/synced_block 내부 포함). url 의 32자리 hex 가 databaseId 이므로 블록 트리
9102
+ // 재귀 없이 이 신호만으로 깊이 중첩된 child_database 를 발견한다. url 호스트는
9103
+ // www.notion.so / app.notion.com/p 두 형태가 실측돼 고정하지 않는다 — 규칙은
9104
+ // extractInlineDbIds(blocks-API 폴백 마커 겸용) 참조.
7753
9105
  collectInlineDbRefs(parentPageId, rawMarkdown) {
7754
- const re = /<database\b[^>]*\burl="https:\/\/www\.notion\.so\/([a-f0-9]{32})"[^>]*>/g;
7755
- let m;
7756
- while ((m = re.exec(rawMarkdown)) !== null) {
7757
- const dbId = m[1];
7758
- if (dbId) this._inlineDbRefs.set(dbId, parentPageId);
9106
+ for (const dbId of extractInlineDbIds(rawMarkdown)) {
9107
+ this._inlineDbRefs.set(dbId, parentPageId);
9108
+ }
9109
+ }
9110
+ /**
9111
+ * 이번 라운드에 기록된 DB row md 를 읽어 본문의 인라인 DB 참조를 수집한다.
9112
+ * row 페이지는 DatabaseSyncer 자체 파이프라인으로 변환돼 fetchPageMarkdown 의
9113
+ * _inlineDbRefs 수집을 타지 않는다 — 디스크에 남은 보존 마커가 유일한 발견 신호다.
9114
+ * 부모 Notion 페이지 id 는 state DB 역조회(getByPath)로 얻는다(row upsert 직후라 항상 존재).
9115
+ */
9116
+ async collectRowInlineRefs(rowPaths) {
9117
+ const refs = [];
9118
+ for (const path of rowPaths) {
9119
+ if (!path.endsWith(".md")) continue;
9120
+ try {
9121
+ const ids = extractInlineDbIds(await this.vaultFs.readFile(path));
9122
+ if (ids.length === 0) continue;
9123
+ const record = this.stateDb.getByPath(path);
9124
+ if (!record?.notionPageId) continue;
9125
+ for (const id of ids) {
9126
+ refs.push({ dbId: normalizeNotionId(id), parentPageId: record.notionPageId });
9127
+ }
9128
+ } catch {
9129
+ }
7759
9130
  }
9131
+ return refs;
7760
9132
  }
7761
9133
  // pull 시 url 기반 page mention 은 `[[notion:<id>]]` 로 1차 변환된다(notionEnhancedToObsidian).
7762
9134
  // 이를 state DB 역조회로 원래 `[[제목]]` 위키링크로 복원해 push↔pull 라운드트립을 수렴시킨다.
@@ -8039,7 +9411,7 @@ var NodeVaultFS = class {
8039
9411
  if (this.isExcluded(relativePath)) continue;
8040
9412
  if (entry.isDirectory()) {
8041
9413
  await this.walkDirNonMd(fullPath, result);
8042
- } else if (entry.isFile() && !entry.name.endsWith(".md") && !entry.name.endsWith(".base")) {
9414
+ } else if (entry.isFile() && !entry.name.endsWith(".md")) {
8043
9415
  const fileStat = await stat(fullPath);
8044
9416
  result.push({
8045
9417
  path: relativePath,