@im-nobsidian/core 0.12.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
@@ -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 = {
@@ -1679,6 +1683,9 @@ function isFrontmatterDate(value) {
1679
1683
  }
1680
1684
  var RANGE_END_SUFFIX = "_end";
1681
1685
  var DATE_VALUED_TYPES = /* @__PURE__ */ new Set(["date", "formula", "rollup"]);
1686
+ function mayHaveRangeEnd(type) {
1687
+ return DATE_VALUED_TYPES.has(type);
1688
+ }
1682
1689
  var TIMESTAMP_TYPES = /* @__PURE__ */ new Set(["created_time", "last_edited_time"]);
1683
1690
  var READ_ONLY_TYPES = /* @__PURE__ */ new Set([
1684
1691
  "created_time",
@@ -2804,6 +2811,10 @@ var DiscoveryTooLargeError = class extends Error {
2804
2811
  };
2805
2812
  var NOT_IDEMPOTENT = { idempotent: false };
2806
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
+ }
2807
2818
  function parentDatabaseIdOf(page) {
2808
2819
  const parent = page.parent;
2809
2820
  if (parent?.type !== "data_source_id" && parent?.type !== "database_id") return null;
@@ -2951,7 +2962,7 @@ var NotionClient = class _NotionClient {
2951
2962
  );
2952
2963
  }
2953
2964
  async createPage(params) {
2954
- const parent = params.parentType === "database" ? { database_id: params.parentId } : { page_id: params.parentId };
2965
+ const parent = rowParentOf(params);
2955
2966
  const properties = params.parentType === "page" ? { title: { title: [{ text: { content: params.title } }] } } : params.properties ?? {
2956
2967
  title: { title: [{ text: { content: params.title } }] }
2957
2968
  };
@@ -3021,7 +3032,7 @@ var NotionClient = class _NotionClient {
3021
3032
  return ds.properties ?? {};
3022
3033
  }
3023
3034
  async createPageWithMarkdown(params) {
3024
- const parent = params.parentType === "database" ? { database_id: params.parentId } : { page_id: params.parentId };
3035
+ const parent = rowParentOf(params);
3025
3036
  const properties = params.parentType === "page" ? { title: { title: [{ text: { content: params.title } }] } } : params.properties ?? {
3026
3037
  title: { title: [{ text: { content: params.title } }] }
3027
3038
  };
@@ -3183,6 +3194,7 @@ var NotionClient = class _NotionClient {
3183
3194
  const dataSources = db.data_sources ?? [];
3184
3195
  const merged = {};
3185
3196
  const owners = [];
3197
+ const own = /* @__PURE__ */ new Map();
3186
3198
  let anyAccessible = false;
3187
3199
  for (const ds of dataSources) {
3188
3200
  try {
@@ -3192,6 +3204,7 @@ var NotionClient = class _NotionClient {
3192
3204
  owners.push(dsObj.parent?.database_id);
3193
3205
  if (dsObj.properties && Object.keys(dsObj.properties).length > 0) {
3194
3206
  anyAccessible = true;
3207
+ own.set(ds.id, dsObj.properties);
3195
3208
  for (const [name, prop] of Object.entries(dsObj.properties)) {
3196
3209
  if (!(name in merged)) merged[name] = prop;
3197
3210
  }
@@ -3203,7 +3216,11 @@ var NotionClient = class _NotionClient {
3203
3216
  const meta = {
3204
3217
  databaseId,
3205
3218
  title: Array.isArray(db.title) ? db.title[0]?.plain_text ?? "" : "",
3206
- 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
+ })),
3207
3224
  properties: anyAccessible ? merged : db.properties ?? {},
3208
3225
  isInline: db.is_inline === true,
3209
3226
  appearance: {
@@ -3412,7 +3429,7 @@ var NotionClient = class _NotionClient {
3412
3429
  if (metas.length > 1) {
3413
3430
  const label = metas.map((m) => m.name || m.id).join(", ");
3414
3431
  getLogger().warn(
3415
- `[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.`
3416
3433
  );
3417
3434
  }
3418
3435
  const all = [];
@@ -3902,14 +3919,14 @@ var NotionClient = class _NotionClient {
3902
3919
  const blockOwner = /* @__PURE__ */ new Map();
3903
3920
  const resolveBlockOwner = async (blockId) => {
3904
3921
  const seen = /* @__PURE__ */ new Set();
3905
- const settle = (owner) => {
3922
+ const settle2 = (owner) => {
3906
3923
  for (const b of seen) blockOwner.set(b, owner);
3907
3924
  return owner;
3908
3925
  };
3909
3926
  let bid = blockId;
3910
3927
  for (let i = 0; i < 20 && bid && !seen.has(bid); i++) {
3911
3928
  const cached = blockOwner.get(bid);
3912
- if (cached !== void 0) return settle(cached);
3929
+ if (cached !== void 0) return settle2(cached);
3913
3930
  seen.add(bid);
3914
3931
  let bp;
3915
3932
  try {
@@ -3918,14 +3935,14 @@ var NotionClient = class _NotionClient {
3918
3935
  getLogger().warn(
3919
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)}`
3920
3937
  );
3921
- return settle(null);
3938
+ return settle2(null);
3922
3939
  }
3923
- 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);
3924
3941
  if (bp.type === "block_id" && bp.block_id) {
3925
3942
  bid = bp.block_id;
3926
3943
  continue;
3927
3944
  }
3928
- return settle(null);
3945
+ return settle2(null);
3929
3946
  }
3930
3947
  return null;
3931
3948
  };
@@ -4019,6 +4036,15 @@ var NotionClient = class _NotionClient {
4019
4036
  async deleteView(viewId) {
4020
4037
  await this.withRateLimit(() => this.client.views.delete({ view_id: viewId }));
4021
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
+ }
4022
4048
  async getView(viewId) {
4023
4049
  const view = await this.withRateLimit(() => this.client.views.retrieve({ view_id: viewId }));
4024
4050
  const v = view;
@@ -6962,8 +6988,8 @@ function assemble(p, fitted) {
6962
6988
  addLines(linesAt, spot.line, spot.exact ? lines : requote(lines, neighbor(p, spot.line)));
6963
6989
  }
6964
6990
  }
6965
- for (const group of groupLoose(p, loose)) {
6966
- 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));
6967
6993
  }
6968
6994
  return joinLines(p, linesAt, inlineAt);
6969
6995
  }
@@ -8042,6 +8068,53 @@ function mergeRowFrontmatter(localNote, pulled, owned) {
8042
8068
  }
8043
8069
  return frontmatterLines(values);
8044
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
+ }
8045
8118
  function mergedKeyOrder(localKeys, pulledKeys, isOwned) {
8046
8119
  const pulledSet = new Set(pulledKeys);
8047
8120
  const order = localKeys.filter((key) => !isOwned(key) || pulledSet.has(key));
@@ -8075,15 +8148,15 @@ function localChunks(localNote, data) {
8075
8148
  }
8076
8149
  const chunks = [];
8077
8150
  const seen = [];
8078
- for (const [i, group] of groups.entries()) {
8079
- let end = group.length;
8080
- 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--;
8081
8154
  if (i === 0) {
8082
8155
  if (end > 0) return null;
8083
- chunks.push({ key: "", body: [], trailing: group });
8156
+ chunks.push({ key: "", body: [], trailing: group2 });
8084
8157
  continue;
8085
8158
  }
8086
- const body = group.slice(0, end);
8159
+ const body = group2.slice(0, end);
8087
8160
  let parsed;
8088
8161
  try {
8089
8162
  const split = splitFrontmatter(`---
@@ -8100,7 +8173,7 @@ ${body.join("\n")}
8100
8173
  const key = keys[0];
8101
8174
  if (!Object.hasOwn(data, key) || !sameFrontmatterValue(parsed[key], data[key])) return null;
8102
8175
  seen.push(key);
8103
- chunks.push({ key, body, trailing: group.slice(end) });
8176
+ chunks.push({ key, body, trailing: group2.slice(end) });
8104
8177
  }
8105
8178
  const dataKeys = Object.keys(data);
8106
8179
  return seen.length === dataKeys.length && seen.every((key, i) => key === dataKeys[i]) ? chunks : null;
@@ -9023,8 +9096,8 @@ ${brokenLines(note2)}`;
9023
9096
  const lines = Math.max(note.split("\n").length, pulled.split("\n").length);
9024
9097
  const limit = Math.min(TRIAL_LIMIT, Math.floor(TRIAL_LINE_LIMIT / lines));
9025
9098
  const trial = new Trial(sent, target, slots, limit - 2);
9026
- const failed = trial.bisect(spots, (group, local2) => {
9027
- 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];
9028
9101
  });
9029
9102
  for (const { slot, run } of failed) {
9030
9103
  if (run) trial.refine(run.local, run.pulled, (lines2) => trial.put(slot, lines2));
@@ -9186,20 +9259,20 @@ var Trial = class {
9186
9259
  */
9187
9260
  bisect(items, swap, check = () => this.fits(), tried = true) {
9188
9261
  const failed = [];
9189
- const visit = (group, seen) => {
9190
- if (group.length === 0) return;
9262
+ const visit = (group2, seen) => {
9263
+ if (group2.length === 0) return;
9191
9264
  if (!seen) {
9192
- swap(group, true);
9265
+ swap(group2, true);
9193
9266
  if (check()) return;
9194
- swap(group, false);
9267
+ swap(group2, false);
9195
9268
  }
9196
- if (group.length === 1 || this.budget <= 0) {
9197
- failed.push(...group);
9269
+ if (group2.length === 1 || this.budget <= 0) {
9270
+ failed.push(...group2);
9198
9271
  return;
9199
9272
  }
9200
- const half = group.length >> 1;
9201
- visit(group.slice(0, half), false);
9202
- visit(group.slice(half), false);
9273
+ const half = group2.length >> 1;
9274
+ visit(group2.slice(0, half), false);
9275
+ visit(group2.slice(half), false);
9203
9276
  };
9204
9277
  visit(items, tried);
9205
9278
  return failed;
@@ -9239,8 +9312,8 @@ var Trial = class {
9239
9312
  const differs = own.flatMap((lines, k) => sameLines(lines, theirs[k]) ? [] : [k]);
9240
9313
  const failed = this.bisect(
9241
9314
  differs,
9242
- (group, useLocal) => {
9243
- 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]];
9244
9317
  },
9245
9318
  () => put(chosen.flat())
9246
9319
  );
@@ -9309,8 +9382,8 @@ var Trial = class {
9309
9382
  );
9310
9383
  this.bisect(
9311
9384
  spots,
9312
- (group, useLocal) => {
9313
- for (const spot of group) {
9385
+ (group2, useLocal) => {
9386
+ for (const spot of group2) {
9314
9387
  chosen[spot.index] = [...useLocal ? spot.local : spot.pulled];
9315
9388
  }
9316
9389
  },
@@ -10283,8 +10356,8 @@ function parseRows(body) {
10283
10356
  }
10284
10357
  function parseCols(body) {
10285
10358
  const head = body.search(ROW_START_RE);
10286
- const group = COLGROUP_RE.exec(head === -1 ? body : body.slice(0, head))?.[1] ?? "";
10287
- 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] ?? ""));
10288
10361
  }
10289
10362
  function sameAttrs(a, b) {
10290
10363
  return a.length === b.length && a.every(([k, v]) => b.some(([bk, bv]) => bk === k && bv === v));
@@ -13604,6 +13677,44 @@ function keepLargeFile(content, fileLink, download, pageId, blockId) {
13604
13677
  return withLargeFileLink(content, fileLink, link);
13605
13678
  }
13606
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
+ }
13717
+
13607
13718
  // src/view/notion-filter-translator.ts
13608
13719
  function translateNotionFilter(filter, schema, resolveName) {
13609
13720
  const ctx = { resolveName, schema, untranslated: /* @__PURE__ */ new Set() };
@@ -13651,7 +13762,7 @@ function translateNode(node, ctx) {
13651
13762
  function translateLeaf(leaf2, ctx) {
13652
13763
  const timestamp = leaf2["timestamp"];
13653
13764
  if (typeof timestamp === "string") {
13654
- const ref2 = timestamp === "created_time" ? "file.ctime" : "file.mtime";
13765
+ const ref2 = BASES_TIMESTAMPS[timestamp === "created_time" ? "created_time" : "last_edited_time"];
13655
13766
  const cond = leaf2[timestamp];
13656
13767
  if (!isRecord(cond)) return unsupported(ctx, `timestamp '${timestamp}' \uD544\uD130`);
13657
13768
  return translateDate(ref2, cond, ctx, `timestamp '${timestamp}'`);
@@ -13775,8 +13886,8 @@ function translateStatus(ref, cond, ctx, label, propName) {
13775
13886
  expanded.push(name);
13776
13887
  continue;
13777
13888
  }
13778
- const group = prop?.groups?.find((g) => g.name === name);
13779
- 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;
13780
13891
  if (members === null) {
13781
13892
  return unsupported(ctx, `${label}: \uC0C1\uD0DC '${name}' \uC744 \uC635\uC158\uC73C\uB85C \uC804\uAC1C\uD558\uC9C0 \uBABB\uD568`);
13782
13893
  }
@@ -13787,8 +13898,8 @@ function translateStatus(ref, cond, ctx, label, propName) {
13787
13898
  }
13788
13899
  return equalityOverNames(ref, op, dedupe(expanded), ctx, label);
13789
13900
  }
13790
- function expandGroup(group, prop) {
13791
- const ids = group.optionIds;
13901
+ function expandGroup(group2, prop) {
13902
+ const ids = group2.optionIds;
13792
13903
  if (!ids || ids.length === 0) return [];
13793
13904
  const byId = new Map((prop.options ?? []).map((o) => [o.id, o.name]));
13794
13905
  const names = [];
@@ -13828,29 +13939,32 @@ function translateEmptiness(ref, cond, ctx, label) {
13828
13939
  const [op] = firstEntry(cond);
13829
13940
  return unsupported(ctx, `${label}: \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
13830
13941
  }
13942
+ var DATE_COMPARISONS = {
13943
+ equals: "==",
13944
+ before: "<",
13945
+ after: ">",
13946
+ on_or_before: "<=",
13947
+ on_or_after: ">="
13948
+ };
13831
13949
  function translateDate(ref, cond, ctx, label) {
13832
13950
  const empt = translateEmptinessOnly(ref, cond);
13833
13951
  if (empt) return empt;
13834
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) };
13835
13955
  if (typeof value !== "string") {
13836
13956
  return unsupported(ctx, `${label}: \uC0C1\uB300 \uB0A0\uC9DC \uC870\uAC74 '${op}' \uC740 \uC62E\uAE30\uC9C0 \uC54A\uC74C`);
13837
13957
  }
13838
- const lit = str(value);
13839
- switch (op) {
13840
- case "equals":
13841
- return `${ref} == ${lit}`;
13842
- case "before":
13843
- return `${ref} < ${lit}`;
13844
- case "after":
13845
- return `${ref} > ${lit}`;
13846
- case "on_or_before":
13847
- return `${ref} <= ${lit}`;
13848
- case "on_or_after":
13849
- return `${ref} >= ${lit}`;
13850
- default:
13851
- 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`);
13852
13964
  }
13965
+ return `${ref} ${symbol} ${str(value)}`;
13853
13966
  }
13967
+ var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+(?:Z|[+-]\d{2}:?\d{2})?)?$/;
13854
13968
  function translateEmptinessOnly(ref, cond) {
13855
13969
  if (cond["is_empty"] === true) return `${ref}.isEmpty()`;
13856
13970
  if (cond["is_not_empty"] === true) return `!${ref}.isEmpty()`;
@@ -13938,6 +14052,10 @@ function inFolderFilter(folderPath) {
13938
14052
  var IN_FOLDER_FILTER_RE = /file\.inFolder\("([^"\n]*)"\)/g;
13939
14053
  var BaseFileGenerator = class {
13940
14054
  generate(options) {
14055
+ return this.generateWithViews(options).content;
14056
+ }
14057
+ /** `.base` 내용과 함께 쓴 보기마다 어느 Notion 보기에서 왔는지 돌려준다 — pull 이 기록해 push 가 견준다. */
14058
+ generateWithViews(options) {
13941
14059
  const lines = [];
13942
14060
  lines.push(`${BASE_FILE_HEADER} from Notion DB: ${options.databaseName}`);
13943
14061
  lines.push(`# Database ID: ${options.databaseId}`);
@@ -13945,8 +14063,13 @@ var BaseFileGenerator = class {
13945
14063
  this.writeFilters(lines, options.folderPath);
13946
14064
  this.writeFormulas(lines, options.viewsConfig.views);
13947
14065
  this.writePropertyDisplayNames(lines, options.schema);
13948
- this.writeViews(lines, options.viewsConfig.views, options.schema, options.rangeEnds);
13949
- 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 };
13950
14073
  }
13951
14074
  writeFilters(lines, folderPath) {
13952
14075
  lines.push("filters:");
@@ -13977,12 +14100,13 @@ var BaseFileGenerator = class {
13977
14100
  lines.push("");
13978
14101
  }
13979
14102
  writeViews(lines, views, schema, rangeEnds = /* @__PURE__ */ new Map()) {
13980
- 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);
13981
14105
  if (basesViews.length === 0) {
13982
14106
  lines.push("views:");
13983
14107
  lines.push(" - type: table");
13984
14108
  lines.push(" name: Table");
13985
- return;
14109
+ return [];
13986
14110
  }
13987
14111
  this.dedupeViewNames(basesViews);
13988
14112
  lines.push("views:");
@@ -14014,6 +14138,7 @@ var BaseFileGenerator = class {
14014
14138
  this.writeFilterValue(lines, view.filters, " ");
14015
14139
  }
14016
14140
  }
14141
+ return converted.map(({ viewId, lossyFilter }) => ({ viewId, lossyFilter }));
14017
14142
  }
14018
14143
  /** `filters:` 키와 그 값을 쓴다. 단일 식이면 한 줄, 그룹이면 블록. */
14019
14144
  writeFilterValue(lines, node, indent) {
@@ -14073,6 +14198,7 @@ var BaseFileGenerator = class {
14073
14198
  convertView(view, schema, rangeEnds) {
14074
14199
  const basesType = basesViewMappingOf(view.type).type;
14075
14200
  if (!basesType) return null;
14201
+ let lossyFilter = false;
14076
14202
  const result = {
14077
14203
  type: basesType,
14078
14204
  name: view.name || view.type
@@ -14086,7 +14212,7 @@ var BaseFileGenerator = class {
14086
14212
  return end === void 0 ? [this.toNoteRef(name)] : [this.toNoteRef(name), this.toNoteRef(end)];
14087
14213
  });
14088
14214
  if (visible.length > 0) {
14089
- result.order = ["file.name", ...visible];
14215
+ result.order = [BASES_TITLE, ...visible];
14090
14216
  }
14091
14217
  }
14092
14218
  if (view.sorts && view.sorts.length > 0) {
@@ -14096,7 +14222,7 @@ var BaseFileGenerator = class {
14096
14222
  const propName = this.resolvePropertyName(s.property, schema) ?? s.property;
14097
14223
  property = this.toNoteRef(propName);
14098
14224
  } else if (s.timestamp) {
14099
- property = s.timestamp === "created_time" ? "file.ctime" : "file.mtime";
14225
+ property = BASES_TIMESTAMPS[s.timestamp];
14100
14226
  } else {
14101
14227
  return null;
14102
14228
  }
@@ -14132,14 +14258,15 @@ var BaseFileGenerator = class {
14132
14258
  }
14133
14259
  }
14134
14260
  if (view.filter !== void 0 && view.filter !== null) {
14135
- const { node } = translateNotionFilter(
14261
+ const { node, untranslated } = translateNotionFilter(
14136
14262
  view.filter,
14137
14263
  schema,
14138
14264
  (id) => this.resolvePropertyName(id, schema)
14139
14265
  );
14140
14266
  if (node) result.filters = node;
14267
+ lossyFilter = untranslated.length > 0 || node === null;
14141
14268
  }
14142
- return result;
14269
+ return { view: result, viewId: view.id, lossyFilter };
14143
14270
  }
14144
14271
  toNoteRef(propertyName) {
14145
14272
  return propertyName;
@@ -15230,18 +15357,17 @@ var DbBaseFiles = class {
15230
15357
  get(databaseId) {
15231
15358
  return this.load().get(compactNotionId(databaseId));
15232
15359
  }
15360
+ /** 기록한 DB 모두 — 키는 줄인 DB id. */
15361
+ all() {
15362
+ return [...this.load().entries()];
15363
+ }
15233
15364
  set(databaseId, info) {
15234
15365
  const entries = this.load();
15235
15366
  const key = compactNotionId(databaseId);
15236
15367
  const known = entries.get(key);
15237
- if (known?.basePath === info.basePath && known.title === info.title && known.hash === info.hash) {
15238
- return;
15239
- }
15240
- entries.set(key, {
15241
- basePath: info.basePath,
15242
- title: info.title,
15243
- ...info.hash ? { hash: info.hash } : {}
15244
- });
15368
+ const next = normalize(info);
15369
+ if (known && JSON.stringify(known) === JSON.stringify(next)) return;
15370
+ entries.set(key, next);
15245
15371
  this.loadedRaw = JSON.stringify(Object.fromEntries(entries));
15246
15372
  this.stateDb.setMeta(DB_BASE_FILES_META_KEY, this.loadedRaw);
15247
15373
  }
@@ -15258,11 +15384,16 @@ var DbBaseFiles = class {
15258
15384
  for (const [key, value] of Object.entries(parsed)) {
15259
15385
  const entry = value;
15260
15386
  if (typeof entry?.basePath === "string" && typeof entry.title === "string") {
15261
- this.entries.set(compactNotionId(key), {
15262
- basePath: entry.basePath,
15263
- title: entry.title,
15264
- ...typeof entry.hash === "string" ? { hash: entry.hash } : {}
15265
- });
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
+ );
15266
15397
  }
15267
15398
  }
15268
15399
  } catch {
@@ -15270,182 +15401,761 @@ var DbBaseFiles = class {
15270
15401
  return this.entries;
15271
15402
  }
15272
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
+ }
15273
15413
 
15274
- // src/sync/discovered-databases.ts
15275
- var ROOT_DATABASES_FOLDER = "databases";
15276
- var DISCOVERED_DBS_META_KEY = "discovered_dbs";
15277
- var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
15278
- var LINKED_DBS_META_KEY = "linked_dbs";
15279
- function parseDiscoveredDbs(raw) {
15280
- 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;
15281
15418
  try {
15282
- const parsed = JSON.parse(raw);
15283
- return Array.isArray(parsed) ? parsed : [];
15419
+ doc = parseFrontmatter(`---
15420
+ ${text}
15421
+ ---
15422
+ `).data;
15284
15423
  } catch {
15285
- return [];
15424
+ return null;
15286
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 };
15287
15430
  }
15288
- function loadInaccessibleDbIds(stateDb) {
15289
- const raw = stateDb.getMeta(INACCESSIBLE_DBS_META_KEY);
15290
- if (!raw) return /* @__PURE__ */ new Set();
15291
- try {
15292
- const arr = JSON.parse(raw);
15293
- return new Set(arr.map((id) => id.replace(/-/g, "")));
15294
- } catch {
15295
- return /* @__PURE__ */ new Set();
15296
- }
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
+ };
15297
15446
  }
15298
- function loadLinkedDbMap(stateDb) {
15299
- const raw = stateDb.getMeta(LINKED_DBS_META_KEY);
15300
- if (!raw) return /* @__PURE__ */ new Map();
15301
- try {
15302
- const obj = JSON.parse(raw);
15303
- return new Map(Object.entries(obj).map(([k, v]) => [k.replace(/-/g, ""), v.replace(/-/g, "")]));
15304
- } catch {
15305
- return /* @__PURE__ */ new Map();
15306
- }
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;
15307
15454
  }
15308
-
15309
- // src/sync/db-folder-moves.ts
15310
- function rewriteDatabaseFolders(stateDb, moves) {
15311
- const folders = moves.filter((move) => !isMarkdownPath(move.from));
15312
- if (folders.length === 0) return;
15313
- const databases = parseDiscoveredDbs(stateDb.getMeta(DISCOVERED_DBS_META_KEY));
15314
- let changed = false;
15315
- for (const db of databases) {
15316
- const folder = db.localFolder.replace(/\/+$/, "");
15317
- const to = finalPath(folder, folders);
15318
- if (to === folder) continue;
15319
- db.localFolder = to;
15320
- changed = true;
15321
- }
15322
- if (changed) stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(databases));
15323
- const raw = stateDb.getMeta(DB_BASE_FILES_META_KEY);
15324
- if (!raw) return;
15325
- let entries;
15326
- try {
15327
- entries = JSON.parse(raw);
15328
- } catch {
15329
- return;
15330
- }
15331
- if (!entries || typeof entries !== "object") return;
15332
- let moved = false;
15333
- for (const entry of Object.values(entries)) {
15334
- if (typeof entry?.basePath !== "string") continue;
15335
- const to = finalPath(entry.basePath, folders);
15336
- if (to === entry.basePath) continue;
15337
- entry.basePath = to;
15338
- moved = true;
15339
- }
15340
- if (moved) stateDb.setMeta(DB_BASE_FILES_META_KEY, JSON.stringify(entries));
15455
+ function columnName(raw) {
15456
+ const name = String(raw ?? "");
15457
+ return name.startsWith("note.") ? name.slice("note.".length) : name;
15341
15458
  }
15342
- async function retargetBaseFiles(vaultFs, moves) {
15343
- const folders = moves.filter((move) => !isMarkdownPath(move.from));
15344
- if (folders.length === 0) return;
15345
- try {
15346
- for (const file of await vaultFs.listNonMarkdownFiles()) {
15347
- if (!file.path.endsWith(BASE_SUFFIX)) continue;
15348
- const content = await vaultFs.readFile(file.path);
15349
- if (!isGeneratedDbArtifact(file.path, content)) continue;
15350
- const next = content.replace(IN_FOLDER_FILTER_RE, (whole, folder) => {
15351
- const to = finalPath(folder, folders);
15352
- return to === folder ? whole : inFolderFilter(to);
15353
- });
15354
- if (next !== content) await vaultFs.writeFile(file.path, next);
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;
15355
15472
  }
15356
- } catch (error) {
15357
- getLogger().warn(
15358
- `[Im-Nobsidian] \uC62E\uAE34 \uD3F4\uB354\uB97C \uAC00\uB9AC\uD0A4\uB358 .base \uBCF4\uAE30\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568: ${error instanceof Error ? error.message : String(error)}`
15359
- );
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
+ });
15360
15511
  }
15512
+ return { views: records, rest: restSignature(parsed, records) };
15361
15513
  }
15362
- function finalPath(path, moves) {
15363
- let current = path;
15364
- for (const move of moves) {
15365
- if (current === move.from || current.startsWith(`${move.from}/`)) {
15366
- current = move.to + current.slice(move.from.length);
15367
- }
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
+ );
15368
15524
  }
15369
- return current;
15525
+ return value;
15370
15526
  }
15371
15527
 
15372
- // src/sync/note-relocation.ts
15373
- var NoteRelocator = class {
15374
- constructor(stateDb, vaultFs, configuredDatabases = []) {
15528
+ // src/sync/sidecar-bases.ts
15529
+ var SIDECAR_BASES_META_KEY = "sidecar_bases";
15530
+ var SidecarBases = class {
15531
+ constructor(stateDb) {
15375
15532
  this.stateDb = stateDb;
15376
- this.vaultFs = vaultFs;
15377
- this.configuredDatabases = configuredDatabases;
15378
15533
  }
15379
15534
  stateDb;
15380
- vaultFs;
15381
- configuredDatabases;
15382
- /**
15383
- * 함께 옮길 것 — 폴더로 추적하는 페이지는 그 폴더, 폴더 노트(`X/X.md`)는 든 폴더, 노트는 그 파일과 옆의 자식
15384
- * 폴더(`X/`, 있으면).
15385
- */
15386
- unitsOf(record) {
15387
- if (isFolderRecord(record)) return [record.obsidianPath];
15388
- if (record.fileType !== "file") return [parentFolderOf(record.obsidianPath)];
15389
- const children = stripMarkdownExtension(record.obsidianPath);
15390
- const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children));
15391
- return hasChildren ? [record.obsidianPath, children] : [record.obsidianPath];
15535
+ get(databaseId) {
15536
+ return this.load()[compactNotionId(databaseId)];
15392
15537
  }
15393
- /** 옮기지 못할 까닭 — 없으면 null. */
15394
- async blocked(move) {
15395
- if (within(move.to, move.from)) return "\uC81C \uC548\uC73C\uB85C \uC62E\uAE38 \uC218 \uC5C6\uB2E4";
15396
- if (!await this.vaultFs.exists(move.from)) return "\uBCFC\uD2B8\uC5D0 \uB178\uD2B8\uAC00 \uC5C6\uB2E4";
15397
- if (await this.taken(move.to)) return `\uC62E\uAE38 \uC790\uB9AC(${move.to})\uC5D0 \uAC19\uC740 \uC774\uB984\uC774 \uC788\uB2E4`;
15398
- const isFolder = !isMarkdownPath(move.from);
15399
- if (this.vaultFs.excludesPath?.(move.to, isFolder ? "folder" : "file")) {
15400
- return `\uC62E\uAE38 \uC790\uB9AC(${move.to})\uAC00 \uB3D9\uAE30\uD654\uC5D0\uC11C \uBE60\uC9C4\uB2E4(paths \xB7 .im-nobsidian-ignore)`;
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 {};
15401
15565
  }
15402
- if (isFolder && this.holdsConfiguredDatabase(move.from)) {
15403
- return "\uD3F4\uB354 \uC548\uC5D0 \uC124\uC815 \uD30C\uC77C\uB85C \uC815\uD55C DB \uD3F4\uB354(notion.databases)\uAC00 \uC788\uB2E4 \u2014 \uC124\uC815\uC758 localFolder \uB97C \uACE0\uCE5C \uB4A4 Obsidian \uC5D0\uC11C \uC62E\uAE30\uBA74 \uB2E4\uC74C push \uAC00 Notion \uC744 \uB9DE\uCD98\uB2E4";
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);
15404
15576
  }
15405
- if (isFolder && (this.vaultFs.holdsOtherConnection?.(move.from) ?? false)) {
15406
- return "\uD3F4\uB354 \uC548\uC5D0 \uB2E4\uB978 \uC5F0\uACB0 \uD3F4\uB354\uAC00 \uC788\uB2E4 \u2014 Obsidian \uC5D0\uC11C \uC62E\uAE30\uBA74 \uB2E4\uC74C push \uAC00 Notion \uC744 \uB9DE\uCD98\uB2E4";
15577
+ } else {
15578
+ for (const ds of meta.dataSources) {
15579
+ if (ds.properties) seen[compactNotionId(ds.id)] = schemaOf(ds.properties);
15407
15580
  }
15408
- return null;
15409
15581
  }
15410
- /** 이 경로에 파일 · 폴더가 있거나 추적하는 것이 있다. */
15411
- async taken(path) {
15412
- return await this.vaultFs.exists(path) || this.stateDb.getAll().some((r) => within(r.obsidianPath, path));
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;
15413
15614
  }
15414
- /**
15415
- * 차례로 옮기고 상태 DB 를 새 경로로 적는다. 하나라도 실패하면 옮긴 것을 되돌리고 던진다 — 상태 DB 는 옛 경로 그대로다.
15416
- * @param alsoInTransaction 경로를 옮겨 적는 트랜잭션 안에서 함께 할 것.
15417
- */
15418
- async relocate(moves, alsoInTransaction) {
15419
- const done = [];
15420
- try {
15421
- for (const move of moves) {
15422
- await this.vaultFs.moveFile(move.from, move.to);
15423
- done.push(move);
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);
15424
15632
  }
15425
- } catch (error) {
15426
- for (const move of done.reverse())
15427
- await this.vaultFs.moveFile(move.to, move.from).catch(() => {
15428
- });
15429
- throw error;
15430
15633
  }
15431
- this.stateDb.transaction(() => {
15432
- for (const move of moves) this.rewritePaths(move);
15433
- rewriteDatabaseFolders(this.stateDb, moves);
15434
- alsoInTransaction?.();
15435
- });
15436
- await retargetBaseFiles(this.vaultFs, moves);
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
+ }
15437
15638
  }
15438
- holdsConfiguredDatabase(folder) {
15439
- return this.configuredDatabases.some(
15440
- (db) => within(db.localFolder.replace(/\/+$/, ""), folder)
15441
- );
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);
15442
15641
  }
15443
- /** 옮긴 것 아래의 레코드 · 위키링크 · 보존 마커 · 첨부 기록을 새 경로로 옮겨 적는다. */
15444
- rewritePaths(move) {
15445
- for (const record of this.stateDb.getAll()) {
15446
- if (!within(record.obsidianPath, move.from)) continue;
15447
- const from = record.obsidianPath;
15448
- const to = move.to + from.slice(move.from.length);
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;
16030
+ changed = true;
16031
+ }
16032
+ if (changed) stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(databases));
16033
+ const raw = stateDb.getMeta(DB_BASE_FILES_META_KEY);
16034
+ if (!raw) return;
16035
+ let entries;
16036
+ try {
16037
+ entries = JSON.parse(raw);
16038
+ } catch {
16039
+ return;
16040
+ }
16041
+ if (!entries || typeof entries !== "object") return;
16042
+ let moved = false;
16043
+ for (const entry of Object.values(entries)) {
16044
+ if (typeof entry?.basePath !== "string") continue;
16045
+ const to = finalPath(entry.basePath, folders);
16046
+ if (to === entry.basePath) continue;
16047
+ entry.basePath = to;
16048
+ moved = true;
16049
+ }
16050
+ if (moved) stateDb.setMeta(DB_BASE_FILES_META_KEY, JSON.stringify(entries));
16051
+ }
16052
+ async function retargetBaseFiles(vaultFs, moves) {
16053
+ const folders = moves.filter((move) => !isMarkdownPath(move.from));
16054
+ if (folders.length === 0) return;
16055
+ try {
16056
+ for (const file of await vaultFs.listNonMarkdownFiles()) {
16057
+ if (!file.path.endsWith(BASE_SUFFIX)) continue;
16058
+ const content = await vaultFs.readFile(file.path);
16059
+ if (!isGeneratedDbArtifact(file.path, content)) continue;
16060
+ const next = content.replace(IN_FOLDER_FILTER_RE, (whole, folder) => {
16061
+ const to = finalPath(folder, folders);
16062
+ return to === folder ? whole : inFolderFilter(to);
16063
+ });
16064
+ if (next !== content) await vaultFs.writeFile(file.path, next);
16065
+ }
16066
+ } catch (error) {
16067
+ getLogger().warn(
16068
+ `[Im-Nobsidian] \uC62E\uAE34 \uD3F4\uB354\uB97C \uAC00\uB9AC\uD0A4\uB358 .base \uBCF4\uAE30\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568: ${error instanceof Error ? error.message : String(error)}`
16069
+ );
16070
+ }
16071
+ }
16072
+ function finalPath(path, moves) {
16073
+ let current = path;
16074
+ for (const move of moves) {
16075
+ if (current === move.from || current.startsWith(`${move.from}/`)) {
16076
+ current = move.to + current.slice(move.from.length);
16077
+ }
16078
+ }
16079
+ return current;
16080
+ }
16081
+
16082
+ // src/sync/note-relocation.ts
16083
+ var NoteRelocator = class {
16084
+ constructor(stateDb, vaultFs, configuredDatabases = []) {
16085
+ this.stateDb = stateDb;
16086
+ this.vaultFs = vaultFs;
16087
+ this.configuredDatabases = configuredDatabases;
16088
+ }
16089
+ stateDb;
16090
+ vaultFs;
16091
+ configuredDatabases;
16092
+ /**
16093
+ * 함께 옮길 것 — 폴더로 추적하는 페이지는 그 폴더, 폴더 노트(`X/X.md`)는 든 폴더, 노트는 그 파일과 옆의 자식
16094
+ * 폴더(`X/`, 있으면).
16095
+ */
16096
+ unitsOf(record) {
16097
+ if (isFolderRecord(record)) return [record.obsidianPath];
16098
+ if (record.fileType !== "file") return [parentFolderOf(record.obsidianPath)];
16099
+ const children = stripMarkdownExtension(record.obsidianPath);
16100
+ const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children));
16101
+ return hasChildren ? [record.obsidianPath, children] : [record.obsidianPath];
16102
+ }
16103
+ /** 옮기지 못할 까닭 — 없으면 null. */
16104
+ async blocked(move) {
16105
+ if (within(move.to, move.from)) return "\uC81C \uC548\uC73C\uB85C \uC62E\uAE38 \uC218 \uC5C6\uB2E4";
16106
+ if (!await this.vaultFs.exists(move.from)) return "\uBCFC\uD2B8\uC5D0 \uB178\uD2B8\uAC00 \uC5C6\uB2E4";
16107
+ if (await this.taken(move.to)) return `\uC62E\uAE38 \uC790\uB9AC(${move.to})\uC5D0 \uAC19\uC740 \uC774\uB984\uC774 \uC788\uB2E4`;
16108
+ const isFolder = !isMarkdownPath(move.from);
16109
+ if (this.vaultFs.excludesPath?.(move.to, isFolder ? "folder" : "file")) {
16110
+ return `\uC62E\uAE38 \uC790\uB9AC(${move.to})\uAC00 \uB3D9\uAE30\uD654\uC5D0\uC11C \uBE60\uC9C4\uB2E4(paths \xB7 .im-nobsidian-ignore)`;
16111
+ }
16112
+ if (isFolder && this.holdsConfiguredDatabase(move.from)) {
16113
+ return "\uD3F4\uB354 \uC548\uC5D0 \uC124\uC815 \uD30C\uC77C\uB85C \uC815\uD55C DB \uD3F4\uB354(notion.databases)\uAC00 \uC788\uB2E4 \u2014 \uC124\uC815\uC758 localFolder \uB97C \uACE0\uCE5C \uB4A4 Obsidian \uC5D0\uC11C \uC62E\uAE30\uBA74 \uB2E4\uC74C push \uAC00 Notion \uC744 \uB9DE\uCD98\uB2E4";
16114
+ }
16115
+ if (isFolder && (this.vaultFs.holdsOtherConnection?.(move.from) ?? false)) {
16116
+ return "\uD3F4\uB354 \uC548\uC5D0 \uB2E4\uB978 \uC5F0\uACB0 \uD3F4\uB354\uAC00 \uC788\uB2E4 \u2014 Obsidian \uC5D0\uC11C \uC62E\uAE30\uBA74 \uB2E4\uC74C push \uAC00 Notion \uC744 \uB9DE\uCD98\uB2E4";
16117
+ }
16118
+ return null;
16119
+ }
16120
+ /** 이 경로에 파일 · 폴더가 있거나 추적하는 것이 있다. */
16121
+ async taken(path) {
16122
+ return await this.vaultFs.exists(path) || this.stateDb.getAll().some((r) => within(r.obsidianPath, path));
16123
+ }
16124
+ /**
16125
+ * 차례로 옮기고 상태 DB 를 새 경로로 적는다. 하나라도 실패하면 옮긴 것을 되돌리고 던진다 — 상태 DB 는 옛 경로 그대로다.
16126
+ * @param alsoInTransaction 경로를 옮겨 적는 트랜잭션 안에서 함께 할 것.
16127
+ */
16128
+ async relocate(moves, alsoInTransaction) {
16129
+ const done = [];
16130
+ try {
16131
+ for (const move of moves) {
16132
+ await this.vaultFs.moveFile(move.from, move.to);
16133
+ done.push(move);
16134
+ }
16135
+ } catch (error) {
16136
+ for (const move of done.reverse())
16137
+ await this.vaultFs.moveFile(move.to, move.from).catch(() => {
16138
+ });
16139
+ throw error;
16140
+ }
16141
+ this.stateDb.transaction(() => {
16142
+ for (const move of moves) this.rewritePaths(move);
16143
+ rewriteDatabaseFolders(this.stateDb, moves);
16144
+ alsoInTransaction?.();
16145
+ });
16146
+ await retargetBaseFiles(this.vaultFs, moves);
16147
+ }
16148
+ holdsConfiguredDatabase(folder) {
16149
+ return this.configuredDatabases.some(
16150
+ (db) => within(db.localFolder.replace(/\/+$/, ""), folder)
16151
+ );
16152
+ }
16153
+ /** 옮긴 것 아래의 레코드 · 위키링크 · 보존 마커 · 첨부 기록을 새 경로로 옮겨 적는다. */
16154
+ rewritePaths(move) {
16155
+ for (const record of this.stateDb.getAll()) {
16156
+ if (!within(record.obsidianPath, move.from)) continue;
16157
+ const from = record.obsidianPath;
16158
+ const to = move.to + from.slice(move.from.length);
15449
16159
  this.stateDb.updatePath(record.id, to);
15450
16160
  const entry = record.notionPageId ? this.stateDb.resolvePageId(record.notionPageId) : null;
15451
16161
  if (entry && entry.obsidianPath === from) {
@@ -15896,6 +16606,8 @@ var DatabaseSyncer = class {
15896
16606
  this.observation = observation;
15897
16607
  this.noteRowBody = noteRowBody;
15898
16608
  this.baseFileInfo = new DbBaseFiles(stateDb);
16609
+ this.sidecarBases = new SidecarBases(stateDb);
16610
+ this.schemaLedger = new SchemaLedger(stateDb);
15899
16611
  this.relocator = new NoteRelocator(stateDb, vaultFs, config.notion.databases ?? []);
15900
16612
  this.propertyMapper = PropertyMapper.fromConfig(config);
15901
16613
  this.propertyMapper.setWikilinkResolver({
@@ -15935,11 +16647,21 @@ var DatabaseSyncer = class {
15935
16647
  };
15936
16648
  /** 볼트에서 사라져 되살린 행의 경로 — pull 마감이 가져가 화면에 알린다(D8). */
15937
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();
15938
16658
  /**
15939
16659
  * 실제로 기록한 .base 경로/DB 제목 — placeholder 임베드 재작성(F22)의 SSOT. 이번 pull 이 조회하지
15940
16660
  * 않은 DB 의 것도 남아 있다({@link DbBaseFiles}).
15941
16661
  */
15942
16662
  baseFileInfo;
16663
+ /** DB 마다 지난 pull 이 본 속성 — Notion 에서 바꾼 속성 이름을 행 노트에 옮기는 근거(ADR-045). */
16664
+ schemaLedger;
15943
16665
  get decorations() {
15944
16666
  return this.decorationsCache ??= new PageDecorations(this.notionClient, this.imageHandler);
15945
16667
  }
@@ -16005,12 +16727,14 @@ var DatabaseSyncer = class {
16005
16727
  const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId, meta);
16006
16728
  this.propertyMapper.loadSchema(schema);
16007
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;
16008
16732
  if (wholeDbInScope) {
16009
16733
  const viewsConfig = await this.pullDatabaseViews(dbConfig);
16010
16734
  await this.generateBaseFile(dbConfig, viewsConfig, meta, void 0, this.rangeEndsOf(pages));
16011
16735
  }
16012
16736
  let created = 0;
16013
- let updated = 0;
16737
+ let updated = followed.rekeyed.size;
16014
16738
  let restored = 0;
16015
16739
  const conflicts = [];
16016
16740
  const failed = [];
@@ -16025,7 +16749,14 @@ var DatabaseSyncer = class {
16025
16749
  for (const page of pages) {
16026
16750
  try {
16027
16751
  const record = this.stateDb.getByNotionId(page.id);
16028
- 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
+ );
16029
16760
  if (action !== "skip") pending.push({ page, action });
16030
16761
  else if (record && (wholeDbInScope || inAnyPathScope(record.obsidianPath, scope))) {
16031
16762
  unchanged.push(page);
@@ -16044,7 +16775,7 @@ var DatabaseSyncer = class {
16044
16775
  try {
16045
16776
  const record = this.stateDb.getByNotionId(page.id);
16046
16777
  const outcome = await withDeadline(
16047
- () => 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),
16048
16779
  this.config.advanced.itemTimeoutMs,
16049
16780
  `pull ${dbConfig.localFolder}/${page.id}`
16050
16781
  );
@@ -16055,7 +16786,7 @@ var DatabaseSyncer = class {
16055
16786
  if (action === "restore") {
16056
16787
  restored++;
16057
16788
  this.restoredRows.push(outcome.path);
16058
- } else updated++;
16789
+ } else if (!followed.rekeyed.has(record.id)) updated++;
16059
16790
  writtenPaths.push(outcome.path);
16060
16791
  } else if (outcome.action === "conflict") {
16061
16792
  conflicts.push(outcome.conflict);
@@ -16218,32 +16949,35 @@ var DatabaseSyncer = class {
16218
16949
  * (틀린) 전제로 db-row 를 제외해 두었기에 양쪽 어디에도 복원 경로가 없는 상태였다.
16219
16950
  * deleteSync 가 켜져 있으면 되살리지 않는다 — 지운 것은 Notion 에서도 지우라는 뜻이고, sync 는
16220
16951
  * pull 을 먼저 돌려 되살리면 뒤이은 push 가 지울 것을 잃는다(detectMissingLocalFiles 와 같다).
16221
- * · 예전 버전이 적은 날짜 모양이 남은 행은 그 줄만 새 모양으로 고쳐 쓴다({@link outdatedDateForm},
16222
- * F-08).
16952
+ * · 예전 버전이 적은 날짜 모양이 남은 행, Notion 이 새로 만들거나 타입을 바꾼 속성의 값이 다른 행은 그 줄만
16953
+ * 고쳐 쓴다({@link staleKeysToRewrite}, F-08 · ADR-045).
16954
+ *
16955
+ * @param refreshed 이 행의 data source 에서 새로 생기거나 타입이 바뀐 속성의 키(ADR-045).
16223
16956
  */
16224
- async rowAction(dbConfig, page, record, wholeDbInScope, scope) {
16957
+ async rowAction(dbConfig, page, record, wholeDbInScope, scope, refreshed) {
16225
16958
  if (!wholeDbInScope && !(record && inAnyPathScope(record.obsidianPath, scope))) return "skip";
16226
16959
  if (record && this.vaultFs.excludesPath?.(record.obsidianPath)) return "skip";
16227
16960
  if (!record) return "pull";
16228
16961
  const misplaced = !isDirectDbRowPath(dbConfig.localFolder, record.obsidianPath);
16229
16962
  const changed = misplaced || this.remoteVerdict(record, page) !== "unchanged";
16230
- if (!changed && await this.outdatedDateForm(record, page)) return "reform";
16963
+ if (!changed && await this.staleKeysToRewrite(record, page, refreshed)) return "reform";
16231
16964
  if (this.config.sync.deleteSync || await this.vaultFs.exists(record.obsidianPath)) {
16232
16965
  return changed ? "pull" : "skip";
16233
16966
  }
16234
16967
  return "restore";
16235
16968
  }
16236
16969
  /**
16237
- * 원격이 그대로인 행이지만 예전 버전이 적은 날짜 모양(`…Z` · `…+09:00` · `{start, end}`)이 남아 새
16238
- * 모양으로 고쳐 쓸 행인가(F-08, {@link PropertyMapper.outdatedDateKeys}). 고쳐 쓰면 사본도 새 모양이
16239
- * 되어 다음 pull 은 건너뛴다.
16970
+ * 원격이 그대로인 행이지만 그 줄만 고쳐 쓸 키가 있는가 — 예전 버전이 적은 날짜 모양(`…Z` · `…+09:00` ·
16971
+ * `{start, end}`, F-08, {@link PropertyMapper.outdatedDateKeys}), 또는 Notion 이 새로 만들거나 타입을 바꾼
16972
+ * 속성의 값이 적힌 것과 다른 키(ADR-045 — 수식 열을 더하면 행마다 값이 생기지만 행의 수정 시각은 그대로다).
16973
+ * 고쳐 쓰면 사본도 새 값이 되어 다음 pull 은 건너뛴다.
16240
16974
  *
16241
16975
  * 지난 동기화 뒤 로컬을 고친 행은 고르지 않는다 — 로컬 편집을 지키느라 쓰지 않으니 pull 마다 고르기만
16242
16976
  * 한다. 그 편집을 올린 뒤의 pull 이 고른다. 로컬 파일을 읽지 못하면 고르지 않는다.
16243
16977
  */
16244
- async outdatedDateForm(record, page) {
16978
+ async staleKeysToRewrite(record, page, refreshed) {
16245
16979
  const base = snapshotFrontmatter(record.baseSnapshot);
16246
- if (base === null || this.propertyMapper.outdatedDateKeys(base, rowProperties(page)).length === 0) {
16980
+ if (base === null || this.staleKeys(base, rowProperties(page), refreshed).length === 0) {
16247
16981
  return false;
16248
16982
  }
16249
16983
  try {
@@ -16252,25 +16986,37 @@ var DatabaseSyncer = class {
16252
16986
  return false;
16253
16987
  }
16254
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
+ }
16255
17001
  /**
16256
- * 예전 버전이 적은 날짜 모양이 남은 행에서 그 키들만 새 모양으로 고쳐 쓴다(F-08) — 원격이 그대로라 본문 ·
16257
- * 첨부는 다시 받지 않는다. 본문은 한 글자도 바꾸지 않고, 다른 키의 줄은 그대로 둔다(키 단위로 나눠 읽을
16258
- * 수 없는 frontmatter 는 값만 지킨다 — {@link mergeRowFrontmatter}).
17002
+ * 원격이 그대로인 행에서 {@link staleKeys} 의 키만 받은 값으로 고쳐 쓴다 — 본문 · 첨부는 다시 받지 않는다. 본문은
17003
+ * 한 글자도 바꾸지 않고, 다른 키의 줄은 그대로 둔다(키 단위로 나눠 읽을 수 없는 frontmatter 는 값만 지킨다 —
17004
+ * {@link mergeRowFrontmatter}).
16259
17005
  *
16260
17006
  * 예전에는 행을 통째로 다시 받았다. 행마다 본문을 읽어 요청이 늘고, 다시 그릴 때마다 달라지는 본문(빈
16261
17007
  * 블록 · 첨부 이름)까지 바뀌었다 — 실볼트 사본에서 다시 받은 149행 중 99행.
16262
17008
  *
16263
17009
  * 고른 뒤 로컬을 고쳤으면 쓰지 않는다(`skipped`) — 이어지는 push 가 올린다.
16264
17010
  */
16265
- async rewriteDateForms(page, record) {
17011
+ async rewriteStaleKeys(page, record, refreshed) {
16266
17012
  const path = record.obsidianPath;
16267
17013
  const local = await this.vaultFs.readFile(path);
16268
17014
  if (computeHash(local) !== record.contentHash) return { action: "skipped", path };
16269
17015
  const raw = rowProperties(page);
16270
- const keys = this.propertyMapper.outdatedDateKeys(splitFrontmatter(local).data, raw);
17016
+ const keys = this.staleKeys(splitFrontmatter(local).data, raw, refreshed);
16271
17017
  const fresh = this.propertyMapper.fromNotionProperties(raw);
16272
17018
  const pulled = Object.fromEntries(
16273
- 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]])
16274
17020
  );
16275
17021
  const lines = keys.length > 0 ? mergeRowFrontmatter(local, pulled, new Set(keys)) : null;
16276
17022
  const content = lines === null ? null : replaceFrontmatterLines(local, lines);
@@ -16283,6 +17029,86 @@ var DatabaseSyncer = class {
16283
17029
  });
16284
17030
  return { action: "written", path };
16285
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
+ }
16286
17112
  /** 이 DB 의 추적 행 가운데 이번 조회에 없던 것 — 지워졌는지는 아직 모른다. */
16287
17113
  unlistedRows(dbConfig, pages, paths) {
16288
17114
  const listed = new Set(pages.map((page) => normalizeNotionId(page.id)));
@@ -16394,7 +17220,7 @@ var DatabaseSyncer = class {
16394
17220
  lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
16395
17221
  views: [{ id: "default", name: "Table", type: "table" }]
16396
17222
  };
16397
- const baseContent = this.baseFileGenerator.generate({
17223
+ const generated = this.baseFileGenerator.generateWithViews({
16398
17224
  databaseId: dbConfig.databaseId,
16399
17225
  databaseName: dbName,
16400
17226
  schema: schemaFull,
@@ -16402,12 +17228,21 @@ var DatabaseSyncer = class {
16402
17228
  folderPath: rowsFolder ?? dbConfig.localFolder,
16403
17229
  rangeEnds
16404
17230
  });
17231
+ const baseContent = generated.content;
16405
17232
  const safeName = sanitizeFileName(dbName);
16406
17233
  const basePath = `${dbConfig.localFolder}/${safeName}${BASE_SUFFIX}`;
16407
17234
  const previous = this.baseFileInfo.get(dbConfig.databaseId);
16408
17235
  const existing = await this.vaultFs.readFile(basePath).catch(() => null);
16409
- let hash = computeHash(baseContent);
16410
- if (existing !== null && !isGeneratedDbArtifact(basePath, existing)) {
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 };
16411
17246
  if (!this.keptUserBases.has(basePath)) {
16412
17247
  this.keptUserBases.add(basePath);
16413
17248
  getLogger().info(
@@ -16415,12 +17250,12 @@ var DatabaseSyncer = class {
16415
17250
  );
16416
17251
  }
16417
17252
  } else if (existing !== null && existing !== baseContent && editedSince(previous, basePath, existing)) {
16418
- hash = previous.hash;
17253
+ record = { ...previous, title: dbName };
16419
17254
  this.warnEditedBase(basePath);
16420
17255
  } else if (existing !== baseContent) {
16421
17256
  await this.vaultFs.writeFile(basePath, baseContent);
16422
17257
  }
16423
- this.baseFileInfo.set(dbConfig.databaseId, { basePath, title: dbName, hash });
17258
+ this.baseFileInfo.set(dbConfig.databaseId, record);
16424
17259
  getLogger().debug(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131: ${basePath}`);
16425
17260
  await this.generateSidecar(
16426
17261
  dbConfig,
@@ -16465,10 +17300,11 @@ var DatabaseSyncer = class {
16465
17300
  }
16466
17301
  /** 볼트에서 고친 `.base` 를 덮어쓰거나 지우지 않았다고 한 번 알린다. */
16467
17302
  warnEditedBase(path) {
17303
+ this.heldBases.add(path);
16468
17304
  if (this.keptUserBases.has(path)) return;
16469
17305
  this.keptUserBases.add(path);
16470
17306
  getLogger().warn(
16471
- `[DB Sync] \uBCFC\uD2B8\uC5D0\uC11C \uACE0\uCE5C .base \uB77C Notion \uBCF4\uAE30\uB85C \uB36E\uC5B4\uC4F0\uC9C0 \uC54A\uC74C: ${path} \u2014 Notion \uC758 \uBCF4\uAE30\uB97C \uB2E4\uC2DC \uBC1B\uC73C\uB824\uBA74 \uC774 \uD30C\uC77C\uC744 \uC9C0\uC6B0\uC138\uC694`
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`
16472
17308
  );
16473
17309
  }
16474
17310
  /**
@@ -16487,6 +17323,7 @@ var DatabaseSyncer = class {
16487
17323
  for (const path of stale) {
16488
17324
  const content = await this.vaultFs.readFile(path).catch(() => null);
16489
17325
  if (content === null || !isGeneratedDbArtifact(path, content)) continue;
17326
+ if (this.heldSidecars.has(path)) continue;
16490
17327
  if (editedSince(previous, path, content)) {
16491
17328
  this.warnEditedBase(path);
16492
17329
  continue;
@@ -16513,11 +17350,26 @@ var DatabaseSyncer = class {
16513
17350
  isInline,
16514
17351
  appearance
16515
17352
  });
16516
- const sidecarPath = `${dbConfig.localFolder}/${safeName}${SIDECAR_SUFFIX}`;
16517
- const serialized = this.sidecarGenerator.serialize(sidecar);
16518
- const existing = await this.vaultFs.readFile(sidecarPath).catch(() => null);
17353
+ const sidecarPath = `${dbConfig.localFolder}/${safeName}${SIDECAR_SUFFIX}`;
17354
+ const serialized = this.sidecarGenerator.serialize(sidecar);
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
+ });
16519
17372
  if (existing === serialized) return;
16520
- if (existing !== null && !isGeneratedDbArtifact(sidecarPath, existing)) return;
16521
17373
  await this.vaultFs.writeFile(sidecarPath, serialized);
16522
17374
  if (sidecar.degraded.length > 0) {
16523
17375
  const unrep = sidecar.degraded.filter((d) => d.kind === "view-unrepresentable").length;
@@ -17000,19 +17852,19 @@ function threeWayMerge(base, local, remote) {
17000
17852
  }
17001
17853
  const regionStart = first.baseStart;
17002
17854
  let regionEnd = first.baseEnd;
17003
- const group = [first];
17855
+ const group2 = [first];
17004
17856
  i++;
17005
17857
  while (i < hunks.length) {
17006
17858
  const next = hunks[i];
17007
17859
  const overlaps = next.baseStart < regionEnd || regionEnd === regionStart && next.baseStart === regionStart;
17008
17860
  if (!overlaps) break;
17009
17861
  regionEnd = Math.max(regionEnd, next.baseEnd);
17010
- group.push(next);
17862
+ group2.push(next);
17011
17863
  i++;
17012
17864
  }
17013
17865
  const baseRegion = baseLines.slice(regionStart, regionEnd);
17014
- const localRegion = applyHunks(baseLines, group, 0, regionStart, regionEnd);
17015
- 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);
17016
17868
  if (arraysEqual(localRegion, baseRegion)) {
17017
17869
  merged.push(...remoteRegion);
17018
17870
  } else if (arraysEqual(remoteRegion, baseRegion)) {
@@ -17063,10 +17915,10 @@ function computeHunks(base, modified, side) {
17063
17915
  emitGap(base.length, modified.length);
17064
17916
  return hunks;
17065
17917
  }
17066
- function applyHunks(baseLines, group, side, regionStart, regionEnd) {
17918
+ function applyHunks(baseLines, group2, side, regionStart, regionEnd) {
17067
17919
  const out = [];
17068
17920
  let b = regionStart;
17069
- for (const hunk of group) {
17921
+ for (const hunk of group2) {
17070
17922
  if (hunk.side !== side) continue;
17071
17923
  if (hunk.baseStart > b) out.push(...baseLines.slice(b, hunk.baseStart));
17072
17924
  out.push(...hunk.content);
@@ -17421,165 +18273,1071 @@ var ScopedVaultFS = class {
17421
18273
  const local = this.fromBase(item.path);
17422
18274
  if (local !== null) out.push({ ...item, path: local });
17423
18275
  }
17424
- return out;
18276
+ return out;
18277
+ }
18278
+ };
18279
+
18280
+ // src/sync/pull-watermark.ts
18281
+ var INCREMENTAL_SAFETY_WINDOW_MS = 15 * 6e4;
18282
+ function incrementalSearchSince(watermark) {
18283
+ const ms = Date.parse(watermark);
18284
+ return Number.isFinite(ms) ? new Date(ms - INCREMENTAL_SAFETY_WINDOW_MS).toISOString() : watermark;
18285
+ }
18286
+ function nextPullWatermark(startedAt, unapplied) {
18287
+ let earliest = Date.parse(startedAt);
18288
+ for (const change of unapplied) {
18289
+ if (change.type === "deleted") continue;
18290
+ const edited = Date.parse(change.lastEdited);
18291
+ if (Number.isFinite(edited) && edited < earliest) earliest = edited;
18292
+ }
18293
+ return new Date(earliest).toISOString();
18294
+ }
18295
+
18296
+ // src/sync/remote-scan.ts
18297
+ var LAST_FULL_PULL_META_KEY = "last_full_pull_at";
18298
+ var LAST_PULL_META_KEY = "last_pull_at";
18299
+ var PENDING_DATABASES_META_KEY = "db_pull_pending";
18300
+ function chooseRemoteScan(state, options) {
18301
+ if (options.force) return { kind: "full", reason: "forced" };
18302
+ if (state.databaseMode) return { kind: "full", reason: "database-mode" };
18303
+ if (!state.lastPullAt || state.trackedRecords === 0) return { kind: "full", reason: "first" };
18304
+ if (!Number.isFinite(state.fullReconcileIntervalSec) || state.fullReconcileIntervalSec <= 0) {
18305
+ return { kind: "full", reason: "every-pull" };
18306
+ }
18307
+ const nextFullAt = nextFullReconcileAt(state.lastFullPullAt, state.fullReconcileIntervalSec);
18308
+ if (nextFullAt === null || Date.parse(nextFullAt) <= options.now) {
18309
+ if (!options.deferDue) return { kind: "full", reason: "due" };
18310
+ return {
18311
+ kind: "incremental",
18312
+ since: state.lastPullAt,
18313
+ lastFullAt: state.lastFullPullAt,
18314
+ nextFullAt: null
18315
+ };
18316
+ }
18317
+ return {
18318
+ kind: "incremental",
18319
+ since: state.lastPullAt,
18320
+ lastFullAt: state.lastFullPullAt,
18321
+ nextFullAt
18322
+ };
18323
+ }
18324
+ function nextFullReconcileAt(lastFullPullAt, intervalSec) {
18325
+ const last = lastFullPullAt === null ? NaN : Date.parse(lastFullPullAt);
18326
+ return Number.isFinite(last) ? new Date(last + intervalSec * 1e3).toISOString() : null;
18327
+ }
18328
+ function remoteScanInfo(scan, lastFullPullAt, deleteSync, deletionsChecked = false) {
18329
+ return scan.kind === "full" ? {
18330
+ kind: "full",
18331
+ reason: scan.reason,
18332
+ lastFullAt: lastFullPullAt,
18333
+ nextFullAt: null,
18334
+ deletionsDeferred: false
18335
+ } : {
18336
+ kind: "incremental",
18337
+ lastFullAt: scan.lastFullAt,
18338
+ nextFullAt: scan.nextFullAt,
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}`);
17425
18750
  }
17426
- };
17427
-
17428
- // src/sync/pull-watermark.ts
17429
- var INCREMENTAL_SAFETY_WINDOW_MS = 15 * 6e4;
17430
- function incrementalSearchSince(watermark) {
17431
- const ms = Date.parse(watermark);
17432
- return Number.isFinite(ms) ? new Date(ms - INCREMENTAL_SAFETY_WINDOW_MS).toISOString() : watermark;
18751
+ return tokens;
17433
18752
  }
17434
- function nextPullWatermark(startedAt, unapplied) {
17435
- let earliest = Date.parse(startedAt);
17436
- for (const change of unapplied) {
17437
- if (change.type === "deleted") continue;
17438
- const edited = Date.parse(change.lastEdited);
17439
- if (Number.isFinite(edited) && edited < earliest) earliest = edited;
17440
- }
17441
- return new Date(earliest).toISOString();
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
+ });
17442
18916
  }
17443
-
17444
- // src/sync/remote-scan.ts
17445
- var LAST_FULL_PULL_META_KEY = "last_full_pull_at";
17446
- var LAST_PULL_META_KEY = "last_pull_at";
17447
- var PENDING_DATABASES_META_KEY = "db_pull_pending";
17448
- function chooseRemoteScan(state, options) {
17449
- if (options.force) return { kind: "full", reason: "forced" };
17450
- if (state.databaseMode) return { kind: "full", reason: "database-mode" };
17451
- if (!state.lastPullAt || state.trackedRecords === 0) return { kind: "full", reason: "first" };
17452
- if (!Number.isFinite(state.fullReconcileIntervalSec) || state.fullReconcileIntervalSec <= 0) {
17453
- return { kind: "full", reason: "every-pull" };
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 } };
17454
19016
  }
17455
- const nextFullAt = nextFullReconcileAt(state.lastFullPullAt, state.fullReconcileIntervalSec);
17456
- if (nextFullAt === null || Date.parse(nextFullAt) <= options.now) {
17457
- if (!options.deferDue) return { kind: "full", reason: "due" };
17458
- return {
17459
- kind: "incremental",
17460
- since: state.lastPullAt,
17461
- lastFullAt: state.lastFullPullAt,
17462
- nextFullAt: null
17463
- };
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`);
17464
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`);
17465
19032
  return {
17466
- kind: "incremental",
17467
- since: state.lastPullAt,
17468
- lastFullAt: state.lastFullPullAt,
17469
- nextFullAt
17470
- };
17471
- }
17472
- function nextFullReconcileAt(lastFullPullAt, intervalSec) {
17473
- const last = lastFullPullAt === null ? NaN : Date.parse(lastFullPullAt);
17474
- return Number.isFinite(last) ? new Date(last + intervalSec * 1e3).toISOString() : null;
17475
- }
17476
- function remoteScanInfo(scan, lastFullPullAt, deleteSync, deletionsChecked = false) {
17477
- return scan.kind === "full" ? {
17478
- kind: "full",
17479
- reason: scan.reason,
17480
- lastFullAt: lastFullPullAt,
17481
- nextFullAt: null,
17482
- deletionsDeferred: false
17483
- } : {
17484
- kind: "incremental",
17485
- lastFullAt: scan.lastFullAt,
17486
- nextFullAt: scan.nextFullAt,
17487
- deletionsDeferred: deleteSync && !deletionsChecked
19033
+ head: { property: decodePropertyId(prop.id) },
19034
+ type: prop.type,
19035
+ label: `\uC18D\uC131 '${ref.name}'`
17488
19036
  };
17489
19037
  }
17490
- var ALL_DATABASES = { kind: "all" };
17491
- function selectsDatabase(selection, databaseId) {
17492
- return selection.kind === "all" || selection.ids.has(compactNotionId(databaseId));
19038
+ function isPendingRange(filter) {
19039
+ return typeof filter["pending"] === "object" && filter["pending"] !== null;
17493
19040
  }
17494
- function parsePendingDatabases(raw) {
17495
- if (!raw) return /* @__PURE__ */ new Set();
17496
- try {
17497
- const parsed = JSON.parse(raw);
17498
- if (!Array.isArray(parsed)) return /* @__PURE__ */ new Set();
17499
- return new Set(
17500
- parsed.filter((id) => typeof id === "string").map((id) => compactNotionId(id))
17501
- );
17502
- } catch {
17503
- 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
+ }
17504
19057
  }
19058
+ return out;
17505
19059
  }
17506
- function serializePendingDatabases(ids) {
17507
- return JSON.stringify([...ids].sort());
19060
+ function squash(text) {
19061
+ return text.replace(/\s+/g, "");
17508
19062
  }
17509
- function addPendingDatabases(stateDb, databaseIds) {
17510
- const pending = parsePendingDatabases(stateDb.getMeta(PENDING_DATABASES_META_KEY));
17511
- const before = pending.size;
17512
- for (const id of databaseIds) pending.add(compactNotionId(id));
17513
- if (pending.size !== before) {
17514
- stateDb.setMeta(PENDING_DATABASES_META_KEY, serializePendingDatabases(pending));
17515
- }
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));
17516
19067
  }
17517
- var DatabasePullLedger = class {
17518
- constructor(selection, pending) {
17519
- this.selection = selection;
17520
- this.pending = pending;
17521
- }
17522
- selection;
17523
- pending;
17524
- settled = /* @__PURE__ */ new Set();
17525
- unfinished = /* @__PURE__ */ new Set();
17526
- skippedCount = 0;
17527
- /** 이제 동기화하는 DB — 알면 대기를 이것으로 좁힌다. */
17528
- syncing = null;
17529
- /** 이 DB 를 조회하나 — 새로 발견한 DB 는 부른 쪽이 따로 조회한다(받은 적이 없다). */
17530
- selects(databaseId) {
17531
- return selectsDatabase(this.selection, databaseId);
17532
- }
17533
- /** 바뀐 것이 보이지 않아 조회하지 않았다. */
17534
- skip() {
17535
- this.skippedCount++;
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);
17536
19086
  }
17537
- /** 받았거나 · 이제 없어서 뺐다 — 다시 조회할 것이 없다. */
17538
- settle(databaseId) {
17539
- 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 };
17540
19186
  }
17541
- /** 조회하다 실패했거나 · 받지 못한 행이 있거나 · 취소로 닿지 못했다 — 다음 pull 이 다시 조회한다. */
17542
- retry(databaseId) {
17543
- 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 };
17544
19221
  }
17545
- get skipped() {
17546
- 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) };
17547
19253
  }
17548
- /**
17549
- * 이제 동기화하는 DB 를 알린다 — 대기를 이것으로 좁힌다. 설정에서 뺐거나 접근 불가로 뺀 DB 를 영영
17550
- * 들고 있지 않는다. 알리지 않으면(발견이 멈춤 등) 좁히지 않는다 — 모르는 것을 버리지 않는다.
17551
- */
17552
- keepOnly(databaseIds) {
17553
- 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);
17554
19302
  }
17555
- /** 다음 pull 의 대기 — 지난 대기에서 받은 것을 빼고 이번에 받지 못한 것을 더한다. */
17556
- nextPending() {
17557
- const next = new Set([...this.pending].filter((id) => !this.settled.has(id)));
17558
- for (const id of this.unfinished) next.add(id);
17559
- const syncing = this.syncing;
17560
- 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
+ );
17561
19307
  }
17562
- };
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
+ }
17563
19317
 
17564
19318
  // src/sync/row-schema-cache.ts
17565
19319
  var RowSchemaCache = class {
17566
- constructor(loadSchema, wikilinkResolver, createMapper = () => new PropertyMapper(), loadPeople) {
19320
+ constructor(loadSchema, wikilinkResolver, createMapper = () => new PropertyMapper(), loadPeople, loadRowSource = async () => null) {
17567
19321
  this.loadSchema = loadSchema;
17568
19322
  this.wikilinkResolver = wikilinkResolver;
17569
19323
  this.createMapper = createMapper;
17570
19324
  this.loadPeople = loadPeople;
19325
+ this.loadRowSource = loadRowSource;
17571
19326
  }
17572
19327
  loadSchema;
17573
19328
  wikilinkResolver;
17574
19329
  createMapper;
17575
19330
  loadPeople;
19331
+ loadRowSource;
17576
19332
  mappers = /* @__PURE__ */ new Map();
19333
+ rowSources = /* @__PURE__ */ new Map();
17577
19334
  people = null;
17578
- mapperFor(databaseId) {
17579
- const key = compactNotionId(databaseId);
19335
+ /** @param dataSourceId 행이 사는 data source — 주면 그 소스의 스키마로 옮긴다. */
19336
+ mapperFor(databaseId, dataSourceId) {
19337
+ const key = `${compactNotionId(databaseId)}:${dataSourceId ? compactNotionId(dataSourceId) : ""}`;
17580
19338
  const cached = this.mappers.get(key);
17581
19339
  if (cached) return cached;
17582
- const created = this.loadSchema(databaseId).then(async (schema) => {
19340
+ const created = this.loadSchema(databaseId, dataSourceId ?? void 0).then(async (schema) => {
17583
19341
  const mapper = this.createMapper();
17584
19342
  mapper.setWikilinkResolver(this.wikilinkResolver);
17585
19343
  mapper.loadSchema(schema);
@@ -17592,8 +19350,24 @@ var RowSchemaCache = class {
17592
19350
  });
17593
19351
  return created;
17594
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
+ }
17595
19368
  clear() {
17596
19369
  this.mappers.clear();
19370
+ this.rowSources.clear();
17597
19371
  this.people = null;
17598
19372
  }
17599
19373
  /** 사용자 목록을 못 읽으면 이름은 풀지 않는다 — id 로 적힌 사람만 보낸다(예전 동작). */
@@ -18141,7 +19915,7 @@ var DatabaseDiscovery = class {
18141
19915
  failed.push({
18142
19916
  path: pathOf(parentPageId),
18143
19917
  operation: "create",
18144
- 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}`
18145
19919
  });
18146
19920
  };
18147
19921
  const register = async (dbId, parentPageId) => {
@@ -18191,10 +19965,11 @@ var DatabaseDiscovery = class {
18191
19965
  nextRetry.parents.push(...retry.parents);
18192
19966
  nextRetry.dbs.push(...retry.dbs);
18193
19967
  } else {
19968
+ const root = this.config.notion.rootPageId;
18194
19969
  const scanIds = scanAll ? [
18195
- this.config.notion.rootPageId,
19970
+ root,
18196
19971
  ...this.stateDb.getAll().filter((r) => r.notionPageId).map((r) => r.notionPageId)
18197
- ].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;
18198
19973
  if (scanIds.length > 0) {
18199
19974
  const { found: found2, unread } = await this.discoverChildDatabases(scanIds);
18200
19975
  for (const { dbId, parentPageId } of found2) {
@@ -19456,7 +21231,7 @@ function rewriteTarget(target, source, context, kind) {
19456
21231
  const shown = keepExtension ? destination : destination.slice(0, -".md".length);
19457
21232
  let next;
19458
21233
  if (resolved.relative) {
19459
- next = relativePath(folderOf4(source.to), shown);
21234
+ next = relativePath(folderOf5(source.to), shown);
19460
21235
  if (!linkpath.startsWith("./") && next.startsWith("./")) next = next.slice(2);
19461
21236
  } else if (style === "name") {
19462
21237
  const name = baseName3(shown);
@@ -19478,14 +21253,14 @@ function resolveLinkpath(linkpath, sourcePath, files) {
19478
21253
  const matches = byName(files, baseName3(candidate)).filter((file) => file.endsWith(suffix));
19479
21254
  if (matches.length === 1) return found(matches[0], linkpath);
19480
21255
  if (matches.length > 1) {
19481
- const local = `${folderPrefix(folderOf4(sourcePath))}${candidate}`;
21256
+ const local = `${folderPrefix(folderOf5(sourcePath))}${candidate}`;
19482
21257
  return matches.includes(local) ? found(local, linkpath) : null;
19483
21258
  }
19484
21259
  }
19485
21260
  return null;
19486
21261
  }
19487
21262
  function resolveRelative(linkpath, sourcePath, files) {
19488
- const joined = normalizeSegments(`${folderPrefix(folderOf4(sourcePath))}${linkpath}`);
21263
+ const joined = normalizeSegments(`${folderPrefix(folderOf5(sourcePath))}${linkpath}`);
19489
21264
  if (joined === null) return null;
19490
21265
  for (const candidate of withExtension(joined)) {
19491
21266
  if (files.has(candidate)) return { ...found(candidate, linkpath), relative: true };
@@ -19544,7 +21319,7 @@ function relativePath(from, to) {
19544
21319
  function encodeMarkdownTarget(next, original) {
19545
21320
  return original.includes("%") || next.includes(" ") ? next.replace(/ /g, "%20") : next;
19546
21321
  }
19547
- function folderOf4(path) {
21322
+ function folderOf5(path) {
19548
21323
  const slash = path.lastIndexOf("/");
19549
21324
  return slash === -1 ? "" : path.slice(0, slash);
19550
21325
  }
@@ -20495,15 +22270,15 @@ var DatabaseCreator = class {
20495
22270
  */
20496
22271
  async addStatus(dataSourceId, status, folder) {
20497
22272
  const groupOf = /* @__PURE__ */ new Map();
20498
- for (const group of status.groups ?? []) {
20499
- 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);
20500
22275
  }
20501
22276
  const unknown = [];
20502
22277
  const options = status.options.map((option) => {
20503
- const group = groupOf.get(option.name);
20504
- if (group === void 0) return option;
20505
- if (NOTION_STATUS_GROUPS.includes(group)) return { ...option, group };
20506
- 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})`);
20507
22282
  return option;
20508
22283
  });
20509
22284
  await this.notionClient.updateDataSourceProperties(dataSourceId, {
@@ -21573,7 +23348,8 @@ var PagePusher = class {
21573
23348
  );
21574
23349
  }
21575
23350
  const title = noteTitle(current.data, path);
21576
- const mapper = await this.rowSchemas.mapperFor(databaseId);
23351
+ const source = await this.rowSchemas.rowSourceFor(databaseId);
23352
+ const mapper = await this.rowSchemas.mapperFor(databaseId, source);
21577
23353
  const changes = diffRowProperties(null, current.data);
21578
23354
  const { properties, skipped } = mapper.toNotionPropertyChanges(changes, title, {
21579
23355
  current: current.data,
@@ -21629,7 +23405,8 @@ var PagePusher = class {
21629
23405
  title,
21630
23406
  conversionResult.content,
21631
23407
  properties,
21632
- { path, frontmatter: current.data }
23408
+ { path, frontmatter: current.data },
23409
+ source
21633
23410
  );
21634
23411
  const { page } = created;
21635
23412
  const mapping = this.stateDb.upsert({
@@ -21761,8 +23538,8 @@ var PagePusher = class {
21761
23538
  `frontmatter \uB97C \uC77D\uC9C0 \uBABB\uD574 \uD589\uC744 \uBCF4\uB0B4\uC9C0 \uC54A\uC74C (${path}): ${error instanceof Error ? error.message : String(error)}`
21762
23539
  );
21763
23540
  }
21764
- const mapper = await this.rowSchemas.mapperFor(databaseId);
21765
23541
  const remote = await this.livePage(record);
23542
+ const mapper = await this.rowSchemas.mapperFor(databaseId, dataSourceIdOf(remote));
21766
23543
  const drift = options.overwriteRemote ? "none" : await this.drift.remoteDrift(record, remote);
21767
23544
  const against = options.overwriteRemote ? this.remoteRowState(mapper, remote) : this.baseRowState(record);
21768
23545
  const title = noteTitle(current.data, path);
@@ -21937,7 +23714,8 @@ var PagePusher = class {
21937
23714
  * 버린 맨 앞 `# H1` 을 호출측이 매핑을 적은 뒤 되살린다({@link restoreCreatedHeading}). 블록으로
21938
23715
  * 보냈으면 null 이다 — 블록은 보낸 그대로 생긴다.
21939
23716
  */
21940
- async pushCreatePage(parentId, parentType, title, markdownContent, properties, note) {
23717
+ async pushCreatePage(parentId, parentType, title, markdownContent, properties, note, dataSourceId) {
23718
+ const parentSource = dataSourceId ? { dataSourceId } : {};
21941
23719
  const decorate2 = async (page2) => await this.decorations.push(page2.id, note.path, note.frontmatter, {}) ?? page2;
21942
23720
  if (this.config.conversion.preferMarkdownApi !== false) {
21943
23721
  const deferred = deferSyncedReferences(obsidianToNotionEnhanced(markdownContent));
@@ -21952,7 +23730,8 @@ var PagePusher = class {
21952
23730
  parentType,
21953
23731
  title,
21954
23732
  markdown,
21955
- properties
23733
+ properties,
23734
+ ...parentSource
21956
23735
  })
21957
23736
  );
21958
23737
  return { page: page2, markdown, syncedRefs: deferred.refs, syncedRefText: deferred.removed };
@@ -21963,7 +23742,8 @@ var PagePusher = class {
21963
23742
  parentId,
21964
23743
  parentType,
21965
23744
  title,
21966
- properties
23745
+ properties,
23746
+ ...parentSource
21967
23747
  })
21968
23748
  );
21969
23749
  if (blocks.length > 0) {
@@ -23246,11 +25026,21 @@ var RemoteDetector = class _RemoteDetector {
23246
25026
  _moved = /* @__PURE__ */ new Map();
23247
25027
  // 증분 감지가 search 로 살아 있다고 본 페이지(압축 id) — 삭제 확인이 다시 묻지 않는다.
23248
25028
  _seenAlive = /* @__PURE__ */ new Set();
25029
+ // 이번 감지에서 루트의 자식이 바뀌었을 수 있나 — {@link rootChanged}.
25030
+ _rootChanged = false;
23249
25031
  // 이번 감지가 삭제를 물은 뒤의 커서 — pull 이 마칠 때 적는다({@link saveDeletionSweep}). undefined 면 묻지 않았다.
23250
25032
  _sweepCursor;
23251
25033
  // 이번 감지가 추적 중인 것을 모두 물었나 — 그러면 증분이어도 미룬 삭제가 없다.
23252
25034
  _sweptAll = false;
23253
25035
  /** 이번 감지에서 자식 페이지를 가진 것으로 본 페이지인가 — 폴더노트 판정의 1차 근거. */
25036
+ /**
25037
+ * 루트 바로 아래에 새 DB 가 생겼을 수 있나 — 증분 search 에 루트가 들었거나 전체 대조다. 루트는 받는 노트가 아니라
25038
+ * 본문을 읽지 않아, 루트 바로 아래 새 DB 는 `pull --force` 만 찾았다(2026-10-29 실측 — DB 를 만들면 루트의 수정
25039
+ * 시각이 바뀐다). 이때 DB 발견이 루트의 자식을 한 번 훑는다.
25040
+ */
25041
+ rootChanged() {
25042
+ return this._rootChanged;
25043
+ }
23254
25044
  hasChildPages(pageId) {
23255
25045
  return this._childParentIds.has(normalizeNotionId(pageId));
23256
25046
  }
@@ -23355,6 +25145,7 @@ var RemoteDetector = class _RemoteDetector {
23355
25145
  */
23356
25146
  async detectRemote(scan, options) {
23357
25147
  this._seenAlive.clear();
25148
+ this._rootChanged = scan.kind === "full";
23358
25149
  this._sweepCursor = void 0;
23359
25150
  this._sweptAll = false;
23360
25151
  const detection = scan.kind === "full" ? { changes: await this.detectRemoteChanges(options.signal), databaseIds: null } : await this.detectRemoteChangesIncremental(scan.since, options);
@@ -23597,7 +25388,10 @@ var RemoteDetector = class _RemoteDetector {
23597
25388
  databaseIds.add(compactNotionId(page.parentDatabaseId));
23598
25389
  continue;
23599
25390
  }
23600
- 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
+ }
23601
25395
  throwIfAborted(options.signal);
23602
25396
  try {
23603
25397
  const parentId = await extractParentId(this.notionClient, page);
@@ -23850,7 +25644,7 @@ function draftFolderDatabase(input) {
23850
25644
  let index = 0;
23851
25645
  for (const [name, list] of values) {
23852
25646
  if (NON_PROPERTY_KEYS.has(name)) continue;
23853
- if (isRangeEnd(name, values)) continue;
25647
+ if (isRangeEnd2(name, values)) continue;
23854
25648
  if (name === DEFAULT_TITLE_PROPERTY) {
23855
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" });
23856
25650
  continue;
@@ -23967,7 +25761,7 @@ function collectValues(notes) {
23967
25761
  }
23968
25762
  return out;
23969
25763
  }
23970
- function isRangeEnd(name, values) {
25764
+ function isRangeEnd2(name, values) {
23971
25765
  if (!name.endsWith(RANGE_END_SUFFIX)) return false;
23972
25766
  const start = values.get(name.slice(0, -RANGE_END_SUFFIX.length));
23973
25767
  return start !== void 0 && start.length > 0 && start.every(isFrontmatterDate);
@@ -24026,7 +25820,7 @@ ${text}
24026
25820
  return [];
24027
25821
  }
24028
25822
  const idOf2 = /* @__PURE__ */ new Map();
24029
- for (const p of properties) idOf2.set(p.type === "title" ? "file.name" : p.name, p.id);
25823
+ for (const p of properties) idOf2.set(p.type === "title" ? BASES_TITLE : p.name, p.id);
24030
25824
  const columnId = (raw) => {
24031
25825
  if (typeof raw !== "string") return void 0;
24032
25826
  return idOf2.get(raw) ?? idOf2.get(raw.replace(/^note\./, ""));
@@ -24152,11 +25946,26 @@ var SyncOrchestrator = class {
24152
25946
  propertyMapper.setWikilinkResolver(wikilinkResolver);
24153
25947
  this.notionClient.setWikilinkResolver(wikilinkResolver);
24154
25948
  this.rowSchemas = new RowSchemaCache(
24155
- (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
+ },
24156
25958
  wikilinkResolver,
24157
25959
  () => PropertyMapper.fromConfig(config),
24158
- 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
+ }
24159
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);
24160
25969
  this.drift = new RemoteDriftChecker(
24161
25970
  config,
24162
25971
  stateDb,
@@ -24254,6 +26063,12 @@ var SyncOrchestrator = class {
24254
26063
  databaseSyncer;
24255
26064
  /** DB 행 push 용 — DB 마다 스키마를 읽은 매퍼. 실행마다 비운다(S-01). */
24256
26065
  rowSchemas;
26066
+ /** 볼트에서 바꾼 DB 속성 이름을 Notion 의 열 이름으로(ADR-045). */
26067
+ vaultRenames;
26068
+ /** 볼트에서 고친 사이드카의 속성을 Notion DB 의 열로(ADR-045). */
26069
+ sidecarEdits;
26070
+ /** 볼트에서 고친 `.base` 의 보기를 Notion 보기로(ADR-046). */
26071
+ viewEdits;
24257
26072
  // 이번 실행이 원격을 보는 기준(N-05) — push · pull · status 가 시작할 때 정한다
24258
26073
  // ({@link RunObservation.begin}). 원격 판정과 관측 기록이 같은 값을 쓴다.
24259
26074
  observation;
@@ -24335,20 +26150,31 @@ var SyncOrchestrator = class {
24335
26150
  const excludeSet = options?.excludePaths ? new Set(options.excludePaths) : null;
24336
26151
  const filtered = (options?.paths ? eligible.filter((c) => inAnyPathScope(c.path, options.paths)) : eligible).filter((c) => !excludeSet || !excludeSet.has(c.path));
24337
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));
24338
26158
  if (filtered.length === 0 && folderMoves.length === 0) {
24339
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);
24340
26162
  return {
24341
26163
  created: 0,
24342
26164
  updated: 0,
24343
26165
  deleted: 0,
24344
26166
  moved: 0,
24345
- failed: [],
24346
- 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 } : {}
24347
26171
  };
24348
26172
  }
24349
26173
  const refusedCreates = this.placement.refusedCreates(filtered, view.lookup);
24350
26174
  const refusedMoves = this.placement.refusedMoves(filtered, view);
24351
26175
  const refusedFailures = [
26176
+ ...sidecarPlan.refused,
26177
+ ...viewPlan.refused,
24352
26178
  ...[...refusedCreates].map(([path, error]) => ({
24353
26179
  path,
24354
26180
  operation: "create",
@@ -24362,9 +26188,14 @@ var SyncOrchestrator = class {
24362
26188
  if (error) refusedFailures.push({ path: move.to, operation: "move", error });
24363
26189
  else movableFolders.push(move);
24364
26190
  }
24365
- const applicable = filtered.filter(
26191
+ const placeable2 = filtered.filter(
24366
26192
  (c) => !refusedCreates.has(c.path) && !refusedMoves.has(c.path)
24367
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 }));
24368
26199
  if (options?.dryRun) {
24369
26200
  const planned = this.config.sync.deleteSync ? applicable : applicable.filter((c) => c.type !== "deleted");
24370
26201
  const items = [
@@ -24389,16 +26220,30 @@ var SyncOrchestrator = class {
24389
26220
  failed: refusedFailures,
24390
26221
  duration: Date.now() - startTime,
24391
26222
  ...unsentDeletions2.length > 0 ? { unsentDeletions: unsentDeletions2 } : {},
24392
- ...readOnlySkipped.length > 0 ? { readOnlySkipped } : {}
26223
+ ...readOnlySkipped.length > 0 ? { readOnlySkipped } : {},
26224
+ ...plannedRenames.length > 0 ? { renamedProperties: plannedRenames } : {},
26225
+ ...plannedEdits.length > 0 ? { schemaEdits: plannedEdits } : {},
26226
+ ...plannedViews.length > 0 ? { viewEdits: plannedViews } : {}
24393
26227
  };
24394
26228
  }
24395
26229
  this.stateDb.setMeta(PUSH_IN_PROGRESS_META_KEY, "true");
24396
26230
  await this.observation.resolveBotUserId();
24397
26231
  const counts = { created: 0, updated: 0, deleted: 0, moved: 0 };
24398
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
+ );
24399
26244
  const unsentDeletions = [];
24400
26245
  let completed = 0;
24401
- const total = movableFolders.length + applicable.length;
26246
+ const total = movableFolders.length + pushing.length;
24402
26247
  counts.moved += await this.pusher.pushFolderMoves(
24403
26248
  movableFolders,
24404
26249
  failed,
@@ -24446,14 +26291,10 @@ var SyncOrchestrator = class {
24446
26291
  retryQueue.push(change);
24447
26292
  }
24448
26293
  };
24449
- const pushedEarly = await this.placement.prepareFolders(
24450
- applicable,
24451
- firstAttempt,
24452
- !options?.paths
24453
- );
26294
+ const pushedEarly = await this.placement.prepareFolders(pushing, firstAttempt, !options?.paths);
24454
26295
  await this.completeDatabaseSchemas();
24455
26296
  await runPool(
24456
- applicable.filter((c) => !pushedEarly.has(c.path)),
26297
+ pushing.filter((c) => !pushedEarly.has(c.path)),
24457
26298
  firstAttempt,
24458
26299
  { concurrency: this.config.advanced.concurrency, signal: options?.signal }
24459
26300
  );
@@ -24582,7 +26423,10 @@ var SyncOrchestrator = class {
24582
26423
  failed,
24583
26424
  duration: Date.now() - startTime,
24584
26425
  readOnlySkipped: this.pusher.takeReadOnlySkips(),
24585
- ...unsentDeletions.length > 0 ? { unsentDeletions } : {}
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 } : {}
24586
26430
  };
24587
26431
  }
24588
26432
  /**
@@ -24667,6 +26511,9 @@ var SyncOrchestrator = class {
24667
26511
  this.puller.beginPull();
24668
26512
  this.rowSchemas.clear();
24669
26513
  this.databaseSyncer.takeRestoredRows();
26514
+ this.databaseSyncer.takeSchemaFollowed();
26515
+ this.databaseSyncer.takeHeldSidecars();
26516
+ this.databaseSyncer.takeHeldBases();
24670
26517
  const emptyResult = {
24671
26518
  created: 0,
24672
26519
  updated: 0,
@@ -24746,6 +26593,9 @@ var SyncOrchestrator = class {
24746
26593
  await this.puller.removeVacatedFolders();
24747
26594
  const renamedRows = this.databaseSyncer.takeRenamedRows();
24748
26595
  restoredPaths.push(...this.databaseSyncer.takeRestoredRows());
26596
+ const schemaFollowed = this.databaseSyncer.takeSchemaFollowed();
26597
+ const heldSidecars = this.databaseSyncer.takeHeldSidecars();
26598
+ const heldBases = this.databaseSyncer.takeHeldBases();
24749
26599
  const relinked = await this.linkUpdater.apply([...relocated.applied, ...renamedRows.applied]);
24750
26600
  const linkCount = writtenPaths.length > 0 ? await this.resolveNotionLinks(writtenPaths) : 0;
24751
26601
  if (!options?.paths) {
@@ -24771,6 +26621,9 @@ var SyncOrchestrator = class {
24771
26621
  deleted,
24772
26622
  restored,
24773
26623
  ...restoredPaths.length > 0 ? { restoredPaths } : {},
26624
+ ...schemaFollowed.length > 0 ? { schemaFollowed } : {},
26625
+ ...heldSidecars.length > 0 ? { heldSidecars } : {},
26626
+ ...heldBases.length > 0 ? { heldBases } : {},
24774
26627
  conflicts,
24775
26628
  writtenPaths,
24776
26629
  failed,
@@ -24885,6 +26738,7 @@ var SyncOrchestrator = class {
24885
26738
  conflicts,
24886
26739
  {
24887
26740
  forceRediscovery: options?.force === true,
26741
+ rescanRoot: this.detector.rootChanged(),
24888
26742
  paths: options?.paths,
24889
26743
  progress: rowProgress,
24890
26744
  ledger,
@@ -25311,14 +27165,14 @@ function toVaultPath(relativePath2, separator) {
25311
27165
  function compileAll(patterns) {
25312
27166
  return patterns.flatMap((raw) => {
25313
27167
  const pattern = normalizePattern(raw);
25314
- return pattern === null ? [] : [compile(pattern)];
27168
+ return pattern === null ? [] : [compile2(pattern)];
25315
27169
  });
25316
27170
  }
25317
27171
  function normalizePattern(raw) {
25318
27172
  const pattern = raw.trim().replace(/\\/g, "/").replace(/^(?:\.\/|\/)+/, "").replace(/\/+$/, "");
25319
27173
  return pattern === "" ? null : pattern;
25320
27174
  }
25321
- function compile(pattern) {
27175
+ function compile2(pattern) {
25322
27176
  if (pattern === "**" || pattern === "**/*") return { kind: "all" };
25323
27177
  if (!/[*?]/.test(pattern)) return { kind: "prefix", prefix: pattern };
25324
27178
  if (pattern.endsWith("/**") && !/[*?]/.test(pattern.slice(0, -3))) {
@@ -26554,7 +28408,7 @@ var SEP_RE = new RegExp(`^[\\t ]*${COLUMN_SEP_SOURCE}[\\t ]*$`);
26554
28408
  var FENCE_RE = /^[\t ]*(`{3,}|~{3,})/;
26555
28409
  function readColumnLayout(lines) {
26556
28410
  const places = new Array(lines.length);
26557
- let group = -1;
28411
+ let group2 = -1;
26558
28412
  let fence = null;
26559
28413
  let open = null;
26560
28414
  const close = (end) => {
@@ -26564,7 +28418,7 @@ function readColumnLayout(lines) {
26564
28418
  const until = columns[index + 1]?.line ?? end;
26565
28419
  for (let line = column.line; line < until; line++) {
26566
28420
  places[line] = {
26567
- group,
28421
+ group: group2,
26568
28422
  index,
26569
28423
  count: columns.length,
26570
28424
  ...column.ratio === void 0 ? {} : { ratio: column.ratio },
@@ -26586,7 +28440,7 @@ function readColumnLayout(lines) {
26586
28440
  }
26587
28441
  if (START_RE.test(line)) {
26588
28442
  close(i);
26589
- group++;
28443
+ group2++;
26590
28444
  open = { start: i, columns: [] };
26591
28445
  return;
26592
28446
  }
@@ -26612,6 +28466,6 @@ function formatRatio(ratio) {
26612
28466
  return String(Math.round(ratio * 100) / 100);
26613
28467
  }
26614
28468
 
26615
- 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, 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 };
26616
28470
  //# sourceMappingURL=index.js.map
26617
28471
  //# sourceMappingURL=index.js.map