@im-nobsidian/core 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { LogLevel, Client } from '@notionhq/client';
2
+ import * as notionSdk from '@notionhq/client';
3
+ import { LogLevel } from '@notionhq/client';
3
4
  import { readFile, mkdir, writeFile, unlink, rmdir, rename, stat, readdir } from 'fs/promises';
4
5
  import { join, dirname, sep, relative } from 'path';
5
6
  import { mkdirSync, unlinkSync, utimesSync, openSync, writeSync, closeSync, readFileSync, statSync, existsSync, readlinkSync } from 'fs';
@@ -152,7 +153,8 @@ var NOTIFYING_WRITE_PATHS = [
152
153
  /^pages\/[^/]+$/,
153
154
  /^blocks\/[^/]+\/children$/
154
155
  ];
155
- var SdkClient = class extends Client {
156
+ var NotionClient = notionSdk.Client ?? notionSdk.default.Client;
157
+ var SdkClient = class extends NotionClient {
156
158
  constructor(options, notifications) {
157
159
  super(options);
158
160
  this.notifications = notifications;
@@ -170,6 +172,15 @@ function withNotifications(args, mode) {
170
172
  return { ...args, body: { ...args.body, notifications: { mode } } };
171
173
  }
172
174
 
175
+ // src/types/sync.ts
176
+ var CONFLICT_STRATEGIES = [
177
+ "local-first",
178
+ "remote-first",
179
+ "manual",
180
+ "duplicate",
181
+ "merge"
182
+ ];
183
+
173
184
  // src/types/config.ts
174
185
  var NOTION_TOKEN_PREFIX = "ntn_";
175
186
  var DEFAULT_TITLE_PROPERTY = "Name";
@@ -244,7 +255,7 @@ var ConfigSchema = z.object({
244
255
  }),
245
256
  sync: z.object({
246
257
  direction: z.enum(["push", "pull", "both"]).default(D.sync.direction),
247
- conflictStrategy: z.enum(["local-first", "remote-first", "manual", "duplicate"]).default(D.sync.conflictStrategy),
258
+ conflictStrategy: z.enum(CONFLICT_STRATEGIES).default(D.sync.conflictStrategy),
248
259
  autoSync: z.boolean().default(D.sync.autoSync),
249
260
  autoSyncInterval: z.number().min(30).max(3600).default(D.sync.autoSyncInterval),
250
261
  // 전체 대조 주기(초) — 그 사이의 pull 은 바뀐 페이지 · 행 · DB 만 받는다. 원격 삭제는 전체 대조가
@@ -2825,7 +2836,7 @@ function dataSourcesToQuery(meta) {
2825
2836
  return meta.dataSources.map((ds) => ({ id: ds.id, name: ds.name }));
2826
2837
  }
2827
2838
  var CHECKED_DATABASE_REUSE_MS = 10 * 60 * 1e3;
2828
- var NotionClient = class _NotionClient {
2839
+ var NotionClient2 = class _NotionClient {
2829
2840
  client;
2830
2841
  sema;
2831
2842
  propertyMapper;
@@ -3272,7 +3283,8 @@ var NotionClient = class _NotionClient {
3272
3283
  cover: db.cover ?? null,
3273
3284
  description: Array.isArray(db.description) ? db.description : [],
3274
3285
  showTitle: db.show_data_source_title !== false
3275
- }
3286
+ },
3287
+ ...db.in_trash === true || db.archived === true ? { inTrash: true } : {}
3276
3288
  };
3277
3289
  return { meta, owners };
3278
3290
  }
@@ -4941,8 +4953,72 @@ function pairLocalMoves(missing, untracked, hints) {
4941
4953
  paired.add(note.path);
4942
4954
  pairs.push({ from: note.path, to: file.path });
4943
4955
  }
4956
+ const pairedTo = new Set(pairs.map((pair) => pair.to));
4957
+ pairs.push(
4958
+ ...pairSimilarContent(
4959
+ missing.filter((note) => !paired.has(note.path)),
4960
+ unmatched.filter((file) => !pairedTo.has(file.path))
4961
+ )
4962
+ );
4944
4963
  return pairs;
4945
4964
  }
4965
+ var SIMILAR_CONTENT_THRESHOLD = 0.6;
4966
+ var MIN_SIMILAR_WORDS = 8;
4967
+ function wordPairs(text) {
4968
+ let body = text;
4969
+ try {
4970
+ body = splitFrontmatter(text).content;
4971
+ } catch {
4972
+ }
4973
+ const words = body.split(/\s+/).filter((word) => word.length > 0);
4974
+ if (words.length < MIN_SIMILAR_WORDS) return null;
4975
+ const counts = /* @__PURE__ */ new Map();
4976
+ for (let i = 1; i < words.length; i++) {
4977
+ const key = `${words[i - 1]} ${words[i]}`;
4978
+ counts.set(key, (counts.get(key) ?? 0) + 1);
4979
+ }
4980
+ return counts;
4981
+ }
4982
+ function diceOf(a, b) {
4983
+ let shared = 0;
4984
+ let sizeA = 0;
4985
+ let sizeB = 0;
4986
+ for (const count of a.values()) sizeA += count;
4987
+ for (const count of b.values()) sizeB += count;
4988
+ for (const [key, count] of a) shared += Math.min(count, b.get(key) ?? 0);
4989
+ return 2 * shared / (sizeA + sizeB);
4990
+ }
4991
+ function pairSimilarContent(missing, untracked) {
4992
+ const files = untracked.map((file) => ({ file, pairs: file.text === void 0 ? null : wordPairs(file.text) })).filter((entry) => !!entry.pairs);
4993
+ if (files.length === 0) return [];
4994
+ const candidates = [];
4995
+ for (const note of missing) {
4996
+ const notePairs = note.text === void 0 ? null : wordPairs(note.text);
4997
+ if (!notePairs) continue;
4998
+ for (const { file, pairs } of files) {
4999
+ const near = parentFolderOf(note.path) === parentFolderOf(file.path) || fileNameOf(note.path) === fileNameOf(file.path);
5000
+ if (!near) continue;
5001
+ const score = diceOf(notePairs, pairs);
5002
+ if (score >= SIMILAR_CONTENT_THRESHOLD)
5003
+ candidates.push({ from: note.path, to: file.path, score });
5004
+ }
5005
+ }
5006
+ candidates.sort(
5007
+ (a, b) => b.score - a.score || byPath({ path: a.from }, { path: b.from }) || byPath({ path: a.to }, { path: b.to })
5008
+ );
5009
+ const taken = /* @__PURE__ */ new Set();
5010
+ const result = [];
5011
+ for (const candidate of candidates) {
5012
+ if (taken.has(candidate.from) || taken.has(candidate.to)) continue;
5013
+ const tied = candidates.some(
5014
+ (other) => other !== candidate && other.score === candidate.score && (other.from === candidate.from || other.to === candidate.to) && !taken.has(other.from) && !taken.has(other.to)
5015
+ );
5016
+ taken.add(candidate.from);
5017
+ taken.add(candidate.to);
5018
+ if (!tied) result.push({ from: candidate.from, to: candidate.to });
5019
+ }
5020
+ return result;
5021
+ }
4946
5022
  function hintedFolderTarget(hints, folder) {
4947
5023
  let best = null;
4948
5024
  let ambiguous = false;
@@ -5040,10 +5116,16 @@ var ChangeDetector = class {
5040
5116
  for (const file of stats) {
5041
5117
  const record = this.stateDb.getByPath(file.path);
5042
5118
  const unchanged = hasRemotePage(record) && !this.movePending(record, moveOrigins) && record.localMtime === file.mtime && record.localFileSize === file.size;
5119
+ if (unchanged) {
5120
+ observed.push({ path: file.path, record, hash: null });
5121
+ continue;
5122
+ }
5123
+ const text = await readFile3(file.path);
5043
5124
  observed.push({
5044
5125
  path: file.path,
5045
5126
  record,
5046
- hash: unchanged ? null : computeHash(await readFile3(file.path))
5127
+ hash: computeHash(text),
5128
+ ...record === null ? { text } : {}
5047
5129
  });
5048
5130
  }
5049
5131
  return this.assemble(observed, moveOrigins, options);
@@ -5053,7 +5135,12 @@ var ChangeDetector = class {
5053
5135
  const observed = currentFiles.map((file) => {
5054
5136
  const record = this.stateDb.getByPath(file.path);
5055
5137
  const unchanged = hasRemotePage(record) && !this.movePending(record, moveOrigins) && Boolean(record.localMtime) && record.localMtime === file.mtime;
5056
- return { path: file.path, record, hash: unchanged ? null : computeHash(file.content) };
5138
+ return {
5139
+ path: file.path,
5140
+ record,
5141
+ hash: unchanged ? null : computeHash(file.content),
5142
+ ...record === null ? { text: file.content } : {}
5143
+ };
5057
5144
  });
5058
5145
  return this.assemble(observed, moveOrigins, options);
5059
5146
  }
@@ -5071,7 +5158,13 @@ var ChangeDetector = class {
5071
5158
  const { record, hash } = file;
5072
5159
  if (!hasRemotePage(record)) {
5073
5160
  if (record === null) {
5074
- if (!isConflictCopyPath(file.path)) untracked.push({ path: file.path, hash });
5161
+ if (!isConflictCopyPath(file.path)) {
5162
+ untracked.push({
5163
+ path: file.path,
5164
+ hash,
5165
+ ...file.text !== void 0 ? { text: file.text } : {}
5166
+ });
5167
+ }
5075
5168
  } else
5076
5169
  changes.push({
5077
5170
  path: file.path,
@@ -5108,7 +5201,8 @@ var ChangeDetector = class {
5108
5201
  missing.map((record) => ({
5109
5202
  path: record.obsidianPath,
5110
5203
  hash: record.contentHash,
5111
- origin: moveOrigins.get(record.id)
5204
+ origin: moveOrigins.get(record.id),
5205
+ ...record.baseSnapshot ? { text: record.baseSnapshot.toString("utf-8") } : {}
5112
5206
  })),
5113
5207
  untracked,
5114
5208
  options?.hints ?? EMPTY_RENAME_HINTS
@@ -5600,6 +5694,69 @@ function decodeMarkerTarget(encoded) {
5600
5694
  var MARKER_URL_CAPTURE = "((?:[^()\\s]|\\([^()]*\\))+)";
5601
5695
  var MARKER_LABEL_CAPTURE = "([^[\\]]+)";
5602
5696
 
5697
+ // src/converter/anchor-links.ts
5698
+ function splitAnchor(target) {
5699
+ const at = target.indexOf("#");
5700
+ if (at < 0) return null;
5701
+ const anchor = target.slice(at + 1).trim();
5702
+ return anchor === "" ? null : { note: target.slice(0, at).trim(), anchor };
5703
+ }
5704
+ function anchorDisplay(note, anchor) {
5705
+ return [...note === "" ? [] : [note], ...anchor.split("#").map((s) => s.trim())].join(" > ");
5706
+ }
5707
+ function anchorTargetOfDisplay(text, notes, anchor) {
5708
+ for (const note of notes) {
5709
+ const prefix = note === "" ? "" : `${note} > `;
5710
+ if (!text.startsWith(prefix)) continue;
5711
+ const segments = text.slice(prefix.length).split(" > ");
5712
+ const last = segments[segments.length - 1];
5713
+ const same = anchor.startsWith("^") ? last === anchor : headingKey(last) === anchor;
5714
+ if (same && segments.every((s) => s.trim() !== "")) return `${note}#${segments.join("#")}`;
5715
+ }
5716
+ return null;
5717
+ }
5718
+ var BLOCK_ID_SUFFIX_RE = /(?:^|\s)\^([A-Za-z0-9-]+)\s*$/;
5719
+ function headingKey(text) {
5720
+ return text.replace(/[#|^:%[\]\\]/g, " ").replace(/\s+/g, " ").trim();
5721
+ }
5722
+ function anchorBlockId(blocks, anchor) {
5723
+ if (anchor.startsWith("^")) {
5724
+ const id = anchor.slice(1);
5725
+ return blocks.find((b) => BLOCK_ID_SUFFIX_RE.exec(b.text)?.[1] === id)?.id ?? null;
5726
+ }
5727
+ const segments = anchor.split("#").map(headingKey);
5728
+ let next = 0;
5729
+ let found2 = null;
5730
+ for (const block of blocks) {
5731
+ if (!block.heading || headingKey(block.text) !== segments[next]) continue;
5732
+ found2 = block.id;
5733
+ if (++next === segments.length) return found2;
5734
+ }
5735
+ return null;
5736
+ }
5737
+ function anchorOfBlock(block) {
5738
+ if (block.heading) return headingKey(block.text) || null;
5739
+ const id = BLOCK_ID_SUFFIX_RE.exec(block.text)?.[1];
5740
+ return id === void 0 ? null : `^${id}`;
5741
+ }
5742
+ function anchorBlockOf(block) {
5743
+ const body = block[block.type];
5744
+ return {
5745
+ id: block.id,
5746
+ heading: /^heading_[1-3]$/.test(block.type),
5747
+ text: (body?.rich_text ?? []).map((t) => t.plain_text ?? "").join("")
5748
+ };
5749
+ }
5750
+ var ANCHORED_WIKILINK_RE = /(?<!!)\[\[([^[\]|]*#[^[\]|]*?)\\?(?:\|[^[\]]+)?\]\]/g;
5751
+ function anchoredWikilinkTargets(markdown) {
5752
+ return [...new Set([...markdown.matchAll(ANCHORED_WIKILINK_RE)].map((m) => m[1].trim()))].filter(
5753
+ (target) => splitAnchor(target) !== null
5754
+ );
5755
+ }
5756
+ function notionBlockUrl(pageId, blockId) {
5757
+ return `https://www.notion.so/${compactNotionId(pageId)}#${compactNotionId(blockId)}`;
5758
+ }
5759
+
5603
5760
  // src/converter/pre-processors/wikilink.ts
5604
5761
  var WIKILINK_REGEX2 = /(?<!!)\[\[([^[\]|]+)(?:\|([^[\]]+))?\]\]/g;
5605
5762
  function withoutTableAliasEscape(target) {
@@ -5624,6 +5781,8 @@ var WikilinkResolver = class {
5624
5781
  (_match, written, display, offset) => {
5625
5782
  const target = display === void 0 ? written : withoutTableAliasEscape(written);
5626
5783
  const label = display ?? target;
5784
+ const anchored = this.anchorLink(target, display, input.context.anchors);
5785
+ if (anchored) return anchored;
5627
5786
  if (this.resolve) {
5628
5787
  const entry = this.resolve(target);
5629
5788
  if (entry) {
@@ -5649,6 +5808,19 @@ var WikilinkResolver = class {
5649
5808
  metadata: { ...input.metadata, preserveMarkers }
5650
5809
  };
5651
5810
  }
5811
+ /**
5812
+ * `[[노트#제목]]` · `[[노트#^블록]]` · `[[#제목]]` → `[글자](https://www.notion.so/<페이지>#<블록>)`. 글자는 별칭,
5813
+ * 없으면 Obsidian 이 보이는 「노트 > 제목」 — pull 이 그 글자면 별칭을 붙이지 않는다. 대상 페이지 · 블록을 못 찾으면 null.
5814
+ */
5815
+ anchorLink(target, display, anchors) {
5816
+ const split = anchors ? splitAnchor(target) : null;
5817
+ if (!anchors || !split) return null;
5818
+ const pageId = split.note === "" ? anchors.selfPageId : this.resolve?.(split.note)?.notionPageId ?? null;
5819
+ if (!pageId) return null;
5820
+ const blockId = anchors.blockOf(pageId, split.anchor);
5821
+ if (!blockId) return null;
5822
+ return `[${display ?? anchorDisplay(split.note, split.anchor)}](${notionBlockUrl(pageId, blockId)})`;
5823
+ }
5652
5824
  };
5653
5825
 
5654
5826
  // src/converter/callout-types.ts
@@ -12816,6 +12988,21 @@ function collectLocalMarkerBasenames(markdown) {
12816
12988
  function baseName(path) {
12817
12989
  return path.split("/").pop() ?? path;
12818
12990
  }
12991
+ function sha256Of(buffer) {
12992
+ return createHash("sha256").update(buffer).digest("hex");
12993
+ }
12994
+ function localEmbedTargets(note) {
12995
+ const targets = [...note.matchAll(/!\[\[([^\]\n]+)\]\]/g)].map((match) => match[1].trim());
12996
+ for (const link of scanMarkdownLinks(note)) {
12997
+ if (!link.isEmbed || /^[a-z][a-z0-9+.-]*:/i.test(link.url)) continue;
12998
+ try {
12999
+ targets.push(decodeURI(link.url));
13000
+ } catch {
13001
+ targets.push(link.url);
13002
+ }
13003
+ }
13004
+ return targets;
13005
+ }
12819
13006
  function folderOf(notePath) {
12820
13007
  return notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
12821
13008
  }
@@ -12951,8 +13138,9 @@ var ImageHandler = class {
12951
13138
  *
12952
13139
  * @param pageId 본문을 받은 페이지 — 그 페이지에 올린 것만 되찾는다
12953
13140
  * @param notePath 노트 경로 — 옛 캡션의 파일을 push 와 같은 사다리로 찾는다
13141
+ * @param adoptFrom 상태 기록에 없던 같은 자리의 노트 글(P7 다시 잇기) — {@link adoptLocalEmbed}
12954
13142
  */
12955
- async restoreUploadedMedia(markdown, pageId, notePath) {
13143
+ async restoreUploadedMedia(markdown, pageId, notePath, adoptFrom) {
12956
13144
  const spans = notionMediaSpans(markdown);
12957
13145
  if (spans.length === 0) return markdown;
12958
13146
  const known = pageId ? recallUploadedMedia(this.stateDb, pageId) : /* @__PURE__ */ new Map();
@@ -12960,11 +13148,51 @@ var ImageHandler = class {
12960
13148
  let result = markdown;
12961
13149
  for (const span of spans) {
12962
13150
  const uploadedName = this.uploadedNameOf(span.url);
12963
- const target = await this.knownEmbedTarget(known, span, uploadedName) ?? await this.resolveOriginalTarget(span.label, uploadedName, noteFolder);
13151
+ const target = await this.knownEmbedTarget(known, span, uploadedName) ?? await this.resolveOriginalTarget(span.label, uploadedName, noteFolder) ?? (adoptFrom !== void 0 && pageId ? await this.adoptLocalEmbed(span, uploadedName, adoptFrom, noteFolder, pageId) : null);
12964
13152
  if (target) result = result.replace(span.full, () => `![[${target}]]`);
12965
13153
  }
12966
13154
  return result;
12967
13155
  }
13156
+ /**
13157
+ * 상태 DB 를 잃고 다시 잇는 노트(P7, ADR-049)의 미디어 — 올린 미디어 기록도 함께 잃어, push 가 올린 그림을 사본으로 받아
13158
+ * 노트가 달라졌다(실측: `![[img/blue.png]]` → `![[attachments/blue-<해시>.png]]` 로 충돌). 받은 미디어의 이름과 바이트가
13159
+ * 노트가 가리키는 볼트 파일과 같으면 그 임베드로 되살리고 받은 것으로 적는다 — 다음 push 가 그 블록을 둔다. 바이트를
13160
+ * 견주므로 Notion 에서 같은 이름의 다른 파일로 바꾼 것은 되살리지 않는다. 받지 못하거나 다르면 null — 사본을 받는다.
13161
+ */
13162
+ async adoptLocalEmbed(span, uploadedName, local, noteFolder, pageId) {
13163
+ if (!uploadedName || INTERNAL_FILE_URL_RE.test(span.url)) return null;
13164
+ const targets = localEmbedTargets(local).filter(
13165
+ (target) => baseName(stripAlias2(target)) === uploadedName
13166
+ );
13167
+ if (targets.length === 0) return null;
13168
+ let remoteHash;
13169
+ try {
13170
+ const response = await this.fetchForDownload(span.url);
13171
+ if (!response.ok) return null;
13172
+ remoteHash = sha256Of(Buffer.from(await response.arrayBuffer()));
13173
+ } catch {
13174
+ return null;
13175
+ }
13176
+ for (const target of targets) {
13177
+ for (const candidate of this.vaultCandidates(stripAlias2(target), noteFolder)) {
13178
+ let localHash;
13179
+ try {
13180
+ localHash = sha256Of(await this.vaultFs.readBinary(candidate));
13181
+ } catch {
13182
+ continue;
13183
+ }
13184
+ if (localHash !== remoteHash) continue;
13185
+ const fileId = notionFileIdOf(span.url);
13186
+ if (fileId && this.stateDb) {
13187
+ rememberReceivedMedia(this.stateDb, pageId, [
13188
+ { fileId, target, localPath: candidate, fileHash: remoteHash }
13189
+ ]);
13190
+ }
13191
+ return target;
13192
+ }
13193
+ }
13194
+ return null;
13195
+ }
12968
13196
  /** 적어 둔 업로드면 되살릴 임베드 대상. 볼트에서 그 파일이 사라졌으면 null — 사본을 받는다. */
12969
13197
  async knownEmbedTarget(known, span, uploadedName) {
12970
13198
  const fileId = notionFileIdOf(span.url);
@@ -14783,6 +15011,136 @@ async function readLocalNote(vaultFs, path) {
14783
15011
  }
14784
15012
  }
14785
15013
 
15014
+ // src/conflict/merger.ts
15015
+ function threeWayMerge(base, local, remote) {
15016
+ const baseLines = base.split("\n");
15017
+ const localLines = local.split("\n");
15018
+ const remoteLines = remote.split("\n");
15019
+ const hunks = [
15020
+ ...computeHunks(baseLines, localLines, 0),
15021
+ ...computeHunks(baseLines, remoteLines, 2)
15022
+ ];
15023
+ hunks.sort((a, b) => a.baseStart - b.baseStart || a.side - b.side);
15024
+ const merged = [];
15025
+ const conflicts = [];
15026
+ let cursor = 0;
15027
+ let i = 0;
15028
+ while (i < hunks.length) {
15029
+ const first = hunks[i];
15030
+ if (first.baseStart > cursor) {
15031
+ merged.push(...baseLines.slice(cursor, first.baseStart));
15032
+ }
15033
+ const regionStart = first.baseStart;
15034
+ let regionEnd = first.baseEnd;
15035
+ const group2 = [first];
15036
+ i++;
15037
+ while (i < hunks.length) {
15038
+ const next = hunks[i];
15039
+ const overlaps = next.baseStart < regionEnd || regionEnd === regionStart && next.baseStart === regionStart;
15040
+ if (!overlaps) break;
15041
+ regionEnd = Math.max(regionEnd, next.baseEnd);
15042
+ group2.push(next);
15043
+ i++;
15044
+ }
15045
+ const baseRegion = baseLines.slice(regionStart, regionEnd);
15046
+ const localRegion = applyHunks(baseLines, group2, 0, regionStart, regionEnd);
15047
+ const remoteRegion = applyHunks(baseLines, group2, 2, regionStart, regionEnd);
15048
+ if (arraysEqual(localRegion, baseRegion)) {
15049
+ merged.push(...remoteRegion);
15050
+ } else if (arraysEqual(remoteRegion, baseRegion)) {
15051
+ merged.push(...localRegion);
15052
+ } else if (arraysEqual(localRegion, remoteRegion)) {
15053
+ merged.push(...localRegion);
15054
+ } else {
15055
+ conflicts.push({ localLines: localRegion, remoteLines: remoteRegion });
15056
+ merged.push("<<<<<<< LOCAL");
15057
+ merged.push(...localRegion);
15058
+ merged.push("=======");
15059
+ merged.push(...remoteRegion);
15060
+ merged.push(">>>>>>> REMOTE");
15061
+ }
15062
+ cursor = regionEnd;
15063
+ }
15064
+ if (cursor < baseLines.length) {
15065
+ merged.push(...baseLines.slice(cursor));
15066
+ }
15067
+ return {
15068
+ success: conflicts.length === 0,
15069
+ merged: merged.join("\n"),
15070
+ conflicts
15071
+ };
15072
+ }
15073
+ function computeHunks(base, modified, side) {
15074
+ const matches = lcsMatches(base, modified);
15075
+ const hunks = [];
15076
+ let prevBase = -1;
15077
+ let prevMod = -1;
15078
+ const emitGap = (curBase, curMod) => {
15079
+ const baseLen = curBase - (prevBase + 1);
15080
+ const modLen = curMod - (prevMod + 1);
15081
+ if (baseLen > 0 || modLen > 0) {
15082
+ hunks.push({
15083
+ baseStart: prevBase + 1,
15084
+ baseEnd: curBase,
15085
+ content: modified.slice(prevMod + 1, curMod),
15086
+ side
15087
+ });
15088
+ }
15089
+ };
15090
+ for (const [b, m] of matches) {
15091
+ emitGap(b, m);
15092
+ prevBase = b;
15093
+ prevMod = m;
15094
+ }
15095
+ emitGap(base.length, modified.length);
15096
+ return hunks;
15097
+ }
15098
+ function applyHunks(baseLines, group2, side, regionStart, regionEnd) {
15099
+ const out = [];
15100
+ let b = regionStart;
15101
+ for (const hunk of group2) {
15102
+ if (hunk.side !== side) continue;
15103
+ if (hunk.baseStart > b) out.push(...baseLines.slice(b, hunk.baseStart));
15104
+ out.push(...hunk.content);
15105
+ b = hunk.baseEnd;
15106
+ }
15107
+ if (b < regionEnd) out.push(...baseLines.slice(b, regionEnd));
15108
+ return out;
15109
+ }
15110
+ function lcsMatches(a, b) {
15111
+ const n = a.length;
15112
+ const m = b.length;
15113
+ const dp = new Array(n + 1);
15114
+ for (let i2 = 0; i2 <= n; i2++) dp[i2] = new Uint32Array(m + 1);
15115
+ for (let i2 = n - 1; i2 >= 0; i2--) {
15116
+ const row = dp[i2];
15117
+ const next = dp[i2 + 1];
15118
+ const ai = a[i2];
15119
+ for (let j2 = m - 1; j2 >= 0; j2--) {
15120
+ row[j2] = ai === b[j2] ? next[j2 + 1] + 1 : Math.max(next[j2], row[j2 + 1]);
15121
+ }
15122
+ }
15123
+ const matches = [];
15124
+ let i = 0;
15125
+ let j = 0;
15126
+ while (i < n && j < m) {
15127
+ if (a[i] === b[j]) {
15128
+ matches.push([i, j]);
15129
+ i++;
15130
+ j++;
15131
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
15132
+ i++;
15133
+ } else {
15134
+ j++;
15135
+ }
15136
+ }
15137
+ return matches;
15138
+ }
15139
+ function arraysEqual(a, b) {
15140
+ if (a.length !== b.length) return false;
15141
+ return a.every((val, idx) => val === b[idx]);
15142
+ }
15143
+
14786
15144
  // src/sync/conflict-detector.ts
14787
15145
  function sameNoteContent(a, b) {
14788
15146
  return a === b || a.replace(/\n+$/, "") === b.replace(/\n+$/, "");
@@ -14810,6 +15168,10 @@ function resolvePullConflict(input) {
14810
15168
  if (strategy === "local-first") {
14811
15169
  return { action: "skip", localHash };
14812
15170
  }
15171
+ if (strategy === "merge" && baseContent !== null) {
15172
+ const result = threeWayMerge(baseContent, localContent, remoteContent);
15173
+ if (result.success) return { action: "merge", merged: result.merged, localHash };
15174
+ }
14813
15175
  const conflict = {
14814
15176
  syncRecord: record,
14815
15177
  localChange: {
@@ -14826,49 +15188,211 @@ function resolvePullConflict(input) {
14826
15188
  return { action: "conflict", conflict, localHash };
14827
15189
  }
14828
15190
 
14829
- // src/sync/remote-deletion.ts
14830
- async function remotePresence(client, pageId) {
14831
- let page;
14832
- try {
14833
- page = await client.getPage(pageId);
14834
- } catch (error) {
14835
- if (isNotionObjectNotFound(error)) return { kind: "gone" };
14836
- throw error;
14837
- }
14838
- return isTrashedOrArchived(page) ? { kind: "gone" } : { kind: "alive", page };
14839
- }
14840
- async function applyRemoteDeletion(stateDb, vaultFs, record, options) {
14841
- const decision = await decideRemoteDeletion(vaultFs, record, options);
14842
- if (decision.action === "kept") {
14843
- untrack(stateDb, record);
14844
- getLogger().info(
14845
- `[Im-Nobsidian] Notion \uC5D0\uC11C \uC9C0\uC6B4 ${record.obsidianPath} \uC5D0 \uC62C\uB9AC\uC9C0 \uC54A\uC740 \uB85C\uCEEC \uD3B8\uC9D1\uC774 \uC788\uC5B4 \uD30C\uC77C\uC744 \uB460 \u2014 \uB2E4\uC74C push \uAC00 \uC0C8 \uD398\uC774\uC9C0\uB85C \uB9CC\uB4E0\uB2E4(local-first)`
14846
- );
14847
- return { action: "kept" };
14848
- }
14849
- if (decision.action === "conflict") {
14850
- stateDb.updateStatus(record.id, "conflict");
14851
- return {
14852
- action: "conflict",
14853
- conflict: remoteDeletionConflict(record, decision.local, options.remoteChange)
14854
- };
15191
+ // src/converter/notion-id-links.ts
15192
+ var NOTION_ID_WIKILINK_RE = /\[\[notion:([a-f0-9]{32}|[a-f0-9-]{36})(?:(\\?\|)([^[\]]+))?\]\]/g;
15193
+ var compactId = (id) => id.replace(/-/g, "");
15194
+ var PENDING_LINK_RE = new RegExp(
15195
+ `${NOTION_ID_WIKILINK_RE.source}|\\b([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}|[a-f0-9]{32})\\b`,
15196
+ "g"
15197
+ );
15198
+ function sameAsPendingLinks(local, remote) {
15199
+ if (!PENDING_LINK_RE.test(remote)) return false;
15200
+ PENDING_LINK_RE.lastIndex = 0;
15201
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15202
+ let pattern = "";
15203
+ let last = 0;
15204
+ for (const match of remote.matchAll(PENDING_LINK_RE)) {
15205
+ pattern += escape(remote.slice(last, match.index));
15206
+ const alias = match[3];
15207
+ pattern += match[4] ? `"?\\[\\[[^\\]\\n]+\\]\\]"?` : alias ? `(?:\\[\\[(?:[^\\]\\n|]*/)?${escape(alias)}(?:\\\\?\\|[^\\]\\n]*)?\\]\\]|\\[\\[[^\\]\\n|]+\\\\?\\|${escape(alias)}\\]\\]|\\[${escape(alias)}\\]\\([^)\\n]+\\))` : `(?:\\[\\[[^\\]\\n]+\\]\\]|\\[[^\\]\\n]+\\]\\([^)\\n]+\\))`;
15208
+ last = match.index + match[0].length;
14855
15209
  }
14856
- await removeTrackedNote(stateDb, vaultFs, record, options.deleteFile);
14857
- return { action: "deleted" };
14858
- }
14859
- async function decideRemoteDeletion(vaultFs, record, options) {
14860
- const local = options.deleteFile ? await unsyncedLocalEdit(vaultFs, record) : null;
14861
- if (local === null || options.strategy === "remote-first") return { action: "deleted" };
14862
- return options.strategy === "local-first" ? { action: "kept" } : { action: "conflict", local };
15210
+ pattern += escape(remote.slice(last).replace(/\n+$/, ""));
15211
+ return new RegExp(`^${pattern}\\n*$`).test(local.replace(/\n+$/, ""));
14863
15212
  }
14864
- async function removeTrackedNote(stateDb, vaultFs, record, deleteFile) {
14865
- if (deleteFile) {
14866
- try {
14867
- await vaultFs.deleteFile(record.obsidianPath);
14868
- } catch {
14869
- }
14870
- }
14871
- untrack(stateDb, record);
15213
+ function resolveNotionIdWikilinks(markdown, lookup) {
15214
+ let resolved = 0;
15215
+ const out = markdown.replace(
15216
+ NOTION_ID_WIKILINK_RE,
15217
+ (match, id, separator, rawAlias) => {
15218
+ const path = lookup(id);
15219
+ if (!path) return match;
15220
+ resolved++;
15221
+ const base = wikilinkTitleFromPath(path);
15222
+ const alias = rawAlias === compactId(id) ? void 0 : rawAlias;
15223
+ const link = formatWikilink(base, alias);
15224
+ return separator === "\\|" ? link.replace("|", separator) : link;
15225
+ }
15226
+ );
15227
+ return { markdown: out, resolved };
15228
+ }
15229
+ function degradeUnresolvedNotionIdWikilinks(markdown, titles = /* @__PURE__ */ new Map()) {
15230
+ let degraded = 0;
15231
+ const out = markdown.replace(
15232
+ NOTION_ID_WIKILINK_RE,
15233
+ (_match, id, _separator, alias) => {
15234
+ degraded++;
15235
+ const compact = compactId(id);
15236
+ return `[${alias ?? (titles.get(compact) || compact)}](https://www.notion.so/${compact})`;
15237
+ }
15238
+ );
15239
+ return { markdown: out, degraded };
15240
+ }
15241
+ function unlabeledNotionIds(markdown) {
15242
+ const ids = /* @__PURE__ */ new Set();
15243
+ for (const m of markdown.matchAll(NOTION_ID_WIKILINK_RE)) {
15244
+ if (m[3] === void 0) ids.add(compactId(m[1]));
15245
+ }
15246
+ return [...ids];
15247
+ }
15248
+ function linkLabelOfTitle(title) {
15249
+ return title.replace(/[\r\n]+/g, " ").trim().replace(/\[/g, "(").replace(/\]/g, ")").replace(/[\\*_`~]/g, "\\$&");
15250
+ }
15251
+ var NOTION_RELATIVE_PAGE_LINK_RE = /\[([^\]]+)\]\(\/(?:p\/)?([a-f0-9]{32})(?:\?[^)#]*)?(?:#([^)]*))?\)/g;
15252
+ function resolveNotionRelativePageLinks(markdown, lookup) {
15253
+ let resolved = 0;
15254
+ const out = markdown.replace(NOTION_RELATIVE_PAGE_LINK_RE, (match, label, id) => {
15255
+ const path = lookup(id);
15256
+ if (!path) return match;
15257
+ resolved++;
15258
+ return formatWikilink(wikilinkTitleFromPath(path), label);
15259
+ });
15260
+ return { markdown: out, resolved };
15261
+ }
15262
+ function degradeUnresolvedNotionRelativePageLinks(markdown) {
15263
+ let degraded = 0;
15264
+ const out = markdown.replace(
15265
+ NOTION_RELATIVE_PAGE_LINK_RE,
15266
+ (_match, label, id, anchor) => {
15267
+ degraded++;
15268
+ return `[${label}](https://www.notion.so/${id}${anchor === void 0 ? "" : `#${anchor}`})`;
15269
+ }
15270
+ );
15271
+ return { markdown: out, degraded };
15272
+ }
15273
+ var NOTION_BLOCK_LINK_RE = new RegExp(
15274
+ `\\[${MARKER_LABEL_CAPTURE}\\]\\(https?:\\/\\/(?:www\\.|app\\.)?notion\\.(?:so|com)\\/(?:p\\/)?([a-f0-9]{32})#([a-f0-9]{32}|[a-f0-9-]{36})\\)`,
15275
+ "g"
15276
+ );
15277
+ function noteForms(path, self) {
15278
+ const parts = path.replace(/\.md$/i, "").split("/");
15279
+ return [...parts.map((_, i) => parts.slice(i).join("/")), ...self ? [""] : []];
15280
+ }
15281
+ function notionBlockLinkIds(markdown, lookup) {
15282
+ const ids = /* @__PURE__ */ new Set();
15283
+ for (const m of markdown.matchAll(NOTION_BLOCK_LINK_RE)) {
15284
+ if (lookup(m[2])) ids.add(compactId(m[3]));
15285
+ }
15286
+ return [...ids];
15287
+ }
15288
+ function resolveNotionBlockLinks(markdown, lookup, anchorOf, selfPageId = null) {
15289
+ let resolved = 0;
15290
+ const self = selfPageId === null ? null : compactId(selfPageId);
15291
+ const out = markdown.replace(
15292
+ NOTION_BLOCK_LINK_RE,
15293
+ (match, label, pageId, blockId, offset) => {
15294
+ const path = lookup(pageId);
15295
+ const anchor = anchorOf(compactId(blockId));
15296
+ if (!path || !anchor) return match;
15297
+ resolved++;
15298
+ const note = compactId(pageId) === self ? "" : wikilinkTitleFromPath(path);
15299
+ const text = label.replace(/\\([>^])/g, "$1");
15300
+ const written = anchorTargetOfDisplay(text, noteForms(path, note === ""), anchor);
15301
+ const link = written ? formatWikilink(written) : formatWikilink(`${note}#${anchor}`, text);
15302
+ const line = markdown.slice(markdown.lastIndexOf("\n", offset) + 1, offset);
15303
+ return line.trimStart().startsWith("|") ? link.replace("|", "\\|") : link;
15304
+ }
15305
+ );
15306
+ return { markdown: out, resolved };
15307
+ }
15308
+
15309
+ // src/sync/update-journal.ts
15310
+ var UpdateJournal = class {
15311
+ constructor(stateDb) {
15312
+ this.stateDb = stateDb;
15313
+ }
15314
+ stateDb;
15315
+ /**
15316
+ * 보내기 전에 적는다. 같은 노트의 끊긴 기록은 마친 것으로 돌린다 — 이번에 보내는 글이 그것을 대신한다.
15317
+ * @param sentHash 보내는 노트 글의 해시(`computeHash(content)`).
15318
+ */
15319
+ begin(recordId, sentHash) {
15320
+ this.finish(recordId);
15321
+ this.stateDb.recordPendingOperation({
15322
+ syncStateId: recordId,
15323
+ operation: "update",
15324
+ direction: "push",
15325
+ payload: JSON.stringify({ hash: sentHash })
15326
+ });
15327
+ }
15328
+ /** 상태 DB 에 적었다 — 끊긴 기록을 마친다. */
15329
+ finish(recordId) {
15330
+ for (const op of this.stateDb.getIncompletePendingOperations()) {
15331
+ if (op.syncStateId === recordId && op.operation === "update") {
15332
+ this.stateDb.markPendingCompleted(op.id);
15333
+ }
15334
+ }
15335
+ }
15336
+ /**
15337
+ * 원격이 이 노트를 쓰다 끊긴 그대로인가.
15338
+ * @param localHash 지금 노트 글의 해시 — 그때 보내던 글과 같아야 한다. 그 뒤 고쳤으면 끊긴 쓰기로 보지 않는다.
15339
+ * @param botUserId 이 토큰의 봇 id. 모르면 가를 수 없어 아니다.
15340
+ */
15341
+ wroteRemote(record, remote, localHash, botUserId) {
15342
+ if (botUserId === null || remote.last_edited_by?.id !== botUserId) return false;
15343
+ const op = this.stateDb.getIncompleteOpByState(record.id, "update");
15344
+ if (!op?.payload) return false;
15345
+ try {
15346
+ return JSON.parse(op.payload).hash === localHash;
15347
+ } catch {
15348
+ return false;
15349
+ }
15350
+ }
15351
+ };
15352
+
15353
+ // src/sync/remote-deletion.ts
15354
+ async function remotePresence(client, pageId) {
15355
+ let page;
15356
+ try {
15357
+ page = await client.getPage(pageId);
15358
+ } catch (error) {
15359
+ if (isNotionObjectNotFound(error)) return { kind: "gone" };
15360
+ throw error;
15361
+ }
15362
+ return isTrashedOrArchived(page) ? { kind: "gone" } : { kind: "alive", page };
15363
+ }
15364
+ async function applyRemoteDeletion(stateDb, vaultFs, record, options) {
15365
+ const decision = await decideRemoteDeletion(vaultFs, record, options);
15366
+ if (decision.action === "kept") {
15367
+ untrack(stateDb, record);
15368
+ getLogger().info(
15369
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uC9C0\uC6B4 ${record.obsidianPath} \uC5D0 \uC62C\uB9AC\uC9C0 \uC54A\uC740 \uB85C\uCEEC \uD3B8\uC9D1\uC774 \uC788\uC5B4 \uD30C\uC77C\uC744 \uB460 \u2014 \uB2E4\uC74C push \uAC00 \uC0C8 \uD398\uC774\uC9C0\uB85C \uB9CC\uB4E0\uB2E4(local-first)`
15370
+ );
15371
+ return { action: "kept" };
15372
+ }
15373
+ if (decision.action === "conflict") {
15374
+ stateDb.updateStatus(record.id, "conflict");
15375
+ return {
15376
+ action: "conflict",
15377
+ conflict: remoteDeletionConflict(record, decision.local, options.remoteChange)
15378
+ };
15379
+ }
15380
+ await removeTrackedNote(stateDb, vaultFs, record, options.deleteFile);
15381
+ return { action: "deleted" };
15382
+ }
15383
+ async function decideRemoteDeletion(vaultFs, record, options) {
15384
+ const local = options.deleteFile ? await unsyncedLocalEdit(vaultFs, record) : null;
15385
+ if (local === null || options.strategy === "remote-first") return { action: "deleted" };
15386
+ return options.strategy === "local-first" ? { action: "kept" } : { action: "conflict", local };
15387
+ }
15388
+ async function removeTrackedNote(stateDb, vaultFs, record, deleteFile) {
15389
+ if (deleteFile) {
15390
+ try {
15391
+ await vaultFs.deleteFile(record.obsidianPath);
15392
+ } catch {
15393
+ }
15394
+ }
15395
+ untrack(stateDb, record);
14872
15396
  }
14873
15397
  function untrack(stateDb, record) {
14874
15398
  stateDb.transaction(() => {
@@ -15088,6 +15612,12 @@ function isDirectDbRowPath(localFolder, path) {
15088
15612
  const rel = path.slice(prefix.length);
15089
15613
  return rel.length > 0 && !rel.includes("/");
15090
15614
  }
15615
+ function databaseFolderName(title) {
15616
+ return truncateBytes(
15617
+ title.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-"),
15618
+ FILE_SYSTEM_NAME_MAX_BYTES
15619
+ );
15620
+ }
15091
15621
 
15092
15622
  // src/sync/discovered-databases.ts
15093
15623
  var ROOT_DATABASES_FOLDER = "databases";
@@ -15704,6 +16234,25 @@ var DbBaseFiles = class {
15704
16234
  this.loadedRaw = JSON.stringify(Object.fromEntries(entries));
15705
16235
  this.stateDb.setMeta(DB_BASE_FILES_META_KEY, this.loadedRaw);
15706
16236
  }
16237
+ /** 기록을 지운다 — Notion 에서 지운 링크드 뷰. */
16238
+ delete(databaseId) {
16239
+ const entries = this.load();
16240
+ if (!entries.delete(compactNotionId(databaseId))) return;
16241
+ this.loadedRaw = JSON.stringify(Object.fromEntries(entries));
16242
+ this.stateDb.setMeta(DB_BASE_FILES_META_KEY, this.loadedRaw);
16243
+ }
16244
+ /** 이 DB 를 원본으로 둔 링크드 뷰의 `.base` 경로. */
16245
+ linkedBasesOf(databaseId) {
16246
+ const owner = compactNotionId(databaseId);
16247
+ return [...this.load().values()].filter((file) => file.linkedTo === owner).map((file) => file.basePath);
16248
+ }
16249
+ /** 이 경로에 쓴 링크드 뷰 컨테이너(줄인 id) — 없으면 null. */
16250
+ linkedContainerAt(basePath) {
16251
+ for (const [id, file] of this.load()) {
16252
+ if (file.linkedTo && file.basePath === basePath) return id;
16253
+ }
16254
+ return null;
16255
+ }
15707
16256
  /** 상태 메타에서 읽는다 — 값이 그대로면 읽어 둔 것을 쓴다. 없거나 깨졌으면 비어 있다 — 이번 실행이 기록하는 것부터 다시 쌓는다. */
15708
16257
  load() {
15709
16258
  const raw = this.stateDb.getMeta(DB_BASE_FILES_META_KEY);
@@ -15724,7 +16273,9 @@ var DbBaseFiles = class {
15724
16273
  title: entry.title,
15725
16274
  ...typeof entry.hash === "string" ? { hash: entry.hash } : {},
15726
16275
  ...Array.isArray(entry.views) ? { views: entry.views } : {},
15727
- ...typeof entry.rest === "string" ? { rest: entry.rest } : {}
16276
+ ...typeof entry.rest === "string" ? { rest: entry.rest } : {},
16277
+ ...typeof entry.docRest === "string" ? { docRest: entry.docRest } : {},
16278
+ ...typeof entry.linkedTo === "string" ? { linkedTo: entry.linkedTo } : {}
15728
16279
  })
15729
16280
  );
15730
16281
  }
@@ -15740,7 +16291,9 @@ function normalize(info) {
15740
16291
  title: info.title,
15741
16292
  ...info.hash ? { hash: info.hash } : {},
15742
16293
  ...info.views && info.views.length > 0 ? { views: info.views } : {},
15743
- ...info.views && info.views.length > 0 && info.rest ? { rest: info.rest } : {}
16294
+ ...info.views && info.views.length > 0 && info.rest ? { rest: info.rest } : {},
16295
+ ...info.views && info.views.length > 0 && info.docRest ? { docRest: info.docRest } : {},
16296
+ ...info.linkedTo ? { linkedTo: info.linkedTo } : {}
15744
16297
  };
15745
16298
  }
15746
16299
 
@@ -15806,8 +16359,22 @@ function matchViews(records, views) {
15806
16359
  });
15807
16360
  return out;
15808
16361
  }
16362
+ var without = (from, keys) => Object.fromEntries(Object.entries(from).filter(([key]) => !keys.includes(key)));
16363
+ function viewRestOf(view) {
16364
+ return computeHash(canonical(without(view, PUSHABLE_KEYS)));
16365
+ }
16366
+ function docRestOf(parsed) {
16367
+ return computeHash(canonical(without(parsed.doc, ["views"])));
16368
+ }
16369
+ function restUnchanged(parsed, matched, base) {
16370
+ const records = base.views ?? [];
16371
+ if (base.docRest === void 0 || records.some((record) => record.rest === void 0)) {
16372
+ return matched.filter(Boolean).length === records.length && restSignature(parsed, matched) === base.rest;
16373
+ }
16374
+ if (docRestOf(parsed) !== base.docRest) return false;
16375
+ return parsed.views.every((view, i) => !matched[i] || viewRestOf(view) === matched[i].rest);
16376
+ }
15809
16377
  function restSignature(parsed, matched) {
15810
- const without = (from, keys) => Object.fromEntries(Object.entries(from).filter(([key]) => !keys.includes(key)));
15811
16378
  const views = {};
15812
16379
  parsed.views.forEach((view, i) => {
15813
16380
  const key = matched[i] ? `id:${matched[i].viewId}` : `new:${String(view["name"])}`;
@@ -15839,10 +16406,11 @@ function recordBaseViews(content, written, views) {
15839
16406
  type: String(parsed.views[i]["type"]),
15840
16407
  ...pushableOf(parsed.views[i]),
15841
16408
  ...source.lossyFilter ? { lossyFilter: true } : {},
15842
- notion: viewFingerprint(view)
16409
+ notion: viewFingerprint(view),
16410
+ rest: viewRestOf(parsed.views[i])
15843
16411
  });
15844
16412
  }
15845
- return { views: records, rest: restSignature(parsed, records) };
16413
+ return { views: records, rest: restSignature(parsed, records), docRest: docRestOf(parsed) };
15846
16414
  }
15847
16415
  function canonical(value) {
15848
16416
  return JSON.stringify(sortKeys3(value)) ?? "null";
@@ -16089,6 +16657,7 @@ var SidecarSchemaEdits = class {
16089
16657
  hash,
16090
16658
  properties,
16091
16659
  payload: diff.payload,
16660
+ ...diff.staged ? { staged: diff.staged, optionRenames: diff.optionRenames } : {},
16092
16661
  edit: { path: base.path, ...diff.edit },
16093
16662
  seen: this.ledger.get(databaseId) ? null : seenSchemaOf(meta)
16094
16663
  });
@@ -16102,6 +16671,12 @@ var SidecarSchemaEdits = class {
16102
16671
  if (edit.seen) this.ledger.set(edit.databaseId, edit.seen);
16103
16672
  let after;
16104
16673
  try {
16674
+ if (edit.staged) {
16675
+ await this.notionClient.updateDataSourceProperties(edit.dataSourceId, edit.staged);
16676
+ for (const rename2 of edit.optionRenames ?? []) {
16677
+ await this.moveOptionValues(edit.databaseId, rename2);
16678
+ }
16679
+ }
16105
16680
  after = await this.notionClient.updateDataSourceProperties(edit.dataSourceId, edit.payload);
16106
16681
  } catch (error) {
16107
16682
  failed.push({
@@ -16123,6 +16698,30 @@ var SidecarSchemaEdits = class {
16123
16698
  }
16124
16699
  return { applied, failed };
16125
16700
  }
16701
+ /**
16702
+ * 옛 옵션 값을 가진 Notion 행을 새 이름으로 옮긴다. 중간에 멈추면 두 이름이 다 남는다 — 사이드카 기록을 고치지 않아
16703
+ * 다음 push 가 같은 이름 바꾸기를 다시 해 남은 행을 마저 옮긴다.
16704
+ */
16705
+ async moveOptionValues(databaseId, rename2) {
16706
+ const { propertyId, type, from, to } = rename2;
16707
+ const filter = type === "select" ? { property: propertyId, select: { equals: from } } : { property: propertyId, multi_select: { contains: from } };
16708
+ const rows = await this.notionClient.queryAllDatabasePages(databaseId, filter);
16709
+ for (const row of rows) {
16710
+ let value;
16711
+ if (type === "select") {
16712
+ value = { name: to };
16713
+ } else {
16714
+ const current = Object.values(row.properties).find(
16715
+ (prop) => prop?.id === propertyId
16716
+ );
16717
+ const names = (current?.multi_select ?? []).map(
16718
+ (option) => option.name === from ? to : option.name ?? ""
16719
+ );
16720
+ value = [...new Set(names.filter((name) => name.length > 0))].map((name) => ({ name }));
16721
+ }
16722
+ await this.notionClient.updatePageProperties(row.id, { [propertyId]: { [type]: value } });
16723
+ }
16724
+ }
16126
16725
  };
16127
16726
  function sidecarPropertiesEdited(content, base) {
16128
16727
  let parsed;
@@ -16157,9 +16756,12 @@ function diffSidecarProperties(base, edited) {
16157
16756
  removed: [],
16158
16757
  renamed: [],
16159
16758
  retyped: [],
16160
- redefined: []
16759
+ redefined: [],
16760
+ renamedOptions: []
16161
16761
  };
16162
16762
  const droppedOptions = [];
16763
+ const optionRenames = [];
16764
+ const staged = {};
16163
16765
  const names = /* @__PURE__ */ new Set();
16164
16766
  const seenIds = /* @__PURE__ */ new Set();
16165
16767
  for (const prop of edited) {
@@ -16194,10 +16796,35 @@ function diffSidecarProperties(base, edited) {
16194
16796
  `\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`
16195
16797
  );
16196
16798
  }
16197
- change[prop.type] = definitionOf(prop, old);
16198
16799
  edit.redefined.push(prop.name);
16199
- const dropped = (old.options ?? []).filter((name) => !(prop.options ?? []).includes(name));
16800
+ const isSelect = prop.type === "select" || prop.type === "multi_select";
16801
+ const before = isSelect ? optionNamesOf(old) : [];
16802
+ const after = isSelect ? sentOptionNamesOf(prop, old) : [];
16803
+ const renames = renamedOptions(before, after);
16804
+ change[prop.type] = definitionOf(prop, old, renames);
16805
+ const dropped = before.filter(
16806
+ (name) => !after.includes(name) && !renames.some(([from]) => from === name)
16807
+ );
16200
16808
  if (dropped.length > 0) droppedOptions.push([prop.name, dropped]);
16809
+ if (renames.length > 0) {
16810
+ const type = prop.type;
16811
+ const final = change[type]?.options ?? [];
16812
+ const known = old.definition?.["options"] ?? [];
16813
+ staged[old.id] = {
16814
+ ...change,
16815
+ [type]: {
16816
+ ...change[type],
16817
+ options: [
16818
+ ...final,
16819
+ ...renames.map(([from]) => known.find((o) => o.name === from) ?? { name: from })
16820
+ ]
16821
+ }
16822
+ };
16823
+ for (const [from, to] of renames) {
16824
+ optionRenames.push({ propertyId: old.id, type, from, to });
16825
+ edit.renamedOptions.push([prop.name, from, to]);
16826
+ }
16827
+ }
16201
16828
  }
16202
16829
  if (Object.keys(change).length > 0) payload[old.id] = change;
16203
16830
  }
@@ -16211,19 +16838,50 @@ function diffSidecarProperties(base, edited) {
16211
16838
  if (kept >= 2 && edit.removed.length === kept && edit.added.length === 0) {
16212
16839
  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");
16213
16840
  }
16841
+ const { renamedOptions: renamedOptionList, ...rest } = edit;
16214
16842
  return {
16215
16843
  ok: true,
16216
16844
  payload,
16217
- edit: droppedOptions.length > 0 ? { ...edit, droppedOptions } : edit
16845
+ // 먼저 보낼 것 — 옵션 이름을 바꾼 속성만 새 이름 · 옛 이름을 함께, 나머지는 마지막 것과 같다.
16846
+ ...optionRenames.length > 0 ? { staged: { ...payload, ...staged }, optionRenames } : {},
16847
+ edit: {
16848
+ ...rest,
16849
+ ...droppedOptions.length > 0 ? { droppedOptions } : {},
16850
+ ...renamedOptionList.length > 0 ? { renamedOptions: renamedOptionList } : {}
16851
+ }
16218
16852
  };
16219
16853
  }
16220
- function definitionOf(prop, old) {
16854
+ function optionNamesOf(prop) {
16855
+ if (prop.options) return [...prop.options];
16856
+ const options = prop.definition?.["options"];
16857
+ return Array.isArray(options) ? options.map((o) => o.name).filter((n) => typeof n === "string") : [];
16858
+ }
16859
+ function sentOptionNamesOf(prop, old) {
16860
+ if (sameJson(prop.definition, old.definition)) return [...prop.options ?? []];
16861
+ return optionNamesOf({ ...prop, options: void 0 });
16862
+ }
16863
+ function renamedOptions(before, after) {
16864
+ const renames = [];
16865
+ for (let i = 0; i < Math.min(before.length, after.length); i++) {
16866
+ const from = before[i];
16867
+ const to = after[i];
16868
+ if (from !== to && !after.includes(from) && !before.includes(to)) renames.push([from, to]);
16869
+ }
16870
+ return renames;
16871
+ }
16872
+ function definitionOf(prop, old, renames = []) {
16221
16873
  if (!DEFINABLE_TYPES.has(prop.type)) return {};
16222
16874
  const definition = prop.definition ?? {};
16223
16875
  if (old && (prop.type === "select" || prop.type === "multi_select") && sameJson(prop.definition, old.definition) && !sameJson(prop.options, old.options)) {
16224
16876
  const known = old.definition?.["options"] ?? [];
16877
+ const renamedFrom = new Map(renames.map(([from, to]) => [to, from]));
16225
16878
  return {
16226
- options: (prop.options ?? []).map((name) => known.find((o) => o.name === name) ?? { name })
16879
+ options: (prop.options ?? []).map((name) => {
16880
+ const from = renamedFrom.get(name);
16881
+ if (from === void 0) return known.find((o) => o.name === name) ?? { name };
16882
+ const color = known.find((o) => o.name === from)?.color;
16883
+ return color === void 0 ? { name } : { name, color };
16884
+ })
16227
16885
  };
16228
16886
  }
16229
16887
  return definition;
@@ -17532,12 +18190,60 @@ function rewriteCreatedDatabaseFolders(stateDb, folders) {
17532
18190
  async function retargetBaseFiles(vaultFs, moves, stateDb) {
17533
18191
  const folders = moves.filter((move) => !isMarkdownPath(move.from));
17534
18192
  if (folders.length === 0) return;
18193
+ const found2 = [];
17535
18194
  await rewriteBaseFilters(
17536
18195
  vaultFs,
17537
18196
  stateDb,
17538
18197
  (folder) => finalPath(folder, folders),
17539
- "\uC62E\uAE34 \uD3F4\uB354\uB97C \uAC00\uB9AC\uD0A4\uB358 .base \uBCF4\uAE30\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568"
18198
+ "\uC62E\uAE34 \uD3F4\uB354\uB97C \uAC00\uB9AC\uD0A4\uB358 .base \uBCF4\uAE30\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568",
18199
+ (fix) => found2.push(fix)
17540
18200
  );
18201
+ if (!stateDb) return;
18202
+ const pending = pendingUserBaseFilterFixes(stateDb).map((fix) => ({
18203
+ path: finalPath(fix.path, folders),
18204
+ from: fix.from,
18205
+ to: finalPath(fix.to, folders)
18206
+ }));
18207
+ for (const fix of found2) {
18208
+ if (!pending.some((p) => p.path === fix.path && p.from === fix.from)) pending.push(fix);
18209
+ }
18210
+ const asks = pending.filter((fix) => fix.from !== fix.to);
18211
+ stateDb.setMeta(USER_BASE_FILTER_FIXES_META_KEY, asks.length > 0 ? JSON.stringify(asks) : "");
18212
+ }
18213
+ var USER_BASE_FILTER_FIXES_META_KEY = "user_base_filter_fixes";
18214
+ function pendingUserBaseFilterFixes(stateDb) {
18215
+ try {
18216
+ const parsed = JSON.parse(stateDb.getMeta(USER_BASE_FILTER_FIXES_META_KEY) || "[]");
18217
+ if (!Array.isArray(parsed)) return [];
18218
+ return parsed.filter(
18219
+ (fix) => typeof fix?.path === "string" && typeof fix.from === "string" && typeof fix.to === "string"
18220
+ );
18221
+ } catch {
18222
+ return [];
18223
+ }
18224
+ }
18225
+ async function resolveUserBaseFilterFixes(vaultFs, stateDb, accept) {
18226
+ const fixes = pendingUserBaseFilterFixes(stateDb);
18227
+ stateDb.setMeta(USER_BASE_FILTER_FIXES_META_KEY, "");
18228
+ if (!accept) return [];
18229
+ const fixed = [];
18230
+ for (const path of [...new Set(fixes.map((fix) => fix.path))]) {
18231
+ const content = await vaultFs.readFile(path).catch(() => null);
18232
+ if (content === null) continue;
18233
+ const next = content.replace(
18234
+ FOLDER_FILTER_RE,
18235
+ (whole, legacy, current) => {
18236
+ const folder = legacy ?? current;
18237
+ const fix = fixes.find((f) => f.path === path && f.from === folder);
18238
+ if (!fix) return whole;
18239
+ return legacy !== void 0 ? `file.inFolder("${fix.to}")` : folderFilter(fix.to);
18240
+ }
18241
+ );
18242
+ if (next === content) continue;
18243
+ await vaultFs.writeFile(path, next);
18244
+ fixed.push(path);
18245
+ }
18246
+ return fixed;
17541
18247
  }
17542
18248
  var BASE_FOLDER_FILTER_UPGRADED_META_KEY = "base_folder_filter_v2";
17543
18249
  async function upgradeBaseFolderFilters(vaultFs, stateDb) {
@@ -17550,7 +18256,7 @@ async function upgradeBaseFolderFilters(vaultFs, stateDb) {
17550
18256
  );
17551
18257
  if (done) stateDb.setMeta(BASE_FOLDER_FILTER_UPGRADED_META_KEY, "1");
17552
18258
  }
17553
- async function rewriteBaseFilters(vaultFs, stateDb, retarget, failure) {
18259
+ async function rewriteBaseFilters(vaultFs, stateDb, retarget, failure, onUserBase) {
17554
18260
  const rehashed = /* @__PURE__ */ new Map();
17555
18261
  const viewed = stateDb ? recordedViewBases(stateDb) : /* @__PURE__ */ new Set();
17556
18262
  let done = true;
@@ -17558,7 +18264,16 @@ async function rewriteBaseFilters(vaultFs, stateDb, retarget, failure) {
17558
18264
  for (const file of await vaultFs.listNonMarkdownFiles()) {
17559
18265
  if (!file.path.endsWith(BASE_SUFFIX)) continue;
17560
18266
  const content = await vaultFs.readFile(file.path);
17561
- if (!isGeneratedDbArtifact(file.path, content) && !viewed.has(file.path)) continue;
18267
+ if (!isGeneratedDbArtifact(file.path, content) && !viewed.has(file.path)) {
18268
+ if (onUserBase) {
18269
+ for (const match of content.matchAll(FOLDER_FILTER_RE)) {
18270
+ const from = match[1] ?? match[2];
18271
+ const to = retarget(from);
18272
+ if (to !== from) onUserBase({ path: file.path, from, to });
18273
+ }
18274
+ }
18275
+ continue;
18276
+ }
17562
18277
  const next = content.replace(
17563
18278
  FOLDER_FILTER_RE,
17564
18279
  (whole, legacy, current) => {
@@ -17947,7 +18662,7 @@ function compareRemote(record, page, botUserId) {
17947
18662
  return "changed";
17948
18663
  }
17949
18664
  if (isSettled(record.notionLastEdited, record.notionSeenAt)) return "unchanged";
17950
- if (botUserId !== null && editor === botUserId && record.notionLastEditedBy === botUserId) {
18665
+ if (botUserId !== null && editor === botUserId && record.notionLastEditedBy === botUserId && (record.notionBodyFingerprint === null || record.baseSnapshot === null)) {
17951
18666
  return "unchanged";
17952
18667
  }
17953
18668
  return "unverified";
@@ -18150,6 +18865,7 @@ var DatabaseSyncer = class {
18150
18865
  this.observation = observation;
18151
18866
  this.noteRowBody = noteRowBody;
18152
18867
  this.baseFileInfo = new DbBaseFiles(stateDb);
18868
+ this.journal = new UpdateJournal(stateDb);
18153
18869
  this.sidecarBases = new SidecarBases(stateDb);
18154
18870
  this.schemaLedger = new SchemaLedger(stateDb);
18155
18871
  this.relocator = new NoteRelocator(stateDb, vaultFs, config.notion.databases ?? []);
@@ -18191,6 +18907,19 @@ var DatabaseSyncer = class {
18191
18907
  };
18192
18908
  /** 볼트에서 사라져 되살린 행의 경로 — pull 마감이 가져가 화면에 알린다(D8). */
18193
18909
  restoredRows = [];
18910
+ /** 쓰다 끊긴 행을 알아본다 — {@link UpdateJournal}. */
18911
+ journal;
18912
+ /** merge 전략이 받은 행에 로컬 편집을 합친 행 — {@link takeMergedRows}. */
18913
+ mergedRows = [];
18914
+ /** 상태 기록에 없던 같은 자리의 행 노트를 다시 이은 것(P7) — {@link takeRelinkedRows}. */
18915
+ relinkedRows = [];
18916
+ /**
18917
+ * 이번 pull 을 시작할 때 Notion 과 이은 노트가 하나도 없었다 — 그때는 자연 이름에 있는 행 노트의 글이 달라도 다시
18918
+ * 잇는다({@link relinkExistingRow}). 오케스트레이터가 pull 을 시작할 때 정한다({@link beginPull}).
18919
+ */
18920
+ unlinkedVault = false;
18921
+ /** 이번 pull 에서 다시 이으려고 고른 행 노트 경로 — 같은 이름의 두 행이 한 노트를 고르지 않게. */
18922
+ relinkClaims = /* @__PURE__ */ new Set();
18194
18923
  /** Notion 에서 바뀐 속성을 행 노트에 옮긴 것 — pull 마감이 가져가 화면에 알린다(ADR-045). */
18195
18924
  schemaFollowed = [];
18196
18925
  /** pull 이 마지막으로 쓴 사이드카 — 볼트에서 고친 것은 덮어쓰지 않는다(ADR-045). */
@@ -18199,6 +18928,10 @@ var DatabaseSyncer = class {
18199
18928
  heldSidecars = /* @__PURE__ */ new Set();
18200
18929
  /** 이번 pull 이 볼트에서 고친 것이라 덮어쓰지 않은 `.base` 경로(ADR-046). */
18201
18930
  heldBases = /* @__PURE__ */ new Set();
18931
+ /** 이번 pull 이 치운 링크드 뷰의 `.base`({@link takeRetiredBases}). */
18932
+ retiredBases = /* @__PURE__ */ new Set();
18933
+ /** 이번 실행에서 받은 DB(줄인 id)의 기간 날짜 짝 키 — 그 DB 를 원본으로 둔 링크드 뷰의 `.base` 도 같게 쓴다. */
18934
+ rangeEnds = /* @__PURE__ */ new Map();
18202
18935
  /**
18203
18936
  * 실제로 기록한 .base 경로/DB 제목 — placeholder 임베드 재작성(F22)의 SSOT. 이번 pull 이 조회하지
18204
18937
  * 않은 DB 의 것도 남아 있다({@link DbBaseFiles}).
@@ -18268,10 +19001,6 @@ var DatabaseSyncer = class {
18268
19001
  }
18269
19002
  }
18270
19003
  if (target.kind === "linked") {
18271
- if (target.wholeDbInScope) {
18272
- const viewsConfig = await this.pullDatabaseViews(dbConfig);
18273
- await this.generateBaseFile(dbConfig, viewsConfig, target.meta, target.ownerFolder);
18274
- }
18275
19004
  return { ...emptyDatabaseSyncResult(), linkedOriginalDbId: target.ownerDbId };
18276
19005
  }
18277
19006
  const { pages, wholeDbInScope, meta } = target;
@@ -18282,7 +19011,9 @@ var DatabaseSyncer = class {
18282
19011
  const refreshedKeys = (page) => followed.changes.get(sourceKeyOf(page, dbConfig.databaseId))?.refreshed;
18283
19012
  if (wholeDbInScope) {
18284
19013
  const viewsConfig = await this.pullDatabaseViews(dbConfig);
18285
- await this.generateBaseFile(dbConfig, viewsConfig, meta, void 0, this.rangeEndsOf(pages));
19014
+ const rangeEnds = this.rangeEndsOf(pages);
19015
+ this.rangeEnds.set(compactNotionId(dbConfig.databaseId), rangeEnds);
19016
+ await this.generateBaseFile(dbConfig, viewsConfig, meta, void 0, rangeEnds);
18286
19017
  }
18287
19018
  let created = 0;
18288
19019
  let updated = followed.rekeyed.size;
@@ -18330,7 +19061,11 @@ var DatabaseSyncer = class {
18330
19061
  this.config.advanced.itemTimeoutMs,
18331
19062
  `pull ${dbConfig.localFolder}/${page.id}`
18332
19063
  );
18333
- if (!record) {
19064
+ if (!record && outcome.relinked) {
19065
+ this.relinkedRows.push(outcome.path);
19066
+ if (outcome.action === "written") writtenPaths.push(outcome.path);
19067
+ else if (outcome.action === "conflict") conflicts.push(outcome.conflict);
19068
+ } else if (!record) {
18334
19069
  created++;
18335
19070
  if (outcome.action === "written") writtenPaths.push(outcome.path);
18336
19071
  } else if (outcome.action === "written") {
@@ -18339,12 +19074,19 @@ var DatabaseSyncer = class {
18339
19074
  this.restoredRows.push(outcome.path);
18340
19075
  } else if (!followed.rekeyed.has(record.id)) updated++;
18341
19076
  writtenPaths.push(outcome.path);
19077
+ } else if (outcome.action === "merged") {
19078
+ updated++;
19079
+ this.mergedRows.push(outcome.path);
18342
19080
  } else if (outcome.action === "conflict") {
18343
19081
  conflicts.push(outcome.conflict);
18344
19082
  }
18345
19083
  opts?.progress?.done({
18346
19084
  path: outcome.path ?? record?.obsidianPath ?? `${dbConfig.localFolder}/${page.id}`,
18347
- operation: record ? "update" : "create"
19085
+ operation: record ? "update" : "create",
19086
+ // 받아 보니 그대로 · local-first 가 지킨 행은 받은 것이 아니다 — 페이지와 같다. 같은 분 봇 쓰기 뒤에는 행을
19087
+ // 다시 읽어 견주므로(ADR-017 보강) 바뀌지 않은 행이 «updated» 로 찍혔다(0.15 회귀). 다시 이은 행은 쓴 것이
19088
+ // 없으면 만든 것이 아니다(P7).
19089
+ ...outcome.action === "unchanged" || outcome.action === "skipped" || !record && outcome.relinked && outcome.action !== "written" ? { unchanged: true } : {}
18348
19090
  });
18349
19091
  } catch (error) {
18350
19092
  failed.push(rowFailure(page, error));
@@ -18404,6 +19146,72 @@ var DatabaseSyncer = class {
18404
19146
  ...planned.map((row) => ({ ...row, path: moved(row.path) }))
18405
19147
  ];
18406
19148
  }
19149
+ /**
19150
+ * 받은 행이 지난 사본 그대로인가 — 렌더한 글이 달라도 받을 것이 없으면 그 사이 로컬 편집을 충돌로 올리지 않는다
19151
+ * (N-05). 본문은 지문으로, 본문 밖은 지난 사본의 속성과 견준다. 이 도구가 이 행을 쓰다 끊긴 원격도 받을 것이
19152
+ * 없다 — push 가 마저 올린다({@link UpdateJournal}).
19153
+ */
19154
+ rowRemoteUnchanged(record, page, bodyFingerprint, localContent) {
19155
+ const base = snapshotFrontmatter(record.baseSnapshot);
19156
+ return base !== null && bodyFingerprint === record.notionBodyFingerprint && this.rowOutsideBodyUnchanged(this.propertyMapper, page, base, record.obsidianPath) || this.journal.wroteRemote(
19157
+ record,
19158
+ page,
19159
+ computeHash(localContent),
19160
+ this.observation().botUserId
19161
+ );
19162
+ }
19163
+ /**
19164
+ * dry-run 이 알릴, 로컬도 고친 행을 실제 pull 이 어떻게 처리할지 — 받은 행과 견주는 판정은 실제 pull 과 같다.
19165
+ * 첨부를 내려받지 않고 렌더하며 상태 DB 를 바꾸지 않는다. 로컬을 고치지 않았거나 읽지 못하면 null(받는다).
19166
+ */
19167
+ async previewRowResolution(record, page) {
19168
+ let localContent;
19169
+ try {
19170
+ localContent = await this.vaultFs.readFile(record.obsidianPath);
19171
+ } catch {
19172
+ return null;
19173
+ }
19174
+ if (computeHash(localContent) === record.contentHash) return null;
19175
+ const rendered = await this.renderRow(page, record.obsidianPath, { downloadMedia: false });
19176
+ const resolution = resolvePullConflict({
19177
+ record,
19178
+ localContent,
19179
+ remoteContent: rendered.content,
19180
+ remoteUnchanged: this.rowRemoteUnchanged(
19181
+ record,
19182
+ page,
19183
+ rendered.bodyFingerprint,
19184
+ localContent
19185
+ ),
19186
+ remoteChange: {
19187
+ pageId: page.id,
19188
+ type: "modified",
19189
+ lastEdited: page.last_edited_time,
19190
+ previousEdited: record.notionLastEdited
19191
+ },
19192
+ strategy: this.config.sync.conflictStrategy
19193
+ });
19194
+ if (resolution.action === "conflict")
19195
+ return { action: "conflict", conflict: resolution.conflict };
19196
+ return resolution.action === "write" ? null : { action: resolution.action };
19197
+ }
19198
+ /**
19199
+ * 수정 시각 · 편집자로 가르지 못한 행이 지난 사본 그대로인가 — 본문 지문과 본문 밖 속성으로({@link rowRemoteUnchanged}).
19200
+ * dry-run 이 실제 pull 처럼 받을 것이 없는 행을 세지 않게. 견줄 사본 · 지문이 없거나 본문을 읽지 못하면 아니다.
19201
+ */
19202
+ async unverifiedRowUnchanged(record, page) {
19203
+ if (this.remoteVerdict(record, page) !== "unverified") return false;
19204
+ const base = snapshotFrontmatter(record.baseSnapshot);
19205
+ if (base === null || record.notionBodyFingerprint === null) return false;
19206
+ try {
19207
+ const fingerprint = remoteBodyFingerprint(
19208
+ (await this.notionClient.getPageMarkdown(page.id)).markdown
19209
+ );
19210
+ return fingerprint === record.notionBodyFingerprint && this.rowOutsideBodyUnchanged(this.propertyMapper, page, base, record.obsidianPath);
19211
+ } catch {
19212
+ return false;
19213
+ }
19214
+ }
18407
19215
  async planRows(dbConfig, target, paths, movingInVault = /* @__PURE__ */ new Set()) {
18408
19216
  const opts = { paths };
18409
19217
  const planned = [];
@@ -18449,11 +19257,18 @@ var DatabaseSyncer = class {
18449
19257
  moves: [{ from: record.obsidianPath, to: path }, ...children ? [children] : []]
18450
19258
  });
18451
19259
  } else {
19260
+ if (action === "pull" && await this.unverifiedRowUnchanged(record, page)) continue;
19261
+ const retitle = await this.retitledRowTarget(dbConfig.localFolder, record, page);
19262
+ const renamed = retitle && "to" in retitle ? retitle : null;
19263
+ const resolution = action === "restore" ? null : await this.previewRowResolution(record, page);
18452
19264
  planned.push({
18453
19265
  pageId: page.id,
18454
- path: record.obsidianPath,
19266
+ path: renamed?.to ?? record.obsidianPath,
18455
19267
  operation: "update",
18456
- restore: action === "restore"
19268
+ restore: action === "restore",
19269
+ ...resolution ? { resolution } : {},
19270
+ // 실제 pull 이 알리는 것과 같게 행만 — 하위 페이지 폴더는 함께 옮기지만 따로 알리지 않는다.
19271
+ ...renamed ? { moves: [{ from: record.obsidianPath, to: renamed.to, renamed: true }] } : {}
18457
19272
  });
18458
19273
  }
18459
19274
  }
@@ -18545,6 +19360,9 @@ var DatabaseSyncer = class {
18545
19360
  const changed = misplaced || this.remoteVerdict(record, page) !== "unchanged";
18546
19361
  if (!changed && await this.staleKeysToRewrite(record, page, refreshed)) return "reform";
18547
19362
  if (this.config.sync.deleteSync || await this.vaultFs.exists(record.obsidianPath)) {
19363
+ if (changed && !misplaced && this.config.sync.deleteSync && !await this.vaultFs.exists(record.obsidianPath) && await this.unverifiedRowUnchanged(record, page)) {
19364
+ return "skip";
19365
+ }
18548
19366
  return changed ? "pull" : "skip";
18549
19367
  }
18550
19368
  return "restore";
@@ -18793,10 +19611,10 @@ var DatabaseSyncer = class {
18793
19611
  }
18794
19612
  return ends;
18795
19613
  }
18796
- async generateBaseFile(dbConfig, viewsConfig, meta, rowsFolder, rangeEnds) {
19614
+ async generateBaseFile(dbConfig, viewsConfig, meta, rowsFolder, rangeEnds, linked) {
18797
19615
  try {
18798
19616
  const schemaFull = await this.notionClient.getDatabaseSchemaFull(dbConfig.databaseId, meta);
18799
- const dbName = viewsConfig?.databaseName || await this.notionClient.getDatabaseTitle(dbConfig.databaseId, meta) || dbConfig.localFolder.split("/").pop() || "Database";
19617
+ const dbName = linked?.name || viewsConfig?.databaseName || await this.notionClient.getDatabaseTitle(dbConfig.databaseId, meta) || dbConfig.localFolder.split("/").pop() || "Database";
18800
19618
  const resolvedViews = viewsConfig ?? {
18801
19619
  databaseId: dbConfig.databaseId,
18802
19620
  databaseName: dbName,
@@ -18821,11 +19639,12 @@ var DatabaseSyncer = class {
18821
19639
  title: dbName,
18822
19640
  hash: computeHash(baseContent),
18823
19641
  // Notion 보기를 받지 못해 기본 표 하나를 쓴 파일은 올릴 보기가 없다.
18824
- ...viewsConfig ? recordBaseViews(baseContent, generated.views, viewsConfig.views) : null
19642
+ ...viewsConfig ? recordBaseViews(baseContent, generated.views, viewsConfig.views) : null,
19643
+ ...linked ? { linkedTo: compactNotionId(linked.ownerId) } : null
18825
19644
  };
18826
19645
  const ours = existing === null || isGeneratedDbArtifact(basePath, existing) || previous?.basePath === basePath && previous.views !== void 0;
18827
19646
  if (!ours) {
18828
- record = { basePath, title: dbName, hash: record.hash };
19647
+ record = { basePath, title: dbName, hash: record.hash, linkedTo: record.linkedTo };
18829
19648
  if (!this.keptUserBases.has(basePath)) {
18830
19649
  this.keptUserBases.add(basePath);
18831
19650
  getLogger().info(
@@ -18840,6 +19659,15 @@ var DatabaseSyncer = class {
18840
19659
  }
18841
19660
  this.baseFileInfo.set(dbConfig.databaseId, record);
18842
19661
  getLogger().debug(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131: ${basePath}`);
19662
+ if (linked) {
19663
+ if (previous && previous.basePath !== basePath) {
19664
+ await this.removeLinkedBase(previous);
19665
+ if (!await this.vaultFs.exists(previous.basePath) && await this.vaultFs.exists(basePath)) {
19666
+ this.renamedRows.applied.push({ from: previous.basePath, to: basePath });
19667
+ }
19668
+ }
19669
+ return;
19670
+ }
18843
19671
  await this.generateSidecar(
18844
19672
  dbConfig,
18845
19673
  dbName,
@@ -18849,7 +19677,15 @@ var DatabaseSyncer = class {
18849
19677
  meta.isInline,
18850
19678
  await this.appearanceOf(meta, safeName)
18851
19679
  );
18852
- await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName, previous);
19680
+ await this.cleanupStaleDbArtifacts(
19681
+ dbConfig.databaseId,
19682
+ dbConfig.localFolder,
19683
+ safeName,
19684
+ previous
19685
+ );
19686
+ if (previous && previous.basePath !== basePath && !await this.vaultFs.exists(previous.basePath) && await this.vaultFs.exists(basePath)) {
19687
+ this.renamedRows.applied.push({ from: previous.basePath, to: basePath });
19688
+ }
18853
19689
  } catch (error) {
18854
19690
  getLogger().warn(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
18855
19691
  }
@@ -18881,13 +19717,87 @@ var DatabaseSyncer = class {
18881
19717
  ...a.showTitle ? {} : { showTitle: false }
18882
19718
  };
18883
19719
  }
19720
+ /**
19721
+ * 링크드 뷰 컨테이너의 `.base` 를 그 컨테이너의 보기(이름 · 필터 · 정렬)로 원본 DB 폴더에 쓴다. 행은 원본 DB 가
19722
+ * 갖는다(F25) — 폴더 필터도 원본 폴더를 가리킨다. 예전에는 링크드 뷰를 원본의 `.base` 로 몰아, 링크드 뷰가 고른
19723
+ * 보기가 볼트에 오지 않았다.
19724
+ *
19725
+ * 이름은 Notion 이 그 블록 위에 보이는 것처럼 「원본 제목 - 첫 보기 이름」 이다. 컨테이너 제목은 비어 온다.
19726
+ *
19727
+ * @returns `gone` — Notion 에서 컨테이너를 지웠다. 쓴 `.base` 를 치웠으니 부른 쪽이 연결을 잊는다.
19728
+ * `failed` — 읽지 못했다. 쓴 `.base` 는 그대로 두고 다음 pull 이 다시 본다.
19729
+ */
19730
+ async pullLinkedView(containerId, owner) {
19731
+ let meta;
19732
+ try {
19733
+ meta = await this.notionClient.getDatabaseMeta(containerId);
19734
+ } catch (error) {
19735
+ if (isNotionObjectNotFound(error)) {
19736
+ await this.retireLinkedView(containerId);
19737
+ return "gone";
19738
+ }
19739
+ getLogger().warn(
19740
+ `[DB Sync] \uB9C1\uD06C\uB4DC \uBDF0 ${containerId} \uB97C \uC77D\uC9C0 \uBABB\uD568 (\uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uBD04):`,
19741
+ error
19742
+ );
19743
+ return "failed";
19744
+ }
19745
+ if (meta.inTrash) {
19746
+ await this.retireLinkedView(containerId);
19747
+ return "gone";
19748
+ }
19749
+ const container = { ...owner, databaseId: containerId };
19750
+ const views = await this.pullDatabaseViews(container);
19751
+ if (!views || views.views.length === 0) return "failed";
19752
+ await this.generateBaseFile(
19753
+ container,
19754
+ views,
19755
+ meta,
19756
+ owner.localFolder,
19757
+ this.rangeEnds.get(compactNotionId(owner.databaseId)),
19758
+ { ownerId: owner.databaseId, name: this.linkedBaseName(containerId, owner, views) }
19759
+ );
19760
+ return "written";
19761
+ }
19762
+ /** 링크드 뷰 `.base` 의 이름 — 같은 폴더의 다른 `.base` 가 쓰는 이름이면 컨테이너 id 앞 네 자리를 붙인다. */
19763
+ linkedBaseName(containerId, owner, views) {
19764
+ const ownerTitle = this.baseFileInfo.get(owner.databaseId)?.title || owner.localFolder.split("/").pop() || "Database";
19765
+ const name = `${ownerTitle} - ${views.views[0]?.name || "View"}`;
19766
+ const self = compactNotionId(containerId);
19767
+ const taken = new Set(
19768
+ this.baseFileInfo.all().filter(([id]) => id !== self).map(([, file]) => file.basePath)
19769
+ );
19770
+ return taken.has(`${owner.localFolder}/${sanitizeFileName(name)}${BASE_SUFFIX}`) ? `${name} (${self.slice(0, 4)})` : name;
19771
+ }
19772
+ /** Notion 에서 지운 링크드 뷰 — 쓴 `.base` 를 치우고 기록을 잊는다. */
19773
+ async retireLinkedView(containerId) {
19774
+ const record = this.baseFileInfo.get(containerId);
19775
+ if (!record) return;
19776
+ if (await this.removeLinkedBase(record)) this.retiredBases.add(record.basePath);
19777
+ this.baseFileInfo.delete(containerId);
19778
+ getLogger().info(`[DB Sync] Notion \uC5D0\uC11C \uC9C0\uC6B4 \uB9C1\uD06C\uB4DC \uBDF0\uC758 .base \uB97C \uCE58\uC6C0: ${record.basePath}`);
19779
+ }
19780
+ /**
19781
+ * pull 이 쓴 그대로인 링크드 뷰 `.base` 를 지운다 — 볼트에서 고쳤으면 두고 알린다.
19782
+ * @returns 지웠나.
19783
+ */
19784
+ async removeLinkedBase(record) {
19785
+ const content = await this.vaultFs.readFile(record.basePath).catch(() => null);
19786
+ if (content === null || !isGeneratedDbArtifact(record.basePath, content)) return false;
19787
+ if (editedSince(record, record.basePath, content)) {
19788
+ this.warnEditedBase(record.basePath);
19789
+ return false;
19790
+ }
19791
+ await this.vaultFs.deleteFile(record.basePath);
19792
+ return true;
19793
+ }
18884
19794
  /** 볼트에서 고친 `.base` 를 덮어쓰거나 지우지 않았다고 한 번 알린다. */
18885
19795
  warnEditedBase(path) {
18886
19796
  this.heldBases.add(path);
18887
19797
  if (this.keptUserBases.has(path)) return;
18888
19798
  this.keptUserBases.add(path);
18889
19799
  getLogger().warn(
18890
- `[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`
19800
+ `[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\uC640 \uB354\uD558\uACE0 \uC9C0\uC6B4 \uBCF4\uAE30\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`
18891
19801
  );
18892
19802
  }
18893
19803
  /**
@@ -18895,11 +19805,13 @@ var DatabaseSyncer = class {
18895
19805
  * 같은 폴더에 고아로 잔존한다(drift·부활 원인). DB 폴더 **직속**의 산출물 중 현재 이름이
18896
19806
  * 아닌 것을 삭제해 멱등을 보장한다. 하위 폴더(중첩 DB)의 산출물은 보호한다.
18897
19807
  */
18898
- async cleanupStaleDbArtifacts(localFolder, safeName, previous) {
19808
+ async cleanupStaleDbArtifacts(databaseId, localFolder, safeName, previous) {
18899
19809
  try {
18900
19810
  const keep = /* @__PURE__ */ new Set([
18901
19811
  `${localFolder}/${safeName}${BASE_SUFFIX}`,
18902
- `${localFolder}/${safeName}${SIDECAR_SUFFIX}`
19812
+ `${localFolder}/${safeName}${SIDECAR_SUFFIX}`,
19813
+ // 이 DB 를 원본으로 둔 링크드 뷰의 `.base` — 같은 폴더에 놓인다.
19814
+ ...this.baseFileInfo.linkedBasesOf(databaseId)
18903
19815
  ]);
18904
19816
  const files = await this.vaultFs.listNonMarkdownFiles();
18905
19817
  const stale = selectStaleDbArtifacts(files, localFolder, keep);
@@ -19033,7 +19945,12 @@ var DatabaseSyncer = class {
19033
19945
  const bodyFingerprint = remoteBodyFingerprint(mdResult.markdown);
19034
19946
  const exportCompact = isCompactExport(mdResult.markdown);
19035
19947
  let markdown = await notionBodyToObsidian(this.notionClient, mdResult.markdown, page.id);
19036
- markdown = await this.imageHandler.restoreUploadedMedia(markdown, page.id, filePath);
19948
+ markdown = await this.imageHandler.restoreUploadedMedia(
19949
+ markdown,
19950
+ page.id,
19951
+ filePath,
19952
+ options?.adoptFrom
19953
+ );
19037
19954
  if (downloadMedia && this.config.conversion.imageDownload === "immediate" && markdown) {
19038
19955
  try {
19039
19956
  const imageResult = await this.imageHandler.downloadAllImages(markdown, title, page.id);
@@ -19115,6 +20032,105 @@ var DatabaseSyncer = class {
19115
20032
  this.restoredRows = [];
19116
20033
  return rows;
19117
20034
  }
20035
+ /** pull 을 시작한다 — 이은 노트가 하나도 없는 볼트인지 적고 다시 잇기 장부를 비운다. */
20036
+ beginPull(unlinkedVault) {
20037
+ this.unlinkedVault = unlinkedVault;
20038
+ this.relinkClaims.clear();
20039
+ }
20040
+ /** 이번 받기에서 다시 이은 행의 경로를 가져가고 비운다. */
20041
+ takeRelinkedRows() {
20042
+ const rows = this.relinkedRows;
20043
+ this.relinkedRows = [];
20044
+ return rows;
20045
+ }
20046
+ /**
20047
+ * 상태 기록에 없는 행 노트가 이 행이 놓일 자리에 이미 있다 — 이 행 id 를 단 이름이 먼저, 그 다음 자연 이름. 페이지의
20048
+ * `claimExistingNote` 와 같은 규칙이다.
20049
+ */
20050
+ async claimExistingRow(localFolder, safeName, pageId) {
20051
+ const [natural, ...suffixed] = pagePathCandidates(localFolder, safeName, pageId);
20052
+ const order = [
20053
+ ...suffixed.map((path) => [path, "own"]),
20054
+ [natural, "name"]
20055
+ ];
20056
+ for (const [candidate, existing] of order) {
20057
+ if (this.relinkClaims.has(candidate) || this.stateDb.getByPath(candidate)) continue;
20058
+ if (!await this.vaultFs.exists(candidate)) continue;
20059
+ this.relinkClaims.add(candidate);
20060
+ return { path: candidate, existing };
20061
+ }
20062
+ return null;
20063
+ }
20064
+ /**
20065
+ * 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 페이지의 `relinkExistingNote` 와 같은 규칙. 글이 같으면 잇고, 다르면
20066
+ * 이 행 id 를 단 이름은 충돌 전략을 따르고 · 자연 이름은 이은 노트가 없는 볼트에서만 충돌로 잇는다. 잇지 않으면 null.
20067
+ * remote-first 는 `"write"` — 호출측이 받은 행으로 덮는다.
20068
+ */
20069
+ async relinkExistingRow(filePath, existing, local, finalContent, page, dbConfig, rendered, bodyFingerprint) {
20070
+ const same = sameNoteContent(local, finalContent) || sameAsPendingLinks(local, finalContent);
20071
+ if (!same && existing === "name" && !this.unlinkedVault) return null;
20072
+ const strategy = existing === "own" ? this.config.sync.conflictStrategy : "manual";
20073
+ if (!same && strategy === "remote-first") return "write";
20074
+ const conflicted = !same && strategy !== "local-first";
20075
+ const stat2 = same ? await this.vaultFs.getFileStat(filePath) : null;
20076
+ this.stateDb.transaction(() => {
20077
+ this.stateDb.upsert({
20078
+ obsidianPath: filePath,
20079
+ notionPageId: page.id,
20080
+ notionParentId: dbConfig.databaseId,
20081
+ // 같으면 로컬 글로 적어 바뀐 것이 없게 · local-first 는 받은 글로 적어 다음 push 가 로컬을 올리게.
20082
+ contentHash: computeHash(same ? local : finalContent),
20083
+ ...observedRecordFields(page, this.observation().seenAt, bodyFingerprint),
20084
+ localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
20085
+ syncDirection: "both",
20086
+ fileType: "db-row",
20087
+ status: conflicted ? "conflict" : "synced",
20088
+ // 충돌로 이으면 공통 조상을 모른다 — merge 도 합치지 않는다.
20089
+ baseSnapshot: conflicted ? null : Buffer.from(finalContent, "utf-8"),
20090
+ localMtime: stat2?.mtime ?? null,
20091
+ localFileSize: stat2?.size ?? null
20092
+ });
20093
+ this.stateDb.upsertWikilink({
20094
+ obsidianPath: filePath,
20095
+ notionPageId: page.id,
20096
+ title: rendered.title,
20097
+ aliases: extractAliases(rendered.properties)
20098
+ });
20099
+ });
20100
+ if (!conflicted) return { action: "unchanged", path: filePath, relinked: true };
20101
+ const record = this.stateDb.getByPath(filePath);
20102
+ return {
20103
+ action: "conflict",
20104
+ path: filePath,
20105
+ relinked: true,
20106
+ conflict: {
20107
+ syncRecord: record,
20108
+ localChange: {
20109
+ path: filePath,
20110
+ type: "modified",
20111
+ currentHash: computeHash(local),
20112
+ previousHash: record.contentHash
20113
+ },
20114
+ remoteChange: {
20115
+ pageId: page.id,
20116
+ type: "modified",
20117
+ lastEdited: page.last_edited_time,
20118
+ previousEdited: null,
20119
+ path: filePath,
20120
+ title: rendered.title
20121
+ },
20122
+ baseContent: null,
20123
+ localContent: local,
20124
+ remoteContent: finalContent
20125
+ }
20126
+ };
20127
+ }
20128
+ /** 이번 받기에서 로컬 편집을 합친 행의 경로를 가져가고 비운다. */
20129
+ takeMergedRows() {
20130
+ const rows = this.mergedRows;
20131
+ this.mergedRows = [];
20132
+ return rows;
20133
+ }
19118
20134
  /**
19119
20135
  * Notion 에서 다른 페이지 아래로 옮긴 DB 의 폴더를 볼트에서도 옮긴다 — 옮겼으면 그 이동. 옮기지 못하면 까닭을 남기고
19120
20136
  * 옛 자리에서 받는다.
@@ -19128,7 +20144,7 @@ var DatabaseSyncer = class {
19128
20144
  this.renamedRows.renamed.push(move);
19129
20145
  this.renamedRows.applied.push(move);
19130
20146
  getLogger().info(
19131
- `[Im-Nobsidian] Notion \uC5D0\uC11C \uC62E\uAE34 DB \uC758 \uD3F4\uB354\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE40: ${move.from} \u2192 ${move.to}`
20147
+ `[Im-Nobsidian] Notion \uC5D0\uC11C ${move.renamed ? "\uC774\uB984\uC744 \uBC14\uAFBC" : "\uC62E\uAE34"} DB \uC758 \uD3F4\uB354\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE40: ${move.from} \u2192 ${move.to}`
19132
20148
  );
19133
20149
  return move;
19134
20150
  }
@@ -19137,7 +20153,7 @@ var DatabaseSyncer = class {
19137
20153
  if (!outcome) return null;
19138
20154
  if ("reason" in outcome) {
19139
20155
  getLogger().warn(
19140
- `[Im-Nobsidian] Notion \uC5D0\uC11C \uC62E\uAE34 DB \uC758 \uD3F4\uB354\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE30\uC9C0 \uC54A\uC74C (${outcome.path}): ${outcome.reason}`
20156
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uC62E\uAE30\uAC70\uB098 \uC774\uB984\uC744 \uBC14\uAFBC DB \uC758 \uD3F4\uB354\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE30\uC9C0 \uC54A\uC74C (${outcome.path}): ${outcome.reason}`
19141
20157
  );
19142
20158
  return null;
19143
20159
  }
@@ -19149,18 +20165,17 @@ var DatabaseSyncer = class {
19149
20165
  return movingInVault.has(record.id) || this.stateDb.getIncompleteOpByState(record.id, "move") !== null;
19150
20166
  }
19151
20167
  /**
19152
- * Notion 에서 다른 페이지 아래로 옮긴 발견 DB 의 폴더를 옮길 곳(ADR-047) — 제자리면 null.
20168
+ * Notion 에서 다른 페이지 아래로 옮기거나 이름을 바꾼 발견 DB 의 폴더를 옮길 곳(ADR-047 · {@link retitledFolderName})
20169
+ * — 제자리면 null.
19153
20170
  *
19154
20171
  * Notion 에서 DB 를 옮겨도 DB · 행의 수정 시각이 그대로라 받은 DB 의 부모로만 안다 — 전체 대조는 모든 DB 를 받으니
19155
20172
  * 거기서 보인다. 새 자리는 처음 발견할 때와 같다 — 부모 페이지의 하위 폴더 이름 그대로, 루트면 `databases` 선반.
19156
20173
  * 맨 위 폴더도 루트 아래로 본다(볼트에서 옮긴 DB 를 push 가 루트 아래에 둔다 — {@link DatabaseMover}).
19157
20174
  * 다음은 옮기지 않는다.
19158
- * - 부모가 블록(토글 · 열) 안 · 볼트가 모르는 페이지 — 볼트에 그 자리가 없다
20175
+ * - 부모가 블록(토글 · 열) 안 · 볼트가 모르는 페이지 — 볼트에 그 자리가 없다(이름만 따른다)
19159
20176
  * - 볼트에서 옮긴 DB 폴더 · 부모 노트를 아직 올리지 않았다 — 볼트가 먼저다(push 가 Notion 을 옮긴다)
19160
20177
  */
19161
20178
  placementMoveOf(dbConfig, meta, movingInVault = /* @__PURE__ */ new Set()) {
19162
- const parentId = meta.parentPageId;
19163
- if (!parentId) return null;
19164
20179
  const id = dbConfig.databaseId;
19165
20180
  if ((this.config.notion.databases ?? []).some((db) => notionIdsEqual(db.databaseId, id))) {
19166
20181
  return null;
@@ -19169,23 +20184,52 @@ var DatabaseSyncer = class {
19169
20184
  return null;
19170
20185
  }
19171
20186
  const folder = dbConfig.localFolder;
19172
- let places;
19173
- if (notionIdsEqual(parentId, this.config.notion.rootPageId)) {
19174
- places = [ROOT_DATABASES_FOLDER, ""];
19175
- } else {
19176
- const parent = this.stateDb.getByNotionId(parentId);
19177
- if (!parent) return null;
19178
- if (this.movedInVault(parent, movingInVault)) {
19179
- return {
19180
- path: folder,
19181
- reason: "\uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE34 \uBD80\uBAA8 \uB178\uD2B8\uB97C \uC544\uC9C1 \uC62C\uB9AC\uC9C0 \uC54A\uC558\uB2E4 \u2014 \uB2E4\uC74C push \uAC00 Notion \uC744 \uB9DE\uCD98\uB2E4"
19182
- };
20187
+ const current = parentFolderOf(folder);
20188
+ let place = current;
20189
+ const parentId = meta.parentPageId;
20190
+ if (parentId) {
20191
+ let places = null;
20192
+ if (notionIdsEqual(parentId, this.config.notion.rootPageId)) {
20193
+ places = [ROOT_DATABASES_FOLDER, ""];
20194
+ } else {
20195
+ const parent = this.stateDb.getByNotionId(parentId);
20196
+ if (parent && this.movedInVault(parent, movingInVault)) {
20197
+ return {
20198
+ path: folder,
20199
+ reason: "\uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE34 \uBD80\uBAA8 \uB178\uD2B8\uB97C \uC544\uC9C1 \uC62C\uB9AC\uC9C0 \uC54A\uC558\uB2E4 \u2014 \uB2E4\uC74C push \uAC00 Notion \uC744 \uB9DE\uCD98\uB2E4"
20200
+ };
20201
+ }
20202
+ if (parent) {
20203
+ places = [childFolderOf(parent, { databaseAt: (path) => this.databaseAt(path) })];
20204
+ if (isFolderRecord(parent)) places.push(parent.obsidianPath);
20205
+ }
19183
20206
  }
19184
- places = [childFolderOf(parent, { databaseAt: (path) => this.databaseAt(path) })];
19185
- if (isFolderRecord(parent)) places.push(parent.obsidianPath);
20207
+ if (places && !places.includes(current)) place = places[0];
19186
20208
  }
19187
- if (places.includes(parentFolderOf(folder))) return null;
19188
- return { from: folder, to: joinPath(places[0], baseName2(folder)) };
20209
+ const renamed = this.retitledFolderName(folder, dbConfig.databaseId, meta.title);
20210
+ const to = renamed === null ? joinPath(place, baseName2(folder)) : resolveDbFolderPath(joinPath(place, renamed), id, (path) => this.databaseAt(path));
20211
+ if (to === folder) return null;
20212
+ return { from: folder, to, ...renamed !== null ? { renamed: true } : {} };
20213
+ }
20214
+ /**
20215
+ * Notion 에서 바꾼 DB 제목을 따른 폴더 이름(`sync.renameOnTitleChange`) — 바꿀 것이 없으면 null.
20216
+ *
20217
+ * 지난번에 받은 제목({@link DbBaseFiles})과 달라졌고, 폴더 이름이 그 옛 제목을 따르고 있을 때만 — 폴더를 만든 규칙
20218
+ * (`databaseFolderName`) 이나 `.base` 이름 규칙(`sanitizeFileName`, 볼트에서 바꿔 올린 이름) 그대로일 때다. 사용자가
20219
+ * 따로 정한 이름이면 두고, 새 이름도 지금 이름과 같은 규칙으로 짓는다. 같은 이름 DB 와 가르려 붙인 ` (id 조각)` 은
20220
+ * 떼고 견준다(F24) — 새 자리에서 다시 가른다.
20221
+ */
20222
+ retitledFolderName(folder, databaseId, title) {
20223
+ if (!this.config.sync.renameOnTitleChange || !title) return null;
20224
+ const previous = this.baseFileInfo.get(databaseId)?.title;
20225
+ if (!previous || previous === title) return null;
20226
+ const name = baseName2(folder).replace(/ \([0-9a-f]{8,32}\)$/, "");
20227
+ for (const style of [databaseFolderName, sanitizeFileName]) {
20228
+ if (style(previous) !== name) continue;
20229
+ const next = style(title);
20230
+ return next && next !== name ? next : null;
20231
+ }
20232
+ return null;
19189
20233
  }
19190
20234
  /** 이 폴더가 받는 DB — 설정한 DB 와 발견한 DB 에서. */
19191
20235
  databaseAt(folder) {
@@ -19196,6 +20240,12 @@ var DatabaseSyncer = class {
19196
20240
  return all.find((db) => db.localFolder === folder)?.databaseId ?? null;
19197
20241
  }
19198
20242
  /** 이름을 바꾼 행을 가져가고 비운다. */
20243
+ /** 이번 pull 이 Notion 에서 지운 링크드 뷰라 치운 `.base` — 그것을 넣은 노트의 임베드를 뺀다({@link LinkUpdater.dropEmbeds}). */
20244
+ takeRetiredBases() {
20245
+ const paths = [...this.retiredBases];
20246
+ this.retiredBases.clear();
20247
+ return paths;
20248
+ }
19199
20249
  takeRenamedRows() {
19200
20250
  const rows = this.renamedRows;
19201
20251
  this.renamedRows = { renamed: [], applied: [] };
@@ -19210,43 +20260,60 @@ var DatabaseSyncer = class {
19210
20260
  const hasChildren = this.stateDb.getAll().some((r) => r.id !== record.id && within(r.obsidianPath, from));
19211
20261
  return hasChildren ? { from, to: stripMarkdownExtension(rowPath) } : null;
19212
20262
  }
20263
+ /**
20264
+ * Notion 에서 바꾼 제목으로 행 이름을 바꿀 자리(`sync.renameOnTitleChange`) — 바꿀 것이 없으면 null.
20265
+ * 받기 · pull dry-run · status 가 같은 판정을 쓴다. 행의 하위 페이지 폴더(`행/`)도 새 이름으로 옮기며, 옮길 수
20266
+ * 없으면 이름을 바꾸지 않는다(예전처럼 제목만 받는다) — 그 까닭을 돌려준다.
20267
+ */
20268
+ async retitledRowTarget(localFolder, record, page) {
20269
+ const recordPath = record.obsidianPath;
20270
+ const title = this.notionClient.extractTitle(page);
20271
+ if (!this.config.sync.renameOnTitleChange || !isDirectDbRowPath(localFolder, recordPath) || !retitledInNotion(snapshotFrontmatter(record.baseSnapshot), null, recordPath, title) || this.stateDb.getIncompleteOpByState(record.id, "move")) {
20272
+ return null;
20273
+ }
20274
+ const safeName = sanitizeFileName(title);
20275
+ const to = resolveDbRowPath(
20276
+ localFolder,
20277
+ safeName,
20278
+ page.id,
20279
+ await this.rowPathOwners(localFolder, safeName, page.id)
20280
+ );
20281
+ if (to === recordPath) return null;
20282
+ const from = stripMarkdownExtension(recordPath);
20283
+ if (!this.stateDb.getAll().some((r) => within(r.obsidianPath, from))) {
20284
+ return { to, children: null };
20285
+ }
20286
+ const children = { from, to: stripMarkdownExtension(to) };
20287
+ const blocked = await this.relocator.blocked(children);
20288
+ return blocked ? { reason: `\uD558\uC704 \uD398\uC774\uC9C0 \uD3F4\uB354 \u2014 ${blocked}` } : { to, children };
20289
+ }
19213
20290
  async pullDatabasePage(page, dbConfig) {
19214
20291
  const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
19215
20292
  const existingRecord = this.stateDb.getByNotionId(page.id);
19216
20293
  const recordPath = existingRecord?.obsidianPath;
20294
+ const retitle = existingRecord && recordPath ? await this.retitledRowTarget(dbConfig.localFolder, existingRecord, page) : null;
19217
20295
  let filePath;
19218
- const retitled = !!existingRecord && !!recordPath && this.config.sync.renameOnTitleChange && isDirectDbRowPath(dbConfig.localFolder, recordPath) && retitledInNotion(
19219
- snapshotFrontmatter(existingRecord.baseSnapshot),
19220
- null,
19221
- recordPath,
19222
- this.notionClient.extractTitle(page)
19223
- ) && !this.stateDb.getIncompleteOpByState(existingRecord.id, "move");
19224
- if (recordPath && isDirectDbRowPath(dbConfig.localFolder, recordPath) && !retitled) {
20296
+ let childFolderMove = null;
20297
+ let adopt = null;
20298
+ if (retitle && "to" in retitle) {
20299
+ filePath = retitle.to;
20300
+ childFolderMove = retitle.children;
20301
+ } else if (recordPath && isDirectDbRowPath(dbConfig.localFolder, recordPath)) {
19225
20302
  filePath = recordPath;
19226
20303
  } else {
19227
- filePath = resolveDbRowPath(
20304
+ filePath = (!existingRecord ? (adopt = await this.claimExistingRow(dbConfig.localFolder, safeName, page.id))?.path : void 0) ?? resolveDbRowPath(
19228
20305
  dbConfig.localFolder,
19229
20306
  safeName,
19230
20307
  page.id,
19231
20308
  await this.rowPathOwners(dbConfig.localFolder, safeName, page.id)
19232
20309
  );
19233
20310
  }
19234
- let childFolderMove = null;
19235
- if (retitled) {
19236
- const from = stripMarkdownExtension(recordPath);
19237
- if (this.stateDb.getAll().some((r) => within(r.obsidianPath, from))) {
19238
- childFolderMove = { from, to: stripMarkdownExtension(filePath) };
19239
- const blocked = await this.relocator.blocked(childFolderMove);
19240
- if (blocked) {
19241
- getLogger().warn(
19242
- `[Im-Nobsidian] Notion \uC5D0\uC11C \uBC14\uAFBC \uC81C\uBAA9\uC73C\uB85C \uD589 \uC774\uB984\uC744 \uBC14\uAFB8\uC9C0 \uC54A\uC74C (${recordPath}): \uD558\uC704 \uD398\uC774\uC9C0 \uD3F4\uB354 \u2014 ${blocked}`
19243
- );
19244
- filePath = recordPath;
19245
- childFolderMove = null;
19246
- }
19247
- }
20311
+ if (retitle && "reason" in retitle) {
20312
+ getLogger().warn(
20313
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uBC14\uAFBC \uC81C\uBAA9\uC73C\uB85C \uD589 \uC774\uB984\uC744 \uBC14\uAFB8\uC9C0 \uC54A\uC74C (${recordPath}): ${retitle.reason}`
20314
+ );
19248
20315
  }
19249
- const renaming = retitled && filePath !== recordPath;
20316
+ const renaming = retitle !== null && "to" in retitle;
19250
20317
  const becomesRow = !!existingRecord && !!recordPath && wasPage(existingRecord);
19251
20318
  const arrives = becomesRow || !!existingRecord && !!recordPath && arrivesFromOtherDatabase(existingRecord, dbConfig);
19252
20319
  if (arrives && recordPath && this.stateDb.getIncompleteOpByState(existingRecord.id, "move")) {
@@ -19269,10 +20336,39 @@ var DatabaseSyncer = class {
19269
20336
  emptiedFolder = parentFolderOf(recordPath);
19270
20337
  }
19271
20338
  }
19272
- const rendered = await this.renderRow(page, filePath, { localPath: recordPath ?? filePath });
20339
+ const local = adopt ? await readLocalNote(this.vaultFs, filePath) : void 0;
20340
+ let rendered = await this.renderRow(page, filePath, {
20341
+ localPath: recordPath ?? filePath,
20342
+ ...local !== void 0 ? { adoptFrom: local } : {}
20343
+ });
20344
+ let relinked = false;
20345
+ if (adopt) {
20346
+ const outcome = local === void 0 ? null : await this.relinkExistingRow(
20347
+ filePath,
20348
+ adopt.existing,
20349
+ local,
20350
+ rendered.content,
20351
+ page,
20352
+ dbConfig,
20353
+ rendered,
20354
+ rendered.bodyFingerprint
20355
+ );
20356
+ if (outcome === "write") relinked = true;
20357
+ else if (outcome) return outcome;
20358
+ else {
20359
+ filePath = resolveDbRowPath(
20360
+ dbConfig.localFolder,
20361
+ safeName,
20362
+ page.id,
20363
+ await this.rowPathOwners(dbConfig.localFolder, safeName, page.id)
20364
+ );
20365
+ rendered = await this.renderRow(page, filePath, { localPath: filePath });
20366
+ }
20367
+ }
19273
20368
  const { title, properties, bodyFingerprint } = rendered;
19274
20369
  const finalContent = becomesRow ? keepPageValues(rendered.content, await readLocalNote(this.vaultFs, recordPath)) : rendered.content;
19275
20370
  const seenAt = this.observation().seenAt;
20371
+ let merged = null;
19276
20372
  if (existingRecord) {
19277
20373
  let localContent = "";
19278
20374
  let localExists = true;
@@ -19283,8 +20379,7 @@ var DatabaseSyncer = class {
19283
20379
  localContent = "";
19284
20380
  localExists = await this.vaultFs.exists(readPath);
19285
20381
  }
19286
- const base = snapshotFrontmatter(existingRecord.baseSnapshot);
19287
- const remoteUnchanged = localExists && base !== null && bodyFingerprint === existingRecord.notionBodyFingerprint && this.rowOutsideBodyUnchanged(this.propertyMapper, page, base, existingRecord.obsidianPath);
20382
+ const remoteUnchanged = localExists && this.rowRemoteUnchanged(existingRecord, page, bodyFingerprint, localContent);
19288
20383
  const resolution = resolvePullConflict({
19289
20384
  record: existingRecord,
19290
20385
  localContent,
@@ -19335,9 +20430,10 @@ var DatabaseSyncer = class {
19335
20430
  });
19336
20431
  return { action: "unchanged", path: filePath };
19337
20432
  }
20433
+ if (resolution.action === "merge") merged = resolution.merged;
19338
20434
  }
19339
20435
  await this.vaultFs.ensureFolder(dbConfig.localFolder);
19340
- await this.vaultFs.writeFile(filePath, finalContent);
20436
+ await this.vaultFs.writeFile(filePath, merged ?? finalContent);
19341
20437
  const rehomed = existingRecord && recordPath && recordPath !== filePath;
19342
20438
  const outOfStep = becomesRow && (rendered.propertiesBlock || !this.rowOutsideBodyUnchanged(
19343
20439
  this.propertyMapper,
@@ -19368,9 +20464,9 @@ var DatabaseSyncer = class {
19368
20464
  fileType: "db-row",
19369
20465
  status: "synced",
19370
20466
  baseSnapshot: Buffer.from(finalContent, "utf-8"),
19371
- // 비운 해시를 변경 확인이 보게 — stat 이 맞으면 내용을 읽지 않는다(B5).
19372
- localMtime: outOfStep ? null : stat2?.mtime ?? null,
19373
- localFileSize: outOfStep ? null : stat2?.size ?? null,
20467
+ // 비운 해시 · 합친 글을 변경 확인이 보게 — stat 이 맞으면 내용을 읽지 않는다(B5).
20468
+ localMtime: outOfStep || merged !== null ? null : stat2?.mtime ?? null,
20469
+ localFileSize: outOfStep || merged !== null ? null : stat2?.size ?? null,
19374
20470
  // 빈 해시와 함께 — push 가 지난 사본 대신 원격의 지금 값과 견줘 다른 속성을 모두 보낸다(overwritesRemote).
19375
20471
  ...outOfStep ? { notionBodyFingerprint: null } : {}
19376
20472
  });
@@ -19408,7 +20504,7 @@ var DatabaseSyncer = class {
19408
20504
  );
19409
20505
  }
19410
20506
  }
19411
- return { action: "written", path: filePath };
20507
+ return merged !== null ? { action: "merged", path: filePath } : { action: "written", path: filePath, ...relinked ? { relinked: true } : {} };
19412
20508
  }
19413
20509
  };
19414
20510
  function arrivesFromOtherDatabase(record, dbConfig) {
@@ -19547,160 +20643,30 @@ function folderOf3(record) {
19547
20643
  if (record.fileType === "db-row") return null;
19548
20644
  if (isFolderRecord(record)) return record.obsidianPath;
19549
20645
  const slash = record.obsidianPath.lastIndexOf("/");
19550
- if (slash < 0) return null;
19551
- const folder = record.obsidianPath.slice(0, slash);
19552
- const stem = stripMarkdownExtension(record.obsidianPath.slice(slash + 1)).replace(ID_SUFFIX, "");
19553
- return stem === folder.slice(folder.lastIndexOf("/") + 1) ? folder : null;
19554
- }
19555
- function findNestedFolderPages(records) {
19556
- const byPage = /* @__PURE__ */ new Map();
19557
- for (const record of records) {
19558
- if (record.notionPageId !== null) byPage.set(compactNotionId(record.notionPageId), record);
19559
- }
19560
- const found2 = [];
19561
- for (const inner of records) {
19562
- if (inner.notionPageId === null || inner.notionParentId === null) continue;
19563
- const folder = folderOf3(inner);
19564
- if (folder === null) continue;
19565
- const outer = byPage.get(compactNotionId(inner.notionParentId));
19566
- if (!outer || outer === inner || folderOf3(outer) !== folder) continue;
19567
- found2.push({
19568
- folder,
19569
- outer: { path: outer.obsidianPath, notionPageId: outer.notionPageId },
19570
- inner: { path: inner.obsidianPath, notionPageId: inner.notionPageId }
19571
- });
19572
- }
19573
- return found2.sort((a, b) => a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0);
19574
- }
19575
-
19576
- // src/conflict/merger.ts
19577
- function threeWayMerge(base, local, remote) {
19578
- const baseLines = base.split("\n");
19579
- const localLines = local.split("\n");
19580
- const remoteLines = remote.split("\n");
19581
- const hunks = [
19582
- ...computeHunks(baseLines, localLines, 0),
19583
- ...computeHunks(baseLines, remoteLines, 2)
19584
- ];
19585
- hunks.sort((a, b) => a.baseStart - b.baseStart || a.side - b.side);
19586
- const merged = [];
19587
- const conflicts = [];
19588
- let cursor = 0;
19589
- let i = 0;
19590
- while (i < hunks.length) {
19591
- const first = hunks[i];
19592
- if (first.baseStart > cursor) {
19593
- merged.push(...baseLines.slice(cursor, first.baseStart));
19594
- }
19595
- const regionStart = first.baseStart;
19596
- let regionEnd = first.baseEnd;
19597
- const group2 = [first];
19598
- i++;
19599
- while (i < hunks.length) {
19600
- const next = hunks[i];
19601
- const overlaps = next.baseStart < regionEnd || regionEnd === regionStart && next.baseStart === regionStart;
19602
- if (!overlaps) break;
19603
- regionEnd = Math.max(regionEnd, next.baseEnd);
19604
- group2.push(next);
19605
- i++;
19606
- }
19607
- const baseRegion = baseLines.slice(regionStart, regionEnd);
19608
- const localRegion = applyHunks(baseLines, group2, 0, regionStart, regionEnd);
19609
- const remoteRegion = applyHunks(baseLines, group2, 2, regionStart, regionEnd);
19610
- if (arraysEqual(localRegion, baseRegion)) {
19611
- merged.push(...remoteRegion);
19612
- } else if (arraysEqual(remoteRegion, baseRegion)) {
19613
- merged.push(...localRegion);
19614
- } else if (arraysEqual(localRegion, remoteRegion)) {
19615
- merged.push(...localRegion);
19616
- } else {
19617
- conflicts.push({ localLines: localRegion, remoteLines: remoteRegion });
19618
- merged.push("<<<<<<< LOCAL");
19619
- merged.push(...localRegion);
19620
- merged.push("=======");
19621
- merged.push(...remoteRegion);
19622
- merged.push(">>>>>>> REMOTE");
19623
- }
19624
- cursor = regionEnd;
19625
- }
19626
- if (cursor < baseLines.length) {
19627
- merged.push(...baseLines.slice(cursor));
19628
- }
19629
- return {
19630
- success: conflicts.length === 0,
19631
- merged: merged.join("\n"),
19632
- conflicts
19633
- };
19634
- }
19635
- function computeHunks(base, modified, side) {
19636
- const matches = lcsMatches(base, modified);
19637
- const hunks = [];
19638
- let prevBase = -1;
19639
- let prevMod = -1;
19640
- const emitGap = (curBase, curMod) => {
19641
- const baseLen = curBase - (prevBase + 1);
19642
- const modLen = curMod - (prevMod + 1);
19643
- if (baseLen > 0 || modLen > 0) {
19644
- hunks.push({
19645
- baseStart: prevBase + 1,
19646
- baseEnd: curBase,
19647
- content: modified.slice(prevMod + 1, curMod),
19648
- side
19649
- });
19650
- }
19651
- };
19652
- for (const [b, m] of matches) {
19653
- emitGap(b, m);
19654
- prevBase = b;
19655
- prevMod = m;
19656
- }
19657
- emitGap(base.length, modified.length);
19658
- return hunks;
19659
- }
19660
- function applyHunks(baseLines, group2, side, regionStart, regionEnd) {
19661
- const out = [];
19662
- let b = regionStart;
19663
- for (const hunk of group2) {
19664
- if (hunk.side !== side) continue;
19665
- if (hunk.baseStart > b) out.push(...baseLines.slice(b, hunk.baseStart));
19666
- out.push(...hunk.content);
19667
- b = hunk.baseEnd;
19668
- }
19669
- if (b < regionEnd) out.push(...baseLines.slice(b, regionEnd));
19670
- return out;
20646
+ if (slash < 0) return null;
20647
+ const folder = record.obsidianPath.slice(0, slash);
20648
+ const stem = stripMarkdownExtension(record.obsidianPath.slice(slash + 1)).replace(ID_SUFFIX, "");
20649
+ return stem === folder.slice(folder.lastIndexOf("/") + 1) ? folder : null;
19671
20650
  }
19672
- function lcsMatches(a, b) {
19673
- const n = a.length;
19674
- const m = b.length;
19675
- const dp = new Array(n + 1);
19676
- for (let i2 = 0; i2 <= n; i2++) dp[i2] = new Uint32Array(m + 1);
19677
- for (let i2 = n - 1; i2 >= 0; i2--) {
19678
- const row = dp[i2];
19679
- const next = dp[i2 + 1];
19680
- const ai = a[i2];
19681
- for (let j2 = m - 1; j2 >= 0; j2--) {
19682
- row[j2] = ai === b[j2] ? next[j2 + 1] + 1 : Math.max(next[j2], row[j2 + 1]);
19683
- }
20651
+ function findNestedFolderPages(records) {
20652
+ const byPage = /* @__PURE__ */ new Map();
20653
+ for (const record of records) {
20654
+ if (record.notionPageId !== null) byPage.set(compactNotionId(record.notionPageId), record);
19684
20655
  }
19685
- const matches = [];
19686
- let i = 0;
19687
- let j = 0;
19688
- while (i < n && j < m) {
19689
- if (a[i] === b[j]) {
19690
- matches.push([i, j]);
19691
- i++;
19692
- j++;
19693
- } else if (dp[i + 1][j] >= dp[i][j + 1]) {
19694
- i++;
19695
- } else {
19696
- j++;
19697
- }
20656
+ const found2 = [];
20657
+ for (const inner of records) {
20658
+ if (inner.notionPageId === null || inner.notionParentId === null) continue;
20659
+ const folder = folderOf3(inner);
20660
+ if (folder === null) continue;
20661
+ const outer = byPage.get(compactNotionId(inner.notionParentId));
20662
+ if (!outer || outer === inner || folderOf3(outer) !== folder) continue;
20663
+ found2.push({
20664
+ folder,
20665
+ outer: { path: outer.obsidianPath, notionPageId: outer.notionPageId },
20666
+ inner: { path: inner.obsidianPath, notionPageId: inner.notionPageId }
20667
+ });
19698
20668
  }
19699
- return matches;
19700
- }
19701
- function arraysEqual(a, b) {
19702
- if (a.length !== b.length) return false;
19703
- return a.every((val, idx) => val === b[idx]);
20669
+ return found2.sort((a, b) => a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0);
19704
20670
  }
19705
20671
 
19706
20672
  // src/conflict/resolver.ts
@@ -19721,6 +20687,7 @@ function choiceForStrategy(conflict, strategy) {
19721
20687
  case "duplicate":
19722
20688
  return isRemoteDeletion(conflict) ? "local" : "duplicate";
19723
20689
  case "manual":
20690
+ case "merge":
19724
20691
  if (isRemoteDeletion(conflict)) {
19725
20692
  throw new Error(
19726
20693
  `Notion \uC5D0\uC11C \uC9C0\uC6B4 \uB178\uD2B8\uB294 \uC790\uB3D9 \uBCD1\uD569\uD560 \uC218 \uC5C6\uC74C \u2014 \uB85C\uCEEC \uC720\uC9C0\uB098 \uC6D0\uACA9 \uC720\uC9C0\uB97C \uACE0\uB974\uC138\uC694: ${conflict.syncRecord.obsidianPath}`
@@ -20689,6 +21656,12 @@ function depthOf(filter) {
20689
21656
  }
20690
21657
 
20691
21658
  // src/sync/base-view-push.ts
21659
+ var BASES_TO_NOTION_VIEW = {
21660
+ table: "table",
21661
+ cards: "gallery",
21662
+ list: "list"
21663
+ };
21664
+ var CREATABLE_KEYS = ["type", "name", "order", "sort", "filters"];
20692
21665
  var PROPERTY_REQUEST_KEYS = [
20693
21666
  "property_id",
20694
21667
  "visible",
@@ -20700,6 +21673,9 @@ var PROPERTY_REQUEST_KEYS = [
20700
21673
  "time_format"
20701
21674
  ];
20702
21675
  var REQUIRED_CONFIGURATION_KEYS = ["group_by", "date_property_id"];
21676
+ function plannedViewEdits(file) {
21677
+ return [...file.created, ...file.views, ...file.deleted].map((planned) => planned.edit);
21678
+ }
20703
21679
  var BaseViewEdits = class {
20704
21680
  constructor(stateDb, vaultFs, notionClient) {
20705
21681
  this.vaultFs = vaultFs;
@@ -20739,7 +21715,9 @@ var BaseViewEdits = class {
20739
21715
  const changed = changedFields(record, edited);
20740
21716
  return changed.length > 0 ? [{ record, edited, changed }] : [];
20741
21717
  });
20742
- if (edits.length === 0) continue;
21718
+ const added = parsed.views.flatMap((view, i) => matched[i] ? [] : [{ view, index: i }]);
21719
+ const removed = base.views.filter((record) => !matched.includes(record));
21720
+ if (edits.length === 0 && added.length === 0 && removed.length === 0) continue;
20743
21721
  const meta = await this.notionClient.getDatabaseMeta(databaseId);
20744
21722
  if (meta.dataSources.length > 1) {
20745
21723
  refuse2(base.basePath, "\uB370\uC774\uD130 \uC18C\uC2A4\uAC00 \uC5EC\uB7FF\uC778 DB \uC758 \uBCF4\uAE30\uB294 Notion \uC5D0\uC11C \uBC14\uAFB8\uC138\uC694");
@@ -20748,15 +21726,66 @@ var BaseViewEdits = class {
20748
21726
  const schema = await this.notionClient.getDatabaseSchemaFull(databaseId, meta);
20749
21727
  const views = [];
20750
21728
  let allPlanned = true;
20751
- for (const { record, edited, changed } of edits) {
20752
- const label = `\uBCF4\uAE30 '${record.name}'`;
20753
- let live;
21729
+ const liveOf = async (viewId) => {
20754
21730
  try {
20755
- live = await this.notionClient.getView(record.viewId);
21731
+ return await this.notionClient.getView(viewId);
20756
21732
  } catch (error) {
20757
21733
  if (!isNotionObjectNotFound(error)) throw error;
20758
- live = null;
21734
+ return null;
21735
+ }
21736
+ };
21737
+ const created = [];
21738
+ for (const { view, index } of added) {
21739
+ const planned2 = planCreate(view, index, base.basePath, schema);
21740
+ if ("error" in planned2) {
21741
+ refuse2(base.basePath, `\uB354\uD55C \uBCF4\uAE30 '${String(view["name"] ?? "")}': ${planned2.error}`);
21742
+ allPlanned = false;
21743
+ continue;
21744
+ }
21745
+ if (!meta.dataSources[0]) {
21746
+ refuse2(
21747
+ base.basePath,
21748
+ `\uB354\uD55C \uBCF4\uAE30 '${planned2.edit.view}': DB \uC758 \uB370\uC774\uD130 \uC18C\uC2A4\uB97C \uC77D\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4`
21749
+ );
21750
+ allPlanned = false;
21751
+ continue;
21752
+ }
21753
+ created.push(planned2);
21754
+ if (Object.keys(view).some((key) => !CREATABLE_KEYS.includes(key))) allPlanned = false;
21755
+ }
21756
+ const deleted = [];
21757
+ const gone = [];
21758
+ for (const record of removed) {
21759
+ const label = `\uC9C0\uC6B4 \uBCF4\uAE30 '${record.name}'`;
21760
+ if (parsed.views.length === 0) {
21761
+ refuse2(
21762
+ base.basePath,
21763
+ `${label}: \uBCF4\uAE30\uB97C \uBAA8\uB450 \uC9C0\uC6B4 .base \uB85C\uB294 Notion \uBCF4\uAE30\uB97C \uC9C0\uC6B0\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4`
21764
+ );
21765
+ allPlanned = false;
21766
+ continue;
21767
+ }
21768
+ const live = await liveOf(record.viewId);
21769
+ if (!live) {
21770
+ gone.push(record.viewId);
21771
+ continue;
21772
+ }
21773
+ if (viewFingerprint(live) !== record.notion) {
21774
+ refuse2(
21775
+ base.basePath,
21776
+ `${label}: \uADF8 \uC0AC\uC774 Notion \uC5D0\uC11C \uC774 \uBCF4\uAE30\uB97C \uACE0\uCCD0 \uC9C0\uC6B0\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. \uC9C0\uC6B0\uB824\uBA74 Notion \uC5D0\uC11C \uC9C0\uC6B0\uC138\uC694`
21777
+ );
21778
+ allPlanned = false;
21779
+ continue;
20759
21780
  }
21781
+ deleted.push({
21782
+ viewId: record.viewId,
21783
+ edit: { path: base.basePath, view: record.name, action: "deleted", changed: [] }
21784
+ });
21785
+ }
21786
+ for (const { record, edited, changed } of edits) {
21787
+ const label = `\uBCF4\uAE30 '${record.name}'`;
21788
+ const live = await liveOf(record.viewId);
20760
21789
  if (!live) {
20761
21790
  refuse2(base.basePath, `${label}: Notion \uC5D0\uC11C \uC9C0\uC6B4 \uBCF4\uAE30\uC785\uB2C8\uB2E4`);
20762
21791
  allPlanned = false;
@@ -20790,18 +21819,25 @@ var BaseViewEdits = class {
20790
21819
  type: record.type,
20791
21820
  ...edited,
20792
21821
  ...record.lossyFilter && !changed.includes("filter") ? { lossyFilter: true } : {},
20793
- notion: record.notion
21822
+ notion: record.notion,
21823
+ ...record.rest !== void 0 ? { rest: record.rest } : {}
20794
21824
  }
20795
21825
  });
20796
21826
  }
20797
- if (views.length === 0) continue;
21827
+ if (views.length === 0 && created.length === 0 && deleted.length === 0 && gone.length === 0) {
21828
+ continue;
21829
+ }
20798
21830
  planned.push({
20799
21831
  databaseId,
20800
21832
  path: base.basePath,
20801
21833
  base,
20802
21834
  hash,
20803
- complete: allPlanned && matched.filter(Boolean).length === base.views.length && restSignature(parsed, matched) === base.rest,
20804
- views
21835
+ complete: allPlanned && restUnchanged(parsed, matched, base),
21836
+ views,
21837
+ created,
21838
+ deleted,
21839
+ gone,
21840
+ matched
20805
21841
  });
20806
21842
  }
20807
21843
  return { planned, refused };
@@ -20811,29 +21847,73 @@ var BaseViewEdits = class {
20811
21847
  const failed = [];
20812
21848
  for (const file of planned) {
20813
21849
  const records = new Map(file.base.views.map((record) => [record.viewId, record]));
21850
+ const made = /* @__PURE__ */ new Map();
20814
21851
  let allApplied = true;
21852
+ const fail = (what, error) => {
21853
+ allApplied = false;
21854
+ failed.push({
21855
+ path: file.path,
21856
+ operation: "update",
21857
+ error: `${what}: ${error instanceof Error ? error.message : String(error)}`
21858
+ });
21859
+ };
21860
+ const fingerprintOf = async (viewId, fallback) => {
21861
+ const after = await this.notionClient.getView(viewId).catch(() => null);
21862
+ return after ? viewFingerprint(after) : fallback;
21863
+ };
21864
+ const meta = file.created.length > 0 ? await this.dataSourceOf(file.databaseId) : null;
21865
+ for (const view of file.created) {
21866
+ let viewId;
21867
+ try {
21868
+ if (!meta) throw new Error("DB \uC758 \uB370\uC774\uD130 \uC18C\uC2A4\uB97C \uC77D\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4");
21869
+ viewId = await this.notionClient.createView({
21870
+ databaseId: file.databaseId,
21871
+ dataSourceId: meta,
21872
+ request: view.request
21873
+ });
21874
+ } catch (error) {
21875
+ fail(`\uBCF4\uAE30 '${view.edit.view}' \uB97C Notion \uC5D0 \uB9CC\uB4E4\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4`, error);
21876
+ continue;
21877
+ }
21878
+ applied.push(view.edit);
21879
+ made.set(view.index, { ...view.record, viewId, notion: await fingerprintOf(viewId, "") });
21880
+ }
20815
21881
  for (const view of file.views) {
20816
21882
  try {
20817
21883
  await this.notionClient.updateView(view.viewId, view.request);
20818
21884
  } catch (error) {
20819
- allApplied = false;
20820
- failed.push({
20821
- path: file.path,
20822
- operation: "update",
20823
- error: `\uBCF4\uAE30 '${view.edit.view}' \uB97C Notion \uC5D0 \uC62C\uB9AC\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: ${error instanceof Error ? error.message : String(error)}`
20824
- });
21885
+ fail(`\uBCF4\uAE30 '${view.edit.view}' \uB97C Notion \uC5D0 \uC62C\uB9AC\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4`, error);
20825
21886
  continue;
20826
21887
  }
20827
21888
  applied.push(view.edit);
20828
- const after = await this.notionClient.getView(view.viewId).catch(() => null);
20829
21889
  records.set(view.viewId, {
20830
21890
  ...view.record,
20831
- notion: after ? viewFingerprint(after) : view.record.notion
21891
+ notion: await fingerprintOf(view.viewId, view.record.notion)
20832
21892
  });
20833
21893
  }
21894
+ for (const viewId of file.gone) records.delete(viewId);
21895
+ for (const view of file.deleted) {
21896
+ try {
21897
+ await this.notionClient.deleteView(view.viewId);
21898
+ } catch (error) {
21899
+ if (!isNotionObjectNotFound(error)) {
21900
+ fail(`\uBCF4\uAE30 '${view.edit.view}' \uB97C Notion \uC5D0\uC11C \uC9C0\uC6B0\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4`, error);
21901
+ continue;
21902
+ }
21903
+ }
21904
+ applied.push(view.edit);
21905
+ records.delete(view.viewId);
21906
+ }
21907
+ const inFile = file.matched.flatMap((record, i) => {
21908
+ const kept = record ? records.get(record.viewId) : made.get(i);
21909
+ return kept ? [kept] : [];
21910
+ });
21911
+ const left = [...records.values()].filter(
21912
+ (record) => !inFile.some((r) => r.viewId === record.viewId)
21913
+ );
20834
21914
  this.bases.set(file.databaseId, {
20835
21915
  ...file.base,
20836
- views: file.base.views.map((record) => records.get(record.viewId) ?? record),
21916
+ views: [...inFile, ...left],
20837
21917
  // 고친 것을 모두 올렸으면 이 파일은 이제 pull 이 쓴 것과 같은 뜻이다 — 다음 pull 이 Notion 의 모습으로 다시 쓴다.
20838
21918
  // 남은 것이 있으면 계속 붙잡아 둔다.
20839
21919
  ...file.complete && allApplied ? { hash: file.hash } : {}
@@ -20841,15 +21921,55 @@ var BaseViewEdits = class {
20841
21921
  }
20842
21922
  return { applied, failed };
20843
21923
  }
21924
+ /** 보기를 만들 data source — 계획 때 하나뿐임을 보았다. */
21925
+ async dataSourceOf(databaseId) {
21926
+ const meta = await this.notionClient.getDatabaseMeta(databaseId).catch(() => null);
21927
+ return meta?.dataSources[0]?.id ?? null;
21928
+ }
20844
21929
  };
20845
- function buildRequest(record, edited, changed, live, schema) {
21930
+ function planCreate(view, index, path, schema) {
21931
+ const basesType = String(view["type"] ?? "");
21932
+ const type = Object.hasOwn(BASES_TO_NOTION_VIEW, basesType) ? BASES_TO_NOTION_VIEW[basesType] : void 0;
21933
+ if (!type) return { error: `Notion \uC5D0 \uAC19\uC740 \uBCF4\uAE30 \uC885\uB958\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4(${basesType || "\uD0C0\uC785 \uC5C6\uC74C"})` };
21934
+ const edited = pushableOf(view);
21935
+ if (!edited.name.trim()) return { error: "\uBCF4\uAE30 \uC774\uB984\uC774 \uBE44\uC5C8\uC2B5\uB2C8\uB2E4" };
21936
+ const live = { id: "", name: edited.name, type, configuration: { type } };
21937
+ const changed = ["columns", "sort", "filter"].filter(
21938
+ (field) => field === "columns" ? edited.order !== void 0 : field === "sort" ? edited.sort !== void 0 : edited.filters !== void 0
21939
+ );
21940
+ const built = buildRequest(
21941
+ { name: edited.name, notion: "", viewId: "", type: basesType },
21942
+ edited,
21943
+ changed,
21944
+ live,
21945
+ schema,
21946
+ { hideRest: true }
21947
+ );
21948
+ if (!built.ok) return { error: built.error };
21949
+ const { configuration, filter, sorts } = built.request;
21950
+ return {
21951
+ index,
21952
+ request: {
21953
+ name: edited.name,
21954
+ type,
21955
+ configuration: configuration ?? { type },
21956
+ ...filter ? { filter } : {},
21957
+ ...sorts ? { sorts } : {}
21958
+ },
21959
+ edit: { path, view: edited.name, action: "created", changed: [] },
21960
+ record: { type: basesType, ...edited, rest: viewRestOf(view) }
21961
+ };
21962
+ }
21963
+ function buildRequest(record, edited, changed, live, schema, options = {}) {
20846
21964
  const request = {};
20847
21965
  try {
20848
21966
  if (changed.includes("name")) {
20849
21967
  if (!edited.name.trim()) throw new Error("\uBCF4\uAE30 \uC774\uB984\uC774 \uBE44\uC5C8\uC2B5\uB2C8\uB2E4");
20850
21968
  request.name = edited.name;
20851
21969
  }
20852
- if (changed.includes("columns")) request.configuration = columnsOf(edited, live, schema);
21970
+ if (changed.includes("columns")) {
21971
+ request.configuration = columnsOf(edited, live, schema, options.hideRest === true);
21972
+ }
20853
21973
  if (changed.includes("sort")) {
20854
21974
  request.sorts = edited.sort ? edited.sort.map((s) => ({
20855
21975
  property: propertyIdOf(s.property, schema, "\uC815\uB82C"),
@@ -20875,7 +21995,7 @@ function buildRequest(record, edited, changed, live, schema) {
20875
21995
  }
20876
21996
  return { ok: true, request };
20877
21997
  }
20878
- function columnsOf(edited, live, schema) {
21998
+ function columnsOf(edited, live, schema, hideRest = false) {
20879
21999
  if (!edited.order) throw new Error("\uC5F4 \uBAA9\uB85D(order)\uC744 \uC9C0\uC6B4 \uBCF4\uAE30\uB294 \uC62C\uB9AC\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
20880
22000
  const configuration = live.configuration ?? {};
20881
22001
  const liveEntries = Array.isArray(configuration["properties"]) ? configuration["properties"] : [];
@@ -20904,7 +22024,7 @@ function columnsOf(edited, live, schema) {
20904
22024
  const properties = [
20905
22025
  ...titleId ? [entryOf(titleId)] : [],
20906
22026
  ...shown.map((id) => ({ ...entryOf(id), visible: true })),
20907
- ...liveEntries.map((entry) => decodePropertyId(String(entry["property_id"]))).filter((id) => id !== titleId && !shown.includes(id)).map((id) => ({ ...entryOf(id), visible: false }))
22027
+ ...(hideRest ? Object.values(schema).map((prop) => decodePropertyId(prop.id)) : liveEntries.map((entry) => decodePropertyId(String(entry["property_id"])))).filter((id, i, all) => id !== titleId && !shown.includes(id) && all.indexOf(id) === i).map((id) => ({ ...entryOf(id), visible: false }))
20908
22028
  ];
20909
22029
  return {
20910
22030
  type: configuration["type"] ?? live.type,
@@ -21021,7 +22141,8 @@ var OPERATION_LABELS = {
21021
22141
  fetch: "\uC6D0\uACA9 \uD655\uC778",
21022
22142
  resolve: "\uCDA9\uB3CC \uD574\uACB0",
21023
22143
  discard: "\uBCC0\uACBD \uB418\uB3CC\uB9AC\uAE30",
21024
- "prepare-database": "DB \uB85C \uC62C\uB9B4 \uC900\uBE44"
22144
+ "prepare-database": "DB \uB85C \uC62C\uB9B4 \uC900\uBE44",
22145
+ "base-filters": ".base \uD3F4\uB354 \uD544\uD130 \uACE0\uCE58\uAE30"
21025
22146
  };
21026
22147
  function operationLabel(operation) {
21027
22148
  return OPERATION_LABELS[operation];
@@ -21206,6 +22327,7 @@ var InterruptedSyncRecovery = class {
21206
22327
  if (ops.length === 0) return;
21207
22328
  for (const op of ops) {
21208
22329
  if (op.direction === "push" && op.operation === "move") continue;
22330
+ if (op.direction === "push" && op.operation === "update") continue;
21209
22331
  if (op.direction !== "push" || op.operation !== "create") {
21210
22332
  this.stateDb.markPendingFailed(op.id, "unsupported resume op");
21211
22333
  continue;
@@ -21327,6 +22449,101 @@ var InterruptedSyncRecovery = class {
21327
22449
  }
21328
22450
  };
21329
22451
 
22452
+ // src/sync/lost-state-guard.ts
22453
+ var SHOWN_CLASHES = 5;
22454
+ var UnlinkedVaultError = class extends Error {
22455
+ clashes;
22456
+ constructor(clashes) {
22457
+ const shown = clashes.slice(0, SHOWN_CLASHES).join(", ");
22458
+ const more = clashes.length > SHOWN_CLASHES ? ` \uC678 ${clashes.length - SHOWN_CLASHES}\uAC1C` : "";
22459
+ super(
22460
+ `\uC0C1\uD0DC \uAE30\uB85D\uC5D0 Notion \uACFC \uC774\uC740 \uB178\uD2B8\uAC00 \uC5C6\uB294\uB370 Notion \uC5D0 \uAC19\uC740 \uC774\uB984\uC758 \uD398\uC774\uC9C0\uAC00 \uC774\uBBF8 \uC788\uC5B4 \uC62C\uB9AC\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4(${shown}${more}) \u2014 \uC774\uB300\uB85C \uC62C\uB9AC\uBA74 \uAC19\uC740 \uD398\uC774\uC9C0\uB97C \uB610 \uB9CC\uB4ED\uB2C8\uB2E4. \uBA3C\uC800 pull(\uB610\uB294 sync)\uB85C \uBC1B\uC73C\uC138\uC694: \uAC19\uC740 \uC790\uB9AC\uC5D0 \uAC19\uC740 \uAE00\uC774 \uC788\uB294 \uB178\uD2B8\uB294 \uADF8 \uD398\uC774\uC9C0\uC640 \uB2E4\uC2DC \uC787\uACE0, \uAE00\uC774 \uB2E4\uB974\uBA74 \uCDA9\uB3CC\uB85C \uB0A8\uAE41\uB2C8\uB2E4`
22461
+ );
22462
+ this.name = "UnlinkedVaultError";
22463
+ this.clashes = clashes;
22464
+ }
22465
+ };
22466
+ async function assertVaultLinked(config, stateDb, notionClient, createdPaths) {
22467
+ const unverified = /* @__PURE__ */ new Map();
22468
+ if (config.sync.direction === "push" || createdPaths.length === 0) return unverified;
22469
+ if (stateDb.getAll().length > 0 || stateDb.getIncompletePendingOperations().length > 0) {
22470
+ return unverified;
22471
+ }
22472
+ const clashes = await new RemoteTree(notionClient).clashes(config, createdPaths, unverified);
22473
+ if (clashes.length > 0) throw new UnlinkedVaultError(clashes);
22474
+ return unverified;
22475
+ }
22476
+ var RemoteTree = class {
22477
+ constructor(notionClient) {
22478
+ this.notionClient = notionClient;
22479
+ }
22480
+ notionClient;
22481
+ listed = /* @__PURE__ */ new Map();
22482
+ async clashes(config, createdPaths, unverified) {
22483
+ const databases = config.notion.databases ?? [];
22484
+ const clashes = [];
22485
+ for (const path of [...createdPaths].sort()) {
22486
+ const database = databases.find((db) => path.startsWith(`${db.localFolder}/`));
22487
+ const found2 = await (database ? this.find({ id: database.databaseId, kind: "database" }, [
22488
+ path.slice(database.localFolder.length + 1)
22489
+ ]) : isDatabaseMode(config) ? this.find({ id: config.notion.databaseId, kind: "database" }, [
22490
+ path.split("/").pop()
22491
+ ]) : this.find({ id: config.notion.rootPageId, kind: "page" }, path.split("/"))).catch((error) => {
22492
+ unverified.set(
22493
+ path,
22494
+ "\uC0C1\uD0DC \uAE30\uB85D\uC774 \uBE48 \uBCFC\uD2B8\uB77C Notion \uC5D0 \uAC19\uC740 \uD398\uC774\uC9C0\uAC00 \uC788\uB294\uC9C0 \uBA3C\uC800 \uBCF4\uB294\uB370 \uC77D\uC9C0 \uBABB\uD574 \uC62C\uB9AC\uC9C0 \uC54A\uC74C: " + (error instanceof Error ? error.message : String(error))
22495
+ );
22496
+ return false;
22497
+ });
22498
+ if (found2) clashes.push(path);
22499
+ }
22500
+ return clashes;
22501
+ }
22502
+ /** `segments`(폴더 … 노트 파일)의 노트 페이지가 `node` 아래에 이미 있나. */
22503
+ async find(node, segments) {
22504
+ let current = node;
22505
+ for (const folder of segments.slice(0, -1)) {
22506
+ const next = (await this.children(current)).get(sanitizeFileName(folder));
22507
+ if (!next) return false;
22508
+ current = next;
22509
+ }
22510
+ const name = sanitizeFileName(stripMarkdownExtension(segments[segments.length - 1]));
22511
+ const parentFolder = segments.length > 1 ? sanitizeFileName(segments[segments.length - 2]) : null;
22512
+ if (parentFolder === name && current.kind === "page") return true;
22513
+ return (await this.children(current)).has(name);
22514
+ }
22515
+ children(node) {
22516
+ let listed = this.listed.get(node.id);
22517
+ if (!listed) {
22518
+ listed = node.kind === "database" ? this.rows(node.id) : this.subpages(node.id);
22519
+ this.listed.set(node.id, listed);
22520
+ }
22521
+ return listed;
22522
+ }
22523
+ async rows(databaseId) {
22524
+ const rows = /* @__PURE__ */ new Map();
22525
+ for (const page of await this.notionClient.queryAllDatabasePages(databaseId)) {
22526
+ rows.set(sanitizeFileName(this.notionClient.extractTitle(page)), {
22527
+ id: page.id,
22528
+ kind: "page"
22529
+ });
22530
+ }
22531
+ return rows;
22532
+ }
22533
+ async subpages(pageId) {
22534
+ const found2 = /* @__PURE__ */ new Map();
22535
+ for (const block of await this.notionClient.fetchAllChildren(pageId)) {
22536
+ const child = block;
22537
+ if (child.type === "child_page" && child.child_page?.title !== void 0) {
22538
+ found2.set(sanitizeFileName(child.child_page.title), { id: child.id, kind: "page" });
22539
+ } else if (child.type === "child_database" && child.child_database?.title !== void 0) {
22540
+ found2.set(sanitizeFileName(child.child_database.title), { id: child.id, kind: "database" });
22541
+ }
22542
+ }
22543
+ return found2;
22544
+ }
22545
+ };
22546
+
21330
22547
  // src/sync/db-placeholder-rewriter.ts
21331
22548
  var ID32 = "[a-f0-9]{32}";
21332
22549
  var IDUUID = "[a-f0-9-]{32,36}";
@@ -21513,6 +22730,7 @@ var DatabaseDiscovery = class {
21513
22730
  }
21514
22731
  const linkedMap = loadLinkedDbMap(this.stateDb);
21515
22732
  let linkedChanged = false;
22733
+ const linkedNow = /* @__PURE__ */ new Set();
21516
22734
  const retry = parseDiscoveryRetry(this.stateDb.getMeta(DISCOVERY_RETRY_META_KEY));
21517
22735
  const nextRetry = { parents: [], dbs: [] };
21518
22736
  const pathOf = (pageId) => notionIdsEqual(pageId, this.config.notion.rootPageId) ? "" : this.stateDb.getByNotionId(pageId)?.obsidianPath ?? pageId;
@@ -21566,6 +22784,7 @@ var DatabaseDiscovery = class {
21566
22784
  } else if (outcome.kind === "linked") {
21567
22785
  const origNohyph = outcome.originalDbId.replace(/-/g, "");
21568
22786
  linkedMap.set(nohyph, origNohyph);
22787
+ linkedNow.add(nohyph);
21569
22788
  linkedChanged = true;
21570
22789
  await registerOriginal(origNohyph);
21571
22790
  } else if (outcome.kind === "inaccessible") {
@@ -21600,6 +22819,7 @@ var DatabaseDiscovery = class {
21600
22819
  await register(normalizeNotionId(nohyph), parentPageId);
21601
22820
  }
21602
22821
  const stillSyncable = [];
22822
+ const pulledWhole = /* @__PURE__ */ new Set();
21603
22823
  let queue = [...dbConfigs];
21604
22824
  const MAX_DISCOVERY_ROUNDS = 4;
21605
22825
  const resolveDbFolder = (dbId) => {
@@ -21639,10 +22859,8 @@ var DatabaseDiscovery = class {
21639
22859
  }
21640
22860
  });
21641
22861
  if (dbResult.linkedOriginalDbId) {
21642
- linkedMap.set(
21643
- dbConfig.databaseId.replace(/-/g, ""),
21644
- dbResult.linkedOriginalDbId.replace(/-/g, "")
21645
- );
22862
+ linkedMap.set(nohyph, dbResult.linkedOriginalDbId.replace(/-/g, ""));
22863
+ linkedNow.add(nohyph);
21646
22864
  linkedChanged = true;
21647
22865
  changed = true;
21648
22866
  ledger?.settle(dbConfig.databaseId);
@@ -21651,6 +22869,7 @@ var DatabaseDiscovery = class {
21651
22869
  );
21652
22870
  continue;
21653
22871
  }
22872
+ if (!paths) pulledWhole.add(nohyph);
21654
22873
  created += dbResult.created;
21655
22874
  updated += dbResult.updated;
21656
22875
  deleted += dbResult.deleted;
@@ -21697,6 +22916,11 @@ var DatabaseDiscovery = class {
21697
22916
  );
21698
22917
  }
21699
22918
  }
22919
+ if (!signal?.aborted) {
22920
+ linkedChanged = await this.pullLinkedViews(linkedMap, stillSyncable, {
22921
+ pulled: (container, original) => forceRediscovery || linkedNow.has(container) || pulledWhole.has(original)
22922
+ }) || linkedChanged;
22923
+ }
21700
22924
  ledger?.keepOnly([...configuredIds, ...stillSyncable.map((c) => c.databaseId)]);
21701
22925
  if (changed || inaccessibleChanged || stillSyncable.length !== dbConfigs.length) {
21702
22926
  this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(stillSyncable));
@@ -21721,6 +22945,29 @@ var DatabaseDiscovery = class {
21721
22945
  }
21722
22946
  return { created, updated, deleted, restored };
21723
22947
  }
22948
+ /**
22949
+ * 링크드 뷰 컨테이너마다 그 보기로 `.base` 를 쓴다({@link DatabaseSyncer.pullLinkedView}). 원본 DB 를 받은 pull ·
22950
+ * 이번에 해소한 컨테이너 · 아직 `.base` 가 없는 컨테이너만 — 보기만 고친 것은 DB 수정 시각을 바꾸지 않아 원본처럼
22951
+ * 다음 전체 대조가 받는다. Notion 에서 지운 컨테이너는 연결에서 뺀다.
22952
+ *
22953
+ * @returns 연결(`linkedMap`)을 바꿨나.
22954
+ */
22955
+ async pullLinkedViews(linkedMap, syncing, due) {
22956
+ let changed = false;
22957
+ const owners = [...syncing, ...this.config.notion.databases ?? []];
22958
+ for (const [container, original] of [...linkedMap]) {
22959
+ const owner = owners.find((c) => c.databaseId.replace(/-/g, "") === original);
22960
+ if (!owner) continue;
22961
+ if (!due.pulled(container, original) && this.databaseSyncer.baseFileInfo.get(container)) {
22962
+ continue;
22963
+ }
22964
+ if (await this.databaseSyncer.pullLinkedView(container, owner) === "gone") {
22965
+ linkedMap.delete(container);
22966
+ changed = true;
22967
+ }
22968
+ }
22969
+ return changed;
22970
+ }
21724
22971
  async discoverChildDatabases(pageIds) {
21725
22972
  const found2 = [];
21726
22973
  const unread = [];
@@ -21834,6 +23081,12 @@ var DatabaseDiscovery = class {
21834
23081
  for (const [key, t] of [...targets]) {
21835
23082
  if (!await this.vaultFs.exists(t.basePath)) targets.delete(key);
21836
23083
  }
23084
+ for (const container of linkedMap.keys()) {
23085
+ const info = this.databaseSyncer.baseFileInfo.get(container);
23086
+ if (info?.linkedTo && await this.vaultFs.exists(info.basePath)) {
23087
+ targets.set(container, { basePath: info.basePath, title: info.title });
23088
+ }
23089
+ }
21837
23090
  if (targets.size === 0) return;
21838
23091
  const resolve2 = (nohyph) => targets.get(nohyph) ?? targets.get(linkedMap.get(nohyph) ?? "") ?? null;
21839
23092
  let total = 0;
@@ -21884,12 +23137,6 @@ var DatabaseDiscovery = class {
21884
23137
  return refs;
21885
23138
  }
21886
23139
  };
21887
- function databaseFolderName(title) {
21888
- return truncateBytes(
21889
- title.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-"),
21890
- FILE_SYSTEM_NAME_MAX_BYTES
21891
- );
21892
- }
21893
23140
 
21894
23141
  // src/sync/folder-placement.ts
21895
23142
  var FolderPlacement = class {
@@ -22978,12 +24225,14 @@ function baseName3(path) {
22978
24225
 
22979
24226
  // src/sync/link-updater.ts
22980
24227
  var LinkUpdater = class {
22981
- constructor(stateDb, vaultFs) {
24228
+ constructor(stateDb, vaultFs, outside) {
22982
24229
  this.stateDb = stateDb;
22983
24230
  this.vaultFs = vaultFs;
24231
+ this.outside = outside;
22984
24232
  }
22985
24233
  stateDb;
22986
24234
  vaultFs;
24235
+ outside;
22987
24236
  /**
22988
24237
  * @param moves 옮긴 차례 그대로의 이동 — 노트 하나거나 폴더 하나.
22989
24238
  * @returns 링크를 고친 노트.
@@ -23001,33 +24250,130 @@ var LinkUpdater = class {
23001
24250
  if (renames.size === 0) return [];
23002
24251
  const before = new Set([...after].map((path) => undo(path, moves)));
23003
24252
  const context = { before, after, renames };
24253
+ const noteRenames = new Map([...renames].filter(([from]) => isMarkdownPath(from)));
23004
24254
  const relinked = [];
23005
24255
  for (const note of notes) {
23006
24256
  const source = { from: undo(note.path, moves), to: note.path };
23007
24257
  const next = rewriteLinks(note.content, source, context);
23008
24258
  if (next === note.content) continue;
24259
+ const notesOnly = noteRenames.size === renames.size || rewriteLinks(note.content, source, { ...context, renames: noteRenames }) === next;
24260
+ if (await this.rewrite(note, next, notesOnly)) relinked.push(note.path);
24261
+ }
24262
+ if (this.outside) relinked.push(...await this.applyOutside(this.outside, moves, after));
24263
+ if (relinked.length > 0) {
24264
+ getLogger().info(`[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE68: \uB178\uD2B8 ${relinked.length}`);
24265
+ }
24266
+ return relinked;
24267
+ }
24268
+ /**
24269
+ * 이 연결이 보지 않는 노트의 링크를 고친다 — 경로는 볼트 맨 위 기준으로 옮겨 견준다. Obsidian 은 링크를 볼트 전체에서
24270
+ * 찾으므로 이름만 적은 링크도 볼트 전체의 파일로 가른다.
24271
+ *
24272
+ * @param own 이 연결이 보는 파일(연결 폴더 기준) — 위에서 고쳤다.
24273
+ * @returns 고친 노트(볼트 맨 위 기준).
24274
+ */
24275
+ async applyOutside(outside, moves, own) {
24276
+ const prefix = outside.folder === "" ? "" : `${outside.folder}/`;
24277
+ const vaultMoves = moves.map((move) => ({
24278
+ ...move,
24279
+ from: prefix + move.from,
24280
+ to: prefix + move.to
24281
+ }));
24282
+ const mine = new Set([...own].map((path) => prefix + path));
24283
+ const notes = await outside.fs.listMarkdownFiles();
24284
+ const others = await outside.fs.listNonMarkdownFiles();
24285
+ const after = new Set([...notes, ...others].map((file) => file.path));
24286
+ const renames = /* @__PURE__ */ new Map();
24287
+ for (const path of after) {
24288
+ const before = undo(path, vaultMoves);
24289
+ if (before !== path) renames.set(before, path);
24290
+ }
24291
+ if (renames.size === 0) return [];
24292
+ const context = {
24293
+ before: new Set([...after].map((path) => undo(path, vaultMoves))),
24294
+ after,
24295
+ renames
24296
+ };
24297
+ const relinked = [];
24298
+ for (const note of notes) {
24299
+ if (mine.has(note.path)) continue;
24300
+ const next = rewriteLinks(note.content, { from: note.path, to: note.path }, context);
24301
+ if (next === note.content) continue;
23009
24302
  try {
23010
- await this.vaultFs.writeFile(note.path, next);
24303
+ await outside.fs.writeFile(note.path, next);
24304
+ relinked.push(note.path);
23011
24305
  } catch (error) {
23012
24306
  getLogger().warn(
23013
- `[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568 (${note.path}): ${error instanceof Error ? error.message : String(error)}`
24307
+ `[Im-Nobsidian] \uB2E4\uB978 \uC5F0\uACB0\uC758 \uB178\uD2B8\uC5D0\uC11C \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568 (${note.path}): ${error instanceof Error ? error.message : String(error)}`
23014
24308
  );
23015
- continue;
23016
24309
  }
23017
- const record = this.stateDb.getByPath(note.path);
23018
- if (record && record.contentHash === computeHash(note.content)) {
23019
- this.stateDb.updateHash(record.id, computeHash(next), Buffer.from(next, "utf-8"));
23020
- const stat2 = await this.vaultFs.getFileStat(note.path);
23021
- if (stat2) this.stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
23022
- }
23023
- relinked.push(note.path);
23024
- }
23025
- if (relinked.length > 0) {
23026
- getLogger().info(`[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE68: \uB178\uD2B8 ${relinked.length}`);
23027
24310
  }
23028
24311
  return relinked;
23029
24312
  }
24313
+ /**
24314
+ * 치운 `.base` 를 넣은 줄을 뺀다 — Notion 에서 지운 링크드 뷰라 pull 이 그 `.base` 를 치웠다. Notion 의 본문에는 그
24315
+ * 블록이 이미 없다. API 로 블록을 지우면 페이지의 수정 시각이 그대로라(2026-10-30 실측) 그 페이지를 다시 받지 않아
24316
+ * 깨진 임베드가 남았다. 동기화된 노트는 고친 글을 사본으로 적는다 — Notion 의 본문과 같아졌다.
24317
+ *
24318
+ * @returns 임베드를 뺀 노트.
24319
+ */
24320
+ async dropEmbeds(basePaths) {
24321
+ if (basePaths.length === 0) return [];
24322
+ const gone = new Set(basePaths);
24323
+ const dropped = [];
24324
+ for (const note of await this.vaultFs.listMarkdownFiles()) {
24325
+ const next = dropEmbedLines(note.content, gone);
24326
+ if (next !== note.content && await this.rewrite(note, next, true)) dropped.push(note.path);
24327
+ }
24328
+ if (dropped.length > 0) {
24329
+ getLogger().info(
24330
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uC9C0\uC6B4 \uB9C1\uD06C\uB4DC \uBDF0\uC758 \uC784\uBCA0\uB4DC\uB97C \uBE8C: \uB178\uD2B8 ${dropped.length}`
24331
+ );
24332
+ }
24333
+ return dropped;
24334
+ }
24335
+ /**
24336
+ * 고친 글을 쓴다. `matchesNotion` 이고 동기화된 노트(지난 사본 그대로)면 고친 글을 사본으로 적어 다음 push 가 올리지
24337
+ * 않게 한다 — Notion 의 본문은 이미 그 모양이다.
24338
+ */
24339
+ async rewrite(note, next, matchesNotion) {
24340
+ try {
24341
+ await this.vaultFs.writeFile(note.path, next);
24342
+ } catch (error) {
24343
+ getLogger().warn(
24344
+ `[Im-Nobsidian] \uB2E4\uB978 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568 (${note.path}): ${error instanceof Error ? error.message : String(error)}`
24345
+ );
24346
+ return false;
24347
+ }
24348
+ const record = this.stateDb.getByPath(note.path);
24349
+ if (record && record.contentHash === computeHash(note.content) && matchesNotion) {
24350
+ this.stateDb.updateHash(record.id, computeHash(next), Buffer.from(next, "utf-8"));
24351
+ const stat2 = await this.vaultFs.getFileStat(note.path);
24352
+ if (stat2) this.stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
24353
+ }
24354
+ return true;
24355
+ }
23030
24356
  };
24357
+ var EMBED_LINE_RE = /^!\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$/;
24358
+ function dropEmbedLines(content, gone) {
24359
+ const kept = [];
24360
+ let skipBlank = false;
24361
+ for (const line of content.split("\n")) {
24362
+ const target = EMBED_LINE_RE.exec(line.trim())?.[1];
24363
+ if (target !== void 0 && gone.has(target)) {
24364
+ if (kept.length > 0 && kept[kept.length - 1].trim() === "") kept.pop();
24365
+ else skipBlank = kept.length === 0;
24366
+ continue;
24367
+ }
24368
+ if (skipBlank && line.trim() === "") {
24369
+ skipBlank = false;
24370
+ continue;
24371
+ }
24372
+ skipBlank = false;
24373
+ kept.push(line);
24374
+ }
24375
+ return kept.join("\n");
24376
+ }
23031
24377
  function undo(path, moves) {
23032
24378
  let current = path;
23033
24379
  for (let i = moves.length - 1; i >= 0; i--) {
@@ -23039,6 +24385,19 @@ function undo(path, moves) {
23039
24385
  return current;
23040
24386
  }
23041
24387
 
24388
+ // src/sync/block-anchors.ts
24389
+ async function readBlockAnchors(client, blockIds) {
24390
+ const anchors = /* @__PURE__ */ new Map();
24391
+ for (const id of new Set(blockIds.map(compactNotionId))) {
24392
+ try {
24393
+ const anchor = anchorOfBlock(anchorBlockOf(await client.getBlock(id)));
24394
+ if (anchor) anchors.set(id, anchor);
24395
+ } catch {
24396
+ }
24397
+ }
24398
+ return anchors;
24399
+ }
24400
+
23042
24401
  // src/sync/change-inspector.ts
23043
24402
  var ChangeInspector = class {
23044
24403
  constructor(stateDb, vaultFs, notionClient, planner, puller) {
@@ -23547,6 +24906,7 @@ var RemoteDriftChecker = class {
23547
24906
  this.databaseSyncer = databaseSyncer;
23548
24907
  this.rowSchemas = rowSchemas;
23549
24908
  this.observation = observation;
24909
+ this.journal = new UpdateJournal(stateDb);
23550
24910
  }
23551
24911
  config;
23552
24912
  stateDb;
@@ -23554,6 +24914,20 @@ var RemoteDriftChecker = class {
23554
24914
  databaseSyncer;
23555
24915
  rowSchemas;
23556
24916
  observation;
24917
+ journal;
24918
+ /**
24919
+ * 원격이 이 노트를 쓰다 끊긴 그대로인가({@link UpdateJournal}) — push 는 마저 올리고, pull 은 받을 것이 없다고
24920
+ * 본다.
24921
+ * @param localContent 지금 노트 글.
24922
+ */
24923
+ interruptedOwnWrite(record, remote, localContent) {
24924
+ return this.journal.wroteRemote(
24925
+ record,
24926
+ remote,
24927
+ computeHash(localContent),
24928
+ this.observation.context.botUserId
24929
+ );
24930
+ }
23557
24931
  /**
23558
24932
  * 원격이 바뀌었는지 확인하지 않고 로컬로 덮어써도 되는가 — 원격을 로컬로 맞추라는 요청이다.
23559
24933
  *
@@ -23700,6 +25074,7 @@ var PagePusher = class {
23700
25074
  this.drift = drift;
23701
25075
  this.placement = placement;
23702
25076
  this.recovery = recovery;
25077
+ this.journal = new UpdateJournal(stateDb);
23703
25078
  }
23704
25079
  config;
23705
25080
  stateDb;
@@ -23718,6 +25093,8 @@ var PagePusher = class {
23718
25093
  }
23719
25094
  /** 이번 실행에서 relation 을 보내지 못한 행 — 대상 노트가 아직 Notion 에 없었다. */
23720
25095
  unsentRelations = /* @__PURE__ */ new Set();
25096
+ /** 본문을 쓰다 끊긴 노트를 다음 실행이 알게 — {@link UpdateJournal}. */
25097
+ journal;
23721
25098
  /** 이번 실행에서 페이지 ↔ 행 · 다른 DB 의 행이 된 노트({@link takePlacementChanges}). */
23722
25099
  placementChanges = [];
23723
25100
  /**
@@ -23768,10 +25145,63 @@ var PagePusher = class {
23768
25145
  );
23769
25146
  if (readOnly.length > 0) skips.push({ path: change.path, properties: readOnly });
23770
25147
  } catch {
23771
- continue;
25148
+ continue;
25149
+ }
25150
+ }
25151
+ return skips.sort((a, b) => a.path.localeCompare(b.path));
25152
+ }
25153
+ /**
25154
+ * dry-run 이 알릴, 실제 push 가 원격을 보고 거절할 고침 · 지움 — {@link pushUpdate} · {@link pushDelete} 와 같은
25155
+ * 판정이다(Notion 에서 지운 페이지 · pull 하지 않은 Notion 편집). 원격을 읽기만 하고 상태 DB 는 바꾸지 않는다 —
25156
+ * 실제 push 는 지운 페이지를 충돌로 남기지만 dry-run 은 이유만 보인다. 원격을 읽지 못했으면 그 실패를 보인다.
25157
+ *
25158
+ * 옮김은 보지 않는다 — 옮기는 노트는 옮길 자리 판정을 먼저 받는다(S-11).
25159
+ */
25160
+ async previewRemoteRefusals(changes) {
25161
+ const refused = [];
25162
+ for (const change of changes) {
25163
+ const operation = change.type === "modified" ? "update" : change.type === "deleted" ? "delete" : null;
25164
+ if (operation === null) continue;
25165
+ if (operation === "delete" && !this.config.sync.deleteSync) continue;
25166
+ const record = this.stateDb.getByPath(change.path);
25167
+ if (!record?.notionPageId) continue;
25168
+ try {
25169
+ if (operation === "update") await this.previewUpdate(change.path, record);
25170
+ else await this.previewDelete(change.path, record);
25171
+ } catch (error) {
25172
+ refused.push({
25173
+ path: change.path,
25174
+ operation,
25175
+ error: error instanceof Error ? error.message : String(error)
25176
+ });
25177
+ }
25178
+ }
25179
+ return refused;
25180
+ }
25181
+ /** 실제 고침이 원격을 보고 거절하면 그 오류를 던진다({@link pushUpdate} · {@link pushRowUpdate}). */
25182
+ async previewUpdate(path, record) {
25183
+ const presence = await remotePresence(this.notionClient, record.notionPageId);
25184
+ if (presence.kind !== "alive") throw new Error(trashedPageRefusal(path));
25185
+ if (this.drift.overwritesRemote(record, false)) return;
25186
+ const content = await this.vaultFs.readFile(path);
25187
+ if (this.drift.interruptedOwnWrite(record, presence.page, content)) return;
25188
+ if (rowDatabaseOf(this.config, record)) {
25189
+ const base = this.baseRowState(record);
25190
+ let body;
25191
+ try {
25192
+ body = parseFrontmatter(content).body;
25193
+ } catch {
25194
+ return;
23772
25195
  }
25196
+ if (base?.body != null && base.body === body) return;
23773
25197
  }
23774
- return skips.sort((a, b) => a.path.localeCompare(b.path));
25198
+ refuseUnpulledBody(await this.drift.remoteDrift(record, presence.page), path);
25199
+ }
25200
+ /** 실제 지움이 원격을 보고 거절하면 그 오류를 던진다({@link pushDelete}). 이미 사라진 원격은 추적만 놓는다. */
25201
+ async previewDelete(path, record) {
25202
+ const presence = await remotePresence(this.notionClient, record.notionPageId);
25203
+ if (presence.kind !== "alive" || this.drift.overwritesRemote(record, false)) return;
25204
+ refuseUnpulledDeletion(await this.drift.remoteDrift(record, presence.page), path);
23775
25205
  }
23776
25206
  /** dry-run 이 견줄 행 — 새 행은 기준 없이, 고친 행은 지난 동기화 사본과. 행이 아니거나 사본이 없으면 null. */
23777
25207
  previewRow(change) {
@@ -23836,7 +25266,8 @@ var PagePusher = class {
23836
25266
  direction: "push",
23837
25267
  path: selectedPath,
23838
25268
  filePath: path,
23839
- parentMode: this.config.notion.parentMode
25269
+ parentMode: this.config.notion.parentMode,
25270
+ anchors: await this.anchorLookup(content, null)
23840
25271
  });
23841
25272
  const title = noteTitle(conversionResult.properties, path);
23842
25273
  const placeholder2 = this.stateDb.upsert({
@@ -23946,13 +25377,40 @@ var PagePusher = class {
23946
25377
  }
23947
25378
  return uploads;
23948
25379
  }
25380
+ /**
25381
+ * 노트의 제목 · 블록 링크(`[[노트#제목]]`)가 가리키는 페이지의 맨 위 블록을 읽어 둔다 — 변환은 기다리지 않는다
25382
+ * (anchor-links). 그런 링크가 없으면 읽지 않는다. 읽지 못한 페이지의 링크는 예전처럼 간다.
25383
+ *
25384
+ * @param selfPageId 이 노트의 페이지 — `[[#제목]]` 의 대상. 새로 만드는 노트는 아직 없다(그 링크는 다음 push 에 걸린다).
25385
+ */
25386
+ async anchorLookup(content, selfPageId) {
25387
+ const pages = /* @__PURE__ */ new Set();
25388
+ for (const target of anchoredWikilinkTargets(content)) {
25389
+ const { note } = splitAnchor(target);
25390
+ const pageId = note === "" ? selfPageId : this.stateDb.resolveWikilink(note)?.notionPageId ?? null;
25391
+ if (pageId) pages.add(compactNotionId(pageId));
25392
+ }
25393
+ if (pages.size === 0) return void 0;
25394
+ const blocks = /* @__PURE__ */ new Map();
25395
+ for (const pageId of pages) {
25396
+ try {
25397
+ blocks.set(pageId, (await this.notionClient.fetchAllChildren(pageId)).map(anchorBlockOf));
25398
+ } catch (error) {
25399
+ getLogger().debug(
25400
+ `[Im-Nobsidian] \uC81C\uBAA9 \uB9C1\uD06C\uC758 \uB300\uC0C1 \uD398\uC774\uC9C0 \uBE14\uB85D\uC744 \uC77D\uC9C0 \uBABB\uD568 (${pageId}): ${error instanceof Error ? error.message : String(error)}`
25401
+ );
25402
+ }
25403
+ }
25404
+ return {
25405
+ selfPageId,
25406
+ blockOf: (pageId, anchor) => anchorBlockId(blocks.get(compactNotionId(pageId)) ?? [], anchor)
25407
+ };
25408
+ }
23949
25409
  async livePage(record) {
23950
25410
  const presence = await remotePresence(this.notionClient, record.notionPageId);
23951
25411
  if (presence.kind === "alive") return presence.page;
23952
25412
  this.stateDb.updateStatus(record.id, "conflict");
23953
- throw new Error(
23954
- `Notion \uC5D0\uC11C \uC9C0\uC6B4 \uD398\uC774\uC9C0\uB77C \uC62C\uB9AC\uC9C0 \uC54A\uC74C \u2014 \uCDA9\uB3CC\uB85C \uB0A8\uACBC\uB2E4. \uCDA9\uB3CC \uD574\uACB0\uC5D0\uC11C \uB85C\uCEEC \uC720\uC9C0(\uC0C8 \uD398\uC774\uC9C0\uB85C \uB2E4\uC2DC \uB9CC\uB4E4\uAE30)\uB098 \uC6D0\uACA9 \uC720\uC9C0(\uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B0\uAE30)\uB97C \uACE0\uB974\uC138\uC694: ${record.obsidianPath}`
23955
- );
25413
+ throw new Error(trashedPageRefusal(record.obsidianPath));
23956
25414
  }
23957
25415
  /**
23958
25416
  * @param options.overwriteRemote 원격을 로컬 내용으로 맞춘다(충돌 해소 결과 전파 · 입양한 행).
@@ -23980,15 +25438,15 @@ var PagePusher = class {
23980
25438
  direction: "push",
23981
25439
  path: updatePath,
23982
25440
  filePath: path,
23983
- parentMode: this.config.notion.parentMode
25441
+ parentMode: this.config.notion.parentMode,
25442
+ anchors: await this.anchorLookup(content, record.notionPageId)
23984
25443
  });
23985
25444
  const remote = await this.livePage(record);
23986
- const drift = this.drift.overwritesRemote(
23987
- record,
23988
- options?.overwriteRemote === true
23989
- ) ? "none" : await this.drift.remoteDrift(record, remote);
25445
+ const drift = this.drift.overwritesRemote(record, options?.overwriteRemote === true) || this.drift.interruptedOwnWrite(record, remote, content) ? "none" : await this.drift.remoteDrift(record, remote);
23990
25446
  refuseUnpulledBody(drift, path);
23991
25447
  const settles = drift === "none";
25448
+ const hash = computeHash(content);
25449
+ this.journal.begin(record.id, hash);
23992
25450
  const pushed = await this.pushUpdatePage(record.notionPageId, conversionResult.content, path);
23993
25451
  let bodyFingerprint = pushed.fingerprint;
23994
25452
  let touched = await this.fillDeferredBlocks(
@@ -24023,11 +25481,11 @@ var PagePusher = class {
24023
25481
  } else if (settles) {
24024
25482
  written = await this.notionClient.getPage(record.notionPageId);
24025
25483
  }
24026
- const hash = computeHash(content);
24027
25484
  const fileStat = await this.vaultFs.getFileStat(path);
24028
25485
  const title = extractTitle(path);
24029
25486
  const aliases = extractAliases(conversionResult.properties);
24030
25487
  this.stateDb.transaction(() => {
25488
+ this.journal.finish(record.id);
24031
25489
  this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
24032
25490
  this.stateDb.updateStatus(record.id, "synced");
24033
25491
  if (written !== null && settles) {
@@ -24242,20 +25700,28 @@ var PagePusher = class {
24242
25700
  * ({@link RemoteDriftChecker.overwritesRemote}).
24243
25701
  *
24244
25702
  * 다시 보내면 Notion 을 덮으므로, 받지 않은 Notion 편집이 있으면 옮기지 않는다.
25703
+ *
25704
+ * 옮기기 전에 보낼 노트를 갱신 WAL 에 적는다(P7). 옮긴 뒤 응답을 잃으면 같은 실행의 재시도 · 다음 push 는 이미 그
25705
+ * 자리에 있고 마지막 편집자가 이 봇인 페이지를 본다 — 우리가 옮기다 끊긴 것이므로 옮기기를 건너뛰고 마저 보낸다.
25706
+ * 예전에는 옮기며 바뀐 수정 시각을 Notion 편집으로 보고 «pull 을 먼저 하라» 고 거절했다.
24245
25707
  */
24246
25708
  async changePlacement(record, move) {
24247
25709
  const { path, databaseId } = move;
24248
25710
  const pageId = record.notionPageId;
25711
+ let content;
24249
25712
  let current;
24250
25713
  try {
24251
- current = parseFrontmatter(await this.vaultFs.readFile(path)).data;
25714
+ content = await this.vaultFs.readFile(path);
25715
+ current = parseFrontmatter(content).data;
24252
25716
  } catch (error) {
24253
25717
  throw new Error(
24254
25718
  `frontmatter \uB97C \uC77D\uC9C0 \uBABB\uD574 \uC62E\uAE30\uC9C0 \uC54A\uC74C (${path}): ${error instanceof Error ? error.message : String(error)}`
24255
25719
  );
24256
25720
  }
24257
25721
  const remote = await this.livePage(record);
24258
- if (!this.drift.overwritesRemote(record, false) && await this.drift.remoteDrift(record, remote) !== "none") {
25722
+ const pageParentId = databaseId ? null : this.placement.moveParentOf(path);
25723
+ const resumed = placedAt(remote, databaseId ?? pageParentId, databaseId !== null) && this.drift.interruptedOwnWrite(record, remote, content);
25724
+ if (!resumed && !this.drift.overwritesRemote(record, false) && await this.drift.remoteDrift(record, remote) !== "none") {
24259
25725
  throw new Error(
24260
25726
  `Notion \uC5D0\uC11C \uBC14\uB010 \uB4A4 \uBC1B\uC9C0 \uC54A\uC740 \uD3B8\uC9D1\uC774 \uC788\uC5B4 \uC62E\uAE30\uC9C0 \uC54A\uC74C \u2014 \uC62E\uAE30\uBA74 \uB178\uD2B8\uB97C \uD1B5\uC9F8\uB85C \uB2E4\uC2DC \uBCF4\uB0B4 \uADF8 \uD3B8\uC9D1\uC744 \uB36E\uB294\uB2E4. pull \uC744 \uBA3C\uC800 \uD558\uC138\uC694: ${path}`
24261
25727
  );
@@ -24274,11 +25740,17 @@ var PagePusher = class {
24274
25740
  );
24275
25741
  addedColumns = columnsAddedByMove(pagePropertiesOf(remote), schema);
24276
25742
  }
24277
- await this.notionClient.movePageToDataSource(pageId, source.id);
25743
+ if (!resumed) {
25744
+ this.journal.begin(record.id, computeHash(content));
25745
+ await this.notionClient.movePageToDataSource(pageId, source.id);
25746
+ }
24278
25747
  parentId = databaseId;
24279
25748
  } else {
24280
- parentId = this.placement.moveParentOf(path);
24281
- await this.notionClient.movePage(pageId, parentId);
25749
+ parentId = pageParentId;
25750
+ if (!resumed) {
25751
+ this.journal.begin(record.id, computeHash(content));
25752
+ await this.notionClient.movePage(pageId, parentId);
25753
+ }
24282
25754
  }
24283
25755
  this.stateDb.transaction(() => {
24284
25756
  this.stateDb.upsert({
@@ -24441,7 +25913,8 @@ var PagePusher = class {
24441
25913
  direction: "push",
24442
25914
  path: selectedPath,
24443
25915
  filePath: path,
24444
- parentMode: "database"
25916
+ parentMode: "database",
25917
+ anchors: await this.anchorLookup(content, null)
24445
25918
  });
24446
25919
  const placeholder2 = this.stateDb.upsert({
24447
25920
  obsidianPath: path,
@@ -24618,7 +26091,7 @@ var PagePusher = class {
24618
26091
  }
24619
26092
  const remote = await this.livePage(record);
24620
26093
  const mapper = await this.rowSchemas.mapperFor(databaseId, dataSourceIdOf(remote));
24621
- const drift = options.overwriteRemote ? "none" : await this.drift.remoteDrift(record, remote);
26094
+ const drift = options.overwriteRemote || this.drift.interruptedOwnWrite(record, remote, content) ? "none" : await this.drift.remoteDrift(record, remote);
24622
26095
  const against = options.overwriteRemote ? this.remoteRowState(mapper, remote) : this.baseRowState(record);
24623
26096
  const title = noteTitle(current.data, path);
24624
26097
  const changes = diffRowProperties(
@@ -24656,8 +26129,10 @@ var PagePusher = class {
24656
26129
  direction: "push",
24657
26130
  path: selectedPath,
24658
26131
  filePath: path,
24659
- parentMode: "database"
26132
+ parentMode: "database",
26133
+ anchors: await this.anchorLookup(content, pageId)
24660
26134
  });
26135
+ this.journal.begin(record.id, computeHash(content));
24661
26136
  const pushed = await this.pushUpdatePage(pageId, conversionResult.content, path);
24662
26137
  bodyFingerprint = pushed.fingerprint;
24663
26138
  let touched = await this.fillDeferredBlocks(
@@ -24707,6 +26182,7 @@ var PagePusher = class {
24707
26182
  const hash = computeHash(snapshot);
24708
26183
  const fileStat = unsent.length > 0 ? null : await this.vaultFs.getFileStat(path);
24709
26184
  this.stateDb.transaction(() => {
26185
+ this.journal.finish(record.id);
24710
26186
  this.stateDb.updateHash(record.id, hash, Buffer.from(snapshot, "utf-8"));
24711
26187
  this.stateDb.updateStatus(record.id, "synced");
24712
26188
  if (written !== null && settles) {
@@ -24883,11 +26359,13 @@ var PagePusher = class {
24883
26359
  }
24884
26360
  }
24885
26361
  /**
24886
- * `.base` 가 나타내는 Notion DB id 들 — 옆 사이드카(`<이름>.notion.json`)의 DB 와, 그 DB 를
24887
- * 원본으로 둔 링크드 뷰 컨테이너(pull 이 `.base` 를 원본 DB 로 몰아 만든다). 사이드카가
24888
- * 없거나 읽지 못하면 빈 목록 — 호출측이 제목으로 맞춘다.
26362
+ * `.base` 가 나타내는 Notion DB id 들 — 링크드 뷰의 `.base` 면 그 컨테이너. 아니면 옆 사이드카(`<이름>.notion.json`)의
26363
+ * DB 와, 그 DB 를 원본으로 둔 링크드 뷰 컨테이너(예전 pull 은 링크드 뷰를 원본의 `.base` 로 몰아 넣었다 — 그 노트를
26364
+ * 올릴 때도 링크드 뷰 블록을 지킨다). 사이드카가 없거나 읽지 못하면 빈 목록 — 호출측이 제목으로 맞춘다.
24889
26365
  */
24890
26366
  async databaseIdsOfBase(basePath) {
26367
+ const container = new DbBaseFiles(this.stateDb).linkedContainerAt(basePath);
26368
+ if (container) return [container];
24891
26369
  const sidecarPath = basePath.slice(0, -BASE_SUFFIX.length) + SIDECAR_SUFFIX;
24892
26370
  let databaseId;
24893
26371
  try {
@@ -24898,7 +26376,7 @@ var PagePusher = class {
24898
26376
  if (typeof databaseId !== "string") return [];
24899
26377
  const own = compactNotionId(databaseId);
24900
26378
  const made = loadCreatedDatabases(this.stateDb).get(own)?.databaseId;
24901
- const linked = [...loadLinkedDbMap(this.stateDb)].filter(([, original]) => original === own).map(([container]) => container);
26379
+ const linked = [...loadLinkedDbMap(this.stateDb)].filter(([, original]) => original === own).map(([container2]) => container2);
24902
26380
  return [own, ...made ? [compactNotionId(made)] : [], ...linked];
24903
26381
  }
24904
26382
  /**
@@ -24992,69 +26470,13 @@ function blockModeBody(markdownContent) {
24992
26470
  function withoutDecorations(skipped) {
24993
26471
  return skipped.filter((key) => key !== "icon" && key !== "cover");
24994
26472
  }
24995
-
24996
- // src/converter/notion-id-links.ts
24997
- var NOTION_ID_WIKILINK_RE = /\[\[notion:([a-f0-9]{32}|[a-f0-9-]{36})(?:(\\?\|)([^[\]]+))?\]\]/g;
24998
- var compactId = (id) => id.replace(/-/g, "");
24999
- function resolveNotionIdWikilinks(markdown, lookup) {
25000
- let resolved = 0;
25001
- const out = markdown.replace(
25002
- NOTION_ID_WIKILINK_RE,
25003
- (match, id, separator, rawAlias) => {
25004
- const path = lookup(id);
25005
- if (!path) return match;
25006
- resolved++;
25007
- const base = wikilinkTitleFromPath(path);
25008
- const alias = rawAlias === compactId(id) ? void 0 : rawAlias;
25009
- const link = formatWikilink(base, alias);
25010
- return separator === "\\|" ? link.replace("|", separator) : link;
25011
- }
25012
- );
25013
- return { markdown: out, resolved };
25014
- }
25015
- function degradeUnresolvedNotionIdWikilinks(markdown, titles = /* @__PURE__ */ new Map()) {
25016
- let degraded = 0;
25017
- const out = markdown.replace(
25018
- NOTION_ID_WIKILINK_RE,
25019
- (_match, id, _separator, alias) => {
25020
- degraded++;
25021
- const compact = compactId(id);
25022
- return `[${alias ?? (titles.get(compact) || compact)}](https://www.notion.so/${compact})`;
25023
- }
25024
- );
25025
- return { markdown: out, degraded };
25026
- }
25027
- function unlabeledNotionIds(markdown) {
25028
- const ids = /* @__PURE__ */ new Set();
25029
- for (const m of markdown.matchAll(NOTION_ID_WIKILINK_RE)) {
25030
- if (m[3] === void 0) ids.add(compactId(m[1]));
25031
- }
25032
- return [...ids];
25033
- }
25034
- function linkLabelOfTitle(title) {
25035
- return title.replace(/[\r\n]+/g, " ").trim().replace(/\[/g, "(").replace(/\]/g, ")").replace(/[\\*_`~]/g, "\\$&");
25036
- }
25037
- var NOTION_RELATIVE_PAGE_LINK_RE = /\[([^\]]+)\]\(\/(?:p\/)?([a-f0-9]{32})(?:\?[^)#]*)?(?:#([^)]*))?\)/g;
25038
- function resolveNotionRelativePageLinks(markdown, lookup) {
25039
- let resolved = 0;
25040
- const out = markdown.replace(NOTION_RELATIVE_PAGE_LINK_RE, (match, label, id) => {
25041
- const path = lookup(id);
25042
- if (!path) return match;
25043
- resolved++;
25044
- return formatWikilink(wikilinkTitleFromPath(path), label);
25045
- });
25046
- return { markdown: out, resolved };
26473
+ function trashedPageRefusal(path) {
26474
+ return `Notion \uC5D0\uC11C \uC9C0\uC6B4 \uD398\uC774\uC9C0\uB77C \uC62C\uB9AC\uC9C0 \uC54A\uC74C \u2014 \uCDA9\uB3CC\uB85C \uB0A8\uAE34\uB2E4. \uCDA9\uB3CC \uD574\uACB0\uC5D0\uC11C \uB85C\uCEEC \uC720\uC9C0(\uC0C8 \uD398\uC774\uC9C0\uB85C \uB2E4\uC2DC \uB9CC\uB4E4\uAE30)\uB098 \uC6D0\uACA9 \uC720\uC9C0(\uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B0\uAE30)\uB97C \uACE0\uB974\uC138\uC694: ${path}`;
25047
26475
  }
25048
- function degradeUnresolvedNotionRelativePageLinks(markdown) {
25049
- let degraded = 0;
25050
- const out = markdown.replace(
25051
- NOTION_RELATIVE_PAGE_LINK_RE,
25052
- (_match, label, id, anchor) => {
25053
- degraded++;
25054
- return `[${label}](https://www.notion.so/${id}${anchor === void 0 ? "" : `#${anchor}`})`;
25055
- }
25056
- );
25057
- return { markdown: out, degraded };
26476
+ function placedAt(page, targetId, asRow) {
26477
+ const parent = page.parent;
26478
+ const at = asRow ? parent.type === "data_source_id" || parent.type === "database_id" ? parent.database_id : void 0 : parent.type === "page_id" ? parent.page_id : void 0;
26479
+ return at !== void 0 && notionIdsEqual(at, targetId);
25058
26480
  }
25059
26481
 
25060
26482
  // src/sync/notion-parent.ts
@@ -25127,6 +26549,15 @@ var PagePuller = class {
25127
26549
  claimTurns = /* @__PURE__ */ new Map();
25128
26550
  // Notion 에서 사라져 볼트에서 지운 페이지의 하위 폴더 — pull 이 마칠 때 비었으면 지운다({@link removeVacatedFolders}).
25129
26551
  vacatedFolders = /* @__PURE__ */ new Set();
26552
+ /**
26553
+ * 이번 pull 을 시작할 때 Notion 과 이은 노트가 하나도 없었다 — 상태 DB 를 잃었거나 처음 잇는 볼트다. 그때는 자연 이름에
26554
+ * 있는 노트의 글이 달라도 다시 잇는다({@link relinkExistingNote}). 시작할 때 정한다 — 받는 동안 기록이 생긴다.
26555
+ */
26556
+ unlinkedVault = false;
26557
+ /** 이번 pull 을 시작할 때 Notion 과 이은 노트가 하나도 없었나 — DB 행 받기도 같은 판정을 쓴다. */
26558
+ get unlinkedVaultAtStart() {
26559
+ return this.unlinkedVault;
26560
+ }
25130
26561
  decorationsCache;
25131
26562
  get decorations() {
25132
26563
  return this.decorationsCache ??= new PageDecorations(this.notionClient, this.imageHandler);
@@ -25142,6 +26573,7 @@ var PagePuller = class {
25142
26573
  this._pullImageCount = 0;
25143
26574
  this._pullFileCount = 0;
25144
26575
  this.vacatedFolders.clear();
26576
+ this.unlinkedVault = !this.stateDb.getAll().some((record) => record.notionPageId !== null);
25145
26577
  }
25146
26578
  /**
25147
26579
  * 이번 pull 이 지운 페이지의 폴더가 비었으면 지운다 — 안쪽부터, 그래야 바깥이 빈다. 예전에는 노트만 지우고
@@ -25261,58 +26693,147 @@ var PagePuller = class {
25261
26693
  const fetched = await this.fetchPageMarkdown(pageId);
25262
26694
  const hasChildPages = this.hasChildContainers(pageId, fetched.children);
25263
26695
  if (hasChildPages && !fetched.hasBody) {
25264
- return {
25265
- path: await this.createFolderOnly(pageId, page, fetched.fingerprint, parentPath, safeName)
25266
- };
25267
- }
25268
- let filePath;
25269
- let fileType;
25270
- if (hasChildPages) {
25271
- const folderPath = parentPath ? `${parentPath}/${safeName}` : safeName;
25272
- filePath = await this.resolveUniqueFilePath(folderPath, safeName, pageId);
25273
- fileType = "folder-note";
25274
- await this.vaultFs.ensureFolder(folderPath);
25275
- } else {
25276
- filePath = await this.resolveUniqueFilePath(parentPath, safeName, pageId);
25277
- fileType = "file";
25278
- }
25279
- const { content: finalContent, properties } = await this.renderPageNote({
26696
+ return this.createFolderOnly(pageId, page, fetched.fingerprint, parentPath, safeName);
26697
+ }
26698
+ const fileType = hasChildPages ? "folder-note" : "file";
26699
+ const noteDir = hasChildPages ? parentPath ? `${parentPath}/${safeName}` : safeName : parentPath;
26700
+ if (hasChildPages) await this.vaultFs.ensureFolder(noteDir);
26701
+ const claim = await this.resolveUniqueFilePath(noteDir, safeName, pageId, { adopt: true });
26702
+ let filePath = claim.path;
26703
+ const render2 = async (path, local) => await this.renderPageNote({
25280
26704
  pageId,
25281
26705
  page,
25282
26706
  fetched,
25283
- filePath,
26707
+ filePath: path,
25284
26708
  title,
25285
- titleFollowsName: extractTitle(filePath) === title,
25286
- downloadMedia: true
26709
+ titleFollowsName: extractTitle(path) === title,
26710
+ downloadMedia: true,
26711
+ ...local !== void 0 ? { local: { content: local, markers: [] }, adoptLocalMedia: true } : {}
25287
26712
  });
25288
26713
  const resolvedParentId = await extractParentId(this.notionClient, page);
25289
- await this.vaultFs.writeFile(filePath, finalContent);
25290
- const hash = computeHash(finalContent);
25291
- const pullStat = await this.vaultFs.getFileStat(filePath);
26714
+ const note = {
26715
+ pageId,
26716
+ page,
26717
+ fingerprint: fetched.fingerprint,
26718
+ parentId: resolvedParentId,
26719
+ fileType,
26720
+ title
26721
+ };
26722
+ let rendered;
26723
+ if (claim.existing) {
26724
+ const local = await readLocalNote(this.vaultFs, filePath);
26725
+ rendered = await render2(filePath, local);
26726
+ const relinked = local === void 0 ? null : this.relinkExistingNote(filePath, claim.existing, local, rendered, note);
26727
+ if (relinked) return relinked;
26728
+ this.claimedPaths.delete(filePath);
26729
+ filePath = (await this.resolveUniqueFilePath(noteDir, safeName, pageId)).path;
26730
+ rendered = await render2(filePath);
26731
+ } else {
26732
+ rendered = await render2(filePath);
26733
+ }
26734
+ await this.vaultFs.writeFile(filePath, rendered.content);
26735
+ await this.recordPulledNote(filePath, note, {
26736
+ content: rendered.content,
26737
+ properties: rendered.properties,
26738
+ status: "synced"
26739
+ });
26740
+ return { path: filePath };
26741
+ }
26742
+ /**
26743
+ * 상태 기록에 없는 노트가 이 페이지가 놓일 자리에 이미 있다 — 상태 DB 를 잃었거나 처음 잇는 볼트다(P7).
26744
+ * 예전에는 남의 노트로 보고 id 를 붙인 이름으로 또 받아 모든 노트가 두 벌이 됐다(push 는 모든 페이지를 또 만들었다).
26745
+ *
26746
+ * - 글이 같으면(끝 개행만 달라도) 그 노트를 이 페이지와 다시 잇는다 — 쓰지 않는다.
26747
+ * - 다르면 이 페이지 id 를 단 이름(지난번 이 페이지를 받은 이름)이면 충돌 전략을 따른다: remote-first 는 원격으로 덮고 ·
26748
+ * local-first 는 로컬을 두어 다음 push 가 올리고 · 그 밖은 충돌로 남긴다(지난 사본이 없어 merge 도 합칠 수 없다).
26749
+ * - 자연 이름에 있는 다른 글은 이은 노트가 하나도 없는 볼트에서만 잇고 전략과 상관없이 충돌로 남긴다 — 다른 노트일 수 있어
26750
+ * 덮거나 올리면 잃는다. 이은 노트가 있는 볼트에서는 잇지 않는다(null) — 그 볼트의 새 노트일 수 있다.
26751
+ */
26752
+ relinkExistingNote(filePath, existing, local, rendered, note) {
26753
+ const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
26754
+ if (!same && existing === "name" && !this.unlinkedVault) return null;
26755
+ return (async () => {
26756
+ const strategy = existing === "own" ? this.config.sync.conflictStrategy : "manual";
26757
+ if (same || strategy === "local-first") {
26758
+ await this.recordPulledNote(filePath, note, {
26759
+ content: rendered.content,
26760
+ properties: rendered.properties,
26761
+ status: "synced",
26762
+ // 같으면 로컬 글로 적어 바뀐 것이 없게 · local-first 는 받은 글로 적어 다음 push 가 로컬을 올리게.
26763
+ hashOf: same ? local : rendered.content,
26764
+ ...same ? {} : { differsOnDisk: true }
26765
+ });
26766
+ return { path: filePath, relinked: true };
26767
+ }
26768
+ if (strategy === "remote-first") {
26769
+ await this.vaultFs.writeFile(filePath, rendered.content);
26770
+ await this.recordPulledNote(filePath, note, {
26771
+ content: rendered.content,
26772
+ properties: rendered.properties,
26773
+ status: "synced"
26774
+ });
26775
+ return { path: filePath, relinked: true, written: true };
26776
+ }
26777
+ await this.recordPulledNote(filePath, note, {
26778
+ content: rendered.content,
26779
+ properties: rendered.properties,
26780
+ status: "conflict",
26781
+ snapshot: null,
26782
+ differsOnDisk: true
26783
+ });
26784
+ const record = this.stateDb.getByPath(filePath);
26785
+ const conflict = {
26786
+ syncRecord: record,
26787
+ localChange: {
26788
+ path: filePath,
26789
+ type: "modified",
26790
+ currentHash: computeHash(local),
26791
+ previousHash: record.contentHash
26792
+ },
26793
+ remoteChange: {
26794
+ pageId: note.pageId,
26795
+ type: "modified",
26796
+ lastEdited: note.page.last_edited_time,
26797
+ previousEdited: null,
26798
+ path: filePath,
26799
+ title: note.title
26800
+ },
26801
+ baseContent: null,
26802
+ localContent: local,
26803
+ remoteContent: rendered.content
26804
+ };
26805
+ return { path: filePath, relinked: true, conflict };
26806
+ })();
26807
+ }
26808
+ /**
26809
+ * 받은 노트를 상태 기록 · 위키링크 표에 적는다. 해시는 `hashOf`(기본은 받은 글), 지난 사본은 `snapshot`(기본은 받은 글 —
26810
+ * 충돌로 이은 노트는 공통 조상을 모르므로 null). `differsOnDisk` 는 노트가 적는 해시와 다르다는 뜻이다 — stat 을 적지 않아
26811
+ * 변경 확인이 내용을 읽게 한다. 적으면 stat 이 맞아 바뀐 노트를 읽지 않고 지나쳤다(B5).
26812
+ */
26813
+ async recordPulledNote(filePath, note, input) {
26814
+ const pullStat = input.differsOnDisk ? null : await this.vaultFs.getFileStat(filePath);
25292
26815
  this.stateDb.transaction(() => {
25293
26816
  this.stateDb.upsert({
25294
26817
  obsidianPath: filePath,
25295
- notionPageId: pageId,
25296
- notionParentId: resolvedParentId,
25297
- contentHash: hash,
25298
- ...this.observation.fieldsOf(page, fetched.fingerprint),
26818
+ notionPageId: note.pageId,
26819
+ notionParentId: note.parentId,
26820
+ contentHash: computeHash(input.hashOf ?? input.content),
26821
+ ...this.observation.fieldsOf(note.page, note.fingerprint),
25299
26822
  localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
25300
26823
  syncDirection: "both",
25301
- fileType,
25302
- status: "synced",
25303
- baseSnapshot: Buffer.from(finalContent, "utf-8"),
26824
+ fileType: note.fileType,
26825
+ status: input.status,
26826
+ baseSnapshot: input.snapshot === null ? null : Buffer.from(input.content, "utf-8"),
25304
26827
  localMtime: pullStat?.mtime ?? null,
25305
26828
  localFileSize: pullStat?.size ?? null
25306
26829
  });
25307
- const aliases = extractAliases(properties);
25308
26830
  this.stateDb.upsertWikilink({
25309
26831
  obsidianPath: filePath,
25310
- notionPageId: pageId,
25311
- title,
25312
- aliases
26832
+ notionPageId: note.pageId,
26833
+ title: note.title,
26834
+ aliases: extractAliases(input.properties)
25313
26835
  });
25314
26836
  });
25315
- return { path: filePath };
25316
26837
  }
25317
26838
  /**
25318
26839
  * 자식 말고 본문이 없는 페이지는 폴더로만 받는다 — 노트를 쓰지 않고 폴더 경로를 추적한다(폴더 레코드).
@@ -25323,9 +26844,10 @@ var PagePuller = class {
25323
26844
  */
25324
26845
  async createFolderOnly(pageId, page, fingerprint, parentPath, safeName) {
25325
26846
  const folderPath = stripMarkdownExtension(
25326
- await this.resolveUniqueFilePath(parentPath, safeName, pageId)
26847
+ (await this.resolveUniqueFilePath(parentPath, safeName, pageId)).path
25327
26848
  );
25328
26849
  const resolvedParentId = await extractParentId(this.notionClient, page);
26850
+ const existed = await this.vaultFs.exists(folderPath);
25329
26851
  await this.vaultFs.ensureFolder(folderPath);
25330
26852
  this.stateDb.upsert({
25331
26853
  obsidianPath: folderPath,
@@ -25338,7 +26860,39 @@ var PagePuller = class {
25338
26860
  fileType: "folder-only",
25339
26861
  status: "synced"
25340
26862
  });
25341
- return folderPath;
26863
+ return existed ? { path: folderPath, folderExisted: true } : { path: folderPath };
26864
+ }
26865
+ /**
26866
+ * dry-run 이 알릴, 로컬도 고친 노트를 실제 pull({@link pullUpdate})이 어떻게 처리할지 — 받은 원격과 견주는 판정은
26867
+ * 실제 pull 과 같다. 첨부를 내려받지 않고 렌더하며 상태 DB 를 바꾸지 않는다. 로컬을 고치지 않았거나 · 읽지 못했거나
26868
+ * · 폴더로 추적하는 페이지면 null(받는다).
26869
+ */
26870
+ async previewUpdate(change) {
26871
+ const record = this.stateDb.getByNotionId(change.pageId);
26872
+ if (!record || isFolderRecord(record)) return null;
26873
+ let localContent;
26874
+ try {
26875
+ localContent = await this.vaultFs.readFile(record.obsidianPath);
26876
+ } catch {
26877
+ return null;
26878
+ }
26879
+ if (computeHash(localContent) === record.contentHash) return null;
26880
+ const page = await this.notionClient.getPage(change.pageId);
26881
+ const rendered = await this.renderRemotePage(record, change.pageId, page, {
26882
+ downloadMedia: false
26883
+ });
26884
+ const resolution = resolvePullConflict({
26885
+ record,
26886
+ localContent,
26887
+ remoteContent: rendered.content,
26888
+ remoteUnchanged: await this.drift.unchangedSinceSync(record, page, rendered.bodyFingerprint) || this.drift.interruptedOwnWrite(record, page, localContent),
26889
+ remoteChange: change,
26890
+ strategy: this.config.sync.conflictStrategy
26891
+ });
26892
+ if (resolution.action === "conflict") {
26893
+ return { action: "conflict", conflict: resolution.conflict };
26894
+ }
26895
+ return resolution.action === "write" ? null : { action: resolution.action };
25342
26896
  }
25343
26897
  async pullUpdate(change) {
25344
26898
  let record = this.stateDb.getByNotionId(change.pageId);
@@ -25363,7 +26917,7 @@ var PagePuller = class {
25363
26917
  localContent = "";
25364
26918
  localExists = await this.vaultFs.exists(record.obsidianPath);
25365
26919
  }
25366
- const remoteUnchanged = localExists && await this.drift.unchangedSinceSync(record, page, rendered.bodyFingerprint);
26920
+ const remoteUnchanged = localExists && (await this.drift.unchangedSinceSync(record, page, rendered.bodyFingerprint) || this.drift.interruptedOwnWrite(record, page, localContent));
25367
26921
  const resolution = resolvePullConflict({
25368
26922
  record,
25369
26923
  localContent,
@@ -25384,6 +26938,32 @@ var PagePuller = class {
25384
26938
  this.stateDb.updateStatus(record.id, "conflict");
25385
26939
  return { action: "conflict", path: record.obsidianPath, conflict: resolution.conflict };
25386
26940
  }
26941
+ if (resolution.action === "merge") {
26942
+ await this.vaultFs.writeFile(record.obsidianPath, resolution.merged);
26943
+ this.stateDb.transaction(() => {
26944
+ this.stateDb.upsert({
26945
+ obsidianPath: record.obsidianPath,
26946
+ notionPageId: change.pageId,
26947
+ notionParentId: record.notionParentId,
26948
+ contentHash: computeHash(remoteContent),
26949
+ ...this.observation.fieldsOf(page, rendered.bodyFingerprint),
26950
+ localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
26951
+ syncDirection: record.syncDirection,
26952
+ fileType: record.fileType,
26953
+ status: "synced",
26954
+ baseSnapshot: Buffer.from(remoteContent, "utf-8"),
26955
+ localMtime: null,
26956
+ localFileSize: null
26957
+ });
26958
+ this.stateDb.upsertWikilink({
26959
+ obsidianPath: record.obsidianPath,
26960
+ notionPageId: change.pageId,
26961
+ title: rendered.title,
26962
+ aliases: extractAliases(rendered.properties)
26963
+ });
26964
+ });
26965
+ return { action: "merged", path: record.obsidianPath };
26966
+ }
25387
26967
  if (localExists && sameNoteContent(remoteContent, localContent)) {
25388
26968
  const stat2 = await this.vaultFs.getFileStat(record.obsidianPath);
25389
26969
  this.stateDb.upsert({
@@ -25507,7 +27087,7 @@ var PagePuller = class {
25507
27087
  properties = this.propertyMapper.fromNotionProperties(raw);
25508
27088
  notionKeys = [...this.propertyMapper.ownedKeys(raw), "title", ...DECORATION_KEYS];
25509
27089
  } else {
25510
- properties = this.notionClient.extractProperties(page);
27090
+ properties = { ...this.notionClient.extractProperties(page) };
25511
27091
  }
25512
27092
  if (isDatabaseMode(this.config) || !input.titleFollowsName) {
25513
27093
  properties.title = title;
@@ -25519,7 +27099,8 @@ var PagePuller = class {
25519
27099
  let markdown = await this.imageHandler.restoreUploadedMedia(
25520
27100
  input.fetched.content,
25521
27101
  pageId,
25522
- filePath
27102
+ filePath,
27103
+ input.adoptLocalMedia ? input.local?.content : void 0
25523
27104
  );
25524
27105
  if (input.downloadMedia && this.config.conversion.imageDownload === "immediate") {
25525
27106
  const imageResult = await this.imageHandler.downloadAllImages(markdown, title, pageId);
@@ -25564,8 +27145,11 @@ var PagePuller = class {
25564
27145
  this.discovery.collectInlineDbRefs(pageId, result.markdown);
25565
27146
  this.detector.noteBodyChildren(pageId, result.markdown);
25566
27147
  return {
25567
- content: this.resolveNotionIdWikilinks(
25568
- await notionBodyToObsidian(this.notionClient, result.markdown, pageId)
27148
+ content: await this.resolveBlockLinks(
27149
+ this.resolveNotionIdWikilinks(
27150
+ await notionBodyToObsidian(this.notionClient, result.markdown, pageId)
27151
+ ),
27152
+ pageId
25569
27153
  ),
25570
27154
  // 압축형 판정은 반드시 원시 export 기준 — enhanced 변환이 <empty-block/> 을
25571
27155
  // 빈 줄로 바꾼 뒤에는 BlockSpacer 가 저작형과 구분할 수 없다(D1).
@@ -25584,7 +27168,10 @@ var PagePuller = class {
25584
27168
  this.discovery.collectInlineDbRefs(pageId, fallback.markdown);
25585
27169
  this.detector.noteBodyChildren(pageId, fallback.markdown);
25586
27170
  return {
25587
- content: this.resolveNotionIdWikilinks(fallback.content),
27171
+ content: await this.resolveBlockLinks(
27172
+ this.resolveNotionIdWikilinks(fallback.content),
27173
+ pageId
27174
+ ),
25588
27175
  compact: isCompactExport(fallback.markdown),
25589
27176
  children: extractChildTags(fallback.markdown),
25590
27177
  fingerprint: null,
@@ -25723,15 +27310,38 @@ var PagePuller = class {
25723
27310
  * 파일 1개·레코드 1건). 그래서 마지막 확인과 등록을 **await 없는 동기 구간**에 묶는다 —
25724
27311
  * 그 사이에는 다른 작업이 끼어들 수 없으므로 두 페이지가 같은 경로를 얻는 일이 없다.
25725
27312
  */
25726
- async resolveUniqueFilePath(dir, safeName, pageId) {
27313
+ async resolveUniqueFilePath(dir, safeName, pageId, options) {
25727
27314
  const turn = this.claimTurns.get(normalizeNotionId(pageId));
25728
27315
  await turn?.previous;
25729
27316
  try {
25730
- return await this.claimFilePath(dir, safeName, pageId);
27317
+ if (options?.adopt) {
27318
+ const existing = await this.claimExistingNote(dir, safeName, pageId);
27319
+ if (existing) return existing;
27320
+ }
27321
+ return { path: await this.claimFilePath(dir, safeName, pageId) };
25731
27322
  } finally {
25732
27323
  turn?.release();
25733
27324
  }
25734
27325
  }
27326
+ /**
27327
+ * 상태 기록에 없는 노트가 이미 있는 자리를 고른다 — 이 페이지 id 를 단 이름(지난번 이 페이지를 받은 이름)이 먼저, 그 다음 자연
27328
+ * 이름. 다시 이을지는 받은 글과 견줘 호출측이 정한다({@link relinkExistingNote}) — 잇지 않으면 장부에서 지운다.
27329
+ */
27330
+ async claimExistingNote(dir, safeName, pageId) {
27331
+ const [natural, ...suffixed] = pagePathCandidates(dir, safeName, pageId);
27332
+ const order = [
27333
+ ...suffixed.map((path) => [path, "own"]),
27334
+ [natural, "name"]
27335
+ ];
27336
+ for (const [candidate, existing] of order) {
27337
+ if (this.claimedPaths.has(candidate) || this.stateDb.getByPath(candidate)) continue;
27338
+ if (!await this.vaultFs.exists(candidate)) continue;
27339
+ if (this.claimedPaths.has(candidate)) continue;
27340
+ this.claimedPaths.add(candidate);
27341
+ return { path: candidate, existing };
27342
+ }
27343
+ return null;
27344
+ }
25735
27345
  async claimFilePath(dir, safeName, pageId) {
25736
27346
  const candidates = pagePathCandidates(dir, safeName, pageId);
25737
27347
  for (const candidate of candidates) {
@@ -25762,6 +27372,17 @@ var PagePuller = class {
25762
27372
  (id) => this.stateDb.getByNotionId(normalizeNotionId(id))?.obsidianPath ?? null
25763
27373
  ).markdown;
25764
27374
  }
27375
+ /**
27376
+ * 볼트에 있는 페이지로 가는 블록 링크를 `[[노트#제목]]` 으로 — 그 블록을 읽어 앵커를 안다(anchor-links). 아직 받지
27377
+ * 않은 페이지로 가는 것은 pull 끝의 링크 패스가 푼다.
27378
+ */
27379
+ async resolveBlockLinks(markdown, pageId) {
27380
+ const pathOf = (id) => this.stateDb.getByNotionId(normalizeNotionId(id))?.obsidianPath ?? null;
27381
+ const blockIds = notionBlockLinkIds(markdown, pathOf);
27382
+ if (blockIds.length === 0) return markdown;
27383
+ const anchors = await readBlockAnchors(this.notionClient, blockIds);
27384
+ return resolveNotionBlockLinks(markdown, pathOf, (id) => anchors.get(id) ?? null, pageId).markdown;
27385
+ }
25765
27386
  async ensureDbSchema() {
25766
27387
  if (this.dbSchemaLoaded || !isDatabaseMode(this.config)) return;
25767
27388
  const schema = await this.notionClient.getDatabaseSchema(this.config.notion.databaseId);
@@ -25811,7 +27432,7 @@ function resolveFrontmatterRelations(content, idToTitle) {
25811
27432
  }
25812
27433
 
25813
27434
  // src/sync/notion-link-pass.ts
25814
- async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle) {
27435
+ async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle, blockAnchors) {
25815
27436
  let totalResolved = 0;
25816
27437
  let totalDegraded = 0;
25817
27438
  const titles = /* @__PURE__ */ new Map();
@@ -25846,6 +27467,21 @@ async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle) {
25846
27467
  totalResolved += urlPass.resolved;
25847
27468
  changed = true;
25848
27469
  }
27470
+ const blockIds = blockAnchors ? notionBlockLinkIds(content, notionIdToPath) : [];
27471
+ if (blockIds.length > 0) {
27472
+ const anchors = await blockAnchors(blockIds);
27473
+ const blockPass = resolveNotionBlockLinks(
27474
+ content,
27475
+ notionIdToPath,
27476
+ (id) => anchors.get(id) ?? null,
27477
+ stateDb.getByPath(filePath)?.notionPageId ?? null
27478
+ );
27479
+ content = blockPass.markdown;
27480
+ if (blockPass.resolved > 0) {
27481
+ totalResolved += blockPass.resolved;
27482
+ changed = true;
27483
+ }
27484
+ }
25849
27485
  const fm = resolveFrontmatterRelations(content, idToTitle);
25850
27486
  if (fm.count > 0) {
25851
27487
  content = fm.content;
@@ -25931,19 +27567,38 @@ var PullPlanner = class {
25931
27567
  async planPull(filtered, restoreChanges, restoreIds, localPlan, options, startTime, scan, ledger) {
25932
27568
  const unchangedDropped = await this.drift.withoutUnchangedRemotes(filtered, options.signal);
25933
27569
  const planned = [];
27570
+ const failed = [];
27571
+ const conflicts = [];
27572
+ const mergedPaths = [];
25934
27573
  for (const change of unchangedDropped) {
25935
27574
  if (change.type === "deleted" && !await this.plannedRemoteDeletion(change)) continue;
25936
27575
  if (change.type === "modified" && !await this.plannedFolderPage(change)) continue;
27576
+ if (change.type === "modified" && !restoreIds.has(change.pageId)) {
27577
+ const resolution = await this.previewUpdate(change, failed);
27578
+ if (resolution === void 0) continue;
27579
+ if (resolution?.action === "conflict") {
27580
+ conflicts.push(resolution.conflict);
27581
+ continue;
27582
+ }
27583
+ if (resolution?.action === "skip") continue;
27584
+ if (resolution?.action === "merge") {
27585
+ mergedPaths.push(this.stateDb.getByNotionId(change.pageId).obsidianPath);
27586
+ }
27587
+ }
25937
27588
  planned.push(change);
25938
27589
  }
25939
- const failed = [];
25940
27590
  const queued = new Set(
25941
27591
  [...filtered, ...restoreChanges].map((change) => compactNotionId(change.pageId))
25942
27592
  );
25943
27593
  const movingInVault = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
25944
- const rows = (await this.planDatabaseRows(options.paths, failed, ledger, movingInVault)).filter(
25945
- (row) => !queued.has(compactNotionId(row.pageId))
25946
- );
27594
+ const rows = [];
27595
+ for (const row of await this.planDatabaseRows(options.paths, failed, ledger, movingInVault)) {
27596
+ if (queued.has(compactNotionId(row.pageId))) continue;
27597
+ if (row.resolution?.action === "conflict") conflicts.push(row.resolution.conflict);
27598
+ else if (row.resolution?.action === "merge") mergedPaths.push(row.path);
27599
+ if (row.resolution?.action === "conflict" || row.resolution?.action === "skip") continue;
27600
+ rows.push(row);
27601
+ }
25947
27602
  const items = [];
25948
27603
  const plannedPath = this.planner.plannedPaths(localPlan);
25949
27604
  for (const change of [...planned, ...restoreChanges]) {
@@ -25977,7 +27632,8 @@ var PullPlanner = class {
25977
27632
  restored: restoreChanges.length + restoredModified + rowCount("update", true),
25978
27633
  ...restoredPaths.length > 0 ? { restoredPaths } : {},
25979
27634
  ...movedRows.length > 0 ? { moved: movedRows.flatMap((row) => row.moves) } : {},
25980
- conflicts: [],
27635
+ conflicts,
27636
+ ...mergedPaths.length > 0 ? { mergedPaths } : {},
25981
27637
  writtenPaths: [],
25982
27638
  failed,
25983
27639
  duration: Date.now() - startTime,
@@ -26007,6 +27663,22 @@ var PullPlanner = class {
26007
27663
  return true;
26008
27664
  }
26009
27665
  }
27666
+ /**
27667
+ * 로컬도 고친 노트를 실제 pull 이 어떻게 처리할지({@link PagePuller.previewUpdate}). 원격을 읽지 못했으면 실제
27668
+ * pull 도 그 노트에서 실패한다 — 실패로 싣고 undefined(세지 않는다).
27669
+ */
27670
+ async previewUpdate(change, failed) {
27671
+ try {
27672
+ return await this.puller.previewUpdate(change);
27673
+ } catch (error) {
27674
+ failed.push({
27675
+ path: this.stateDb.getByNotionId(change.pageId)?.obsidianPath ?? change.pageId,
27676
+ operation: "update",
27677
+ error: error instanceof Error ? error.message : String(error)
27678
+ });
27679
+ return void 0;
27680
+ }
27681
+ }
26010
27682
  /** 원격에서 지운 페이지를 실제 pull 이 볼트에서 지우는가 — `PagePuller.pullDelete` 와 같게 가른다. */
26011
27683
  async plannedRemoteDeletion(change) {
26012
27684
  const record = this.stateDb.getByNotionId(change.pageId);
@@ -26731,7 +28403,7 @@ async function detectMissingLocalFiles(config, stateDb, vaultFs, paths) {
26731
28403
  }
26732
28404
  var LAST_SYNC_META_KEY = "last_sync_at";
26733
28405
  var SyncOrchestrator = class {
26734
- constructor(config, stateDb, notionClient, vaultFs, customFetch) {
28406
+ constructor(config, stateDb, notionClient, vaultFs, customFetch, outside) {
26735
28407
  this.config = config;
26736
28408
  this.stateDb = stateDb;
26737
28409
  this.notionClient = notionClient;
@@ -26864,7 +28536,7 @@ var SyncOrchestrator = class {
26864
28536
  async (pageId) => this.detector.discoveredPage(pageId) ?? await notionClient.getPage(pageId),
26865
28537
  config.notion.databases ?? []
26866
28538
  );
26867
- this.linkUpdater = new LinkUpdater(stateDb, vaultFs);
28539
+ this.linkUpdater = new LinkUpdater(stateDb, vaultFs, outside);
26868
28540
  this.puller = new PagePuller(
26869
28541
  config,
26870
28542
  stateDb,
@@ -26980,7 +28652,15 @@ var SyncOrchestrator = class {
26980
28652
  const foreign = this.stateDb.getAll().map((record) => record.obsidianPath).filter((path) => !vaultFs.ownsPath(path));
26981
28653
  if (foreign.length > 0) throw new ConnectionScopeError(foreign);
26982
28654
  }
26983
- async executePush(options) {
28655
+ /**
28656
+ * @param mergedByPull sync dry-run 만 — 앞선 pull dry-run 이 합칠 노트. 실제 sync 는 합친 뒤 원격을 본 것으로
28657
+ * 적어 push 가 올린다 — dry-run 은 받지 않아 원격이 바뀐 그대로라, 원격을 보고 거절하지 않는다.
28658
+ */
28659
+ /**
28660
+ * @param relinkedByPull 같은 sync 의 dry-run pull 이 다시 이을 것이라고 미리 봤다 — 실제 sync 는 받으며 먼저 잇고 올리므로
28661
+ * 상태 기록이 비었어도 막지 않는다({@link assertVaultLinked}).
28662
+ */
28663
+ async executePush(options, mergedByPull = /* @__PURE__ */ new Set(), relinkedByPull = false) {
26984
28664
  const startTime = Date.now();
26985
28665
  this.assertConnectionScope();
26986
28666
  if (this.config.sync.direction === "pull") {
@@ -27011,13 +28691,25 @@ var SyncOrchestrator = class {
27011
28691
  const conflictPaths = new Set(this.stateDb.getByStatus("conflict").map((r) => r.obsidianPath));
27012
28692
  const eligible = options?.force ? changes : changes.filter((c) => !conflictPaths.has(c.path));
27013
28693
  const excludeSet = options?.excludePaths ? new Set(options.excludePaths) : null;
27014
- const filtered = (options?.paths ? eligible.filter((c) => inAnyPathScope(c.path, options.paths)) : eligible).filter((c) => !excludeSet || !excludeSet.has(c.path));
28694
+ const scoped = (options?.paths ? eligible.filter((c) => inAnyPathScope(c.path, options.paths)) : eligible).filter((c) => !excludeSet || !excludeSet.has(c.path));
28695
+ const unverified = relinkedByPull ? /* @__PURE__ */ new Map() : await assertVaultLinked(
28696
+ this.config,
28697
+ this.stateDb,
28698
+ this.notionClient,
28699
+ scoped.filter((c) => c.type === "created").map((c) => c.path)
28700
+ );
28701
+ const filtered = scoped.filter((c) => !unverified.has(c.path));
28702
+ const unverifiedFailures = [...unverified].map(([path, error]) => ({
28703
+ path,
28704
+ operation: "create",
28705
+ error
28706
+ }));
27015
28707
  const folderMoves = this.planner.pendingFolderMoves(plan).filter((move) => inAnyPathScope(move.to, options?.paths));
27016
28708
  const inScope = (path) => inAnyPathScope(path, options?.paths) && !excludeSet?.has(path);
27017
28709
  const sidecarPlan = await this.sidecarEdits.plan(inScope);
27018
28710
  const plannedEdits = sidecarPlan.planned.map((edit) => edit.edit);
27019
28711
  const viewPlan = await this.viewEdits.plan(inScope);
27020
- const plannedViews = viewPlan.planned.flatMap((file) => file.views.map((view2) => view2.edit));
28712
+ const plannedViews = viewPlan.planned.flatMap(plannedViewEdits);
27021
28713
  const preparedDatabases = loadPreparedDatabases(this.stateDb).size > 0;
27022
28714
  const recordedLookup = this.placement.folderLookup();
27023
28715
  const databaseMoves = mergeDatabaseMoves(
@@ -27043,7 +28735,13 @@ var SyncOrchestrator = class {
27043
28735
  updated: 0,
27044
28736
  deleted: 0,
27045
28737
  moved: 0,
27046
- failed: [...sidecarPlan.refused, ...edited2.failed, ...viewPlan.refused, ...viewed2.failed],
28738
+ failed: [
28739
+ ...unverifiedFailures,
28740
+ ...sidecarPlan.refused,
28741
+ ...edited2.failed,
28742
+ ...viewPlan.refused,
28743
+ ...viewed2.failed
28744
+ ],
27047
28745
  duration: Date.now() - startTime,
27048
28746
  ...edited2.applied.length > 0 ? { schemaEdits: edited2.applied } : {},
27049
28747
  ...viewed2.applied.length > 0 ? { viewEdits: viewed2.applied } : {}
@@ -27076,7 +28774,12 @@ var SyncOrchestrator = class {
27076
28774
  const applicable = placeable2.filter((c) => !renameRefused.has(c.path));
27077
28775
  const plannedRenames = renames.planned.map(({ folder, from, to }) => ({ folder, from, to }));
27078
28776
  if (options?.dryRun) {
27079
- const planned = this.config.sync.deleteSync ? applicable : applicable.filter((c) => c.type !== "deleted");
28777
+ await this.observation.resolveBotUserId();
28778
+ const remoteRefusals = await this.pusher.previewRemoteRefusals(
28779
+ applicable.filter((change) => !mergedByPull.has(change.path))
28780
+ );
28781
+ const remoteRefused = new Set(remoteRefusals.map((failure) => failure.path));
28782
+ const planned = (this.config.sync.deleteSync ? applicable : applicable.filter((c) => c.type !== "deleted")).filter((change) => !remoteRefused.has(change.path));
27080
28783
  const items = [
27081
28784
  ...movableFolders.map(({ to }) => ({ path: to, operation: "move" })),
27082
28785
  ...planned.map((change) => ({ path: change.path, operation: pushOperationOf(change) }))
@@ -27107,7 +28810,12 @@ var SyncOrchestrator = class {
27107
28810
  updated: count("update") - convertedUpdates,
27108
28811
  deleted: count("delete"),
27109
28812
  moved: count("move") + preparedConversions.length + movableDatabases.length,
27110
- failed: [...refusedFailures, ...refusedDatabaseMoves],
28813
+ failed: [
28814
+ ...unverifiedFailures,
28815
+ ...refusedFailures,
28816
+ ...refusedDatabaseMoves,
28817
+ ...remoteRefusals
28818
+ ],
27111
28819
  duration: Date.now() - startTime,
27112
28820
  ...unsentDeletions2.length > 0 ? { unsentDeletions: unsentDeletions2 } : {},
27113
28821
  ...readOnlySkipped.length > 0 ? { readOnlySkipped } : {},
@@ -27121,7 +28829,7 @@ var SyncOrchestrator = class {
27121
28829
  this.stateDb.setMeta(PUSH_IN_PROGRESS_META_KEY, "true");
27122
28830
  await this.observation.resolveBotUserId();
27123
28831
  const counts = { created: 0, updated: 0, deleted: 0, moved: 0 };
27124
- const failed = [...refusedFailures];
28832
+ const failed = [...unverifiedFailures, ...refusedFailures];
27125
28833
  const edited = await this.sidecarEdits.apply(sidecarPlan.planned);
27126
28834
  failed.push(...edited.failed);
27127
28835
  const viewed = await this.viewEdits.apply(viewPlan.planned);
@@ -27416,8 +29124,10 @@ var SyncOrchestrator = class {
27416
29124
  const startTime = Date.now();
27417
29125
  this.assertConnectionScope();
27418
29126
  this.puller.beginPull();
29127
+ this.databaseSyncer.beginPull(this.puller.unlinkedVaultAtStart);
27419
29128
  this.rowSchemas.clear();
27420
29129
  this.databaseSyncer.takeRestoredRows();
29130
+ this.databaseSyncer.takeMergedRows();
27421
29131
  this.databaseSyncer.takeSchemaFollowed();
27422
29132
  this.databaseSyncer.takeHeldSidecars();
27423
29133
  this.databaseSyncer.takeHeldBases();
@@ -27457,6 +29167,8 @@ var SyncOrchestrator = class {
27457
29167
  const conflicts = [];
27458
29168
  const writtenPaths = [];
27459
29169
  const restoredPaths = [];
29170
+ const mergedPaths = [];
29171
+ const relinkedPaths = [];
27460
29172
  const excluded = [];
27461
29173
  const failed = [];
27462
29174
  let detected = [];
@@ -27503,10 +29215,15 @@ var SyncOrchestrator = class {
27503
29215
  await this.puller.removeVacatedFolders();
27504
29216
  const renamedRows = this.databaseSyncer.takeRenamedRows();
27505
29217
  restoredPaths.push(...this.databaseSyncer.takeRestoredRows());
29218
+ mergedPaths.push(...this.databaseSyncer.takeMergedRows());
29219
+ relinkedPaths.push(...this.databaseSyncer.takeRelinkedRows());
27506
29220
  const schemaFollowed = this.databaseSyncer.takeSchemaFollowed();
27507
29221
  const heldSidecars = this.databaseSyncer.takeHeldSidecars();
27508
29222
  const heldBases = this.databaseSyncer.takeHeldBases();
27509
- const relinked = await this.linkUpdater.apply([...relocated.applied, ...renamedRows.applied]);
29223
+ const relinked = [
29224
+ ...await this.linkUpdater.apply([...relocated.applied, ...renamedRows.applied]),
29225
+ ...await this.linkUpdater.dropEmbeds(this.databaseSyncer.takeRetiredBases())
29226
+ ];
27510
29227
  const linkCount = writtenPaths.length > 0 ? await this.resolveNotionLinks(writtenPaths) : 0;
27511
29228
  if (!options?.paths) {
27512
29229
  const startedAt = new Date(startTime).toISOString();
@@ -27531,6 +29248,8 @@ var SyncOrchestrator = class {
27531
29248
  deleted,
27532
29249
  restored,
27533
29250
  ...restoredPaths.length > 0 ? { restoredPaths } : {},
29251
+ ...mergedPaths.length > 0 ? { mergedPaths } : {},
29252
+ ...relinkedPaths.length > 0 ? { relinkedPaths } : {},
27534
29253
  ...schemaFollowed.length > 0 ? { schemaFollowed } : {},
27535
29254
  ...heldSidecars.length > 0 ? { heldSidecars } : {},
27536
29255
  ...heldBases.length > 0 ? { heldBases } : {},
@@ -27603,7 +29322,8 @@ var SyncOrchestrator = class {
27603
29322
  },
27604
29323
  moved: [...relocated.moved, ...plan.moved ?? []],
27605
29324
  moveSkipped: relocated.skipped,
27606
- excluded: [...exclusions.values()]
29325
+ excluded: [...exclusions.values()],
29326
+ ...this.puller.unlinkedVaultAtStart && plan.created > 0 ? { relinkPending: true } : {}
27607
29327
  };
27608
29328
  }
27609
29329
  let pullTotal = workItems.length;
@@ -27685,6 +29405,14 @@ var SyncOrchestrator = class {
27685
29405
  excluded.push(created.path);
27686
29406
  return { path: created.path, unchanged: true };
27687
29407
  }
29408
+ if (created.folderExisted) return { path: created.path, unchanged: true };
29409
+ if (created.relinked) {
29410
+ relinkedPaths.push(created.path);
29411
+ if (created.conflict) conflicts.push(created.conflict);
29412
+ if (!created.written) return { path: created.path, unchanged: true };
29413
+ writtenPaths.push(created.path);
29414
+ return { path: created.path, unchanged: false };
29415
+ }
27688
29416
  writtenPaths.push(created.path);
27689
29417
  counts.created++;
27690
29418
  return { path: created.path, unchanged: false };
@@ -27693,6 +29421,9 @@ var SyncOrchestrator = class {
27693
29421
  const outcome = await this.puller.pullUpdate(change);
27694
29422
  if (outcome.action === "conflict") {
27695
29423
  conflicts.push(outcome.conflict);
29424
+ } else if (outcome.action === "merged") {
29425
+ counts.updated++;
29426
+ mergedPaths.push(outcome.path);
27696
29427
  } else if (outcome.action === "written") {
27697
29428
  writtenPaths.push(outcome.path);
27698
29429
  if (restoreIds.has(change.pageId)) {
@@ -27803,12 +29534,17 @@ var SyncOrchestrator = class {
27803
29534
  const onProgress = options?.onProgress;
27804
29535
  const tagged = (direction) => onProgress && ((current, total, item) => onProgress(current, total, { ...item, direction }));
27805
29536
  const pullResult = await this.executePull({ ...options, onProgress: tagged("pull") });
27806
- const pushResult = await this.executePush({
27807
- ...options,
27808
- onProgress: tagged("push"),
27809
- paths: options?.paths,
27810
- excludePaths: pullResult.writtenPaths
27811
- });
29537
+ const conflicted = options?.dryRun ? pullResult.conflicts.map((conflict) => conflict.syncRecord.obsidianPath) : [];
29538
+ const pushResult = await this.executePush(
29539
+ {
29540
+ ...options,
29541
+ onProgress: tagged("push"),
29542
+ paths: options?.paths,
29543
+ excludePaths: [...pullResult.writtenPaths, ...conflicted]
29544
+ },
29545
+ new Set(options?.dryRun ? pullResult.mergedPaths ?? [] : []),
29546
+ pullResult.relinkPending === true
29547
+ );
27812
29548
  return {
27813
29549
  pull: pullResult,
27814
29550
  push: pushResult,
@@ -27836,6 +29572,7 @@ var SyncOrchestrator = class {
27836
29572
  const detected = await this.drift.withoutUnchangedRemotes(detection.changes, signal);
27837
29573
  const moves = await this.followRemoteMoves({ dryRun: true }, plan);
27838
29574
  const renames = await this.followRemoteRenames(detected, { dryRun: true });
29575
+ const rowRenames = await this.previewRowRenames(detected, signal);
27839
29576
  const exclusions = await this.plannedExclusions(detected);
27840
29577
  const remoteChanges = detected.filter((change) => !exclusions.has(change.pageId));
27841
29578
  const conflictRecords = this.stateDb.getByStatus("conflict");
@@ -27856,11 +29593,45 @@ var SyncOrchestrator = class {
27856
29593
  lastSyncAt,
27857
29594
  lastFullScanAt,
27858
29595
  remoteScan: remoteScanInfo(scan, lastFullScanAt, this.config.sync.deleteSync),
27859
- remoteMoves: [...moves.moved, ...renames.renamed],
27860
- remoteMoveSkipped: [...moves.skipped, ...renames.skipped],
29596
+ remoteMoves: [...moves.moved, ...renames.renamed, ...rowRenames.renamed],
29597
+ remoteMoveSkipped: [...moves.skipped, ...renames.skipped, ...rowRenames.skipped],
27861
29598
  remoteExcluded: [...exclusions.values()]
27862
29599
  };
27863
29600
  }
29601
+ /**
29602
+ * 다음 pull 이 Notion 에서 바꾼 제목으로 이름을 바꿀 행 — 바꾸지 않고 본다. 행 이름은 DB 받기가 바꾸므로
29603
+ * 페이지 이름 바꾸기({@link followRemoteRenames})에 없다. 판정은 받기와 같다(`retitledRowTarget`).
29604
+ */
29605
+ async previewRowRenames(changes, signal) {
29606
+ const renamed = [];
29607
+ const skipped = [];
29608
+ if (!this.config.sync.renameOnTitleChange) return { renamed, skipped };
29609
+ const folders = /* @__PURE__ */ new Map();
29610
+ for (const db of [
29611
+ ...this.config.notion.databases ?? [],
29612
+ ...parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY))
29613
+ ]) {
29614
+ if (db.databaseId) folders.set(compactNotionId(db.databaseId), db.localFolder);
29615
+ }
29616
+ for (const change of changes) {
29617
+ throwIfAborted(signal);
29618
+ if (change.type !== "modified") continue;
29619
+ const record = this.stateDb.getByNotionId(change.pageId);
29620
+ if (record?.fileType !== "db-row" || !record.notionParentId) continue;
29621
+ if (this.vaultFs.excludesPath?.(record.obsidianPath)) continue;
29622
+ const folder = folders.get(compactNotionId(record.notionParentId));
29623
+ if (folder === void 0) continue;
29624
+ try {
29625
+ const page = this.detector.discoveredPage(change.pageId) ?? await this.notionClient.getPage(change.pageId);
29626
+ const target = await this.databaseSyncer.retitledRowTarget(folder, record, page);
29627
+ if (target === null) continue;
29628
+ if ("reason" in target) skipped.push({ path: record.obsidianPath, reason: target.reason });
29629
+ else renamed.push({ from: record.obsidianPath, to: target.to, renamed: true });
29630
+ } catch {
29631
+ }
29632
+ }
29633
+ return { renamed, skipped };
29634
+ }
27864
29635
  /**
27865
29636
  * 로컬만 본다 — 원격 기준을 정하지 않아 도는 작업과 겹쳐도 된다. 변경은 push 가 쓰는 판정 그대로다
27866
29637
  * (옮긴 노트 · 폴더 포함). 상태 DB 는 바꾸지 않는다.
@@ -27953,7 +29724,8 @@ var SyncOrchestrator = class {
27953
29724
  this.stateDb,
27954
29725
  this.vaultFs,
27955
29726
  paths,
27956
- async (pageId) => this.notionClient.extractTitle(await this.notionClient.getPage(pageId))
29727
+ async (pageId) => this.notionClient.extractTitle(await this.notionClient.getPage(pageId)),
29728
+ (blockIds) => readBlockAnchors(this.notionClient, blockIds)
27957
29729
  );
27958
29730
  }
27959
29731
  // ──────────────────────────────────────────────────────────────────────────
@@ -28008,6 +29780,19 @@ var SyncOrchestrator = class {
28008
29780
  async discardLocalChange(path) {
28009
29781
  return this.gate.run("discard", () => this.inspector.discardLocalChange(path));
28010
29782
  }
29783
+ /**
29784
+ * 동기화가 옮긴 폴더를 가리키는 사용자 `.base` 의 폴더 필터 — 말없이 고치지 않고 고칠지 묻는다. 없으면 빈 목록.
29785
+ */
29786
+ userBaseFilterFixes() {
29787
+ return pendingUserBaseFilterFixes(this.stateDb);
29788
+ }
29789
+ /** {@link userBaseFilterFixes} 를 물은 답대로 — 고치면 고친 `.base` 경로. 어느 쪽이든 다시 묻지 않는다. */
29790
+ async resolveUserBaseFilterFixes(accept) {
29791
+ return this.gate.run(
29792
+ "base-filters",
29793
+ () => resolveUserBaseFilterFixes(this.vaultFs, this.stateDb, accept)
29794
+ );
29795
+ }
28011
29796
  /**
28012
29797
  * 볼트에서 만든 노트 폴더를 Notion DB 로 올릴 준비 — frontmatter · `.base` 로 사이드카를 써 둔다(#75).
28013
29798
  * Notion 에는 쓰지 않는다. 다음 push 가 DB 를 만들고 노트를 행으로 올린다(ADR-036 · ADR-037).
@@ -28139,10 +29924,14 @@ function matchesWithin(path, pattern) {
28139
29924
 
28140
29925
  // src/sync/node-vault-fs.ts
28141
29926
  var NodeVaultFS = class {
28142
- constructor(rootPath, pathConfig) {
29927
+ /**
29928
+ * @param ignoreFile 폴더의 `.im-nobsidian-ignore` 를 따른다 — 볼트 전체를 보는 쪽(다른 연결의 노트에서 링크를 고칠 때)은
29929
+ * 그 폴더를 맡은 연결의 제외 목록을 따르지 않는다.
29930
+ */
29931
+ constructor(rootPath, pathConfig, ignoreFile = true) {
28143
29932
  this.rootPath = rootPath;
28144
29933
  const configExclude = pathConfig?.exclude ?? [];
28145
- const ignorePatterns = this.loadImNobsidianIgnore();
29934
+ const ignorePatterns = ignoreFile ? this.loadImNobsidianIgnore() : [];
28146
29935
  this.filter = new PathFilter(pathConfig?.include ?? [], [...configExclude, ...ignorePatterns]);
28147
29936
  }
28148
29937
  rootPath;
@@ -29381,6 +31170,6 @@ function formatRatio(ratio) {
29381
31170
  return String(Math.round(ratio * 100) / 100);
29382
31171
  }
29383
31172
 
29384
- 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 };
31173
+ export { BaseFileGenerator, BlankLineCollapser, CONFIG_FILE, CONFLICT_STRATEGIES, 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, NotionClient2 as 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, UnlinkedVaultError, 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 };
29385
31174
  //# sourceMappingURL=index.js.map
29386
31175
  //# sourceMappingURL=index.js.map