@im-nobsidian/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2312,6 +2312,50 @@ var FrontmatterExtractor = class {
2312
2312
  }
2313
2313
  };
2314
2314
 
2315
+ // src/utils/md-regions.ts
2316
+ function computeAnchor(content, idx) {
2317
+ const before = content.slice(Math.max(0, idx - 32), idx);
2318
+ const sameLine = before.split("\n").pop() ?? "";
2319
+ if (sameLine.trim().length >= 4) return sameLine;
2320
+ const prevLines = content.slice(0, idx).split("\n");
2321
+ for (let i = prevLines.length - 2; i >= 0; i--) {
2322
+ const line = prevLines[i].trim();
2323
+ if (line.length > 0) return line.slice(0, 48);
2324
+ }
2325
+ return "";
2326
+ }
2327
+ function mapOutsideCodeFences(content, fn) {
2328
+ const lines = content.split("\n");
2329
+ const out = [];
2330
+ let buffer = [];
2331
+ let fence = null;
2332
+ const flush = () => {
2333
+ if (buffer.length > 0) {
2334
+ out.push(fn(buffer.join("\n")));
2335
+ buffer = [];
2336
+ }
2337
+ };
2338
+ for (const line of lines) {
2339
+ const fenceMatch = line.match(/^\s*(```+|~~~+)/);
2340
+ if (fence === null && fenceMatch) {
2341
+ flush();
2342
+ fence = fenceMatch[1][0].repeat(3);
2343
+ out.push(line);
2344
+ continue;
2345
+ }
2346
+ if (fence !== null) {
2347
+ out.push(line);
2348
+ if (fenceMatch && fenceMatch[1].startsWith(fence)) {
2349
+ fence = null;
2350
+ }
2351
+ continue;
2352
+ }
2353
+ buffer.push(line);
2354
+ }
2355
+ flush();
2356
+ return out.join("\n");
2357
+ }
2358
+
2315
2359
  // src/converter/pre-processors/wikilink.ts
2316
2360
  var WIKILINK_REGEX2 = /(?<!!)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
2317
2361
  var WikilinkResolver = class {
@@ -2328,7 +2372,7 @@ var WikilinkResolver = class {
2328
2372
  const preserveMarkers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
2329
2373
  const resolved = input.content.replace(
2330
2374
  WIKILINK_REGEX2,
2331
- (_match, target, display) => {
2375
+ (_match, target, display, offset) => {
2332
2376
  const label = display ?? target;
2333
2377
  if (this.resolve) {
2334
2378
  const entry = this.resolve(target);
@@ -2336,10 +2380,12 @@ var WikilinkResolver = class {
2336
2380
  return `<mention-page id="${entry.notionPageId}">${label}</mention-page>`;
2337
2381
  }
2338
2382
  }
2383
+ const params = display ? { text: target, display } : { text: target };
2384
+ params.__anchor = computeAnchor(input.content, offset);
2339
2385
  preserveMarkers.push({
2340
2386
  type: "wikilink",
2341
- params: display ? { text: target, display } : { text: target },
2342
- startIndex: 0
2387
+ params,
2388
+ startIndex: offset
2343
2389
  });
2344
2390
  const encodedTarget = encodeURIComponent(target);
2345
2391
  return `[${label}](${WIKILINK_PROTOCOL}${encodedTarget})`;
@@ -2444,6 +2490,11 @@ var EmbedResolver = class {
2444
2490
  }
2445
2491
  return `![${target}](${encodeURI(target)})`;
2446
2492
  }
2493
+ if (isPush && !isExternalUrl(target) && isAttachmentFile(target)) {
2494
+ const fileName = target.split("/").pop() ?? target;
2495
+ return `> \u{1F4CE} ${fileName}
2496
+ > ${spacedMarker(`local-file:${target}`)}`;
2497
+ }
2447
2498
  const encoded = encodeURIComponent(target);
2448
2499
  return `[${target}](${EMBED_PROTOCOL}${encoded})`;
2449
2500
  });
@@ -2466,6 +2517,11 @@ var EmbedResolver = class {
2466
2517
  function isImageFile(path) {
2467
2518
  return /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(path);
2468
2519
  }
2520
+ function isAttachmentFile(target) {
2521
+ const path = target.split("|")[0];
2522
+ const ext = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase();
2523
+ return ext !== void 0 && ext !== "md" && ext !== "canvas";
2524
+ }
2469
2525
  function isVideoUrl(url) {
2470
2526
  return VIDEO_HOSTS.some((host) => url.includes(host));
2471
2527
  }
@@ -2484,7 +2540,7 @@ var PreserveMarkerCollector = class {
2484
2540
  for (const match of matches) {
2485
2541
  markers.push({
2486
2542
  type: match[1],
2487
- params: parseMarkerParams(match[2]),
2543
+ params: parseMarkerParams(match[2], input.content, match.index),
2488
2544
  startIndex: match.index
2489
2545
  });
2490
2546
  }
@@ -2494,14 +2550,19 @@ var PreserveMarkerCollector = class {
2494
2550
  };
2495
2551
  }
2496
2552
  };
2497
- function parseMarkerParams(paramString) {
2553
+ function parseMarkerParams(paramString, content, index) {
2498
2554
  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));
2555
+ if (paramString.includes("=")) {
2556
+ for (const pair of paramString.split("&")) {
2557
+ const eqIdx = pair.indexOf("=");
2558
+ if (eqIdx > 0) {
2559
+ params[pair.slice(0, eqIdx)] = decodeURIComponent(pair.slice(eqIdx + 1));
2560
+ }
2503
2561
  }
2562
+ } else {
2563
+ params.__raw = paramString;
2504
2564
  }
2565
+ params.__anchor = computeAnchor(content, index);
2505
2566
  return params;
2506
2567
  }
2507
2568
 
@@ -2522,7 +2583,20 @@ var UnsupportedBlockStripper = class {
2522
2583
  }
2523
2584
  };
2524
2585
  function stringifyFrontmatter(content, data) {
2525
- return matter.stringify({ content }, data);
2586
+ const out = matter.stringify(
2587
+ { content },
2588
+ data
2589
+ );
2590
+ return unquoteFrontmatterDates(out);
2591
+ }
2592
+ var QUOTED_DATE_LINE_RE = /^([ \t]*[^:\n]+:[ \t]*)'(\d{4}-\d{2}-\d{2})'([ \t]*)$/gm;
2593
+ function unquoteFrontmatterDates(out) {
2594
+ if (!out.startsWith("---\n")) return out;
2595
+ const close = out.indexOf("\n---\n", 3);
2596
+ if (close === -1) return out;
2597
+ const fmEnd = close + "\n---\n".length;
2598
+ const fm = out.slice(0, fmEnd).replace(QUOTED_DATE_LINE_RE, "$1$2$3");
2599
+ return fm + out.slice(fmEnd);
2526
2600
  }
2527
2601
 
2528
2602
  // src/converter/pre-processors/properties-table.ts
@@ -2557,6 +2631,7 @@ var PropertiesTableInjector = class {
2557
2631
 
2558
2632
  // src/converter/pre-processors/html-annotation.ts
2559
2633
  var UNDERLINE_REGEX = /<u>([\s\S]*?)<\/u>/g;
2634
+ var HIGHLIGHT_REGEX = /(?<!=)==([^=\n]+?)==(?!=)/g;
2560
2635
  var COLOR_SPAN_REGEX = /<span class="notion-(\w+)(-bg)?">([\s\S]*?)<\/span>/g;
2561
2636
  var SPACED_COLOR_REGEX = new RegExp(
2562
2637
  `%% ${MARKER_BRAND_RE}:color:(\\w+(?:_background)?) %%([\\s\\S]*?)%% ${MARKER_BRAND_RE}:end %%`,
@@ -2585,10 +2660,136 @@ var InlineAnnotationPreserver = class {
2585
2660
  return `${compactMarker(`color:${color}`)}${text}%%/color%%`;
2586
2661
  }
2587
2662
  );
2663
+ content = mapOutsideCodeFences(
2664
+ content,
2665
+ (segment) => segment.replace(
2666
+ HIGHLIGHT_REGEX,
2667
+ (_m, text) => `${compactMarker("color:yellow_bg")}${text}%%/color%%`
2668
+ )
2669
+ );
2670
+ return { content, metadata: input.metadata };
2671
+ }
2672
+ };
2673
+
2674
+ // src/converter/pre-processors/comment-stripper.ts
2675
+ var COMMENT_REGEX = /%%([\s\S]*?)%%/g;
2676
+ var CommentStripper = class {
2677
+ name = "CommentStripper";
2678
+ // FrontmatterExtractor(10) 뒤 — YAML 값 속 `%%` 를 본문 주석으로 오인하지 않는다.
2679
+ order = 11;
2680
+ process(input) {
2681
+ if (input.context.direction !== "push") {
2682
+ return { content: input.content, metadata: input.metadata };
2683
+ }
2684
+ const markers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
2685
+ const content = mapOutsideCodeFences(
2686
+ input.content,
2687
+ (segment) => segment.replace(COMMENT_REGEX, (match, body, offset) => {
2688
+ const trimmed = body.trimStart();
2689
+ if (trimmed.startsWith(`${MARKER_BRAND}:`) || trimmed.startsWith("/")) {
2690
+ return match;
2691
+ }
2692
+ markers.push({
2693
+ type: "comment",
2694
+ params: { text: body, __anchor: computeAnchor(segment, offset) },
2695
+ startIndex: offset
2696
+ });
2697
+ return "";
2698
+ })
2699
+ );
2700
+ return {
2701
+ content,
2702
+ metadata: { ...input.metadata, preserveMarkers: markers }
2703
+ };
2704
+ }
2705
+ };
2706
+
2707
+ // src/converter/pre-processors/footnote-guard.ts
2708
+ var FOOTNOTE_TOKEN_REGEX = /(?<!\\)\[\^([^\]\s]+)\]/g;
2709
+ var FootnoteGuard = class {
2710
+ name = "FootnoteGuard";
2711
+ order = 12;
2712
+ process(input) {
2713
+ if (input.context.direction !== "push") {
2714
+ return { content: input.content, metadata: input.metadata };
2715
+ }
2716
+ const content = mapOutsideCodeFences(
2717
+ input.content,
2718
+ (segment) => segment.replace(FOOTNOTE_TOKEN_REGEX, "\\[^$1]")
2719
+ );
2588
2720
  return { content, metadata: input.metadata };
2589
2721
  }
2590
2722
  };
2591
2723
 
2724
+ // src/converter/pre-processors/table-alignment.ts
2725
+ var DELIMITER_CELL_RE = /^:?-{1,}:?$/;
2726
+ function parseDelimiterRow(line) {
2727
+ const trimmed = line.trim();
2728
+ if (!trimmed.includes("-") || !trimmed.includes("|")) return null;
2729
+ const inner = trimmed.replace(/^\|/, "").replace(/\|$/, "");
2730
+ const cells = inner.split("|").map((c) => c.trim());
2731
+ if (cells.length === 0) return null;
2732
+ return cells.every((c) => DELIMITER_CELL_RE.test(c)) ? cells : null;
2733
+ }
2734
+ function cellAlignment(cell) {
2735
+ const l = cell.startsWith(":");
2736
+ const r = cell.endsWith(":");
2737
+ if (l && r) return "center";
2738
+ if (r) return "right";
2739
+ if (l) return "left";
2740
+ return "none";
2741
+ }
2742
+ function alignmentsToDelimiter(alignments) {
2743
+ const cells = alignments.map((a) => {
2744
+ switch (a) {
2745
+ case "center":
2746
+ return ":---:";
2747
+ case "right":
2748
+ return "---:";
2749
+ case "left":
2750
+ return ":---";
2751
+ default:
2752
+ return "---";
2753
+ }
2754
+ });
2755
+ return `| ${cells.join(" | ")} |`;
2756
+ }
2757
+ var TableAlignmentGuard = class {
2758
+ name = "TableAlignmentGuard";
2759
+ order = 45;
2760
+ process(input) {
2761
+ if (input.context.direction !== "push") {
2762
+ return { content: input.content, metadata: input.metadata };
2763
+ }
2764
+ const markers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
2765
+ const lines = input.content.split("\n");
2766
+ let changed = false;
2767
+ for (let i = 1; i < lines.length; i++) {
2768
+ const cells = parseDelimiterRow(lines[i]);
2769
+ if (!cells || !lines[i].includes(":")) continue;
2770
+ const prev = lines[i - 1];
2771
+ if (!prev.includes("|")) continue;
2772
+ markers.push({
2773
+ type: "table-align",
2774
+ params: {
2775
+ align: cells.map(cellAlignment).join(","),
2776
+ __anchor: prev.trim().slice(0, 64),
2777
+ // 원본 구분행 원문. 복원 시 정렬뿐 아니라 셀 폭 패딩까지 그대로 되살려
2778
+ // 라운드트립 delta-0 을 만족시킨다(__ 접두라 마커 렌더링에서 제외).
2779
+ __line: lines[i]
2780
+ },
2781
+ startIndex: 0
2782
+ });
2783
+ lines[i] = alignmentsToDelimiter(cells.map(() => "none"));
2784
+ changed = true;
2785
+ }
2786
+ return {
2787
+ content: changed ? lines.join("\n") : input.content,
2788
+ metadata: { ...input.metadata, preserveMarkers: markers }
2789
+ };
2790
+ }
2791
+ };
2792
+
2592
2793
  // src/converter/link-restore.ts
2593
2794
  var NOTION_LINK_REGEX = /\[([^\]]+)\]\((?:https:\/\/(?:www\.)?notion\.so\/|notion:\/\/)([a-f0-9-]+)\)/g;
2594
2795
  var WIKILINK_PRESERVE_REGEX = new RegExp(`\\[([^\\]]+)\\]\\(${WIKILINK_PROTOCOL}([^)]+)\\)`, "g");
@@ -2620,6 +2821,91 @@ var MentionToWikilink = class {
2620
2821
  }
2621
2822
  };
2622
2823
 
2824
+ // src/converter/post-processors/escape-normalizer.ts
2825
+ var EscapeNormalizer = class {
2826
+ name = "EscapeNormalizer";
2827
+ order = 3;
2828
+ process(input) {
2829
+ if (input.context.direction !== "pull") {
2830
+ return { content: input.content, metadata: input.metadata };
2831
+ }
2832
+ const content = mapOutsideCodeFences(
2833
+ input.content,
2834
+ (segment) => segment.replace(/\\\[\^([^\]\\\n]+)\\\]/g, "[^$1]").replace(/\\\[\^/g, "[^").replace(/\\:/g, ":")
2835
+ );
2836
+ return { content, metadata: input.metadata };
2837
+ }
2838
+ };
2839
+
2840
+ // src/converter/post-processors/highlight-restorer.ts
2841
+ var COMPACT_YELLOW_RE = new RegExp(
2842
+ `%%${MARKER_BRAND_RE}:color:yellow(?:_background|_bg)%%([^\\n]*?)%%\\/color%%`,
2843
+ "g"
2844
+ );
2845
+ var SPACED_YELLOW_RE = new RegExp(
2846
+ `%% ${MARKER_BRAND_RE}:color:yellow(?:_background|_bg) %%([^\\n]*?)%% ${MARKER_BRAND_RE}:end %%`,
2847
+ "g"
2848
+ );
2849
+ var HighlightRestorer = class {
2850
+ name = "HighlightRestorer";
2851
+ order = 25;
2852
+ process(input) {
2853
+ if (input.context.direction !== "pull") {
2854
+ return { content: input.content, metadata: input.metadata };
2855
+ }
2856
+ const content = input.content.replace(COMPACT_YELLOW_RE, (_m, text) => text ? `==${text}==` : "").replace(SPACED_YELLOW_RE, (_m, text) => text ? `==${text}==` : "");
2857
+ return { content, metadata: input.metadata };
2858
+ }
2859
+ };
2860
+
2861
+ // src/converter/post-processors/table-alignment-restorer.ts
2862
+ var TableAlignmentRestorer = class {
2863
+ name = "TableAlignmentRestorer";
2864
+ order = 35;
2865
+ process(input) {
2866
+ if (input.context.direction !== "pull") {
2867
+ return { content: input.content, metadata: input.metadata };
2868
+ }
2869
+ const lines = input.content.split("\n");
2870
+ for (let i = 1; i < lines.length - 1; i++) {
2871
+ const delim = parseDelimiterRow(lines[i]);
2872
+ if (!delim || lines[i].includes(":")) continue;
2873
+ const phantom = parseDelimiterRow(lines[i + 1]);
2874
+ if (!phantom || !lines[i + 1].includes(":") || phantom.length !== delim.length) continue;
2875
+ if (!lines[i - 1].includes("|")) continue;
2876
+ lines[i] = alignmentsToDelimiter(phantom.map(cellAlignment));
2877
+ lines.splice(i + 1, 1);
2878
+ }
2879
+ const markers = input.metadata.preserveMarkers ?? [];
2880
+ const remaining = [];
2881
+ for (const marker of markers) {
2882
+ if (marker.type !== "table-align" || !marker.params.align || !marker.params.__anchor) {
2883
+ remaining.push(marker);
2884
+ continue;
2885
+ }
2886
+ const anchor = marker.params.__anchor;
2887
+ const idx = lines.findIndex(
2888
+ (line, i) => i < lines.length - 1 && line.trim().startsWith(anchor) && parseDelimiterRow(lines[i + 1]) !== null
2889
+ );
2890
+ if (idx === -1) continue;
2891
+ const delimCells = parseDelimiterRow(lines[idx + 1]);
2892
+ if (lines[idx + 1].includes(":")) continue;
2893
+ const originalLine = marker.params.__line;
2894
+ if (originalLine && parseDelimiterRow(originalLine)?.length === delimCells.length) {
2895
+ lines[idx + 1] = originalLine;
2896
+ continue;
2897
+ }
2898
+ const alignments = marker.params.align.split(",");
2899
+ while (alignments.length < delimCells.length) alignments.push("none");
2900
+ lines[idx + 1] = alignmentsToDelimiter(alignments.slice(0, delimCells.length));
2901
+ }
2902
+ return {
2903
+ content: lines.join("\n"),
2904
+ metadata: { ...input.metadata, preserveMarkers: remaining }
2905
+ };
2906
+ }
2907
+ };
2908
+
2623
2909
  // src/converter/post-processors/preserve-marker-injector.ts
2624
2910
  var PreserveMarkerInjector = class {
2625
2911
  name = "PreserveMarkerInjector";
@@ -2633,23 +2919,89 @@ var PreserveMarkerInjector = class {
2633
2919
  return { content: input.content, metadata: input.metadata };
2634
2920
  }
2635
2921
  let content = input.content;
2636
- const missingMarkers = markers.filter((m) => !content.includes(this.formatMarker(m)));
2637
- if (missingMarkers.length === 0) {
2922
+ const missing = markers.filter((m) => {
2923
+ const rendered = this.renderMarker(m);
2924
+ if (rendered === null) return false;
2925
+ if (content.includes(rendered)) return false;
2926
+ return !this.isSemanticallyRestored(m, content);
2927
+ });
2928
+ if (missing.length === 0) {
2638
2929
  return { content, metadata: input.metadata };
2639
2930
  }
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);
2931
+ const sorted = [...missing].sort((a, b) => a.startIndex - b.startIndex);
2932
+ for (const marker of sorted) {
2933
+ const rendered = this.renderMarker(marker);
2934
+ if (marker.params?.__anchor === void 0) {
2935
+ const at2 = Math.max(frontmatterEnd(content), Math.min(marker.startIndex, content.length));
2936
+ content = content.slice(0, at2) + rendered + content.slice(at2);
2937
+ continue;
2938
+ }
2939
+ const at = this.findInsertionPoint(marker, content);
2940
+ const before = content.slice(0, at);
2941
+ const needsNewline = before.length > 0 && !before.endsWith("\n");
2942
+ content = before + (needsNewline ? "\n" : "") + rendered + "\n" + content.slice(at);
2645
2943
  }
2646
2944
  return { content, metadata: input.metadata };
2647
2945
  }
2648
- formatMarker(marker) {
2649
- const params = Object.entries(marker.params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
2650
- return spacedMarker(`${marker.type}:${params}`);
2946
+ /** 마커의 본문 표현. 복원 불가능한(빈) 마커는 null — 삽입하지 않고 폐기한다. */
2947
+ renderMarker(marker) {
2948
+ const params = marker.params ?? {};
2949
+ if (marker.type === "comment") {
2950
+ return params.text ? `%%${params.text}%%` : null;
2951
+ }
2952
+ if (params.__raw !== void 0) {
2953
+ return spacedMarker(`${marker.type}:${params.__raw}`);
2954
+ }
2955
+ const entries = Object.entries(params).filter(([key]) => !key.startsWith("__"));
2956
+ if (entries.length === 0) return null;
2957
+ const body = entries.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
2958
+ return spacedMarker(`${marker.type}:${body}`);
2959
+ }
2960
+ /** 마커가 이미 원래 문법으로 복원돼 있으면 재삽입은 중복·오염이다. */
2961
+ isSemanticallyRestored(marker, content) {
2962
+ const params = marker.params ?? {};
2963
+ switch (marker.type) {
2964
+ case "wikilink": {
2965
+ const text = params.text;
2966
+ if (!text) return false;
2967
+ return content.includes(`[[${text}]]`) || content.includes(`[[${text}|`);
2968
+ }
2969
+ case "local-image":
2970
+ case "local-file": {
2971
+ const target = params.__raw ?? params.path;
2972
+ return target ? content.includes(`![[${target}]]`) : false;
2973
+ }
2974
+ case "table-align":
2975
+ return true;
2976
+ default:
2977
+ return false;
2978
+ }
2979
+ }
2980
+ /** 삽입 위치: 앵커 → startIndex(줄 경계 보정) 순. 항상 프론트매터 뒤. */
2981
+ findInsertionPoint(marker, content) {
2982
+ const fmEnd = frontmatterEnd(content);
2983
+ const anchor = marker.params?.__anchor;
2984
+ if (anchor && anchor.trim().length >= 4) {
2985
+ const idx = content.indexOf(anchor, fmEnd);
2986
+ if (idx !== -1) {
2987
+ return nextLineStart(content, idx + anchor.length);
2988
+ }
2989
+ }
2990
+ const clamped = Math.max(fmEnd, Math.min(marker.startIndex, content.length));
2991
+ return nextLineStart(content, clamped);
2651
2992
  }
2652
2993
  };
2994
+ function frontmatterEnd(content) {
2995
+ if (!content.startsWith("---\n")) return 0;
2996
+ const close = content.indexOf("\n---", 3);
2997
+ if (close === -1) return 0;
2998
+ const lineEnd = content.indexOf("\n", close + 4);
2999
+ return lineEnd === -1 ? content.length : lineEnd + 1;
3000
+ }
3001
+ function nextLineStart(content, pos) {
3002
+ const nl = content.indexOf("\n", pos);
3003
+ return nl === -1 ? content.length : nl + 1;
3004
+ }
2653
3005
 
2654
3006
  // src/converter/post-processors/callout-restorer.ts
2655
3007
  var EMOJI_TYPE_MAP = {
@@ -2785,7 +3137,7 @@ function normalizeDate(dateStr) {
2785
3137
 
2786
3138
  // src/converter/post-processors/local-image-restorer.ts
2787
3139
  var LOCAL_IMAGE_MARKER_REGEX = new RegExp(
2788
- `>\\s*\u{1F4CE}\\s*[^\\n]*\\n>\\s*%%\\s*${MARKER_BRAND_RE}:local-image:([^\\s]+)\\s*%%`,
3140
+ `>\\s*\u{1F4CE}\\s*[^\\n]*\\n>\\s*%%\\s*${MARKER_BRAND_RE}:local-(?:image|file):([^\\s]+)\\s*%%`,
2789
3141
  "g"
2790
3142
  );
2791
3143
  var LocalImageRestorer = class {
@@ -2897,25 +3249,178 @@ function stripQuotes(s) {
2897
3249
  return s;
2898
3250
  }
2899
3251
 
3252
+ // src/converter/post-processors/block-spacer.ts
3253
+ var BlockSpacer = class {
3254
+ name = "BlockSpacer";
3255
+ order = 110;
3256
+ process(input) {
3257
+ if (input.context.direction !== "pull") {
3258
+ return { content: input.content, metadata: input.metadata };
3259
+ }
3260
+ return {
3261
+ content: respace(input.content, input.metadata.notionExportCompact),
3262
+ metadata: input.metadata
3263
+ };
3264
+ }
3265
+ };
3266
+ var LIST_ITEM_RE = /^[ \t]*(?:[-*+]|\d+[.)])\s/;
3267
+ var QUOTE_RE = /^[ \t]*>/;
3268
+ var CALLOUT_START_RE = /^[ \t]*>\s*\[![^\]]*\]/;
3269
+ var TABLE_ROW_RE = /^[ \t]*\|/;
3270
+ var FOOTNOTE_DEF_RE = /^\[\^[^\]]+\]:/;
3271
+ var FENCE_RE = /^[ \t]*(```+|~~~+)/;
3272
+ var MATH_FENCE_RE = /^[ \t]*\$\$\s*$/;
3273
+ var BRAND_MARKER_LINE_RE = new RegExp(`^[ \\t]*%%\\s*${MARKER_BRAND}:`);
3274
+ var INDENTED_CONTINUATION_RE = /^(?:\t| {2,})\S/;
3275
+ function respace(content, sourceCompact) {
3276
+ if (sourceCompact === false) return content;
3277
+ const lines = content.split("\n");
3278
+ const head = [];
3279
+ let start = 0;
3280
+ if (lines[0]?.trim() === "---") {
3281
+ for (let i = 1; i < lines.length; i++) {
3282
+ if (lines[i].trim() === "---") {
3283
+ for (let j = 0; j <= i; j++) head.push(lines[j]);
3284
+ start = i + 1;
3285
+ break;
3286
+ }
3287
+ }
3288
+ }
3289
+ if (sourceCompact === void 0 && hasBlankOutsideFences(lines, start)) return content;
3290
+ const blocks = [];
3291
+ let fence = null;
3292
+ let fenceToken = "";
3293
+ let boundary = false;
3294
+ const push = (kind, line) => {
3295
+ blocks.push({ kind, lines: [line] });
3296
+ };
3297
+ const appendToLast = (line) => {
3298
+ blocks[blocks.length - 1].lines.push(line);
3299
+ };
3300
+ const last = () => blocks[blocks.length - 1];
3301
+ for (let i = start; i < lines.length; i++) {
3302
+ const raw = lines[i];
3303
+ if (fence !== null) {
3304
+ appendToLast(raw);
3305
+ const closed = fence === "code" ? FENCE_RE.test(raw) && raw.trim().startsWith(fenceToken) : MATH_FENCE_RE.test(raw);
3306
+ if (closed) fence = null;
3307
+ continue;
3308
+ }
3309
+ if (raw.trim() === "") {
3310
+ boundary = true;
3311
+ continue;
3312
+ }
3313
+ const atBoundary = boundary;
3314
+ boundary = false;
3315
+ const fenceMatch = raw.match(FENCE_RE);
3316
+ if (fenceMatch) {
3317
+ push("fence", raw);
3318
+ fence = "code";
3319
+ fenceToken = fenceMatch[1][0].repeat(3);
3320
+ continue;
3321
+ }
3322
+ if (MATH_FENCE_RE.test(raw)) {
3323
+ push("math", raw);
3324
+ fence = "math";
3325
+ continue;
3326
+ }
3327
+ const line = LIST_ITEM_RE.test(raw) || INDENTED_CONTINUATION_RE.test(raw) ? raw.replace(/^\t+/, (t) => " ".repeat(t.length)) : raw;
3328
+ if (BRAND_MARKER_LINE_RE.test(line) && blocks.length > 0) {
3329
+ appendToLast(line);
3330
+ continue;
3331
+ }
3332
+ if (LIST_ITEM_RE.test(line)) {
3333
+ if (last()?.kind === "list" && !atBoundary) appendToLast(line);
3334
+ else push("list", line);
3335
+ continue;
3336
+ }
3337
+ if (last()?.kind === "list" && INDENTED_CONTINUATION_RE.test(line)) {
3338
+ appendToLast(line);
3339
+ continue;
3340
+ }
3341
+ if (QUOTE_RE.test(line)) {
3342
+ if (last()?.kind === "quote" && !CALLOUT_START_RE.test(line) && !atBoundary) {
3343
+ appendToLast(line);
3344
+ } else {
3345
+ push("quote", line);
3346
+ }
3347
+ continue;
3348
+ }
3349
+ if (TABLE_ROW_RE.test(line)) {
3350
+ if (last()?.kind === "table" && !atBoundary) appendToLast(line);
3351
+ else push("table", line);
3352
+ continue;
3353
+ }
3354
+ if (FOOTNOTE_DEF_RE.test(line)) {
3355
+ if (last()?.kind === "footnote" && !atBoundary) appendToLast(line);
3356
+ else push("footnote", line);
3357
+ continue;
3358
+ }
3359
+ push("other", line);
3360
+ }
3361
+ const body = blocks.map((b) => b.lines.join("\n")).join("\n\n");
3362
+ const tail = content.endsWith("\n") ? "\n" : "";
3363
+ if (head.length === 0) return body + tail;
3364
+ return `${head.join("\n")}
3365
+
3366
+ ${body}${tail}`;
3367
+ }
3368
+ function isCompactExport(rawMarkdown) {
3369
+ return !hasBlankOutsideFences(rawMarkdown.split("\n"), 0);
3370
+ }
3371
+ function hasBlankOutsideFences(lines, start) {
3372
+ let end = lines.length - 1;
3373
+ while (end >= start && lines[end].trim() === "") end--;
3374
+ let fence = null;
3375
+ let fenceToken = "";
3376
+ for (let i = start; i <= end; i++) {
3377
+ const raw = lines[i];
3378
+ if (fence !== null) {
3379
+ const closed = fence === "code" ? FENCE_RE.test(raw) && raw.trim().startsWith(fenceToken) : MATH_FENCE_RE.test(raw);
3380
+ if (closed) fence = null;
3381
+ continue;
3382
+ }
3383
+ const fenceMatch = raw.match(FENCE_RE);
3384
+ if (fenceMatch) {
3385
+ fence = "code";
3386
+ fenceToken = fenceMatch[1][0].repeat(3);
3387
+ continue;
3388
+ }
3389
+ if (MATH_FENCE_RE.test(raw)) {
3390
+ fence = "math";
3391
+ continue;
3392
+ }
3393
+ if (raw.trim() === "") return true;
3394
+ }
3395
+ return false;
3396
+ }
3397
+
2900
3398
  // src/converter/pipeline-factory.ts
2901
3399
  function createDefaultPipeline(options) {
2902
3400
  const pipeline = new ConversionPipeline();
2903
3401
  pipeline.registerPreProcessor(new InlineAnnotationPreserver());
2904
3402
  pipeline.registerPreProcessor(new UnsupportedBlockStripper());
3403
+ pipeline.registerPreProcessor(new CommentStripper());
2905
3404
  pipeline.registerPreProcessor(new FrontmatterExtractor());
3405
+ pipeline.registerPreProcessor(new FootnoteGuard());
2906
3406
  pipeline.registerPreProcessor(new PropertiesTableInjector());
2907
3407
  pipeline.registerPreProcessor(new WikilinkResolver(options?.wikilinkResolver));
2908
3408
  pipeline.registerPreProcessor(new CalloutTransformer());
3409
+ pipeline.registerPreProcessor(new TableAlignmentGuard());
2909
3410
  pipeline.registerPreProcessor(new MathNormalizer());
2910
3411
  pipeline.registerPreProcessor(new EmbedResolver());
2911
3412
  pipeline.registerPreProcessor(new PreserveMarkerCollector());
3413
+ pipeline.registerPostProcessor(new EscapeNormalizer());
2912
3414
  pipeline.registerPostProcessor(new PropertiesTableRestorer());
2913
3415
  pipeline.registerPostProcessor(new LocalImageRestorer());
3416
+ pipeline.registerPostProcessor(new HighlightRestorer());
3417
+ pipeline.registerPostProcessor(new TableAlignmentRestorer());
2914
3418
  pipeline.registerPostProcessor(new PreserveMarkerInjector());
2915
3419
  pipeline.registerPostProcessor(new MentionToWikilink());
2916
3420
  pipeline.registerPostProcessor(new CalloutRestorer());
2917
3421
  pipeline.registerPostProcessor(new ColorAnnotator());
2918
3422
  pipeline.registerPostProcessor(new FrontmatterGenerator());
3423
+ pipeline.registerPostProcessor(new BlockSpacer());
2919
3424
  return pipeline;
2920
3425
  }
2921
3426
 
@@ -3728,6 +4233,87 @@ var EXTENSION_TO_MIME = {
3728
4233
  ".ico": "image/x-icon",
3729
4234
  ".tiff": "image/tiff"
3730
4235
  };
4236
+ function scanMarkdownLinks(markdown) {
4237
+ const spans = [];
4238
+ let i = 0;
4239
+ while (i < markdown.length) {
4240
+ const open = markdown.indexOf("[", i);
4241
+ if (open === -1) break;
4242
+ if (markdown[open - 1] === "\\") {
4243
+ i = open + 1;
4244
+ continue;
4245
+ }
4246
+ const isEmbed = markdown[open - 1] === "!" && markdown[open - 2] !== "\\";
4247
+ let depth = 1;
4248
+ let j = open + 1;
4249
+ let broken = false;
4250
+ while (j < markdown.length && depth > 0) {
4251
+ const ch = markdown[j];
4252
+ if (ch === "\\") {
4253
+ j += 2;
4254
+ continue;
4255
+ }
4256
+ if (ch === "\n" && markdown[j + 1] === "\n") {
4257
+ broken = true;
4258
+ break;
4259
+ }
4260
+ if (ch === "[") depth++;
4261
+ else if (ch === "]") depth--;
4262
+ j++;
4263
+ }
4264
+ if (broken || depth !== 0 || markdown[j] !== "(") {
4265
+ i = open + 1;
4266
+ continue;
4267
+ }
4268
+ const label = markdown.slice(open + 1, j - 1);
4269
+ let parenDepth = 1;
4270
+ let k = j + 1;
4271
+ while (k < markdown.length && parenDepth > 0) {
4272
+ const ch = markdown[k];
4273
+ if (ch === "\\") {
4274
+ k += 2;
4275
+ continue;
4276
+ }
4277
+ if (ch === "\n") {
4278
+ broken = true;
4279
+ break;
4280
+ }
4281
+ if (ch === "(") parenDepth++;
4282
+ else if (ch === ")") parenDepth--;
4283
+ k++;
4284
+ }
4285
+ if (broken || parenDepth !== 0) {
4286
+ i = open + 1;
4287
+ continue;
4288
+ }
4289
+ const start = isEmbed ? open - 1 : open;
4290
+ spans.push({
4291
+ full: markdown.slice(start, k),
4292
+ label,
4293
+ url: markdown.slice(j + 1, k - 1),
4294
+ isEmbed
4295
+ });
4296
+ i = k;
4297
+ }
4298
+ return spans;
4299
+ }
4300
+ function toWikilinkAlias(label) {
4301
+ return label.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, "$1").replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, t, d) => d ?? t).replace(/[\r\n]+/g, " ").replace(/\]\]/g, ")").replace(/\|/g, "-").trim();
4302
+ }
4303
+ var INTERNAL_FILE_URL_RE = /^file:\/\/%7B.*%7D%7D$/;
4304
+ var FILE_LABEL_PREFIX_RE = /^(?:📎|🎬|🎞|📄|🔊)\s*/u;
4305
+ var LOCAL_MARKER_RE = new RegExp(`${MARKER_BRAND_RE}:local-(?:image|file):([^\\s%]+)`, "g");
4306
+ function collectLocalMarkerBasenames(markdown) {
4307
+ const names = /* @__PURE__ */ new Set();
4308
+ const unescaped = markdown.replace(/\\/g, "");
4309
+ for (const m of unescaped.matchAll(LOCAL_MARKER_RE)) {
4310
+ names.add(baseName(m[1]));
4311
+ }
4312
+ return names;
4313
+ }
4314
+ function baseName(path) {
4315
+ return path.split("/").pop() ?? path;
4316
+ }
3731
4317
  var ImageHandler = class {
3732
4318
  constructor(vaultFs, attachmentFolder = "attachments", notionClient, customFetch, options, stateDb) {
3733
4319
  this.vaultFs = vaultFs;
@@ -3753,6 +4339,9 @@ var ImageHandler = class {
3753
4339
  return this.customFetch ?? globalThis.fetch;
3754
4340
  }
3755
4341
  async downloadImage(url, pageTitle) {
4342
+ return this.persistImage(await this.fetchImage(url, pageTitle));
4343
+ }
4344
+ async fetchImage(url, pageTitle) {
3756
4345
  const maxRetries = this.maxRetries;
3757
4346
  let lastError = null;
3758
4347
  for (let attempt = 0; attempt < maxRetries; attempt++) {
@@ -3770,38 +4359,60 @@ var ImageHandler = class {
3770
4359
  const contentType = response.headers.get("content-type") ?? "image/png";
3771
4360
  const extension = IMAGE_EXTENSIONS[contentType] ?? this.guessExtension(url) ?? ".png";
3772
4361
  const buffer = Buffer.from(await response.arrayBuffer());
3773
- const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 12);
4362
+ const fullHash = createHash("sha256").update(buffer).digest("hex");
4363
+ const hash = fullHash.slice(0, 12);
3774
4364
  const safeName = pageTitle.replace(/[^a-zA-Z0-9가-힣\s-]/g, "").replace(/\s+/g, "-").slice(0, 50);
3775
4365
  const fileName = `${safeName}-${hash}${extension}`;
3776
4366
  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 };
4367
+ return { originalUrl: url, localPath, hash, fullHash, buffer };
3780
4368
  } catch (error) {
3781
4369
  lastError = error instanceof Error ? error : new Error(String(error));
3782
4370
  }
3783
4371
  }
3784
4372
  throw lastError;
3785
4373
  }
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)];
4374
+ async persistImage(fetched) {
4375
+ await this.vaultFs.ensureFolder(this.attachmentFolder);
4376
+ await this.vaultFs.writeBinary(fetched.localPath, fetched.buffer);
4377
+ return {
4378
+ originalUrl: fetched.originalUrl,
4379
+ localPath: fetched.localPath,
4380
+ hash: fetched.hash,
4381
+ size: fetched.buffer.length
4382
+ };
4383
+ }
4384
+ async downloadAllImages(markdown, pageTitle, pageId) {
4385
+ const embeds = scanMarkdownLinks(markdown).filter((s) => s.isEmbed);
4386
+ const notionMatches = embeds.filter(
4387
+ (s) => /^https?:\/\//.test(s.url) && this.isNotionImageUrl(s.url)
4388
+ );
4389
+ const internalMatches = embeds.filter((s) => INTERNAL_FILE_URL_RE.test(s.url));
3792
4390
  if (notionMatches.length === 0 && internalMatches.length === 0) {
3793
4391
  return { content: markdown, downloads: [] };
3794
4392
  }
4393
+ const registryByHash = /* @__PURE__ */ new Map();
4394
+ if (pageId && this.stateDb) {
4395
+ for (const entry of this.stateDb.getFilesByPageId(pageId)) {
4396
+ const list = registryByHash.get(entry.fileHash) ?? [];
4397
+ list.push(entry.localPath);
4398
+ registryByHash.set(entry.fileHash, list);
4399
+ }
4400
+ }
4401
+ const markerBasenames = collectLocalMarkerBasenames(markdown);
4402
+ const isAppendedCopy = (fetched) => {
4403
+ const paths = registryByHash.get(fetched.fullHash);
4404
+ return paths !== void 0 && paths.some((p) => markerBasenames.has(baseName(p)));
4405
+ };
3795
4406
  const sema = new Sema(this.concurrency);
3796
4407
  const downloads = [];
3797
4408
  let result = markdown;
3798
4409
  const httpResults = await Promise.allSettled(
3799
- notionMatches.map(async (match) => {
4410
+ notionMatches.map(async (span) => {
3800
4411
  await sema.acquire();
3801
4412
  try {
3802
4413
  return {
3803
- match,
3804
- download: await this.downloadImage(match[2], pageTitle)
4414
+ span,
4415
+ fetched: await this.fetchImage(span.url, pageTitle)
3805
4416
  };
3806
4417
  } finally {
3807
4418
  sema.release();
@@ -3810,26 +4421,37 @@ var ImageHandler = class {
3810
4421
  );
3811
4422
  for (const settled of httpResults) {
3812
4423
  if (settled.status !== "fulfilled") continue;
3813
- const { match, download } = settled.value;
4424
+ const { span, fetched } = settled.value;
4425
+ if (isAppendedCopy(fetched)) {
4426
+ result = result.replace(span.full, "");
4427
+ continue;
4428
+ }
4429
+ const download = await this.persistImage(fetched);
3814
4430
  downloads.push(download);
3815
- const obsidianEmbed = `![[${download.localPath}${match[1] ? `|${match[1]}` : ""}]]`;
3816
- result = result.replace(match[0], obsidianEmbed);
4431
+ const alias = toWikilinkAlias(span.label);
4432
+ const obsidianEmbed = `![[${download.localPath}${alias ? `|${alias}` : ""}]]`;
4433
+ result = result.replace(span.full, obsidianEmbed);
3817
4434
  }
3818
- for (const match of internalMatches) {
4435
+ for (const span of internalMatches) {
3819
4436
  await sema.acquire();
3820
4437
  try {
3821
- const fileUrl = match[2];
3822
- const parsed = this.parseNotionFileUrl(fileUrl);
4438
+ const parsed = this.parseNotionFileUrl(span.url);
3823
4439
  if (!parsed || !this.notionClient) continue;
3824
4440
  const realUrl = await this.notionClient.getFileBlockUrl(parsed.blockId);
3825
4441
  if (!realUrl) {
3826
4442
  getLogger().warn(`[Im-Nobsidian] \uC774\uBBF8\uC9C0 Block URL \uC870\uD68C \uC2E4\uD328: ${parsed.fileName}`);
3827
4443
  continue;
3828
4444
  }
3829
- const download = await this.downloadImage(realUrl, pageTitle);
4445
+ const fetched = await this.fetchImage(realUrl, pageTitle);
4446
+ if (isAppendedCopy(fetched)) {
4447
+ result = result.replace(span.full, "");
4448
+ continue;
4449
+ }
4450
+ const download = await this.persistImage(fetched);
3830
4451
  downloads.push(download);
3831
- const obsidianEmbed = `![[${download.localPath}${match[1] ? `|${match[1]}` : ""}]]`;
3832
- result = result.replace(match[0], obsidianEmbed);
4452
+ const alias = toWikilinkAlias(span.label);
4453
+ const obsidianEmbed = `![[${download.localPath}${alias ? `|${alias}` : ""}]]`;
4454
+ result = result.replace(span.full, obsidianEmbed);
3833
4455
  } catch (error) {
3834
4456
  getLogger().warn(`\uB0B4\uBD80 \uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
3835
4457
  } finally {
@@ -3838,27 +4460,28 @@ var ImageHandler = class {
3838
4460
  }
3839
4461
  return { content: result, downloads };
3840
4462
  }
3841
- async uploadLocalImage(localPath) {
4463
+ async uploadLocalImage(localPath, noteFolder) {
3842
4464
  if (!this.notionClient) {
3843
4465
  throw new Error("NotionClient required for image upload");
3844
4466
  }
3845
- const buffer = await this.readImageFromVault(localPath);
4467
+ const { buffer, resolvedPath } = await this.readImageFromVault(localPath, noteFolder);
3846
4468
  const filename = localPath.split("/").pop() ?? "image.png";
3847
4469
  const contentType = this.getContentTypeFromPath(filename);
3848
4470
  const blob = new Blob([buffer], { type: contentType });
3849
4471
  const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
3850
4472
  const fileHash = createHash("sha256").update(buffer).digest("hex");
3851
- return { localPath, fileUploadId, fileHash, fileSize: buffer.length };
4473
+ return { localPath: resolvedPath, fileUploadId, fileHash, fileSize: buffer.length };
3852
4474
  }
3853
- async uploadAndAppendImages(pageId, images) {
4475
+ async uploadAndAppendImages(pageId, images, notePath) {
3854
4476
  if (!this.notionClient) return [];
3855
4477
  const localImages = images.filter((img) => !img.isExternal && img.localPath);
3856
4478
  if (localImages.length === 0) return [];
4479
+ const noteFolder = notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
3857
4480
  const results = [];
3858
4481
  const imageBlocks = [];
3859
4482
  for (const img of localImages) {
3860
4483
  try {
3861
- const result = await this.uploadLocalImage(img.localPath);
4484
+ const result = await this.uploadLocalImage(img.localPath, noteFolder);
3862
4485
  results.push(result);
3863
4486
  imageBlocks.push({
3864
4487
  type: "image",
@@ -3868,7 +4491,11 @@ var ImageHandler = class {
3868
4491
  }
3869
4492
  });
3870
4493
  } catch (error) {
3871
- getLogger().warn(`\uC774\uBBF8\uC9C0 \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${img.localPath}):`, error);
4494
+ if (error?.code === "ENOENT") {
4495
+ 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}`);
4496
+ } else {
4497
+ getLogger().warn(`\uC774\uBBF8\uC9C0 \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${img.localPath}):`, error);
4498
+ }
3872
4499
  }
3873
4500
  }
3874
4501
  if (imageBlocks.length > 0) {
@@ -3892,23 +4519,39 @@ var ImageHandler = class {
3892
4519
  }
3893
4520
  return results;
3894
4521
  }
3895
- async readImageFromVault(localPath) {
3896
- try {
3897
- return await this.vaultFs.readBinary(localPath);
3898
- } catch {
3899
- return await this.vaultFs.readBinary(`${this.attachmentFolder}/${localPath}`);
4522
+ /**
4523
+ * 임베드 경로를 볼트 실제 파일로 해석한다(F23). Obsidian 위키링크 임베드는 노트 옆
4524
+ * 파일을 파일명만으로 참조하는 게 관례라, 노트 폴더 → 볼트 루트 → attachments/ 순으로
4525
+ * 시도한다. 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
4526
+ */
4527
+ async readImageFromVault(localPath, noteFolder) {
4528
+ const candidates = [];
4529
+ if (noteFolder && !localPath.includes("/")) {
4530
+ candidates.push(`${noteFolder}/${localPath}`);
4531
+ }
4532
+ candidates.push(localPath, `${this.attachmentFolder}/${localPath}`);
4533
+ let lastError;
4534
+ for (const candidate of candidates) {
4535
+ try {
4536
+ return { buffer: await this.vaultFs.readBinary(candidate), resolvedPath: candidate };
4537
+ } catch (error) {
4538
+ lastError = error;
4539
+ }
3900
4540
  }
4541
+ throw lastError;
3901
4542
  }
3902
4543
  getContentTypeFromPath(filename) {
3903
4544
  const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
3904
4545
  return (ext && EXTENSION_TO_MIME[ext]) ?? "application/octet-stream";
3905
4546
  }
3906
4547
  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)];
4548
+ const fileLinks = scanMarkdownLinks(markdown).filter(
4549
+ (s) => !s.isEmbed && FILE_LABEL_PREFIX_RE.test(s.label)
4550
+ );
4551
+ const notionHttpMatches = fileLinks.filter(
4552
+ (s) => /^https?:\/\//.test(s.url) && this.isNotionFileUrl(s.url)
4553
+ );
4554
+ const mdInternalMatches = fileLinks.filter((s) => INTERNAL_FILE_URL_RE.test(s.url));
3912
4555
  const xmlHttpRegex = /<(?:file|video|audio|pdf)\s+src="(https?:\/\/[^"]+)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
3913
4556
  const xmlHttpMatches = [...markdown.matchAll(xmlHttpRegex)];
3914
4557
  const notionXmlHttpMatches = xmlHttpMatches.filter((m) => m[1] && this.isNotionFileUrl(m[1]));
@@ -3921,38 +4564,36 @@ var ImageHandler = class {
3921
4564
  const sema = new Sema(this.concurrency);
3922
4565
  const downloads = [];
3923
4566
  let result = markdown;
3924
- for (const match of notionHttpMatches) {
4567
+ for (const span of notionHttpMatches) {
3925
4568
  await sema.acquire();
3926
4569
  try {
3927
- const url = match[2];
3928
- const caption = match[1]?.trim() || "file";
3929
- const download = await this.downloadFile(url, pageTitle, caption);
4570
+ const caption = toWikilinkAlias(span.label.replace(FILE_LABEL_PREFIX_RE, "")) || "file";
4571
+ const download = await this.downloadFile(span.url, pageTitle, caption);
3930
4572
  if (!download.localPath) continue;
3931
4573
  downloads.push(download);
3932
- const obsidianLink = `[[${download.localPath}|${caption}]]`;
3933
- result = result.replace(match[0], obsidianLink);
4574
+ const obsidianLink = `![[${download.localPath}|${caption}]]`;
4575
+ result = result.replace(span.full, obsidianLink);
3934
4576
  } catch (error) {
3935
4577
  getLogger().warn(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
3936
4578
  } finally {
3937
4579
  sema.release();
3938
4580
  }
3939
4581
  }
3940
- for (const match of mdInternalMatches) {
4582
+ for (const span of mdInternalMatches) {
3941
4583
  await sema.acquire();
3942
4584
  try {
3943
- const fileUrl = match[2];
3944
- const parsed = this.parseNotionFileUrl(fileUrl);
4585
+ const parsed = this.parseNotionFileUrl(span.url);
3945
4586
  if (!parsed || !this.notionClient) continue;
3946
4587
  const blockId = parsed.blockId;
3947
4588
  const fileName = parsed.fileName;
3948
4589
  const realUrl = await this.notionClient.getFileBlockUrl(blockId);
3949
4590
  if (!realUrl) continue;
3950
- const caption = fileName || match[1]?.trim() || "file";
4591
+ const caption = fileName || toWikilinkAlias(span.label.replace(FILE_LABEL_PREFIX_RE, "")) || "file";
3951
4592
  const download = await this.downloadFile(realUrl, pageTitle, caption);
3952
4593
  if (!download.localPath) continue;
3953
4594
  downloads.push(download);
3954
- const obsidianLink = `[[${download.localPath}|${caption}]]`;
3955
- result = result.replace(match[0], obsidianLink);
4595
+ const obsidianLink = `![[${download.localPath}|${caption}]]`;
4596
+ result = result.replace(span.full, obsidianLink);
3956
4597
  } catch (error) {
3957
4598
  getLogger().warn(`\uB0B4\uBD80 \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
3958
4599
  } finally {
@@ -3963,11 +4604,11 @@ var ImageHandler = class {
3963
4604
  await sema.acquire();
3964
4605
  try {
3965
4606
  const url = match[1];
3966
- const caption = match[2]?.trim() || "file";
4607
+ const caption = toWikilinkAlias(match[2] ?? "") || "file";
3967
4608
  const download = await this.downloadFile(url, pageTitle, caption);
3968
4609
  if (!download.localPath) continue;
3969
4610
  downloads.push(download);
3970
- const obsidianLink = `[[${download.localPath}|${caption}]]`;
4611
+ const obsidianLink = `![[${download.localPath}|${caption}]]`;
3971
4612
  result = result.replace(match[0], obsidianLink);
3972
4613
  } catch (error) {
3973
4614
  getLogger().warn(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
@@ -3985,11 +4626,11 @@ var ImageHandler = class {
3985
4626
  const fileName = parsed.fileName;
3986
4627
  const realUrl = await this.notionClient.getFileBlockUrl(blockId);
3987
4628
  if (!realUrl) continue;
3988
- const caption = fileName || match[2]?.trim() || "file";
4629
+ const caption = fileName || toWikilinkAlias(match[2] ?? "") || "file";
3989
4630
  const download = await this.downloadFile(realUrl, pageTitle, caption);
3990
4631
  if (!download.localPath) continue;
3991
4632
  downloads.push(download);
3992
- const obsidianLink = `[[${download.localPath}|${caption}]]`;
4633
+ const obsidianLink = `![[${download.localPath}|${caption}]]`;
3993
4634
  result = result.replace(match[0], obsidianLink);
3994
4635
  } catch (error) {
3995
4636
  getLogger().warn(`\uB0B4\uBD80 \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
@@ -5257,7 +5898,7 @@ function unescapeNotionChars(content) {
5257
5898
  return content.replace(/\\~/g, "~").replace(/\\\^/g, "^");
5258
5899
  }
5259
5900
  var NOTION_TABLE_RE = /<table[^>]*>([\s\S]*?)<\/table>/g;
5260
- var TABLE_ROW_RE = /<tr>([\s\S]*?)<\/tr>/g;
5901
+ var TABLE_ROW_RE2 = /<tr>([\s\S]*?)<\/tr>/g;
5261
5902
  var TABLE_CELL_RE = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/g;
5262
5903
  function isAlignmentRow(cells) {
5263
5904
  return cells.every((c) => /^:?-{2,}:?$/.test(c.trim()));
@@ -5266,7 +5907,7 @@ function convertNotionTables(content) {
5266
5907
  return content.replace(NOTION_TABLE_RE, (_match, tableBody) => {
5267
5908
  const rows = [];
5268
5909
  let rowMatch;
5269
- const rowRe = new RegExp(TABLE_ROW_RE.source, TABLE_ROW_RE.flags);
5910
+ const rowRe = new RegExp(TABLE_ROW_RE2.source, TABLE_ROW_RE2.flags);
5270
5911
  while ((rowMatch = rowRe.exec(tableBody)) !== null) {
5271
5912
  const cells = [];
5272
5913
  let cellMatch;
@@ -5647,8 +6288,10 @@ var DatabaseSyncer = class {
5647
6288
  properties.icon = icon.value;
5648
6289
  }
5649
6290
  let markdown = "";
6291
+ let exportCompact = false;
5650
6292
  try {
5651
6293
  const mdResult = await this.notionClient.getPageMarkdown(page.id);
6294
+ exportCompact = isCompactExport(mdResult.markdown);
5652
6295
  markdown = notionEnhancedToObsidian(mdResult.markdown);
5653
6296
  } catch {
5654
6297
  }
@@ -5688,7 +6331,7 @@ var DatabaseSyncer = class {
5688
6331
  filePath,
5689
6332
  parentMode: "database"
5690
6333
  },
5691
- { properties }
6334
+ { properties, notionExportCompact: exportCompact }
5692
6335
  );
5693
6336
  if (existingRecord) {
5694
6337
  let localContent = "";
@@ -6089,8 +6732,8 @@ var ConflictResolver = class {
6089
6732
  async resolveDuplicate(conflict) {
6090
6733
  const originalPath = conflict.syncRecord.obsidianPath;
6091
6734
  const ext = originalPath.endsWith(".md") ? ".md" : "";
6092
- const baseName = originalPath.replace(/\.md$/, "");
6093
- const conflictPath = `${baseName}.conflict${ext}`;
6735
+ const baseName2 = originalPath.replace(/\.md$/, "");
6736
+ const conflictPath = `${baseName2}.conflict${ext}`;
6094
6737
  await this.vaultFs.writeFile(conflictPath, conflict.remoteContent);
6095
6738
  const localHash = computeHash(conflict.localContent);
6096
6739
  this.stateDb.updateHash(
@@ -6179,6 +6822,7 @@ function resolveFrontmatterRelations(content, idToTitle) {
6179
6822
 
6180
6823
  // src/sync/orchestrator.ts
6181
6824
  var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
6825
+ var INCREMENTAL_SAFETY_WINDOW_MS = 15 * 6e4;
6182
6826
  function strategyToChoice(strategy) {
6183
6827
  switch (strategy) {
6184
6828
  case "local-first":
@@ -6479,7 +7123,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
6479
7123
  return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
6480
7124
  }) : remoteChanges;
6481
7125
  if (filtered.length === 0 && (this.config.notion.databases?.length ?? 0) === 0) {
6482
- const dbDiscovery = await this.pullDiscoveredDatabases(writtenPaths, failed, conflicts);
7126
+ const dbDiscovery = await this.pullDiscoveredDatabases(
7127
+ writtenPaths,
7128
+ failed,
7129
+ conflicts,
7130
+ options?.force === true
7131
+ );
6483
7132
  return finalize(dbDiscovery.created, dbDiscovery.updated, 0);
6484
7133
  }
6485
7134
  if (filtered.length === 0) {
@@ -6606,7 +7255,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
6606
7255
  }
6607
7256
  }
6608
7257
  {
6609
- const dbDiscovery = await this.pullDiscoveredDatabases(writtenPaths, failed, conflicts);
7258
+ const dbDiscovery = await this.pullDiscoveredDatabases(
7259
+ writtenPaths,
7260
+ failed,
7261
+ conflicts,
7262
+ options?.force === true
7263
+ );
6610
7264
  counts.created += dbDiscovery.created;
6611
7265
  counts.updated += dbDiscovery.updated;
6612
7266
  }
@@ -6698,7 +7352,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
6698
7352
  let remoteContent = "";
6699
7353
  if (record.notionPageId) {
6700
7354
  try {
6701
- remoteContent = await this.fetchPageMarkdown(record.notionPageId);
7355
+ remoteContent = (await this.fetchPageMarkdown(record.notionPageId)).content;
6702
7356
  } catch {
6703
7357
  }
6704
7358
  }
@@ -6795,7 +7449,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
6795
7449
  return /* @__PURE__ */ new Set();
6796
7450
  }
6797
7451
  }
6798
- async pullDiscoveredDatabases(writtenPaths, failed, conflicts) {
7452
+ async pullDiscoveredDatabases(writtenPaths, failed, conflicts, forceRediscovery = false) {
6799
7453
  if (this.isDatabaseMode) return { created: 0, updated: 0 };
6800
7454
  let created = 0;
6801
7455
  let updated = 0;
@@ -6829,7 +7483,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
6829
7483
  `[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
7484
  );
6831
7485
  };
6832
- if (dbConfigs.length === 0) {
7486
+ if (dbConfigs.length === 0 || forceRediscovery) {
6833
7487
  const discovered = await this.discoverChildDatabases();
6834
7488
  for (const { dbId, parentPageId } of discovered) {
6835
7489
  const nohyph = dbId.replace(/-/g, "");
@@ -7035,7 +7689,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
7035
7689
  localMtime: null,
7036
7690
  localFileSize: null
7037
7691
  });
7038
- await this.imageHandler.uploadAndAppendImages(page.id, conversionResult.images);
7692
+ await this.imageHandler.uploadAndAppendImages(page.id, conversionResult.images, path);
7039
7693
  const hash = computeHash(content);
7040
7694
  const fileStat = await this.vaultFs.getFileStat(path);
7041
7695
  this.stateDb.transaction(() => {
@@ -7081,7 +7735,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
7081
7735
  parentMode: this.config.notion.parentMode
7082
7736
  });
7083
7737
  await this.pushUpdatePage(record.notionPageId, conversionResult.content, record.baseSnapshot);
7084
- await this.imageHandler.uploadAndAppendImages(record.notionPageId, conversionResult.images);
7738
+ await this.imageHandler.uploadAndAppendImages(
7739
+ record.notionPageId,
7740
+ conversionResult.images,
7741
+ path
7742
+ );
7085
7743
  let propsToUpdate;
7086
7744
  if (this.isDatabaseMode && conversionResult.properties && Object.keys(conversionResult.properties).length > 0) {
7087
7745
  await this.ensureDbSchema();
@@ -7305,7 +7963,9 @@ var SyncOrchestrator = class _SyncOrchestrator {
7305
7963
  async detectRemoteChangesIncremental(since) {
7306
7964
  const changes = [];
7307
7965
  this._childParentIds.clear();
7308
- const recentPages = await this.notionClient.searchRecentPages(since);
7966
+ const sinceMs = Date.parse(since);
7967
+ const safeSince = Number.isFinite(sinceMs) ? new Date(sinceMs - INCREMENTAL_SAFETY_WINDOW_MS).toISOString() : since;
7968
+ const recentPages = await this.notionClient.searchRecentPages(safeSince);
7309
7969
  for (const page of recentPages) {
7310
7970
  const record = this.stateDb.getByNotionId(page.id);
7311
7971
  if (!record) {
@@ -7346,7 +8006,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
7346
8006
  const safeName = sanitizeFileName(title);
7347
8007
  const parentPath = await this.resolveParentPath(page);
7348
8008
  const hasChildPages = await this.pageHasChildContainers(pageId);
7349
- const markdown = await this.fetchPageMarkdown(pageId);
8009
+ const { content: markdown, compact: exportCompact } = await this.fetchPageMarkdown(pageId);
7350
8010
  const hasContent = markdown.trim().length > 0;
7351
8011
  let filePath;
7352
8012
  let fileType;
@@ -7374,10 +8034,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
7374
8034
  } else {
7375
8035
  properties = this.notionClient.extractProperties(page);
7376
8036
  }
7377
- properties.title = title;
8037
+ if (this.isDatabaseMode || extractTitle(filePath) !== title) {
8038
+ properties.title = title;
8039
+ }
7378
8040
  let processedMarkdown = markdown;
7379
8041
  if (this.config.conversion.imageDownload === "immediate") {
7380
- const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
8042
+ const imageResult = await this.imageHandler.downloadAllImages(markdown, title, pageId);
7381
8043
  processedMarkdown = imageResult.content;
7382
8044
  this._pullImageCount += imageResult.downloads.length;
7383
8045
  }
@@ -7392,7 +8054,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
7392
8054
  filePath,
7393
8055
  parentMode: this.config.notion.parentMode
7394
8056
  },
7395
- { properties }
8057
+ { properties, notionExportCompact: exportCompact }
7396
8058
  );
7397
8059
  const resolvedParentId = await this.extractParentId(page);
7398
8060
  await this.vaultFs.writeFile(filePath, finalContent);
@@ -7449,7 +8111,8 @@ var SyncOrchestrator = class _SyncOrchestrator {
7449
8111
  const record = this.stateDb.getByNotionId(change.pageId);
7450
8112
  if (!record) return {};
7451
8113
  const page = await this.notionClient.getPage(change.pageId);
7452
- let markdown = await this.fetchPageMarkdown(change.pageId);
8114
+ const fetched = await this.fetchPageMarkdown(change.pageId);
8115
+ let markdown = fetched.content;
7453
8116
  const title = this.notionClient.extractTitle(page);
7454
8117
  let properties;
7455
8118
  if (this.isDatabaseMode) {
@@ -7460,9 +8123,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
7460
8123
  } else {
7461
8124
  properties = this.notionClient.extractProperties(page);
7462
8125
  }
7463
- properties.title = title;
8126
+ if (this.isDatabaseMode || extractTitle(record.obsidianPath) !== title) {
8127
+ properties.title = title;
8128
+ }
7464
8129
  if (this.config.conversion.imageDownload === "immediate") {
7465
- const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
8130
+ const imageResult = await this.imageHandler.downloadAllImages(markdown, title, change.pageId);
7466
8131
  markdown = imageResult.content;
7467
8132
  this._pullImageCount += imageResult.downloads.length;
7468
8133
  }
@@ -7478,7 +8143,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
7478
8143
  filePath: record.obsidianPath,
7479
8144
  parentMode: this.config.notion.parentMode
7480
8145
  },
7481
- { properties, preserveMarkers: savedMarkers.length > 0 ? savedMarkers : void 0 }
8146
+ {
8147
+ properties,
8148
+ preserveMarkers: savedMarkers.length > 0 ? savedMarkers : void 0,
8149
+ notionExportCompact: fetched.compact
8150
+ }
7482
8151
  );
7483
8152
  let localContent;
7484
8153
  try {
@@ -7739,11 +8408,16 @@ var SyncOrchestrator = class _SyncOrchestrator {
7739
8408
  try {
7740
8409
  const result = await this.notionClient.getPageMarkdown(pageId);
7741
8410
  this.collectInlineDbRefs(pageId, result.markdown);
7742
- return this.resolveNotionIdWikilinks(notionEnhancedToObsidian(result.markdown));
8411
+ return {
8412
+ content: this.resolveNotionIdWikilinks(notionEnhancedToObsidian(result.markdown)),
8413
+ // 압축형 판정은 반드시 원시 export 기준 — enhanced 변환이 <empty-block/> 을
8414
+ // 빈 줄로 바꾼 뒤에는 BlockSpacer 가 저작형과 구분할 수 없다(D1).
8415
+ compact: isCompactExport(result.markdown)
8416
+ };
7743
8417
  } catch {
7744
8418
  }
7745
8419
  }
7746
- return this.blockConverter.notionBlocksToMarkdown(pageId);
8420
+ return { content: await this.blockConverter.notionBlocksToMarkdown(pageId), compact: false };
7747
8421
  }
7748
8422
  // Markdown API는 인라인 데이터베이스를 다음처럼 렌더한다(컬럼/synced_block 내부 포함):
7749
8423
  // <database url="https://www.notion.so/<id-nohyph>" inline="true"