@im-nobsidian/core 0.1.1 → 0.1.3

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.d.ts CHANGED
@@ -340,6 +340,7 @@ declare class StateDB {
340
340
  updateStatus(id: string, status: SyncStatus): void;
341
341
  updateHash(id: string, hash: string, snapshot?: Buffer | null): void;
342
342
  setNotionLastEdited(id: string, lastEdited: string): void;
343
+ setNotionParentId(id: string, parentId: string): void;
343
344
  delete(id: string): void;
344
345
  resolveWikilink(text: string): WikilinkEntry | null;
345
346
  resolvePageId(pageId: string): WikilinkEntry | null;
@@ -394,6 +395,12 @@ declare class NotionClient {
394
395
  updatePageProperties(pageId: string, properties: Record<string, unknown>): Promise<PageObjectResponse>;
395
396
  getPageMarkdown(pageId: string): Promise<PageMarkdownResponse>;
396
397
  replacePageMarkdown(pageId: string, markdown: string): Promise<PageMarkdownResponse>;
398
+ updatePageMarkdownPartial(pageId: string, patches: Array<{
399
+ oldStr: string;
400
+ newStr: string;
401
+ replaceAll?: boolean;
402
+ }>): Promise<PageMarkdownResponse>;
403
+ movePage(pageId: string, newParentId: string, newParentType: "page" | "database"): Promise<PageObjectResponse>;
397
404
  getDatabaseSchema(databaseId: string): Promise<Record<string, {
398
405
  id: string;
399
406
  type: string;
@@ -542,8 +549,14 @@ declare class NotionBlockBuilder {
542
549
  }
543
550
 
544
551
  type NotionPropertyValue = Record<string, unknown>;
552
+ interface WikilinkResolver$1 {
553
+ resolve(title: string): string | null;
554
+ resolvePageId(pageId: string): string | null;
555
+ }
545
556
  declare class PropertyMapper {
546
557
  private schema;
558
+ private wikilinkResolver;
559
+ setWikilinkResolver(resolver: WikilinkResolver$1): void;
547
560
  loadSchema(properties: Record<string, {
548
561
  id: string;
549
562
  type: string;
@@ -601,6 +614,7 @@ declare class SyncOrchestrator {
601
614
  private pushCreate;
602
615
  private pushUpdate;
603
616
  private pushDelete;
617
+ private pushMove;
604
618
  private detectRemoteChanges;
605
619
  private pullCreate;
606
620
  private pullUpdate;
@@ -611,6 +625,7 @@ declare class SyncOrchestrator {
611
625
  private resolveParentPath;
612
626
  private pushCreatePage;
613
627
  private pushUpdatePage;
628
+ private computePatches;
614
629
  private fetchPageMarkdown;
615
630
  private extractParentId;
616
631
  private cleanupInterruptedSync;
@@ -698,6 +713,8 @@ declare class BlockConverter {
698
713
  private registerTocTransformer;
699
714
  private registerBreadcrumbTransformer;
700
715
  private registerLinkPreviewTransformer;
716
+ private registerPdfTransformer;
717
+ private registerEmbedTransformer;
701
718
  private registerCalloutTransformer;
702
719
  markdownToNotionBlocks(markdown: string): unknown[];
703
720
  private toggleContents;
package/dist/index.js CHANGED
@@ -371,6 +371,11 @@ var StateDB = class _StateDB {
371
371
  "UPDATE sync_state SET notion_last_edited = ?, updated_at = datetime('now') WHERE id = ?"
372
372
  ).run(lastEdited, id);
373
373
  }
374
+ setNotionParentId(id, parentId) {
375
+ this.db.prepare(
376
+ "UPDATE sync_state SET notion_parent_id = ?, updated_at = datetime('now') WHERE id = ?"
377
+ ).run(parentId, id);
378
+ }
374
379
  delete(id) {
375
380
  this.db.prepare("DELETE FROM sync_state WHERE id = ?").run(id);
376
381
  }
@@ -453,8 +458,13 @@ var StateDB = class _StateDB {
453
458
 
454
459
  // src/notion/property-mapper.ts
455
460
  var DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2})?/;
461
+ var WIKILINK_REGEX = /^\[\[(.+?)(?:\|.+?)?\]\]$/;
456
462
  var PropertyMapper = class {
457
463
  schema = /* @__PURE__ */ new Map();
464
+ wikilinkResolver = null;
465
+ setWikilinkResolver(resolver) {
466
+ this.wikilinkResolver = resolver;
467
+ }
458
468
  loadSchema(properties) {
459
469
  this.schema.clear();
460
470
  for (const [name, prop] of Object.entries(properties)) {
@@ -506,9 +516,13 @@ var PropertyMapper = class {
506
516
  case "checkbox":
507
517
  return { checkbox: Boolean(value) };
508
518
  case "date": {
519
+ if (typeof value === "object" && value !== null && "start" in value) {
520
+ const obj = value;
521
+ return { date: { start: obj.start, end: obj.end ?? null } };
522
+ }
509
523
  const dateStr = String(value);
510
524
  if (DATE_REGEX.test(dateStr)) {
511
- return { date: { start: dateStr } };
525
+ return { date: { start: dateStr, end: null } };
512
526
  }
513
527
  return null;
514
528
  }
@@ -520,6 +534,54 @@ var PropertyMapper = class {
520
534
  return { phone_number: String(value) };
521
535
  case "status":
522
536
  return { status: { name: String(value) } };
537
+ case "relation": {
538
+ const items = Array.isArray(value) ? value : [value];
539
+ const ids = [];
540
+ for (const item of items) {
541
+ const str = String(item);
542
+ const match = str.match(WIKILINK_REGEX);
543
+ if (match?.[1] && this.wikilinkResolver) {
544
+ const pageId = this.wikilinkResolver.resolve(match[1]);
545
+ if (pageId) ids.push({ id: pageId });
546
+ } else if (str.match(/^[0-9a-f-]{32,36}$/)) {
547
+ ids.push({ id: str });
548
+ }
549
+ }
550
+ return ids.length > 0 ? { relation: ids } : null;
551
+ }
552
+ case "people": {
553
+ const users = Array.isArray(value) ? value : [value];
554
+ const peopleArr = users.map((u) => {
555
+ const s = String(u);
556
+ if (s.match(/^[0-9a-f-]{32,36}$/)) return { object: "user", id: s };
557
+ return null;
558
+ }).filter(Boolean);
559
+ return peopleArr.length > 0 ? { people: peopleArr } : null;
560
+ }
561
+ case "files": {
562
+ const fileList = Array.isArray(value) ? value : [value];
563
+ const filesArr = fileList.map((f) => {
564
+ if (typeof f === "object" && f !== null && "url" in f) {
565
+ const obj = f;
566
+ return { type: "external", name: obj.name ?? "file", external: { url: obj.url } };
567
+ }
568
+ return {
569
+ type: "external",
570
+ name: String(f),
571
+ external: { url: String(f) }
572
+ };
573
+ });
574
+ return { files: filesArr };
575
+ }
576
+ case "created_time":
577
+ case "last_edited_time":
578
+ case "created_by":
579
+ case "last_edited_by":
580
+ case "formula":
581
+ case "rollup":
582
+ case "unique_id":
583
+ case "verification":
584
+ return null;
523
585
  default:
524
586
  return { rich_text: [{ text: { content: String(value) } }] };
525
587
  }
@@ -538,7 +600,7 @@ var PropertyMapper = class {
538
600
  }
539
601
  if (typeof value === "string") {
540
602
  if (DATE_REGEX.test(value)) {
541
- return { date: { start: value } };
603
+ return { date: { start: value, end: null } };
542
604
  }
543
605
  if (value.startsWith("http://") || value.startsWith("https://")) {
544
606
  return { url: value };
@@ -565,8 +627,12 @@ var PropertyMapper = class {
565
627
  }
566
628
  case "checkbox":
567
629
  return prop.checkbox;
568
- case "date":
569
- return prop.date ?? null;
630
+ case "date": {
631
+ const dateObj = prop.date;
632
+ if (!dateObj) return null;
633
+ if (dateObj.end) return { start: dateObj.start, end: dateObj.end };
634
+ return dateObj.start;
635
+ }
570
636
  case "url":
571
637
  return prop.url;
572
638
  case "email":
@@ -599,7 +665,14 @@ var PropertyMapper = class {
599
665
  }
600
666
  case "relation": {
601
667
  const rel = prop.relation;
602
- return rel?.map((r) => r.id) ?? [];
668
+ if (!rel || rel.length === 0) return [];
669
+ return rel.map((r) => {
670
+ if (this.wikilinkResolver) {
671
+ const title = this.wikilinkResolver.resolvePageId(r.id);
672
+ if (title) return `[[${title}]]`;
673
+ }
674
+ return r.id;
675
+ });
603
676
  }
604
677
  case "rollup": {
605
678
  const rollup = prop.rollup;
@@ -701,6 +774,32 @@ var NotionClient = class {
701
774
  })
702
775
  );
703
776
  }
777
+ async updatePageMarkdownPartial(pageId, patches) {
778
+ return this.withRateLimit(
779
+ () => this.client.pages.updateMarkdown({
780
+ page_id: pageId,
781
+ type: "update_content",
782
+ update_content: {
783
+ content_updates: patches.map((p) => ({
784
+ old_str: p.oldStr,
785
+ new_str: p.newStr,
786
+ replace_all_matches: p.replaceAll ?? false
787
+ })),
788
+ allow_deleting_content: true
789
+ }
790
+ })
791
+ );
792
+ }
793
+ // ─── Page Move ───
794
+ async movePage(pageId, newParentId, newParentType) {
795
+ const parent = newParentType === "database" ? { database_id: newParentId } : { page_id: newParentId };
796
+ return this.withRateLimit(
797
+ () => this.client.pages.update({
798
+ page_id: pageId,
799
+ parent
800
+ })
801
+ );
802
+ }
704
803
  // ─── Database / DataSource ───
705
804
  async getDatabaseSchema(databaseId) {
706
805
  const db = await this.withRateLimit(
@@ -1398,7 +1497,7 @@ var FrontmatterExtractor = class {
1398
1497
  };
1399
1498
 
1400
1499
  // src/converter/pre-processors/wikilink.ts
1401
- var WIKILINK_REGEX = /(?<!!)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
1500
+ var WIKILINK_REGEX2 = /(?<!!)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
1402
1501
  var WikilinkResolver = class {
1403
1502
  constructor(resolve) {
1404
1503
  this.resolve = resolve;
@@ -1412,7 +1511,7 @@ var WikilinkResolver = class {
1412
1511
  }
1413
1512
  const preserveMarkers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
1414
1513
  const resolved = input.content.replace(
1415
- WIKILINK_REGEX,
1514
+ WIKILINK_REGEX2,
1416
1515
  (_match, target, display) => {
1417
1516
  const label = display ?? target;
1418
1517
  if (this.resolve) {
@@ -1784,13 +1883,50 @@ var FrontmatterGenerator = class {
1784
1883
  if (!properties || Object.keys(properties).length === 0) {
1785
1884
  return { content: input.content, metadata: input.metadata };
1786
1885
  }
1787
- const content = matter.stringify(input.content, properties);
1886
+ const normalized = normalizeProperties(properties);
1887
+ const content = matter.stringify(input.content, normalized);
1788
1888
  return {
1789
1889
  content,
1790
1890
  metadata: input.metadata
1791
1891
  };
1792
1892
  }
1793
1893
  };
1894
+ function normalizeProperties(props) {
1895
+ const result = {};
1896
+ for (const [key, value] of Object.entries(props)) {
1897
+ const normalized = normalizeValue(value);
1898
+ if (normalized !== void 0) {
1899
+ result[key] = normalized;
1900
+ }
1901
+ }
1902
+ return result;
1903
+ }
1904
+ function normalizeValue(value) {
1905
+ if (typeof value === "string") {
1906
+ return normalizeDate(value);
1907
+ }
1908
+ if (Array.isArray(value)) {
1909
+ if (value.length === 0) return void 0;
1910
+ return value;
1911
+ }
1912
+ if (typeof value === "object" && value !== null && "start" in value) {
1913
+ const dateObj = value;
1914
+ const normalized = {
1915
+ start: normalizeDate(dateObj.start),
1916
+ ...dateObj.end ? { end: normalizeDate(dateObj.end) } : {}
1917
+ };
1918
+ return normalized;
1919
+ }
1920
+ return value;
1921
+ }
1922
+ var MIDNIGHT_RE = /T00:00:00(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})/;
1923
+ function normalizeDate(dateStr) {
1924
+ if (typeof dateStr !== "string") return dateStr;
1925
+ if (MIDNIGHT_RE.test(dateStr)) {
1926
+ return dateStr.split("T")[0] ?? dateStr;
1927
+ }
1928
+ return dateStr;
1929
+ }
1794
1930
 
1795
1931
  // src/converter/post-processors/local-image-restorer.ts
1796
1932
  var LOCAL_IMAGE_MARKER_REGEX = />\s*📎\s*[^\n]*\n>\s*%%\s*im-nobsidian:local-image:([^\s]+)\s*%%/g;
@@ -2029,6 +2165,8 @@ var BlockConverter = class {
2029
2165
  this.registerTocTransformer();
2030
2166
  this.registerBreadcrumbTransformer();
2031
2167
  this.registerLinkPreviewTransformer();
2168
+ this.registerPdfTransformer();
2169
+ this.registerEmbedTransformer();
2032
2170
  this.registerCalloutTransformer();
2033
2171
  this.registerToggleHeadingTransformers();
2034
2172
  }
@@ -2201,6 +2339,24 @@ ${COLUMN_LIST_END}`;
2201
2339
  return `[${url}](${url})`;
2202
2340
  });
2203
2341
  }
2342
+ registerPdfTransformer() {
2343
+ if (!this.n2m) return;
2344
+ this.n2m.setCustomTransformer("pdf", async (block) => {
2345
+ const b = block;
2346
+ const url = b.pdf?.type === "file" ? b.pdf.file?.url ?? "" : b.pdf?.external?.url ?? "";
2347
+ const caption = b.pdf?.caption ? richTextToMarkdown(b.pdf.caption) : "";
2348
+ return `[\u{1F4C4} ${caption || "PDF"}](${url})`;
2349
+ });
2350
+ }
2351
+ registerEmbedTransformer() {
2352
+ if (!this.n2m) return;
2353
+ this.n2m.setCustomTransformer("embed", async (block) => {
2354
+ const b = block;
2355
+ const url = b.embed?.url ?? "";
2356
+ const caption = b.embed?.caption ? richTextToMarkdown(b.embed.caption) : "";
2357
+ return `[${caption || url}](${url})`;
2358
+ });
2359
+ }
2204
2360
  registerCalloutTransformer() {
2205
2361
  if (!this.n2m) return;
2206
2362
  const n2m = this.n2m;
@@ -2744,7 +2900,13 @@ var NOTION_TOGGLE_RE = /<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/d
2744
2900
  var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
2745
2901
  var NOTION_USER_MENTION_RE = /<mention-user id="[^"]*">([^<]*)<\/mention-user>/g;
2746
2902
  var NOTION_DATE_MENTION_RE = /<mention-date start="([^"]*)"(?: end="([^"]*)")?[^>]*\/>/g;
2747
- var NOTION_UNKNOWN_RE = /<unknown id="[^"]*"[^>]*\/>/g;
2903
+ var NOTION_UNKNOWN_RE = /<unknown id="([^"]*)"([^>]*)\/>/g;
2904
+ var NOTION_AUDIO_RE = /<audio src="([^"]*)">([\s\S]*?)<\/audio>/g;
2905
+ var NOTION_VIDEO_RE = /<video src="([^"]*)">([\s\S]*?)<\/video>/g;
2906
+ var NOTION_PDF_RE = /<pdf src="([^"]*)">([\s\S]*?)<\/pdf>/g;
2907
+ var NOTION_FILE_RE = /<file src="([^"]*)">([\s\S]*?)<\/file>/g;
2908
+ var NOTION_TAB_RE = /<tab title="([^"]*)">([\s\S]*?)<\/tab>/g;
2909
+ var NOTION_UNDERLINE_RE = /<span underline="true">([\s\S]*?)<\/span>/g;
2748
2910
  var TOGGLE_START2 = "%%im-nobsidian:toggle:start%%";
2749
2911
  var TOGGLE_END2 = "%%im-nobsidian:toggle:end%%";
2750
2912
  function notionEnhancedToObsidian(enhanced) {
@@ -2755,10 +2917,13 @@ function notionEnhancedToObsidian(enhanced) {
2755
2917
  result = convertPageLinks(result);
2756
2918
  result = convertUserMentions(result);
2757
2919
  result = convertDateMentions(result);
2758
- result = removeUnknownBlocks(result);
2920
+ result = convertMediaTags(result);
2921
+ result = convertTabBlocks(result);
2922
+ result = preserveUnknownBlocks(result);
2759
2923
  result = convertNotionMath(result);
2760
2924
  result = convertNotionTables(result);
2761
2925
  result = convertColorSpans(result);
2926
+ result = convertUnderlineSpans(result);
2762
2927
  result = removeEmptyBlocks(result);
2763
2928
  result = unescapePipes(result);
2764
2929
  return result;
@@ -2766,7 +2931,12 @@ function notionEnhancedToObsidian(enhanced) {
2766
2931
  function obsidianToNotionEnhanced(obsidian) {
2767
2932
  let result = obsidian;
2768
2933
  result = convertTogglesToHtml(result);
2934
+ result = restoreTabBlocks(result);
2769
2935
  result = convertObsidianCallouts(result);
2936
+ result = restoreMediaTags(result);
2937
+ result = restoreUnknownBlocks(result);
2938
+ result = restoreColorSpans(result);
2939
+ result = restoreUnderlineSpans(result);
2770
2940
  return result;
2771
2941
  }
2772
2942
  function convertToggles(content) {
@@ -2811,8 +2981,46 @@ function convertDateMentions(content) {
2811
2981
  return end ? `${start} \u2192 ${end}` : start;
2812
2982
  });
2813
2983
  }
2814
- function removeUnknownBlocks(content) {
2815
- return content.replace(NOTION_UNKNOWN_RE, "");
2984
+ function preserveUnknownBlocks(content) {
2985
+ return content.replace(NOTION_UNKNOWN_RE, (_match, id, attrs) => {
2986
+ const typeMatch = /type="([^"]*)"/.exec(attrs);
2987
+ const altMatch = /alt="([^"]*)"/.exec(attrs);
2988
+ const blockType = altMatch?.[1] ?? typeMatch?.[1] ?? "unknown";
2989
+ return `%%im-nobsidian:unknown:id=${id}&type=${blockType}%%`;
2990
+ });
2991
+ }
2992
+ function convertMediaTags(content) {
2993
+ let result = content;
2994
+ result = result.replace(NOTION_AUDIO_RE, (_match, src, caption) => {
2995
+ const cap = caption.trim();
2996
+ return cap ? `[\u{1F50A} ${cap}](${src})` : `[\u{1F50A} audio](${src})`;
2997
+ });
2998
+ result = result.replace(NOTION_VIDEO_RE, (_match, src, caption) => {
2999
+ const cap = caption.trim();
3000
+ return cap ? `[\u{1F3AC} ${cap}](${src})` : `[\u{1F3AC} video](${src})`;
3001
+ });
3002
+ result = result.replace(NOTION_PDF_RE, (_match, src, caption) => {
3003
+ const cap = caption.trim();
3004
+ return cap ? `[\u{1F4C4} ${cap}](${src})` : `[\u{1F4C4} pdf](${src})`;
3005
+ });
3006
+ result = result.replace(NOTION_FILE_RE, (_match, src, caption) => {
3007
+ const cap = caption.trim();
3008
+ return cap ? `[\u{1F4CE} ${cap}](${src})` : `[\u{1F4CE} file](${src})`;
3009
+ });
3010
+ return result;
3011
+ }
3012
+ function convertTabBlocks(content) {
3013
+ return content.replace(NOTION_TAB_RE, (_match, title, body) => {
3014
+ const trimmed = body.trim();
3015
+ const indented = trimmed.split("\n").map((line) => `> ${line}`).join("\n");
3016
+ return `> [!tab] ${title}
3017
+ ${indented}`;
3018
+ });
3019
+ }
3020
+ function convertUnderlineSpans(content) {
3021
+ return content.replace(NOTION_UNDERLINE_RE, (_match, text) => {
3022
+ return `%%im-nobsidian:underline%%${text}%%/underline%%`;
3023
+ });
2816
3024
  }
2817
3025
  function convertTogglesToHtml(content) {
2818
3026
  const startRe = new RegExp(escapeRegex3(TOGGLE_START2), "g");
@@ -2922,7 +3130,10 @@ function convertPageLinks(content) {
2922
3130
  });
2923
3131
  }
2924
3132
  function convertColorSpans(content) {
2925
- return content.replace(NOTION_COLOR_SPAN_RE, (_match, _color, text) => text);
3133
+ return content.replace(
3134
+ NOTION_COLOR_SPAN_RE,
3135
+ (_match, color, text) => `%%im-nobsidian:color:${color}%%${text}%%/color%%`
3136
+ );
2926
3137
  }
2927
3138
  function removeEmptyBlocks(content) {
2928
3139
  return content.replace(NOTION_EMPTY_BLOCK_RE, "\n");
@@ -2969,6 +3180,51 @@ function convertNotionTables(content) {
2969
3180
  return lines.join("\n");
2970
3181
  });
2971
3182
  }
3183
+ var OBSIDIAN_MEDIA_AUDIO_RE = /\[🔊\s*([^\]]*)\]\(([^)]+)\)/g;
3184
+ var OBSIDIAN_MEDIA_VIDEO_RE = /\[🎬\s*([^\]]*)\]\(([^)]+)\)/g;
3185
+ var OBSIDIAN_MEDIA_PDF_RE = /\[📄\s*([^\]]*)\]\(([^)]+)\)/g;
3186
+ var OBSIDIAN_MEDIA_FILE_RE = /\[📎\s*([^\]]*)\]\(([^)]+)\)/g;
3187
+ function restoreMediaTags(content) {
3188
+ let result = content;
3189
+ result = result.replace(OBSIDIAN_MEDIA_AUDIO_RE, (_match, caption, src) => {
3190
+ return `<audio src="${src}">${caption}</audio>`;
3191
+ });
3192
+ result = result.replace(OBSIDIAN_MEDIA_VIDEO_RE, (_match, caption, src) => {
3193
+ return `<video src="${src}">${caption}</video>`;
3194
+ });
3195
+ result = result.replace(OBSIDIAN_MEDIA_PDF_RE, (_match, caption, src) => {
3196
+ return `<pdf src="${src}">${caption}</pdf>`;
3197
+ });
3198
+ result = result.replace(OBSIDIAN_MEDIA_FILE_RE, (_match, caption, src) => {
3199
+ return `<file src="${src}">${caption}</file>`;
3200
+ });
3201
+ return result;
3202
+ }
3203
+ var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)\n((?:> .*\n?)*)/gm;
3204
+ function restoreTabBlocks(content) {
3205
+ return content.replace(OBSIDIAN_TAB_RE, (_match, title, body) => {
3206
+ const unquoted = body.split("\n").map((line) => line.replace(/^> /, "")).join("\n").trim();
3207
+ return `<tab title="${title}">${unquoted}</tab>`;
3208
+ });
3209
+ }
3210
+ var OBSIDIAN_UNKNOWN_RE = /%%im-nobsidian:unknown:id=([^&]+)&type=([^%]+)%%/g;
3211
+ function restoreUnknownBlocks(content) {
3212
+ return content.replace(OBSIDIAN_UNKNOWN_RE, (_match, id, type) => {
3213
+ return `<unknown id="${id}" type="${type}"/>`;
3214
+ });
3215
+ }
3216
+ var OBSIDIAN_COLOR_RE = /%%im-nobsidian:color:([^%]+)%%([\s\S]*?)%%\/color%%/g;
3217
+ function restoreColorSpans(content) {
3218
+ return content.replace(OBSIDIAN_COLOR_RE, (_match, color, text) => {
3219
+ return `<span color="${color}">${text}</span>`;
3220
+ });
3221
+ }
3222
+ var OBSIDIAN_UNDERLINE_RE = /%%im-nobsidian:underline%%([\s\S]*?)%%\/underline%%/g;
3223
+ function restoreUnderlineSpans(content) {
3224
+ return content.replace(OBSIDIAN_UNDERLINE_RE, (_match, text) => {
3225
+ return `<span underline="true">${text}</span>`;
3226
+ });
3227
+ }
2972
3228
  function unescapePipes(content) {
2973
3229
  const lines = content.split("\n");
2974
3230
  const result = [];
@@ -3003,6 +3259,10 @@ var SyncOrchestrator = class {
3003
3259
  this.blockConverter = new BlockConverter();
3004
3260
  this.imageHandler = new ImageHandler(vaultFs, config.paths.attachments, notionClient);
3005
3261
  this.propertyMapper = new PropertyMapper();
3262
+ this.propertyMapper.setWikilinkResolver({
3263
+ resolve: (title) => stateDb.resolveWikilink(title)?.notionPageId ?? null,
3264
+ resolvePageId: (pageId) => stateDb.resolvePageId(pageId)?.title ?? null
3265
+ });
3006
3266
  this.blockConverter.initNotionToMd(this.notionClient.getInternalClient());
3007
3267
  }
3008
3268
  config;
@@ -3074,10 +3334,13 @@ var SyncOrchestrator = class {
3074
3334
  created++;
3075
3335
  break;
3076
3336
  case "modified":
3077
- case "moved":
3078
3337
  await this.pushUpdate(change.path);
3079
3338
  updated++;
3080
3339
  break;
3340
+ case "moved":
3341
+ await this.pushMove(change.path);
3342
+ updated++;
3343
+ break;
3081
3344
  case "deleted":
3082
3345
  await this.pushDelete(change.path);
3083
3346
  deleted++;
@@ -3254,13 +3517,20 @@ var SyncOrchestrator = class {
3254
3517
  localContent = await this.vaultFs.readFile(record.obsidianPath);
3255
3518
  } catch {
3256
3519
  }
3520
+ let remoteContent = "";
3521
+ if (record.notionPageId) {
3522
+ try {
3523
+ remoteContent = await this.fetchPageMarkdown(record.notionPageId);
3524
+ } catch {
3525
+ }
3526
+ }
3257
3527
  conflicts.push({
3258
3528
  syncRecord: record,
3259
3529
  localChange,
3260
3530
  remoteChange,
3261
3531
  baseContent: record.baseSnapshot?.toString("utf-8") ?? null,
3262
3532
  localContent,
3263
- remoteContent: ""
3533
+ remoteContent
3264
3534
  });
3265
3535
  }
3266
3536
  return conflicts;
@@ -3350,7 +3620,7 @@ var SyncOrchestrator = class {
3350
3620
  filePath: path,
3351
3621
  parentMode: this.config.notion.parentMode
3352
3622
  });
3353
- await this.pushUpdatePage(record.notionPageId, conversionResult.content);
3623
+ await this.pushUpdatePage(record.notionPageId, conversionResult.content, record.baseSnapshot);
3354
3624
  await this.imageHandler.uploadAndAppendImages(record.notionPageId, conversionResult.images);
3355
3625
  let propsToUpdate = conversionResult.properties;
3356
3626
  if (this.isDatabaseMode && propsToUpdate && Object.keys(propsToUpdate).length > 0) {
@@ -3388,6 +3658,22 @@ var SyncOrchestrator = class {
3388
3658
  this.stateDb.updateStatus(record.id, "pending");
3389
3659
  }
3390
3660
  }
3661
+ async pushMove(path) {
3662
+ const record = this.stateDb.getByPath(path);
3663
+ if (!record?.notionPageId) return;
3664
+ const newParentId = await this.resolveNotionParent(path);
3665
+ const currentParentId = record.notionParentId;
3666
+ if (currentParentId && !notionIdsEqual(newParentId, currentParentId)) {
3667
+ try {
3668
+ const parentType = this.isDatabaseMode ? "database" : "page";
3669
+ await this.notionClient.movePage(record.notionPageId, newParentId, parentType);
3670
+ this.stateDb.setNotionParentId(record.id, newParentId);
3671
+ } catch {
3672
+ getLogger().warn(`[Im-Nobsidian] Move API \uC2E4\uD328 \u2014 "${path}" \uB0B4\uC6A9\uB9CC \uC5C5\uB370\uC774\uD2B8\uD569\uB2C8\uB2E4`);
3673
+ }
3674
+ }
3675
+ await this.pushUpdate(path);
3676
+ }
3391
3677
  async detectRemoteChanges() {
3392
3678
  const changes = [];
3393
3679
  const lastPull = this.stateDb.getMeta("last_pull_at");
@@ -3723,10 +4009,18 @@ var SyncOrchestrator = class {
3723
4009
  }
3724
4010
  return page;
3725
4011
  }
3726
- async pushUpdatePage(pageId, markdownContent) {
4012
+ async pushUpdatePage(pageId, markdownContent, baseSnapshot) {
3727
4013
  if (this.config.conversion.preferMarkdownApi !== false) {
3728
4014
  try {
3729
4015
  const enhanced = obsidianToNotionEnhanced(markdownContent);
4016
+ if (baseSnapshot) {
4017
+ const baseEnhanced = obsidianToNotionEnhanced(baseSnapshot.toString("utf-8"));
4018
+ const patches = this.computePatches(baseEnhanced, enhanced);
4019
+ if (patches.length > 0 && patches.length <= 20) {
4020
+ await this.notionClient.updatePageMarkdownPartial(pageId, patches);
4021
+ return;
4022
+ }
4023
+ }
3730
4024
  await this.notionClient.replacePageMarkdown(pageId, enhanced);
3731
4025
  return;
3732
4026
  } catch {
@@ -3749,6 +4043,51 @@ var SyncOrchestrator = class {
3749
4043
  })
3750
4044
  );
3751
4045
  }
4046
+ computePatches(oldContent, newContent) {
4047
+ const oldLines = oldContent.split("\n");
4048
+ const newLines = newContent.split("\n");
4049
+ const patches = [];
4050
+ let i = 0;
4051
+ let j = 0;
4052
+ while (i < oldLines.length && j < newLines.length) {
4053
+ if (oldLines[i] === newLines[j]) {
4054
+ i++;
4055
+ j++;
4056
+ continue;
4057
+ }
4058
+ let oldEnd = i + 1;
4059
+ let newEnd = j + 1;
4060
+ const lookahead = Math.min(10, oldLines.length - i, newLines.length - j);
4061
+ for (let k = 1; k <= lookahead; k++) {
4062
+ if (i + k < oldLines.length && newLines[j] === oldLines[i + k]) {
4063
+ oldEnd = i + k;
4064
+ newEnd = j;
4065
+ break;
4066
+ }
4067
+ if (j + k < newLines.length && oldLines[i] === newLines[j + k]) {
4068
+ oldEnd = i;
4069
+ newEnd = j + k;
4070
+ break;
4071
+ }
4072
+ oldEnd = i + k;
4073
+ newEnd = j + k;
4074
+ }
4075
+ const oldChunk = oldLines.slice(i, oldEnd).join("\n");
4076
+ const newChunk = newLines.slice(j, newEnd).join("\n");
4077
+ if (oldChunk || newChunk) {
4078
+ patches.push({ oldStr: oldChunk, newStr: newChunk });
4079
+ }
4080
+ i = oldEnd;
4081
+ j = newEnd;
4082
+ }
4083
+ if (i < oldLines.length || j < newLines.length) {
4084
+ patches.push({
4085
+ oldStr: oldLines.slice(i).join("\n"),
4086
+ newStr: newLines.slice(j).join("\n")
4087
+ });
4088
+ }
4089
+ return patches;
4090
+ }
3752
4091
  async fetchPageMarkdown(pageId) {
3753
4092
  if (this.config.conversion.preferMarkdownApi !== false) {
3754
4093
  try {