@im-nobsidian/core 0.1.12 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import Database from 'better-sqlite3';
5
5
  import { randomUUID, createHash } from 'crypto';
6
6
  import { Client, LogLevel } from '@notionhq/client';
7
7
  import { Sema } from 'async-sema';
8
- import matter7 from 'gray-matter';
8
+ import matter from 'gray-matter';
9
9
  import { markdownToBlocks } from '@tryfabric/martian';
10
10
  import { NotionToMarkdown } from 'notion-to-md';
11
11
  import { readFileSync } from 'fs';
@@ -228,6 +228,9 @@ function normalizeNotionId(id) {
228
228
  function notionIdsEqual(a, b) {
229
229
  return a.replace(/-/g, "") === b.replace(/-/g, "");
230
230
  }
231
+ function compactNotionId(id) {
232
+ return id.replace(/-/g, "").toLowerCase();
233
+ }
231
234
 
232
235
  // src/state/migrations/001-initial.ts
233
236
  var INITIAL_MIGRATION = `
@@ -553,6 +556,57 @@ var StateDB = class _StateDB {
553
556
  fileSize: row.file_size
554
557
  };
555
558
  }
559
+ // --- pending_operations (I12 크래시 복구 WAL) ---
560
+ recordPendingOperation(input) {
561
+ const id = generateId();
562
+ this.db.prepare(
563
+ `INSERT INTO pending_operations
564
+ (id, sync_state_id, operation, direction, payload, status)
565
+ VALUES (?, ?, ?, ?, ?, 'pending')`
566
+ ).run(id, input.syncStateId, input.operation, input.direction, input.payload ?? null);
567
+ return id;
568
+ }
569
+ getIncompletePendingOperations() {
570
+ const rows = this.db.prepare(
571
+ "SELECT * FROM pending_operations WHERE status IN ('pending', 'processing') ORDER BY created_at ASC"
572
+ ).all();
573
+ return rows.map((r) => this.mapPendingRow(r));
574
+ }
575
+ getIncompleteOpByState(syncStateId, operation) {
576
+ const row = this.db.prepare(
577
+ `SELECT * FROM pending_operations
578
+ WHERE sync_state_id = ? AND operation = ? AND status IN ('pending', 'processing')
579
+ ORDER BY created_at ASC LIMIT 1`
580
+ ).get(syncStateId, operation);
581
+ return row ? this.mapPendingRow(row) : null;
582
+ }
583
+ markPendingCompleted(id) {
584
+ this.db.prepare(
585
+ "UPDATE pending_operations SET status = 'completed', completed_at = datetime('now') WHERE id = ?"
586
+ ).run(id);
587
+ }
588
+ markPendingFailed(id, errorMessage) {
589
+ this.db.prepare(
590
+ "UPDATE pending_operations SET status = 'failed', retry_count = retry_count + 1, error_message = ?, completed_at = datetime('now') WHERE id = ?"
591
+ ).run(errorMessage, id);
592
+ }
593
+ clearCompletedOperations() {
594
+ this.db.prepare("DELETE FROM pending_operations WHERE status IN ('completed', 'failed')").run();
595
+ }
596
+ mapPendingRow(row) {
597
+ return {
598
+ id: row.id,
599
+ syncStateId: row.sync_state_id,
600
+ operation: row.operation,
601
+ direction: row.direction,
602
+ payload: row.payload,
603
+ retryCount: row.retry_count,
604
+ errorMessage: row.error_message,
605
+ status: row.status,
606
+ createdAt: row.created_at,
607
+ completedAt: row.completed_at
608
+ };
609
+ }
556
610
  // --- preserve markers ---
557
611
  storePreserveMarkers(path, markers) {
558
612
  if (markers.length === 0) {
@@ -608,6 +662,9 @@ var StateDB = class _StateDB {
608
662
  // src/notion/property-mapper.ts
609
663
  var DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
610
664
  var WIKILINK_REGEX = /^\[\[(.+?)(?:\|.+?)?\]\]$/;
665
+ function asArray(value) {
666
+ return Array.isArray(value) ? value : [];
667
+ }
611
668
  var PropertyMapper = class {
612
669
  schema = /* @__PURE__ */ new Map();
613
670
  wikilinkResolver = null;
@@ -768,8 +825,8 @@ var PropertyMapper = class {
768
825
  extractValue(prop) {
769
826
  switch (prop.type) {
770
827
  case "rich_text": {
771
- const arr = prop.rich_text;
772
- return arr?.map((t) => t.plain_text).join("") || null;
828
+ const arr = asArray(prop.rich_text);
829
+ return arr.map((t) => t.plain_text).join("") || null;
773
830
  }
774
831
  case "number":
775
832
  return prop.number;
@@ -778,8 +835,7 @@ var PropertyMapper = class {
778
835
  return sel?.name ?? null;
779
836
  }
780
837
  case "multi_select": {
781
- const items = prop.multi_select;
782
- return items?.map((s) => s.name) ?? [];
838
+ return asArray(prop.multi_select).map((s) => s.name);
783
839
  }
784
840
  case "checkbox":
785
841
  return prop.checkbox;
@@ -804,12 +860,11 @@ var PropertyMapper = class {
804
860
  case "last_edited_time":
805
861
  return prop.last_edited_time;
806
862
  case "people": {
807
- const people = prop.people;
808
- return people?.map((p) => p.name ?? p.id) ?? [];
863
+ return asArray(prop.people).map((p) => p.name ?? p.id);
809
864
  }
810
865
  case "files": {
811
- const files = prop.files;
812
- const urls = (files ?? []).map((f) => f.type === "file" ? f.file?.url : f.external?.url).filter((u) => typeof u === "string" && u.length > 0);
866
+ const files = asArray(prop.files);
867
+ const urls = files.map((f) => f.type === "file" ? f.file?.url : f.external?.url).filter((u) => typeof u === "string" && u.length > 0);
813
868
  if (urls.length === 0) return [];
814
869
  return urls.length === 1 ? urls[0] : urls;
815
870
  }
@@ -819,8 +874,8 @@ var PropertyMapper = class {
819
874
  return formula[formula.type];
820
875
  }
821
876
  case "relation": {
822
- const rel = prop.relation;
823
- if (!rel || rel.length === 0) return [];
877
+ const rel = asArray(prop.relation);
878
+ if (rel.length === 0) return [];
824
879
  return rel.map((r) => {
825
880
  if (this.wikilinkResolver) {
826
881
  const title = this.wikilinkResolver.resolvePageId(r.id);
@@ -833,14 +888,14 @@ var PropertyMapper = class {
833
888
  const rollup = prop.rollup;
834
889
  if (!rollup) return null;
835
890
  if (rollup.type === "array") {
836
- const arr = rollup.array;
891
+ const arr = asArray(rollup.array);
837
892
  return arr.map((item) => this.extractValue(item));
838
893
  }
839
894
  return rollup[rollup.type] ?? null;
840
895
  }
841
896
  case "unique_id": {
842
897
  const uid = prop.unique_id;
843
- if (!uid) return null;
898
+ if (!uid || typeof uid.number !== "number") return null;
844
899
  return uid.prefix ? `${uid.prefix}-${uid.number}` : String(uid.number);
845
900
  }
846
901
  case "created_by":
@@ -875,12 +930,21 @@ function getLogger() {
875
930
  }
876
931
 
877
932
  // src/notion/client.ts
933
+ function isNotionObjectNotFound(error) {
934
+ if (typeof error !== "object" || error === null) return false;
935
+ const e = error;
936
+ return e.code === "object_not_found" || e.status === 404;
937
+ }
938
+ var DiscoveryTooLargeError = class extends Error {
939
+ constructor(elapsedMs) {
940
+ super(`subtree discovery exceeded time budget (${elapsedMs}ms)`);
941
+ this.name = "DiscoveryTooLargeError";
942
+ }
943
+ };
878
944
  var NotionClient = class _NotionClient {
879
945
  client;
880
946
  sema;
881
- token;
882
947
  propertyMapper = new PropertyMapper();
883
- customFetch;
884
948
  minRequestInterval;
885
949
  maxRetries;
886
950
  retryBaseDelayMs;
@@ -894,8 +958,6 @@ var NotionClient = class _NotionClient {
894
958
  logLevel: LogLevel.ERROR,
895
959
  ...options.fetch ? { fetch: options.fetch } : {}
896
960
  });
897
- this.token = options.token;
898
- this.customFetch = options.fetch;
899
961
  this.sema = new Sema(options.concurrency ?? 3);
900
962
  this.minRequestInterval = options.rateLimitIntervalMs ?? 350;
901
963
  this.maxRetries = options.maxRetries ?? 5;
@@ -1006,30 +1068,61 @@ var NotionClient = class _NotionClient {
1006
1068
  );
1007
1069
  }
1008
1070
  // ─── Database / DataSource ───
1009
- async fetchDatabaseLegacy(databaseId) {
1010
- const cleanId = databaseId.replace(/-/g, "");
1011
- const formatted = `${cleanId.slice(0, 8)}-${cleanId.slice(8, 12)}-${cleanId.slice(12, 16)}-${cleanId.slice(16, 20)}-${cleanId.slice(20)}`;
1012
- const resp = await this.withRateLimit(async () => {
1013
- const doFetch = this.customFetch ?? globalThis.fetch;
1014
- const r = await doFetch(`https://api.notion.com/v1/databases/${formatted}`, {
1015
- headers: {
1016
- Authorization: `Bearer ${this.token}`,
1017
- "Notion-Version": "2022-06-28",
1018
- "Content-Type": "application/json"
1071
+ /**
1072
+ * 데이터베이스 1개를 신 모델(2025-09-03 data source 분리)로 조회한다.
1073
+ * - title: database 객체에 그대로 존재한다.
1074
+ * - properties(스키마): 신 모델에선 data source 에 있으므로 1차 data source 를 조회해 채운다.
1075
+ * data source 접근 불가(링크드 DB 등)면 database 객체의 properties 로 폴백한다(제목은 보존).
1076
+ *
1077
+ * 폐기한 레거시 raw fetch(`GET /v1/databases/{id}`, Notion-Version 2022-06-28)는 신 모델로
1078
+ * 업그레이드된 다수 DB에 400 을 반환해 자동 발견 DB 가 통째 드롭(내용 손실)되던 원인이었다.
1079
+ * SDK(`databases.retrieve`/`dataSources.retrieve`)는 동일 토큰으로 정상 동작한다.
1080
+ */
1081
+ async fetchDatabaseModern(databaseId) {
1082
+ const db = await this.withRateLimit(
1083
+ () => this.client.databases.retrieve({ database_id: databaseId })
1084
+ );
1085
+ const dataSources = db.data_sources ?? [];
1086
+ const merged = {};
1087
+ let anyAccessible = false;
1088
+ for (const ds of dataSources) {
1089
+ try {
1090
+ const dsObj = await this.withRateLimit(
1091
+ () => this.client.dataSources.retrieve({ data_source_id: ds.id })
1092
+ );
1093
+ if (dsObj.properties && Object.keys(dsObj.properties).length > 0) {
1094
+ anyAccessible = true;
1095
+ for (const [name, prop] of Object.entries(dsObj.properties)) {
1096
+ if (!(name in merged)) merged[name] = prop;
1097
+ }
1019
1098
  }
1020
- });
1021
- if (!r.ok) throw new Error(`Database fetch failed: ${r.status}`);
1022
- return await r.json();
1023
- });
1024
- return resp;
1099
+ } catch {
1100
+ }
1101
+ }
1102
+ const properties = anyAccessible ? merged : db.properties ?? {};
1103
+ return { title: db.title, properties };
1025
1104
  }
1026
1105
  async getDatabaseTitle(databaseId) {
1027
- const db = await this.fetchDatabaseLegacy(databaseId);
1106
+ const db = await this.fetchDatabaseModern(databaseId);
1028
1107
  const titleArr = db.title;
1029
1108
  return titleArr?.[0]?.plain_text ?? "";
1030
1109
  }
1110
+ /**
1111
+ * 발견된 DB 가 **동기화 가능한지**(접근 가능한 data source 가 있는지) 1회 호출로 판별한다.
1112
+ * 신 모델(2025-09-03)에서 링크드 DB·미공유 데이터 소스·삭제 DB 는 `data_sources` 가
1113
+ * 비어, 행 조회(`dataSources.query`)가 404 로 실패하고 그 전에 생성된 빈 폴더/`.base` 만
1114
+ * 남긴다. 발견 단계에서 미리 걸러 **빈 폴더/.base 오염 + 매 pull 의 404 노이즈**를 차단한다.
1115
+ * 제목은 함께 반환해 호출처가 추가 조회 없이 폴더명을 잡게 한다.
1116
+ */
1117
+ async getDatabaseSyncability(databaseId) {
1118
+ const db = await this.withRateLimit(
1119
+ () => this.client.databases.retrieve({ database_id: databaseId })
1120
+ );
1121
+ const title = Array.isArray(db.title) ? db.title[0]?.plain_text ?? "" : "";
1122
+ return { title, queryable: (db.data_sources?.length ?? 0) > 0 };
1123
+ }
1031
1124
  async getDatabaseSchema(databaseId) {
1032
- const db = await this.fetchDatabaseLegacy(databaseId);
1125
+ const db = await this.fetchDatabaseModern(databaseId);
1033
1126
  const properties = db.properties;
1034
1127
  if (!properties) return {};
1035
1128
  const schema = {};
@@ -1039,7 +1132,7 @@ var NotionClient = class _NotionClient {
1039
1132
  return schema;
1040
1133
  }
1041
1134
  async getDatabaseSchemaFull(databaseId) {
1042
- const db = await this.fetchDatabaseLegacy(databaseId);
1135
+ const db = await this.fetchDatabaseModern(databaseId);
1043
1136
  const properties = db.properties;
1044
1137
  if (!properties) return {};
1045
1138
  const schema = {};
@@ -1071,11 +1164,11 @@ var NotionClient = class _NotionClient {
1071
1164
  }
1072
1165
  return schema;
1073
1166
  }
1074
- async queryDatabase(databaseId, options) {
1075
- const dsId = await this.getDataSourceId(databaseId);
1167
+ /** 특정 data source 1개를 직접 쿼리한다(저수준). 다중 data source 순회의 빌딩블록. */
1168
+ async queryDataSource(dataSourceId, options) {
1076
1169
  const response = await this.withRateLimit(
1077
1170
  () => this.client.dataSources.query({
1078
- data_source_id: dsId,
1171
+ data_source_id: dataSourceId,
1079
1172
  start_cursor: options?.startCursor,
1080
1173
  page_size: options?.pageSize ?? this.defaultPageSize,
1081
1174
  filter: options?.filter
@@ -1086,26 +1179,66 @@ var NotionClient = class _NotionClient {
1086
1179
  nextCursor: response.next_cursor
1087
1180
  };
1088
1181
  }
1182
+ /**
1183
+ * database 의 1차 data source 1페이지를 쿼리한다(증분 detect 등 단일 소스 경로용).
1184
+ * 전 행을 빠짐없이 받으려면 {@link queryAllDatabasePages} 를 쓴다(전 data source 순회).
1185
+ */
1186
+ async queryDatabase(databaseId, options) {
1187
+ const dsId = await this.getDataSourceId(databaseId);
1188
+ return this.queryDataSource(dsId, options);
1189
+ }
1190
+ /**
1191
+ * database 의 **모든 data source** 행을 빠짐없이 조회한다(2025-09-03 신 모델).
1192
+ * 한 database 가 2개 이상의 data source(각자 행 집합)를 가질 수 있어, 1차 data source 만
1193
+ * 페이지네이션하면 2번째+ 의 행이 통째 침묵 유실된다. 전 data source 를 순회·페이지네이션
1194
+ * 하고 page_id 로 디듀프(소스 간 동일 페이지 방어)한 뒤 합친다. 다중 소스면 1회 경고해
1195
+ * "소스별 탭 구분이 한 폴더로 병합"되는 점을 비침묵으로 알린다.
1196
+ */
1089
1197
  async queryAllDatabasePages(databaseId, filter) {
1198
+ const metas = await this.getDataSourceMetas(databaseId);
1199
+ if (metas.length > 1) {
1200
+ const label = metas.map((m) => m.name || m.id).join(", ");
1201
+ getLogger().warn(
1202
+ `[Notion] DB ${databaseId} \uC5D0 data source ${metas.length}\uAC1C \uBC1C\uACAC \u2014 \uC804 \uC18C\uC2A4 \uD589\uC744 \uD55C \uD3F4\uB354\uB85C \uBCD1\uD569 \uB3D9\uAE30\uD654\uD569\uB2C8\uB2E4 (${label}). \uC18C\uC2A4\uBCC4 \uD0ED \uAD6C\uBD84\uC740 .base \uBDF0\uC5D0 \uBCF4\uC874\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.`
1203
+ );
1204
+ }
1090
1205
  const all = [];
1091
- let cursor;
1092
- do {
1093
- const response = await this.queryDatabase(databaseId, {
1094
- startCursor: cursor,
1095
- pageSize: this.defaultPageSize,
1096
- filter
1097
- });
1098
- all.push(...response.results);
1099
- cursor = response.nextCursor ?? void 0;
1100
- } while (cursor);
1206
+ const seen = /* @__PURE__ */ new Set();
1207
+ for (const meta of metas) {
1208
+ let cursor;
1209
+ do {
1210
+ const response = await this.queryDataSource(meta.id, {
1211
+ startCursor: cursor,
1212
+ pageSize: this.defaultPageSize,
1213
+ filter
1214
+ });
1215
+ for (const page of response.results) {
1216
+ const key = normalizeNotionId(page.id);
1217
+ if (seen.has(key)) continue;
1218
+ seen.add(key);
1219
+ all.push(page);
1220
+ }
1221
+ cursor = response.nextCursor ?? void 0;
1222
+ } while (cursor);
1223
+ }
1101
1224
  return all;
1102
1225
  }
1226
+ /** database 의 1차 data source id(없으면 databaseId 폴백). 단일 소스 경로 호환용. */
1103
1227
  async getDataSourceId(databaseId) {
1228
+ const metas = await this.getDataSourceMetas(databaseId);
1229
+ return metas[0]?.id ?? databaseId;
1230
+ }
1231
+ /**
1232
+ * database 의 전 data source 메타(id + 이름)를 반환한다. data source 가 없으면(레거시/단일)
1233
+ * databaseId 자체를 단일 소스로 폴백해 호출처가 항상 ≥1 개를 받게 한다.
1234
+ */
1235
+ async getDataSourceMetas(databaseId) {
1104
1236
  const db = await this.withRateLimit(
1105
1237
  () => this.client.databases.retrieve({ database_id: databaseId })
1106
1238
  );
1107
- const dataSources = db.data_sources;
1108
- return dataSources?.[0]?.id ?? databaseId;
1239
+ const list = db.data_sources ?? [];
1240
+ if (list.length === 0) return [{ id: databaseId, name: "" }];
1241
+ return list.map((ds) => ({ id: ds.id, name: ds.name ?? "" }));
1109
1242
  }
1110
1243
  async archivePage(pageId) {
1111
1244
  await this.withRateLimit(() => this.client.pages.update({ page_id: pageId, archived: true }));
@@ -1254,32 +1387,6 @@ var NotionClient = class _NotionClient {
1254
1387
  } while (cursor);
1255
1388
  return results;
1256
1389
  }
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
- }
1283
1390
  async getChildPages(parentId) {
1284
1391
  const blocks = await this.fetchAllChildrenDeep(parentId);
1285
1392
  const childPageBlocks = blocks.filter((b) => b.type === "child_page");
@@ -1296,12 +1403,24 @@ var NotionClient = class _NotionClient {
1296
1403
  const children = await this.fetchAllChildren(parentId);
1297
1404
  return children.filter((b) => b.type === "child_database").map((b) => b.id);
1298
1405
  }
1299
- async getChildPagesRecursive(parentId) {
1406
+ /**
1407
+ * root 서브트리를 직접 BFS 순회해 하위 페이지를 모두 수집한다(중첩 깊이 무관·무손실).
1408
+ * 비용은 서브트리 전체 블록 수에 비례한다.
1409
+ *
1410
+ * @param opts.deadlineMs `Date.now()` 기준 마감 시각. 각 페이지 처리 전 초과를 검사해
1411
+ * 초과 시 {@link DiscoveryTooLargeError}를 던진다(대규모 서브트리 → search 폴백 유도).
1412
+ * 미지정 시 무제한(기존 동작 유지 — 테스트 mock 경로는 영향 없음).
1413
+ */
1414
+ async getChildPagesRecursive(parentId, opts) {
1300
1415
  const all = [];
1301
1416
  let currentLevel = [parentId];
1417
+ const start = Date.now();
1302
1418
  while (currentLevel.length > 0) {
1303
1419
  const nextLevel = [];
1304
1420
  for (const id of currentLevel) {
1421
+ if (opts?.deadlineMs !== void 0 && Date.now() > opts.deadlineMs) {
1422
+ throw new DiscoveryTooLargeError(Date.now() - start);
1423
+ }
1305
1424
  let children;
1306
1425
  try {
1307
1426
  children = await this.getChildPages(id);
@@ -1320,6 +1439,112 @@ var NotionClient = class _NotionClient {
1320
1439
  }
1321
1440
  return all;
1322
1441
  }
1442
+ /** 워크스페이스에서 통합에 공유된 모든 페이지를 bulk search 로 열거한다(100/요청). */
1443
+ async searchAllPages() {
1444
+ const pages = [];
1445
+ let cursor;
1446
+ do {
1447
+ const r = await this.search({
1448
+ filter: { property: "object", value: "page" },
1449
+ startCursor: cursor
1450
+ });
1451
+ pages.push(...r.results);
1452
+ cursor = r.nextCursor ?? void 0;
1453
+ } while (cursor);
1454
+ return pages;
1455
+ }
1456
+ /**
1457
+ * root 하위 페이지를 **search 기반**으로 디스커버리한다(대규모 서브트리용 폴백).
1458
+ *
1459
+ * 전략: 워크스페이스 전체 페이지를 1회 bulk search 로 열거(비용 ∝ 워크스페이스 페이지 수,
1460
+ * 예측가능·유한)한 뒤, 각 페이지의 부모 체인을 따라 root 도달 여부를 판정한다. page_id
1461
+ * 부모는 메모리 맵으로 추가 호출 없이 즉시 해석하고, block_id 부모(컬럼·토글 등 레이아웃에
1462
+ * 중첩된 페이지)만 getBlock 으로 소유 페이지를 해석(memo)한다. search 는 중첩 깊이와 무관하게
1463
+ * 모든 페이지를 반환하므로 직접 순회와 동일하게 **무손실**이다.
1464
+ *
1465
+ * getChildPagesRecursive 와 동일 집합을 보장하기 위해:
1466
+ * - DB 행(parent=data_source_id/database_id)은 child_page 로 도달 불가 → 제외
1467
+ * - 휴지통/보관(in_trash/archived) 페이지 제외
1468
+ * - root 자신은 제외(직접 순회도 root 의 *자식*부터 수집)
1469
+ */
1470
+ async getPagesUnderRootViaSearch(rootId) {
1471
+ const all = await this.searchAllPages();
1472
+ const rootN = normalizeNotionId(rootId);
1473
+ const byId = /* @__PURE__ */ new Map();
1474
+ for (const p of all) byId.set(normalizeNotionId(p.id), p);
1475
+ const blockOwner = /* @__PURE__ */ new Map();
1476
+ const resolveBlockOwner = async (blockId) => {
1477
+ let bid = blockId;
1478
+ const seen = /* @__PURE__ */ new Set();
1479
+ for (let i = 0; i < 20 && bid && !seen.has(bid); i++) {
1480
+ seen.add(bid);
1481
+ const cached = blockOwner.get(bid);
1482
+ if (cached !== void 0) return cached;
1483
+ let bp;
1484
+ try {
1485
+ bp = (await this.getBlock(bid)).parent;
1486
+ } catch {
1487
+ blockOwner.set(bid, null);
1488
+ return null;
1489
+ }
1490
+ if (bp.type === "page_id" && bp.page_id) {
1491
+ const owner = bp.page_id;
1492
+ blockOwner.set(blockId, owner);
1493
+ return owner;
1494
+ }
1495
+ if (bp.type === "block_id" && bp.block_id) {
1496
+ bid = bp.block_id;
1497
+ continue;
1498
+ }
1499
+ blockOwner.set(blockId, null);
1500
+ return null;
1501
+ }
1502
+ return null;
1503
+ };
1504
+ const memo = /* @__PURE__ */ new Map();
1505
+ const isUnderRoot = async (startId) => {
1506
+ const chain = [];
1507
+ let cur = normalizeNotionId(startId);
1508
+ const seen = /* @__PURE__ */ new Set();
1509
+ let result = false;
1510
+ while (cur && !seen.has(cur)) {
1511
+ if (cur === rootN) {
1512
+ result = true;
1513
+ break;
1514
+ }
1515
+ const cached = memo.get(cur);
1516
+ if (cached !== void 0) {
1517
+ result = cached;
1518
+ break;
1519
+ }
1520
+ seen.add(cur);
1521
+ chain.push(cur);
1522
+ const pg = byId.get(cur);
1523
+ if (!pg) break;
1524
+ const parent = pg.parent;
1525
+ if (parent.type === "page_id" && parent.page_id) {
1526
+ cur = normalizeNotionId(parent.page_id);
1527
+ } else if (parent.type === "block_id" && parent.block_id) {
1528
+ const owner = await resolveBlockOwner(parent.block_id);
1529
+ cur = owner ? normalizeNotionId(owner) : null;
1530
+ } else {
1531
+ cur = null;
1532
+ }
1533
+ }
1534
+ for (const c of chain) memo.set(c, result);
1535
+ return result;
1536
+ };
1537
+ const out = [];
1538
+ for (const p of all) {
1539
+ const pid = normalizeNotionId(p.id);
1540
+ if (pid === rootN) continue;
1541
+ const ptype = p.parent.type;
1542
+ if (ptype === "data_source_id" || ptype === "database_id") continue;
1543
+ if (_NotionClient.isTrashedOrArchived(p)) continue;
1544
+ if (await isUnderRoot(p.id)) out.push(p);
1545
+ }
1546
+ return out;
1547
+ }
1323
1548
  // ─── Views API ───
1324
1549
  async listDatabaseViews(databaseId) {
1325
1550
  const allViews = [];
@@ -1394,13 +1619,23 @@ var NotionClient = class _NotionClient {
1394
1619
  extractTitle(page) {
1395
1620
  const props = page.properties;
1396
1621
  for (const prop of Object.values(props)) {
1397
- if (prop.type === "title" && prop.title) {
1622
+ if (prop.type === "title" && Array.isArray(prop.title)) {
1398
1623
  const joined = prop.title.map((t) => t.plain_text ?? "").join("");
1399
1624
  if (joined) return joined;
1400
1625
  }
1401
1626
  }
1402
1627
  return "\uC81C\uBAA9 \uC5C6\uC74C";
1403
1628
  }
1629
+ /**
1630
+ * 페이지 모드 pull(extractProperties)의 relation/people 속성을 raw Notion UUID 가
1631
+ * 아니라 [[제목]] 위키링크로 해소하도록 resolver 를 주입한다. 주입하지 않으면 매
1632
+ * pull 마다 relation 이 raw UUID 로 재생성돼, 후처리(resolveNotionLinks)에만 의존하는
1633
+ * 2-write churn 이 남는다(M1). DB 모드는 orchestrator/DatabaseSyncer 의 자체 mapper 가
1634
+ * 이미 resolver 를 주입받아 면역이다.
1635
+ */
1636
+ setWikilinkResolver(resolver) {
1637
+ this.propertyMapper.setWikilinkResolver(resolver);
1638
+ }
1404
1639
  extractProperties(page) {
1405
1640
  return this.propertyMapper.fromNotionProperties(page.properties);
1406
1641
  }
@@ -1980,7 +2215,6 @@ var EMBED_PROTOCOL = `${MARKER_BRAND}://embed/`;
1980
2215
  function spacedMarker(body) {
1981
2216
  return `%% ${MARKER_BRAND}:${body} %%`;
1982
2217
  }
1983
- var SPACED_END = spacedMarker("end");
1984
2218
  function compactMarker(body) {
1985
2219
  return `%%${MARKER_BRAND}:${body}%%`;
1986
2220
  }
@@ -1990,6 +2224,7 @@ var COLUMN_LIST_START = compactMarker("column-list:start");
1990
2224
  var COLUMN_LIST_END = compactMarker("column-list:end");
1991
2225
  var COLUMN_SEP = compactMarker("column");
1992
2226
  var TOC_MARKER = compactMarker("toc");
2227
+ var BREADCRUMB_MARKER = compactMarker("breadcrumb");
1993
2228
  var PROPERTIES_TAG = `# ${MARKER_BRAND}:properties`;
1994
2229
  var MARKER_BRAND_RE = MARKER_BRAND;
1995
2230
 
@@ -2066,7 +2301,7 @@ var FrontmatterExtractor = class {
2066
2301
  name = "FrontmatterExtractor";
2067
2302
  order = 10;
2068
2303
  process(input) {
2069
- const { data, content } = matter7(input.content);
2304
+ const { data, content } = matter(input.content);
2070
2305
  return {
2071
2306
  content: content.trim(),
2072
2307
  metadata: {
@@ -2286,6 +2521,11 @@ var UnsupportedBlockStripper = class {
2286
2521
  };
2287
2522
  }
2288
2523
  };
2524
+ function stringifyFrontmatter(content, data) {
2525
+ return matter.stringify({ content }, data);
2526
+ }
2527
+
2528
+ // src/converter/pre-processors/properties-table.ts
2289
2529
  var PropertiesTableInjector = class {
2290
2530
  name = "PropertiesTableInjector";
2291
2531
  order = 15;
@@ -2307,7 +2547,7 @@ var PropertiesTableInjector = class {
2307
2547
  if (Object.keys(filteredProps).length === 0) {
2308
2548
  return { content: input.content, metadata: input.metadata };
2309
2549
  }
2310
- const yamlStr = matter7.stringify("", filteredProps).trim();
2550
+ const yamlStr = stringifyFrontmatter("", filteredProps).trim();
2311
2551
  const yamlBody = yamlStr.slice(4, -3).trim();
2312
2552
  const codeBlock = "```yaml\n" + PROPERTIES_TAG + "\n" + yamlBody + "\n```";
2313
2553
  const content = codeBlock + "\n\n---\n\n" + input.content;
@@ -2317,51 +2557,66 @@ var PropertiesTableInjector = class {
2317
2557
 
2318
2558
  // src/converter/pre-processors/html-annotation.ts
2319
2559
  var UNDERLINE_REGEX = /<u>([\s\S]*?)<\/u>/g;
2320
- var COLOR_SPAN_REGEX = /<span class="notion-(\w+?)(?:-bg)?">([\s\S]*?)<\/span>/g;
2321
- var IM_NOBSIDIAN_COLOR_REGEX = new RegExp(
2560
+ var COLOR_SPAN_REGEX = /<span class="notion-(\w+)(-bg)?">([\s\S]*?)<\/span>/g;
2561
+ var SPACED_COLOR_REGEX = new RegExp(
2322
2562
  `%% ${MARKER_BRAND_RE}:color:(\\w+(?:_background)?) %%([\\s\\S]*?)%% ${MARKER_BRAND_RE}:end %%`,
2323
2563
  "g"
2324
2564
  );
2325
- var HtmlAnnotationStripper = class {
2326
- name = "HtmlAnnotationStripper";
2565
+ var InlineAnnotationPreserver = class {
2566
+ name = "InlineAnnotationPreserver";
2327
2567
  order = 5;
2328
2568
  process(input) {
2329
2569
  if (input.context.direction !== "push") {
2330
2570
  return { content: input.content, metadata: input.metadata };
2331
2571
  }
2332
2572
  let content = input.content;
2333
- content = content.replace(IM_NOBSIDIAN_COLOR_REGEX, (_m, _color, text) => text);
2334
- content = content.replace(UNDERLINE_REGEX, (_m, text) => text);
2335
- content = content.replace(COLOR_SPAN_REGEX, (_m, _color, text) => text);
2573
+ content = content.replace(
2574
+ SPACED_COLOR_REGEX,
2575
+ (_m, color, text) => `${compactMarker(`color:${color}`)}${text}%%/color%%`
2576
+ );
2577
+ content = content.replace(
2578
+ UNDERLINE_REGEX,
2579
+ (_m, text) => `${compactMarker("underline")}${text}%%/underline%%`
2580
+ );
2581
+ content = content.replace(
2582
+ COLOR_SPAN_REGEX,
2583
+ (_m, base, bg, text) => {
2584
+ const color = bg ? `${base}_background` : base;
2585
+ return `${compactMarker(`color:${color}`)}${text}%%/color%%`;
2586
+ }
2587
+ );
2336
2588
  return { content, metadata: input.metadata };
2337
2589
  }
2338
2590
  };
2339
2591
 
2340
- // src/converter/post-processors/mention-to-wikilink.ts
2592
+ // src/converter/link-restore.ts
2341
2593
  var NOTION_LINK_REGEX = /\[([^\]]+)\]\((?:https:\/\/(?:www\.)?notion\.so\/|notion:\/\/)([a-f0-9-]+)\)/g;
2342
2594
  var WIKILINK_PRESERVE_REGEX = new RegExp(`\\[([^\\]]+)\\]\\(${WIKILINK_PROTOCOL}([^)]+)\\)`, "g");
2343
2595
  var EMBED_PRESERVE_REGEX = new RegExp(`\\[([^\\]]+)\\]\\(${EMBED_PROTOCOL}([^)]+)\\)`, "g");
2596
+ function restoreLinkMarkers(text) {
2597
+ let out = text.replace(NOTION_LINK_REGEX, (_match, display) => `[[${display}]]`);
2598
+ out = out.replace(WIKILINK_PRESERVE_REGEX, (_match, display, encodedTarget) => {
2599
+ const target = decodeURIComponent(encodedTarget);
2600
+ return target === display ? `[[${target}]]` : `[[${target}|${display}]]`;
2601
+ });
2602
+ out = out.replace(EMBED_PRESERVE_REGEX, (_match, _display, encodedTarget) => {
2603
+ const target = decodeURIComponent(encodedTarget);
2604
+ return `![[${target}]]`;
2605
+ });
2606
+ return out;
2607
+ }
2608
+ function restoreLinkMarkersInValue(value) {
2609
+ if (typeof value === "string") return restoreLinkMarkers(value);
2610
+ if (Array.isArray(value)) return value.map((v) => restoreLinkMarkersInValue(v));
2611
+ return value;
2612
+ }
2613
+
2614
+ // src/converter/post-processors/mention-to-wikilink.ts
2344
2615
  var MentionToWikilink = class {
2345
2616
  name = "MentionToWikilink";
2346
2617
  order = 10;
2347
2618
  process(input) {
2348
- let content = input.content;
2349
- content = content.replace(NOTION_LINK_REGEX, (_match, display) => `[[${display}]]`);
2350
- content = content.replace(
2351
- WIKILINK_PRESERVE_REGEX,
2352
- (_match, display, encodedTarget) => {
2353
- const target = decodeURIComponent(encodedTarget);
2354
- return target === display ? `[[${target}]]` : `[[${target}|${display}]]`;
2355
- }
2356
- );
2357
- content = content.replace(
2358
- EMBED_PRESERVE_REGEX,
2359
- (_match, _display, encodedTarget) => {
2360
- const target = decodeURIComponent(encodedTarget);
2361
- return `![[${target}]]`;
2362
- }
2363
- );
2364
- return { content, metadata: input.metadata };
2619
+ return { content: restoreLinkMarkers(input.content), metadata: input.metadata };
2365
2620
  }
2366
2621
  };
2367
2622
 
@@ -2382,9 +2637,12 @@ var PreserveMarkerInjector = class {
2382
2637
  if (missingMarkers.length === 0) {
2383
2638
  return { content, metadata: input.metadata };
2384
2639
  }
2385
- const markerLines = missingMarkers.map((m) => this.formatMarker(m));
2386
- const suffix = "\n\n" + markerLines.join("\n");
2387
- content = content.trimEnd() + suffix + "\n";
2640
+ const sorted = [...missingMarkers].sort((a, b) => a.startIndex - b.startIndex);
2641
+ for (const m of sorted) {
2642
+ const markerText = this.formatMarker(m);
2643
+ const at = Math.max(0, Math.min(m.startIndex, content.length));
2644
+ content = content.slice(0, at) + markerText + content.slice(at);
2645
+ }
2388
2646
  return { content, metadata: input.metadata };
2389
2647
  }
2390
2648
  formatMarker(marker) {
@@ -2465,6 +2723,8 @@ var ColorAnnotator = class {
2465
2723
  };
2466
2724
  }
2467
2725
  };
2726
+
2727
+ // src/converter/post-processors/frontmatter-generator.ts
2468
2728
  var FrontmatterGenerator = class {
2469
2729
  name = "FrontmatterGenerator";
2470
2730
  order = 90;
@@ -2473,18 +2733,20 @@ var FrontmatterGenerator = class {
2473
2733
  if (!properties || Object.keys(properties).length === 0) {
2474
2734
  return { content: input.content, metadata: input.metadata };
2475
2735
  }
2476
- const normalized = normalizeProperties(properties);
2477
- const content = matter7.stringify(input.content, normalized);
2736
+ const restoreMarkers = input.context.direction === "pull";
2737
+ const normalized = normalizeProperties(properties, restoreMarkers);
2738
+ const content = stringifyFrontmatter(input.content, normalized);
2478
2739
  return {
2479
2740
  content,
2480
2741
  metadata: input.metadata
2481
2742
  };
2482
2743
  }
2483
2744
  };
2484
- function normalizeProperties(props) {
2745
+ function normalizeProperties(props, restoreMarkers) {
2485
2746
  const result = {};
2486
2747
  for (const [key, value] of Object.entries(props)) {
2487
- const normalized = normalizeValue(value);
2748
+ const restored = restoreMarkers ? restoreLinkMarkersInValue(value) : value;
2749
+ const normalized = normalizeValue(restored);
2488
2750
  if (normalized !== void 0) {
2489
2751
  result[key] = normalized;
2490
2752
  }
@@ -2567,7 +2829,7 @@ ${yamlContent}---
2567
2829
  `;
2568
2830
  let parsed;
2569
2831
  try {
2570
- const result = matter7(fakeDocument);
2832
+ const result = matter(fakeDocument);
2571
2833
  parsed = result.data;
2572
2834
  } catch {
2573
2835
  return { content: input.content, metadata: input.metadata };
@@ -2638,7 +2900,7 @@ function stripQuotes(s) {
2638
2900
  // src/converter/pipeline-factory.ts
2639
2901
  function createDefaultPipeline(options) {
2640
2902
  const pipeline = new ConversionPipeline();
2641
- pipeline.registerPreProcessor(new HtmlAnnotationStripper());
2903
+ pipeline.registerPreProcessor(new InlineAnnotationPreserver());
2642
2904
  pipeline.registerPreProcessor(new UnsupportedBlockStripper());
2643
2905
  pipeline.registerPreProcessor(new FrontmatterExtractor());
2644
2906
  pipeline.registerPreProcessor(new PropertiesTableInjector());
@@ -2656,30 +2918,8 @@ function createDefaultPipeline(options) {
2656
2918
  pipeline.registerPostProcessor(new FrontmatterGenerator());
2657
2919
  return pipeline;
2658
2920
  }
2659
- var TRULY_UNSUPPORTED_BLOCK_TYPES = ["unsupported", "template"];
2660
- var DIVIDER_PLACEHOLDER = "\u200B%%IM-NOBSIDIAN_DIVIDER%%\u200B";
2661
- var VIDEO_URL_PATTERNS = [
2662
- /^https?:\/\/(www\.)?youtube\.com\/watch/,
2663
- /^https?:\/\/youtu\.be\//,
2664
- /^https?:\/\/(www\.)?vimeo\.com\//,
2665
- /^https?:\/\/(www\.)?dailymotion\.com\//,
2666
- /^https?:\/\/(www\.)?loom\.com\/share\//
2667
- ];
2668
- var EMBED_URL_PATTERNS = [
2669
- /^https?:\/\/(www\.)?figma\.com\//,
2670
- /^https?:\/\/docs\.google\.com\//,
2671
- /^https?:\/\/drive\.google\.com\//,
2672
- /^https?:\/\/(www\.)?miro\.com\//,
2673
- /^https?:\/\/(www\.)?twitter\.com\//,
2674
- /^https?:\/\/(www\.)?x\.com\//,
2675
- /^https?:\/\/codepen\.io\//,
2676
- /^https?:\/\/gist\.github\.com\//,
2677
- /^https?:\/\/(www\.)?spotify\.com\//,
2678
- /^https?:\/\/(www\.)?soundcloud\.com\//
2679
- ];
2680
- function escapeRegex2(str) {
2681
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2682
- }
2921
+
2922
+ // src/converter/rich-text-converter.ts
2683
2923
  function richTextToPlain(richText) {
2684
2924
  if (!richText) return "";
2685
2925
  return richText.map((t) => t.plain_text).join("");
@@ -2691,7 +2931,7 @@ function richTextToMarkdown(richText) {
2691
2931
  return `$${t.equation.expression}$`;
2692
2932
  }
2693
2933
  if (t.type === "mention" && t.mention) {
2694
- return formatMention(t.mention);
2934
+ return formatMention(t.mention, t.plain_text);
2695
2935
  }
2696
2936
  let text = t.plain_text;
2697
2937
  const a = t.annotations;
@@ -2703,28 +2943,54 @@ function richTextToMarkdown(richText) {
2703
2943
  if (t.href) text = `[${text}](${t.href})`;
2704
2944
  const color = a?.color;
2705
2945
  if (color && color !== "default") {
2706
- text = `${spacedMarker(`color:${color}`)}${text}${SPACED_END}`;
2946
+ text = `${compactMarker(`color:${color}`)}${text}%%/color%%`;
2707
2947
  }
2708
2948
  return text;
2709
2949
  }).join("");
2710
2950
  }
2711
- function formatMention(mention) {
2951
+ function formatMention(mention, plainText) {
2712
2952
  switch (mention.type) {
2713
2953
  case "page":
2714
- return mention.page ? `[[${mention.page.id}]]` : "";
2954
+ return mention.page ? `[[notion:${compactNotionId(mention.page.id)}]]` : plainText;
2715
2955
  case "date": {
2716
- if (!mention.date) return "";
2956
+ if (!mention.date) return plainText;
2717
2957
  const start = mention.date.start;
2718
2958
  return mention.date.end ? `${start} \u2192 ${mention.date.end}` : start;
2719
2959
  }
2720
2960
  case "user":
2721
- return mention.user?.name ? `@${mention.user.name}` : "@user";
2961
+ return mention.user?.name ? `@${mention.user.name}` : plainText || "@user";
2722
2962
  case "database":
2723
- return mention.database ? `[[${mention.database.id}]]` : "";
2963
+ return mention.database ? `[[notion:${compactNotionId(mention.database.id)}]]` : plainText;
2724
2964
  default:
2725
- return "";
2965
+ return plainText;
2726
2966
  }
2727
2967
  }
2968
+
2969
+ // src/converter/block-converter.ts
2970
+ var TRULY_UNSUPPORTED_BLOCK_TYPES = ["unsupported", "template"];
2971
+ var DIVIDER_PLACEHOLDER = "\u200B%%IM-NOBSIDIAN_DIVIDER%%\u200B";
2972
+ var VIDEO_URL_PATTERNS = [
2973
+ /^https?:\/\/(www\.)?youtube\.com\/watch/,
2974
+ /^https?:\/\/youtu\.be\//,
2975
+ /^https?:\/\/(www\.)?vimeo\.com\//,
2976
+ /^https?:\/\/(www\.)?dailymotion\.com\//,
2977
+ /^https?:\/\/(www\.)?loom\.com\/share\//
2978
+ ];
2979
+ var EMBED_URL_PATTERNS = [
2980
+ /^https?:\/\/(www\.)?figma\.com\//,
2981
+ /^https?:\/\/docs\.google\.com\//,
2982
+ /^https?:\/\/drive\.google\.com\//,
2983
+ /^https?:\/\/(www\.)?miro\.com\//,
2984
+ /^https?:\/\/(www\.)?twitter\.com\//,
2985
+ /^https?:\/\/(www\.)?x\.com\//,
2986
+ /^https?:\/\/codepen\.io\//,
2987
+ /^https?:\/\/gist\.github\.com\//,
2988
+ /^https?:\/\/(www\.)?spotify\.com\//,
2989
+ /^https?:\/\/(www\.)?soundcloud\.com\//
2990
+ ];
2991
+ function escapeRegex2(str) {
2992
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2993
+ }
2728
2994
  var BlockConverter = class {
2729
2995
  n2m = null;
2730
2996
  client = null;
@@ -2762,6 +3028,37 @@ var BlockConverter = class {
2762
3028
  this.registerEmbedTransformer();
2763
3029
  this.registerCalloutTransformer();
2764
3030
  this.registerToggleHeadingTransformers();
3031
+ this.registerNumberedListTransformer();
3032
+ }
3033
+ /**
3034
+ * `numbered_list_item` 을 `1.` 마크다운으로 내보내 **번호 목록 타입을 보존**한다(I2 블록 라운드트립).
3035
+ *
3036
+ * notion-to-md 기본 렌더는 `md.bullet(text, block.numbered_list_item.number)` 를 쓰는데
3037
+ * Notion API 는 list item 에 `.number` 를 채워주지 않아 `undefined` → 불릿(`- `)으로 **강등**된다.
3038
+ * 그러면 블록 폴백 경로(notionBlocksToMarkdown)로 pull 한 뒤 다시 push 하면 numbered_list_item 이
3039
+ * bulleted_list_item 으로 **타입 유실**된다(기본 markdown-api 경로는 영향 없음).
3040
+ *
3041
+ * 항상 `1.` 로 내보내면 마크다운이 시각적으로 자동 재번호하므로 표시는 동일하고, martian 이 다시
3042
+ * numbered_list_item 으로 파싱해 타입이 보존된다. 커스텀 트랜스포머 등록은 notion-to-md 의 기본
3043
+ * 자식 처리를 끄므로(라이브러리 설계), 자식은 toggle/callout 과 동일하게 직접 fetch·들여쓰기한다.
3044
+ */
3045
+ registerNumberedListTransformer() {
3046
+ if (!this.n2m) return;
3047
+ const n2m = this.n2m;
3048
+ this.n2m.setCustomTransformer("numbered_list_item", async (block) => {
3049
+ const b = block;
3050
+ const text = richTextToMarkdown(b.numbered_list_item?.rich_text);
3051
+ let result = `1. ${text}`;
3052
+ if (b.has_children) {
3053
+ const children = await n2m.pageToMarkdown(b.id);
3054
+ const childMd = (Array.isArray(children) ? n2m.toMarkdownString(children).parent : "") ?? "";
3055
+ if (childMd.trim()) {
3056
+ const indented = childMd.split("\n").map((l) => l.trim() ? ` ${l}` : "").join("\n").trimEnd();
3057
+ result += "\n" + indented;
3058
+ }
3059
+ }
3060
+ return result;
3061
+ });
2765
3062
  }
2766
3063
  registerToggleTransformer() {
2767
3064
  if (!this.n2m) return;
@@ -2922,7 +3219,7 @@ ${COLUMN_LIST_END}`;
2922
3219
  }
2923
3220
  registerBreadcrumbTransformer() {
2924
3221
  if (!this.n2m) return;
2925
- this.n2m.setCustomTransformer("breadcrumb", async () => "");
3222
+ this.n2m.setCustomTransformer("breadcrumb", async () => BREADCRUMB_MARKER);
2926
3223
  }
2927
3224
  registerLinkPreviewTransformer() {
2928
3225
  if (!this.n2m) return;
@@ -3001,8 +3298,19 @@ ${COLUMN_LIST_END}`;
3001
3298
  processed = this.extractColumnBlocks(processed);
3002
3299
  processed = this.replaceDividers(processed);
3003
3300
  processed = processed.replace(/<unknown[^>]*\/>/g, "");
3301
+ processed = this.stripInlineAnnotationMarkers(processed);
3004
3302
  return processed;
3005
3303
  }
3304
+ /**
3305
+ * compact underline/color 보존마커를 평문으로 강등 — block(martian) 폴백 경로 전용.
3306
+ *
3307
+ * 무손실 underline/color 복원은 기본 경로인 Markdown API({@link obsidianToNotionEnhanced})가
3308
+ * 전담한다. martian 은 이 서식을 표현할 수 없으므로, Markdown API 가 실패해 이 폴백을 탈 때는
3309
+ * 마커 텍스트가 본문에 리터럴로 새지 않도록 텍스트만 남기고 마커를 제거한다(서식 degrade).
3310
+ */
3311
+ stripInlineAnnotationMarkers(markdown) {
3312
+ return markdown.replace(new RegExp(`%%${MARKER_BRAND}:underline%%([\\s\\S]*?)%%\\/underline%%`, "g"), "$1").replace(new RegExp(`%%${MARKER_BRAND}:color:[^%]+%%([\\s\\S]*?)%%\\/color%%`, "g"), "$1");
3313
+ }
3006
3314
  replaceDividers(markdown) {
3007
3315
  const lines = markdown.split("\n");
3008
3316
  const result = [];
@@ -3074,12 +3382,18 @@ ${COLUMN_LIST_END}`;
3074
3382
  result.push(dividerConverted);
3075
3383
  continue;
3076
3384
  }
3385
+ const markerConverted = this.convertSingleLineMarker(block);
3386
+ if (markerConverted) {
3387
+ result.push(markerConverted);
3388
+ continue;
3389
+ }
3077
3390
  const videoConverted = this.convertImageToVideoOrEmbed(block);
3078
3391
  if (videoConverted) {
3079
3392
  result.push(videoConverted);
3080
3393
  continue;
3081
3394
  }
3082
- const calloutConverted = this.convertQuoteToCallout(block);
3395
+ const flattened = this.flattenMartianBlockquote(block);
3396
+ const calloutConverted = this.convertQuoteToCallout(flattened);
3083
3397
  result.push(calloutConverted);
3084
3398
  }
3085
3399
  return result;
@@ -3127,12 +3441,33 @@ ${COLUMN_LIST_END}`;
3127
3441
  }
3128
3442
  return null;
3129
3443
  }
3444
+ /**
3445
+ * 단일 라인 보존 마커(목차·breadcrumb)를 원래 블록으로 복원한다.
3446
+ * pull 시 마크다운 표현이 없는 블록을 `%%im-nobsidian:toc%%` /
3447
+ * `%%im-nobsidian:breadcrumb%%` 마커 문단으로 남겨두므로, push 시 이 문단을
3448
+ * 감지해 table_of_contents / breadcrumb 블록으로 되돌린다. 그러지 않으면
3449
+ * 마커 텍스트가 일반 문단으로 Notion 에 기록되는 잠재 손실이 된다.
3450
+ */
3451
+ convertSingleLineMarker(block) {
3452
+ if (block.type !== "paragraph") return null;
3453
+ const para = block.paragraph;
3454
+ const texts = para?.rich_text ?? [];
3455
+ if (texts.length !== 1) return null;
3456
+ const text = (texts[0]?.text?.content ?? "").trim();
3457
+ if (text === TOC_MARKER) {
3458
+ return NotionBlockBuilder.tableOfContents();
3459
+ }
3460
+ if (text === BREADCRUMB_MARKER) {
3461
+ return NotionBlockBuilder.breadcrumb();
3462
+ }
3463
+ return null;
3464
+ }
3130
3465
  convertImageToVideoOrEmbed(block) {
3131
3466
  let url = "";
3132
3467
  let caption;
3133
3468
  if (block.type === "image") {
3134
3469
  const img = block.image;
3135
- url = img?.external?.url ?? "";
3470
+ url = img?.external?.url ?? img?.file?.url ?? "";
3136
3471
  caption = img?.caption?.[0]?.text?.content;
3137
3472
  } else if (block.type === "paragraph") {
3138
3473
  const para = block.paragraph;
@@ -3153,6 +3488,53 @@ ${COLUMN_LIST_END}`;
3153
3488
  }
3154
3489
  return null;
3155
3490
  }
3491
+ /**
3492
+ * `@tryfabric/martian` 의 blockquote 표현을 **평탄화**한다(I2/I3 무손실 push).
3493
+ *
3494
+ * martian 의 parseBlockquote 는 `notion.blockquote([], children)` 로 본문을 quote 의
3495
+ * **자식 paragraph** 로 내려보내고 quote.rich_text 를 비운다(라이브러리 설계). 그 결과
3496
+ * `> 인용문` 을 push 하면 Notion 에 "빈 인용 줄 + 들여쓴 자식 문단"이 만들어지고, 다시
3497
+ * pull 하면 `> `(빈 줄) 아래에 자식 문단이 매달려 **원문과 구조가 갈라진다**(라운드트립 손실).
3498
+ * 또한 quote.rich_text[0] 이 항상 비어 callout 이모지 감지({@link convertQuoteToCallout})도
3499
+ * 무력화됐다.
3500
+ *
3501
+ * 선두의 연속된 paragraph 자식을 quote.rich_text 로 끌어올리고(문단 사이는 줄바꿈 rich_text
3502
+ * 로 연결 — Notion 네이티브 멀티라인 인용 표현), paragraph 가 아닌 자식(중첩 리스트 등)은
3503
+ * children 으로 남겨 구조를 보존한다. 이미 rich_text 에 본문이 있거나 끌어올릴 paragraph 가
3504
+ * 없으면 원본을 그대로 반환한다(안전 no-op).
3505
+ */
3506
+ flattenMartianBlockquote(block) {
3507
+ if (block.type !== "quote") return block;
3508
+ const quote = block.quote;
3509
+ if (!quote) return block;
3510
+ const richText = quote.rich_text ?? [];
3511
+ const hasText = richText.some((r) => {
3512
+ const t = r;
3513
+ return (t.text?.content ?? t.plain_text ?? "") !== "";
3514
+ });
3515
+ const children = quote.children ?? [];
3516
+ if (hasText || children.length === 0) return block;
3517
+ const hoisted = [];
3518
+ let consumed = 0;
3519
+ for (; consumed < children.length; consumed++) {
3520
+ const child = children[consumed];
3521
+ if (child.type !== "paragraph") break;
3522
+ const para = child.paragraph;
3523
+ const paraRt = para?.rich_text ?? [];
3524
+ if (hoisted.length > 0) {
3525
+ hoisted.push(
3526
+ ...NotionBlockBuilder.richText("\n")
3527
+ );
3528
+ }
3529
+ hoisted.push(...paraRt);
3530
+ }
3531
+ if (hoisted.length === 0) return block;
3532
+ const remaining = children.slice(consumed);
3533
+ const newQuote = { ...quote, rich_text: hoisted };
3534
+ if (remaining.length > 0) newQuote.children = remaining;
3535
+ else delete newQuote.children;
3536
+ return { ...block, quote: newQuote };
3537
+ }
3156
3538
  convertQuoteToCallout(block) {
3157
3539
  if (block.type !== "quote") return block;
3158
3540
  const quote = block.quote;
@@ -3204,6 +3586,22 @@ ${COLUMN_LIST_END}`;
3204
3586
  const result = this.n2m.toMarkdownString(mdBlocks);
3205
3587
  return result.parent ?? "";
3206
3588
  }
3589
+ /**
3590
+ * **in-memory** 블록 배열을 마크다운으로 변환한다(페이지 fetch 없음). notion-to-md 의
3591
+ * blocksToMarkdown 을 직접 호출하므로, `has_children:false` 인 평면 블록은 Notion 클라이언트를
3592
+ * 전혀 건드리지 않는다 → 고정 픽스처로 **오프라인 결정론** 블록 라운드트립(I2)을 잠글 수 있는
3593
+ * 정식 테스트 시임. children 이 있는 블록(table 행·callout 자식 등)은 클라이언트를 호출하므로
3594
+ * 호출 측이 평면 블록만 넣어야 오프라인이 보장된다.
3595
+ */
3596
+ async notionBlockArrayToMarkdown(blocks) {
3597
+ if (!this.n2m) {
3598
+ throw new Error(
3599
+ "NotionToMarkdown\uC774 \uCD08\uAE30\uD654\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. initNotionToMd()\uB97C \uBA3C\uC800 \uD638\uCD9C\uD558\uC138\uC694."
3600
+ );
3601
+ }
3602
+ const mdBlocks = await this.n2m.blocksToMarkdown(blocks);
3603
+ return this.n2m.toMarkdownString(mdBlocks).parent ?? "";
3604
+ }
3207
3605
  };
3208
3606
  var NOTION_MAX_TABLE_ROWS = 100;
3209
3607
  var NOTION_MAX_LIST_DEPTH = 3;
@@ -3331,10 +3729,11 @@ var EXTENSION_TO_MIME = {
3331
3729
  ".tiff": "image/tiff"
3332
3730
  };
3333
3731
  var ImageHandler = class {
3334
- constructor(vaultFs, attachmentFolder = "attachments", notionClient, customFetch, options) {
3732
+ constructor(vaultFs, attachmentFolder = "attachments", notionClient, customFetch, options, stateDb) {
3335
3733
  this.vaultFs = vaultFs;
3336
3734
  this.attachmentFolder = attachmentFolder;
3337
3735
  this.notionClient = notionClient;
3736
+ this.stateDb = stateDb;
3338
3737
  this.customFetch = customFetch;
3339
3738
  this.concurrency = options?.concurrency ?? 3;
3340
3739
  this.maxRetries = options?.maxRetries ?? 3;
@@ -3344,6 +3743,7 @@ var ImageHandler = class {
3344
3743
  vaultFs;
3345
3744
  attachmentFolder;
3346
3745
  notionClient;
3746
+ stateDb;
3347
3747
  customFetch;
3348
3748
  concurrency;
3349
3749
  maxRetries;
@@ -3447,7 +3847,8 @@ var ImageHandler = class {
3447
3847
  const contentType = this.getContentTypeFromPath(filename);
3448
3848
  const blob = new Blob([buffer], { type: contentType });
3449
3849
  const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
3450
- return { localPath, fileUploadId };
3850
+ const fileHash = createHash("sha256").update(buffer).digest("hex");
3851
+ return { localPath, fileUploadId, fileHash, fileSize: buffer.length };
3451
3852
  }
3452
3853
  async uploadAndAppendImages(pageId, images) {
3453
3854
  if (!this.notionClient) return [];
@@ -3472,10 +3873,26 @@ var ImageHandler = class {
3472
3873
  }
3473
3874
  if (imageBlocks.length > 0) {
3474
3875
  await this.notionClient.appendChildren(pageId, imageBlocks);
3475
- }
3476
- return results;
3477
- }
3478
- async readImageFromVault(localPath) {
3876
+ if (this.stateDb) {
3877
+ for (const r of results) {
3878
+ try {
3879
+ this.stateDb.registerFile({
3880
+ localPath: r.localPath,
3881
+ notionPageId: pageId,
3882
+ fileUploadId: r.fileUploadId,
3883
+ fileType: "image",
3884
+ fileHash: r.fileHash,
3885
+ fileSize: r.fileSize
3886
+ });
3887
+ } catch (error) {
3888
+ getLogger().warn(`\uC774\uBBF8\uC9C0 \uB808\uC9C0\uC2A4\uD2B8\uB9AC \uB4F1\uB85D \uC2E4\uD328 (${r.localPath}):`, error);
3889
+ }
3890
+ }
3891
+ }
3892
+ }
3893
+ return results;
3894
+ }
3895
+ async readImageFromVault(localPath) {
3479
3896
  try {
3480
3897
  return await this.vaultFs.readBinary(localPath);
3481
3898
  } catch {
@@ -3963,6 +4380,7 @@ function resolvePullConflict(input) {
3963
4380
 
3964
4381
  // src/utils/sanitize.ts
3965
4382
  var INVALID_CHARS = /[<>:"/\\|?*\x00-\x1f]/g;
4383
+ var WIKILINK_UNSAFE_CHARS = /[[\]]/g;
3966
4384
  var TRAILING_DOTS = /\.+$/;
3967
4385
  var RESERVED_NAMES = /* @__PURE__ */ new Set([
3968
4386
  "CON",
@@ -3989,7 +4407,7 @@ var RESERVED_NAMES = /* @__PURE__ */ new Set([
3989
4407
  "LPT9"
3990
4408
  ]);
3991
4409
  function sanitizeFileName(name) {
3992
- let sanitized = name.replace(INVALID_CHARS, "_").replace(TRAILING_DOTS, "").trim().slice(0, 200);
4410
+ let sanitized = name.replace(INVALID_CHARS, "_").replace(WIKILINK_UNSAFE_CHARS, "_").replace(TRAILING_DOTS, "").trim().slice(0, 200);
3993
4411
  if (RESERVED_NAMES.has(sanitized.toUpperCase())) {
3994
4412
  sanitized = `_${sanitized}`;
3995
4413
  }
@@ -3999,6 +4417,36 @@ function sanitizeFileName(name) {
3999
4417
  return sanitized;
4000
4418
  }
4001
4419
 
4420
+ // src/utils/db-row-path.ts
4421
+ function selectDbRowFiles(files, localFolder) {
4422
+ const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
4423
+ return files.filter((file) => {
4424
+ if (!file.path.startsWith(prefix)) return false;
4425
+ const rel = file.path.slice(prefix.length);
4426
+ return rel.length > 0 && !rel.includes("/");
4427
+ });
4428
+ }
4429
+ function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
4430
+ const ownedBySelf = (candidate) => {
4431
+ const owner = lookupByPath(candidate);
4432
+ if (!owner) return true;
4433
+ return owner.notionPageId != null && notionIdsEqual(owner.notionPageId, pageId);
4434
+ };
4435
+ const natural = `${localFolder}/${safeName}.md`;
4436
+ if (ownedBySelf(natural)) return natural;
4437
+ const raw = pageId.replace(/-/g, "");
4438
+ for (const len of [8, 16, 32]) {
4439
+ const candidate = `${localFolder}/${safeName} (${raw.slice(0, len)}).md`;
4440
+ if (ownedBySelf(candidate)) return candidate;
4441
+ }
4442
+ return `${localFolder}/${safeName} (${raw}).md`;
4443
+ }
4444
+
4445
+ // src/utils/wikilink-title.ts
4446
+ function wikilinkTitleFromPath(obsidianPath) {
4447
+ return obsidianPath.replace(/\.md$/i, "").split("/").pop() ?? "";
4448
+ }
4449
+
4002
4450
  // src/view/base-file-generator.ts
4003
4451
  var NOTION_TO_BASES_VIEW = {
4004
4452
  table: "table",
@@ -4012,6 +4460,9 @@ var NOTION_TO_BASES_VIEW = {
4012
4460
  map: null,
4013
4461
  dashboard: null
4014
4462
  };
4463
+ function basesViewTypeOf(notionType) {
4464
+ return NOTION_TO_BASES_VIEW[notionType] ?? null;
4465
+ }
4015
4466
  var BaseFileGenerator = class {
4016
4467
  generate(options) {
4017
4468
  const lines = [];
@@ -4060,6 +4511,7 @@ var BaseFileGenerator = class {
4060
4511
  lines.push(" name: Table");
4061
4512
  return;
4062
4513
  }
4514
+ this.dedupeViewNames(basesViews);
4063
4515
  lines.push("views:");
4064
4516
  for (const view of basesViews) {
4065
4517
  lines.push(` - type: ${view.type}`);
@@ -4089,6 +4541,23 @@ var BaseFileGenerator = class {
4089
4541
  }
4090
4542
  }
4091
4543
  }
4544
+ /**
4545
+ * 뷰 이름을 Bases 요구사항대로 유일화한다(in-place). 동명 충돌 시 ' 2',' 3'… 를 붙이되
4546
+ * 이미 존재하는 이름과 다시 충돌하지 않을 때까지 증가시킨다.
4547
+ */
4548
+ dedupeViewNames(views) {
4549
+ const used = /* @__PURE__ */ new Set();
4550
+ for (const v of views) {
4551
+ let name = v.name;
4552
+ if (used.has(name)) {
4553
+ let n = 2;
4554
+ while (used.has(`${v.name} ${n}`)) n++;
4555
+ name = `${v.name} ${n}`;
4556
+ }
4557
+ used.add(name);
4558
+ v.name = name;
4559
+ }
4560
+ }
4092
4561
  convertView(view, schema) {
4093
4562
  const basesType = NOTION_TO_BASES_VIEW[view.type];
4094
4563
  if (!basesType) return null;
@@ -4181,10 +4650,156 @@ var BaseFileGenerator = class {
4181
4650
  }
4182
4651
  };
4183
4652
 
4653
+ // src/view/sidecar-generator.ts
4654
+ var DROPPED_SETTING_REASONS = {
4655
+ filter: "Notion \uD544\uD130\uC2DD\uC740 .base \uB85C \uD45C\uD604\uB418\uC9C0 \uC54A\uC74C(\uD3F4\uB354 \uD544\uD130\uB9CC \uC801\uC6A9)",
4656
+ coverSize: "\uAC24\uB7EC\uB9AC \uCEE4\uBC84 \uD06C\uAE30\uB294 Bases \uBBF8\uC9C0\uC6D0",
4657
+ coverAspect: "\uAC24\uB7EC\uB9AC \uCEE4\uBC84 \uBE44\uC728\uC740 Bases \uBBF8\uC9C0\uC6D0",
4658
+ viewRange: "\uCE98\uB9B0\uB354 \uD45C\uC2DC \uBC94\uC704(week/month)\uB294 Bases \uBBF8\uC9C0\uC6D0",
4659
+ columnWidths: "\uCEEC\uB7FC \uB108\uBE44(width)\uB294 Bases \uBBF8\uC9C0\uC6D0",
4660
+ columnWraps: "\uCEEC\uB7FC \uC904\uBC14\uAFC8(wrap)\uC740 Bases \uBBF8\uC9C0\uC6D0",
4661
+ hideEmptyGroups: "\uBE48 \uADF8\uB8F9 \uC228\uAE40\uC740 Bases \uBBF8\uC9C0\uC6D0"
4662
+ };
4663
+ var SidecarGenerator = class {
4664
+ /**
4665
+ * 사이드카 객체를 빌드한다(순수 — I/O 없음). 직렬화는 {@link serialize} 가 담당.
4666
+ */
4667
+ build(options) {
4668
+ const degraded = [];
4669
+ const properties = this.buildProperties(options.schema);
4670
+ const views = options.viewsConfig.views.map((v) => this.buildView(v, degraded));
4671
+ degraded.sort(
4672
+ (a, b) => a.view.localeCompare(b.view) || a.kind.localeCompare(b.kind) || a.detail.localeCompare(b.detail)
4673
+ );
4674
+ return {
4675
+ generator: "im-nobsidian",
4676
+ schemaVersion: 1,
4677
+ databaseId: options.databaseId,
4678
+ databaseName: options.databaseName,
4679
+ properties,
4680
+ views,
4681
+ degraded
4682
+ };
4683
+ }
4684
+ /**
4685
+ * 사이드카를 결정적 JSON 문자열로 직렬화한다(키 재귀 정렬 + 말미 개행).
4686
+ * 동일 입력 → 동일 바이트 → 멱등(drift/churn 0).
4687
+ */
4688
+ serialize(sidecar) {
4689
+ return stableStringify(sidecar) + "\n";
4690
+ }
4691
+ buildProperties(schema) {
4692
+ return Object.entries(schema).map(([name, prop]) => {
4693
+ const options = prop.options?.map((o) => o.name).filter((n) => n.length > 0);
4694
+ return {
4695
+ name,
4696
+ id: prop.id,
4697
+ type: prop.type,
4698
+ ...options && options.length > 0 ? { options } : {}
4699
+ };
4700
+ }).sort((a, b) => a.name.localeCompare(b.name));
4701
+ }
4702
+ buildView(view, degraded) {
4703
+ const basesType = basesViewTypeOf(view.type);
4704
+ const viewName = view.name || view.type;
4705
+ if (basesType === null) {
4706
+ degraded.push({
4707
+ view: viewName,
4708
+ kind: "view-unrepresentable",
4709
+ detail: `'${view.type}' \uBDF0\uB294 Obsidian Bases \uB300\uC751\uC774 \uC5C6\uC5B4 .base \uC5D0\uC11C \uB204\uB77D \u2014 \uC0AC\uC774\uB4DC\uCE74\uB85C\uB9CC \uBCF4\uC874`
4710
+ });
4711
+ }
4712
+ const config = this.extractConfig(view, basesType, viewName, degraded);
4713
+ return {
4714
+ id: view.id,
4715
+ name: viewName,
4716
+ notionType: view.type,
4717
+ basesType,
4718
+ config
4719
+ };
4720
+ }
4721
+ /**
4722
+ * 뷰 설정 중 의미 있는 항목을 추출한다. `.base` 가 표현 못하는 항목(필터·커버크기 등)은
4723
+ * config 에 보존하면서 동시에 degrade 리포트에 1줄씩 기록한다(표현되는 뷰에 한해 —
4724
+ * 미표현 뷰는 이미 view-unrepresentable 로 통째 보고됨).
4725
+ */
4726
+ extractConfig(view, basesType, viewName, degraded) {
4727
+ const config = {};
4728
+ const noteDrop = (key) => {
4729
+ if (basesType !== null && DROPPED_SETTING_REASONS[key]) {
4730
+ degraded.push({
4731
+ view: viewName,
4732
+ kind: "setting-dropped",
4733
+ detail: DROPPED_SETTING_REASONS[key]
4734
+ });
4735
+ }
4736
+ };
4737
+ if (view.filter !== void 0 && view.filter !== null) {
4738
+ config["filter"] = view.filter;
4739
+ noteDrop("filter");
4740
+ }
4741
+ if (view.sorts && view.sorts.length > 0) config["sorts"] = view.sorts;
4742
+ if (view.groupBy) {
4743
+ config["groupBy"] = view.groupBy;
4744
+ if (view.groupBy.hideEmptyGroups) noteDrop("hideEmptyGroups");
4745
+ }
4746
+ if (view.cover) config["cover"] = view.cover;
4747
+ if (view.coverSize) {
4748
+ config["coverSize"] = view.coverSize;
4749
+ noteDrop("coverSize");
4750
+ }
4751
+ if (view.coverAspect) {
4752
+ config["coverAspect"] = view.coverAspect;
4753
+ noteDrop("coverAspect");
4754
+ }
4755
+ if (view.datePropertyId) config["datePropertyId"] = view.datePropertyId;
4756
+ if (view.datePropertyName) config["datePropertyName"] = view.datePropertyName;
4757
+ if (view.viewRange) {
4758
+ config["viewRange"] = view.viewRange;
4759
+ noteDrop("viewRange");
4760
+ }
4761
+ if (view.properties && view.properties.length > 0) {
4762
+ config["properties"] = view.properties;
4763
+ if (view.properties.some((p) => p.width !== void 0)) noteDrop("columnWidths");
4764
+ if (view.properties.some((p) => p.wrap !== void 0)) noteDrop("columnWraps");
4765
+ }
4766
+ return config;
4767
+ }
4768
+ };
4769
+ function stableStringify(value) {
4770
+ return JSON.stringify(sortKeys(value), null, 2);
4771
+ }
4772
+ function sortKeys(value) {
4773
+ if (Array.isArray(value)) return value.map(sortKeys);
4774
+ if (value !== null && typeof value === "object") {
4775
+ const sorted = {};
4776
+ for (const key of Object.keys(value).sort()) {
4777
+ sorted[key] = sortKeys(value[key]);
4778
+ }
4779
+ return sorted;
4780
+ }
4781
+ return value;
4782
+ }
4783
+
4784
+ // src/sync/stale-db-artifacts.ts
4785
+ var ARTIFACT_SUFFIXES = [".base", ".notion.json"];
4786
+ function selectStaleDbArtifacts(files, localFolder, keepPaths) {
4787
+ const prefix = `${localFolder}/`;
4788
+ const stale = [];
4789
+ for (const file of files) {
4790
+ const path = file.path;
4791
+ if (!path.startsWith(prefix)) continue;
4792
+ const rel = path.slice(prefix.length);
4793
+ if (rel.includes("/")) continue;
4794
+ if (!ARTIFACT_SUFFIXES.some((suffix) => rel.endsWith(suffix))) continue;
4795
+ if (keepPaths.has(path)) continue;
4796
+ stale.push(path);
4797
+ }
4798
+ return stale;
4799
+ }
4800
+
4184
4801
  // src/converter/enhanced-md-converter.ts
4185
4802
  var NOTION_CALLOUT_RE = /^::: callout\n([\s\S]*?)\n:::/gm;
4186
- var NOTION_CALLOUT_TAG_RE = /<callout[^>]*>\n?([\s\S]*?)<\/callout>/g;
4187
- var NOTION_TOGGLE_RE = /[\t ]*<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gs;
4188
4803
  var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
4189
4804
  var NOTION_USER_MENTION_RE = /<mention-user id="[^"]*">([^<]*)<\/mention-user>/g;
4190
4805
  var NOTION_DATE_MENTION_RE = /<mention-date start="([^"]*)"(?: end="([^"]*)")?[^>]*\/>/g;
@@ -4195,13 +4810,12 @@ var NOTION_VIDEO_RE = /[\t ]*<video src="([^"]*)">([\s\S]*?)<\/video>/g;
4195
4810
  var NOTION_PDF_RE = /[\t ]*<pdf src="([^"]*)">([\s\S]*?)<\/pdf>/g;
4196
4811
  var NOTION_FILE_RE = /[\t ]*<file src="([^"]*)">([\s\S]*?)<\/file>/g;
4197
4812
  var NOTION_TAB_RE = /<tab title="([^"]*)">([\s\S]*?)<\/tab>/g;
4198
- var NOTION_UNDERLINE_RE = /<span underline="true">([\s\S]*?)<\/span>/g;
4199
4813
  function notionEnhancedToObsidian(enhanced) {
4200
4814
  let result = enhanced;
4201
4815
  result = convertSyncedBlockRef(result);
4202
4816
  result = normalizeCodeBlockToggles(result);
4203
- result = convertToggles(result);
4204
- result = convertCallouts(result);
4817
+ result = convertContainers(result);
4818
+ result = convertFencedCallouts(result);
4205
4819
  result = convertPageMentions(result);
4206
4820
  result = convertPageLinks(result);
4207
4821
  result = convertUserMentions(result);
@@ -4211,9 +4825,7 @@ function notionEnhancedToObsidian(enhanced) {
4211
4825
  result = preserveUnknownBlocks(result);
4212
4826
  result = convertNotionMath(result);
4213
4827
  result = convertNotionTables(result);
4214
- result = convertColorSpans(result);
4215
- result = convertUnderlineSpans(result);
4216
- result = convertColumnBlocks(result);
4828
+ result = convertSpans(result);
4217
4829
  result = convertDatabaseBlocks(result);
4218
4830
  result = cleanInlineColorAttrs(result);
4219
4831
  result = removeEmptyBlocks(result);
@@ -4260,35 +4872,136 @@ function restoreWikilinkPreserveLinks(content) {
4260
4872
  return target === label ? `[[${target}]]` : `[[${target}|${label}]]`;
4261
4873
  });
4262
4874
  }
4875
+ function fenceFor(body) {
4876
+ let longest = 0;
4877
+ const re = /`+/g;
4878
+ let m;
4879
+ while ((m = re.exec(body)) !== null) longest = Math.max(longest, m[0].length);
4880
+ return "`".repeat(Math.max(3, longest + 1));
4881
+ }
4882
+ function dedentContainerBody(text) {
4883
+ const lines = text.split("\n");
4884
+ while (lines.length && lines[0].trim() === "") lines.shift();
4885
+ while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
4886
+ const kinds = [];
4887
+ let inCode = false;
4888
+ let fenceChar = "";
4889
+ let fenceLen = 0;
4890
+ let inTable = false;
4891
+ for (const line of lines) {
4892
+ const fence = /^[\t ]*(`{3,}|~{3,})(.*)$/.exec(line);
4893
+ if (inCode) {
4894
+ if (fence && fence[1][0] === fenceChar && fence[1].length >= fenceLen && fence[2].trim() === "") {
4895
+ inCode = false;
4896
+ kinds.push("fence");
4897
+ } else {
4898
+ kinds.push("code");
4899
+ }
4900
+ } else if (inTable) {
4901
+ const closes = /<\/table>/.test(line);
4902
+ kinds.push(closes ? "fence" : "code");
4903
+ if (closes) inTable = false;
4904
+ } else if (fence) {
4905
+ inCode = true;
4906
+ fenceChar = fence[1][0];
4907
+ fenceLen = fence[1].length;
4908
+ kinds.push("fence");
4909
+ } else if (/^[\t ]*<table[^>]*>/.test(line)) {
4910
+ if (!/<\/table>/.test(line)) inTable = true;
4911
+ kinds.push("fence");
4912
+ } else {
4913
+ kinds.push("prose");
4914
+ }
4915
+ }
4916
+ let min = Infinity;
4917
+ lines.forEach((l, i) => {
4918
+ if (kinds[i] !== "prose" || l.trim() === "") return;
4919
+ min = Math.min(min, /^[\t ]*/.exec(l)[0].length);
4920
+ });
4921
+ if (!Number.isFinite(min)) min = 0;
4922
+ return lines.map((l, i) => {
4923
+ if (l.trim() === "") return "";
4924
+ if (kinds[i] === "code") return l;
4925
+ if (kinds[i] === "fence") return l.replace(/^[\t ]+/, "");
4926
+ return l.slice(min);
4927
+ }).join("\n");
4928
+ }
4263
4929
  function normalizeCodeBlockToggles(content) {
4264
4930
  return content.replace(
4265
- /^- (.+)\n\t```(\w*)\n([\s\S]*?)\n\t```$/gm,
4266
- (_match, title, lang, body) => {
4267
- const langTag = lang ? lang : "";
4931
+ /^[\t ]*- (.+)\n[\t ]*(`{3,})(\w*)\n([\s\S]*?)\n[\t ]*\2[\t ]*$/gm,
4932
+ (_match, title, _open, lang, rawBody) => {
4933
+ const body = dedentContainerBody(rawBody);
4934
+ const fence = fenceFor(body);
4268
4935
  return `<details>
4269
4936
  <summary>${title.trim()}</summary>
4270
- \`\`\`${langTag}
4937
+ ${fence}${lang}
4271
4938
  ${body}
4272
- \`\`\`
4939
+ ${fence}
4273
4940
  </details>`;
4274
4941
  }
4275
4942
  );
4276
4943
  }
4277
- function convertToggles(content) {
4278
- function replaceToggle(match) {
4279
- const m = /[\t ]*<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/s.exec(match);
4280
- if (!m) return match;
4281
- const title = m[1].trim();
4282
- let body = m[2].trim();
4283
- body = convertToggles(body);
4284
- const calloutBody = body.split("\n").map((line) => line ? `> ${line}` : ">").join("\n");
4285
- return `> [!toggle]- ${title}
4944
+ var NO_CONTAINER_BODY = "(?:(?!<details>|<callout|<columns>)[\\s\\S])*?";
4945
+ var SUMMARY_TITLE = "((?:(?!<\\/summary>|<details>|<summary>)[\\s\\S])*?)";
4946
+ var INNERMOST_DETAILS_RE = new RegExp(
4947
+ `([\\t ]*)<details>\\s*<summary>${SUMMARY_TITLE}<\\/summary>(${NO_CONTAINER_BODY})<\\/details>`,
4948
+ "g"
4949
+ );
4950
+ var INNERMOST_CALLOUT_RE = new RegExp(
4951
+ `([\\t ]*)<callout[^>]*>\\n?(${NO_CONTAINER_BODY})<\\/callout>`,
4952
+ "g"
4953
+ );
4954
+ var INNERMOST_COLUMNS_RE = new RegExp(
4955
+ `([\\t ]*)<columns>\\n?(${NO_CONTAINER_BODY})<\\/columns>`,
4956
+ "g"
4957
+ );
4958
+ var COLUMN_WRAP_RE = /[\t ]*<column>\n?([\s\S]*?)[\t ]*<\/column>\n?/g;
4959
+ function reindentLines(text, indent) {
4960
+ if (!indent) return text;
4961
+ return text.split("\n").map((line) => line === "" ? line : indent + line).join("\n");
4962
+ }
4963
+ function toggleToCallout(title, rawBody) {
4964
+ const body = dedentContainerBody(rawBody);
4965
+ const calloutBody = body.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n");
4966
+ return `> [!toggle]- ${title.trim()}
4286
4967
  ${calloutBody}`;
4968
+ }
4969
+ function flattenColumns(body) {
4970
+ const cols = [];
4971
+ const re = new RegExp(COLUMN_WRAP_RE.source, COLUMN_WRAP_RE.flags);
4972
+ let m;
4973
+ let matched = false;
4974
+ while ((m = re.exec(body)) !== null) {
4975
+ matched = true;
4976
+ const col = dedentContainerBody(m[1]);
4977
+ if (col.trim() !== "") cols.push(col);
4978
+ }
4979
+ if (!matched) return dedentContainerBody(body);
4980
+ return cols.join("\n\n");
4981
+ }
4982
+ function convertContainers(content) {
4983
+ let result = content;
4984
+ let safety = 0;
4985
+ while (safety++ < 2e3 && (result.includes("<details>") || result.includes("<callout") || result.includes("<columns>"))) {
4986
+ const before = result;
4987
+ result = result.replace(
4988
+ INNERMOST_DETAILS_RE,
4989
+ (_m, indent, title, body) => reindentLines(toggleToCallout(title, body), indent)
4990
+ );
4991
+ result = result.replace(
4992
+ INNERMOST_CALLOUT_RE,
4993
+ (_m, indent, body) => reindentLines(calloutBodyToObsidian(body), indent)
4994
+ );
4995
+ result = result.replace(
4996
+ INNERMOST_COLUMNS_RE,
4997
+ (_m, indent, body) => reindentLines(flattenColumns(body), indent)
4998
+ );
4999
+ if (result === before) break;
4287
5000
  }
4288
- return content.replace(NOTION_TOGGLE_RE, replaceToggle);
5001
+ return result;
4289
5002
  }
4290
5003
  function calloutBodyToObsidian(body) {
4291
- const lines = body.split("\n").map((l) => l.replace(/^\t/, "")).filter((l, i, arr) => !(i === 0 && l === "") && !(i === arr.length - 1 && l === ""));
5004
+ const lines = dedentContainerBody(body).split("\n").filter((l, i, arr) => !(i === 0 && l === "") && !(i === arr.length - 1 && l === ""));
4292
5005
  const firstLine = lines[0] ?? "";
4293
5006
  const emojiMatch = /^([\p{Emoji}️‍]+)\s*(.*)/u.exec(firstLine);
4294
5007
  const type = emojiMatch ? emojiToCalloutType(emojiMatch[1]) : "note";
@@ -4298,13 +5011,8 @@ function calloutBodyToObsidian(body) {
4298
5011
  const calloutBody = rest ? "\n" + rest.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n") : "";
4299
5012
  return calloutTitle + calloutBody;
4300
5013
  }
4301
- function convertCallouts(content) {
4302
- let result = content.replace(
4303
- NOTION_CALLOUT_TAG_RE,
4304
- (_match, body) => calloutBodyToObsidian(body)
4305
- );
4306
- result = result.replace(NOTION_CALLOUT_RE, (_match, body) => calloutBodyToObsidian(body));
4307
- return result;
5014
+ function convertFencedCallouts(content) {
5015
+ return content.replace(NOTION_CALLOUT_RE, (_match, body) => calloutBodyToObsidian(body));
4308
5016
  }
4309
5017
  function convertPageMentions(content) {
4310
5018
  let result = content.replace(NOTION_PAGE_MENTION_RE, (_match, _id, text) => {
@@ -4312,7 +5020,7 @@ function convertPageMentions(content) {
4312
5020
  return `[[${cleaned}]]`;
4313
5021
  });
4314
5022
  result = result.replace(
4315
- /<mention-page\s+url="https?:\/\/(?:www\.)?notion\.so\/([a-f0-9]{32})"[^>]*\/>/g,
5023
+ /<mention-page\s+url="https?:\/\/(?:www\.)?notion\.so\/([a-f0-9]{32})"[^>]*?(?:\/>|>[\s\S]*?<\/mention-page>)/g,
4316
5024
  (_match, id) => `[[notion:${id}]]`
4317
5025
  );
4318
5026
  return result;
@@ -4383,10 +5091,19 @@ function convertTabBlocks(content) {
4383
5091
  ${indented}`;
4384
5092
  });
4385
5093
  }
4386
- function convertUnderlineSpans(content) {
4387
- return content.replace(NOTION_UNDERLINE_RE, (_match, text) => {
4388
- return `${compactMarker("underline")}${text}%%/underline%%`;
4389
- });
5094
+ var INNERMOST_SPAN_RE = /<span (?:underline="true"|color="([^"]+)")>((?:(?!<span )[\s\S])*?)<\/span>/g;
5095
+ function convertSpans(content) {
5096
+ let result = content;
5097
+ let safety = 0;
5098
+ while (safety++ < 1e3) {
5099
+ const next = result.replace(
5100
+ INNERMOST_SPAN_RE,
5101
+ (_match, color, inner) => color !== void 0 ? `${compactMarker(`color:${color}`)}${inner}%%/color%%` : `${compactMarker("underline")}${inner}%%/underline%%`
5102
+ );
5103
+ if (next === result) break;
5104
+ result = next;
5105
+ }
5106
+ return result;
4390
5107
  }
4391
5108
  function convertTogglesToHtml(content) {
4392
5109
  const calloutToggleRe = /^> \[!toggle\]-\s*(.+)\n((?:>.*\n?)*)/gm;
@@ -4502,7 +5219,6 @@ function escapeRegex3(str) {
4502
5219
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4503
5220
  }
4504
5221
  var NOTION_PAGE_LINK_RE = /<page url="[^"]*">([\s\S]*?)<\/page>/g;
4505
- var NOTION_COLOR_SPAN_RE = /<span color="([^"]+)">([\s\S]*?)<\/span>/g;
4506
5222
  var NOTION_EMPTY_BLOCK_RE = /^(?:>[\t ]*)*[\t ]*<empty-block\/>\n?/gm;
4507
5223
  function convertPageLinks(content) {
4508
5224
  return content.replace(NOTION_PAGE_LINK_RE, (_match, text) => {
@@ -4510,12 +5226,6 @@ function convertPageLinks(content) {
4510
5226
  return `[[${cleaned}]]`;
4511
5227
  });
4512
5228
  }
4513
- function convertColorSpans(content) {
4514
- return content.replace(
4515
- NOTION_COLOR_SPAN_RE,
4516
- (_match, color, text) => `${compactMarker(`color:${color}`)}${text}%%/color%%`
4517
- );
4518
- }
4519
5229
  function convertDatabaseBlocks(content) {
4520
5230
  return content.replace(
4521
5231
  /<database[^>]*>([\s\S]*?)<\/database>/g,
@@ -4525,11 +5235,6 @@ function convertDatabaseBlocks(content) {
4525
5235
  function cleanInlineColorAttrs(content) {
4526
5236
  return content.replace(/\s*\{color="[^"]*"\}/g, "");
4527
5237
  }
4528
- function convertColumnBlocks(content) {
4529
- return content.replace(/<columns>\n?([\s\S]*?)<\/columns>/g, (_match, inner) => {
4530
- return inner.replace(/\t?<column>\n?/g, "").replace(/\t?<\/column>\n?/g, "").trim();
4531
- });
4532
- }
4533
5238
  function removeEmptyBlocks(content) {
4534
5239
  return content.replace(NOTION_EMPTY_BLOCK_RE, "\n");
4535
5240
  }
@@ -4691,7 +5396,12 @@ var DatabaseSyncer = class {
4691
5396
  this.imageHandler = imageHandler;
4692
5397
  this.propertyMapper.setWikilinkResolver({
4693
5398
  resolve: (title) => stateDb.resolveWikilink(title)?.notionPageId ?? null,
4694
- resolvePageId: (pageId) => stateDb.resolvePageId(pageId)?.title ?? null
5399
+ // M4: 후처리 패스(resolveNotionLinks)와 동일하게 파일 basename 으로 해소 — 원시 제목과
5400
+ // sanitize 된 파일명의 불일치로 인한 깨진/이중 위키링크 차단.
5401
+ resolvePageId: (pageId) => {
5402
+ const entry = stateDb.resolvePageId(pageId);
5403
+ return entry ? wikilinkTitleFromPath(entry.obsidianPath) : null;
5404
+ }
4695
5405
  });
4696
5406
  }
4697
5407
  config;
@@ -4702,15 +5412,17 @@ var DatabaseSyncer = class {
4702
5412
  imageHandler;
4703
5413
  propertyMapper = new PropertyMapper();
4704
5414
  baseFileGenerator = new BaseFileGenerator();
5415
+ sidecarGenerator = new SidecarGenerator();
4705
5416
  async pullAll() {
4706
5417
  const databases = this.config.notion.databases;
4707
5418
  if (!databases || databases.length === 0) {
4708
- return { created: 0, updated: 0, conflicts: [], failed: [] };
5419
+ return { created: 0, updated: 0, conflicts: [], failed: [], writtenPaths: [] };
4709
5420
  }
4710
5421
  let created = 0;
4711
5422
  let updated = 0;
4712
5423
  const conflicts = [];
4713
5424
  const failed = [];
5425
+ const writtenPaths = [];
4714
5426
  for (const dbConfig of databases) {
4715
5427
  try {
4716
5428
  const result = await this.pullDatabase(dbConfig);
@@ -4718,6 +5430,7 @@ var DatabaseSyncer = class {
4718
5430
  updated += result.updated;
4719
5431
  conflicts.push(...result.conflicts);
4720
5432
  failed.push(...result.failed);
5433
+ writtenPaths.push(...result.writtenPaths);
4721
5434
  } catch (error) {
4722
5435
  getLogger().warn(`[DB Sync] DB ${dbConfig.databaseId} pull \uC2E4\uD328:`, error);
4723
5436
  failed.push({
@@ -4727,12 +5440,12 @@ var DatabaseSyncer = class {
4727
5440
  });
4728
5441
  }
4729
5442
  }
4730
- return { created, updated, conflicts, failed };
5443
+ return { created, updated, conflicts, failed, writtenPaths };
4731
5444
  }
4732
5445
  async pushAll() {
4733
5446
  const databases = this.config.notion.databases;
4734
5447
  if (!databases || databases.length === 0) {
4735
- return { created: 0, updated: 0, conflicts: [], failed: [] };
5448
+ return { created: 0, updated: 0, conflicts: [], failed: [], writtenPaths: [] };
4736
5449
  }
4737
5450
  let created = 0;
4738
5451
  let updated = 0;
@@ -4752,7 +5465,7 @@ var DatabaseSyncer = class {
4752
5465
  });
4753
5466
  }
4754
5467
  }
4755
- return { created, updated, conflicts: [], failed };
5468
+ return { created, updated, conflicts: [], failed, writtenPaths: [] };
4756
5469
  }
4757
5470
  async pullDatabase(dbConfig) {
4758
5471
  const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
@@ -4768,6 +5481,7 @@ var DatabaseSyncer = class {
4768
5481
  let updated = 0;
4769
5482
  const conflicts = [];
4770
5483
  const failed = [];
5484
+ const writtenPaths = [];
4771
5485
  for (const page of pages) {
4772
5486
  try {
4773
5487
  const record = this.stateDb.getByNotionId(page.id);
@@ -4776,12 +5490,16 @@ var DatabaseSyncer = class {
4776
5490
  const outcome = await this.pullDatabasePage(page, dbConfig);
4777
5491
  if (outcome.action === "written") {
4778
5492
  updated++;
5493
+ writtenPaths.push(outcome.path);
4779
5494
  } else if (outcome.action === "conflict") {
4780
5495
  conflicts.push(outcome.conflict);
4781
5496
  }
4782
5497
  } else {
4783
- await this.pullDatabasePage(page, dbConfig);
5498
+ const outcome = await this.pullDatabasePage(page, dbConfig);
4784
5499
  created++;
5500
+ if (outcome.action === "written") {
5501
+ writtenPaths.push(outcome.path);
5502
+ }
4785
5503
  }
4786
5504
  } catch (error) {
4787
5505
  failed.push({
@@ -4794,7 +5512,7 @@ var DatabaseSyncer = class {
4794
5512
  getLogger().debug(
4795
5513
  `[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8, ${conflicts.length} \uCDA9\uB3CC`
4796
5514
  );
4797
- return { created, updated, conflicts, failed };
5515
+ return { created, updated, conflicts, failed, writtenPaths };
4798
5516
  }
4799
5517
  async pullDatabaseViews(dbConfig, force = false) {
4800
5518
  try {
@@ -4830,25 +5548,76 @@ var DatabaseSyncer = class {
4830
5548
  try {
4831
5549
  const schemaFull = await this.notionClient.getDatabaseSchemaFull(dbConfig.databaseId);
4832
5550
  const dbName = viewsConfig?.databaseName || await this.notionClient.getDatabaseTitle(dbConfig.databaseId) || dbConfig.localFolder.split("/").pop() || "Database";
5551
+ const resolvedViews = viewsConfig ?? {
5552
+ databaseId: dbConfig.databaseId,
5553
+ databaseName: dbName,
5554
+ lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
5555
+ views: [{ id: "default", name: "Table", type: "table" }]
5556
+ };
4833
5557
  const baseContent = this.baseFileGenerator.generate({
4834
5558
  databaseId: dbConfig.databaseId,
4835
5559
  databaseName: dbName,
4836
5560
  schema: schemaFull,
4837
- viewsConfig: viewsConfig ?? {
4838
- databaseId: dbConfig.databaseId,
4839
- databaseName: dbName,
4840
- lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
4841
- views: [{ id: "default", name: "Table", type: "table" }]
4842
- },
5561
+ viewsConfig: resolvedViews,
4843
5562
  folderPath: dbConfig.localFolder
4844
5563
  });
4845
- const basePath = `${dbConfig.localFolder}/${sanitizeFileName(dbName)}.base`;
5564
+ const safeName = sanitizeFileName(dbName);
5565
+ const basePath = `${dbConfig.localFolder}/${safeName}.base`;
4846
5566
  await this.vaultFs.writeFile(basePath, baseContent);
4847
5567
  getLogger().debug(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131: ${basePath}`);
5568
+ await this.generateSidecar(dbConfig, dbName, safeName, schemaFull, resolvedViews);
5569
+ await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName);
4848
5570
  } catch (error) {
4849
5571
  getLogger().warn(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
4850
5572
  }
4851
5573
  }
5574
+ /**
5575
+ * DB 제목이 바뀌면 `.base`/`.notion.json` 파일명(safeName)이 바뀌어 옛 이름의 산출물이
5576
+ * 같은 폴더에 고아로 잔존한다(drift·부활 원인). DB 폴더 **직속**의 산출물 중 현재 이름이
5577
+ * 아닌 것을 삭제해 멱등을 보장한다. 하위 폴더(중첩 DB)의 산출물은 보호한다.
5578
+ */
5579
+ async cleanupStaleDbArtifacts(localFolder, safeName) {
5580
+ try {
5581
+ const keep = /* @__PURE__ */ new Set([
5582
+ `${localFolder}/${safeName}.base`,
5583
+ `${localFolder}/${safeName}.notion.json`
5584
+ ]);
5585
+ const files = await this.vaultFs.listNonMarkdownFiles();
5586
+ const stale = selectStaleDbArtifacts(files, localFolder, keep);
5587
+ for (const path of stale) {
5588
+ await this.vaultFs.deleteFile(path);
5589
+ getLogger().info(`[DB Sync] \uACE0\uC544 DB \uC0B0\uCD9C\uBB3C \uC0AD\uC81C(rename): ${path}`);
5590
+ }
5591
+ } catch (error) {
5592
+ getLogger().warn(`[DB Sync] \uACE0\uC544 .base/.notion.json \uC815\uB9AC \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
5593
+ }
5594
+ }
5595
+ /**
5596
+ * `.base` 가 표현하지 못하는 Notion DB 메타데이터(미지원 뷰·필터식·커버크기 등)를
5597
+ * 사이드카 `<db>.notion.json` 으로 무손실 보존하고, degrade 된 항목을 정직하게 로그한다.
5598
+ * 결정적 직렬화라 동일 DB 상태 → 동일 바이트 → 멱등(drift/churn 0).
5599
+ */
5600
+ async generateSidecar(dbConfig, dbName, safeName, schemaFull, resolvedViews) {
5601
+ try {
5602
+ const sidecar = this.sidecarGenerator.build({
5603
+ databaseId: dbConfig.databaseId,
5604
+ databaseName: dbName,
5605
+ schema: schemaFull,
5606
+ viewsConfig: resolvedViews
5607
+ });
5608
+ const sidecarPath = `${dbConfig.localFolder}/${safeName}.notion.json`;
5609
+ await this.vaultFs.writeFile(sidecarPath, this.sidecarGenerator.serialize(sidecar));
5610
+ if (sidecar.degraded.length > 0) {
5611
+ const unrep = sidecar.degraded.filter((d) => d.kind === "view-unrepresentable").length;
5612
+ const dropped = sidecar.degraded.length - unrep;
5613
+ getLogger().info(
5614
+ `[DB Sync] '${dbName}' degrade ${sidecar.degraded.length}\uAC74 (\uBBF8\uD45C\uD604 \uBDF0 ${unrep} \xB7 \uBBF8\uD45C\uD604 \uC124\uC815 ${dropped}) \u2192 ${safeName}.notion.json \uBCF4\uC874`
5615
+ );
5616
+ }
5617
+ } catch (error) {
5618
+ getLogger().warn(`[DB Sync] \uC0AC\uC774\uB4DC\uCE74 \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
5619
+ }
5620
+ }
4852
5621
  async pullDatabasePage(page, dbConfig) {
4853
5622
  const title = this.notionClient.extractTitle(page);
4854
5623
  const safeName = sanitizeFileName(title);
@@ -4864,9 +5633,9 @@ var DatabaseSyncer = class {
4864
5633
  `![cover](${cover.url})`,
4865
5634
  `${safeName}-cover`
4866
5635
  );
4867
- const localUrlMatch = coverResult.content.match(/!\[cover\]\((.+?)\)/);
4868
- if (localUrlMatch?.[1]) {
4869
- properties.cover = `[[${localUrlMatch[1]}]]`;
5636
+ const resolved = coverResult.content.match(/!\[cover\]\((.+?)\)/)?.[1];
5637
+ if (resolved && !/^https?:\/\//i.test(resolved)) {
5638
+ properties.cover = `[[${resolved}]]`;
4870
5639
  } else {
4871
5640
  properties.cover = cover.url;
4872
5641
  }
@@ -4904,11 +5673,12 @@ var DatabaseSyncer = class {
4904
5673
  if (existingRecord?.obsidianPath) {
4905
5674
  filePath = existingRecord.obsidianPath;
4906
5675
  } else {
4907
- filePath = `${dbConfig.localFolder}/${safeName}.md`;
4908
- const existingByPath = this.stateDb.getByPath(filePath);
4909
- if (existingByPath && existingByPath.notionPageId !== page.id) {
4910
- filePath = `${dbConfig.localFolder}/${safeName} (${page.id.slice(0, 8)}).md`;
4911
- }
5676
+ filePath = resolveDbRowPath(
5677
+ dbConfig.localFolder,
5678
+ safeName,
5679
+ page.id,
5680
+ (p) => this.stateDb.getByPath(p)
5681
+ );
4912
5682
  }
4913
5683
  const finalContent = this.pipeline.convertToMarkdown(
4914
5684
  markdown,
@@ -4976,8 +5746,7 @@ var DatabaseSyncer = class {
4976
5746
  const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
4977
5747
  this.propertyMapper.loadSchema(schema);
4978
5748
  const allFiles = await this.vaultFs.listMarkdownFiles();
4979
- const prefix = dbConfig.localFolder.endsWith("/") ? dbConfig.localFolder : dbConfig.localFolder + "/";
4980
- const dbFiles = allFiles.filter((f) => f.path.startsWith(prefix));
5749
+ const dbFiles = selectDbRowFiles(allFiles, dbConfig.localFolder);
4981
5750
  const livePaths = new Set(dbFiles.map((f) => f.path));
4982
5751
  const orphanByHash = this.buildOrphanHashIndex(dbConfig.databaseId, livePaths);
4983
5752
  let created = 0;
@@ -4989,7 +5758,7 @@ var DatabaseSyncer = class {
4989
5758
  const hash = computeHash(content);
4990
5759
  const record = this.stateDb.getByPath(file.path);
4991
5760
  if (record?.contentHash === hash) continue;
4992
- const parsed = matter7(content);
5761
+ const parsed = matter(content);
4993
5762
  const frontmatter = parsed.data;
4994
5763
  const body = parsed.content;
4995
5764
  const title = frontmatter.title ?? extractTitleFromPath(file.path);
@@ -5079,7 +5848,7 @@ var DatabaseSyncer = class {
5079
5848
  }
5080
5849
  }
5081
5850
  getLogger().debug(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
5082
- return { created, updated, conflicts: [], failed };
5851
+ return { created, updated, conflicts: [], failed, writtenPaths: [] };
5083
5852
  }
5084
5853
  /**
5085
5854
  * rename 감지를 위한 고아 레코드 인덱스(내용 해시 → 레코드)를 만든다.
@@ -5110,82 +5879,381 @@ function extractTitleFromPath(filePath) {
5110
5879
  return filename.replace(/\.md$/, "");
5111
5880
  }
5112
5881
 
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));
5882
+ // src/conflict/merger.ts
5883
+ function threeWayMerge(base, local, remote) {
5884
+ const baseLines = base.split("\n");
5885
+ const localLines = local.split("\n");
5886
+ const remoteLines = remote.split("\n");
5887
+ const hunks = [
5888
+ ...computeHunks(baseLines, localLines, 0),
5889
+ ...computeHunks(baseLines, remoteLines, 2)
5890
+ ];
5891
+ hunks.sort((a, b) => a.baseStart - b.baseStart || a.side - b.side);
5892
+ const merged = [];
5893
+ const conflicts = [];
5118
5894
  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
- }
5895
+ let i = 0;
5896
+ while (i < hunks.length) {
5897
+ const first = hunks[i];
5898
+ if (first.baseStart > cursor) {
5899
+ merged.push(...baseLines.slice(cursor, first.baseStart));
5900
+ }
5901
+ const regionStart = first.baseStart;
5902
+ let regionEnd = first.baseEnd;
5903
+ const group = [first];
5904
+ i++;
5905
+ while (i < hunks.length) {
5906
+ const next = hunks[i];
5907
+ const overlaps = next.baseStart < regionEnd || regionEnd === regionStart && next.baseStart === regionStart;
5908
+ if (!overlaps) break;
5909
+ regionEnd = Math.max(regionEnd, next.baseEnd);
5910
+ group.push(next);
5911
+ i++;
5912
+ }
5913
+ const baseRegion = baseLines.slice(regionStart, regionEnd);
5914
+ const localRegion = applyHunks(baseLines, group, 0, regionStart, regionEnd);
5915
+ const remoteRegion = applyHunks(baseLines, group, 2, regionStart, regionEnd);
5916
+ if (arraysEqual(localRegion, baseRegion)) {
5917
+ merged.push(...remoteRegion);
5918
+ } else if (arraysEqual(remoteRegion, baseRegion)) {
5919
+ merged.push(...localRegion);
5920
+ } else if (arraysEqual(localRegion, remoteRegion)) {
5921
+ merged.push(...localRegion);
5922
+ } else {
5923
+ conflicts.push({ localLines: localRegion, remoteLines: remoteRegion });
5924
+ merged.push("<<<<<<< LOCAL");
5925
+ merged.push(...localRegion);
5926
+ merged.push("=======");
5927
+ merged.push(...remoteRegion);
5928
+ merged.push(">>>>>>> REMOTE");
5128
5929
  }
5930
+ cursor = regionEnd;
5931
+ }
5932
+ if (cursor < baseLines.length) {
5933
+ merged.push(...baseLines.slice(cursor));
5934
+ }
5935
+ return {
5936
+ success: conflicts.length === 0,
5937
+ merged: merged.join("\n"),
5938
+ conflicts
5129
5939
  };
5130
- await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
5131
5940
  }
5132
-
5133
- // src/sync/orchestrator.ts
5134
- var SyncOrchestrator = class {
5135
- constructor(config, stateDb, notionClient, vaultFs, customFetch) {
5136
- this.config = config;
5137
- this.stateDb = stateDb;
5138
- this.notionClient = notionClient;
5139
- this.vaultFs = vaultFs;
5140
- this.changeDetector = new ChangeDetector(stateDb);
5141
- this.pipeline = createDefaultPipeline({
5142
- wikilinkResolver: (text) => stateDb.resolveWikilink(text)
5143
- });
5144
- this.blockConverter = new BlockConverter();
5145
- this.imageHandler = new ImageHandler(
5146
- vaultFs,
5147
- config.paths.attachments,
5148
- notionClient,
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
5162
- );
5163
- this.propertyMapper = new PropertyMapper();
5164
- this.databaseSyncer = new DatabaseSyncer(
5165
- config,
5166
- stateDb,
5167
- notionClient,
5168
- vaultFs,
5169
- this.pipeline,
5170
- this.imageHandler
5171
- );
5172
- this.propertyMapper.setWikilinkResolver({
5173
- resolve: (title) => stateDb.resolveWikilink(title)?.notionPageId ?? null,
5174
- resolvePageId: (pageId) => stateDb.resolvePageId(pageId)?.title ?? null
5175
- });
5176
- this.blockConverter.initNotionToMd(this.notionClient.getInternalClient());
5177
- }
5178
- config;
5179
- stateDb;
5180
- notionClient;
5181
- vaultFs;
5182
- changeDetector;
5183
- pipeline;
5184
- blockConverter;
5941
+ function computeHunks(base, modified, side) {
5942
+ const matches = lcsMatches(base, modified);
5943
+ const hunks = [];
5944
+ let prevBase = -1;
5945
+ let prevMod = -1;
5946
+ const emitGap = (curBase, curMod) => {
5947
+ const baseLen = curBase - (prevBase + 1);
5948
+ const modLen = curMod - (prevMod + 1);
5949
+ if (baseLen > 0 || modLen > 0) {
5950
+ hunks.push({
5951
+ baseStart: prevBase + 1,
5952
+ baseEnd: curBase,
5953
+ content: modified.slice(prevMod + 1, curMod),
5954
+ side
5955
+ });
5956
+ }
5957
+ };
5958
+ for (const [b, m] of matches) {
5959
+ emitGap(b, m);
5960
+ prevBase = b;
5961
+ prevMod = m;
5962
+ }
5963
+ emitGap(base.length, modified.length);
5964
+ return hunks;
5965
+ }
5966
+ function applyHunks(baseLines, group, side, regionStart, regionEnd) {
5967
+ const out = [];
5968
+ let b = regionStart;
5969
+ for (const hunk of group) {
5970
+ if (hunk.side !== side) continue;
5971
+ if (hunk.baseStart > b) out.push(...baseLines.slice(b, hunk.baseStart));
5972
+ out.push(...hunk.content);
5973
+ b = hunk.baseEnd;
5974
+ }
5975
+ if (b < regionEnd) out.push(...baseLines.slice(b, regionEnd));
5976
+ return out;
5977
+ }
5978
+ function lcsMatches(a, b) {
5979
+ const n = a.length;
5980
+ const m = b.length;
5981
+ const dp = new Array(n + 1);
5982
+ for (let i2 = 0; i2 <= n; i2++) dp[i2] = new Uint32Array(m + 1);
5983
+ for (let i2 = n - 1; i2 >= 0; i2--) {
5984
+ const row = dp[i2];
5985
+ const next = dp[i2 + 1];
5986
+ const ai = a[i2];
5987
+ for (let j2 = m - 1; j2 >= 0; j2--) {
5988
+ row[j2] = ai === b[j2] ? next[j2 + 1] + 1 : Math.max(next[j2], row[j2 + 1]);
5989
+ }
5990
+ }
5991
+ const matches = [];
5992
+ let i = 0;
5993
+ let j = 0;
5994
+ while (i < n && j < m) {
5995
+ if (a[i] === b[j]) {
5996
+ matches.push([i, j]);
5997
+ i++;
5998
+ j++;
5999
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
6000
+ i++;
6001
+ } else {
6002
+ j++;
6003
+ }
6004
+ }
6005
+ return matches;
6006
+ }
6007
+ function arraysEqual(a, b) {
6008
+ if (a.length !== b.length) return false;
6009
+ return a.every((val, idx) => val === b[idx]);
6010
+ }
6011
+
6012
+ // src/conflict/resolver.ts
6013
+ var ConflictResolver = class {
6014
+ constructor(stateDb, vaultFs) {
6015
+ this.stateDb = stateDb;
6016
+ this.vaultFs = vaultFs;
6017
+ }
6018
+ stateDb;
6019
+ vaultFs;
6020
+ async resolve(conflict, choice) {
6021
+ switch (choice) {
6022
+ case "local":
6023
+ return this.resolveLocal(conflict);
6024
+ case "remote":
6025
+ return this.resolveRemote(conflict);
6026
+ case "merge":
6027
+ return this.resolveMerge(conflict);
6028
+ case "duplicate":
6029
+ return this.resolveDuplicate(conflict);
6030
+ }
6031
+ }
6032
+ async resolveByStrategy(conflict, strategy) {
6033
+ switch (strategy) {
6034
+ case "local-first":
6035
+ return this.resolve(conflict, "local");
6036
+ case "remote-first":
6037
+ return this.resolve(conflict, "remote");
6038
+ case "duplicate":
6039
+ return this.resolve(conflict, "duplicate");
6040
+ case "manual":
6041
+ return this.resolve(conflict, "merge");
6042
+ }
6043
+ }
6044
+ async resolveAll(conflicts, strategy) {
6045
+ const results = [];
6046
+ for (const conflict of conflicts) {
6047
+ const result = await this.resolveByStrategy(conflict, strategy);
6048
+ results.push(result);
6049
+ }
6050
+ return results;
6051
+ }
6052
+ async resolveLocal(conflict) {
6053
+ const hash = computeHash(conflict.localContent);
6054
+ this.stateDb.updateHash(
6055
+ conflict.syncRecord.id,
6056
+ hash,
6057
+ Buffer.from(conflict.localContent, "utf-8")
6058
+ );
6059
+ this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
6060
+ return { path: conflict.syncRecord.obsidianPath, choice: "local", success: true };
6061
+ }
6062
+ async resolveRemote(conflict) {
6063
+ await this.vaultFs.writeFile(conflict.syncRecord.obsidianPath, conflict.remoteContent);
6064
+ const hash = computeHash(conflict.remoteContent);
6065
+ this.stateDb.updateHash(
6066
+ conflict.syncRecord.id,
6067
+ hash,
6068
+ Buffer.from(conflict.remoteContent, "utf-8")
6069
+ );
6070
+ this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
6071
+ return { path: conflict.syncRecord.obsidianPath, choice: "remote", success: true };
6072
+ }
6073
+ async resolveMerge(conflict) {
6074
+ const base = conflict.baseContent ?? "";
6075
+ const mergeResult = threeWayMerge(base, conflict.localContent, conflict.remoteContent);
6076
+ await this.vaultFs.writeFile(conflict.syncRecord.obsidianPath, mergeResult.merged);
6077
+ const hash = computeHash(mergeResult.merged);
6078
+ this.stateDb.updateHash(conflict.syncRecord.id, hash, Buffer.from(mergeResult.merged, "utf-8"));
6079
+ if (mergeResult.success) {
6080
+ this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
6081
+ }
6082
+ return {
6083
+ path: conflict.syncRecord.obsidianPath,
6084
+ choice: "merge",
6085
+ success: mergeResult.success,
6086
+ mergeHadConflicts: !mergeResult.success
6087
+ };
6088
+ }
6089
+ async resolveDuplicate(conflict) {
6090
+ const originalPath = conflict.syncRecord.obsidianPath;
6091
+ const ext = originalPath.endsWith(".md") ? ".md" : "";
6092
+ const baseName = originalPath.replace(/\.md$/, "");
6093
+ const conflictPath = `${baseName}.conflict${ext}`;
6094
+ await this.vaultFs.writeFile(conflictPath, conflict.remoteContent);
6095
+ const localHash = computeHash(conflict.localContent);
6096
+ this.stateDb.updateHash(
6097
+ conflict.syncRecord.id,
6098
+ localHash,
6099
+ Buffer.from(conflict.localContent, "utf-8")
6100
+ );
6101
+ this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
6102
+ return { path: originalPath, choice: "duplicate", success: true };
6103
+ }
6104
+ generateDiff(conflict) {
6105
+ const localLines = conflict.localContent.split("\n");
6106
+ const remoteLines = conflict.remoteContent.split("\n");
6107
+ const lines = [];
6108
+ lines.push(`--- local: ${conflict.syncRecord.obsidianPath}`);
6109
+ lines.push(`+++ remote: Notion (${conflict.remoteChange.pageId})`);
6110
+ lines.push("");
6111
+ const maxLen = Math.max(localLines.length, remoteLines.length);
6112
+ for (let i = 0; i < maxLen; i++) {
6113
+ const localLine = localLines[i];
6114
+ const remoteLine = remoteLines[i];
6115
+ if (localLine === remoteLine) {
6116
+ lines.push(` ${localLine ?? ""}`);
6117
+ } else {
6118
+ if (localLine !== void 0) lines.push(`- ${localLine}`);
6119
+ if (remoteLine !== void 0) lines.push(`+ ${remoteLine}`);
6120
+ }
6121
+ }
6122
+ return lines.join("\n");
6123
+ }
6124
+ };
6125
+
6126
+ // src/utils/pool.ts
6127
+ async function runPool(items, worker, options) {
6128
+ const total = items.length;
6129
+ if (total === 0) return;
6130
+ const workerCount = Math.max(1, Math.min(Math.floor(options.concurrency) || 1, total));
6131
+ let cursor = 0;
6132
+ const runWorker = async () => {
6133
+ for (; ; ) {
6134
+ if (options.signal?.aborted) return;
6135
+ const index = cursor++;
6136
+ if (index >= total) return;
6137
+ try {
6138
+ await worker(items[index], index);
6139
+ } catch {
6140
+ }
6141
+ }
6142
+ };
6143
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
6144
+ }
6145
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$|^[0-9a-f]{32}$/i;
6146
+ function resolveFrontmatterRelations(content, idToTitle) {
6147
+ if (!content.startsWith("---") || idToTitle.size === 0) {
6148
+ return { content, count: 0 };
6149
+ }
6150
+ let parsed;
6151
+ try {
6152
+ parsed = matter(content);
6153
+ } catch {
6154
+ return { content, count: 0 };
6155
+ }
6156
+ const data = parsed.data;
6157
+ if (!data || Object.keys(data).length === 0) {
6158
+ return { content, count: 0 };
6159
+ }
6160
+ let count = 0;
6161
+ const resolveValue = (value) => {
6162
+ if (typeof value === "string") {
6163
+ if (!UUID_RE.test(value)) return value;
6164
+ const title = idToTitle.get(value.replace(/-/g, ""));
6165
+ if (!title) return value;
6166
+ count++;
6167
+ return `[[${title}]]`;
6168
+ }
6169
+ if (Array.isArray(value)) return value.map(resolveValue);
6170
+ return value;
6171
+ };
6172
+ const resolved = {};
6173
+ for (const key of Object.keys(data)) {
6174
+ resolved[key] = resolveValue(data[key]);
6175
+ }
6176
+ if (count === 0) return { content, count: 0 };
6177
+ return { content: stringifyFrontmatter(parsed.content, resolved), count };
6178
+ }
6179
+
6180
+ // src/sync/orchestrator.ts
6181
+ var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
6182
+ function strategyToChoice(strategy) {
6183
+ switch (strategy) {
6184
+ case "local-first":
6185
+ return "local";
6186
+ case "remote-first":
6187
+ return "remote";
6188
+ case "duplicate":
6189
+ return "duplicate";
6190
+ case "manual":
6191
+ return "merge";
6192
+ }
6193
+ }
6194
+ var SyncOrchestrator = class _SyncOrchestrator {
6195
+ constructor(config, stateDb, notionClient, vaultFs, customFetch) {
6196
+ this.config = config;
6197
+ this.stateDb = stateDb;
6198
+ this.notionClient = notionClient;
6199
+ this.vaultFs = vaultFs;
6200
+ this.changeDetector = new ChangeDetector(stateDb);
6201
+ this.pipeline = createDefaultPipeline({
6202
+ wikilinkResolver: (text) => stateDb.resolveWikilink(text)
6203
+ });
6204
+ this.blockConverter = new BlockConverter();
6205
+ this.imageHandler = new ImageHandler(
6206
+ vaultFs,
6207
+ config.paths.attachments,
6208
+ notionClient,
6209
+ customFetch,
6210
+ {
6211
+ concurrency: config.advanced.mediaConcurrency,
6212
+ maxRetries: config.advanced.mediaMaxRetries,
6213
+ retryBaseMs: config.advanced.mediaRetryBaseMs,
6214
+ maxFileSizeBytes: config.advanced.maxFileSizeBytes
6215
+ },
6216
+ stateDb
6217
+ );
6218
+ this.fileHandler = new FileHandler(
6219
+ vaultFs,
6220
+ notionClient,
6221
+ stateDb,
6222
+ config.advanced.fileConcurrency
6223
+ );
6224
+ this.propertyMapper = new PropertyMapper();
6225
+ this.databaseSyncer = new DatabaseSyncer(
6226
+ config,
6227
+ stateDb,
6228
+ notionClient,
6229
+ vaultFs,
6230
+ this.pipeline,
6231
+ this.imageHandler
6232
+ );
6233
+ const wikilinkResolver = {
6234
+ resolve: (title) => stateDb.resolveWikilink(title)?.notionPageId ?? null,
6235
+ resolvePageId: (pageId) => {
6236
+ const entry = stateDb.resolvePageId(pageId);
6237
+ return entry ? wikilinkTitleFromPath(entry.obsidianPath) : null;
6238
+ }
6239
+ };
6240
+ this.propertyMapper.setWikilinkResolver(wikilinkResolver);
6241
+ this.notionClient.setWikilinkResolver(wikilinkResolver);
6242
+ this.blockConverter.initNotionToMd(this.notionClient.getInternalClient());
6243
+ this.conflictResolver = new ConflictResolver(stateDb, vaultFs);
6244
+ }
6245
+ config;
6246
+ stateDb;
6247
+ notionClient;
6248
+ vaultFs;
6249
+ changeDetector;
6250
+ pipeline;
6251
+ blockConverter;
5185
6252
  imageHandler;
5186
6253
  fileHandler;
5187
6254
  databaseSyncer;
5188
6255
  propertyMapper;
6256
+ conflictResolver;
5189
6257
  dbSchemaLoaded = false;
5190
6258
  _pullImageCount = 0;
5191
6259
  _pullFileCount = 0;
@@ -5194,6 +6262,16 @@ var SyncOrchestrator = class {
5194
6262
  // 블록 트리 재귀 없이 markdown 신호만으로 컬럼/synced_block 등 깊이 중첩된
5195
6263
  // child_database 까지 발견해 폴더+.base 동기화 대상으로 등록한다.
5196
6264
  _inlineDbRefs = /* @__PURE__ */ new Map();
6265
+ // pull 발견 단계에서 모든 페이지의 부모를 해소하며 채우는 "자식을 가진 페이지" 집합
6266
+ // (정규화된 page id). 폴더노트/폴더 판정의 단일 신뢰 원천 — 얕은 블록 검사로 callout·
6267
+ // column 등 컨테이너에 중첩된 자식 페이지를 놓쳐 폴더노트를 file 로 오분류하던 결함
6268
+ // (본문이 최상위로 밀려 ' (1).md' 로 분리)을 차단한다. detectRemoteChanges* 진입 시 재구성.
6269
+ _childParentIds = /* @__PURE__ */ new Set();
6270
+ // 서브트리 직접 순회(getChildPagesRecursive) 시간 예산. 초과하면 search 기반 디스커버리로
6271
+ // 폴백한다. 분기점 근거: 워크스페이스 search 열거는 latency-bound 로 대략 이 수준(수천 페이지
6272
+ // 워크스페이스에서 ~100s)이므로, 순회가 이 시간을 넘기면 search 가 더 저렴해진다. 작은 볼트는
6273
+ // 이 예산 안에서 순회가 끝나 폴백 없이 빠르게 완료된다. (단위: ms)
6274
+ static DISCOVERY_RECURSIVE_BUDGET_MS = 9e4;
5197
6275
  async push(options) {
5198
6276
  const startTime = Date.now();
5199
6277
  if (this.config.sync.direction === "pull") {
@@ -5201,6 +6279,7 @@ var SyncOrchestrator = class {
5201
6279
  }
5202
6280
  this.cleanupInterruptedSync();
5203
6281
  this.repairFolderRecords();
6282
+ await this.recoverInterruptedPushOps();
5204
6283
  const stats = await this.vaultFs.listMarkdownFileStats();
5205
6284
  const changes = await this.changeDetector.detectLocalChangesFast(
5206
6285
  stats,
@@ -5271,10 +6350,11 @@ var SyncOrchestrator = class {
5271
6350
  await this.pushMove(change.path);
5272
6351
  counts.updated++;
5273
6352
  break;
5274
- case "deleted":
5275
- await this.pushDelete(change.path);
5276
- counts.deleted++;
6353
+ case "deleted": {
6354
+ const propagated = await this.pushDelete(change.path);
6355
+ if (propagated) counts.deleted++;
5277
6356
  break;
6357
+ }
5278
6358
  }
5279
6359
  };
5280
6360
  const retryQueue = [];
@@ -5338,6 +6418,7 @@ var SyncOrchestrator = class {
5338
6418
  this.stateDb.setMeta("last_push_at", (/* @__PURE__ */ new Date()).toISOString());
5339
6419
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
5340
6420
  this.stateDb.setMeta("push_in_progress", "");
6421
+ this.stateDb.clearCompletedOperations();
5341
6422
  return {
5342
6423
  created: counts.created,
5343
6424
  updated: counts.updated,
@@ -5371,36 +6452,35 @@ var SyncOrchestrator = class {
5371
6452
  const conflicts = [];
5372
6453
  const writtenPaths = [];
5373
6454
  const failed = [];
5374
- const lastPull = this.stateDb.getMeta("last_pull_at") ?? this.stateDb.getMeta("last_sync_at");
5375
- const trackedCount = this.stateDb.getAll().length;
5376
- const remoteChanges = !this.isDatabaseMode && lastPull && trackedCount > 0 && !options?.force ? await this.detectRemoteChangesIncremental(lastPull) : await this.detectRemoteChanges();
5377
- const filtered = options?.paths ? remoteChanges.filter((c) => {
5378
- const record = this.stateDb.getByNotionId(c.pageId);
5379
- return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
5380
- }) : remoteChanges;
5381
- if (filtered.length === 0 && (this.config.notion.databases?.length ?? 0) === 0) {
5382
- const hasDbResults = await this.pullDiscoveredDatabases(writtenPaths, failed, conflicts);
5383
- if (!hasDbResults.created && !hasDbResults.updated) {
5384
- this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
5385
- this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
5386
- this.stateDb.setMeta("pull_in_progress", "");
5387
- return emptyResult;
5388
- }
6455
+ const finalize = async (created, updated, deleted) => {
6456
+ const linkCount = writtenPaths.length > 0 ? await this.resolveNotionLinks(writtenPaths) : 0;
5389
6457
  this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
5390
6458
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
5391
6459
  this.stateDb.setMeta("pull_in_progress", "");
5392
6460
  return {
5393
- created: hasDbResults.created,
5394
- updated: hasDbResults.updated,
5395
- deleted: 0,
6461
+ created,
6462
+ updated,
6463
+ deleted,
5396
6464
  conflicts,
5397
6465
  writtenPaths,
5398
6466
  failed,
5399
6467
  duration: Date.now() - startTime,
5400
- imageCount: 0,
5401
- fileCount: 0,
5402
- linkCount: 0
6468
+ imageCount: this._pullImageCount,
6469
+ fileCount: this._pullFileCount,
6470
+ linkCount
5403
6471
  };
6472
+ };
6473
+ const lastPull = this.stateDb.getMeta("last_pull_at") ?? this.stateDb.getMeta("last_sync_at");
6474
+ const trackedCount = this.stateDb.getAll().length;
6475
+ const canUseIncremental = !this.isDatabaseMode && !!lastPull && trackedCount > 0 && !options?.force && !this.config.sync.deleteSync;
6476
+ const remoteChanges = canUseIncremental ? await this.detectRemoteChangesIncremental(lastPull) : await this.detectRemoteChanges();
6477
+ const filtered = options?.paths ? remoteChanges.filter((c) => {
6478
+ const record = this.stateDb.getByNotionId(c.pageId);
6479
+ return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
6480
+ }) : remoteChanges;
6481
+ if (filtered.length === 0 && (this.config.notion.databases?.length ?? 0) === 0) {
6482
+ const dbDiscovery = await this.pullDiscoveredDatabases(writtenPaths, failed, conflicts);
6483
+ return finalize(dbDiscovery.created, dbDiscovery.updated, 0);
5404
6484
  }
5405
6485
  if (filtered.length === 0) {
5406
6486
  return emptyResult;
@@ -5453,7 +6533,7 @@ var SyncOrchestrator = class {
5453
6533
  const result = await this.pullUpdate(change);
5454
6534
  if (result.conflict) {
5455
6535
  conflicts.push(result.conflict);
5456
- } else if (result.path) {
6536
+ } else if (result.path && !result.unchanged) {
5457
6537
  writtenPaths.push(result.path);
5458
6538
  counts.updated++;
5459
6539
  }
@@ -5520,10 +6600,7 @@ var SyncOrchestrator = class {
5520
6600
  counts.updated += dbResult.updated;
5521
6601
  conflicts.push(...dbResult.conflicts);
5522
6602
  failed.push(...dbResult.failed);
5523
- if (dbResult.created + dbResult.updated > 0) {
5524
- const dbPaths = this.stateDb.getByStatus("synced").filter((r) => r.fileType === "db-row").map((r) => r.obsidianPath);
5525
- writtenPaths.push(...dbPaths.slice(-dbResult.created - dbResult.updated));
5526
- }
6603
+ writtenPaths.push(...dbResult.writtenPaths);
5527
6604
  } catch (error) {
5528
6605
  getLogger().warn("[Im-Nobsidian] DB Pull \uC911 \uC624\uB958:", error);
5529
6606
  }
@@ -5533,23 +6610,7 @@ var SyncOrchestrator = class {
5533
6610
  counts.created += dbDiscovery.created;
5534
6611
  counts.updated += dbDiscovery.updated;
5535
6612
  }
5536
- const linkTargetPaths = writtenPaths.length > 0 ? writtenPaths : [];
5537
- const linkCount = linkTargetPaths.length > 0 ? await this.resolveNotionLinks(linkTargetPaths) : 0;
5538
- this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
5539
- this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
5540
- this.stateDb.setMeta("pull_in_progress", "");
5541
- return {
5542
- created: counts.created,
5543
- updated: counts.updated,
5544
- deleted: counts.deleted,
5545
- conflicts,
5546
- writtenPaths,
5547
- failed,
5548
- duration: Date.now() - startTime,
5549
- imageCount: this._pullImageCount,
5550
- fileCount: this._pullFileCount,
5551
- linkCount
5552
- };
6613
+ return finalize(counts.created, counts.updated, counts.deleted);
5553
6614
  }
5554
6615
  async sync(options) {
5555
6616
  const startTime = Date.now();
@@ -5570,7 +6631,7 @@ var SyncOrchestrator = class {
5570
6631
  const files = await this.vaultFs.listMarkdownFiles();
5571
6632
  const localChanges = this.changeDetector.detectLocalChanges(files);
5572
6633
  const lastSyncAt = this.stateDb.getMeta("last_sync_at");
5573
- const remoteChanges = lastSyncAt ? await this.detectRemoteChangesIncremental(lastSyncAt) : await this.detectRemoteChanges();
6634
+ const remoteChanges = lastSyncAt && !this.config.sync.deleteSync ? await this.detectRemoteChangesIncremental(lastSyncAt) : await this.detectRemoteChanges();
5574
6635
  const conflictRecords = this.stateDb.getByStatus("conflict");
5575
6636
  const conflicts = await this.buildConflictsFromRecords(
5576
6637
  conflictRecords,
@@ -5681,37 +6742,57 @@ var SyncOrchestrator = class {
5681
6742
  * - 제목 취득 실패 시 부모 폴더명 기반 안전 이름으로 폴백
5682
6743
  */
5683
6744
  async buildDiscoveredDbConfig(dbId, parentPageId) {
6745
+ let dbTitle;
5684
6746
  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
- }
6747
+ const info = await this.notionClient.getDatabaseSyncability(dbId);
6748
+ if (!info.queryable) {
6749
+ return { kind: "inaccessible" };
5697
6750
  }
5698
- let safeName;
5699
- if (dbTitle) {
5700
- safeName = dbTitle.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-");
6751
+ dbTitle = info.title;
6752
+ } catch (error) {
6753
+ if (isNotionObjectNotFound(error)) return { kind: "inaccessible" };
6754
+ getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uC124\uC815 \uC0DD\uC131 \uC2E4\uD328:`, error);
6755
+ return { kind: "error" };
6756
+ }
6757
+ const parentEntry = parentPageId ? this.stateDb.getByNotionId(parentPageId) : null;
6758
+ let parentFolder = "";
6759
+ if (parentEntry?.obsidianPath) {
6760
+ const obsPath = parentEntry.obsidianPath;
6761
+ if (obsPath.endsWith(".md")) {
6762
+ const parts = obsPath.split("/");
6763
+ parts.pop();
6764
+ parentFolder = parts.length > 0 ? parts.join("/") : obsPath.replace(/\.md$/, "");
5701
6765
  } else {
5702
- const parentName = parentFolder.split("/").pop() || "";
5703
- safeName = parentName ? `${parentName}-DB` : `db-${dbId.replace(/-/g, "").slice(0, 8)}`;
6766
+ parentFolder = obsPath;
5704
6767
  }
5705
- if (!safeName) safeName = `db-${dbId.replace(/-/g, "").slice(0, 8)}`;
5706
- if (!parentFolder) parentFolder = "databases";
5707
- return {
6768
+ }
6769
+ let safeName;
6770
+ if (dbTitle) {
6771
+ safeName = dbTitle.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-");
6772
+ } else {
6773
+ const parentName = parentFolder.split("/").pop() || "";
6774
+ safeName = parentName ? `${parentName}-DB` : `db-${dbId.replace(/-/g, "").slice(0, 8)}`;
6775
+ }
6776
+ if (!safeName) safeName = `db-${dbId.replace(/-/g, "").slice(0, 8)}`;
6777
+ if (!parentFolder) parentFolder = "databases";
6778
+ return {
6779
+ kind: "ok",
6780
+ config: {
5708
6781
  databaseId: dbId,
5709
6782
  localFolder: `${parentFolder}/${safeName}`,
5710
6783
  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;
6784
+ }
6785
+ };
6786
+ }
6787
+ /** 접근 불가 DB denylist 를 상태 메타에서 로드한다(하이픈 정규화). */
6788
+ loadInaccessibleDbIds() {
6789
+ const raw = this.stateDb.getMeta(INACCESSIBLE_DBS_META_KEY);
6790
+ if (!raw) return /* @__PURE__ */ new Set();
6791
+ try {
6792
+ const arr = JSON.parse(raw);
6793
+ return new Set(arr.map((id) => id.replace(/-/g, "")));
6794
+ } catch {
6795
+ return /* @__PURE__ */ new Set();
5715
6796
  }
5716
6797
  }
5717
6798
  async pullDiscoveredDatabases(writtenPaths, failed, conflicts) {
@@ -5736,46 +6817,71 @@ var SyncOrchestrator = class {
5736
6817
  ...dbConfigs.map((c) => c.databaseId.replace(/-/g, ""))
5737
6818
  ]);
5738
6819
  let changed = false;
6820
+ const inaccessibleIds = this.loadInaccessibleDbIds();
6821
+ let inaccessibleChanged = false;
6822
+ const degrade = (rawId) => {
6823
+ const nohyph = rawId.replace(/-/g, "");
6824
+ if (!inaccessibleIds.has(nohyph)) {
6825
+ inaccessibleIds.add(nohyph);
6826
+ inaccessibleChanged = true;
6827
+ }
6828
+ getLogger().info(
6829
+ `[Im-Nobsidian] DB ${rawId}: \uC811\uADFC \uAC00\uB2A5\uD55C data source \uC5C6\uC74C(\uB9C1\uD06C\uB4DC/\uBBF8\uACF5\uC720/\uC0AD\uC81C \uCD94\uC815) \u2014 \uB3D9\uAE30\uD654 \uB300\uC0C1\uC5D0\uC11C \uC81C\uC678(\uC81C\uBAA9\uC740 \uBD80\uBAA8 \uD398\uC774\uC9C0\uC5D0 \uBCF4\uC874)`
6830
+ );
6831
+ };
5739
6832
  if (dbConfigs.length === 0) {
5740
6833
  const discovered = await this.discoverChildDatabases();
5741
6834
  for (const { dbId, parentPageId } of discovered) {
5742
6835
  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);
6836
+ if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) continue;
6837
+ const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
6838
+ knownIds.add(nohyph);
6839
+ if (outcome.kind === "ok") {
6840
+ dbConfigs.push(outcome.config);
5748
6841
  changed = true;
6842
+ } else if (outcome.kind === "inaccessible") {
6843
+ degrade(dbId);
5749
6844
  }
5750
6845
  }
5751
6846
  }
5752
6847
  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);
6848
+ if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) continue;
6849
+ const dbId = normalizeNotionId(nohyph);
6850
+ const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
6851
+ knownIds.add(nohyph);
6852
+ if (outcome.kind === "ok") {
6853
+ dbConfigs.push(outcome.config);
5758
6854
  changed = true;
6855
+ } else if (outcome.kind === "inaccessible") {
6856
+ degrade(dbId);
5759
6857
  }
5760
6858
  }
5761
- if (changed && dbConfigs.length > 0) {
5762
- this.stateDb.setMeta("discovered_dbs", JSON.stringify(dbConfigs));
5763
- }
6859
+ const stillSyncable = [];
5764
6860
  for (const dbConfig of dbConfigs) {
6861
+ if (inaccessibleIds.has(dbConfig.databaseId.replace(/-/g, ""))) continue;
5765
6862
  try {
5766
6863
  const dbResult = await this.databaseSyncer.pullDatabase(dbConfig);
5767
6864
  created += dbResult.created;
5768
6865
  updated += dbResult.updated;
5769
6866
  conflicts.push(...dbResult.conflicts);
5770
6867
  failed.push(...dbResult.failed);
5771
- if (dbResult.created + dbResult.updated > 0) {
5772
- const dbPaths = this.stateDb.getByStatus("synced").filter((r) => r.fileType === "db-row").map((r) => r.obsidianPath);
5773
- writtenPaths.push(...dbPaths.slice(-dbResult.created - dbResult.updated));
5774
- }
6868
+ writtenPaths.push(...dbResult.writtenPaths);
6869
+ stillSyncable.push(dbConfig);
5775
6870
  } catch (error) {
5776
- getLogger().warn(`[Im-Nobsidian] DB ${dbConfig.databaseId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
6871
+ if (isNotionObjectNotFound(error)) {
6872
+ degrade(dbConfig.databaseId);
6873
+ } else {
6874
+ getLogger().warn(`[Im-Nobsidian] DB ${dbConfig.databaseId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
6875
+ stillSyncable.push(dbConfig);
6876
+ }
5777
6877
  }
5778
6878
  }
6879
+ if (changed || inaccessibleChanged || stillSyncable.length !== dbConfigs.length) {
6880
+ this.stateDb.setMeta("discovered_dbs", JSON.stringify(stillSyncable));
6881
+ }
6882
+ if (inaccessibleChanged) {
6883
+ this.stateDb.setMeta(INACCESSIBLE_DBS_META_KEY, JSON.stringify([...inaccessibleIds]));
6884
+ }
5779
6885
  } catch (error) {
5780
6886
  getLogger().warn("[Im-Nobsidian] child_database \uC790\uB3D9 \uBC1C\uACAC \uC2E4\uD328:", error);
5781
6887
  }
@@ -5787,7 +6893,7 @@ var SyncOrchestrator = class {
5787
6893
  const idToTitle = /* @__PURE__ */ new Map();
5788
6894
  for (const r of allRecords) {
5789
6895
  if (r.notionPageId && r.obsidianPath) {
5790
- const title = r.obsidianPath.replace(/\.md$/, "").split("/").pop() ?? "";
6896
+ const title = wikilinkTitleFromPath(r.obsidianPath);
5791
6897
  const cleanId = r.notionPageId.replace(/-/g, "");
5792
6898
  idToTitle.set(cleanId, title);
5793
6899
  idToTitle.set(r.notionPageId, title);
@@ -5822,8 +6928,26 @@ var SyncOrchestrator = class {
5822
6928
  }
5823
6929
  );
5824
6930
  content = resolved2;
6931
+ const fm = resolveFrontmatterRelations(content, idToTitle);
6932
+ if (fm.count > 0) {
6933
+ content = fm.content;
6934
+ totalResolved += fm.count;
6935
+ changed = true;
6936
+ }
5825
6937
  if (changed) {
5826
6938
  await this.vaultFs.writeFile(filePath, content);
6939
+ const record = this.stateDb.getByPath(filePath);
6940
+ if (record) {
6941
+ const stat2 = await this.vaultFs.getFileStat(filePath);
6942
+ this.stateDb.transaction(() => {
6943
+ this.stateDb.updateHash(
6944
+ record.id,
6945
+ computeHash(content),
6946
+ Buffer.from(content, "utf-8")
6947
+ );
6948
+ if (stat2) this.stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
6949
+ });
6950
+ }
5827
6951
  }
5828
6952
  } catch {
5829
6953
  }
@@ -5869,6 +6993,27 @@ var SyncOrchestrator = class {
5869
6993
  title
5870
6994
  );
5871
6995
  }
6996
+ const placeholder = this.stateDb.upsert({
6997
+ obsidianPath: path,
6998
+ notionPageId: null,
6999
+ notionParentId: parentId,
7000
+ contentHash: "",
7001
+ notionLastEdited: null,
7002
+ localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
7003
+ syncDirection: "both",
7004
+ fileType: this.isFolderNote(path) ? "folder-note" : "file",
7005
+ status: "pending",
7006
+ baseSnapshot: null,
7007
+ localMtime: null,
7008
+ localFileSize: null
7009
+ });
7010
+ const existingOp = this.stateDb.getIncompleteOpByState(placeholder.id, "create");
7011
+ const walOpId = existingOp?.id ?? this.stateDb.recordPendingOperation({
7012
+ syncStateId: placeholder.id,
7013
+ operation: "create",
7014
+ direction: "push",
7015
+ payload: JSON.stringify({ path, parentId, title })
7016
+ });
5872
7017
  const page = await this.pushCreatePage(
5873
7018
  effectiveParentId,
5874
7019
  effectiveParentType,
@@ -5917,6 +7062,7 @@ var SyncOrchestrator = class {
5917
7062
  });
5918
7063
  this.stateDb.storePreserveMarkers(path, conversionResult.preserveMarkers);
5919
7064
  });
7065
+ this.stateDb.markPendingCompleted(walOpId);
5920
7066
  }
5921
7067
  async pushUpdate(path) {
5922
7068
  const content = await this.vaultFs.readFile(path);
@@ -5947,13 +7093,16 @@ var SyncOrchestrator = class {
5947
7093
  title: { title: [{ text: { content: titleStr } }] }
5948
7094
  };
5949
7095
  }
5950
- let lastEditedTime = (/* @__PURE__ */ new Date()).toISOString();
7096
+ let lastEditedTime;
5951
7097
  if (propsToUpdate && Object.keys(propsToUpdate).length > 0) {
5952
7098
  const updatedPage = await this.notionClient.updatePageProperties(
5953
7099
  record.notionPageId,
5954
7100
  propsToUpdate
5955
7101
  );
5956
7102
  lastEditedTime = updatedPage.last_edited_time;
7103
+ } else {
7104
+ const refreshed = await this.notionClient.getPage(record.notionPageId);
7105
+ lastEditedTime = refreshed.last_edited_time;
5957
7106
  }
5958
7107
  const hash = computeHash(content);
5959
7108
  const fileStat = await this.vaultFs.getFileStat(path);
@@ -5975,25 +7124,78 @@ var SyncOrchestrator = class {
5975
7124
  });
5976
7125
  });
5977
7126
  }
7127
+ // ──────────────────────────────────────────────────────────────────────────
7128
+ // 충돌 해소 (I8) — 해소 결과를 로컬에만 쓰지 않고 Notion 으로 재push + notionLastEdited
7129
+ // 재조정까지 한 트랜잭션으로 묶는다. ConflictResolver 단독은 로컬 write + updateHash 만
7130
+ // 수행하므로(merge 결과가 Notion 에 반영되지 않음) 다음 pull 이 원격으로 덮어써 영구
7131
+ // 유실·충돌 루프가 발생한다. 해소 → 전파(propagate)를 오케스트레이터에서 봉합해 무손실
7132
+ // 보장. 변환 파이프라인이 필요한 push 는 기존 엔터프라이즈 경로(pushUpdate)를 재사용한다.
7133
+ // ──────────────────────────────────────────────────────────────────────────
7134
+ /** 단일 충돌을 사용자가 고른 선택지(local/remote/merge/duplicate)로 해소 + Notion 전파. */
7135
+ async resolveConflict(conflict, choice) {
7136
+ const result = await this.conflictResolver.resolve(conflict, choice);
7137
+ await this.propagateResolution(conflict, choice, result);
7138
+ return result;
7139
+ }
7140
+ /** 단일 충돌을 전략(manual/local-first/remote-first/duplicate)으로 해소 + Notion 전파. */
7141
+ async resolveConflictByStrategy(conflict, strategy) {
7142
+ const result = await this.conflictResolver.resolveByStrategy(conflict, strategy);
7143
+ await this.propagateResolution(conflict, strategyToChoice(strategy), result);
7144
+ return result;
7145
+ }
7146
+ /** 여러 충돌을 동일 전략으로 일괄 해소 + Notion 전파. */
7147
+ async resolveAllConflicts(conflicts, strategy) {
7148
+ const results = [];
7149
+ for (const conflict of conflicts) {
7150
+ results.push(await this.resolveConflictByStrategy(conflict, strategy));
7151
+ }
7152
+ return results;
7153
+ }
7154
+ /** 충돌 미리보기용 통합 diff(원본 vs 로컬 vs 원격). 해소 없이 표시 전용. */
7155
+ generateConflictDiff(conflict) {
7156
+ return this.conflictResolver.generateDiff(conflict);
7157
+ }
7158
+ /**
7159
+ * 해소 결과를 Notion 으로 전파해 로컬↔원격 일관성을 봉합한다.
7160
+ * - remote 선택: 로컬이 원격으로 갱신됐을 뿐이므로 push 불필요. notionLastEdited 만
7161
+ * 원격 변경의 lastEdited 로 재조정 → 다음 pull 이 같은 변경을 재충돌로 보지 않음.
7162
+ * - merge 실패(충돌 마커 잔존): 사용자가 직접 풀어야 하므로 conflict 상태 유지·push 안 함.
7163
+ * - local / merge(성공) / duplicate: 해소된 로컬 내용을 Notion 에 재push(pushUpdate 가
7164
+ * 변환·이미지·속성·해시·notionLastEdited 를 한 트랜잭션으로 재조정) → 무손실 수렴.
7165
+ */
7166
+ async propagateResolution(conflict, choice, result) {
7167
+ const record = conflict.syncRecord;
7168
+ if (!record.notionPageId) return;
7169
+ if (choice === "remote") {
7170
+ this.stateDb.setNotionLastEdited(record.id, conflict.remoteChange.lastEdited);
7171
+ return;
7172
+ }
7173
+ if (!result.success) return;
7174
+ await this.pushUpdate(record.obsidianPath);
7175
+ }
7176
+ // 반환값: 실제로 원격(Notion) 삭제가 전파되었는지 여부.
7177
+ // deleteSync=false 면 로컬 삭제를 pending 으로만 기록하고 Notion 은 보존하므로
7178
+ // false 를 돌려준다 → 호출부가 deleted 카운트를 올리지 않아 보고가 정직해진다.
5978
7179
  async pushDelete(path) {
5979
7180
  const record = this.stateDb.getByPath(path);
5980
- if (!record?.notionPageId) return;
5981
- if (this.config.sync.deleteSync) {
5982
- try {
5983
- await this.notionClient.archivePage(record.notionPageId);
5984
- } catch (error) {
5985
- const msg = error instanceof Error ? error.message : String(error);
5986
- if (msg.includes("archived ancestor")) ; else {
5987
- throw error;
5988
- }
5989
- }
5990
- this.stateDb.transaction(() => {
5991
- this.stateDb.delete(record.id);
5992
- this.stateDb.deleteWikilink(record.obsidianPath);
5993
- });
5994
- } else {
7181
+ if (!record?.notionPageId) return false;
7182
+ if (!this.config.sync.deleteSync) {
5995
7183
  this.stateDb.updateStatus(record.id, "pending");
7184
+ return false;
7185
+ }
7186
+ try {
7187
+ await this.notionClient.archivePage(record.notionPageId);
7188
+ } catch (error) {
7189
+ const msg = error instanceof Error ? error.message : String(error);
7190
+ if (msg.includes("archived ancestor")) ; else {
7191
+ throw error;
7192
+ }
5996
7193
  }
7194
+ this.stateDb.transaction(() => {
7195
+ this.stateDb.delete(record.id);
7196
+ this.stateDb.deleteWikilink(record.obsidianPath);
7197
+ });
7198
+ return true;
5997
7199
  }
5998
7200
  async pushMove(path) {
5999
7201
  const record = this.stateDb.getByPath(path);
@@ -6013,6 +7215,7 @@ var SyncOrchestrator = class {
6013
7215
  }
6014
7216
  async detectRemoteChanges() {
6015
7217
  const changes = [];
7218
+ this._childParentIds.clear();
6016
7219
  const lastPull = this.stateDb.getMeta("last_pull_at");
6017
7220
  const syncedRecords = this.stateDb.getAll();
6018
7221
  const trackedPageIds = new Set(
@@ -6033,11 +7236,32 @@ var SyncOrchestrator = class {
6033
7236
  } while (cursor);
6034
7237
  remotePages = allPages;
6035
7238
  } else {
6036
- const allAccessible = await this.notionClient.searchAllPages();
6037
- const underRoot = await this.filterPagesUnderRoot(allAccessible);
7239
+ let underRoot;
7240
+ try {
7241
+ underRoot = await this.notionClient.getChildPagesRecursive(this.config.notion.rootPageId, {
7242
+ deadlineMs: Date.now() + _SyncOrchestrator.DISCOVERY_RECURSIVE_BUDGET_MS
7243
+ });
7244
+ } catch (error) {
7245
+ if (!(error instanceof DiscoveryTooLargeError)) throw error;
7246
+ getLogger().info(
7247
+ `[Im-Nobsidian] \uC11C\uBE0C\uD2B8\uB9AC\uAC00 \uD07C(${error.message}) \u2192 \uC6CC\uD06C\uC2A4\uD398\uC774\uC2A4 search \uAE30\uBC18 \uB514\uC2A4\uCEE4\uBC84\uB9AC\uB85C \uC804\uD658`
7248
+ );
7249
+ underRoot = await this.notionClient.getPagesUnderRootViaSearch(
7250
+ this.config.notion.rootPageId
7251
+ );
7252
+ }
7253
+ this._childParentIds.add(normalizeNotionId(this.config.notion.rootPageId));
7254
+ for (const page of underRoot) {
7255
+ const parentId = await this.extractParentId(page);
7256
+ if (parentId) this._childParentIds.add(normalizeNotionId(parentId));
7257
+ }
6038
7258
  remotePages = underRoot.map((p) => ({ id: p.id, last_edited_time: p.last_edited_time }));
6039
7259
  }
7260
+ const seenRemoteIds = /* @__PURE__ */ new Set();
6040
7261
  for (const page of remotePages) {
7262
+ const key = normalizeNotionId(page.id);
7263
+ if (seenRemoteIds.has(key)) continue;
7264
+ seenRemoteIds.add(key);
6041
7265
  const record = this.stateDb.getByNotionId(page.id);
6042
7266
  if (!record) {
6043
7267
  changes.push({
@@ -6071,8 +7295,16 @@ var SyncOrchestrator = class {
6071
7295
  }
6072
7296
  return changes;
6073
7297
  }
7298
+ /**
7299
+ * 증분 원격 변경 감지 — `since` 이후 수정된 페이지만 search 로 받아 created/modified 만
7300
+ * 만든다. **삭제는 의도적으로 감지하지 않는다**: search API 는 in_trash/archived 페이지를
7301
+ * 반환하지 않아(=사라진 것을 증분만으로는 구분 불가) 삭제 판정에는 전체 enumeration 이
7302
+ * 필수다. 따라서 호출부는 `deleteSync` 가 켜진 경우 이 fast-path 를 쓰지 않고
7303
+ * `detectRemoteChanges`(전체 스캔, 삭제 diff 포함)로 우회한다. (I10)
7304
+ */
6074
7305
  async detectRemoteChangesIncremental(since) {
6075
7306
  const changes = [];
7307
+ this._childParentIds.clear();
6076
7308
  const recentPages = await this.notionClient.searchRecentPages(since);
6077
7309
  for (const page of recentPages) {
6078
7310
  const record = this.stateDb.getByNotionId(page.id);
@@ -6080,6 +7312,7 @@ var SyncOrchestrator = class {
6080
7312
  try {
6081
7313
  const fullPage = await this.notionClient.getPage(page.id);
6082
7314
  const parentId = await this.extractParentId(fullPage);
7315
+ if (parentId) this._childParentIds.add(normalizeNotionId(parentId));
6083
7316
  if (parentId && this.isTrackedParent(parentId)) {
6084
7317
  changes.push({
6085
7318
  pageId: page.id,
@@ -6107,85 +7340,12 @@ var SyncOrchestrator = class {
6107
7340
  if (notionIdsEqual(parentId, this.config.notion.rootPageId)) return true;
6108
7341
  return !!this.stateDb.getByNotionId(parentId);
6109
7342
  }
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
- }
6180
7343
  async pullCreate(pageId) {
6181
7344
  const page = await this.notionClient.getPage(pageId);
6182
7345
  const title = this.notionClient.extractTitle(page);
6183
7346
  const safeName = sanitizeFileName(title);
6184
7347
  const parentPath = await this.resolveParentPath(page);
6185
- const firstChildren = await this.notionClient.listChildren(pageId, {
6186
- pageSize: this.config.advanced.pageSize
6187
- });
6188
- const hasChildPages = firstChildren.results.some((b) => "type" in b && b.type === "child_page");
7348
+ const hasChildPages = await this.pageHasChildContainers(pageId);
6189
7349
  const markdown = await this.fetchPageMarkdown(pageId);
6190
7350
  const hasContent = markdown.trim().length > 0;
6191
7351
  let filePath;
@@ -6234,10 +7394,10 @@ var SyncOrchestrator = class {
6234
7394
  },
6235
7395
  { properties }
6236
7396
  );
7397
+ const resolvedParentId = await this.extractParentId(page);
6237
7398
  await this.vaultFs.writeFile(filePath, finalContent);
6238
7399
  const hash = computeHash(finalContent);
6239
7400
  const pullStat = await this.vaultFs.getFileStat(filePath);
6240
- const resolvedParentId = await this.extractParentId(page);
6241
7401
  this.stateDb.transaction(() => {
6242
7402
  this.stateDb.upsert({
6243
7403
  obsidianPath: filePath,
@@ -6263,6 +7423,28 @@ var SyncOrchestrator = class {
6263
7423
  });
6264
7424
  return filePath;
6265
7425
  }
7426
+ /**
7427
+ * 페이지가 폴더(자식 페이지·자식 DB 보유)인지 신뢰성 있게 판정한다.
7428
+ *
7429
+ * 결함(폴더노트 본문분리)의 근본 원인은 얕은 판정이었다 — 최상위 블록 첫 페이지에서
7430
+ * child_page 만 검사하면 callout·column·toggle 안에 중첩된 자식 페이지나 child_database 를
7431
+ * 놓쳐 폴더노트가 file 로 오분류되고, 본문이 폴더 밖 최상위로 밀려 resolveUniqueFilePath
7432
+ * 충돌(' (1).md')로 쪼개졌다.
7433
+ *
7434
+ * 1차: 발견 단계(서브트리 순회/증분)에서 전 페이지의 부모를 해소하며 만든
7435
+ * _childParentIds 집합으로 O(1) 판정(추가 API 호출 0, 처리 순서 무관) — 전체 pull 경로.
7436
+ * 폴백: 집합에 없을 때만(증분 pull 의 신규 폴더 등) fetchAllChildrenDeep 로 컨테이너를
7437
+ * 재귀 탐색해 child_page·child_database 를 직접 확인한다.
7438
+ */
7439
+ async pageHasChildContainers(pageId) {
7440
+ if (this._childParentIds.has(normalizeNotionId(pageId))) return true;
7441
+ try {
7442
+ const deep = await this.notionClient.fetchAllChildrenDeep(pageId);
7443
+ return deep.some((b) => b.type === "child_page" || b.type === "child_database");
7444
+ } catch {
7445
+ return false;
7446
+ }
7447
+ }
6266
7448
  async pullUpdate(change) {
6267
7449
  const record = this.stateDb.getByNotionId(change.pageId);
6268
7450
  if (!record) return {};
@@ -6318,6 +7500,24 @@ var SyncOrchestrator = class {
6318
7500
  this.stateDb.updateStatus(record.id, "conflict");
6319
7501
  return { conflict: resolution.conflict };
6320
7502
  }
7503
+ if (remoteContent === localContent) {
7504
+ const stat2 = await this.vaultFs.getFileStat(record.obsidianPath);
7505
+ this.stateDb.upsert({
7506
+ obsidianPath: record.obsidianPath,
7507
+ notionPageId: change.pageId,
7508
+ notionParentId: record.notionParentId,
7509
+ contentHash: computeHash(remoteContent),
7510
+ notionLastEdited: page.last_edited_time,
7511
+ localLastModified: record.localLastModified,
7512
+ syncDirection: record.syncDirection,
7513
+ fileType: record.fileType,
7514
+ status: "synced",
7515
+ baseSnapshot: Buffer.from(remoteContent, "utf-8"),
7516
+ localMtime: stat2?.mtime ?? record.localMtime ?? null,
7517
+ localFileSize: stat2?.size ?? record.localFileSize ?? null
7518
+ });
7519
+ return { path: record.obsidianPath, unchanged: true };
7520
+ }
6321
7521
  await this.vaultFs.writeFile(record.obsidianPath, remoteContent);
6322
7522
  const updateStat = await this.vaultFs.getFileStat(record.obsidianPath);
6323
7523
  const newHash = computeHash(remoteContent);
@@ -6492,7 +7692,26 @@ var SyncOrchestrator = class {
6492
7692
  }
6493
7693
  return page;
6494
7694
  }
7695
+ // 페이지에 살아있는(휴지통 아님) child page/database 가 직속 블록으로 존재하는가.
7696
+ // 휴지통(in_trash) 자식은 children.list 에 잡히지 않으므로 "live" 는 암묵적이다.
7697
+ async pageHasLiveChildren(pageId) {
7698
+ try {
7699
+ const children = await this.notionClient.fetchAllChildren(pageId);
7700
+ return children.some((b) => b.type === "child_page" || b.type === "child_database");
7701
+ } catch (error) {
7702
+ getLogger().warn(
7703
+ `[Im-Nobsidian] \uC790\uC2DD \uD398\uC774\uC9C0 \uC870\uD68C \uC2E4\uD328 \u2014 \uBCF8\uBB38 push \uBCF4\uC218\uC801 \uC0DD\uB7B5(\uC790\uC2DD \uBCF4\uD638): ${pageId} (${error instanceof Error ? error.message : String(error)})`
7704
+ );
7705
+ return true;
7706
+ }
7707
+ }
6495
7708
  async pushUpdatePage(pageId, markdownContent, _baseSnapshot) {
7709
+ if (await this.pageHasLiveChildren(pageId)) {
7710
+ getLogger().warn(
7711
+ `[Im-Nobsidian] \uC790\uC2DD \uD398\uC774\uC9C0 \uBCF4\uC720 \u2014 \uBCF8\uBB38 push \uC0DD\uB7B5(\uC790\uC2DD \uC0AD\uC81C \uBC29\uC9C0): ${pageId}. \uC790\uC2DD \uD398\uC774\uC9C0\uB294 \uAC01\uC790 \uB3D9\uAE30\uD654\uB418\uBA70, \uC774 \uD398\uC774\uC9C0 \uBCF8\uBB38 \uD3B8\uC9D1\uC740 Notion \uC5D0 \uBC18\uC601\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.`
7712
+ );
7713
+ return;
7714
+ }
6496
7715
  if (this.config.conversion.preferMarkdownApi !== false) {
6497
7716
  const enhanced = obsidianToNotionEnhanced(markdownContent);
6498
7717
  await this.notionClient.replacePageMarkdown(pageId, enhanced);
@@ -6589,486 +7808,334 @@ var SyncOrchestrator = class {
6589
7808
  this.stateDb.setMeta("pull_in_progress", "");
6590
7809
  }
6591
7810
  }
6592
- async resolveUniqueFilePath(basePath) {
6593
- if (!await this.vaultFs.exists(basePath)) return basePath;
6594
- const dir = basePath.lastIndexOf("/") >= 0 ? basePath.slice(0, basePath.lastIndexOf("/")) : "";
6595
- const ext = ".md";
6596
- const name = basePath.slice(dir ? dir.length + 1 : 0, -ext.length);
6597
- for (let i = 1; i <= 99; i++) {
6598
- const candidate = dir ? `${dir}/${name} (${i})${ext}` : `${name} (${i})${ext}`;
6599
- if (!await this.vaultFs.exists(candidate)) return candidate;
6600
- }
6601
- return basePath;
6602
- }
6603
- };
6604
- function extractTitle(filePath) {
6605
- const parts = filePath.split("/");
6606
- const filename = parts[parts.length - 1] ?? "";
6607
- return filename.replace(/\.md$/, "");
6608
- }
6609
- function extractAliases2(properties) {
6610
- const raw = properties.aliases ?? properties.alias;
6611
- if (!raw) return [];
6612
- if (Array.isArray(raw)) return raw.filter((a) => typeof a === "string");
6613
- if (typeof raw === "string")
6614
- return raw.split(",").map((s) => s.trim()).filter(Boolean);
6615
- return [];
6616
- }
6617
- var NodeVaultFS = class {
6618
- constructor(rootPath, pathConfig) {
6619
- this.rootPath = rootPath;
6620
- this.includePatterns = pathConfig?.include ?? [];
6621
- const configExclude = pathConfig?.exclude ?? [];
6622
- const ignorePatterns = this.loadImNobsidianIgnore();
6623
- this.excludePatterns = [...configExclude, ...ignorePatterns];
6624
- }
6625
- rootPath;
6626
- includePatterns;
6627
- excludePatterns;
6628
- loadImNobsidianIgnore() {
6629
- try {
6630
- const ignorePath = join(this.rootPath, IGNORE_FILE);
6631
- const content = readFileSync(ignorePath, "utf-8");
6632
- return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
6633
- } catch {
6634
- return [];
6635
- }
6636
- }
6637
- async readFile(path) {
6638
- const fullPath = join(this.rootPath, path);
6639
- return readFile(fullPath, "utf-8");
6640
- }
6641
- async readBinary(path) {
6642
- const fullPath = join(this.rootPath, path);
6643
- return readFile(fullPath);
6644
- }
6645
- async writeFile(path, content) {
6646
- const fullPath = join(this.rootPath, path);
6647
- await mkdir(dirname(fullPath), { recursive: true });
6648
- await writeFile(fullPath, content, "utf-8");
6649
- }
6650
- async writeBinary(path, data) {
6651
- const fullPath = join(this.rootPath, path);
6652
- await mkdir(dirname(fullPath), { recursive: true });
6653
- await writeFile(fullPath, data);
6654
- }
6655
- async deleteFile(path) {
6656
- const fullPath = join(this.rootPath, path);
6657
- await unlink(fullPath);
6658
- }
6659
- async moveFile(from, to) {
6660
- const fromPath = join(this.rootPath, from);
6661
- const toPath = join(this.rootPath, to);
6662
- await mkdir(dirname(toPath), { recursive: true });
6663
- await rename(fromPath, toPath);
6664
- }
6665
- async exists(path) {
6666
- try {
6667
- await stat(join(this.rootPath, path));
6668
- return true;
6669
- } catch {
6670
- return false;
6671
- }
6672
- }
6673
- async ensureFolder(path) {
6674
- await mkdir(join(this.rootPath, path), { recursive: true });
6675
- }
6676
- async listMarkdownFiles() {
6677
- const files = [];
6678
- await this.walkDir(this.rootPath, files);
6679
- return files;
6680
- }
6681
- async listMarkdownFileStats() {
6682
- const stats = [];
6683
- await this.walkDirStats(this.rootPath, stats);
6684
- return stats;
6685
- }
6686
- async getFileStat(path) {
6687
- try {
6688
- const fullPath = join(this.rootPath, path);
6689
- const fileStat = await stat(fullPath);
6690
- return { path, mtime: fileStat.mtime.toISOString(), size: fileStat.size };
6691
- } catch {
6692
- return null;
6693
- }
6694
- }
6695
- async listNonMarkdownFiles() {
6696
- const files = [];
6697
- await this.walkDirNonMd(this.rootPath, files);
6698
- return files;
6699
- }
6700
- matchesPattern(relativePath, pattern) {
6701
- if (pattern === "**/*" || pattern === "**") return true;
6702
- if (pattern.endsWith("/**")) {
6703
- const dir = pattern.slice(0, -3);
6704
- return relativePath.startsWith(dir + "/") || relativePath === dir;
6705
- } else if (pattern.startsWith("*.")) {
6706
- const ext = pattern.slice(1);
6707
- return relativePath.endsWith(ext);
6708
- } else if (pattern.includes("*")) {
6709
- const regexStr = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
6710
- return new RegExp("^" + regexStr + "$").test(relativePath);
6711
- } else {
6712
- return relativePath === pattern || relativePath.startsWith(pattern + "/");
6713
- }
6714
- }
6715
- isExcluded(relativePath) {
6716
- if (this.includePatterns.length > 0) {
6717
- const included = this.includePatterns.some((p) => this.matchesPattern(relativePath, p));
6718
- if (!included) return true;
6719
- }
6720
- return this.excludePatterns.some((p) => this.matchesPattern(relativePath, p));
6721
- }
6722
- async walkDirNonMd(dir, result) {
6723
- const entries = await readdir(dir, { withFileTypes: true });
6724
- for (const entry of entries) {
6725
- const fullPath = join(dir, entry.name);
6726
- if (entry.name.startsWith(".")) continue;
6727
- const relativePath = relative(this.rootPath, fullPath);
6728
- if (this.isExcluded(relativePath)) continue;
6729
- if (entry.isDirectory()) {
6730
- await this.walkDirNonMd(fullPath, result);
6731
- } else if (entry.isFile() && !entry.name.endsWith(".md") && !entry.name.endsWith(".base")) {
6732
- const fileStat = await stat(fullPath);
6733
- result.push({
6734
- path: relativePath,
6735
- size: fileStat.size,
6736
- mtime: fileStat.mtime.toISOString()
6737
- });
7811
+ /**
7812
+ * I12 — 중단된 push create 작업 재개.
7813
+ *
7814
+ * pending_operations 에 미완료(create·push) 항목이 있으면:
7815
+ * - state 에 notion_page_id 가 이미 있으면 → 생성·매핑까지는 끝났고 markCompleted 직전에
7816
+ * 중단된 것 → 완료 처리(잔여 본문/이미지는 다음 변경감지가 pushUpdate 로 마무리).
7817
+ * - notion_page_id 가 비어 있으면(Window A: 생성 적용됐으나 매핑 기록 전 중단) → 부모에서
7818
+ * 제목으로 child_page 를 검색해 고아 페이지를 입양(중복 생성 차단). 없으면 자리표시
7819
+ * 레코드를 제거(FK CASCADE 로 op 도 삭제)해 다음 push 가 새로 생성하게 한다.
7820
+ *
7821
+ * 한계(문서화): Notion 은 idempotency key 가 없어 "생성 요청 적용 직후 같은 호출 내 재시도"
7822
+ * 로 인한 중복은 완전히 차단하지 못한다. 429 는 적용 전 거절이라 안전하고, 프로세스 재시작
7823
+ * 후 재개는 본 검색-입양으로 중복을 막는다. DB 모드(부모가 database)는 child_page 검색이
7824
+ * 불가하므로 자리표시 제거 후 재생성으로 폴백한다.
7825
+ */
7826
+ async recoverInterruptedPushOps() {
7827
+ const ops = this.stateDb.getIncompletePendingOperations();
7828
+ if (ops.length === 0) return;
7829
+ for (const op of ops) {
7830
+ if (op.direction !== "push" || op.operation !== "create") {
7831
+ this.stateDb.markPendingFailed(op.id, "unsupported resume op");
7832
+ continue;
6738
7833
  }
6739
- }
6740
- }
6741
- async walkDirStats(dir, result) {
6742
- const entries = await readdir(dir, { withFileTypes: true });
6743
- for (const entry of entries) {
6744
- const fullPath = join(dir, entry.name);
6745
- if (entry.name.startsWith(".")) continue;
6746
- const relativePath = relative(this.rootPath, fullPath);
6747
- if (this.isExcluded(relativePath)) continue;
6748
- if (entry.isDirectory()) {
6749
- await this.walkDirStats(fullPath, result);
6750
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
6751
- const fileStat = await stat(fullPath);
6752
- result.push({
6753
- path: relativePath,
6754
- mtime: fileStat.mtime.toISOString(),
6755
- size: fileStat.size
6756
- });
7834
+ let payload = {};
7835
+ try {
7836
+ payload = JSON.parse(op.payload ?? "{}");
7837
+ } catch {
7838
+ this.stateDb.markPendingFailed(op.id, "invalid payload json");
7839
+ continue;
6757
7840
  }
6758
- }
6759
- }
6760
- async walkDir(dir, result) {
6761
- const entries = await readdir(dir, { withFileTypes: true });
6762
- for (const entry of entries) {
6763
- const fullPath = join(dir, entry.name);
6764
- if (entry.name.startsWith(".")) continue;
6765
- const relativePath = relative(this.rootPath, fullPath);
6766
- if (this.isExcluded(relativePath)) continue;
6767
- if (entry.isDirectory()) {
6768
- await this.walkDir(fullPath, result);
6769
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
6770
- const content = await readFile(fullPath, "utf-8");
6771
- const fileStat = await stat(fullPath);
6772
- result.push({
6773
- path: relativePath,
6774
- content,
6775
- mtime: fileStat.mtime.toISOString()
7841
+ const path = payload.path;
7842
+ if (!path) {
7843
+ this.stateDb.markPendingFailed(op.id, "missing payload.path");
7844
+ continue;
7845
+ }
7846
+ const state = this.stateDb.getByPath(path);
7847
+ if (state?.notionPageId) {
7848
+ this.stateDb.markPendingCompleted(op.id);
7849
+ continue;
7850
+ }
7851
+ const parentId = payload.parentId ?? state?.notionParentId ?? void 0;
7852
+ const title = payload.title;
7853
+ const adopted = parentId && title ? await this.findChildPageByTitle(parentId, title) : null;
7854
+ if (adopted) {
7855
+ this.stateDb.upsert({
7856
+ obsidianPath: path,
7857
+ notionPageId: adopted,
7858
+ notionParentId: parentId ?? null,
7859
+ contentHash: "",
7860
+ notionLastEdited: state?.notionLastEdited ?? null,
7861
+ localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
7862
+ syncDirection: state?.syncDirection ?? "both",
7863
+ fileType: state?.fileType ?? (this.isFolderNote(path) ? "folder-note" : "file"),
7864
+ status: "pending",
7865
+ baseSnapshot: null,
7866
+ localMtime: null,
7867
+ localFileSize: null
6776
7868
  });
7869
+ this.stateDb.markPendingCompleted(op.id);
7870
+ getLogger().info(`[Im-Nobsidian] \uC911\uB2E8\uB41C create \uC7AC\uAC1C \u2014 \uACE0\uC544 \uD398\uC774\uC9C0 \uC785\uC591: ${path}`);
7871
+ } else if (state) {
7872
+ this.stateDb.delete(state.id);
7873
+ getLogger().info(`[Im-Nobsidian] \uC911\uB2E8\uB41C create \uC7AC\uAC1C \u2014 \uBBF8\uC0DD\uC131 \uD655\uC778, \uC790\uB9AC\uD45C\uC2DC \uC81C\uAC70: ${path}`);
7874
+ } else {
7875
+ this.stateDb.markPendingCompleted(op.id);
6777
7876
  }
6778
7877
  }
6779
7878
  }
6780
- };
6781
-
6782
- // src/converter/pre-processors/inline-db.ts
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");
6785
- var InlineDBParser = class {
6786
- name = "InlineDBParser";
6787
- order = 40;
6788
- process(input) {
6789
- const inlineDbBlocks = [];
6790
- const content = input.content;
6791
- const startMatches = [...content.matchAll(INLINE_DB_START)];
6792
- for (const match of startMatches) {
6793
- const startIdx = match.index;
6794
- const params = parseParams(match[1]);
6795
- const afterStart = content.indexOf("\n", startIdx) + 1;
6796
- const endMatch = INLINE_DB_END.exec(content.slice(afterStart));
6797
- if (endMatch) {
6798
- const tableContent = content.slice(afterStart, afterStart + endMatch.index);
6799
- inlineDbBlocks.push({
6800
- id: params["id"] ?? "",
6801
- title: params["title"] ?? "",
6802
- tableMarkdown: tableContent.trim(),
6803
- startIdx,
6804
- endIdx: afterStart + endMatch.index + endMatch[0].length
6805
- });
7879
+ /** 부모 페이지의 직속 자식 중 제목이 일치하는(보관/휴지통 제외) child_page id 를 찾는다. */
7880
+ async findChildPageByTitle(parentId, title) {
7881
+ try {
7882
+ const children = await this.notionClient.fetchAllChildren(parentId);
7883
+ for (const b of children) {
7884
+ if (b.type !== "child_page") continue;
7885
+ const childTitle = b.child_page?.title;
7886
+ if (childTitle !== title) continue;
7887
+ try {
7888
+ const page = await this.notionClient.getPage(b.id);
7889
+ const inTrash = page.in_trash === true;
7890
+ if (inTrash || page.archived) continue;
7891
+ } catch {
7892
+ continue;
7893
+ }
7894
+ return b.id;
6806
7895
  }
6807
- INLINE_DB_END.lastIndex = 0;
7896
+ } catch (error) {
7897
+ getLogger().warn(
7898
+ `[Im-Nobsidian] \uACE0\uC544 \uD398\uC774\uC9C0 \uAC80\uC0C9 \uC2E4\uD328 (${parentId}): ${error instanceof Error ? error.message : String(error)}`
7899
+ );
6808
7900
  }
6809
- return {
6810
- content,
6811
- metadata: {
6812
- ...input.metadata,
6813
- inlineDbBlocks
6814
- }
6815
- };
7901
+ return null;
6816
7902
  }
6817
- };
6818
- function parseParams(paramString) {
6819
- const params = {};
6820
- for (const pair of paramString.split("&")) {
6821
- const [key, value] = pair.split("=");
6822
- if (key && value) {
6823
- params[key] = decodeURIComponent(value);
7903
+ async resolveUniqueFilePath(basePath) {
7904
+ if (!await this.vaultFs.exists(basePath)) return basePath;
7905
+ const dir = basePath.lastIndexOf("/") >= 0 ? basePath.slice(0, basePath.lastIndexOf("/")) : "";
7906
+ const ext = ".md";
7907
+ const name = basePath.slice(dir ? dir.length + 1 : 0, -ext.length);
7908
+ for (let i = 1; i <= 99; i++) {
7909
+ const candidate = dir ? `${dir}/${name} (${i})${ext}` : `${name} (${i})${ext}`;
7910
+ if (!await this.vaultFs.exists(candidate)) return candidate;
6824
7911
  }
7912
+ return basePath;
6825
7913
  }
6826
- return params;
7914
+ };
7915
+ function extractTitle(filePath) {
7916
+ const parts = filePath.split("/");
7917
+ const filename = parts[parts.length - 1] ?? "";
7918
+ return filename.replace(/\.md$/, "");
6827
7919
  }
6828
-
6829
- // src/conflict/merger.ts
6830
- function threeWayMerge(base, local, remote) {
6831
- const baseLines = base.split("\n");
6832
- const localLines = local.split("\n");
6833
- const remoteLines = remote.split("\n");
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);
6839
- const merged = [];
6840
- const conflicts = [];
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);
6869
- } else {
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");
7920
+ function extractAliases2(properties) {
7921
+ const raw = properties.aliases ?? properties.alias;
7922
+ if (!raw) return [];
7923
+ if (Array.isArray(raw)) return raw.filter((a) => typeof a === "string");
7924
+ if (typeof raw === "string")
7925
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
7926
+ return [];
7927
+ }
7928
+ var NodeVaultFS = class {
7929
+ constructor(rootPath, pathConfig) {
7930
+ this.rootPath = rootPath;
7931
+ this.includePatterns = pathConfig?.include ?? [];
7932
+ const configExclude = pathConfig?.exclude ?? [];
7933
+ const ignorePatterns = this.loadImNobsidianIgnore();
7934
+ this.excludePatterns = [...configExclude, ...ignorePatterns];
7935
+ }
7936
+ rootPath;
7937
+ includePatterns;
7938
+ excludePatterns;
7939
+ loadImNobsidianIgnore() {
7940
+ try {
7941
+ const ignorePath = join(this.rootPath, IGNORE_FILE);
7942
+ const content = readFileSync(ignorePath, "utf-8");
7943
+ return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
7944
+ } catch {
7945
+ return [];
6876
7946
  }
6877
- cursor = regionEnd;
6878
7947
  }
6879
- if (cursor < baseLines.length) {
6880
- merged.push(...baseLines.slice(cursor));
7948
+ async readFile(path) {
7949
+ const fullPath = join(this.rootPath, path);
7950
+ return readFile(fullPath, "utf-8");
6881
7951
  }
6882
- return {
6883
- success: conflicts.length === 0,
6884
- merged: merged.join("\n"),
6885
- conflicts
6886
- };
6887
- }
6888
- function computeHunks(base, modified, side) {
6889
- const matches = lcsMatches(base, modified);
6890
- const hunks = [];
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
- });
6903
- }
6904
- };
6905
- for (const [b, m] of matches) {
6906
- emitGap(b, m);
6907
- prevBase = b;
6908
- prevMod = m;
7952
+ async readBinary(path) {
7953
+ const fullPath = join(this.rootPath, path);
7954
+ return readFile(fullPath);
6909
7955
  }
6910
- emitGap(base.length, modified.length);
6911
- return hunks;
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;
7956
+ async writeFile(path, content) {
7957
+ const fullPath = join(this.rootPath, path);
7958
+ await mkdir(dirname(fullPath), { recursive: true });
7959
+ await writeFile(fullPath, content, "utf-8");
6921
7960
  }
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]);
7961
+ async writeBinary(path, data) {
7962
+ const fullPath = join(this.rootPath, path);
7963
+ await mkdir(dirname(fullPath), { recursive: true });
7964
+ await writeFile(fullPath, data);
7965
+ }
7966
+ async deleteFile(path) {
7967
+ const fullPath = join(this.rootPath, path);
7968
+ await unlink(fullPath);
7969
+ }
7970
+ async moveFile(from, to) {
7971
+ const fromPath = join(this.rootPath, from);
7972
+ const toPath = join(this.rootPath, to);
7973
+ await mkdir(dirname(toPath), { recursive: true });
7974
+ await rename(fromPath, toPath);
7975
+ }
7976
+ async exists(path) {
7977
+ try {
7978
+ await stat(join(this.rootPath, path));
7979
+ return true;
7980
+ } catch {
7981
+ return false;
6936
7982
  }
6937
7983
  }
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++;
7984
+ async ensureFolder(path) {
7985
+ await mkdir(join(this.rootPath, path), { recursive: true });
7986
+ }
7987
+ async listMarkdownFiles() {
7988
+ const files = [];
7989
+ await this.walkDir(this.rootPath, files);
7990
+ return files;
7991
+ }
7992
+ async listMarkdownFileStats() {
7993
+ const stats = [];
7994
+ await this.walkDirStats(this.rootPath, stats);
7995
+ return stats;
7996
+ }
7997
+ async getFileStat(path) {
7998
+ try {
7999
+ const fullPath = join(this.rootPath, path);
8000
+ const fileStat = await stat(fullPath);
8001
+ return { path, mtime: fileStat.mtime.toISOString(), size: fileStat.size };
8002
+ } catch {
8003
+ return null;
6950
8004
  }
6951
8005
  }
6952
- return matches;
6953
- }
6954
- function arraysEqual(a, b) {
6955
- if (a.length !== b.length) return false;
6956
- return a.every((val, idx) => val === b[idx]);
6957
- }
6958
-
6959
- // src/conflict/resolver.ts
6960
- var ConflictResolver = class {
6961
- constructor(stateDb, vaultFs) {
6962
- this.stateDb = stateDb;
6963
- this.vaultFs = vaultFs;
8006
+ async listNonMarkdownFiles() {
8007
+ const files = [];
8008
+ await this.walkDirNonMd(this.rootPath, files);
8009
+ return files;
6964
8010
  }
6965
- stateDb;
6966
- vaultFs;
6967
- async resolve(conflict, choice) {
6968
- switch (choice) {
6969
- case "local":
6970
- return this.resolveLocal(conflict);
6971
- case "remote":
6972
- return this.resolveRemote(conflict);
6973
- case "merge":
6974
- return this.resolveMerge(conflict);
6975
- case "duplicate":
6976
- return this.resolveDuplicate(conflict);
8011
+ matchesPattern(relativePath, pattern) {
8012
+ if (pattern === "**/*" || pattern === "**") return true;
8013
+ if (pattern.endsWith("/**")) {
8014
+ const dir = pattern.slice(0, -3);
8015
+ return relativePath.startsWith(dir + "/") || relativePath === dir;
8016
+ } else if (pattern.startsWith("*.")) {
8017
+ const ext = pattern.slice(1);
8018
+ return relativePath.endsWith(ext);
8019
+ } else if (pattern.includes("*")) {
8020
+ const regexStr = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
8021
+ return new RegExp("^" + regexStr + "$").test(relativePath);
8022
+ } else {
8023
+ return relativePath === pattern || relativePath.startsWith(pattern + "/");
6977
8024
  }
6978
8025
  }
6979
- async resolveByStrategy(conflict, strategy) {
6980
- switch (strategy) {
6981
- case "local-first":
6982
- return this.resolve(conflict, "local");
6983
- case "remote-first":
6984
- return this.resolve(conflict, "remote");
6985
- case "duplicate":
6986
- return this.resolve(conflict, "duplicate");
6987
- case "manual":
6988
- return this.resolve(conflict, "merge");
8026
+ isExcluded(relativePath) {
8027
+ if (this.includePatterns.length > 0) {
8028
+ const included = this.includePatterns.some((p) => this.matchesPattern(relativePath, p));
8029
+ if (!included) return true;
6989
8030
  }
8031
+ return this.excludePatterns.some((p) => this.matchesPattern(relativePath, p));
6990
8032
  }
6991
- async resolveAll(conflicts, strategy) {
6992
- const results = [];
6993
- for (const conflict of conflicts) {
6994
- const result = await this.resolveByStrategy(conflict, strategy);
6995
- results.push(result);
8033
+ async walkDirNonMd(dir, result) {
8034
+ const entries = await readdir(dir, { withFileTypes: true });
8035
+ for (const entry of entries) {
8036
+ const fullPath = join(dir, entry.name);
8037
+ if (entry.name.startsWith(".")) continue;
8038
+ const relativePath = relative(this.rootPath, fullPath);
8039
+ if (this.isExcluded(relativePath)) continue;
8040
+ if (entry.isDirectory()) {
8041
+ await this.walkDirNonMd(fullPath, result);
8042
+ } else if (entry.isFile() && !entry.name.endsWith(".md") && !entry.name.endsWith(".base")) {
8043
+ const fileStat = await stat(fullPath);
8044
+ result.push({
8045
+ path: relativePath,
8046
+ size: fileStat.size,
8047
+ mtime: fileStat.mtime.toISOString()
8048
+ });
8049
+ }
6996
8050
  }
6997
- return results;
6998
8051
  }
6999
- async resolveLocal(conflict) {
7000
- const hash = computeHash(conflict.localContent);
7001
- this.stateDb.updateHash(
7002
- conflict.syncRecord.id,
7003
- hash,
7004
- Buffer.from(conflict.localContent, "utf-8")
7005
- );
7006
- this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
7007
- return { path: conflict.syncRecord.obsidianPath, choice: "local", success: true };
8052
+ async walkDirStats(dir, result) {
8053
+ const entries = await readdir(dir, { withFileTypes: true });
8054
+ for (const entry of entries) {
8055
+ const fullPath = join(dir, entry.name);
8056
+ if (entry.name.startsWith(".")) continue;
8057
+ const relativePath = relative(this.rootPath, fullPath);
8058
+ if (this.isExcluded(relativePath)) continue;
8059
+ if (entry.isDirectory()) {
8060
+ await this.walkDirStats(fullPath, result);
8061
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
8062
+ const fileStat = await stat(fullPath);
8063
+ result.push({
8064
+ path: relativePath,
8065
+ mtime: fileStat.mtime.toISOString(),
8066
+ size: fileStat.size
8067
+ });
8068
+ }
8069
+ }
7008
8070
  }
7009
- async resolveRemote(conflict) {
7010
- await this.vaultFs.writeFile(conflict.syncRecord.obsidianPath, conflict.remoteContent);
7011
- const hash = computeHash(conflict.remoteContent);
7012
- this.stateDb.updateHash(
7013
- conflict.syncRecord.id,
7014
- hash,
7015
- Buffer.from(conflict.remoteContent, "utf-8")
7016
- );
7017
- this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
7018
- return { path: conflict.syncRecord.obsidianPath, choice: "remote", success: true };
8071
+ async walkDir(dir, result) {
8072
+ const entries = await readdir(dir, { withFileTypes: true });
8073
+ for (const entry of entries) {
8074
+ const fullPath = join(dir, entry.name);
8075
+ if (entry.name.startsWith(".")) continue;
8076
+ const relativePath = relative(this.rootPath, fullPath);
8077
+ if (this.isExcluded(relativePath)) continue;
8078
+ if (entry.isDirectory()) {
8079
+ await this.walkDir(fullPath, result);
8080
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
8081
+ const content = await readFile(fullPath, "utf-8");
8082
+ const fileStat = await stat(fullPath);
8083
+ result.push({
8084
+ path: relativePath,
8085
+ content,
8086
+ mtime: fileStat.mtime.toISOString()
8087
+ });
8088
+ }
8089
+ }
7019
8090
  }
7020
- async resolveMerge(conflict) {
7021
- const base = conflict.baseContent ?? "";
7022
- const mergeResult = threeWayMerge(base, conflict.localContent, conflict.remoteContent);
7023
- await this.vaultFs.writeFile(conflict.syncRecord.obsidianPath, mergeResult.merged);
7024
- const hash = computeHash(mergeResult.merged);
7025
- this.stateDb.updateHash(conflict.syncRecord.id, hash, Buffer.from(mergeResult.merged, "utf-8"));
7026
- if (mergeResult.success) {
7027
- this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
8091
+ };
8092
+
8093
+ // src/converter/pre-processors/inline-db.ts
8094
+ var INLINE_DB_START = new RegExp(`%% ${MARKER_BRAND_RE}:inline-db:([^\\s]+) %%`, "g");
8095
+ var INLINE_DB_END = new RegExp(`%% ${MARKER_BRAND_RE}:end %%`, "g");
8096
+ var InlineDBParser = class {
8097
+ name = "InlineDBParser";
8098
+ order = 40;
8099
+ process(input) {
8100
+ const inlineDbBlocks = [];
8101
+ const content = input.content;
8102
+ const startMatches = [...content.matchAll(INLINE_DB_START)];
8103
+ for (const match of startMatches) {
8104
+ const startIdx = match.index;
8105
+ const params = parseParams(match[1]);
8106
+ const afterStart = content.indexOf("\n", startIdx) + 1;
8107
+ const endMatch = INLINE_DB_END.exec(content.slice(afterStart));
8108
+ if (endMatch) {
8109
+ const tableContent = content.slice(afterStart, afterStart + endMatch.index);
8110
+ inlineDbBlocks.push({
8111
+ id: params["id"] ?? "",
8112
+ title: params["title"] ?? "",
8113
+ tableMarkdown: tableContent.trim(),
8114
+ startIdx,
8115
+ endIdx: afterStart + endMatch.index + endMatch[0].length
8116
+ });
8117
+ }
8118
+ INLINE_DB_END.lastIndex = 0;
7028
8119
  }
7029
8120
  return {
7030
- path: conflict.syncRecord.obsidianPath,
7031
- choice: "merge",
7032
- success: mergeResult.success,
7033
- mergeHadConflicts: !mergeResult.success
8121
+ content,
8122
+ metadata: {
8123
+ ...input.metadata,
8124
+ inlineDbBlocks
8125
+ }
7034
8126
  };
7035
8127
  }
7036
- async resolveDuplicate(conflict) {
7037
- const originalPath = conflict.syncRecord.obsidianPath;
7038
- const ext = originalPath.endsWith(".md") ? ".md" : "";
7039
- const baseName = originalPath.replace(/\.md$/, "");
7040
- const conflictPath = `${baseName}.conflict${ext}`;
7041
- await this.vaultFs.writeFile(conflictPath, conflict.remoteContent);
7042
- const localHash = computeHash(conflict.localContent);
7043
- this.stateDb.updateHash(
7044
- conflict.syncRecord.id,
7045
- localHash,
7046
- Buffer.from(conflict.localContent, "utf-8")
7047
- );
7048
- this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
7049
- return { path: originalPath, choice: "duplicate", success: true };
7050
- }
7051
- generateDiff(conflict) {
7052
- const localLines = conflict.localContent.split("\n");
7053
- const remoteLines = conflict.remoteContent.split("\n");
7054
- const lines = [];
7055
- lines.push(`--- local: ${conflict.syncRecord.obsidianPath}`);
7056
- lines.push(`+++ remote: Notion (${conflict.remoteChange.pageId})`);
7057
- lines.push("");
7058
- const maxLen = Math.max(localLines.length, remoteLines.length);
7059
- for (let i = 0; i < maxLen; i++) {
7060
- const localLine = localLines[i];
7061
- const remoteLine = remoteLines[i];
7062
- if (localLine === remoteLine) {
7063
- lines.push(` ${localLine ?? ""}`);
7064
- } else {
7065
- if (localLine !== void 0) lines.push(`- ${localLine}`);
7066
- if (remoteLine !== void 0) lines.push(`+ ${remoteLine}`);
7067
- }
8128
+ };
8129
+ function parseParams(paramString) {
8130
+ const params = {};
8131
+ for (const pair of paramString.split("&")) {
8132
+ const [key, value] = pair.split("=");
8133
+ if (key && value) {
8134
+ params[key] = decodeURIComponent(value);
7068
8135
  }
7069
- return lines.join("\n");
7070
8136
  }
7071
- };
8137
+ return params;
8138
+ }
7072
8139
 
7073
8140
  // src/structure/tree-mapper.ts
7074
8141
  var TreeMapper = class {
@@ -7479,8 +8546,7 @@ var ViewDataProvider = class {
7479
8546
  }
7480
8547
  async collectEntries(folderPath) {
7481
8548
  const allFiles = await this.vaultFs.listMarkdownFiles();
7482
- const prefix = folderPath.endsWith("/") ? folderPath : folderPath + "/";
7483
- const dbFiles = allFiles.filter((f) => f.path.startsWith(prefix));
8549
+ const dbFiles = selectDbRowFiles(allFiles, folderPath);
7484
8550
  const entries = [];
7485
8551
  for (const file of dbFiles) {
7486
8552
  try {
@@ -7542,11 +8608,11 @@ var ViewDataProvider = class {
7542
8608
  };
7543
8609
  function parseEntry(path, content) {
7544
8610
  try {
7545
- const parsed = matter7(content);
8611
+ const parsed = matter(content);
7546
8612
  const fm = parsed.data;
7547
- const title = fm.title ?? extractTitleFromPath2(path);
7548
- const icon = fm.icon;
7549
- const cover = fm.cover;
8613
+ const title = coerceString(fm.title) ?? extractTitleFromPath2(path);
8614
+ const icon = coerceString(fm.icon);
8615
+ const cover = coerceCover(fm.cover);
7550
8616
  const properties = {};
7551
8617
  for (const [key, value] of Object.entries(fm)) {
7552
8618
  if (key === "title" || key === "icon" || key === "cover") continue;
@@ -7557,6 +8623,36 @@ function parseEntry(path, content) {
7557
8623
  return null;
7558
8624
  }
7559
8625
  }
8626
+ function coerceCover(raw) {
8627
+ const firstUrl = (v) => {
8628
+ if (typeof v === "string") return v.trim() || void 0;
8629
+ if (v && typeof v === "object") {
8630
+ const o = v;
8631
+ const url = o.url ?? o.external?.url;
8632
+ if (typeof url === "string") return url.trim() || void 0;
8633
+ }
8634
+ return void 0;
8635
+ };
8636
+ if (Array.isArray(raw)) {
8637
+ for (const item of raw) {
8638
+ const url = firstUrl(item);
8639
+ if (url) return url;
8640
+ }
8641
+ return void 0;
8642
+ }
8643
+ return firstUrl(raw);
8644
+ }
8645
+ function coerceString(raw) {
8646
+ if (typeof raw === "string") return raw.trim() || void 0;
8647
+ if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
8648
+ if (Array.isArray(raw)) {
8649
+ for (const item of raw) {
8650
+ const s = coerceString(item);
8651
+ if (s) return s;
8652
+ }
8653
+ }
8654
+ return void 0;
8655
+ }
7560
8656
  function normalizePropertyValue(value) {
7561
8657
  if (value === null || value === void 0) return null;
7562
8658
  if (typeof value === "string") return value;
@@ -7600,16 +8696,16 @@ var EntryEditor = class {
7600
8696
  vaultFs;
7601
8697
  async updateProperty(entryPath, propertyName, newValue) {
7602
8698
  const content = await this.vaultFs.readFile(entryPath);
7603
- const parsed = matter7(content);
8699
+ const parsed = matter(content);
7604
8700
  parsed.data[propertyName] = newValue;
7605
- const updated = matter7.stringify(parsed.content, parsed.data);
8701
+ const updated = stringifyFrontmatter(parsed.content, parsed.data);
7606
8702
  await this.vaultFs.writeFile(entryPath, updated);
7607
8703
  }
7608
8704
  async createEntry(folderPath, title, properties) {
7609
8705
  const safeName = title.replace(/[\\/:*?"<>|]/g, "_").trim() || "Untitled";
7610
8706
  const filePath = `${folderPath}/${safeName}.md`;
7611
8707
  const frontmatter = { title, ...properties };
7612
- const content = matter7.stringify("", frontmatter);
8708
+ const content = stringifyFrontmatter("", frontmatter);
7613
8709
  await this.vaultFs.ensureFolder(folderPath);
7614
8710
  await this.vaultFs.writeFile(filePath, content);
7615
8711
  return filePath;
@@ -7666,6 +8762,77 @@ ${lines.join("\n")}
7666
8762
  }`;
7667
8763
  }
7668
8764
 
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 };
8765
+ // src/audit/fidelity.ts
8766
+ var NOTION_ID_RE = /[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi;
8767
+ var MD_LINK_RE = new RegExp(
8768
+ `\\[[^\\]]*\\]\\((https?://[^)\\s]*notion\\.so/[^)\\s]*)\\)(\\s*%%\\s*${MARKER_BRAND}\\b)?`,
8769
+ "gi"
8770
+ );
8771
+ var MENTION_PAGE_RE = /<mention-page\b[\s\S]*?(?:\/>|>[\s\S]*?<\/mention-page>)/gi;
8772
+ function lastNotionId(text) {
8773
+ const matches = text.match(NOTION_ID_RE);
8774
+ if (!matches || matches.length === 0) return null;
8775
+ return compactNotionId(matches[matches.length - 1]);
8776
+ }
8777
+ function classifyBodyFidelity(body, ctx) {
8778
+ const result = {
8779
+ preservedMarkers: 0,
8780
+ externalLinks: 0,
8781
+ selfReferences: 0,
8782
+ defects: []
8783
+ };
8784
+ const own = ctx.ownPageId ? compactNotionId(ctx.ownPageId) : null;
8785
+ const titleOf = (id) => ctx.titleById?.get(id) ?? null;
8786
+ const classifyId = (id, form, raw) => {
8787
+ if (id === null) {
8788
+ result.externalLinks += 1;
8789
+ return;
8790
+ }
8791
+ if (own !== null && id === own) {
8792
+ result.selfReferences += 1;
8793
+ return;
8794
+ }
8795
+ if (ctx.knownPageIds.has(id)) {
8796
+ result.defects.push({ form, targetPageId: id, targetTitle: titleOf(id), raw: raw.trim() });
8797
+ return;
8798
+ }
8799
+ result.externalLinks += 1;
8800
+ };
8801
+ for (const m of body.matchAll(MD_LINK_RE)) {
8802
+ const url = m[1] ?? "";
8803
+ const hasMarker = Boolean(m[2]);
8804
+ if (hasMarker) {
8805
+ result.preservedMarkers += 1;
8806
+ continue;
8807
+ }
8808
+ classifyId(lastNotionId(url), "md-link", m[0]);
8809
+ }
8810
+ for (const m of body.matchAll(MENTION_PAGE_RE)) {
8811
+ classifyId(lastNotionId(m[0]), "mention-page", m[0]);
8812
+ }
8813
+ return result;
8814
+ }
8815
+ function summarizeFidelity(perFile) {
8816
+ const summary = {
8817
+ files: perFile.length,
8818
+ preservedMarkers: 0,
8819
+ externalLinks: 0,
8820
+ selfReferences: 0,
8821
+ defects: 0,
8822
+ defectsByFile: {}
8823
+ };
8824
+ for (const { path, classification } of perFile) {
8825
+ summary.preservedMarkers += classification.preservedMarkers;
8826
+ summary.externalLinks += classification.externalLinks;
8827
+ summary.selfReferences += classification.selfReferences;
8828
+ summary.defects += classification.defects.length;
8829
+ if (classification.defects.length > 0) {
8830
+ summary.defectsByFile[path] = classification.defects;
8831
+ }
8832
+ }
8833
+ return summary;
8834
+ }
8835
+
8836
+ 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, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, ImageHandler, InlineAnnotationPreserver, InlineDBParser, LocalImageRestorer, MARKER_BRAND, MathNormalizer, MentionToWikilink, NOTION_TO_BASES_VIEW, NodeVaultFS, NotionBlockBuilder, NotionClient, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, STATE_DB_FILE, STATE_DB_PATH, SidecarGenerator, StateDB, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WatchSyncService, WikilinkResolver, basesViewTypeOf, classifyBodyFidelity, compactNotionId, computeBufferHash, computeHash, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, isInternalPath, normalizeNotionId, notionIdsEqual, sanitizeFileName, setLogger, sortEntries, summarizeFidelity, threeWayMerge };
7670
8837
  //# sourceMappingURL=index.js.map
7671
8838
  //# sourceMappingURL=index.js.map