@im-nobsidian/core 0.1.2 → 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;
@@ -607,6 +614,7 @@ declare class SyncOrchestrator {
607
614
  private pushCreate;
608
615
  private pushUpdate;
609
616
  private pushDelete;
617
+ private pushMove;
610
618
  private detectRemoteChanges;
611
619
  private pullCreate;
612
620
  private pullUpdate;
@@ -617,6 +625,7 @@ declare class SyncOrchestrator {
617
625
  private resolveParentPath;
618
626
  private pushCreatePage;
619
627
  private pushUpdatePage;
628
+ private computePatches;
620
629
  private fetchPageMarkdown;
621
630
  private extractParentId;
622
631
  private cleanupInterruptedSync;
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
  }
@@ -568,6 +573,15 @@ var PropertyMapper = class {
568
573
  });
569
574
  return { files: filesArr };
570
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;
571
585
  default:
572
586
  return { rich_text: [{ text: { content: String(value) } }] };
573
587
  }
@@ -760,6 +774,32 @@ var NotionClient = class {
760
774
  })
761
775
  );
762
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
+ }
763
803
  // ─── Database / DataSource ───
764
804
  async getDatabaseSchema(databaseId) {
765
805
  const db = await this.withRateLimit(
@@ -1854,7 +1894,10 @@ var FrontmatterGenerator = class {
1854
1894
  function normalizeProperties(props) {
1855
1895
  const result = {};
1856
1896
  for (const [key, value] of Object.entries(props)) {
1857
- result[key] = normalizeValue(value);
1897
+ const normalized = normalizeValue(value);
1898
+ if (normalized !== void 0) {
1899
+ result[key] = normalized;
1900
+ }
1858
1901
  }
1859
1902
  return result;
1860
1903
  }
@@ -1862,6 +1905,10 @@ function normalizeValue(value) {
1862
1905
  if (typeof value === "string") {
1863
1906
  return normalizeDate(value);
1864
1907
  }
1908
+ if (Array.isArray(value)) {
1909
+ if (value.length === 0) return void 0;
1910
+ return value;
1911
+ }
1865
1912
  if (typeof value === "object" && value !== null && "start" in value) {
1866
1913
  const dateObj = value;
1867
1914
  const normalized = {
@@ -1872,12 +1919,10 @@ function normalizeValue(value) {
1872
1919
  }
1873
1920
  return value;
1874
1921
  }
1922
+ var MIDNIGHT_RE = /T00:00:00(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})/;
1875
1923
  function normalizeDate(dateStr) {
1876
1924
  if (typeof dateStr !== "string") return dateStr;
1877
- if (dateStr.match(/T00:00:00\.000[Z+]/)) {
1878
- return dateStr.split("T")[0] ?? dateStr;
1879
- }
1880
- if (dateStr.match(/T00:00:00[Z+]/)) {
1925
+ if (MIDNIGHT_RE.test(dateStr)) {
1881
1926
  return dateStr.split("T")[0] ?? dateStr;
1882
1927
  }
1883
1928
  return dateStr;
@@ -2855,7 +2900,13 @@ var NOTION_TOGGLE_RE = /<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/d
2855
2900
  var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
2856
2901
  var NOTION_USER_MENTION_RE = /<mention-user id="[^"]*">([^<]*)<\/mention-user>/g;
2857
2902
  var NOTION_DATE_MENTION_RE = /<mention-date start="([^"]*)"(?: end="([^"]*)")?[^>]*\/>/g;
2858
- 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;
2859
2910
  var TOGGLE_START2 = "%%im-nobsidian:toggle:start%%";
2860
2911
  var TOGGLE_END2 = "%%im-nobsidian:toggle:end%%";
2861
2912
  function notionEnhancedToObsidian(enhanced) {
@@ -2866,10 +2917,13 @@ function notionEnhancedToObsidian(enhanced) {
2866
2917
  result = convertPageLinks(result);
2867
2918
  result = convertUserMentions(result);
2868
2919
  result = convertDateMentions(result);
2869
- result = removeUnknownBlocks(result);
2920
+ result = convertMediaTags(result);
2921
+ result = convertTabBlocks(result);
2922
+ result = preserveUnknownBlocks(result);
2870
2923
  result = convertNotionMath(result);
2871
2924
  result = convertNotionTables(result);
2872
2925
  result = convertColorSpans(result);
2926
+ result = convertUnderlineSpans(result);
2873
2927
  result = removeEmptyBlocks(result);
2874
2928
  result = unescapePipes(result);
2875
2929
  return result;
@@ -2877,7 +2931,12 @@ function notionEnhancedToObsidian(enhanced) {
2877
2931
  function obsidianToNotionEnhanced(obsidian) {
2878
2932
  let result = obsidian;
2879
2933
  result = convertTogglesToHtml(result);
2934
+ result = restoreTabBlocks(result);
2880
2935
  result = convertObsidianCallouts(result);
2936
+ result = restoreMediaTags(result);
2937
+ result = restoreUnknownBlocks(result);
2938
+ result = restoreColorSpans(result);
2939
+ result = restoreUnderlineSpans(result);
2881
2940
  return result;
2882
2941
  }
2883
2942
  function convertToggles(content) {
@@ -2922,8 +2981,46 @@ function convertDateMentions(content) {
2922
2981
  return end ? `${start} \u2192 ${end}` : start;
2923
2982
  });
2924
2983
  }
2925
- function removeUnknownBlocks(content) {
2926
- 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
+ });
2927
3024
  }
2928
3025
  function convertTogglesToHtml(content) {
2929
3026
  const startRe = new RegExp(escapeRegex3(TOGGLE_START2), "g");
@@ -3033,7 +3130,10 @@ function convertPageLinks(content) {
3033
3130
  });
3034
3131
  }
3035
3132
  function convertColorSpans(content) {
3036
- 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
+ );
3037
3137
  }
3038
3138
  function removeEmptyBlocks(content) {
3039
3139
  return content.replace(NOTION_EMPTY_BLOCK_RE, "\n");
@@ -3080,6 +3180,51 @@ function convertNotionTables(content) {
3080
3180
  return lines.join("\n");
3081
3181
  });
3082
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
+ }
3083
3228
  function unescapePipes(content) {
3084
3229
  const lines = content.split("\n");
3085
3230
  const result = [];
@@ -3189,10 +3334,13 @@ var SyncOrchestrator = class {
3189
3334
  created++;
3190
3335
  break;
3191
3336
  case "modified":
3192
- case "moved":
3193
3337
  await this.pushUpdate(change.path);
3194
3338
  updated++;
3195
3339
  break;
3340
+ case "moved":
3341
+ await this.pushMove(change.path);
3342
+ updated++;
3343
+ break;
3196
3344
  case "deleted":
3197
3345
  await this.pushDelete(change.path);
3198
3346
  deleted++;
@@ -3369,13 +3517,20 @@ var SyncOrchestrator = class {
3369
3517
  localContent = await this.vaultFs.readFile(record.obsidianPath);
3370
3518
  } catch {
3371
3519
  }
3520
+ let remoteContent = "";
3521
+ if (record.notionPageId) {
3522
+ try {
3523
+ remoteContent = await this.fetchPageMarkdown(record.notionPageId);
3524
+ } catch {
3525
+ }
3526
+ }
3372
3527
  conflicts.push({
3373
3528
  syncRecord: record,
3374
3529
  localChange,
3375
3530
  remoteChange,
3376
3531
  baseContent: record.baseSnapshot?.toString("utf-8") ?? null,
3377
3532
  localContent,
3378
- remoteContent: ""
3533
+ remoteContent
3379
3534
  });
3380
3535
  }
3381
3536
  return conflicts;
@@ -3465,7 +3620,7 @@ var SyncOrchestrator = class {
3465
3620
  filePath: path,
3466
3621
  parentMode: this.config.notion.parentMode
3467
3622
  });
3468
- await this.pushUpdatePage(record.notionPageId, conversionResult.content);
3623
+ await this.pushUpdatePage(record.notionPageId, conversionResult.content, record.baseSnapshot);
3469
3624
  await this.imageHandler.uploadAndAppendImages(record.notionPageId, conversionResult.images);
3470
3625
  let propsToUpdate = conversionResult.properties;
3471
3626
  if (this.isDatabaseMode && propsToUpdate && Object.keys(propsToUpdate).length > 0) {
@@ -3503,6 +3658,22 @@ var SyncOrchestrator = class {
3503
3658
  this.stateDb.updateStatus(record.id, "pending");
3504
3659
  }
3505
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
+ }
3506
3677
  async detectRemoteChanges() {
3507
3678
  const changes = [];
3508
3679
  const lastPull = this.stateDb.getMeta("last_pull_at");
@@ -3838,10 +4009,18 @@ var SyncOrchestrator = class {
3838
4009
  }
3839
4010
  return page;
3840
4011
  }
3841
- async pushUpdatePage(pageId, markdownContent) {
4012
+ async pushUpdatePage(pageId, markdownContent, baseSnapshot) {
3842
4013
  if (this.config.conversion.preferMarkdownApi !== false) {
3843
4014
  try {
3844
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
+ }
3845
4024
  await this.notionClient.replacePageMarkdown(pageId, enhanced);
3846
4025
  return;
3847
4026
  } catch {
@@ -3864,6 +4043,51 @@ var SyncOrchestrator = class {
3864
4043
  })
3865
4044
  );
3866
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
+ }
3867
4091
  async fetchPageMarkdown(pageId) {
3868
4092
  if (this.config.conversion.preferMarkdownApi !== false) {
3869
4093
  try {