@im-nobsidian/core 0.3.1 → 0.3.2

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
@@ -70,7 +70,11 @@ var ConfigSchema = z.object({
70
70
  // 비마크다운 파일(첨부) 업로드/다운로드 동시성
71
71
  fileConcurrency: z.number().min(1).max(10).default(2),
72
72
  // 다운로드 파일 크기 상한 (bytes, 기본 100MB)
73
- maxFileSizeBytes: z.number().min(1024).default(100 * 1024 * 1024)
73
+ maxFileSizeBytes: z.number().min(1024).default(100 * 1024 * 1024),
74
+ // 미디어·첨부 다운로드 1회 시도의 시간 상한 (ms, 기본 300초). 0 = 상한 없음(권장하지 않음)
75
+ mediaDownloadTimeoutMs: z.number().min(0).max(18e5).default(3e5),
76
+ // 페이지 1건 처리의 시간 상한 (ms, 기본 30분). 한 건이 동기화 전체를 멈춰 세우지 못하게 한다. 0 = 상한 없음
77
+ itemTimeoutMs: z.number().min(0).max(72e5).default(18e5)
74
78
  })
75
79
  });
76
80
  var DEFAULT_CONFIG = {
@@ -114,7 +118,9 @@ var DEFAULT_CONFIG = {
114
118
  mediaMaxRetries: 3,
115
119
  mediaRetryBaseMs: 1e3,
116
120
  fileConcurrency: 2,
117
- maxFileSizeBytes: 100 * 1024 * 1024
121
+ maxFileSizeBytes: 100 * 1024 * 1024,
122
+ mediaDownloadTimeoutMs: 3e5,
123
+ itemTimeoutMs: 18e5
118
124
  }
119
125
  };
120
126
 
@@ -761,13 +767,13 @@ var PropertyMapper = class {
761
767
  const items = Array.isArray(value) ? value : [value];
762
768
  const ids = [];
763
769
  for (const item of items) {
764
- const str = String(item);
765
- const match = str.match(WIKILINK_REGEX);
770
+ const str2 = String(item);
771
+ const match = str2.match(WIKILINK_REGEX);
766
772
  if (match?.[1] && this.wikilinkResolver) {
767
773
  const pageId = this.wikilinkResolver.resolve(match[1]);
768
774
  if (pageId) ids.push({ id: pageId });
769
- } else if (str.match(/^[0-9a-f-]{32,36}$/)) {
770
- ids.push({ id: str });
775
+ } else if (str2.match(/^[0-9a-f-]{32,36}$/)) {
776
+ ids.push({ id: str2 });
771
777
  }
772
778
  }
773
779
  return ids.length > 0 ? { relation: ids } : null;
@@ -956,10 +962,12 @@ function isNotionObjectNotFound(error) {
956
962
  return e.code === "object_not_found" || e.status === 404;
957
963
  }
958
964
  var DiscoveryTooLargeError = class extends Error {
959
- constructor(elapsedMs) {
965
+ constructor(elapsedMs, partial = []) {
960
966
  super(`subtree discovery exceeded time budget (${elapsedMs}ms)`);
967
+ this.partial = partial;
961
968
  this.name = "DiscoveryTooLargeError";
962
969
  }
970
+ partial;
963
971
  };
964
972
  var NotionClient = class _NotionClient {
965
973
  client;
@@ -987,7 +995,7 @@ var NotionClient = class _NotionClient {
987
995
  this.batchSize = options.batchSize ?? 100;
988
996
  }
989
997
  /** config.advanced 의 운영 튜닝값으로 클라이언트를 생성한다 (매직넘버 단일 진실원). */
990
- static fromConfig(config, fetch2) {
998
+ static fromConfig(config, fetch) {
991
999
  const a = config.advanced;
992
1000
  return new _NotionClient({
993
1001
  token: config.notion.token,
@@ -999,7 +1007,7 @@ var NotionClient = class _NotionClient {
999
1007
  retryBackoffFactor: a.retryBackoffFactor,
1000
1008
  pageSize: a.pageSize,
1001
1009
  batchSize: a.batchSize,
1002
- ...fetch2 ? { fetch: fetch2 } : {}
1010
+ ...fetch ? { fetch } : {}
1003
1011
  });
1004
1012
  }
1005
1013
  getInternalClient() {
@@ -1193,36 +1201,25 @@ var NotionClient = class _NotionClient {
1193
1201
  }
1194
1202
  return schema;
1195
1203
  }
1204
+ /**
1205
+ * DB 속성 스키마 전체(선택지·status 그룹 포함).
1206
+ *
1207
+ * 옵션 `id` 까지 그대로 실어 보낸다 — 산출물에는 안 나가지만, status 뷰 필터가
1208
+ * 옵션명이 아니라 **그룹명**(`To-do` 등)으로 오기 때문에 `groups[].optionIds` 를
1209
+ * 옵션명으로 되돌리려면 이 id 가 있어야 한다. 예전엔 여기서 id 를 떨궈,
1210
+ * 그룹으로 필터링된 뷰를 Bases 로 옮길 방법이 아예 없었다.
1211
+ */
1196
1212
  async getDatabaseSchemaFull(databaseId) {
1197
1213
  const db = await this.fetchDatabaseModern(databaseId);
1198
1214
  const properties = db.properties;
1199
1215
  if (!properties) return {};
1200
1216
  const schema = {};
1201
1217
  for (const [name, prop] of Object.entries(properties)) {
1202
- const entry = {
1218
+ schema[name] = {
1203
1219
  id: prop.id,
1204
- type: prop.type
1220
+ type: prop.type,
1221
+ ...extractPropertyChoices(prop)
1205
1222
  };
1206
- if (prop.type === "select" || prop.type === "multi_select") {
1207
- const typeData = prop[prop.type];
1208
- if (typeData?.options) {
1209
- entry.options = typeData.options.map((o) => ({ name: o.name, color: o.color }));
1210
- }
1211
- }
1212
- if (prop.type === "status") {
1213
- const statusData = prop.status;
1214
- if (statusData?.options) {
1215
- entry.options = statusData.options.map((o) => ({ name: o.name, color: o.color }));
1216
- }
1217
- if (statusData?.groups) {
1218
- entry.groups = statusData.groups.map((g) => ({
1219
- name: g.name,
1220
- color: g.color,
1221
- optionIds: g.option_ids
1222
- }));
1223
- }
1224
- }
1225
- schema[name] = entry;
1226
1223
  }
1227
1224
  return schema;
1228
1225
  }
@@ -1375,17 +1372,32 @@ var NotionClient = class _NotionClient {
1375
1372
  }
1376
1373
  return allBlocks;
1377
1374
  }
1378
- async appendChildren(blockId, children) {
1375
+ /**
1376
+ * 자식 블록을 덧붙인다. `options.after` 를 주면 그 블록 **바로 뒤**에 삽입한다
1377
+ * (기본은 맨 끝). 자리표시자를 실제 미디어 블록으로 제자리 교체할 때 필요하다.
1378
+ *
1379
+ * 실측(2025-09-03): position 삽입 응답의 `results` 는 방금 넣은 블록뿐 아니라
1380
+ * 삽입 지점 이후의 형제까지 함께 돌려준다. 앞에서부터 batch 길이만큼이 새 블록이므로
1381
+ * 배치를 이어 붙일 때는 그 마지막 id 를 다음 기준점으로 삼는다.
1382
+ */
1383
+ async appendChildren(blockId, children, options) {
1379
1384
  const batchSize = this.batchSize;
1385
+ const created = [];
1386
+ let after = options?.after;
1380
1387
  for (let i = 0; i < children.length; i += batchSize) {
1381
1388
  const batch = children.slice(i, i + batchSize);
1382
- await this.withRateLimit(
1389
+ const response = await this.withRateLimit(
1383
1390
  () => this.client.blocks.children.append({
1384
1391
  block_id: blockId,
1385
- children: batch
1392
+ children: batch,
1393
+ ...after ? { position: { type: "after_block", after_block: { id: after } } } : {}
1386
1394
  })
1387
1395
  );
1396
+ const ids = response.results.slice(0, batch.length).map((b) => b.id);
1397
+ created.push(...ids);
1398
+ if (after && ids.length > 0) after = ids[ids.length - 1];
1388
1399
  }
1400
+ return created;
1389
1401
  }
1390
1402
  async deleteBlock(blockId) {
1391
1403
  await this.withRateLimit(() => this.client.blocks.delete({ block_id: blockId }));
@@ -1509,6 +1521,8 @@ var NotionClient = class _NotionClient {
1509
1521
  *
1510
1522
  * @param opts.deadlineMs `Date.now()` 기준 마감 시각. 각 페이지 처리 전 초과를 검사해
1511
1523
  * 초과 시 {@link DiscoveryTooLargeError}를 던진다(대규모 서브트리 → search 폴백 유도).
1524
+ * **그때까지 찾은 페이지는 에러의 `partial` 에 실어 보낸다** — 버리면 호출측이 두
1525
+ * 경로를 경합시키게 되고, 어느 쪽이 이기는지를 벽시계가 정하게 된다(R12-A).
1512
1526
  * 미지정 시 무제한(기존 동작 유지 — 테스트 mock 경로는 영향 없음).
1513
1527
  */
1514
1528
  async getChildPagesRecursive(parentId, opts) {
@@ -1519,7 +1533,7 @@ var NotionClient = class _NotionClient {
1519
1533
  const nextLevel = [];
1520
1534
  for (const id of currentLevel) {
1521
1535
  if (opts?.deadlineMs !== void 0 && Date.now() > opts.deadlineMs) {
1522
- throw new DiscoveryTooLargeError(Date.now() - start);
1536
+ throw new DiscoveryTooLargeError(Date.now() - start, all);
1523
1537
  }
1524
1538
  let children;
1525
1539
  try {
@@ -1583,7 +1597,10 @@ var NotionClient = class _NotionClient {
1583
1597
  let bp;
1584
1598
  try {
1585
1599
  bp = (await this.getBlock(bid)).parent;
1586
- } catch {
1600
+ } catch (error) {
1601
+ getLogger().warn(
1602
+ `[Im-Nobsidian] \uBD80\uBAA8 \uBE14\uB85D \uD574\uC18C \uC2E4\uD328 (${bid}) \u2014 \uC774 \uBE14\uB85D\uC5D0 \uC911\uCCA9\uB41C \uD398\uC774\uC9C0\uB294 search \uB514\uC2A4\uCEE4\uBC84\uB9AC\uC5D0\uC11C \uC81C\uC678\uB428: ${error instanceof Error ? error.message : String(error)}`
1603
+ );
1587
1604
  blockOwner.set(bid, null);
1588
1605
  return null;
1589
1606
  }
@@ -1741,34 +1758,78 @@ var NotionClient = class _NotionClient {
1741
1758
  }
1742
1759
  // ─── Internal ───
1743
1760
  lastRequestTime = 0;
1761
+ /**
1762
+ * 429 이후 **클라이언트 전체**가 쉬어야 하는 시각(epoch ms).
1763
+ *
1764
+ * rate limit 은 요청 하나의 문제가 아니라 워크스페이스 전체에 걸린 신호다. 그러니
1765
+ * 429 를 맞은 요청만 기다리게 두면 나머지 슬롯이 곧바로 다시 두드려 429 를 재생산한다.
1766
+ * 슬롯을 붙잡아 남을 막는 대신, 다 같이 보는 이 게이트로 쉬게 한다.
1767
+ */
1768
+ cooldownUntil = 0;
1769
+ /**
1770
+ * 요청 1건을 rate limit 슬롯 안에서 실행하고, 재시도 가능한 실패는 백오프 후 다시 시도한다.
1771
+ *
1772
+ * 백오프 대기는 **슬롯을 놓은 뒤** 한다. 예전 구현은 `sema.acquire()` 안에서 재시도
1773
+ * 전체(최대 60초 x 5회)를 돌려, 불운한 요청 하나가 동시성 한 칸을 최대 5분 점유했다.
1774
+ * 동시성 기본값이 3이므로 그런 요청 3건이면 클라이언트 전체가 멈춘다. 게다가 재시도는
1775
+ * 로그를 한 줄도 남기지 않아, 겉보기에는 죽은 프로세스와 구분되지 않았다 — 실제로
1776
+ * 268페이지 pull 이 진행 로그가 멈춘 채 수 분씩 정지하는 것으로 나타났다(R9).
1777
+ * 그래서 (1) 대기 중에는 슬롯을 반납하고, (2) 429 는 {@link cooldownUntil} 전역
1778
+ * 게이트로 함께 쉬고, (3) 모든 재시도를 경고로 남긴다.
1779
+ */
1744
1780
  async withRateLimit(fn) {
1745
- await this.sema.acquire();
1746
- try {
1747
- const now = Date.now();
1748
- const elapsed = now - this.lastRequestTime;
1749
- if (elapsed < this.minRequestInterval) {
1750
- await sleep(this.minRequestInterval - elapsed);
1781
+ let backoffMs = 0;
1782
+ for (let attempt = 0; ; attempt++) {
1783
+ await this.sema.acquire();
1784
+ try {
1785
+ await this.waitForTurn();
1786
+ this.lastRequestTime = Date.now();
1787
+ return await fn();
1788
+ } catch (error) {
1789
+ if (!isRetryable(error) || attempt >= this.maxRetries) throw error;
1790
+ const retryAfterMs = extractRetryAfter(error);
1791
+ backoffMs = retryAfterMs !== null ? retryAfterMs * (1 + Math.random() * 0.25) : this.retryBaseDelayMs * Math.pow(this.retryBackoffFactor, attempt) * (0.5 + Math.random() * 0.5);
1792
+ if (isRateLimited(error)) {
1793
+ this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + backoffMs);
1794
+ }
1795
+ getLogger().warn(
1796
+ `Notion API \uC7AC\uC2DC\uB3C4 ${attempt + 1}/${this.maxRetries} \u2014 ${Math.round(backoffMs)}ms \uB300\uAE30 (${describeRetryCause(error)})`
1797
+ );
1798
+ } finally {
1799
+ this.sema.release();
1751
1800
  }
1752
- this.lastRequestTime = Date.now();
1753
- return await this.executeWithRetry(fn);
1754
- } finally {
1755
- this.sema.release();
1801
+ await sleep(backoffMs);
1756
1802
  }
1757
1803
  }
1758
- async executeWithRetry(fn, attempt = 0) {
1759
- try {
1760
- return await fn();
1761
- } catch (error) {
1762
- if (isRetryable(error) && attempt < this.maxRetries) {
1763
- const baseDelay = extractRetryAfter(error) ?? this.retryBaseDelayMs * Math.pow(this.retryBackoffFactor, attempt);
1764
- const jitter = baseDelay * (0.5 + Math.random() * 0.5);
1765
- await sleep(jitter);
1766
- return this.executeWithRetry(fn, attempt + 1);
1767
- }
1768
- throw error;
1769
- }
1804
+ /** 슬롯을 쥔 뒤의 발사 대기 — 전역 쿨다운(429)과 최소 요청 간격을 함께 지킨다. */
1805
+ async waitForTurn() {
1806
+ const cooldownMs = this.cooldownUntil - Date.now();
1807
+ if (cooldownMs > 0) await sleep(cooldownMs);
1808
+ const elapsed = Date.now() - this.lastRequestTime;
1809
+ if (elapsed < this.minRequestInterval) await sleep(this.minRequestInterval - elapsed);
1770
1810
  }
1771
1811
  };
1812
+ function extractPropertyChoices(prop) {
1813
+ const type = prop.type;
1814
+ if (type !== "select" && type !== "multi_select" && type !== "status") return {};
1815
+ const typeData = prop[type];
1816
+ if (!typeData) return {};
1817
+ const result = {};
1818
+ if (typeData.options) {
1819
+ result.options = typeData.options.map((o) => ({ id: o.id, name: o.name, color: o.color }));
1820
+ }
1821
+ if (typeData.groups) {
1822
+ result.groups = typeData.groups.map((g) => ({
1823
+ name: g.name,
1824
+ color: g.color,
1825
+ optionIds: g.option_ids
1826
+ }));
1827
+ }
1828
+ return result;
1829
+ }
1830
+ function isRateLimited(error) {
1831
+ return typeof error === "object" && error !== null && error.status === 429;
1832
+ }
1772
1833
  function isRetryable(error) {
1773
1834
  if (typeof error !== "object" || error === null) return false;
1774
1835
  const code = error.code;
@@ -1779,14 +1840,44 @@ function isRetryable(error) {
1779
1840
  const status = error.status;
1780
1841
  return status === 429 || status === 502 || status === 503 || status === 504;
1781
1842
  }
1782
- function extractRetryAfter(error) {
1783
- if (typeof error === "object" && error !== null && "headers" in error) {
1784
- const headers = error.headers;
1785
- const retryAfter = headers?.["retry-after"];
1786
- if (retryAfter) return Number(retryAfter) * 1e3;
1843
+ function describeRetryCause(error) {
1844
+ if (typeof error !== "object" || error === null) return String(error);
1845
+ const e = error;
1846
+ const parts = [];
1847
+ if (typeof e.status === "number") parts.push(`status ${e.status}`);
1848
+ if (typeof e.code === "string") parts.push(e.code);
1849
+ if (typeof e.message === "string" && e.message) parts.push(e.message.slice(0, 120));
1850
+ return parts.length > 0 ? parts.join(" \xB7 ") : "\uC54C \uC218 \uC5C6\uB294 \uC624\uB958";
1851
+ }
1852
+ var MAX_RETRY_AFTER_MS = 6e4;
1853
+ function readHeader(headers, name) {
1854
+ if (typeof headers !== "object" || headers === null) return null;
1855
+ const get = headers.get;
1856
+ if (typeof get === "function") {
1857
+ const value = get.call(headers, name);
1858
+ return typeof value === "string" && value ? value : null;
1859
+ }
1860
+ for (const [key, value] of Object.entries(headers)) {
1861
+ if (key.toLowerCase() !== name) continue;
1862
+ return typeof value === "string" && value ? value : null;
1787
1863
  }
1788
1864
  return null;
1789
1865
  }
1866
+ function extractRetryAfter(error) {
1867
+ if (typeof error !== "object" || error === null || !("headers" in error)) return null;
1868
+ const raw = readHeader(error.headers, "retry-after");
1869
+ if (!raw) return null;
1870
+ const seconds = Number(raw);
1871
+ let ms;
1872
+ if (Number.isFinite(seconds)) {
1873
+ ms = seconds * 1e3;
1874
+ } else {
1875
+ const at = Date.parse(raw);
1876
+ ms = Number.isNaN(at) ? Number.NaN : at - Date.now();
1877
+ }
1878
+ if (!Number.isFinite(ms) || ms <= 0) return null;
1879
+ return Math.min(ms, MAX_RETRY_AFTER_MS);
1880
+ }
1790
1881
  function sleep(ms) {
1791
1882
  return new Promise((resolve) => setTimeout(resolve, ms));
1792
1883
  }
@@ -2320,10 +2411,33 @@ function compactMarker(body) {
2320
2411
  }
2321
2412
  var TOGGLE_START = compactMarker("toggle:start");
2322
2413
  var TOGGLE_END = compactMarker("toggle:end");
2414
+ var TOGGLE_HEADING_START = compactMarker("toggle-heading");
2415
+ var TOGGLE_HEADING_END = compactMarker("toggle-heading:end");
2416
+ function calloutIndentMarker(depth) {
2417
+ return compactMarker(`callout-indent:${depth}`);
2418
+ }
2323
2419
  var COLUMN_LIST_START = compactMarker("column-list:start");
2324
2420
  var COLUMN_LIST_END = compactMarker("column-list:end");
2325
2421
  var COLUMN_SEP = compactMarker("column");
2422
+ function columnMarker(ratio) {
2423
+ return ratio ? compactMarker(`column:ratio=${ratio}`) : COLUMN_SEP;
2424
+ }
2425
+ function columnSepSource(ratio = "capture") {
2426
+ const value = ratio === "capture" ? "([\\d.]+)" : "(?:[\\d.]+)";
2427
+ return `%%${MARKER_BRAND}:column(?::ratio=${value})?%%`;
2428
+ }
2429
+ var COLUMN_SEP_SOURCE = columnSepSource("capture");
2326
2430
  var TOC_MARKER = compactMarker("toc");
2431
+ function tocMarker(color) {
2432
+ return !color || color === "default" ? TOC_MARKER : compactMarker(`toc:color=${color}`);
2433
+ }
2434
+ function embedMarker(src) {
2435
+ return compactMarker(`embed:src=${encodeURIComponent(src)}`);
2436
+ }
2437
+ function unknownMentionMarker(url, alt) {
2438
+ const tail = alt ? `&alt=${encodeURIComponent(alt)}` : "";
2439
+ return compactMarker(`unknown-mention:url=${encodeURIComponent(url)}${tail}`);
2440
+ }
2327
2441
  function syncedStartMarker(kind, url) {
2328
2442
  return compactMarker(`synced:start:kind=${kind}&url=${encodeURIComponent(url)}`);
2329
2443
  }
@@ -2342,7 +2456,13 @@ function blockColorMarker(color) {
2342
2456
  return compactMarker(`block-color:${color}`);
2343
2457
  }
2344
2458
  var PROPERTIES_TAG = `# ${MARKER_BRAND}:properties`;
2459
+ var MARKER_PAYLOAD_CHAR = "(?:[^\\n%]|%(?!%))";
2460
+ var MEDIA_PLACEHOLDER_HEAD = `>\\s*\u{1F4CE}\\s*${MARKER_PAYLOAD_CHAR}*(?:\\n>)?\\s*%%\\s*${MARKER_BRAND}:local-(?:image|file):`;
2345
2461
  var MARKER_BRAND_RE = MARKER_BRAND;
2462
+ var MARKER_TOKEN_RE = new RegExp(
2463
+ `%%\\s*(?:${MARKER_BRAND_RE}:${MARKER_PAYLOAD_CHAR}*|/[A-Za-z][\\w-]*)\\s*%%`,
2464
+ "g"
2465
+ );
2346
2466
 
2347
2467
  // src/converter/pipeline.ts
2348
2468
  var INLINE_DB_PROBE = new RegExp(`%% ${MARKER_BRAND_RE}:inline-db`);
@@ -2444,10 +2564,12 @@ function mapOutsideCodeFences(content, fn) {
2444
2564
  const lines = content.split("\n");
2445
2565
  const out = [];
2446
2566
  let buffer = [];
2567
+ let bufferStart = 0;
2568
+ let pos = 0;
2447
2569
  let fence = null;
2448
2570
  const flush = () => {
2449
2571
  if (buffer.length > 0) {
2450
- out.push(fn(buffer.join("\n")));
2572
+ out.push(fn(buffer.join("\n"), bufferStart));
2451
2573
  buffer = [];
2452
2574
  }
2453
2575
  };
@@ -2457,23 +2579,78 @@ function mapOutsideCodeFences(content, fn) {
2457
2579
  flush();
2458
2580
  fence = fenceMatch[1][0].repeat(3);
2459
2581
  out.push(line);
2460
- continue;
2461
- }
2462
- if (fence !== null) {
2582
+ } else if (fence !== null) {
2463
2583
  out.push(line);
2464
2584
  if (fenceMatch && fenceMatch[1].startsWith(fence)) {
2465
2585
  fence = null;
2466
2586
  }
2467
- continue;
2587
+ } else {
2588
+ if (buffer.length === 0) bufferStart = pos;
2589
+ buffer.push(line);
2468
2590
  }
2469
- buffer.push(line);
2591
+ pos += line.length + 1;
2470
2592
  }
2471
2593
  flush();
2472
2594
  return out.join("\n");
2473
2595
  }
2596
+ function mapOutsideInlineCode(content, fn) {
2597
+ let out = "";
2598
+ let plainStart = 0;
2599
+ let i = 0;
2600
+ while (i < content.length) {
2601
+ if (content[i] !== "`") {
2602
+ i += 1;
2603
+ continue;
2604
+ }
2605
+ let run = 1;
2606
+ while (content[i + run] === "`") run += 1;
2607
+ const close = content.indexOf("`".repeat(run), i + run);
2608
+ if (close === -1) {
2609
+ i += run;
2610
+ continue;
2611
+ }
2612
+ out += fn(content.slice(plainStart, i), plainStart);
2613
+ out += content.slice(i, close + run);
2614
+ i = close + run;
2615
+ plainStart = i;
2616
+ }
2617
+ return out + fn(content.slice(plainStart), plainStart);
2618
+ }
2619
+ function mapOutsideCode(content, fn) {
2620
+ return mapOutsideCodeFences(
2621
+ content,
2622
+ (segment, base) => mapOutsideInlineCode(segment, (text, inner) => fn(text, base + inner))
2623
+ );
2624
+ }
2625
+ function mapOutsideMarkers(content, fn) {
2626
+ const re = new RegExp(MARKER_TOKEN_RE.source, "g");
2627
+ let out = "";
2628
+ let last = 0;
2629
+ let match;
2630
+ while ((match = re.exec(content)) !== null) {
2631
+ out += fn(content.slice(last, match.index), last);
2632
+ out += match[0];
2633
+ last = match.index + match[0].length;
2634
+ }
2635
+ return out + fn(content.slice(last), last);
2636
+ }
2637
+
2638
+ // src/converter/marker-url.ts
2639
+ function encodeMarkerTarget(target) {
2640
+ return encodeURIComponent(target).replace(/\(/g, "%28").replace(/\)/g, "%29");
2641
+ }
2642
+ function decodeMarkerTarget(encoded) {
2643
+ try {
2644
+ return decodeURIComponent(encoded);
2645
+ } catch {
2646
+ return encoded;
2647
+ }
2648
+ }
2649
+ var MARKER_URL_CAPTURE = "((?:[^()\\s]|\\([^()]*\\))+)";
2650
+ var MARKER_LABEL_CAPTURE = "([^[\\]]+)";
2474
2651
 
2475
2652
  // src/converter/pre-processors/wikilink.ts
2476
- var WIKILINK_REGEX2 = /(?<!!)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
2653
+ var WIKILINK_REGEX2 = /(?<!!)\[\[([^[\]|]+)(?:\|([^[\]]+))?\]\]/g;
2477
2654
  var WikilinkResolver = class {
2478
2655
  constructor(resolve) {
2479
2656
  this.resolve = resolve;
@@ -2486,26 +2663,31 @@ var WikilinkResolver = class {
2486
2663
  return { content: input.content, metadata: input.metadata };
2487
2664
  }
2488
2665
  const preserveMarkers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
2489
- const resolved = input.content.replace(
2490
- WIKILINK_REGEX2,
2491
- (_match, target, display, offset) => {
2492
- const label = display ?? target;
2493
- if (this.resolve) {
2494
- const entry = this.resolve(target);
2495
- if (entry) {
2496
- return `<mention-page id="${entry.notionPageId}">${label}</mention-page>`;
2666
+ const resolved = mapOutsideCode(
2667
+ input.content,
2668
+ (segment, base) => segment.replace(
2669
+ WIKILINK_REGEX2,
2670
+ (_match, target, display, offset) => {
2671
+ const label = display ?? target;
2672
+ if (this.resolve) {
2673
+ const entry = this.resolve(target);
2674
+ if (entry) {
2675
+ if (display !== void 0 && display !== target) {
2676
+ return `[${label}](https://www.notion.so/${entry.notionPageId.replace(/-/g, "")})`;
2677
+ }
2678
+ return `<mention-page id="${entry.notionPageId}">${label}</mention-page>`;
2679
+ }
2497
2680
  }
2681
+ const params = display ? { text: target, display } : { text: target };
2682
+ params.__anchor = computeAnchor(input.content, base + offset);
2683
+ preserveMarkers.push({
2684
+ type: "wikilink",
2685
+ params,
2686
+ startIndex: base + offset
2687
+ });
2688
+ return `[${label}](${WIKILINK_PROTOCOL}${encodeMarkerTarget(target)})`;
2498
2689
  }
2499
- const params = display ? { text: target, display } : { text: target };
2500
- params.__anchor = computeAnchor(input.content, offset);
2501
- preserveMarkers.push({
2502
- type: "wikilink",
2503
- params,
2504
- startIndex: offset
2505
- });
2506
- const encodedTarget = encodeURIComponent(target);
2507
- return `[${label}](${WIKILINK_PROTOCOL}${encodedTarget})`;
2508
- }
2690
+ )
2509
2691
  );
2510
2692
  return {
2511
2693
  content: resolved,
@@ -2587,7 +2769,8 @@ $$`;
2587
2769
  };
2588
2770
 
2589
2771
  // src/converter/pre-processors/embed.ts
2590
- var OBSIDIAN_EMBED_REGEX = /!\[\[([^\]]+)\]\]/g;
2772
+ var OBSIDIAN_EMBED_REGEX = /((?:^[ \t]*(?:>[ \t]*)+)?[ \t]*)!\[\[([^[\]]+)\]\]([ \t]*)/gm;
2773
+ var QUOTE_LEAD = /^[ \t]*>/;
2591
2774
  var MARKDOWN_IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
2592
2775
  var VIDEO_HOSTS = ["youtube.com", "youtu.be", "vimeo.com"];
2593
2776
  var EmbedResolver = class {
@@ -2596,24 +2779,23 @@ var EmbedResolver = class {
2596
2779
  process(input) {
2597
2780
  const images = input.metadata.images ? [...input.metadata.images] : [];
2598
2781
  const isPush = input.context.direction === "push";
2599
- let content = input.content.replace(OBSIDIAN_EMBED_REGEX, (_match, target) => {
2600
- if (isImageFile(target)) {
2601
- images.push({ url: target, localPath: target, isExternal: false });
2602
- if (isPush && !isExternalUrl(target)) {
2603
- const fileName = target.split("/").pop() ?? target;
2604
- return `> \u{1F4CE} ${fileName}
2605
- > ${spacedMarker(`local-image:${target}`)}`;
2782
+ let content = input.content.replace(
2783
+ OBSIDIAN_EMBED_REGEX,
2784
+ (match, lead, target, trail, offset, whole) => {
2785
+ const span = { offset, length: match.length, whole, lead};
2786
+ if (isImageFile(target)) {
2787
+ images.push({ url: target, localPath: stripAlias(target), isExternal: false });
2788
+ if (isPush && !isExternalUrl(target)) {
2789
+ return isolate(placeholder("local-image", target), span);
2790
+ }
2791
+ return `${lead}![${target}](${encodeURI(target)})${trail}`;
2606
2792
  }
2607
- return `![${target}](${encodeURI(target)})`;
2608
- }
2609
- if (isPush && !isExternalUrl(target) && isAttachmentFile(target)) {
2610
- const fileName = target.split("/").pop() ?? target;
2611
- return `> \u{1F4CE} ${fileName}
2612
- > ${spacedMarker(`local-file:${target}`)}`;
2793
+ if (isPush && !isExternalUrl(target) && isAttachmentFile(target)) {
2794
+ return isolate(placeholder("local-file", target), span);
2795
+ }
2796
+ return match;
2613
2797
  }
2614
- const encoded = encodeURIComponent(target);
2615
- return `[${target}](${EMBED_PROTOCOL}${encoded})`;
2616
- });
2798
+ );
2617
2799
  content = content.replace(MARKDOWN_IMAGE_REGEX, (_match, alt, url) => {
2618
2800
  if (isVideoUrl(url)) {
2619
2801
  return `${spacedMarker(`embed:type=video&url=${encodeURIComponent(url)}`)}
@@ -2630,8 +2812,26 @@ var EmbedResolver = class {
2630
2812
  };
2631
2813
  }
2632
2814
  };
2815
+ function stripAlias(target) {
2816
+ return target.split("|")[0];
2817
+ }
2818
+ function placeholder(kind, target) {
2819
+ const fileName = stripAlias(target).split("/").pop() ?? target;
2820
+ return `> \u{1F4CE} ${fileName} ${spacedMarker(`${kind}:${encodeMarkerTarget(target)}`)}`;
2821
+ }
2822
+ function isolate(text, span) {
2823
+ const { offset, length, whole } = span;
2824
+ const beforeOnLine = whole.slice(whole.lastIndexOf("\n", offset - 1) + 1, offset);
2825
+ const afterIdx = offset + length;
2826
+ const nl = whole.indexOf("\n", afterIdx);
2827
+ const afterOnLine = whole.slice(afterIdx, nl === -1 ? whole.length : nl);
2828
+ const tail = afterOnLine.trim() === "" ? "" : "\n\n";
2829
+ if (QUOTE_LEAD.test(span.lead)) return `${offset === 0 ? "" : "\n"}${text}${tail}`;
2830
+ const head = beforeOnLine.trim() === "" ? span.lead : "\n\n";
2831
+ return `${head}${text}${tail}`;
2832
+ }
2633
2833
  function isImageFile(path) {
2634
- return /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(path);
2834
+ return /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(stripAlias(path));
2635
2835
  }
2636
2836
  function isAttachmentFile(target) {
2637
2837
  const path = target.split("|")[0];
@@ -2800,20 +3000,20 @@ var CommentStripper = class {
2800
3000
  }
2801
3001
  const markers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
2802
3002
  const content = mapOutsideCodeFences(input.content, (segment) => {
2803
- const afterObsidian = segment.replace(
2804
- COMMENT_REGEX,
2805
- (match, body, offset) => {
3003
+ const afterObsidian = mapOutsideMarkers(
3004
+ segment,
3005
+ (plain, base) => plain.replace(COMMENT_REGEX, (match, body, offset) => {
2806
3006
  const trimmed = body.trimStart();
2807
3007
  if (trimmed.startsWith(`${MARKER_BRAND}:`) || trimmed.startsWith("/")) {
2808
3008
  return match;
2809
3009
  }
2810
3010
  markers.push({
2811
3011
  type: "comment",
2812
- params: { text: body, __anchor: computeAnchor(segment, offset) },
2813
- startIndex: offset
3012
+ params: { text: body, __anchor: computeAnchor(segment, base + offset) },
3013
+ startIndex: base + offset
2814
3014
  });
2815
3015
  return "";
2816
- }
3016
+ })
2817
3017
  );
2818
3018
  return afterObsidian.replace(HTML_COMMENT_REGEX, (_match, body, offset) => {
2819
3019
  markers.push({
@@ -2917,19 +3117,34 @@ var TableAlignmentGuard = class {
2917
3117
  }
2918
3118
  };
2919
3119
 
3120
+ // src/utils/wikilink-title.ts
3121
+ function wikilinkTitleFromPath(obsidianPath) {
3122
+ return obsidianPath.replace(/\.md$/i, "").split("/").pop() ?? "";
3123
+ }
3124
+ function formatWikilink(target, label) {
3125
+ return label === void 0 || label === target ? `[[${target}]]` : `[[${target}|${label}]]`;
3126
+ }
3127
+
2920
3128
  // src/converter/link-restore.ts
2921
- var NOTION_LINK_REGEX = /\[([^\]]+)\]\((?:https:\/\/(?:www\.)?notion\.so\/|notion:\/\/)([a-f0-9-]+)\)/g;
2922
- var WIKILINK_PRESERVE_REGEX = new RegExp(`\\[([^\\]]+)\\]\\(${WIKILINK_PROTOCOL}([^)]+)\\)`, "g");
2923
- var EMBED_PRESERVE_REGEX = new RegExp(`\\[([^\\]]+)\\]\\(${EMBED_PROTOCOL}([^)]+)\\)`, "g");
3129
+ var NOTION_LINK_REGEX = new RegExp(
3130
+ `\\[${MARKER_LABEL_CAPTURE}\\]\\((?:https:\\/\\/(?:www\\.)?notion\\.so\\/|notion:\\/\\/)([a-f0-9-]+)\\)`,
3131
+ "g"
3132
+ );
3133
+ var WIKILINK_PRESERVE_REGEX = new RegExp(
3134
+ `\\[${MARKER_LABEL_CAPTURE}\\]\\(${WIKILINK_PROTOCOL}${MARKER_URL_CAPTURE}\\)`,
3135
+ "g"
3136
+ );
3137
+ var EMBED_PRESERVE_REGEX = new RegExp(
3138
+ `\\[${MARKER_LABEL_CAPTURE}\\]\\(${EMBED_PROTOCOL}${MARKER_URL_CAPTURE}\\)`,
3139
+ "g"
3140
+ );
2924
3141
  function restoreLinkMarkers(text) {
2925
3142
  let out = text.replace(NOTION_LINK_REGEX, (_match, display) => `[[${display}]]`);
2926
3143
  out = out.replace(WIKILINK_PRESERVE_REGEX, (_match, display, encodedTarget) => {
2927
- const target = decodeURIComponent(encodedTarget);
2928
- return target === display ? `[[${target}]]` : `[[${target}|${display}]]`;
3144
+ return formatWikilink(decodeMarkerTarget(encodedTarget), display);
2929
3145
  });
2930
3146
  out = out.replace(EMBED_PRESERVE_REGEX, (_match, _display, encodedTarget) => {
2931
- const target = decodeURIComponent(encodedTarget);
2932
- return `![[${target}]]`;
3147
+ return `![[${decodeMarkerTarget(encodedTarget)}]]`;
2933
3148
  });
2934
3149
  return out;
2935
3150
  }
@@ -3096,7 +3311,8 @@ var PreserveMarkerInjector = class {
3096
3311
  }
3097
3312
  case "local-image":
3098
3313
  case "local-file": {
3099
- const target = params.__raw ?? params.path;
3314
+ const raw = params.__raw ?? params.path;
3315
+ const target = raw !== void 0 ? decodeMarkerTarget(raw) : void 0;
3100
3316
  return target ? content.includes(`![[${target}]]`) : false;
3101
3317
  }
3102
3318
  case "table-align":
@@ -3177,8 +3393,8 @@ var CalloutRestorer = class {
3177
3393
  };
3178
3394
  }
3179
3395
  };
3180
- function escapeRegex(str) {
3181
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3396
+ function escapeRegex(str2) {
3397
+ return str2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3182
3398
  }
3183
3399
 
3184
3400
  // src/converter/post-processors/color-annotator.ts
@@ -3265,7 +3481,7 @@ function normalizeDate(dateStr) {
3265
3481
 
3266
3482
  // src/converter/post-processors/local-image-restorer.ts
3267
3483
  var LOCAL_IMAGE_MARKER_REGEX = new RegExp(
3268
- `>\\s*\u{1F4CE}\\s*[^\\n]*\\n>\\s*%%\\s*${MARKER_BRAND_RE}:local-(?:image|file):([^\\s]+)\\s*%%`,
3484
+ `${MEDIA_PLACEHOLDER_HEAD}(${MARKER_PAYLOAD_CHAR}+?)\\s*%%`,
3269
3485
  "g"
3270
3486
  );
3271
3487
  var LocalImageRestorer = class {
@@ -3276,7 +3492,7 @@ var LocalImageRestorer = class {
3276
3492
  return { content: input.content, metadata: input.metadata };
3277
3493
  }
3278
3494
  const content = input.content.replace(LOCAL_IMAGE_MARKER_REGEX, (_match, target) => {
3279
- return `![[${target}]]`;
3495
+ return `![[${decodeMarkerTarget(target)}]]`;
3280
3496
  });
3281
3497
  return { content, metadata: input.metadata };
3282
3498
  }
@@ -3377,6 +3593,229 @@ function stripQuotes(s) {
3377
3593
  return s;
3378
3594
  }
3379
3595
 
3596
+ // src/converter/container-indent.ts
3597
+ var CONTAINER_PREFIX_SOURCE = "[\\t ]*(?:>[\\t ]*)*";
3598
+ var CONTAINER_PREFIX_RE = new RegExp(`^${CONTAINER_PREFIX_SOURCE}`);
3599
+ function nfmOpenTagSource(name, attrs = "ignore") {
3600
+ return `<${name}${attrs === "capture" ? "(\\s[^>]*|)" : "(?:\\s[^>]*)?"}>`;
3601
+ }
3602
+ function splitContainerPrefix(line) {
3603
+ const prefix = CONTAINER_PREFIX_RE.exec(line)?.[0] ?? "";
3604
+ return { prefix, body: line.slice(prefix.length) };
3605
+ }
3606
+ function dedentContainerBody(text) {
3607
+ const lines = text.split("\n");
3608
+ while (lines.length && lines[0].trim() === "") lines.shift();
3609
+ while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
3610
+ const blocks = scanContainerBlocks(lines);
3611
+ const kinds = kindsOf(lines, blocks);
3612
+ const widened = widenAmbiguousFences(lines, blocks);
3613
+ let min = Infinity;
3614
+ lines.forEach((l, i) => {
3615
+ if (kinds[i] !== "prose" || l.trim() === "") return;
3616
+ min = Math.min(min, /^[\t ]*/.exec(l)[0].length);
3617
+ });
3618
+ if (!Number.isFinite(min)) min = 0;
3619
+ return lines.map((l, i) => {
3620
+ if (l.trim() === "") return "";
3621
+ if (kinds[i] === "code") return l;
3622
+ if (kinds[i] === "fence") {
3623
+ const flat = l.replace(/^[\t ]+/, "");
3624
+ const bar = widened.get(i);
3625
+ return bar === void 0 ? flat : flat.replace(/`{3,}|~{3,}/, bar);
3626
+ }
3627
+ return l.slice(min);
3628
+ }).join("\n");
3629
+ }
3630
+ function indentContainerBody(text, indent = " ") {
3631
+ const lines = text.split("\n");
3632
+ const kinds = classifyContainerLines(lines);
3633
+ return lines.map((line, i) => line === "" || kinds[i] === "code" ? line : indent + line).join("\n");
3634
+ }
3635
+ function stripContainerIndent(text, indent) {
3636
+ if (indent === "") return text;
3637
+ return text.split("\n").map((line) => line.startsWith(indent) ? line.slice(indent.length) : line).join("\n");
3638
+ }
3639
+ var TABLE_OPEN_LINE_RE = new RegExp(`^[\\t ]*${nfmOpenTagSource("table")}`);
3640
+ var FENCE_LINE_RE = /^([\t ]*)(`{3,}|~{3,})(.*)$/;
3641
+ function matchFence(line) {
3642
+ const m = FENCE_LINE_RE.exec(line);
3643
+ return m ? { indent: m[1], char: m[2][0], len: m[2].length, info: m[3] } : null;
3644
+ }
3645
+ function findFenceClose(lines, start, open) {
3646
+ let loose = null;
3647
+ for (let j = start + 1; j < lines.length; j++) {
3648
+ const f = matchFence(lines[j]);
3649
+ if (!f || f.char !== open.char || f.len < open.len || f.info.trim() !== "") continue;
3650
+ if (f.indent === open.indent) return j;
3651
+ loose ??= j;
3652
+ }
3653
+ return loose;
3654
+ }
3655
+ function scanContainerBlocks(lines) {
3656
+ const blocks = [];
3657
+ for (let i = 0; i < lines.length; i++) {
3658
+ const fence = matchFence(lines[i]);
3659
+ if (fence) {
3660
+ const close2 = findFenceClose(lines, i, fence);
3661
+ blocks.push({ type: "code", open: i, close: close2, fence });
3662
+ i = close2 ?? lines.length;
3663
+ continue;
3664
+ }
3665
+ if (!TABLE_OPEN_LINE_RE.test(lines[i])) continue;
3666
+ if (/<\/table>/.test(lines[i])) {
3667
+ blocks.push({ type: "table", open: i, close: i });
3668
+ continue;
3669
+ }
3670
+ let close = null;
3671
+ for (let j = i + 1; j < lines.length; j++) {
3672
+ if (!/<\/table>/.test(lines[j])) continue;
3673
+ close = j;
3674
+ break;
3675
+ }
3676
+ blocks.push({ type: "table", open: i, close });
3677
+ i = close ?? lines.length;
3678
+ }
3679
+ return blocks;
3680
+ }
3681
+ function kindsOf(lines, blocks) {
3682
+ const kinds = lines.map(() => "prose");
3683
+ for (const b of blocks) {
3684
+ kinds[b.open] = "fence";
3685
+ const end = b.close ?? lines.length;
3686
+ for (let j = b.open + 1; j < end; j++) kinds[j] = "code";
3687
+ if (b.close !== null && b.close !== b.open) kinds[b.close] = "fence";
3688
+ }
3689
+ return kinds;
3690
+ }
3691
+ function widenAmbiguousFences(lines, blocks) {
3692
+ const widened = /* @__PURE__ */ new Map();
3693
+ for (const b of blocks) {
3694
+ if (b.type !== "code" || !b.fence) continue;
3695
+ const end = b.close ?? lines.length;
3696
+ let longest = 0;
3697
+ for (let j = b.open + 1; j < end; j++) {
3698
+ const f = matchFence(lines[j]);
3699
+ if (f?.char === b.fence.char) longest = Math.max(longest, f.len);
3700
+ }
3701
+ if (longest < b.fence.len) continue;
3702
+ const bar = b.fence.char.repeat(longest + 1);
3703
+ widened.set(b.open, bar);
3704
+ if (b.close !== null) widened.set(b.close, bar);
3705
+ }
3706
+ return widened;
3707
+ }
3708
+ function codeInteriorRanges(text) {
3709
+ const lines = text.split("\n");
3710
+ const kinds = classifyContainerLines(lines.map((l) => splitContainerPrefix(l).body));
3711
+ const ranges = [];
3712
+ let offset = 0;
3713
+ let start = null;
3714
+ lines.forEach((line, i) => {
3715
+ if (kinds[i] === "code" && start === null) start = offset;
3716
+ else if (kinds[i] !== "code" && start !== null) {
3717
+ ranges.push([start, offset]);
3718
+ start = null;
3719
+ }
3720
+ offset += line.length + 1;
3721
+ });
3722
+ if (start !== null) ranges.push([start, offset]);
3723
+ return ranges;
3724
+ }
3725
+ function isInsideRanges(ranges, offset) {
3726
+ return ranges.some(([start, end]) => offset >= start && offset < end);
3727
+ }
3728
+ function classifyContainerLines(lines) {
3729
+ return kindsOf(lines, scanContainerBlocks(lines));
3730
+ }
3731
+ function containerBlockEnds(lines) {
3732
+ const ends = lines.map((_, i) => i);
3733
+ for (const b of scanContainerBlocks(lines)) {
3734
+ const end = b.close ?? lines.length - 1;
3735
+ for (let j = b.open; j <= end; j++) ends[j] = end;
3736
+ }
3737
+ return ends;
3738
+ }
3739
+
3740
+ // src/converter/callout-indent.ts
3741
+ var CLAMPED_INDENT = " ";
3742
+ var CODE_INDENT_WIDTH = 4;
3743
+ var INDENTED_QUOTE_RE = /^([\t ]+)>/;
3744
+ var CALLOUT_HEAD_RE = /^> \[!\w+\][-+]?/;
3745
+ var CALLOUT_INDENT_MARKER_RE = new RegExp(`\\s*%%${MARKER_BRAND_RE}:callout-indent:(\\d+)%%`);
3746
+ function visualWidth(ws) {
3747
+ let width = 0;
3748
+ for (const ch of ws)
3749
+ width = ch === " " ? width + CODE_INDENT_WIDTH - width % CODE_INDENT_WIDTH : width + 1;
3750
+ return width;
3751
+ }
3752
+ function clampCalloutIndent(content) {
3753
+ const lines = content.split("\n");
3754
+ const kinds = classifyContainerLines(lines);
3755
+ const out = [];
3756
+ for (let i = 0; i < lines.length; i++) {
3757
+ const indent = kinds[i] === "prose" ? INDENTED_QUOTE_RE.exec(lines[i])?.[1] ?? null : null;
3758
+ if (indent === null || visualWidth(indent) < CODE_INDENT_WIDTH) {
3759
+ out.push(lines[i]);
3760
+ continue;
3761
+ }
3762
+ let end = i;
3763
+ while (end + 1 < lines.length && kinds[end + 1] === "prose" && lines[end + 1].startsWith(`${indent}>`)) {
3764
+ end++;
3765
+ }
3766
+ const depth = Math.floor(visualWidth(indent) / CODE_INDENT_WIDTH);
3767
+ const head = lines[i].slice(indent.length);
3768
+ const marked = depth > 1 && CALLOUT_HEAD_RE.test(head) ? `${head} ${calloutIndentMarker(depth)}` : head;
3769
+ out.push(CLAMPED_INDENT + marked);
3770
+ for (let j = i + 1; j <= end; j++) out.push(CLAMPED_INDENT + lines[j].slice(indent.length));
3771
+ i = end;
3772
+ }
3773
+ return out.join("\n");
3774
+ }
3775
+ var BODY_INDENT_RE = /^\t+(?![ \t]*(?:[-*+]|\d+[.)])\s)/;
3776
+ function clampBodyIndent(line) {
3777
+ return line.replace(BODY_INDENT_RE, CLAMPED_INDENT);
3778
+ }
3779
+ var QUOTE_RUN_RE = /^(?:>[\t ]?)*/;
3780
+ function inheritedQuoteRun(lines, i) {
3781
+ const runOf = (step) => {
3782
+ for (let j = i + step; j >= 0 && j < lines.length; j += step) {
3783
+ if (lines[j].trim() === "") continue;
3784
+ return (QUOTE_RUN_RE.exec(lines[j])?.[0] ?? "").trimEnd();
3785
+ }
3786
+ return null;
3787
+ };
3788
+ const runs = [runOf(-1), runOf(1)].filter((r) => r !== null);
3789
+ return runs.length === 0 ? "" : runs.reduce((a, b) => a.length <= b.length ? a : b);
3790
+ }
3791
+ function quoteCalloutBody(body) {
3792
+ const lines = body.split("\n");
3793
+ const kinds = classifyContainerLines(lines);
3794
+ return lines.map((line, i) => {
3795
+ if (!line.trim()) {
3796
+ const run = inheritedQuoteRun(lines, i);
3797
+ return run ? `> ${run}` : ">";
3798
+ }
3799
+ return `> ${kinds[i] === "prose" ? clampBodyIndent(line) : line}`;
3800
+ }).join("\n");
3801
+ }
3802
+ var RESTORE_BODY_INDENT_RE = new RegExp(`^${CLAMPED_INDENT}`);
3803
+ function restoreBodyIndent(body) {
3804
+ const lines = body.split("\n");
3805
+ const kinds = classifyContainerLines(lines);
3806
+ return lines.map((line, i) => kinds[i] === "prose" ? line.replace(RESTORE_BODY_INDENT_RE, " ") : line).join("\n");
3807
+ }
3808
+ function readCalloutIndentDepth(indent, title) {
3809
+ const match = CALLOUT_INDENT_MARKER_RE.exec(title);
3810
+ if (match) {
3811
+ return { depth: Number(match[1]), title: title.replace(CALLOUT_INDENT_MARKER_RE, "") };
3812
+ }
3813
+ return { depth: indent === "" ? 0 : 1, title };
3814
+ }
3815
+ function applyCalloutIndent(block, depth) {
3816
+ return depth > 0 ? indentContainerBody(block, " ".repeat(depth)) : block;
3817
+ }
3818
+
3380
3819
  // src/converter/post-processors/block-spacer.ts
3381
3820
  var BlockSpacer = class {
3382
3821
  name = "BlockSpacer";
@@ -3452,7 +3891,7 @@ function respace(content, sourceCompact) {
3452
3891
  fence = "math";
3453
3892
  continue;
3454
3893
  }
3455
- const line = LIST_ITEM_RE.test(raw) || INDENTED_CONTINUATION_RE.test(raw) ? raw.replace(/^\t+/, (t) => " ".repeat(t.length)) : raw;
3894
+ const line = QUOTE_RE.test(raw) ? raw.replace(/^\t+/, CLAMPED_INDENT) : LIST_ITEM_RE.test(raw) || INDENTED_CONTINUATION_RE.test(raw) ? raw.replace(/^\t+/, (t) => " ".repeat(t.length)) : raw;
3456
3895
  if (BRAND_MARKER_LINE_RE.test(line) && blocks.length > 0) {
3457
3896
  appendToLast(line);
3458
3897
  continue;
@@ -3463,6 +3902,9 @@ function respace(content, sourceCompact) {
3463
3902
  continue;
3464
3903
  }
3465
3904
  if (last()?.kind === "list" && INDENTED_CONTINUATION_RE.test(line)) {
3905
+ if (QUOTE_RE.test(line) && (atBoundary || CALLOUT_START_RE.test(line))) {
3906
+ appendToLast("");
3907
+ }
3466
3908
  appendToLast(line);
3467
3909
  continue;
3468
3910
  }
@@ -3600,6 +4042,7 @@ function formatMention(mention, plainText) {
3600
4042
  }
3601
4043
 
3602
4044
  // src/converter/block-converter.ts
4045
+ var TOC_COLOR_MARKER_RE = new RegExp(`^%%${MARKER_BRAND}:toc:color=([a-z_]+)%%$`);
3603
4046
  var TRULY_UNSUPPORTED_BLOCK_TYPES = ["unsupported", "template"];
3604
4047
  var DIVIDER_PLACEHOLDER = "\u200B%%IM-NOBSIDIAN_DIVIDER%%\u200B";
3605
4048
  var VIDEO_URL_PATTERNS = [
@@ -3621,8 +4064,8 @@ var EMBED_URL_PATTERNS = [
3621
4064
  /^https?:\/\/(www\.)?spotify\.com\//,
3622
4065
  /^https?:\/\/(www\.)?soundcloud\.com\//
3623
4066
  ];
3624
- function escapeRegex2(str) {
3625
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4067
+ function escapeRegex2(str2) {
4068
+ return str2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3626
4069
  }
3627
4070
  var BlockConverter = class {
3628
4071
  n2m = null;
@@ -3972,9 +4415,9 @@ ${COLUMN_LIST_END}`;
3972
4415
  const content = result.slice(match.index + match[0].length + 1, endMatch.index).trimEnd();
3973
4416
  const id = this.toggleCounter++;
3974
4417
  this.toggleContents.set(id, content);
3975
- const placeholder = `%%IM-NOBSIDIAN_TOGGLE_${id}%%`;
3976
- result = result.slice(0, match.index) + placeholder + result.slice(endMatch.index + endMatch[0].length);
3977
- startRe.lastIndex = match.index + placeholder.length;
4418
+ const placeholder2 = `%%IM-NOBSIDIAN_TOGGLE_${id}%%`;
4419
+ result = result.slice(0, match.index) + placeholder2 + result.slice(endMatch.index + endMatch[0].length);
4420
+ startRe.lastIndex = match.index + placeholder2.length;
3978
4421
  }
3979
4422
  return result;
3980
4423
  }
@@ -3988,12 +4431,12 @@ ${COLUMN_LIST_END}`;
3988
4431
  const endMatch = endRe.exec(result);
3989
4432
  if (!endMatch) break;
3990
4433
  const inner = result.slice(match.index + match[0].length + 1, endMatch.index);
3991
- const columns = inner.split(new RegExp(`^${escapeRegex2(COLUMN_SEP)}$`, "m")).map((c) => c.trim()).filter(Boolean);
4434
+ const columns = inner.split(new RegExp(`^${columnSepSource("ignore")}$`, "m")).map((c) => c.trim()).filter(Boolean);
3992
4435
  const id = this.columnCounter++;
3993
4436
  this.columnContents.set(id, columns);
3994
- const placeholder = `%%IM-NOBSIDIAN_COLLIST_${id}%%`;
3995
- result = result.slice(0, match.index) + placeholder + result.slice(endMatch.index + endMatch[0].length);
3996
- startRe.lastIndex = match.index + placeholder.length;
4437
+ const placeholder2 = `%%IM-NOBSIDIAN_COLLIST_${id}%%`;
4438
+ result = result.slice(0, match.index) + placeholder2 + result.slice(endMatch.index + endMatch[0].length);
4439
+ startRe.lastIndex = match.index + placeholder2.length;
3997
4440
  }
3998
4441
  return result;
3999
4442
  }
@@ -4087,8 +4530,9 @@ ${COLUMN_LIST_END}`;
4087
4530
  const texts = para?.rich_text ?? [];
4088
4531
  if (texts.length !== 1) return null;
4089
4532
  const text = (texts[0]?.text?.content ?? "").trim();
4090
- if (text === TOC_MARKER) {
4091
- return NotionBlockBuilder.tableOfContents();
4533
+ const toc = TOC_COLOR_MARKER_RE.exec(text);
4534
+ if (text === TOC_MARKER || toc) {
4535
+ return NotionBlockBuilder.tableOfContents(toc?.[1]);
4092
4536
  }
4093
4537
  if (text === BREADCRUMB_MARKER) {
4094
4538
  return NotionBlockBuilder.breadcrumb();
@@ -4341,14 +4785,43 @@ function collectAllDescendants(blocks) {
4341
4785
  }
4342
4786
  return result;
4343
4787
  }
4344
- var IMAGE_EXTENSIONS = {
4345
- "image/png": ".png",
4346
- "image/jpeg": ".jpg",
4347
- "image/gif": ".gif",
4348
- "image/webp": ".webp",
4349
- "image/svg+xml": ".svg",
4350
- "image/bmp": ".bmp",
4351
- "image/tiff": ".tiff"
4788
+
4789
+ // src/utils/mime.ts
4790
+ var EXTENSION_TO_BLOCK_TYPE = {
4791
+ ".png": "image",
4792
+ ".jpg": "image",
4793
+ ".jpeg": "image",
4794
+ ".gif": "image",
4795
+ ".svg": "image",
4796
+ ".webp": "image",
4797
+ ".ico": "image",
4798
+ ".bmp": "image",
4799
+ ".tiff": "image",
4800
+ ".tif": "image",
4801
+ ".avif": "image",
4802
+ ".apng": "image",
4803
+ ".heic": "image",
4804
+ ".pdf": "pdf",
4805
+ ".mp4": "video",
4806
+ ".mov": "video",
4807
+ ".webm": "video",
4808
+ ".avi": "video",
4809
+ ".mkv": "video",
4810
+ ".flv": "video",
4811
+ ".wmv": "video",
4812
+ ".m4v": "video",
4813
+ ".mpeg": "video",
4814
+ ".ogv": "video",
4815
+ ".3gp": "video",
4816
+ ".mp3": "audio",
4817
+ ".wav": "audio",
4818
+ ".ogg": "audio",
4819
+ ".m4a": "audio",
4820
+ ".flac": "audio",
4821
+ ".aac": "audio",
4822
+ ".wma": "audio",
4823
+ ".opus": "audio",
4824
+ ".weba": "audio"
4352
4825
  };
4353
4826
  var EXTENSION_TO_MIME = {
4354
4827
  ".png": "image/png",
@@ -4359,18 +4832,81 @@ var EXTENSION_TO_MIME = {
4359
4832
  ".svg": "image/svg+xml",
4360
4833
  ".bmp": "image/bmp",
4361
4834
  ".ico": "image/x-icon",
4362
- ".tiff": "image/tiff"
4835
+ ".tiff": "image/tiff",
4836
+ ".tif": "image/tiff",
4837
+ ".avif": "image/avif",
4838
+ ".heic": "image/heic",
4839
+ ".pdf": "application/pdf",
4840
+ ".mp4": "video/mp4",
4841
+ ".mov": "video/quicktime",
4842
+ ".webm": "video/webm",
4843
+ ".avi": "video/x-msvideo",
4844
+ ".mkv": "video/x-matroska",
4845
+ ".mp3": "audio/mpeg",
4846
+ ".wav": "audio/wav",
4847
+ ".ogg": "audio/ogg",
4848
+ ".m4a": "audio/mp4",
4849
+ ".flac": "audio/flac",
4850
+ ".aac": "audio/aac",
4851
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
4852
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
4853
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
4854
+ ".zip": "application/zip",
4855
+ ".gz": "application/gzip",
4856
+ ".tar": "application/x-tar",
4857
+ ".rar": "application/vnd.rar",
4858
+ ".7z": "application/x-7z-compressed",
4859
+ ".py": "text/x-python",
4860
+ ".js": "text/javascript",
4861
+ ".ts": "text/typescript",
4862
+ ".json": "application/json",
4863
+ ".csv": "text/csv",
4864
+ ".txt": "text/plain",
4865
+ ".html": "text/html",
4866
+ ".xml": "application/xml",
4867
+ ".hwp": "application/x-hwp",
4868
+ ".xls": "application/vnd.ms-excel",
4869
+ ".doc": "application/msword",
4870
+ ".ppt": "application/vnd.ms-powerpoint"
4363
4871
  };
4364
- function scanMarkdownLinks(markdown) {
4365
- const spans = [];
4366
- let i = 0;
4367
- while (i < markdown.length) {
4368
- const open = markdown.indexOf("[", i);
4369
- if (open === -1) break;
4370
- if (markdown[open - 1] === "\\") {
4371
- i = open + 1;
4372
- continue;
4373
- }
4872
+ function getBlockType(filename) {
4873
+ const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
4874
+ if (!ext) return "file";
4875
+ return EXTENSION_TO_BLOCK_TYPE[ext] ?? "file";
4876
+ }
4877
+ function getMimeType(filename) {
4878
+ const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
4879
+ if (!ext) return "application/octet-stream";
4880
+ return EXTENSION_TO_MIME[ext] ?? "application/octet-stream";
4881
+ }
4882
+
4883
+ // src/utils/download-fetch.ts
4884
+ var DEFAULT_DOWNLOAD_TIMEOUT_MS = 3e5;
4885
+ function fetchForDownload(fetchFn, url, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS) {
4886
+ if (timeoutMs <= 0) return fetchFn(url);
4887
+ return fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) });
4888
+ }
4889
+
4890
+ // src/sync/image-handler.ts
4891
+ var IMAGE_EXTENSIONS = {
4892
+ "image/png": ".png",
4893
+ "image/jpeg": ".jpg",
4894
+ "image/gif": ".gif",
4895
+ "image/webp": ".webp",
4896
+ "image/svg+xml": ".svg",
4897
+ "image/bmp": ".bmp",
4898
+ "image/tiff": ".tiff"
4899
+ };
4900
+ function scanMarkdownLinks(markdown) {
4901
+ const spans = [];
4902
+ let i = 0;
4903
+ while (i < markdown.length) {
4904
+ const open = markdown.indexOf("[", i);
4905
+ if (open === -1) break;
4906
+ if (markdown[open - 1] === "\\") {
4907
+ i = open + 1;
4908
+ continue;
4909
+ }
4374
4910
  const isEmbed = markdown[open - 1] === "!" && markdown[open - 2] !== "\\";
4375
4911
  let depth = 1;
4376
4912
  let j = open + 1;
@@ -4430,18 +4966,44 @@ function toWikilinkAlias(label) {
4430
4966
  }
4431
4967
  var INTERNAL_FILE_URL_RE = /^file:\/\/%7B.*%7D%7D$/;
4432
4968
  var FILE_LABEL_PREFIX_RE = /^(?:📎|🎬|🎞|📄|🔊)\s*/u;
4433
- var LOCAL_MARKER_RE = new RegExp(`${MARKER_BRAND_RE}:local-(?:image|file):([^\\s%]+)`, "g");
4969
+ var LOCAL_MARKER_RE = new RegExp(
4970
+ `${MARKER_BRAND_RE}:local-(?:image|file):(${MARKER_PAYLOAD_CHAR}+?)\\s*%%`,
4971
+ "g"
4972
+ );
4973
+ var PLACEHOLDER_MARKER_RE = new RegExp(
4974
+ `${MARKER_BRAND_RE}:local-(image|file):(${MARKER_PAYLOAD_CHAR}+?)\\s*%%`,
4975
+ "u"
4976
+ );
4977
+ function stripAlias2(target) {
4978
+ return target.split("|")[0];
4979
+ }
4980
+ function uploadedFileNameFromUrl(url) {
4981
+ try {
4982
+ return decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
4983
+ } catch {
4984
+ return "";
4985
+ }
4986
+ }
4987
+ var NON_DESCENDABLE_TYPES = /* @__PURE__ */ new Set(["child_page", "child_database", "synced_block", "table"]);
4434
4988
  function collectLocalMarkerBasenames(markdown) {
4435
4989
  const names = /* @__PURE__ */ new Set();
4436
4990
  const unescaped = markdown.replace(/\\/g, "");
4437
4991
  for (const m of unescaped.matchAll(LOCAL_MARKER_RE)) {
4438
- names.add(baseName(m[1]));
4992
+ names.add(baseName(decodeMarkerTarget(m[1])));
4439
4993
  }
4440
4994
  return names;
4441
4995
  }
4442
4996
  function baseName(path) {
4443
4997
  return path.split("/").pop() ?? path;
4444
4998
  }
4999
+ function folderOf(notePath) {
5000
+ return notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
5001
+ }
5002
+ function plainTextOf(block) {
5003
+ const body = block[block.type];
5004
+ if (!body?.rich_text) return "";
5005
+ return body.rich_text.map((r) => r.plain_text ?? "").join("");
5006
+ }
4445
5007
  var ImageHandler = class {
4446
5008
  constructor(vaultFs, attachmentFolder = "attachments", notionClient, customFetch, options, stateDb) {
4447
5009
  this.vaultFs = vaultFs;
@@ -4453,6 +5015,7 @@ var ImageHandler = class {
4453
5015
  this.maxRetries = options?.maxRetries ?? 3;
4454
5016
  this.retryBaseMs = options?.retryBaseMs ?? 1e3;
4455
5017
  this.maxFileSizeBytes = options?.maxFileSizeBytes ?? 100 * 1024 * 1024;
5018
+ this.downloadTimeoutMs = options?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
4456
5019
  }
4457
5020
  vaultFs;
4458
5021
  attachmentFolder;
@@ -4463,9 +5026,14 @@ var ImageHandler = class {
4463
5026
  maxRetries;
4464
5027
  retryBaseMs;
4465
5028
  maxFileSizeBytes;
5029
+ downloadTimeoutMs;
4466
5030
  get fetchFn() {
4467
5031
  return this.customFetch ?? globalThis.fetch;
4468
5032
  }
5033
+ /** 상한 로직은 첨부 경로와 공유한다 — 한쪽만 빠뜨리지 않도록(utils/download-fetch). */
5034
+ fetchForDownload(url) {
5035
+ return fetchForDownload(this.fetchFn, url, this.downloadTimeoutMs);
5036
+ }
4469
5037
  async downloadImage(url, pageTitle) {
4470
5038
  return this.persistImage(await this.fetchImage(url, pageTitle));
4471
5039
  }
@@ -4480,7 +5048,7 @@ var ImageHandler = class {
4480
5048
  `[Im-Nobsidian] \uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC7AC\uC2DC\uB3C4 (${attempt + 1}/${maxRetries}): ${pageTitle}`
4481
5049
  );
4482
5050
  }
4483
- const response = await this.fetchFn(url);
5051
+ const response = await this.fetchForDownload(url);
4484
5052
  if (!response.ok) {
4485
5053
  throw new Error(`\uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
4486
5054
  }
@@ -4499,6 +5067,31 @@ var ImageHandler = class {
4499
5067
  }
4500
5068
  throw lastError;
4501
5069
  }
5070
+ /**
5071
+ * 캡션이 push 가 적어 둔 원본 임베드 대상인지 판정한다(R1). 맞으면 그 문자열을
5072
+ * 그대로 돌려주고, 호출자는 사본을 내려받는 대신 `![[원본]]` 으로 되살린다.
5073
+ *
5074
+ * 사용자가 Notion 에서 손으로 쓴 캡션을 경로로 오인하면 깨진 위키링크가 되므로,
5075
+ * ① 파일명이 실제 업로드된 파일명과 같고 ② 볼트에 그 파일이 실재할 때만 인정한다.
5076
+ * 둘 중 하나라도 어긋나면 null 을 돌려 기존 다운로드 경로로 떨어진다.
5077
+ *
5078
+ * ②의 실재 확인은 push 와 **같은 후보 사다리**(노트 폴더 → 루트 → attachments/)를
5079
+ * 쓴다 — 위키링크는 파일명만 적는 게 관례라 볼트 루트만 보면 노트 옆 파일을 놓친다.
5080
+ */
5081
+ async resolveOriginalTarget(label, uploadedName, noteFolder) {
5082
+ if (!label || !uploadedName) return null;
5083
+ const target = label.replace(FILE_LABEL_PREFIX_RE, "").replace(/\\(.)/g, "$1").trim();
5084
+ if (!target || /^https?:\/\//.test(target) || target.includes("\n")) return null;
5085
+ const vaultPath = stripAlias2(target);
5086
+ if (baseName(vaultPath) !== uploadedName) return null;
5087
+ for (const candidate of this.vaultCandidates(vaultPath, noteFolder)) {
5088
+ try {
5089
+ if (await this.vaultFs.exists(candidate)) return target;
5090
+ } catch {
5091
+ }
5092
+ }
5093
+ return null;
5094
+ }
4502
5095
  async persistImage(fetched) {
4503
5096
  await this.vaultFs.ensureFolder(this.attachmentFolder);
4504
5097
  await this.vaultFs.writeBinary(fetched.localPath, fetched.buffer);
@@ -4509,7 +5102,8 @@ var ImageHandler = class {
4509
5102
  size: fetched.buffer.length
4510
5103
  };
4511
5104
  }
4512
- async downloadAllImages(markdown, pageTitle, pageId) {
5105
+ async downloadAllImages(markdown, pageTitle, pageId, notePath) {
5106
+ const noteFolder = folderOf(notePath);
4513
5107
  const embeds = scanMarkdownLinks(markdown).filter((s) => s.isEmbed);
4514
5108
  const notionMatches = embeds.filter(
4515
5109
  (s) => /^https?:\/\//.test(s.url) && this.isNotionImageUrl(s.url)
@@ -4561,6 +5155,15 @@ var ImageHandler = class {
4561
5155
  result = result.replace(span.full, "");
4562
5156
  continue;
4563
5157
  }
5158
+ const original = await this.resolveOriginalTarget(
5159
+ span.label,
5160
+ uploadedFileNameFromUrl(span.url),
5161
+ noteFolder
5162
+ );
5163
+ if (original) {
5164
+ result = result.replace(span.full, `![[${original}]]`);
5165
+ continue;
5166
+ }
4564
5167
  const download = await this.persistImage(fetched);
4565
5168
  downloads.push(download);
4566
5169
  const alias = toWikilinkAlias(span.label);
@@ -4582,6 +5185,15 @@ var ImageHandler = class {
4582
5185
  result = result.replace(span.full, "");
4583
5186
  continue;
4584
5187
  }
5188
+ const original = await this.resolveOriginalTarget(
5189
+ span.label,
5190
+ parsed.fileName || uploadedFileNameFromUrl(realUrl),
5191
+ noteFolder
5192
+ );
5193
+ if (original) {
5194
+ result = result.replace(span.full, `![[${original}]]`);
5195
+ continue;
5196
+ }
4585
5197
  const download = await this.persistImage(fetched);
4586
5198
  downloads.push(download);
4587
5199
  const alias = toWikilinkAlias(span.label);
@@ -4601,7 +5213,7 @@ var ImageHandler = class {
4601
5213
  }
4602
5214
  const { buffer, resolvedPath } = await this.readImageFromVault(localPath, noteFolder);
4603
5215
  const filename = localPath.split("/").pop() ?? "image.png";
4604
- const contentType = this.getContentTypeFromPath(filename);
5216
+ const contentType = getMimeType(filename);
4605
5217
  const blob = new Blob([buffer], { type: contentType });
4606
5218
  const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
4607
5219
  const fileHash = createHash("sha256").update(buffer).digest("hex");
@@ -4611,7 +5223,7 @@ var ImageHandler = class {
4611
5223
  if (!this.notionClient) return [];
4612
5224
  const localImages = images.filter((img) => !img.isExternal && img.localPath);
4613
5225
  if (localImages.length === 0) return [];
4614
- const noteFolder = notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
5226
+ const noteFolder = folderOf(notePath);
4615
5227
  const results = [];
4616
5228
  const imageBlocks = [];
4617
5229
  for (const img of localImages) {
@@ -4635,36 +5247,150 @@ var ImageHandler = class {
4635
5247
  }
4636
5248
  if (imageBlocks.length > 0) {
4637
5249
  await this.notionClient.appendChildren(pageId, imageBlocks);
4638
- if (this.stateDb) {
4639
- for (const r of results) {
4640
- try {
4641
- this.stateDb.registerFile({
4642
- localPath: r.localPath,
4643
- notionPageId: pageId,
4644
- fileUploadId: r.fileUploadId,
4645
- fileType: "image",
4646
- fileHash: r.fileHash,
4647
- fileSize: r.fileSize
4648
- });
4649
- } catch (error) {
4650
- getLogger().warn(`\uC774\uBBF8\uC9C0 \uB808\uC9C0\uC2A4\uD2B8\uB9AC \uB4F1\uB85D \uC2E4\uD328 (${r.localPath}):`, error);
5250
+ this.registerUploads(pageId, results);
5251
+ }
5252
+ return results;
5253
+ }
5254
+ /**
5255
+ * push 가 심어 둔 자리표시자 quote 를 **제자리에서** 실제 image/file 블록으로 바꾼다(R1).
5256
+ *
5257
+ * 그 전에는 임베드 하나가 Notion 에서 두 조각으로 갈라졌다 — 본문 자리에는
5258
+ * `> 📎 foo.png` + `%% im-nobsidian:local-image:… %%` 리터럴 마커가, 실제 이미지는
5259
+ * 페이지 맨 끝에 따로. 실측: 볼트 175개 파일에서 마커 978건이 Notion 에 그대로
5260
+ * 텍스트로 새고 있었고 그중 964건(98.6%)이 이 미디어 쌍이었다. 사용자가 Notion 에서
5261
+ * 보는 화면이 깨질 뿐 아니라 이미지가 본문 흐름에서 이탈한다.
5262
+ *
5263
+ * 캡션에는 원본 임베드 대상을 그대로 적는다. pull 이 경로(와 `|별칭`)를 되찾는
5264
+ * 진실원이 되고, 사용자에게도 출처가 보인다.
5265
+ *
5266
+ * 볼트에서 파일을 못 찾으면 자리표시자를 그대로 둔다 — 마커가 남아야 pull 이
5267
+ * 임베드를 복원할 수 있으므로, 반쯤 지우는 것보다 안전하다.
5268
+ */
5269
+ async materializeLocalMedia(pageId, pushedMarkdown, notePath) {
5270
+ const empty = { uploaded: [], handledTargets: /* @__PURE__ */ new Set() };
5271
+ if (!this.notionClient) return empty;
5272
+ LOCAL_MARKER_RE.lastIndex = 0;
5273
+ if (!LOCAL_MARKER_RE.test(pushedMarkdown)) return empty;
5274
+ let hits;
5275
+ try {
5276
+ hits = await this.collectPlaceholders(pageId);
5277
+ } catch (error) {
5278
+ getLogger().warn(`\uBBF8\uB514\uC5B4 \uC790\uB9AC\uD45C\uC2DC\uC790 \uC870\uD68C \uC2E4\uD328 (${pageId}):`, error);
5279
+ return empty;
5280
+ }
5281
+ if (hits.length === 0) return empty;
5282
+ const noteFolder = folderOf(notePath);
5283
+ const uploaded = [];
5284
+ const handledTargets = /* @__PURE__ */ new Set();
5285
+ for (const hit of hits) {
5286
+ const vaultTarget = stripAlias2(hit.target);
5287
+ try {
5288
+ const result = await this.uploadLocalImage(vaultTarget, noteFolder);
5289
+ const fileName = baseName(vaultTarget);
5290
+ const caption = [{ type: "text", text: { content: hit.target } }];
5291
+ const block = hit.kind === "image" ? {
5292
+ type: "image",
5293
+ image: { type: "file_upload", file_upload: { id: result.fileUploadId }, caption }
5294
+ } : {
5295
+ type: "file",
5296
+ file: {
5297
+ type: "file_upload",
5298
+ file_upload: { id: result.fileUploadId },
5299
+ name: fileName,
5300
+ caption
4651
5301
  }
5302
+ };
5303
+ await this.notionClient.appendChildren(hit.parentId, [block], { after: hit.blockId });
5304
+ await this.notionClient.deleteBlock(hit.blockId);
5305
+ uploaded.push(result);
5306
+ handledTargets.add(hit.target);
5307
+ handledTargets.add(vaultTarget);
5308
+ handledTargets.add(result.localPath);
5309
+ } catch (error) {
5310
+ if (error?.code === "ENOENT") {
5311
+ getLogger().warn(`\uBBF8\uB514\uC5B4 \uC81C\uC790\uB9AC \uC0BD\uC785 \uAC74\uB108\uB700 \u2014 \uBCFC\uD2B8\uC5D0\uC11C \uD30C\uC77C\uC744 \uCC3E\uC9C0 \uBABB\uD568: ${vaultTarget}`);
5312
+ } else {
5313
+ getLogger().warn(`\uBBF8\uB514\uC5B4 \uC81C\uC790\uB9AC \uC0BD\uC785 \uC2E4\uD328 (${vaultTarget}):`, error);
4652
5314
  }
4653
5315
  }
4654
5316
  }
4655
- return results;
5317
+ this.registerUploads(pageId, uploaded);
5318
+ return { uploaded, handledTargets };
4656
5319
  }
4657
5320
  /**
4658
- * 임베드 경로를 볼트 실제 파일로 해석한다(F23). Obsidian 위키링크 임베드는 노트 옆
4659
- * 파일을 파일명만으로 참조하는 게 관례라, 노트 폴더 → 볼트 루트 → attachments/ 순으로
4660
- * 시도한다. 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
5321
+ * 페이지 블록 트리를 훑어 자리표시자를 부모와 함께 모은다. 콜아웃·토글·목록 안에
5322
+ * 들어간 임베드도 제자리 교체하려면 부모 id 가 필요하다(append 의 after_block 은
5323
+ * 같은 부모의 자식만 기준으로 삼는다).
4661
5324
  */
4662
- async readImageFromVault(localPath, noteFolder) {
5325
+ async collectPlaceholders(rootId, depth = 0) {
5326
+ if (depth > 4) return [];
5327
+ const client = this.notionClient;
5328
+ const children = await client.fetchAllChildren(rootId);
5329
+ const hits = [];
5330
+ for (const block of children) {
5331
+ const marker = PLACEHOLDER_MARKER_RE.exec(plainTextOf(block));
5332
+ if (marker) {
5333
+ hits.push({
5334
+ parentId: rootId,
5335
+ blockId: block.id,
5336
+ kind: marker[1] === "image" ? "image" : "file",
5337
+ // 마커에 실린 경로는 퍼센트 인코딩돼 있다 — 볼트 조회·캡션 모두 원문이어야 한다.
5338
+ target: decodeMarkerTarget(marker[2])
5339
+ });
5340
+ continue;
5341
+ }
5342
+ if (block.has_children && !NON_DESCENDABLE_TYPES.has(block.type)) {
5343
+ hits.push(...await this.collectPlaceholders(block.id, depth + 1));
5344
+ }
5345
+ }
5346
+ return hits;
5347
+ }
5348
+ /**
5349
+ * 업로드분을 file_registry 에 남긴다. 노트 임베드는 여기서 페이지에 붙으므로,
5350
+ * 이후 FileHandler.pushAllFiles 가 같은 첨부를 폴더 페이지에 standalone 으로
5351
+ * 재업로드하는 중복을 공유 레지스트리로 차단한다.
5352
+ */
5353
+ registerUploads(pageId, results) {
5354
+ if (!this.stateDb || results.length === 0) return;
5355
+ for (const r of results) {
5356
+ try {
5357
+ this.stateDb.registerFile({
5358
+ localPath: r.localPath,
5359
+ notionPageId: pageId,
5360
+ fileUploadId: r.fileUploadId,
5361
+ fileType: "image",
5362
+ fileHash: r.fileHash,
5363
+ fileSize: r.fileSize
5364
+ });
5365
+ } catch (error) {
5366
+ getLogger().warn(`\uBBF8\uB514\uC5B4 \uB808\uC9C0\uC2A4\uD2B8\uB9AC \uB4F1\uB85D \uC2E4\uD328 (${r.localPath}):`, error);
5367
+ }
5368
+ }
5369
+ }
5370
+ /**
5371
+ * 임베드 경로가 가리킬 수 있는 볼트 실제 파일 후보를 우선순위대로 만든다(F23).
5372
+ * Obsidian 위키링크 임베드는 노트 옆 파일을 파일명만으로 참조하는 게 관례라
5373
+ * 노트 폴더 → 볼트 루트 → attachments/ 순.
5374
+ *
5375
+ * push(업로드)와 pull(원본 복원)이 **같은 사다리**를 봐야 왕복이 닫힌다. 예전에는
5376
+ * pull 쪽이 볼트 루트만 확인해서, `__e2e_probe__/노트.md` 안의 `![[t-img.png]]` 는
5377
+ * 올라갈 때는 노트 폴더에서 잘 찾아 올리고도 내려올 때는 원본을 못 찾아 사본을
5378
+ * 새로 받았다(실측) — 왕복할 때마다 첨부가 한 벌씩 늘어난다.
5379
+ */
5380
+ vaultCandidates(localPath, noteFolder) {
4663
5381
  const candidates = [];
4664
5382
  if (noteFolder && !localPath.includes("/")) {
4665
5383
  candidates.push(`${noteFolder}/${localPath}`);
4666
5384
  }
4667
5385
  candidates.push(localPath, `${this.attachmentFolder}/${localPath}`);
5386
+ return candidates;
5387
+ }
5388
+ /**
5389
+ * 임베드 경로를 볼트 실제 파일로 해석해 읽는다(F23).
5390
+ * 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
5391
+ */
5392
+ async readImageFromVault(localPath, noteFolder) {
5393
+ const candidates = this.vaultCandidates(localPath, noteFolder);
4668
5394
  let lastError;
4669
5395
  for (const candidate of candidates) {
4670
5396
  try {
@@ -4675,11 +5401,8 @@ var ImageHandler = class {
4675
5401
  }
4676
5402
  throw lastError;
4677
5403
  }
4678
- getContentTypeFromPath(filename) {
4679
- const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
4680
- return (ext && EXTENSION_TO_MIME[ext]) ?? "application/octet-stream";
4681
- }
4682
- async downloadAllFiles(markdown, pageTitle) {
5404
+ async downloadAllFiles(markdown, pageTitle, notePath) {
5405
+ const noteFolder = folderOf(notePath);
4683
5406
  const fileLinks = scanMarkdownLinks(markdown).filter(
4684
5407
  (s) => !s.isEmbed && FILE_LABEL_PREFIX_RE.test(s.label)
4685
5408
  );
@@ -4702,6 +5425,15 @@ var ImageHandler = class {
4702
5425
  for (const span of notionHttpMatches) {
4703
5426
  await sema.acquire();
4704
5427
  try {
5428
+ const original = await this.resolveOriginalTarget(
5429
+ span.label,
5430
+ uploadedFileNameFromUrl(span.url),
5431
+ noteFolder
5432
+ );
5433
+ if (original) {
5434
+ result = result.replace(span.full, `![[${original}]]`);
5435
+ continue;
5436
+ }
4705
5437
  const caption = toWikilinkAlias(span.label.replace(FILE_LABEL_PREFIX_RE, "")) || "file";
4706
5438
  const download = await this.downloadFile(span.url, pageTitle, caption);
4707
5439
  if (!download.localPath) continue;
@@ -4721,6 +5453,11 @@ var ImageHandler = class {
4721
5453
  if (!parsed || !this.notionClient) continue;
4722
5454
  const blockId = parsed.blockId;
4723
5455
  const fileName = parsed.fileName;
5456
+ const original = await this.resolveOriginalTarget(span.label, fileName, noteFolder);
5457
+ if (original) {
5458
+ result = result.replace(span.full, `![[${original}]]`);
5459
+ continue;
5460
+ }
4724
5461
  const realUrl = await this.notionClient.getFileBlockUrl(blockId);
4725
5462
  if (!realUrl) continue;
4726
5463
  const caption = fileName || toWikilinkAlias(span.label.replace(FILE_LABEL_PREFIX_RE, "")) || "file";
@@ -4739,6 +5476,15 @@ var ImageHandler = class {
4739
5476
  await sema.acquire();
4740
5477
  try {
4741
5478
  const url = match[1];
5479
+ const original = await this.resolveOriginalTarget(
5480
+ match[2] ?? "",
5481
+ uploadedFileNameFromUrl(url),
5482
+ noteFolder
5483
+ );
5484
+ if (original) {
5485
+ result = result.replace(match[0], `![[${original}]]`);
5486
+ continue;
5487
+ }
4742
5488
  const caption = toWikilinkAlias(match[2] ?? "") || "file";
4743
5489
  const download = await this.downloadFile(url, pageTitle, caption);
4744
5490
  if (!download.localPath) continue;
@@ -4759,6 +5505,11 @@ var ImageHandler = class {
4759
5505
  if (!parsed || !this.notionClient) continue;
4760
5506
  const blockId = parsed.blockId;
4761
5507
  const fileName = parsed.fileName;
5508
+ const original = await this.resolveOriginalTarget(match[2] ?? "", fileName, noteFolder);
5509
+ if (original) {
5510
+ result = result.replace(match[0], `![[${original}]]`);
5511
+ continue;
5512
+ }
4762
5513
  const realUrl = await this.notionClient.getFileBlockUrl(blockId);
4763
5514
  if (!realUrl) continue;
4764
5515
  const caption = fileName || toWikilinkAlias(match[2] ?? "") || "file";
@@ -4799,7 +5550,7 @@ var ImageHandler = class {
4799
5550
  `[Im-Nobsidian] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC7AC\uC2DC\uB3C4 (${attempt + 1}/${maxRetries}): ${caption}`
4800
5551
  );
4801
5552
  }
4802
- const response = await this.fetchFn(url);
5553
+ const response = await this.fetchForDownload(url);
4803
5554
  if (!response.ok) {
4804
5555
  throw new Error(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
4805
5556
  }
@@ -4898,139 +5649,105 @@ function selectStaleDbArtifacts(files, localFolder, keepPaths) {
4898
5649
  return stale;
4899
5650
  }
4900
5651
 
4901
- // src/sync/file-handler.ts
4902
- var EXTENSION_TO_BLOCK_TYPE = {
4903
- ".png": "image",
4904
- ".jpg": "image",
4905
- ".jpeg": "image",
4906
- ".gif": "image",
4907
- ".svg": "image",
4908
- ".webp": "image",
4909
- ".ico": "image",
4910
- ".bmp": "image",
4911
- ".tiff": "image",
4912
- ".tif": "image",
4913
- ".avif": "image",
4914
- ".apng": "image",
4915
- ".heic": "image",
4916
- ".pdf": "pdf",
4917
- ".mp4": "video",
4918
- ".mov": "video",
4919
- ".webm": "video",
4920
- ".avi": "video",
4921
- ".mkv": "video",
4922
- ".flv": "video",
4923
- ".wmv": "video",
4924
- ".m4v": "video",
4925
- ".mpeg": "video",
4926
- ".ogv": "video",
4927
- ".3gp": "video",
4928
- ".mp3": "audio",
4929
- ".wav": "audio",
4930
- ".ogg": "audio",
4931
- ".m4a": "audio",
4932
- ".flac": "audio",
4933
- ".aac": "audio",
4934
- ".wma": "audio",
4935
- ".opus": "audio",
4936
- ".weba": "audio"
4937
- };
4938
- var EXTENSION_TO_MIME2 = {
4939
- ".png": "image/png",
4940
- ".jpg": "image/jpeg",
4941
- ".jpeg": "image/jpeg",
4942
- ".gif": "image/gif",
4943
- ".webp": "image/webp",
4944
- ".svg": "image/svg+xml",
4945
- ".bmp": "image/bmp",
4946
- ".ico": "image/x-icon",
4947
- ".tiff": "image/tiff",
4948
- ".tif": "image/tiff",
4949
- ".avif": "image/avif",
4950
- ".heic": "image/heic",
4951
- ".pdf": "application/pdf",
4952
- ".mp4": "video/mp4",
4953
- ".mov": "video/quicktime",
4954
- ".webm": "video/webm",
4955
- ".avi": "video/x-msvideo",
4956
- ".mkv": "video/x-matroska",
4957
- ".mp3": "audio/mpeg",
4958
- ".wav": "audio/wav",
4959
- ".ogg": "audio/ogg",
4960
- ".m4a": "audio/mp4",
4961
- ".flac": "audio/flac",
4962
- ".aac": "audio/aac",
4963
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
4964
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
4965
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
4966
- ".zip": "application/zip",
4967
- ".gz": "application/gzip",
4968
- ".tar": "application/x-tar",
4969
- ".rar": "application/vnd.rar",
4970
- ".7z": "application/x-7z-compressed",
4971
- ".py": "text/x-python",
4972
- ".js": "text/javascript",
4973
- ".ts": "text/typescript",
4974
- ".json": "application/json",
4975
- ".csv": "text/csv",
4976
- ".txt": "text/plain",
4977
- ".html": "text/html",
4978
- ".xml": "application/xml",
4979
- ".hwp": "application/x-hwp",
4980
- ".xls": "application/vnd.ms-excel",
4981
- ".doc": "application/msword",
4982
- ".ppt": "application/vnd.ms-powerpoint"
5652
+ // src/utils/deadline.ts
5653
+ var DEFAULT_ITEM_TIMEOUT_MS = 18e5;
5654
+ var DeadlineExceededError = class extends Error {
5655
+ constructor(label, timeoutMs) {
5656
+ super(`\uC2DC\uAC04 \uC0C1\uD55C \uCD08\uACFC(${Math.round(timeoutMs / 1e3)}\uCD08): ${label}`);
5657
+ this.label = label;
5658
+ this.timeoutMs = timeoutMs;
5659
+ this.name = "DeadlineExceededError";
5660
+ }
5661
+ label;
5662
+ timeoutMs;
4983
5663
  };
4984
- function getBlockType(filename) {
4985
- const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
4986
- if (!ext) return "file";
4987
- return EXTENSION_TO_BLOCK_TYPE[ext] ?? "file";
4988
- }
4989
- function getMimeType(filename) {
4990
- const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
4991
- if (!ext) return "application/octet-stream";
4992
- return EXTENSION_TO_MIME2[ext] ?? "application/octet-stream";
5664
+ async function withDeadline(task, timeoutMs, label) {
5665
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return task();
5666
+ let timer;
5667
+ try {
5668
+ return await Promise.race([
5669
+ task(),
5670
+ new Promise((_resolve, reject) => {
5671
+ timer = setTimeout(() => reject(new DeadlineExceededError(label, timeoutMs)), timeoutMs);
5672
+ })
5673
+ ]);
5674
+ } finally {
5675
+ if (timer !== void 0) clearTimeout(timer);
5676
+ }
4993
5677
  }
5678
+
5679
+ // src/sync/file-handler.ts
4994
5680
  function getFolderPath(filePath) {
4995
5681
  const parts = filePath.split("/");
4996
5682
  return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
4997
5683
  }
4998
5684
  var FileHandler = class {
4999
- constructor(vaultFs, notionClient, stateDb, concurrency = 2) {
5685
+ constructor(vaultFs, notionClient, stateDb, concurrency = 2, options) {
5000
5686
  this.vaultFs = vaultFs;
5001
5687
  this.notionClient = notionClient;
5002
5688
  this.stateDb = stateDb;
5003
5689
  this.sema = new Sema(concurrency);
5690
+ this.customFetch = options?.fetch;
5691
+ this.downloadTimeoutMs = options?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
5692
+ this.itemTimeoutMs = options?.itemTimeoutMs ?? DEFAULT_ITEM_TIMEOUT_MS;
5004
5693
  }
5005
5694
  vaultFs;
5006
5695
  notionClient;
5007
5696
  stateDb;
5008
5697
  sema;
5698
+ customFetch;
5699
+ downloadTimeoutMs;
5700
+ itemTimeoutMs;
5701
+ get fetchFn() {
5702
+ return this.customFetch ?? globalThis.fetch;
5703
+ }
5009
5704
  async pushFilesForFolder(folderPageId, folderPath) {
5010
5705
  const allFiles = await this.listUploadableFiles();
5011
5706
  const folderFiles = allFiles.filter((f) => getFolderPath(f.path) === folderPath);
5012
5707
  if (folderFiles.length === 0) return [];
5013
5708
  const results = [];
5014
5709
  for (const file of folderFiles) {
5015
- const existing = this.stateDb.getFileRegistry(file.path);
5016
- if (existing) {
5017
- if (existing.fileSize === file.size) continue;
5018
- const buffer = await this.vaultFs.readBinary(file.path);
5019
- const currentHash = createHash("sha256").update(buffer).digest("hex");
5020
- if (existing.fileHash === currentHash) continue;
5021
- }
5022
- await this.sema.acquire();
5023
- try {
5024
- const result = await this.uploadFile(file, folderPageId);
5025
- if (result) results.push(result);
5026
- } catch (error) {
5027
- getLogger().warn(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${file.path}):`, error);
5028
- } finally {
5029
- this.sema.release();
5030
- }
5710
+ const result = await this.pushSingleFile(file, folderPageId);
5711
+ if (result) results.push(result);
5031
5712
  }
5032
5713
  return results;
5033
5714
  }
5715
+ /**
5716
+ * 첨부 1건을 폴더 페이지에 올린다 — 두 push 경로(`pushFilesForFolder` · `pushAllFiles`)의
5717
+ * 공통 단위. 변경 없음 판정·동시성 슬롯·시간 상한·실패 격리를 모두 여기 한 곳에 둔다.
5718
+ *
5719
+ * 한 곳에 모은 이유는 R9a 에서 실제로 데인 적이 있어서다. 같은 다운로드 로직이
5720
+ * image/file 핸들러에 두 벌 존재한 탓에 한쪽에만 상한이 걸린 채로 남았고, 상한 없는
5721
+ * 쪽이 세마포어를 쥔 채 영원히 매달렸다. 여기도 같은 코드가 두 루프에 나뉘어 있었다.
5722
+ *
5723
+ * 상한(R9f): 업로드는 호출 1건마다 상한이 있어도(SDK 30초) 멀티파트는 파트 수만큼
5724
+ * 호출이 늘어나는 **합성 경로**라 전체로는 여전히 무한대다. 상한을 넘긴 첨부는 경고만
5725
+ * 남기고 나머지 첨부는 계속 올린다 — 첨부 하나가 push 전체를 멎게 하지 않는다.
5726
+ *
5727
+ * @returns 업로드 결과. 변경 없음(스킵)이거나 실패했으면 null.
5728
+ */
5729
+ async pushSingleFile(file, folderPageId) {
5730
+ const existing = this.stateDb.getFileRegistry(file.path);
5731
+ if (existing) {
5732
+ if (existing.fileSize === file.size) return null;
5733
+ const buffer = await this.vaultFs.readBinary(file.path);
5734
+ const currentHash = createHash("sha256").update(buffer).digest("hex");
5735
+ if (existing.fileHash === currentHash) return null;
5736
+ }
5737
+ await this.sema.acquire();
5738
+ try {
5739
+ return await withDeadline(
5740
+ () => this.uploadFile(file, folderPageId),
5741
+ this.itemTimeoutMs,
5742
+ `\uCCA8\uBD80 \uC5C5\uB85C\uB4DC ${file.path}`
5743
+ );
5744
+ } catch (error) {
5745
+ getLogger().warn(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${file.path}):`, error);
5746
+ return null;
5747
+ } finally {
5748
+ this.sema.release();
5749
+ }
5750
+ }
5034
5751
  /**
5035
5752
  * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 제외한다.
5036
5753
  * node 측 VaultFS 는 과거 `.base` 를 워커에서 빼는 식으로 우회했지만, Obsidian
@@ -5064,28 +5781,14 @@ var FileHandler = class {
5064
5781
  continue;
5065
5782
  }
5066
5783
  for (const file of files) {
5067
- const existing = this.stateDb.getFileRegistry(file.path);
5068
- if (existing) {
5069
- if (existing.fileSize === file.size) continue;
5070
- const buffer = await this.vaultFs.readBinary(file.path);
5071
- const currentHash = createHash("sha256").update(buffer).digest("hex");
5072
- if (existing.fileHash === currentHash) continue;
5073
- }
5074
- await this.sema.acquire();
5075
- try {
5076
- const result = await this.uploadFile(file, folderPageId);
5077
- if (result) results.push(result);
5078
- } catch (error) {
5079
- getLogger().warn(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${file.path}):`, error);
5080
- } finally {
5081
- this.sema.release();
5082
- }
5784
+ const result = await this.pushSingleFile(file, folderPageId);
5785
+ if (result) results.push(result);
5083
5786
  }
5084
5787
  }
5085
5788
  return results;
5086
5789
  }
5087
5790
  async downloadFileBlock(url, filename, targetFolder) {
5088
- const response = await fetch(url);
5791
+ const response = await fetchForDownload(this.fetchFn, url, this.downloadTimeoutMs);
5089
5792
  if (!response.ok) {
5090
5793
  throw new Error(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
5091
5794
  }
@@ -5174,6 +5877,9 @@ var FileHandler = class {
5174
5877
  function resolvePullConflict(input) {
5175
5878
  const { record, localContent, remoteContent, remoteChange, strategy } = input;
5176
5879
  const localHash = computeHash(localContent);
5880
+ if (input.localExists === false) {
5881
+ return { action: "write", localHash };
5882
+ }
5177
5883
  const localModified = localHash !== record.contentHash;
5178
5884
  if (!localModified) {
5179
5885
  return { action: "write", localHash };
@@ -5255,20 +5961,19 @@ function selectDbRowFiles(files, localFolder) {
5255
5961
  return rel.length > 0 && !rel.includes("/");
5256
5962
  });
5257
5963
  }
5964
+ function pagePathCandidates(dir, safeName, pageId) {
5965
+ const base = dir ? `${dir}/${safeName}` : safeName;
5966
+ const raw = pageId.replace(/-/g, "");
5967
+ return [`${base}.md`, ...[8, 16, 32].map((len) => `${base} (${raw.slice(0, len)}).md`)];
5968
+ }
5258
5969
  function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
5259
5970
  const ownedBySelf = (candidate) => {
5260
5971
  const owner = lookupByPath(candidate);
5261
5972
  if (!owner) return true;
5262
5973
  return owner.notionPageId != null && notionIdsEqual(owner.notionPageId, pageId);
5263
5974
  };
5264
- const natural = `${localFolder}/${safeName}.md`;
5265
- if (ownedBySelf(natural)) return natural;
5266
- const raw = pageId.replace(/-/g, "");
5267
- for (const len of [8, 16, 32]) {
5268
- const candidate = `${localFolder}/${safeName} (${raw.slice(0, len)}).md`;
5269
- if (ownedBySelf(candidate)) return candidate;
5270
- }
5271
- return `${localFolder}/${safeName} (${raw}).md`;
5975
+ const candidates = pagePathCandidates(localFolder, safeName, pageId);
5976
+ return candidates.find(ownedBySelf) ?? candidates[candidates.length - 1];
5272
5977
  }
5273
5978
 
5274
5979
  // src/utils/db-folder-path.ts
@@ -5285,50 +5990,356 @@ function resolveDbFolderPath(folder, dbId, ownerOf) {
5285
5990
  }
5286
5991
  return `${folder} (${raw})`;
5287
5992
  }
5288
- function repairDbFolderCollisions(configs, takenFolders) {
5289
- const owner = new Map(takenFolders ?? []);
5290
- let repaired = 0;
5291
- for (const config of configs) {
5292
- const resolved = resolveDbFolderPath(
5293
- config.localFolder,
5294
- config.databaseId,
5295
- (f) => owner.get(f) ?? null
5296
- );
5297
- if (resolved !== config.localFolder) {
5298
- config.localFolder = resolved;
5299
- repaired++;
5993
+ function repairDbFolderCollisions(configs, takenFolders) {
5994
+ const owner = new Map(takenFolders ?? []);
5995
+ let repaired = 0;
5996
+ for (const config of configs) {
5997
+ const resolved = resolveDbFolderPath(
5998
+ config.localFolder,
5999
+ config.databaseId,
6000
+ (f) => owner.get(f) ?? null
6001
+ );
6002
+ if (resolved !== config.localFolder) {
6003
+ config.localFolder = resolved;
6004
+ repaired++;
6005
+ }
6006
+ owner.set(resolved, config.databaseId);
6007
+ }
6008
+ return repaired;
6009
+ }
6010
+ function isDirectDbRowPath(localFolder, path) {
6011
+ const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
6012
+ if (!path.startsWith(prefix)) return false;
6013
+ const rel = path.slice(prefix.length);
6014
+ return rel.length > 0 && !rel.includes("/");
6015
+ }
6016
+
6017
+ // src/view/notion-filter-translator.ts
6018
+ function translateNotionFilter(filter, schema, resolveName) {
6019
+ const ctx = { resolveName, schema, untranslated: /* @__PURE__ */ new Set() };
6020
+ const node = translateNode(filter, ctx);
6021
+ return {
6022
+ node,
6023
+ untranslated: [...ctx.untranslated].sort((a, b) => a.localeCompare(b))
6024
+ };
6025
+ }
6026
+ function translateNode(node, ctx) {
6027
+ if (!isRecord(node)) return null;
6028
+ if (Array.isArray(node["and"])) {
6029
+ const parts = node["and"].map((c) => translateNode(c, ctx)).filter(isNode);
6030
+ if (parts.length === 0) return null;
6031
+ return parts.length === 1 ? parts[0] : { and: parts };
6032
+ }
6033
+ if (Array.isArray(node["or"])) {
6034
+ const children = node["or"];
6035
+ const parts = [];
6036
+ for (const child of children) {
6037
+ const part = translateNode(child, ctx);
6038
+ if (part === null) {
6039
+ ctx.untranslated.add("or \uADF8\uB8F9 \uC548\uC5D0 \uC62E\uAE38 \uC218 \uC5C6\uB294 \uC870\uAC74\uC774 \uC788\uC5B4 \uADF8\uB8F9 \uC804\uCCB4\uB97C \uC0DD\uB7B5\uD568");
6040
+ return null;
6041
+ }
6042
+ parts.push(part);
6043
+ }
6044
+ if (parts.length === 0) return null;
6045
+ return parts.length === 1 ? parts[0] : { or: parts };
6046
+ }
6047
+ if (Array.isArray(node["not"])) {
6048
+ const parts = [];
6049
+ for (const child of node["not"]) {
6050
+ const part = translateNode(child, ctx);
6051
+ if (part === null) {
6052
+ ctx.untranslated.add("not \uADF8\uB8F9 \uC548\uC5D0 \uC62E\uAE38 \uC218 \uC5C6\uB294 \uC870\uAC74\uC774 \uC788\uC5B4 \uADF8\uB8F9 \uC804\uCCB4\uB97C \uC0DD\uB7B5\uD568");
6053
+ return null;
6054
+ }
6055
+ parts.push(part);
6056
+ }
6057
+ return parts.length === 0 ? null : { not: parts };
6058
+ }
6059
+ return translateLeaf(node, ctx);
6060
+ }
6061
+ function translateLeaf(leaf, ctx) {
6062
+ const timestamp = leaf["timestamp"];
6063
+ if (typeof timestamp === "string") {
6064
+ const ref2 = timestamp === "created_time" ? "file.ctime" : "file.mtime";
6065
+ const cond = leaf[timestamp];
6066
+ if (!isRecord(cond)) return unsupported(ctx, `timestamp '${timestamp}' \uD544\uD130`);
6067
+ return translateDate(ref2, cond, ctx, `timestamp '${timestamp}'`);
6068
+ }
6069
+ const propRaw = leaf["property"];
6070
+ if (typeof propRaw !== "string") return null;
6071
+ const propName = ctx.resolveName(propRaw);
6072
+ if (!propName) {
6073
+ return unsupported(ctx, `\uC18D\uC131 '${propRaw}' \uC744 \uC2A4\uD0A4\uB9C8\uC5D0\uC11C \uCC3E\uC9C0 \uBABB\uD574 \uC870\uAC74\uC744 \uC0DD\uB7B5\uD568`);
6074
+ }
6075
+ const ref = noteRef(propName);
6076
+ const label = `\uC18D\uC131 '${propName}'`;
6077
+ for (const [typeKey, condRaw] of Object.entries(leaf)) {
6078
+ if (typeKey === "property") continue;
6079
+ if (!isRecord(condRaw)) continue;
6080
+ switch (typeKey) {
6081
+ case "title":
6082
+ case "rich_text":
6083
+ case "url":
6084
+ case "email":
6085
+ case "phone_number":
6086
+ return translateText(ref, condRaw, ctx, label);
6087
+ case "number":
6088
+ return translateNumber(ref, condRaw, ctx, label);
6089
+ case "checkbox":
6090
+ return translateCheckbox(ref, condRaw, ctx, label);
6091
+ case "select":
6092
+ return translateSelect(ref, condRaw, ctx, label);
6093
+ case "status":
6094
+ return translateStatus(ref, condRaw, ctx, label, propName);
6095
+ case "multi_select":
6096
+ case "relation":
6097
+ case "people":
6098
+ return translateList(ref, condRaw, ctx, label);
6099
+ case "files":
6100
+ return translateEmptiness(ref, condRaw, ctx, label);
6101
+ case "date":
6102
+ case "created_time":
6103
+ case "last_edited_time":
6104
+ return translateDate(ref, condRaw, ctx, label);
6105
+ case "formula":
6106
+ case "rollup":
6107
+ return unsupported(ctx, `${label}: ${typeKey} \uD544\uD130\uB294 Bases \uB85C \uC62E\uAE38 \uC218 \uC5C6\uC74C`);
6108
+ default:
6109
+ return unsupported(ctx, `${label}: '${typeKey}' \uD544\uD130 \uC720\uD615 \uBBF8\uC9C0\uC6D0`);
6110
+ }
6111
+ }
6112
+ return null;
6113
+ }
6114
+ function translateText(ref, cond, ctx, label) {
6115
+ const empt = translateEmptinessOnly(ref, cond);
6116
+ if (empt) return empt;
6117
+ const [op, value] = firstEntry(cond);
6118
+ if (typeof value !== "string") return unsupported(ctx, `${label}: '${op}' \uAC12\uC774 \uBB38\uC790\uC5F4\uC774 \uC544\uB2D8`);
6119
+ const lit = str(value);
6120
+ switch (op) {
6121
+ case "equals":
6122
+ return `${ref} == ${lit}`;
6123
+ case "does_not_equal":
6124
+ return `${ref} != ${lit}`;
6125
+ case "contains":
6126
+ return `${ref}.contains(${lit})`;
6127
+ case "does_not_contain":
6128
+ return `!${ref}.contains(${lit})`;
6129
+ case "starts_with":
6130
+ return `${ref}.startsWith(${lit})`;
6131
+ case "ends_with":
6132
+ return `${ref}.endsWith(${lit})`;
6133
+ default:
6134
+ return unsupported(ctx, `${label}: \uD14D\uC2A4\uD2B8 \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
6135
+ }
6136
+ }
6137
+ function translateNumber(ref, cond, ctx, label) {
6138
+ const empt = translateEmptinessOnly(ref, cond);
6139
+ if (empt) return empt;
6140
+ const [op, value] = firstEntry(cond);
6141
+ if (typeof value !== "number") return unsupported(ctx, `${label}: '${op}' \uAC12\uC774 \uC22B\uC790\uAC00 \uC544\uB2D8`);
6142
+ switch (op) {
6143
+ case "equals":
6144
+ return `${ref} == ${value}`;
6145
+ case "does_not_equal":
6146
+ return `${ref} != ${value}`;
6147
+ case "greater_than":
6148
+ return `${ref} > ${value}`;
6149
+ case "less_than":
6150
+ return `${ref} < ${value}`;
6151
+ case "greater_than_or_equal_to":
6152
+ return `${ref} >= ${value}`;
6153
+ case "less_than_or_equal_to":
6154
+ return `${ref} <= ${value}`;
6155
+ default:
6156
+ return unsupported(ctx, `${label}: \uC22B\uC790 \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
6157
+ }
6158
+ }
6159
+ function translateCheckbox(ref, cond, ctx, label) {
6160
+ const [op, value] = firstEntry(cond);
6161
+ if (typeof value !== "boolean") return unsupported(ctx, `${label}: '${op}' \uAC12\uC774 \uBD88\uB9AC\uC5B8\uC774 \uC544\uB2D8`);
6162
+ if (op === "equals") return `${ref} == ${value}`;
6163
+ if (op === "does_not_equal") return `${ref} != ${value}`;
6164
+ return unsupported(ctx, `${label}: \uCCB4\uD06C\uBC15\uC2A4 \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
6165
+ }
6166
+ function translateSelect(ref, cond, ctx, label) {
6167
+ const empt = translateEmptinessOnly(ref, cond);
6168
+ if (empt) return empt;
6169
+ const [op, value] = firstEntry(cond);
6170
+ const names = toStringList(value);
6171
+ if (names === null) return unsupported(ctx, `${label}: '${op}' \uAC12 \uD615\uD0DC\uB97C \uD574\uC11D\uD558\uC9C0 \uBABB\uD568`);
6172
+ return equalityOverNames(ref, op, names, ctx, label);
6173
+ }
6174
+ function translateStatus(ref, cond, ctx, label, propName) {
6175
+ const empt = translateEmptinessOnly(ref, cond);
6176
+ if (empt) return empt;
6177
+ const [op, value] = firstEntry(cond);
6178
+ const raw = toStringList(value);
6179
+ if (raw === null) return unsupported(ctx, `${label}: '${op}' \uAC12 \uD615\uD0DC\uB97C \uD574\uC11D\uD558\uC9C0 \uBABB\uD568`);
6180
+ const prop = ctx.schema[propName];
6181
+ const optionNames = new Set((prop?.options ?? []).map((o) => o.name));
6182
+ const expanded = [];
6183
+ for (const name of raw) {
6184
+ if (optionNames.has(name)) {
6185
+ expanded.push(name);
6186
+ continue;
5300
6187
  }
5301
- owner.set(resolved, config.databaseId);
6188
+ const group = prop?.groups?.find((g) => g.name === name);
6189
+ const members = group && prop ? expandGroup(group, prop) : null;
6190
+ if (members === null) {
6191
+ return unsupported(ctx, `${label}: \uC0C1\uD0DC '${name}' \uC744 \uC635\uC158\uC73C\uB85C \uC804\uAC1C\uD558\uC9C0 \uBABB\uD568`);
6192
+ }
6193
+ expanded.push(...members);
5302
6194
  }
5303
- return repaired;
6195
+ if (expanded.length === 0) {
6196
+ return unsupported(ctx, `${label}: \uC0C1\uD0DC \uADF8\uB8F9\uC5D0 \uC18D\uD55C \uC635\uC158\uC774 \uC5C6\uC5B4 \uC870\uAC74\uC744 \uC0DD\uB7B5\uD568`);
6197
+ }
6198
+ return equalityOverNames(ref, op, dedupe(expanded), ctx, label);
5304
6199
  }
5305
- function isDirectDbRowPath(localFolder, path) {
5306
- const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
5307
- if (!path.startsWith(prefix)) return false;
5308
- const rel = path.slice(prefix.length);
5309
- return rel.length > 0 && !rel.includes("/");
6200
+ function expandGroup(group, prop) {
6201
+ const ids = group.optionIds;
6202
+ if (!ids || ids.length === 0) return [];
6203
+ const byId = new Map((prop.options ?? []).map((o) => [o.id, o.name]));
6204
+ const names = [];
6205
+ for (const id of ids) {
6206
+ const name = id === void 0 ? void 0 : byId.get(id);
6207
+ if (name === void 0) return null;
6208
+ names.push(name);
6209
+ }
6210
+ return names;
6211
+ }
6212
+ function equalityOverNames(ref, op, names, ctx, label) {
6213
+ if (names.length === 0) return unsupported(ctx, `${label}: '${op}' \uBE44\uAD50 \uB300\uC0C1\uC774 \uBE44\uC5B4 \uC788\uC74C`);
6214
+ if (op === "equals") {
6215
+ const parts = names.map((n) => `${ref} == ${str(n)}`);
6216
+ return parts.length === 1 ? parts[0] : { or: parts };
6217
+ }
6218
+ if (op === "does_not_equal") {
6219
+ const parts = names.map((n) => `${ref} != ${str(n)}`);
6220
+ return parts.length === 1 ? parts[0] : { and: parts };
6221
+ }
6222
+ return unsupported(ctx, `${label}: \uC120\uD0DD \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
6223
+ }
6224
+ function translateList(ref, cond, ctx, label) {
6225
+ const empt = translateEmptinessOnly(ref, cond);
6226
+ if (empt) return empt;
6227
+ const [op, value] = firstEntry(cond);
6228
+ const names = toStringList(value);
6229
+ if (names === null) return unsupported(ctx, `${label}: '${op}' \uAC12 \uD615\uD0DC\uB97C \uD574\uC11D\uD558\uC9C0 \uBABB\uD568`);
6230
+ const args = names.map(str).join(", ");
6231
+ if (op === "contains") return `${ref}.containsAny(${args})`;
6232
+ if (op === "does_not_contain") return `!${ref}.containsAny(${args})`;
6233
+ return unsupported(ctx, `${label}: \uBAA9\uB85D \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
6234
+ }
6235
+ function translateEmptiness(ref, cond, ctx, label) {
6236
+ const empt = translateEmptinessOnly(ref, cond);
6237
+ if (empt) return empt;
6238
+ const [op] = firstEntry(cond);
6239
+ return unsupported(ctx, `${label}: \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
6240
+ }
6241
+ function translateDate(ref, cond, ctx, label) {
6242
+ const empt = translateEmptinessOnly(ref, cond);
6243
+ if (empt) return empt;
6244
+ const [op, value] = firstEntry(cond);
6245
+ if (typeof value !== "string") {
6246
+ return unsupported(ctx, `${label}: \uC0C1\uB300 \uB0A0\uC9DC \uC870\uAC74 '${op}' \uC740 \uC62E\uAE30\uC9C0 \uC54A\uC74C`);
6247
+ }
6248
+ const lit = str(value);
6249
+ switch (op) {
6250
+ case "equals":
6251
+ return `${ref} == ${lit}`;
6252
+ case "before":
6253
+ return `${ref} < ${lit}`;
6254
+ case "after":
6255
+ return `${ref} > ${lit}`;
6256
+ case "on_or_before":
6257
+ return `${ref} <= ${lit}`;
6258
+ case "on_or_after":
6259
+ return `${ref} >= ${lit}`;
6260
+ default:
6261
+ return unsupported(ctx, `${label}: \uB0A0\uC9DC \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
6262
+ }
6263
+ }
6264
+ function translateEmptinessOnly(ref, cond) {
6265
+ if (cond["is_empty"] === true) return `${ref}.isEmpty()`;
6266
+ if (cond["is_not_empty"] === true) return `!${ref}.isEmpty()`;
6267
+ return null;
6268
+ }
6269
+ function unsupported(ctx, reason) {
6270
+ ctx.untranslated.add(reason);
6271
+ return null;
6272
+ }
6273
+ function noteRef(propertyName) {
6274
+ return `note[${str(propertyName)}]`;
6275
+ }
6276
+ function str(value) {
6277
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
6278
+ }
6279
+ function toStringList(value) {
6280
+ if (typeof value === "string") return [value];
6281
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) return value;
6282
+ return null;
6283
+ }
6284
+ function firstEntry(cond) {
6285
+ const entries = Object.entries(cond);
6286
+ return entries[0] ?? ["", void 0];
6287
+ }
6288
+ function dedupe(values) {
6289
+ return [...new Set(values)];
6290
+ }
6291
+ function isRecord(value) {
6292
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6293
+ }
6294
+ function isNode(value) {
6295
+ return value !== null;
5310
6296
  }
5311
6297
 
5312
- // src/utils/wikilink-title.ts
5313
- function wikilinkTitleFromPath(obsidianPath) {
5314
- return obsidianPath.replace(/\.md$/i, "").split("/").pop() ?? "";
6298
+ // src/view/property-resolver.ts
6299
+ function resolvePropertyName(propertyId, schema) {
6300
+ const target = decodePropertyId(propertyId);
6301
+ for (const [name, prop] of Object.entries(schema)) {
6302
+ if (decodePropertyId(prop.id) === target) return name;
6303
+ }
6304
+ return schema[propertyId] ? propertyId : void 0;
6305
+ }
6306
+ function decodePropertyId(id) {
6307
+ try {
6308
+ return decodeURIComponent(id);
6309
+ } catch {
6310
+ return id;
6311
+ }
5315
6312
  }
5316
6313
 
5317
6314
  // src/view/base-file-generator.ts
5318
6315
  var NOTION_TO_BASES_VIEW = {
5319
- table: "table",
5320
- gallery: "cards",
5321
- list: "list",
5322
- board: "cards",
5323
- calendar: null,
5324
- timeline: null,
5325
- form: null,
5326
- chart: null,
5327
- map: null,
5328
- dashboard: null
6316
+ table: { type: "table" },
6317
+ gallery: { type: "cards" },
6318
+ list: { type: "list" },
6319
+ board: { type: "cards" },
6320
+ calendar: {
6321
+ type: "table",
6322
+ degradeReason: "\uB2EC\uB825 \uACA9\uC790\uB294 Bases \uBBF8\uC9C0\uC6D0 \u2014 \uB0A0\uC9DC \uC624\uB984\uCC28\uC21C \uD45C\uB85C \uACA9\uD558"
6323
+ },
6324
+ timeline: {
6325
+ type: "table",
6326
+ degradeReason: "\uD0C0\uC784\uB77C\uC778 \uCD95\uC740 Bases \uBBF8\uC9C0\uC6D0 \u2014 \uB0A0\uC9DC \uC624\uB984\uCC28\uC21C \uD45C\uB85C \uACA9\uD558"
6327
+ },
6328
+ form: { type: null },
6329
+ chart: { type: null },
6330
+ map: { type: null },
6331
+ dashboard: { type: null }
5329
6332
  };
6333
+ function basesViewMappingOf(notionType) {
6334
+ return NOTION_TO_BASES_VIEW[notionType] ?? { type: null };
6335
+ }
5330
6336
  function basesViewTypeOf(notionType) {
5331
- return NOTION_TO_BASES_VIEW[notionType] ?? null;
6337
+ return basesViewMappingOf(notionType).type;
6338
+ }
6339
+ function filterGroup(node) {
6340
+ if ("and" in node) return ["and", node.and];
6341
+ if ("or" in node) return ["or", node.or];
6342
+ return ["not", node.not];
5332
6343
  }
5333
6344
  var BaseFileGenerator = class {
5334
6345
  generate(options) {
@@ -5406,8 +6417,49 @@ var BaseFileGenerator = class {
5406
6417
  if (view.image) {
5407
6418
  lines.push(` image: ${this.propRef(view.image)}`);
5408
6419
  }
6420
+ if (view.filters) {
6421
+ this.writeFilterValue(lines, view.filters, " ");
6422
+ }
6423
+ }
6424
+ }
6425
+ /** `filters:` 키와 그 값을 쓴다. 단일 식이면 한 줄, 그룹이면 블록. */
6426
+ writeFilterValue(lines, node, indent) {
6427
+ if (typeof node === "string") {
6428
+ lines.push(`${indent}filters: ${this.yamlExpr(node)}`);
6429
+ return;
6430
+ }
6431
+ lines.push(`${indent}filters:`);
6432
+ const [op, children] = filterGroup(node);
6433
+ lines.push(`${indent} ${op}:`);
6434
+ for (const child of children) {
6435
+ this.writeFilterItem(lines, child, `${indent} `);
6436
+ }
6437
+ }
6438
+ /** 필터 노드를 시퀀스 항목(`- …`)으로 쓴다. 그룹이면 재귀적으로 중첩한다. */
6439
+ writeFilterItem(lines, node, indent) {
6440
+ if (typeof node === "string") {
6441
+ lines.push(`${indent}- ${this.yamlExpr(node)}`);
6442
+ return;
6443
+ }
6444
+ const [op, children] = filterGroup(node);
6445
+ lines.push(`${indent}- ${op}:`);
6446
+ for (const child of children) {
6447
+ this.writeFilterItem(lines, child, `${indent} `);
5409
6448
  }
5410
6449
  }
6450
+ /**
6451
+ * 필터 식을 YAML 스칼라로 쓴다.
6452
+ *
6453
+ * 식은 대개 `note["상태"] == "진행 중"` 처럼 큰따옴표를 품는다. 평문 스칼라는 큰따옴표를
6454
+ * 안에 담을 수 있으므로(선두만 아니면 된다) 기본은 평문으로 두어 기존 `file.ext == "md"`
6455
+ * 라인과 모양을 맞춘다. 다만 `: `·` #`·선두 지시문자는 평문에서 의미가 달라지므로
6456
+ * 그때만 작은따옴표로 감싼다(속성명에 콜론이나 해시가 들어갈 수 있다).
6457
+ */
6458
+ yamlExpr(expr) {
6459
+ const unsafe = expr !== expr.trim() || expr.includes(": ") || expr.includes(" #") || expr.endsWith(":") || /^[-?:,[\]{}#&*!|>'"%@`]/.test(expr);
6460
+ if (!unsafe) return expr;
6461
+ return `'${expr.replace(/'/g, "''")}'`;
6462
+ }
5411
6463
  /**
5412
6464
  * 뷰 이름을 Bases 요구사항대로 유일화한다(in-place). 동명 충돌 시 ' 2',' 3'… 를 붙이되
5413
6465
  * 이미 존재하는 이름과 다시 충돌하지 않을 때까지 증가시킨다.
@@ -5426,7 +6478,7 @@ var BaseFileGenerator = class {
5426
6478
  }
5427
6479
  }
5428
6480
  convertView(view, schema) {
5429
- const basesType = NOTION_TO_BASES_VIEW[view.type];
6481
+ const basesType = basesViewMappingOf(view.type).type;
5430
6482
  if (!basesType) return null;
5431
6483
  const result = {
5432
6484
  type: basesType,
@@ -5469,6 +6521,12 @@ var BaseFileGenerator = class {
5469
6521
  };
5470
6522
  }
5471
6523
  }
6524
+ if ((view.type === "calendar" || view.type === "timeline") && !result.sort?.length) {
6525
+ const dateName = view.datePropertyName ?? (view.datePropertyId ? this.resolvePropertyName(view.datePropertyId, schema) : void 0);
6526
+ if (dateName) {
6527
+ result.sort = [{ property: this.toNoteRef(dateName), direction: "ASC" }];
6528
+ }
6529
+ }
5472
6530
  if (view.type === "gallery" && view.cover) {
5473
6531
  if (view.cover.type === "property" && view.cover.propertyId) {
5474
6532
  const propName = this.resolvePropertyName(view.cover.propertyId, schema);
@@ -5479,6 +6537,14 @@ var BaseFileGenerator = class {
5479
6537
  result.image = "formula.coverImage";
5480
6538
  }
5481
6539
  }
6540
+ if (view.filter !== void 0 && view.filter !== null) {
6541
+ const { node } = translateNotionFilter(
6542
+ view.filter,
6543
+ schema,
6544
+ (id) => this.resolvePropertyName(id, schema)
6545
+ );
6546
+ if (node) result.filters = node;
6547
+ }
5482
6548
  return result;
5483
6549
  }
5484
6550
  toNoteRef(propertyName) {
@@ -5494,18 +6560,7 @@ var BaseFileGenerator = class {
5494
6560
  return `"${ref.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
5495
6561
  }
5496
6562
  resolvePropertyName(propertyId, schema) {
5497
- const target = this.decodeId(propertyId);
5498
- for (const [name, prop] of Object.entries(schema)) {
5499
- if (this.decodeId(prop.id) === target) return name;
5500
- }
5501
- return void 0;
5502
- }
5503
- decodeId(id) {
5504
- try {
5505
- return decodeURIComponent(id);
5506
- } catch {
5507
- return id;
5508
- }
6563
+ return resolvePropertyName(propertyId, schema);
5509
6564
  }
5510
6565
  yamlKey(key) {
5511
6566
  if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) return key;
@@ -5519,7 +6574,6 @@ var BaseFileGenerator = class {
5519
6574
 
5520
6575
  // src/view/sidecar-generator.ts
5521
6576
  var DROPPED_SETTING_REASONS = {
5522
- filter: "Notion \uD544\uD130\uC2DD\uC740 .base \uB85C \uD45C\uD604\uB418\uC9C0 \uC54A\uC74C(\uD3F4\uB354 \uD544\uD130\uB9CC \uC801\uC6A9)",
5523
6577
  coverSize: "\uAC24\uB7EC\uB9AC \uCEE4\uBC84 \uD06C\uAE30\uB294 Bases \uBBF8\uC9C0\uC6D0",
5524
6578
  coverAspect: "\uAC24\uB7EC\uB9AC \uCEE4\uBC84 \uBE44\uC728\uC740 Bases \uBBF8\uC9C0\uC6D0",
5525
6579
  viewRange: "\uCE98\uB9B0\uB354 \uD45C\uC2DC \uBC94\uC704(week/month)\uB294 Bases \uBBF8\uC9C0\uC6D0",
@@ -5534,7 +6588,7 @@ var SidecarGenerator = class {
5534
6588
  build(options) {
5535
6589
  const degraded = [];
5536
6590
  const properties = this.buildProperties(options.schema);
5537
- const views = options.viewsConfig.views.map((v) => this.buildView(v, degraded));
6591
+ const views = options.viewsConfig.views.map((v) => this.buildView(v, options.schema, degraded));
5538
6592
  degraded.sort(
5539
6593
  (a, b) => a.view.localeCompare(b.view) || a.kind.localeCompare(b.kind) || a.detail.localeCompare(b.detail)
5540
6594
  );
@@ -5566,8 +6620,9 @@ var SidecarGenerator = class {
5566
6620
  };
5567
6621
  }).sort((a, b) => a.name.localeCompare(b.name));
5568
6622
  }
5569
- buildView(view, degraded) {
5570
- const basesType = basesViewTypeOf(view.type);
6623
+ buildView(view, schema, degraded) {
6624
+ const mapping = basesViewMappingOf(view.type);
6625
+ const basesType = mapping.type;
5571
6626
  const viewName = view.name || view.type;
5572
6627
  if (basesType === null) {
5573
6628
  degraded.push({
@@ -5575,8 +6630,14 @@ var SidecarGenerator = class {
5575
6630
  kind: "view-unrepresentable",
5576
6631
  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`
5577
6632
  });
6633
+ } else if (mapping.degradeReason) {
6634
+ degraded.push({
6635
+ view: viewName,
6636
+ kind: "view-degraded",
6637
+ detail: `'${view.type}' \uBDF0: ${mapping.degradeReason}`
6638
+ });
5578
6639
  }
5579
- const config = this.extractConfig(view, basesType, viewName, degraded);
6640
+ const config = this.extractConfig(view, schema, basesType, viewName, degraded);
5580
6641
  return {
5581
6642
  id: view.id,
5582
6643
  name: viewName,
@@ -5590,7 +6651,7 @@ var SidecarGenerator = class {
5590
6651
  * config 에 보존하면서 동시에 degrade 리포트에 1줄씩 기록한다(표현되는 뷰에 한해 —
5591
6652
  * 미표현 뷰는 이미 view-unrepresentable 로 통째 보고됨).
5592
6653
  */
5593
- extractConfig(view, basesType, viewName, degraded) {
6654
+ extractConfig(view, schema, basesType, viewName, degraded) {
5594
6655
  const config = {};
5595
6656
  const noteDrop = (key) => {
5596
6657
  if (basesType !== null && DROPPED_SETTING_REASONS[key]) {
@@ -5603,7 +6664,7 @@ var SidecarGenerator = class {
5603
6664
  };
5604
6665
  if (view.filter !== void 0 && view.filter !== null) {
5605
6666
  config["filter"] = view.filter;
5606
- noteDrop("filter");
6667
+ this.noteFilterDegrade(view.filter, schema, basesType, viewName, degraded);
5607
6668
  }
5608
6669
  if (view.sorts && view.sorts.length > 0) config["sorts"] = view.sorts;
5609
6670
  if (view.groupBy) {
@@ -5632,6 +6693,36 @@ var SidecarGenerator = class {
5632
6693
  }
5633
6694
  return config;
5634
6695
  }
6696
+ /**
6697
+ * 필터 손실을 **실제로 못 옮긴 것만** 보고한다.
6698
+ *
6699
+ * 예전엔 필터가 있기만 하면 "Notion 필터식은 .base 로 표현되지 않음"을 무조건 찍었다.
6700
+ * 이제 `.base` 가 뷰별 `filters:` 를 내보내므로 그 문구는 대부분의 경우 거짓이다.
6701
+ * 거짓 degrade 는 진짜 degrade 를 묻어 버리므로, `.base` 와 **같은 번역기**를 돌려
6702
+ * 남은 것만 적는다(두 산출물이 어긋날 여지를 없앤다).
6703
+ */
6704
+ noteFilterDegrade(filter, schema, basesType, viewName, degraded) {
6705
+ if (basesType === null) return;
6706
+ const { node, untranslated } = translateNotionFilter(
6707
+ filter,
6708
+ schema,
6709
+ (id) => resolvePropertyName(id, schema)
6710
+ );
6711
+ for (const reason of untranslated) {
6712
+ degraded.push({
6713
+ view: viewName,
6714
+ kind: "setting-dropped",
6715
+ detail: `\uD544\uD130 \uC77C\uBD80 \uB204\uB77D \u2014 ${reason}`
6716
+ });
6717
+ }
6718
+ if (node === null && untranslated.length === 0) {
6719
+ degraded.push({
6720
+ view: viewName,
6721
+ kind: "setting-dropped",
6722
+ detail: "\uD544\uD130 \uB204\uB77D \u2014 Notion \uD544\uD130\uC2DD\uC744 \uD574\uC11D\uD558\uC9C0 \uBABB\uD574 .base \uC5D0 \uC62E\uAE30\uC9C0 \uBABB\uD568"
6723
+ });
6724
+ }
6725
+ }
5635
6726
  };
5636
6727
  function stableStringify(value) {
5637
6728
  return JSON.stringify(sortKeys(value), null, 2);
@@ -5648,6 +6739,94 @@ function sortKeys(value) {
5648
6739
  return value;
5649
6740
  }
5650
6741
 
6742
+ // src/converter/toggle-heading.ts
6743
+ var NFM_TOGGLE_HEADING_RE = /^([\t ]*)(#{1,6})[ \t]+(.*?)[ \t]*\{toggle="true"\}[ \t]*$/;
6744
+ var MARKED_TOGGLE_HEADING_RE = new RegExp(
6745
+ `^([\\t ]*)(#{1,6})[ \\t]+(.*?)[ \\t]*${TOGGLE_HEADING_START}[ \\t]*$`
6746
+ );
6747
+ var ANY_HEADING_RE = /^(#{1,6})[ \t]/;
6748
+ function headingLine(hashes, title, suffix) {
6749
+ return [hashes, title.trim(), suffix].filter((part) => part !== "").join(" ");
6750
+ }
6751
+ function convertToggleHeadings(content) {
6752
+ return convertLevel(content.split("\n")).join("\n");
6753
+ }
6754
+ function convertLevel(lines) {
6755
+ const out = [];
6756
+ const ends = containerBlockEnds(lines);
6757
+ for (let i = 0; i < lines.length; i++) {
6758
+ const match = NFM_TOGGLE_HEADING_RE.exec(lines[i]);
6759
+ if (!match) {
6760
+ out.push(lines[i]);
6761
+ continue;
6762
+ }
6763
+ const [, indent, hashes, title] = match;
6764
+ const childIndent = `${indent} `;
6765
+ let lastChild = i;
6766
+ for (let j = i + 1; j < lines.length; j++) {
6767
+ const line = lines[j];
6768
+ if (line.trim() === "") continue;
6769
+ if (!line.startsWith(childIndent)) break;
6770
+ lastChild = ends[j];
6771
+ j = lastChild;
6772
+ }
6773
+ if (lastChild === i) {
6774
+ out.push(indent + headingLine(hashes, title, TOGGLE_HEADING_START));
6775
+ continue;
6776
+ }
6777
+ const body = dedentContainerBody(lines.slice(i + 1, lastChild + 1).join("\n"));
6778
+ const inner = convertLevel(body.split("\n")).join("\n");
6779
+ out.push(indent + headingLine(hashes, title, TOGGLE_HEADING_START));
6780
+ out.push(...(indent === "" ? inner : indentContainerBody(inner, indent)).split("\n"));
6781
+ out.push(indent + TOGGLE_HEADING_END);
6782
+ i = lastChild;
6783
+ }
6784
+ return out;
6785
+ }
6786
+ function restoreToggleHeadings(content) {
6787
+ return restoreLevel(content.split("\n")).join("\n");
6788
+ }
6789
+ function restoreLevel(lines) {
6790
+ const out = [];
6791
+ for (let i = 0; i < lines.length; i++) {
6792
+ const line = lines[i];
6793
+ const match = MARKED_TOGGLE_HEADING_RE.exec(line);
6794
+ if (!match) {
6795
+ if (line.trim() !== TOGGLE_HEADING_END) out.push(line);
6796
+ continue;
6797
+ }
6798
+ const [, indent, hashes, title] = match;
6799
+ const bound = findBodyEnd(lines, i + 1, hashes.length);
6800
+ const raw = stripContainerIndent(lines.slice(i + 1, bound.bodyEnd).join("\n"), indent);
6801
+ const inner = restoreLevel(raw.split("\n"));
6802
+ const body = inner.join("\n").replace(/^\n+/, "").replace(/\n+$/, "");
6803
+ out.push(indent + headingLine(hashes, title, '{toggle="true"}'));
6804
+ if (body.trim() !== "") out.push(...indentContainerBody(body, `${indent} `).split("\n"));
6805
+ i = bound.consumed;
6806
+ }
6807
+ return out;
6808
+ }
6809
+ function findBodyEnd(lines, from, level) {
6810
+ let depth = 0;
6811
+ for (let j = from; j < lines.length; j++) {
6812
+ const line = lines[j];
6813
+ if (MARKED_TOGGLE_HEADING_RE.test(line)) {
6814
+ depth++;
6815
+ continue;
6816
+ }
6817
+ if (line.trim() === TOGGLE_HEADING_END) {
6818
+ if (depth > 0) {
6819
+ depth--;
6820
+ continue;
6821
+ }
6822
+ return { bodyEnd: j, consumed: j };
6823
+ }
6824
+ const heading = ANY_HEADING_RE.exec(line);
6825
+ if (heading && heading[1].length <= level) return { bodyEnd: j, consumed: j - 1 };
6826
+ }
6827
+ return { bodyEnd: lines.length, consumed: lines.length - 1 };
6828
+ }
6829
+
5651
6830
  // src/converter/enhanced-md-converter.ts
5652
6831
  var NOTION_CALLOUT_RE = /^::: callout\n([\s\S]*?)\n:::/gm;
5653
6832
  var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
@@ -5660,10 +6839,14 @@ var NOTION_VIDEO_RE = /[\t ]*<video src="([^"]*)">([\s\S]*?)<\/video>/g;
5660
6839
  var NOTION_PDF_RE = /[\t ]*<pdf src="([^"]*)">([\s\S]*?)<\/pdf>/g;
5661
6840
  var NOTION_FILE_RE = /[\t ]*<file src="([^"]*)">([\s\S]*?)<\/file>/g;
5662
6841
  var NOTION_TAB_RE = /<tab title="([^"]*)">([\s\S]*?)<\/tab>/g;
6842
+ var NOTION_LABELED_PAGE_LINK_RE = new RegExp(
6843
+ `\\[${MARKER_LABEL_CAPTURE}\\]\\(https?:\\/\\/(?:[a-z]+\\.)?notion\\.(?:so|com)\\/(?:p\\/)?([a-f0-9]{32}|[a-f0-9-]{36})\\)`,
6844
+ "g"
6845
+ );
5663
6846
  function notionEnhancedToObsidian(enhanced) {
5664
6847
  let result = enhanced;
6848
+ result = convertToggleHeadings(result);
5665
6849
  result = convertSyncedBlockRef(result);
5666
- result = normalizeCodeBlockToggles(result);
5667
6850
  result = convertContainers(result);
5668
6851
  result = convertFencedCallouts(result);
5669
6852
  result = convertPageMentions(result);
@@ -5673,6 +6856,7 @@ function notionEnhancedToObsidian(enhanced) {
5673
6856
  result = convertMediaTags(result);
5674
6857
  result = convertTabBlocks(result);
5675
6858
  result = preserveUnknownBlocks(result);
6859
+ result = preserveNfmOnlyBlocks(result);
5676
6860
  result = convertNotionMath(result);
5677
6861
  result = convertNotionTables(result);
5678
6862
  result = convertSpans(result);
@@ -5681,13 +6865,24 @@ function notionEnhancedToObsidian(enhanced) {
5681
6865
  result = removeEmptyBlocks(result);
5682
6866
  result = unescapePipes(result);
5683
6867
  result = unescapeNotionChars(result);
5684
- result = unescapeWikilinkBrackets(result);
6868
+ result = unescapeBrackets(result);
5685
6869
  result = ensureCalloutContinuity(result);
5686
6870
  result = separateAdjacentCallouts(result);
6871
+ result = clampCalloutIndent(result);
5687
6872
  return result;
5688
6873
  }
5689
- function unescapeWikilinkBrackets(content) {
5690
- return content.replace(/\\\[\\\[([\s\S]*?)\\\]\\\]/g, "[[$1]]");
6874
+ function unescapeBrackets(content) {
6875
+ return mapOutsideCodeFences(
6876
+ content,
6877
+ (segment) => (
6878
+ // 첫 갈래가 escape 된 백슬래시 쌍을 통째로 소비해, 뒤따르는 대괄호가 escape 로
6879
+ // 오인되지 않게 한다. 갈래 순서가 곧 우선순위다.
6880
+ segment.replace(
6881
+ /\\\\|\\\[[^\n[\]]*\\\]\(|\\([[\]])/g,
6882
+ (match, bracket) => bracket === void 0 ? match : bracket
6883
+ )
6884
+ )
6885
+ );
5691
6886
  }
5692
6887
  function obsidianToNotionEnhanced(obsidian) {
5693
6888
  let result = obsidian;
@@ -5696,6 +6891,7 @@ function obsidianToNotionEnhanced(obsidian) {
5696
6891
  result = convertObsidianCallouts(result);
5697
6892
  result = restoreMediaTags(result);
5698
6893
  result = restoreUnknownBlocks(result);
6894
+ result = restoreNfmOnlyBlocks(result);
5699
6895
  result = restoreColorSpans(result);
5700
6896
  result = restoreBlockColorMarkers(result);
5701
6897
  result = restoreUnderlineSpans(result);
@@ -5703,11 +6899,12 @@ function obsidianToNotionEnhanced(obsidian) {
5703
6899
  result = restoreWikilinkPreserveLinks(result);
5704
6900
  result = restoreSyncedBlocks(result);
5705
6901
  result = reassembleColumns(result);
6902
+ result = restoreToggleHeadings(result);
5706
6903
  return result;
5707
6904
  }
5708
6905
  var MENTION_PAGE_ID_RE = /<mention-page id="([^"]+)">[\s\S]*?<\/mention-page>/g;
5709
6906
  var WIKILINK_PRESERVE_LINK_RE = new RegExp(
5710
- `\\[([^\\]]+)\\]\\(${WIKILINK_PROTOCOL}([^)]+)\\)`,
6907
+ `\\[${MARKER_LABEL_CAPTURE}\\]\\(${WIKILINK_PROTOCOL}${MARKER_URL_CAPTURE}\\)`,
5711
6908
  "g"
5712
6909
  );
5713
6910
  function convertMentionPageIdToUrl(content) {
@@ -5718,82 +6915,8 @@ function convertMentionPageIdToUrl(content) {
5718
6915
  }
5719
6916
  function restoreWikilinkPreserveLinks(content) {
5720
6917
  return content.replace(WIKILINK_PRESERVE_LINK_RE, (_match, label, enc) => {
5721
- let target = enc;
5722
- try {
5723
- target = decodeURIComponent(enc);
5724
- } catch {
5725
- }
5726
- return target === label ? `[[${target}]]` : `[[${target}|${label}]]`;
5727
- });
5728
- }
5729
- function fenceFor(body) {
5730
- let longest = 0;
5731
- const re = /`+/g;
5732
- let m;
5733
- while ((m = re.exec(body)) !== null) longest = Math.max(longest, m[0].length);
5734
- return "`".repeat(Math.max(3, longest + 1));
5735
- }
5736
- function dedentContainerBody(text) {
5737
- const lines = text.split("\n");
5738
- while (lines.length && lines[0].trim() === "") lines.shift();
5739
- while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
5740
- const kinds = [];
5741
- let inCode = false;
5742
- let fenceChar = "";
5743
- let fenceLen = 0;
5744
- let inTable = false;
5745
- for (const line of lines) {
5746
- const fence = /^[\t ]*(`{3,}|~{3,})(.*)$/.exec(line);
5747
- if (inCode) {
5748
- if (fence && fence[1][0] === fenceChar && fence[1].length >= fenceLen && fence[2].trim() === "") {
5749
- inCode = false;
5750
- kinds.push("fence");
5751
- } else {
5752
- kinds.push("code");
5753
- }
5754
- } else if (inTable) {
5755
- const closes = /<\/table>/.test(line);
5756
- kinds.push(closes ? "fence" : "code");
5757
- if (closes) inTable = false;
5758
- } else if (fence) {
5759
- inCode = true;
5760
- fenceChar = fence[1][0];
5761
- fenceLen = fence[1].length;
5762
- kinds.push("fence");
5763
- } else if (/^[\t ]*<table[^>]*>/.test(line)) {
5764
- if (!/<\/table>/.test(line)) inTable = true;
5765
- kinds.push("fence");
5766
- } else {
5767
- kinds.push("prose");
5768
- }
5769
- }
5770
- let min = Infinity;
5771
- lines.forEach((l, i) => {
5772
- if (kinds[i] !== "prose" || l.trim() === "") return;
5773
- min = Math.min(min, /^[\t ]*/.exec(l)[0].length);
6918
+ return formatWikilink(decodeMarkerTarget(enc), label);
5774
6919
  });
5775
- if (!Number.isFinite(min)) min = 0;
5776
- return lines.map((l, i) => {
5777
- if (l.trim() === "") return "";
5778
- if (kinds[i] === "code") return l;
5779
- if (kinds[i] === "fence") return l.replace(/^[\t ]+/, "");
5780
- return l.slice(min);
5781
- }).join("\n");
5782
- }
5783
- function normalizeCodeBlockToggles(content) {
5784
- return content.replace(
5785
- /^[\t ]*- (.+)\n[\t ]*(`{3,})(\w*)\n([\s\S]*?)\n[\t ]*\2[\t ]*$/gm,
5786
- (_match, title, _open, lang, rawBody) => {
5787
- const body = dedentContainerBody(rawBody);
5788
- const fence = fenceFor(body);
5789
- return `<details>
5790
- <summary>${title.trim()}</summary>
5791
- ${fence}${lang}
5792
- ${body}
5793
- ${fence}
5794
- </details>`;
5795
- }
5796
- );
5797
6920
  }
5798
6921
  var NO_CONTAINER_BODY = "(?:(?!<details|<callout|<columns)[\\s\\S])*?";
5799
6922
  var SUMMARY_TITLE = "((?:(?!<\\/summary>|<details|<summary>)[\\s\\S])*?)";
@@ -5802,14 +6925,17 @@ var INNERMOST_DETAILS_RE = new RegExp(
5802
6925
  "g"
5803
6926
  );
5804
6927
  var INNERMOST_CALLOUT_RE = new RegExp(
5805
- `([\\t ]*)<callout([^>]*)>\\n?(${NO_CONTAINER_BODY})<\\/callout>`,
6928
+ `([\\t ]*)${nfmOpenTagSource("callout", "capture")}\\n?(${NO_CONTAINER_BODY})<\\/callout>`,
5806
6929
  "g"
5807
6930
  );
5808
6931
  var INNERMOST_COLUMNS_RE = new RegExp(
5809
- `([\\t ]*)<columns[^>]*>\\n?(${NO_CONTAINER_BODY})<\\/columns>`,
6932
+ `([\\t ]*)${nfmOpenTagSource("columns")}\\n?(${NO_CONTAINER_BODY})<\\/columns>`,
6933
+ "g"
6934
+ );
6935
+ var COLUMN_WRAP_RE = new RegExp(
6936
+ `[\\t ]*${nfmOpenTagSource("column", "capture")}\\n?([\\s\\S]*?)[\\t ]*<\\/column>\\n?`,
5810
6937
  "g"
5811
6938
  );
5812
- var COLUMN_WRAP_RE = /[\t ]*<column(?!s)[^>]*>\n?([\s\S]*?)[\t ]*<\/column>\n?/g;
5813
6939
  function reindentLines(text, indent) {
5814
6940
  if (!indent) return text;
5815
6941
  return text.split("\n").map((line) => line === "" ? line : indent + line).join("\n");
@@ -5819,13 +6945,15 @@ function parseDesignAttrs(attrs) {
5819
6945
  const color = /\bcolor="([^"]*)"/.exec(attrs)?.[1];
5820
6946
  return { icon: icon || void 0, color: color || void 0 };
5821
6947
  }
6948
+ function parseColumnRatio(attrs) {
6949
+ return /\bratio="(\d+(?:\.\d+)?)"/.exec(attrs)?.[1];
6950
+ }
5822
6951
  function toggleToCallout(title, rawBody, color) {
5823
6952
  const body = dedentContainerBody(rawBody);
5824
6953
  const head = ["> [!toggle]-", title.trim(), color ? toggleColorMarker(color) : ""].filter((part) => part !== "").join(" ");
5825
6954
  if (body.trim() === "") return head;
5826
- const calloutBody = body.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n");
5827
6955
  return `${head}
5828
- ${calloutBody}`;
6956
+ ${quoteCalloutBody(body)}`;
5829
6957
  }
5830
6958
  function flattenColumns(body) {
5831
6959
  const cols = [];
@@ -5834,13 +6962,16 @@ function flattenColumns(body) {
5834
6962
  let matched = false;
5835
6963
  while ((m = re.exec(body)) !== null) {
5836
6964
  matched = true;
5837
- const col = dedentContainerBody(m[1]);
5838
- if (col.trim() !== "") cols.push(col);
6965
+ cols.push({ ratio: parseColumnRatio(m[1] ?? ""), body: dedentContainerBody(m[2]) });
5839
6966
  }
5840
6967
  if (!matched) return dedentContainerBody(body);
5841
- if (cols.length === 0) return "";
5842
- return [COLUMN_LIST_START, ...cols.map((c) => `${COLUMN_SEP}
5843
- ${c}`), COLUMN_LIST_END].join("\n");
6968
+ if (cols.every((c) => c.body.trim() === "")) return "";
6969
+ return [
6970
+ COLUMN_LIST_START,
6971
+ ...cols.map((c) => `${columnMarker(c.ratio)}
6972
+ ${c.body}`),
6973
+ COLUMN_LIST_END
6974
+ ].join("\n");
5844
6975
  }
5845
6976
  function convertContainers(content) {
5846
6977
  let result = content;
@@ -5861,39 +6992,30 @@ function convertContainers(content) {
5861
6992
  );
5862
6993
  if (result === before) break;
5863
6994
  }
5864
- return stripQuotedColumnMarkers(result);
6995
+ return result;
5865
6996
  }
5866
- var QUOTED_COLUMN_EDGE_RE = new RegExp(
5867
- `^(?:>[ \\t]?)+[ \\t]*%%${MARKER_BRAND_RE}:column-list:(?:start|end)%%[ \\t]*\\n?`,
5868
- "gm"
5869
- );
5870
- var QUOTED_COLUMN_SEP_RE = new RegExp(
5871
- `^((?:>[ \\t]?)+)[ \\t]*%%${MARKER_BRAND_RE}:column%%[ \\t]*$`,
5872
- "gm"
5873
- );
5874
- var QUOTED_COLUMN_INLINE_RE = new RegExp(
5875
- `[ \\t]*%%${MARKER_BRAND_RE}:column(?:-list:(?:start|end))?%%`,
5876
- "g"
6997
+ var COLUMN_MARKER_LINE_RE = new RegExp(
6998
+ `^${CONTAINER_PREFIX_SOURCE}(?:${COLUMN_SEP_SOURCE}|%%${MARKER_BRAND_RE}:column-list:(?:start|end)%%)[\\t ]*$`
5877
6999
  );
5878
- function stripQuotedColumnMarkers(content) {
5879
- return content.replace(QUOTED_COLUMN_EDGE_RE, "").replace(QUOTED_COLUMN_SEP_RE, (_m, prefix) => prefix.trimEnd()).replace(
5880
- /^(?:>[ \t]?).*%%.*$/gm,
5881
- (line) => line.replace(QUOTED_COLUMN_INLINE_RE, "").trimEnd()
5882
- );
7000
+ var QUOTED_LINE_RE = /^[\t ]*>/;
7001
+ function isStructuralFirstLine(line) {
7002
+ return COLUMN_MARKER_LINE_RE.test(line) || QUOTED_LINE_RE.test(line);
5883
7003
  }
5884
7004
  function calloutBodyToObsidian(body, style) {
5885
7005
  const lines = dedentContainerBody(body).split("\n").filter((l, i, arr) => !(i === 0 && l === "") && !(i === arr.length - 1 && l === ""));
5886
- const firstLine = lines[0] ?? "";
7006
+ const titleIndex = isStructuralFirstLine(lines[0] ?? "") ? -1 : 0;
7007
+ const firstLine = titleIndex === 0 ? lines[0] ?? "" : "";
5887
7008
  const emojiMatch = style?.icon || style?.color ? null : /^([\p{Emoji}️‍]+)\s*(.*)/u.exec(firstLine);
5888
7009
  const rawIcon = style?.icon ?? (emojiMatch ? emojiMatch[1] : void 0);
5889
7010
  const icon = rawIcon !== void 0 && /^https?:\/\//i.test(rawIcon) ? void 0 : rawIcon;
5890
7011
  const type = icon ? emojiToCalloutType(icon) : "note";
5891
7012
  const title = emojiMatch ? emojiMatch[2] : firstLine;
5892
- const rest = lines.slice(1).join("\n").trim();
7013
+ const rest = lines.slice(titleIndex + 1).join("\n").replace(/^(?:[\t ]*\n)+/, "").replace(/\n[\t ]*$/, "");
5893
7014
  const needsMarker = Boolean(style?.color) || icon !== void 0 && calloutTypeToEmoji(type) !== icon;
5894
7015
  const styleTail = needsMarker ? ` ${calloutStyleMarker({ icon, color: style?.color })}` : "";
5895
7016
  const calloutTitle = title ? `> [!${type}] ${title}${styleTail}` : `> [!${type}]${styleTail}`;
5896
- const calloutBody = rest ? "\n" + rest.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n") : "";
7017
+ const calloutBody = rest.trim() ? `
7018
+ ${quoteCalloutBody(rest)}` : "";
5897
7019
  return calloutTitle + calloutBody;
5898
7020
  }
5899
7021
  function convertFencedCallouts(content) {
@@ -5908,6 +7030,10 @@ function convertPageMentions(content) {
5908
7030
  /<mention-page\s+url="https?:\/\/(?:[a-z]+\.)?notion\.(?:so|com)\/(?:p\/)?([a-f0-9]{32})"[^>]*?(?:\/>|>[\s\S]*?<\/mention-page>)/g,
5909
7031
  (_match, id) => `[[notion:${id}]]`
5910
7032
  );
7033
+ result = result.replace(NOTION_LABELED_PAGE_LINK_RE, (_match, label, id) => {
7034
+ const normalized = id.replace(/-/g, "");
7035
+ return `[[notion:${normalized}|${label}]]`;
7036
+ });
5911
7037
  return result;
5912
7038
  }
5913
7039
  function convertUserMentions(content) {
@@ -5918,6 +7044,10 @@ function convertDateMentions(content) {
5918
7044
  return end ? `${start} \u2192 ${end}` : start;
5919
7045
  });
5920
7046
  }
7047
+ function degradeLink(label, url, marker) {
7048
+ return `[${label}](${url.replace(/\(/g, "%28").replace(/\)/g, "%29")})${marker}`;
7049
+ }
7050
+ var DEGRADE_LINK_SOURCE = "(?:\\[[^\\]]*\\]\\([^)]*\\))?";
5921
7051
  function preserveUnknownBlocks(content) {
5922
7052
  let result = content.replace(NOTION_UNKNOWN_RE, (_match, id, attrs) => {
5923
7053
  const typeMatch = /type="([^"]*)"/.exec(attrs);
@@ -5930,34 +7060,48 @@ function preserveUnknownBlocks(content) {
5930
7060
  const blockType = altMatch?.[1] ?? "bookmark";
5931
7061
  const marker = compactMarker(`unknown:id=${encodeURIComponent(url)}&type=${blockType}`);
5932
7062
  const label = blockType === "embed" ? "\u{1F517} Embed" : blockType === "bookmark" ? "\u{1F516} Bookmark" : `\u{1F517} ${blockType}`;
5933
- return `[${label}](${url})${marker}`;
7063
+ return degradeLink(label, url, marker);
5934
7064
  });
5935
7065
  return result;
5936
7066
  }
5937
- function convertSyncedBlockRef(content) {
5938
- const unwrap = (match, openRe, closeRe) => match.replace(openRe, "").replace(closeRe, "").split("\n").map((l) => l.replace(/^\t/, "")).join("\n").trim();
5939
- let result = content.replace(
5940
- /<synced_block_reference([^>]*)>[\s\S]*?<\/synced_block_reference>/g,
5941
- (match, attrs) => {
5942
- const inner = unwrap(match, /<synced_block_reference[^>]*>\n?/, /<\/synced_block_reference>/);
5943
- const url = /url="([^"]*)"/.exec(attrs)?.[1];
5944
- if (!url) return inner;
5945
- return `${syncedStartMarker("ref", url)}
5946
- ${inner}
5947
- ${SYNCED_END}`;
5948
- }
5949
- );
7067
+ var NOTION_TOC_RE = /<table_of_contents(?:\s+color="([^"]*)")?\s*\/>/g;
7068
+ var NOTION_EMBED_RE = /<embed\s+src="([^"]*)"[^>]*>\s*<\/embed>/g;
7069
+ var NOTION_UNKNOWN_MENTION_RE = /<unknown_mention\s+url="([^"]*)"([^>]*?)\/>/g;
7070
+ function preserveNfmOnlyBlocks(content) {
7071
+ let result = content.replace(NOTION_TOC_RE, (_match, color) => tocMarker(color));
5950
7072
  result = result.replace(
5951
- /<synced_block([^>]*)>[\s\S]*?<\/synced_block>/g,
5952
- (match, attrs) => {
5953
- const inner = unwrap(match, /<synced_block[^>]*>\n?/, /<\/synced_block>/);
5954
- const url = /url="([^"]*)"/.exec(attrs)?.[1];
5955
- if (!url) return inner;
5956
- return `${syncedStartMarker("orig", url)}
7073
+ NOTION_EMBED_RE,
7074
+ (_match, src) => degradeLink("\u{1F517} Embed", src, embedMarker(src))
7075
+ );
7076
+ return result.replace(
7077
+ NOTION_UNKNOWN_MENTION_RE,
7078
+ (_match, url, attrs) => unknownMentionMarker(url, /alt="([^"]*)"/.exec(attrs)?.[1])
7079
+ );
7080
+ }
7081
+ function convertSyncedBlockRef(content) {
7082
+ const rebuild = (match, indent, attrs, kind, openRe, closeRe) => {
7083
+ const inner = dedentContainerBody(
7084
+ match.slice(indent.length).replace(openRe, "").replace(closeRe, "")
7085
+ );
7086
+ const url = /url="([^"]*)"/.exec(attrs)?.[1];
7087
+ if (!url) return reindentLines(inner, indent);
7088
+ return reindentLines(`${syncedStartMarker(kind, url)}
5957
7089
  ${inner}
5958
- ${SYNCED_END}`;
5959
- }
7090
+ ${SYNCED_END}`, indent);
7091
+ };
7092
+ const replaceSynced = (text, name, kind) => text.replace(
7093
+ new RegExp(`([\\t ]*)${nfmOpenTagSource(name, "capture")}[\\s\\S]*?<\\/${name}>`, "g"),
7094
+ (match, indent, attrs) => rebuild(
7095
+ match,
7096
+ indent,
7097
+ attrs,
7098
+ kind,
7099
+ new RegExp(`${nfmOpenTagSource(name)}\\n?`),
7100
+ new RegExp(`</${name}>`)
7101
+ )
5960
7102
  );
7103
+ let result = replaceSynced(content, "synced_block_reference", "ref");
7104
+ result = replaceSynced(result, "synced_block", "orig");
5961
7105
  return result;
5962
7106
  }
5963
7107
  var SYNCED_MARKER_PAIR_RE = new RegExp(
@@ -5975,7 +7119,7 @@ function restoreSyncedBlocks(content) {
5975
7119
  }
5976
7120
  if (!url) return body.trim();
5977
7121
  const tag = kind === "ref" ? "synced_block_reference" : "synced_block";
5978
- const indented = body.trim().split("\n").map((l) => l.length > 0 ? ` ${l}` : l).join("\n");
7122
+ const indented = indentContainerBody(body.trim());
5979
7123
  return `<${tag} url="${url}">
5980
7124
  ${indented}
5981
7125
  </${tag}>`;
@@ -6025,24 +7169,31 @@ function convertSpans(content) {
6025
7169
  }
6026
7170
  var TOGGLE_COLOR_MARKER_RE = new RegExp(`\\s*%%${MARKER_BRAND_RE}:toggle-color:([a-z_]+)%%`);
6027
7171
  function convertTogglesToHtml(content) {
6028
- const calloutToggleRe = /^> \[!toggle\]-[ \t]*(.*)\n?((?:>.*\n?)*)/gm;
7172
+ const calloutToggleRe = /^([ \t]{0,3})> \[!toggle\]-[ \t]*(.*)((?:\n\1>.*)*)/gm;
6029
7173
  let result = content;
6030
7174
  let prev = "";
6031
7175
  let safety = 0;
6032
7176
  while (result !== prev && safety++ < 100) {
6033
7177
  prev = result;
6034
- result = result.replace(calloutToggleRe, (_match, title, body) => {
6035
- const colorMatch = TOGGLE_COLOR_MARKER_RE.exec(title);
6036
- const cleanTitle = colorMatch ? title.replace(TOGGLE_COLOR_MARKER_RE, "") : title;
6037
- const attrs = colorMatch ? ` color="${colorMatch[1]}"` : "";
6038
- const bodyText = body.split("\n").map((line) => line.replace(/^>\s?/, "")).join("\n").trim();
6039
- return `<details${attrs}>
7178
+ result = result.replace(
7179
+ calloutToggleRe,
7180
+ (_match, indent, rawTitle, body) => {
7181
+ const { depth, title } = readCalloutIndentDepth(indent, rawTitle);
7182
+ const colorMatch = TOGGLE_COLOR_MARKER_RE.exec(title);
7183
+ const cleanTitle = colorMatch ? title.replace(TOGGLE_COLOR_MARKER_RE, "") : title;
7184
+ const attrs = colorMatch ? ` color="${colorMatch[1]}"` : "";
7185
+ const bodyText = restoreBodyIndent(
7186
+ body.split("\n").map((line) => line.replace(/^[ \t]*>\s?/, "")).join("\n")
7187
+ ).trim();
7188
+ const html = `<details${attrs}>
6040
7189
  <summary>${cleanTitle.trim()}</summary>
6041
7190
 
6042
7191
  ${bodyText}
6043
7192
 
6044
7193
  </details>`;
6045
- });
7194
+ return applyCalloutIndent(html, depth);
7195
+ }
7196
+ );
6046
7197
  }
6047
7198
  const startRe = new RegExp(escapeRegex3(TOGGLE_START), "g");
6048
7199
  const endRe = new RegExp(escapeRegex3(TOGGLE_END), "g");
@@ -6064,7 +7215,7 @@ ${body}
6064
7215
  result = result.slice(0, startIdx) + html + result.slice(endIdx + TOGGLE_END.length);
6065
7216
  }
6066
7217
  result = result.replace(startRe, "").replace(endRe, "");
6067
- result = result.replace(/^(<\/details>)(?!\n|$)/gm, "$1\n");
7218
+ result = result.replace(/^([\t ]*<\/details>)(?!\n|$)/gm, "$1\n");
6068
7219
  return result;
6069
7220
  }
6070
7221
  function findMatchingEnd(content, startFrom) {
@@ -6103,10 +7254,12 @@ function convertObsidianCallouts(content) {
6103
7254
  const result = [];
6104
7255
  let i = 0;
6105
7256
  while (i < lines.length) {
6106
- const headerMatch = /^> \[!(\w+)\]([-+])?[ \t]*(.*)$/.exec(lines[i]);
6107
- const type = headerMatch?.[1]?.toLowerCase();
7257
+ const headerMatch = /^([ \t]{0,3})> \[!(\w+)\]([-+])?[ \t]*(.*)$/.exec(lines[i]);
7258
+ const type = headerMatch?.[2]?.toLowerCase();
6108
7259
  if (headerMatch && type !== "toggle" && type !== "tab") {
6109
- let title = headerMatch[3];
7260
+ const indentRead = readCalloutIndentDepth(headerMatch[1], headerMatch[4]);
7261
+ const depth = indentRead.depth;
7262
+ let title = indentRead.title;
6110
7263
  let style = {};
6111
7264
  const styleMatch = CALLOUT_STYLE_MARKER_RE.exec(title);
6112
7265
  if (styleMatch) {
@@ -6117,21 +7270,23 @@ function convertObsidianCallouts(content) {
6117
7270
  }
6118
7271
  const bodyLines = [];
6119
7272
  i++;
6120
- while (i < lines.length && (lines[i] === ">" || lines[i].startsWith("> "))) {
6121
- bodyLines.push(lines[i] === ">" ? "" : lines[i].slice(2));
7273
+ const quote = `${headerMatch[1]}>`;
7274
+ while (i < lines.length && (lines[i] === quote || lines[i].startsWith(`${quote} `))) {
7275
+ bodyLines.push(lines[i] === quote ? "" : lines[i].slice(quote.length + 1));
6122
7276
  i++;
6123
7277
  }
6124
- const innerConverted = convertObsidianCallouts(convertTogglesToHtml(bodyLines.join("\n")));
7278
+ const innerConverted = convertObsidianCallouts(
7279
+ convertTogglesToHtml(restoreBodyIndent(bodyLines.join("\n")))
7280
+ );
6125
7281
  const attrs = `${style.icon ? ` icon="${style.icon}"` : ""}${style.color ? ` color="${style.color}"` : ""}`;
6126
- result.push(`<callout${attrs}>`);
7282
+ const block = [`<callout${attrs}>`];
6127
7283
  const cleanTitle = title.trim();
6128
- if (cleanTitle) result.push(` ${cleanTitle}`);
7284
+ if (cleanTitle) block.push(` ${cleanTitle}`);
6129
7285
  if (innerConverted.trim() !== "") {
6130
- for (const line of innerConverted.split("\n")) {
6131
- result.push(line === "" ? "" : ` ${line}`);
6132
- }
7286
+ block.push(...indentContainerBody(innerConverted).split("\n"));
6133
7287
  }
6134
- result.push("</callout>");
7288
+ block.push("</callout>");
7289
+ result.push(...applyCalloutIndent(block.join("\n"), depth).split("\n"));
6135
7290
  } else {
6136
7291
  result.push(lines[i]);
6137
7292
  i++;
@@ -6162,11 +7317,11 @@ function emojiToCalloutType(emoji) {
6162
7317
  function calloutTypeToEmoji(type) {
6163
7318
  return TYPE_EMOJI_MAP[type.toLowerCase()];
6164
7319
  }
6165
- function escapeRegex3(str) {
6166
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7320
+ function escapeRegex3(str2) {
7321
+ return str2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6167
7322
  }
6168
7323
  var NOTION_PAGE_LINK_RE = /<page url="[^"]*">([\s\S]*?)<\/page>/g;
6169
- var NOTION_EMPTY_BLOCK_RE = /^(?:>[\t ]*)*[\t ]*<empty-block\/>\n?/gm;
7324
+ var NOTION_EMPTY_BLOCK_RE = /^([\t ]*(?:>[\t ]*)*)<empty-block\/>[\t ]*\n?/gm;
6170
7325
  function convertPageLinks(content) {
6171
7326
  return content.replace(NOTION_PAGE_LINK_RE, (_match, text) => {
6172
7327
  const cleaned = text.replace(/\*\*/g, "").trim();
@@ -6178,10 +7333,10 @@ function convertDatabaseBlocks(content) {
6178
7333
  /<database\b([^>]*)>([\s\S]*?)<\/database>/g,
6179
7334
  (_match, attrs, title) => {
6180
7335
  const clean = title.trim();
6181
- const placeholder = `**${clean}** *(Notion DB)*`;
7336
+ const placeholder2 = `**${clean}** *(Notion DB)*`;
6182
7337
  const idMatch = /\burl="[^"]*?([a-f0-9]{32})[^"]*"/.exec(attrs);
6183
- if (!idMatch?.[1]) return placeholder;
6184
- return `${placeholder}${compactMarker(
7338
+ if (!idMatch?.[1]) return placeholder2;
7339
+ return `${placeholder2}${compactMarker(
6185
7340
  `child-database:id=${idMatch[1]}&title=${encodeURIComponent(clean)}`
6186
7341
  )}`;
6187
7342
  }
@@ -6191,14 +7346,14 @@ var TRAILING_BLOCK_COLOR_RE = /[ \t]*\{color="([a-z_]+)"\}(?=[ \t]*$)/gm;
6191
7346
  function convertBlockColorAttrs(content) {
6192
7347
  return content.replace(TRAILING_BLOCK_COLOR_RE, (_m, color) => ` ${blockColorMarker(color)}`).replace(/\s*\{color="[^"]*"\}/g, "");
6193
7348
  }
6194
- var CALLOUT_HEAD_RE = /^((?:> )*)> \[!\w+\][-+]?/;
7349
+ var CALLOUT_HEAD_RE2 = /^([\t ]*)((?:> )*)> \[!\w+\][-+]?/;
6195
7350
  function separateAdjacentCallouts(content) {
6196
7351
  const lines = content.split("\n");
6197
7352
  const out = [];
6198
7353
  for (const line of lines) {
6199
- const head = CALLOUT_HEAD_RE.exec(line);
7354
+ const head = CALLOUT_HEAD_RE2.exec(line);
6200
7355
  if (head && out.length > 0) {
6201
- const parentPrefix = head[1];
7356
+ const parentPrefix = `${head[1]}${head[2]}`;
6202
7357
  const prev = out[out.length - 1];
6203
7358
  if (prev.startsWith(`${parentPrefix}>`)) {
6204
7359
  out.push(parentPrefix.trimEnd());
@@ -6208,8 +7363,13 @@ function separateAdjacentCallouts(content) {
6208
7363
  }
6209
7364
  return out.join("\n");
6210
7365
  }
7366
+ var QUOTE_ONLY_PREFIX_RE = /^[\t ]*(?:>[\t ]*)+$/;
6211
7367
  function removeEmptyBlocks(content) {
6212
- return content.replace(NOTION_EMPTY_BLOCK_RE, "\n");
7368
+ return content.replace(
7369
+ NOTION_EMPTY_BLOCK_RE,
7370
+ (_match, prefix) => QUOTE_ONLY_PREFIX_RE.test(prefix) ? `${prefix.trimEnd()}
7371
+ ` : "\n"
7372
+ );
6213
7373
  }
6214
7374
  var NOTION_INLINE_MATH_RE = /\$`([^`]+)`\$/g;
6215
7375
  var NOTION_BLOCK_MATH_RE = /\$\$\n```\n([\s\S]*?)\n```\n\$\$/g;
@@ -6229,14 +7389,22 @@ $$`;
6229
7389
  function unescapeNotionChars(content) {
6230
7390
  return content.replace(/\\~/g, "~").replace(/\\\^/g, "^");
6231
7391
  }
6232
- var NOTION_TABLE_RE = /<table[^>]*>([\s\S]*?)<\/table>/g;
6233
- var TABLE_ROW_RE2 = /<tr>([\s\S]*?)<\/tr>/g;
7392
+ var NOTION_TABLE_RE = new RegExp(
7393
+ `^(${CONTAINER_PREFIX_SOURCE})${nfmOpenTagSource("table")}([\\s\\S]*?)</table>`,
7394
+ "gm"
7395
+ );
7396
+ var TABLE_ROW_RE2 = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
6234
7397
  var TABLE_CELL_RE = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/g;
6235
7398
  function isAlignmentRow(cells) {
6236
7399
  return cells.every((c) => /^:?-{2,}:?$/.test(c.trim()));
6237
7400
  }
7401
+ function toTableCell(raw) {
7402
+ return raw.trim().replace(/\r?\n/g, "<br>").replace(/(?<!\\)\|/g, "\\|");
7403
+ }
6238
7404
  function convertNotionTables(content) {
6239
- return content.replace(NOTION_TABLE_RE, (_match, tableBody) => {
7405
+ const code = codeInteriorRanges(content);
7406
+ return content.replace(NOTION_TABLE_RE, (_match, prefix, tableBody, offset) => {
7407
+ if (isInsideRanges(code, offset)) return _match;
6240
7408
  const rows = [];
6241
7409
  let rowMatch;
6242
7410
  const rowRe = new RegExp(TABLE_ROW_RE2.source, TABLE_ROW_RE2.flags);
@@ -6245,7 +7413,7 @@ function convertNotionTables(content) {
6245
7413
  let cellMatch;
6246
7414
  const cellRe = new RegExp(TABLE_CELL_RE.source, TABLE_CELL_RE.flags);
6247
7415
  while ((cellMatch = cellRe.exec(rowMatch[1])) !== null) {
6248
- cells.push(cellMatch[1].trim());
7416
+ cells.push(toTableCell(cellMatch[1]));
6249
7417
  }
6250
7418
  if (!isAlignmentRow(cells)) {
6251
7419
  rows.push(cells);
@@ -6262,7 +7430,7 @@ function convertNotionTables(content) {
6262
7430
  lines.push(`| ${padded.map(() => "---").join(" | ")} |`);
6263
7431
  }
6264
7432
  }
6265
- return lines.join("\n");
7433
+ return lines.map((line) => prefix + line).join("\n");
6266
7434
  });
6267
7435
  }
6268
7436
  var OBSIDIAN_MEDIA_AUDIO_RE = /\[🔊\s*([^\]]*)\]\(([^)]+)\)/g;
@@ -6281,16 +7449,15 @@ function restoreMediaTags(content) {
6281
7449
  result = result.replace(OBSIDIAN_MEDIA_FILE_RE, restore("file"));
6282
7450
  return result;
6283
7451
  }
6284
- var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)\n((?:> .*\n?)*)/gm;
7452
+ var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)((?:\n> .*)*)/gm;
6285
7453
  function restoreTabBlocks(content) {
6286
- const result = content.replace(OBSIDIAN_TAB_RE, (_match, title, body) => {
7454
+ return content.replace(OBSIDIAN_TAB_RE, (_match, title, body) => {
6287
7455
  const unquoted = body.split("\n").map((line) => line.replace(/^> /, "")).join("\n").trim();
6288
7456
  return `<tab title="${title}">${unquoted}</tab>`;
6289
7457
  });
6290
- return result.replace(/(<\/tab>)(?!\n|$)/g, "$1\n");
6291
7458
  }
6292
7459
  var OBSIDIAN_UNKNOWN_RE = new RegExp(
6293
- `(?:\\[[^\\]]*\\]\\([^)]*\\))?%%${MARKER_BRAND_RE}:unknown:id=([^&]+)&type=([^%]+)%%`,
7460
+ `${DEGRADE_LINK_SOURCE}%%${MARKER_BRAND_RE}:unknown:id=([^&]+)&type=([^%]+)%%`,
6294
7461
  "g"
6295
7462
  );
6296
7463
  function restoreUnknownBlocks(content) {
@@ -6302,6 +7469,30 @@ function restoreUnknownBlocks(content) {
6302
7469
  return `<unknown id="${id}" type="${type}"/>`;
6303
7470
  });
6304
7471
  }
7472
+ var OBSIDIAN_TOC_RE = new RegExp(`%%${MARKER_BRAND_RE}:toc(?::color=([^%]+))?%%`, "g");
7473
+ var OBSIDIAN_EMBED_RE = new RegExp(
7474
+ `${DEGRADE_LINK_SOURCE}%%${MARKER_BRAND_RE}:embed:src=(${MARKER_PAYLOAD_CHAR}+)%%`,
7475
+ "g"
7476
+ );
7477
+ var OBSIDIAN_UNKNOWN_MENTION_RE = new RegExp(
7478
+ `%%${MARKER_BRAND_RE}:unknown-mention:url=(${MARKER_PAYLOAD_CHAR}+)%%`,
7479
+ "g"
7480
+ );
7481
+ function restoreNfmOnlyBlocks(content) {
7482
+ let result = content.replace(
7483
+ OBSIDIAN_TOC_RE,
7484
+ (_match, color) => color ? `<table_of_contents color="${color}"/>` : `<table_of_contents/>`
7485
+ );
7486
+ result = result.replace(
7487
+ OBSIDIAN_EMBED_RE,
7488
+ (_match, src) => `<embed src="${decodeURIComponent(src)}"></embed>`
7489
+ );
7490
+ return result.replace(OBSIDIAN_UNKNOWN_MENTION_RE, (_match, payload) => {
7491
+ const [url, alt] = payload.split("&alt=");
7492
+ const altAttr = alt ? ` alt="${decodeURIComponent(alt)}"` : "";
7493
+ return `<unknown_mention url="${decodeURIComponent(url ?? "")}"${altAttr}/>`;
7494
+ });
7495
+ }
6305
7496
  var OBSIDIAN_COLOR_RE = new RegExp(
6306
7497
  `%%${MARKER_BRAND_RE}:color:([^%]+)%%([\\s\\S]*?)%%\\/color%%`,
6307
7498
  "g"
@@ -6327,29 +7518,51 @@ var OBSIDIAN_BLOCK_COLOR_RE = new RegExp(
6327
7518
  function restoreBlockColorMarkers(content) {
6328
7519
  return content.replace(OBSIDIAN_BLOCK_COLOR_RE, (_m, color) => ` {color="${color}"}`);
6329
7520
  }
6330
- var COLUMN_REGION_RE = new RegExp(
6331
- `^${escapeRegex3(COLUMN_LIST_START)}[ \\t]*\\n([\\s\\S]*?)\\n?^${escapeRegex3(COLUMN_LIST_END)}[ \\t]*$\\n?`,
7521
+ var INNERMOST_COLUMN_REGION_RE = new RegExp(
7522
+ `^([ \\t]*)${escapeRegex3(COLUMN_LIST_START)}[ \\t]*\\n((?:(?!^[ \\t]*${escapeRegex3(COLUMN_LIST_START)})[\\s\\S])*?)\\n?^[ \\t]*${escapeRegex3(COLUMN_LIST_END)}[ \\t]*$\\n?`,
6332
7523
  "gm"
6333
7524
  );
6334
- function indentColumnLines(text) {
6335
- return text.split("\n").map((line) => line === "" ? "" : ` ${line}`).join("\n");
7525
+ function splitColumnSegments(inner) {
7526
+ const sepRe = new RegExp(`^[ \\t]*${COLUMN_SEP_SOURCE}[ \\t]*$`, "gm");
7527
+ const segments = [];
7528
+ let cursor = 0;
7529
+ let pending = { ratio: void 0, body: "" };
7530
+ let m;
7531
+ while ((m = sepRe.exec(inner)) !== null) {
7532
+ pending.body = inner.slice(cursor, m.index);
7533
+ segments.push(pending);
7534
+ pending = { ratio: m[1], body: "" };
7535
+ cursor = m.index + m[0].length;
7536
+ }
7537
+ pending.body = inner.slice(cursor);
7538
+ segments.push(pending);
7539
+ return segments.map((s) => ({ ...s, body: s.body.replace(/^\n/, "").replace(/\n$/, "") }));
6336
7540
  }
6337
7541
  function reassembleColumns(content) {
6338
- const sepRe = new RegExp(`^${escapeRegex3(COLUMN_SEP)}[ \\t]*$`, "m");
6339
- let result = content.replace(COLUMN_REGION_RE, (_m, inner) => {
6340
- const cols = inner.split(new RegExp(sepRe.source, "gm")).map((c) => c.replace(/^\n/, "").replace(/\n$/, "")).filter((c) => c.trim() !== "");
6341
- if (cols.length === 0) return "";
6342
- const parts = cols.map((c) => `<column>
6343
- ${indentColumnLines(c)}
6344
- </column>`).join("\n");
6345
- return `<columns>
6346
- ${indentColumnLines(parts)}
6347
- </columns>
7542
+ let result = content;
7543
+ let safety = 0;
7544
+ while (result.includes(COLUMN_LIST_START) && safety++ < 100) {
7545
+ const before = result;
7546
+ result = result.replace(INNERMOST_COLUMN_REGION_RE, (_m, indent, inner) => {
7547
+ const segments = splitColumnSegments(indent ? dedentContainerBody(inner) : inner);
7548
+ const cols = segments[0]?.body.trim() === "" ? segments.slice(1) : segments;
7549
+ if (cols.every((c) => c.body.trim() === "")) return "";
7550
+ const parts = cols.map(
7551
+ (c) => `<column${c.ratio ? ` ratio="${c.ratio}"` : ""}>
7552
+ ${indentContainerBody(c.body)}
7553
+ </column>`
7554
+ ).join("\n");
7555
+ const block = `<columns>
7556
+ ${indentContainerBody(parts)}
7557
+ </columns>`;
7558
+ return `${indent ? indentContainerBody(block, indent) : block}
6348
7559
  `;
6349
- });
7560
+ });
7561
+ if (result === before) break;
7562
+ }
6350
7563
  result = result.replace(
6351
7564
  new RegExp(
6352
- `^[>\\t ]*%%${MARKER_BRAND_RE}:(?:column-list:start|column-list:end|column)%%[ \\t]*\\n?`,
7565
+ `^[>\\t ]*(?:%%${MARKER_BRAND_RE}:column-list:(?:start|end)%%|${COLUMN_SEP_SOURCE})[ \\t]*\\n?`,
6353
7566
  "gm"
6354
7567
  ),
6355
7568
  ""
@@ -6361,17 +7574,14 @@ function unescapePipes(content) {
6361
7574
  const result = [];
6362
7575
  let inTable = false;
6363
7576
  for (const line of lines) {
6364
- if (/^\|.*\|$/.test(line.trim()) || /^\|[\s-|]+\|$/.test(line.trim())) {
7577
+ const trimmed = splitContainerPrefix(line).body.trim();
7578
+ if (/^\|.*\|$/.test(trimmed) || /^\|[\s-|]+\|$/.test(trimmed)) {
6365
7579
  inTable = true;
6366
7580
  result.push(line);
6367
- } else {
6368
- if (inTable && line.trim() === "") {
6369
- inTable = false;
6370
- } else if (!/^\|/.test(line.trim())) {
6371
- inTable = false;
6372
- }
6373
- result.push(inTable ? line : line.replace(/\\\|/g, "|"));
7581
+ continue;
6374
7582
  }
7583
+ if (!trimmed.startsWith("|")) inTable = false;
7584
+ result.push(inTable ? line : line.replace(/\\\|/g, "|"));
6375
7585
  }
6376
7586
  return result.join("\n");
6377
7587
  }
@@ -6383,7 +7593,8 @@ function ensureCalloutContinuity(content) {
6383
7593
  if (line.trim() === "" && i > 0 && i < lines.length - 1) {
6384
7594
  const prev = result[result.length - 1] ?? "";
6385
7595
  const next = lines[i + 1] ?? "";
6386
- if (/^>/.test(prev) && /^>/.test(next) && !/^> \[!/.test(next)) {
7596
+ const joinable = (s) => /^>/.test(s) && !MEDIA_PLACEHOLDER_LINE_RE.test(s);
7597
+ if (joinable(prev) && joinable(next) && !/^> \[!/.test(next)) {
6387
7598
  result.push(">");
6388
7599
  continue;
6389
7600
  }
@@ -6392,6 +7603,7 @@ function ensureCalloutContinuity(content) {
6392
7603
  }
6393
7604
  return result.join("\n");
6394
7605
  }
7606
+ var MEDIA_PLACEHOLDER_LINE_RE = new RegExp(`^${MEDIA_PLACEHOLDER_HEAD}`);
6395
7607
  var DatabaseSyncer = class {
6396
7608
  constructor(config, stateDb, notionClient, vaultFs, pipeline, imageHandler) {
6397
7609
  this.config = config;
@@ -6430,10 +7642,11 @@ var DatabaseSyncer = class {
6430
7642
  async pullAll() {
6431
7643
  const databases = this.config.notion.databases;
6432
7644
  if (!databases || databases.length === 0) {
6433
- return { created: 0, updated: 0, conflicts: [], failed: [], writtenPaths: [] };
7645
+ return { created: 0, updated: 0, restored: 0, conflicts: [], failed: [], writtenPaths: [] };
6434
7646
  }
6435
7647
  let created = 0;
6436
7648
  let updated = 0;
7649
+ let restored = 0;
6437
7650
  const conflicts = [];
6438
7651
  const failed = [];
6439
7652
  const writtenPaths = [];
@@ -6442,6 +7655,7 @@ var DatabaseSyncer = class {
6442
7655
  const result = await this.pullDatabase(dbConfig);
6443
7656
  created += result.created;
6444
7657
  updated += result.updated;
7658
+ restored += result.restored;
6445
7659
  conflicts.push(...result.conflicts);
6446
7660
  failed.push(...result.failed);
6447
7661
  writtenPaths.push(...result.writtenPaths);
@@ -6454,12 +7668,12 @@ var DatabaseSyncer = class {
6454
7668
  });
6455
7669
  }
6456
7670
  }
6457
- return { created, updated, conflicts, failed, writtenPaths };
7671
+ return { created, updated, restored, conflicts, failed, writtenPaths };
6458
7672
  }
6459
7673
  async pushAll() {
6460
7674
  const databases = this.config.notion.databases;
6461
7675
  if (!databases || databases.length === 0) {
6462
- return { created: 0, updated: 0, conflicts: [], failed: [], writtenPaths: [] };
7676
+ return { created: 0, updated: 0, restored: 0, conflicts: [], failed: [], writtenPaths: [] };
6463
7677
  }
6464
7678
  let created = 0;
6465
7679
  let updated = 0;
@@ -6479,7 +7693,7 @@ var DatabaseSyncer = class {
6479
7693
  });
6480
7694
  }
6481
7695
  }
6482
- return { created, updated, conflicts: [], failed, writtenPaths: [] };
7696
+ return { created, updated, restored: 0, conflicts: [], failed, writtenPaths: [] };
6483
7697
  }
6484
7698
  async pullDatabase(dbConfig, opts) {
6485
7699
  const pages = await this.notionClient.queryAllDatabasePages(
@@ -6496,6 +7710,7 @@ var DatabaseSyncer = class {
6496
7710
  return {
6497
7711
  created: 0,
6498
7712
  updated: 0,
7713
+ restored: 0,
6499
7714
  conflicts: [],
6500
7715
  failed: [],
6501
7716
  writtenPaths: [],
@@ -6510,28 +7725,35 @@ var DatabaseSyncer = class {
6510
7725
  await this.generateBaseFile(dbConfig, viewsConfig);
6511
7726
  let created = 0;
6512
7727
  let updated = 0;
7728
+ let restored = 0;
6513
7729
  const conflicts = [];
6514
7730
  const failed = [];
6515
7731
  const writtenPaths = [];
6516
7732
  for (const page of pages) {
6517
7733
  try {
6518
7734
  const record = this.stateDb.getByNotionId(page.id);
7735
+ let restoring = false;
6519
7736
  if (record) {
6520
7737
  const misplaced = !isDirectDbRowPath(dbConfig.localFolder, record.obsidianPath);
6521
- if (page.last_edited_time === record.notionLastEdited && !misplaced) continue;
6522
- const outcome = await this.pullDatabasePage(page, dbConfig);
6523
- if (outcome.action === "written") {
6524
- updated++;
6525
- writtenPaths.push(outcome.path);
6526
- } else if (outcome.action === "conflict") {
6527
- conflicts.push(outcome.conflict);
7738
+ if (page.last_edited_time === record.notionLastEdited && !misplaced) {
7739
+ if (await this.vaultFs.exists(record.obsidianPath)) continue;
7740
+ restoring = true;
6528
7741
  }
6529
- } else {
6530
- const outcome = await this.pullDatabasePage(page, dbConfig);
7742
+ }
7743
+ const outcome = await withDeadline(
7744
+ () => this.pullDatabasePage(page, dbConfig),
7745
+ this.config.advanced.itemTimeoutMs,
7746
+ `pull ${dbConfig.localFolder}/${page.id}`
7747
+ );
7748
+ if (!record) {
6531
7749
  created++;
6532
- if (outcome.action === "written") {
6533
- writtenPaths.push(outcome.path);
6534
- }
7750
+ if (outcome.action === "written") writtenPaths.push(outcome.path);
7751
+ } else if (outcome.action === "written") {
7752
+ if (restoring) restored++;
7753
+ else updated++;
7754
+ writtenPaths.push(outcome.path);
7755
+ } else if (outcome.action === "conflict") {
7756
+ conflicts.push(outcome.conflict);
6535
7757
  }
6536
7758
  } catch (error) {
6537
7759
  failed.push({
@@ -6542,9 +7764,9 @@ var DatabaseSyncer = class {
6542
7764
  }
6543
7765
  }
6544
7766
  getLogger().debug(
6545
- `[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8, ${conflicts.length} \uCDA9\uB3CC`
7767
+ `[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8, ${restored} \uBCF5\uC6D0, ${conflicts.length} \uCDA9\uB3CC`
6546
7768
  );
6547
- return { created, updated, conflicts, failed, writtenPaths };
7769
+ return { created, updated, restored, conflicts, failed, writtenPaths };
6548
7770
  }
6549
7771
  async pullDatabaseViews(dbConfig, force = false) {
6550
7772
  try {
@@ -6760,14 +7982,18 @@ var DatabaseSyncer = class {
6760
7982
  );
6761
7983
  if (existingRecord) {
6762
7984
  let localContent = "";
7985
+ let localExists = true;
7986
+ const readPath = recordPath ?? filePath;
6763
7987
  try {
6764
- localContent = await this.vaultFs.readFile(recordPath ?? filePath);
7988
+ localContent = await this.vaultFs.readFile(readPath);
6765
7989
  } catch {
6766
7990
  localContent = "";
7991
+ localExists = await this.vaultFs.exists(readPath);
6767
7992
  }
6768
7993
  const resolution = resolvePullConflict({
6769
7994
  record: existingRecord,
6770
7995
  localContent,
7996
+ localExists,
6771
7997
  remoteContent: finalContent,
6772
7998
  remoteChange: {
6773
7999
  pageId: page.id,
@@ -6827,101 +8053,24 @@ var DatabaseSyncer = class {
6827
8053
  }
6828
8054
  async pushDatabase(dbConfig) {
6829
8055
  const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
6830
- this.propertyMapper.loadSchema(schema);
6831
- const allFiles = await this.vaultFs.listMarkdownFiles();
6832
- const dbFiles = selectDbRowFiles(allFiles, dbConfig.localFolder);
6833
- const livePaths = new Set(dbFiles.map((f) => f.path));
6834
- const orphanByHash = this.buildOrphanHashIndex(dbConfig.databaseId, livePaths);
6835
- let created = 0;
6836
- let updated = 0;
6837
- const failed = [];
6838
- for (const file of dbFiles) {
6839
- try {
6840
- const content = await this.vaultFs.readFile(file.path);
6841
- const hash = computeHash(content);
6842
- const record = this.stateDb.getByPath(file.path);
6843
- if (record?.contentHash === hash) continue;
6844
- const parsed = matter(content);
6845
- const frontmatter = parsed.data;
6846
- const body = parsed.content;
6847
- const title = frontmatter.title ?? extractTitleFromPath(file.path);
6848
- delete frontmatter.title;
6849
- const notionProps = this.propertyMapper.toNotionProperties(frontmatter, title);
6850
- const enhanced = obsidianToNotionEnhanced(body.trim());
6851
- if (record?.notionPageId) {
6852
- try {
6853
- await this.notionClient.updatePageProperties(record.notionPageId, notionProps);
6854
- await this.notionClient.replacePageMarkdown(record.notionPageId, enhanced);
6855
- } catch (error) {
6856
- this.stateDb.updateStatus(record.id, "error");
6857
- failed.push({
6858
- path: file.path,
6859
- operation: "update",
6860
- error: error instanceof Error ? error.message : String(error)
6861
- });
6862
- continue;
6863
- }
6864
- const updatedPage = await this.notionClient.getPage(record.notionPageId);
6865
- this.stateDb.transaction(() => {
6866
- this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
6867
- this.stateDb.updateStatus(record.id, "synced");
6868
- this.stateDb.setNotionLastEdited(record.id, updatedPage.last_edited_time);
6869
- this.stateDb.upsertWikilink({
6870
- obsidianPath: file.path,
6871
- notionPageId: record.notionPageId,
6872
- title,
6873
- aliases: extractAliases(frontmatter)
6874
- });
6875
- });
6876
- updated++;
6877
- } else {
6878
- const renamed = orphanByHash.get(hash);
6879
- if (renamed?.notionPageId) {
6880
- const movedPageId = renamed.notionPageId;
6881
- orphanByHash.delete(hash);
6882
- this.stateDb.transaction(() => {
6883
- this.stateDb.updatePath(renamed.id, file.path);
6884
- this.stateDb.updateHash(renamed.id, hash, Buffer.from(content, "utf-8"));
6885
- this.stateDb.updateStatus(renamed.id, "synced");
6886
- this.stateDb.upsertWikilink({
6887
- obsidianPath: file.path,
6888
- notionPageId: movedPageId,
6889
- title,
6890
- aliases: extractAliases(frontmatter)
6891
- });
6892
- });
6893
- updated++;
6894
- continue;
6895
- }
6896
- const page = await this.notionClient.createPageWithMarkdown({
6897
- parentId: dbConfig.databaseId,
6898
- parentType: "database",
6899
- title,
6900
- markdown: enhanced,
6901
- properties: notionProps
6902
- });
6903
- this.stateDb.transaction(() => {
6904
- this.stateDb.upsert({
6905
- obsidianPath: file.path,
6906
- notionPageId: page.id,
6907
- notionParentId: dbConfig.databaseId,
6908
- contentHash: hash,
6909
- notionLastEdited: page.last_edited_time,
6910
- localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
6911
- syncDirection: "both",
6912
- fileType: "db-row",
6913
- status: "synced",
6914
- baseSnapshot: Buffer.from(content, "utf-8")
6915
- });
6916
- this.stateDb.upsertWikilink({
6917
- obsidianPath: file.path,
6918
- notionPageId: page.id,
6919
- title,
6920
- aliases: extractAliases(frontmatter)
6921
- });
6922
- });
6923
- created++;
6924
- }
8056
+ this.propertyMapper.loadSchema(schema);
8057
+ const allFiles = await this.vaultFs.listMarkdownFiles();
8058
+ const dbFiles = selectDbRowFiles(allFiles, dbConfig.localFolder);
8059
+ const livePaths = new Set(dbFiles.map((f) => f.path));
8060
+ const orphanByHash = this.buildOrphanHashIndex(dbConfig.databaseId, livePaths);
8061
+ let created = 0;
8062
+ let updated = 0;
8063
+ const failed = [];
8064
+ for (const file of dbFiles) {
8065
+ try {
8066
+ const outcome = await withDeadline(
8067
+ () => this.pushDatabaseRow(file.path, dbConfig, orphanByHash),
8068
+ this.config.advanced.itemTimeoutMs,
8069
+ `push ${file.path}`
8070
+ );
8071
+ if (outcome.action === "created") created++;
8072
+ else if (outcome.action === "updated") updated++;
8073
+ else if (outcome.action === "failed") failed.push(outcome.failure);
6925
8074
  } catch (error) {
6926
8075
  failed.push({
6927
8076
  path: file.path,
@@ -6931,7 +8080,105 @@ var DatabaseSyncer = class {
6931
8080
  }
6932
8081
  }
6933
8082
  getLogger().debug(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
6934
- return { created, updated, conflicts: [], failed, writtenPaths: [] };
8083
+ return { created, updated, restored: 0, conflicts: [], failed, writtenPaths: [] };
8084
+ }
8085
+ /**
8086
+ * db-row 파일 1건을 Notion 으로 밀어 올린다 — 생성·갱신·rename 재매핑·스킵을 모두 담당.
8087
+ *
8088
+ * 호출부의 루프에서 떼어낸 이유는 상한(`withDeadline`)을 걸 단위가 필요해서다(R9e).
8089
+ * 그래서 결과는 카운터를 직접 건드리지 않고 {@link PushRowOutcome} 으로 돌려준다 —
8090
+ * 상한에 걸린 행이 카운터를 절반만 올린 채 끊기는 일이 구조적으로 불가능해진다.
8091
+ *
8092
+ * 본문 push 실패만 `failed` 를 **반환**하고 나머지 예외는 그대로 던진다. 둘의 차이는
8093
+ * 상태 처리에 있다: 전자는 레코드를 `error` 로 내려 해시 전진을 막아야 하므로 여기서
8094
+ * 처리하고, 후자는 호출부의 catch 가 일괄 기록한다(기존 동작 그대로).
8095
+ */
8096
+ async pushDatabaseRow(filePath, dbConfig, orphanByHash) {
8097
+ const content = await this.vaultFs.readFile(filePath);
8098
+ const hash = computeHash(content);
8099
+ const record = this.stateDb.getByPath(filePath);
8100
+ if (record?.contentHash === hash) return { action: "skipped" };
8101
+ const parsed = matter(content);
8102
+ const frontmatter = parsed.data;
8103
+ const body = parsed.content;
8104
+ const title = frontmatter.title ?? extractTitleFromPath(filePath);
8105
+ delete frontmatter.title;
8106
+ const notionProps = this.propertyMapper.toNotionProperties(frontmatter, title);
8107
+ const enhanced = obsidianToNotionEnhanced(body.trim());
8108
+ if (record?.notionPageId) {
8109
+ try {
8110
+ await this.notionClient.updatePageProperties(record.notionPageId, notionProps);
8111
+ await this.notionClient.replacePageMarkdown(record.notionPageId, enhanced);
8112
+ } catch (error) {
8113
+ this.stateDb.updateStatus(record.id, "error");
8114
+ return {
8115
+ action: "failed",
8116
+ failure: {
8117
+ path: filePath,
8118
+ operation: "update",
8119
+ error: error instanceof Error ? error.message : String(error)
8120
+ }
8121
+ };
8122
+ }
8123
+ const updatedPage = await this.notionClient.getPage(record.notionPageId);
8124
+ this.stateDb.transaction(() => {
8125
+ this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
8126
+ this.stateDb.updateStatus(record.id, "synced");
8127
+ this.stateDb.setNotionLastEdited(record.id, updatedPage.last_edited_time);
8128
+ this.stateDb.upsertWikilink({
8129
+ obsidianPath: filePath,
8130
+ notionPageId: record.notionPageId,
8131
+ title,
8132
+ aliases: extractAliases(frontmatter)
8133
+ });
8134
+ });
8135
+ return { action: "updated" };
8136
+ }
8137
+ const renamed = orphanByHash.get(hash);
8138
+ if (renamed?.notionPageId) {
8139
+ const movedPageId = renamed.notionPageId;
8140
+ orphanByHash.delete(hash);
8141
+ this.stateDb.transaction(() => {
8142
+ this.stateDb.updatePath(renamed.id, filePath);
8143
+ this.stateDb.updateHash(renamed.id, hash, Buffer.from(content, "utf-8"));
8144
+ this.stateDb.updateStatus(renamed.id, "synced");
8145
+ this.stateDb.upsertWikilink({
8146
+ obsidianPath: filePath,
8147
+ notionPageId: movedPageId,
8148
+ title,
8149
+ aliases: extractAliases(frontmatter)
8150
+ });
8151
+ });
8152
+ return { action: "updated" };
8153
+ }
8154
+ const page = await this.notionClient.createPageWithMarkdown({
8155
+ parentId: dbConfig.databaseId,
8156
+ parentType: "database",
8157
+ title,
8158
+ markdown: enhanced,
8159
+ properties: notionProps
8160
+ });
8161
+ this.stateDb.transaction(() => {
8162
+ this.stateDb.upsert({
8163
+ obsidianPath: filePath,
8164
+ notionPageId: page.id,
8165
+ notionParentId: dbConfig.databaseId,
8166
+ contentHash: hash,
8167
+ notionLastEdited: page.last_edited_time,
8168
+ localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
8169
+ syncDirection: "both",
8170
+ fileType: "db-row",
8171
+ status: "synced",
8172
+ baseSnapshot: Buffer.from(content, "utf-8")
8173
+ });
8174
+ this.stateDb.upsertWikilink({
8175
+ obsidianPath: filePath,
8176
+ notionPageId: page.id,
8177
+ title,
8178
+ aliases: extractAliases(frontmatter)
8179
+ });
8180
+ });
8181
+ return { action: "created" };
6935
8182
  }
6936
8183
  /**
6937
8184
  * rename 감지를 위한 고아 레코드 인덱스(내용 해시 → 레코드)를 만든다.
@@ -6962,6 +8209,101 @@ function extractTitleFromPath(filePath) {
6962
8209
  return filename.replace(/\.md$/, "");
6963
8210
  }
6964
8211
 
8212
+ // src/audit/completeness.ts
8213
+ function groupLocalRowsByDatabase(local) {
8214
+ const byDb = /* @__PURE__ */ new Map();
8215
+ for (const record of local.getAll()) {
8216
+ if (record.fileType !== "db-row") continue;
8217
+ if (!record.notionParentId || !record.notionPageId) continue;
8218
+ const dbKey = compactNotionId(record.notionParentId);
8219
+ let rows = byDb.get(dbKey);
8220
+ if (!rows) {
8221
+ rows = /* @__PURE__ */ new Set();
8222
+ byDb.set(dbKey, rows);
8223
+ }
8224
+ rows.add(compactNotionId(record.notionPageId));
8225
+ }
8226
+ return byDb;
8227
+ }
8228
+ async function verifyDatabaseCompleteness(remote, local, options) {
8229
+ const localRowsByDb = groupLocalRowsByDatabase(local);
8230
+ const targets = [];
8231
+ const seen = /* @__PURE__ */ new Set();
8232
+ for (const id of options?.databaseIds ?? []) {
8233
+ const key = compactNotionId(id);
8234
+ if (seen.has(key)) continue;
8235
+ seen.add(key);
8236
+ targets.push({ databaseId: id, key });
8237
+ }
8238
+ for (const key of localRowsByDb.keys()) {
8239
+ if (seen.has(key)) continue;
8240
+ seen.add(key);
8241
+ targets.push({ databaseId: key, key });
8242
+ }
8243
+ const databases = [];
8244
+ const failures = [];
8245
+ let remoteTotal = 0;
8246
+ let localTotal = 0;
8247
+ for (const target of targets) {
8248
+ const localIds = localRowsByDb.get(target.key) ?? /* @__PURE__ */ new Set();
8249
+ let remoteIds;
8250
+ try {
8251
+ const pages = await remote.queryAllDatabasePages(target.databaseId);
8252
+ remoteIds = new Set(pages.map((p) => compactNotionId(p.id)));
8253
+ } catch (error) {
8254
+ failures.push({
8255
+ databaseId: target.databaseId,
8256
+ error: error instanceof Error ? error.message : String(error)
8257
+ });
8258
+ continue;
8259
+ }
8260
+ const missingIds = [...remoteIds].filter((id) => !localIds.has(id));
8261
+ const extraIds = [...localIds].filter((id) => !remoteIds.has(id));
8262
+ remoteTotal += remoteIds.size;
8263
+ localTotal += localIds.size;
8264
+ databases.push({
8265
+ databaseId: target.databaseId,
8266
+ remoteRows: remoteIds.size,
8267
+ localRows: localIds.size,
8268
+ missingIds,
8269
+ extraIds
8270
+ });
8271
+ }
8272
+ const complete = failures.length === 0 && databases.every((d) => d.missingIds.length === 0 && d.extraIds.length === 0);
8273
+ return { databases, failures, remoteTotal, localTotal, complete };
8274
+ }
8275
+ async function verifyPageCompleteness(remote, local, rootPageId) {
8276
+ const localIds = /* @__PURE__ */ new Set();
8277
+ for (const record of local.getAll()) {
8278
+ if (record.fileType === "db-row") continue;
8279
+ if (!record.notionPageId) continue;
8280
+ localIds.add(compactNotionId(record.notionPageId));
8281
+ }
8282
+ let remoteIds;
8283
+ try {
8284
+ const pages = await remote.getPagesUnderRootViaSearch(rootPageId);
8285
+ remoteIds = new Set(pages.map((p) => compactNotionId(p.id)));
8286
+ } catch (error) {
8287
+ return {
8288
+ remotePages: 0,
8289
+ vaultPages: localIds.size,
8290
+ missingIds: [],
8291
+ localOnlyIds: [],
8292
+ error: error instanceof Error ? error.message : String(error),
8293
+ complete: false
8294
+ };
8295
+ }
8296
+ const missingIds = [...remoteIds].filter((id) => !localIds.has(id));
8297
+ const localOnlyIds = [...localIds].filter((id) => !remoteIds.has(id));
8298
+ return {
8299
+ remotePages: remoteIds.size,
8300
+ vaultPages: localIds.size,
8301
+ missingIds,
8302
+ localOnlyIds,
8303
+ complete: missingIds.length === 0
8304
+ };
8305
+ }
8306
+
6965
8307
  // src/conflict/merger.ts
6966
8308
  function threeWayMerge(base, local, remote) {
6967
8309
  const baseLines = base.split("\n");
@@ -7206,6 +8548,18 @@ var ConflictResolver = class {
7206
8548
  }
7207
8549
  };
7208
8550
 
8551
+ // src/utils/path-scope.ts
8552
+ function matchesPathScope(path, scope) {
8553
+ const prefix = scope.endsWith("/") ? scope.slice(0, -1) : scope;
8554
+ if (prefix === "") return true;
8555
+ if (path === prefix) return true;
8556
+ return path.startsWith(`${prefix}/`);
8557
+ }
8558
+ function inAnyPathScope(path, scopes) {
8559
+ if (!scopes || scopes.length === 0) return true;
8560
+ return scopes.some((s) => matchesPathScope(path, s));
8561
+ }
8562
+
7209
8563
  // src/utils/pool.ts
7210
8564
  async function runPool(items, worker, options) {
7211
8565
  const total = items.length;
@@ -7260,6 +8614,59 @@ function resolveFrontmatterRelations(content, idToTitle) {
7260
8614
  return { content: stringifyFrontmatter(parsed.content, resolved), count };
7261
8615
  }
7262
8616
 
8617
+ // src/converter/notion-id-links.ts
8618
+ var NOTION_ID_WIKILINK_RE = /\[\[notion:([a-f0-9]{32}|[a-f0-9-]{36})(?:\|([^[\]]+))?\]\]/g;
8619
+ var compactId = (id) => id.replace(/-/g, "");
8620
+ function resolveNotionIdWikilinks(markdown, lookup) {
8621
+ let resolved = 0;
8622
+ const out = markdown.replace(
8623
+ NOTION_ID_WIKILINK_RE,
8624
+ (match, id, rawAlias) => {
8625
+ const path = lookup(id);
8626
+ if (!path) return match;
8627
+ resolved++;
8628
+ const base = wikilinkTitleFromPath(path);
8629
+ const alias = rawAlias === compactId(id) ? void 0 : rawAlias;
8630
+ return formatWikilink(base, alias);
8631
+ }
8632
+ );
8633
+ return { markdown: out, resolved };
8634
+ }
8635
+ function degradeUnresolvedNotionIdWikilinks(markdown) {
8636
+ let degraded = 0;
8637
+ const out = markdown.replace(
8638
+ NOTION_ID_WIKILINK_RE,
8639
+ (_match, id, alias) => {
8640
+ degraded++;
8641
+ const compact = compactId(id);
8642
+ return `[${alias ?? compact}](https://www.notion.so/${compact})`;
8643
+ }
8644
+ );
8645
+ return { markdown: out, degraded };
8646
+ }
8647
+ var NOTION_RELATIVE_PAGE_LINK_RE = /\[([^\]]+)\]\(\/(?:p\/)?([a-f0-9]{32})(?:\?[^)#]*)?(?:#([^)]*))?\)/g;
8648
+ function resolveNotionRelativePageLinks(markdown, lookup) {
8649
+ let resolved = 0;
8650
+ const out = markdown.replace(NOTION_RELATIVE_PAGE_LINK_RE, (match, label, id) => {
8651
+ const path = lookup(id);
8652
+ if (!path) return match;
8653
+ resolved++;
8654
+ return formatWikilink(wikilinkTitleFromPath(path), label);
8655
+ });
8656
+ return { markdown: out, resolved };
8657
+ }
8658
+ function degradeUnresolvedNotionRelativePageLinks(markdown) {
8659
+ let degraded = 0;
8660
+ const out = markdown.replace(
8661
+ NOTION_RELATIVE_PAGE_LINK_RE,
8662
+ (_match, label, id, anchor) => {
8663
+ degraded++;
8664
+ return `[${label}](https://www.notion.so/${id}${anchor === void 0 ? "" : `#${anchor}`})`;
8665
+ }
8666
+ );
8667
+ return { markdown: out, degraded };
8668
+ }
8669
+
7263
8670
  // src/utils/inline-db-refs.ts
7264
8671
  function extractInlineDbIds(markdown) {
7265
8672
  const ids = /* @__PURE__ */ new Set();
@@ -7363,7 +8770,8 @@ var SyncOrchestrator = class _SyncOrchestrator {
7363
8770
  concurrency: config.advanced.mediaConcurrency,
7364
8771
  maxRetries: config.advanced.mediaMaxRetries,
7365
8772
  retryBaseMs: config.advanced.mediaRetryBaseMs,
7366
- maxFileSizeBytes: config.advanced.maxFileSizeBytes
8773
+ maxFileSizeBytes: config.advanced.maxFileSizeBytes,
8774
+ downloadTimeoutMs: config.advanced.mediaDownloadTimeoutMs
7367
8775
  },
7368
8776
  stateDb
7369
8777
  );
@@ -7371,7 +8779,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
7371
8779
  vaultFs,
7372
8780
  notionClient,
7373
8781
  stateDb,
7374
- config.advanced.fileConcurrency
8782
+ config.advanced.fileConcurrency,
8783
+ {
8784
+ fetch: customFetch,
8785
+ downloadTimeoutMs: config.advanced.mediaDownloadTimeoutMs,
8786
+ itemTimeoutMs: config.advanced.itemTimeoutMs
8787
+ }
7375
8788
  );
7376
8789
  this.propertyMapper = new PropertyMapper();
7377
8790
  this.databaseSyncer = new DatabaseSyncer(
@@ -7419,6 +8832,10 @@ var SyncOrchestrator = class _SyncOrchestrator {
7419
8832
  // column 등 컨테이너에 중첩된 자식 페이지를 놓쳐 폴더노트를 file 로 오분류하던 결함
7420
8833
  // (본문이 최상위로 밀려 ' (1).md' 로 분리)을 차단한다. detectRemoteChanges* 진입 시 재구성.
7421
8834
  _childParentIds = /* @__PURE__ */ new Set();
8835
+ // 이번 pull 실행에서 이미 배정된 파일 경로. 워커 풀이 동시에 도는 동안 동명 페이지가
8836
+ // 같은 경로를 골라 서로를 덮어쓰는 것을 막는다({@link resolveUniqueFilePath}).
8837
+ // 실행마다 비운다 — 지난 실행에서 삭제된 경로를 영구히 막지 않기 위해.
8838
+ claimedPaths = /* @__PURE__ */ new Set();
7422
8839
  // 서브트리 직접 순회(getChildPagesRecursive) 시간 예산. 초과하면 search 기반 디스커버리로
7423
8840
  // 폴백한다. 분기점 근거: 워크스페이스 search 열거는 latency-bound 로 대략 이 수준(수천 페이지
7424
8841
  // 워크스페이스에서 ~100s)이므로, 순회가 이 시간을 넘기면 search 가 더 저렴해진다. 작은 볼트는
@@ -7444,7 +8861,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
7444
8861
  );
7445
8862
  const isDbPath = (path) => dbFolders.some((prefix) => path.startsWith(prefix));
7446
8863
  const excludeSet = options?.excludePaths ? new Set(options.excludePaths) : null;
7447
- const filtered = (options?.paths ? eligible.filter((c) => options.paths.some((p) => c.path.startsWith(p))) : eligible).filter((c) => !isDbPath(c.path) && (!excludeSet || !excludeSet.has(c.path)));
8864
+ const filtered = (options?.paths ? eligible.filter((c) => inAnyPathScope(c.path, options.paths)) : eligible).filter((c) => !isDbPath(c.path) && (!excludeSet || !excludeSet.has(c.path)));
7448
8865
  const hasDbConfigs = (this.config.notion.databases?.length ?? 0) > 0;
7449
8866
  if (filtered.length === 0 && !hasDbConfigs) {
7450
8867
  return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
@@ -7516,7 +8933,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
7516
8933
  if (options?.signal?.aborted) return;
7517
8934
  options?.onProgress?.(++completed, total, { path: change.path, operation: opOf(change) });
7518
8935
  try {
7519
- await applyPushChange(change);
8936
+ await withDeadline(
8937
+ () => applyPushChange(change),
8938
+ this.config.advanced.itemTimeoutMs,
8939
+ `push ${change.path}`
8940
+ );
7520
8941
  } catch {
7521
8942
  retryQueue.push(change);
7522
8943
  }
@@ -7533,7 +8954,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
7533
8954
  retryQueue,
7534
8955
  async (change) => {
7535
8956
  try {
7536
- await applyPushChange(change);
8957
+ await withDeadline(
8958
+ () => applyPushChange(change),
8959
+ this.config.advanced.itemTimeoutMs,
8960
+ `push ${change.path}`
8961
+ );
7537
8962
  getLogger().info(`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC131\uACF5: ${change.path}`);
7538
8963
  } catch (error) {
7539
8964
  failed.push({
@@ -7581,10 +9006,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
7581
9006
  }
7582
9007
  async pull(options) {
7583
9008
  const startTime = Date.now();
9009
+ this.claimedPaths.clear();
7584
9010
  const emptyResult = {
7585
9011
  created: 0,
7586
9012
  updated: 0,
7587
9013
  deleted: 0,
9014
+ restored: 0,
7588
9015
  conflicts: [],
7589
9016
  writtenPaths: [],
7590
9017
  failed: [],
@@ -7597,14 +9024,14 @@ var SyncOrchestrator = class _SyncOrchestrator {
7597
9024
  return emptyResult;
7598
9025
  }
7599
9026
  this.cleanupInterruptedSync();
7600
- const counts = { created: 0, updated: 0, deleted: 0 };
9027
+ const counts = { created: 0, updated: 0, deleted: 0, restored: 0 };
7601
9028
  this._pullImageCount = 0;
7602
9029
  this._pullFileCount = 0;
7603
9030
  this._inlineDbRefs.clear();
7604
9031
  const conflicts = [];
7605
9032
  const writtenPaths = [];
7606
9033
  const failed = [];
7607
- const finalize = async (created, updated, deleted) => {
9034
+ const finalize = async (created, updated, deleted, restored = 0) => {
7608
9035
  const linkCount = writtenPaths.length > 0 ? await this.resolveNotionLinks(writtenPaths) : 0;
7609
9036
  this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
7610
9037
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
@@ -7613,6 +9040,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
7613
9040
  created,
7614
9041
  updated,
7615
9042
  deleted,
9043
+ restored,
7616
9044
  conflicts,
7617
9045
  writtenPaths,
7618
9046
  failed,
@@ -7628,27 +9056,54 @@ var SyncOrchestrator = class _SyncOrchestrator {
7628
9056
  const remoteChanges = canUseIncremental ? await this.detectRemoteChangesIncremental(lastPull) : await this.detectRemoteChanges();
7629
9057
  const filtered = options?.paths ? remoteChanges.filter((c) => {
7630
9058
  const record = this.stateDb.getByNotionId(c.pageId);
7631
- return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
9059
+ return !!record && inAnyPathScope(record.obsidianPath, options.paths);
7632
9060
  }) : remoteChanges;
7633
- if (filtered.length === 0 && (this.config.notion.databases?.length ?? 0) === 0) {
9061
+ const queuedIds = new Set(filtered.map((c) => c.pageId));
9062
+ const restoreChanges = (await this.detectMissingLocalFiles(options?.paths)).filter((r) => r.notionPageId !== null && !queuedIds.has(r.notionPageId)).map((r) => ({
9063
+ pageId: r.notionPageId,
9064
+ type: "modified",
9065
+ lastEdited: r.notionLastEdited ?? r.updatedAt,
9066
+ previousEdited: r.notionLastEdited
9067
+ }));
9068
+ const restoreIds = new Set(restoreChanges.map((c) => c.pageId));
9069
+ const workItems = restoreChanges.length > 0 ? [...filtered, ...restoreChanges] : filtered;
9070
+ if (workItems.length === 0) {
9071
+ let dbCreated = 0;
9072
+ let dbUpdated = 0;
9073
+ let dbRestored = 0;
9074
+ if ((this.config.notion.databases?.length ?? 0) > 0) {
9075
+ try {
9076
+ const dbResult = await this.databaseSyncer.pullAll();
9077
+ dbCreated += dbResult.created;
9078
+ dbUpdated += dbResult.updated;
9079
+ dbRestored += dbResult.restored;
9080
+ conflicts.push(...dbResult.conflicts);
9081
+ failed.push(...dbResult.failed);
9082
+ writtenPaths.push(...dbResult.writtenPaths);
9083
+ } catch (error) {
9084
+ getLogger().warn("[Im-Nobsidian] DB Pull \uC911 \uC624\uB958:", error);
9085
+ }
9086
+ }
7634
9087
  const dbDiscovery = await this.pullDiscoveredDatabases(
7635
9088
  writtenPaths,
7636
9089
  failed,
7637
9090
  conflicts,
7638
9091
  options?.force === true
7639
9092
  );
7640
- return finalize(dbDiscovery.created, dbDiscovery.updated, 0);
7641
- }
7642
- if (filtered.length === 0) {
7643
- return emptyResult;
9093
+ return finalize(
9094
+ dbCreated + dbDiscovery.created,
9095
+ dbUpdated + dbDiscovery.updated,
9096
+ 0,
9097
+ dbRestored + dbDiscovery.restored
9098
+ );
7644
9099
  }
7645
9100
  if (options?.dryRun) {
7646
9101
  const dryCreated = filtered.filter((c) => c.type === "created").length;
7647
9102
  const dryUpdated = filtered.filter((c) => c.type === "modified").length;
7648
9103
  const dryDeleted = filtered.filter((c) => c.type === "deleted").length;
7649
9104
  let dryProgress = 0;
7650
- const dryTotal = filtered.length;
7651
- for (const change of filtered) {
9105
+ const dryTotal = workItems.length;
9106
+ for (const change of workItems) {
7652
9107
  const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
7653
9108
  const record = this.stateDb.getByNotionId(change.pageId);
7654
9109
  const displayPath = record?.obsidianPath ?? change.pageId;
@@ -7658,6 +9113,9 @@ var SyncOrchestrator = class _SyncOrchestrator {
7658
9113
  created: dryCreated,
7659
9114
  updated: dryUpdated,
7660
9115
  deleted: dryDeleted,
9116
+ // 복원은 updated 에 섞지 않는다 — dry-run 이 "수정 N건" 이라고만 말하면
9117
+ // 사용자가 사라진 파일이 되살아난다는 사실을 미리 알 수 없다.
9118
+ restored: restoreChanges.length,
7661
9119
  conflicts: [],
7662
9120
  writtenPaths: [],
7663
9121
  failed: [],
@@ -7668,7 +9126,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
7668
9126
  };
7669
9127
  }
7670
9128
  this.stateDb.setMeta("pull_in_progress", "true");
7671
- const pullTotal = filtered.length;
9129
+ const pullTotal = workItems.length;
7672
9130
  let pullCompleted = 0;
7673
9131
  const toFailure = (change, error) => {
7674
9132
  const record = this.stateDb.getByNotionId(change.pageId);
@@ -7692,7 +9150,8 @@ var SyncOrchestrator = class _SyncOrchestrator {
7692
9150
  conflicts.push(result.conflict);
7693
9151
  } else if (result.path && !result.unchanged) {
7694
9152
  writtenPaths.push(result.path);
7695
- counts.updated++;
9153
+ if (restoreIds.has(change.pageId)) counts.restored++;
9154
+ else counts.updated++;
7696
9155
  }
7697
9156
  return result.path;
7698
9157
  }
@@ -7711,11 +9170,15 @@ var SyncOrchestrator = class _SyncOrchestrator {
7711
9170
  };
7712
9171
  const retryQueue = [];
7713
9172
  await runPool(
7714
- filtered,
9173
+ workItems,
7715
9174
  async (change) => {
7716
9175
  if (options?.signal?.aborted) return;
7717
9176
  try {
7718
- const resultPath = await applyChange(change);
9177
+ const resultPath = await withDeadline(
9178
+ () => applyChange(change),
9179
+ this.config.advanced.itemTimeoutMs,
9180
+ `pull ${this.stateDb.getByNotionId(change.pageId)?.obsidianPath ?? change.pageId}`
9181
+ );
7719
9182
  const record = this.stateDb.getByNotionId(change.pageId);
7720
9183
  const displayPath = resultPath ?? record?.obsidianPath ?? change.pageId;
7721
9184
  const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
@@ -7736,7 +9199,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
7736
9199
  retryQueue,
7737
9200
  async (change) => {
7738
9201
  try {
7739
- await applyChange(change);
9202
+ await withDeadline(
9203
+ () => applyChange(change),
9204
+ this.config.advanced.itemTimeoutMs,
9205
+ `pull ${this.stateDb.getByNotionId(change.pageId)?.obsidianPath ?? change.pageId}`
9206
+ );
7740
9207
  const record = this.stateDb.getByNotionId(change.pageId);
7741
9208
  getLogger().info(
7742
9209
  `[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC131\uACF5: ${record?.obsidianPath ?? change.pageId}`
@@ -7755,6 +9222,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
7755
9222
  const dbResult = await this.databaseSyncer.pullAll();
7756
9223
  counts.created += dbResult.created;
7757
9224
  counts.updated += dbResult.updated;
9225
+ counts.restored += dbResult.restored;
7758
9226
  conflicts.push(...dbResult.conflicts);
7759
9227
  failed.push(...dbResult.failed);
7760
9228
  writtenPaths.push(...dbResult.writtenPaths);
@@ -7771,8 +9239,9 @@ var SyncOrchestrator = class _SyncOrchestrator {
7771
9239
  );
7772
9240
  counts.created += dbDiscovery.created;
7773
9241
  counts.updated += dbDiscovery.updated;
9242
+ counts.restored += dbDiscovery.restored;
7774
9243
  }
7775
- return finalize(counts.created, counts.updated, counts.deleted);
9244
+ return finalize(counts.created, counts.updated, counts.deleted, counts.restored);
7776
9245
  }
7777
9246
  async sync(options) {
7778
9247
  const startTime = Date.now();
@@ -7826,6 +9295,45 @@ var SyncOrchestrator = class _SyncOrchestrator {
7826
9295
  lastSyncAt
7827
9296
  };
7828
9297
  }
9298
+ /**
9299
+ * 볼트 완결성 검증 — 원격에 있는 것이 볼트에 빠짐없이 있는지 대조한다(R11-B · R12-C).
9300
+ *
9301
+ * 기존 게이트(해시 일치·repull 바이트 동일·churn 0)는 전부 **멱등성**을 본다. 매번
9302
+ * 같은 것을 놓치는 체계적 미발견은 그 게이트를 전부 통과한다 — 실제로 296개 DB 행이
9303
+ * 그렇게 침묵 유실됐다. 여기서만 "빠짐없다"를 본다.
9304
+ *
9305
+ * 대조 대상 database id 는 이 클래스가 조립한다: 설정에 적힌 것 + 디스커버리 캐시가
9306
+ * 아는 것 + DB 모드의 루트 DB. `discovered_dbs` 메타 키를 아는 곳을 이 파일 하나로
9307
+ * 유지해, 호출부(CLI·E2E)가 캐시 표현을 각자 다시 해석하지 않게 한다.
9308
+ *
9309
+ * 페이지(R12-C)는 pull 이 쓴 경로가 아니라 **search 열거**로 대조한다. 페이지는 열거
9310
+ * 경로가 둘이고 둘이 같은 집합을 내지 않는 게 애초의 결함이므로(R12-A), 독립된 두 번째
9311
+ * 열거와 맞대 봐야 의미가 있다.
9312
+ */
9313
+ async verifyCompleteness() {
9314
+ const ids = [];
9315
+ if (this.isDatabaseMode) ids.push(this.config.notion.databaseId);
9316
+ for (const db of this.config.notion.databases ?? []) ids.push(db.databaseId);
9317
+ const cachedRaw = this.stateDb.getMeta("discovered_dbs");
9318
+ if (cachedRaw) {
9319
+ try {
9320
+ const cached = JSON.parse(cachedRaw);
9321
+ for (const entry of cached) {
9322
+ if (entry.databaseId) ids.push(entry.databaseId);
9323
+ }
9324
+ } catch {
9325
+ }
9326
+ }
9327
+ const databases = await verifyDatabaseCompleteness(this.notionClient, this.stateDb, {
9328
+ databaseIds: ids
9329
+ });
9330
+ const pages = this.isDatabaseMode ? null : await verifyPageCompleteness(
9331
+ this.notionClient,
9332
+ this.stateDb,
9333
+ this.config.notion.rootPageId
9334
+ );
9335
+ return { databases, pages, complete: databases.complete && (pages?.complete ?? true) };
9336
+ }
7829
9337
  async fetch() {
7830
9338
  const startTime = Date.now();
7831
9339
  const changes = await this.detectRemoteChanges();
@@ -7837,7 +9345,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
7837
9345
  duration: Date.now() - startTime
7838
9346
  };
7839
9347
  }
7840
- async buildConflictsFromRecords(records, localChanges, remoteChanges) {
9348
+ /**
9349
+ * @param options.fullRender 원격 본문을 pull 과 동일한 파이프라인으로 렌더할지.
9350
+ * 기본(false)은 화면 미리보기용 경량 렌더 — 첨부를 내려받지 않으므로 `status` 처럼
9351
+ * 읽기만 하는 경로가 쓴다. 볼트에 덮어쓸 본문이 필요한 해소 경로는 반드시 켠다.
9352
+ */
9353
+ async buildConflictsFromRecords(records, localChanges, remoteChanges, options) {
7841
9354
  const conflicts = [];
7842
9355
  for (const record of records) {
7843
9356
  const localChange = localChanges.find((c) => c.path === record.obsidianPath) ?? {
@@ -7846,24 +9359,34 @@ var SyncOrchestrator = class _SyncOrchestrator {
7846
9359
  currentHash: record.contentHash,
7847
9360
  previousHash: record.contentHash
7848
9361
  };
7849
- const remoteChange = remoteChanges.find((c) => c.pageId === record.notionPageId) ?? {
7850
- pageId: record.notionPageId ?? "",
7851
- type: "modified",
7852
- lastEdited: record.notionLastEdited ?? "",
7853
- previousEdited: null
7854
- };
7855
9362
  let localContent = "";
7856
9363
  try {
7857
9364
  localContent = await this.vaultFs.readFile(record.obsidianPath);
7858
9365
  } catch {
7859
9366
  }
7860
9367
  let remoteContent = "";
9368
+ let remoteLastEdited = null;
7861
9369
  if (record.notionPageId) {
7862
9370
  try {
7863
- remoteContent = (await this.fetchPageMarkdown(record.notionPageId)).content;
9371
+ if (options?.fullRender) {
9372
+ const page = await this.notionClient.getPage(record.notionPageId);
9373
+ remoteLastEdited = page.last_edited_time ?? null;
9374
+ remoteContent = (await this.renderRemotePage(record, record.notionPageId, page)).content;
9375
+ } else {
9376
+ remoteContent = (await this.fetchPageMarkdown(record.notionPageId)).content;
9377
+ }
7864
9378
  } catch {
7865
9379
  }
7866
9380
  }
9381
+ const remoteChange = remoteChanges.find((c) => c.pageId === record.notionPageId) ?? {
9382
+ pageId: record.notionPageId ?? "",
9383
+ type: "modified",
9384
+ // 현재 원격 시각을 실제로 읽어왔다면 그 값을 쓴다. 해소 후 재조정(propagateResolution)
9385
+ // 이 이 값을 그대로 기준점으로 삼는데, 낡은 저장값을 실으면 다음 pull 이 같은 변경을
9386
+ // 다시 충돌로 보고 무한 재충돌한다.
9387
+ lastEdited: remoteLastEdited ?? record.notionLastEdited ?? "",
9388
+ previousEdited: null
9389
+ };
7867
9390
  conflicts.push({
7868
9391
  syncRecord: record,
7869
9392
  localChange,
@@ -7985,9 +9508,10 @@ var SyncOrchestrator = class _SyncOrchestrator {
7985
9508
  }
7986
9509
  }
7987
9510
  async pullDiscoveredDatabases(writtenPaths, failed, conflicts, forceRediscovery = false) {
7988
- if (this.isDatabaseMode) return { created: 0, updated: 0 };
9511
+ if (this.isDatabaseMode) return { created: 0, updated: 0, restored: 0 };
7989
9512
  let created = 0;
7990
9513
  let updated = 0;
9514
+ let restored = 0;
7991
9515
  try {
7992
9516
  const cachedRaw = this.stateDb.getMeta("discovered_dbs");
7993
9517
  let dbConfigs = [];
@@ -8117,6 +9641,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
8117
9641
  }
8118
9642
  created += dbResult.created;
8119
9643
  updated += dbResult.updated;
9644
+ restored += dbResult.restored;
8120
9645
  conflicts.push(...dbResult.conflicts);
8121
9646
  failed.push(...dbResult.failed);
8122
9647
  writtenPaths.push(...dbResult.writtenPaths);
@@ -8162,7 +9687,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
8162
9687
  } catch (error) {
8163
9688
  getLogger().warn("[Im-Nobsidian] child_database \uC790\uB3D9 \uBC1C\uACAC \uC2E4\uD328:", error);
8164
9689
  }
8165
- return { created, updated };
9690
+ return { created, updated, restored };
8166
9691
  }
8167
9692
  /**
8168
9693
  * pull 산출 md 의 인라인 DB placeholder(`**제목** *(Notion DB)*` + 보존 마커)를
@@ -8219,51 +9744,55 @@ var SyncOrchestrator = class _SyncOrchestrator {
8219
9744
  }
8220
9745
  async resolveNotionLinks(paths) {
8221
9746
  let totalResolved = 0;
9747
+ let totalDegraded = 0;
8222
9748
  const allRecords = this.stateDb.getAll();
8223
9749
  const idToTitle = /* @__PURE__ */ new Map();
9750
+ const idToPath = /* @__PURE__ */ new Map();
8224
9751
  for (const r of allRecords) {
8225
9752
  if (r.notionPageId && r.obsidianPath) {
8226
9753
  const title = wikilinkTitleFromPath(r.obsidianPath);
8227
9754
  const cleanId = r.notionPageId.replace(/-/g, "");
8228
9755
  idToTitle.set(cleanId, title);
8229
9756
  idToTitle.set(r.notionPageId, title);
9757
+ idToPath.set(cleanId, r.obsidianPath);
8230
9758
  }
8231
9759
  }
8232
9760
  if (idToTitle.size === 0) return 0;
9761
+ const notionIdToPath = (id) => idToPath.get(id.replace(/-/g, "")) ?? null;
8233
9762
  for (const filePath of paths) {
8234
9763
  if (!filePath.endsWith(".md")) continue;
8235
9764
  try {
8236
9765
  let content = await this.vaultFs.readFile(filePath);
8237
9766
  let changed = false;
8238
- const resolved = content.replace(/\[\[notion:([a-f0-9-]+)\]\]/g, (_match, id) => {
8239
- const title = idToTitle.get(id.replace(/-/g, ""));
8240
- if (title) {
8241
- totalResolved++;
8242
- changed = true;
8243
- return `[[${title}]]`;
8244
- }
8245
- return _match;
8246
- });
8247
- content = resolved;
8248
- const resolved2 = content.replace(
8249
- /\[([^\]]+)\]\(\/(?:p\/)?([a-f0-9]{32})(?:\?[^)]*)?\)/g,
8250
- (_match, text, id) => {
8251
- const title = idToTitle.get(id);
8252
- if (title) {
8253
- totalResolved++;
8254
- changed = true;
8255
- return `[[${title}|${text}]]`;
8256
- }
8257
- return _match;
8258
- }
8259
- );
8260
- content = resolved2;
9767
+ const idPass = resolveNotionIdWikilinks(content, notionIdToPath);
9768
+ content = idPass.markdown;
9769
+ if (idPass.resolved > 0) {
9770
+ totalResolved += idPass.resolved;
9771
+ changed = true;
9772
+ }
9773
+ const urlPass = resolveNotionRelativePageLinks(content, notionIdToPath);
9774
+ content = urlPass.markdown;
9775
+ if (urlPass.resolved > 0) {
9776
+ totalResolved += urlPass.resolved;
9777
+ changed = true;
9778
+ }
8261
9779
  const fm = resolveFrontmatterRelations(content, idToTitle);
8262
9780
  if (fm.count > 0) {
8263
9781
  content = fm.content;
8264
9782
  totalResolved += fm.count;
8265
9783
  changed = true;
8266
9784
  }
9785
+ for (const degrade of [
9786
+ degradeUnresolvedNotionIdWikilinks,
9787
+ degradeUnresolvedNotionRelativePageLinks
9788
+ ]) {
9789
+ const degradation = degrade(content);
9790
+ if (degradation.degraded > 0) {
9791
+ content = degradation.markdown;
9792
+ totalDegraded += degradation.degraded;
9793
+ changed = true;
9794
+ }
9795
+ }
8267
9796
  if (changed) {
8268
9797
  await this.vaultFs.writeFile(filePath, content);
8269
9798
  const record = this.stateDb.getByPath(filePath);
@@ -8282,6 +9811,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
8282
9811
  } catch {
8283
9812
  }
8284
9813
  }
9814
+ if (totalDegraded > 0) {
9815
+ getLogger().info(
9816
+ `[Im-Nobsidian] \uBCFC\uD2B8 \uBC16 Notion \uD398\uC774\uC9C0 \uB9C1\uD06C ${totalDegraded}\uAC74\uC744 URL \uB9C1\uD06C\uB85C \uC720\uC9C0`
9817
+ );
9818
+ }
8285
9819
  return totalResolved;
8286
9820
  }
8287
9821
  async ensureDbSchema() {
@@ -8323,7 +9857,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
8323
9857
  title
8324
9858
  );
8325
9859
  }
8326
- const placeholder = this.stateDb.upsert({
9860
+ const placeholder2 = this.stateDb.upsert({
8327
9861
  obsidianPath: path,
8328
9862
  notionPageId: null,
8329
9863
  notionParentId: parentId,
@@ -8337,9 +9871,9 @@ var SyncOrchestrator = class _SyncOrchestrator {
8337
9871
  localMtime: null,
8338
9872
  localFileSize: null
8339
9873
  });
8340
- const existingOp = this.stateDb.getIncompleteOpByState(placeholder.id, "create");
9874
+ const existingOp = this.stateDb.getIncompleteOpByState(placeholder2.id, "create");
8341
9875
  const walOpId = existingOp?.id ?? this.stateDb.recordPendingOperation({
8342
- syncStateId: placeholder.id,
9876
+ syncStateId: placeholder2.id,
8343
9877
  operation: "create",
8344
9878
  direction: "push",
8345
9879
  payload: JSON.stringify({ path, parentId, title })
@@ -8365,7 +9899,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
8365
9899
  localMtime: null,
8366
9900
  localFileSize: null
8367
9901
  });
8368
- await this.imageHandler.uploadAndAppendImages(page.id, conversionResult.images, path);
9902
+ await this.syncEmbeddedMedia(page.id, conversionResult, path);
8369
9903
  const hash = computeHash(content);
8370
9904
  const fileStat = await this.vaultFs.getFileStat(path);
8371
9905
  this.stateDb.transaction(() => {
@@ -8394,6 +9928,27 @@ var SyncOrchestrator = class _SyncOrchestrator {
8394
9928
  });
8395
9929
  this.stateDb.markPendingCompleted(walOpId);
8396
9930
  }
9931
+ /**
9932
+ * 노트에 박힌 로컬 미디어를 Notion 에 올린다(R1).
9933
+ *
9934
+ * 먼저 본문 자리표시자를 실제 image/file 블록으로 **제자리** 교체한다. 그러고도 남은
9935
+ * 이미지만 페이지 끝에 덧붙이는 예전 경로로 흘린다 — 자리표시자가 없는 경우는 본문
9936
+ * push 가 통째로 스킵됐을 때(pageHasLiveChildren) 정도이고, 그때도 이미지 자체는
9937
+ * 올라가야 하므로 폴백을 남긴다.
9938
+ */
9939
+ async syncEmbeddedMedia(pageId, conversionResult, path) {
9940
+ const { handledTargets } = await this.imageHandler.materializeLocalMedia(
9941
+ pageId,
9942
+ conversionResult.content,
9943
+ path
9944
+ );
9945
+ const leftovers = conversionResult.images.filter(
9946
+ (img) => !img.localPath || !handledTargets.has(img.localPath)
9947
+ );
9948
+ if (leftovers.length > 0) {
9949
+ await this.imageHandler.uploadAndAppendImages(pageId, leftovers, path);
9950
+ }
9951
+ }
8397
9952
  async pushUpdate(path) {
8398
9953
  const content = await this.vaultFs.readFile(path);
8399
9954
  const record = this.stateDb.getByPath(path);
@@ -8411,11 +9966,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
8411
9966
  parentMode: this.config.notion.parentMode
8412
9967
  });
8413
9968
  await this.pushUpdatePage(record.notionPageId, conversionResult.content, record.baseSnapshot);
8414
- await this.imageHandler.uploadAndAppendImages(
8415
- record.notionPageId,
8416
- conversionResult.images,
8417
- path
8418
- );
9969
+ await this.syncEmbeddedMedia(record.notionPageId, conversionResult, path);
8419
9970
  let propsToUpdate;
8420
9971
  if (this.isDatabaseMode && conversionResult.properties && Object.keys(conversionResult.properties).length > 0) {
8421
9972
  await this.ensureDbSchema();
@@ -8465,6 +10016,67 @@ var SyncOrchestrator = class _SyncOrchestrator {
8465
10016
  // 유실·충돌 루프가 발생한다. 해소 → 전파(propagate)를 오케스트레이터에서 봉합해 무손실
8466
10017
  // 보장. 변환 파이프라인이 필요한 push 는 기존 엔터프라이즈 경로(pushUpdate)를 재사용한다.
8467
10018
  // ──────────────────────────────────────────────────────────────────────────
10019
+ /**
10020
+ * 해소 대상 충돌 목록 — 원격 본문을 pull 과 **같은 파이프라인**으로 렌더해 담는다.
10021
+ *
10022
+ * 해소는 실제로 볼트 파일을 덮어쓰므로 `status()` 의 경량 미리보기 렌더를 쓰면 안 된다
10023
+ * (프론트매터·첨부가 빠진 반쪽 본문이 덮인다). 반대로 전체 pull 을 먼저 돌려 목록을
10024
+ * 얻는 것도 안 된다 — 해소하겠다고 볼트를 먼저 원격으로 덮어쓰는 셈이라 순서가 거꾸로다.
10025
+ * 그래서 충돌 레코드만 좁혀 그 페이지들만 읽어 온다.
10026
+ */
10027
+ async listConflicts() {
10028
+ const records = this.stateDb.getByStatus("conflict");
10029
+ if (records.length === 0) return [];
10030
+ const files = await this.vaultFs.listMarkdownFiles();
10031
+ const localChanges = this.changeDetector.detectLocalChanges(files);
10032
+ return this.buildConflictsFromRecords(records, localChanges, [], { fullRender: true });
10033
+ }
10034
+ /**
10035
+ * 추적 파일의 **현재 원격 본문**을 pull 과 같은 변환으로 렌더한다 — 표시 전용.
10036
+ *
10037
+ * 첨부는 내려받지 않는다: 비교를 보려다 볼트에 파일이 생기면 안 된다. 그 대가로 아직
10038
+ * 내려받지 않은 미디어는 원격 URL 로 남아 비교 화면에만 차이로 보인다.
10039
+ *
10040
+ * @returns 추적되지 않았거나 원격 페이지가 없으면 null.
10041
+ */
10042
+ async renderRemoteSnapshot(path) {
10043
+ const record = this.stateDb.getByPath(path);
10044
+ if (!record?.notionPageId) return null;
10045
+ const page = await this.notionClient.getPage(record.notionPageId);
10046
+ const rendered = await this.renderRemotePage(record, record.notionPageId, page, {
10047
+ downloadMedia: false
10048
+ });
10049
+ return rendered.content;
10050
+ }
10051
+ /**
10052
+ * 해소할 게 남지 않은 충돌 레코드를 `synced` 로 되돌리고, 되돌린 경로를 반환한다.
10053
+ *
10054
+ * 충돌로 표시된 파일은 push 대상에서 통째로 빠진다(양쪽 덮어쓰기 방지). 그래서 사용자가
10055
+ * 손으로 양쪽을 맞춰 둬 이미 같은 내용이 됐는데도 레코드만 남으면, 그 파일의 이후 편집이
10056
+ * **영원히 Notion 에 올라가지 않는다** — 아무 경고 없이 정체된다. 내용이 이미 동일한
10057
+ * 건만 골라 상태를 되돌린다(진짜 충돌은 손대지 않는다).
10058
+ */
10059
+ clearStaleConflicts(conflicts) {
10060
+ const cleared = [];
10061
+ for (const conflict of conflicts) {
10062
+ if (conflict.localContent === "" && conflict.remoteContent === "") continue;
10063
+ if (conflict.localContent !== conflict.remoteContent) continue;
10064
+ const record = conflict.syncRecord;
10065
+ this.stateDb.transaction(() => {
10066
+ this.stateDb.updateHash(
10067
+ record.id,
10068
+ computeHash(conflict.localContent),
10069
+ Buffer.from(conflict.localContent, "utf-8")
10070
+ );
10071
+ this.stateDb.updateStatus(record.id, "synced");
10072
+ if (conflict.remoteChange.lastEdited) {
10073
+ this.stateDb.setNotionLastEdited(record.id, conflict.remoteChange.lastEdited);
10074
+ }
10075
+ });
10076
+ cleared.push(record.obsidianPath);
10077
+ }
10078
+ return cleared;
10079
+ }
8468
10080
  /** 단일 충돌을 사용자가 고른 선택지(local/remote/merge/duplicate)로 해소 + Notion 전파. */
8469
10081
  async resolveConflict(conflict, choice) {
8470
10082
  const result = await this.conflictResolver.resolve(conflict, choice);
@@ -8557,18 +10169,10 @@ var SyncOrchestrator = class _SyncOrchestrator {
8557
10169
  );
8558
10170
  let remotePages;
8559
10171
  if (this.isDatabaseMode) {
8560
- const allPages = [];
8561
- let cursor;
8562
- do {
8563
- const result = await this.notionClient.queryDatabase(this.config.notion.databaseId, {
8564
- startCursor: cursor
8565
- });
8566
- allPages.push(
8567
- ...result.results.map((p) => ({ id: p.id, last_edited_time: p.last_edited_time }))
8568
- );
8569
- cursor = result.nextCursor ?? void 0;
8570
- } while (cursor);
8571
- remotePages = allPages;
10172
+ const allPages = await this.notionClient.queryAllDatabasePages(
10173
+ this.config.notion.databaseId
10174
+ );
10175
+ remotePages = allPages.map((p) => ({ id: p.id, last_edited_time: p.last_edited_time }));
8572
10176
  } else {
8573
10177
  let underRoot;
8574
10178
  try {
@@ -8578,11 +10182,19 @@ var SyncOrchestrator = class _SyncOrchestrator {
8578
10182
  } catch (error) {
8579
10183
  if (!(error instanceof DiscoveryTooLargeError)) throw error;
8580
10184
  getLogger().info(
8581
- `[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`
10185
+ `[Im-Nobsidian] \uC11C\uBE0C\uD2B8\uB9AC\uAC00 \uD07C(${error.message}) \u2192 \uC21C\uD68C \uBD80\uBD84 \uACB0\uACFC ${error.partial.length}\uAC74\uC5D0 \uC6CC\uD06C\uC2A4\uD398\uC774\uC2A4 search \uAE30\uBC18 \uB514\uC2A4\uCEE4\uBC84\uB9AC\uB97C \uD569\uCE69\uB2C8\uB2E4`
8582
10186
  );
8583
- underRoot = await this.notionClient.getPagesUnderRootViaSearch(
10187
+ const viaSearch = await this.notionClient.getPagesUnderRootViaSearch(
8584
10188
  this.config.notion.rootPageId
8585
10189
  );
10190
+ const byId = /* @__PURE__ */ new Map();
10191
+ for (const page of [...error.partial, ...viaSearch]) {
10192
+ byId.set(normalizeNotionId(page.id), page);
10193
+ }
10194
+ underRoot = [...byId.values()];
10195
+ getLogger().info(
10196
+ `[Im-Nobsidian] \uB514\uC2A4\uCEE4\uBC84\uB9AC \uD569\uC9D1\uD569: \uC21C\uD68C ${error.partial.length} \u222A search ${viaSearch.length} \u2192 ${underRoot.length}\uAC74`
10197
+ );
8586
10198
  }
8587
10199
  this._childParentIds.add(normalizeNotionId(this.config.notion.rootPageId));
8588
10200
  for (const page of underRoot) {
@@ -8676,6 +10288,82 @@ var SyncOrchestrator = class _SyncOrchestrator {
8676
10288
  if (notionIdsEqual(parentId, this.config.notion.rootPageId)) return true;
8677
10289
  return !!this.stateDb.getByNotionId(parentId);
8678
10290
  }
10291
+ /**
10292
+ * 추적 중(sync_state)인데 디스크에서 사라진 파일을 찾는다.
10293
+ *
10294
+ * 원격 변경 감지(detectRemoteChanges*)는 "Notion 에서 바뀐 것"만 본다. 로컬에서 파일이
10295
+ * 지워진 경우는 어느 경로로도 잡히지 않아, 재pull 해도 `--force` 로도 되살아나지 않고
10296
+ * 영구히 발산했다. push 는 deleteSync=false 면 원격을 지우지 않으므로 사용자에겐 복구
10297
+ * 수단이 볼트 전체 초기화밖에 남지 않는다 — 이 스캔이 그 마지막 구멍을 막는다.
10298
+ *
10299
+ * 레코드마다 stat 을 던지면 1000건 규모에서 수 초가 든다. 볼트 워크 1회로 실재 목록을
10300
+ * 만든 뒤 차집합을 취한다(md). md 가 아닌 추적 레코드는 수가 적어 개별 확인으로 남긴다.
10301
+ *
10302
+ * 로컬 이름변경·이동은 "사라짐" 과 구별해야 한다 — 되살리면 원본과 새 이름의 사본이
10303
+ * 둘 다 남는다. push 의 detectMoves 와 같은 기준(내용 해시 일치)을 쓰되, 볼트 전체를
10304
+ * 해시하지 않도록 크기가 같은 미추적 파일만 후보로 좁혀 확인한다.
10305
+ */
10306
+ async detectMissingLocalFiles(paths) {
10307
+ if (this.config.sync.deleteSync) return [];
10308
+ const records = this.stateDb.getAll();
10309
+ if (records.length === 0) return [];
10310
+ const scoped = records.filter(
10311
+ (r) => (
10312
+ // folder-only 는 실체가 폴더라 파일 부재가 정상. db-row 를 제외하는 이유는
10313
+ // 여기서 되살리면 행 전용 frontmatter(속성 매핑) 없이 본문만 쓰는 잘못된 경로로
10314
+ // 새기 때문이다 — 복원은 반드시 database-syncer 의 행 경로가 해야 한다.
10315
+ // 다만 그쪽이 실제로 복원하는지는 오래 참이 아니었다: 원격 무변경이면 로컬 존재를
10316
+ // 묻지도 않고 건너뛰어, 지운 행이 어느 경로로도 돌아오지 않았다. R13 에서 그
10317
+ // 존재 확인을 넣어 이 제외가 비로소 근거를 갖는다(tests/sync/db-row-restore-deleted).
10318
+ (r.fileType === "file" || r.fileType === "folder-note") && r.notionPageId !== null && inAnyPathScope(r.obsidianPath, paths)
10319
+ )
10320
+ );
10321
+ if (scoped.length === 0) return [];
10322
+ const stats = await this.vaultFs.listMarkdownFileStats();
10323
+ const live = new Set(stats.map((f) => f.path));
10324
+ const candidates = [];
10325
+ for (const record of scoped) {
10326
+ if (record.obsidianPath.endsWith(".md")) {
10327
+ if (!live.has(record.obsidianPath)) candidates.push(record);
10328
+ } else if (!await this.vaultFs.exists(record.obsidianPath)) {
10329
+ candidates.push(record);
10330
+ }
10331
+ }
10332
+ if (candidates.length === 0) return [];
10333
+ const tracked = new Set(records.map((r) => r.obsidianPath));
10334
+ const untrackedBySize = /* @__PURE__ */ new Map();
10335
+ for (const f of stats) {
10336
+ if (tracked.has(f.path)) continue;
10337
+ const bucket = untrackedBySize.get(f.size);
10338
+ if (bucket) bucket.push(f.path);
10339
+ else untrackedBySize.set(f.size, [f.path]);
10340
+ }
10341
+ const hashCache = /* @__PURE__ */ new Map();
10342
+ const hashOf = async (path) => {
10343
+ const cached = hashCache.get(path);
10344
+ if (cached !== void 0) return cached;
10345
+ let hash = null;
10346
+ try {
10347
+ hash = computeHash(await this.vaultFs.readFile(path));
10348
+ } catch {
10349
+ }
10350
+ hashCache.set(path, hash);
10351
+ return hash;
10352
+ };
10353
+ const missing = [];
10354
+ for (const record of candidates) {
10355
+ const sameSize = record.localFileSize !== null ? untrackedBySize.get(record.localFileSize) ?? [] : [...untrackedBySize.values()].flat();
10356
+ let renamed = false;
10357
+ for (const path of sameSize) {
10358
+ if (await hashOf(path) === record.contentHash) {
10359
+ renamed = true;
10360
+ break;
10361
+ }
10362
+ }
10363
+ if (!renamed) missing.push(record);
10364
+ }
10365
+ return missing;
10366
+ }
8679
10367
  async pullCreate(pageId) {
8680
10368
  const page = await this.notionClient.getPage(pageId);
8681
10369
  const title = this.notionClient.extractTitle(page);
@@ -8688,17 +10376,16 @@ var SyncOrchestrator = class _SyncOrchestrator {
8688
10376
  let fileType;
8689
10377
  if (hasChildPages && hasContent) {
8690
10378
  const folderPath = parentPath ? `${parentPath}/${safeName}` : safeName;
8691
- filePath = await this.resolveUniqueFilePath(`${folderPath}/${safeName}.md`);
10379
+ filePath = await this.resolveUniqueFilePath(folderPath, safeName, pageId);
8692
10380
  fileType = "folder-note";
8693
10381
  await this.vaultFs.ensureFolder(folderPath);
8694
10382
  } else if (hasChildPages && !hasContent) {
8695
10383
  const folderPath = parentPath ? `${parentPath}/${safeName}` : safeName;
8696
- filePath = await this.resolveUniqueFilePath(`${folderPath}/${safeName}.md`);
10384
+ filePath = await this.resolveUniqueFilePath(folderPath, safeName, pageId);
8697
10385
  fileType = "folder-only";
8698
10386
  await this.vaultFs.ensureFolder(folderPath);
8699
10387
  } else {
8700
- const basePath = parentPath ? `${parentPath}/${safeName}.md` : `${safeName}.md`;
8701
- filePath = await this.resolveUniqueFilePath(basePath);
10388
+ filePath = await this.resolveUniqueFilePath(parentPath, safeName, pageId);
8702
10389
  fileType = "file";
8703
10390
  }
8704
10391
  let properties;
@@ -8715,11 +10402,16 @@ var SyncOrchestrator = class _SyncOrchestrator {
8715
10402
  }
8716
10403
  let processedMarkdown = markdown;
8717
10404
  if (this.config.conversion.imageDownload === "immediate") {
8718
- const imageResult = await this.imageHandler.downloadAllImages(markdown, title, pageId);
10405
+ const imageResult = await this.imageHandler.downloadAllImages(
10406
+ markdown,
10407
+ title,
10408
+ pageId,
10409
+ filePath
10410
+ );
8719
10411
  processedMarkdown = imageResult.content;
8720
10412
  this._pullImageCount += imageResult.downloads.length;
8721
10413
  }
8722
- const fileResult = await this.imageHandler.downloadAllFiles(processedMarkdown, title);
10414
+ const fileResult = await this.imageHandler.downloadAllFiles(processedMarkdown, title, filePath);
8723
10415
  processedMarkdown = fileResult.content;
8724
10416
  this._pullFileCount += fileResult.downloads.length;
8725
10417
  const finalContent = this.pipeline.convertToMarkdown(
@@ -8783,11 +10475,20 @@ var SyncOrchestrator = class _SyncOrchestrator {
8783
10475
  return false;
8784
10476
  }
8785
10477
  }
8786
- async pullUpdate(change) {
8787
- const record = this.stateDb.getByNotionId(change.pageId);
8788
- if (!record) return {};
8789
- const page = await this.notionClient.getPage(change.pageId);
8790
- const fetched = await this.fetchPageMarkdown(change.pageId);
10478
+ /**
10479
+ * 원격 페이지를 **pull 과 동일한 파이프라인**으로 볼트에 쓸 수 있는 본문까지 렌더한다.
10480
+ * (본문 변환 → 이미지/첨부 내려받기 → 속성 주입 → preserve marker → 압축형 간격 판정)
10481
+ *
10482
+ * pull 경로와 충돌 해소 경로가 이 한 곳을 공유해야 한다. 예전엔 충돌 목록만
10483
+ * {@link fetchPageMarkdown} 원문을 그대로 담아, `nobsi resolve` 에서 "원격 유지"를 고르면
10484
+ * 프론트매터도 첨부도 없는 반쪽 본문이 볼트에 덮여 썼다 — 해소가 곧 손실이었다.
10485
+ *
10486
+ * @returns 렌더된 본문과 함께, 호출자가 위키링크 레지스트리 등에 쓰는 제목·속성.
10487
+ * 여기서 이미 읽은 값을 되돌려 줘야 호출자가 같은 페이지를 다시 파싱하지 않는다.
10488
+ */
10489
+ async renderRemotePage(record, pageId, page, options) {
10490
+ const downloadMedia = options?.downloadMedia !== false;
10491
+ const fetched = await this.fetchPageMarkdown(pageId);
8791
10492
  let markdown = fetched.content;
8792
10493
  const title = this.notionClient.extractTitle(page);
8793
10494
  let properties;
@@ -8802,16 +10503,27 @@ var SyncOrchestrator = class _SyncOrchestrator {
8802
10503
  if (this.isDatabaseMode || extractTitle(record.obsidianPath) !== title) {
8803
10504
  properties.title = title;
8804
10505
  }
8805
- if (this.config.conversion.imageDownload === "immediate") {
8806
- const imageResult = await this.imageHandler.downloadAllImages(markdown, title, change.pageId);
10506
+ if (downloadMedia && this.config.conversion.imageDownload === "immediate") {
10507
+ const imageResult = await this.imageHandler.downloadAllImages(
10508
+ markdown,
10509
+ title,
10510
+ pageId,
10511
+ record.obsidianPath
10512
+ );
8807
10513
  markdown = imageResult.content;
8808
10514
  this._pullImageCount += imageResult.downloads.length;
8809
10515
  }
8810
- const fileResult = await this.imageHandler.downloadAllFiles(markdown, title);
8811
- markdown = fileResult.content;
8812
- this._pullFileCount += fileResult.downloads.length;
10516
+ if (downloadMedia) {
10517
+ const fileResult = await this.imageHandler.downloadAllFiles(
10518
+ markdown,
10519
+ title,
10520
+ record.obsidianPath
10521
+ );
10522
+ markdown = fileResult.content;
10523
+ this._pullFileCount += fileResult.downloads.length;
10524
+ }
8813
10525
  const savedMarkers = this.stateDb.getPreserveMarkers(record.obsidianPath);
8814
- const remoteContent = this.pipeline.convertToMarkdown(
10526
+ const content = this.pipeline.convertToMarkdown(
8815
10527
  markdown,
8816
10528
  {
8817
10529
  direction: "pull",
@@ -8825,15 +10537,30 @@ var SyncOrchestrator = class _SyncOrchestrator {
8825
10537
  notionExportCompact: fetched.compact
8826
10538
  }
8827
10539
  );
10540
+ return { content, title, properties };
10541
+ }
10542
+ async pullUpdate(change) {
10543
+ const record = this.stateDb.getByNotionId(change.pageId);
10544
+ if (!record) return {};
10545
+ const page = await this.notionClient.getPage(change.pageId);
10546
+ const remoteGone = page.in_trash === true || page.archived === true;
10547
+ if (remoteGone && !await this.vaultFs.exists(record.obsidianPath)) {
10548
+ return { unchanged: true };
10549
+ }
10550
+ const rendered = await this.renderRemotePage(record, change.pageId, page);
10551
+ const remoteContent = rendered.content;
8828
10552
  let localContent;
10553
+ let localExists = true;
8829
10554
  try {
8830
10555
  localContent = await this.vaultFs.readFile(record.obsidianPath);
8831
10556
  } catch {
8832
10557
  localContent = "";
10558
+ localExists = await this.vaultFs.exists(record.obsidianPath);
8833
10559
  }
8834
10560
  const resolution = resolvePullConflict({
8835
10561
  record,
8836
10562
  localContent,
10563
+ localExists,
8837
10564
  remoteContent,
8838
10565
  remoteChange: change,
8839
10566
  strategy: this.config.sync.conflictStrategy
@@ -8845,7 +10572,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
8845
10572
  this.stateDb.updateStatus(record.id, "conflict");
8846
10573
  return { conflict: resolution.conflict };
8847
10574
  }
8848
- if (remoteContent === localContent) {
10575
+ if (localExists && remoteContent === localContent) {
8849
10576
  const stat2 = await this.vaultFs.getFileStat(record.obsidianPath);
8850
10577
  this.stateDb.upsert({
8851
10578
  obsidianPath: record.obsidianPath,
@@ -8881,11 +10608,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
8881
10608
  localMtime: updateStat?.mtime ?? null,
8882
10609
  localFileSize: updateStat?.size ?? null
8883
10610
  });
8884
- const aliases = extractAliases2(properties);
10611
+ const aliases = extractAliases2(rendered.properties);
8885
10612
  this.stateDb.upsertWikilink({
8886
10613
  obsidianPath: record.obsidianPath,
8887
10614
  notionPageId: change.pageId,
8888
- title,
10615
+ title: rendered.title,
8889
10616
  aliases
8890
10617
  });
8891
10618
  });
@@ -9134,13 +10861,10 @@ var SyncOrchestrator = class _SyncOrchestrator {
9134
10861
  // 이를 state DB 역조회로 원래 `[[제목]]` 위키링크로 복원해 push↔pull 라운드트립을 수렴시킨다.
9135
10862
  // 볼트 밖/미추적 페이지면 `[[notion:<id>]]` 를 그대로 두어 정보 손실을 막는다.
9136
10863
  resolveNotionIdWikilinks(markdown) {
9137
- return markdown.replace(/\[\[notion:([a-f0-9]{32})\]\]/g, (match, id) => {
9138
- const record = this.stateDb.getByNotionId(normalizeNotionId(id));
9139
- if (!record?.obsidianPath) return match;
9140
- const base = record.obsidianPath.split("/").pop() ?? record.obsidianPath;
9141
- const title = base.replace(/\.md$/, "");
9142
- return `[[${title}]]`;
9143
- });
10864
+ return resolveNotionIdWikilinks(
10865
+ markdown,
10866
+ (id) => this.stateDb.getByNotionId(normalizeNotionId(id))?.obsidianPath ?? null
10867
+ ).markdown;
9144
10868
  }
9145
10869
  async extractParentId(page) {
9146
10870
  const parent = page.parent;
@@ -9272,16 +10996,45 @@ var SyncOrchestrator = class _SyncOrchestrator {
9272
10996
  }
9273
10997
  return null;
9274
10998
  }
9275
- async resolveUniqueFilePath(basePath) {
9276
- if (!await this.vaultFs.exists(basePath)) return basePath;
9277
- const dir = basePath.lastIndexOf("/") >= 0 ? basePath.slice(0, basePath.lastIndexOf("/")) : "";
9278
- const ext = ".md";
9279
- const name = basePath.slice(dir ? dir.length + 1 : 0, -ext.length);
9280
- for (let i = 1; i <= 99; i++) {
9281
- const candidate = dir ? `${dir}/${name} (${i})${ext}` : `${name} (${i})${ext}`;
9282
- if (!await this.vaultFs.exists(candidate)) return candidate;
10999
+ /**
11000
+ * 신규 pull 페이지가 쓸 파일 경로를 충돌 없이 결정하고 **그 자리에서 선점**한다.
11001
+ *
11002
+ * DB 행과 **같은 규칙**({@link pagePathCandidates})을 쓴다. 예전에는 `(1)`, `(2)` …
11003
+ * 순번을 99 까지 훑고 고갈되면 **원본 경로를 그대로 돌려줬는데**, 그러면 남의 노트를
11004
+ * 조용히 덮어써 내용이 사라진다. 순번은 그때의 볼트 상태로 정해져 실행마다 페이지끼리
11005
+ * 접미사가 뒤바뀔 수도 있었다(pull 마다 파일이 갈아엎히는 churn).
11006
+ *
11007
+ * 점유 판정은 세 가지를 모두 본다:
11008
+ * · 이번 실행의 선점 장부 — 아래 참조.
11009
+ * · 추적 레코드 — 다른 페이지가 소유한 경로면 피하고, 자기 소유면 그대로 재사용한다.
11010
+ * · 볼트 파일 — 추적되지 않는 사용자 노트가 놓여 있으면 피한다.
11011
+ *
11012
+ * 선점 장부가 필요한 이유는 pull 이 워커 풀로 **동시 실행**되기 때문이다. 조회와 기록
11013
+ * 사이에 await 가 끼면 동명 페이지 여럿이 나란히 "비어 있음"을 보고 같은 경로를 고른다.
11014
+ * 그러면 마지막에 쓴 페이지만 남고 나머지 본문이 사라진다(실측 재현: 동명 3페이지 →
11015
+ * 파일 1개·레코드 1건). 그래서 마지막 확인과 등록을 **await 없는 동기 구간**에 묶는다 —
11016
+ * 그 사이에는 다른 작업이 끼어들 수 없으므로 두 페이지가 같은 경로를 얻는 일이 없다.
11017
+ */
11018
+ async resolveUniqueFilePath(dir, safeName, pageId) {
11019
+ const candidates = pagePathCandidates(dir, safeName, pageId);
11020
+ for (const candidate of candidates) {
11021
+ if (this.claimedPaths.has(candidate)) continue;
11022
+ const owner = this.stateDb.getByPath(candidate);
11023
+ if (owner) {
11024
+ if (owner.notionPageId != null && notionIdsEqual(owner.notionPageId, pageId)) {
11025
+ this.claimedPaths.add(candidate);
11026
+ return candidate;
11027
+ }
11028
+ continue;
11029
+ }
11030
+ if (await this.vaultFs.exists(candidate)) continue;
11031
+ if (this.claimedPaths.has(candidate)) continue;
11032
+ this.claimedPaths.add(candidate);
11033
+ return candidate;
9283
11034
  }
9284
- return basePath;
11035
+ const fallback = candidates[candidates.length - 1];
11036
+ this.claimedPaths.add(fallback);
11037
+ return fallback;
9285
11038
  }
9286
11039
  };
9287
11040
  function extractTitle(filePath) {
@@ -10205,6 +11958,63 @@ function summarizeFidelity(perFile) {
10205
11958
  return summary;
10206
11959
  }
10207
11960
 
10208
- 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 };
11961
+ // src/audit/render.ts
11962
+ var RENDER_LINE_RULES = [
11963
+ {
11964
+ code: "\u2460",
11965
+ label: "\uCF5C\uC544\uC6C3 4\uCE78+ \uB4E4\uC5EC\uC4F0\uAE30",
11966
+ // Obsidian 은 절대 들여쓰기 4칸부터 들여쓰기 코드블록으로 오파싱한다.
11967
+ // 콜아웃이 통째로 회색 코드 상자가 되어 서식이 전부 죽는다.
11968
+ re: /^(?: {4,}|\t)+>\s*\[!/
11969
+ },
11970
+ {
11971
+ code: "\u2461",
11972
+ label: "\uD1A0\uAE00\uD5E4\uB529 \uC18D\uC131 \uB204\uC218",
11973
+ // NFM 전용 속성이 본문에 그대로 노출된 상태 — 토글도 접히지 않는다.
11974
+ re: /\{toggle="true"\}/
11975
+ },
11976
+ {
11977
+ code: "\u2462",
11978
+ label: "NFM \uCEE8\uD14C\uC774\uB108 \uD0DC\uADF8 \uB204\uC218",
11979
+ re: /<\/?(?:details|summary|callout|columns|column)\b/
11980
+ },
11981
+ {
11982
+ code: "\u2463",
11983
+ label: "NFM \uC804\uC6A9 \uD0DC\uADF8 \uB204\uC218",
11984
+ // 이름이 붙어 `<unknown>` 폴백에 걸리지 않던 태그들.
11985
+ re: /<(?:table_of_contents|embed|unknown_mention|empty-block)\b/
11986
+ },
11987
+ {
11988
+ code: "\u2464",
11989
+ label: "\uCF54\uB4DC\uD39C\uC2A4 4\uCE78 \uB4E4\uC5EC\uC4F0\uAE30(\uCF5C\uC544\uC6C3 \uBC16)",
11990
+ re: /^ {4,}```/
11991
+ }
11992
+ ];
11993
+ var SEPARATOR_BODY = "[ \\t:|-]*-{3,}";
11994
+ var SEPARATOR_RE = new RegExp(`^[\\t ]*>?[\\t ]*\\|${SEPARATOR_BODY}[ \\t:|-]*\\|`);
11995
+ var FENCE_RE2 = /^[\t ]*(?:>[\t ]*)*```/;
11996
+ function lintRenderedMarkdown(markdown) {
11997
+ const lines = markdown.split("\n");
11998
+ const findings = [];
11999
+ for (let i = 0; i < lines.length; i++) {
12000
+ const line = lines[i];
12001
+ for (const rule of RENDER_LINE_RULES) {
12002
+ if (rule.re.test(line)) findings.push({ code: rule.code, label: rule.label, line: i + 1 });
12003
+ }
12004
+ if (SEPARATOR_RE.test(line)) {
12005
+ const prefix = /^([\t ]*>?[\t ]*)/.exec(line)[1];
12006
+ const prev = lines[i - 1] ?? "";
12007
+ if (!prev.startsWith(prefix) || !prev.slice(prefix.length).trimStart().startsWith("|")) {
12008
+ findings.push({ code: "\u2466", label: "\uD45C \uAD6C\uBD84\uD589 \uACE0\uC544(\uD45C \uC0AC\uB9DD)", line: i + 1 });
12009
+ }
12010
+ }
12011
+ }
12012
+ if (lines.filter((l) => FENCE_RE2.test(l)).length % 2 !== 0) {
12013
+ findings.push({ code: "\u2467", label: "\uCF54\uB4DC\uD39C\uC2A4 \uD640\uC218(\uBB38\uC11C \uC794\uC5EC\uBD84 \uC0BC\uD0B4)", line: 0 });
12014
+ }
12015
+ return findings;
12016
+ }
12017
+
12018
+ 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, RENDER_LINE_RULES, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_PATH, SidecarGenerator, StateDB, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WatchSyncService, WikilinkResolver, basesViewMappingOf, basesViewTypeOf, classifyBodyFidelity, compactNotionId, computeBufferHash, computeHash, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, inAnyPathScope, isInternalPath, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, notionIdsEqual, resolvePropertyName, sanitizeFileName, setLogger, sortEntries, summarizeFidelity, threeWayMerge, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness };
10209
12019
  //# sourceMappingURL=index.js.map
10210
12020
  //# sourceMappingURL=index.js.map