@im-nobsidian/core 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { LogLevel, Client } from '@notionhq/client';
3
- import { readFile, mkdir, writeFile, unlink, rename, stat, readdir } from 'fs/promises';
3
+ import { readFile, mkdir, writeFile, unlink, rmdir, rename, stat, readdir } from 'fs/promises';
4
4
  import { join, dirname, sep, relative } from 'path';
5
5
  import { mkdirSync, unlinkSync, utimesSync, openSync, writeSync, closeSync, readFileSync, statSync, existsSync, readlinkSync } from 'fs';
6
6
  import Database from 'better-sqlite3';
@@ -457,6 +457,10 @@ function notionIdsEqual(a, b) {
457
457
  function compactNotionId(id) {
458
458
  return id.replace(/-/g, "").toLowerCase();
459
459
  }
460
+ var SHORT_NOTION_ID_LENGTH = 8;
461
+ function shortNotionId(id) {
462
+ return compactNotionId(id).slice(0, SHORT_NOTION_ID_LENGTH);
463
+ }
460
464
 
461
465
  // src/utils/logger.ts
462
466
  var consoleLogger = {
@@ -1476,6 +1480,13 @@ function normalizeAnnotations(annotations) {
1476
1480
  };
1477
1481
  }
1478
1482
  var DEFAULT_STYLE = JSON.stringify(normalizeAnnotations(void 0));
1483
+ function titleRunsOf(properties) {
1484
+ for (const prop of Object.values(properties)) {
1485
+ const value = prop;
1486
+ if (value?.type === "title") return Array.isArray(value.title) ? value.title : null;
1487
+ }
1488
+ return null;
1489
+ }
1479
1490
  function stringifyFrontmatter(content, data) {
1480
1491
  const astral = maskAstralCharacters(data);
1481
1492
  const out = matter.stringify(
@@ -1660,6 +1671,7 @@ function notionFileIdOf(url) {
1660
1671
  }
1661
1672
  function fileUrlOfBlock(block) {
1662
1673
  const media = block.type ? block[block.type] : void 0;
1674
+ if (block.type === "embed") return media?.url ?? null;
1663
1675
  return media?.file?.url ?? media?.external?.url ?? null;
1664
1676
  }
1665
1677
 
@@ -1671,6 +1683,9 @@ function isFrontmatterDate(value) {
1671
1683
  }
1672
1684
  var RANGE_END_SUFFIX = "_end";
1673
1685
  var DATE_VALUED_TYPES = /* @__PURE__ */ new Set(["date", "formula", "rollup"]);
1686
+ function mayHaveRangeEnd(type) {
1687
+ return DATE_VALUED_TYPES.has(type);
1688
+ }
1674
1689
  var TIMESTAMP_TYPES = /* @__PURE__ */ new Set(["created_time", "last_edited_time"]);
1675
1690
  var READ_ONLY_TYPES = /* @__PURE__ */ new Set([
1676
1691
  "created_time",
@@ -1879,6 +1894,13 @@ var PropertyMapper = class _PropertyMapper {
1879
1894
  relationKeys(keys) {
1880
1895
  return keys.filter((key) => this.schema.get(key)?.type === "relation");
1881
1896
  }
1897
+ /** 이 속성들 가운데 Notion 이 값을 정하는 것(수식 · 롤업 · ID · 생성일 …) — 볼트에서 고쳐도 보낼 수 없다. */
1898
+ readOnlyKeys(keys) {
1899
+ return keys.filter((key) => {
1900
+ const type = this.schema.get(key)?.type;
1901
+ return type !== void 0 && READ_ONLY_TYPES.has(type);
1902
+ });
1903
+ }
1882
1904
  /**
1883
1905
  * 값을 보낼 수 있는 속성만, pull 이 적는 모양으로 — 원격 값과 로컬 값을 견줄 때 쓴다.
1884
1906
  *
@@ -2307,13 +2329,6 @@ function dateText(value, zone) {
2307
2329
  if (!DATE_REGEX.test(text)) return null;
2308
2330
  return isWallTime(text) ? toZonedIso(text, zone) : text;
2309
2331
  }
2310
- function titleRunsOf(remote) {
2311
- for (const prop of Object.values(remote)) {
2312
- const value = prop;
2313
- if (value?.type === "title") return Array.isArray(value.title) ? value.title : null;
2314
- }
2315
- return null;
2316
- }
2317
2332
  function richTextRunsOf(remote, key) {
2318
2333
  const value = remote?.[key];
2319
2334
  return value?.type === "rich_text" && Array.isArray(value.rich_text) ? value.rich_text : null;
@@ -2762,8 +2777,7 @@ function isNotionAccessDenied(error) {
2762
2777
  return e.code === "restricted_resource" || e.status === 403;
2763
2778
  }
2764
2779
  function isTrashedOrArchived(page) {
2765
- const inTrash = page.in_trash === true;
2766
- return inTrash || page.archived === true;
2780
+ return page.in_trash === true || page.archived === true;
2767
2781
  }
2768
2782
  function isNotionBadRequest(error) {
2769
2783
  return notionStatusOf(error) === 400;
@@ -2796,6 +2810,11 @@ var DiscoveryTooLargeError = class extends Error {
2796
2810
  partial;
2797
2811
  };
2798
2812
  var NOT_IDEMPOTENT = { idempotent: false };
2813
+ var CREATES_BLOCKS = { idempotent: false, createsBlocks: true };
2814
+ function rowParentOf(params) {
2815
+ if (params.parentType === "page") return { page_id: params.parentId };
2816
+ return params.dataSourceId ? { type: "data_source_id", data_source_id: params.dataSourceId } : { database_id: params.parentId };
2817
+ }
2799
2818
  function parentDatabaseIdOf(page) {
2800
2819
  const parent = page.parent;
2801
2820
  if (parent?.type !== "data_source_id" && parent?.type !== "database_id") return null;
@@ -2841,8 +2860,8 @@ var NotionClient = class _NotionClient {
2841
2860
  // 전역 쿨다운 밖에서, 로그 없이 흐른다.
2842
2861
  retry: false,
2843
2862
  // SDK 5.27+ 는 브라우저 환경(Obsidian 렌더러)에서 토큰을 받으면 던진다 — 남이 여는 페이지에 토큰이 실리는
2844
- // 것을 막는 검사다. 플러그인의 토큰은 사용자 기기의 볼트 설정에 있다. 5.23 은 이 옵션을 모른다(무시).
2845
- ...{ dangerouslyAllowBrowser: true },
2863
+ // 것을 막는 검사다. 플러그인의 토큰은 사용자 기기의 볼트 설정에 있다.
2864
+ dangerouslyAllowBrowser: true,
2846
2865
  ...options.fetch ? { fetch: options.fetch } : {}
2847
2866
  },
2848
2867
  options.notifications ?? DEFAULT_CONFIG.sync.notifications
@@ -2943,7 +2962,7 @@ var NotionClient = class _NotionClient {
2943
2962
  );
2944
2963
  }
2945
2964
  async createPage(params) {
2946
- const parent = params.parentType === "database" ? { database_id: params.parentId } : { page_id: params.parentId };
2965
+ const parent = rowParentOf(params);
2947
2966
  const properties = params.parentType === "page" ? { title: { title: [{ text: { content: params.title } }] } } : params.properties ?? {
2948
2967
  title: { title: [{ text: { content: params.title } }] }
2949
2968
  };
@@ -2953,7 +2972,7 @@ var NotionClient = class _NotionClient {
2953
2972
  properties,
2954
2973
  children: params.children
2955
2974
  }),
2956
- NOT_IDEMPOTENT
2975
+ CREATES_BLOCKS
2957
2976
  );
2958
2977
  }
2959
2978
  /**
@@ -2978,7 +2997,7 @@ var NotionClient = class _NotionClient {
2978
2997
  }
2979
2998
  })
2980
2999
  ),
2981
- NOT_IDEMPOTENT
3000
+ CREATES_BLOCKS
2982
3001
  );
2983
3002
  const dataSourceId = db.data_sources?.[0]?.id ?? await this.primaryDataSourceId(db.id);
2984
3003
  if (!dataSourceId) throw new Error(`\uB9CC\uB4E0 DB(${db.id})\uC758 data source \uB97C \uC54C \uC218 \uC5C6\uC74C`);
@@ -3013,7 +3032,7 @@ var NotionClient = class _NotionClient {
3013
3032
  return ds.properties ?? {};
3014
3033
  }
3015
3034
  async createPageWithMarkdown(params) {
3016
- const parent = params.parentType === "database" ? { database_id: params.parentId } : { page_id: params.parentId };
3035
+ const parent = rowParentOf(params);
3017
3036
  const properties = params.parentType === "page" ? { title: { title: [{ text: { content: params.title } }] } } : params.properties ?? {
3018
3037
  title: { title: [{ text: { content: params.title } }] }
3019
3038
  };
@@ -3023,7 +3042,7 @@ var NotionClient = class _NotionClient {
3023
3042
  properties,
3024
3043
  markdown: params.markdown
3025
3044
  }),
3026
- NOT_IDEMPOTENT
3045
+ CREATES_BLOCKS
3027
3046
  );
3028
3047
  }
3029
3048
  /**
@@ -3175,6 +3194,7 @@ var NotionClient = class _NotionClient {
3175
3194
  const dataSources = db.data_sources ?? [];
3176
3195
  const merged = {};
3177
3196
  const owners = [];
3197
+ const own = /* @__PURE__ */ new Map();
3178
3198
  let anyAccessible = false;
3179
3199
  for (const ds of dataSources) {
3180
3200
  try {
@@ -3184,6 +3204,7 @@ var NotionClient = class _NotionClient {
3184
3204
  owners.push(dsObj.parent?.database_id);
3185
3205
  if (dsObj.properties && Object.keys(dsObj.properties).length > 0) {
3186
3206
  anyAccessible = true;
3207
+ own.set(ds.id, dsObj.properties);
3187
3208
  for (const [name, prop] of Object.entries(dsObj.properties)) {
3188
3209
  if (!(name in merged)) merged[name] = prop;
3189
3210
  }
@@ -3195,7 +3216,11 @@ var NotionClient = class _NotionClient {
3195
3216
  const meta = {
3196
3217
  databaseId,
3197
3218
  title: Array.isArray(db.title) ? db.title[0]?.plain_text ?? "" : "",
3198
- dataSources: dataSources.map((ds) => ({ id: ds.id, name: ds.name ?? "" })),
3219
+ dataSources: dataSources.map((ds) => ({
3220
+ id: ds.id,
3221
+ name: ds.name ?? "",
3222
+ ...own.has(ds.id) ? { properties: own.get(ds.id) } : {}
3223
+ })),
3199
3224
  properties: anyAccessible ? merged : db.properties ?? {},
3200
3225
  isInline: db.is_inline === true,
3201
3226
  appearance: {
@@ -3404,7 +3429,7 @@ var NotionClient = class _NotionClient {
3404
3429
  if (metas.length > 1) {
3405
3430
  const label = metas.map((m) => m.name || m.id).join(", ");
3406
3431
  getLogger().warn(
3407
- `[Notion] DB ${databaseId} \uC5D0 data source ${metas.length}\uAC1C \uBC1C\uACAC \u2014 \uC804 \uC18C\uC2A4 \uD589\uC744 \uD55C \uD3F4\uB354\uB85C \uBCD1\uD569 \uB3D9\uAE30\uD654\uD569\uB2C8\uB2E4 (${label}). \uC18C\uC2A4\uBCC4 \uD0ED \uAD6C\uBD84\uC740 .base \uBDF0\uC5D0 \uBCF4\uC874\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.`
3432
+ `[Notion] DB ${shortNotionId(databaseId)} \uC5D0 data source ${metas.length}\uAC1C \uBC1C\uACAC \u2014 \uC804 \uC18C\uC2A4 \uD589\uC744 \uD55C \uD3F4\uB354\uB85C \uBCD1\uD569 \uB3D9\uAE30\uD654\uD569\uB2C8\uB2E4 (${label}). \uC18C\uC2A4\uBCC4 \uD0ED \uAD6C\uBD84\uC740 .base \uBDF0\uC5D0 \uBCF4\uC874\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.`
3408
3433
  );
3409
3434
  }
3410
3435
  const all = [];
@@ -3438,7 +3463,7 @@ var NotionClient = class _NotionClient {
3438
3463
  });
3439
3464
  }
3440
3465
  async archivePage(pageId) {
3441
- await this.withRateLimit(() => this.client.pages.update({ page_id: pageId, archived: true }));
3466
+ await this.withRateLimit(() => this.client.pages.update({ page_id: pageId, in_trash: true }));
3442
3467
  }
3443
3468
  /**
3444
3469
  * 파일 블록의 지금 받을 수 있는 주소. 블록이 없거나 볼 수 없으면 null 이다.
@@ -3582,7 +3607,7 @@ var NotionClient = class _NotionClient {
3582
3607
  children: batch,
3583
3608
  ...after ? { position: { type: "after_block", after_block: { id: after } } } : {}
3584
3609
  }),
3585
- NOT_IDEMPOTENT
3610
+ CREATES_BLOCKS
3586
3611
  );
3587
3612
  const blocks = response.results.slice(0, batch.length);
3588
3613
  created.push(...blocks);
@@ -3894,14 +3919,14 @@ var NotionClient = class _NotionClient {
3894
3919
  const blockOwner = /* @__PURE__ */ new Map();
3895
3920
  const resolveBlockOwner = async (blockId) => {
3896
3921
  const seen = /* @__PURE__ */ new Set();
3897
- const settle = (owner) => {
3922
+ const settle2 = (owner) => {
3898
3923
  for (const b of seen) blockOwner.set(b, owner);
3899
3924
  return owner;
3900
3925
  };
3901
3926
  let bid = blockId;
3902
3927
  for (let i = 0; i < 20 && bid && !seen.has(bid); i++) {
3903
3928
  const cached = blockOwner.get(bid);
3904
- if (cached !== void 0) return settle(cached);
3929
+ if (cached !== void 0) return settle2(cached);
3905
3930
  seen.add(bid);
3906
3931
  let bp;
3907
3932
  try {
@@ -3910,14 +3935,14 @@ var NotionClient = class _NotionClient {
3910
3935
  getLogger().warn(
3911
3936
  `[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)}`
3912
3937
  );
3913
- return settle(null);
3938
+ return settle2(null);
3914
3939
  }
3915
- if (bp.type === "page_id" && bp.page_id) return settle(bp.page_id);
3940
+ if (bp.type === "page_id" && bp.page_id) return settle2(bp.page_id);
3916
3941
  if (bp.type === "block_id" && bp.block_id) {
3917
3942
  bid = bp.block_id;
3918
3943
  continue;
3919
3944
  }
3920
- return settle(null);
3945
+ return settle2(null);
3921
3946
  }
3922
3947
  return null;
3923
3948
  };
@@ -4011,6 +4036,15 @@ var NotionClient = class _NotionClient {
4011
4036
  async deleteView(viewId) {
4012
4037
  await this.withRateLimit(() => this.client.views.delete({ view_id: viewId }));
4013
4038
  }
4039
+ /**
4040
+ * 보기를 고친다(ADR-046). 보내지 않은 항목은 그대로 두고 null 은 지운다. `configuration.properties` 는 통째로 바뀐다 —
4041
+ * 목록에 없는 속성은 숨겨지고 목록 차례가 열 차례다(2026-10-29 실측).
4042
+ */
4043
+ async updateView(viewId, request) {
4044
+ await this.withRateLimit(
4045
+ () => this.client.views.update({ ...request, view_id: viewId })
4046
+ );
4047
+ }
4014
4048
  async getView(viewId) {
4015
4049
  const view = await this.withRateLimit(() => this.client.views.retrieve({ view_id: viewId }));
4016
4050
  const v = view;
@@ -4086,6 +4120,12 @@ var NotionClient = class _NotionClient {
4086
4120
  * 슬롯을 붙잡아 남을 막는 대신, 다 같이 보는 이 게이트로 쉬게 한다.
4087
4121
  */
4088
4122
  cooldownUntil = 0;
4123
+ /**
4124
+ * Free 워크스페이스 블록 한도에 막힌 거절과 그 시각. 막힌 동안 블록을 만드는 요청은 보내지 않고 이 거절을 그대로
4125
+ * 돌려준다 — 플랜을 올리기 전에는 풀리지 않으므로 노트마다 같은 거절을 받으러 가지 않는다. 그 사이 플랜을
4126
+ * 올렸을 수 있어 {@link BLOCK_LIMIT_RECHECK_MS} 가 지나면 다시 보내 본다.
4127
+ */
4128
+ blockLimit = null;
4089
4129
  /**
4090
4130
  * 요청 1건을 rate limit 슬롯 안에서 실행하고, 재시도 가능한 실패는 백오프 후 다시 시도한다.
4091
4131
  *
@@ -4106,6 +4146,10 @@ var NotionClient = class _NotionClient {
4106
4146
  */
4107
4147
  async withRateLimit(fn, options) {
4108
4148
  const idempotent = options?.idempotent ?? true;
4149
+ if (options?.createsBlocks && this.blockLimit) {
4150
+ if (Date.now() - this.blockLimit.at < BLOCK_LIMIT_RECHECK_MS) throw this.blockLimit.error;
4151
+ this.blockLimit = null;
4152
+ }
4109
4153
  this.requestsSent++;
4110
4154
  let backoffMs = 0;
4111
4155
  for (let attempt = 0; ; attempt++) {
@@ -4115,6 +4159,12 @@ var NotionClient = class _NotionClient {
4115
4159
  this.lastRequestTime = Date.now();
4116
4160
  return await fn();
4117
4161
  } catch (error) {
4162
+ if (isWorkspaceBlockLimit(error)) {
4163
+ const limited = new NotionBlockLimitError(error);
4164
+ this.blockLimit = { error: limited, at: Date.now() };
4165
+ getLogger().warn(`[Im-Nobsidian] ${limited.message}`);
4166
+ throw limited;
4167
+ }
4118
4168
  if (!isRetryable(error, idempotent) || attempt >= this.maxRetries) throw error;
4119
4169
  const retryAfterMs = extractRetryAfter(error);
4120
4170
  backoffMs = retryAfterMs !== null ? retryAfterMs * (1 + Math.random() * 0.25) : this.retryBaseDelayMs * Math.pow(this.retryBackoffFactor, attempt) * (0.5 + Math.random() * 0.5);
@@ -4179,10 +4229,31 @@ function isAmbiguousFailure(error) {
4179
4229
  return status === 500 || status === 502 || status === 503 || status === 504;
4180
4230
  }
4181
4231
  function isRequestBlocked(error) {
4182
- if (notionStatusOf(error) !== 429) return false;
4232
+ return rateLimitReasonOf(error) === "public_api_request_blocked";
4233
+ }
4234
+ function rateLimitReasonOf(error) {
4235
+ if (notionStatusOf(error) !== 429) return null;
4183
4236
  const data = error.additional_data;
4184
- return data?.rate_limit_reason === "public_api_request_blocked";
4237
+ return typeof data?.rate_limit_reason === "string" ? data.rate_limit_reason : null;
4185
4238
  }
4239
+ function isWorkspaceBlockLimit(error) {
4240
+ if (notionStatusOf(error) !== 403) return false;
4241
+ const data = error.additional_data;
4242
+ return data?.block_limit !== void 0 && data.block_limit !== null;
4243
+ }
4244
+ var BLOCK_LIMIT_RECHECK_MS = 5 * 6e4;
4245
+ var NotionBlockLimitError = class extends Error {
4246
+ status = 403;
4247
+ code = "restricted_resource";
4248
+ constructor(cause) {
4249
+ const reason = cause instanceof Error ? cause.message : String(cause);
4250
+ super(
4251
+ `Notion \uC6CC\uD06C\uC2A4\uD398\uC774\uC2A4\uAC00 \uBB34\uB8CC \uBE14\uB85D \uD55C\uB3C4\uB97C \uB2E4 \uC368\uC11C \uC0C8 \uB0B4\uC6A9\uC744 \uB9CC\uB4E4 \uC218 \uC5C6\uC74C \u2014 Notion \uC5D0\uC11C \uD50C\uB79C\uC744 \uC62C\uB9AC\uBA74 \uD480\uB9B0\uB2E4 (Notion: ${reason})`,
4252
+ { cause }
4253
+ );
4254
+ this.name = "NotionBlockLimitError";
4255
+ }
4256
+ };
4186
4257
  function isRetryable(error, idempotent) {
4187
4258
  if (isRequestBlocked(error)) return false;
4188
4259
  return isExplicitRejection(error) || idempotent && isAmbiguousFailure(error);
@@ -4193,10 +4264,13 @@ function describeRetryCause(error) {
4193
4264
  const parts = [];
4194
4265
  if (typeof e.status === "number") parts.push(`status ${e.status}`);
4195
4266
  if (typeof e.code === "string") parts.push(e.code);
4267
+ const reason = rateLimitReasonOf(error);
4268
+ if (reason) parts.push(reason);
4196
4269
  if (typeof e.message === "string" && e.message) parts.push(e.message.slice(0, 120));
4197
4270
  return parts.length > 0 ? parts.join(" \xB7 ") : "\uC54C \uC218 \uC5C6\uB294 \uC624\uB958";
4198
4271
  }
4199
4272
  var MAX_RETRY_AFTER_MS = 6e4;
4273
+ var MAX_SHARED_RETRY_AFTER_MS = 5 * 6e4;
4200
4274
  function readHeader(headers, name) {
4201
4275
  if (typeof headers !== "object" || headers === null) return null;
4202
4276
  const get = headers.get;
@@ -4223,7 +4297,8 @@ function extractRetryAfter(error) {
4223
4297
  ms = Number.isNaN(at) ? Number.NaN : at - Date.now();
4224
4298
  }
4225
4299
  if (!Number.isFinite(ms) || ms <= 0) return null;
4226
- return Math.min(ms, MAX_RETRY_AFTER_MS);
4300
+ const cap = rateLimitReasonOf(error) === "public_api_space_request_rate_limit" ? MAX_SHARED_RETRY_AFTER_MS : MAX_RETRY_AFTER_MS;
4301
+ return Math.min(ms, cap);
4227
4302
  }
4228
4303
  function sleep(ms) {
4229
4304
  return new Promise((resolve2) => setTimeout(resolve2, ms));
@@ -5061,6 +5136,7 @@ function deferredSyncedRefMarker(index) {
5061
5136
  }
5062
5137
  var TOGGLE_START = compactMarker("toggle:start");
5063
5138
  var TOGGLE_END = compactMarker("toggle:end");
5139
+ var LARGE_FILE_MARKER = compactMarker("large-file");
5064
5140
  var TOGGLE_HEADING_START = compactMarker("toggle-heading");
5065
5141
  var TOGGLE_HEADING_END = compactMarker("toggle-heading:end");
5066
5142
  function calloutIndentMarker(depth2) {
@@ -5143,7 +5219,6 @@ var MARKER_TOKEN_RE = new RegExp(
5143
5219
 
5144
5220
  // src/converter/pipeline.ts
5145
5221
  var INLINE_DB_PROBE = new RegExp(`%% ${MARKER_BRAND_RE}:inline-db`);
5146
- var TOGGLE_HEADING_PROBE = new RegExp(`%% ${MARKER_BRAND_RE}:toggle-heading`);
5147
5222
  var ConversionPipeline = class {
5148
5223
  preProcessors = [];
5149
5224
  postProcessors = [];
@@ -5155,11 +5230,14 @@ var ConversionPipeline = class {
5155
5230
  this.postProcessors.push(processor);
5156
5231
  this.postProcessors.sort((a, b) => a.order - b.order);
5157
5232
  }
5233
+ /**
5234
+ * 토글 헤딩은 Markdown API 경로가 변환한다(`toggle-heading.ts`) — 블록 경로로 보내면 콜아웃이 옛 이모지 꼴로
5235
+ * 바뀐다(`CalloutTransformer`). 예전에 여기 있던 토글 헤딩 탐지는 띄어 쓴 마커를 찾아 한 번도 맞지 않았다.
5236
+ */
5158
5237
  selectPath(content) {
5159
5238
  const hasInlineDb = INLINE_DB_PROBE.test(content);
5160
5239
  const hasColumnLayout2 = />\s*\[!col\]/.test(content);
5161
- const hasToggleHeading = TOGGLE_HEADING_PROBE.test(content);
5162
- if (hasInlineDb || hasColumnLayout2 || hasToggleHeading) {
5240
+ if (hasInlineDb || hasColumnLayout2) {
5163
5241
  return "block-api";
5164
5242
  }
5165
5243
  return "markdown-api";
@@ -5788,6 +5866,27 @@ function obsidianMathCloseAt(line, open) {
5788
5866
  return close;
5789
5867
  }
5790
5868
 
5869
+ // src/converter/media-labels.ts
5870
+ var MEDIA_LABELS = {
5871
+ audio: "\u{1F50A}",
5872
+ video: "\u{1F3AC}",
5873
+ pdf: "\u{1F4C4}",
5874
+ file: "\u{1F4CE}",
5875
+ html: "\u{1F310}"
5876
+ };
5877
+ var LEGACY_MEDIA_ICONS = ["\u{1F39E}"];
5878
+ var MEDIA_PLACEHOLDER_CAPTIONS = new Set(Object.keys(MEDIA_LABELS));
5879
+ function mediaLabel(kind, caption) {
5880
+ return `${MEDIA_LABELS[kind]} ${caption || kind}`;
5881
+ }
5882
+ function mediaLinkRe(kind) {
5883
+ return new RegExp(`\\[${MEDIA_LABELS[kind]}\\s*([^\\]]*)\\]\\(([^)]+)\\)`, "g");
5884
+ }
5885
+ var MEDIA_LABEL_PREFIX_RE = new RegExp(
5886
+ `^(?:${[...Object.values(MEDIA_LABELS), ...LEGACY_MEDIA_ICONS].join("|")})\\s*`,
5887
+ "u"
5888
+ );
5889
+
5791
5890
  // src/converter/container-head.ts
5792
5891
  var CALLOUT_HEAD_LINE_RE = /^([ \t]{0,3})> \[!(\w+)\]([-+])?[ \t]*(.*)$/;
5793
5892
  var TOGGLE_HEAD_SOURCE = String.raw`^([ \t]{0,3})> \[!toggle\]-`;
@@ -5862,7 +5961,7 @@ var EmbedResolver = class {
5862
5961
  (match, alt, url, offset) => {
5863
5962
  if (code.some(([start, end]) => offset >= start && offset < end)) return match;
5864
5963
  if (isVideoUrl(url)) {
5865
- return `[\u{1F3AC} ${alt || "video"}](${url})`;
5964
+ return `[${mediaLabel("video", alt)}](${url})`;
5866
5965
  }
5867
5966
  if (isExternalUrl(url)) {
5868
5967
  images.push({ url, isExternal: true });
@@ -6889,8 +6988,8 @@ function assemble(p, fitted) {
6889
6988
  addLines(linesAt, spot.line, spot.exact ? lines : requote(lines, neighbor(p, spot.line)));
6890
6989
  }
6891
6990
  }
6892
- for (const group of groupLoose(p, loose)) {
6893
- addLines(linesAt, group.at, fitLines(p, group.at, group.lines));
6991
+ for (const group2 of groupLoose(p, loose)) {
6992
+ addLines(linesAt, group2.at, fitLines(p, group2.at, group2.lines));
6894
6993
  }
6895
6994
  return joinLines(p, linesAt, inlineAt);
6896
6995
  }
@@ -7426,6 +7525,112 @@ var BlankLineCollapser = class {
7426
7525
  }
7427
7526
  };
7428
7527
 
7528
+ // src/converter/heading-markers.ts
7529
+ var MARKER = `%%${MARKER_BRAND_RE}:(?:block-color:[a-z_]+|toggle-heading)%%`;
7530
+ var MARKERS = `(?:${MARKER}[ \\t]*)+`;
7531
+ var HEADING_WITH_MARKERS_RE = new RegExp(`^([\\t ]*)(#{1,6}[ \\t].*?)[ \\t]+(${MARKERS})$`, "gm");
7532
+ var MARKER_LINE_AFTER_HEADING_RE = new RegExp(
7533
+ `^([\\t ]*#{1,6}[ \\t].*?)[ \\t]*\\n[\\t ]*(${MARKERS})$`,
7534
+ "gm"
7535
+ );
7536
+ var HEADING_LINE_RE = /^[\t ]*#{1,6}[ \t]/;
7537
+ var MARKER_LINE_RE = new RegExp(`^[\\t ]*${MARKERS}$`);
7538
+ function placeHeadingMarkersBelow(content) {
7539
+ return mapOutsideCodeFences(
7540
+ content,
7541
+ (segment) => segment.replace(
7542
+ HEADING_WITH_MARKERS_RE,
7543
+ (_m, indent, heading, markers) => `${indent}${heading}
7544
+ ${indent}${markers.trim()}`
7545
+ )
7546
+ );
7547
+ }
7548
+ function liftHeadingMarkers(content) {
7549
+ return mapOutsideCodeFences(
7550
+ content,
7551
+ (segment) => segment.replace(
7552
+ MARKER_LINE_AFTER_HEADING_RE,
7553
+ (_m, heading, markers) => `${heading} ${markers.trim()}`
7554
+ )
7555
+ );
7556
+ }
7557
+ function isHeadingLine(line) {
7558
+ return HEADING_LINE_RE.test(line);
7559
+ }
7560
+ function isHeadingMarkerLine(line) {
7561
+ return MARKER_LINE_RE.test(line);
7562
+ }
7563
+ function sectionLinesWithHeadingMarkers(all, start, end) {
7564
+ const lines = all.slice(start, end + 1);
7565
+ const last = lines.length - 1;
7566
+ const next = all[end + 1];
7567
+ if (last >= 0 && next !== void 0 && isHeadingLine(lines[last]) && isHeadingMarkerLine(next)) {
7568
+ lines[last] = `${lines[last]} ${next.trim()}`;
7569
+ }
7570
+ return lines;
7571
+ }
7572
+
7573
+ // src/converter/pre-processors/heading-marker-lifter.ts
7574
+ var HeadingMarkerLifter = class {
7575
+ name = "HeadingMarkerLifter";
7576
+ order = 0;
7577
+ process(input) {
7578
+ if (input.context.direction !== "push") {
7579
+ return { content: input.content, metadata: input.metadata };
7580
+ }
7581
+ return { content: liftHeadingMarkers(input.content), metadata: input.metadata };
7582
+ }
7583
+ };
7584
+
7585
+ // src/converter/degrade-link.ts
7586
+ function degradeLink(label, url, marker) {
7587
+ return `[${label}](${url.replace(/\(/g, "%28").replace(/\)/g, "%29")})${marker}`;
7588
+ }
7589
+ var DEGRADE_LINK_SOURCE = "(?:\\[(?:\\\\.|[^\\\\\\]])*\\]\\([^)]*\\))?";
7590
+ function embedVaultLink(src, caption = "") {
7591
+ const label = caption.replace(/<[^>]*>/g, "").replace(/[[\]\\]/g, "").trim();
7592
+ return degradeLink(label === "" ? "\u{1F517} Embed" : `\u{1F517} ${label}`, src, embedMarker(src, caption));
7593
+ }
7594
+
7595
+ // src/converter/large-file-link.ts
7596
+ var LARGE_FILE_LINK_RE = new RegExp(` ?${DEGRADE_LINK_SOURCE}${LARGE_FILE_MARKER}`, "g");
7597
+ function notionFileLocation(pageId, blockId) {
7598
+ const page = `https://www.notion.so/${compactNotionId(pageId)}`;
7599
+ return blockId ? `${page}#${compactNotionId(blockId)}` : page;
7600
+ }
7601
+ function fileSizeLabel(bytes) {
7602
+ const mb = bytes / 1024 / 1024;
7603
+ return mb >= 1 ? `${Math.round(mb)}MB` : `${Math.max(1, Math.round(bytes / 1024))}KB`;
7604
+ }
7605
+ function largeFileLink(location, bytes) {
7606
+ return ` ${degradeLink(`\u2197 Notion \uC5D0\uC11C \uC5F4\uAE30 \xB7 ${fileSizeLabel(bytes)}`, location, LARGE_FILE_MARKER)}`;
7607
+ }
7608
+ function withLargeFileLink(content, fileLink, link) {
7609
+ const at = content.indexOf(fileLink);
7610
+ if (at < 0) return content;
7611
+ const end = at + fileLink.length;
7612
+ const sticky = new RegExp(LARGE_FILE_LINK_RE.source, "y");
7613
+ sticky.lastIndex = end;
7614
+ const existing = sticky.exec(content);
7615
+ const rest = existing ? end + existing[0].length : end;
7616
+ return `${content.slice(0, end)}${link}${content.slice(rest)}`;
7617
+ }
7618
+ function stripLargeFileLinks(content) {
7619
+ return content.includes(LARGE_FILE_MARKER) ? content.replace(LARGE_FILE_LINK_RE, "") : content;
7620
+ }
7621
+
7622
+ // src/converter/pre-processors/large-file-link-stripper.ts
7623
+ var LargeFileLinkStripper = class {
7624
+ name = "LargeFileLinkStripper";
7625
+ order = 0;
7626
+ process(input) {
7627
+ if (input.context.direction !== "push") {
7628
+ return { content: input.content, metadata: input.metadata };
7629
+ }
7630
+ return { content: stripLargeFileLinks(input.content), metadata: input.metadata };
7631
+ }
7632
+ };
7633
+
7429
7634
  // src/utils/wikilink-title.ts
7430
7635
  function wikilinkTitleFromPath(obsidianPath) {
7431
7636
  return noteNameFromPath(obsidianPath);
@@ -7436,7 +7641,7 @@ function formatWikilink(target, label) {
7436
7641
 
7437
7642
  // src/converter/link-restore.ts
7438
7643
  var NOTION_LINK_REGEX = new RegExp(
7439
- `\\[${MARKER_LABEL_CAPTURE}\\]\\((?:https:\\/\\/(?:www\\.)?notion\\.so\\/|notion:\\/\\/)([a-f0-9-]+)\\)(?!%%${MARKER_BRAND_RE}:(?:unknown|embed):)`,
7644
+ `\\[${MARKER_LABEL_CAPTURE}\\]\\((?:https:\\/\\/(?:www\\.)?notion\\.so\\/|notion:\\/\\/)([a-f0-9-]+)\\)(?!%%${MARKER_BRAND_RE}:(?:unknown|embed):|${LARGE_FILE_MARKER})`,
7440
7645
  "g"
7441
7646
  );
7442
7647
  var WIKILINK_PRESERVE_REGEX = new RegExp(
@@ -7701,7 +7906,7 @@ var PreserveMarkerInjector = class {
7701
7906
  const rendered = this.renderMarker(m);
7702
7907
  if (rendered === null) return false;
7703
7908
  if (content.includes(rendered)) return false;
7704
- return !this.isSemanticallyRestored(m, content);
7909
+ return !this.restoredElsewhere(m);
7705
7910
  });
7706
7911
  if (missing.length === 0) {
7707
7912
  return { content, metadata: input.metadata };
@@ -7736,15 +7941,11 @@ var PreserveMarkerInjector = class {
7736
7941
  const body = entries.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
7737
7942
  return spacedMarker(`${marker.type}:${body}`);
7738
7943
  }
7739
- /** 마커가 이미 원래 문법으로 복원돼 있으면 재삽입은 중복·오염이다. */
7740
- isSemanticallyRestored(marker, content) {
7741
- const params = marker.params ?? {};
7944
+ /** 다른 복원기가 맡는 마커 — 본문에 다시 끼우면 중복 · 오염이다. */
7945
+ restoredElsewhere(marker) {
7742
7946
  switch (marker.type) {
7743
- case "wikilink": {
7744
- const text = params.text;
7745
- if (!text) return false;
7746
- return content.includes(`[[${text}]]`) || content.includes(`[[${text}|`);
7747
- }
7947
+ case "wikilink":
7948
+ return true;
7748
7949
  case "local-image":
7749
7950
  case "local-file":
7750
7951
  return true;
@@ -7867,6 +8068,53 @@ function mergeRowFrontmatter(localNote, pulled, owned) {
7867
8068
  }
7868
8069
  return frontmatterLines(values);
7869
8070
  }
8071
+ function rekeyRowFrontmatter(localNote, renamed, dropped) {
8072
+ let local;
8073
+ try {
8074
+ local = splitFrontmatter(localNote);
8075
+ } catch {
8076
+ return null;
8077
+ }
8078
+ if (!local.hasFrontmatter) return null;
8079
+ const keys = Object.keys(local.data);
8080
+ const drops = new Set(keys.filter((key) => dropped(key, local.data[key])));
8081
+ const staying = new Set(keys.filter((key) => !drops.has(key) && !renamed.has(key)));
8082
+ const target = /* @__PURE__ */ new Map();
8083
+ for (const key of keys) {
8084
+ const next = renamed.get(key);
8085
+ if (next === void 0 || next === key || drops.has(key) || staying.has(next)) continue;
8086
+ if ([...target.values()].includes(next)) continue;
8087
+ target.set(key, next);
8088
+ }
8089
+ if (drops.size === 0 && target.size === 0) return null;
8090
+ const values = {};
8091
+ for (const key of keys) {
8092
+ if (drops.has(key)) continue;
8093
+ values[target.get(key) ?? key] = plainFrontmatterValue(local.data[key]);
8094
+ }
8095
+ const chunks = localChunks(localNote, local.data);
8096
+ if (chunks !== null) {
8097
+ const lines = [];
8098
+ for (const chunk of chunks) {
8099
+ if (chunk.key !== "" && !drops.has(chunk.key)) {
8100
+ const next = target.get(chunk.key);
8101
+ lines.push(...next === void 0 ? chunk.body : renamedChunk(chunk, next, values[next]));
8102
+ }
8103
+ lines.push(...chunk.trailing);
8104
+ }
8105
+ if (readsAs(lines, values)) return lines;
8106
+ }
8107
+ return frontmatterLines(values);
8108
+ }
8109
+ function renamedChunk(chunk, next, value) {
8110
+ const [first = "", ...rest] = chunk.body;
8111
+ const tokens = [chunk.key, JSON.stringify(chunk.key), `'${chunk.key.replace(/'/g, "''")}'`];
8112
+ const token2 = tokens.find((t) => first.startsWith(`${t}:`));
8113
+ if (token2 === void 0) return frontmatterLines({ [next]: value });
8114
+ const sample = frontmatterLines({ [next]: 0 })[0] ?? "";
8115
+ const nextToken = sample.slice(0, sample.lastIndexOf(":"));
8116
+ return [`${nextToken}${first.slice(token2.length)}`, ...rest];
8117
+ }
7870
8118
  function mergedKeyOrder(localKeys, pulledKeys, isOwned) {
7871
8119
  const pulledSet = new Set(pulledKeys);
7872
8120
  const order = localKeys.filter((key) => !isOwned(key) || pulledSet.has(key));
@@ -7900,15 +8148,15 @@ function localChunks(localNote, data) {
7900
8148
  }
7901
8149
  const chunks = [];
7902
8150
  const seen = [];
7903
- for (const [i, group] of groups.entries()) {
7904
- let end = group.length;
7905
- while (end > 0 && TRIVIA_RE.test(group[end - 1])) end--;
8151
+ for (const [i, group2] of groups.entries()) {
8152
+ let end = group2.length;
8153
+ while (end > 0 && TRIVIA_RE.test(group2[end - 1])) end--;
7906
8154
  if (i === 0) {
7907
8155
  if (end > 0) return null;
7908
- chunks.push({ key: "", body: [], trailing: group });
8156
+ chunks.push({ key: "", body: [], trailing: group2 });
7909
8157
  continue;
7910
8158
  }
7911
- const body = group.slice(0, end);
8159
+ const body = group2.slice(0, end);
7912
8160
  let parsed;
7913
8161
  try {
7914
8162
  const split = splitFrontmatter(`---
@@ -7925,7 +8173,7 @@ ${body.join("\n")}
7925
8173
  const key = keys[0];
7926
8174
  if (!Object.hasOwn(data, key) || !sameFrontmatterValue(parsed[key], data[key])) return null;
7927
8175
  seen.push(key);
7928
- chunks.push({ key, body, trailing: group.slice(end) });
8176
+ chunks.push({ key, body, trailing: group2.slice(end) });
7929
8177
  }
7930
8178
  const dataKeys = Object.keys(data);
7931
8179
  return seen.length === dataKeys.length && seen.every((key, i) => key === dataKeys[i]) ? chunks : null;
@@ -8604,7 +8852,10 @@ var CommentRestorer = class {
8604
8852
  if (input.context.direction !== "pull" || local === void 0) {
8605
8853
  return { content: input.content, metadata: input.metadata };
8606
8854
  }
8607
- return { content: restoreComments(input.content, local), metadata: input.metadata };
8855
+ return {
8856
+ content: restoreComments(input.content, liftHeadingMarkers(local)),
8857
+ metadata: input.metadata
8858
+ };
8608
8859
  }
8609
8860
  };
8610
8861
 
@@ -8845,8 +9096,8 @@ ${brokenLines(note2)}`;
8845
9096
  const lines = Math.max(note.split("\n").length, pulled.split("\n").length);
8846
9097
  const limit = Math.min(TRIAL_LIMIT, Math.floor(TRIAL_LINE_LIMIT / lines));
8847
9098
  const trial = new Trial(sent, target, slots, limit - 2);
8848
- const failed = trial.bisect(spots, (group, local2) => {
8849
- for (const spot of group) trial.slots[spot.slot] = [...local2 ? spot.local : spot.pulled];
9099
+ const failed = trial.bisect(spots, (group2, local2) => {
9100
+ for (const spot of group2) trial.slots[spot.slot] = [...local2 ? spot.local : spot.pulled];
8850
9101
  });
8851
9102
  for (const { slot, run } of failed) {
8852
9103
  if (run) trial.refine(run.local, run.pulled, (lines2) => trial.put(slot, lines2));
@@ -8953,9 +9204,10 @@ function pieces(local, pulled) {
8953
9204
  let j = 0;
8954
9205
  const zip = (to) => {
8955
9206
  const theirs = pulled.slice(j, to);
8956
- for (let k = 0; k < Math.max(pending.length, theirs.length); k++) {
8957
- out.push([pending.slice(k, k + 1), theirs.slice(k, k + 1)]);
8958
- }
9207
+ const paired = Math.min(pending.length, theirs.length);
9208
+ for (let k = 0; k < paired; k++) out.push([[pending[k]], [theirs[k]]]);
9209
+ if (pending.length > paired) out.push([pending.slice(paired), []]);
9210
+ for (let k = paired; k < theirs.length; k++) out.push([[], [theirs[k]]]);
8959
9211
  pending = [];
8960
9212
  j = to;
8961
9213
  };
@@ -9007,20 +9259,20 @@ var Trial = class {
9007
9259
  */
9008
9260
  bisect(items, swap, check = () => this.fits(), tried = true) {
9009
9261
  const failed = [];
9010
- const visit = (group, seen) => {
9011
- if (group.length === 0) return;
9262
+ const visit = (group2, seen) => {
9263
+ if (group2.length === 0) return;
9012
9264
  if (!seen) {
9013
- swap(group, true);
9265
+ swap(group2, true);
9014
9266
  if (check()) return;
9015
- swap(group, false);
9267
+ swap(group2, false);
9016
9268
  }
9017
- if (group.length === 1 || this.budget <= 0) {
9018
- failed.push(...group);
9269
+ if (group2.length === 1 || this.budget <= 0) {
9270
+ failed.push(...group2);
9019
9271
  return;
9020
9272
  }
9021
- const half = group.length >> 1;
9022
- visit(group.slice(0, half), false);
9023
- visit(group.slice(half), false);
9273
+ const half = group2.length >> 1;
9274
+ visit(group2.slice(0, half), false);
9275
+ visit(group2.slice(half), false);
9024
9276
  };
9025
9277
  visit(items, tried);
9026
9278
  return failed;
@@ -9060,8 +9312,8 @@ var Trial = class {
9060
9312
  const differs = own.flatMap((lines, k) => sameLines(lines, theirs[k]) ? [] : [k]);
9061
9313
  const failed = this.bisect(
9062
9314
  differs,
9063
- (group, useLocal) => {
9064
- for (const k of group) chosen[k] = [...(useLocal ? own : theirs)[k]];
9315
+ (group2, useLocal) => {
9316
+ for (const k of group2) chosen[k] = [...(useLocal ? own : theirs)[k]];
9065
9317
  },
9066
9318
  () => put(chosen.flat())
9067
9319
  );
@@ -9130,8 +9382,8 @@ var Trial = class {
9130
9382
  );
9131
9383
  this.bisect(
9132
9384
  spots,
9133
- (group, useLocal) => {
9134
- for (const spot of group) {
9385
+ (group2, useLocal) => {
9386
+ for (const spot of group2) {
9135
9387
  chosen[spot.index] = [...useLocal ? spot.local : spot.pulled];
9136
9388
  }
9137
9389
  },
@@ -9172,6 +9424,18 @@ function readable(note) {
9172
9424
  }
9173
9425
  }
9174
9426
 
9427
+ // src/converter/post-processors/heading-marker-placer.ts
9428
+ var HeadingMarkerPlacer = class {
9429
+ name = "HeadingMarkerPlacer";
9430
+ order = 140;
9431
+ process(input) {
9432
+ if (input.context.direction !== "pull") {
9433
+ return { content: input.content, metadata: input.metadata };
9434
+ }
9435
+ return { content: placeHeadingMarkersBelow(input.content), metadata: input.metadata };
9436
+ }
9437
+ };
9438
+
9175
9439
  // src/converter/literal-escapes.ts
9176
9440
  var ASCII_PUNCT_RE = /[!-/:-@[-`{-~]/;
9177
9441
  var MATH_FENCE_LINE_RE = /^(?:[ \t]*>)*[ \t]*\$\$[ \t]*$/;
@@ -9752,6 +10016,8 @@ function findBodyEnd(lines, from, level) {
9752
10016
  // src/utils/sanitize.ts
9753
10017
  var INVALID_CHARS = /[<>:"/\\|?*\x00-\x1f]/g;
9754
10018
  var WIKILINK_UNSAFE_CHARS = /[[\]]/g;
10019
+ var LINK_SUBPATH_CHARS = /[#^]/g;
10020
+ var TRAILING_DOTS_AND_SPACES = /[.\s]+$/;
9755
10021
  var TRAILING_DOTS = /\.+$/;
9756
10022
  var RESERVED_NAMES = /* @__PURE__ */ new Set([
9757
10023
  "CON",
@@ -9777,15 +10043,38 @@ var RESERVED_NAMES = /* @__PURE__ */ new Set([
9777
10043
  "LPT8",
9778
10044
  "LPT9"
9779
10045
  ]);
9780
- function sanitizeFileName(name) {
9781
- let sanitized = name.replace(INVALID_CHARS, "_").replace(WIKILINK_UNSAFE_CHARS, "_").replace(TRAILING_DOTS, "").trim().slice(0, 200);
9782
- if (RESERVED_NAMES.has(sanitized.toUpperCase())) {
9783
- sanitized = `_${sanitized}`;
9784
- }
9785
- if (sanitized.length === 0) {
9786
- sanitized = "_untitled";
10046
+ var FILE_NAME_MAX_BYTES = 200;
10047
+ var FILE_SYSTEM_NAME_MAX_BYTES = 255;
10048
+ var utf8 = new TextEncoder();
10049
+ function utf8Bytes(text) {
10050
+ return utf8.encode(text).length;
10051
+ }
10052
+ function truncateBytes(name, maxBytes) {
10053
+ if (utf8.encode(name).length <= maxBytes) return name;
10054
+ let out = "";
10055
+ let bytes = 0;
10056
+ for (const { segment } of new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(
10057
+ name
10058
+ )) {
10059
+ const size = utf8.encode(segment).length;
10060
+ if (bytes + size > maxBytes) break;
10061
+ out += segment;
10062
+ bytes += size;
9787
10063
  }
9788
- return sanitized;
10064
+ return out;
10065
+ }
10066
+ function guarded(name) {
10067
+ const safe = RESERVED_NAMES.has(name.toUpperCase()) ? `_${name}` : name;
10068
+ return safe.length === 0 ? "_untitled" : safe;
10069
+ }
10070
+ function sanitizeFileName(name) {
10071
+ const cleaned = name.replace(INVALID_CHARS, "_").replace(WIKILINK_UNSAFE_CHARS, "_").replace(LINK_SUBPATH_CHARS, "_").trim();
10072
+ return guarded(truncateBytes(cleaned, FILE_NAME_MAX_BYTES).replace(TRAILING_DOTS_AND_SPACES, ""));
10073
+ }
10074
+ function legacySanitizeFileName(name) {
10075
+ return guarded(
10076
+ name.replace(INVALID_CHARS, "_").replace(WIKILINK_UNSAFE_CHARS, "_").replace(TRAILING_DOTS, "").trim().slice(0, 200)
10077
+ );
9789
10078
  }
9790
10079
 
9791
10080
  // src/converter/nfm-attrs.ts
@@ -10067,8 +10356,8 @@ function parseRows(body) {
10067
10356
  }
10068
10357
  function parseCols(body) {
10069
10358
  const head = body.search(ROW_START_RE);
10070
- const group = COLGROUP_RE.exec(head === -1 ? body : body.slice(0, head))?.[1] ?? "";
10071
- return [...group.matchAll(COL_RE)].map((col) => parseNfmAttrs(col[1] ?? ""));
10359
+ const group2 = COLGROUP_RE.exec(head === -1 ? body : body.slice(0, head))?.[1] ?? "";
10360
+ return [...group2.matchAll(COL_RE)].map((col) => parseNfmAttrs(col[1] ?? ""));
10072
10361
  }
10073
10362
  function sameAttrs(a, b) {
10074
10363
  return a.length === b.length && a.every(([k, v]) => b.some(([bk, bv]) => bk === k && bv === v));
@@ -10289,16 +10578,6 @@ function breaksToEmptyBlocks(content) {
10289
10578
  });
10290
10579
  }
10291
10580
 
10292
- // src/converter/degrade-link.ts
10293
- function degradeLink(label, url, marker) {
10294
- return `[${label}](${url.replace(/\(/g, "%28").replace(/\)/g, "%29")})${marker}`;
10295
- }
10296
- var DEGRADE_LINK_SOURCE = "(?:\\[(?:\\\\.|[^\\\\\\]])*\\]\\([^)]*\\))?";
10297
- function embedVaultLink(src, caption = "") {
10298
- const label = caption.replace(/<[^>]*>/g, "").replace(/[[\]\\]/g, "").trim();
10299
- return degradeLink(label === "" ? "\u{1F517} Embed" : `\u{1F517} ${label}`, src, embedMarker(src, caption));
10300
- }
10301
-
10302
10581
  // src/converter/placeholder-indent.ts
10303
10582
  var PLACEHOLDER_LINE_RE = new RegExp(`^([ \\t]*)${MEDIA_PLACEHOLDER_HEAD}`);
10304
10583
  var LEADING_SPACE_RE3 = /^[ \t]*/;
@@ -11240,6 +11519,7 @@ var NOTION_AUDIO_RE = /<audio src="([^"]*)">([\s\S]*?)<\/audio>/g;
11240
11519
  var NOTION_VIDEO_RE = /<video src="([^"]*)">([\s\S]*?)<\/video>/g;
11241
11520
  var NOTION_PDF_RE = /<pdf src="([^"]*)">([\s\S]*?)<\/pdf>/g;
11242
11521
  var NOTION_FILE_RE = /<file src="([^"]*)">([\s\S]*?)<\/file>/g;
11522
+ var NOTION_HOSTED_EMBED_RE = /<embed\s+src="([^"]*)"[^>]*>([^\n]*?)\s*<\/embed>/g;
11243
11523
  var NOTION_TAB_RE = /<tab title="([^"]*)">([\s\S]*?)<\/tab>/g;
11244
11524
  var NOTION_LABELED_PAGE_LINK_RE = new RegExp(
11245
11525
  `\\[${MARKER_LABEL_CAPTURE}\\]\\(https?:\\/\\/(?:[a-z]+\\.)?notion\\.(?:so|com)\\/(?:p\\/)?([a-f0-9]{32}|[a-f0-9-]{36})\\)`,
@@ -11294,7 +11574,7 @@ function unescapeBrackets(content) {
11294
11574
  );
11295
11575
  }
11296
11576
  function obsidianToNotionEnhanced(obsidian) {
11297
- let result = obsidian;
11577
+ let result = liftHeadingMarkers(stripLargeFileLinks(obsidian));
11298
11578
  result = quotedCodeToChildren(result);
11299
11579
  result = breaksToEmptyBlocks(result);
11300
11580
  result = pipeTablesToNfmTables(result);
@@ -11556,20 +11836,20 @@ ${indented}
11556
11836
  function convertMediaTags(content) {
11557
11837
  let result = content;
11558
11838
  result = result.replace(NOTION_AUDIO_RE, (_match, src, caption) => {
11559
- const cap = caption.trim();
11560
- return cap ? `[\u{1F50A} ${cap}](${src})` : `[\u{1F50A} audio](${src})`;
11839
+ return `[${mediaLabel("audio", caption.trim())}](${src})`;
11561
11840
  });
11562
11841
  result = result.replace(NOTION_VIDEO_RE, (_match, src, caption) => {
11563
- const cap = caption.trim();
11564
- return cap ? `[\u{1F3AC} ${cap}](${src})` : `[\u{1F3AC} video](${src})`;
11842
+ return `[${mediaLabel("video", caption.trim())}](${src})`;
11565
11843
  });
11566
11844
  result = result.replace(NOTION_PDF_RE, (_match, src, caption) => {
11567
- const cap = caption.trim();
11568
- return cap ? `[\u{1F4C4} ${cap}](${src})` : `[\u{1F4C4} pdf](${src})`;
11845
+ return `[${mediaLabel("pdf", caption.trim())}](${src})`;
11569
11846
  });
11570
11847
  result = result.replace(NOTION_FILE_RE, (_match, src, caption) => {
11571
- const cap = caption.trim();
11572
- return cap ? `[\u{1F4CE} ${cap}](${src})` : `[\u{1F4CE} file](${src})`;
11848
+ return `[${mediaLabel("file", caption.trim())}](${src})`;
11849
+ });
11850
+ result = result.replace(NOTION_HOSTED_EMBED_RE, (match, src, caption) => {
11851
+ if (!isNotionHostedFileUrl(src)) return match;
11852
+ return `[${mediaLabel("html", caption.trim())}](${src})`;
11573
11853
  });
11574
11854
  return result;
11575
11855
  }
@@ -11806,10 +12086,11 @@ $$`;
11806
12086
  function unescapeNotionChars(content) {
11807
12087
  return content.replace(/\\~/g, "~").replace(/\\\^/g, "^");
11808
12088
  }
11809
- var OBSIDIAN_MEDIA_AUDIO_RE = /\[🔊\s*([^\]]*)\]\(([^)]+)\)/g;
11810
- var OBSIDIAN_MEDIA_VIDEO_RE = /\[🎬\s*([^\]]*)\]\(([^)]+)\)/g;
11811
- var OBSIDIAN_MEDIA_PDF_RE = /\[📄\s*([^\]]*)\]\(([^)]+)\)/g;
11812
- var OBSIDIAN_MEDIA_FILE_RE = /\[📎\s*([^\]]*)\]\(([^)]+)\)/g;
12089
+ var OBSIDIAN_MEDIA_AUDIO_RE = mediaLinkRe("audio");
12090
+ var OBSIDIAN_MEDIA_VIDEO_RE = mediaLinkRe("video");
12091
+ var OBSIDIAN_MEDIA_PDF_RE = mediaLinkRe("pdf");
12092
+ var OBSIDIAN_MEDIA_FILE_RE = mediaLinkRe("file");
12093
+ var OBSIDIAN_MEDIA_HTML_RE = mediaLinkRe("html");
11813
12094
  function restoreMediaTags(content) {
11814
12095
  let result = content;
11815
12096
  const restore = (tag) => (_match, caption, src) => {
@@ -11820,6 +12101,10 @@ function restoreMediaTags(content) {
11820
12101
  result = result.replace(OBSIDIAN_MEDIA_VIDEO_RE, restore("video"));
11821
12102
  result = result.replace(OBSIDIAN_MEDIA_PDF_RE, restore("pdf"));
11822
12103
  result = result.replace(OBSIDIAN_MEDIA_FILE_RE, restore("file"));
12104
+ result = result.replace(OBSIDIAN_MEDIA_HTML_RE, (_match, caption, src) => {
12105
+ const cap = caption.trim() === "html" ? "" : caption;
12106
+ return `<embed src="${src}">${cap}</embed>`;
12107
+ });
11823
12108
  return result;
11824
12109
  }
11825
12110
  var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)((?:\n> .*)*)/gm;
@@ -12090,6 +12375,8 @@ function sortKeys(value) {
12090
12375
  // src/converter/pipeline-factory.ts
12091
12376
  function createDefaultPipeline(options) {
12092
12377
  const pipeline = new ConversionPipeline();
12378
+ pipeline.registerPreProcessor(new HeadingMarkerLifter());
12379
+ pipeline.registerPreProcessor(new LargeFileLinkStripper());
12093
12380
  pipeline.registerPreProcessor(new InlineAnnotationPreserver());
12094
12381
  pipeline.registerPreProcessor(new UnsupportedBlockStripper());
12095
12382
  pipeline.registerPreProcessor(new CommentStripper());
@@ -12120,6 +12407,7 @@ function createDefaultPipeline(options) {
12120
12407
  pipeline.registerPostProcessor(
12121
12408
  new LocalFormRestorer((note, context, children) => sentForm(pipeline, note, context, children))
12122
12409
  );
12410
+ pipeline.registerPostProcessor(new HeadingMarkerPlacer());
12123
12411
  return pipeline;
12124
12412
  }
12125
12413
 
@@ -12227,6 +12515,11 @@ function isNotionUploadable(path) {
12227
12515
  const ext = /\.[^.]+$/.exec(name)?.[0].toLowerCase();
12228
12516
  return ext !== void 0 && NOTION_UPLOADABLE_EXTENSIONS.has(ext);
12229
12517
  }
12518
+ var HTML_BLOCK_EXTENSIONS = /* @__PURE__ */ new Set([".html", ".htm"]);
12519
+ function isHtmlBlockFile(path) {
12520
+ const ext = /\.[^./]+$/.exec(path)?.[0].toLowerCase();
12521
+ return ext !== void 0 && HTML_BLOCK_EXTENSIONS.has(ext);
12522
+ }
12230
12523
  function getBlockType(filename) {
12231
12524
  const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
12232
12525
  if (!ext) return "file";
@@ -12354,10 +12647,15 @@ function toWikilinkAlias(label) {
12354
12647
  return label.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, "$1").replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, t, d) => d ?? t).replace(/[\r\n]+/g, " ").replace(/\\([!-/:-@[-`{-~])/g, "$1").replace(/\]\]/g, ")").replace(/\|/g, "-").trim();
12355
12648
  }
12356
12649
  var INTERNAL_FILE_URL_RE = /^file:\/\/%7B.*%7D%7D$/;
12357
- var FILE_LABEL_PREFIX_RE = /^(?:📎|🎬|🎞|📄|🔊)\s*/u;
12358
- var MEDIA_TAG_RE = /<(?:file|video|audio|pdf)\s+src="([^"]+)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
12359
- var MEDIA_TAG_NAME_RE = /^<(file|video|audio|pdf)\s/;
12360
- var MEDIA_PLACEHOLDER_CAPTIONS = /* @__PURE__ */ new Set(["file", "pdf", "video", "audio"]);
12650
+ var MEDIA_TAG_NAMES = "file|video|audio|pdf|embed";
12651
+ function mediaTagRe(src) {
12652
+ return new RegExp(
12653
+ `<(?:${MEDIA_TAG_NAMES})\\s+src="(${src})">([\\s\\S]*?)<\\/(?:${MEDIA_TAG_NAMES})>`,
12654
+ "g"
12655
+ );
12656
+ }
12657
+ var MEDIA_TAG_RE = mediaTagRe('[^"]+');
12658
+ var MEDIA_TAG_NAME_RE = new RegExp(`^<(${MEDIA_TAG_NAMES})\\s`);
12361
12659
  var PLACEHOLDER_IMAGE_STEMS = /* @__PURE__ */ new Set(["image", "untitled"]);
12362
12660
  var EMBED_SIZE_RE = /^\d+(?:x\d+)?$/;
12363
12661
  function splitEmbedTarget(target) {
@@ -12381,8 +12679,8 @@ function embedTargetFromCaption(target, caption, uploadedName) {
12381
12679
  return description ? sized(`${path}|${alias}`) : `${path}|${alias}`;
12382
12680
  }
12383
12681
  function captionOfLabel(label) {
12384
- const caption = label.replace(FILE_LABEL_PREFIX_RE, "").replace(/\\(.)/g, "$1").trim();
12385
- return FILE_LABEL_PREFIX_RE.test(label) && MEDIA_PLACEHOLDER_CAPTIONS.has(caption) ? "" : caption;
12682
+ const caption = label.replace(MEDIA_LABEL_PREFIX_RE, "").replace(/\\(.)/g, "$1").trim();
12683
+ return MEDIA_LABEL_PREFIX_RE.test(label) && MEDIA_PLACEHOLDER_CAPTIONS.has(caption) ? "" : caption;
12386
12684
  }
12387
12685
  function fileNameOf2(caption, url) {
12388
12686
  return savedNameOf(caption, uploadedFileNameFromUrl(url));
@@ -12409,7 +12707,7 @@ function isNotionMediaUrl(url) {
12409
12707
  function notionMediaSpans(markdown) {
12410
12708
  const spans = [];
12411
12709
  for (const link of scanMarkdownLinks(markdown)) {
12412
- if (!link.isEmbed && !FILE_LABEL_PREFIX_RE.test(link.label)) continue;
12710
+ if (!link.isEmbed && !MEDIA_LABEL_PREFIX_RE.test(link.label)) continue;
12413
12711
  if (isNotionMediaUrl(link.url)) spans.push(link);
12414
12712
  }
12415
12713
  for (const tag of markdown.matchAll(MEDIA_TAG_RE)) {
@@ -12481,6 +12779,7 @@ function mediaBlock2(kind, fileUploadId, vaultTarget, target) {
12481
12779
  const description = captionOfEmbed(target);
12482
12780
  const caption = description ? [{ type: "text", text: { content: description } }] : [];
12483
12781
  const file = { type: "file_upload", file_upload: { id: fileUploadId }, caption };
12782
+ if (kind !== "image" && isHtmlBlockFile(vaultTarget)) return { type: "embed", embed: file };
12484
12783
  const type = kind === "image" ? "image" : getBlockType(baseName(vaultTarget));
12485
12784
  return type === "file" ? { type, file: { ...file, name: baseName(vaultTarget) } } : { type, [type]: file };
12486
12785
  }
@@ -13125,17 +13424,15 @@ var ImageHandler = class {
13125
13424
  }
13126
13425
  async downloadAllFiles(markdown, pageTitle, pageId) {
13127
13426
  const fileLinks = scanMarkdownLinks(markdown).filter(
13128
- (s) => !s.isEmbed && FILE_LABEL_PREFIX_RE.test(s.label)
13427
+ (s) => !s.isEmbed && MEDIA_LABEL_PREFIX_RE.test(s.label)
13129
13428
  );
13130
13429
  const notionHttpMatches = fileLinks.filter(
13131
13430
  (s) => /^https?:\/\//.test(s.url) && this.isNotionFileUrl(s.url)
13132
13431
  );
13133
13432
  const mdInternalMatches = fileLinks.filter((s) => INTERNAL_FILE_URL_RE.test(s.url));
13134
- const xmlHttpRegex = /<(?:file|video|audio|pdf)\s+src="(https?:\/\/[^"]+)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
13135
- const xmlHttpMatches = [...markdown.matchAll(xmlHttpRegex)];
13433
+ const xmlHttpMatches = [...markdown.matchAll(mediaTagRe('https?:\\/\\/[^"]+'))];
13136
13434
  const notionXmlHttpMatches = xmlHttpMatches.filter((m) => m[1] && this.isNotionFileUrl(m[1]));
13137
- const xmlInternalRegex = /<(?:file|video|audio|pdf)\s+src="(file:\/\/%7B.*?%7D%7D)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
13138
- const xmlInternalMatches = [...markdown.matchAll(xmlInternalRegex)];
13435
+ const xmlInternalMatches = [...markdown.matchAll(mediaTagRe("file:\\/\\/%7B.*?%7D%7D"))];
13139
13436
  const totalMatches = notionHttpMatches.length + mdInternalMatches.length + notionXmlHttpMatches.length + xmlInternalMatches.length;
13140
13437
  if (totalMatches === 0) {
13141
13438
  return { content: markdown, downloads: [] };
@@ -13147,9 +13444,12 @@ var ImageHandler = class {
13147
13444
  for (const span of notionHttpMatches) {
13148
13445
  await sema.acquire();
13149
13446
  try {
13150
- const caption = fileNameOf2(span.label.replace(FILE_LABEL_PREFIX_RE, ""), span.url);
13447
+ const caption = fileNameOf2(span.label.replace(MEDIA_LABEL_PREFIX_RE, ""), span.url);
13151
13448
  const download = await this.downloadFile(span.url, pageTitle, caption);
13152
- if (!download.localPath) continue;
13449
+ if (!download.localPath) {
13450
+ result = keepLargeFile(result, span.full, download, pageId);
13451
+ continue;
13452
+ }
13153
13453
  downloads.push(download);
13154
13454
  const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
13155
13455
  result = result.replace(span.full, () => obsidianLink);
@@ -13178,9 +13478,12 @@ var ImageHandler = class {
13178
13478
  getLogger().warn(`[Im-Nobsidian] \uD30C\uC77C Block URL \uC870\uD68C \uC2E4\uD328: ${fileName2}`);
13179
13479
  continue;
13180
13480
  }
13181
- const caption = savedNameOf(span.label.replace(FILE_LABEL_PREFIX_RE, ""), fileName2);
13481
+ const caption = savedNameOf(span.label.replace(MEDIA_LABEL_PREFIX_RE, ""), fileName2);
13182
13482
  const download = await this.downloadFile(realUrl, pageTitle, caption);
13183
- if (!download.localPath) continue;
13483
+ if (!download.localPath) {
13484
+ result = keepLargeFile(result, span.full, download, pageId, blockId);
13485
+ continue;
13486
+ }
13184
13487
  downloads.push(download);
13185
13488
  const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
13186
13489
  result = result.replace(span.full, () => obsidianLink);
@@ -13203,7 +13506,10 @@ var ImageHandler = class {
13203
13506
  const url = match[1];
13204
13507
  const caption = fileNameOf2(match[2] ?? "", url);
13205
13508
  const download = await this.downloadFile(url, pageTitle, caption);
13206
- if (!download.localPath) continue;
13509
+ if (!download.localPath) {
13510
+ result = keepLargeFile(result, match[0], download, pageId);
13511
+ continue;
13512
+ }
13207
13513
  downloads.push(download);
13208
13514
  const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
13209
13515
  result = result.replace(match[0], () => obsidianLink);
@@ -13235,7 +13541,10 @@ var ImageHandler = class {
13235
13541
  }
13236
13542
  const caption = savedNameOf(match[2] ?? "", fileName2);
13237
13543
  const download = await this.downloadFile(realUrl, pageTitle, caption);
13238
- if (!download.localPath) continue;
13544
+ if (!download.localPath) {
13545
+ result = keepLargeFile(result, match[0], download, pageId, blockId);
13546
+ continue;
13547
+ }
13239
13548
  downloads.push(download);
13240
13549
  const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
13241
13550
  result = result.replace(match[0], () => obsidianLink);
@@ -13286,18 +13595,21 @@ var ImageHandler = class {
13286
13595
  const contentLength = response.headers.get("content-length");
13287
13596
  const MAX_FILE_SIZE = this.maxFileSizeBytes;
13288
13597
  if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
13289
- const sizeMB = Math.round(parseInt(contentLength, 10) / 1024 / 1024);
13598
+ const bytes = parseInt(contentLength, 10);
13290
13599
  getLogger().info(
13291
- `[Im-Nobsidian] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uAC74\uB108\uB700 \u2014 \uD06C\uAE30 \uC0C1\uD55C \uCD08\uACFC (${sizeMB}MB > ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB), Notion \uC6D0\uBCF8 \uB9C1\uD06C \uC720\uC9C0: ${caption}`
13600
+ `[Im-Nobsidian] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uAC74\uB108\uB700 \u2014 \uD06C\uAE30 \uC0C1\uD55C \uCD08\uACFC (${fileSizeLabel(bytes)} > ${fileSizeLabel(MAX_FILE_SIZE)}), Notion \uC5D0\uC11C \uC5F4\uAE30 \uB9C1\uD06C\uB97C \uB460: ${caption}`
13292
13601
  );
13293
- return { originalUrl: url, localPath: "", hash: "", size: 0 };
13602
+ return { originalUrl: url, localPath: "", hash: "", size: 0, skippedBytes: bytes };
13294
13603
  }
13295
13604
  const buffer = Buffer.from(await response.arrayBuffer());
13296
13605
  const fileHash = createHash("sha256").update(buffer).digest("hex");
13297
13606
  const hash = nameHashOf(fileHash);
13298
13607
  const extension = this.guessFileExtension(url, response.headers.get("content-type"));
13299
13608
  const name = withoutOwnHash(caption, hash);
13300
- const safeName = name.replace(/[^a-zA-Z0-9가-힣\s._-]/g, "").replace(/\s+/g, "-").slice(0, 80);
13609
+ const safeName = truncateBytes(
13610
+ name.replace(/[^a-zA-Z0-9가-힣\s._-]/g, "").replace(/\s+/g, "-").slice(0, 80),
13611
+ FILE_SYSTEM_NAME_MAX_BYTES - utf8Bytes(`-${hash}${extension}`)
13612
+ );
13301
13613
  const fileName2 = `${safeName}-${hash}${extension}`;
13302
13614
  const localPath = `${this.attachmentFolder}/${fileName2}`;
13303
13615
  await this.vaultFs.ensureFolder(this.attachmentFolder);
@@ -13359,6 +13671,49 @@ var ImageHandler = class {
13359
13671
  return "";
13360
13672
  }
13361
13673
  };
13674
+ function keepLargeFile(content, fileLink, download, pageId, blockId) {
13675
+ if (download.skippedBytes === void 0 || !pageId) return content;
13676
+ const link = largeFileLink(notionFileLocation(pageId, blockId), download.skippedBytes);
13677
+ return withLargeFileLink(content, fileLink, link);
13678
+ }
13679
+
13680
+ // src/view/relative-dates.ts
13681
+ var RELATIVE_DATE_VALUES = {
13682
+ today: "today()",
13683
+ tomorrow: 'today() + "1d"',
13684
+ yesterday: 'today() - "1d"',
13685
+ one_week_ago: 'today() - "7d"',
13686
+ one_week_from_now: 'today() + "7d"',
13687
+ one_month_ago: 'today() - "1M"',
13688
+ one_month_from_now: 'today() + "1M"'
13689
+ };
13690
+ var RELATIVE_DATE_RANGES = {
13691
+ past_week: { toward: "past", span: "7d" },
13692
+ past_month: { toward: "past", span: "1M" },
13693
+ past_year: { toward: "past", span: "1y" },
13694
+ next_week: { toward: "next", span: "7d" },
13695
+ next_month: { toward: "next", span: "1M" },
13696
+ next_year: { toward: "next", span: "1y" }
13697
+ };
13698
+ function dateOnly(ref) {
13699
+ return `${ref}.date()`;
13700
+ }
13701
+ function relativeRangeExprs(ref, range) {
13702
+ const day = dateOnly(ref);
13703
+ return range.toward === "past" ? [`${day} >= today() - "${range.span}"`, `${day} <= today()`] : [`${day} >= today()`, `${day} <= today() + "${range.span}"`];
13704
+ }
13705
+
13706
+ // src/view/bases-file-properties.ts
13707
+ var BASES_TITLE = "file.name";
13708
+ var BASES_TIMESTAMPS = {
13709
+ created_time: "file.ctime",
13710
+ last_edited_time: "file.mtime"
13711
+ };
13712
+ function notionTimestampOf(column) {
13713
+ return Object.keys(BASES_TIMESTAMPS).find(
13714
+ (timestamp) => BASES_TIMESTAMPS[timestamp] === column
13715
+ );
13716
+ }
13362
13717
 
13363
13718
  // src/view/notion-filter-translator.ts
13364
13719
  function translateNotionFilter(filter, schema, resolveName) {
@@ -13407,7 +13762,7 @@ function translateNode(node, ctx) {
13407
13762
  function translateLeaf(leaf2, ctx) {
13408
13763
  const timestamp = leaf2["timestamp"];
13409
13764
  if (typeof timestamp === "string") {
13410
- const ref2 = timestamp === "created_time" ? "file.ctime" : "file.mtime";
13765
+ const ref2 = BASES_TIMESTAMPS[timestamp === "created_time" ? "created_time" : "last_edited_time"];
13411
13766
  const cond = leaf2[timestamp];
13412
13767
  if (!isRecord(cond)) return unsupported(ctx, `timestamp '${timestamp}' \uD544\uD130`);
13413
13768
  return translateDate(ref2, cond, ctx, `timestamp '${timestamp}'`);
@@ -13531,8 +13886,8 @@ function translateStatus(ref, cond, ctx, label, propName) {
13531
13886
  expanded.push(name);
13532
13887
  continue;
13533
13888
  }
13534
- const group = prop?.groups?.find((g) => g.name === name);
13535
- const members = group && prop ? expandGroup(group, prop) : null;
13889
+ const group2 = prop?.groups?.find((g) => g.name === name);
13890
+ const members = group2 && prop ? expandGroup(group2, prop) : null;
13536
13891
  if (members === null) {
13537
13892
  return unsupported(ctx, `${label}: \uC0C1\uD0DC '${name}' \uC744 \uC635\uC158\uC73C\uB85C \uC804\uAC1C\uD558\uC9C0 \uBABB\uD568`);
13538
13893
  }
@@ -13543,8 +13898,8 @@ function translateStatus(ref, cond, ctx, label, propName) {
13543
13898
  }
13544
13899
  return equalityOverNames(ref, op, dedupe(expanded), ctx, label);
13545
13900
  }
13546
- function expandGroup(group, prop) {
13547
- const ids = group.optionIds;
13901
+ function expandGroup(group2, prop) {
13902
+ const ids = group2.optionIds;
13548
13903
  if (!ids || ids.length === 0) return [];
13549
13904
  const byId = new Map((prop.options ?? []).map((o) => [o.id, o.name]));
13550
13905
  const names = [];
@@ -13584,29 +13939,32 @@ function translateEmptiness(ref, cond, ctx, label) {
13584
13939
  const [op] = firstEntry(cond);
13585
13940
  return unsupported(ctx, `${label}: \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
13586
13941
  }
13942
+ var DATE_COMPARISONS = {
13943
+ equals: "==",
13944
+ before: "<",
13945
+ after: ">",
13946
+ on_or_before: "<=",
13947
+ on_or_after: ">="
13948
+ };
13587
13949
  function translateDate(ref, cond, ctx, label) {
13588
13950
  const empt = translateEmptinessOnly(ref, cond);
13589
13951
  if (empt) return empt;
13590
13952
  const [op, value] = firstEntry(cond);
13953
+ const range = Object.hasOwn(RELATIVE_DATE_RANGES, op) ? RELATIVE_DATE_RANGES[op] : void 0;
13954
+ if (range) return { and: relativeRangeExprs(ref, range) };
13591
13955
  if (typeof value !== "string") {
13592
13956
  return unsupported(ctx, `${label}: \uC0C1\uB300 \uB0A0\uC9DC \uC870\uAC74 '${op}' \uC740 \uC62E\uAE30\uC9C0 \uC54A\uC74C`);
13593
13957
  }
13594
- const lit = str(value);
13595
- switch (op) {
13596
- case "equals":
13597
- return `${ref} == ${lit}`;
13598
- case "before":
13599
- return `${ref} < ${lit}`;
13600
- case "after":
13601
- return `${ref} > ${lit}`;
13602
- case "on_or_before":
13603
- return `${ref} <= ${lit}`;
13604
- case "on_or_after":
13605
- return `${ref} >= ${lit}`;
13606
- default:
13607
- return unsupported(ctx, `${label}: \uB0A0\uC9DC \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
13958
+ const symbol = Object.hasOwn(DATE_COMPARISONS, op) ? DATE_COMPARISONS[op] : void 0;
13959
+ if (!symbol) return unsupported(ctx, `${label}: \uB0A0\uC9DC \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
13960
+ const relative2 = Object.hasOwn(RELATIVE_DATE_VALUES, value) ? RELATIVE_DATE_VALUES[value] : void 0;
13961
+ if (relative2) return `${dateOnly(ref)} ${symbol} ${relative2}`;
13962
+ if (!ISO_DATE_RE.test(value)) {
13963
+ return unsupported(ctx, `${label}: \uB0A0\uC9DC \uAC12 '${value}' \uC744 \uD574\uC11D\uD558\uC9C0 \uBABB\uD568`);
13608
13964
  }
13965
+ return `${ref} ${symbol} ${str(value)}`;
13609
13966
  }
13967
+ var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+(?:Z|[+-]\d{2}:?\d{2})?)?$/;
13610
13968
  function translateEmptinessOnly(ref, cond) {
13611
13969
  if (cond["is_empty"] === true) return `${ref}.isEmpty()`;
13612
13970
  if (cond["is_not_empty"] === true) return `!${ref}.isEmpty()`;
@@ -13688,8 +14046,16 @@ function filterGroup(node) {
13688
14046
  return ["not", node.not];
13689
14047
  }
13690
14048
  var BASE_FILE_HEADER = "# Auto-generated by Im-Nobsidian";
14049
+ function inFolderFilter(folderPath) {
14050
+ return `file.inFolder("${folderPath}")`;
14051
+ }
14052
+ var IN_FOLDER_FILTER_RE = /file\.inFolder\("([^"\n]*)"\)/g;
13691
14053
  var BaseFileGenerator = class {
13692
14054
  generate(options) {
14055
+ return this.generateWithViews(options).content;
14056
+ }
14057
+ /** `.base` 내용과 함께 쓴 보기마다 어느 Notion 보기에서 왔는지 돌려준다 — pull 이 기록해 push 가 견준다. */
14058
+ generateWithViews(options) {
13693
14059
  const lines = [];
13694
14060
  lines.push(`${BASE_FILE_HEADER} from Notion DB: ${options.databaseName}`);
13695
14061
  lines.push(`# Database ID: ${options.databaseId}`);
@@ -13697,13 +14063,18 @@ var BaseFileGenerator = class {
13697
14063
  this.writeFilters(lines, options.folderPath);
13698
14064
  this.writeFormulas(lines, options.viewsConfig.views);
13699
14065
  this.writePropertyDisplayNames(lines, options.schema);
13700
- this.writeViews(lines, options.viewsConfig.views, options.schema, options.rangeEnds);
13701
- return lines.join("\n") + "\n";
14066
+ const views = this.writeViews(
14067
+ lines,
14068
+ options.viewsConfig.views,
14069
+ options.schema,
14070
+ options.rangeEnds
14071
+ );
14072
+ return { content: lines.join("\n") + "\n", views };
13702
14073
  }
13703
14074
  writeFilters(lines, folderPath) {
13704
14075
  lines.push("filters:");
13705
14076
  lines.push(" and:");
13706
- lines.push(` - file.inFolder("${folderPath}")`);
14077
+ lines.push(` - ${inFolderFilter(folderPath)}`);
13707
14078
  lines.push(` - file.ext == "md"`);
13708
14079
  lines.push("");
13709
14080
  }
@@ -13729,12 +14100,13 @@ var BaseFileGenerator = class {
13729
14100
  lines.push("");
13730
14101
  }
13731
14102
  writeViews(lines, views, schema, rangeEnds = /* @__PURE__ */ new Map()) {
13732
- const basesViews = views.map((v) => this.convertView(v, schema, rangeEnds)).filter((v) => v !== null);
14103
+ const converted = views.map((v) => this.convertView(v, schema, rangeEnds)).filter((v) => v !== null);
14104
+ const basesViews = converted.map((c) => c.view);
13733
14105
  if (basesViews.length === 0) {
13734
14106
  lines.push("views:");
13735
14107
  lines.push(" - type: table");
13736
14108
  lines.push(" name: Table");
13737
- return;
14109
+ return [];
13738
14110
  }
13739
14111
  this.dedupeViewNames(basesViews);
13740
14112
  lines.push("views:");
@@ -13766,6 +14138,7 @@ var BaseFileGenerator = class {
13766
14138
  this.writeFilterValue(lines, view.filters, " ");
13767
14139
  }
13768
14140
  }
14141
+ return converted.map(({ viewId, lossyFilter }) => ({ viewId, lossyFilter }));
13769
14142
  }
13770
14143
  /** `filters:` 키와 그 값을 쓴다. 단일 식이면 한 줄, 그룹이면 블록. */
13771
14144
  writeFilterValue(lines, node, indent) {
@@ -13825,6 +14198,7 @@ var BaseFileGenerator = class {
13825
14198
  convertView(view, schema, rangeEnds) {
13826
14199
  const basesType = basesViewMappingOf(view.type).type;
13827
14200
  if (!basesType) return null;
14201
+ let lossyFilter = false;
13828
14202
  const result = {
13829
14203
  type: basesType,
13830
14204
  name: view.name || view.type
@@ -13838,7 +14212,7 @@ var BaseFileGenerator = class {
13838
14212
  return end === void 0 ? [this.toNoteRef(name)] : [this.toNoteRef(name), this.toNoteRef(end)];
13839
14213
  });
13840
14214
  if (visible.length > 0) {
13841
- result.order = ["file.name", ...visible];
14215
+ result.order = [BASES_TITLE, ...visible];
13842
14216
  }
13843
14217
  }
13844
14218
  if (view.sorts && view.sorts.length > 0) {
@@ -13848,7 +14222,7 @@ var BaseFileGenerator = class {
13848
14222
  const propName = this.resolvePropertyName(s.property, schema) ?? s.property;
13849
14223
  property = this.toNoteRef(propName);
13850
14224
  } else if (s.timestamp) {
13851
- property = s.timestamp === "created_time" ? "file.ctime" : "file.mtime";
14225
+ property = BASES_TIMESTAMPS[s.timestamp];
13852
14226
  } else {
13853
14227
  return null;
13854
14228
  }
@@ -13884,14 +14258,15 @@ var BaseFileGenerator = class {
13884
14258
  }
13885
14259
  }
13886
14260
  if (view.filter !== void 0 && view.filter !== null) {
13887
- const { node } = translateNotionFilter(
14261
+ const { node, untranslated } = translateNotionFilter(
13888
14262
  view.filter,
13889
14263
  schema,
13890
14264
  (id) => this.resolvePropertyName(id, schema)
13891
14265
  );
13892
14266
  if (node) result.filters = node;
14267
+ lossyFilter = untranslated.length > 0 || node === null;
13893
14268
  }
13894
- return result;
14269
+ return { view: result, viewId: view.id, lossyFilter };
13895
14270
  }
13896
14271
  toNoteRef(propertyName) {
13897
14272
  return propertyName;
@@ -14541,9 +14916,9 @@ function noteTitle(frontmatter, path) {
14541
14916
  return explicitTitle(frontmatter) ?? wikilinkTitleFromPath(path);
14542
14917
  }
14543
14918
  function isFileNameOf(title, fileName2) {
14544
- const safe = sanitizeFileName(title);
14545
- if (fileName2 === safe) return true;
14546
- return fileName2.startsWith(safe) && /^ \([0-9a-f]{8,32}\)$/i.test(fileName2.slice(safe.length));
14919
+ return [sanitizeFileName(title), legacySanitizeFileName(title)].some(
14920
+ (safe) => fileName2 === safe || fileName2.startsWith(safe) && /^ \([0-9a-f]{8,32}\)$/i.test(fileName2.slice(safe.length))
14921
+ );
14547
14922
  }
14548
14923
  function followsFileName(title, path) {
14549
14924
  return isFileNameOf(title, wikilinkTitleFromPath(path));
@@ -14571,8 +14946,11 @@ function titleMayChange(base, current, from, to) {
14571
14946
  function extractTitle(filePath) {
14572
14947
  return noteNameFromPath(filePath);
14573
14948
  }
14574
- function titleProperty(title) {
14575
- return { title: { title: [{ text: { content: title } }] } };
14949
+ function titleProperty(title, remote) {
14950
+ const runs = remote ? titleRunsOf(remote) : null;
14951
+ return {
14952
+ title: { title: (runs && patchRichText(runs, title)) ?? [{ text: { content: title } }] }
14953
+ };
14576
14954
  }
14577
14955
  function extractAliases(properties) {
14578
14956
  const raw = properties.aliases ?? properties.alias;
@@ -14599,10 +14977,14 @@ function selectDbRowFiles(files, localFolder) {
14599
14977
  return rel.length > 0 && !rel.includes("/");
14600
14978
  });
14601
14979
  }
14980
+ var PAGE_ID_SUFFIX_LENGTHS = [8, 16, 32];
14602
14981
  function pagePathCandidates(dir, safeName, pageId) {
14603
14982
  const base = dir ? `${dir}/${safeName}` : safeName;
14604
14983
  const raw = pageId.replace(/-/g, "");
14605
- return [`${base}.md`, ...[8, 16, 32].map((len) => `${base} (${raw.slice(0, len)}).md`)];
14984
+ return [
14985
+ `${base}.md`,
14986
+ ...PAGE_ID_SUFFIX_LENGTHS.map((len) => `${base} (${raw.slice(0, len)}).md`)
14987
+ ];
14606
14988
  }
14607
14989
  function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
14608
14990
  const ownedBySelf = (candidate) => {
@@ -14613,9 +14995,9 @@ function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
14613
14995
  const candidates = pagePathCandidates(localFolder, safeName, pageId);
14614
14996
  return candidates.find(ownedBySelf) ?? candidates[candidates.length - 1];
14615
14997
  }
14616
- function inClaimOrder(items, chosen, idOf) {
14998
+ function inClaimOrder(items, chosen, idOf2) {
14617
14999
  const slots = items.flatMap((item, index) => chosen(item) ? [index] : []);
14618
- const key = (item) => compactNotionId(idOf(item));
15000
+ const key = (item) => compactNotionId(idOf2(item));
14619
15001
  const members = slots.map((index) => items[index]).sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
14620
15002
  const ordered = [...items];
14621
15003
  slots.forEach((slot, k) => ordered[slot] = members[k]);
@@ -14975,12 +15357,17 @@ var DbBaseFiles = class {
14975
15357
  get(databaseId) {
14976
15358
  return this.load().get(compactNotionId(databaseId));
14977
15359
  }
15360
+ /** 기록한 DB 모두 — 키는 줄인 DB id. */
15361
+ all() {
15362
+ return [...this.load().entries()];
15363
+ }
14978
15364
  set(databaseId, info) {
14979
15365
  const entries = this.load();
14980
15366
  const key = compactNotionId(databaseId);
14981
15367
  const known = entries.get(key);
14982
- if (known?.basePath === info.basePath && known.title === info.title) return;
14983
- entries.set(key, { basePath: info.basePath, title: info.title });
15368
+ const next = normalize(info);
15369
+ if (known && JSON.stringify(known) === JSON.stringify(next)) return;
15370
+ entries.set(key, next);
14984
15371
  this.loadedRaw = JSON.stringify(Object.fromEntries(entries));
14985
15372
  this.stateDb.setMeta(DB_BASE_FILES_META_KEY, this.loadedRaw);
14986
15373
  }
@@ -14997,7 +15384,16 @@ var DbBaseFiles = class {
14997
15384
  for (const [key, value] of Object.entries(parsed)) {
14998
15385
  const entry = value;
14999
15386
  if (typeof entry?.basePath === "string" && typeof entry.title === "string") {
15000
- this.entries.set(compactNotionId(key), { basePath: entry.basePath, title: entry.title });
15387
+ this.entries.set(
15388
+ compactNotionId(key),
15389
+ normalize({
15390
+ basePath: entry.basePath,
15391
+ title: entry.title,
15392
+ ...typeof entry.hash === "string" ? { hash: entry.hash } : {},
15393
+ ...Array.isArray(entry.views) ? { views: entry.views } : {},
15394
+ ...typeof entry.rest === "string" ? { rest: entry.rest } : {}
15395
+ })
15396
+ );
15001
15397
  }
15002
15398
  }
15003
15399
  } catch {
@@ -15005,53 +15401,632 @@ var DbBaseFiles = class {
15005
15401
  return this.entries;
15006
15402
  }
15007
15403
  };
15404
+ function normalize(info) {
15405
+ return {
15406
+ basePath: info.basePath,
15407
+ title: info.title,
15408
+ ...info.hash ? { hash: info.hash } : {},
15409
+ ...info.views && info.views.length > 0 ? { views: info.views } : {},
15410
+ ...info.views && info.views.length > 0 && info.rest ? { rest: info.rest } : {}
15411
+ };
15412
+ }
15008
15413
 
15009
- // src/sync/discovered-databases.ts
15010
- var ROOT_DATABASES_FOLDER = "databases";
15011
- var DISCOVERED_DBS_META_KEY = "discovered_dbs";
15012
- var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
15013
- var LINKED_DBS_META_KEY = "linked_dbs";
15014
- function parseDiscoveredDbs(raw) {
15015
- if (!raw) return [];
15414
+ // src/sync/base-view-record.ts
15415
+ var PUSHABLE_KEYS = ["name", "order", "sort", "filters"];
15416
+ function readBaseFile(text) {
15417
+ let doc;
15016
15418
  try {
15017
- const parsed = JSON.parse(raw);
15018
- return Array.isArray(parsed) ? parsed : [];
15419
+ doc = parseFrontmatter(`---
15420
+ ${text}
15421
+ ---
15422
+ `).data;
15019
15423
  } catch {
15020
- return [];
15424
+ return null;
15021
15425
  }
15426
+ const views = doc["views"] ?? [];
15427
+ if (!Array.isArray(views)) return null;
15428
+ if (views.some((view) => !view || typeof view !== "object" || Array.isArray(view))) return null;
15429
+ return { doc, views };
15022
15430
  }
15023
- function loadInaccessibleDbIds(stateDb) {
15024
- const raw = stateDb.getMeta(INACCESSIBLE_DBS_META_KEY);
15025
- if (!raw) return /* @__PURE__ */ new Set();
15026
- try {
15027
- const arr = JSON.parse(raw);
15028
- return new Set(arr.map((id) => id.replace(/-/g, "")));
15029
- } catch {
15030
- return /* @__PURE__ */ new Set();
15431
+ function pushableOf(view) {
15432
+ const order = Array.isArray(view["order"]) ? view["order"].map((c) => columnName(c)) : [];
15433
+ const sort = Array.isArray(view["sort"]) ? view["sort"].map((s) => {
15434
+ const entry = s ?? {};
15435
+ return {
15436
+ property: columnName(entry["property"]),
15437
+ direction: String(entry["direction"] ?? "ASC").toUpperCase()
15438
+ };
15439
+ }) : [];
15440
+ return {
15441
+ name: typeof view["name"] === "string" ? view["name"] : "",
15442
+ ...order.length > 0 ? { order } : {},
15443
+ ...sort.length > 0 ? { sort } : {},
15444
+ ...view["filters"] !== void 0 && view["filters"] !== null ? { filters: view["filters"] } : {}
15445
+ };
15446
+ }
15447
+ function changedFields(record, edited) {
15448
+ const changed = [];
15449
+ if (record.name !== edited.name) changed.push("name");
15450
+ if (canonical(record.order) !== canonical(edited.order)) changed.push("columns");
15451
+ if (canonical(record.sort) !== canonical(edited.sort)) changed.push("sort");
15452
+ if (canonical(record.filters) !== canonical(edited.filters)) changed.push("filter");
15453
+ return changed;
15454
+ }
15455
+ function columnName(raw) {
15456
+ const name = String(raw ?? "");
15457
+ return name.startsWith("note.") ? name.slice("note.".length) : name;
15458
+ }
15459
+ function matchViews(records, views) {
15460
+ const used = /* @__PURE__ */ new Set();
15461
+ const out = views.map((view) => {
15462
+ const found2 = records.find((r) => !used.has(r.viewId) && r.name === view["name"]);
15463
+ if (found2) used.add(found2.viewId);
15464
+ return found2;
15465
+ });
15466
+ views.forEach((view, i) => {
15467
+ if (out[i]) return;
15468
+ const record = records[i];
15469
+ if (record && !used.has(record.viewId) && record.type === view["type"]) {
15470
+ used.add(record.viewId);
15471
+ out[i] = record;
15472
+ }
15473
+ });
15474
+ return out;
15475
+ }
15476
+ function restSignature(parsed, matched) {
15477
+ const without = (from, keys) => Object.fromEntries(Object.entries(from).filter(([key]) => !keys.includes(key)));
15478
+ const views = {};
15479
+ parsed.views.forEach((view, i) => {
15480
+ const key = matched[i] ? `id:${matched[i].viewId}` : `new:${String(view["name"])}`;
15481
+ views[key] = without(view, PUSHABLE_KEYS);
15482
+ });
15483
+ return computeHash(canonical({ doc: without(parsed.doc, ["views"]), views }));
15484
+ }
15485
+ function viewFingerprint(view) {
15486
+ return computeHash(
15487
+ canonical({
15488
+ name: view.name,
15489
+ type: view.type,
15490
+ filter: view.filter ?? null,
15491
+ sorts: view.sorts ?? null,
15492
+ configuration: view.configuration ?? null
15493
+ })
15494
+ );
15495
+ }
15496
+ function recordBaseViews(content, written, views) {
15497
+ const parsed = readBaseFile(content);
15498
+ if (!parsed || written.length !== parsed.views.length) return null;
15499
+ const byId = new Map(views.map((view) => [view.id, view]));
15500
+ const records = [];
15501
+ for (const [i, source] of written.entries()) {
15502
+ const view = byId.get(source.viewId);
15503
+ if (!view) return null;
15504
+ records.push({
15505
+ viewId: source.viewId,
15506
+ type: String(parsed.views[i]["type"]),
15507
+ ...pushableOf(parsed.views[i]),
15508
+ ...source.lossyFilter ? { lossyFilter: true } : {},
15509
+ notion: viewFingerprint(view)
15510
+ });
15031
15511
  }
15512
+ return { views: records, rest: restSignature(parsed, records) };
15032
15513
  }
15033
- function loadLinkedDbMap(stateDb) {
15034
- const raw = stateDb.getMeta(LINKED_DBS_META_KEY);
15035
- if (!raw) return /* @__PURE__ */ new Map();
15036
- try {
15037
- const obj = JSON.parse(raw);
15038
- return new Map(Object.entries(obj).map(([k, v]) => [k.replace(/-/g, ""), v.replace(/-/g, "")]));
15039
- } catch {
15040
- return /* @__PURE__ */ new Map();
15514
+ function canonical(value) {
15515
+ return JSON.stringify(sortKeys3(value)) ?? "null";
15516
+ }
15517
+ function sortKeys3(value) {
15518
+ if (Array.isArray(value)) return value.map(sortKeys3);
15519
+ if (value instanceof Date) return value.toISOString();
15520
+ if (value && typeof value === "object") {
15521
+ return Object.fromEntries(
15522
+ Object.keys(value).sort().map((key) => [key, sortKeys3(value[key])])
15523
+ );
15041
15524
  }
15525
+ return value;
15042
15526
  }
15043
15527
 
15044
- // src/sync/db-folder-moves.ts
15045
- function rewriteDatabaseFolders(stateDb, moves) {
15046
- const folders = moves.filter((move) => !isMarkdownPath(move.from));
15047
- if (folders.length === 0) return;
15048
- const databases = parseDiscoveredDbs(stateDb.getMeta(DISCOVERED_DBS_META_KEY));
15049
- let changed = false;
15050
- for (const db of databases) {
15051
- const folder = db.localFolder.replace(/\/+$/, "");
15052
- const to = finalPath(folder, folders);
15053
- if (to === folder) continue;
15054
- db.localFolder = to;
15528
+ // src/sync/sidecar-bases.ts
15529
+ var SIDECAR_BASES_META_KEY = "sidecar_bases";
15530
+ var SidecarBases = class {
15531
+ constructor(stateDb) {
15532
+ this.stateDb = stateDb;
15533
+ }
15534
+ stateDb;
15535
+ get(databaseId) {
15536
+ return this.load()[compactNotionId(databaseId)];
15537
+ }
15538
+ set(databaseId, base) {
15539
+ const all = this.load();
15540
+ const key = compactNotionId(databaseId);
15541
+ if (JSON.stringify(all[key]) === JSON.stringify(base)) return;
15542
+ all[key] = base;
15543
+ this.stateDb.setMeta(SIDECAR_BASES_META_KEY, JSON.stringify(all));
15544
+ }
15545
+ /** 기록한 DB 마다 — 하이픈 없는 id 와 그 기록. */
15546
+ entries() {
15547
+ return Object.entries(this.load());
15548
+ }
15549
+ load() {
15550
+ const raw = this.stateDb.getMeta(SIDECAR_BASES_META_KEY);
15551
+ if (!raw) return {};
15552
+ try {
15553
+ const parsed = JSON.parse(raw);
15554
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
15555
+ const out = {};
15556
+ for (const [key, value] of Object.entries(parsed)) {
15557
+ const entry = value;
15558
+ if (typeof entry?.path === "string" && typeof entry.hash === "string" && Array.isArray(entry.properties)) {
15559
+ out[key] = { path: entry.path, hash: entry.hash, properties: entry.properties };
15560
+ }
15561
+ }
15562
+ return out;
15563
+ } catch {
15564
+ return {};
15565
+ }
15566
+ }
15567
+ };
15568
+
15569
+ // src/sync/schema-ledger.ts
15570
+ var DB_SCHEMAS_META_KEY = "db_schemas";
15571
+ function seenSchemaOf(meta) {
15572
+ const seen = {};
15573
+ if (meta.dataSources.length === 0) {
15574
+ if (Object.keys(meta.properties).length > 0) {
15575
+ seen[compactNotionId(meta.databaseId)] = schemaOf(meta.properties);
15576
+ }
15577
+ } else {
15578
+ for (const ds of meta.dataSources) {
15579
+ if (ds.properties) seen[compactNotionId(ds.id)] = schemaOf(ds.properties);
15580
+ }
15581
+ }
15582
+ return Object.keys(seen).length > 0 ? seen : null;
15583
+ }
15584
+ function dataSourceIdOf(page) {
15585
+ const parent = page.parent;
15586
+ return parent?.type === "data_source_id" && parent.data_source_id ? parent.data_source_id : null;
15587
+ }
15588
+ function sourceKeyOf(page, databaseId) {
15589
+ return compactNotionId(dataSourceIdOf(page) ?? databaseId);
15590
+ }
15591
+ function schemaOf(properties) {
15592
+ const schema = {};
15593
+ for (const [name, raw] of Object.entries(properties)) {
15594
+ const prop = raw;
15595
+ if (typeof prop?.["id"] !== "string" || typeof prop["type"] !== "string") continue;
15596
+ if (prop["type"] === "title") continue;
15597
+ const values = valuesOf(prop["type"], prop[prop["type"]]);
15598
+ schema[prop["id"]] = { name, type: prop["type"], ...values ? { values } : {} };
15599
+ }
15600
+ return schema;
15601
+ }
15602
+ function valuesOf(type, config) {
15603
+ const c = config ?? {};
15604
+ let decides;
15605
+ if (type === "select" || type === "multi_select" || type === "status") {
15606
+ const options = Array.isArray(c["options"]) ? c["options"] : [];
15607
+ decides = options.map((o) => String(o?.name ?? "")).sort();
15608
+ } else if (type === "formula") {
15609
+ decides = c["expression"] ?? null;
15610
+ } else if (type === "rollup") {
15611
+ decides = [c["relation_property_id"], c["rollup_property_id"], c["function"]];
15612
+ } else {
15613
+ return void 0;
15614
+ }
15615
+ return computeHash(JSON.stringify(decides)).slice(0, 16);
15616
+ }
15617
+ function diffSourceSchema(before, after) {
15618
+ const renamed = /* @__PURE__ */ new Map();
15619
+ const removed = /* @__PURE__ */ new Set();
15620
+ const refreshed = /* @__PURE__ */ new Set();
15621
+ const withEnd = (name, type) => mayHaveRangeEnd(type) ? [name, name + RANGE_END_SUFFIX] : [name];
15622
+ for (const [id, old] of Object.entries(before)) {
15623
+ const now = after[id];
15624
+ if (!now) {
15625
+ for (const key of withEnd(old.name, old.type)) removed.add(key);
15626
+ continue;
15627
+ }
15628
+ if (now.name !== old.name) {
15629
+ renamed.set(old.name, now.name);
15630
+ if (mayHaveRangeEnd(old.type) && mayHaveRangeEnd(now.type)) {
15631
+ renamed.set(old.name + RANGE_END_SUFFIX, now.name + RANGE_END_SUFFIX);
15632
+ }
15633
+ }
15634
+ const revalued = old.values !== void 0 && now.values !== void 0 && old.values !== now.values;
15635
+ if (now.type !== old.type || revalued) {
15636
+ for (const key of withEnd(now.name, now.type)) refreshed.add(key);
15637
+ }
15638
+ }
15639
+ for (const [id, now] of Object.entries(after)) {
15640
+ if (!before[id]) for (const key of withEnd(now.name, now.type)) refreshed.add(key);
15641
+ }
15642
+ return { renamed, removed, refreshed };
15643
+ }
15644
+ function isEmptySchemaChange(change) {
15645
+ return change.renamed.size === 0 && change.removed.size === 0 && change.refreshed.size === 0;
15646
+ }
15647
+ var SchemaLedger = class {
15648
+ constructor(stateDb) {
15649
+ this.stateDb = stateDb;
15650
+ }
15651
+ stateDb;
15652
+ get(databaseId) {
15653
+ return this.load()[compactNotionId(databaseId)];
15654
+ }
15655
+ /** 이번에 본 소스로 바꾼다 — 이번에 읽지 못한 소스는 지난번 것을 둔다. */
15656
+ set(databaseId, seen) {
15657
+ const all = this.load();
15658
+ const key = compactNotionId(databaseId);
15659
+ const next = { ...all[key], ...seen };
15660
+ if (JSON.stringify(all[key]) === JSON.stringify(next)) return;
15661
+ all[key] = next;
15662
+ this.stateDb.setMeta(DB_SCHEMAS_META_KEY, JSON.stringify(all));
15663
+ }
15664
+ load() {
15665
+ const raw = this.stateDb.getMeta(DB_SCHEMAS_META_KEY);
15666
+ if (!raw) return {};
15667
+ try {
15668
+ const parsed = JSON.parse(raw);
15669
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
15670
+ } catch {
15671
+ return {};
15672
+ }
15673
+ }
15674
+ };
15675
+
15676
+ // src/sync/sidecar-schema-edit.ts
15677
+ var DEFINABLE_TYPES = /* @__PURE__ */ new Set([
15678
+ "formula",
15679
+ "number",
15680
+ "select",
15681
+ "multi_select"
15682
+ ]);
15683
+ var SidecarSchemaEdits = class {
15684
+ constructor(stateDb, vaultFs, notionClient) {
15685
+ this.vaultFs = vaultFs;
15686
+ this.notionClient = notionClient;
15687
+ this.bases = new SidecarBases(stateDb);
15688
+ this.ledger = new SchemaLedger(stateDb);
15689
+ }
15690
+ vaultFs;
15691
+ notionClient;
15692
+ bases;
15693
+ ledger;
15694
+ /** 고친 사이드카를 찾아 보낼 것을 정한다 — Notion 은 고친 것의 DB 만 읽는다. */
15695
+ async plan(inScope = () => true) {
15696
+ const planned = [];
15697
+ const refused = [];
15698
+ const refuse2 = (path, error) => {
15699
+ refused.push({
15700
+ path,
15701
+ operation: "update",
15702
+ error: `\uC0AC\uC774\uB4DC\uCE74\uB97C Notion \uC5D0 \uC62C\uB9AC\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4 \u2014 ${error}`
15703
+ });
15704
+ };
15705
+ for (const [databaseId, base] of this.bases.entries()) {
15706
+ if (!inScope(base.path)) continue;
15707
+ const content = await this.vaultFs.readFile(base.path).catch(() => null);
15708
+ if (content === null) continue;
15709
+ const hash = computeHash(content);
15710
+ if (hash === base.hash) continue;
15711
+ let edited;
15712
+ try {
15713
+ edited = JSON.parse(content);
15714
+ } catch {
15715
+ refuse2(base.path, "JSON \uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4");
15716
+ continue;
15717
+ }
15718
+ const sidecar = edited;
15719
+ if (sidecar.generator !== SIDECAR_GENERATOR) continue;
15720
+ if (typeof sidecar.databaseId !== "string" || compactNotionId(sidecar.databaseId) !== databaseId) {
15721
+ refuse2(base.path, "databaseId \uB97C \uBC14\uAFC0 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4");
15722
+ continue;
15723
+ }
15724
+ const properties = parseProperties(sidecar.properties);
15725
+ if (properties === null) {
15726
+ refuse2(base.path, "properties \uB294 name \xB7 type \uC774 \uC788\uB294 \uD56D\uBAA9\uC758 \uBAA9\uB85D\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4");
15727
+ continue;
15728
+ }
15729
+ const diff = diffSidecarProperties(base.properties, properties);
15730
+ if (!diff.ok) {
15731
+ refuse2(base.path, diff.error);
15732
+ continue;
15733
+ }
15734
+ if (Object.keys(diff.payload).length === 0) continue;
15735
+ const meta = await this.notionClient.getDatabaseMeta(databaseId);
15736
+ if (meta.dataSources.length !== 1) {
15737
+ refuse2(
15738
+ base.path,
15739
+ "\uB370\uC774\uD130 \uC18C\uC2A4\uAC00 \uC5EC\uB7FF\uC778 DB \uC758 \uC18D\uC131\uC740 \uC0AC\uC774\uB4DC\uCE74\uB85C \uBC14\uAFB8\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. Notion \uC5D0\uC11C \uBC14\uAFB8\uC138\uC694"
15740
+ );
15741
+ continue;
15742
+ }
15743
+ const source = meta.dataSources[0];
15744
+ const live = source.properties ?? meta.properties;
15745
+ if (!sameSchema(base.properties, live)) {
15746
+ refuse2(
15747
+ base.path,
15748
+ "\uADF8 \uC0AC\uC774 Notion \uC5D0\uC11C\uB3C4 \uC18D\uC131\uC774 \uBC14\uB00C\uC5C8\uC2B5\uB2C8\uB2E4. \uACE0\uCE5C \uB0B4\uC6A9\uC744 \uC62E\uACA8 \uC801\uACE0 \uC774 \uD30C\uC77C\uC744 \uC9C0\uC6B4 \uB4A4 pull \uD558\uBA74 \uC0C8\uB85C \uBC1B\uC2B5\uB2C8\uB2E4"
15749
+ );
15750
+ continue;
15751
+ }
15752
+ planned.push({
15753
+ databaseId,
15754
+ dataSourceId: source.id,
15755
+ path: base.path,
15756
+ hash,
15757
+ properties,
15758
+ payload: diff.payload,
15759
+ edit: { path: base.path, ...diff.edit },
15760
+ seen: this.ledger.get(databaseId) ? null : seenSchemaOf(meta)
15761
+ });
15762
+ }
15763
+ return { planned, refused };
15764
+ }
15765
+ async apply(planned) {
15766
+ const applied = [];
15767
+ const failed = [];
15768
+ for (const edit of planned) {
15769
+ if (edit.seen) this.ledger.set(edit.databaseId, edit.seen);
15770
+ let after;
15771
+ try {
15772
+ after = await this.notionClient.updateDataSourceProperties(edit.dataSourceId, edit.payload);
15773
+ } catch (error) {
15774
+ failed.push({
15775
+ path: edit.path,
15776
+ operation: "update",
15777
+ error: `\uC0AC\uC774\uB4DC\uCE74\uC758 \uC18D\uC131\uC744 Notion \uC5D0 \uC62C\uB9AC\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: ${error instanceof Error ? error.message : String(error)}`
15778
+ });
15779
+ continue;
15780
+ }
15781
+ this.bases.set(edit.databaseId, {
15782
+ path: edit.path,
15783
+ hash: edit.hash,
15784
+ properties: edit.properties.map((prop) => {
15785
+ const id = prop.id ?? after[prop.name]?.["id"];
15786
+ return typeof id === "string" ? { ...prop, id } : prop;
15787
+ })
15788
+ });
15789
+ applied.push(edit.edit);
15790
+ }
15791
+ return { applied, failed };
15792
+ }
15793
+ };
15794
+ function sidecarPropertiesEdited(content, base) {
15795
+ let parsed;
15796
+ try {
15797
+ parsed = JSON.parse(content);
15798
+ } catch {
15799
+ return true;
15800
+ }
15801
+ const properties = parseProperties(parsed.properties);
15802
+ if (properties === null) return true;
15803
+ const diff = diffSidecarProperties(base.properties, properties);
15804
+ return !diff.ok || Object.keys(diff.payload).length > 0;
15805
+ }
15806
+ function parseProperties(value) {
15807
+ if (!Array.isArray(value)) return null;
15808
+ const out = [];
15809
+ for (const item of value) {
15810
+ const prop = item;
15811
+ if (!prop || typeof prop !== "object" || typeof prop.name !== "string" || prop.name.trim() === "" || typeof prop.type !== "string" || prop.id !== void 0 && typeof prop.id !== "string" || prop.options !== void 0 && !(Array.isArray(prop.options) && prop.options.every((o) => typeof o === "string")) || prop.definition !== void 0 && (typeof prop.definition !== "object" || prop.definition === null || Array.isArray(prop.definition))) {
15812
+ return null;
15813
+ }
15814
+ out.push(prop);
15815
+ }
15816
+ return out;
15817
+ }
15818
+ function diffSidecarProperties(base, edited) {
15819
+ const fail = (error) => ({ ok: false, error });
15820
+ const byId = new Map(base.map((prop) => [prop.id, prop]));
15821
+ const payload = {};
15822
+ const edit = {
15823
+ added: [],
15824
+ removed: [],
15825
+ renamed: [],
15826
+ retyped: [],
15827
+ redefined: []
15828
+ };
15829
+ const droppedOptions = [];
15830
+ const names = /* @__PURE__ */ new Set();
15831
+ const seenIds = /* @__PURE__ */ new Set();
15832
+ for (const prop of edited) {
15833
+ if (names.has(prop.name)) return fail(`\uC18D\uC131 \uC774\uB984 \u300C${prop.name}\u300D \uC774 \uB458\uC785\uB2C8\uB2E4`);
15834
+ names.add(prop.name);
15835
+ const old = prop.id === void 0 ? void 0 : byId.get(prop.id);
15836
+ if (prop.id !== void 0 && !old) {
15837
+ return fail(`\u300C${prop.name}\u300D \uC758 id \uB97C \uBAA8\uB985\uB2C8\uB2E4 \u2014 \uC0C8 \uC18D\uC131\uC740 id \uB97C \uC801\uC9C0 \uB9C8\uC138\uC694`);
15838
+ }
15839
+ if (old && seenIds.has(old.id)) return fail(`\u300C${prop.name}\u300D \uC758 id \uAC00 \uB458\uC785\uB2C8\uB2E4`);
15840
+ if (!old) {
15841
+ if (prop.type === "title") return fail("\uC81C\uBAA9 \uC18D\uC131\uC740 \uB354\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4");
15842
+ payload[prop.name] = { [prop.type]: definitionOf(prop, void 0) };
15843
+ edit.added.push(prop.name);
15844
+ continue;
15845
+ }
15846
+ seenIds.add(old.id);
15847
+ const change = {};
15848
+ if (prop.name !== old.name) {
15849
+ change["name"] = prop.name;
15850
+ edit.renamed.push([old.name, prop.name]);
15851
+ }
15852
+ if (prop.type !== old.type) {
15853
+ if (old.type === "title" || prop.type === "title") {
15854
+ return fail("\uC81C\uBAA9 \uC18D\uC131\uC758 \uD0C0\uC785\uC740 \uBC14\uAFC0 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4");
15855
+ }
15856
+ change[prop.type] = definitionOf(prop, void 0);
15857
+ edit.retyped.push([prop.name, prop.type]);
15858
+ } else if (!sameDefinition(prop, old)) {
15859
+ if (!DEFINABLE_TYPES.has(prop.type)) {
15860
+ return fail(
15861
+ `\u300C${prop.name}\u300D(${prop.type}) \uC758 \uC124\uC815\uC740 \uC0AC\uC774\uB4DC\uCE74\uC5D0\uC11C \uBC14\uAFC0 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. Notion \uC5D0\uC11C \uBC14\uAFB8\uC138\uC694`
15862
+ );
15863
+ }
15864
+ change[prop.type] = definitionOf(prop, old);
15865
+ edit.redefined.push(prop.name);
15866
+ const dropped = (old.options ?? []).filter((name) => !(prop.options ?? []).includes(name));
15867
+ if (dropped.length > 0) droppedOptions.push([prop.name, dropped]);
15868
+ }
15869
+ if (Object.keys(change).length > 0) payload[old.id] = change;
15870
+ }
15871
+ for (const old of base) {
15872
+ if (seenIds.has(old.id)) continue;
15873
+ if (old.type === "title") return fail("\uC81C\uBAA9 \uC18D\uC131\uC740 \uC9C0\uC6B8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4");
15874
+ payload[old.id] = null;
15875
+ edit.removed.push(old.name);
15876
+ }
15877
+ const kept = base.filter((p) => p.type !== "title").length;
15878
+ if (kept >= 2 && edit.removed.length === kept && edit.added.length === 0) {
15879
+ return fail("\uC81C\uBAA9 \uB9D0\uACE0 \uBAA8\uB4E0 \uC18D\uC131\uC744 \uC9C0\uC6B0\uB294 \uAC83\uC740 \uBC1B\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4 \u2014 \uC815\uB9D0\uC774\uBA74 Notion \uC5D0\uC11C \uC9C0\uC6B0\uC138\uC694");
15880
+ }
15881
+ return {
15882
+ ok: true,
15883
+ payload,
15884
+ edit: droppedOptions.length > 0 ? { ...edit, droppedOptions } : edit
15885
+ };
15886
+ }
15887
+ function definitionOf(prop, old) {
15888
+ if (!DEFINABLE_TYPES.has(prop.type)) return {};
15889
+ const definition = prop.definition ?? {};
15890
+ if (old && (prop.type === "select" || prop.type === "multi_select") && sameJson(prop.definition, old.definition) && !sameJson(prop.options, old.options)) {
15891
+ const known = old.definition?.["options"] ?? [];
15892
+ return {
15893
+ options: (prop.options ?? []).map((name) => known.find((o) => o.name === name) ?? { name })
15894
+ };
15895
+ }
15896
+ return definition;
15897
+ }
15898
+ function sameDefinition(a, b) {
15899
+ return sameJson(a.definition, b.definition) && sameJson(a.options, b.options);
15900
+ }
15901
+ function sameSchema(base, live) {
15902
+ const liveById = /* @__PURE__ */ new Map();
15903
+ for (const [name, raw] of Object.entries(live)) {
15904
+ const prop = raw;
15905
+ if (typeof prop?.id === "string") liveById.set(prop.id, { name, type: prop.type });
15906
+ }
15907
+ return liveById.size === base.length && base.every((prop) => {
15908
+ const now = liveById.get(prop.id);
15909
+ return now?.name === prop.name && now.type === prop.type;
15910
+ });
15911
+ }
15912
+ function sameJson(a, b) {
15913
+ return JSON.stringify(sortKeys4(a)) === JSON.stringify(sortKeys4(b));
15914
+ }
15915
+ function sortKeys4(value) {
15916
+ if (Array.isArray(value)) return value.map(sortKeys4);
15917
+ if (value !== null && typeof value === "object") {
15918
+ return Object.fromEntries(
15919
+ Object.keys(value).sort().map((key) => [key, sortKeys4(value[key])])
15920
+ );
15921
+ }
15922
+ return value;
15923
+ }
15924
+
15925
+ // src/sync/schema-follow.ts
15926
+ async function rekeyTrackedRows(stateDb, vaultFs, databaseId, pages, changes, soleSource) {
15927
+ const sourceOf = new Map(
15928
+ pages.map((page) => [normalizeNotionId(page.id), sourceKeyOf(page, databaseId)])
15929
+ );
15930
+ const rewritten = [];
15931
+ let failed = 0;
15932
+ const kept = [];
15933
+ for (const record of stateDb.getAll()) {
15934
+ if (record.fileType !== "db-row" || record.notionPageId === null || record.notionParentId === null || !notionIdsEqual(record.notionParentId, databaseId) || (vaultFs.excludesPath?.(record.obsidianPath) ?? false)) {
15935
+ continue;
15936
+ }
15937
+ const source = sourceOf.get(normalizeNotionId(record.notionPageId)) ?? soleSource;
15938
+ const change = source === null ? void 0 : changes.get(source);
15939
+ if (!change || change.renamed.size === 0 && change.removed.size === 0) continue;
15940
+ try {
15941
+ const outcome = await rekeyRow(stateDb, vaultFs, record, change);
15942
+ if (outcome.wrote) rewritten.push(record.id);
15943
+ kept.push(...outcome.kept.map((key) => ({ path: record.obsidianPath, key })));
15944
+ } catch {
15945
+ failed++;
15946
+ }
15947
+ }
15948
+ return { rewritten, kept, failed };
15949
+ }
15950
+ async function rekeyRow(stateDb, vaultFs, record, change) {
15951
+ if (!await vaultFs.exists(record.obsidianPath)) return { wrote: false, kept: [] };
15952
+ const local = await vaultFs.readFile(record.obsidianPath);
15953
+ const snapshot = record.baseSnapshot ? record.baseSnapshot.toString("utf-8") : null;
15954
+ const base = snapshotFrontmatter(record.baseSnapshot);
15955
+ const unmodified = computeHash(local) === record.contentHash;
15956
+ const kept = [];
15957
+ const dropLocal = (key, value) => {
15958
+ if (!change.removed.has(key)) return false;
15959
+ if (unmodified || base !== null && sameFrontmatterValue(plainFrontmatterValue(base[key]), value)) {
15960
+ return true;
15961
+ }
15962
+ kept.push(key);
15963
+ return false;
15964
+ };
15965
+ const nextLocal = rekeyed(local, change, dropLocal);
15966
+ const nextSnapshot = snapshot === null ? null : rekeyed(snapshot, change, (key) => change.removed.has(key));
15967
+ if (nextLocal === null && nextSnapshot === null) return { wrote: false, kept };
15968
+ if (nextLocal !== null) await vaultFs.writeFile(record.obsidianPath, nextLocal);
15969
+ const synced = unmodified ? nextLocal ?? local : nextSnapshot ?? snapshot;
15970
+ const stat2 = unmodified && nextLocal !== null ? await vaultFs.getFileStat(record.obsidianPath) : null;
15971
+ if (synced !== null) {
15972
+ stateDb.transaction(() => {
15973
+ stateDb.updateHash(record.id, computeHash(synced), Buffer.from(synced, "utf-8"));
15974
+ if (stat2) stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
15975
+ });
15976
+ }
15977
+ return { wrote: nextLocal !== null, kept };
15978
+ }
15979
+ function rekeyed(note, change, dropped) {
15980
+ const lines = rekeyRowFrontmatter(note, change.renamed, dropped);
15981
+ return lines === null ? null : replaceFrontmatterLines(note, lines);
15982
+ }
15983
+
15984
+ // src/sync/discovered-databases.ts
15985
+ var ROOT_DATABASES_FOLDER = "databases";
15986
+ var DISCOVERED_DBS_META_KEY = "discovered_dbs";
15987
+ var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
15988
+ var LINKED_DBS_META_KEY = "linked_dbs";
15989
+ function parseDiscoveredDbs(raw) {
15990
+ if (!raw) return [];
15991
+ try {
15992
+ const parsed = JSON.parse(raw);
15993
+ return Array.isArray(parsed) ? parsed : [];
15994
+ } catch {
15995
+ return [];
15996
+ }
15997
+ }
15998
+ function loadInaccessibleDbIds(stateDb) {
15999
+ const raw = stateDb.getMeta(INACCESSIBLE_DBS_META_KEY);
16000
+ if (!raw) return /* @__PURE__ */ new Set();
16001
+ try {
16002
+ const arr = JSON.parse(raw);
16003
+ return new Set(arr.map((id) => id.replace(/-/g, "")));
16004
+ } catch {
16005
+ return /* @__PURE__ */ new Set();
16006
+ }
16007
+ }
16008
+ function loadLinkedDbMap(stateDb) {
16009
+ const raw = stateDb.getMeta(LINKED_DBS_META_KEY);
16010
+ if (!raw) return /* @__PURE__ */ new Map();
16011
+ try {
16012
+ const obj = JSON.parse(raw);
16013
+ return new Map(Object.entries(obj).map(([k, v]) => [k.replace(/-/g, ""), v.replace(/-/g, "")]));
16014
+ } catch {
16015
+ return /* @__PURE__ */ new Map();
16016
+ }
16017
+ }
16018
+
16019
+ // src/sync/db-folder-moves.ts
16020
+ function rewriteDatabaseFolders(stateDb, moves) {
16021
+ const folders = moves.filter((move) => !isMarkdownPath(move.from));
16022
+ if (folders.length === 0) return;
16023
+ const databases = parseDiscoveredDbs(stateDb.getMeta(DISCOVERED_DBS_META_KEY));
16024
+ let changed = false;
16025
+ for (const db of databases) {
16026
+ const folder = db.localFolder.replace(/\/+$/, "");
16027
+ const to = finalPath(folder, folders);
16028
+ if (to === folder) continue;
16029
+ db.localFolder = to;
15055
16030
  changed = true;
15056
16031
  }
15057
16032
  if (changed) stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(databases));
@@ -15082,9 +16057,9 @@ async function retargetBaseFiles(vaultFs, moves) {
15082
16057
  if (!file.path.endsWith(BASE_SUFFIX)) continue;
15083
16058
  const content = await vaultFs.readFile(file.path);
15084
16059
  if (!isGeneratedDbArtifact(file.path, content)) continue;
15085
- const next = content.replace(IN_FOLDER, (whole, folder) => {
16060
+ const next = content.replace(IN_FOLDER_FILTER_RE, (whole, folder) => {
15086
16061
  const to = finalPath(folder, folders);
15087
- return to === folder ? whole : `file.inFolder("${to}")`;
16062
+ return to === folder ? whole : inFolderFilter(to);
15088
16063
  });
15089
16064
  if (next !== content) await vaultFs.writeFile(file.path, next);
15090
16065
  }
@@ -15094,7 +16069,6 @@ async function retargetBaseFiles(vaultFs, moves) {
15094
16069
  );
15095
16070
  }
15096
16071
  }
15097
- var IN_FOLDER = /file\.inFolder\("([^"\n]*)"\)/g;
15098
16072
  function finalPath(path, moves) {
15099
16073
  let current = path;
15100
16074
  for (const move of moves) {
@@ -15632,6 +16606,8 @@ var DatabaseSyncer = class {
15632
16606
  this.observation = observation;
15633
16607
  this.noteRowBody = noteRowBody;
15634
16608
  this.baseFileInfo = new DbBaseFiles(stateDb);
16609
+ this.sidecarBases = new SidecarBases(stateDb);
16610
+ this.schemaLedger = new SchemaLedger(stateDb);
15635
16611
  this.relocator = new NoteRelocator(stateDb, vaultFs, config.notion.databases ?? []);
15636
16612
  this.propertyMapper = PropertyMapper.fromConfig(config);
15637
16613
  this.propertyMapper.setWikilinkResolver({
@@ -15660,7 +16636,7 @@ var DatabaseSyncer = class {
15660
16636
  */
15661
16637
  appearances = /* @__PURE__ */ new WeakMap();
15662
16638
  baseFileGenerator = new BaseFileGenerator();
15663
- /** 사용자가 만든 `.base` 라 덮어쓰지 않았다고 이미 알린 경로 — 한 pull 이 같은 DB 를 여러 번 다시 쓴다. */
16639
+ /** 사용자가 만들거나 고친 `.base` 라 덮어쓰지 않았다고 이미 알린 경로 — 한 pull 이 같은 DB 를 여러 번 다시 쓴다. */
15664
16640
  keptUserBases = /* @__PURE__ */ new Set();
15665
16641
  sidecarGenerator = new SidecarGenerator();
15666
16642
  /** Notion 에서 바꾼 제목으로 이름을 바꾼 행과 실제로 옮긴 차례 — pull 마감이 가져가 다른 노트의 링크를 고친다. */
@@ -15669,11 +16645,23 @@ var DatabaseSyncer = class {
15669
16645
  renamed: [],
15670
16646
  applied: []
15671
16647
  };
16648
+ /** 볼트에서 사라져 되살린 행의 경로 — pull 마감이 가져가 화면에 알린다(D8). */
16649
+ restoredRows = [];
16650
+ /** Notion 에서 바뀐 속성을 행 노트에 옮긴 것 — pull 마감이 가져가 화면에 알린다(ADR-045). */
16651
+ schemaFollowed = [];
16652
+ /** pull 이 마지막으로 쓴 사이드카 — 볼트에서 고친 것은 덮어쓰지 않는다(ADR-045). */
16653
+ sidecarBases;
16654
+ /** 이번 pull 이 고쳐 둔 것이라 덮어쓰지 않은 사이드카 경로. */
16655
+ heldSidecars = /* @__PURE__ */ new Set();
16656
+ /** 이번 pull 이 볼트에서 고친 것이라 덮어쓰지 않은 `.base` 경로(ADR-046). */
16657
+ heldBases = /* @__PURE__ */ new Set();
15672
16658
  /**
15673
16659
  * 실제로 기록한 .base 경로/DB 제목 — placeholder 임베드 재작성(F22)의 SSOT. 이번 pull 이 조회하지
15674
16660
  * 않은 DB 의 것도 남아 있다({@link DbBaseFiles}).
15675
16661
  */
15676
16662
  baseFileInfo;
16663
+ /** DB 마다 지난 pull 이 본 속성 — Notion 에서 바꾼 속성 이름을 행 노트에 옮기는 근거(ADR-045). */
16664
+ schemaLedger;
15677
16665
  get decorations() {
15678
16666
  return this.decorationsCache ??= new PageDecorations(this.notionClient, this.imageHandler);
15679
16667
  }
@@ -15739,12 +16727,14 @@ var DatabaseSyncer = class {
15739
16727
  const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId, meta);
15740
16728
  this.propertyMapper.loadSchema(schema);
15741
16729
  await this.vaultFs.ensureFolder(dbConfig.localFolder);
16730
+ const followed = wholeDbInScope ? await this.followSchema(dbConfig, meta, pages) : { changes: /* @__PURE__ */ new Map(), rekeyed: /* @__PURE__ */ new Set() };
16731
+ const refreshedKeys = (page) => followed.changes.get(sourceKeyOf(page, dbConfig.databaseId))?.refreshed;
15742
16732
  if (wholeDbInScope) {
15743
16733
  const viewsConfig = await this.pullDatabaseViews(dbConfig);
15744
16734
  await this.generateBaseFile(dbConfig, viewsConfig, meta, void 0, this.rangeEndsOf(pages));
15745
16735
  }
15746
16736
  let created = 0;
15747
- let updated = 0;
16737
+ let updated = followed.rekeyed.size;
15748
16738
  let restored = 0;
15749
16739
  const conflicts = [];
15750
16740
  const failed = [];
@@ -15759,7 +16749,14 @@ var DatabaseSyncer = class {
15759
16749
  for (const page of pages) {
15760
16750
  try {
15761
16751
  const record = this.stateDb.getByNotionId(page.id);
15762
- const action = await this.rowAction(dbConfig, page, record, wholeDbInScope, scope);
16752
+ const action = await this.rowAction(
16753
+ dbConfig,
16754
+ page,
16755
+ record,
16756
+ wholeDbInScope,
16757
+ scope,
16758
+ refreshedKeys(page)
16759
+ );
15763
16760
  if (action !== "skip") pending.push({ page, action });
15764
16761
  else if (record && (wholeDbInScope || inAnyPathScope(record.obsidianPath, scope))) {
15765
16762
  unchanged.push(page);
@@ -15778,7 +16775,7 @@ var DatabaseSyncer = class {
15778
16775
  try {
15779
16776
  const record = this.stateDb.getByNotionId(page.id);
15780
16777
  const outcome = await withDeadline(
15781
- () => action === "reform" && record ? this.rewriteDateForms(page, record) : this.pullDatabasePage(page, dbConfig),
16778
+ () => action === "reform" && record ? this.rewriteStaleKeys(page, record, refreshedKeys(page)) : this.pullDatabasePage(page, dbConfig),
15782
16779
  this.config.advanced.itemTimeoutMs,
15783
16780
  `pull ${dbConfig.localFolder}/${page.id}`
15784
16781
  );
@@ -15786,8 +16783,10 @@ var DatabaseSyncer = class {
15786
16783
  created++;
15787
16784
  if (outcome.action === "written") writtenPaths.push(outcome.path);
15788
16785
  } else if (outcome.action === "written") {
15789
- if (action === "restore") restored++;
15790
- else updated++;
16786
+ if (action === "restore") {
16787
+ restored++;
16788
+ this.restoredRows.push(outcome.path);
16789
+ } else if (!followed.rekeyed.has(record.id)) updated++;
15791
16790
  writtenPaths.push(outcome.path);
15792
16791
  } else if (outcome.action === "conflict") {
15793
16792
  conflicts.push(outcome.conflict);
@@ -15950,32 +16949,35 @@ var DatabaseSyncer = class {
15950
16949
  * (틀린) 전제로 db-row 를 제외해 두었기에 양쪽 어디에도 복원 경로가 없는 상태였다.
15951
16950
  * deleteSync 가 켜져 있으면 되살리지 않는다 — 지운 것은 Notion 에서도 지우라는 뜻이고, sync 는
15952
16951
  * pull 을 먼저 돌려 되살리면 뒤이은 push 가 지울 것을 잃는다(detectMissingLocalFiles 와 같다).
15953
- * · 예전 버전이 적은 날짜 모양이 남은 행은 그 줄만 새 모양으로 고쳐 쓴다({@link outdatedDateForm},
15954
- * F-08).
16952
+ * · 예전 버전이 적은 날짜 모양이 남은 행, Notion 이 새로 만들거나 타입을 바꾼 속성의 값이 다른 행은 그 줄만
16953
+ * 고쳐 쓴다({@link staleKeysToRewrite}, F-08 · ADR-045).
16954
+ *
16955
+ * @param refreshed 이 행의 data source 에서 새로 생기거나 타입이 바뀐 속성의 키(ADR-045).
15955
16956
  */
15956
- async rowAction(dbConfig, page, record, wholeDbInScope, scope) {
16957
+ async rowAction(dbConfig, page, record, wholeDbInScope, scope, refreshed) {
15957
16958
  if (!wholeDbInScope && !(record && inAnyPathScope(record.obsidianPath, scope))) return "skip";
15958
16959
  if (record && this.vaultFs.excludesPath?.(record.obsidianPath)) return "skip";
15959
16960
  if (!record) return "pull";
15960
16961
  const misplaced = !isDirectDbRowPath(dbConfig.localFolder, record.obsidianPath);
15961
16962
  const changed = misplaced || this.remoteVerdict(record, page) !== "unchanged";
15962
- if (!changed && await this.outdatedDateForm(record, page)) return "reform";
16963
+ if (!changed && await this.staleKeysToRewrite(record, page, refreshed)) return "reform";
15963
16964
  if (this.config.sync.deleteSync || await this.vaultFs.exists(record.obsidianPath)) {
15964
16965
  return changed ? "pull" : "skip";
15965
16966
  }
15966
16967
  return "restore";
15967
16968
  }
15968
16969
  /**
15969
- * 원격이 그대로인 행이지만 예전 버전이 적은 날짜 모양(`…Z` · `…+09:00` · `{start, end}`)이 남아 새
15970
- * 모양으로 고쳐 쓸 행인가(F-08, {@link PropertyMapper.outdatedDateKeys}). 고쳐 쓰면 사본도 새 모양이
15971
- * 되어 다음 pull 은 건너뛴다.
16970
+ * 원격이 그대로인 행이지만 그 줄만 고쳐 쓸 키가 있는가 — 예전 버전이 적은 날짜 모양(`…Z` · `…+09:00` ·
16971
+ * `{start, end}`, F-08, {@link PropertyMapper.outdatedDateKeys}), 또는 Notion 이 새로 만들거나 타입을 바꾼
16972
+ * 속성의 값이 적힌 것과 다른 키(ADR-045 — 수식 열을 더하면 행마다 값이 생기지만 행의 수정 시각은 그대로다).
16973
+ * 고쳐 쓰면 사본도 새 값이 되어 다음 pull 은 건너뛴다.
15972
16974
  *
15973
16975
  * 지난 동기화 뒤 로컬을 고친 행은 고르지 않는다 — 로컬 편집을 지키느라 쓰지 않으니 pull 마다 고르기만
15974
16976
  * 한다. 그 편집을 올린 뒤의 pull 이 고른다. 로컬 파일을 읽지 못하면 고르지 않는다.
15975
16977
  */
15976
- async outdatedDateForm(record, page) {
16978
+ async staleKeysToRewrite(record, page, refreshed) {
15977
16979
  const base = snapshotFrontmatter(record.baseSnapshot);
15978
- if (base === null || this.propertyMapper.outdatedDateKeys(base, rowProperties(page)).length === 0) {
16980
+ if (base === null || this.staleKeys(base, rowProperties(page), refreshed).length === 0) {
15979
16981
  return false;
15980
16982
  }
15981
16983
  try {
@@ -15984,25 +16986,37 @@ var DatabaseSyncer = class {
15984
16986
  return false;
15985
16987
  }
15986
16988
  }
16989
+ /** 적힌 frontmatter 에서 받은 값으로 고쳐 쓸 키 — {@link staleKeysToRewrite}. */
16990
+ staleKeys(written, raw, refreshed) {
16991
+ const keys = new Set(this.propertyMapper.outdatedDateKeys(written, raw));
16992
+ if (refreshed && refreshed.size > 0) {
16993
+ const fresh = this.propertyMapper.fromNotionProperties(raw);
16994
+ for (const key of refreshed) {
16995
+ if (raw[key]?.type === "files") continue;
16996
+ if (!sameFrontmatterValue(written[key] ?? null, fresh[key] ?? null)) keys.add(key);
16997
+ }
16998
+ }
16999
+ return [...keys];
17000
+ }
15987
17001
  /**
15988
- * 예전 버전이 적은 날짜 모양이 남은 행에서 그 키들만 새 모양으로 고쳐 쓴다(F-08) — 원격이 그대로라 본문 ·
15989
- * 첨부는 다시 받지 않는다. 본문은 한 글자도 바꾸지 않고, 다른 키의 줄은 그대로 둔다(키 단위로 나눠 읽을
15990
- * 수 없는 frontmatter 는 값만 지킨다 — {@link mergeRowFrontmatter}).
17002
+ * 원격이 그대로인 행에서 {@link staleKeys} 의 키만 받은 값으로 고쳐 쓴다 — 본문 · 첨부는 다시 받지 않는다. 본문은
17003
+ * 한 글자도 바꾸지 않고, 다른 키의 줄은 그대로 둔다(키 단위로 나눠 읽을 수 없는 frontmatter 는 값만 지킨다 —
17004
+ * {@link mergeRowFrontmatter}).
15991
17005
  *
15992
17006
  * 예전에는 행을 통째로 다시 받았다. 행마다 본문을 읽어 요청이 늘고, 다시 그릴 때마다 달라지는 본문(빈
15993
17007
  * 블록 · 첨부 이름)까지 바뀌었다 — 실볼트 사본에서 다시 받은 149행 중 99행.
15994
17008
  *
15995
17009
  * 고른 뒤 로컬을 고쳤으면 쓰지 않는다(`skipped`) — 이어지는 push 가 올린다.
15996
17010
  */
15997
- async rewriteDateForms(page, record) {
17011
+ async rewriteStaleKeys(page, record, refreshed) {
15998
17012
  const path = record.obsidianPath;
15999
17013
  const local = await this.vaultFs.readFile(path);
16000
17014
  if (computeHash(local) !== record.contentHash) return { action: "skipped", path };
16001
17015
  const raw = rowProperties(page);
16002
- const keys = this.propertyMapper.outdatedDateKeys(splitFrontmatter(local).data, raw);
17016
+ const keys = this.staleKeys(splitFrontmatter(local).data, raw, refreshed);
16003
17017
  const fresh = this.propertyMapper.fromNotionProperties(raw);
16004
17018
  const pulled = Object.fromEntries(
16005
- keys.filter((key) => Object.hasOwn(fresh, key)).map((key) => [key, fresh[key]])
17019
+ keys.filter((key) => Object.hasOwn(fresh, key) && !isEmptyFrontmatterValue(fresh[key])).map((key) => [key, fresh[key]])
16006
17020
  );
16007
17021
  const lines = keys.length > 0 ? mergeRowFrontmatter(local, pulled, new Set(keys)) : null;
16008
17022
  const content = lines === null ? null : replaceFrontmatterLines(local, lines);
@@ -16015,6 +17029,86 @@ var DatabaseSyncer = class {
16015
17029
  });
16016
17030
  return { action: "written", path };
16017
17031
  }
17032
+ /**
17033
+ * Notion 에서 바뀐 속성을 지난 pull 이 본 스키마와 id 로 견줘 행 노트에 옮긴다(ADR-045) — 이름을 바꾼 속성은 키
17034
+ * 이름을, 지운 속성은 키를. 새로 생기거나 타입이 바뀐 속성은 돌려준다 — 그대로인 행도 그 값을 고쳐 쓴다.
17035
+ *
17036
+ * 처음 보는 DB 는 견줄 것이 없어 적기만 한다. 행 하나라도 옮기지 못하면 본 스키마를 적지 않는다 — 다음 pull 이 다시
17037
+ * 옮긴다(이미 옮긴 행은 바꿀 것이 없다).
17038
+ *
17039
+ * @returns 바뀐 속성(data source → 바뀐 것, 바뀐 소스만)과 키를 바꾼 행의 레코드 id.
17040
+ */
17041
+ async followSchema(dbConfig, meta, pages) {
17042
+ const changes = /* @__PURE__ */ new Map();
17043
+ const seen = seenSchemaOf(meta);
17044
+ if (seen === null) return { changes, rekeyed: /* @__PURE__ */ new Set() };
17045
+ const before = this.schemaLedger.get(dbConfig.databaseId);
17046
+ for (const [source, after] of Object.entries(seen)) {
17047
+ const prev = before?.[source];
17048
+ if (!prev) continue;
17049
+ const change = diffSourceSchema(prev, after);
17050
+ if (!isEmptySchemaChange(change)) changes.set(source, change);
17051
+ }
17052
+ let rekeyed2 = /* @__PURE__ */ new Set();
17053
+ if (changes.size > 0) {
17054
+ const sources = Object.keys(seen);
17055
+ const soleSource = meta.dataSources.length <= 1 && sources.length === 1 ? sources[0] : null;
17056
+ const outcome = await rekeyTrackedRows(
17057
+ this.stateDb,
17058
+ this.vaultFs,
17059
+ dbConfig.databaseId,
17060
+ pages,
17061
+ changes,
17062
+ soleSource
17063
+ );
17064
+ rekeyed2 = new Set(outcome.rewritten);
17065
+ this.noteSchemaFollowed(dbConfig, changes, outcome);
17066
+ if (outcome.failed > 0) {
17067
+ getLogger().warn(
17068
+ `[DB Sync] ${dbConfig.localFolder}: \uBC14\uB010 \uC18D\uC131\uC744 \uD589 \uB178\uD2B8 ${outcome.failed}\uAC1C\uC5D0 \uC62E\uAE30\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uD569\uB2C8\uB2E4`
17069
+ );
17070
+ return { changes, rekeyed: rekeyed2 };
17071
+ }
17072
+ }
17073
+ this.schemaLedger.set(dbConfig.databaseId, seen);
17074
+ return { changes, rekeyed: rekeyed2 };
17075
+ }
17076
+ /** 옮긴 것을 pull 마감이 알리게 적는다 — 이름 바꿈 · 지움이 있었던 DB 만. 날짜 범위 끝 키는 따로 세지 않는다. */
17077
+ noteSchemaFollowed(dbConfig, changes, outcome) {
17078
+ const derived = (key, keys) => key.endsWith(RANGE_END_SUFFIX) && keys.has(key.slice(0, -RANGE_END_SUFFIX.length));
17079
+ const renamed = [...changes.values()].flatMap(
17080
+ (c) => [...c.renamed].filter(([from]) => !derived(from, c.renamed))
17081
+ );
17082
+ const removed = [...changes.values()].flatMap(
17083
+ (c) => [...c.removed].filter((key) => !derived(key, c.removed))
17084
+ );
17085
+ if (renamed.length === 0 && removed.length === 0) return;
17086
+ this.schemaFollowed.push({
17087
+ folder: dbConfig.localFolder,
17088
+ renamed,
17089
+ removed,
17090
+ rows: outcome.rewritten.length,
17091
+ kept: outcome.kept
17092
+ });
17093
+ }
17094
+ /** 이번 pull 이 행 노트에 옮긴 Notion 속성 변경 — 가져가면 비운다(ADR-045). */
17095
+ /** 고쳐 둔 것이라 덮어쓰지 않은 사이드카 — 가져가면 비운다. */
17096
+ takeHeldSidecars() {
17097
+ const held = [...this.heldSidecars];
17098
+ this.heldSidecars = /* @__PURE__ */ new Set();
17099
+ return held;
17100
+ }
17101
+ /** 고쳐 둔 것이라 덮어쓰지 않은 `.base` — 가져가면 비운다. */
17102
+ takeHeldBases() {
17103
+ const held = [...this.heldBases];
17104
+ this.heldBases = /* @__PURE__ */ new Set();
17105
+ return held;
17106
+ }
17107
+ takeSchemaFollowed() {
17108
+ const followed = this.schemaFollowed;
17109
+ this.schemaFollowed = [];
17110
+ return followed;
17111
+ }
16018
17112
  /** 이 DB 의 추적 행 가운데 이번 조회에 없던 것 — 지워졌는지는 아직 모른다. */
16019
17113
  unlistedRows(dbConfig, pages, paths) {
16020
17114
  const listed = new Set(pages.map((page) => normalizeNotionId(page.id)));
@@ -16126,7 +17220,7 @@ var DatabaseSyncer = class {
16126
17220
  lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
16127
17221
  views: [{ id: "default", name: "Table", type: "table" }]
16128
17222
  };
16129
- const baseContent = this.baseFileGenerator.generate({
17223
+ const generated = this.baseFileGenerator.generateWithViews({
16130
17224
  databaseId: dbConfig.databaseId,
16131
17225
  databaseName: dbName,
16132
17226
  schema: schemaFull,
@@ -16134,19 +17228,34 @@ var DatabaseSyncer = class {
16134
17228
  folderPath: rowsFolder ?? dbConfig.localFolder,
16135
17229
  rangeEnds
16136
17230
  });
17231
+ const baseContent = generated.content;
16137
17232
  const safeName = sanitizeFileName(dbName);
16138
17233
  const basePath = `${dbConfig.localFolder}/${safeName}${BASE_SUFFIX}`;
16139
- if (await this.isUserFile(basePath)) {
17234
+ const previous = this.baseFileInfo.get(dbConfig.databaseId);
17235
+ const existing = await this.vaultFs.readFile(basePath).catch(() => null);
17236
+ let record = {
17237
+ basePath,
17238
+ title: dbName,
17239
+ hash: computeHash(baseContent),
17240
+ // Notion 보기를 받지 못해 기본 표 하나를 쓴 파일은 올릴 보기가 없다.
17241
+ ...viewsConfig ? recordBaseViews(baseContent, generated.views, viewsConfig.views) : null
17242
+ };
17243
+ const ours = existing === null || isGeneratedDbArtifact(basePath, existing) || previous?.basePath === basePath && previous.views !== void 0;
17244
+ if (!ours) {
17245
+ record = { basePath, title: dbName, hash: record.hash };
16140
17246
  if (!this.keptUserBases.has(basePath)) {
16141
17247
  this.keptUserBases.add(basePath);
16142
17248
  getLogger().info(
16143
17249
  `[DB Sync] \uC9C1\uC811 \uB9CC\uB4E0 .base \uB77C Notion \uBCF4\uAE30\uB85C \uB36E\uC5B4\uC4F0\uC9C0 \uC54A\uC74C: ${basePath} \u2014 Notion \uBCF4\uAE30\uB97C \uBC1B\uC73C\uB824\uBA74 \uC774 \uD30C\uC77C\uC758 \uC774\uB984\uC744 \uBC14\uAFB8\uC138\uC694`
16144
17250
  );
16145
17251
  }
16146
- } else {
17252
+ } else if (existing !== null && existing !== baseContent && editedSince(previous, basePath, existing)) {
17253
+ record = { ...previous, title: dbName };
17254
+ this.warnEditedBase(basePath);
17255
+ } else if (existing !== baseContent) {
16147
17256
  await this.vaultFs.writeFile(basePath, baseContent);
16148
17257
  }
16149
- this.baseFileInfo.set(dbConfig.databaseId, { basePath, title: dbName });
17258
+ this.baseFileInfo.set(dbConfig.databaseId, record);
16150
17259
  getLogger().debug(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131: ${basePath}`);
16151
17260
  await this.generateSidecar(
16152
17261
  dbConfig,
@@ -16157,7 +17266,7 @@ var DatabaseSyncer = class {
16157
17266
  meta.isInline,
16158
17267
  await this.appearanceOf(meta, safeName)
16159
17268
  );
16160
- await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName);
17269
+ await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName, previous);
16161
17270
  } catch (error) {
16162
17271
  getLogger().warn(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
16163
17272
  }
@@ -16189,17 +17298,21 @@ var DatabaseSyncer = class {
16189
17298
  ...a.showTitle ? {} : { showTitle: false }
16190
17299
  };
16191
17300
  }
16192
- /** pull 이 쓰지 않은 `.base` · 사이드카가 이미 있는가 — 없거나 읽지 못하면 false. */
16193
- async isUserFile(path) {
16194
- const content = await this.vaultFs.readFile(path).catch(() => null);
16195
- return content !== null && !isGeneratedDbArtifact(path, content);
17301
+ /** 볼트에서 고친 `.base` 를 덮어쓰거나 지우지 않았다고 한 번 알린다. */
17302
+ warnEditedBase(path) {
17303
+ this.heldBases.add(path);
17304
+ if (this.keptUserBases.has(path)) return;
17305
+ this.keptUserBases.add(path);
17306
+ getLogger().warn(
17307
+ `[DB Sync] \uBCFC\uD2B8\uC5D0\uC11C \uACE0\uCE5C .base \uB77C Notion \uBCF4\uAE30\uB85C \uB36E\uC5B4\uC4F0\uC9C0 \uC54A\uC74C: ${path} \u2014 push \uD558\uBA74 \uACE0\uCE5C \uBCF4\uAE30 \uC774\uB984 \xB7 \uC5F4 \xB7 \uC815\uB82C \xB7 \uD544\uD130\uB97C Notion \uC5D0 \uC62C\uB9BD\uB2C8\uB2E4. Notion \uC758 \uBCF4\uAE30\uB97C \uB2E4\uC2DC \uBC1B\uC73C\uB824\uBA74 \uC774 \uD30C\uC77C\uC744 \uC9C0\uC6B0\uC138\uC694`
17308
+ );
16196
17309
  }
16197
17310
  /**
16198
17311
  * DB 제목이 바뀌면 `.base`/`.notion.json` 파일명(safeName)이 바뀌어 옛 이름의 산출물이
16199
17312
  * 같은 폴더에 고아로 잔존한다(drift·부활 원인). DB 폴더 **직속**의 산출물 중 현재 이름이
16200
17313
  * 아닌 것을 삭제해 멱등을 보장한다. 하위 폴더(중첩 DB)의 산출물은 보호한다.
16201
17314
  */
16202
- async cleanupStaleDbArtifacts(localFolder, safeName) {
17315
+ async cleanupStaleDbArtifacts(localFolder, safeName, previous) {
16203
17316
  try {
16204
17317
  const keep = /* @__PURE__ */ new Set([
16205
17318
  `${localFolder}/${safeName}${BASE_SUFFIX}`,
@@ -16208,7 +17321,13 @@ var DatabaseSyncer = class {
16208
17321
  const files = await this.vaultFs.listNonMarkdownFiles();
16209
17322
  const stale = selectStaleDbArtifacts(files, localFolder, keep);
16210
17323
  for (const path of stale) {
16211
- if (await this.isUserFile(path)) continue;
17324
+ const content = await this.vaultFs.readFile(path).catch(() => null);
17325
+ if (content === null || !isGeneratedDbArtifact(path, content)) continue;
17326
+ if (this.heldSidecars.has(path)) continue;
17327
+ if (editedSince(previous, path, content)) {
17328
+ this.warnEditedBase(path);
17329
+ continue;
17330
+ }
16212
17331
  await this.vaultFs.deleteFile(path);
16213
17332
  getLogger().info(`[DB Sync] \uACE0\uC544 DB \uC0B0\uCD9C\uBB3C \uC0AD\uC81C(rename): ${path}`);
16214
17333
  }
@@ -16234,8 +17353,23 @@ var DatabaseSyncer = class {
16234
17353
  const sidecarPath = `${dbConfig.localFolder}/${safeName}${SIDECAR_SUFFIX}`;
16235
17354
  const serialized = this.sidecarGenerator.serialize(sidecar);
16236
17355
  const existing = await this.vaultFs.readFile(sidecarPath).catch(() => null);
17356
+ if (existing !== null && existing !== serialized && !isGeneratedDbArtifact(sidecarPath, existing)) {
17357
+ return;
17358
+ }
17359
+ const previous = this.sidecarBases.get(dbConfig.databaseId);
17360
+ if (previous) {
17361
+ const written = previous.path === sidecarPath ? existing : await this.vaultFs.readFile(previous.path).catch(() => null);
17362
+ if (written !== null && written !== serialized && computeHash(written) !== previous.hash && isGeneratedDbArtifact(previous.path, written) && sidecarPropertiesEdited(written, previous)) {
17363
+ this.heldSidecars.add(previous.path);
17364
+ return;
17365
+ }
17366
+ }
17367
+ this.sidecarBases.set(dbConfig.databaseId, {
17368
+ path: sidecarPath,
17369
+ hash: computeHash(serialized),
17370
+ properties: sidecar.properties
17371
+ });
16237
17372
  if (existing === serialized) return;
16238
- if (existing !== null && !isGeneratedDbArtifact(sidecarPath, existing)) return;
16239
17373
  await this.vaultFs.writeFile(sidecarPath, serialized);
16240
17374
  if (sidecar.degraded.length > 0) {
16241
17375
  const unrep = sidecar.degraded.filter((d) => d.kind === "view-unrepresentable").length;
@@ -16386,6 +17520,12 @@ var DatabaseSyncer = class {
16386
17520
  }
16387
17521
  return (path) => this.stateDb.getByPath(path) ?? (untracked.has(path) ? { notionPageId: null } : null);
16388
17522
  }
17523
+ /** 되살린 행의 경로를 가져가고 비운다. */
17524
+ takeRestoredRows() {
17525
+ const rows = this.restoredRows;
17526
+ this.restoredRows = [];
17527
+ return rows;
17528
+ }
16389
17529
  /** 이름을 바꾼 행을 가져가고 비운다. */
16390
17530
  takeRenamedRows() {
16391
17531
  const rows = this.renamedRows;
@@ -16558,6 +17698,9 @@ var DatabaseSyncer = class {
16558
17698
  function rowProperties(page) {
16559
17699
  return page.properties ?? {};
16560
17700
  }
17701
+ function editedSince(previous, path, content) {
17702
+ return previous?.basePath === path && previous.hash !== void 0 && computeHash(content) !== previous.hash;
17703
+ }
16561
17704
 
16562
17705
  // src/audit/completeness.ts
16563
17706
  function groupLocalRowsByDatabase(local) {
@@ -16654,6 +17797,40 @@ async function verifyPageCompleteness(remote, local, rootPageId) {
16654
17797
  };
16655
17798
  }
16656
17799
 
17800
+ // src/audit/nested-folder-pages.ts
17801
+ var ID_SUFFIX = new RegExp(
17802
+ ` \\((?:${PAGE_ID_SUFFIX_LENGTHS.map((len) => `[0-9a-f]{${len}}`).join("|")})\\)$`
17803
+ );
17804
+ function folderOf3(record) {
17805
+ if (record.fileType === "db-row") return null;
17806
+ if (isFolderRecord(record)) return record.obsidianPath;
17807
+ const slash = record.obsidianPath.lastIndexOf("/");
17808
+ if (slash < 0) return null;
17809
+ const folder = record.obsidianPath.slice(0, slash);
17810
+ const stem = stripMarkdownExtension(record.obsidianPath.slice(slash + 1)).replace(ID_SUFFIX, "");
17811
+ return stem === folder.slice(folder.lastIndexOf("/") + 1) ? folder : null;
17812
+ }
17813
+ function findNestedFolderPages(records) {
17814
+ const byPage = /* @__PURE__ */ new Map();
17815
+ for (const record of records) {
17816
+ if (record.notionPageId !== null) byPage.set(compactNotionId(record.notionPageId), record);
17817
+ }
17818
+ const found2 = [];
17819
+ for (const inner of records) {
17820
+ if (inner.notionPageId === null || inner.notionParentId === null) continue;
17821
+ const folder = folderOf3(inner);
17822
+ if (folder === null) continue;
17823
+ const outer = byPage.get(compactNotionId(inner.notionParentId));
17824
+ if (!outer || outer === inner || folderOf3(outer) !== folder) continue;
17825
+ found2.push({
17826
+ folder,
17827
+ outer: { path: outer.obsidianPath, notionPageId: outer.notionPageId },
17828
+ inner: { path: inner.obsidianPath, notionPageId: inner.notionPageId }
17829
+ });
17830
+ }
17831
+ return found2.sort((a, b) => a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0);
17832
+ }
17833
+
16657
17834
  // src/conflict/merger.ts
16658
17835
  function threeWayMerge(base, local, remote) {
16659
17836
  const baseLines = base.split("\n");
@@ -16675,19 +17852,19 @@ function threeWayMerge(base, local, remote) {
16675
17852
  }
16676
17853
  const regionStart = first.baseStart;
16677
17854
  let regionEnd = first.baseEnd;
16678
- const group = [first];
17855
+ const group2 = [first];
16679
17856
  i++;
16680
17857
  while (i < hunks.length) {
16681
17858
  const next = hunks[i];
16682
17859
  const overlaps = next.baseStart < regionEnd || regionEnd === regionStart && next.baseStart === regionStart;
16683
17860
  if (!overlaps) break;
16684
17861
  regionEnd = Math.max(regionEnd, next.baseEnd);
16685
- group.push(next);
17862
+ group2.push(next);
16686
17863
  i++;
16687
17864
  }
16688
17865
  const baseRegion = baseLines.slice(regionStart, regionEnd);
16689
- const localRegion = applyHunks(baseLines, group, 0, regionStart, regionEnd);
16690
- const remoteRegion = applyHunks(baseLines, group, 2, regionStart, regionEnd);
17866
+ const localRegion = applyHunks(baseLines, group2, 0, regionStart, regionEnd);
17867
+ const remoteRegion = applyHunks(baseLines, group2, 2, regionStart, regionEnd);
16691
17868
  if (arraysEqual(localRegion, baseRegion)) {
16692
17869
  merged.push(...remoteRegion);
16693
17870
  } else if (arraysEqual(remoteRegion, baseRegion)) {
@@ -16738,10 +17915,10 @@ function computeHunks(base, modified, side) {
16738
17915
  emitGap(base.length, modified.length);
16739
17916
  return hunks;
16740
17917
  }
16741
- function applyHunks(baseLines, group, side, regionStart, regionEnd) {
17918
+ function applyHunks(baseLines, group2, side, regionStart, regionEnd) {
16742
17919
  const out = [];
16743
17920
  let b = regionStart;
16744
- for (const hunk of group) {
17921
+ for (const hunk of group2) {
16745
17922
  if (hunk.side !== side) continue;
16746
17923
  if (hunk.baseStart > b) out.push(...baseLines.slice(b, hunk.baseStart));
16747
17924
  out.push(...hunk.content);
@@ -17068,6 +18245,9 @@ var ScopedVaultFS = class {
17068
18245
  async ensureFolder(path) {
17069
18246
  return await this.base.ensureFolder(this.toBase(path, true));
17070
18247
  }
18248
+ async removeEmptyFolder(path) {
18249
+ return await this.base.removeEmptyFolder?.(this.toBase(path, true)) ?? false;
18250
+ }
17071
18251
  /** 맡은 노트만 읽는다 — 볼트 전체의 본문을 읽지 않는다. */
17072
18252
  async listMarkdownFiles() {
17073
18253
  const stats = await this.listMarkdownFileStats();
@@ -17145,7 +18325,7 @@ function nextFullReconcileAt(lastFullPullAt, intervalSec) {
17145
18325
  const last = lastFullPullAt === null ? NaN : Date.parse(lastFullPullAt);
17146
18326
  return Number.isFinite(last) ? new Date(last + intervalSec * 1e3).toISOString() : null;
17147
18327
  }
17148
- function remoteScanInfo(scan, lastFullPullAt, deleteSync) {
18328
+ function remoteScanInfo(scan, lastFullPullAt, deleteSync, deletionsChecked = false) {
17149
18329
  return scan.kind === "full" ? {
17150
18330
  kind: "full",
17151
18331
  reason: scan.reason,
@@ -17156,94 +18336,1008 @@ function remoteScanInfo(scan, lastFullPullAt, deleteSync) {
17156
18336
  kind: "incremental",
17157
18337
  lastFullAt: scan.lastFullAt,
17158
18338
  nextFullAt: scan.nextFullAt,
17159
- deletionsDeferred: deleteSync
18339
+ deletionsDeferred: deleteSync && !deletionsChecked
18340
+ };
18341
+ }
18342
+ var ALL_DATABASES = { kind: "all" };
18343
+ function selectsDatabase(selection, databaseId) {
18344
+ return selection.kind === "all" || selection.ids.has(compactNotionId(databaseId));
18345
+ }
18346
+ function parsePendingDatabases(raw) {
18347
+ if (!raw) return /* @__PURE__ */ new Set();
18348
+ try {
18349
+ const parsed = JSON.parse(raw);
18350
+ if (!Array.isArray(parsed)) return /* @__PURE__ */ new Set();
18351
+ return new Set(
18352
+ parsed.filter((id) => typeof id === "string").map((id) => compactNotionId(id))
18353
+ );
18354
+ } catch {
18355
+ return /* @__PURE__ */ new Set();
18356
+ }
18357
+ }
18358
+ function serializePendingDatabases(ids) {
18359
+ return JSON.stringify([...ids].sort());
18360
+ }
18361
+ function addPendingDatabases(stateDb, databaseIds) {
18362
+ const pending = parsePendingDatabases(stateDb.getMeta(PENDING_DATABASES_META_KEY));
18363
+ const before = pending.size;
18364
+ for (const id of databaseIds) pending.add(compactNotionId(id));
18365
+ if (pending.size !== before) {
18366
+ stateDb.setMeta(PENDING_DATABASES_META_KEY, serializePendingDatabases(pending));
18367
+ }
18368
+ }
18369
+ var DatabasePullLedger = class {
18370
+ constructor(selection, pending) {
18371
+ this.selection = selection;
18372
+ this.pending = pending;
18373
+ }
18374
+ selection;
18375
+ pending;
18376
+ settled = /* @__PURE__ */ new Set();
18377
+ unfinished = /* @__PURE__ */ new Set();
18378
+ skippedCount = 0;
18379
+ /** 이제 동기화하는 DB — 알면 대기를 이것으로 좁힌다. */
18380
+ syncing = null;
18381
+ /** 이 DB 를 조회하나 — 새로 발견한 DB 는 부른 쪽이 따로 조회한다(받은 적이 없다). */
18382
+ selects(databaseId) {
18383
+ return selectsDatabase(this.selection, databaseId);
18384
+ }
18385
+ /** 바뀐 것이 보이지 않아 조회하지 않았다. */
18386
+ skip() {
18387
+ this.skippedCount++;
18388
+ }
18389
+ /** 받았거나 · 이제 없어서 뺐다 — 다시 조회할 것이 없다. */
18390
+ settle(databaseId) {
18391
+ this.settled.add(compactNotionId(databaseId));
18392
+ }
18393
+ /** 조회하다 실패했거나 · 받지 못한 행이 있거나 · 취소로 닿지 못했다 — 다음 pull 이 다시 조회한다. */
18394
+ retry(databaseId) {
18395
+ this.unfinished.add(compactNotionId(databaseId));
18396
+ }
18397
+ get skipped() {
18398
+ return this.skippedCount;
18399
+ }
18400
+ /**
18401
+ * 이제 동기화하는 DB 를 알린다 — 대기를 이것으로 좁힌다. 설정에서 뺐거나 접근 불가로 뺀 DB 를 영영
18402
+ * 들고 있지 않는다. 알리지 않으면(발견이 멈춤 등) 좁히지 않는다 — 모르는 것을 버리지 않는다.
18403
+ */
18404
+ keepOnly(databaseIds) {
18405
+ this.syncing = new Set([...databaseIds].map((id) => compactNotionId(id)));
18406
+ }
18407
+ /** 다음 pull 의 대기 — 지난 대기에서 받은 것을 빼고 이번에 받지 못한 것을 더한다. */
18408
+ nextPending() {
18409
+ const next = new Set([...this.pending].filter((id) => !this.settled.has(id)));
18410
+ for (const id of this.unfinished) next.add(id);
18411
+ const syncing = this.syncing;
18412
+ return syncing ? new Set([...next].filter((id) => syncing.has(id))) : next;
18413
+ }
18414
+ };
18415
+
18416
+ // src/sync/schema-push.ts
18417
+ var NOT_PROPERTIES = /* @__PURE__ */ new Set(["title", ...DECORATION_KEYS]);
18418
+ var VaultSchemaRenames = class {
18419
+ constructor(stateDb, vaultFs, notionClient) {
18420
+ this.stateDb = stateDb;
18421
+ this.vaultFs = vaultFs;
18422
+ this.notionClient = notionClient;
18423
+ this.ledger = new SchemaLedger(stateDb);
18424
+ }
18425
+ stateDb;
18426
+ vaultFs;
18427
+ notionClient;
18428
+ ledger;
18429
+ /** 이번 push 의 바뀐 행에서 이름 바꾸기를 찾는다 — Notion 은 그런 DB 만 읽고, 쓰지 않는다. */
18430
+ async plan(changes) {
18431
+ const byDatabase = /* @__PURE__ */ new Map();
18432
+ for (const change of changes) {
18433
+ if (change.type !== "modified") continue;
18434
+ const row = await this.changedRow(change.path);
18435
+ if (!row) continue;
18436
+ const key = compactNotionId(row.record.notionParentId);
18437
+ byDatabase.set(key, [...byDatabase.get(key) ?? [], row]);
18438
+ }
18439
+ const planned = [];
18440
+ const refused = [];
18441
+ for (const rows of byDatabase.values()) {
18442
+ const databaseId = rows[0].record.notionParentId;
18443
+ const pairs = await this.localRenames(databaseId, rows);
18444
+ if (pairs.length === 0) continue;
18445
+ const meta = await this.notionClient.getDatabaseMeta(databaseId);
18446
+ for (const [from, to] of pairs) {
18447
+ const carrying = rows.filter((row) => Object.hasOwn(row.current, to));
18448
+ const folder = folderOf4(carrying[0].record.obsidianPath);
18449
+ const owners = meta.dataSources.filter(
18450
+ (ds) => ds.properties?.[from] !== void 0
18451
+ );
18452
+ if (owners.length === 0) continue;
18453
+ const owner = owners[0];
18454
+ const ownProps = owner.properties;
18455
+ if (Object.hasOwn(ownProps, to)) continue;
18456
+ const propertyId = ownProps[from]?.id;
18457
+ if (owners.length > 1 || typeof propertyId !== "string") {
18458
+ const error = `\uC18D\uC131 \uC774\uB984 \u300C${from}\u300D \u2192 \u300C${to}\u300D \uB97C Notion \uC5D0 \uC62E\uAE38 \uC218 \uC5C6\uC5B4 \uC62C\uB9AC\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4 \u2014 \uC774 DB \uC758 \uB370\uC774\uD130 \uC18C\uC2A4 \uC5EC\uB7FF\uC5D0 \u300C${from}\u300D \uC774 \uC788\uC2B5\uB2C8\uB2E4. Notion \uC5D0\uC11C \uBC14\uAFB8\uC138\uC694`;
18459
+ refused.push(
18460
+ ...carrying.map((row) => ({
18461
+ path: row.record.obsidianPath,
18462
+ operation: "update",
18463
+ error
18464
+ }))
18465
+ );
18466
+ continue;
18467
+ }
18468
+ planned.push({
18469
+ databaseId,
18470
+ dataSourceId: owner.id,
18471
+ propertyId,
18472
+ folder,
18473
+ from,
18474
+ to,
18475
+ rows: carrying.map((row) => row.record)
18476
+ });
18477
+ }
18478
+ }
18479
+ return { planned, refused };
18480
+ }
18481
+ /**
18482
+ * 열 이름을 바꾸고, 그 행들의 동기화 사본도 새 키로 — 사본이 옛 키를 들고 있으면 행 push 가 옛 열을 비운다. 이름
18483
+ * 바꾸기 말고 바뀐 것이 없는 행은 동기화를 마친 것으로 적는다.
18484
+ */
18485
+ async apply(planned) {
18486
+ const applied = [];
18487
+ const settled = /* @__PURE__ */ new Set();
18488
+ const failed = [];
18489
+ for (const rename2 of planned) {
18490
+ try {
18491
+ const after = await this.notionClient.updateDataSourceProperties(rename2.dataSourceId, {
18492
+ [rename2.propertyId]: { name: rename2.to }
18493
+ });
18494
+ if (after[rename2.to]?.id !== rename2.propertyId) {
18495
+ getLogger().warn(
18496
+ `[Im-Nobsidian] \uC18D\uC131 \uC774\uB984 \u300C${rename2.from}\u300D \u2192 \u300C${rename2.to}\u300D: Notion \uC774 \uB2E4\uB978 \uC774\uB984\uC744 \uBD99\uC600\uC2B5\uB2C8\uB2E4 \u2014 Notion \uC5D0\uC11C \uD655\uC778\uD558\uC138\uC694`
18497
+ );
18498
+ }
18499
+ } catch (error) {
18500
+ const reason = error instanceof Error ? error.message : String(error);
18501
+ for (const record of rename2.rows) {
18502
+ failed.push({
18503
+ path: record.obsidianPath,
18504
+ operation: "update",
18505
+ error: `\uC18D\uC131 \uC774\uB984 \u300C${rename2.from}\u300D \u2192 \u300C${rename2.to}\u300D \uB97C Notion \uC5D0\uC11C \uBC14\uAFB8\uC9C0 \uBABB\uD574 \uC62C\uB9AC\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4: ${reason}`
18506
+ });
18507
+ }
18508
+ continue;
18509
+ }
18510
+ applied.push({ folder: rename2.folder, from: rename2.from, to: rename2.to });
18511
+ this.noteRenamed(rename2);
18512
+ for (const record of rename2.rows) {
18513
+ if (await this.rekeySnapshot(record, rename2)) settled.add(record.obsidianPath);
18514
+ }
18515
+ }
18516
+ return { applied, settled, failed };
18517
+ }
18518
+ /** 바뀐 행 노트 — 지난 동기화 사본이 있는 DB 행만. */
18519
+ async changedRow(path) {
18520
+ const record = this.stateDb.getByPath(path);
18521
+ if (record?.fileType !== "db-row" || !record.notionParentId) return null;
18522
+ const base = snapshotFrontmatter(record.baseSnapshot);
18523
+ if (base === null) return null;
18524
+ try {
18525
+ const current = splitFrontmatter(await this.vaultFs.readFile(path)).data;
18526
+ return { record, base, current };
18527
+ } catch {
18528
+ return null;
18529
+ }
18530
+ }
18531
+ /** 바뀐 행이 말하는 이름 바꾸기 — 옛 키 → 새 키. 한 키를 두 갈래로 읽을 수 있으면 뺀다. */
18532
+ async localRenames(databaseId, rows) {
18533
+ const candidates = /* @__PURE__ */ new Map();
18534
+ for (const { base, current } of rows) {
18535
+ for (const from of Object.keys(base)) {
18536
+ if (NOT_PROPERTIES.has(from) || Object.hasOwn(current, from)) continue;
18537
+ const value = plainFrontmatterValue(base[from]);
18538
+ if (isEmptyFrontmatterValue(value)) continue;
18539
+ for (const to of Object.keys(current)) {
18540
+ if (NOT_PROPERTIES.has(to) || Object.hasOwn(base, to)) continue;
18541
+ if (!sameFrontmatterValue(value, plainFrontmatterValue(current[to]))) continue;
18542
+ candidates.set(from, /* @__PURE__ */ new Set([...candidates.get(from) ?? [], to]));
18543
+ }
18544
+ }
18545
+ }
18546
+ const pairs = [];
18547
+ const targets = /* @__PURE__ */ new Map();
18548
+ for (const [from, tos] of candidates) {
18549
+ const consistent = [...tos].filter(
18550
+ (to) => rows.every(
18551
+ ({ base, current }) => !Object.hasOwn(current, from) && (isEmptyFrontmatterValue(plainFrontmatterValue(base[from])) || Object.hasOwn(current, to))
18552
+ )
18553
+ );
18554
+ if (consistent.length !== 1) continue;
18555
+ pairs.push([from, consistent[0]]);
18556
+ targets.set(consistent[0], (targets.get(consistent[0]) ?? 0) + 1);
18557
+ }
18558
+ const unique = pairs.filter(([, to]) => targets.get(to) === 1);
18559
+ if (unique.length === 0) return [];
18560
+ const changed = new Set(rows.map((row) => row.record.id));
18561
+ const others = this.stateDb.getAll().filter(
18562
+ (record) => record.fileType === "db-row" && record.notionParentId !== null && notionIdsEqual(record.notionParentId, databaseId) && !changed.has(record.id)
18563
+ );
18564
+ const keysOf = await Promise.all(others.map((record) => this.keysOf(record)));
18565
+ return unique.filter(
18566
+ ([from, to]) => keysOf.every((keys) => keys !== null && !keys.has(from) && !keys.has(to))
18567
+ );
18568
+ }
18569
+ /** 행 노트에 지금 적힌 키 — 읽지 못하면 null(그 DB 의 이름 바꾸기는 보지 않는다). */
18570
+ async keysOf(record) {
18571
+ try {
18572
+ const data = splitFrontmatter(await this.vaultFs.readFile(record.obsidianPath)).data;
18573
+ return new Set(Object.keys(data).filter((key) => !isEmptyFrontmatterValue(data[key])));
18574
+ } catch {
18575
+ return null;
18576
+ }
18577
+ }
18578
+ /** 그 행의 사본을 새 키로 — 노트가 사본과 같아졌으면 true(동기화를 마쳤다). */
18579
+ async rekeySnapshot(record, rename2) {
18580
+ const snapshot = record.baseSnapshot?.toString("utf-8");
18581
+ if (snapshot === void 0) return false;
18582
+ const lines = rekeyRowFrontmatter(snapshot, /* @__PURE__ */ new Map([[rename2.from, rename2.to]]), () => false);
18583
+ const next = lines === null ? null : replaceFrontmatterLines(snapshot, lines);
18584
+ if (next === null) return false;
18585
+ let local = null;
18586
+ try {
18587
+ local = await this.vaultFs.readFile(record.obsidianPath);
18588
+ } catch {
18589
+ }
18590
+ const synced = local !== null && sameRow(local, next);
18591
+ const content = synced ? local : next;
18592
+ this.stateDb.transaction(() => {
18593
+ this.stateDb.updateHash(record.id, computeHash(content), Buffer.from(content, "utf-8"));
18594
+ });
18595
+ return synced;
18596
+ }
18597
+ /** 지난 pull 이 본 스키마에도 새 이름을 적는다 — 다음 pull 이 이 이름 바꾸기를 Notion 의 것으로 다시 옮기지 않게. */
18598
+ noteRenamed(rename2) {
18599
+ const seen = this.ledger.get(rename2.databaseId);
18600
+ const source = compactNotionId(rename2.dataSourceId);
18601
+ const known = seen?.[source]?.[rename2.propertyId];
18602
+ if (!seen || !known) return;
18603
+ this.ledger.set(rename2.databaseId, {
18604
+ [source]: { ...seen[source], [rename2.propertyId]: { ...known, name: rename2.to } }
18605
+ });
18606
+ }
18607
+ };
18608
+ function sameRow(a, b) {
18609
+ const left = splitFrontmatter(a);
18610
+ const right = splitFrontmatter(b);
18611
+ const keys = Object.keys(left.data);
18612
+ return left.content === right.content && keys.length === Object.keys(right.data).length && keys.every((key) => sameFrontmatterValue(left.data[key], right.data[key]));
18613
+ }
18614
+ function folderOf4(path) {
18615
+ const at = path.lastIndexOf("/");
18616
+ return at === -1 ? "" : path.slice(0, at);
18617
+ }
18618
+
18619
+ // src/view/bases-filter-to-notion.ts
18620
+ var MAX_FILTER_DEPTH = 2;
18621
+ var TEXT_TYPES = /* @__PURE__ */ new Set([
18622
+ "title",
18623
+ "rich_text",
18624
+ "url",
18625
+ "email",
18626
+ "phone_number"
18627
+ ]);
18628
+ var EMPTY_TYPES = /* @__PURE__ */ new Set([
18629
+ ...TEXT_TYPES,
18630
+ "number",
18631
+ "select",
18632
+ "status",
18633
+ "multi_select",
18634
+ "date"
18635
+ ]);
18636
+ var NUMBER_OPS = {
18637
+ "==": "equals",
18638
+ "!=": "does_not_equal",
18639
+ ">": "greater_than",
18640
+ "<": "less_than",
18641
+ ">=": "greater_than_or_equal_to",
18642
+ "<=": "less_than_or_equal_to"
18643
+ };
18644
+ var DATE_OPS = {
18645
+ "==": "equals",
18646
+ "<": "before",
18647
+ ">": "after",
18648
+ "<=": "on_or_before",
18649
+ ">=": "on_or_after"
18650
+ };
18651
+ var RELATIVE_VALUE_OF = new Map(
18652
+ Object.entries(RELATIVE_DATE_VALUES).map(([value, expr]) => [squash(expr), value])
18653
+ );
18654
+ var Refusal = class extends Error {
18655
+ };
18656
+ function refuse(message) {
18657
+ throw new Refusal(message);
18658
+ }
18659
+ function basesFilterToNotion(node, schema) {
18660
+ try {
18661
+ const filter = settle([toFilter(node, schema)])[0];
18662
+ if (depthOf(filter) > MAX_FILTER_DEPTH) {
18663
+ refuse("Notion \uD544\uD130\uB294 and \xB7 or \uBB36\uC74C\uC744 \uB450 \uACB9\uAE4C\uC9C0\uB9CC \uBC1B\uC2B5\uB2C8\uB2E4");
18664
+ }
18665
+ return { ok: true, filter };
18666
+ } catch (error) {
18667
+ if (error instanceof Refusal) return { ok: false, error: error.message };
18668
+ throw error;
18669
+ }
18670
+ }
18671
+ function toFilter(node, schema) {
18672
+ if (typeof node === "string") return compile(parseExpression(node), schema);
18673
+ if (!node || typeof node !== "object" || Array.isArray(node)) {
18674
+ refuse("\uD544\uD130\uB294 \uC2DD \uD558\uB098\uC774\uAC70\uB098 and \xB7 or \xB7 not \uBB36\uC74C\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4");
18675
+ }
18676
+ const group2 = node;
18677
+ const keys = Object.keys(group2);
18678
+ if (keys.length !== 1 || !["and", "or", "not"].includes(keys[0])) {
18679
+ refuse(`\uD544\uD130 \uBB36\uC74C\uC740 and \xB7 or \xB7 not \uC911 \uD558\uB098\uC5EC\uC57C \uD569\uB2C8\uB2E4(${keys.join(", ")})`);
18680
+ }
18681
+ const op = keys[0];
18682
+ const children = group2[op];
18683
+ if (!Array.isArray(children) || children.length === 0) refuse(`${op} \uBB36\uC74C\uC774 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4`);
18684
+ const asts = children.map(
18685
+ (child) => typeof child === "string" ? parseExpression(child) : { kind: "yaml", node: child }
18686
+ );
18687
+ const ast = op === "not" ? { kind: "and", items: asts.map((item) => ({ kind: "not", item })) } : { kind: op, items: asts };
18688
+ return compile(ast, schema);
18689
+ }
18690
+ var OPERATORS = [
18691
+ "&&",
18692
+ "||",
18693
+ "==",
18694
+ "!=",
18695
+ ">=",
18696
+ "<=",
18697
+ ">",
18698
+ "<",
18699
+ "!",
18700
+ "+",
18701
+ "-",
18702
+ "(",
18703
+ ")",
18704
+ "[",
18705
+ "]",
18706
+ ".",
18707
+ ","
18708
+ ];
18709
+ function tokenize(text) {
18710
+ const tokens = [];
18711
+ let i = 0;
18712
+ while (i < text.length) {
18713
+ const c = text[i];
18714
+ if (/\s/.test(c)) {
18715
+ i++;
18716
+ continue;
18717
+ }
18718
+ if (c === '"' || c === "'") {
18719
+ let value = "";
18720
+ i++;
18721
+ while (i < text.length && text[i] !== c) {
18722
+ if (text[i] === "\\" && i + 1 < text.length) i++;
18723
+ value += text[i];
18724
+ i++;
18725
+ }
18726
+ if (i >= text.length) refuse(`\uB530\uC634\uD45C\uAC00 \uB2EB\uD788\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4: ${text}`);
18727
+ i++;
18728
+ tokens.push({ t: "string", v: value });
18729
+ continue;
18730
+ }
18731
+ const number = /^\d+(?:\.\d+)?/.exec(text.slice(i));
18732
+ if (number) {
18733
+ tokens.push({ t: "number", v: Number(number[0]) });
18734
+ i += number[0].length;
18735
+ continue;
18736
+ }
18737
+ const op = OPERATORS.find((o) => text.startsWith(o, i));
18738
+ if (op) {
18739
+ tokens.push({ t: "op", v: op });
18740
+ i += op.length;
18741
+ continue;
18742
+ }
18743
+ const ident = /^[\p{L}\p{N}_]+/u.exec(text.slice(i));
18744
+ if (ident) {
18745
+ tokens.push({ t: "ident", v: ident[0] });
18746
+ i += ident[0].length;
18747
+ continue;
18748
+ }
18749
+ refuse(`\uD544\uD130 \uC2DD\uC744 \uC77D\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: ${text}`);
18750
+ }
18751
+ return tokens;
18752
+ }
18753
+ function parseExpression(text) {
18754
+ const tokens = tokenize(text);
18755
+ let pos = 0;
18756
+ const peek = (v) => {
18757
+ const token2 = tokens[pos];
18758
+ return v === void 0 || token2?.t === "op" && token2.v === v ? token2 : void 0;
18759
+ };
18760
+ const take = (v) => {
18761
+ if (!peek(v)) return false;
18762
+ pos++;
18763
+ return true;
18764
+ };
18765
+ const expect = (v) => {
18766
+ if (!take(v)) refuse(`\uD544\uD130 \uC2DD\uC5D0\uC11C '${v}' \uAC00 \uC640\uC57C \uD569\uB2C8\uB2E4: ${text}`);
18767
+ };
18768
+ const orExpr = () => {
18769
+ const items = [andExpr()];
18770
+ while (take("||")) items.push(andExpr());
18771
+ return items.length === 1 ? items[0] : { kind: "or", items };
18772
+ };
18773
+ const andExpr = () => {
18774
+ const items = [unary()];
18775
+ while (take("&&")) items.push(unary());
18776
+ return items.length === 1 ? items[0] : { kind: "and", items };
18777
+ };
18778
+ const unary = () => {
18779
+ if (take("!")) return { kind: "not", item: unary() };
18780
+ if (peek("(")) {
18781
+ pos++;
18782
+ const inner = orExpr();
18783
+ expect(")");
18784
+ return inner;
18785
+ }
18786
+ return comparison();
18787
+ };
18788
+ const comparison = () => {
18789
+ const left = operand();
18790
+ const op = tokens[pos];
18791
+ if (op?.t === "op" && ["==", "!=", ">", "<", ">=", "<="].includes(op.v)) {
18792
+ if (left.call) refuse(`\uD568\uC218 \uACB0\uACFC\uB97C \uACAC\uC8FC\uB294 \uC2DD\uC740 \uC62E\uAE30\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: ${text}`);
18793
+ pos++;
18794
+ return { kind: "compare", op: op.v, left: left.term, right: operand().term };
18795
+ }
18796
+ if (left.call) return left.call;
18797
+ refuse(`\uBE44\uAD50\uB098 \uD568\uC218 \uD638\uCD9C\uC774 \uC544\uB2CC \uC2DD\uC740 \uC62E\uAE30\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: ${text}`);
18798
+ };
18799
+ const operand = () => {
18800
+ const token2 = tokens[pos];
18801
+ if (!token2) refuse(`\uD544\uD130 \uC2DD\uC774 \uB35C \uB05D\uB0AC\uC2B5\uB2C8\uB2E4: ${text}`);
18802
+ if (token2.t === "string") {
18803
+ pos++;
18804
+ return { term: { kind: "string", value: token2.v } };
18805
+ }
18806
+ if (token2.t === "number") {
18807
+ pos++;
18808
+ return { term: { kind: "number", value: token2.v } };
18809
+ }
18810
+ if (token2.t === "ident" && (token2.v === "true" || token2.v === "false")) {
18811
+ pos++;
18812
+ return { term: { kind: "boolean", value: token2.v === "true" } };
18813
+ }
18814
+ if (token2.t === "ident" && (token2.v === "today" || token2.v === "date") && tokens[pos + 1]?.v === "(") {
18815
+ return { term: dateTerm() };
18816
+ }
18817
+ if (token2.t === "ident") return reference();
18818
+ refuse(`\uD544\uD130 \uC2DD\uC744 \uC77D\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: ${text}`);
18819
+ };
18820
+ const dateTerm = () => {
18821
+ const start = pos;
18822
+ const fn = tokens[pos].v;
18823
+ pos += 2;
18824
+ let literal;
18825
+ if (fn === "date") {
18826
+ const arg = tokens[pos];
18827
+ if (arg?.t !== "string") refuse(`date() \uC5D0\uB294 \uB0A0\uC9DC \uBB38\uC790\uC5F4\uC744 \uB123\uC5B4\uC57C \uD569\uB2C8\uB2E4: ${text}`);
18828
+ literal = arg.v;
18829
+ pos++;
18830
+ }
18831
+ expect(")");
18832
+ if (fn === "today" && (peek("+") || peek("-")) && tokens[pos + 1]?.t === "string") pos += 2;
18833
+ const raw = tokens.slice(start, pos).map((t) => t.t === "string" ? JSON.stringify(t.v) : String(t.v)).join("");
18834
+ return { kind: "date", text: raw, ...literal !== void 0 ? { literal } : {} };
18835
+ };
18836
+ const reference = () => {
18837
+ const head = tokens[pos++].v;
18838
+ let ref;
18839
+ if ((head === "note" || head === "file") && (peek(".") || peek("["))) {
18840
+ let name;
18841
+ if (take("[")) {
18842
+ const key = tokens[pos++];
18843
+ if (key?.t !== "string") refuse(`${head}[\u2026] \uC5D0\uB294 \uBB38\uC790\uC5F4 \uC774\uB984\uC744 \uB123\uC5B4\uC57C \uD569\uB2C8\uB2E4: ${text}`);
18844
+ name = key.v;
18845
+ expect("]");
18846
+ } else {
18847
+ pos++;
18848
+ const key = tokens[pos++];
18849
+ if (key?.t !== "ident") refuse(`${head}. \uB4A4\uC5D0 \uC774\uB984\uC774 \uC640\uC57C \uD569\uB2C8\uB2E4: ${text}`);
18850
+ name = key.v;
18851
+ }
18852
+ ref = { scope: head, name, dateOnly: false };
18853
+ } else if (head === "formula" || head === "this") {
18854
+ refuse(`${head} \uB294 Notion \uC18D\uC131\uC774 \uC544\uB2C8\uB77C \uC62E\uAE30\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: ${text}`);
18855
+ } else {
18856
+ ref = { scope: "note", name: head, dateOnly: false };
18857
+ }
18858
+ while (take(".")) {
18859
+ const method = tokens[pos++];
18860
+ if (method?.t !== "ident") refuse(`'.' \uB4A4\uC5D0 \uD568\uC218 \uC774\uB984\uC774 \uC640\uC57C \uD569\uB2C8\uB2E4: ${text}`);
18861
+ expect("(");
18862
+ const args = [];
18863
+ if (!take(")")) {
18864
+ do
18865
+ args.push(operand().term);
18866
+ while (take(","));
18867
+ expect(")");
18868
+ }
18869
+ if (method.v === "date" && args.length === 0 && !ref.dateOnly) {
18870
+ ref = { ...ref, dateOnly: true };
18871
+ continue;
18872
+ }
18873
+ if (peek(".")) refuse(`\uD568\uC218\uB97C \uC774\uC5B4 \uBD80\uB978 \uC2DD\uC740 \uC62E\uAE30\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: ${text}`);
18874
+ return {
18875
+ term: { kind: "ref", ref },
18876
+ call: { kind: "call", target: { kind: "ref", ref }, name: method.v, args }
18877
+ };
18878
+ }
18879
+ return { term: { kind: "ref", ref } };
18880
+ };
18881
+ const ast = orExpr();
18882
+ if (pos !== tokens.length) refuse(`\uD544\uD130 \uC2DD \uB05D\uC5D0 \uC77D\uC9C0 \uBABB\uD55C \uAC83\uC774 \uC788\uC2B5\uB2C8\uB2E4: ${text}`);
18883
+ return ast;
18884
+ }
18885
+ function compile(ast, schema) {
18886
+ switch (ast.kind) {
18887
+ case "yaml":
18888
+ return toFilter(ast.node, schema);
18889
+ case "and":
18890
+ case "or":
18891
+ return group(
18892
+ ast.kind,
18893
+ ast.items.map((item) => compile(item, schema))
18894
+ );
18895
+ case "not":
18896
+ return negate(compile(ast.item, schema));
18897
+ case "compare":
18898
+ return compare(ast, schema);
18899
+ case "call":
18900
+ return call(ast, schema);
18901
+ }
18902
+ }
18903
+ function group(op, items) {
18904
+ const merged = settle(op === "and" ? mergeRelativeRanges(items) : items);
18905
+ return merged.length === 1 ? merged[0] : { [op]: merged };
18906
+ }
18907
+ function settle(items) {
18908
+ return items.map((item) => {
18909
+ if (!isPendingRange(item)) return item;
18910
+ const { pending, ...leaf2 } = item;
18911
+ if (Object.keys(leaf2).length === 0) {
18912
+ refuse(`\uC0C1\uB300 \uAE30\uAC04 '${pending.text}' \uC740 \uBC18\uB300\uCABD \uB05D(today())\uACFC and \uB85C \uC9DD\uC744 \uC774\uB904\uC57C \uC62E\uAE41\uB2C8\uB2E4`);
18913
+ }
18914
+ return leaf2;
18915
+ });
18916
+ }
18917
+ function compare(ast, schema) {
18918
+ if (ast.left.kind !== "ref") refuse("\uBE44\uAD50\uC758 \uC67C\uCABD\uC740 \uC18D\uC131\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4");
18919
+ const target = targetOf(ast.left.ref, schema);
18920
+ const value = ast.right;
18921
+ const leaf2 = (op, v) => ({
18922
+ ...target.head,
18923
+ [target.type]: { [op]: v }
18924
+ });
18925
+ if (TEXT_TYPES.has(target.type) || target.type === "select" || target.type === "status") {
18926
+ if (value.kind !== "string") refuse(`${target.label}: \uBB38\uC790\uC5F4\uACFC\uB9CC \uACAC\uC904 \uC218 \uC788\uC2B5\uB2C8\uB2E4`);
18927
+ if (ast.op === "==") return leaf2("equals", value.value);
18928
+ if (ast.op === "!=") return leaf2("does_not_equal", value.value);
18929
+ refuse(`${target.label}: '${ast.op}' \uB85C\uB294 \uACAC\uC8FC\uC9C0 \uBABB\uD569\uB2C8\uB2E4`);
18930
+ }
18931
+ if (target.type === "number") {
18932
+ if (value.kind !== "number") refuse(`${target.label}: \uC22B\uC790\uC640\uB9CC \uACAC\uC904 \uC218 \uC788\uC2B5\uB2C8\uB2E4`);
18933
+ return leaf2(NUMBER_OPS[ast.op], value.value);
18934
+ }
18935
+ if (target.type === "checkbox") {
18936
+ if (value.kind !== "boolean") refuse(`${target.label}: true \xB7 false \uC640\uB9CC \uACAC\uC904 \uC218 \uC788\uC2B5\uB2C8\uB2E4`);
18937
+ if (ast.op === "==") return leaf2("equals", value.value);
18938
+ if (ast.op === "!=") return leaf2("does_not_equal", value.value);
18939
+ refuse(`${target.label}: '${ast.op}' \uB85C\uB294 \uACAC\uC8FC\uC9C0 \uBABB\uD569\uB2C8\uB2E4`);
18940
+ }
18941
+ if (target.type === "date" || target.type === "created_time" || target.type === "last_edited_time") {
18942
+ const op = DATE_OPS[ast.op];
18943
+ if (!op) refuse(`${target.label}: \uB0A0\uC9DC\uB294 '${ast.op}' \uB85C \uACAC\uC8FC\uC9C0 \uBABB\uD569\uB2C8\uB2E4`);
18944
+ return dateLeaf(target, op, ast.op, value);
18945
+ }
18946
+ refuse(`${target.label}: ${target.type} \uC18D\uC131\uC758 \uD544\uD130\uB294 \uC62E\uAE30\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4`);
18947
+ }
18948
+ function dateLeaf(target, op, symbol, value) {
18949
+ const leaf2 = (v) => ({ ...target.head, [target.type]: { [op]: v } });
18950
+ if (value.kind === "string") {
18951
+ if (!/^\d{4}-\d{2}-\d{2}/.test(value.value))
18952
+ refuse(`${target.label}: \uB0A0\uC9DC\uAC00 \uC544\uB2CC \uAC12 '${value.value}'`);
18953
+ return leaf2(value.value);
18954
+ }
18955
+ if (value.kind !== "date") refuse(`${target.label}: \uB0A0\uC9DC\uC640\uB9CC \uACAC\uC904 \uC218 \uC788\uC2B5\uB2C8\uB2E4`);
18956
+ if (value.literal !== void 0) {
18957
+ if (!/^\d{4}-\d{2}-\d{2}/.test(value.literal))
18958
+ refuse(`${target.label}: \uB0A0\uC9DC\uAC00 \uC544\uB2CC \uAC12 '${value.literal}'`);
18959
+ return leaf2(value.literal);
18960
+ }
18961
+ const pending = { target, symbol, text: value.text };
18962
+ const relative2 = RELATIVE_VALUE_OF.get(value.text);
18963
+ return relative2 === void 0 ? { pending } : { ...leaf2(relative2), pending };
18964
+ }
18965
+ function call(ast, schema) {
18966
+ if (ast.target.kind !== "ref") refuse("\uD568\uC218\uB294 \uC18D\uC131\uC5D0 \uBD99\uC5EC\uC57C \uD569\uB2C8\uB2E4");
18967
+ const target = targetOf(ast.target.ref, schema);
18968
+ const leaf2 = (op, v) => ({
18969
+ ...target.head,
18970
+ [target.type]: { [op]: v }
18971
+ });
18972
+ const strings = ast.args.map((arg) => {
18973
+ if (arg.kind !== "string") refuse(`${target.label}: ${ast.name}() \uC5D0\uB294 \uBB38\uC790\uC5F4\uC744 \uB123\uC5B4\uC57C \uD569\uB2C8\uB2E4`);
18974
+ return arg.value;
18975
+ });
18976
+ if (ast.name === "isEmpty" && strings.length === 0) {
18977
+ if (!EMPTY_TYPES.has(target.type)) refuse(`${target.label}: \uBE48 \uAC12 \uC870\uAC74\uC740 \uC62E\uAE30\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4`);
18978
+ return leaf2("is_empty", true);
18979
+ }
18980
+ if (TEXT_TYPES.has(target.type) && strings.length === 1) {
18981
+ if (ast.name === "contains") return leaf2("contains", strings[0]);
18982
+ if (ast.name === "startsWith") return leaf2("starts_with", strings[0]);
18983
+ if (ast.name === "endsWith") return leaf2("ends_with", strings[0]);
18984
+ }
18985
+ if (target.type === "multi_select" && strings.length > 0) {
18986
+ if (ast.name === "containsAny" || ast.name === "contains" && strings.length === 1) {
18987
+ const parts = strings.map((s) => leaf2("contains", s));
18988
+ return parts.length === 1 ? parts[0] : { or: parts };
18989
+ }
18990
+ }
18991
+ refuse(`${target.label}: ${ast.name}() \uC740 \uC62E\uAE30\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4`);
18992
+ }
18993
+ function negate(filter) {
18994
+ if (isPendingRange(filter)) refuse("\uC0C1\uB300 \uAE30\uAC04 \uC870\uAC74\uC758 \uBD80\uC815\uC740 \uC62E\uAE30\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
18995
+ if (Array.isArray(filter["and"])) return { or: filter["and"].map(negate) };
18996
+ if (Array.isArray(filter["or"])) return { and: filter["or"].map(negate) };
18997
+ const flips = {
18998
+ equals: "does_not_equal",
18999
+ does_not_equal: "equals",
19000
+ contains: "does_not_contain",
19001
+ does_not_contain: "contains",
19002
+ is_empty: "is_not_empty",
19003
+ is_not_empty: "is_empty"
19004
+ };
19005
+ for (const [key, cond] of Object.entries(filter)) {
19006
+ if (key === "property" || key === "timestamp" || !cond || typeof cond !== "object") continue;
19007
+ if (key === "date" || key === "created_time" || key === "last_edited_time" || key === "number") {
19008
+ if (!("is_empty" in cond) && !("is_not_empty" in cond)) {
19009
+ refuse("\uB0A0\uC9DC \xB7 \uC22B\uC790 \uBE44\uAD50\uC758 \uBD80\uC815\uC740 \uC62E\uAE30\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4 \u2014 \uBC18\uB300 \uBE44\uAD50\uB85C \uBC14\uAFD4 \uC4F0\uC138\uC694");
19010
+ }
19011
+ }
19012
+ const [op, value] = Object.entries(cond)[0] ?? [];
19013
+ const flipped = op === void 0 ? void 0 : flips[op];
19014
+ if (!flipped) refuse(`'${String(op)}' \uC870\uAC74\uC758 \uBD80\uC815\uC740 \uC62E\uAE30\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4`);
19015
+ return { ...filter, [key]: { [flipped]: flipped.startsWith("is_") ? true : value } };
19016
+ }
19017
+ refuse("\uBD80\uC815\uD560 \uC870\uAC74\uC744 \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4");
19018
+ }
19019
+ function targetOf(ref, schema) {
19020
+ if (ref.scope === "file") {
19021
+ const label = `file.${ref.name}`;
19022
+ const timestamp = notionTimestampOf(label);
19023
+ if (timestamp) return { head: { timestamp }, type: timestamp, label };
19024
+ const title = Object.values(schema).find((p) => p.type === "title");
19025
+ if (label === BASES_TITLE && title) {
19026
+ return { head: { property: decodePropertyId(title.id) }, type: "title", label };
19027
+ }
19028
+ refuse(`${label} \uC740 Notion \uC18D\uC131\uC774 \uC544\uB2C8\uB77C \uC62E\uAE30\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4`);
19029
+ }
19030
+ const prop = Object.hasOwn(schema, ref.name) ? schema[ref.name] : void 0;
19031
+ if (!prop) refuse(`'${ref.name}' \uC740 \uC774 DB \uC758 \uC18D\uC131\uC774 \uC544\uB2D9\uB2C8\uB2E4`);
19032
+ return {
19033
+ head: { property: decodePropertyId(prop.id) },
19034
+ type: prop.type,
19035
+ label: `\uC18D\uC131 '${ref.name}'`
17160
19036
  };
17161
19037
  }
17162
- var ALL_DATABASES = { kind: "all" };
17163
- function selectsDatabase(selection, databaseId) {
17164
- return selection.kind === "all" || selection.ids.has(compactNotionId(databaseId));
19038
+ function isPendingRange(filter) {
19039
+ return typeof filter["pending"] === "object" && filter["pending"] !== null;
17165
19040
  }
17166
- function parsePendingDatabases(raw) {
17167
- if (!raw) return /* @__PURE__ */ new Set();
17168
- try {
17169
- const parsed = JSON.parse(raw);
17170
- if (!Array.isArray(parsed)) return /* @__PURE__ */ new Set();
17171
- return new Set(
17172
- parsed.filter((id) => typeof id === "string").map((id) => compactNotionId(id))
17173
- );
17174
- } catch {
17175
- return /* @__PURE__ */ new Set();
19041
+ function mergeRelativeRanges(items) {
19042
+ const out = [...items];
19043
+ for (const [range, { toward, span }] of Object.entries(RELATIVE_DATE_RANGES)) {
19044
+ const [lowText, highText] = toward === "past" ? [squash(`today() - "${span}"`), "today()"] : ["today()", squash(`today() + "${span}"`)];
19045
+ for (let i = 0; i < out.length; i++) {
19046
+ const low = out[i];
19047
+ if (!isPendingRange(low) || low.pending.symbol !== ">=" || low.pending.text !== lowText)
19048
+ continue;
19049
+ const j = out.findIndex(
19050
+ (high, k) => k !== i && isPendingRange(high) && high.pending.symbol === "<=" && high.pending.text === highText && JSON.stringify(high.pending.target.head) === JSON.stringify(low.pending.target.head)
19051
+ );
19052
+ if (j < 0) continue;
19053
+ const { target } = low.pending;
19054
+ out[i] = { ...target.head, [target.type]: { [range]: {} } };
19055
+ out.splice(j, 1);
19056
+ }
17176
19057
  }
19058
+ return out;
17177
19059
  }
17178
- function serializePendingDatabases(ids) {
17179
- return JSON.stringify([...ids].sort());
19060
+ function squash(text) {
19061
+ return text.replace(/\s+/g, "");
17180
19062
  }
17181
- var DatabasePullLedger = class {
17182
- constructor(selection, pending) {
17183
- this.selection = selection;
17184
- this.pending = pending;
17185
- }
17186
- selection;
17187
- pending;
17188
- settled = /* @__PURE__ */ new Set();
17189
- unfinished = /* @__PURE__ */ new Set();
17190
- skippedCount = 0;
17191
- /** 이제 동기화하는 DB — 알면 대기를 이것으로 좁힌다. */
17192
- syncing = null;
17193
- /** 이 DB 를 조회하나 — 새로 발견한 DB 는 부른 쪽이 따로 조회한다(받은 적이 없다). */
17194
- selects(databaseId) {
17195
- return selectsDatabase(this.selection, databaseId);
17196
- }
17197
- /** 바뀐 것이 보이지 않아 조회하지 않았다. */
17198
- skip() {
17199
- this.skippedCount++;
19063
+ function depthOf(filter) {
19064
+ const children = filter["and"] ?? filter["or"];
19065
+ if (!Array.isArray(children)) return 0;
19066
+ return 1 + Math.max(0, ...children.map(depthOf));
19067
+ }
19068
+
19069
+ // src/sync/base-view-push.ts
19070
+ var PROPERTY_REQUEST_KEYS = [
19071
+ "property_id",
19072
+ "visible",
19073
+ "width",
19074
+ "wrap",
19075
+ "status_show_as",
19076
+ "card_property_width_mode",
19077
+ "date_format",
19078
+ "time_format"
19079
+ ];
19080
+ var REQUIRED_CONFIGURATION_KEYS = ["group_by", "date_property_id"];
19081
+ var BaseViewEdits = class {
19082
+ constructor(stateDb, vaultFs, notionClient) {
19083
+ this.vaultFs = vaultFs;
19084
+ this.notionClient = notionClient;
19085
+ this.bases = new DbBaseFiles(stateDb);
17200
19086
  }
17201
- /** 받았거나 · 이제 없어서 뺐다 — 다시 조회할 것이 없다. */
17202
- settle(databaseId) {
17203
- this.settled.add(compactNotionId(databaseId));
19087
+ vaultFs;
19088
+ notionClient;
19089
+ bases;
19090
+ /** 고친 `.base` 를 찾아 보낼 것을 정한다 — Notion 은 고친 보기만 읽는다. */
19091
+ async plan(inScope = () => true) {
19092
+ const planned = [];
19093
+ const refused = [];
19094
+ const refuse2 = (path, error) => {
19095
+ refused.push({
19096
+ path,
19097
+ operation: "update",
19098
+ error: `.base \uC758 \uBCF4\uAE30\uB97C Notion \uC5D0 \uC62C\uB9AC\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4 \u2014 ${error}`
19099
+ });
19100
+ };
19101
+ for (const [databaseId, base] of this.bases.all()) {
19102
+ if (!base.views || !inScope(base.basePath)) continue;
19103
+ const content = await this.vaultFs.readFile(base.basePath).catch(() => null);
19104
+ if (content === null) continue;
19105
+ const hash = computeHash(content);
19106
+ if (hash === base.hash) continue;
19107
+ const parsed = readBaseFile(content);
19108
+ if (!parsed) {
19109
+ refuse2(base.basePath, "YAML \uB85C \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4");
19110
+ continue;
19111
+ }
19112
+ const matched = matchViews(base.views, parsed.views);
19113
+ const edits = parsed.views.flatMap((view, i) => {
19114
+ const record = matched[i];
19115
+ if (!record) return [];
19116
+ const edited = pushableOf(view);
19117
+ const changed = changedFields(record, edited);
19118
+ return changed.length > 0 ? [{ record, edited, changed }] : [];
19119
+ });
19120
+ if (edits.length === 0) continue;
19121
+ const meta = await this.notionClient.getDatabaseMeta(databaseId);
19122
+ if (meta.dataSources.length > 1) {
19123
+ refuse2(base.basePath, "\uB370\uC774\uD130 \uC18C\uC2A4\uAC00 \uC5EC\uB7FF\uC778 DB \uC758 \uBCF4\uAE30\uB294 Notion \uC5D0\uC11C \uBC14\uAFB8\uC138\uC694");
19124
+ continue;
19125
+ }
19126
+ const schema = await this.notionClient.getDatabaseSchemaFull(databaseId, meta);
19127
+ const views = [];
19128
+ let allPlanned = true;
19129
+ for (const { record, edited, changed } of edits) {
19130
+ const label = `\uBCF4\uAE30 '${record.name}'`;
19131
+ let live;
19132
+ try {
19133
+ live = await this.notionClient.getView(record.viewId);
19134
+ } catch (error) {
19135
+ if (!isNotionObjectNotFound(error)) throw error;
19136
+ live = null;
19137
+ }
19138
+ if (!live) {
19139
+ refuse2(base.basePath, `${label}: Notion \uC5D0\uC11C \uC9C0\uC6B4 \uBCF4\uAE30\uC785\uB2C8\uB2E4`);
19140
+ allPlanned = false;
19141
+ continue;
19142
+ }
19143
+ if (viewFingerprint(live) !== record.notion) {
19144
+ refuse2(
19145
+ base.basePath,
19146
+ `${label}: \uADF8 \uC0AC\uC774 Notion \uC5D0\uC11C\uB3C4 \uC774 \uBCF4\uAE30\uB97C \uACE0\uCCE4\uC2B5\uB2C8\uB2E4. \uACE0\uCE5C \uB0B4\uC6A9\uC744 \uC62E\uACA8 \uC801\uACE0 \uC774 \uD30C\uC77C\uC744 \uC9C0\uC6B4 \uB4A4 pull \uD558\uBA74 \uC0C8\uB85C \uBC1B\uC2B5\uB2C8\uB2E4`
19147
+ );
19148
+ allPlanned = false;
19149
+ continue;
19150
+ }
19151
+ const built = buildRequest(record, edited, changed, live, schema);
19152
+ if (!built.ok) {
19153
+ refuse2(base.basePath, `${label}: ${built.error}`);
19154
+ allPlanned = false;
19155
+ continue;
19156
+ }
19157
+ views.push({
19158
+ viewId: record.viewId,
19159
+ request: built.request,
19160
+ edit: {
19161
+ path: base.basePath,
19162
+ view: edited.name,
19163
+ ...edited.name !== record.name ? { from: record.name } : {},
19164
+ changed
19165
+ },
19166
+ record: {
19167
+ viewId: record.viewId,
19168
+ type: record.type,
19169
+ ...edited,
19170
+ ...record.lossyFilter && !changed.includes("filter") ? { lossyFilter: true } : {},
19171
+ notion: record.notion
19172
+ }
19173
+ });
19174
+ }
19175
+ if (views.length === 0) continue;
19176
+ planned.push({
19177
+ databaseId,
19178
+ path: base.basePath,
19179
+ base,
19180
+ hash,
19181
+ complete: allPlanned && matched.filter(Boolean).length === base.views.length && restSignature(parsed, matched) === base.rest,
19182
+ views
19183
+ });
19184
+ }
19185
+ return { planned, refused };
17204
19186
  }
17205
- /** 조회하다 실패했거나 · 받지 못한 행이 있거나 · 취소로 닿지 못했다 — 다음 pull 이 다시 조회한다. */
17206
- retry(databaseId) {
17207
- this.unfinished.add(compactNotionId(databaseId));
19187
+ async apply(planned) {
19188
+ const applied = [];
19189
+ const failed = [];
19190
+ for (const file of planned) {
19191
+ const records = new Map(file.base.views.map((record) => [record.viewId, record]));
19192
+ let allApplied = true;
19193
+ for (const view of file.views) {
19194
+ try {
19195
+ await this.notionClient.updateView(view.viewId, view.request);
19196
+ } catch (error) {
19197
+ allApplied = false;
19198
+ failed.push({
19199
+ path: file.path,
19200
+ operation: "update",
19201
+ error: `\uBCF4\uAE30 '${view.edit.view}' \uB97C Notion \uC5D0 \uC62C\uB9AC\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: ${error instanceof Error ? error.message : String(error)}`
19202
+ });
19203
+ continue;
19204
+ }
19205
+ applied.push(view.edit);
19206
+ const after = await this.notionClient.getView(view.viewId).catch(() => null);
19207
+ records.set(view.viewId, {
19208
+ ...view.record,
19209
+ notion: after ? viewFingerprint(after) : view.record.notion
19210
+ });
19211
+ }
19212
+ this.bases.set(file.databaseId, {
19213
+ ...file.base,
19214
+ views: file.base.views.map((record) => records.get(record.viewId) ?? record),
19215
+ // 고친 것을 모두 올렸으면 이 파일은 이제 pull 이 쓴 것과 같은 뜻이다 — 다음 pull 이 Notion 의 모습으로 다시 쓴다.
19216
+ // 남은 것이 있으면 계속 붙잡아 둔다.
19217
+ ...file.complete && allApplied ? { hash: file.hash } : {}
19218
+ });
19219
+ }
19220
+ return { applied, failed };
17208
19221
  }
17209
- get skipped() {
17210
- return this.skippedCount;
19222
+ };
19223
+ function buildRequest(record, edited, changed, live, schema) {
19224
+ const request = {};
19225
+ try {
19226
+ if (changed.includes("name")) {
19227
+ if (!edited.name.trim()) throw new Error("\uBCF4\uAE30 \uC774\uB984\uC774 \uBE44\uC5C8\uC2B5\uB2C8\uB2E4");
19228
+ request.name = edited.name;
19229
+ }
19230
+ if (changed.includes("columns")) request.configuration = columnsOf(edited, live, schema);
19231
+ if (changed.includes("sort")) {
19232
+ request.sorts = edited.sort ? edited.sort.map((s) => ({
19233
+ property: propertyIdOf(s.property, schema, "\uC815\uB82C"),
19234
+ direction: s.direction === "DESC" ? "descending" : "ascending"
19235
+ })) : null;
19236
+ }
19237
+ if (changed.includes("filter")) {
19238
+ if (record.lossyFilter) {
19239
+ throw new Error(
19240
+ "Notion \uD544\uD130\uC5D0 \uBCFC\uD2B8\uB85C \uC62E\uAE30\uC9C0 \uBABB\uD55C \uC870\uAC74\uC774 \uC788\uC5B4 \uBCFC\uD2B8\uC758 \uD544\uD130\uB85C \uB36E\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uD544\uD130\uB294 Notion \uC5D0\uC11C \uACE0\uCE58\uC138\uC694"
19241
+ );
19242
+ }
19243
+ if (edited.filters === void 0) {
19244
+ request.filter = null;
19245
+ } else {
19246
+ const converted = basesFilterToNotion(edited.filters, schema);
19247
+ if (!converted.ok) throw new Error(`\uD544\uD130 \u2014 ${converted.error}`);
19248
+ request.filter = converted.filter;
19249
+ }
19250
+ }
19251
+ } catch (error) {
19252
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
17211
19253
  }
17212
- /**
17213
- * 이제 동기화하는 DB 를 알린다 — 대기를 이것으로 좁힌다. 설정에서 뺐거나 접근 불가로 뺀 DB 를 영영
17214
- * 들고 있지 않는다. 알리지 않으면(발견이 멈춤 등) 좁히지 않는다 — 모르는 것을 버리지 않는다.
17215
- */
17216
- keepOnly(databaseIds) {
17217
- this.syncing = new Set([...databaseIds].map((id) => compactNotionId(id)));
19254
+ return { ok: true, request };
19255
+ }
19256
+ function columnsOf(edited, live, schema) {
19257
+ if (!edited.order) throw new Error("\uC5F4 \uBAA9\uB85D(order)\uC744 \uC9C0\uC6B4 \uBCF4\uAE30\uB294 \uC62C\uB9AC\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
19258
+ const configuration = live.configuration ?? {};
19259
+ const liveEntries = Array.isArray(configuration["properties"]) ? configuration["properties"] : [];
19260
+ const entryOf = (id) => {
19261
+ const found2 = liveEntries.find(
19262
+ (entry) => decodePropertyId(String(entry["property_id"])) === id
19263
+ );
19264
+ const out = { property_id: id };
19265
+ for (const key of PROPERTY_REQUEST_KEYS) {
19266
+ if (found2 && key !== "property_id" && found2[key] !== void 0 && found2[key] !== null) {
19267
+ out[key] = found2[key];
19268
+ }
19269
+ }
19270
+ return out;
19271
+ };
19272
+ const title = Object.values(schema).find((prop) => prop.type === "title");
19273
+ const shown = [];
19274
+ for (const column of edited.order) {
19275
+ if (column === BASES_TITLE) continue;
19276
+ if (isRangeEnd(column, schema)) continue;
19277
+ const id = propertyIdOf(column, schema, "\uC5F4");
19278
+ if (title && id === decodePropertyId(title.id)) continue;
19279
+ if (!shown.includes(id)) shown.push(id);
19280
+ }
19281
+ const titleId = title ? decodePropertyId(title.id) : void 0;
19282
+ const properties = [
19283
+ ...titleId ? [entryOf(titleId)] : [],
19284
+ ...shown.map((id) => ({ ...entryOf(id), visible: true })),
19285
+ ...liveEntries.map((entry) => decodePropertyId(String(entry["property_id"]))).filter((id) => id !== titleId && !shown.includes(id)).map((id) => ({ ...entryOf(id), visible: false }))
19286
+ ];
19287
+ return {
19288
+ type: configuration["type"] ?? live.type,
19289
+ ...Object.fromEntries(
19290
+ REQUIRED_CONFIGURATION_KEYS.filter((key) => configuration[key] !== void 0).map((key) => [
19291
+ key,
19292
+ configuration[key]
19293
+ ])
19294
+ ),
19295
+ properties
19296
+ };
19297
+ }
19298
+ function propertyIdOf(column, schema, where) {
19299
+ if (column === BASES_TITLE) {
19300
+ const title = Object.values(schema).find((prop2) => prop2.type === "title");
19301
+ if (title) return decodePropertyId(title.id);
17218
19302
  }
17219
- /** 다음 pull 의 대기 — 지난 대기에서 받은 것을 빼고 이번에 받지 못한 것을 더한다. */
17220
- nextPending() {
17221
- const next = new Set([...this.pending].filter((id) => !this.settled.has(id)));
17222
- for (const id of this.unfinished) next.add(id);
17223
- const syncing = this.syncing;
17224
- return syncing ? new Set([...next].filter((id) => syncing.has(id))) : next;
19303
+ if (notionTimestampOf(column)) {
19304
+ throw new Error(
19305
+ `${where} '${column}' \u2014 Notion \uBCF4\uAE30\uB294 \uB9CC\uB4E0 \xB7 \uACE0\uCE5C \uC2DC\uAC01 \uC815\uB82C\uC744 API \uB85C \uBC1B\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. Notion \uC5D0\uC11C \uBC14\uAFB8\uC138\uC694`
19306
+ );
17225
19307
  }
17226
- };
19308
+ const prop = Object.hasOwn(schema, column) ? schema[column] : void 0;
19309
+ if (!prop) throw new Error(`${where} '${column}' \uC740 \uC774 DB \uC758 Notion \uC18D\uC131\uC774 \uC544\uB2D9\uB2C8\uB2E4`);
19310
+ return decodePropertyId(prop.id);
19311
+ }
19312
+ function isRangeEnd(column, schema) {
19313
+ if (!column.endsWith(RANGE_END_SUFFIX) || Object.hasOwn(schema, column)) return false;
19314
+ const start = column.slice(0, -RANGE_END_SUFFIX.length);
19315
+ return Object.hasOwn(schema, start) && schema[start].type === "date";
19316
+ }
17227
19317
 
17228
19318
  // src/sync/row-schema-cache.ts
17229
19319
  var RowSchemaCache = class {
17230
- constructor(loadSchema, wikilinkResolver, createMapper = () => new PropertyMapper(), loadPeople) {
19320
+ constructor(loadSchema, wikilinkResolver, createMapper = () => new PropertyMapper(), loadPeople, loadRowSource = async () => null) {
17231
19321
  this.loadSchema = loadSchema;
17232
19322
  this.wikilinkResolver = wikilinkResolver;
17233
19323
  this.createMapper = createMapper;
17234
19324
  this.loadPeople = loadPeople;
19325
+ this.loadRowSource = loadRowSource;
17235
19326
  }
17236
19327
  loadSchema;
17237
19328
  wikilinkResolver;
17238
19329
  createMapper;
17239
19330
  loadPeople;
19331
+ loadRowSource;
17240
19332
  mappers = /* @__PURE__ */ new Map();
19333
+ rowSources = /* @__PURE__ */ new Map();
17241
19334
  people = null;
17242
- mapperFor(databaseId) {
17243
- const key = compactNotionId(databaseId);
19335
+ /** @param dataSourceId 행이 사는 data source — 주면 그 소스의 스키마로 옮긴다. */
19336
+ mapperFor(databaseId, dataSourceId) {
19337
+ const key = `${compactNotionId(databaseId)}:${dataSourceId ? compactNotionId(dataSourceId) : ""}`;
17244
19338
  const cached = this.mappers.get(key);
17245
19339
  if (cached) return cached;
17246
- const created = this.loadSchema(databaseId).then(async (schema) => {
19340
+ const created = this.loadSchema(databaseId, dataSourceId ?? void 0).then(async (schema) => {
17247
19341
  const mapper = this.createMapper();
17248
19342
  mapper.setWikilinkResolver(this.wikilinkResolver);
17249
19343
  mapper.loadSchema(schema);
@@ -17256,8 +19350,24 @@ var RowSchemaCache = class {
17256
19350
  });
17257
19351
  return created;
17258
19352
  }
19353
+ /**
19354
+ * 새 행을 둘 data source — 데이터 소스가 여럿인 DB 는 첫 소스. 그런 DB 에 DB 를 부모로 행을 만들면 Notion 이 400 으로
19355
+ * 거절한다(2026-10-29 실측: «Databases with multiple data sources are not supported in this API version»).
19356
+ */
19357
+ rowSourceFor(databaseId) {
19358
+ const key = compactNotionId(databaseId);
19359
+ const cached = this.rowSources.get(key);
19360
+ if (cached) return cached;
19361
+ const loaded = this.loadRowSource(databaseId);
19362
+ this.rowSources.set(key, loaded);
19363
+ loaded.catch(() => {
19364
+ if (this.rowSources.get(key) === loaded) this.rowSources.delete(key);
19365
+ });
19366
+ return loaded;
19367
+ }
17259
19368
  clear() {
17260
19369
  this.mappers.clear();
19370
+ this.rowSources.clear();
17261
19371
  this.people = null;
17262
19372
  }
17263
19373
  /** 사용자 목록을 못 읽으면 이름은 풀지 않는다 — id 로 적힌 사람만 보낸다(예전 동작). */
@@ -17539,8 +19649,7 @@ var InterruptedSyncRecovery = class {
17539
19649
  if (isNotionObjectNotFound(error)) continue;
17540
19650
  throw error;
17541
19651
  }
17542
- const inTrash = page.in_trash === true;
17543
- if (inTrash || page.archived) continue;
19652
+ if (isTrashedOrArchived(page)) continue;
17544
19653
  return page;
17545
19654
  }
17546
19655
  return null;
@@ -17559,8 +19668,7 @@ var InterruptedSyncRecovery = class {
17559
19668
  });
17560
19669
  for (const row of rows) {
17561
19670
  if (this.stateDb.getByNotionId(row.id)) continue;
17562
- const inTrash = row.in_trash === true;
17563
- if (inTrash || row.archived) continue;
19671
+ if (isTrashedOrArchived(row)) continue;
17564
19672
  return row;
17565
19673
  }
17566
19674
  return null;
@@ -17807,7 +19915,7 @@ var DatabaseDiscovery = class {
17807
19915
  failed.push({
17808
19916
  path: pathOf(parentPageId),
17809
19917
  operation: "create",
17810
- error: `DB ${compactNotionId(dbId).slice(0, 8)} \uB97C \uC77D\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uBCF8\uB2E4: ${error}`
19918
+ error: `DB ${shortNotionId(dbId)} \uB97C \uC77D\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uBCF8\uB2E4: ${error}`
17811
19919
  });
17812
19920
  };
17813
19921
  const register = async (dbId, parentPageId) => {
@@ -17857,10 +19965,11 @@ var DatabaseDiscovery = class {
17857
19965
  nextRetry.parents.push(...retry.parents);
17858
19966
  nextRetry.dbs.push(...retry.dbs);
17859
19967
  } else {
19968
+ const root = this.config.notion.rootPageId;
17860
19969
  const scanIds = scanAll ? [
17861
- this.config.notion.rootPageId,
19970
+ root,
17862
19971
  ...this.stateDb.getAll().filter((r) => r.notionPageId).map((r) => r.notionPageId)
17863
- ].filter((id) => !this._bodyReadPages.has(normalizeNotionId(id))) : retry.parents;
19972
+ ].filter((id) => !this._bodyReadPages.has(normalizeNotionId(id))) : options.rescanRoot && !retry.parents.some((id) => notionIdsEqual(id, root)) ? [root, ...retry.parents] : retry.parents;
17864
19973
  if (scanIds.length > 0) {
17865
19974
  const { found: found2, unread } = await this.discoverChildDatabases(scanIds);
17866
19975
  for (const { dbId, parentPageId } of found2) {
@@ -18059,7 +20168,7 @@ var DatabaseDiscovery = class {
18059
20168
  }
18060
20169
  let safeName;
18061
20170
  if (dbTitle) {
18062
- safeName = dbTitle.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-");
20171
+ safeName = databaseFolderName(dbTitle);
18063
20172
  } else {
18064
20173
  const parentName = parentFolder.split("/").pop() || "";
18065
20174
  safeName = parentName ? `${parentName}-DB` : `db-${dbId.replace(/-/g, "").slice(0, 8)}`;
@@ -18152,6 +20261,12 @@ var DatabaseDiscovery = class {
18152
20261
  return refs;
18153
20262
  }
18154
20263
  };
20264
+ function databaseFolderName(title) {
20265
+ return truncateBytes(
20266
+ title.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-"),
20267
+ FILE_SYSTEM_NAME_MAX_BYTES
20268
+ );
20269
+ }
18155
20270
 
18156
20271
  // src/sync/folder-placement.ts
18157
20272
  var FolderPlacement = class {
@@ -18975,7 +21090,7 @@ var RemoteRenameFollower = class {
18975
21090
  /** 바꿀 것이 없으면 null. */
18976
21091
  async followOne(record, dryRun) {
18977
21092
  const page = await this.pageOf(record.notionPageId);
18978
- if (page.in_trash || page.archived) return null;
21093
+ if (isTrashedOrArchived(page)) return null;
18979
21094
  const title = this.notionClient.extractTitle(page);
18980
21095
  if (!retitledInNotion(...await this.frontmatters(record), record.obsidianPath, title)) {
18981
21096
  return null;
@@ -19116,7 +21231,7 @@ function rewriteTarget(target, source, context, kind) {
19116
21231
  const shown = keepExtension ? destination : destination.slice(0, -".md".length);
19117
21232
  let next;
19118
21233
  if (resolved.relative) {
19119
- next = relativePath(folderOf3(source.to), shown);
21234
+ next = relativePath(folderOf5(source.to), shown);
19120
21235
  if (!linkpath.startsWith("./") && next.startsWith("./")) next = next.slice(2);
19121
21236
  } else if (style === "name") {
19122
21237
  const name = baseName3(shown);
@@ -19138,14 +21253,14 @@ function resolveLinkpath(linkpath, sourcePath, files) {
19138
21253
  const matches = byName(files, baseName3(candidate)).filter((file) => file.endsWith(suffix));
19139
21254
  if (matches.length === 1) return found(matches[0], linkpath);
19140
21255
  if (matches.length > 1) {
19141
- const local = `${folderPrefix(folderOf3(sourcePath))}${candidate}`;
21256
+ const local = `${folderPrefix(folderOf5(sourcePath))}${candidate}`;
19142
21257
  return matches.includes(local) ? found(local, linkpath) : null;
19143
21258
  }
19144
21259
  }
19145
21260
  return null;
19146
21261
  }
19147
21262
  function resolveRelative(linkpath, sourcePath, files) {
19148
- const joined = normalizeSegments(`${folderPrefix(folderOf3(sourcePath))}${linkpath}`);
21263
+ const joined = normalizeSegments(`${folderPrefix(folderOf5(sourcePath))}${linkpath}`);
19149
21264
  if (joined === null) return null;
19150
21265
  for (const candidate of withExtension(joined)) {
19151
21266
  if (files.has(candidate)) return { ...found(candidate, linkpath), relative: true };
@@ -19204,7 +21319,7 @@ function relativePath(from, to) {
19204
21319
  function encodeMarkdownTarget(next, original) {
19205
21320
  return original.includes("%") || next.includes(" ") ? next.replace(/ /g, "%20") : next;
19206
21321
  }
19207
- function folderOf3(path) {
21322
+ function folderOf5(path) {
19208
21323
  const slash = path.lastIndexOf("/");
19209
21324
  return slash === -1 ? "" : path.slice(0, slash);
19210
21325
  }
@@ -20155,15 +22270,15 @@ var DatabaseCreator = class {
20155
22270
  */
20156
22271
  async addStatus(dataSourceId, status, folder) {
20157
22272
  const groupOf = /* @__PURE__ */ new Map();
20158
- for (const group of status.groups ?? []) {
20159
- for (const option of group.options) groupOf.set(option, group.name);
22273
+ for (const group2 of status.groups ?? []) {
22274
+ for (const option of group2.options) groupOf.set(option, group2.name);
20160
22275
  }
20161
22276
  const unknown = [];
20162
22277
  const options = status.options.map((option) => {
20163
- const group = groupOf.get(option.name);
20164
- if (group === void 0) return option;
20165
- if (NOTION_STATUS_GROUPS.includes(group)) return { ...option, group };
20166
- unknown.push(`${option.name}(${group})`);
22278
+ const group2 = groupOf.get(option.name);
22279
+ if (group2 === void 0) return option;
22280
+ if (NOTION_STATUS_GROUPS.includes(group2)) return { ...option, group: group2 };
22281
+ unknown.push(`${option.name}(${group2})`);
20167
22282
  return option;
20168
22283
  });
20169
22284
  await this.notionClient.updateDataSourceProperties(dataSourceId, {
@@ -20473,9 +22588,7 @@ var DatabaseCreator = class {
20473
22588
  discovered.push({ databaseId, localFolder: folder, titleProperty: titleProperty2 });
20474
22589
  this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(discovered));
20475
22590
  }
20476
- const pending = parsePendingDatabases(this.stateDb.getMeta(PENDING_DATABASES_META_KEY));
20477
- pending.add(compactNotionId(databaseId));
20478
- this.stateDb.setMeta(PENDING_DATABASES_META_KEY, serializePendingDatabases(pending));
22591
+ addPendingDatabases(this.stateDb, [databaseId]);
20479
22592
  }
20480
22593
  async readSidecar(path) {
20481
22594
  try {
@@ -20780,6 +22893,7 @@ var PagePusher = class {
20780
22893
  * (ADR-017). relation 을 보내면 가리킨 행의 반대쪽 relation 이, 행을 만들면 그 행의 수식 · ID 가 바뀐다.
20781
22894
  */
20782
22895
  markRemoteUnverified(pageIds) {
22896
+ const databases = [];
20783
22897
  for (const pageId of new Set(pageIds)) {
20784
22898
  const target = this.stateDb.getByNotionId(pageId);
20785
22899
  if (!target?.notionLastEdited) continue;
@@ -20788,6 +22902,76 @@ var PagePusher = class {
20788
22902
  lastEditedBy: null,
20789
22903
  seenAt: null
20790
22904
  });
22905
+ if (target.fileType === "db-row" && target.notionParentId) {
22906
+ databases.push(target.notionParentId);
22907
+ }
22908
+ }
22909
+ addPendingDatabases(this.stateDb, databases);
22910
+ }
22911
+ /** 이번 실행에서 볼트의 값을 올리지 않은 읽기 전용 속성 — 노트 경로별. */
22912
+ readOnlySkips = /* @__PURE__ */ new Map();
22913
+ /** 올리지 않은 읽기 전용 속성을 꺼낸다(목록은 비운다). */
22914
+ takeReadOnlySkips() {
22915
+ const skips = [...this.readOnlySkips].sort(([a], [b]) => a.localeCompare(b)).map(([path, properties]) => ({ path, properties }));
22916
+ this.readOnlySkips.clear();
22917
+ return skips;
22918
+ }
22919
+ /**
22920
+ * dry-run 이 알릴 읽기 전용 속성 — 실제 push 처럼 볼트에서 바꾼 행 속성(새 행은 모든 속성) 가운데 Notion 이 값을
22921
+ * 정하는 것. 원격은 읽지 않고 지난 동기화 사본과 견준다 — 원격도 바뀌었으면 실제 push 와 다를 수 있다. 사본이
22922
+ * 없거나 frontmatter · 스키마를 읽지 못하면 그 노트는 건너뛴다(실제 push 가 이유와 함께 알린다).
22923
+ */
22924
+ async previewReadOnlySkips(changes) {
22925
+ const skips = [];
22926
+ for (const change of changes) {
22927
+ const row = this.previewRow(change);
22928
+ if (!row) continue;
22929
+ try {
22930
+ const current = parseFrontmatter(await this.vaultFs.readFile(change.path));
22931
+ const { changed, cleared } = diffRowProperties(row.base, current.data);
22932
+ const mapper = await this.rowSchemas.mapperFor(row.databaseId);
22933
+ const readOnly = mapper.readOnlyKeys(
22934
+ withoutDecorations([...Object.keys(changed), ...cleared])
22935
+ );
22936
+ if (readOnly.length > 0) skips.push({ path: change.path, properties: readOnly });
22937
+ } catch {
22938
+ continue;
22939
+ }
22940
+ }
22941
+ return skips.sort((a, b) => a.path.localeCompare(b.path));
22942
+ }
22943
+ /** dry-run 이 견줄 행 — 새 행은 기준 없이, 고친 행은 지난 동기화 사본과. 행이 아니거나 사본이 없으면 null. */
22944
+ previewRow(change) {
22945
+ if (change.type === "created") {
22946
+ const databaseId2 = this.placement.newRowDatabaseOf(change.path);
22947
+ return databaseId2 ? { databaseId: databaseId2, base: null } : null;
22948
+ }
22949
+ if (change.type !== "modified") return null;
22950
+ const record = this.stateDb.getByPath(change.path);
22951
+ const databaseId = record ? rowDatabaseOf(this.config, record) : null;
22952
+ const base = record && databaseId ? this.baseRowState(record) : null;
22953
+ return databaseId && base ? { databaseId, base: base.properties } : null;
22954
+ }
22955
+ /**
22956
+ * 보내지 않은 속성을 알린다. 읽기 전용 속성은 경고로 따로 모은다 — 다음 pull 이 볼트의 값을 Notion 값으로 되돌리니
22957
+ * 고친 사람이 알아야 한다. 나머지(DB 에 없는 속성 · 변환 불가 …)는 예전처럼 정보 로그다.
22958
+ */
22959
+ reportUnsent(path, mapper, skipped, what) {
22960
+ const unsent = withoutDecorations(skipped);
22961
+ const readOnly = mapper.readOnlyKeys(unsent);
22962
+ if (readOnly.length > 0) {
22963
+ this.readOnlySkips.set(path, readOnly);
22964
+ getLogger().warn(
22965
+ `[Im-Nobsidian] \uC77D\uAE30 \uC804\uC6A9 \uC18D\uC131 ${readOnly.length}\uAC1C\uB294 Notion \uC774 \uAC12\uC744 \uC815\uD574 \uC62C\uB9AC\uC9C0 \uC54A\uC74C \u2014 \uB2E4\uC74C pull \uC774 Notion \uAC12\uC73C\uB85C \uB418\uB3CC\uB9B0\uB2E4: ${path} \u2014 ${readOnly.join(", ")}`
22966
+ );
22967
+ } else {
22968
+ this.readOnlySkips.delete(path);
22969
+ }
22970
+ const others = unsent.filter((key) => !readOnly.includes(key));
22971
+ if (others.length > 0) {
22972
+ getLogger().info(
22973
+ `[Im-Nobsidian] ${what} ${others.length}\uAC1C\uB294 \uBCF4\uB0B4\uC9C0 \uC54A\uC74C(DB \uC5D0 \uC5C6\uB294 \uC18D\uC131 \xB7 \uBE44\uC6B8 \uC218 \uC5C6\uB294 \uC18D\uC131 \xB7 \uBCC0\uD658 \uBD88\uAC00): ${path} \u2014 ${others.join(", ")}`
22974
+ );
20791
22975
  }
20792
22976
  }
20793
22977
  /** relation 을 보내지 못한 행을 꺼낸다(목록은 비운다). */
@@ -20812,7 +22996,7 @@ var PagePusher = class {
20812
22996
  const selectedPath = this.pipeline.selectPath(content);
20813
22997
  if (selectedPath === "block-api") {
20814
22998
  getLogger().warn(
20815
- `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column/toggle) \u2014 converted with reduced fidelity in v0.1.0`
22999
+ `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column) \u2014 converted with reduced fidelity in v0.1.0`
20816
23000
  );
20817
23001
  }
20818
23002
  const conversionResult = this.pipeline.convertToNotion(content, {
@@ -20956,7 +23140,7 @@ var PagePusher = class {
20956
23140
  const updatePath = this.pipeline.selectPath(content);
20957
23141
  if (updatePath === "block-api") {
20958
23142
  getLogger().warn(
20959
- `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column/toggle) \u2014 converted with reduced fidelity in v0.1.0`
23143
+ `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column) \u2014 converted with reduced fidelity in v0.1.0`
20960
23144
  );
20961
23145
  }
20962
23146
  const conversionResult = this.pipeline.convertToNotion(content, {
@@ -20987,7 +23171,7 @@ var PagePusher = class {
20987
23171
  bodyFingerprint = await this.drift.remoteBodyFingerprintOf(record.notionPageId);
20988
23172
  }
20989
23173
  const newTitle = this.changedPageTitle(record, content, path);
20990
- const propsToUpdate = newTitle === null ? void 0 : titleProperty(newTitle);
23174
+ const propsToUpdate = newTitle === null ? void 0 : titleProperty(newTitle, pagePropertiesOf(remote));
20991
23175
  const decorated = await this.decorations.push(
20992
23176
  record.notionPageId,
20993
23177
  path,
@@ -21164,22 +23348,18 @@ var PagePusher = class {
21164
23348
  );
21165
23349
  }
21166
23350
  const title = noteTitle(current.data, path);
21167
- const mapper = await this.rowSchemas.mapperFor(databaseId);
23351
+ const source = await this.rowSchemas.rowSourceFor(databaseId);
23352
+ const mapper = await this.rowSchemas.mapperFor(databaseId, source);
21168
23353
  const changes = diffRowProperties(null, current.data);
21169
23354
  const { properties, skipped } = mapper.toNotionPropertyChanges(changes, title, {
21170
23355
  current: current.data,
21171
23356
  uploads: await this.uploadsFor(mapper, changes.changed, path)
21172
23357
  });
21173
- const unsentProperties = withoutDecorations(skipped);
21174
- if (unsentProperties.length > 0) {
21175
- getLogger().info(
21176
- `[Im-Nobsidian] \uC0C8 \uD589 \uC18D\uC131 ${unsentProperties.length}\uAC1C\uB294 \uBCF4\uB0B4\uC9C0 \uC54A\uC74C(DB \uC5D0 \uC5C6\uB294 \uC18D\uC131 \xB7 \uC77D\uAE30 \uC804\uC6A9 \xB7 \uBCC0\uD658 \uBD88\uAC00): ${path} \u2014 ${unsentProperties.join(", ")}`
21177
- );
21178
- }
23358
+ this.reportUnsent(path, mapper, skipped, "\uC0C8 \uD589 \uC18D\uC131");
21179
23359
  const selectedPath = this.pipeline.selectPath(content);
21180
23360
  if (selectedPath === "block-api") {
21181
23361
  getLogger().warn(
21182
- `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column/toggle) \u2014 converted with reduced fidelity in v0.1.0`
23362
+ `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column) \u2014 converted with reduced fidelity in v0.1.0`
21183
23363
  );
21184
23364
  }
21185
23365
  const conversionResult = this.pipeline.convertToNotion(content, {
@@ -21225,7 +23405,8 @@ var PagePusher = class {
21225
23405
  title,
21226
23406
  conversionResult.content,
21227
23407
  properties,
21228
- { path, frontmatter: current.data }
23408
+ { path, frontmatter: current.data },
23409
+ source
21229
23410
  );
21230
23411
  const { page } = created;
21231
23412
  const mapping = this.stateDb.upsert({
@@ -21357,8 +23538,8 @@ var PagePusher = class {
21357
23538
  `frontmatter \uB97C \uC77D\uC9C0 \uBABB\uD574 \uD589\uC744 \uBCF4\uB0B4\uC9C0 \uC54A\uC74C (${path}): ${error instanceof Error ? error.message : String(error)}`
21358
23539
  );
21359
23540
  }
21360
- const mapper = await this.rowSchemas.mapperFor(databaseId);
21361
23541
  const remote = await this.livePage(record);
23542
+ const mapper = await this.rowSchemas.mapperFor(databaseId, dataSourceIdOf(remote));
21362
23543
  const drift = options.overwriteRemote ? "none" : await this.drift.remoteDrift(record, remote);
21363
23544
  const against = options.overwriteRemote ? this.remoteRowState(mapper, remote) : this.baseRowState(record);
21364
23545
  const title = noteTitle(current.data, path);
@@ -21378,12 +23559,7 @@ var PagePusher = class {
21378
23559
  ...against === null || options.overwriteRemote ? {} : { uploads: await this.uploadsFor(mapper, changes.changed, path) }
21379
23560
  }
21380
23561
  );
21381
- const unsentProperties = withoutDecorations(skipped);
21382
- if (unsentProperties.length > 0) {
21383
- getLogger().info(
21384
- `[Im-Nobsidian] \uD589 \uC18D\uC131 ${unsentProperties.length}\uAC1C\uB294 \uBCF4\uB0B4\uC9C0 \uC54A\uC74C(DB \uC5D0 \uC5C6\uB294 \uC18D\uC131 \xB7 \uC77D\uAE30 \uC804\uC6A9 \xB7 \uBE44\uC6B8 \uC218 \uC5C6\uB294 \uC18D\uC131 \xB7 \uBCC0\uD658 \uBD88\uAC00): ${path} \u2014 ${unsentProperties.join(", ")}`
21385
- );
21386
- }
23562
+ this.reportUnsent(path, mapper, skipped, "\uD589 \uC18D\uC131");
21387
23563
  const bodyChanged = against?.body == null || against.body !== current.body;
21388
23564
  const settles = drift === "none";
21389
23565
  if (bodyChanged && !this.drift.overwritesRemote(record, options.overwriteRemote)) {
@@ -21395,7 +23571,7 @@ var PagePusher = class {
21395
23571
  const selectedPath = this.pipeline.selectPath(content);
21396
23572
  if (selectedPath === "block-api") {
21397
23573
  getLogger().warn(
21398
- `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column/toggle) \u2014 converted with reduced fidelity in v0.1.0`
23574
+ `[Im-Nobsidian] "${path}" contains block-api features (inline-db/column) \u2014 converted with reduced fidelity in v0.1.0`
21399
23575
  );
21400
23576
  }
21401
23577
  conversionResult = this.pipeline.convertToNotion(content, {
@@ -21473,6 +23649,8 @@ var PagePusher = class {
21473
23649
  aliases: extractAliases(current.data)
21474
23650
  });
21475
23651
  });
23652
+ if (mapper.readOnlyKeys(withoutDecorations(skipped)).length > 0)
23653
+ this.markRemoteUnverified([pageId]);
21476
23654
  }
21477
23655
  /**
21478
23656
  * 지난 동기화 시점의 행. 사본이 없거나 읽지 못하면 null — 호출측은 비교할 기준이 없다고
@@ -21517,7 +23695,10 @@ var PagePusher = class {
21517
23695
  const title = change.title ? change.title(this.notionClient.extractTitle(remote)) : null;
21518
23696
  if (newParent !== null) await this.notionClient.movePage(pageId, newParent);
21519
23697
  if (title !== null) {
21520
- written = await this.notionClient.updatePageProperties(pageId, titleProperty(title));
23698
+ written = await this.notionClient.updatePageProperties(
23699
+ pageId,
23700
+ titleProperty(title, pagePropertiesOf(remote))
23701
+ );
21521
23702
  } else if (newParent !== null) {
21522
23703
  written = await this.notionClient.getPage(pageId);
21523
23704
  }
@@ -21533,7 +23714,8 @@ var PagePusher = class {
21533
23714
  * 버린 맨 앞 `# H1` 을 호출측이 매핑을 적은 뒤 되살린다({@link restoreCreatedHeading}). 블록으로
21534
23715
  * 보냈으면 null 이다 — 블록은 보낸 그대로 생긴다.
21535
23716
  */
21536
- async pushCreatePage(parentId, parentType, title, markdownContent, properties, note) {
23717
+ async pushCreatePage(parentId, parentType, title, markdownContent, properties, note, dataSourceId) {
23718
+ const parentSource = dataSourceId ? { dataSourceId } : {};
21537
23719
  const decorate2 = async (page2) => await this.decorations.push(page2.id, note.path, note.frontmatter, {}) ?? page2;
21538
23720
  if (this.config.conversion.preferMarkdownApi !== false) {
21539
23721
  const deferred = deferSyncedReferences(obsidianToNotionEnhanced(markdownContent));
@@ -21548,7 +23730,8 @@ var PagePusher = class {
21548
23730
  parentType,
21549
23731
  title,
21550
23732
  markdown,
21551
- properties
23733
+ properties,
23734
+ ...parentSource
21552
23735
  })
21553
23736
  );
21554
23737
  return { page: page2, markdown, syncedRefs: deferred.refs, syncedRefText: deferred.removed };
@@ -21559,7 +23742,8 @@ var PagePusher = class {
21559
23742
  parentId,
21560
23743
  parentType,
21561
23744
  title,
21562
- properties
23745
+ properties,
23746
+ ...parentSource
21563
23747
  })
21564
23748
  );
21565
23749
  if (blocks.length > 0) {
@@ -21863,6 +24047,8 @@ var PagePuller = class {
21863
24047
  // 제목이 같은 새 페이지끼리 경로를 고르는 차례({@link orderCreates}) — 페이지 → 앞 차례가 경로를 고르면
21864
24048
  // 풀리는 약속과 내 차례를 끝내는 함수.
21865
24049
  claimTurns = /* @__PURE__ */ new Map();
24050
+ // Notion 에서 사라져 볼트에서 지운 페이지의 하위 폴더 — pull 이 마칠 때 비었으면 지운다({@link removeVacatedFolders}).
24051
+ vacatedFolders = /* @__PURE__ */ new Set();
21866
24052
  decorationsCache;
21867
24053
  get decorations() {
21868
24054
  return this.decorationsCache ??= new PageDecorations(this.notionClient, this.imageHandler);
@@ -21877,6 +24063,35 @@ var PagePuller = class {
21877
24063
  this.claimTurns.clear();
21878
24064
  this._pullImageCount = 0;
21879
24065
  this._pullFileCount = 0;
24066
+ this.vacatedFolders.clear();
24067
+ }
24068
+ /**
24069
+ * 이번 pull 이 지운 페이지의 폴더가 비었으면 지운다 — 안쪽부터, 그래야 바깥이 빈다. 예전에는 노트만 지우고
24070
+ * 폴더를 남겨 볼트에 빈 폴더가 쌓였다. 사용자 파일 · 숨김 파일이 하나라도 남은 폴더는 둔다
24071
+ * ({@link VaultFS.removeEmptyFolder}).
24072
+ */
24073
+ async removeVacatedFolders() {
24074
+ const folders = [...this.vacatedFolders].sort(
24075
+ (a, b) => b.split("/").length - a.split("/").length
24076
+ );
24077
+ this.vacatedFolders.clear();
24078
+ for (const folder of folders) {
24079
+ try {
24080
+ await this.vaultFs.removeEmptyFolder?.(folder);
24081
+ } catch (error) {
24082
+ getLogger().warn(`[Im-Nobsidian] \uBE48 \uD3F4\uB354 ${folder} \uB97C \uC9C0\uC6B0\uC9C0 \uBABB\uD568:`, error);
24083
+ }
24084
+ }
24085
+ }
24086
+ /**
24087
+ * 원격에서 사라진 페이지의 하위 폴더를 적어 둔다 — 그 아래 페이지도 함께 사라졌으면 pull 끝에 빈다. 볼트 맨 위와
24088
+ * DB 폴더는 적지 않는다 — DB 는 페이지가 아니다.
24089
+ */
24090
+ noteVacatedFolder(record) {
24091
+ const lookup = this.placement.folderLookup();
24092
+ if (isFolderRecord(record) && lookup.databaseAt(record.obsidianPath)) return;
24093
+ const folder = childFolderOf(record, lookup);
24094
+ if (folder !== "" && !lookup.databaseAt(folder)) this.vacatedFolders.add(folder);
21880
24095
  }
21881
24096
  /** 이번 pull 이 내려받은 이미지 수. */
21882
24097
  get imageCount() {
@@ -22056,7 +24271,7 @@ var PagePuller = class {
22056
24271
  if (!folderNote) return { action: "unchanged", path: record.obsidianPath };
22057
24272
  record = folderNote;
22058
24273
  }
22059
- const remoteGone = page.in_trash === true || page.archived === true;
24274
+ const remoteGone = isTrashedOrArchived(page);
22060
24275
  if (remoteGone && !await this.vaultFs.exists(record.obsidianPath)) {
22061
24276
  return { action: "unchanged", path: record.obsidianPath };
22062
24277
  }
@@ -22152,6 +24367,7 @@ var PagePuller = class {
22152
24367
  this.stateDb.delete(record.id);
22153
24368
  this.stateDb.deleteWikilink(record.obsidianPath);
22154
24369
  });
24370
+ if (this.config.sync.deleteSync) this.noteVacatedFolder(record);
22155
24371
  return { action: "untracked", path: record.obsidianPath };
22156
24372
  }
22157
24373
  const outcome = await applyRemoteDeletion(this.stateDb, this.vaultFs, record, {
@@ -22159,6 +24375,7 @@ var PagePuller = class {
22159
24375
  strategy: this.config.sync.conflictStrategy,
22160
24376
  remoteChange: change
22161
24377
  });
24378
+ if (outcome.action === "deleted" && this.config.sync.deleteSync) this.noteVacatedFolder(record);
22162
24379
  return { ...outcome, path: record.obsidianPath };
22163
24380
  }
22164
24381
  /**
@@ -22314,8 +24531,7 @@ var PagePuller = class {
22314
24531
  */
22315
24532
  async pullFolderRecord(record, page) {
22316
24533
  const folder = record.obsidianPath;
22317
- const remoteGone = page.in_trash === true || page.archived === true;
22318
- if (remoteGone) return null;
24534
+ if (isTrashedOrArchived(page)) return null;
22319
24535
  const { markdown } = await this.notionClient.getPageMarkdown(page.id);
22320
24536
  if (!hasBodyBesidesChildren(markdown)) {
22321
24537
  this.observation.record(record.id, page, remoteBodyFingerprint(markdown));
@@ -22647,6 +24863,13 @@ var PullPlanner = class {
22647
24863
  const restoredModified = planned.filter(
22648
24864
  (c) => c.type === "modified" && restoreIds.has(c.pageId)
22649
24865
  ).length;
24866
+ const restoredPaths = [
24867
+ ...[
24868
+ ...restoreChanges,
24869
+ ...planned.filter((c) => c.type === "modified" && restoreIds.has(c.pageId))
24870
+ ].map((change) => this.stateDb.getByNotionId(change.pageId)?.obsidianPath).filter((path) => path !== void 0),
24871
+ ...rows.filter((row) => row.operation === "update" && row.restore).map((row) => row.path)
24872
+ ];
22650
24873
  const rowCount = (operation, restore = false) => rows.filter((row) => row.operation === operation && (row.restore ?? false) === restore).length;
22651
24874
  return {
22652
24875
  created: count("created") + rowCount("create"),
@@ -22655,6 +24878,7 @@ var PullPlanner = class {
22655
24878
  // 복원은 updated 에 섞지 않는다 — dry-run 이 "수정 N건" 이라고만 말하면
22656
24879
  // 사용자가 사라진 파일이 되살아난다는 사실을 미리 알 수 없다.
22657
24880
  restored: restoreChanges.length + restoredModified + rowCount("update", true),
24881
+ ...restoredPaths.length > 0 ? { restoredPaths } : {},
22658
24882
  conflicts: [],
22659
24883
  writtenPaths: [],
22660
24884
  failed,
@@ -22729,6 +24953,42 @@ var PullPlanner = class {
22729
24953
  }
22730
24954
  };
22731
24955
 
24956
+ // src/sync/deletion-sweep.ts
24957
+ var DELETION_SWEEP_CURSOR_META_KEY = "deletion_sweep_cursor";
24958
+ var DELETION_SWEEP_BUDGET = 15;
24959
+ function sweepable(records, skip) {
24960
+ return records.filter((r) => r.notionPageId !== null && !skip.has(idOf(r))).sort((a, b) => idOf(a) < idOf(b) ? -1 : idOf(a) > idOf(b) ? 1 : 0);
24961
+ }
24962
+ function sweepBatch(records, cursor, skip, budget = DELETION_SWEEP_BUDGET) {
24963
+ const ordered = sweepable(records, skip);
24964
+ if (ordered.length === 0) return { batch: [], next: null, complete: true };
24965
+ if (ordered.length <= budget) {
24966
+ return { batch: ordered, next: idOf(ordered[ordered.length - 1]), complete: true };
24967
+ }
24968
+ const start = Math.max(0, cursor === null ? 0 : ordered.findIndex((r) => idOf(r) > cursor));
24969
+ const batch = Array.from({ length: budget }, (_, i) => ordered[(start + i) % ordered.length]);
24970
+ return { batch, next: idOf(batch[batch.length - 1]), complete: false };
24971
+ }
24972
+ function trackedDescendants(records, goneIds) {
24973
+ const gone = new Set(goneIds);
24974
+ const out = [];
24975
+ for (let grew = true; grew; ) {
24976
+ grew = false;
24977
+ for (const record of records) {
24978
+ if (record.notionPageId === null || record.notionParentId === null) continue;
24979
+ const id = idOf(record);
24980
+ if (gone.has(id) || !gone.has(compactNotionId(record.notionParentId))) continue;
24981
+ gone.add(id);
24982
+ out.push(record);
24983
+ grew = true;
24984
+ }
24985
+ }
24986
+ return out;
24987
+ }
24988
+ function idOf(record) {
24989
+ return compactNotionId(record.notionPageId);
24990
+ }
24991
+
22732
24992
  // src/sync/remote-detector.ts
22733
24993
  var RemoteDetector = class _RemoteDetector {
22734
24994
  constructor(config, stateDb, notionClient, observation) {
@@ -22764,7 +25024,23 @@ var RemoteDetector = class _RemoteDetector {
22764
25024
  // Notion 에서 부모가 바뀐 추적 페이지(정규화 id → 새 부모 페이지 id) — pull 이 볼트에서도 옮긴다(ADR-039).
22765
25025
  // 옮겨도 수정 시각이 바뀌지 않아(실측) 변경으로는 보이지 않는다. 감지마다 다시 채운다.
22766
25026
  _moved = /* @__PURE__ */ new Map();
25027
+ // 증분 감지가 search 로 살아 있다고 본 페이지(압축 id) — 삭제 확인이 다시 묻지 않는다.
25028
+ _seenAlive = /* @__PURE__ */ new Set();
25029
+ // 이번 감지에서 루트의 자식이 바뀌었을 수 있나 — {@link rootChanged}.
25030
+ _rootChanged = false;
25031
+ // 이번 감지가 삭제를 물은 뒤의 커서 — pull 이 마칠 때 적는다({@link saveDeletionSweep}). undefined 면 묻지 않았다.
25032
+ _sweepCursor;
25033
+ // 이번 감지가 추적 중인 것을 모두 물었나 — 그러면 증분이어도 미룬 삭제가 없다.
25034
+ _sweptAll = false;
22767
25035
  /** 이번 감지에서 자식 페이지를 가진 것으로 본 페이지인가 — 폴더노트 판정의 1차 근거. */
25036
+ /**
25037
+ * 루트 바로 아래에 새 DB 가 생겼을 수 있나 — 증분 search 에 루트가 들었거나 전체 대조다. 루트는 받는 노트가 아니라
25038
+ * 본문을 읽지 않아, 루트 바로 아래 새 DB 는 `pull --force` 만 찾았다(2026-10-29 실측 — DB 를 만들면 루트의 수정
25039
+ * 시각이 바뀐다). 이때 DB 발견이 루트의 자식을 한 번 훑는다.
25040
+ */
25041
+ rootChanged() {
25042
+ return this._rootChanged;
25043
+ }
22768
25044
  hasChildPages(pageId) {
22769
25045
  return this._childParentIds.has(normalizeNotionId(pageId));
22770
25046
  }
@@ -22805,7 +25081,7 @@ var RemoteDetector = class _RemoteDetector {
22805
25081
  throwIfAborted(signal);
22806
25082
  try {
22807
25083
  const page = await this.notionClient.getPage(childId);
22808
- if (page.in_trash || page.archived) continue;
25084
+ if (isTrashedOrArchived(page)) continue;
22809
25085
  const actualParent = await extractParentId(this.notionClient, page);
22810
25086
  if (!actualParent || !notionIdsEqual(actualParent, parentId)) continue;
22811
25087
  this._childParentIds.add(normalizeNotionId(parentId));
@@ -22868,10 +25144,84 @@ var RemoteDetector = class _RemoteDetector {
22868
25144
  * @param options.databases 바뀐 DB 도 찾는다(pull · dry-run). 상태 확인은 DB 를 조회하지 않아 찾지 않는다.
22869
25145
  */
22870
25146
  async detectRemote(scan, options) {
25147
+ this._seenAlive.clear();
25148
+ this._rootChanged = scan.kind === "full";
25149
+ this._sweepCursor = void 0;
25150
+ this._sweptAll = false;
22871
25151
  const detection = scan.kind === "full" ? { changes: await this.detectRemoteChanges(options.signal), databaseIds: null } : await this.detectRemoteChangesIncremental(scan.since, options);
25152
+ if (options.databases) {
25153
+ detection.changes.push(
25154
+ ...await this.sweepDeletions(scan, detection.changes, options.signal)
25155
+ );
25156
+ }
22872
25157
  this.beginDetection(detection.changes);
22873
25158
  return detection;
22874
25159
  }
25160
+ /**
25161
+ * 이번 감지가 보지 못하는 삭제를 차례로 몇 개씩 원격에 묻는다({@link sweepBatch}). Notion 은 지운 것을
25162
+ * 목록으로 주지 않는다 — search · DB 조회 모두 휴지통을 돌려주지 않고 거르지도 못한다(ADR-044).
25163
+ *
25164
+ * - 증분 → 추적 중인 페이지 · 행 모두가 차례에 든다. search 가 살아 있다고 보인 것은 뺀다.
25165
+ * - 전체 대조 → 조회 필터를 단 DB 의 행만. 나머지는 순회 · DB 조회가 가른다. 필터를 단 DB 는 조회에 없는
25166
+ * 행이 필터 밖인지 지워진 것인지 몰라 가르지 않는다(DatabaseSyncer) — 여기서 하나씩 묻는다.
25167
+ *
25168
+ * 지워진 페이지 아래의 추적 페이지는 차례와 상관없이 함께 묻는다 — 부모를 휴지통에 넣으면 그 아래도 모두
25169
+ * 휴지통이다(실측 2026-10-28). 삭제는 deleteSync 일 때만, 첫 pull 이 아닐 때만 가른다 — 전체 대조와 같다.
25170
+ */
25171
+ async sweepDeletions(scan, found2, signal) {
25172
+ if (!this.config.sync.deleteSync || !this.stateDb.getMeta(LAST_PULL_META_KEY)) return [];
25173
+ const records = this.stateDb.getAll();
25174
+ const candidates = scan.kind === "incremental" ? records : records.filter((r) => this.inFilteredDatabase(r));
25175
+ const skip = /* @__PURE__ */ new Set([...this._seenAlive, ...found2.map((c) => compactNotionId(c.pageId))]);
25176
+ const { batch, next, complete: complete2 } = sweepBatch(
25177
+ candidates,
25178
+ this.stateDb.getMeta(DELETION_SWEEP_CURSOR_META_KEY) || null,
25179
+ skip
25180
+ );
25181
+ this._sweepCursor = next;
25182
+ this._sweptAll = scan.kind === "incremental" && complete2;
25183
+ const deletions = [];
25184
+ const gone = /* @__PURE__ */ new Set();
25185
+ const ask = async (record) => {
25186
+ throwIfAborted(signal);
25187
+ const id = compactNotionId(record.notionPageId);
25188
+ try {
25189
+ if ((await remotePresence(this.notionClient, record.notionPageId)).kind !== "gone") return;
25190
+ } catch (error) {
25191
+ getLogger().warn(
25192
+ `[Im-Nobsidian] ${record.obsidianPath} \u2014 \uC6D0\uACA9\uC744 \uD655\uC778\uD558\uC9C0 \uBABB\uD574 \uC774\uBC88\uC5D0\uB294 \uC0AD\uC81C\uB97C \uAC00\uB974\uC9C0 \uC54A\uC74C:`,
25193
+ error
25194
+ );
25195
+ return;
25196
+ }
25197
+ gone.add(id);
25198
+ deletions.push(remoteDeletionChange(record));
25199
+ };
25200
+ for (const record of batch) await ask(record);
25201
+ const asked = new Set(batch.map((r) => compactNotionId(r.notionPageId)));
25202
+ for (const record of trackedDescendants(records, gone)) {
25203
+ const id = compactNotionId(record.notionPageId);
25204
+ if (!asked.has(id) && !skip.has(id)) await ask(record);
25205
+ }
25206
+ return deletions;
25207
+ }
25208
+ /** 조회 필터(`pullFilter`)를 단 설정 DB 의 행인가. */
25209
+ inFilteredDatabase(record) {
25210
+ if (record.fileType !== "db-row" || record.notionParentId === null) return false;
25211
+ return (this.config.notion.databases ?? []).some(
25212
+ (db) => db.pullFilter !== void 0 && notionIdsEqual(db.databaseId, record.notionParentId)
25213
+ );
25214
+ }
25215
+ /** 이번 감지가 추적 중인 페이지 · 행을 모두 원격에 물었나 — 결과의 `deletionsDeferred` 를 가른다. */
25216
+ deletionsChecked() {
25217
+ return this._sweptAll;
25218
+ }
25219
+ /** 삭제를 물은 자리를 적는다 — 다음 pull 이 이어서 묻는다. 경로를 좁힌 pull · dry-run 은 부르지 않는다. */
25220
+ saveDeletionSweep() {
25221
+ if (this._sweepCursor !== void 0) {
25222
+ this.stateDb.setMeta(DELETION_SWEEP_CURSOR_META_KEY, this._sweepCursor ?? "");
25223
+ }
25224
+ }
22875
25225
  /**
22876
25226
  * 전체 대조 — 루트 아래 페이지를 모두 훑고 추적 중인데 목록에 없는 페이지를 원격에 물어 삭제를
22877
25227
  * 가른다. 취소하면 {@link OperationAbortedError} 를 던진다 — 다 훑지 못한 목록으로 삭제를 가르지
@@ -23012,9 +25362,8 @@ var RemoteDetector = class _RemoteDetector {
23012
25362
  }
23013
25363
  /**
23014
25364
  * 증분 원격 변경 감지 — `since` 이후 수정된 페이지만 search 로 받아 created/modified 만
23015
- * 만든다. **삭제는 감지하지 않는다**: search API 는 in_trash/archived 페이지를 반환하지 않아
23016
- * (=사라진 것을 증분만으로는 구분 불가) 삭제는 전체 대조(`detectRemoteChanges`)가 가른다.
23017
- * 전체 대조는 주기마다 돈다(ADR-027 — 예전에는 deleteSync 가 켜져 있으면 매번 돌았다. I10).
25365
+ * 만든다. search API 는 in_trash/archived 페이지를 반환하지 않아 삭제는 여기서 보이지 않는다 —
25366
+ * 차례로 묻는 삭제 확인({@link sweepDeletions})과 주기마다 도는 전체 대조(ADR-027)가 가른다.
23018
25367
  *
23019
25368
  * 바뀐 행 · 새 행 · 스키마를 고친 DB 도 모은다 — pull 은 그 DB 만 조회한다. 새 행은 페이지로 받지
23020
25369
  * 않는다: DB 조회가 행 속성과 함께 받는다. 그래서 부모를 묻지 않는다(행마다 요청 1회 이상 아낀다).
@@ -23032,13 +25381,17 @@ var RemoteDetector = class _RemoteDetector {
23032
25381
  const recentPages = await this.notionClient.searchRecentPages(searchSince, options.signal);
23033
25382
  const untracked = [];
23034
25383
  for (const page of recentPages) {
25384
+ this._seenAlive.add(compactNotionId(page.id));
23035
25385
  const record = this.stateDb.getByNotionId(page.id);
23036
25386
  if (!record) {
23037
25387
  if (page.parentDatabaseId) {
23038
25388
  databaseIds.add(compactNotionId(page.parentDatabaseId));
23039
25389
  continue;
23040
25390
  }
23041
- if (notionIdsEqual(page.id, this.config.notion.rootPageId)) continue;
25391
+ if (notionIdsEqual(page.id, this.config.notion.rootPageId)) {
25392
+ this._rootChanged = true;
25393
+ continue;
25394
+ }
23042
25395
  throwIfAborted(options.signal);
23043
25396
  try {
23044
25397
  const parentId = await extractParentId(this.notionClient, page);
@@ -23185,6 +25538,9 @@ function isRerenderable(record) {
23185
25538
  }
23186
25539
 
23187
25540
  // src/sync/missing-local-files.ts
25541
+ function restoredWhenMissing(record) {
25542
+ return record.notionPageId !== null && !isFolderRecord(record) && (record.fileType === "file" || record.fileType === "folder-note" || record.fileType === "db-row");
25543
+ }
23188
25544
  async function detectMissingLocalFiles(config, stateDb, vaultFs, paths) {
23189
25545
  if (config.sync.deleteSync) return [];
23190
25546
  const records = stateDb.getAll();
@@ -23288,7 +25644,7 @@ function draftFolderDatabase(input) {
23288
25644
  let index = 0;
23289
25645
  for (const [name, list] of values) {
23290
25646
  if (NON_PROPERTY_KEYS.has(name)) continue;
23291
- if (isRangeEnd(name, values)) continue;
25647
+ if (isRangeEnd2(name, values)) continue;
23292
25648
  if (name === DEFAULT_TITLE_PROPERTY) {
23293
25649
  skipped.push({ what: name, reason: "\uC81C\uBAA9 \uC18D\uC131\uACFC \uC774\uB984\uC774 \uAC19\uB2E4 \u2014 \uC774\uB984\uC744 \uBC14\uAFB8\uBA74 \uC18D\uC131\uC774 \uB41C\uB2E4" });
23294
25650
  continue;
@@ -23405,7 +25761,7 @@ function collectValues(notes) {
23405
25761
  }
23406
25762
  return out;
23407
25763
  }
23408
- function isRangeEnd(name, values) {
25764
+ function isRangeEnd2(name, values) {
23409
25765
  if (!name.endsWith(RANGE_END_SUFFIX)) return false;
23410
25766
  const start = values.get(name.slice(0, -RANGE_END_SUFFIX.length));
23411
25767
  return start !== void 0 && start.length > 0 && start.every(isFrontmatterDate);
@@ -23463,11 +25819,11 @@ ${text}
23463
25819
  skipped.push({ what: "\uBCF4\uAE30", reason: ".base \uB97C \uC77D\uC9C0 \uBABB\uD588\uB2E4" });
23464
25820
  return [];
23465
25821
  }
23466
- const idOf = /* @__PURE__ */ new Map();
23467
- for (const p of properties) idOf.set(p.type === "title" ? "file.name" : p.name, p.id);
25822
+ const idOf2 = /* @__PURE__ */ new Map();
25823
+ for (const p of properties) idOf2.set(p.type === "title" ? BASES_TITLE : p.name, p.id);
23468
25824
  const columnId = (raw) => {
23469
25825
  if (typeof raw !== "string") return void 0;
23470
- return idOf.get(raw) ?? idOf.get(raw.replace(/^note\./, ""));
25826
+ return idOf2.get(raw) ?? idOf2.get(raw.replace(/^note\./, ""));
23471
25827
  };
23472
25828
  const views = [];
23473
25829
  const list = Array.isArray(parsed.views) ? parsed.views : [];
@@ -23590,11 +25946,26 @@ var SyncOrchestrator = class {
23590
25946
  propertyMapper.setWikilinkResolver(wikilinkResolver);
23591
25947
  this.notionClient.setWikilinkResolver(wikilinkResolver);
23592
25948
  this.rowSchemas = new RowSchemaCache(
23593
- (databaseId) => notionClient.getDatabaseSchema(databaseId),
25949
+ async (databaseId, dataSourceId) => {
25950
+ if (!dataSourceId) return notionClient.getDatabaseSchema(databaseId);
25951
+ const meta = await notionClient.getDatabaseMeta(databaseId);
25952
+ const own = meta.dataSources.length > 1 ? meta.dataSources.find((ds) => notionIdsEqual(ds.id, dataSourceId))?.properties : void 0;
25953
+ return notionClient.getDatabaseSchema(
25954
+ databaseId,
25955
+ own ? { ...meta, properties: own } : meta
25956
+ );
25957
+ },
23594
25958
  wikilinkResolver,
23595
25959
  () => PropertyMapper.fromConfig(config),
23596
- async () => peopleResolverOf(await notionClient.listUsers())
25960
+ async () => peopleResolverOf(await notionClient.listUsers()),
25961
+ async (databaseId) => {
25962
+ const sources = (await notionClient.getDatabaseMeta(databaseId)).dataSources;
25963
+ return sources.length > 1 ? sources[0].id : null;
25964
+ }
23597
25965
  );
25966
+ this.vaultRenames = new VaultSchemaRenames(stateDb, vaultFs, notionClient);
25967
+ this.sidecarEdits = new SidecarSchemaEdits(stateDb, vaultFs, notionClient);
25968
+ this.viewEdits = new BaseViewEdits(stateDb, vaultFs, notionClient);
23598
25969
  this.drift = new RemoteDriftChecker(
23599
25970
  config,
23600
25971
  stateDb,
@@ -23692,6 +26063,12 @@ var SyncOrchestrator = class {
23692
26063
  databaseSyncer;
23693
26064
  /** DB 행 push 용 — DB 마다 스키마를 읽은 매퍼. 실행마다 비운다(S-01). */
23694
26065
  rowSchemas;
26066
+ /** 볼트에서 바꾼 DB 속성 이름을 Notion 의 열 이름으로(ADR-045). */
26067
+ vaultRenames;
26068
+ /** 볼트에서 고친 사이드카의 속성을 Notion DB 의 열로(ADR-045). */
26069
+ sidecarEdits;
26070
+ /** 볼트에서 고친 `.base` 의 보기를 Notion 보기로(ADR-046). */
26071
+ viewEdits;
23695
26072
  // 이번 실행이 원격을 보는 기준(N-05) — push · pull · status 가 시작할 때 정한다
23696
26073
  // ({@link RunObservation.begin}). 원격 판정과 관측 기록이 같은 값을 쓴다.
23697
26074
  observation;
@@ -23762,6 +26139,7 @@ var SyncOrchestrator = class {
23762
26139
  this.rowSchemas.clear();
23763
26140
  this.placement.resetUnpreparedFolders();
23764
26141
  this.databaseCreator.reset();
26142
+ this.pusher.takeReadOnlySkips();
23765
26143
  if (!options?.dryRun) await this.recovery.recoverInterruptedPushOps();
23766
26144
  const plan = await this.planner.planLocalChanges(await this.vaultFs.listMarkdownFileStats());
23767
26145
  const view = this.planner.localView(plan);
@@ -23772,20 +26150,31 @@ var SyncOrchestrator = class {
23772
26150
  const excludeSet = options?.excludePaths ? new Set(options.excludePaths) : null;
23773
26151
  const filtered = (options?.paths ? eligible.filter((c) => inAnyPathScope(c.path, options.paths)) : eligible).filter((c) => !excludeSet || !excludeSet.has(c.path));
23774
26152
  const folderMoves = this.planner.pendingFolderMoves(plan).filter((move) => inAnyPathScope(move.to, options?.paths));
26153
+ const inScope = (path) => inAnyPathScope(path, options?.paths) && !excludeSet?.has(path);
26154
+ const sidecarPlan = await this.sidecarEdits.plan(inScope);
26155
+ const plannedEdits = sidecarPlan.planned.map((edit) => edit.edit);
26156
+ const viewPlan = await this.viewEdits.plan(inScope);
26157
+ const plannedViews = viewPlan.planned.flatMap((file) => file.views.map((view2) => view2.edit));
23775
26158
  if (filtered.length === 0 && folderMoves.length === 0) {
23776
26159
  if (!options?.dryRun) await this.completeDatabaseSchemas();
26160
+ const edited2 = options?.dryRun ? { applied: plannedEdits, failed: [] } : await this.sidecarEdits.apply(sidecarPlan.planned);
26161
+ const viewed2 = options?.dryRun ? { applied: plannedViews, failed: [] } : await this.viewEdits.apply(viewPlan.planned);
23777
26162
  return {
23778
26163
  created: 0,
23779
26164
  updated: 0,
23780
26165
  deleted: 0,
23781
26166
  moved: 0,
23782
- failed: [],
23783
- duration: Date.now() - startTime
26167
+ failed: [...sidecarPlan.refused, ...edited2.failed, ...viewPlan.refused, ...viewed2.failed],
26168
+ duration: Date.now() - startTime,
26169
+ ...edited2.applied.length > 0 ? { schemaEdits: edited2.applied } : {},
26170
+ ...viewed2.applied.length > 0 ? { viewEdits: viewed2.applied } : {}
23784
26171
  };
23785
26172
  }
23786
26173
  const refusedCreates = this.placement.refusedCreates(filtered, view.lookup);
23787
26174
  const refusedMoves = this.placement.refusedMoves(filtered, view);
23788
26175
  const refusedFailures = [
26176
+ ...sidecarPlan.refused,
26177
+ ...viewPlan.refused,
23789
26178
  ...[...refusedCreates].map(([path, error]) => ({
23790
26179
  path,
23791
26180
  operation: "create",
@@ -23799,9 +26188,14 @@ var SyncOrchestrator = class {
23799
26188
  if (error) refusedFailures.push({ path: move.to, operation: "move", error });
23800
26189
  else movableFolders.push(move);
23801
26190
  }
23802
- const applicable = filtered.filter(
26191
+ const placeable2 = filtered.filter(
23803
26192
  (c) => !refusedCreates.has(c.path) && !refusedMoves.has(c.path)
23804
26193
  );
26194
+ const renames = await this.vaultRenames.plan(placeable2);
26195
+ refusedFailures.push(...renames.refused);
26196
+ const renameRefused = new Set(renames.refused.map((f) => f.path));
26197
+ const applicable = placeable2.filter((c) => !renameRefused.has(c.path));
26198
+ const plannedRenames = renames.planned.map(({ folder, from, to }) => ({ folder, from, to }));
23805
26199
  if (options?.dryRun) {
23806
26200
  const planned = this.config.sync.deleteSync ? applicable : applicable.filter((c) => c.type !== "deleted");
23807
26201
  const items = [
@@ -23815,6 +26209,8 @@ var SyncOrchestrator = class {
23815
26209
  view.lookup,
23816
26210
  !options?.paths
23817
26211
  );
26212
+ const unsentDeletions2 = applicable.filter((change) => this.unsentDeletion(change)).map((change) => change.path);
26213
+ const readOnlySkipped = await this.pusher.previewReadOnlySkips(applicable);
23818
26214
  return {
23819
26215
  createdDatabases: newDatabases.size,
23820
26216
  created: count("create"),
@@ -23822,15 +26218,32 @@ var SyncOrchestrator = class {
23822
26218
  deleted: count("delete"),
23823
26219
  moved: count("move"),
23824
26220
  failed: refusedFailures,
23825
- duration: Date.now() - startTime
26221
+ duration: Date.now() - startTime,
26222
+ ...unsentDeletions2.length > 0 ? { unsentDeletions: unsentDeletions2 } : {},
26223
+ ...readOnlySkipped.length > 0 ? { readOnlySkipped } : {},
26224
+ ...plannedRenames.length > 0 ? { renamedProperties: plannedRenames } : {},
26225
+ ...plannedEdits.length > 0 ? { schemaEdits: plannedEdits } : {},
26226
+ ...plannedViews.length > 0 ? { viewEdits: plannedViews } : {}
23826
26227
  };
23827
26228
  }
23828
26229
  this.stateDb.setMeta(PUSH_IN_PROGRESS_META_KEY, "true");
23829
26230
  await this.observation.resolveBotUserId();
23830
26231
  const counts = { created: 0, updated: 0, deleted: 0, moved: 0 };
23831
26232
  const failed = [...refusedFailures];
26233
+ const edited = await this.sidecarEdits.apply(sidecarPlan.planned);
26234
+ failed.push(...edited.failed);
26235
+ const viewed = await this.viewEdits.apply(viewPlan.planned);
26236
+ failed.push(...viewed.failed);
26237
+ const renamed = await this.vaultRenames.apply(renames.planned);
26238
+ failed.push(...renamed.failed);
26239
+ if (renamed.applied.length > 0 || edited.applied.length > 0) this.rowSchemas.clear();
26240
+ const renameFailed = new Set(renamed.failed.map((f) => f.path));
26241
+ const pushing = applicable.filter(
26242
+ (c) => !renamed.settled.has(c.path) && !renameFailed.has(c.path)
26243
+ );
26244
+ const unsentDeletions = [];
23832
26245
  let completed = 0;
23833
- const total = movableFolders.length + applicable.length;
26246
+ const total = movableFolders.length + pushing.length;
23834
26247
  counts.moved += await this.pusher.pushFolderMoves(
23835
26248
  movableFolders,
23836
26249
  failed,
@@ -23856,6 +26269,7 @@ var SyncOrchestrator = class {
23856
26269
  case "deleted": {
23857
26270
  const propagated = await this.pusher.pushDelete(change.path);
23858
26271
  if (propagated) counts.deleted++;
26272
+ else if (this.unsentDeletion(change)) unsentDeletions.push(change.path);
23859
26273
  break;
23860
26274
  }
23861
26275
  }
@@ -23877,14 +26291,10 @@ var SyncOrchestrator = class {
23877
26291
  retryQueue.push(change);
23878
26292
  }
23879
26293
  };
23880
- const pushedEarly = await this.placement.prepareFolders(
23881
- applicable,
23882
- firstAttempt,
23883
- !options?.paths
23884
- );
26294
+ const pushedEarly = await this.placement.prepareFolders(pushing, firstAttempt, !options?.paths);
23885
26295
  await this.completeDatabaseSchemas();
23886
26296
  await runPool(
23887
- applicable.filter((c) => !pushedEarly.has(c.path)),
26297
+ pushing.filter((c) => !pushedEarly.has(c.path)),
23888
26298
  firstAttempt,
23889
26299
  { concurrency: this.config.advanced.concurrency, signal: options?.signal }
23890
26300
  );
@@ -24011,9 +26421,23 @@ var SyncOrchestrator = class {
24011
26421
  deleted: counts.deleted,
24012
26422
  moved: counts.moved,
24013
26423
  failed,
24014
- duration: Date.now() - startTime
26424
+ duration: Date.now() - startTime,
26425
+ readOnlySkipped: this.pusher.takeReadOnlySkips(),
26426
+ ...unsentDeletions.length > 0 ? { unsentDeletions } : {},
26427
+ ...renamed.applied.length > 0 ? { renamedProperties: renamed.applied } : {},
26428
+ ...edited.applied.length > 0 ? { schemaEdits: edited.applied } : {},
26429
+ ...viewed.applied.length > 0 ? { viewEdits: viewed.applied } : {}
24015
26430
  };
24016
26431
  }
26432
+ /**
26433
+ * 볼트에서 지운 이 노트를 Notion 에서 지우지 않아 다음 pull 이 되살리나 — deleteSync 가 꺼져 있고 pull 이
26434
+ * 되살리는 레코드일 때. 예전에는 대기로 적기만 하고 알리지 않아, 지운 파일이 말없이 돌아왔다(D8).
26435
+ */
26436
+ unsentDeletion(change) {
26437
+ if (change.type !== "deleted" || this.config.sync.deleteSync) return false;
26438
+ const record = this.stateDb.getByPath(change.path);
26439
+ return !!record && restoredWhenMissing(record);
26440
+ }
24017
26441
  /**
24018
26442
  * 만든 DB 에 relation · rollup · formula 를 붙이고, 행 스키마를 다시 읽게 한 뒤 보기를 만든다. 실패는
24019
26443
  * 다음 push 가 이어 붙인다 — 행은 그대로 올린다.
@@ -24086,6 +26510,10 @@ var SyncOrchestrator = class {
24086
26510
  this.assertConnectionScope();
24087
26511
  this.puller.beginPull();
24088
26512
  this.rowSchemas.clear();
26513
+ this.databaseSyncer.takeRestoredRows();
26514
+ this.databaseSyncer.takeSchemaFollowed();
26515
+ this.databaseSyncer.takeHeldSidecars();
26516
+ this.databaseSyncer.takeHeldBases();
24089
26517
  const emptyResult = {
24090
26518
  created: 0,
24091
26519
  updated: 0,
@@ -24118,6 +26546,7 @@ var SyncOrchestrator = class {
24118
26546
  this.discovery.clearInlineRefs();
24119
26547
  const conflicts = [];
24120
26548
  const writtenPaths = [];
26549
+ const restoredPaths = [];
24121
26550
  const excluded = [];
24122
26551
  const failed = [];
24123
26552
  let detected = [];
@@ -24161,7 +26590,12 @@ var SyncOrchestrator = class {
24161
26590
  pendingDatabases
24162
26591
  );
24163
26592
  const finalize = async (created, updated, deleted, restored = 0) => {
26593
+ await this.puller.removeVacatedFolders();
24164
26594
  const renamedRows = this.databaseSyncer.takeRenamedRows();
26595
+ restoredPaths.push(...this.databaseSyncer.takeRestoredRows());
26596
+ const schemaFollowed = this.databaseSyncer.takeSchemaFollowed();
26597
+ const heldSidecars = this.databaseSyncer.takeHeldSidecars();
26598
+ const heldBases = this.databaseSyncer.takeHeldBases();
24165
26599
  const relinked = await this.linkUpdater.apply([...relocated.applied, ...renamedRows.applied]);
24166
26600
  const linkCount = writtenPaths.length > 0 ? await this.resolveNotionLinks(writtenPaths) : 0;
24167
26601
  if (!options?.paths) {
@@ -24177,6 +26611,7 @@ var SyncOrchestrator = class {
24177
26611
  this.stateDb.setMeta(LAST_FULL_PULL_META_KEY, startedAt);
24178
26612
  }
24179
26613
  this.detector.savePendingDatabases(ledger);
26614
+ this.detector.saveDeletionSweep();
24180
26615
  }
24181
26616
  this.stateDb.setMeta(LAST_SYNC_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
24182
26617
  this.stateDb.setMeta(PULL_IN_PROGRESS_META_KEY, "");
@@ -24185,6 +26620,10 @@ var SyncOrchestrator = class {
24185
26620
  updated,
24186
26621
  deleted,
24187
26622
  restored,
26623
+ ...restoredPaths.length > 0 ? { restoredPaths } : {},
26624
+ ...schemaFollowed.length > 0 ? { schemaFollowed } : {},
26625
+ ...heldSidecars.length > 0 ? { heldSidecars } : {},
26626
+ ...heldBases.length > 0 ? { heldBases } : {},
24188
26627
  conflicts,
24189
26628
  writtenPaths,
24190
26629
  failed,
@@ -24196,7 +26635,8 @@ var SyncOrchestrator = class {
24196
26635
  ...remoteScanInfo(
24197
26636
  scan,
24198
26637
  this.stateDb.getMeta(LAST_FULL_PULL_META_KEY),
24199
- this.config.sync.deleteSync
26638
+ this.config.sync.deleteSync,
26639
+ this.detector.deletionsChecked()
24200
26640
  ),
24201
26641
  skippedDatabases: ledger.skipped
24202
26642
  },
@@ -24247,6 +26687,10 @@ var SyncOrchestrator = class {
24247
26687
  );
24248
26688
  return {
24249
26689
  ...plan,
26690
+ remoteScan: plan.remoteScan && {
26691
+ ...plan.remoteScan,
26692
+ deletionsDeferred: plan.remoteScan.deletionsDeferred && !this.detector.deletionsChecked()
26693
+ },
24250
26694
  moved: relocated.moved,
24251
26695
  moveSkipped: relocated.skipped,
24252
26696
  excluded: [...exclusions.values()]
@@ -24294,6 +26738,7 @@ var SyncOrchestrator = class {
24294
26738
  conflicts,
24295
26739
  {
24296
26740
  forceRediscovery: options?.force === true,
26741
+ rescanRoot: this.detector.rootChanged(),
24297
26742
  paths: options?.paths,
24298
26743
  progress: rowProgress,
24299
26744
  ledger,
@@ -24340,8 +26785,10 @@ var SyncOrchestrator = class {
24340
26785
  conflicts.push(outcome.conflict);
24341
26786
  } else if (outcome.action === "written") {
24342
26787
  writtenPaths.push(outcome.path);
24343
- if (restoreIds.has(change.pageId)) counts.restored++;
24344
- else counts.updated++;
26788
+ if (restoreIds.has(change.pageId)) {
26789
+ counts.restored++;
26790
+ restoredPaths.push(outcome.path);
26791
+ } else counts.updated++;
24345
26792
  }
24346
26793
  return {
24347
26794
  path: outcome.path,
@@ -24569,7 +27016,12 @@ var SyncOrchestrator = class {
24569
27016
  this.stateDb,
24570
27017
  this.config.notion.rootPageId
24571
27018
  );
24572
- return { databases, pages, complete: databases.complete && (pages?.complete ?? true) };
27019
+ return {
27020
+ databases,
27021
+ pages,
27022
+ nestedFolderPages: findNestedFolderPages(this.stateDb.getAll()),
27023
+ complete: databases.complete && (pages?.complete ?? true)
27024
+ };
24573
27025
  }
24574
27026
  async fetch() {
24575
27027
  return this.gate.run("fetch", () => this.executeFetch());
@@ -24713,14 +27165,14 @@ function toVaultPath(relativePath2, separator) {
24713
27165
  function compileAll(patterns) {
24714
27166
  return patterns.flatMap((raw) => {
24715
27167
  const pattern = normalizePattern(raw);
24716
- return pattern === null ? [] : [compile(pattern)];
27168
+ return pattern === null ? [] : [compile2(pattern)];
24717
27169
  });
24718
27170
  }
24719
27171
  function normalizePattern(raw) {
24720
27172
  const pattern = raw.trim().replace(/\\/g, "/").replace(/^(?:\.\/|\/)+/, "").replace(/\/+$/, "");
24721
27173
  return pattern === "" ? null : pattern;
24722
27174
  }
24723
- function compile(pattern) {
27175
+ function compile2(pattern) {
24724
27176
  if (pattern === "**" || pattern === "**/*") return { kind: "all" };
24725
27177
  if (!/[*?]/.test(pattern)) return { kind: "prefix", prefix: pattern };
24726
27178
  if (pattern.endsWith("/**") && !/[*?]/.test(pattern.slice(0, -3))) {
@@ -24814,6 +27266,19 @@ var NodeVaultFS = class {
24814
27266
  const fullPath = join(this.rootPath, path);
24815
27267
  await unlink(fullPath);
24816
27268
  }
27269
+ /** `rmdir` 는 비지 않은 폴더를 지우지 않는다(ENOTEMPTY) — 숨김 파일도 그대로 둔다. */
27270
+ async removeEmptyFolder(path) {
27271
+ try {
27272
+ await rmdir(join(this.rootPath, path));
27273
+ return true;
27274
+ } catch (error) {
27275
+ const code = error.code;
27276
+ if (code === "ENOTEMPTY" || code === "EEXIST" || code === "ENOENT" || code === "ENOTDIR") {
27277
+ return false;
27278
+ }
27279
+ throw error;
27280
+ }
27281
+ }
24817
27282
  async moveFile(from, to) {
24818
27283
  const fromPath = join(this.rootPath, from);
24819
27284
  const toPath = join(this.rootPath, to);
@@ -25772,6 +28237,9 @@ var FilteredVaultFS = class {
25772
28237
  ensureFolder(path) {
25773
28238
  return this.inner.ensureFolder(path);
25774
28239
  }
28240
+ async removeEmptyFolder(path) {
28241
+ return await this.inner.removeEmptyFolder?.(path) ?? false;
28242
+ }
25775
28243
  getFileStat(path) {
25776
28244
  return this.inner.getFileStat(path);
25777
28245
  }
@@ -25940,7 +28408,7 @@ var SEP_RE = new RegExp(`^[\\t ]*${COLUMN_SEP_SOURCE}[\\t ]*$`);
25940
28408
  var FENCE_RE = /^[\t ]*(`{3,}|~{3,})/;
25941
28409
  function readColumnLayout(lines) {
25942
28410
  const places = new Array(lines.length);
25943
- let group = -1;
28411
+ let group2 = -1;
25944
28412
  let fence = null;
25945
28413
  let open = null;
25946
28414
  const close = (end) => {
@@ -25950,7 +28418,7 @@ function readColumnLayout(lines) {
25950
28418
  const until = columns[index + 1]?.line ?? end;
25951
28419
  for (let line = column.line; line < until; line++) {
25952
28420
  places[line] = {
25953
- group,
28421
+ group: group2,
25954
28422
  index,
25955
28423
  count: columns.length,
25956
28424
  ...column.ratio === void 0 ? {} : { ratio: column.ratio },
@@ -25972,7 +28440,7 @@ function readColumnLayout(lines) {
25972
28440
  }
25973
28441
  if (START_RE.test(line)) {
25974
28442
  close(i);
25975
- group++;
28443
+ group2++;
25976
28444
  open = { start: i, columns: [] };
25977
28445
  return;
25978
28446
  }
@@ -25998,6 +28466,6 @@ function formatRatio(ratio) {
25998
28466
  return String(Math.round(ratio * 100) / 100);
25999
28467
  }
26000
28468
 
26001
- export { BaseFileGenerator, BlankLineCollapser, CONFIG_FILE, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConnectionBoundaryError, ConnectionScopeError, ConversionPipeline, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DEFAULT_TITLE_PROPERTY, DEFAULT_WATCH_DEBOUNCE_MS, DIFF_SIGN, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FilteredVaultFS, FrontmatterExtractor, FrontmatterGenerator, GITIGNORE_ENTRY, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, ImageHandler, InlineAnnotationPreserver, InlineDBParser, LocalImageRestorer, MARKER_BRAND, MathNormalizer, MentionToWikilink, NOTION_COLOR_NAMES, NOTION_TOKEN_PREFIX, NOTION_TO_BASES_VIEW, NodeVaultFS, NotionBlockBuilder, NotionClient, OBSIDIAN_CONFIG_DIR, PathFilter, PendingWalError, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, RENDER_LINE_RULES, SAVED_STATE_TABLES_QUERY, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_MIGRATIONS, STATE_DB_PATH, SavedStateDbError, ScopedVaultFS, SidecarGenerator, StateDB, StateDbLockedError, StateDbUnavailableError, StateLock, SyncBusyError, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WIKILINK_LOOKUPS, WatchSyncService, WikilinkResolver, applicableChoices, assertNoPendingWal, basesViewMappingOf, basesViewTypeOf, choiceForStrategy, classifyBodyFidelity, columnLabel, compactNotionId, computeBufferHash, computeHash, configDirFrom, connectionFolderOf, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, findConnectionFolders, formatUnifiedDiff, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, hasColumnLayout, hasDesignMarker, inAnyPathScope, isInternalPath, isMarkdownPath, isRemoteDeletion, lineDiff, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, normalizeVaultPath, noteNameFromPath, notionIdFromInput, notionIdsEqual, operationLabel, parseDesignColor, parseIgnorePatterns, pendingWalBytes, readColumnLayout, readDesignLine, resolvePropertyName, sanitizeFileName, setLogger, sortEntries, stateDbLockPath, stateDbWalPath, stripMarkdownExtension, summarizeFidelity, systemTimeZone, threeWayMerge, toVaultPath, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness, wikilinkLookupsIn };
28469
+ export { BaseFileGenerator, BlankLineCollapser, CONFIG_FILE, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConnectionBoundaryError, ConnectionScopeError, ConversionPipeline, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DEFAULT_TITLE_PROPERTY, DEFAULT_WATCH_DEBOUNCE_MS, DIFF_SIGN, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FilteredVaultFS, FrontmatterExtractor, FrontmatterGenerator, GITIGNORE_ENTRY, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, ImageHandler, InlineAnnotationPreserver, InlineDBParser, LocalImageRestorer, MARKER_BRAND, MathNormalizer, MentionToWikilink, NOTION_COLOR_NAMES, NOTION_TOKEN_PREFIX, NOTION_TO_BASES_VIEW, NodeVaultFS, NotionBlockBuilder, NotionClient, OBSIDIAN_CONFIG_DIR, PathFilter, PendingWalError, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, RENDER_LINE_RULES, SAVED_STATE_TABLES_QUERY, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_MIGRATIONS, STATE_DB_PATH, SavedStateDbError, ScopedVaultFS, SidecarGenerator, StateDB, StateDbLockedError, StateDbUnavailableError, StateLock, SyncBusyError, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WIKILINK_LOOKUPS, WatchSyncService, WikilinkResolver, applicableChoices, assertNoPendingWal, basesViewMappingOf, basesViewTypeOf, choiceForStrategy, classifyBodyFidelity, columnLabel, compactNotionId, computeBufferHash, computeHash, configDirFrom, connectionFolderOf, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, findConnectionFolders, findNestedFolderPages, formatUnifiedDiff, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, hasColumnLayout, hasDesignMarker, inAnyPathScope, isHeadingLine, isHeadingMarkerLine, isInternalPath, isMarkdownPath, isRemoteDeletion, lineDiff, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, normalizeVaultPath, noteNameFromPath, notionIdFromInput, notionIdsEqual, operationLabel, parseDesignColor, parseIgnorePatterns, pendingWalBytes, readColumnLayout, readDesignLine, resolvePropertyName, sanitizeFileName, sectionLinesWithHeadingMarkers, setLogger, shortNotionId, sortEntries, stateDbLockPath, stateDbWalPath, stripMarkdownExtension, summarizeFidelity, systemTimeZone, threeWayMerge, toVaultPath, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness, wikilinkLookupsIn };
26002
28470
  //# sourceMappingURL=index.js.map
26003
28471
  //# sourceMappingURL=index.js.map