@im-nobsidian/core 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -366,6 +366,11 @@ var StateDB = class _StateDB {
366
366
  ).run(hash, id);
367
367
  }
368
368
  }
369
+ setNotionLastEdited(id, lastEdited) {
370
+ this.db.prepare(
371
+ "UPDATE sync_state SET notion_last_edited = ?, updated_at = datetime('now') WHERE id = ?"
372
+ ).run(lastEdited, id);
373
+ }
369
374
  delete(id) {
370
375
  this.db.prepare("DELETE FROM sync_state WHERE id = ?").run(id);
371
376
  }
@@ -640,6 +645,7 @@ var NotionClient = class {
640
645
  getInternalClient() {
641
646
  return this.client;
642
647
  }
648
+ // ─── Page CRUD ───
643
649
  async getPage(pageId) {
644
650
  return this.withRateLimit(
645
651
  () => this.client.pages.retrieve({ page_id: pageId })
@@ -658,6 +664,19 @@ var NotionClient = class {
658
664
  })
659
665
  );
660
666
  }
667
+ async createPageWithMarkdown(params) {
668
+ const parent = params.parentType === "database" ? { database_id: params.parentId } : { page_id: params.parentId };
669
+ const properties = params.parentType === "page" ? { title: { title: [{ text: { content: params.title } }] } } : params.properties ?? {
670
+ title: { title: [{ text: { content: params.title } }] }
671
+ };
672
+ return this.withRateLimit(
673
+ () => this.client.pages.create({
674
+ parent,
675
+ properties,
676
+ markdown: params.markdown
677
+ })
678
+ );
679
+ }
661
680
  async updatePageProperties(pageId, properties) {
662
681
  return this.withRateLimit(
663
682
  () => this.client.pages.update({
@@ -666,6 +685,23 @@ var NotionClient = class {
666
685
  })
667
686
  );
668
687
  }
688
+ // ─── Markdown API ───
689
+ async getPageMarkdown(pageId) {
690
+ return this.withRateLimit(() => this.client.pages.retrieveMarkdown({ page_id: pageId }));
691
+ }
692
+ async replacePageMarkdown(pageId, markdown) {
693
+ return this.withRateLimit(
694
+ () => this.client.pages.updateMarkdown({
695
+ page_id: pageId,
696
+ type: "replace_content",
697
+ replace_content: {
698
+ new_str: markdown,
699
+ allow_deleting_content: true
700
+ }
701
+ })
702
+ );
703
+ }
704
+ // ─── Database / DataSource ───
669
705
  async getDatabaseSchema(databaseId) {
670
706
  const db = await this.withRateLimit(
671
707
  () => this.client.databases.retrieve({ database_id: databaseId })
@@ -679,8 +715,8 @@ var NotionClient = class {
679
715
  }
680
716
  async queryDatabase(databaseId, options) {
681
717
  const response = await this.withRateLimit(
682
- () => this.client.databases.query({
683
- database_id: databaseId,
718
+ () => this.client.dataSources.query({
719
+ data_source_id: databaseId,
684
720
  start_cursor: options?.startCursor,
685
721
  page_size: options?.pageSize ?? 100
686
722
  })
@@ -693,6 +729,7 @@ var NotionClient = class {
693
729
  async archivePage(pageId) {
694
730
  await this.withRateLimit(() => this.client.pages.update({ page_id: pageId, archived: true }));
695
731
  }
732
+ // ─── Block CRUD ───
696
733
  async listChildren(blockId, options) {
697
734
  const response = await this.withRateLimit(
698
735
  () => this.client.blocks.children.list({
@@ -731,6 +768,24 @@ var NotionClient = class {
731
768
  async deleteBlock(blockId) {
732
769
  await this.withRateLimit(() => this.client.blocks.delete({ block_id: blockId }));
733
770
  }
771
+ // ─── File Upload ───
772
+ async uploadFile(fileData, filename, contentType) {
773
+ const upload = await this.withRateLimit(
774
+ () => this.client.fileUploads.create({ filename, content_type: contentType })
775
+ );
776
+ const fileUploadId = upload.id;
777
+ await this.withRateLimit(
778
+ () => this.client.fileUploads.send({
779
+ file_upload_id: fileUploadId,
780
+ file: { data: fileData, filename }
781
+ })
782
+ );
783
+ await this.withRateLimit(
784
+ () => this.client.fileUploads.complete({ file_upload_id: fileUploadId })
785
+ );
786
+ return fileUploadId;
787
+ }
788
+ // ─── Search & Navigation ───
734
789
  async search(params) {
735
790
  const response = await this.withRateLimit(
736
791
  () => this.client.search({
@@ -768,11 +823,13 @@ var NotionClient = class {
768
823
  }
769
824
  return all;
770
825
  }
826
+ // ─── Property Extraction ───
771
827
  extractTitle(page) {
772
- for (const prop of Object.values(page.properties)) {
773
- if (prop.type === "title" && "title" in prop) {
774
- const titleArr = prop.title;
775
- if (titleArr[0]?.plain_text) return titleArr[0].plain_text;
828
+ const props = page.properties;
829
+ for (const prop of Object.values(props)) {
830
+ if (prop.type === "title" && prop.title) {
831
+ const joined = prop.title.map((t) => t.plain_text ?? "").join("");
832
+ if (joined) return joined;
776
833
  }
777
834
  }
778
835
  return "\uC81C\uBAA9 \uC5C6\uC74C";
@@ -780,6 +837,7 @@ var NotionClient = class {
780
837
  extractProperties(page) {
781
838
  return this.propertyMapper.fromNotionProperties(page.properties);
782
839
  }
840
+ // ─── Internal ───
783
841
  async withRateLimit(fn) {
784
842
  await this.sema.acquire();
785
843
  try {
@@ -792,7 +850,7 @@ var NotionClient = class {
792
850
  try {
793
851
  return await fn();
794
852
  } catch (error) {
795
- if (isRateLimited(error) && attempt < 5) {
853
+ if (isRetryable(error) && attempt < 5) {
796
854
  const baseDelay = extractRetryAfter(error) ?? 1e3 * Math.pow(2, attempt);
797
855
  const jitter = baseDelay * (0.5 + Math.random() * 0.5);
798
856
  await sleep(jitter);
@@ -802,13 +860,20 @@ var NotionClient = class {
802
860
  }
803
861
  }
804
862
  };
805
- function isRateLimited(error) {
806
- return typeof error === "object" && error !== null && "status" in error && error.status === 429;
863
+ function isRetryable(error) {
864
+ if (typeof error !== "object" || error === null) return false;
865
+ const code = error.code;
866
+ if (code === "notionhq_client_request_timeout" || code === "ECONNRESET" || code === "ETIMEDOUT") {
867
+ return true;
868
+ }
869
+ if (!("status" in error)) return false;
870
+ const status = error.status;
871
+ return status === 429 || status === 502 || status === 503 || status === 504;
807
872
  }
808
873
  function extractRetryAfter(error) {
809
874
  if (typeof error === "object" && error !== null && "headers" in error) {
810
875
  const headers = error.headers;
811
- const retryAfter = headers["retry-after"];
876
+ const retryAfter = headers?.["retry-after"];
812
877
  if (retryAfter) return Number(retryAfter) * 1e3;
813
878
  }
814
879
  return null;
@@ -1187,6 +1252,9 @@ var ChangeDetector = class {
1187
1252
  const syncedRecords = this.stateDb.getByStatus("synced");
1188
1253
  for (const record of syncedRecords) {
1189
1254
  if (!existingPaths.has(record.obsidianPath)) {
1255
+ if (record.fileType === "folder-note" || record.fileType === "folder-only") {
1256
+ if (!record.obsidianPath.endsWith(".md")) continue;
1257
+ }
1190
1258
  changes.push({
1191
1259
  path: record.obsidianPath,
1192
1260
  type: "deleted",
@@ -1314,17 +1382,11 @@ var ConversionPipeline = class {
1314
1382
  return input.content;
1315
1383
  }
1316
1384
  };
1317
- var FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/;
1318
- var WIKILINK_IN_YAML = /\[\[([^\]]+)\]\]/g;
1319
1385
  var FrontmatterExtractor = class {
1320
1386
  name = "FrontmatterExtractor";
1321
1387
  order = 10;
1322
1388
  process(input) {
1323
- const sanitized = input.content.replace(FRONTMATTER_REGEX, (match, yaml) => {
1324
- const fixed = yaml.replace(WIKILINK_IN_YAML, "$1");
1325
- return match.replace(yaml, fixed);
1326
- });
1327
- const { data, content } = matter(sanitized);
1389
+ const { data, content } = matter(input.content);
1328
1390
  return {
1329
1391
  content: content.trim(),
1330
1392
  metadata: {
@@ -1336,21 +1398,41 @@ var FrontmatterExtractor = class {
1336
1398
  };
1337
1399
 
1338
1400
  // src/converter/pre-processors/wikilink.ts
1339
- var WIKILINK_REGEX = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
1401
+ var WIKILINK_REGEX = /(?<!!)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
1340
1402
  var WikilinkResolver = class {
1403
+ constructor(resolve) {
1404
+ this.resolve = resolve;
1405
+ }
1406
+ resolve;
1341
1407
  name = "WikilinkResolver";
1342
1408
  order = 20;
1343
1409
  process(input) {
1410
+ if (input.context.direction !== "push") {
1411
+ return { content: input.content, metadata: input.metadata };
1412
+ }
1413
+ const preserveMarkers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
1344
1414
  const resolved = input.content.replace(
1345
1415
  WIKILINK_REGEX,
1346
1416
  (_match, target, display) => {
1347
1417
  const label = display ?? target;
1348
- return `**${label}**`;
1418
+ if (this.resolve) {
1419
+ const entry = this.resolve(target);
1420
+ if (entry) {
1421
+ return `<mention-page id="${entry.notionPageId}">${label}</mention-page>`;
1422
+ }
1423
+ }
1424
+ preserveMarkers.push({
1425
+ type: "wikilink",
1426
+ params: display ? { text: target, display } : { text: target },
1427
+ startIndex: 0
1428
+ });
1429
+ const encodedTarget = encodeURIComponent(target);
1430
+ return `[${label}](im-nobsidian://wikilink/${encodedTarget})`;
1349
1431
  }
1350
1432
  );
1351
1433
  return {
1352
1434
  content: resolved,
1353
- metadata: input.metadata
1435
+ metadata: { ...input.metadata, preserveMarkers }
1354
1436
  };
1355
1437
  }
1356
1438
  };
@@ -1383,13 +1465,17 @@ var CalloutTransformer = class {
1383
1465
  name = "CalloutTransformer";
1384
1466
  order = 30;
1385
1467
  process(input) {
1468
+ if (input.context.path === "markdown-api") {
1469
+ return { content: input.content, metadata: input.metadata };
1470
+ }
1386
1471
  const content = input.content.replace(
1387
1472
  CALLOUT_REGEX,
1388
1473
  (_match, type, foldable, title) => {
1389
1474
  const emoji = CALLOUT_EMOJI_MAP[type.toLowerCase()] ?? "\u{1F4DD}";
1390
1475
  const titleText = title?.trim() || type;
1391
1476
  const foldMeta = foldable ? `
1392
- %% im-nobsidian:callout:type=${type}&foldable=${foldable === "+" ? "open" : "closed"} %%` : "";
1477
+ %% im-nobsidian:callout:type=${type}&foldable=${foldable === "+" ? "open" : "closed"} %%` : `
1478
+ %% im-nobsidian:callout:type=${type} %%`;
1393
1479
  return `> ${emoji} **${titleText}**${foldMeta}`;
1394
1480
  }
1395
1481
  );
@@ -1438,12 +1524,13 @@ var EmbedResolver = class {
1438
1524
  images.push({ url: target, localPath: target, isExternal: false });
1439
1525
  if (isPush && !isExternalUrl(target)) {
1440
1526
  const fileName = target.split("/").pop() ?? target;
1441
- return `> \u{1F4CE} ${fileName} (\uB85C\uCEEC \uC774\uBBF8\uC9C0 \u2014 Notion API \uC81C\uD55C\uC73C\uB85C \uC5C5\uB85C\uB4DC \uBD88\uAC00)
1527
+ return `> \u{1F4CE} ${fileName}
1442
1528
  > %% im-nobsidian:local-image:${target} %%`;
1443
1529
  }
1444
1530
  return `![${target}](${encodeURI(target)})`;
1445
1531
  }
1446
- return `[${target}](${encodeURI(target)})`;
1532
+ const encoded = encodeURIComponent(target);
1533
+ return `[${target}](im-nobsidian://embed/${encoded})`;
1447
1534
  });
1448
1535
  content = content.replace(MARKDOWN_IMAGE_REGEX, (_match, alt, url) => {
1449
1536
  if (isVideoUrl(url)) {
@@ -1519,8 +1606,6 @@ var UnsupportedBlockStripper = class {
1519
1606
  };
1520
1607
  }
1521
1608
  };
1522
-
1523
- // src/converter/pre-processors/properties-table.ts
1524
1609
  var PropertiesTableInjector = class {
1525
1610
  name = "PropertiesTableInjector";
1526
1611
  order = 15;
@@ -1535,32 +1620,20 @@ var PropertiesTableInjector = class {
1535
1620
  if (!properties || Object.keys(properties).length === 0) {
1536
1621
  return { content: input.content, metadata: input.metadata };
1537
1622
  }
1538
- const filteredEntries = Object.entries(properties).filter(([key]) => key !== "title");
1539
- if (filteredEntries.length === 0) {
1623
+ const filteredProps = {};
1624
+ for (const [key, value] of Object.entries(properties)) {
1625
+ filteredProps[key] = value;
1626
+ }
1627
+ if (Object.keys(filteredProps).length === 0) {
1540
1628
  return { content: input.content, metadata: input.metadata };
1541
1629
  }
1542
- const rows = filteredEntries.map(([key, value]) => {
1543
- const display = formatValue(value);
1544
- return `| ${key} | ${display} |`;
1545
- });
1546
- const table = ["| Property | Value |", "| --- | --- |", ...rows].join("\n");
1547
- const content = table + "\n\n---\n\n" + input.content;
1630
+ const yamlStr = matter.stringify("", filteredProps).trim();
1631
+ const yamlBody = yamlStr.slice(4, -3).trim();
1632
+ const codeBlock = "```yaml\n# im-nobsidian:properties\n" + yamlBody + "\n```";
1633
+ const content = codeBlock + "\n\n---\n\n" + input.content;
1548
1634
  return { content, metadata: input.metadata };
1549
1635
  }
1550
1636
  };
1551
- function formatValue(value) {
1552
- if (value === null || value === void 0) return "";
1553
- if (value instanceof Date) {
1554
- return value.toISOString().replace(/T00:00:00(?:\.000)?Z$/, "");
1555
- }
1556
- if (Array.isArray(value)) {
1557
- return value.map((v) => formatValue(v)).join(", ");
1558
- }
1559
- if (typeof value === "object") {
1560
- return JSON.stringify(value);
1561
- }
1562
- return String(value);
1563
- }
1564
1637
 
1565
1638
  // src/converter/pre-processors/html-annotation.ts
1566
1639
  var UNDERLINE_REGEX = /<u>([\s\S]*?)<\/u>/g;
@@ -1582,21 +1655,30 @@ var HtmlAnnotationStripper = class {
1582
1655
  };
1583
1656
 
1584
1657
  // src/converter/post-processors/mention-to-wikilink.ts
1585
- var NOTION_LINK_REGEX = /\[([^\]]+)\]\((?:https:\/\/www\.notion\.so\/|notion:\/\/)([a-f0-9-]+)\)/g;
1658
+ var NOTION_LINK_REGEX = /\[([^\]]+)\]\((?:https:\/\/(?:www\.)?notion\.so\/|notion:\/\/)([a-f0-9-]+)\)/g;
1659
+ var WIKILINK_PRESERVE_REGEX = /\[([^\]]+)\]\(im-nobsidian:\/\/wikilink\/([^)]+)\)/g;
1660
+ var EMBED_PRESERVE_REGEX = /\[([^\]]+)\]\(im-nobsidian:\/\/embed\/([^)]+)\)/g;
1586
1661
  var MentionToWikilink = class {
1587
1662
  name = "MentionToWikilink";
1588
1663
  order = 10;
1589
1664
  process(input) {
1590
- const content = input.content.replace(
1591
- NOTION_LINK_REGEX,
1592
- (_match, display, _pageId) => {
1593
- return `[[${display}]]`;
1665
+ let content = input.content;
1666
+ content = content.replace(NOTION_LINK_REGEX, (_match, display) => `[[${display}]]`);
1667
+ content = content.replace(
1668
+ WIKILINK_PRESERVE_REGEX,
1669
+ (_match, display, encodedTarget) => {
1670
+ const target = decodeURIComponent(encodedTarget);
1671
+ return target === display ? `[[${target}]]` : `[[${target}|${display}]]`;
1594
1672
  }
1595
1673
  );
1596
- return {
1597
- content,
1598
- metadata: input.metadata
1599
- };
1674
+ content = content.replace(
1675
+ EMBED_PRESERVE_REGEX,
1676
+ (_match, _display, encodedTarget) => {
1677
+ const target = decodeURIComponent(encodedTarget);
1678
+ return `![[${target}]]`;
1679
+ }
1680
+ );
1681
+ return { content, metadata: input.metadata };
1600
1682
  }
1601
1683
  };
1602
1684
 
@@ -1643,15 +1725,18 @@ var EMOJI_TYPE_MAP = {
1643
1725
  "\u{1F4CC}": "example",
1644
1726
  "\u{1F4AC}": "quote"
1645
1727
  };
1646
- var CALLOUT_PRESERVE_REGEX = /%% im-nobsidian:callout:type=(\w+)&foldable=(open|closed) %%/g;
1728
+ var CALLOUT_PRESERVE_REGEX = /%% im-nobsidian:callout:type=(\w+)(?:&foldable=(open|closed))? %%/g;
1647
1729
  var CalloutRestorer = class {
1648
1730
  name = "CalloutRestorer";
1649
1731
  order = 20;
1650
1732
  process(input) {
1651
1733
  let content = input.content;
1652
1734
  content = content.replace(CALLOUT_PRESERVE_REGEX, (_match, type, foldable) => {
1653
- const foldChar = foldable === "open" ? "+" : "-";
1654
- return `[!${type}]${foldChar}`;
1735
+ if (foldable) {
1736
+ const foldChar = foldable === "open" ? "+" : "-";
1737
+ return `[!${type}]${foldChar}`;
1738
+ }
1739
+ return `[!${type}]`;
1655
1740
  });
1656
1741
  for (const [emoji, type] of Object.entries(EMOJI_TYPE_MAP)) {
1657
1742
  const pattern = new RegExp(`> ${escapeRegex(emoji)} \\*\\*(.+?)\\*\\*`, "g");
@@ -1722,9 +1807,8 @@ var LocalImageRestorer = class {
1722
1807
  return { content, metadata: input.metadata };
1723
1808
  }
1724
1809
  };
1725
-
1726
- // src/converter/post-processors/properties-table-restorer.ts
1727
- var PROPERTIES_TABLE_REGEX = /^\s*\| Property\s*\| Value\s*\|\n\s*\|\s*-{3,}\s*\|\s*-{3,}\s*\|\n((?:\|[^\n]+\|\n?)+)\n*---\n*/;
1810
+ var YAML_PROPERTIES_REGEX = /```yaml\n# im-nobsidian:properties\n([\s\S]*?)```\n*(?:---\n*)?/;
1811
+ var LEGACY_TABLE_REGEX = /^\s*\| Property\s*\| Value\s*\|\n\s*\|\s*-{3,}\s*\|\s*-{3,}\s*\|\n((?:\|[^\n]+\|\n?)+)\n*---\n*/;
1728
1812
  var PropertiesTableRestorer = class {
1729
1813
  name = "PropertiesTableRestorer";
1730
1814
  order = 5;
@@ -1732,10 +1816,39 @@ var PropertiesTableRestorer = class {
1732
1816
  if (input.context.direction !== "pull") {
1733
1817
  return { content: input.content, metadata: input.metadata };
1734
1818
  }
1735
- const match = input.content.match(PROPERTIES_TABLE_REGEX);
1736
- if (!match) {
1819
+ const yamlMatch = input.content.match(YAML_PROPERTIES_REGEX);
1820
+ if (yamlMatch) {
1821
+ return this.restoreFromYaml(input, yamlMatch);
1822
+ }
1823
+ const tableMatch = input.content.match(LEGACY_TABLE_REGEX);
1824
+ if (tableMatch) {
1825
+ return this.restoreFromTable(input, tableMatch);
1826
+ }
1827
+ return { content: input.content, metadata: input.metadata };
1828
+ }
1829
+ restoreFromYaml(input, match) {
1830
+ const yamlContent = match[1];
1831
+ const fakeDocument = `---
1832
+ ${yamlContent}---
1833
+ `;
1834
+ let parsed;
1835
+ try {
1836
+ const result = matter(fakeDocument);
1837
+ parsed = result.data;
1838
+ } catch {
1737
1839
  return { content: input.content, metadata: input.metadata };
1738
1840
  }
1841
+ const properties = {
1842
+ ...input.metadata.properties,
1843
+ ...parsed
1844
+ };
1845
+ const content = input.content.replace(YAML_PROPERTIES_REGEX, "").trimStart();
1846
+ return {
1847
+ content,
1848
+ metadata: { ...input.metadata, properties }
1849
+ };
1850
+ }
1851
+ restoreFromTable(input, match) {
1739
1852
  const rows = match[1].trim().split("\n");
1740
1853
  const properties = {
1741
1854
  ...input.metadata.properties
@@ -1745,31 +1858,21 @@ var PropertiesTableRestorer = class {
1745
1858
  if (cells.length >= 2) {
1746
1859
  const key = cells[0];
1747
1860
  const rawValue = cells[1];
1748
- properties[key] = parseValue(rawValue);
1861
+ properties[key] = parseLegacyValue(rawValue);
1749
1862
  }
1750
1863
  }
1751
- const content = input.content.replace(PROPERTIES_TABLE_REGEX, "").trimStart();
1864
+ const content = input.content.replace(LEGACY_TABLE_REGEX, "").trimStart();
1752
1865
  return {
1753
1866
  content,
1754
1867
  metadata: { ...input.metadata, properties }
1755
1868
  };
1756
1869
  }
1757
1870
  };
1758
- function stripQuotes(s) {
1759
- if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
1760
- return s.slice(1, -1);
1761
- }
1762
- return s;
1763
- }
1764
- var ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/;
1765
- function parseValue(raw) {
1871
+ function parseLegacyValue(raw) {
1766
1872
  if (raw === "" || raw === "null") return null;
1767
1873
  if (raw === "true") return true;
1768
1874
  if (raw === "false") return false;
1769
1875
  const unquoted = stripQuotes(raw);
1770
- if (ISO_DATE_REGEX.test(unquoted)) {
1771
- return unquoted.replace(/T00:00:00(?:\.000)?Z?$/, "");
1772
- }
1773
1876
  const num = Number(unquoted);
1774
1877
  if (!isNaN(num) && unquoted.trim() !== "") return num;
1775
1878
  if (unquoted.includes(", ")) {
@@ -1791,15 +1894,21 @@ function parseValue(raw) {
1791
1894
  }
1792
1895
  return unquoted;
1793
1896
  }
1897
+ function stripQuotes(s) {
1898
+ if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
1899
+ return s.slice(1, -1);
1900
+ }
1901
+ return s;
1902
+ }
1794
1903
 
1795
1904
  // src/converter/pipeline-factory.ts
1796
- function createDefaultPipeline() {
1905
+ function createDefaultPipeline(options) {
1797
1906
  const pipeline = new ConversionPipeline();
1798
1907
  pipeline.registerPreProcessor(new HtmlAnnotationStripper());
1799
1908
  pipeline.registerPreProcessor(new UnsupportedBlockStripper());
1800
1909
  pipeline.registerPreProcessor(new FrontmatterExtractor());
1801
1910
  pipeline.registerPreProcessor(new PropertiesTableInjector());
1802
- pipeline.registerPreProcessor(new WikilinkResolver());
1911
+ pipeline.registerPreProcessor(new WikilinkResolver(options?.wikilinkResolver));
1803
1912
  pipeline.registerPreProcessor(new CalloutTransformer());
1804
1913
  pipeline.registerPreProcessor(new MathNormalizer());
1805
1914
  pipeline.registerPreProcessor(new EmbedResolver());
@@ -2127,7 +2236,8 @@ ${COLUMN_LIST_END}`;
2127
2236
  markdownToNotionBlocks(markdown) {
2128
2237
  const preprocessed = this.preProcessMarkdown(markdown);
2129
2238
  const blocks = markdownToBlocks(preprocessed);
2130
- return this.postProcessBlocks(blocks);
2239
+ const processed = this.postProcessBlocks(blocks);
2240
+ return normalizeBlocksForNotion(processed);
2131
2241
  }
2132
2242
  toggleContents = /* @__PURE__ */ new Map();
2133
2243
  columnContents = /* @__PURE__ */ new Map();
@@ -2345,6 +2455,111 @@ ${COLUMN_LIST_END}`;
2345
2455
  return result.parent ?? "";
2346
2456
  }
2347
2457
  };
2458
+ var NOTION_MAX_TABLE_ROWS = 100;
2459
+ var NOTION_MAX_LIST_DEPTH = 3;
2460
+ function normalizeBlocksForNotion(blocks) {
2461
+ const result = [];
2462
+ for (const block of blocks) {
2463
+ if (block.type === "table") {
2464
+ result.push(...normalizeTable(block));
2465
+ } else if (isListBlock(block)) {
2466
+ result.push(flattenListDepth(block, 0));
2467
+ } else {
2468
+ result.push(block);
2469
+ }
2470
+ }
2471
+ return result;
2472
+ }
2473
+ function normalizeTable(block) {
2474
+ const table = block.table;
2475
+ if (!table?.children) return [block];
2476
+ const rows = table.children;
2477
+ if (rows.length === 0) return [block];
2478
+ let maxCells = table.table_width;
2479
+ for (const row of rows) {
2480
+ const tr = row.table_row;
2481
+ if (tr?.cells && tr.cells.length > maxCells) {
2482
+ maxCells = tr.cells.length;
2483
+ }
2484
+ }
2485
+ const normalizedRows = rows.map((row) => {
2486
+ const tr = row.table_row;
2487
+ if (!tr?.cells) return row;
2488
+ const cells = [...tr.cells];
2489
+ while (cells.length < maxCells) {
2490
+ cells.push([{ type: "text", text: { content: "" } }]);
2491
+ }
2492
+ if (cells.length > maxCells) {
2493
+ cells.length = maxCells;
2494
+ }
2495
+ return { ...row, table_row: { ...tr, cells } };
2496
+ });
2497
+ if (normalizedRows.length <= NOTION_MAX_TABLE_ROWS) {
2498
+ return [
2499
+ {
2500
+ ...block,
2501
+ table: { ...table, table_width: maxCells, children: normalizedRows }
2502
+ }
2503
+ ];
2504
+ }
2505
+ const tables = [];
2506
+ const headerRow = table.has_column_header ? normalizedRows[0] : null;
2507
+ const dataRows = table.has_column_header ? normalizedRows.slice(1) : normalizedRows;
2508
+ const chunkSize = headerRow ? NOTION_MAX_TABLE_ROWS - 1 : NOTION_MAX_TABLE_ROWS;
2509
+ for (let i = 0; i < dataRows.length; i += chunkSize) {
2510
+ const chunk = dataRows.slice(i, i + chunkSize);
2511
+ const children = headerRow ? [headerRow, ...chunk] : chunk;
2512
+ tables.push({
2513
+ type: "table",
2514
+ table: {
2515
+ table_width: maxCells,
2516
+ has_column_header: !!headerRow,
2517
+ has_row_header: table.has_row_header,
2518
+ children
2519
+ }
2520
+ });
2521
+ }
2522
+ return tables;
2523
+ }
2524
+ function isListBlock(block) {
2525
+ return block.type === "bulleted_list_item" || block.type === "numbered_list_item" || block.type === "to_do";
2526
+ }
2527
+ function flattenListDepth(block, depth) {
2528
+ const blockType = block.type;
2529
+ const blockData = block[blockType];
2530
+ if (!blockData?.children || blockData.children.length === 0) return block;
2531
+ if (depth >= NOTION_MAX_LIST_DEPTH - 1) {
2532
+ const flattened = collectAllDescendants(blockData.children);
2533
+ return {
2534
+ ...block,
2535
+ [blockType]: { ...blockData, children: flattened }
2536
+ };
2537
+ }
2538
+ const normalizedChildren = blockData.children.map((child) => {
2539
+ if (isListBlock(child)) {
2540
+ return flattenListDepth(child, depth + 1);
2541
+ }
2542
+ return child;
2543
+ });
2544
+ return {
2545
+ ...block,
2546
+ [blockType]: { ...blockData, children: normalizedChildren }
2547
+ };
2548
+ }
2549
+ function collectAllDescendants(blocks) {
2550
+ const result = [];
2551
+ for (const block of blocks) {
2552
+ const blockType = block.type;
2553
+ const blockData = block[blockType];
2554
+ const children = blockData?.children;
2555
+ const blockWithoutChildren = children ? { ...block, [blockType]: { ...blockData, children: void 0 } } : block;
2556
+ result.push(blockWithoutChildren);
2557
+ if (children && children.length > 0) {
2558
+ result.push(...collectAllDescendants(children));
2559
+ }
2560
+ }
2561
+ return result;
2562
+ }
2348
2563
  var IMAGE_EXTENSIONS = {
2349
2564
  "image/png": ".png",
2350
2565
  "image/jpeg": ".jpg",
@@ -2354,13 +2569,26 @@ var IMAGE_EXTENSIONS = {
2354
2569
  "image/bmp": ".bmp",
2355
2570
  "image/tiff": ".tiff"
2356
2571
  };
2572
+ var EXTENSION_TO_MIME = {
2573
+ ".png": "image/png",
2574
+ ".jpg": "image/jpeg",
2575
+ ".jpeg": "image/jpeg",
2576
+ ".gif": "image/gif",
2577
+ ".webp": "image/webp",
2578
+ ".svg": "image/svg+xml",
2579
+ ".bmp": "image/bmp",
2580
+ ".ico": "image/x-icon",
2581
+ ".tiff": "image/tiff"
2582
+ };
2357
2583
  var ImageHandler = class {
2358
- constructor(vaultFs, attachmentFolder = "attachments") {
2584
+ constructor(vaultFs, attachmentFolder = "attachments", notionClient) {
2359
2585
  this.vaultFs = vaultFs;
2360
2586
  this.attachmentFolder = attachmentFolder;
2587
+ this.notionClient = notionClient;
2361
2588
  }
2362
2589
  vaultFs;
2363
2590
  attachmentFolder;
2591
+ notionClient;
2364
2592
  async downloadImage(url, pageTitle) {
2365
2593
  const response = await fetch(url);
2366
2594
  if (!response.ok) {
@@ -2414,6 +2642,54 @@ var ImageHandler = class {
2414
2642
  }
2415
2643
  return { content: result, downloads };
2416
2644
  }
2645
+ async uploadLocalImage(localPath) {
2646
+ if (!this.notionClient) {
2647
+ throw new Error("NotionClient required for image upload");
2648
+ }
2649
+ const buffer = await this.readImageFromVault(localPath);
2650
+ const filename = localPath.split("/").pop() ?? "image.png";
2651
+ const contentType = this.getContentTypeFromPath(filename);
2652
+ const blob = new Blob([buffer], { type: contentType });
2653
+ const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
2654
+ return { localPath, fileUploadId };
2655
+ }
2656
+ async uploadAndAppendImages(pageId, images) {
2657
+ if (!this.notionClient) return [];
2658
+ const localImages = images.filter((img) => !img.isExternal && img.localPath);
2659
+ if (localImages.length === 0) return [];
2660
+ const results = [];
2661
+ const imageBlocks = [];
2662
+ for (const img of localImages) {
2663
+ try {
2664
+ const result = await this.uploadLocalImage(img.localPath);
2665
+ results.push(result);
2666
+ imageBlocks.push({
2667
+ type: "image",
2668
+ image: {
2669
+ type: "file_upload",
2670
+ file_upload: { id: result.fileUploadId }
2671
+ }
2672
+ });
2673
+ } catch (error) {
2674
+ getLogger().warn(`\uC774\uBBF8\uC9C0 \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${img.localPath}):`, error);
2675
+ }
2676
+ }
2677
+ if (imageBlocks.length > 0) {
2678
+ await this.notionClient.appendChildren(pageId, imageBlocks);
2679
+ }
2680
+ return results;
2681
+ }
2682
+ async readImageFromVault(localPath) {
2683
+ try {
2684
+ return await this.vaultFs.readBinary(localPath);
2685
+ } catch {
2686
+ return await this.vaultFs.readBinary(`${this.attachmentFolder}/${localPath}`);
2687
+ }
2688
+ }
2689
+ getContentTypeFromPath(filename) {
2690
+ const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
2691
+ return (ext && EXTENSION_TO_MIME[ext]) ?? "application/octet-stream";
2692
+ }
2417
2693
  isNotionImageUrl(url) {
2418
2694
  return url.includes("secure.notion-static.com") || url.includes("prod-files-secure") || url.includes("s3.us-west-2.amazonaws.com/secure.notion-static.com");
2419
2695
  }
@@ -2462,6 +2738,257 @@ function sanitizeFileName(name) {
2462
2738
  return sanitized;
2463
2739
  }
2464
2740
 
2741
+ // src/converter/enhanced-md-converter.ts
2742
+ var NOTION_CALLOUT_RE = /^::: callout\n([\s\S]*?)\n:::/gm;
2743
+ var NOTION_TOGGLE_RE = /<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gs;
2744
+ var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
2745
+ var NOTION_USER_MENTION_RE = /<mention-user id="[^"]*">([^<]*)<\/mention-user>/g;
2746
+ var NOTION_DATE_MENTION_RE = /<mention-date start="([^"]*)"(?: end="([^"]*)")?[^>]*\/>/g;
2747
+ var NOTION_UNKNOWN_RE = /<unknown id="[^"]*"[^>]*\/>/g;
2748
+ var TOGGLE_START2 = "%%im-nobsidian:toggle:start%%";
2749
+ var TOGGLE_END2 = "%%im-nobsidian:toggle:end%%";
2750
+ function notionEnhancedToObsidian(enhanced) {
2751
+ let result = enhanced;
2752
+ result = convertToggles(result);
2753
+ result = convertCallouts(result);
2754
+ result = convertPageMentions(result);
2755
+ result = convertPageLinks(result);
2756
+ result = convertUserMentions(result);
2757
+ result = convertDateMentions(result);
2758
+ result = removeUnknownBlocks(result);
2759
+ result = convertNotionMath(result);
2760
+ result = convertNotionTables(result);
2761
+ result = convertColorSpans(result);
2762
+ result = removeEmptyBlocks(result);
2763
+ result = unescapePipes(result);
2764
+ return result;
2765
+ }
2766
+ function obsidianToNotionEnhanced(obsidian) {
2767
+ let result = obsidian;
2768
+ result = convertTogglesToHtml(result);
2769
+ result = convertObsidianCallouts(result);
2770
+ return result;
2771
+ }
2772
+ function convertToggles(content) {
2773
+ function replaceToggle(match) {
2774
+ const m = /<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/s.exec(match);
2775
+ if (!m) return match;
2776
+ const title = m[1].trim();
2777
+ let body = m[2].trim();
2778
+ body = convertToggles(body);
2779
+ const indented = body.split("\n").map((line) => line ? ` ${line}` : "").join("\n").trimEnd();
2780
+ return `${TOGGLE_START2}
2781
+ - ${title}
2782
+ ${indented}
2783
+ ${TOGGLE_END2}`;
2784
+ }
2785
+ return content.replace(NOTION_TOGGLE_RE, replaceToggle);
2786
+ }
2787
+ function convertCallouts(content) {
2788
+ return content.replace(NOTION_CALLOUT_RE, (_match, body) => {
2789
+ const lines = body.trim().split("\n");
2790
+ const firstLine = lines[0] ?? "";
2791
+ const emojiMatch = /^([\p{Emoji}️‍]+)\s*(.*)/u.exec(firstLine);
2792
+ const type = emojiMatch ? emojiToCalloutType(emojiMatch[1]) : "note";
2793
+ const title = emojiMatch ? emojiMatch[2] : firstLine;
2794
+ const rest = lines.slice(1).join("\n").trim();
2795
+ const calloutTitle = title ? `> [!${type}] ${title}` : `> [!${type}]`;
2796
+ const calloutBody = rest ? "\n" + rest.split("\n").map((line) => `> ${line}`).join("\n") : "";
2797
+ return calloutTitle + calloutBody;
2798
+ });
2799
+ }
2800
+ function convertPageMentions(content) {
2801
+ return content.replace(NOTION_PAGE_MENTION_RE, (_match, _id, text) => {
2802
+ const cleaned = text.trim();
2803
+ return `[[${cleaned}]]`;
2804
+ });
2805
+ }
2806
+ function convertUserMentions(content) {
2807
+ return content.replace(NOTION_USER_MENTION_RE, (_match, name) => `@${name}`);
2808
+ }
2809
+ function convertDateMentions(content) {
2810
+ return content.replace(NOTION_DATE_MENTION_RE, (_match, start, end) => {
2811
+ return end ? `${start} \u2192 ${end}` : start;
2812
+ });
2813
+ }
2814
+ function removeUnknownBlocks(content) {
2815
+ return content.replace(NOTION_UNKNOWN_RE, "");
2816
+ }
2817
+ function convertTogglesToHtml(content) {
2818
+ const startRe = new RegExp(escapeRegex3(TOGGLE_START2), "g");
2819
+ const endRe = new RegExp(escapeRegex3(TOGGLE_END2), "g");
2820
+ let result = content;
2821
+ let safety = 0;
2822
+ while (result.includes(TOGGLE_START2) && safety++ < 100) {
2823
+ const startIdx = result.indexOf(TOGGLE_START2);
2824
+ const endIdx = findMatchingEnd(result, startIdx + TOGGLE_START2.length);
2825
+ if (endIdx === -1) break;
2826
+ const inner = result.slice(startIdx + TOGGLE_START2.length, endIdx).trim();
2827
+ const titleMatch = /^- (.+)$/m.exec(inner);
2828
+ const title = titleMatch ? titleMatch[1] : "";
2829
+ const body = inner.split("\n").slice(1).map((line) => line.replace(/^ {2}/, "")).join("\n").trim();
2830
+ const html = `<details>
2831
+ <summary>${title}</summary>
2832
+
2833
+ ${body}
2834
+
2835
+ </details>`;
2836
+ result = result.slice(0, startIdx) + html + result.slice(endIdx + TOGGLE_END2.length);
2837
+ }
2838
+ result = result.replace(startRe, "").replace(endRe, "");
2839
+ return result;
2840
+ }
2841
+ function findMatchingEnd(content, startFrom) {
2842
+ let depth = 1;
2843
+ let pos = startFrom;
2844
+ while (pos < content.length && depth > 0) {
2845
+ const nextStart = content.indexOf(TOGGLE_START2, pos);
2846
+ const nextEnd = content.indexOf(TOGGLE_END2, pos);
2847
+ if (nextEnd === -1) return -1;
2848
+ if (nextStart !== -1 && nextStart < nextEnd) {
2849
+ depth++;
2850
+ pos = nextStart + TOGGLE_START2.length;
2851
+ } else {
2852
+ depth--;
2853
+ if (depth === 0) return nextEnd;
2854
+ pos = nextEnd + TOGGLE_END2.length;
2855
+ }
2856
+ }
2857
+ return -1;
2858
+ }
2859
+ function convertObsidianCallouts(content) {
2860
+ const lines = content.split("\n");
2861
+ const result = [];
2862
+ let i = 0;
2863
+ while (i < lines.length) {
2864
+ const headerMatch = /^> \[!(\w+)\]([-+])?\s*(.*)/.exec(lines[i]);
2865
+ if (headerMatch) {
2866
+ const type = headerMatch[1];
2867
+ const title = headerMatch[3];
2868
+ const emoji = calloutTypeToEmoji(type) ?? "\u{1F4A1}";
2869
+ const calloutTitle = title ? `${emoji} ${title}` : emoji;
2870
+ const bodyLines = [];
2871
+ i++;
2872
+ while (i < lines.length && lines[i].startsWith("> ")) {
2873
+ bodyLines.push(lines[i].slice(2));
2874
+ i++;
2875
+ }
2876
+ result.push("::: callout");
2877
+ result.push(calloutTitle);
2878
+ if (bodyLines.length > 0) {
2879
+ result.push(...bodyLines);
2880
+ }
2881
+ result.push(":::");
2882
+ } else {
2883
+ result.push(lines[i]);
2884
+ i++;
2885
+ }
2886
+ }
2887
+ return result.join("\n");
2888
+ }
2889
+ var EMOJI_TYPE_MAP2 = {
2890
+ "\u{1F4A1}": "tip",
2891
+ "\u2139\uFE0F": "info",
2892
+ "\u26A0\uFE0F": "warning",
2893
+ "\u{1F525}": "danger",
2894
+ "\u2705": "success",
2895
+ "\u274C": "failure",
2896
+ "\u2753": "question",
2897
+ "\u{1F4DD}": "note",
2898
+ "\u{1F4CC}": "abstract",
2899
+ "\u{1F41B}": "bug",
2900
+ "\u{1F4AC}": "quote",
2901
+ "\u{1F4CB}": "example"
2902
+ };
2903
+ var TYPE_EMOJI_MAP = Object.fromEntries(
2904
+ Object.entries(EMOJI_TYPE_MAP2).map(([k, v]) => [v, k])
2905
+ );
2906
+ function emojiToCalloutType(emoji) {
2907
+ return EMOJI_TYPE_MAP2[emoji] ?? "note";
2908
+ }
2909
+ function calloutTypeToEmoji(type) {
2910
+ return TYPE_EMOJI_MAP[type.toLowerCase()];
2911
+ }
2912
+ function escapeRegex3(str) {
2913
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2914
+ }
2915
+ var NOTION_PAGE_LINK_RE = /<page url="[^"]*">([\s\S]*?)<\/page>/g;
2916
+ var NOTION_COLOR_SPAN_RE = /<span color="([^"]+)">([\s\S]*?)<\/span>/g;
2917
+ var NOTION_EMPTY_BLOCK_RE = /^<empty-block\/>\n?/gm;
2918
+ function convertPageLinks(content) {
2919
+ return content.replace(NOTION_PAGE_LINK_RE, (_match, text) => {
2920
+ const cleaned = text.replace(/\*\*/g, "").trim();
2921
+ return `[[${cleaned}]]`;
2922
+ });
2923
+ }
2924
+ function convertColorSpans(content) {
2925
+ return content.replace(NOTION_COLOR_SPAN_RE, (_match, _color, text) => text);
2926
+ }
2927
+ function removeEmptyBlocks(content) {
2928
+ return content.replace(NOTION_EMPTY_BLOCK_RE, "\n");
2929
+ }
2930
+ var NOTION_INLINE_MATH_RE = /\$`([^`]+)`\$/g;
2931
+ var NOTION_BLOCK_MATH_RE = /\$\$\n```\n([\s\S]*?)\n```\n\$\$/g;
2932
+ function convertNotionMath(content) {
2933
+ let result = content.replace(NOTION_BLOCK_MATH_RE, (_match, eq) => {
2934
+ return `$$
2935
+ ${eq.trim()}
2936
+ $$`;
2937
+ });
2938
+ result = result.replace(NOTION_INLINE_MATH_RE, (_match, eq) => `$${eq}$`);
2939
+ return result;
2940
+ }
2941
+ var NOTION_TABLE_RE = /<table[^>]*>([\s\S]*?)<\/table>/g;
2942
+ var TABLE_ROW_RE = /<tr>([\s\S]*?)<\/tr>/g;
2943
+ var TABLE_CELL_RE = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/g;
2944
+ function convertNotionTables(content) {
2945
+ return content.replace(NOTION_TABLE_RE, (_match, tableBody) => {
2946
+ const rows = [];
2947
+ let rowMatch;
2948
+ const rowRe = new RegExp(TABLE_ROW_RE.source, TABLE_ROW_RE.flags);
2949
+ while ((rowMatch = rowRe.exec(tableBody)) !== null) {
2950
+ const cells = [];
2951
+ let cellMatch;
2952
+ const cellRe = new RegExp(TABLE_CELL_RE.source, TABLE_CELL_RE.flags);
2953
+ while ((cellMatch = cellRe.exec(rowMatch[1])) !== null) {
2954
+ cells.push(cellMatch[1].trim());
2955
+ }
2956
+ rows.push(cells);
2957
+ }
2958
+ if (rows.length === 0) return _match;
2959
+ const colCount = Math.max(...rows.map((r) => r.length));
2960
+ const lines = [];
2961
+ for (let i = 0; i < rows.length; i++) {
2962
+ const padded = rows[i];
2963
+ while (padded.length < colCount) padded.push("");
2964
+ lines.push(`| ${padded.join(" | ")} |`);
2965
+ if (i === 0) {
2966
+ lines.push(`| ${padded.map(() => "---").join(" | ")} |`);
2967
+ }
2968
+ }
2969
+ return lines.join("\n");
2970
+ });
2971
+ }
2972
+ function unescapePipes(content) {
2973
+ const lines = content.split("\n");
2974
+ const result = [];
2975
+ let inTable = false;
2976
+ for (const line of lines) {
2977
+ if (/^\|.*\|$/.test(line.trim()) || /^\|[\s-|]+\|$/.test(line.trim())) {
2978
+ inTable = true;
2979
+ result.push(line);
2980
+ } else {
2981
+ if (inTable && line.trim() === "") {
2982
+ inTable = false;
2983
+ } else if (!/^\|/.test(line.trim())) {
2984
+ inTable = false;
2985
+ }
2986
+ result.push(inTable ? line : line.replace(/\\\|/g, "|"));
2987
+ }
2988
+ }
2989
+ return result.join("\n");
2990
+ }
2991
+
2465
2992
  // src/sync/orchestrator.ts
2466
2993
  var SyncOrchestrator = class {
2467
2994
  constructor(config, stateDb, notionClient, vaultFs) {
@@ -2470,9 +2997,11 @@ var SyncOrchestrator = class {
2470
2997
  this.notionClient = notionClient;
2471
2998
  this.vaultFs = vaultFs;
2472
2999
  this.changeDetector = new ChangeDetector(stateDb);
2473
- this.pipeline = createDefaultPipeline();
3000
+ this.pipeline = createDefaultPipeline({
3001
+ wikilinkResolver: (text) => stateDb.resolveWikilink(text)
3002
+ });
2474
3003
  this.blockConverter = new BlockConverter();
2475
- this.imageHandler = new ImageHandler(vaultFs, config.paths.attachments);
3004
+ this.imageHandler = new ImageHandler(vaultFs, config.paths.attachments, notionClient);
2476
3005
  this.propertyMapper = new PropertyMapper();
2477
3006
  this.blockConverter.initNotionToMd(this.notionClient.getInternalClient());
2478
3007
  }
@@ -2488,16 +3017,29 @@ var SyncOrchestrator = class {
2488
3017
  dbSchemaLoaded = false;
2489
3018
  async push(options) {
2490
3019
  const startTime = Date.now();
3020
+ if (this.config.sync.direction === "pull") {
3021
+ return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
3022
+ }
3023
+ this.cleanupInterruptedSync();
2491
3024
  const files = await this.vaultFs.listMarkdownFiles();
2492
3025
  const changes = this.changeDetector.detectLocalChanges(files);
2493
3026
  const conflictPaths = new Set(this.stateDb.getByStatus("conflict").map((r) => r.obsidianPath));
2494
- const nonConflict = changes.filter((c) => !conflictPaths.has(c.path));
2495
- const filtered = options?.paths ? nonConflict.filter((c) => options.paths.some((p) => c.path.startsWith(p))) : nonConflict;
3027
+ const eligible = options?.force ? changes : changes.filter((c) => !conflictPaths.has(c.path));
3028
+ const filtered = options?.paths ? eligible.filter((c) => options.paths.some((p) => c.path.startsWith(p))) : eligible;
2496
3029
  if (filtered.length === 0) {
2497
3030
  return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
2498
3031
  }
2499
3032
  if (options?.dryRun) {
2500
- return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
3033
+ const dryCreated = filtered.filter((c) => c.type === "created").length;
3034
+ const dryUpdated = filtered.filter((c) => c.type === "modified" || c.type === "moved").length;
3035
+ const dryDeleted = filtered.filter((c) => c.type === "deleted").length;
3036
+ return {
3037
+ created: dryCreated,
3038
+ updated: dryUpdated,
3039
+ deleted: dryDeleted,
3040
+ failed: [],
3041
+ duration: Date.now() - startTime
3042
+ };
2501
3043
  }
2502
3044
  this.stateDb.setMeta("push_in_progress", "true");
2503
3045
  const folderPaths = /* @__PURE__ */ new Set();
@@ -2559,6 +3101,19 @@ var SyncOrchestrator = class {
2559
3101
  }
2560
3102
  async pull(options) {
2561
3103
  const startTime = Date.now();
3104
+ const emptyResult = {
3105
+ created: 0,
3106
+ updated: 0,
3107
+ deleted: 0,
3108
+ conflicts: [],
3109
+ writtenPaths: [],
3110
+ failed: [],
3111
+ duration: Date.now() - startTime
3112
+ };
3113
+ if (this.config.sync.direction === "push") {
3114
+ return emptyResult;
3115
+ }
3116
+ this.cleanupInterruptedSync();
2562
3117
  let created = 0;
2563
3118
  let updated = 0;
2564
3119
  let deleted = 0;
@@ -2570,11 +3125,17 @@ var SyncOrchestrator = class {
2570
3125
  const record = this.stateDb.getByNotionId(c.pageId);
2571
3126
  return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
2572
3127
  }) : remoteChanges;
2573
- if (options?.dryRun || filtered.length === 0) {
3128
+ if (filtered.length === 0) {
3129
+ return emptyResult;
3130
+ }
3131
+ if (options?.dryRun) {
3132
+ const dryCreated = filtered.filter((c) => c.type === "created").length;
3133
+ const dryUpdated = filtered.filter((c) => c.type === "modified").length;
3134
+ const dryDeleted = filtered.filter((c) => c.type === "deleted").length;
2574
3135
  return {
2575
- created: 0,
2576
- updated: 0,
2577
- deleted: 0,
3136
+ created: dryCreated,
3137
+ updated: dryUpdated,
3138
+ deleted: dryDeleted,
2578
3139
  conflicts: [],
2579
3140
  writtenPaths: [],
2580
3141
  failed: [],
@@ -2729,7 +3290,6 @@ var SyncOrchestrator = class {
2729
3290
  filePath: path,
2730
3291
  parentMode: this.config.notion.parentMode
2731
3292
  });
2732
- const blocks = this.blockConverter.markdownToNotionBlocks(conversionResult.content);
2733
3293
  let effectiveParentId = parentId;
2734
3294
  let effectiveParentType = "page";
2735
3295
  let effectiveProperties = conversionResult.properties;
@@ -2742,15 +3302,14 @@ var SyncOrchestrator = class {
2742
3302
  title
2743
3303
  );
2744
3304
  }
2745
- const page = await this.notionClient.createPage({
2746
- parentId: effectiveParentId,
2747
- parentType: effectiveParentType,
3305
+ const page = await this.pushCreatePage(
3306
+ effectiveParentId,
3307
+ effectiveParentType,
2748
3308
  title,
2749
- properties: effectiveProperties
2750
- });
2751
- if (blocks.length > 0) {
2752
- await this.notionClient.appendChildren(page.id, blocks);
2753
- }
3309
+ conversionResult.content,
3310
+ effectiveProperties
3311
+ );
3312
+ await this.imageHandler.uploadAndAppendImages(page.id, conversionResult.images);
2754
3313
  const hash = computeHash(content);
2755
3314
  this.stateDb.transaction(() => {
2756
3315
  this.stateDb.upsert({
@@ -2765,11 +3324,12 @@ var SyncOrchestrator = class {
2765
3324
  status: "synced",
2766
3325
  baseSnapshot: Buffer.from(content, "utf-8")
2767
3326
  });
3327
+ const aliases = extractAliases(conversionResult.properties);
2768
3328
  this.stateDb.upsertWikilink({
2769
3329
  obsidianPath: path,
2770
3330
  notionPageId: page.id,
2771
3331
  title,
2772
- aliases: []
3332
+ aliases
2773
3333
  });
2774
3334
  this.stateDb.storePreserveMarkers(path, conversionResult.preserveMarkers);
2775
3335
  });
@@ -2790,28 +3350,32 @@ var SyncOrchestrator = class {
2790
3350
  filePath: path,
2791
3351
  parentMode: this.config.notion.parentMode
2792
3352
  });
2793
- const blocks = this.blockConverter.markdownToNotionBlocks(conversionResult.content);
2794
- const existingBlocks = await this.notionClient.fetchAllChildren(record.notionPageId);
2795
- if (blocks.length > 0) {
2796
- await this.notionClient.appendChildren(record.notionPageId, blocks);
2797
- }
2798
- for (const block of existingBlocks) {
2799
- await this.notionClient.deleteBlock(block.id);
2800
- }
3353
+ await this.pushUpdatePage(record.notionPageId, conversionResult.content);
3354
+ await this.imageHandler.uploadAndAppendImages(record.notionPageId, conversionResult.images);
2801
3355
  let propsToUpdate = conversionResult.properties;
2802
3356
  if (this.isDatabaseMode && propsToUpdate && Object.keys(propsToUpdate).length > 0) {
2803
3357
  await this.ensureDbSchema();
2804
- const title = extractTitle(path);
2805
- propsToUpdate = this.propertyMapper.toNotionProperties(propsToUpdate, title);
3358
+ const title2 = extractTitle(path);
3359
+ propsToUpdate = this.propertyMapper.toNotionProperties(propsToUpdate, title2);
2806
3360
  }
2807
3361
  if (propsToUpdate && Object.keys(propsToUpdate).length > 0) {
2808
3362
  await this.notionClient.updatePageProperties(record.notionPageId, propsToUpdate);
2809
3363
  }
3364
+ const updatedPage = await this.notionClient.getPage(record.notionPageId);
2810
3365
  const hash = computeHash(content);
3366
+ const title = extractTitle(path);
3367
+ const aliases = extractAliases(conversionResult.properties);
2811
3368
  this.stateDb.transaction(() => {
2812
3369
  this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
2813
3370
  this.stateDb.updateStatus(record.id, "synced");
3371
+ this.stateDb.setNotionLastEdited(record.id, updatedPage.last_edited_time);
2814
3372
  this.stateDb.storePreserveMarkers(path, conversionResult.preserveMarkers);
3373
+ this.stateDb.upsertWikilink({
3374
+ obsidianPath: path,
3375
+ notionPageId: record.notionPageId,
3376
+ title,
3377
+ aliases
3378
+ });
2815
3379
  });
2816
3380
  }
2817
3381
  async pushDelete(path) {
@@ -2819,8 +3383,10 @@ var SyncOrchestrator = class {
2819
3383
  if (!record?.notionPageId) return;
2820
3384
  if (this.config.sync.deleteSync) {
2821
3385
  await this.notionClient.archivePage(record.notionPageId);
3386
+ this.stateDb.delete(record.id);
3387
+ } else {
3388
+ this.stateDb.updateStatus(record.id, "pending");
2822
3389
  }
2823
- this.stateDb.delete(record.id);
2824
3390
  }
2825
3391
  async detectRemoteChanges() {
2826
3392
  const changes = [];
@@ -2885,24 +3451,25 @@ var SyncOrchestrator = class {
2885
3451
  const title = this.notionClient.extractTitle(page);
2886
3452
  const safeName = sanitizeFileName(title);
2887
3453
  const parentPath = await this.resolveParentPath(page);
2888
- const children = await this.notionClient.listChildren(pageId, { pageSize: 1 });
2889
- const hasChildPages = children.results.some((b) => "type" in b && b.type === "child_page");
2890
- const markdown = await this.blockConverter.notionBlocksToMarkdown(pageId);
3454
+ const allChildren = await this.notionClient.fetchAllChildren(pageId);
3455
+ const hasChildPages = allChildren.some((b) => "type" in b && b.type === "child_page");
3456
+ const markdown = await this.fetchPageMarkdown(pageId);
2891
3457
  const hasContent = markdown.trim().length > 0;
2892
3458
  let filePath;
2893
3459
  let fileType;
2894
3460
  if (hasChildPages && hasContent) {
2895
3461
  const folderPath = parentPath ? `${parentPath}/${safeName}` : safeName;
2896
- filePath = `${folderPath}/${safeName}.md`;
3462
+ filePath = await this.resolveUniqueFilePath(`${folderPath}/${safeName}.md`);
2897
3463
  fileType = "folder-note";
2898
3464
  await this.vaultFs.ensureFolder(folderPath);
2899
3465
  } else if (hasChildPages && !hasContent) {
2900
3466
  const folderPath = parentPath ? `${parentPath}/${safeName}` : safeName;
2901
- filePath = `${folderPath}/${safeName}.md`;
3467
+ filePath = await this.resolveUniqueFilePath(`${folderPath}/${safeName}.md`);
2902
3468
  fileType = "folder-only";
2903
3469
  await this.vaultFs.ensureFolder(folderPath);
2904
3470
  } else {
2905
- filePath = parentPath ? `${parentPath}/${safeName}.md` : `${safeName}.md`;
3471
+ const basePath = parentPath ? `${parentPath}/${safeName}.md` : `${safeName}.md`;
3472
+ filePath = await this.resolveUniqueFilePath(basePath);
2906
3473
  fileType = "file";
2907
3474
  }
2908
3475
  let properties;
@@ -2914,6 +3481,7 @@ var SyncOrchestrator = class {
2914
3481
  } else {
2915
3482
  properties = this.notionClient.extractProperties(page);
2916
3483
  }
3484
+ properties.title = title;
2917
3485
  let processedMarkdown = markdown;
2918
3486
  if (this.config.conversion.imageDownload === "immediate") {
2919
3487
  const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
@@ -2944,11 +3512,12 @@ var SyncOrchestrator = class {
2944
3512
  status: "synced",
2945
3513
  baseSnapshot: Buffer.from(finalContent, "utf-8")
2946
3514
  });
3515
+ const aliases = extractAliases(properties);
2947
3516
  this.stateDb.upsertWikilink({
2948
3517
  obsidianPath: filePath,
2949
3518
  notionPageId: pageId,
2950
3519
  title,
2951
- aliases: []
3520
+ aliases
2952
3521
  });
2953
3522
  });
2954
3523
  return filePath;
@@ -2957,7 +3526,8 @@ var SyncOrchestrator = class {
2957
3526
  const record = this.stateDb.getByNotionId(change.pageId);
2958
3527
  if (!record) return {};
2959
3528
  const page = await this.notionClient.getPage(change.pageId);
2960
- let markdown = await this.blockConverter.notionBlocksToMarkdown(change.pageId);
3529
+ let markdown = await this.fetchPageMarkdown(change.pageId);
3530
+ const title = this.notionClient.extractTitle(page);
2961
3531
  let properties;
2962
3532
  if (this.isDatabaseMode) {
2963
3533
  await this.ensureDbSchema();
@@ -2967,8 +3537,8 @@ var SyncOrchestrator = class {
2967
3537
  } else {
2968
3538
  properties = this.notionClient.extractProperties(page);
2969
3539
  }
3540
+ properties.title = title;
2970
3541
  if (this.config.conversion.imageDownload === "immediate") {
2971
- const title = this.notionClient.extractTitle(page);
2972
3542
  const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
2973
3543
  markdown = imageResult.content;
2974
3544
  }
@@ -2992,21 +3562,26 @@ var SyncOrchestrator = class {
2992
3562
  const localHash = computeHash(localContent);
2993
3563
  const localModified = localHash !== record.contentHash;
2994
3564
  if (localModified) {
2995
- const conflict = {
2996
- syncRecord: record,
2997
- localChange: {
2998
- path: record.obsidianPath,
2999
- type: "modified",
3000
- currentHash: localHash,
3001
- previousHash: record.contentHash
3002
- },
3003
- remoteChange: change,
3004
- baseContent: record.baseSnapshot?.toString("utf-8") ?? null,
3005
- localContent,
3006
- remoteContent
3007
- };
3008
- this.stateDb.updateStatus(record.id, "conflict");
3009
- return { conflict };
3565
+ const strategy = this.config.sync.conflictStrategy;
3566
+ if (strategy === "remote-first") ; else if (strategy === "local-first") {
3567
+ return { path: record.obsidianPath };
3568
+ } else {
3569
+ const conflict = {
3570
+ syncRecord: record,
3571
+ localChange: {
3572
+ path: record.obsidianPath,
3573
+ type: "modified",
3574
+ currentHash: localHash,
3575
+ previousHash: record.contentHash
3576
+ },
3577
+ remoteChange: change,
3578
+ baseContent: record.baseSnapshot?.toString("utf-8") ?? null,
3579
+ localContent,
3580
+ remoteContent
3581
+ };
3582
+ this.stateDb.updateStatus(record.id, "conflict");
3583
+ return { conflict };
3584
+ }
3010
3585
  }
3011
3586
  await this.vaultFs.writeFile(record.obsidianPath, remoteContent);
3012
3587
  const newHash = computeHash(remoteContent);
@@ -3039,6 +3614,22 @@ var SyncOrchestrator = class {
3039
3614
  async ensureFolderPage(folderPath) {
3040
3615
  const existing = this.stateDb.getByPath(folderPath);
3041
3616
  if (existing?.notionPageId) return;
3617
+ const folderNotePath = `${folderPath}/${folderPath.split("/").pop()}.md`;
3618
+ const folderNoteRecord = this.stateDb.getByPath(folderNotePath);
3619
+ if (folderNoteRecord?.notionParentId) {
3620
+ this.stateDb.upsert({
3621
+ obsidianPath: folderPath,
3622
+ notionPageId: folderNoteRecord.notionParentId,
3623
+ notionParentId: "",
3624
+ contentHash: "",
3625
+ notionLastEdited: folderNoteRecord.notionLastEdited,
3626
+ localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
3627
+ syncDirection: "both",
3628
+ fileType: "folder-note",
3629
+ status: "synced"
3630
+ });
3631
+ return;
3632
+ }
3042
3633
  const parts = folderPath.split("/");
3043
3634
  const folderName = parts[parts.length - 1];
3044
3635
  let parentId = this.config.notion.rootPageId;
@@ -3099,23 +3690,118 @@ var SyncOrchestrator = class {
3099
3690
  try {
3100
3691
  const parentPage = await this.notionClient.getPage(parentId);
3101
3692
  const parentTitle = this.notionClient.extractTitle(parentPage);
3102
- return sanitizeFileName(parentTitle);
3693
+ const grandparentPath = await this.resolveParentPath(parentPage);
3694
+ const safeName = sanitizeFileName(parentTitle);
3695
+ return grandparentPath ? `${grandparentPath}/${safeName}` : safeName;
3103
3696
  } catch {
3104
3697
  return "";
3105
3698
  }
3106
3699
  }
3700
+ async pushCreatePage(parentId, parentType, title, markdownContent, properties) {
3701
+ if (this.config.conversion.preferMarkdownApi !== false) {
3702
+ try {
3703
+ const enhanced = obsidianToNotionEnhanced(markdownContent);
3704
+ return await this.notionClient.createPageWithMarkdown({
3705
+ parentId,
3706
+ parentType,
3707
+ title,
3708
+ markdown: enhanced,
3709
+ properties
3710
+ });
3711
+ } catch {
3712
+ }
3713
+ }
3714
+ const blocks = this.blockConverter.markdownToNotionBlocks(markdownContent);
3715
+ const page = await this.notionClient.createPage({
3716
+ parentId,
3717
+ parentType,
3718
+ title,
3719
+ properties
3720
+ });
3721
+ if (blocks.length > 0) {
3722
+ await this.notionClient.appendChildren(page.id, blocks);
3723
+ }
3724
+ return page;
3725
+ }
3726
+ async pushUpdatePage(pageId, markdownContent) {
3727
+ if (this.config.conversion.preferMarkdownApi !== false) {
3728
+ try {
3729
+ const enhanced = obsidianToNotionEnhanced(markdownContent);
3730
+ await this.notionClient.replacePageMarkdown(pageId, enhanced);
3731
+ return;
3732
+ } catch {
3733
+ }
3734
+ }
3735
+ const blocks = this.blockConverter.markdownToNotionBlocks(markdownContent);
3736
+ const existingBlocks = await this.notionClient.fetchAllChildren(pageId);
3737
+ if (blocks.length > 0) {
3738
+ await this.notionClient.appendChildren(pageId, blocks);
3739
+ }
3740
+ const deleteSema = new Sema(this.config.advanced.concurrency);
3741
+ await Promise.all(
3742
+ existingBlocks.map(async (block) => {
3743
+ await deleteSema.acquire();
3744
+ try {
3745
+ await this.notionClient.deleteBlock(block.id);
3746
+ } finally {
3747
+ deleteSema.release();
3748
+ }
3749
+ })
3750
+ );
3751
+ }
3752
+ async fetchPageMarkdown(pageId) {
3753
+ if (this.config.conversion.preferMarkdownApi !== false) {
3754
+ try {
3755
+ const result = await this.notionClient.getPageMarkdown(pageId);
3756
+ return notionEnhancedToObsidian(result.markdown);
3757
+ } catch {
3758
+ }
3759
+ }
3760
+ return this.blockConverter.notionBlocksToMarkdown(pageId);
3761
+ }
3107
3762
  extractParentId(page) {
3108
3763
  const parent = page.parent;
3109
3764
  if (parent.type === "page_id") return parent.page_id ?? null;
3110
3765
  if (parent.type === "database_id") return parent.database_id ?? null;
3111
3766
  return null;
3112
3767
  }
3768
+ cleanupInterruptedSync() {
3769
+ const pushInProgress = this.stateDb.getMeta("push_in_progress");
3770
+ const pullInProgress = this.stateDb.getMeta("pull_in_progress");
3771
+ if (pushInProgress === "true") {
3772
+ getLogger().warn("[Im-Nobsidian] \uC774\uC804 push\uAC00 \uBE44\uC815\uC0C1 \uC885\uB8CC\uB428 \u2014 \uD50C\uB798\uADF8 \uC815\uB9AC");
3773
+ this.stateDb.setMeta("push_in_progress", "");
3774
+ }
3775
+ if (pullInProgress === "true") {
3776
+ getLogger().warn("[Im-Nobsidian] \uC774\uC804 pull\uC774 \uBE44\uC815\uC0C1 \uC885\uB8CC\uB428 \u2014 \uD50C\uB798\uADF8 \uC815\uB9AC");
3777
+ this.stateDb.setMeta("pull_in_progress", "");
3778
+ }
3779
+ }
3780
+ async resolveUniqueFilePath(basePath) {
3781
+ if (!await this.vaultFs.exists(basePath)) return basePath;
3782
+ const dir = basePath.lastIndexOf("/") >= 0 ? basePath.slice(0, basePath.lastIndexOf("/")) : "";
3783
+ const ext = ".md";
3784
+ const name = basePath.slice(dir ? dir.length + 1 : 0, -ext.length);
3785
+ for (let i = 1; i <= 99; i++) {
3786
+ const candidate = dir ? `${dir}/${name} (${i})${ext}` : `${name} (${i})${ext}`;
3787
+ if (!await this.vaultFs.exists(candidate)) return candidate;
3788
+ }
3789
+ return basePath;
3790
+ }
3113
3791
  };
3114
3792
  function extractTitle(filePath) {
3115
3793
  const parts = filePath.split("/");
3116
3794
  const filename = parts[parts.length - 1] ?? "";
3117
3795
  return filename.replace(/\.md$/, "");
3118
3796
  }
3797
+ function extractAliases(properties) {
3798
+ const raw = properties.aliases ?? properties.alias;
3799
+ if (!raw) return [];
3800
+ if (Array.isArray(raw)) return raw.filter((a) => typeof a === "string");
3801
+ if (typeof raw === "string")
3802
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
3803
+ return [];
3804
+ }
3119
3805
  var NodeVaultFS = class {
3120
3806
  constructor(rootPath, pathConfig) {
3121
3807
  this.rootPath = rootPath;
@@ -3140,6 +3826,10 @@ var NodeVaultFS = class {
3140
3826
  const fullPath = join(this.rootPath, path);
3141
3827
  return readFile(fullPath, "utf-8");
3142
3828
  }
3829
+ async readBinary(path) {
3830
+ const fullPath = join(this.rootPath, path);
3831
+ return readFile(fullPath);
3832
+ }
3143
3833
  async writeFile(path, content) {
3144
3834
  const fullPath = join(this.rootPath, path);
3145
3835
  await mkdir(dirname(fullPath), { recursive: true });