@im-nobsidian/core 0.1.4 → 0.1.7

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
@@ -5,7 +5,7 @@ import Database from 'better-sqlite3';
5
5
  import { randomUUID, createHash } from 'crypto';
6
6
  import { Client } from '@notionhq/client';
7
7
  import { Sema } from 'async-sema';
8
- import matter from 'gray-matter';
8
+ import matter7 from 'gray-matter';
9
9
  import { markdownToBlocks } from '@tryfabric/martian';
10
10
  import { NotionToMarkdown } from 'notion-to-md';
11
11
  import { readFileSync } from 'fs';
@@ -783,15 +783,17 @@ var PropertyMapper = class {
783
783
  };
784
784
 
785
785
  // src/notion/client.ts
786
- var NotionClient = class {
786
+ var NotionClient = class _NotionClient {
787
787
  client;
788
788
  sema;
789
+ token;
789
790
  propertyMapper = new PropertyMapper();
790
791
  constructor(options) {
791
792
  this.client = new Client({
792
793
  auth: options.token,
793
794
  timeoutMs: options.timeoutMs ?? 3e4
794
795
  });
796
+ this.token = options.token;
795
797
  this.sema = new Sema(options.concurrency ?? 3);
796
798
  }
797
799
  getInternalClient() {
@@ -880,11 +882,31 @@ var NotionClient = class {
880
882
  );
881
883
  }
882
884
  // ─── Database / DataSource ───
885
+ async fetchDatabaseLegacy(databaseId) {
886
+ const cleanId = databaseId.replace(/-/g, "");
887
+ const formatted = `${cleanId.slice(0, 8)}-${cleanId.slice(8, 12)}-${cleanId.slice(12, 16)}-${cleanId.slice(16, 20)}-${cleanId.slice(20)}`;
888
+ const resp = await this.withRateLimit(async () => {
889
+ const r = await fetch(`https://api.notion.com/v1/databases/${formatted}`, {
890
+ headers: {
891
+ Authorization: `Bearer ${this.token}`,
892
+ "Notion-Version": "2022-06-28",
893
+ "Content-Type": "application/json"
894
+ }
895
+ });
896
+ if (!r.ok) throw new Error(`Database fetch failed: ${r.status}`);
897
+ return await r.json();
898
+ });
899
+ return resp;
900
+ }
901
+ async getDatabaseTitle(databaseId) {
902
+ const db = await this.fetchDatabaseLegacy(databaseId);
903
+ const titleArr = db.title;
904
+ return titleArr?.[0]?.plain_text ?? "";
905
+ }
883
906
  async getDatabaseSchema(databaseId) {
884
- const db = await this.withRateLimit(
885
- () => this.client.databases.retrieve({ database_id: databaseId })
886
- );
907
+ const db = await this.fetchDatabaseLegacy(databaseId);
887
908
  const properties = db.properties;
909
+ if (!properties) return {};
888
910
  const schema = {};
889
911
  for (const [name, prop] of Object.entries(properties)) {
890
912
  schema[name] = { id: prop.id, type: prop.type };
@@ -892,9 +914,10 @@ var NotionClient = class {
892
914
  return schema;
893
915
  }
894
916
  async queryDatabase(databaseId, options) {
917
+ const dsId = await this.getDataSourceId(databaseId);
895
918
  const response = await this.withRateLimit(
896
919
  () => this.client.dataSources.query({
897
- data_source_id: databaseId,
920
+ data_source_id: dsId,
898
921
  start_cursor: options?.startCursor,
899
922
  page_size: options?.pageSize ?? 100,
900
923
  filter: options?.filter
@@ -929,6 +952,23 @@ var NotionClient = class {
929
952
  async archivePage(pageId) {
930
953
  await this.withRateLimit(() => this.client.pages.update({ page_id: pageId, archived: true }));
931
954
  }
955
+ async getFileBlockUrl(blockId) {
956
+ try {
957
+ const block = await this.withRateLimit(
958
+ () => this.client.blocks.retrieve({ block_id: blockId })
959
+ );
960
+ const b = block;
961
+ const media = b.file ?? b.pdf ?? b.video ?? b.audio ?? b.image;
962
+ return media?.file?.url ?? media?.external?.url ?? null;
963
+ } catch {
964
+ return null;
965
+ }
966
+ }
967
+ async getBlock(blockId) {
968
+ return this.withRateLimit(
969
+ () => this.client.blocks.retrieve({ block_id: blockId })
970
+ );
971
+ }
932
972
  // ─── Block CRUD ───
933
973
  async listChildren(blockId, options) {
934
974
  const response = await this.withRateLimit(
@@ -953,6 +993,35 @@ var NotionClient = class {
953
993
  } while (cursor);
954
994
  return blocks;
955
995
  }
996
+ static CONTAINER_BLOCK_TYPES = /* @__PURE__ */ new Set([
997
+ "column_list",
998
+ "column",
999
+ "callout",
1000
+ "toggle",
1001
+ "quote"
1002
+ ]);
1003
+ async fetchAllChildrenDeep(blockId) {
1004
+ const directChildren = await this.fetchAllChildren(blockId);
1005
+ const allBlocks = [...directChildren];
1006
+ const toExpand = directChildren.filter((b) => {
1007
+ if (!b.has_children) return false;
1008
+ if (b.type === "child_page" || b.type === "child_database") return false;
1009
+ if (_NotionClient.CONTAINER_BLOCK_TYPES.has(b.type)) return true;
1010
+ if (b.type === "synced_block") {
1011
+ const sb = b;
1012
+ return sb.synced_block?.synced_from === null;
1013
+ }
1014
+ return true;
1015
+ });
1016
+ for (const container of toExpand) {
1017
+ try {
1018
+ const nested = await this.fetchAllChildrenDeep(container.id);
1019
+ allBlocks.push(...nested);
1020
+ } catch {
1021
+ }
1022
+ }
1023
+ return allBlocks;
1024
+ }
956
1025
  async appendChildren(blockId, children) {
957
1026
  const BATCH_SIZE = 100;
958
1027
  for (let i = 0; i < children.length; i += BATCH_SIZE) {
@@ -1004,19 +1073,23 @@ var NotionClient = class {
1004
1073
  };
1005
1074
  }
1006
1075
  async getChildPages(parentId) {
1007
- const blocks = await this.fetchAllChildren(parentId);
1076
+ const blocks = await this.fetchAllChildrenDeep(parentId);
1008
1077
  const childPageBlocks = blocks.filter((b) => b.type === "child_page");
1009
1078
  if (childPageBlocks.length === 0) return [];
1010
1079
  const pages = await Promise.all(childPageBlocks.map((b) => this.getPage(b.id)));
1011
1080
  return pages;
1012
1081
  }
1082
+ async getChildDatabaseIds(parentId) {
1083
+ const blocks = await this.fetchAllChildrenDeep(parentId);
1084
+ return blocks.filter((b) => b.type === "child_database").map((b) => b.id);
1085
+ }
1013
1086
  async getChildPagesRecursive(parentId) {
1014
1087
  const all = [];
1015
1088
  let currentLevel = [parentId];
1016
1089
  while (currentLevel.length > 0) {
1017
- const childArrays = await Promise.all(currentLevel.map((id) => this.getChildPages(id)));
1018
1090
  const nextLevel = [];
1019
- for (const children of childArrays) {
1091
+ for (const id of currentLevel) {
1092
+ const children = await this.getChildPages(id);
1020
1093
  for (const child of children) {
1021
1094
  all.push(child);
1022
1095
  nextLevel.push(child.id);
@@ -1026,6 +1099,76 @@ var NotionClient = class {
1026
1099
  }
1027
1100
  return all;
1028
1101
  }
1102
+ // ─── Views API ───
1103
+ async listDatabaseViews(databaseId) {
1104
+ const allViews = [];
1105
+ let cursor;
1106
+ do {
1107
+ const response = await this.withRateLimit(
1108
+ () => this.client.views.list({
1109
+ database_id: databaseId,
1110
+ start_cursor: cursor,
1111
+ page_size: 100
1112
+ })
1113
+ );
1114
+ const viewRefs = response.results;
1115
+ for (const ref of viewRefs) {
1116
+ try {
1117
+ const detail = await this.getView(ref.id);
1118
+ if (detail) allViews.push(detail);
1119
+ } catch {
1120
+ }
1121
+ }
1122
+ cursor = response.next_cursor;
1123
+ } while (cursor);
1124
+ return allViews;
1125
+ }
1126
+ async getView(viewId) {
1127
+ const view = await this.withRateLimit(() => this.client.views.retrieve({ view_id: viewId }));
1128
+ const v = view;
1129
+ if (!v.type) return null;
1130
+ return parseViewResponse(v);
1131
+ }
1132
+ async getDatabaseViewsConfig(databaseId) {
1133
+ const views = await this.listDatabaseViews(databaseId);
1134
+ return {
1135
+ databaseId,
1136
+ lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
1137
+ views
1138
+ };
1139
+ }
1140
+ // ─── Cover / Icon Extraction ───
1141
+ extractCover(page) {
1142
+ const raw = page;
1143
+ if (!raw.cover) return null;
1144
+ if (raw.cover.type === "file" && raw.cover.file) {
1145
+ return {
1146
+ type: "file",
1147
+ url: raw.cover.file.url,
1148
+ expiryTime: raw.cover.file.expiry_time
1149
+ };
1150
+ }
1151
+ if (raw.cover.type === "external" && raw.cover.external) {
1152
+ return { type: "external", url: raw.cover.external.url };
1153
+ }
1154
+ return null;
1155
+ }
1156
+ extractIcon(page) {
1157
+ const raw = page;
1158
+ if (!raw.icon) return null;
1159
+ switch (raw.icon.type) {
1160
+ case "emoji":
1161
+ return { type: "emoji", value: raw.icon.emoji };
1162
+ case "external":
1163
+ return { type: "external", value: raw.icon.external.url };
1164
+ case "file":
1165
+ return { type: "file", value: raw.icon.file.url };
1166
+ case "icon":
1167
+ return { type: "icon", value: raw.icon.icon.name, color: raw.icon.icon.color };
1168
+ default:
1169
+ return null;
1170
+ }
1171
+ }
1029
1172
  // ─── Property Extraction ───
1030
1173
  extractTitle(page) {
1031
1174
  const props = page.properties;
@@ -1041,9 +1184,17 @@ var NotionClient = class {
1041
1184
  return this.propertyMapper.fromNotionProperties(page.properties);
1042
1185
  }
1043
1186
  // ─── Internal ───
1187
+ lastRequestTime = 0;
1188
+ minRequestInterval = 350;
1044
1189
  async withRateLimit(fn) {
1045
1190
  await this.sema.acquire();
1046
1191
  try {
1192
+ const now = Date.now();
1193
+ const elapsed = now - this.lastRequestTime;
1194
+ if (elapsed < this.minRequestInterval) {
1195
+ await sleep(this.minRequestInterval - elapsed);
1196
+ }
1197
+ this.lastRequestTime = Date.now();
1047
1198
  return await this.executeWithRetry(fn);
1048
1199
  } finally {
1049
1200
  this.sema.release();
@@ -1084,6 +1235,60 @@ function extractRetryAfter(error) {
1084
1235
  function sleep(ms) {
1085
1236
  return new Promise((resolve) => setTimeout(resolve, ms));
1086
1237
  }
1238
+ function parseViewResponse(v) {
1239
+ const config = {
1240
+ id: v.id,
1241
+ name: v.name,
1242
+ type: v.type,
1243
+ dataSourceId: v.data_source_id,
1244
+ filter: v.filter,
1245
+ sorts: v.sorts?.map((s) => {
1246
+ if ("property" in s) {
1247
+ return { property: s.property, direction: s.direction };
1248
+ }
1249
+ return { timestamp: s.timestamp, direction: s.direction };
1250
+ }),
1251
+ ...extractViewTypeConfig(v.configuration)
1252
+ };
1253
+ return config;
1254
+ }
1255
+ function extractViewTypeConfig(cfg) {
1256
+ if (!cfg) return {};
1257
+ const result = {};
1258
+ if ("properties" in cfg && cfg.properties) {
1259
+ result.properties = cfg.properties.map((p) => ({
1260
+ propertyId: p.property_id,
1261
+ propertyName: p.property_name,
1262
+ visible: p.visible,
1263
+ width: p.width,
1264
+ wrap: p.wrap
1265
+ }));
1266
+ }
1267
+ if ("group_by" in cfg && cfg.group_by) {
1268
+ const gb = cfg.group_by;
1269
+ result.groupBy = {
1270
+ type: gb.type,
1271
+ propertyId: gb.property_id,
1272
+ propertyName: "property_name" in gb ? gb.property_name : void 0,
1273
+ sort: "sort" in gb && gb.sort ? gb.sort.type : void 0,
1274
+ hideEmptyGroups: "hide_empty_groups" in gb ? gb.hide_empty_groups : void 0
1275
+ };
1276
+ }
1277
+ if ("cover" in cfg && cfg.cover) {
1278
+ result.cover = {
1279
+ type: cfg.cover.type,
1280
+ propertyId: cfg.cover.property_id
1281
+ };
1282
+ }
1283
+ if ("cover_size" in cfg) result.coverSize = cfg.cover_size;
1284
+ if ("cover_aspect" in cfg) result.coverAspect = cfg.cover_aspect;
1285
+ if ("date_property_id" in cfg) {
1286
+ result.datePropertyId = cfg.date_property_id;
1287
+ result.datePropertyName = cfg.date_property_name;
1288
+ }
1289
+ if ("view_range" in cfg) result.viewRange = cfg.view_range;
1290
+ return result;
1291
+ }
1087
1292
 
1088
1293
  // src/notion/block-builder.ts
1089
1294
  var VALID_COLORS = /* @__PURE__ */ new Set([
@@ -1589,7 +1794,7 @@ var FrontmatterExtractor = class {
1589
1794
  name = "FrontmatterExtractor";
1590
1795
  order = 10;
1591
1796
  process(input) {
1592
- const { data, content } = matter(input.content);
1797
+ const { data, content } = matter7(input.content);
1593
1798
  return {
1594
1799
  content: content.trim(),
1595
1800
  metadata: {
@@ -1830,7 +2035,7 @@ var PropertiesTableInjector = class {
1830
2035
  if (Object.keys(filteredProps).length === 0) {
1831
2036
  return { content: input.content, metadata: input.metadata };
1832
2037
  }
1833
- const yamlStr = matter.stringify("", filteredProps).trim();
2038
+ const yamlStr = matter7.stringify("", filteredProps).trim();
1834
2039
  const yamlBody = yamlStr.slice(4, -3).trim();
1835
2040
  const codeBlock = "```yaml\n# im-nobsidian:properties\n" + yamlBody + "\n```";
1836
2041
  const content = codeBlock + "\n\n---\n\n" + input.content;
@@ -1988,7 +2193,7 @@ var FrontmatterGenerator = class {
1988
2193
  return { content: input.content, metadata: input.metadata };
1989
2194
  }
1990
2195
  const normalized = normalizeProperties(properties);
1991
- const content = matter.stringify(input.content, normalized);
2196
+ const content = matter7.stringify(input.content, normalized);
1992
2197
  return {
1993
2198
  content,
1994
2199
  metadata: input.metadata
@@ -2006,6 +2211,9 @@ function normalizeProperties(props) {
2006
2211
  return result;
2007
2212
  }
2008
2213
  function normalizeValue(value) {
2214
+ if (value instanceof Date) {
2215
+ return normalizeDate(value.toISOString());
2216
+ }
2009
2217
  if (typeof value === "string") {
2010
2218
  return normalizeDate(value);
2011
2219
  }
@@ -2073,7 +2281,7 @@ ${yamlContent}---
2073
2281
  `;
2074
2282
  let parsed;
2075
2283
  try {
2076
- const result = matter(fakeDocument);
2284
+ const result = matter7(fakeDocument);
2077
2285
  parsed = result.data;
2078
2286
  } catch {
2079
2287
  return { content: input.content, metadata: input.metadata };
@@ -2282,7 +2490,7 @@ var BlockConverter = class {
2282
2490
  const title = richTextToMarkdown(b.toggle?.rich_text);
2283
2491
  const children = b.has_children ? await n2m.pageToMarkdown(b.id) : [];
2284
2492
  const childMd = (Array.isArray(children) ? n2m.toMarkdownString(children).parent : "") ?? "";
2285
- const indented = childMd.split("\n").map((line) => line ? ` ${line}` : "").join("\n").trimEnd();
2493
+ const indented = childMd.split("\n").map((line) => line.trim() ? ` ${line}` : " ").join("\n").trimEnd();
2286
2494
  return `${TOGGLE_START}
2287
2495
  - ${title}
2288
2496
  ${indented}
@@ -2487,7 +2695,7 @@ ${COLUMN_LIST_END}`;
2487
2695
  const calloutType = EMOJI_TO_TYPE[emoji] ?? "note";
2488
2696
  let result = `> [!${calloutType}] ${text}`;
2489
2697
  if (childMd?.trim()) {
2490
- const lines = childMd.trim().split("\n").map((l) => `> ${l}`);
2698
+ const lines = childMd.trim().split("\n").map((l) => l.trim() ? `> ${l}` : ">");
2491
2699
  result += "\n" + lines.join("\n");
2492
2700
  }
2493
2701
  return result;
@@ -2850,35 +3058,49 @@ var ImageHandler = class {
2850
3058
  attachmentFolder;
2851
3059
  notionClient;
2852
3060
  async downloadImage(url, pageTitle) {
2853
- const response = await fetch(url);
2854
- if (!response.ok) {
2855
- throw new Error(`\uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
3061
+ const maxRetries = 3;
3062
+ let lastError = null;
3063
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
3064
+ try {
3065
+ if (attempt > 0) {
3066
+ await new Promise((r) => setTimeout(r, 1e3 * attempt));
3067
+ getLogger().info(
3068
+ `[Im-Nobsidian] \uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC7AC\uC2DC\uB3C4 (${attempt + 1}/${maxRetries}): ${pageTitle}`
3069
+ );
3070
+ }
3071
+ const response = await fetch(url);
3072
+ if (!response.ok) {
3073
+ throw new Error(`\uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
3074
+ }
3075
+ const contentType = response.headers.get("content-type") ?? "image/png";
3076
+ const extension = IMAGE_EXTENSIONS[contentType] ?? this.guessExtension(url) ?? ".png";
3077
+ const buffer = Buffer.from(await response.arrayBuffer());
3078
+ const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 12);
3079
+ const safeName = pageTitle.replace(/[^a-zA-Z0-9가-힣\s-]/g, "").replace(/\s+/g, "-").slice(0, 50);
3080
+ const fileName = `${safeName}-${hash}${extension}`;
3081
+ const localPath = `${this.attachmentFolder}/${fileName}`;
3082
+ await this.vaultFs.ensureFolder(this.attachmentFolder);
3083
+ await this.vaultFs.writeBinary(localPath, buffer);
3084
+ return { originalUrl: url, localPath, hash, size: buffer.length };
3085
+ } catch (error) {
3086
+ lastError = error instanceof Error ? error : new Error(String(error));
3087
+ }
2856
3088
  }
2857
- const contentType = response.headers.get("content-type") ?? "image/png";
2858
- const extension = IMAGE_EXTENSIONS[contentType] ?? this.guessExtension(url) ?? ".png";
2859
- const buffer = Buffer.from(await response.arrayBuffer());
2860
- const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 12);
2861
- const safeName = pageTitle.replace(/[^a-zA-Z0-9가-힣\s-]/g, "").replace(/\s+/g, "-").slice(0, 50);
2862
- const fileName = `${safeName}-${hash}${extension}`;
2863
- const localPath = `${this.attachmentFolder}/${fileName}`;
2864
- await this.vaultFs.ensureFolder(this.attachmentFolder);
2865
- await this.vaultFs.writeBinary(localPath, buffer);
2866
- return {
2867
- originalUrl: url,
2868
- localPath,
2869
- hash,
2870
- size: buffer.length
2871
- };
3089
+ throw lastError;
2872
3090
  }
2873
3091
  async downloadAllImages(markdown, pageTitle) {
2874
3092
  const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
2875
3093
  const matches = [...markdown.matchAll(imageRegex)];
2876
3094
  const notionMatches = matches.filter((m) => m[2] && this.isNotionImageUrl(m[2]));
2877
- if (notionMatches.length === 0) {
3095
+ const internalImageRegex = /!\[([^\]]*)\]\((file:\/\/%7B.*?%7D%7D)\)/g;
3096
+ const internalMatches = [...markdown.matchAll(internalImageRegex)];
3097
+ if (notionMatches.length === 0 && internalMatches.length === 0) {
2878
3098
  return { content: markdown, downloads: [] };
2879
3099
  }
2880
3100
  const sema = new Sema(3);
2881
- const results = await Promise.allSettled(
3101
+ const downloads = [];
3102
+ let result = markdown;
3103
+ const httpResults = await Promise.allSettled(
2882
3104
  notionMatches.map(async (match) => {
2883
3105
  await sema.acquire();
2884
3106
  try {
@@ -2891,15 +3113,34 @@ var ImageHandler = class {
2891
3113
  }
2892
3114
  })
2893
3115
  );
2894
- const downloads = [];
2895
- let result = markdown;
2896
- for (const settled of results) {
3116
+ for (const settled of httpResults) {
2897
3117
  if (settled.status !== "fulfilled") continue;
2898
3118
  const { match, download } = settled.value;
2899
3119
  downloads.push(download);
2900
3120
  const obsidianEmbed = `![[${download.localPath}${match[1] ? `|${match[1]}` : ""}]]`;
2901
3121
  result = result.replace(match[0], obsidianEmbed);
2902
3122
  }
3123
+ for (const match of internalMatches) {
3124
+ await sema.acquire();
3125
+ try {
3126
+ const fileUrl = match[2];
3127
+ const parsed = this.parseNotionFileUrl(fileUrl);
3128
+ if (!parsed || !this.notionClient) continue;
3129
+ const realUrl = await this.notionClient.getFileBlockUrl(parsed.blockId);
3130
+ if (!realUrl) {
3131
+ getLogger().warn(`[Im-Nobsidian] \uC774\uBBF8\uC9C0 Block URL \uC870\uD68C \uC2E4\uD328: ${parsed.fileName}`);
3132
+ continue;
3133
+ }
3134
+ const download = await this.downloadImage(realUrl, pageTitle);
3135
+ downloads.push(download);
3136
+ const obsidianEmbed = `![[${download.localPath}${match[1] ? `|${match[1]}` : ""}]]`;
3137
+ result = result.replace(match[0], obsidianEmbed);
3138
+ } catch (error) {
3139
+ getLogger().warn(`\uB0B4\uBD80 \uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
3140
+ } finally {
3141
+ sema.release();
3142
+ }
3143
+ }
2903
3144
  return { content: result, downloads };
2904
3145
  }
2905
3146
  async uploadLocalImage(localPath) {
@@ -2950,7 +3191,145 @@ var ImageHandler = class {
2950
3191
  const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
2951
3192
  return (ext && EXTENSION_TO_MIME[ext]) ?? "application/octet-stream";
2952
3193
  }
3194
+ async downloadAllFiles(markdown, pageTitle) {
3195
+ const mdLinkRegex = /\[(?:📎|🎬|🎞|📄|🔊)\s*([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
3196
+ const mdHttpMatches = [...markdown.matchAll(mdLinkRegex)];
3197
+ const notionHttpMatches = mdHttpMatches.filter((m) => m[2] && this.isNotionFileUrl(m[2]));
3198
+ const mdInternalRegex = /\[(?:📎|🎬|🎞|📄|🔊)\s*([^\]]*)\]\((file:\/\/%7B.*?%7D%7D)\)/g;
3199
+ const mdInternalMatches = [...markdown.matchAll(mdInternalRegex)];
3200
+ const xmlHttpRegex = /<(?:file|video|audio|pdf)\s+src="(https?:\/\/[^"]+)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
3201
+ const xmlHttpMatches = [...markdown.matchAll(xmlHttpRegex)];
3202
+ const notionXmlHttpMatches = xmlHttpMatches.filter((m) => m[1] && this.isNotionFileUrl(m[1]));
3203
+ const xmlInternalRegex = /<(?:file|video|audio|pdf)\s+src="(file:\/\/%7B.*?%7D%7D)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
3204
+ const xmlInternalMatches = [...markdown.matchAll(xmlInternalRegex)];
3205
+ const totalMatches = notionHttpMatches.length + mdInternalMatches.length + notionXmlHttpMatches.length + xmlInternalMatches.length;
3206
+ if (totalMatches === 0) {
3207
+ return { content: markdown, downloads: [] };
3208
+ }
3209
+ const sema = new Sema(3);
3210
+ const downloads = [];
3211
+ let result = markdown;
3212
+ for (const match of notionHttpMatches) {
3213
+ await sema.acquire();
3214
+ try {
3215
+ const url = match[2];
3216
+ const caption = match[1]?.trim() || "file";
3217
+ const download = await this.downloadFile(url, pageTitle, caption);
3218
+ downloads.push(download);
3219
+ const obsidianLink = `[[${download.localPath}|${caption}]]`;
3220
+ result = result.replace(match[0], obsidianLink);
3221
+ } catch (error) {
3222
+ getLogger().warn(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
3223
+ } finally {
3224
+ sema.release();
3225
+ }
3226
+ }
3227
+ for (const match of mdInternalMatches) {
3228
+ await sema.acquire();
3229
+ try {
3230
+ const fileUrl = match[2];
3231
+ const parsed = this.parseNotionFileUrl(fileUrl);
3232
+ if (!parsed || !this.notionClient) continue;
3233
+ const blockId = parsed.blockId;
3234
+ const fileName = parsed.fileName;
3235
+ const realUrl = await this.notionClient.getFileBlockUrl(blockId);
3236
+ if (!realUrl) continue;
3237
+ const caption = fileName || match[1]?.trim() || "file";
3238
+ const download = await this.downloadFile(realUrl, pageTitle, caption);
3239
+ downloads.push(download);
3240
+ const obsidianLink = `[[${download.localPath}|${caption}]]`;
3241
+ result = result.replace(match[0], obsidianLink);
3242
+ } catch (error) {
3243
+ getLogger().warn(`\uB0B4\uBD80 \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
3244
+ } finally {
3245
+ sema.release();
3246
+ }
3247
+ }
3248
+ for (const match of notionXmlHttpMatches) {
3249
+ await sema.acquire();
3250
+ try {
3251
+ const url = match[1];
3252
+ const caption = match[2]?.trim() || "file";
3253
+ const download = await this.downloadFile(url, pageTitle, caption);
3254
+ downloads.push(download);
3255
+ const obsidianLink = `[[${download.localPath}|${caption}]]`;
3256
+ result = result.replace(match[0], obsidianLink);
3257
+ } catch (error) {
3258
+ getLogger().warn(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
3259
+ } finally {
3260
+ sema.release();
3261
+ }
3262
+ }
3263
+ for (const match of xmlInternalMatches) {
3264
+ await sema.acquire();
3265
+ try {
3266
+ const fileUrl = match[1];
3267
+ const parsed = this.parseNotionFileUrl(fileUrl);
3268
+ if (!parsed || !this.notionClient) continue;
3269
+ const blockId = parsed.blockId;
3270
+ const fileName = parsed.fileName;
3271
+ const realUrl = await this.notionClient.getFileBlockUrl(blockId);
3272
+ if (!realUrl) continue;
3273
+ const caption = fileName || match[2]?.trim() || "file";
3274
+ const download = await this.downloadFile(realUrl, pageTitle, caption);
3275
+ downloads.push(download);
3276
+ const obsidianLink = `[[${download.localPath}|${caption}]]`;
3277
+ result = result.replace(match[0], obsidianLink);
3278
+ } catch (error) {
3279
+ getLogger().warn(`\uB0B4\uBD80 \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
3280
+ } finally {
3281
+ sema.release();
3282
+ }
3283
+ }
3284
+ return { content: result, downloads };
3285
+ }
3286
+ parseNotionFileUrl(fileUrl) {
3287
+ try {
3288
+ const decoded = decodeURIComponent(fileUrl.replace("file://", ""));
3289
+ const json = JSON.parse(decoded);
3290
+ const blockId = json.permissionRecord?.id ?? "";
3291
+ const source = json.source ?? "";
3292
+ const fileNameMatch = source.match(/attachment:[^:]+:(.+)$/);
3293
+ const fileName = fileNameMatch ? decodeURIComponent(fileNameMatch[1]) : "";
3294
+ return blockId ? { blockId, fileName } : null;
3295
+ } catch {
3296
+ return null;
3297
+ }
3298
+ }
3299
+ async downloadFile(url, _pageTitle, caption) {
3300
+ const maxRetries = 3;
3301
+ let lastError = null;
3302
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
3303
+ try {
3304
+ if (attempt > 0) {
3305
+ await new Promise((r) => setTimeout(r, 1e3 * attempt));
3306
+ getLogger().info(
3307
+ `[Im-Nobsidian] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC7AC\uC2DC\uB3C4 (${attempt + 1}/${maxRetries}): ${caption}`
3308
+ );
3309
+ }
3310
+ const response = await fetch(url);
3311
+ if (!response.ok) {
3312
+ throw new Error(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
3313
+ }
3314
+ const buffer = Buffer.from(await response.arrayBuffer());
3315
+ const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 12);
3316
+ const extension = this.guessFileExtension(url, response.headers.get("content-type"));
3317
+ const safeName = caption.replace(/[^a-zA-Z0-9가-힣\s._-]/g, "").replace(/\s+/g, "-").slice(0, 80);
3318
+ const fileName = `${safeName}-${hash}${extension}`;
3319
+ const localPath = `${this.attachmentFolder}/${fileName}`;
3320
+ await this.vaultFs.ensureFolder(this.attachmentFolder);
3321
+ await this.vaultFs.writeBinary(localPath, buffer);
3322
+ return { originalUrl: url, localPath, hash, size: buffer.length };
3323
+ } catch (error) {
3324
+ lastError = error instanceof Error ? error : new Error(String(error));
3325
+ }
3326
+ }
3327
+ throw lastError;
3328
+ }
2953
3329
  isNotionImageUrl(url) {
3330
+ return this.isNotionFileUrl(url);
3331
+ }
3332
+ isNotionFileUrl(url) {
2954
3333
  return url.includes("secure.notion-static.com") || url.includes("prod-files-secure") || url.includes("s3.us-west-2.amazonaws.com/secure.notion-static.com");
2955
3334
  }
2956
3335
  guessExtension(url) {
@@ -2958,6 +3337,25 @@ var ImageHandler = class {
2958
3337
  const ext = pathname.match(/\.(png|jpg|jpeg|gif|webp|svg|bmp|tiff)(\?|$)/i);
2959
3338
  return ext ? `.${ext[1].toLowerCase()}` : null;
2960
3339
  }
3340
+ guessFileExtension(url, contentType) {
3341
+ try {
3342
+ const pathname = new URL(url).pathname;
3343
+ const extMatch = pathname.match(/\.([a-zA-Z0-9]{1,10})(?:\?|$)/);
3344
+ if (extMatch) return `.${extMatch[1].toLowerCase()}`;
3345
+ } catch {
3346
+ }
3347
+ const mimeMap = {
3348
+ "application/pdf": ".pdf",
3349
+ "application/zip": ".zip",
3350
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
3351
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
3352
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
3353
+ "application/x-hwp": ".hwp",
3354
+ "application/gzip": ".gz"
3355
+ };
3356
+ if (contentType && mimeMap[contentType]) return mimeMap[contentType];
3357
+ return "";
3358
+ }
2961
3359
  };
2962
3360
  var EXTENSION_TO_BLOCK_TYPE = {
2963
3361
  ".png": "image",
@@ -3259,21 +3657,24 @@ function sanitizeFileName(name) {
3259
3657
 
3260
3658
  // src/converter/enhanced-md-converter.ts
3261
3659
  var NOTION_CALLOUT_RE = /^::: callout\n([\s\S]*?)\n:::/gm;
3262
- var NOTION_TOGGLE_RE = /<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gs;
3660
+ var NOTION_CALLOUT_TAG_RE = /<callout[^>]*>\n?([\s\S]*?)<\/callout>/g;
3661
+ var NOTION_TOGGLE_RE = /[\t ]*<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gs;
3263
3662
  var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
3264
3663
  var NOTION_USER_MENTION_RE = /<mention-user id="[^"]*">([^<]*)<\/mention-user>/g;
3265
3664
  var NOTION_DATE_MENTION_RE = /<mention-date start="([^"]*)"(?: end="([^"]*)")?[^>]*\/>/g;
3266
3665
  var NOTION_UNKNOWN_RE = /<unknown id="([^"]*)"([^>]*)\/>/g;
3267
- var NOTION_AUDIO_RE = /<audio src="([^"]*)">([\s\S]*?)<\/audio>/g;
3268
- var NOTION_VIDEO_RE = /<video src="([^"]*)">([\s\S]*?)<\/video>/g;
3269
- var NOTION_PDF_RE = /<pdf src="([^"]*)">([\s\S]*?)<\/pdf>/g;
3270
- var NOTION_FILE_RE = /<file src="([^"]*)">([\s\S]*?)<\/file>/g;
3666
+ var NOTION_AUDIO_RE = /[\t ]*<audio src="([^"]*)">([\s\S]*?)<\/audio>/g;
3667
+ var NOTION_VIDEO_RE = /[\t ]*<video src="([^"]*)">([\s\S]*?)<\/video>/g;
3668
+ var NOTION_PDF_RE = /[\t ]*<pdf src="([^"]*)">([\s\S]*?)<\/pdf>/g;
3669
+ var NOTION_FILE_RE = /[\t ]*<file src="([^"]*)">([\s\S]*?)<\/file>/g;
3271
3670
  var NOTION_TAB_RE = /<tab title="([^"]*)">([\s\S]*?)<\/tab>/g;
3272
3671
  var NOTION_UNDERLINE_RE = /<span underline="true">([\s\S]*?)<\/span>/g;
3273
3672
  var TOGGLE_START2 = "%%im-nobsidian:toggle:start%%";
3274
3673
  var TOGGLE_END2 = "%%im-nobsidian:toggle:end%%";
3275
3674
  function notionEnhancedToObsidian(enhanced) {
3276
3675
  let result = enhanced;
3676
+ result = convertSyncedBlockRef(result);
3677
+ result = normalizeCodeBlockToggles(result);
3277
3678
  result = convertToggles(result);
3278
3679
  result = convertCallouts(result);
3279
3680
  result = convertPageMentions(result);
@@ -3287,8 +3688,13 @@ function notionEnhancedToObsidian(enhanced) {
3287
3688
  result = convertNotionTables(result);
3288
3689
  result = convertColorSpans(result);
3289
3690
  result = convertUnderlineSpans(result);
3691
+ result = convertColumnBlocks(result);
3692
+ result = convertDatabaseBlocks(result);
3693
+ result = cleanInlineColorAttrs(result);
3290
3694
  result = removeEmptyBlocks(result);
3291
3695
  result = unescapePipes(result);
3696
+ result = unescapeNotionChars(result);
3697
+ result = ensureCalloutContinuity(result);
3292
3698
  return result;
3293
3699
  }
3294
3700
  function obsidianToNotionEnhanced(obsidian) {
@@ -3302,39 +3708,62 @@ function obsidianToNotionEnhanced(obsidian) {
3302
3708
  result = restoreUnderlineSpans(result);
3303
3709
  return result;
3304
3710
  }
3711
+ function normalizeCodeBlockToggles(content) {
3712
+ return content.replace(
3713
+ /^- (.+)\n\t```(\w*)\n([\s\S]*?)\n\t```$/gm,
3714
+ (_match, title, lang, body) => {
3715
+ const langTag = lang ? lang : "";
3716
+ return `<details>
3717
+ <summary>${title.trim()}</summary>
3718
+ \`\`\`${langTag}
3719
+ ${body}
3720
+ \`\`\`
3721
+ </details>`;
3722
+ }
3723
+ );
3724
+ }
3305
3725
  function convertToggles(content) {
3306
3726
  function replaceToggle(match) {
3307
- const m = /<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/s.exec(match);
3727
+ const m = /[\t ]*<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/s.exec(match);
3308
3728
  if (!m) return match;
3309
3729
  const title = m[1].trim();
3310
3730
  let body = m[2].trim();
3311
3731
  body = convertToggles(body);
3312
- const indented = body.split("\n").map((line) => line ? ` ${line}` : "").join("\n").trimEnd();
3313
- return `${TOGGLE_START2}
3314
- - ${title}
3315
- ${indented}
3316
- ${TOGGLE_END2}`;
3732
+ const calloutBody = body.split("\n").map((line) => line ? `> ${line}` : ">").join("\n");
3733
+ return `> [!toggle]- ${title}
3734
+ ${calloutBody}`;
3317
3735
  }
3318
3736
  return content.replace(NOTION_TOGGLE_RE, replaceToggle);
3319
3737
  }
3738
+ function calloutBodyToObsidian(body) {
3739
+ const lines = body.split("\n").map((l) => l.replace(/^\t/, "")).filter((l, i, arr) => !(i === 0 && l === "") && !(i === arr.length - 1 && l === ""));
3740
+ const firstLine = lines[0] ?? "";
3741
+ const emojiMatch = /^([\p{Emoji}️‍]+)\s*(.*)/u.exec(firstLine);
3742
+ const type = emojiMatch ? emojiToCalloutType(emojiMatch[1]) : "note";
3743
+ const title = emojiMatch ? emojiMatch[2] : firstLine;
3744
+ const rest = lines.slice(1).join("\n").trim();
3745
+ const calloutTitle = title ? `> [!${type}] ${title}` : `> [!${type}]`;
3746
+ const calloutBody = rest ? "\n" + rest.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n") : "";
3747
+ return calloutTitle + calloutBody;
3748
+ }
3320
3749
  function convertCallouts(content) {
3321
- return content.replace(NOTION_CALLOUT_RE, (_match, body) => {
3322
- const lines = body.trim().split("\n");
3323
- const firstLine = lines[0] ?? "";
3324
- const emojiMatch = /^([\p{Emoji}️‍]+)\s*(.*)/u.exec(firstLine);
3325
- const type = emojiMatch ? emojiToCalloutType(emojiMatch[1]) : "note";
3326
- const title = emojiMatch ? emojiMatch[2] : firstLine;
3327
- const rest = lines.slice(1).join("\n").trim();
3328
- const calloutTitle = title ? `> [!${type}] ${title}` : `> [!${type}]`;
3329
- const calloutBody = rest ? "\n" + rest.split("\n").map((line) => `> ${line}`).join("\n") : "";
3330
- return calloutTitle + calloutBody;
3331
- });
3750
+ let result = content.replace(
3751
+ NOTION_CALLOUT_TAG_RE,
3752
+ (_match, body) => calloutBodyToObsidian(body)
3753
+ );
3754
+ result = result.replace(NOTION_CALLOUT_RE, (_match, body) => calloutBodyToObsidian(body));
3755
+ return result;
3332
3756
  }
3333
3757
  function convertPageMentions(content) {
3334
- return content.replace(NOTION_PAGE_MENTION_RE, (_match, _id, text) => {
3758
+ let result = content.replace(NOTION_PAGE_MENTION_RE, (_match, _id, text) => {
3335
3759
  const cleaned = text.trim();
3336
3760
  return `[[${cleaned}]]`;
3337
3761
  });
3762
+ result = result.replace(
3763
+ /<mention-page\s+url="https?:\/\/(?:www\.)?notion\.so\/([a-f0-9]{32})"[^>]*\/>/g,
3764
+ (_match, id) => `[[notion:${id}]]`
3765
+ );
3766
+ return result;
3338
3767
  }
3339
3768
  function convertUserMentions(content) {
3340
3769
  return content.replace(NOTION_USER_MENTION_RE, (_match, name) => `@${name}`);
@@ -3352,6 +3781,20 @@ function preserveUnknownBlocks(content) {
3352
3781
  return `%%im-nobsidian:unknown:id=${id}&type=${blockType}%%`;
3353
3782
  });
3354
3783
  }
3784
+ function convertSyncedBlockRef(content) {
3785
+ let result = content.replace(
3786
+ /<synced_block_reference[^>]*>[\s\S]*?<\/synced_block_reference>/g,
3787
+ (match) => {
3788
+ const inner = match.replace(/<synced_block_reference[^>]*>\n?/, "").replace(/<\/synced_block_reference>/, "").split("\n").map((l) => l.replace(/^\t/, "")).join("\n").trim();
3789
+ return inner;
3790
+ }
3791
+ );
3792
+ result = result.replace(
3793
+ /<synced_block[^>]*>\n?([\s\S]*?)<\/synced_block>/g,
3794
+ (_match, inner) => inner.trim()
3795
+ );
3796
+ return result;
3797
+ }
3355
3798
  function convertMediaTags(content) {
3356
3799
  let result = content;
3357
3800
  result = result.replace(NOTION_AUDIO_RE, (_match, src, caption) => {
@@ -3386,10 +3829,25 @@ function convertUnderlineSpans(content) {
3386
3829
  });
3387
3830
  }
3388
3831
  function convertTogglesToHtml(content) {
3389
- const startRe = new RegExp(escapeRegex3(TOGGLE_START2), "g");
3390
- const endRe = new RegExp(escapeRegex3(TOGGLE_END2), "g");
3832
+ const calloutToggleRe = /^> \[!toggle\]-\s*(.+)\n((?:>.*\n?)*)/gm;
3391
3833
  let result = content;
3834
+ let prev = "";
3392
3835
  let safety = 0;
3836
+ while (result !== prev && safety++ < 100) {
3837
+ prev = result;
3838
+ result = result.replace(calloutToggleRe, (_match, title, body) => {
3839
+ const bodyText = body.split("\n").map((line) => line.replace(/^>\s?/, "")).join("\n").trim();
3840
+ return `<details>
3841
+ <summary>${title.trim()}</summary>
3842
+
3843
+ ${bodyText}
3844
+
3845
+ </details>`;
3846
+ });
3847
+ }
3848
+ const startRe = new RegExp(escapeRegex3(TOGGLE_START2), "g");
3849
+ const endRe = new RegExp(escapeRegex3(TOGGLE_END2), "g");
3850
+ safety = 0;
3393
3851
  while (result.includes(TOGGLE_START2) && safety++ < 100) {
3394
3852
  const startIdx = result.indexOf(TOGGLE_START2);
3395
3853
  const endIdx = findMatchingEnd(result, startIdx + TOGGLE_START2.length);
@@ -3485,7 +3943,7 @@ function escapeRegex3(str) {
3485
3943
  }
3486
3944
  var NOTION_PAGE_LINK_RE = /<page url="[^"]*">([\s\S]*?)<\/page>/g;
3487
3945
  var NOTION_COLOR_SPAN_RE = /<span color="([^"]+)">([\s\S]*?)<\/span>/g;
3488
- var NOTION_EMPTY_BLOCK_RE = /^<empty-block\/>\n?/gm;
3946
+ var NOTION_EMPTY_BLOCK_RE = /^(?:>[\t ]*)*[\t ]*<empty-block\/>\n?/gm;
3489
3947
  function convertPageLinks(content) {
3490
3948
  return content.replace(NOTION_PAGE_LINK_RE, (_match, text) => {
3491
3949
  const cleaned = text.replace(/\*\*/g, "").trim();
@@ -3498,6 +3956,20 @@ function convertColorSpans(content) {
3498
3956
  (_match, color, text) => `%%im-nobsidian:color:${color}%%${text}%%/color%%`
3499
3957
  );
3500
3958
  }
3959
+ function convertDatabaseBlocks(content) {
3960
+ return content.replace(
3961
+ /<database[^>]*>([\s\S]*?)<\/database>/g,
3962
+ (_match, title) => `**${title.trim()}** *(Notion DB)*`
3963
+ );
3964
+ }
3965
+ function cleanInlineColorAttrs(content) {
3966
+ return content.replace(/\s*\{color="[^"]*"\}/g, "");
3967
+ }
3968
+ function convertColumnBlocks(content) {
3969
+ return content.replace(/<columns>\n?([\s\S]*?)<\/columns>/g, (_match, inner) => {
3970
+ return inner.replace(/\t?<column>\n?/g, "").replace(/\t?<\/column>\n?/g, "").trim();
3971
+ });
3972
+ }
3501
3973
  function removeEmptyBlocks(content) {
3502
3974
  return content.replace(NOTION_EMPTY_BLOCK_RE, "\n");
3503
3975
  }
@@ -3510,11 +3982,21 @@ ${eq.trim()}
3510
3982
  $$`;
3511
3983
  });
3512
3984
  result = result.replace(NOTION_INLINE_MATH_RE, (_match, eq) => `$${eq}$`);
3985
+ result = result.replace(/\\\$([^$]+?)\\\$/g, (_match, inner) => {
3986
+ const unescaped = inner.replace(/\\\^/g, "^").replace(/\\~/g, "~");
3987
+ return `$${unescaped}$`;
3988
+ });
3513
3989
  return result;
3514
3990
  }
3991
+ function unescapeNotionChars(content) {
3992
+ return content.replace(/\\~/g, "~").replace(/\\\^/g, "^");
3993
+ }
3515
3994
  var NOTION_TABLE_RE = /<table[^>]*>([\s\S]*?)<\/table>/g;
3516
3995
  var TABLE_ROW_RE = /<tr>([\s\S]*?)<\/tr>/g;
3517
3996
  var TABLE_CELL_RE = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/g;
3997
+ function isAlignmentRow(cells) {
3998
+ return cells.every((c) => /^:?-{2,}:?$/.test(c.trim()));
3999
+ }
3518
4000
  function convertNotionTables(content) {
3519
4001
  return content.replace(NOTION_TABLE_RE, (_match, tableBody) => {
3520
4002
  const rows = [];
@@ -3527,7 +4009,9 @@ function convertNotionTables(content) {
3527
4009
  while ((cellMatch = cellRe.exec(rowMatch[1])) !== null) {
3528
4010
  cells.push(cellMatch[1].trim());
3529
4011
  }
3530
- rows.push(cells);
4012
+ if (!isAlignmentRow(cells)) {
4013
+ rows.push(cells);
4014
+ }
3531
4015
  }
3532
4016
  if (rows.length === 0) return _match;
3533
4017
  const colCount = Math.max(...rows.map((r) => r.length));
@@ -3607,6 +4091,23 @@ function unescapePipes(content) {
3607
4091
  }
3608
4092
  return result.join("\n");
3609
4093
  }
4094
+ function ensureCalloutContinuity(content) {
4095
+ const lines = content.split("\n");
4096
+ const result = [];
4097
+ for (let i = 0; i < lines.length; i++) {
4098
+ const line = lines[i];
4099
+ if (line.trim() === "" && i > 0 && i < lines.length - 1) {
4100
+ const prev = result[result.length - 1] ?? "";
4101
+ const next = lines[i + 1] ?? "";
4102
+ if (/^>/.test(prev) && /^>/.test(next) && !/^> \[!/.test(next)) {
4103
+ result.push(">");
4104
+ continue;
4105
+ }
4106
+ }
4107
+ result.push(line);
4108
+ }
4109
+ return result.join("\n");
4110
+ }
3610
4111
  var DatabaseSyncer = class {
3611
4112
  constructor(config, stateDb, notionClient, vaultFs, pipeline, imageHandler) {
3612
4113
  this.config = config;
@@ -3681,6 +4182,7 @@ var DatabaseSyncer = class {
3681
4182
  const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
3682
4183
  this.propertyMapper.loadSchema(schema);
3683
4184
  await this.vaultFs.ensureFolder(dbConfig.localFolder);
4185
+ await this.pullDatabaseViews(dbConfig);
3684
4186
  const pages = await this.notionClient.queryAllDatabasePages(
3685
4187
  dbConfig.databaseId,
3686
4188
  dbConfig.pullFilter
@@ -3710,6 +4212,26 @@ var DatabaseSyncer = class {
3710
4212
  getLogger().info(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
3711
4213
  return { created, updated, failed };
3712
4214
  }
4215
+ async pullDatabaseViews(dbConfig) {
4216
+ try {
4217
+ const viewsConfig = await this.notionClient.getDatabaseViewsConfig(dbConfig.databaseId);
4218
+ const configPath = ".im-nobsidian/db-views.json";
4219
+ await this.vaultFs.ensureFolder(".im-nobsidian");
4220
+ let allViewsConfigs = {};
4221
+ try {
4222
+ const existing = await this.vaultFs.readFile(configPath);
4223
+ allViewsConfigs = JSON.parse(existing);
4224
+ } catch {
4225
+ }
4226
+ allViewsConfigs[dbConfig.databaseId] = viewsConfig;
4227
+ await this.vaultFs.writeFile(configPath, JSON.stringify(allViewsConfigs, null, 2));
4228
+ getLogger().info(
4229
+ `[DB Sync] \uBDF0 \uC124\uC815 ${viewsConfig.views.length}\uAC1C \uC800\uC7A5: ${dbConfig.databaseId}`
4230
+ );
4231
+ } catch (error) {
4232
+ getLogger().warn(`[DB Sync] \uBDF0 \uC124\uC815 Pull \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
4233
+ }
4234
+ }
3713
4235
  async pullDatabasePage(page, dbConfig) {
3714
4236
  const title = this.notionClient.extractTitle(page);
3715
4237
  const safeName = sanitizeFileName(title);
@@ -3717,6 +4239,27 @@ var DatabaseSyncer = class {
3717
4239
  page.properties
3718
4240
  );
3719
4241
  properties.title = title;
4242
+ const cover = this.notionClient.extractCover(page);
4243
+ const icon = this.notionClient.extractIcon(page);
4244
+ if (cover) {
4245
+ try {
4246
+ const coverResult = await this.imageHandler.downloadAllImages(
4247
+ `![cover](${cover.url})`,
4248
+ `${safeName}-cover`
4249
+ );
4250
+ const localUrlMatch = coverResult.content.match(/!\[cover\]\((.+?)\)/);
4251
+ if (localUrlMatch?.[1]) {
4252
+ properties.cover = localUrlMatch[1];
4253
+ } else {
4254
+ properties.cover = cover.url;
4255
+ }
4256
+ } catch {
4257
+ properties.cover = cover.url;
4258
+ }
4259
+ }
4260
+ if (icon) {
4261
+ properties.icon = icon.value;
4262
+ }
3720
4263
  let markdown = "";
3721
4264
  try {
3722
4265
  const mdResult = await this.notionClient.getPageMarkdown(page.id);
@@ -3727,6 +4270,10 @@ var DatabaseSyncer = class {
3727
4270
  const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
3728
4271
  markdown = imageResult.content;
3729
4272
  }
4273
+ if (markdown) {
4274
+ const fileResult = await this.imageHandler.downloadAllFiles(markdown, title);
4275
+ markdown = fileResult.content;
4276
+ }
3730
4277
  const existingRecord = this.stateDb.getByNotionId(page.id);
3731
4278
  const filePath = existingRecord?.obsidianPath ?? `${dbConfig.localFolder}/${safeName}.md`;
3732
4279
  const finalContent = this.pipeline.convertToMarkdown(
@@ -3779,7 +4326,7 @@ var DatabaseSyncer = class {
3779
4326
  const hash = computeHash(content);
3780
4327
  const record = this.stateDb.getByPath(file.path);
3781
4328
  if (record?.contentHash === hash) continue;
3782
- const parsed = matter(content);
4329
+ const parsed = matter7(content);
3783
4330
  const frontmatter = parsed.data;
3784
4331
  const body = parsed.content;
3785
4332
  const title = frontmatter.title ?? extractTitleFromPath(file.path);
@@ -3896,6 +4443,8 @@ var SyncOrchestrator = class {
3896
4443
  databaseSyncer;
3897
4444
  propertyMapper;
3898
4445
  dbSchemaLoaded = false;
4446
+ _pullImageCount = 0;
4447
+ _pullFileCount = 0;
3899
4448
  async push(options) {
3900
4449
  const startTime = Date.now();
3901
4450
  if (this.config.sync.direction === "pull") {
@@ -3906,14 +4455,25 @@ var SyncOrchestrator = class {
3906
4455
  const changes = this.changeDetector.detectLocalChanges(files);
3907
4456
  const conflictPaths = new Set(this.stateDb.getByStatus("conflict").map((r) => r.obsidianPath));
3908
4457
  const eligible = options?.force ? changes : changes.filter((c) => !conflictPaths.has(c.path));
3909
- const filtered = options?.paths ? eligible.filter((c) => options.paths.some((p) => c.path.startsWith(p))) : eligible;
3910
- if (filtered.length === 0) {
4458
+ const dbFolders = (this.config.notion.databases ?? []).map(
4459
+ (d) => d.localFolder.endsWith("/") ? d.localFolder : d.localFolder + "/"
4460
+ );
4461
+ const isDbPath = (path) => dbFolders.some((prefix) => path.startsWith(prefix));
4462
+ const filtered = (options?.paths ? eligible.filter((c) => options.paths.some((p) => c.path.startsWith(p))) : eligible).filter((c) => !isDbPath(c.path));
4463
+ const hasDbConfigs = (this.config.notion.databases?.length ?? 0) > 0;
4464
+ if (filtered.length === 0 && !hasDbConfigs) {
3911
4465
  return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
3912
4466
  }
3913
4467
  if (options?.dryRun) {
3914
4468
  const dryCreated = filtered.filter((c) => c.type === "created").length;
3915
4469
  const dryUpdated = filtered.filter((c) => c.type === "modified" || c.type === "moved").length;
3916
4470
  const dryDeleted = filtered.filter((c) => c.type === "deleted").length;
4471
+ let dryProgress = 0;
4472
+ const dryTotal = filtered.length;
4473
+ for (const change of filtered) {
4474
+ const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
4475
+ options?.onProgress?.(++dryProgress, dryTotal, { path: change.path, operation: op });
4476
+ }
3917
4477
  return {
3918
4478
  created: dryCreated,
3919
4479
  updated: dryUpdated,
@@ -3948,7 +4508,8 @@ var SyncOrchestrator = class {
3948
4508
  const tasks = filtered.map((change) => async () => {
3949
4509
  await sema.acquire();
3950
4510
  try {
3951
- options?.onProgress?.(++completed, total, change.path);
4511
+ const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
4512
+ options?.onProgress?.(++completed, total, { path: change.path, operation: op });
3952
4513
  switch (change.type) {
3953
4514
  case "created":
3954
4515
  await this.pushCreate(change.path);
@@ -3978,6 +4539,46 @@ var SyncOrchestrator = class {
3978
4539
  }
3979
4540
  });
3980
4541
  await Promise.all(tasks.map((t) => t()));
4542
+ if (failed.length > 0) {
4543
+ const retryTargets = failed.splice(0, failed.length);
4544
+ getLogger().info(`[Im-Nobsidian] Push ${retryTargets.length}\uAC74 \uC7AC\uC2DC\uB3C4 (2\uCD08 \uD6C4)`);
4545
+ await new Promise((r) => setTimeout(r, 2e3));
4546
+ for (const target of retryTargets) {
4547
+ const change = filtered.find((c) => c.path === target.path);
4548
+ if (!change) {
4549
+ failed.push(target);
4550
+ continue;
4551
+ }
4552
+ try {
4553
+ switch (change.type) {
4554
+ case "created":
4555
+ await this.pushCreate(change.path);
4556
+ created++;
4557
+ break;
4558
+ case "modified":
4559
+ await this.pushUpdate(change.path);
4560
+ updated++;
4561
+ break;
4562
+ case "moved":
4563
+ await this.pushMove(change.path);
4564
+ updated++;
4565
+ break;
4566
+ case "deleted":
4567
+ await this.pushDelete(change.path);
4568
+ deleted++;
4569
+ break;
4570
+ }
4571
+ getLogger().info(`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC131\uACF5: ${change.path}`);
4572
+ } catch (error) {
4573
+ failed.push({
4574
+ path: change.path,
4575
+ operation: target.operation,
4576
+ error: error instanceof Error ? error.message : String(error)
4577
+ });
4578
+ getLogger().warn(`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC2E4\uD328: ${change.path}`);
4579
+ }
4580
+ }
4581
+ }
3981
4582
  if (this.config.sync.syncFiles !== false) {
3982
4583
  try {
3983
4584
  const fileResults = await this.fileHandler.pushAllFiles();
@@ -4012,7 +4613,10 @@ var SyncOrchestrator = class {
4012
4613
  conflicts: [],
4013
4614
  writtenPaths: [],
4014
4615
  failed: [],
4015
- duration: Date.now() - startTime
4616
+ duration: Date.now() - startTime,
4617
+ imageCount: 0,
4618
+ fileCount: 0,
4619
+ linkCount: 0
4016
4620
  };
4017
4621
  if (this.config.sync.direction === "push") {
4018
4622
  return emptyResult;
@@ -4021,6 +4625,8 @@ var SyncOrchestrator = class {
4021
4625
  let created = 0;
4022
4626
  let updated = 0;
4023
4627
  let deleted = 0;
4628
+ this._pullImageCount = 0;
4629
+ this._pullFileCount = 0;
4024
4630
  const conflicts = [];
4025
4631
  const writtenPaths = [];
4026
4632
  const failed = [];
@@ -4036,6 +4642,14 @@ var SyncOrchestrator = class {
4036
4642
  const dryCreated = filtered.filter((c) => c.type === "created").length;
4037
4643
  const dryUpdated = filtered.filter((c) => c.type === "modified").length;
4038
4644
  const dryDeleted = filtered.filter((c) => c.type === "deleted").length;
4645
+ let dryProgress = 0;
4646
+ const dryTotal = filtered.length;
4647
+ for (const change of filtered) {
4648
+ const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
4649
+ const record = this.stateDb.getByNotionId(change.pageId);
4650
+ const displayPath = record?.obsidianPath ?? change.pageId;
4651
+ options?.onProgress?.(++dryProgress, dryTotal, { path: displayPath, operation: op });
4652
+ }
4039
4653
  return {
4040
4654
  created: dryCreated,
4041
4655
  updated: dryUpdated,
@@ -4043,7 +4657,10 @@ var SyncOrchestrator = class {
4043
4657
  conflicts: [],
4044
4658
  writtenPaths: [],
4045
4659
  failed: [],
4046
- duration: Date.now() - startTime
4660
+ duration: Date.now() - startTime,
4661
+ imageCount: 0,
4662
+ fileCount: 0,
4663
+ linkCount: 0
4047
4664
  };
4048
4665
  }
4049
4666
  this.stateDb.setMeta("pull_in_progress", "true");
@@ -4053,7 +4670,10 @@ var SyncOrchestrator = class {
4053
4670
  const tasks = filtered.map((change) => async () => {
4054
4671
  await sema.acquire();
4055
4672
  try {
4056
- options?.onProgress?.(++pullCompleted, pullTotal, change.pageId);
4673
+ const record = this.stateDb.getByNotionId(change.pageId);
4674
+ const displayPath = record?.obsidianPath ?? change.pageId;
4675
+ const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
4676
+ options?.onProgress?.(++pullCompleted, pullTotal, { path: displayPath, operation: op });
4057
4677
  switch (change.type) {
4058
4678
  case "created": {
4059
4679
  const path = await this.pullCreate(change.pageId);
@@ -4091,6 +4711,54 @@ var SyncOrchestrator = class {
4091
4711
  }
4092
4712
  });
4093
4713
  await Promise.all(tasks.map((t) => t()));
4714
+ if (failed.length > 0) {
4715
+ const retryTargets = failed.splice(0, failed.length);
4716
+ getLogger().info(`[Im-Nobsidian] Pull ${retryTargets.length}\uAC74 \uC7AC\uC2DC\uB3C4 (2\uCD08 \uD6C4)`);
4717
+ await new Promise((r) => setTimeout(r, 2e3));
4718
+ for (const target of retryTargets) {
4719
+ const change = filtered.find((c) => {
4720
+ const record = this.stateDb.getByNotionId(c.pageId);
4721
+ return (record?.obsidianPath ?? c.pageId) === target.path;
4722
+ });
4723
+ if (!change) {
4724
+ failed.push(target);
4725
+ continue;
4726
+ }
4727
+ try {
4728
+ switch (change.type) {
4729
+ case "created": {
4730
+ const path = await this.pullCreate(change.pageId);
4731
+ writtenPaths.push(path);
4732
+ created++;
4733
+ break;
4734
+ }
4735
+ case "modified": {
4736
+ const result = await this.pullUpdate(change);
4737
+ if (result.conflict) {
4738
+ conflicts.push(result.conflict);
4739
+ } else if (result.path) {
4740
+ writtenPaths.push(result.path);
4741
+ updated++;
4742
+ }
4743
+ break;
4744
+ }
4745
+ case "deleted": {
4746
+ const path = await this.pullDelete(change.pageId);
4747
+ if (path) deleted++;
4748
+ break;
4749
+ }
4750
+ }
4751
+ getLogger().info(`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC131\uACF5: ${target.path}`);
4752
+ } catch (error) {
4753
+ failed.push({
4754
+ path: target.path,
4755
+ operation: target.operation,
4756
+ error: error instanceof Error ? error.message : String(error)
4757
+ });
4758
+ getLogger().warn(`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC2E4\uD328: ${target.path}`);
4759
+ }
4760
+ }
4761
+ }
4094
4762
  if ((this.config.notion.databases?.length ?? 0) > 0) {
4095
4763
  try {
4096
4764
  const dbResult = await this.databaseSyncer.pullAll();
@@ -4105,6 +4773,44 @@ var SyncOrchestrator = class {
4105
4773
  getLogger().warn("[Im-Nobsidian] DB Pull \uC911 \uC624\uB958:", error);
4106
4774
  }
4107
4775
  }
4776
+ if (!this.isDatabaseMode) {
4777
+ try {
4778
+ const discoveredDbs = await this.discoverChildDatabases();
4779
+ const configuredIds = new Set(
4780
+ (this.config.notion.databases ?? []).map((d) => d.databaseId.replace(/-/g, ""))
4781
+ );
4782
+ const newDbs = discoveredDbs.filter((db) => !configuredIds.has(db.dbId.replace(/-/g, "")));
4783
+ for (const { dbId, parentPageId } of newDbs) {
4784
+ try {
4785
+ const dbTitle = await this.notionClient.getDatabaseTitle(dbId);
4786
+ const safeName = (dbTitle || dbId.slice(0, 8)).replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-");
4787
+ const parentEntry = parentPageId ? this.stateDb.getByNotionId(parentPageId) : null;
4788
+ let parentFolder = "databases";
4789
+ if (parentEntry?.obsidianPath) {
4790
+ const parts = parentEntry.obsidianPath.split("/");
4791
+ parts.pop();
4792
+ parentFolder = parts.length > 0 ? parts.join("/") : "databases";
4793
+ }
4794
+ const folderName = `${parentFolder}/${safeName}`;
4795
+ const dbConfig = {
4796
+ databaseId: dbId,
4797
+ localFolder: folderName,
4798
+ titleProperty: "Name"
4799
+ };
4800
+ const dbResult = await this.databaseSyncer.pullDatabase(dbConfig);
4801
+ created += dbResult.created;
4802
+ updated += dbResult.updated;
4803
+ failed.push(...dbResult.failed);
4804
+ } catch (error) {
4805
+ getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
4806
+ }
4807
+ }
4808
+ } catch (error) {
4809
+ getLogger().warn("[Im-Nobsidian] child_database \uC790\uB3D9 \uBC1C\uACAC \uC2E4\uD328:", error);
4810
+ }
4811
+ }
4812
+ const allSyncedMdPaths = this.stateDb.getAll().filter((r) => r.obsidianPath?.endsWith(".md")).map((r) => r.obsidianPath);
4813
+ const linkCount = await this.resolveNotionLinks(allSyncedMdPaths);
4108
4814
  this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
4109
4815
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
4110
4816
  this.stateDb.setMeta("pull_in_progress", "");
@@ -4115,7 +4821,10 @@ var SyncOrchestrator = class {
4115
4821
  conflicts,
4116
4822
  writtenPaths,
4117
4823
  failed,
4118
- duration: Date.now() - startTime
4824
+ duration: Date.now() - startTime,
4825
+ imageCount: this._pullImageCount,
4826
+ fileCount: this._pullFileCount,
4827
+ linkCount
4119
4828
  };
4120
4829
  }
4121
4830
  async sync(options) {
@@ -4193,6 +4902,76 @@ var SyncOrchestrator = class {
4193
4902
  get isDatabaseMode() {
4194
4903
  return this.config.notion.parentMode === "database" && !!this.config.notion.databaseId;
4195
4904
  }
4905
+ async discoverChildDatabases() {
4906
+ const rootId = this.config.notion.rootPageId;
4907
+ const allDbs = [];
4908
+ let currentLevel = [rootId];
4909
+ while (currentLevel.length > 0) {
4910
+ const nextLevel = [];
4911
+ for (const parentId of currentLevel) {
4912
+ try {
4913
+ const dbIds = await this.notionClient.getChildDatabaseIds(parentId);
4914
+ for (const dbId of dbIds) {
4915
+ allDbs.push({ dbId, parentPageId: parentId });
4916
+ }
4917
+ const childPages = await this.notionClient.getChildPages(parentId);
4918
+ nextLevel.push(...childPages.map((p) => p.id));
4919
+ } catch {
4920
+ }
4921
+ }
4922
+ currentLevel = nextLevel;
4923
+ }
4924
+ return allDbs;
4925
+ }
4926
+ async resolveNotionLinks(paths) {
4927
+ let totalResolved = 0;
4928
+ const allRecords = this.stateDb.getAll();
4929
+ const idToTitle = /* @__PURE__ */ new Map();
4930
+ for (const r of allRecords) {
4931
+ if (r.notionPageId && r.obsidianPath) {
4932
+ const title = r.obsidianPath.replace(/\.md$/, "").split("/").pop() ?? "";
4933
+ const cleanId = r.notionPageId.replace(/-/g, "");
4934
+ idToTitle.set(cleanId, title);
4935
+ idToTitle.set(r.notionPageId, title);
4936
+ }
4937
+ }
4938
+ if (idToTitle.size === 0) return 0;
4939
+ for (const filePath of paths) {
4940
+ if (!filePath.endsWith(".md")) continue;
4941
+ try {
4942
+ let content = await this.vaultFs.readFile(filePath);
4943
+ let changed = false;
4944
+ const resolved = content.replace(/\[\[notion:([a-f0-9-]+)\]\]/g, (_match, id) => {
4945
+ const title = idToTitle.get(id.replace(/-/g, ""));
4946
+ if (title) {
4947
+ totalResolved++;
4948
+ changed = true;
4949
+ return `[[${title}]]`;
4950
+ }
4951
+ return _match;
4952
+ });
4953
+ content = resolved;
4954
+ const resolved2 = content.replace(
4955
+ /\[([^\]]+)\]\(\/([a-f0-9]{32})\?pvs=\d+\)/g,
4956
+ (_match, text, id) => {
4957
+ const title = idToTitle.get(id);
4958
+ if (title) {
4959
+ totalResolved++;
4960
+ changed = true;
4961
+ return `[[${title}|${text}]]`;
4962
+ }
4963
+ return _match;
4964
+ }
4965
+ );
4966
+ content = resolved2;
4967
+ if (changed) {
4968
+ await this.vaultFs.writeFile(filePath, content);
4969
+ }
4970
+ } catch {
4971
+ }
4972
+ }
4973
+ return totalResolved;
4974
+ }
4196
4975
  async ensureDbSchema() {
4197
4976
  if (this.dbSchemaLoaded || !this.isDatabaseMode) return;
4198
4977
  const schema = await this.notionClient.getDatabaseSchema(this.config.notion.databaseId);
@@ -4392,7 +5171,7 @@ var SyncOrchestrator = class {
4392
5171
  const title = this.notionClient.extractTitle(page);
4393
5172
  const safeName = sanitizeFileName(title);
4394
5173
  const parentPath = await this.resolveParentPath(page);
4395
- const allChildren = await this.notionClient.fetchAllChildren(pageId);
5174
+ const allChildren = await this.notionClient.fetchAllChildrenDeep(pageId);
4396
5175
  const hasChildPages = allChildren.some((b) => "type" in b && b.type === "child_page");
4397
5176
  const markdown = await this.fetchPageMarkdown(pageId);
4398
5177
  const hasContent = markdown.trim().length > 0;
@@ -4427,7 +5206,11 @@ var SyncOrchestrator = class {
4427
5206
  if (this.config.conversion.imageDownload === "immediate") {
4428
5207
  const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
4429
5208
  processedMarkdown = imageResult.content;
5209
+ this._pullImageCount += imageResult.downloads.length;
4430
5210
  }
5211
+ const fileResult = await this.imageHandler.downloadAllFiles(processedMarkdown, title);
5212
+ processedMarkdown = fileResult.content;
5213
+ this._pullFileCount += fileResult.downloads.length;
4431
5214
  const finalContent = this.pipeline.convertToMarkdown(
4432
5215
  processedMarkdown,
4433
5216
  {
@@ -4440,11 +5223,12 @@ var SyncOrchestrator = class {
4440
5223
  );
4441
5224
  await this.vaultFs.writeFile(filePath, finalContent);
4442
5225
  const hash = computeHash(finalContent);
5226
+ const resolvedParentId = await this.extractParentId(page);
4443
5227
  this.stateDb.transaction(() => {
4444
5228
  this.stateDb.upsert({
4445
5229
  obsidianPath: filePath,
4446
5230
  notionPageId: pageId,
4447
- notionParentId: this.extractParentId(page),
5231
+ notionParentId: resolvedParentId,
4448
5232
  contentHash: hash,
4449
5233
  notionLastEdited: page.last_edited_time,
4450
5234
  localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
@@ -4482,7 +5266,11 @@ var SyncOrchestrator = class {
4482
5266
  if (this.config.conversion.imageDownload === "immediate") {
4483
5267
  const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
4484
5268
  markdown = imageResult.content;
5269
+ this._pullImageCount += imageResult.downloads.length;
4485
5270
  }
5271
+ const fileResult = await this.imageHandler.downloadAllFiles(markdown, title);
5272
+ markdown = fileResult.content;
5273
+ this._pullFileCount += fileResult.downloads.length;
4486
5274
  const savedMarkers = this.stateDb.getPreserveMarkers(record.obsidianPath);
4487
5275
  const remoteContent = this.pipeline.convertToMarkdown(
4488
5276
  markdown,
@@ -4614,7 +5402,7 @@ var SyncOrchestrator = class {
4614
5402
  return fileName === folderName;
4615
5403
  }
4616
5404
  async resolveParentPath(page) {
4617
- const parentId = this.extractParentId(page);
5405
+ const parentId = await this.extractParentId(page);
4618
5406
  if (!parentId || notionIdsEqual(parentId, this.config.notion.rootPageId)) return "";
4619
5407
  const parentRecord = this.stateDb.getByNotionId(parentId);
4620
5408
  if (parentRecord) {
@@ -4758,10 +5546,30 @@ var SyncOrchestrator = class {
4758
5546
  }
4759
5547
  return this.blockConverter.notionBlocksToMarkdown(pageId);
4760
5548
  }
4761
- extractParentId(page) {
5549
+ async extractParentId(page) {
4762
5550
  const parent = page.parent;
4763
5551
  if (parent.type === "page_id") return parent.page_id ?? null;
4764
5552
  if (parent.type === "database_id") return parent.database_id ?? null;
5553
+ if (parent.type === "block_id" && parent.block_id) {
5554
+ return this.resolveBlockToPageId(parent.block_id);
5555
+ }
5556
+ return null;
5557
+ }
5558
+ async resolveBlockToPageId(blockId) {
5559
+ for (let i = 0; i < 10; i++) {
5560
+ try {
5561
+ const block = await this.notionClient.getBlock(blockId);
5562
+ const bp = block.parent;
5563
+ if (bp.type === "page_id") return bp.page_id ?? null;
5564
+ if (bp.type === "block_id" && bp.block_id) {
5565
+ blockId = bp.block_id;
5566
+ continue;
5567
+ }
5568
+ return null;
5569
+ } catch {
5570
+ return null;
5571
+ }
5572
+ }
4765
5573
  return null;
4766
5574
  }
4767
5575
  cleanupInterruptedSync() {
@@ -5391,6 +6199,325 @@ var WatchSyncService = class {
5391
6199
  }
5392
6200
  };
5393
6201
 
5394
- export { BlockConverter, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConversionPipeline, DEFAULT_CONFIG, DatabaseSyncer, EmbedResolver, FileHandler, FileWatcher, FrontmatterExtractor, FrontmatterGenerator, HtmlAnnotationStripper, ImageHandler, InlineDBParser, LocalImageRestorer, MathNormalizer, MentionToWikilink, NodeVaultFS, NotionBlockBuilder, NotionClient, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, StateDB, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, WatchSyncService, WikilinkResolver, computeBufferHash, computeHash, createDefaultPipeline, generateId, getLogger, normalizeNotionId, notionIdsEqual, sanitizeFileName, setLogger, threeWayMerge };
6202
+ // src/view/filter-engine.ts
6203
+ function sortEntries(entries, sorts) {
6204
+ if (sorts.length === 0) return entries;
6205
+ return [...entries].sort((a, b) => {
6206
+ for (const sort of sorts) {
6207
+ const key = sort.property ?? sort.timestamp;
6208
+ if (!key) continue;
6209
+ const aVal = a.properties[key] ?? null;
6210
+ const bVal = b.properties[key] ?? null;
6211
+ const cmp = compareValues(aVal, bVal);
6212
+ if (cmp !== 0) return sort.direction === "ascending" ? cmp : -cmp;
6213
+ }
6214
+ return 0;
6215
+ });
6216
+ }
6217
+ function groupEntries(entries, groupByProperty, options) {
6218
+ const groups = /* @__PURE__ */ new Map();
6219
+ const ungrouped = [];
6220
+ for (const entry of entries) {
6221
+ const val = entry.properties[groupByProperty] ?? null;
6222
+ const groupName = extractGroupName(val);
6223
+ if (groupName === null) {
6224
+ ungrouped.push(entry);
6225
+ } else {
6226
+ const existing = groups.get(groupName);
6227
+ if (existing) {
6228
+ existing.push(entry);
6229
+ } else {
6230
+ groups.set(groupName, [entry]);
6231
+ }
6232
+ }
6233
+ }
6234
+ let result = Array.from(groups.entries()).map(([name, items]) => ({
6235
+ groupName: name,
6236
+ entries: items
6237
+ }));
6238
+ if (options?.sort === "ascending") {
6239
+ result.sort((a, b) => a.groupName.localeCompare(b.groupName));
6240
+ } else if (options?.sort === "descending") {
6241
+ result.sort((a, b) => b.groupName.localeCompare(a.groupName));
6242
+ }
6243
+ if (ungrouped.length > 0) {
6244
+ result.push({ groupName: "\uADF8\uB8F9 \uC5C6\uC74C", entries: ungrouped });
6245
+ }
6246
+ if (options?.hideEmptyGroups) {
6247
+ result = result.filter((g) => g.entries.length > 0);
6248
+ }
6249
+ return result;
6250
+ }
6251
+ function extractCalendarEntries(entries, dateProperty) {
6252
+ const result = [];
6253
+ for (const entry of entries) {
6254
+ const val = entry.properties[dateProperty];
6255
+ if (!val) continue;
6256
+ if (typeof val === "string") {
6257
+ const date = parseDate(val);
6258
+ if (date) result.push({ entry, date });
6259
+ } else if (typeof val === "object" && val !== null && !Array.isArray(val) && "start" in val) {
6260
+ const date = parseDate(val.start);
6261
+ if (date) {
6262
+ result.push({
6263
+ entry,
6264
+ date,
6265
+ endDate: val.end ? parseDate(val.end) ?? void 0 : void 0
6266
+ });
6267
+ }
6268
+ }
6269
+ }
6270
+ return result;
6271
+ }
6272
+ function filterByMonth(calendarEntries, year, month) {
6273
+ const monthStr = `${year}-${String(month).padStart(2, "0")}`;
6274
+ return calendarEntries.filter((ce) => ce.date.startsWith(monthStr));
6275
+ }
6276
+ function getVisibleProperties(viewConfig) {
6277
+ if (!viewConfig.properties) return [];
6278
+ return viewConfig.properties.filter((p) => p.visible !== false).map((p) => ({
6279
+ id: p.propertyId,
6280
+ name: p.propertyName ?? p.propertyId,
6281
+ width: p.width
6282
+ }));
6283
+ }
6284
+ function compareValues(a, b) {
6285
+ if (a === null && b === null) return 0;
6286
+ if (a === null) return 1;
6287
+ if (b === null) return -1;
6288
+ if (typeof a === "string" && typeof b === "string") return a.localeCompare(b);
6289
+ if (typeof a === "number" && typeof b === "number") return a - b;
6290
+ if (typeof a === "boolean" && typeof b === "boolean") return (a ? 1 : 0) - (b ? 1 : 0);
6291
+ if (Array.isArray(a) && Array.isArray(b)) {
6292
+ return a.join(",").localeCompare(b.join(","));
6293
+ }
6294
+ if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) {
6295
+ if ("start" in a && "start" in b) {
6296
+ return (a.start ?? "").localeCompare(b.start ?? "");
6297
+ }
6298
+ }
6299
+ return String(a).localeCompare(String(b));
6300
+ }
6301
+ function extractGroupName(val) {
6302
+ if (val === null || val === void 0) return null;
6303
+ if (typeof val === "string") return val || null;
6304
+ if (typeof val === "boolean") return val ? "true" : "false";
6305
+ if (typeof val === "number") return String(val);
6306
+ if (Array.isArray(val)) return val.length > 0 ? val.join(", ") : null;
6307
+ if (typeof val === "object" && "start" in val) return val.start;
6308
+ return null;
6309
+ }
6310
+ function parseDate(value) {
6311
+ const match = /^(\d{4}-\d{2}-\d{2})/.exec(value);
6312
+ return match?.[1] ?? null;
6313
+ }
6314
+ var DB_VIEWS_PATH = ".im-nobsidian/db-views.json";
6315
+ var ViewDataProvider = class {
6316
+ constructor(vaultFs) {
6317
+ this.vaultFs = vaultFs;
6318
+ }
6319
+ vaultFs;
6320
+ async loadAllViewConfigs() {
6321
+ try {
6322
+ const raw = await this.vaultFs.readFile(DB_VIEWS_PATH);
6323
+ return JSON.parse(raw);
6324
+ } catch {
6325
+ return {};
6326
+ }
6327
+ }
6328
+ async getViewConfigs(databaseId) {
6329
+ const all = await this.loadAllViewConfigs();
6330
+ return all[databaseId] ?? null;
6331
+ }
6332
+ async collectEntries(folderPath) {
6333
+ const allFiles = await this.vaultFs.listMarkdownFiles();
6334
+ const prefix = folderPath.endsWith("/") ? folderPath : folderPath + "/";
6335
+ const dbFiles = allFiles.filter((f) => f.path.startsWith(prefix));
6336
+ const entries = [];
6337
+ for (const file of dbFiles) {
6338
+ try {
6339
+ const content = await this.vaultFs.readFile(file.path);
6340
+ const entry = parseEntry(file.path, content);
6341
+ if (entry) entries.push(entry);
6342
+ } catch {
6343
+ }
6344
+ }
6345
+ return entries;
6346
+ }
6347
+ async buildViewData(databaseId, viewId, folderPath, schema) {
6348
+ const dbConfig = await this.getViewConfigs(databaseId);
6349
+ if (!dbConfig) return null;
6350
+ const viewConfig = dbConfig.views.find((v) => v.id === viewId);
6351
+ if (!viewConfig) return null;
6352
+ const entries = await this.collectEntries(folderPath);
6353
+ return this.processViewData(entries, viewConfig, databaseId, dbConfig.databaseName, schema);
6354
+ }
6355
+ async buildDefaultViewData(databaseId, folderPath, schema) {
6356
+ const dbConfig = await this.getViewConfigs(databaseId);
6357
+ if (!dbConfig || dbConfig.views.length === 0) return null;
6358
+ const viewConfig = dbConfig.views[0];
6359
+ const entries = await this.collectEntries(folderPath);
6360
+ return this.processViewData(entries, viewConfig, databaseId, dbConfig.databaseName, schema);
6361
+ }
6362
+ processViewData(entries, viewConfig, databaseId, databaseName, schema) {
6363
+ let processed = entries;
6364
+ if (viewConfig.sorts && viewConfig.sorts.length > 0) {
6365
+ processed = sortEntries(processed, viewConfig.sorts);
6366
+ }
6367
+ const visibleProps = getVisibleProperties(viewConfig);
6368
+ const propertyNames = visibleProps.length > 0 ? visibleProps.map((p) => p.name) : collectPropertyNames(processed);
6369
+ const data = {
6370
+ databaseId,
6371
+ databaseName,
6372
+ viewConfig,
6373
+ entries: processed,
6374
+ propertyNames,
6375
+ schema
6376
+ };
6377
+ if (viewConfig.type === "board" && viewConfig.groupBy) {
6378
+ const grouped = groupEntries(
6379
+ processed,
6380
+ viewConfig.groupBy.propertyName ?? viewConfig.groupBy.propertyId,
6381
+ {
6382
+ sort: viewConfig.groupBy.sort === "manual" ? void 0 : viewConfig.groupBy.sort,
6383
+ hideEmptyGroups: viewConfig.groupBy.hideEmptyGroups
6384
+ }
6385
+ );
6386
+ return { ...data, grouped };
6387
+ }
6388
+ if (viewConfig.type === "calendar" && viewConfig.datePropertyName) {
6389
+ const calendarEntries = extractCalendarEntries(processed, viewConfig.datePropertyName);
6390
+ return { ...data, calendarEntries };
6391
+ }
6392
+ return data;
6393
+ }
6394
+ };
6395
+ function parseEntry(path, content) {
6396
+ try {
6397
+ const parsed = matter7(content);
6398
+ const fm = parsed.data;
6399
+ const title = fm.title ?? extractTitleFromPath2(path);
6400
+ const icon = fm.icon;
6401
+ const cover = fm.cover;
6402
+ const properties = {};
6403
+ for (const [key, value] of Object.entries(fm)) {
6404
+ if (key === "title" || key === "icon" || key === "cover") continue;
6405
+ properties[key] = normalizePropertyValue(value);
6406
+ }
6407
+ return { path, title, icon, cover, properties };
6408
+ } catch {
6409
+ return null;
6410
+ }
6411
+ }
6412
+ function normalizePropertyValue(value) {
6413
+ if (value === null || value === void 0) return null;
6414
+ if (typeof value === "string") return value;
6415
+ if (typeof value === "number") return value;
6416
+ if (typeof value === "boolean") return value;
6417
+ if (value instanceof Date) {
6418
+ return value.toISOString().split("T")[0];
6419
+ }
6420
+ if (Array.isArray(value)) {
6421
+ return value.map((v) => String(v));
6422
+ }
6423
+ if (typeof value === "object" && value !== null) {
6424
+ const obj = value;
6425
+ if ("start" in obj) {
6426
+ return {
6427
+ start: String(obj.start),
6428
+ end: obj.end ? String(obj.end) : void 0
6429
+ };
6430
+ }
6431
+ }
6432
+ return String(value);
6433
+ }
6434
+ function extractTitleFromPath2(filePath) {
6435
+ const parts = filePath.split("/");
6436
+ const filename = parts[parts.length - 1] ?? "";
6437
+ return filename.replace(/\.md$/, "");
6438
+ }
6439
+ function collectPropertyNames(entries) {
6440
+ const names = /* @__PURE__ */ new Set();
6441
+ for (const entry of entries) {
6442
+ for (const key of Object.keys(entry.properties)) {
6443
+ names.add(key);
6444
+ }
6445
+ }
6446
+ return Array.from(names);
6447
+ }
6448
+ var EntryEditor = class {
6449
+ constructor(vaultFs) {
6450
+ this.vaultFs = vaultFs;
6451
+ }
6452
+ vaultFs;
6453
+ async updateProperty(entryPath, propertyName, newValue) {
6454
+ const content = await this.vaultFs.readFile(entryPath);
6455
+ const parsed = matter7(content);
6456
+ parsed.data[propertyName] = newValue;
6457
+ const updated = matter7.stringify(parsed.content, parsed.data);
6458
+ await this.vaultFs.writeFile(entryPath, updated);
6459
+ }
6460
+ async createEntry(folderPath, title, properties) {
6461
+ const safeName = title.replace(/[\\/:*?"<>|]/g, "_").trim() || "Untitled";
6462
+ const filePath = `${folderPath}/${safeName}.md`;
6463
+ const frontmatter = { title, ...properties };
6464
+ const content = matter7.stringify("", frontmatter);
6465
+ await this.vaultFs.ensureFolder(folderPath);
6466
+ await this.vaultFs.writeFile(filePath, content);
6467
+ return filePath;
6468
+ }
6469
+ async moveEntryToGroup(entry, groupProperty, newGroupValue) {
6470
+ await this.updateProperty(entry.path, groupProperty, newGroupValue);
6471
+ }
6472
+ async updateDate(entryPath, dateProperty, newDate) {
6473
+ await this.updateProperty(entryPath, dateProperty, newDate);
6474
+ }
6475
+ };
6476
+
6477
+ // src/view/color-map.ts
6478
+ var NOTION_COLORS = {
6479
+ default: "#37352F",
6480
+ gray: "#787774",
6481
+ brown: "#9F6B53",
6482
+ orange: "#D9730D",
6483
+ yellow: "#CB912F",
6484
+ green: "#448361",
6485
+ blue: "#337EA9",
6486
+ purple: "#9065B0",
6487
+ pink: "#C14C8A",
6488
+ red: "#D44C47"
6489
+ };
6490
+ var NOTION_BG_COLORS = {
6491
+ default: "#F1F1EF",
6492
+ gray: "#F1F1EF",
6493
+ brown: "#F4EEEE",
6494
+ orange: "#FBECDD",
6495
+ yellow: "#FBF3DB",
6496
+ green: "#EDF3EC",
6497
+ blue: "#E7F3F8",
6498
+ purple: "#F4F0F7",
6499
+ pink: "#F9F0F5",
6500
+ red: "#FDEBEC"
6501
+ };
6502
+ function getNotionColor(color) {
6503
+ return NOTION_COLORS[color] ?? NOTION_COLORS.default;
6504
+ }
6505
+ function getNotionBgColor(color) {
6506
+ return NOTION_BG_COLORS[color] ?? NOTION_BG_COLORS.default;
6507
+ }
6508
+ function generateColorCSS() {
6509
+ const lines = [];
6510
+ for (const [name, hex] of Object.entries(NOTION_COLORS)) {
6511
+ lines.push(` --notion-color-${name}: ${hex};`);
6512
+ }
6513
+ for (const [name, hex] of Object.entries(NOTION_BG_COLORS)) {
6514
+ lines.push(` --notion-bg-${name}: ${hex};`);
6515
+ }
6516
+ return `:root {
6517
+ ${lines.join("\n")}
6518
+ }`;
6519
+ }
6520
+
6521
+ export { BlockConverter, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConversionPipeline, DEFAULT_CONFIG, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FrontmatterExtractor, FrontmatterGenerator, HtmlAnnotationStripper, ImageHandler, InlineDBParser, LocalImageRestorer, MathNormalizer, MentionToWikilink, NodeVaultFS, NotionBlockBuilder, NotionClient, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, StateDB, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WatchSyncService, WikilinkResolver, computeBufferHash, computeHash, createDefaultPipeline, extractCalendarEntries, filterByMonth, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, normalizeNotionId, notionIdsEqual, sanitizeFileName, setLogger, sortEntries, threeWayMerge };
5395
6522
  //# sourceMappingURL=index.js.map
5396
6523
  //# sourceMappingURL=index.js.map