@im-nobsidian/core 0.1.11 → 0.1.12

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
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { readFile, mkdir, writeFile, unlink, rename, stat, readdir } from 'fs/promises';
3
- import { join, dirname, relative } from 'path';
3
+ import { join, dirname, relative, sep } from 'path';
4
4
  import Database from 'better-sqlite3';
5
5
  import { randomUUID, createHash } from 'crypto';
6
6
  import { Client, LogLevel } from '@notionhq/client';
@@ -49,10 +49,28 @@ var ConfigSchema = z.object({
49
49
  imageDownload: z.enum(["immediate", "lazy", "skip"]).default("immediate")
50
50
  }),
51
51
  advanced: z.object({
52
+ // Notion API 클라이언트 동시성/타임아웃
52
53
  concurrency: z.number().min(1).max(10).default(3),
53
- maxRetries: z.number().min(0).max(10).default(5),
54
54
  timeoutMs: z.number().min(5e3).max(6e4).default(3e4),
55
- batchSize: z.number().min(1).max(100).default(50)
55
+ // 페이지네이션/블록 배치 (Notion API children append 상한 = 100)
56
+ pageSize: z.number().min(1).max(100).default(100),
57
+ batchSize: z.number().min(1).max(100).default(100),
58
+ // Rate limit 게이트 (요청 간 최소 간격, ms)
59
+ rateLimitIntervalMs: z.number().min(0).max(5e3).default(350),
60
+ // API 재시도 (지수 백오프: retryBaseDelayMs * retryBackoffFactor^attempt)
61
+ maxRetries: z.number().min(0).max(10).default(5),
62
+ retryBaseDelayMs: z.number().min(100).max(1e4).default(1e3),
63
+ retryBackoffFactor: z.number().min(1).max(10).default(2),
64
+ // 오케스트레이터 단계 간 재시도 대기 (ms)
65
+ retryWaitMs: z.number().min(0).max(3e4).default(2e3),
66
+ // 미디어(이미지/파일) 다운로드 동시성/재시도
67
+ mediaConcurrency: z.number().min(1).max(10).default(3),
68
+ mediaMaxRetries: z.number().min(0).max(10).default(3),
69
+ mediaRetryBaseMs: z.number().min(100).max(1e4).default(1e3),
70
+ // 비마크다운 파일(첨부) 업로드/다운로드 동시성
71
+ fileConcurrency: z.number().min(1).max(10).default(2),
72
+ // 다운로드 파일 크기 상한 (bytes, 기본 100MB)
73
+ maxFileSizeBytes: z.number().min(1024).default(100 * 1024 * 1024)
56
74
  })
57
75
  });
58
76
  var DEFAULT_CONFIG = {
@@ -84,13 +102,37 @@ var DEFAULT_CONFIG = {
84
102
  },
85
103
  advanced: {
86
104
  concurrency: 3,
87
- maxRetries: 5,
88
105
  timeoutMs: 3e4,
89
- batchSize: 50
106
+ pageSize: 100,
107
+ batchSize: 100,
108
+ rateLimitIntervalMs: 350,
109
+ maxRetries: 5,
110
+ retryBaseDelayMs: 1e3,
111
+ retryBackoffFactor: 2,
112
+ retryWaitMs: 2e3,
113
+ mediaConcurrency: 3,
114
+ mediaMaxRetries: 3,
115
+ mediaRetryBaseMs: 1e3,
116
+ fileConcurrency: 2,
117
+ maxFileSizeBytes: 100 * 1024 * 1024
90
118
  }
91
119
  };
120
+
121
+ // src/constants/paths.ts
122
+ var INTERNAL_DIR = ".im-nobsidian";
92
123
  var CONFIG_FILE = "config.json";
93
- var IM_NOBSIDIAN_DIR = ".im-nobsidian";
124
+ var STATE_DB_FILE = "sync.db";
125
+ var DB_VIEWS_FILE = "db-views.json";
126
+ var IGNORE_FILE = ".im-nobsidian-ignore";
127
+ var GITIGNORE_ENTRY = `${INTERNAL_DIR}/`;
128
+ var DB_VIEWS_PATH = `${INTERNAL_DIR}/${DB_VIEWS_FILE}`;
129
+ var STATE_DB_PATH = `${INTERNAL_DIR}/${STATE_DB_FILE}`;
130
+ var INTERNAL_DIR_GLOB = `**/${INTERNAL_DIR}/**`;
131
+ function isInternalPath(path) {
132
+ return path === INTERNAL_DIR || path.startsWith(`${INTERNAL_DIR}/`);
133
+ }
134
+
135
+ // src/config/manager.ts
94
136
  var ConfigManager = class {
95
137
  constructor(vaultRoot) {
96
138
  this.vaultRoot = vaultRoot;
@@ -98,13 +140,13 @@ var ConfigManager = class {
98
140
  vaultRoot;
99
141
  config = null;
100
142
  get configDir() {
101
- return join(this.vaultRoot, IM_NOBSIDIAN_DIR);
143
+ return join(this.vaultRoot, INTERNAL_DIR);
102
144
  }
103
145
  get configPath() {
104
146
  return join(this.configDir, CONFIG_FILE);
105
147
  }
106
148
  get dbPath() {
107
- return join(this.configDir, "sync.db");
149
+ return join(this.configDir, STATE_DB_FILE);
108
150
  }
109
151
  async load() {
110
152
  try {
@@ -152,7 +194,7 @@ var ConfigManager = class {
152
194
  }
153
195
  async ensureGitignore() {
154
196
  const gitignorePath = join(this.vaultRoot, ".gitignore");
155
- const entry = ".im-nobsidian/";
197
+ const entry = GITIGNORE_ENTRY;
156
198
  try {
157
199
  const content = await readFile(gitignorePath, "utf-8");
158
200
  if (!content.includes(entry)) {
@@ -432,6 +474,9 @@ var StateDB = class _StateDB {
432
474
  "UPDATE sync_state SET notion_parent_id = ?, updated_at = datetime('now') WHERE id = ?"
433
475
  ).run(parentId, id);
434
476
  }
477
+ updatePath(id, newPath) {
478
+ this.db.prepare("UPDATE sync_state SET obsidian_path = ?, updated_at = datetime('now') WHERE id = ?").run(newPath, id);
479
+ }
435
480
  delete(id) {
436
481
  this.db.prepare("DELETE FROM sync_state WHERE id = ?").run(id);
437
482
  }
@@ -454,6 +499,9 @@ var StateDB = class _StateDB {
454
499
  VALUES (?, ?, ?, ?, datetime('now'))`
455
500
  ).run(entry.obsidianPath, entry.notionPageId, entry.title, JSON.stringify(entry.aliases));
456
501
  }
502
+ deleteWikilink(obsidianPath) {
503
+ this.db.prepare("DELETE FROM wikilink_map WHERE obsidian_path = ?").run(obsidianPath);
504
+ }
457
505
  // --- sync_metadata ---
458
506
  getMeta(key) {
459
507
  const row = this.db.prepare("SELECT value FROM sync_metadata WHERE key = ?").get(key);
@@ -558,7 +606,7 @@ var StateDB = class _StateDB {
558
606
  };
559
607
 
560
608
  // src/notion/property-mapper.ts
561
- var DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2})?/;
609
+ var DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
562
610
  var WIKILINK_REGEX = /^\[\[(.+?)(?:\|.+?)?\]\]$/;
563
611
  var PropertyMapper = class {
564
612
  schema = /* @__PURE__ */ new Map();
@@ -606,13 +654,18 @@ var PropertyMapper = class {
606
654
  switch (type) {
607
655
  case "rich_text":
608
656
  return { rich_text: [{ text: { content: String(value) } }] };
609
- case "number":
610
- return { number: typeof value === "number" ? value : Number(value) };
611
- case "select":
612
- return { select: { name: String(value) } };
657
+ case "number": {
658
+ const num = typeof value === "number" ? value : Number(value);
659
+ return Number.isFinite(num) ? { number: num } : null;
660
+ }
661
+ case "select": {
662
+ const name = String(value);
663
+ return name.trim() ? { select: { name } } : null;
664
+ }
613
665
  case "multi_select": {
614
666
  const items = Array.isArray(value) ? value : String(value).split(",").map((s) => s.trim());
615
- return { multi_select: items.map((name) => ({ name: String(name) })) };
667
+ const names = items.map((name) => String(name)).filter((name) => name.trim());
668
+ return { multi_select: names.map((name) => ({ name })) };
616
669
  }
617
670
  case "checkbox":
618
671
  return { checkbox: Boolean(value) };
@@ -633,8 +686,10 @@ var PropertyMapper = class {
633
686
  return { email: String(value) };
634
687
  case "phone_number":
635
688
  return { phone_number: String(value) };
636
- case "status":
637
- return { status: { name: String(value) } };
689
+ case "status": {
690
+ const name = String(value);
691
+ return name.trim() ? { status: { name } } : null;
692
+ }
638
693
  case "relation": {
639
694
  const items = Array.isArray(value) ? value : [value];
640
695
  const ids = [];
@@ -664,14 +719,14 @@ var PropertyMapper = class {
664
719
  const filesArr = fileList.map((f) => {
665
720
  if (typeof f === "object" && f !== null && "url" in f) {
666
721
  const obj = f;
667
- return { type: "external", name: obj.name ?? "file", external: { url: obj.url } };
722
+ const url2 = String(obj.url ?? "");
723
+ if (!url2) return null;
724
+ return { type: "external", name: obj.name ?? "file", external: { url: url2 } };
668
725
  }
669
- return {
670
- type: "external",
671
- name: String(f),
672
- external: { url: String(f) }
673
- };
674
- });
726
+ const url = String(f);
727
+ if (!url) return null;
728
+ return { type: "external", name: url, external: { url } };
729
+ }).filter(Boolean);
675
730
  return { files: filesArr };
676
731
  }
677
732
  case "created_time":
@@ -754,10 +809,9 @@ var PropertyMapper = class {
754
809
  }
755
810
  case "files": {
756
811
  const files = prop.files;
757
- return files?.map((f) => ({
758
- name: f.name,
759
- url: f.type === "file" ? f.file?.url : f.external?.url
760
- })) ?? [];
812
+ const urls = (files ?? []).map((f) => f.type === "file" ? f.file?.url : f.external?.url).filter((u) => typeof u === "string" && u.length > 0);
813
+ if (urls.length === 0) return [];
814
+ return urls.length === 1 ? urls[0] : urls;
761
815
  }
762
816
  case "formula": {
763
817
  const formula = prop.formula;
@@ -804,6 +858,22 @@ var PropertyMapper = class {
804
858
  }
805
859
  };
806
860
 
861
+ // src/utils/logger.ts
862
+ var consoleLogger = {
863
+ warn: (msg, ...args) => console.warn(msg, ...args),
864
+ error: (msg, ...args) => console.error(msg, ...args),
865
+ info: (msg, ...args) => console.info(msg, ...args),
866
+ debug: () => {
867
+ }
868
+ };
869
+ var currentLogger = consoleLogger;
870
+ function setLogger(logger) {
871
+ currentLogger = logger;
872
+ }
873
+ function getLogger() {
874
+ return currentLogger;
875
+ }
876
+
807
877
  // src/notion/client.ts
808
878
  var NotionClient = class _NotionClient {
809
879
  client;
@@ -811,6 +881,12 @@ var NotionClient = class _NotionClient {
811
881
  token;
812
882
  propertyMapper = new PropertyMapper();
813
883
  customFetch;
884
+ minRequestInterval;
885
+ maxRetries;
886
+ retryBaseDelayMs;
887
+ retryBackoffFactor;
888
+ defaultPageSize;
889
+ batchSize;
814
890
  constructor(options) {
815
891
  this.client = new Client({
816
892
  auth: options.token,
@@ -821,6 +897,28 @@ var NotionClient = class _NotionClient {
821
897
  this.token = options.token;
822
898
  this.customFetch = options.fetch;
823
899
  this.sema = new Sema(options.concurrency ?? 3);
900
+ this.minRequestInterval = options.rateLimitIntervalMs ?? 350;
901
+ this.maxRetries = options.maxRetries ?? 5;
902
+ this.retryBaseDelayMs = options.retryBaseDelayMs ?? 1e3;
903
+ this.retryBackoffFactor = options.retryBackoffFactor ?? 2;
904
+ this.defaultPageSize = options.pageSize ?? 100;
905
+ this.batchSize = options.batchSize ?? 100;
906
+ }
907
+ /** config.advanced 의 운영 튜닝값으로 클라이언트를 생성한다 (매직넘버 단일 진실원). */
908
+ static fromConfig(config, fetch2) {
909
+ const a = config.advanced;
910
+ return new _NotionClient({
911
+ token: config.notion.token,
912
+ concurrency: a.concurrency,
913
+ timeoutMs: a.timeoutMs,
914
+ rateLimitIntervalMs: a.rateLimitIntervalMs,
915
+ maxRetries: a.maxRetries,
916
+ retryBaseDelayMs: a.retryBaseDelayMs,
917
+ retryBackoffFactor: a.retryBackoffFactor,
918
+ pageSize: a.pageSize,
919
+ batchSize: a.batchSize,
920
+ ...fetch2 ? { fetch: fetch2 } : {}
921
+ });
824
922
  }
825
923
  getInternalClient() {
826
924
  return this.client;
@@ -979,7 +1077,7 @@ var NotionClient = class _NotionClient {
979
1077
  () => this.client.dataSources.query({
980
1078
  data_source_id: dsId,
981
1079
  start_cursor: options?.startCursor,
982
- page_size: options?.pageSize ?? 100,
1080
+ page_size: options?.pageSize ?? this.defaultPageSize,
983
1081
  filter: options?.filter
984
1082
  })
985
1083
  );
@@ -994,7 +1092,7 @@ var NotionClient = class _NotionClient {
994
1092
  do {
995
1093
  const response = await this.queryDatabase(databaseId, {
996
1094
  startCursor: cursor,
997
- pageSize: 100,
1095
+ pageSize: this.defaultPageSize,
998
1096
  filter
999
1097
  });
1000
1098
  all.push(...response.results);
@@ -1035,7 +1133,7 @@ var NotionClient = class _NotionClient {
1035
1133
  () => this.client.blocks.children.list({
1036
1134
  block_id: blockId,
1037
1135
  start_cursor: options?.startCursor,
1038
- page_size: options?.pageSize ?? 100
1136
+ page_size: options?.pageSize ?? this.defaultPageSize
1039
1137
  })
1040
1138
  );
1041
1139
  return {
@@ -1083,9 +1181,9 @@ var NotionClient = class _NotionClient {
1083
1181
  return allBlocks;
1084
1182
  }
1085
1183
  async appendChildren(blockId, children) {
1086
- const BATCH_SIZE = 100;
1087
- for (let i = 0; i < children.length; i += BATCH_SIZE) {
1088
- const batch = children.slice(i, i + BATCH_SIZE);
1184
+ const batchSize = this.batchSize;
1185
+ for (let i = 0; i < children.length; i += batchSize) {
1186
+ const batch = children.slice(i, i + batchSize);
1089
1187
  await this.withRateLimit(
1090
1188
  () => this.client.blocks.children.append({
1091
1189
  block_id: blockId,
@@ -1124,7 +1222,7 @@ var NotionClient = class _NotionClient {
1124
1222
  query: params.query,
1125
1223
  filter: params.filter,
1126
1224
  start_cursor: params.startCursor,
1127
- page_size: params.pageSize ?? 100
1225
+ page_size: params.pageSize ?? this.defaultPageSize
1128
1226
  })
1129
1227
  );
1130
1228
  return {
@@ -1142,7 +1240,7 @@ var NotionClient = class _NotionClient {
1142
1240
  filter: { property: "object", value: "page" },
1143
1241
  sort: { direction: "descending", timestamp: "last_edited_time" },
1144
1242
  start_cursor: cursor,
1145
- page_size: 100
1243
+ page_size: this.defaultPageSize
1146
1244
  })
1147
1245
  );
1148
1246
  for (const page of response.results) {
@@ -1156,12 +1254,43 @@ var NotionClient = class _NotionClient {
1156
1254
  } while (cursor);
1157
1255
  return results;
1158
1256
  }
1257
+ /**
1258
+ * 통합이 접근 가능한 모든 페이지를 search API로 일괄 조회한다(요청당 최대 100건).
1259
+ * 블록 트리를 페이지별로 재귀 순회하는 getChildPagesRecursive 대비 API 호출 수가
1260
+ * 페이지 수의 수십분의 1로 줄어든다. search API는 휴지통(in_trash) 페이지를 반환하지 않는다.
1261
+ * 반환 객체에는 parent 정보가 포함되어 호출 측에서 root subtree ancestry 필터링이 가능하다.
1262
+ */
1263
+ async searchAllPages() {
1264
+ const results = [];
1265
+ let cursor;
1266
+ do {
1267
+ const response = await this.withRateLimit(
1268
+ () => this.client.search({
1269
+ filter: { property: "object", value: "page" },
1270
+ start_cursor: cursor,
1271
+ page_size: this.defaultPageSize
1272
+ })
1273
+ );
1274
+ for (const page of response.results) {
1275
+ if ("properties" in page) {
1276
+ results.push(page);
1277
+ }
1278
+ }
1279
+ cursor = response.next_cursor ?? void 0;
1280
+ } while (cursor);
1281
+ return results;
1282
+ }
1159
1283
  async getChildPages(parentId) {
1160
1284
  const blocks = await this.fetchAllChildrenDeep(parentId);
1161
1285
  const childPageBlocks = blocks.filter((b) => b.type === "child_page");
1162
1286
  if (childPageBlocks.length === 0) return [];
1163
1287
  const pages = await Promise.all(childPageBlocks.map((b) => this.getPage(b.id)));
1164
- return pages;
1288
+ return pages.filter((p) => !_NotionClient.isTrashedOrArchived(p));
1289
+ }
1290
+ /** 페이지가 휴지통(in_trash)이거나 보관(archived) 상태인지 판정한다. */
1291
+ static isTrashedOrArchived(page) {
1292
+ const inTrash = page.in_trash === true;
1293
+ return inTrash || page.archived === true;
1165
1294
  }
1166
1295
  async getChildDatabaseIds(parentId) {
1167
1296
  const children = await this.fetchAllChildren(parentId);
@@ -1173,7 +1302,15 @@ var NotionClient = class _NotionClient {
1173
1302
  while (currentLevel.length > 0) {
1174
1303
  const nextLevel = [];
1175
1304
  for (const id of currentLevel) {
1176
- const children = await this.getChildPages(id);
1305
+ let children;
1306
+ try {
1307
+ children = await this.getChildPages(id);
1308
+ } catch (error) {
1309
+ getLogger().warn(
1310
+ `[Im-Nobsidian] \uD558\uC704 \uD398\uC774\uC9C0 \uC2A4\uCE94 \uAC74\uB108\uB700 (${id}): ${error instanceof Error ? error.message : String(error)}`
1311
+ );
1312
+ continue;
1313
+ }
1177
1314
  for (const child of children) {
1178
1315
  all.push(child);
1179
1316
  nextLevel.push(child.id);
@@ -1192,7 +1329,7 @@ var NotionClient = class _NotionClient {
1192
1329
  () => this.client.views.list({
1193
1330
  database_id: databaseId,
1194
1331
  start_cursor: cursor,
1195
- page_size: 100
1332
+ page_size: this.defaultPageSize
1196
1333
  })
1197
1334
  );
1198
1335
  const viewRefs = response.results;
@@ -1269,7 +1406,6 @@ var NotionClient = class _NotionClient {
1269
1406
  }
1270
1407
  // ─── Internal ───
1271
1408
  lastRequestTime = 0;
1272
- minRequestInterval = 350;
1273
1409
  async withRateLimit(fn) {
1274
1410
  await this.sema.acquire();
1275
1411
  try {
@@ -1288,8 +1424,8 @@ var NotionClient = class _NotionClient {
1288
1424
  try {
1289
1425
  return await fn();
1290
1426
  } catch (error) {
1291
- if (isRetryable(error) && attempt < 5) {
1292
- const baseDelay = extractRetryAfter(error) ?? 1e3 * Math.pow(2, attempt);
1427
+ if (isRetryable(error) && attempt < this.maxRetries) {
1428
+ const baseDelay = extractRetryAfter(error) ?? this.retryBaseDelayMs * Math.pow(this.retryBackoffFactor, attempt);
1293
1429
  const jitter = baseDelay * (0.5 + Math.random() * 0.5);
1294
1430
  await sleep(jitter);
1295
1431
  return this.executeWithRetry(fn, attempt + 1);
@@ -1837,23 +1973,29 @@ var ChangeDetector = class {
1837
1973
  }
1838
1974
  };
1839
1975
 
1840
- // src/utils/logger.ts
1841
- var consoleLogger = {
1842
- warn: (msg, ...args) => console.warn(msg, ...args),
1843
- error: (msg, ...args) => console.error(msg, ...args),
1844
- info: (msg, ...args) => console.info(msg, ...args),
1845
- debug: () => {
1846
- }
1847
- };
1848
- var currentLogger = consoleLogger;
1849
- function setLogger(logger) {
1850
- currentLogger = logger;
1976
+ // src/constants/markers.ts
1977
+ var MARKER_BRAND = "im-nobsidian";
1978
+ var WIKILINK_PROTOCOL = `${MARKER_BRAND}://wikilink/`;
1979
+ var EMBED_PROTOCOL = `${MARKER_BRAND}://embed/`;
1980
+ function spacedMarker(body) {
1981
+ return `%% ${MARKER_BRAND}:${body} %%`;
1851
1982
  }
1852
- function getLogger() {
1853
- return currentLogger;
1983
+ var SPACED_END = spacedMarker("end");
1984
+ function compactMarker(body) {
1985
+ return `%%${MARKER_BRAND}:${body}%%`;
1854
1986
  }
1987
+ var TOGGLE_START = compactMarker("toggle:start");
1988
+ var TOGGLE_END = compactMarker("toggle:end");
1989
+ var COLUMN_LIST_START = compactMarker("column-list:start");
1990
+ var COLUMN_LIST_END = compactMarker("column-list:end");
1991
+ var COLUMN_SEP = compactMarker("column");
1992
+ var TOC_MARKER = compactMarker("toc");
1993
+ var PROPERTIES_TAG = `# ${MARKER_BRAND}:properties`;
1994
+ var MARKER_BRAND_RE = MARKER_BRAND;
1855
1995
 
1856
1996
  // src/converter/pipeline.ts
1997
+ var INLINE_DB_PROBE = new RegExp(`%% ${MARKER_BRAND_RE}:inline-db`);
1998
+ var TOGGLE_HEADING_PROBE = new RegExp(`%% ${MARKER_BRAND_RE}:toggle-heading`);
1857
1999
  var ConversionPipeline = class {
1858
2000
  preProcessors = [];
1859
2001
  postProcessors = [];
@@ -1866,9 +2008,9 @@ var ConversionPipeline = class {
1866
2008
  this.postProcessors.sort((a, b) => a.order - b.order);
1867
2009
  }
1868
2010
  selectPath(content) {
1869
- const hasInlineDb = /%% im-nobsidian:inline-db/.test(content);
2011
+ const hasInlineDb = INLINE_DB_PROBE.test(content);
1870
2012
  const hasColumnLayout = />\s*\[!col\]/.test(content);
1871
- const hasToggleHeading = /%% im-nobsidian:toggle-heading/.test(content);
2013
+ const hasToggleHeading = TOGGLE_HEADING_PROBE.test(content);
1872
2014
  if (hasInlineDb || hasColumnLayout || hasToggleHeading) {
1873
2015
  return "block-api";
1874
2016
  }
@@ -1965,7 +2107,7 @@ var WikilinkResolver = class {
1965
2107
  startIndex: 0
1966
2108
  });
1967
2109
  const encodedTarget = encodeURIComponent(target);
1968
- return `[${label}](im-nobsidian://wikilink/${encodedTarget})`;
2110
+ return `[${label}](${WIKILINK_PROTOCOL}${encodedTarget})`;
1969
2111
  }
1970
2112
  );
1971
2113
  return {
@@ -2012,8 +2154,8 @@ var CalloutTransformer = class {
2012
2154
  const emoji = CALLOUT_EMOJI_MAP[type.toLowerCase()] ?? "\u{1F4DD}";
2013
2155
  const titleText = title?.trim() || type;
2014
2156
  const foldMeta = foldable ? `
2015
- %% im-nobsidian:callout:type=${type}&foldable=${foldable === "+" ? "open" : "closed"} %%` : `
2016
- %% im-nobsidian:callout:type=${type} %%`;
2157
+ ${spacedMarker(`callout:type=${type}&foldable=${foldable === "+" ? "open" : "closed"}`)}` : `
2158
+ ${spacedMarker(`callout:type=${type}`)}`;
2017
2159
  return `> ${emoji} **${titleText}**${foldMeta}`;
2018
2160
  }
2019
2161
  );
@@ -2063,16 +2205,16 @@ var EmbedResolver = class {
2063
2205
  if (isPush && !isExternalUrl(target)) {
2064
2206
  const fileName = target.split("/").pop() ?? target;
2065
2207
  return `> \u{1F4CE} ${fileName}
2066
- > %% im-nobsidian:local-image:${target} %%`;
2208
+ > ${spacedMarker(`local-image:${target}`)}`;
2067
2209
  }
2068
2210
  return `![${target}](${encodeURI(target)})`;
2069
2211
  }
2070
2212
  const encoded = encodeURIComponent(target);
2071
- return `[${target}](im-nobsidian://embed/${encoded})`;
2213
+ return `[${target}](${EMBED_PROTOCOL}${encoded})`;
2072
2214
  });
2073
2215
  content = content.replace(MARKDOWN_IMAGE_REGEX, (_match, alt, url) => {
2074
2216
  if (isVideoUrl(url)) {
2075
- return `%% im-nobsidian:embed:type=video&url=${encodeURIComponent(url)} %%
2217
+ return `${spacedMarker(`embed:type=video&url=${encodeURIComponent(url)}`)}
2076
2218
  [${alt || "Video"}](${url})`;
2077
2219
  }
2078
2220
  if (isExternalUrl(url)) {
@@ -2097,7 +2239,7 @@ function isExternalUrl(url) {
2097
2239
  }
2098
2240
 
2099
2241
  // src/converter/pre-processors/preserve-marker.ts
2100
- var MARKER_REGEX = /%% im-nobsidian:(\w[\w-]*):([^\s]+?) %%/g;
2242
+ var MARKER_REGEX = new RegExp(`%% ${MARKER_BRAND_RE}:(\\w[\\w-]*):([^\\s]+?) %%`, "g");
2101
2243
  var PreserveMarkerCollector = class {
2102
2244
  name = "PreserveMarkerCollector";
2103
2245
  order = 70;
@@ -2167,7 +2309,7 @@ var PropertiesTableInjector = class {
2167
2309
  }
2168
2310
  const yamlStr = matter7.stringify("", filteredProps).trim();
2169
2311
  const yamlBody = yamlStr.slice(4, -3).trim();
2170
- const codeBlock = "```yaml\n# im-nobsidian:properties\n" + yamlBody + "\n```";
2312
+ const codeBlock = "```yaml\n" + PROPERTIES_TAG + "\n" + yamlBody + "\n```";
2171
2313
  const content = codeBlock + "\n\n---\n\n" + input.content;
2172
2314
  return { content, metadata: input.metadata };
2173
2315
  }
@@ -2176,7 +2318,10 @@ var PropertiesTableInjector = class {
2176
2318
  // src/converter/pre-processors/html-annotation.ts
2177
2319
  var UNDERLINE_REGEX = /<u>([\s\S]*?)<\/u>/g;
2178
2320
  var COLOR_SPAN_REGEX = /<span class="notion-(\w+?)(?:-bg)?">([\s\S]*?)<\/span>/g;
2179
- var IM_NOBSIDIAN_COLOR_REGEX = /%% im-nobsidian:color:(\w+(?:_background)?) %%([\s\S]*?)%% im-nobsidian:end %%/g;
2321
+ var IM_NOBSIDIAN_COLOR_REGEX = new RegExp(
2322
+ `%% ${MARKER_BRAND_RE}:color:(\\w+(?:_background)?) %%([\\s\\S]*?)%% ${MARKER_BRAND_RE}:end %%`,
2323
+ "g"
2324
+ );
2180
2325
  var HtmlAnnotationStripper = class {
2181
2326
  name = "HtmlAnnotationStripper";
2182
2327
  order = 5;
@@ -2194,8 +2339,8 @@ var HtmlAnnotationStripper = class {
2194
2339
 
2195
2340
  // src/converter/post-processors/mention-to-wikilink.ts
2196
2341
  var NOTION_LINK_REGEX = /\[([^\]]+)\]\((?:https:\/\/(?:www\.)?notion\.so\/|notion:\/\/)([a-f0-9-]+)\)/g;
2197
- var WIKILINK_PRESERVE_REGEX = /\[([^\]]+)\]\(im-nobsidian:\/\/wikilink\/([^)]+)\)/g;
2198
- var EMBED_PRESERVE_REGEX = /\[([^\]]+)\]\(im-nobsidian:\/\/embed\/([^)]+)\)/g;
2342
+ var WIKILINK_PRESERVE_REGEX = new RegExp(`\\[([^\\]]+)\\]\\(${WIKILINK_PROTOCOL}([^)]+)\\)`, "g");
2343
+ var EMBED_PRESERVE_REGEX = new RegExp(`\\[([^\\]]+)\\]\\(${EMBED_PROTOCOL}([^)]+)\\)`, "g");
2199
2344
  var MentionToWikilink = class {
2200
2345
  name = "MentionToWikilink";
2201
2346
  order = 10;
@@ -2244,7 +2389,7 @@ var PreserveMarkerInjector = class {
2244
2389
  }
2245
2390
  formatMarker(marker) {
2246
2391
  const params = Object.entries(marker.params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
2247
- return `%% im-nobsidian:${marker.type}:${params} %%`;
2392
+ return spacedMarker(`${marker.type}:${params}`);
2248
2393
  }
2249
2394
  };
2250
2395
 
@@ -2263,7 +2408,10 @@ var EMOJI_TYPE_MAP = {
2263
2408
  "\u{1F4CC}": "example",
2264
2409
  "\u{1F4AC}": "quote"
2265
2410
  };
2266
- var CALLOUT_PRESERVE_REGEX = /%% im-nobsidian:callout:type=(\w+)(?:&foldable=(open|closed))? %%/g;
2411
+ var CALLOUT_PRESERVE_REGEX = new RegExp(
2412
+ `%% ${MARKER_BRAND_RE}:callout:type=(\\w+)(?:&foldable=(open|closed))? %%`,
2413
+ "g"
2414
+ );
2267
2415
  var CalloutRestorer = class {
2268
2416
  name = "CalloutRestorer";
2269
2417
  order = 20;
@@ -2296,7 +2444,10 @@ function escapeRegex(str) {
2296
2444
  }
2297
2445
 
2298
2446
  // src/converter/post-processors/color-annotator.ts
2299
- var COLOR_MARKER_REGEX = /%% im-nobsidian:color:(\w+(?:_background)?) %%([\s\S]*?)%% im-nobsidian:end %%/g;
2447
+ var COLOR_MARKER_REGEX = new RegExp(
2448
+ `%% ${MARKER_BRAND_RE}:color:(\\w+(?:_background)?) %%([\\s\\S]*?)%% ${MARKER_BRAND_RE}:end %%`,
2449
+ "g"
2450
+ );
2300
2451
  var ColorAnnotator = class {
2301
2452
  name = "ColorAnnotator";
2302
2453
  order = 30;
@@ -2371,7 +2522,10 @@ function normalizeDate(dateStr) {
2371
2522
  }
2372
2523
 
2373
2524
  // src/converter/post-processors/local-image-restorer.ts
2374
- var LOCAL_IMAGE_MARKER_REGEX = />\s*📎\s*[^\n]*\n>\s*%%\s*im-nobsidian:local-image:([^\s]+)\s*%%/g;
2525
+ var LOCAL_IMAGE_MARKER_REGEX = new RegExp(
2526
+ `>\\s*\u{1F4CE}\\s*[^\\n]*\\n>\\s*%%\\s*${MARKER_BRAND_RE}:local-image:([^\\s]+)\\s*%%`,
2527
+ "g"
2528
+ );
2375
2529
  var LocalImageRestorer = class {
2376
2530
  name = "LocalImageRestorer";
2377
2531
  order = 15;
@@ -2385,7 +2539,9 @@ var LocalImageRestorer = class {
2385
2539
  return { content, metadata: input.metadata };
2386
2540
  }
2387
2541
  };
2388
- var YAML_PROPERTIES_REGEX = /```yaml\n# im-nobsidian:properties\n([\s\S]*?)```\n*(?:---\n*)?/;
2542
+ var YAML_PROPERTIES_REGEX = new RegExp(
2543
+ "```yaml\\n# " + MARKER_BRAND_RE + ":properties\\n([\\s\\S]*?)```\\n*(?:---\\n*)?"
2544
+ );
2389
2545
  var LEGACY_TABLE_REGEX = /^\s*\| Property\s*\| Value\s*\|\n\s*\|\s*-{3,}\s*\|\s*-{3,}\s*\|\n((?:\|[^\n]+\|\n?)+)\n*---\n*/;
2390
2546
  var PropertiesTableRestorer = class {
2391
2547
  name = "PropertiesTableRestorer";
@@ -2502,11 +2658,6 @@ function createDefaultPipeline(options) {
2502
2658
  }
2503
2659
  var TRULY_UNSUPPORTED_BLOCK_TYPES = ["unsupported", "template"];
2504
2660
  var DIVIDER_PLACEHOLDER = "\u200B%%IM-NOBSIDIAN_DIVIDER%%\u200B";
2505
- var TOGGLE_START = "%%im-nobsidian:toggle:start%%";
2506
- var TOGGLE_END = "%%im-nobsidian:toggle:end%%";
2507
- var COLUMN_LIST_START = "%%im-nobsidian:column-list:start%%";
2508
- var COLUMN_SEP = "%%im-nobsidian:column%%";
2509
- var COLUMN_LIST_END = "%%im-nobsidian:column-list:end%%";
2510
2661
  var VIDEO_URL_PATTERNS = [
2511
2662
  /^https?:\/\/(www\.)?youtube\.com\/watch/,
2512
2663
  /^https?:\/\/youtu\.be\//,
@@ -2552,7 +2703,7 @@ function richTextToMarkdown(richText) {
2552
2703
  if (t.href) text = `[${text}](${t.href})`;
2553
2704
  const color = a?.color;
2554
2705
  if (color && color !== "default") {
2555
- text = `%% im-nobsidian:color:${color} %%${text}%% im-nobsidian:end %%`;
2706
+ text = `${spacedMarker(`color:${color}`)}${text}${SPACED_END}`;
2556
2707
  }
2557
2708
  return text;
2558
2709
  }).join("");
@@ -2588,9 +2739,9 @@ var BlockConverter = class {
2588
2739
  this.n2m.setCustomTransformer(blockType, (block) => {
2589
2740
  const id = block.id ?? "unknown";
2590
2741
  return Promise.resolve(
2591
- `> [!im-nobsidian-unsupported] Notion \uC804\uC6A9 \uBE14\uB85D
2742
+ `> [!${MARKER_BRAND}-unsupported] Notion \uC804\uC6A9 \uBE14\uB85D
2592
2743
  > type: ${blockType}, id: ${id}
2593
- > %%im-nobsidian:unsupported:type=${blockType}&id=${id}%%`
2744
+ > ${compactMarker(`unsupported:type=${blockType}&id=${id}`)}`
2594
2745
  );
2595
2746
  });
2596
2747
  }
@@ -2740,7 +2891,7 @@ ${COLUMN_LIST_END}`;
2740
2891
  const b = block;
2741
2892
  const title = b.child_database?.title ?? "Database";
2742
2893
  return `> [!database] ${title}
2743
- > %%im-nobsidian:child-database:id=${b.id}&title=${encodeURIComponent(title)}%%`;
2894
+ > ${compactMarker(`child-database:id=${b.id}&title=${encodeURIComponent(title)}`)}`;
2744
2895
  });
2745
2896
  }
2746
2897
  registerLinkToPageTransformer() {
@@ -2766,7 +2917,7 @@ ${COLUMN_LIST_END}`;
2766
2917
  registerTocTransformer() {
2767
2918
  if (!this.n2m) return;
2768
2919
  this.n2m.setCustomTransformer("table_of_contents", async () => {
2769
- return `%%im-nobsidian:toc%%`;
2920
+ return TOC_MARKER;
2770
2921
  });
2771
2922
  }
2772
2923
  registerBreadcrumbTransformer() {
@@ -3180,26 +3331,34 @@ var EXTENSION_TO_MIME = {
3180
3331
  ".tiff": "image/tiff"
3181
3332
  };
3182
3333
  var ImageHandler = class {
3183
- constructor(vaultFs, attachmentFolder = "attachments", notionClient, customFetch) {
3334
+ constructor(vaultFs, attachmentFolder = "attachments", notionClient, customFetch, options) {
3184
3335
  this.vaultFs = vaultFs;
3185
3336
  this.attachmentFolder = attachmentFolder;
3186
3337
  this.notionClient = notionClient;
3187
3338
  this.customFetch = customFetch;
3339
+ this.concurrency = options?.concurrency ?? 3;
3340
+ this.maxRetries = options?.maxRetries ?? 3;
3341
+ this.retryBaseMs = options?.retryBaseMs ?? 1e3;
3342
+ this.maxFileSizeBytes = options?.maxFileSizeBytes ?? 100 * 1024 * 1024;
3188
3343
  }
3189
3344
  vaultFs;
3190
3345
  attachmentFolder;
3191
3346
  notionClient;
3192
3347
  customFetch;
3348
+ concurrency;
3349
+ maxRetries;
3350
+ retryBaseMs;
3351
+ maxFileSizeBytes;
3193
3352
  get fetchFn() {
3194
3353
  return this.customFetch ?? globalThis.fetch;
3195
3354
  }
3196
3355
  async downloadImage(url, pageTitle) {
3197
- const maxRetries = 3;
3356
+ const maxRetries = this.maxRetries;
3198
3357
  let lastError = null;
3199
3358
  for (let attempt = 0; attempt < maxRetries; attempt++) {
3200
3359
  try {
3201
3360
  if (attempt > 0) {
3202
- await new Promise((r) => setTimeout(r, 1e3 * attempt));
3361
+ await new Promise((r) => setTimeout(r, this.retryBaseMs * attempt));
3203
3362
  getLogger().info(
3204
3363
  `[Im-Nobsidian] \uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC7AC\uC2DC\uB3C4 (${attempt + 1}/${maxRetries}): ${pageTitle}`
3205
3364
  );
@@ -3233,7 +3392,7 @@ var ImageHandler = class {
3233
3392
  if (notionMatches.length === 0 && internalMatches.length === 0) {
3234
3393
  return { content: markdown, downloads: [] };
3235
3394
  }
3236
- const sema = new Sema(3);
3395
+ const sema = new Sema(this.concurrency);
3237
3396
  const downloads = [];
3238
3397
  let result = markdown;
3239
3398
  const httpResults = await Promise.allSettled(
@@ -3342,7 +3501,7 @@ var ImageHandler = class {
3342
3501
  if (totalMatches === 0) {
3343
3502
  return { content: markdown, downloads: [] };
3344
3503
  }
3345
- const sema = new Sema(3);
3504
+ const sema = new Sema(this.concurrency);
3346
3505
  const downloads = [];
3347
3506
  let result = markdown;
3348
3507
  for (const match of notionHttpMatches) {
@@ -3437,12 +3596,12 @@ var ImageHandler = class {
3437
3596
  }
3438
3597
  }
3439
3598
  async downloadFile(url, _pageTitle, caption) {
3440
- const maxRetries = 3;
3599
+ const maxRetries = this.maxRetries;
3441
3600
  let lastError = null;
3442
3601
  for (let attempt = 0; attempt < maxRetries; attempt++) {
3443
3602
  try {
3444
3603
  if (attempt > 0) {
3445
- await new Promise((r) => setTimeout(r, 1e3 * attempt));
3604
+ await new Promise((r) => setTimeout(r, this.retryBaseMs * attempt));
3446
3605
  getLogger().info(
3447
3606
  `[Im-Nobsidian] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC7AC\uC2DC\uB3C4 (${attempt + 1}/${maxRetries}): ${caption}`
3448
3607
  );
@@ -3452,7 +3611,7 @@ var ImageHandler = class {
3452
3611
  throw new Error(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
3453
3612
  }
3454
3613
  const contentLength = response.headers.get("content-length");
3455
- const MAX_FILE_SIZE = 100 * 1024 * 1024;
3614
+ const MAX_FILE_SIZE = this.maxFileSizeBytes;
3456
3615
  if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
3457
3616
  const sizeMB = Math.round(parseInt(contentLength, 10) / 1024 / 1024);
3458
3617
  getLogger().debug(`[Im-Nobsidian] \uD30C\uC77C \uC2A4\uD0B5 \u2014 \uB108\uBB34 \uD07C (${sizeMB}MB): ${caption}`);
@@ -3601,15 +3760,16 @@ function getFolderPath(filePath) {
3601
3760
  return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
3602
3761
  }
3603
3762
  var FileHandler = class {
3604
- constructor(vaultFs, notionClient, stateDb) {
3763
+ constructor(vaultFs, notionClient, stateDb, concurrency = 2) {
3605
3764
  this.vaultFs = vaultFs;
3606
3765
  this.notionClient = notionClient;
3607
3766
  this.stateDb = stateDb;
3767
+ this.sema = new Sema(concurrency);
3608
3768
  }
3609
3769
  vaultFs;
3610
3770
  notionClient;
3611
3771
  stateDb;
3612
- sema = new Sema(2);
3772
+ sema;
3613
3773
  async pushFilesForFolder(folderPageId, folderPath) {
3614
3774
  const allFiles = await this.vaultFs.listNonMarkdownFiles();
3615
3775
  const folderFiles = allFiles.filter((f) => getFolderPath(f.path) === folderPath);
@@ -3764,6 +3924,43 @@ var FileHandler = class {
3764
3924
  }
3765
3925
  };
3766
3926
 
3927
+ // src/sync/conflict-detector.ts
3928
+ function resolvePullConflict(input) {
3929
+ const { record, localContent, remoteContent, remoteChange, strategy } = input;
3930
+ const localHash = computeHash(localContent);
3931
+ const localModified = localHash !== record.contentHash;
3932
+ if (!localModified) {
3933
+ return { action: "write", localHash };
3934
+ }
3935
+ if (strategy === "remote-first") {
3936
+ return { action: "write", localHash };
3937
+ }
3938
+ if (strategy === "local-first") {
3939
+ return { action: "skip", localHash };
3940
+ }
3941
+ if (localContent === remoteContent) {
3942
+ return { action: "write", localHash };
3943
+ }
3944
+ const baseContent = record.baseSnapshot?.toString("utf-8") ?? null;
3945
+ if (baseContent !== null && remoteContent === baseContent) {
3946
+ return { action: "skip", localHash };
3947
+ }
3948
+ const conflict = {
3949
+ syncRecord: record,
3950
+ localChange: {
3951
+ path: record.obsidianPath,
3952
+ type: "modified",
3953
+ currentHash: localHash,
3954
+ previousHash: record.contentHash
3955
+ },
3956
+ remoteChange,
3957
+ baseContent,
3958
+ localContent,
3959
+ remoteContent
3960
+ };
3961
+ return { action: "conflict", conflict, localHash };
3962
+ }
3963
+
3767
3964
  // src/utils/sanitize.ts
3768
3965
  var INVALID_CHARS = /[<>:"/\\|?*\x00-\x1f]/g;
3769
3966
  var TRAILING_DOTS = /\.+$/;
@@ -3825,12 +4022,13 @@ var BaseFileGenerator = class {
3825
4022
  this.writeFormulas(lines, options.viewsConfig.views);
3826
4023
  this.writePropertyDisplayNames(lines, options.schema);
3827
4024
  this.writeViews(lines, options.viewsConfig.views, options.schema);
3828
- return lines.join("\n");
4025
+ return lines.join("\n") + "\n";
3829
4026
  }
3830
4027
  writeFilters(lines, folderPath) {
3831
4028
  lines.push("filters:");
3832
4029
  lines.push(" and:");
3833
4030
  lines.push(` - file.inFolder("${folderPath}")`);
4031
+ lines.push(` - file.ext == "md"`);
3834
4032
  lines.push("");
3835
4033
  }
3836
4034
  needsContentCoverFormula(views) {
@@ -3869,25 +4067,25 @@ var BaseFileGenerator = class {
3869
4067
  if (view.order && view.order.length > 0) {
3870
4068
  lines.push(" order:");
3871
4069
  for (const col of view.order) {
3872
- lines.push(` - ${col}`);
4070
+ lines.push(` - ${this.propRef(col)}`);
3873
4071
  }
3874
4072
  }
3875
4073
  if (view.sort && view.sort.length > 0) {
3876
4074
  lines.push(" sort:");
3877
4075
  for (const s of view.sort) {
3878
- lines.push(` - column: ${s.column}`);
4076
+ lines.push(` - property: ${this.propRef(s.property)}`);
3879
4077
  lines.push(` direction: ${s.direction}`);
3880
4078
  }
3881
4079
  }
3882
4080
  if (view.groupBy) {
3883
4081
  lines.push(" groupBy:");
3884
- lines.push(` property: ${view.groupBy.property}`);
4082
+ lines.push(` property: ${this.propRef(view.groupBy.property)}`);
3885
4083
  if (view.groupBy.direction) {
3886
4084
  lines.push(` direction: ${view.groupBy.direction}`);
3887
4085
  }
3888
4086
  }
3889
4087
  if (view.image) {
3890
- lines.push(` image: ${view.image}`);
4088
+ lines.push(` image: ${this.propRef(view.image)}`);
3891
4089
  }
3892
4090
  }
3893
4091
  }
@@ -3901,7 +4099,9 @@ var BaseFileGenerator = class {
3901
4099
  if (view.properties && view.properties.length > 0) {
3902
4100
  const visible = view.properties.filter((p) => p.visible !== false).map((p) => {
3903
4101
  const name = p.propertyName ?? this.resolvePropertyName(p.propertyId, schema);
3904
- return name ? this.toNoteRef(name) : null;
4102
+ if (!name) return null;
4103
+ if (schema[name]?.type === "title") return null;
4104
+ return this.toNoteRef(name);
3905
4105
  }).filter((name) => !!name);
3906
4106
  if (visible.length > 0) {
3907
4107
  result.order = ["file.name", ...visible];
@@ -3909,17 +4109,17 @@ var BaseFileGenerator = class {
3909
4109
  }
3910
4110
  if (view.sorts && view.sorts.length > 0) {
3911
4111
  result.sort = view.sorts.map((s) => {
3912
- let column;
4112
+ let property;
3913
4113
  if (s.property) {
3914
4114
  const propName = this.resolvePropertyName(s.property, schema) ?? s.property;
3915
- column = this.toNoteRef(propName);
4115
+ property = this.toNoteRef(propName);
3916
4116
  } else if (s.timestamp) {
3917
- column = s.timestamp === "created_time" ? "file.ctime" : "file.mtime";
4117
+ property = s.timestamp === "created_time" ? "file.ctime" : "file.mtime";
3918
4118
  } else {
3919
4119
  return null;
3920
4120
  }
3921
4121
  return {
3922
- column,
4122
+ property,
3923
4123
  direction: s.direction === "ascending" ? "ASC" : "DESC"
3924
4124
  };
3925
4125
  }).filter((s) => s !== null);
@@ -3948,12 +4148,29 @@ var BaseFileGenerator = class {
3948
4148
  toNoteRef(propertyName) {
3949
4149
  return propertyName;
3950
4150
  }
4151
+ /**
4152
+ * 속성 참조(order/sort/groupBy/image)를 YAML로 안전하게 출력한다.
4153
+ * `file.name`·`formula.coverImage`·`note.cover` 같은 접두사 식별자와 공백 포함 일반 속성명은
4154
+ * 평문 스칼라로 안전하므로 그대로 두고, 콜론·해시·따옴표 등 YAML 메타문자가 있을 때만 인용한다.
4155
+ */
4156
+ propRef(ref) {
4157
+ if (/^[A-Za-z0-9_][A-Za-z0-9_. ]*$/.test(ref) && !ref.endsWith(" ")) return ref;
4158
+ return `"${ref.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
4159
+ }
3951
4160
  resolvePropertyName(propertyId, schema) {
4161
+ const target = this.decodeId(propertyId);
3952
4162
  for (const [name, prop] of Object.entries(schema)) {
3953
- if (prop.id === propertyId) return name;
4163
+ if (this.decodeId(prop.id) === target) return name;
3954
4164
  }
3955
4165
  return void 0;
3956
4166
  }
4167
+ decodeId(id) {
4168
+ try {
4169
+ return decodeURIComponent(id);
4170
+ } catch {
4171
+ return id;
4172
+ }
4173
+ }
3957
4174
  yamlKey(key) {
3958
4175
  if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) return key;
3959
4176
  return `"${key.replace(/"/g, '\\"')}"`;
@@ -3979,8 +4196,6 @@ var NOTION_PDF_RE = /[\t ]*<pdf src="([^"]*)">([\s\S]*?)<\/pdf>/g;
3979
4196
  var NOTION_FILE_RE = /[\t ]*<file src="([^"]*)">([\s\S]*?)<\/file>/g;
3980
4197
  var NOTION_TAB_RE = /<tab title="([^"]*)">([\s\S]*?)<\/tab>/g;
3981
4198
  var NOTION_UNDERLINE_RE = /<span underline="true">([\s\S]*?)<\/span>/g;
3982
- var TOGGLE_START2 = "%%im-nobsidian:toggle:start%%";
3983
- var TOGGLE_END2 = "%%im-nobsidian:toggle:end%%";
3984
4199
  function notionEnhancedToObsidian(enhanced) {
3985
4200
  let result = enhanced;
3986
4201
  result = convertSyncedBlockRef(result);
@@ -4004,9 +4219,13 @@ function notionEnhancedToObsidian(enhanced) {
4004
4219
  result = removeEmptyBlocks(result);
4005
4220
  result = unescapePipes(result);
4006
4221
  result = unescapeNotionChars(result);
4222
+ result = unescapeWikilinkBrackets(result);
4007
4223
  result = ensureCalloutContinuity(result);
4008
4224
  return result;
4009
4225
  }
4226
+ function unescapeWikilinkBrackets(content) {
4227
+ return content.replace(/\\\[\\\[([\s\S]*?)\\\]\\\]/g, "[[$1]]");
4228
+ }
4010
4229
  function obsidianToNotionEnhanced(obsidian) {
4011
4230
  let result = obsidian;
4012
4231
  result = convertTogglesToHtml(result);
@@ -4016,8 +4235,31 @@ function obsidianToNotionEnhanced(obsidian) {
4016
4235
  result = restoreUnknownBlocks(result);
4017
4236
  result = restoreColorSpans(result);
4018
4237
  result = restoreUnderlineSpans(result);
4238
+ result = convertMentionPageIdToUrl(result);
4239
+ result = restoreWikilinkPreserveLinks(result);
4019
4240
  return result;
4020
4241
  }
4242
+ var MENTION_PAGE_ID_RE = /<mention-page id="([^"]+)">[\s\S]*?<\/mention-page>/g;
4243
+ var WIKILINK_PRESERVE_LINK_RE = new RegExp(
4244
+ `\\[([^\\]]+)\\]\\(${WIKILINK_PROTOCOL}([^)]+)\\)`,
4245
+ "g"
4246
+ );
4247
+ function convertMentionPageIdToUrl(content) {
4248
+ return content.replace(MENTION_PAGE_ID_RE, (_match, id) => {
4249
+ const nohyph = id.replace(/-/g, "");
4250
+ return `<mention-page url="https://www.notion.so/${nohyph}"/>`;
4251
+ });
4252
+ }
4253
+ function restoreWikilinkPreserveLinks(content) {
4254
+ return content.replace(WIKILINK_PRESERVE_LINK_RE, (_match, label, enc) => {
4255
+ let target = enc;
4256
+ try {
4257
+ target = decodeURIComponent(enc);
4258
+ } catch {
4259
+ }
4260
+ return target === label ? `[[${target}]]` : `[[${target}|${label}]]`;
4261
+ });
4262
+ }
4021
4263
  function normalizeCodeBlockToggles(content) {
4022
4264
  return content.replace(
4023
4265
  /^- (.+)\n\t```(\w*)\n([\s\S]*?)\n\t```$/gm,
@@ -4088,12 +4330,14 @@ function preserveUnknownBlocks(content) {
4088
4330
  const typeMatch = /type="([^"]*)"/.exec(attrs);
4089
4331
  const altMatch = /alt="([^"]*)"/.exec(attrs);
4090
4332
  const blockType = altMatch?.[1] ?? typeMatch?.[1] ?? "unknown";
4091
- return `%%im-nobsidian:unknown:id=${id}&type=${blockType}%%`;
4333
+ return compactMarker(`unknown:id=${id}&type=${blockType}`);
4092
4334
  });
4093
4335
  result = result.replace(NOTION_UNKNOWN_URL_RE, (_match, url, attrs) => {
4094
4336
  const altMatch = /alt="([^"]*)"/.exec(attrs);
4095
4337
  const blockType = altMatch?.[1] ?? "bookmark";
4096
- return `%%im-nobsidian:unknown:id=${encodeURIComponent(url)}&type=${blockType}%%`;
4338
+ const marker = compactMarker(`unknown:id=${encodeURIComponent(url)}&type=${blockType}`);
4339
+ const label = blockType === "embed" ? "\u{1F517} Embed" : blockType === "bookmark" ? "\u{1F516} Bookmark" : `\u{1F517} ${blockType}`;
4340
+ return `[${label}](${url})${marker}`;
4097
4341
  });
4098
4342
  return result;
4099
4343
  }
@@ -4141,7 +4385,7 @@ ${indented}`;
4141
4385
  }
4142
4386
  function convertUnderlineSpans(content) {
4143
4387
  return content.replace(NOTION_UNDERLINE_RE, (_match, text) => {
4144
- return `%%im-nobsidian:underline%%${text}%%/underline%%`;
4388
+ return `${compactMarker("underline")}${text}%%/underline%%`;
4145
4389
  });
4146
4390
  }
4147
4391
  function convertTogglesToHtml(content) {
@@ -4161,14 +4405,14 @@ ${bodyText}
4161
4405
  </details>`;
4162
4406
  });
4163
4407
  }
4164
- const startRe = new RegExp(escapeRegex3(TOGGLE_START2), "g");
4165
- const endRe = new RegExp(escapeRegex3(TOGGLE_END2), "g");
4408
+ const startRe = new RegExp(escapeRegex3(TOGGLE_START), "g");
4409
+ const endRe = new RegExp(escapeRegex3(TOGGLE_END), "g");
4166
4410
  safety = 0;
4167
- while (result.includes(TOGGLE_START2) && safety++ < 100) {
4168
- const startIdx = result.indexOf(TOGGLE_START2);
4169
- const endIdx = findMatchingEnd(result, startIdx + TOGGLE_START2.length);
4411
+ while (result.includes(TOGGLE_START) && safety++ < 100) {
4412
+ const startIdx = result.indexOf(TOGGLE_START);
4413
+ const endIdx = findMatchingEnd(result, startIdx + TOGGLE_START.length);
4170
4414
  if (endIdx === -1) break;
4171
- const inner = result.slice(startIdx + TOGGLE_START2.length, endIdx).trim();
4415
+ const inner = result.slice(startIdx + TOGGLE_START.length, endIdx).trim();
4172
4416
  const titleMatch = /^- (.+)$/m.exec(inner);
4173
4417
  const title = titleMatch ? titleMatch[1] : "";
4174
4418
  const body = inner.split("\n").slice(1).map((line) => line.replace(/^ {2}/, "")).join("\n").trim();
@@ -4178,7 +4422,7 @@ ${bodyText}
4178
4422
  ${body}
4179
4423
 
4180
4424
  </details>`;
4181
- result = result.slice(0, startIdx) + html + result.slice(endIdx + TOGGLE_END2.length);
4425
+ result = result.slice(0, startIdx) + html + result.slice(endIdx + TOGGLE_END.length);
4182
4426
  }
4183
4427
  result = result.replace(startRe, "").replace(endRe, "");
4184
4428
  return result;
@@ -4187,16 +4431,16 @@ function findMatchingEnd(content, startFrom) {
4187
4431
  let depth = 1;
4188
4432
  let pos = startFrom;
4189
4433
  while (pos < content.length && depth > 0) {
4190
- const nextStart = content.indexOf(TOGGLE_START2, pos);
4191
- const nextEnd = content.indexOf(TOGGLE_END2, pos);
4434
+ const nextStart = content.indexOf(TOGGLE_START, pos);
4435
+ const nextEnd = content.indexOf(TOGGLE_END, pos);
4192
4436
  if (nextEnd === -1) return -1;
4193
4437
  if (nextStart !== -1 && nextStart < nextEnd) {
4194
4438
  depth++;
4195
- pos = nextStart + TOGGLE_START2.length;
4439
+ pos = nextStart + TOGGLE_START.length;
4196
4440
  } else {
4197
4441
  depth--;
4198
4442
  if (depth === 0) return nextEnd;
4199
- pos = nextEnd + TOGGLE_END2.length;
4443
+ pos = nextEnd + TOGGLE_END.length;
4200
4444
  }
4201
4445
  }
4202
4446
  return -1;
@@ -4269,7 +4513,7 @@ function convertPageLinks(content) {
4269
4513
  function convertColorSpans(content) {
4270
4514
  return content.replace(
4271
4515
  NOTION_COLOR_SPAN_RE,
4272
- (_match, color, text) => `%%im-nobsidian:color:${color}%%${text}%%/color%%`
4516
+ (_match, color, text) => `${compactMarker(`color:${color}`)}${text}%%/color%%`
4273
4517
  );
4274
4518
  }
4275
4519
  function convertDatabaseBlocks(content) {
@@ -4370,7 +4614,10 @@ function restoreTabBlocks(content) {
4370
4614
  return `<tab title="${title}">${unquoted}</tab>`;
4371
4615
  });
4372
4616
  }
4373
- var OBSIDIAN_UNKNOWN_RE = /%%im-nobsidian:unknown:id=([^&]+)&type=([^%]+)%%/g;
4617
+ var OBSIDIAN_UNKNOWN_RE = new RegExp(
4618
+ `(?:\\[[^\\]]*\\]\\([^)]*\\))?%%${MARKER_BRAND_RE}:unknown:id=([^&]+)&type=([^%]+)%%`,
4619
+ "g"
4620
+ );
4374
4621
  function restoreUnknownBlocks(content) {
4375
4622
  return content.replace(OBSIDIAN_UNKNOWN_RE, (_match, id, type) => {
4376
4623
  const decoded = decodeURIComponent(id);
@@ -4380,13 +4627,19 @@ function restoreUnknownBlocks(content) {
4380
4627
  return `<unknown id="${id}" type="${type}"/>`;
4381
4628
  });
4382
4629
  }
4383
- var OBSIDIAN_COLOR_RE = /%%im-nobsidian:color:([^%]+)%%([\s\S]*?)%%\/color%%/g;
4630
+ var OBSIDIAN_COLOR_RE = new RegExp(
4631
+ `%%${MARKER_BRAND_RE}:color:([^%]+)%%([\\s\\S]*?)%%\\/color%%`,
4632
+ "g"
4633
+ );
4384
4634
  function restoreColorSpans(content) {
4385
4635
  return content.replace(OBSIDIAN_COLOR_RE, (_match, color, text) => {
4386
4636
  return `<span color="${color}">${text}</span>`;
4387
4637
  });
4388
4638
  }
4389
- var OBSIDIAN_UNDERLINE_RE = /%%im-nobsidian:underline%%([\s\S]*?)%%\/underline%%/g;
4639
+ var OBSIDIAN_UNDERLINE_RE = new RegExp(
4640
+ `%%${MARKER_BRAND_RE}:underline%%([\\s\\S]*?)%%\\/underline%%`,
4641
+ "g"
4642
+ );
4390
4643
  function restoreUnderlineSpans(content) {
4391
4644
  return content.replace(OBSIDIAN_UNDERLINE_RE, (_match, text) => {
4392
4645
  return `<span underline="true">${text}</span>`;
@@ -4452,16 +4705,18 @@ var DatabaseSyncer = class {
4452
4705
  async pullAll() {
4453
4706
  const databases = this.config.notion.databases;
4454
4707
  if (!databases || databases.length === 0) {
4455
- return { created: 0, updated: 0, failed: [] };
4708
+ return { created: 0, updated: 0, conflicts: [], failed: [] };
4456
4709
  }
4457
4710
  let created = 0;
4458
4711
  let updated = 0;
4712
+ const conflicts = [];
4459
4713
  const failed = [];
4460
4714
  for (const dbConfig of databases) {
4461
4715
  try {
4462
4716
  const result = await this.pullDatabase(dbConfig);
4463
4717
  created += result.created;
4464
4718
  updated += result.updated;
4719
+ conflicts.push(...result.conflicts);
4465
4720
  failed.push(...result.failed);
4466
4721
  } catch (error) {
4467
4722
  getLogger().warn(`[DB Sync] DB ${dbConfig.databaseId} pull \uC2E4\uD328:`, error);
@@ -4472,12 +4727,12 @@ var DatabaseSyncer = class {
4472
4727
  });
4473
4728
  }
4474
4729
  }
4475
- return { created, updated, failed };
4730
+ return { created, updated, conflicts, failed };
4476
4731
  }
4477
4732
  async pushAll() {
4478
4733
  const databases = this.config.notion.databases;
4479
4734
  if (!databases || databases.length === 0) {
4480
- return { created: 0, updated: 0, failed: [] };
4735
+ return { created: 0, updated: 0, conflicts: [], failed: [] };
4481
4736
  }
4482
4737
  let created = 0;
4483
4738
  let updated = 0;
@@ -4497,7 +4752,7 @@ var DatabaseSyncer = class {
4497
4752
  });
4498
4753
  }
4499
4754
  }
4500
- return { created, updated, failed };
4755
+ return { created, updated, conflicts: [], failed };
4501
4756
  }
4502
4757
  async pullDatabase(dbConfig) {
4503
4758
  const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
@@ -4511,14 +4766,19 @@ var DatabaseSyncer = class {
4511
4766
  );
4512
4767
  let created = 0;
4513
4768
  let updated = 0;
4769
+ const conflicts = [];
4514
4770
  const failed = [];
4515
4771
  for (const page of pages) {
4516
4772
  try {
4517
4773
  const record = this.stateDb.getByNotionId(page.id);
4518
4774
  if (record) {
4519
4775
  if (page.last_edited_time === record.notionLastEdited) continue;
4520
- await this.pullDatabasePage(page, dbConfig);
4521
- updated++;
4776
+ const outcome = await this.pullDatabasePage(page, dbConfig);
4777
+ if (outcome.action === "written") {
4778
+ updated++;
4779
+ } else if (outcome.action === "conflict") {
4780
+ conflicts.push(outcome.conflict);
4781
+ }
4522
4782
  } else {
4523
4783
  await this.pullDatabasePage(page, dbConfig);
4524
4784
  created++;
@@ -4531,12 +4791,14 @@ var DatabaseSyncer = class {
4531
4791
  });
4532
4792
  }
4533
4793
  }
4534
- getLogger().debug(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
4535
- return { created, updated, failed };
4794
+ getLogger().debug(
4795
+ `[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8, ${conflicts.length} \uCDA9\uB3CC`
4796
+ );
4797
+ return { created, updated, conflicts, failed };
4536
4798
  }
4537
4799
  async pullDatabaseViews(dbConfig, force = false) {
4538
4800
  try {
4539
- const configPath = ".im-nobsidian/db-views.json";
4801
+ const configPath = DB_VIEWS_PATH;
4540
4802
  if (!force) {
4541
4803
  try {
4542
4804
  const existing = await this.vaultFs.readFile(configPath);
@@ -4546,7 +4808,7 @@ var DatabaseSyncer = class {
4546
4808
  }
4547
4809
  }
4548
4810
  const viewsConfig = await this.notionClient.getDatabaseViewsConfig(dbConfig.databaseId);
4549
- await this.vaultFs.ensureFolder(".im-nobsidian");
4811
+ await this.vaultFs.ensureFolder(INTERNAL_DIR);
4550
4812
  let allViewsConfigs = {};
4551
4813
  try {
4552
4814
  const existing = await this.vaultFs.readFile(configPath);
@@ -4658,6 +4920,33 @@ var DatabaseSyncer = class {
4658
4920
  },
4659
4921
  { properties }
4660
4922
  );
4923
+ if (existingRecord) {
4924
+ let localContent = "";
4925
+ try {
4926
+ localContent = await this.vaultFs.readFile(filePath);
4927
+ } catch {
4928
+ localContent = "";
4929
+ }
4930
+ const resolution = resolvePullConflict({
4931
+ record: existingRecord,
4932
+ localContent,
4933
+ remoteContent: finalContent,
4934
+ remoteChange: {
4935
+ pageId: page.id,
4936
+ type: "modified",
4937
+ lastEdited: page.last_edited_time,
4938
+ previousEdited: existingRecord.notionLastEdited
4939
+ },
4940
+ strategy: this.config.sync.conflictStrategy
4941
+ });
4942
+ if (resolution.action === "skip") {
4943
+ return { action: "skipped", path: filePath };
4944
+ }
4945
+ if (resolution.action === "conflict") {
4946
+ this.stateDb.updateStatus(existingRecord.id, "conflict");
4947
+ return { action: "conflict", path: filePath, conflict: resolution.conflict };
4948
+ }
4949
+ }
4661
4950
  await this.vaultFs.ensureFolder(dbConfig.localFolder);
4662
4951
  await this.vaultFs.writeFile(filePath, finalContent);
4663
4952
  const hash = computeHash(finalContent);
@@ -4681,7 +4970,7 @@ var DatabaseSyncer = class {
4681
4970
  aliases: extractAliases(properties)
4682
4971
  });
4683
4972
  });
4684
- return filePath;
4973
+ return { action: "written", path: filePath };
4685
4974
  }
4686
4975
  async pushDatabase(dbConfig) {
4687
4976
  const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
@@ -4689,6 +4978,8 @@ var DatabaseSyncer = class {
4689
4978
  const allFiles = await this.vaultFs.listMarkdownFiles();
4690
4979
  const prefix = dbConfig.localFolder.endsWith("/") ? dbConfig.localFolder : dbConfig.localFolder + "/";
4691
4980
  const dbFiles = allFiles.filter((f) => f.path.startsWith(prefix));
4981
+ const livePaths = new Set(dbFiles.map((f) => f.path));
4982
+ const orphanByHash = this.buildOrphanHashIndex(dbConfig.databaseId, livePaths);
4692
4983
  let created = 0;
4693
4984
  let updated = 0;
4694
4985
  const failed = [];
@@ -4706,19 +4997,50 @@ var DatabaseSyncer = class {
4706
4997
  const notionProps = this.propertyMapper.toNotionProperties(frontmatter, title);
4707
4998
  const enhanced = obsidianToNotionEnhanced(body.trim());
4708
4999
  if (record?.notionPageId) {
4709
- await this.notionClient.updatePageProperties(record.notionPageId, notionProps);
4710
5000
  try {
5001
+ await this.notionClient.updatePageProperties(record.notionPageId, notionProps);
4711
5002
  await this.notionClient.replacePageMarkdown(record.notionPageId, enhanced);
4712
- } catch {
5003
+ } catch (error) {
5004
+ this.stateDb.updateStatus(record.id, "error");
5005
+ failed.push({
5006
+ path: file.path,
5007
+ operation: "update",
5008
+ error: error instanceof Error ? error.message : String(error)
5009
+ });
5010
+ continue;
4713
5011
  }
4714
5012
  const updatedPage = await this.notionClient.getPage(record.notionPageId);
4715
5013
  this.stateDb.transaction(() => {
4716
5014
  this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
4717
5015
  this.stateDb.updateStatus(record.id, "synced");
4718
5016
  this.stateDb.setNotionLastEdited(record.id, updatedPage.last_edited_time);
5017
+ this.stateDb.upsertWikilink({
5018
+ obsidianPath: file.path,
5019
+ notionPageId: record.notionPageId,
5020
+ title,
5021
+ aliases: extractAliases(frontmatter)
5022
+ });
4719
5023
  });
4720
5024
  updated++;
4721
5025
  } else {
5026
+ const renamed = orphanByHash.get(hash);
5027
+ if (renamed?.notionPageId) {
5028
+ const movedPageId = renamed.notionPageId;
5029
+ orphanByHash.delete(hash);
5030
+ this.stateDb.transaction(() => {
5031
+ this.stateDb.updatePath(renamed.id, file.path);
5032
+ this.stateDb.updateHash(renamed.id, hash, Buffer.from(content, "utf-8"));
5033
+ this.stateDb.updateStatus(renamed.id, "synced");
5034
+ this.stateDb.upsertWikilink({
5035
+ obsidianPath: file.path,
5036
+ notionPageId: movedPageId,
5037
+ title,
5038
+ aliases: extractAliases(frontmatter)
5039
+ });
5040
+ });
5041
+ updated++;
5042
+ continue;
5043
+ }
4722
5044
  const page = await this.notionClient.createPageWithMarkdown({
4723
5045
  parentId: dbConfig.databaseId,
4724
5046
  parentType: "database",
@@ -4757,7 +5079,21 @@ var DatabaseSyncer = class {
4757
5079
  }
4758
5080
  }
4759
5081
  getLogger().debug(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
4760
- return { created, updated, failed };
5082
+ return { created, updated, conflicts: [], failed };
5083
+ }
5084
+ /**
5085
+ * rename 감지를 위한 고아 레코드 인덱스(내용 해시 → 레코드)를 만든다.
5086
+ * 고아 = 해당 DB 소속 db-row 레코드 중 로컬 파일이 더 이상 존재하지 않는(livePaths 에 없는) 것.
5087
+ * 같은 해시가 여러 고아에 걸리면 첫 항목을 유지한다(희박한 케이스).
5088
+ */
5089
+ buildOrphanHashIndex(databaseId, livePaths) {
5090
+ const index = /* @__PURE__ */ new Map();
5091
+ for (const record of this.stateDb.getAll()) {
5092
+ if (record.fileType === "db-row" && record.notionParentId === databaseId && record.notionPageId && !livePaths.has(record.obsidianPath) && !index.has(record.contentHash)) {
5093
+ index.set(record.contentHash, record);
5094
+ }
5095
+ }
5096
+ return index;
4761
5097
  }
4762
5098
  };
4763
5099
  function extractAliases(properties) {
@@ -4774,6 +5110,26 @@ function extractTitleFromPath(filePath) {
4774
5110
  return filename.replace(/\.md$/, "");
4775
5111
  }
4776
5112
 
5113
+ // src/utils/pool.ts
5114
+ async function runPool(items, worker, options) {
5115
+ const total = items.length;
5116
+ if (total === 0) return;
5117
+ const workerCount = Math.max(1, Math.min(Math.floor(options.concurrency) || 1, total));
5118
+ let cursor = 0;
5119
+ const runWorker = async () => {
5120
+ for (; ; ) {
5121
+ if (options.signal?.aborted) return;
5122
+ const index = cursor++;
5123
+ if (index >= total) return;
5124
+ try {
5125
+ await worker(items[index], index);
5126
+ } catch {
5127
+ }
5128
+ }
5129
+ };
5130
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
5131
+ }
5132
+
4777
5133
  // src/sync/orchestrator.ts
4778
5134
  var SyncOrchestrator = class {
4779
5135
  constructor(config, stateDb, notionClient, vaultFs, customFetch) {
@@ -4790,9 +5146,20 @@ var SyncOrchestrator = class {
4790
5146
  vaultFs,
4791
5147
  config.paths.attachments,
4792
5148
  notionClient,
4793
- customFetch
5149
+ customFetch,
5150
+ {
5151
+ concurrency: config.advanced.mediaConcurrency,
5152
+ maxRetries: config.advanced.mediaMaxRetries,
5153
+ retryBaseMs: config.advanced.mediaRetryBaseMs,
5154
+ maxFileSizeBytes: config.advanced.maxFileSizeBytes
5155
+ }
5156
+ );
5157
+ this.fileHandler = new FileHandler(
5158
+ vaultFs,
5159
+ notionClient,
5160
+ stateDb,
5161
+ config.advanced.fileConcurrency
4794
5162
  );
4795
- this.fileHandler = new FileHandler(vaultFs, notionClient, stateDb);
4796
5163
  this.propertyMapper = new PropertyMapper();
4797
5164
  this.databaseSyncer = new DatabaseSyncer(
4798
5165
  config,
@@ -4822,6 +5189,11 @@ var SyncOrchestrator = class {
4822
5189
  dbSchemaLoaded = false;
4823
5190
  _pullImageCount = 0;
4824
5191
  _pullFileCount = 0;
5192
+ // pull 중 각 페이지 markdown(<database url=.../>)에서 추출한 인라인 DB 참조.
5193
+ // key: 하이픈 제거 databaseId, value: 그 DB가 박힌 부모 페이지 id.
5194
+ // 블록 트리 재귀 없이 markdown 신호만으로 컬럼/synced_block 등 깊이 중첩된
5195
+ // child_database 까지 발견해 폴더+.base 동기화 대상으로 등록한다.
5196
+ _inlineDbRefs = /* @__PURE__ */ new Map();
4825
5197
  async push(options) {
4826
5198
  const startTime = Date.now();
4827
5199
  if (this.config.sync.direction === "pull") {
@@ -4880,87 +5252,68 @@ var SyncOrchestrator = class {
4880
5252
  for (const folderPath of sortedFolders) {
4881
5253
  await this.ensureFolderPage(folderPath);
4882
5254
  }
4883
- const sema = new Sema(this.config.advanced.concurrency);
4884
- let created = 0;
4885
- let updated = 0;
4886
- let deleted = 0;
5255
+ const counts = { created: 0, updated: 0, deleted: 0 };
4887
5256
  const failed = [];
4888
5257
  let completed = 0;
4889
5258
  const total = filtered.length;
4890
- const tasks = filtered.map((change) => async () => {
4891
- await sema.acquire();
4892
- try {
4893
- if (options?.signal?.aborted) return;
4894
- const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
4895
- options?.onProgress?.(++completed, total, { path: change.path, operation: op });
4896
- switch (change.type) {
4897
- case "created":
4898
- await this.pushCreate(change.path);
4899
- created++;
4900
- break;
4901
- case "modified":
4902
- await this.pushUpdate(change.path);
4903
- updated++;
4904
- break;
4905
- case "moved":
4906
- await this.pushMove(change.path);
4907
- updated++;
4908
- break;
4909
- case "deleted":
4910
- await this.pushDelete(change.path);
4911
- deleted++;
4912
- break;
4913
- }
4914
- } catch (error) {
4915
- failed.push({
4916
- path: change.path,
4917
- operation: change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update",
4918
- error: error instanceof Error ? error.message : String(error)
4919
- });
4920
- } finally {
4921
- sema.release();
5259
+ const opOf = (change) => change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
5260
+ const applyPushChange = async (change) => {
5261
+ switch (change.type) {
5262
+ case "created":
5263
+ await this.pushCreate(change.path);
5264
+ counts.created++;
5265
+ break;
5266
+ case "modified":
5267
+ await this.pushUpdate(change.path);
5268
+ counts.updated++;
5269
+ break;
5270
+ case "moved":
5271
+ await this.pushMove(change.path);
5272
+ counts.updated++;
5273
+ break;
5274
+ case "deleted":
5275
+ await this.pushDelete(change.path);
5276
+ counts.deleted++;
5277
+ break;
4922
5278
  }
4923
- });
4924
- await Promise.all(tasks.map((t) => t()));
4925
- if (failed.length > 0) {
4926
- const retryTargets = failed.splice(0, failed.length);
4927
- getLogger().info(`[Im-Nobsidian] Push ${retryTargets.length}\uAC74 \uC7AC\uC2DC\uB3C4 (2\uCD08 \uD6C4)`);
4928
- await new Promise((r) => setTimeout(r, 2e3));
4929
- for (const target of retryTargets) {
4930
- const change = filtered.find((c) => c.path === target.path);
4931
- if (!change) {
4932
- failed.push(target);
4933
- continue;
4934
- }
5279
+ };
5280
+ const retryQueue = [];
5281
+ await runPool(
5282
+ filtered,
5283
+ async (change) => {
5284
+ if (options?.signal?.aborted) return;
5285
+ options?.onProgress?.(++completed, total, { path: change.path, operation: opOf(change) });
4935
5286
  try {
4936
- switch (change.type) {
4937
- case "created":
4938
- await this.pushCreate(change.path);
4939
- created++;
4940
- break;
4941
- case "modified":
4942
- await this.pushUpdate(change.path);
4943
- updated++;
4944
- break;
4945
- case "moved":
4946
- await this.pushMove(change.path);
4947
- updated++;
4948
- break;
4949
- case "deleted":
4950
- await this.pushDelete(change.path);
4951
- deleted++;
4952
- break;
4953
- }
4954
- getLogger().info(`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC131\uACF5: ${change.path}`);
4955
- } catch (error) {
4956
- failed.push({
4957
- path: change.path,
4958
- operation: target.operation,
4959
- error: error instanceof Error ? error.message : String(error)
4960
- });
4961
- getLogger().warn(`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC2E4\uD328: ${change.path}`);
5287
+ await applyPushChange(change);
5288
+ } catch {
5289
+ retryQueue.push(change);
4962
5290
  }
4963
- }
5291
+ },
5292
+ { concurrency: this.config.advanced.concurrency, signal: options?.signal }
5293
+ );
5294
+ if (retryQueue.length > 0) {
5295
+ const retryWaitMs = this.config.advanced.retryWaitMs;
5296
+ getLogger().info(
5297
+ `[Im-Nobsidian] Push ${retryQueue.length}\uAC74 \uC7AC\uC2DC\uB3C4 (${retryWaitMs / 1e3}\uCD08 \uD6C4)`
5298
+ );
5299
+ await new Promise((r) => setTimeout(r, retryWaitMs));
5300
+ await runPool(
5301
+ retryQueue,
5302
+ async (change) => {
5303
+ try {
5304
+ await applyPushChange(change);
5305
+ getLogger().info(`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC131\uACF5: ${change.path}`);
5306
+ } catch (error) {
5307
+ failed.push({
5308
+ path: change.path,
5309
+ operation: opOf(change),
5310
+ error: error instanceof Error ? error.message : String(error)
5311
+ });
5312
+ getLogger().warn(`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC2E4\uD328: ${change.path}`);
5313
+ }
5314
+ },
5315
+ { concurrency: this.config.advanced.concurrency, signal: options?.signal }
5316
+ );
4964
5317
  }
4965
5318
  if (this.config.sync.syncFiles !== false) {
4966
5319
  try {
@@ -4975,8 +5328,8 @@ var SyncOrchestrator = class {
4975
5328
  if ((this.config.notion.databases?.length ?? 0) > 0) {
4976
5329
  try {
4977
5330
  const dbResult = await this.databaseSyncer.pushAll();
4978
- created += dbResult.created;
4979
- updated += dbResult.updated;
5331
+ counts.created += dbResult.created;
5332
+ counts.updated += dbResult.updated;
4980
5333
  failed.push(...dbResult.failed);
4981
5334
  } catch (error) {
4982
5335
  getLogger().warn("[Im-Nobsidian] DB Push \uC911 \uC624\uB958:", error);
@@ -4985,7 +5338,13 @@ var SyncOrchestrator = class {
4985
5338
  this.stateDb.setMeta("last_push_at", (/* @__PURE__ */ new Date()).toISOString());
4986
5339
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
4987
5340
  this.stateDb.setMeta("push_in_progress", "");
4988
- return { created, updated, deleted, failed, duration: Date.now() - startTime };
5341
+ return {
5342
+ created: counts.created,
5343
+ updated: counts.updated,
5344
+ deleted: counts.deleted,
5345
+ failed,
5346
+ duration: Date.now() - startTime
5347
+ };
4989
5348
  }
4990
5349
  async pull(options) {
4991
5350
  const startTime = Date.now();
@@ -5005,11 +5364,10 @@ var SyncOrchestrator = class {
5005
5364
  return emptyResult;
5006
5365
  }
5007
5366
  this.cleanupInterruptedSync();
5008
- let created = 0;
5009
- let updated = 0;
5010
- let deleted = 0;
5367
+ const counts = { created: 0, updated: 0, deleted: 0 };
5011
5368
  this._pullImageCount = 0;
5012
5369
  this._pullFileCount = 0;
5370
+ this._inlineDbRefs.clear();
5013
5371
  const conflicts = [];
5014
5372
  const writtenPaths = [];
5015
5373
  const failed = [];
@@ -5021,7 +5379,7 @@ var SyncOrchestrator = class {
5021
5379
  return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
5022
5380
  }) : remoteChanges;
5023
5381
  if (filtered.length === 0 && (this.config.notion.databases?.length ?? 0) === 0) {
5024
- const hasDbResults = await this.pullDiscoveredDatabases(writtenPaths, failed);
5382
+ const hasDbResults = await this.pullDiscoveredDatabases(writtenPaths, failed, conflicts);
5025
5383
  if (!hasDbResults.created && !hasDbResults.updated) {
5026
5384
  this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
5027
5385
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
@@ -5035,7 +5393,7 @@ var SyncOrchestrator = class {
5035
5393
  created: hasDbResults.created,
5036
5394
  updated: hasDbResults.updated,
5037
5395
  deleted: 0,
5038
- conflicts: [],
5396
+ conflicts,
5039
5397
  writtenPaths,
5040
5398
  failed,
5041
5399
  duration: Date.now() - startTime,
@@ -5073,113 +5431,94 @@ var SyncOrchestrator = class {
5073
5431
  };
5074
5432
  }
5075
5433
  this.stateDb.setMeta("pull_in_progress", "true");
5076
- const sema = new Sema(this.config.advanced.concurrency);
5077
- let pullCompleted = 0;
5078
5434
  const pullTotal = filtered.length;
5079
- const tasks = filtered.map((change) => async () => {
5080
- await sema.acquire();
5081
- try {
5082
- if (options?.signal?.aborted) return;
5083
- let resultPath;
5084
- switch (change.type) {
5085
- case "created": {
5086
- const path = await this.pullCreate(change.pageId);
5087
- writtenPaths.push(path);
5088
- created++;
5089
- resultPath = path;
5090
- break;
5091
- }
5092
- case "modified": {
5093
- const result = await this.pullUpdate(change);
5094
- if (result.conflict) {
5095
- conflicts.push(result.conflict);
5096
- } else if (result.path) {
5097
- writtenPaths.push(result.path);
5098
- updated++;
5099
- }
5100
- resultPath = result.path;
5101
- break;
5435
+ let pullCompleted = 0;
5436
+ const toFailure = (change, error) => {
5437
+ const record = this.stateDb.getByNotionId(change.pageId);
5438
+ return {
5439
+ path: record?.obsidianPath ?? change.pageId,
5440
+ operation: change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update",
5441
+ error: error instanceof Error ? error.message : String(error)
5442
+ };
5443
+ };
5444
+ const applyChange = async (change) => {
5445
+ switch (change.type) {
5446
+ case "created": {
5447
+ const path = await this.pullCreate(change.pageId);
5448
+ writtenPaths.push(path);
5449
+ counts.created++;
5450
+ return path;
5451
+ }
5452
+ case "modified": {
5453
+ const result = await this.pullUpdate(change);
5454
+ if (result.conflict) {
5455
+ conflicts.push(result.conflict);
5456
+ } else if (result.path) {
5457
+ writtenPaths.push(result.path);
5458
+ counts.updated++;
5102
5459
  }
5103
- case "deleted": {
5104
- const record2 = this.stateDb.getByNotionId(change.pageId);
5105
- resultPath = record2?.obsidianPath;
5106
- const path = await this.pullDelete(change.pageId);
5107
- if (path) {
5108
- deleted++;
5109
- resultPath = path;
5110
- }
5111
- break;
5460
+ return result.path;
5461
+ }
5462
+ case "deleted": {
5463
+ const record = this.stateDb.getByNotionId(change.pageId);
5464
+ const path = await this.pullDelete(change.pageId);
5465
+ if (path) {
5466
+ counts.deleted++;
5467
+ return path;
5112
5468
  }
5469
+ return record?.obsidianPath;
5113
5470
  }
5114
- const record = this.stateDb.getByNotionId(change.pageId);
5115
- const displayPath = resultPath ?? record?.obsidianPath ?? change.pageId;
5116
- const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
5117
- options?.onProgress?.(++pullCompleted, pullTotal, { path: displayPath, operation: op });
5118
- } catch (error) {
5119
- const record = this.stateDb.getByNotionId(change.pageId);
5120
- failed.push({
5121
- path: record?.obsidianPath ?? change.pageId,
5122
- operation: change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update",
5123
- error: error instanceof Error ? error.message : String(error)
5124
- });
5125
- } finally {
5126
- sema.release();
5471
+ default:
5472
+ return void 0;
5127
5473
  }
5128
- });
5129
- await Promise.all(tasks.map((t) => t()));
5130
- if (failed.length > 0) {
5131
- const retryTargets = failed.splice(0, failed.length);
5132
- getLogger().info(`[Im-Nobsidian] Pull ${retryTargets.length}\uAC74 \uC7AC\uC2DC\uB3C4 (2\uCD08 \uD6C4)`);
5133
- await new Promise((r) => setTimeout(r, 2e3));
5134
- for (const target of retryTargets) {
5135
- const change = filtered.find((c) => {
5136
- const record = this.stateDb.getByNotionId(c.pageId);
5137
- return (record?.obsidianPath ?? c.pageId) === target.path;
5138
- });
5139
- if (!change) {
5140
- failed.push(target);
5141
- continue;
5142
- }
5474
+ };
5475
+ const retryQueue = [];
5476
+ await runPool(
5477
+ filtered,
5478
+ async (change) => {
5479
+ if (options?.signal?.aborted) return;
5143
5480
  try {
5144
- switch (change.type) {
5145
- case "created": {
5146
- const path = await this.pullCreate(change.pageId);
5147
- writtenPaths.push(path);
5148
- created++;
5149
- break;
5150
- }
5151
- case "modified": {
5152
- const result = await this.pullUpdate(change);
5153
- if (result.conflict) {
5154
- conflicts.push(result.conflict);
5155
- } else if (result.path) {
5156
- writtenPaths.push(result.path);
5157
- updated++;
5158
- }
5159
- break;
5160
- }
5161
- case "deleted": {
5162
- const path = await this.pullDelete(change.pageId);
5163
- if (path) deleted++;
5164
- break;
5165
- }
5166
- }
5167
- getLogger().info(`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC131\uACF5: ${target.path}`);
5168
- } catch (error) {
5169
- failed.push({
5170
- path: target.path,
5171
- operation: target.operation,
5172
- error: error instanceof Error ? error.message : String(error)
5173
- });
5174
- getLogger().warn(`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC2E4\uD328: ${target.path}`);
5481
+ const resultPath = await applyChange(change);
5482
+ const record = this.stateDb.getByNotionId(change.pageId);
5483
+ const displayPath = resultPath ?? record?.obsidianPath ?? change.pageId;
5484
+ const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
5485
+ options?.onProgress?.(++pullCompleted, pullTotal, { path: displayPath, operation: op });
5486
+ } catch {
5487
+ retryQueue.push(change);
5175
5488
  }
5176
- }
5489
+ },
5490
+ { concurrency: this.config.advanced.concurrency, signal: options?.signal }
5491
+ );
5492
+ if (retryQueue.length > 0) {
5493
+ const retryWaitMs = this.config.advanced.retryWaitMs;
5494
+ getLogger().info(
5495
+ `[Im-Nobsidian] Pull ${retryQueue.length}\uAC74 \uC7AC\uC2DC\uB3C4 (${retryWaitMs / 1e3}\uCD08 \uD6C4)`
5496
+ );
5497
+ await new Promise((r) => setTimeout(r, retryWaitMs));
5498
+ await runPool(
5499
+ retryQueue,
5500
+ async (change) => {
5501
+ try {
5502
+ await applyChange(change);
5503
+ const record = this.stateDb.getByNotionId(change.pageId);
5504
+ getLogger().info(
5505
+ `[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC131\uACF5: ${record?.obsidianPath ?? change.pageId}`
5506
+ );
5507
+ } catch (error) {
5508
+ const failure = toFailure(change, error);
5509
+ failed.push(failure);
5510
+ getLogger().warn(`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC2E4\uD328: ${failure.path}`);
5511
+ }
5512
+ },
5513
+ { concurrency: this.config.advanced.concurrency, signal: options?.signal }
5514
+ );
5177
5515
  }
5178
5516
  if ((this.config.notion.databases?.length ?? 0) > 0) {
5179
5517
  try {
5180
5518
  const dbResult = await this.databaseSyncer.pullAll();
5181
- created += dbResult.created;
5182
- updated += dbResult.updated;
5519
+ counts.created += dbResult.created;
5520
+ counts.updated += dbResult.updated;
5521
+ conflicts.push(...dbResult.conflicts);
5183
5522
  failed.push(...dbResult.failed);
5184
5523
  if (dbResult.created + dbResult.updated > 0) {
5185
5524
  const dbPaths = this.stateDb.getByStatus("synced").filter((r) => r.fileType === "db-row").map((r) => r.obsidianPath);
@@ -5190,9 +5529,9 @@ var SyncOrchestrator = class {
5190
5529
  }
5191
5530
  }
5192
5531
  {
5193
- const dbDiscovery = await this.pullDiscoveredDatabases(writtenPaths, failed);
5194
- created += dbDiscovery.created;
5195
- updated += dbDiscovery.updated;
5532
+ const dbDiscovery = await this.pullDiscoveredDatabases(writtenPaths, failed, conflicts);
5533
+ counts.created += dbDiscovery.created;
5534
+ counts.updated += dbDiscovery.updated;
5196
5535
  }
5197
5536
  const linkTargetPaths = writtenPaths.length > 0 ? writtenPaths : [];
5198
5537
  const linkCount = linkTargetPaths.length > 0 ? await this.resolveNotionLinks(linkTargetPaths) : 0;
@@ -5200,9 +5539,9 @@ var SyncOrchestrator = class {
5200
5539
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
5201
5540
  this.stateDb.setMeta("pull_in_progress", "");
5202
5541
  return {
5203
- created,
5204
- updated,
5205
- deleted,
5542
+ created: counts.created,
5543
+ updated: counts.updated,
5544
+ deleted: counts.deleted,
5206
5545
  conflicts,
5207
5546
  writtenPaths,
5208
5547
  failed,
@@ -5334,7 +5673,48 @@ var SyncOrchestrator = class {
5334
5673
  }
5335
5674
  return allDbs;
5336
5675
  }
5337
- async pullDiscoveredDatabases(writtenPaths, failed) {
5676
+ /**
5677
+ * 자동 발견된 child_database 1개를 DatabaseSyncer 설정으로 변환한다.
5678
+ * 블록 스캔/markdown 양쪽 발견 경로에서 공통으로 사용한다.
5679
+ * - localFolder: 부모 페이지의 obsidianPath 기준으로 폴더를 잡아 원본 중첩 구조를 보존
5680
+ * (예: 부모 "AI Engineer (1).md" + DB "Minirecord Project" → "AI Engineer (1)/Minirecord-Project")
5681
+ * - 제목 취득 실패 시 부모 폴더명 기반 안전 이름으로 폴백
5682
+ */
5683
+ async buildDiscoveredDbConfig(dbId, parentPageId) {
5684
+ try {
5685
+ const dbTitle = await this.notionClient.getDatabaseTitle(dbId);
5686
+ const parentEntry = parentPageId ? this.stateDb.getByNotionId(parentPageId) : null;
5687
+ let parentFolder = "";
5688
+ if (parentEntry?.obsidianPath) {
5689
+ const obsPath = parentEntry.obsidianPath;
5690
+ if (obsPath.endsWith(".md")) {
5691
+ const parts = obsPath.split("/");
5692
+ parts.pop();
5693
+ parentFolder = parts.length > 0 ? parts.join("/") : obsPath.replace(/\.md$/, "");
5694
+ } else {
5695
+ parentFolder = obsPath;
5696
+ }
5697
+ }
5698
+ let safeName;
5699
+ if (dbTitle) {
5700
+ safeName = dbTitle.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-");
5701
+ } else {
5702
+ const parentName = parentFolder.split("/").pop() || "";
5703
+ safeName = parentName ? `${parentName}-DB` : `db-${dbId.replace(/-/g, "").slice(0, 8)}`;
5704
+ }
5705
+ if (!safeName) safeName = `db-${dbId.replace(/-/g, "").slice(0, 8)}`;
5706
+ if (!parentFolder) parentFolder = "databases";
5707
+ return {
5708
+ databaseId: dbId,
5709
+ localFolder: `${parentFolder}/${safeName}`,
5710
+ titleProperty: "Name"
5711
+ };
5712
+ } catch (error) {
5713
+ getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uC124\uC815 \uC0DD\uC131 \uC2E4\uD328:`, error);
5714
+ return null;
5715
+ }
5716
+ }
5717
+ async pullDiscoveredDatabases(writtenPaths, failed, conflicts) {
5338
5718
  if (this.isDatabaseMode) return { created: 0, updated: 0 };
5339
5719
  let created = 0;
5340
5720
  let updated = 0;
@@ -5348,53 +5728,45 @@ var SyncOrchestrator = class {
5348
5728
  dbConfigs = [];
5349
5729
  }
5350
5730
  }
5731
+ const configuredIds = new Set(
5732
+ (this.config.notion.databases ?? []).map((d) => d.databaseId.replace(/-/g, ""))
5733
+ );
5734
+ const knownIds = /* @__PURE__ */ new Set([
5735
+ ...configuredIds,
5736
+ ...dbConfigs.map((c) => c.databaseId.replace(/-/g, ""))
5737
+ ]);
5738
+ let changed = false;
5351
5739
  if (dbConfigs.length === 0) {
5352
- const discoveredDbs = await this.discoverChildDatabases();
5353
- const configuredIds = new Set(
5354
- (this.config.notion.databases ?? []).map((d) => d.databaseId.replace(/-/g, ""))
5355
- );
5356
- const newDbs = discoveredDbs.filter((db) => !configuredIds.has(db.dbId.replace(/-/g, "")));
5357
- for (const { dbId, parentPageId } of newDbs) {
5358
- try {
5359
- const dbTitle = await this.notionClient.getDatabaseTitle(dbId);
5360
- const parentEntry = parentPageId ? this.stateDb.getByNotionId(parentPageId) : null;
5361
- let parentFolder = "";
5362
- if (parentEntry?.obsidianPath) {
5363
- const obsPath = parentEntry.obsidianPath;
5364
- if (obsPath.endsWith(".md")) {
5365
- const parts = obsPath.split("/");
5366
- parts.pop();
5367
- parentFolder = parts.length > 0 ? parts.join("/") : obsPath.replace(/\.md$/, "");
5368
- } else {
5369
- parentFolder = obsPath;
5370
- }
5371
- }
5372
- let safeName;
5373
- if (dbTitle) {
5374
- safeName = dbTitle.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-");
5375
- } else {
5376
- const parentName = parentFolder.split("/").pop() || "";
5377
- safeName = parentName ? `${parentName}-DB` : `db-${dbId.slice(0, 8)}`;
5378
- }
5379
- if (!parentFolder) parentFolder = "databases";
5380
- dbConfigs.push({
5381
- databaseId: dbId,
5382
- localFolder: `${parentFolder}/${safeName}`,
5383
- titleProperty: "Name"
5384
- });
5385
- } catch (error) {
5386
- getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
5740
+ const discovered = await this.discoverChildDatabases();
5741
+ for (const { dbId, parentPageId } of discovered) {
5742
+ const nohyph = dbId.replace(/-/g, "");
5743
+ if (knownIds.has(nohyph)) continue;
5744
+ const cfg = await this.buildDiscoveredDbConfig(dbId, parentPageId);
5745
+ if (cfg) {
5746
+ dbConfigs.push(cfg);
5747
+ knownIds.add(nohyph);
5748
+ changed = true;
5387
5749
  }
5388
5750
  }
5389
- if (dbConfigs.length > 0) {
5390
- this.stateDb.setMeta("discovered_dbs", JSON.stringify(dbConfigs));
5751
+ }
5752
+ for (const [nohyph, parentPageId] of this._inlineDbRefs) {
5753
+ if (knownIds.has(nohyph)) continue;
5754
+ const cfg = await this.buildDiscoveredDbConfig(normalizeNotionId(nohyph), parentPageId);
5755
+ if (cfg) {
5756
+ dbConfigs.push(cfg);
5757
+ knownIds.add(nohyph);
5758
+ changed = true;
5391
5759
  }
5392
5760
  }
5761
+ if (changed && dbConfigs.length > 0) {
5762
+ this.stateDb.setMeta("discovered_dbs", JSON.stringify(dbConfigs));
5763
+ }
5393
5764
  for (const dbConfig of dbConfigs) {
5394
5765
  try {
5395
5766
  const dbResult = await this.databaseSyncer.pullDatabase(dbConfig);
5396
5767
  created += dbResult.created;
5397
5768
  updated += dbResult.updated;
5769
+ conflicts.push(...dbResult.conflicts);
5398
5770
  failed.push(...dbResult.failed);
5399
5771
  if (dbResult.created + dbResult.updated > 0) {
5400
5772
  const dbPaths = this.stateDb.getByStatus("synced").filter((r) => r.fileType === "db-row").map((r) => r.obsidianPath);
@@ -5438,7 +5810,7 @@ var SyncOrchestrator = class {
5438
5810
  });
5439
5811
  content = resolved;
5440
5812
  const resolved2 = content.replace(
5441
- /\[([^\]]+)\]\(\/([a-f0-9]{32})\?pvs=\d+\)/g,
5813
+ /\[([^\]]+)\]\(\/(?:p\/)?([a-f0-9]{32})(?:\?[^)]*)?\)/g,
5442
5814
  (_match, text, id) => {
5443
5815
  const title = idToTitle.get(id);
5444
5816
  if (title) {
@@ -5465,6 +5837,11 @@ var SyncOrchestrator = class {
5465
5837
  this.dbSchemaLoaded = true;
5466
5838
  }
5467
5839
  async pushCreate(path) {
5840
+ const existingRecord = this.stateDb.getByPath(path);
5841
+ if (existingRecord?.notionPageId) {
5842
+ await this.pushUpdate(path);
5843
+ return;
5844
+ }
5468
5845
  const content = await this.vaultFs.readFile(path);
5469
5846
  const title = extractTitle(path);
5470
5847
  const parentId = await this.resolveNotionParent(path);
@@ -5499,6 +5876,20 @@ var SyncOrchestrator = class {
5499
5876
  conversionResult.content,
5500
5877
  effectiveProperties
5501
5878
  );
5879
+ this.stateDb.upsert({
5880
+ obsidianPath: path,
5881
+ notionPageId: page.id,
5882
+ notionParentId: parentId,
5883
+ contentHash: "",
5884
+ notionLastEdited: page.last_edited_time,
5885
+ localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
5886
+ syncDirection: "both",
5887
+ fileType: this.isFolderNote(path) ? "folder-note" : "file",
5888
+ status: "pending",
5889
+ baseSnapshot: null,
5890
+ localMtime: null,
5891
+ localFileSize: null
5892
+ });
5502
5893
  await this.imageHandler.uploadAndAppendImages(page.id, conversionResult.images);
5503
5894
  const hash = computeHash(content);
5504
5895
  const fileStat = await this.vaultFs.getFileStat(path);
@@ -5596,7 +5987,10 @@ var SyncOrchestrator = class {
5596
5987
  throw error;
5597
5988
  }
5598
5989
  }
5599
- this.stateDb.delete(record.id);
5990
+ this.stateDb.transaction(() => {
5991
+ this.stateDb.delete(record.id);
5992
+ this.stateDb.deleteWikilink(record.obsidianPath);
5993
+ });
5600
5994
  } else {
5601
5995
  this.stateDb.updateStatus(record.id, "pending");
5602
5996
  }
@@ -5639,7 +6033,9 @@ var SyncOrchestrator = class {
5639
6033
  } while (cursor);
5640
6034
  remotePages = allPages;
5641
6035
  } else {
5642
- remotePages = await this.notionClient.getChildPagesRecursive(this.config.notion.rootPageId);
6036
+ const allAccessible = await this.notionClient.searchAllPages();
6037
+ const underRoot = await this.filterPagesUnderRoot(allAccessible);
6038
+ remotePages = underRoot.map((p) => ({ id: p.id, last_edited_time: p.last_edited_time }));
5643
6039
  }
5644
6040
  for (const page of remotePages) {
5645
6041
  const record = this.stateDb.getByNotionId(page.id);
@@ -5711,12 +6107,84 @@ var SyncOrchestrator = class {
5711
6107
  if (notionIdsEqual(parentId, this.config.notion.rootPageId)) return true;
5712
6108
  return !!this.stateDb.getByNotionId(parentId);
5713
6109
  }
6110
+ /**
6111
+ * search API로 받은 전체 접근 가능 페이지 중 rootPageId 하위(자손)만 남긴다.
6112
+ * 각 페이지의 parent 체인을 root에 닿을 때까지 거슬러 올라가며 판정한다.
6113
+ * - 부모가 이미 받은 페이지면 추가 API 호출 없이 메모리에서 해석(공통 경로)
6114
+ * - 부모가 검색 결과에 없으면(데이터베이스·미공유 조상 등) getPage로 1회 조회 후 캐시
6115
+ * - 조회 불가/순환/깊이 초과 시 보수적으로 root 하위가 아님으로 판정
6116
+ * 판정 결과는 체인 전체에 메모이즈해 형제 페이지 처리 시 재사용한다.
6117
+ */
6118
+ async filterPagesUnderRoot(allPages) {
6119
+ const root = normalizeNotionId(this.config.notion.rootPageId);
6120
+ const byId = /* @__PURE__ */ new Map();
6121
+ for (const p of allPages) byId.set(normalizeNotionId(p.id), p);
6122
+ const verdict = /* @__PURE__ */ new Map();
6123
+ const isUnderRoot = async (start) => {
6124
+ const chain = [];
6125
+ let current = start;
6126
+ let result = false;
6127
+ while (current) {
6128
+ const id = normalizeNotionId(current.id);
6129
+ if (id === root) {
6130
+ result = true;
6131
+ break;
6132
+ }
6133
+ const memo = verdict.get(id);
6134
+ if (memo !== void 0) {
6135
+ result = memo;
6136
+ break;
6137
+ }
6138
+ if (chain.includes(id)) {
6139
+ result = false;
6140
+ break;
6141
+ }
6142
+ chain.push(id);
6143
+ const parentId = await this.extractParentId(current);
6144
+ if (!parentId) {
6145
+ result = false;
6146
+ break;
6147
+ }
6148
+ const pid = normalizeNotionId(parentId);
6149
+ if (pid === root) {
6150
+ result = true;
6151
+ break;
6152
+ }
6153
+ const memoParent = verdict.get(pid);
6154
+ if (memoParent !== void 0) {
6155
+ result = memoParent;
6156
+ break;
6157
+ }
6158
+ let parentPage = byId.get(pid) ?? null;
6159
+ if (!parentPage) {
6160
+ try {
6161
+ parentPage = await this.notionClient.getPage(parentId);
6162
+ byId.set(pid, parentPage);
6163
+ } catch {
6164
+ result = false;
6165
+ break;
6166
+ }
6167
+ }
6168
+ current = parentPage;
6169
+ }
6170
+ for (const id of chain) verdict.set(id, result);
6171
+ return result;
6172
+ };
6173
+ const out = [];
6174
+ for (const p of allPages) {
6175
+ if (normalizeNotionId(p.id) === root) continue;
6176
+ if (await isUnderRoot(p)) out.push(p);
6177
+ }
6178
+ return out;
6179
+ }
5714
6180
  async pullCreate(pageId) {
5715
6181
  const page = await this.notionClient.getPage(pageId);
5716
6182
  const title = this.notionClient.extractTitle(page);
5717
6183
  const safeName = sanitizeFileName(title);
5718
6184
  const parentPath = await this.resolveParentPath(page);
5719
- const firstChildren = await this.notionClient.listChildren(pageId, { pageSize: 100 });
6185
+ const firstChildren = await this.notionClient.listChildren(pageId, {
6186
+ pageSize: this.config.advanced.pageSize
6187
+ });
5720
6188
  const hasChildPages = firstChildren.results.some((b) => "type" in b && b.type === "child_page");
5721
6189
  const markdown = await this.fetchPageMarkdown(pageId);
5722
6190
  const hasContent = markdown.trim().length > 0;
@@ -5836,46 +6304,45 @@ var SyncOrchestrator = class {
5836
6304
  } catch {
5837
6305
  localContent = "";
5838
6306
  }
5839
- const localHash = computeHash(localContent);
5840
- const localModified = localHash !== record.contentHash;
5841
- if (localModified) {
5842
- const strategy = this.config.sync.conflictStrategy;
5843
- if (strategy === "remote-first") ; else if (strategy === "local-first") {
5844
- return { path: record.obsidianPath };
5845
- } else {
5846
- const conflict = {
5847
- syncRecord: record,
5848
- localChange: {
5849
- path: record.obsidianPath,
5850
- type: "modified",
5851
- currentHash: localHash,
5852
- previousHash: record.contentHash
5853
- },
5854
- remoteChange: change,
5855
- baseContent: record.baseSnapshot?.toString("utf-8") ?? null,
5856
- localContent,
5857
- remoteContent
5858
- };
5859
- this.stateDb.updateStatus(record.id, "conflict");
5860
- return { conflict };
5861
- }
6307
+ const resolution = resolvePullConflict({
6308
+ record,
6309
+ localContent,
6310
+ remoteContent,
6311
+ remoteChange: change,
6312
+ strategy: this.config.sync.conflictStrategy
6313
+ });
6314
+ if (resolution.action === "skip") {
6315
+ return { path: record.obsidianPath };
6316
+ }
6317
+ if (resolution.action === "conflict") {
6318
+ this.stateDb.updateStatus(record.id, "conflict");
6319
+ return { conflict: resolution.conflict };
5862
6320
  }
5863
6321
  await this.vaultFs.writeFile(record.obsidianPath, remoteContent);
5864
6322
  const updateStat = await this.vaultFs.getFileStat(record.obsidianPath);
5865
6323
  const newHash = computeHash(remoteContent);
5866
- this.stateDb.upsert({
5867
- obsidianPath: record.obsidianPath,
5868
- notionPageId: change.pageId,
5869
- notionParentId: record.notionParentId,
5870
- contentHash: newHash,
5871
- notionLastEdited: page.last_edited_time,
5872
- localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
5873
- syncDirection: record.syncDirection,
5874
- fileType: record.fileType,
5875
- status: "synced",
5876
- baseSnapshot: Buffer.from(remoteContent, "utf-8"),
5877
- localMtime: updateStat?.mtime ?? null,
5878
- localFileSize: updateStat?.size ?? null
6324
+ this.stateDb.transaction(() => {
6325
+ this.stateDb.upsert({
6326
+ obsidianPath: record.obsidianPath,
6327
+ notionPageId: change.pageId,
6328
+ notionParentId: record.notionParentId,
6329
+ contentHash: newHash,
6330
+ notionLastEdited: page.last_edited_time,
6331
+ localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
6332
+ syncDirection: record.syncDirection,
6333
+ fileType: record.fileType,
6334
+ status: "synced",
6335
+ baseSnapshot: Buffer.from(remoteContent, "utf-8"),
6336
+ localMtime: updateStat?.mtime ?? null,
6337
+ localFileSize: updateStat?.size ?? null
6338
+ });
6339
+ const aliases = extractAliases2(properties);
6340
+ this.stateDb.upsertWikilink({
6341
+ obsidianPath: record.obsidianPath,
6342
+ notionPageId: change.pageId,
6343
+ title,
6344
+ aliases
6345
+ });
5879
6346
  });
5880
6347
  return { path: record.obsidianPath };
5881
6348
  }
@@ -5888,7 +6355,10 @@ var SyncOrchestrator = class {
5888
6355
  } catch {
5889
6356
  }
5890
6357
  }
5891
- this.stateDb.delete(record.id);
6358
+ this.stateDb.transaction(() => {
6359
+ this.stateDb.delete(record.id);
6360
+ this.stateDb.deleteWikilink(record.obsidianPath);
6361
+ });
5892
6362
  return record.obsidianPath;
5893
6363
  }
5894
6364
  async ensureFolderPage(folderPath) {
@@ -5896,7 +6366,12 @@ var SyncOrchestrator = class {
5896
6366
  const folderNoteRecord = this.stateDb.getByPath(folderNotePath);
5897
6367
  if (folderNoteRecord?.notionPageId) {
5898
6368
  const existing2 = this.stateDb.getByPath(folderPath);
5899
- if (existing2) this.stateDb.delete(existing2.id);
6369
+ if (existing2) {
6370
+ this.stateDb.transaction(() => {
6371
+ this.stateDb.delete(existing2.id);
6372
+ this.stateDb.deleteWikilink(existing2.obsidianPath);
6373
+ });
6374
+ }
5900
6375
  return;
5901
6376
  }
5902
6377
  const existing = this.stateDb.getByPath(folderPath);
@@ -5955,7 +6430,10 @@ var SyncOrchestrator = class {
5955
6430
  const folderNotePath = `${folder.obsidianPath}/${folderName}.md`;
5956
6431
  const noteRecord = this.stateDb.getByPath(folderNotePath);
5957
6432
  if (noteRecord?.notionPageId) {
5958
- this.stateDb.delete(folder.id);
6433
+ this.stateDb.transaction(() => {
6434
+ this.stateDb.delete(folder.id);
6435
+ this.stateDb.deleteWikilink(folder.obsidianPath);
6436
+ });
5959
6437
  }
5960
6438
  }
5961
6439
  }
@@ -6041,12 +6519,38 @@ var SyncOrchestrator = class {
6041
6519
  if (this.config.conversion.preferMarkdownApi !== false) {
6042
6520
  try {
6043
6521
  const result = await this.notionClient.getPageMarkdown(pageId);
6044
- return notionEnhancedToObsidian(result.markdown);
6522
+ this.collectInlineDbRefs(pageId, result.markdown);
6523
+ return this.resolveNotionIdWikilinks(notionEnhancedToObsidian(result.markdown));
6045
6524
  } catch {
6046
6525
  }
6047
6526
  }
6048
6527
  return this.blockConverter.notionBlocksToMarkdown(pageId);
6049
6528
  }
6529
+ // Markdown API는 인라인 데이터베이스를 다음처럼 렌더한다(컬럼/synced_block 내부 포함):
6530
+ // <database url="https://www.notion.so/<id-nohyph>" inline="true"
6531
+ // data-source-url="collection://<ds-id>">Title</database>
6532
+ // url 안의 32자리 hex 가 databaseId 이므로, 별도 블록 트리 재귀 없이 이 태그만 파싱해
6533
+ // 깊이 중첩된 child_database 까지 폴더+.base 동기화 대상으로 등록한다.
6534
+ collectInlineDbRefs(parentPageId, rawMarkdown) {
6535
+ const re = /<database\b[^>]*\burl="https:\/\/www\.notion\.so\/([a-f0-9]{32})"[^>]*>/g;
6536
+ let m;
6537
+ while ((m = re.exec(rawMarkdown)) !== null) {
6538
+ const dbId = m[1];
6539
+ if (dbId) this._inlineDbRefs.set(dbId, parentPageId);
6540
+ }
6541
+ }
6542
+ // pull 시 url 기반 page mention 은 `[[notion:<id>]]` 로 1차 변환된다(notionEnhancedToObsidian).
6543
+ // 이를 state DB 역조회로 원래 `[[제목]]` 위키링크로 복원해 push↔pull 라운드트립을 수렴시킨다.
6544
+ // 볼트 밖/미추적 페이지면 `[[notion:<id>]]` 를 그대로 두어 정보 손실을 막는다.
6545
+ resolveNotionIdWikilinks(markdown) {
6546
+ return markdown.replace(/\[\[notion:([a-f0-9]{32})\]\]/g, (match, id) => {
6547
+ const record = this.stateDb.getByNotionId(normalizeNotionId(id));
6548
+ if (!record?.obsidianPath) return match;
6549
+ const base = record.obsidianPath.split("/").pop() ?? record.obsidianPath;
6550
+ const title = base.replace(/\.md$/, "");
6551
+ return `[[${title}]]`;
6552
+ });
6553
+ }
6050
6554
  async extractParentId(page) {
6051
6555
  const parent = page.parent;
6052
6556
  if (parent.type === "page_id") return parent.page_id ?? null;
@@ -6123,7 +6627,7 @@ var NodeVaultFS = class {
6123
6627
  excludePatterns;
6124
6628
  loadImNobsidianIgnore() {
6125
6629
  try {
6126
- const ignorePath = join(this.rootPath, ".im-nobsidian-ignore");
6630
+ const ignorePath = join(this.rootPath, IGNORE_FILE);
6127
6631
  const content = readFileSync(ignorePath, "utf-8");
6128
6632
  return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
6129
6633
  } catch {
@@ -6276,8 +6780,8 @@ var NodeVaultFS = class {
6276
6780
  };
6277
6781
 
6278
6782
  // src/converter/pre-processors/inline-db.ts
6279
- var INLINE_DB_START = /%% im-nobsidian:inline-db:([^\s]+) %%/g;
6280
- var INLINE_DB_END = /%% im-nobsidian:end %%/g;
6783
+ var INLINE_DB_START = new RegExp(`%% ${MARKER_BRAND_RE}:inline-db:([^\\s]+) %%`, "g");
6784
+ var INLINE_DB_END = new RegExp(`%% ${MARKER_BRAND_RE}:end %%`, "g");
6281
6785
  var InlineDBParser = class {
6282
6786
  name = "InlineDBParser";
6283
6787
  order = 40;
@@ -6327,82 +6831,126 @@ function threeWayMerge(base, local, remote) {
6327
6831
  const baseLines = base.split("\n");
6328
6832
  const localLines = local.split("\n");
6329
6833
  const remoteLines = remote.split("\n");
6330
- const localDiff = computeDiff(baseLines, localLines);
6331
- const remoteDiff = computeDiff(baseLines, remoteLines);
6834
+ const hunks = [
6835
+ ...computeHunks(baseLines, localLines, 0),
6836
+ ...computeHunks(baseLines, remoteLines, 2)
6837
+ ];
6838
+ hunks.sort((a, b) => a.baseStart - b.baseStart || a.side - b.side);
6332
6839
  const merged = [];
6333
6840
  const conflicts = [];
6334
- let success = true;
6335
- let baseIdx = 0;
6336
- while (baseIdx < baseLines.length || localDiff.length > 0 || remoteDiff.length > 0) {
6337
- const localChange = localDiff.find((d) => d.baseStart === baseIdx);
6338
- const remoteChange = remoteDiff.find((d) => d.baseStart === baseIdx);
6339
- if (localChange && remoteChange) {
6340
- if (arraysEqual(localChange.newLines, remoteChange.newLines)) {
6341
- merged.push(...localChange.newLines);
6342
- } else {
6343
- success = false;
6344
- conflicts.push({
6345
- localLines: localChange.newLines,
6346
- remoteLines: remoteChange.newLines
6347
- });
6348
- merged.push("<<<<<<< LOCAL");
6349
- merged.push(...localChange.newLines);
6350
- merged.push("=======");
6351
- merged.push(...remoteChange.newLines);
6352
- merged.push(">>>>>>> REMOTE");
6353
- }
6354
- baseIdx = Math.max(localChange.baseEnd, remoteChange.baseEnd);
6355
- localDiff.splice(localDiff.indexOf(localChange), 1);
6356
- remoteDiff.splice(remoteDiff.indexOf(remoteChange), 1);
6357
- } else if (localChange) {
6358
- merged.push(...localChange.newLines);
6359
- baseIdx = localChange.baseEnd;
6360
- localDiff.splice(localDiff.indexOf(localChange), 1);
6361
- } else if (remoteChange) {
6362
- merged.push(...remoteChange.newLines);
6363
- baseIdx = remoteChange.baseEnd;
6364
- remoteDiff.splice(remoteDiff.indexOf(remoteChange), 1);
6841
+ let cursor = 0;
6842
+ let i = 0;
6843
+ while (i < hunks.length) {
6844
+ const first = hunks[i];
6845
+ if (first.baseStart > cursor) {
6846
+ merged.push(...baseLines.slice(cursor, first.baseStart));
6847
+ }
6848
+ const regionStart = first.baseStart;
6849
+ let regionEnd = first.baseEnd;
6850
+ const group = [first];
6851
+ i++;
6852
+ while (i < hunks.length) {
6853
+ const next = hunks[i];
6854
+ const overlaps = next.baseStart < regionEnd || regionEnd === regionStart && next.baseStart === regionStart;
6855
+ if (!overlaps) break;
6856
+ regionEnd = Math.max(regionEnd, next.baseEnd);
6857
+ group.push(next);
6858
+ i++;
6859
+ }
6860
+ const baseRegion = baseLines.slice(regionStart, regionEnd);
6861
+ const localRegion = applyHunks(baseLines, group, 0, regionStart, regionEnd);
6862
+ const remoteRegion = applyHunks(baseLines, group, 2, regionStart, regionEnd);
6863
+ if (arraysEqual(localRegion, baseRegion)) {
6864
+ merged.push(...remoteRegion);
6865
+ } else if (arraysEqual(remoteRegion, baseRegion)) {
6866
+ merged.push(...localRegion);
6867
+ } else if (arraysEqual(localRegion, remoteRegion)) {
6868
+ merged.push(...localRegion);
6365
6869
  } else {
6366
- if (baseIdx < baseLines.length) {
6367
- merged.push(baseLines[baseIdx]);
6368
- }
6369
- baseIdx++;
6870
+ conflicts.push({ localLines: localRegion, remoteLines: remoteRegion });
6871
+ merged.push("<<<<<<< LOCAL");
6872
+ merged.push(...localRegion);
6873
+ merged.push("=======");
6874
+ merged.push(...remoteRegion);
6875
+ merged.push(">>>>>>> REMOTE");
6370
6876
  }
6877
+ cursor = regionEnd;
6878
+ }
6879
+ if (cursor < baseLines.length) {
6880
+ merged.push(...baseLines.slice(cursor));
6371
6881
  }
6372
6882
  return {
6373
- success,
6883
+ success: conflicts.length === 0,
6374
6884
  merged: merged.join("\n"),
6375
6885
  conflicts
6376
6886
  };
6377
6887
  }
6378
- function computeDiff(base, modified) {
6888
+ function computeHunks(base, modified, side) {
6889
+ const matches = lcsMatches(base, modified);
6379
6890
  const hunks = [];
6380
- let baseIdx = 0;
6381
- let modIdx = 0;
6382
- while (baseIdx < base.length && modIdx < modified.length) {
6383
- if (base[baseIdx] === modified[modIdx]) {
6384
- baseIdx++;
6385
- modIdx++;
6386
- } else {
6387
- const hunkStart = baseIdx;
6388
- const newLines = [];
6389
- while (modIdx < modified.length && base[baseIdx] !== modified[modIdx]) {
6390
- newLines.push(modified[modIdx]);
6391
- modIdx++;
6392
- }
6393
- baseIdx++;
6394
- hunks.push({ baseStart: hunkStart, baseEnd: baseIdx, newLines });
6891
+ let prevBase = -1;
6892
+ let prevMod = -1;
6893
+ const emitGap = (curBase, curMod) => {
6894
+ const baseLen = curBase - (prevBase + 1);
6895
+ const modLen = curMod - (prevMod + 1);
6896
+ if (baseLen > 0 || modLen > 0) {
6897
+ hunks.push({
6898
+ baseStart: prevBase + 1,
6899
+ baseEnd: curBase,
6900
+ content: modified.slice(prevMod + 1, curMod),
6901
+ side
6902
+ });
6395
6903
  }
6904
+ };
6905
+ for (const [b, m] of matches) {
6906
+ emitGap(b, m);
6907
+ prevBase = b;
6908
+ prevMod = m;
6396
6909
  }
6397
- if (modIdx < modified.length) {
6398
- hunks.push({
6399
- baseStart: baseIdx,
6400
- baseEnd: base.length,
6401
- newLines: modified.slice(modIdx)
6402
- });
6403
- }
6910
+ emitGap(base.length, modified.length);
6404
6911
  return hunks;
6405
6912
  }
6913
+ function applyHunks(baseLines, group, side, regionStart, regionEnd) {
6914
+ const out = [];
6915
+ let b = regionStart;
6916
+ for (const hunk of group) {
6917
+ if (hunk.side !== side) continue;
6918
+ if (hunk.baseStart > b) out.push(...baseLines.slice(b, hunk.baseStart));
6919
+ out.push(...hunk.content);
6920
+ b = hunk.baseEnd;
6921
+ }
6922
+ if (b < regionEnd) out.push(...baseLines.slice(b, regionEnd));
6923
+ return out;
6924
+ }
6925
+ function lcsMatches(a, b) {
6926
+ const n = a.length;
6927
+ const m = b.length;
6928
+ const dp = new Array(n + 1);
6929
+ for (let i2 = 0; i2 <= n; i2++) dp[i2] = new Uint32Array(m + 1);
6930
+ for (let i2 = n - 1; i2 >= 0; i2--) {
6931
+ const row = dp[i2];
6932
+ const next = dp[i2 + 1];
6933
+ const ai = a[i2];
6934
+ for (let j2 = m - 1; j2 >= 0; j2--) {
6935
+ row[j2] = ai === b[j2] ? next[j2 + 1] + 1 : Math.max(next[j2], row[j2 + 1]);
6936
+ }
6937
+ }
6938
+ const matches = [];
6939
+ let i = 0;
6940
+ let j = 0;
6941
+ while (i < n && j < m) {
6942
+ if (a[i] === b[j]) {
6943
+ matches.push([i, j]);
6944
+ i++;
6945
+ j++;
6946
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
6947
+ i++;
6948
+ } else {
6949
+ j++;
6950
+ }
6951
+ }
6952
+ return matches;
6953
+ }
6406
6954
  function arraysEqual(a, b) {
6407
6955
  if (a.length !== b.length) return false;
6408
6956
  return a.every((val, idx) => val === b[idx]);
@@ -6621,7 +7169,12 @@ var FileWatcher = class {
6621
7169
  watcher = null;
6622
7170
  start() {
6623
7171
  this.watcher = watch(this.rootPath, {
6624
- ignored: [/(^|[/\\])\./, "**/node_modules/**", "**/.im-nobsidian/**"],
7172
+ // 이벤트 경로를 rootPath 기준 "볼트 상대경로"로 방출한다. 동기화 엔진의 변경
7173
+ // 감지·paths 필터는 모두 볼트 상대경로(listMarkdownFileStats 기준)를 쓰므로,
7174
+ // 절대경로를 그대로 넘기면 push 의 startsWith 스코프 필터에서 누락돼 변경 파일이
7175
+ // 실제로 동기화되지 않는다(증분 watch 의 핵심 버그).
7176
+ cwd: this.rootPath,
7177
+ ignored: [/(^|[/\\])\./, "**/node_modules/**", INTERNAL_DIR_GLOB],
6625
7178
  persistent: true,
6626
7179
  ignoreInitial: true,
6627
7180
  awaitWriteFinish: {
@@ -6629,9 +7182,13 @@ var FileWatcher = class {
6629
7182
  pollInterval: 100
6630
7183
  }
6631
7184
  });
6632
- this.watcher.on("add", (path) => this.callback("add", path));
6633
- this.watcher.on("change", (path) => this.callback("change", path));
6634
- this.watcher.on("unlink", (path) => this.callback("unlink", path));
7185
+ this.watcher.on("add", (path) => this.callback("add", this.toVaultPath(path)));
7186
+ this.watcher.on("change", (path) => this.callback("change", this.toVaultPath(path)));
7187
+ this.watcher.on("unlink", (path) => this.callback("unlink", this.toVaultPath(path)));
7188
+ }
7189
+ // 볼트 경로는 항상 "/" 구분자를 사용한다. Windows 백슬래시를 정규화.
7190
+ toVaultPath(path) {
7191
+ return sep === "/" ? path : path.split(sep).join("/");
6635
7192
  }
6636
7193
  async stop() {
6637
7194
  if (this.watcher) {
@@ -6711,16 +7268,19 @@ var WatchSyncService = class {
6711
7268
  this.syncQueued = true;
6712
7269
  return;
6713
7270
  }
7271
+ this.clearDebounce();
7272
+ if (this.pendingChanges.size === 0) return;
6714
7273
  this.syncing = true;
6715
- const changedPaths = [...this.pendingChanges.keys()];
7274
+ const batch = new Map(this.pendingChanges);
6716
7275
  this.pendingChanges.clear();
6717
7276
  try {
6718
7277
  this.options.onSyncStart?.();
6719
- const result = await this.orchestrator.sync({
6720
- paths: changedPaths.length > 0 ? changedPaths : void 0
6721
- });
7278
+ const result = await this.orchestrator.sync({ paths: [...batch.keys()] });
6722
7279
  this.options.onSyncComplete?.(result);
6723
7280
  } catch (error) {
7281
+ for (const [path, event] of batch) {
7282
+ if (!this.pendingChanges.has(path)) this.pendingChanges.set(path, event);
7283
+ }
6724
7284
  const err = error instanceof Error ? error : new Error(String(error));
6725
7285
  this.options.onSyncError?.(err);
6726
7286
  } finally {
@@ -6728,6 +7288,8 @@ var WatchSyncService = class {
6728
7288
  if (this.syncQueued) {
6729
7289
  this.syncQueued = false;
6730
7290
  void this.executeSync();
7291
+ } else if (this.pendingChanges.size > 0) {
7292
+ this.scheduleSync();
6731
7293
  }
6732
7294
  }
6733
7295
  }
@@ -6898,7 +7460,6 @@ function parseDate(value) {
6898
7460
  const match = /^(\d{4}-\d{2}-\d{2})/.exec(value);
6899
7461
  return match?.[1] ?? null;
6900
7462
  }
6901
- var DB_VIEWS_PATH = ".im-nobsidian/db-views.json";
6902
7463
  var ViewDataProvider = class {
6903
7464
  constructor(vaultFs) {
6904
7465
  this.vaultFs = vaultFs;
@@ -7105,6 +7666,6 @@ ${lines.join("\n")}
7105
7666
  }`;
7106
7667
  }
7107
7668
 
7108
- export { BaseFileGenerator, 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, filterEntries, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, normalizeNotionId, notionIdsEqual, sanitizeFileName, setLogger, sortEntries, threeWayMerge };
7669
+ export { BaseFileGenerator, BlockConverter, CONFIG_FILE, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConversionPipeline, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FrontmatterExtractor, FrontmatterGenerator, GITIGNORE_ENTRY, HtmlAnnotationStripper, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, ImageHandler, InlineDBParser, LocalImageRestorer, MARKER_BRAND, MathNormalizer, MentionToWikilink, NodeVaultFS, NotionBlockBuilder, NotionClient, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, STATE_DB_FILE, STATE_DB_PATH, StateDB, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WatchSyncService, WikilinkResolver, computeBufferHash, computeHash, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, isInternalPath, normalizeNotionId, notionIdsEqual, sanitizeFileName, setLogger, sortEntries, threeWayMerge };
7109
7670
  //# sourceMappingURL=index.js.map
7110
7671
  //# sourceMappingURL=index.js.map