@im-nobsidian/core 0.14.1 → 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
@@ -172,6 +172,15 @@ function withNotifications(args, mode) {
172
172
  return { ...args, body: { ...args.body, notifications: { mode } } };
173
173
  }
174
174
 
175
+ // src/types/sync.ts
176
+ var CONFLICT_STRATEGIES = [
177
+ "local-first",
178
+ "remote-first",
179
+ "manual",
180
+ "duplicate",
181
+ "merge"
182
+ ];
183
+
175
184
  // src/types/config.ts
176
185
  var NOTION_TOKEN_PREFIX = "ntn_";
177
186
  var DEFAULT_TITLE_PROPERTY = "Name";
@@ -246,7 +255,7 @@ var ConfigSchema = z.object({
246
255
  }),
247
256
  sync: z.object({
248
257
  direction: z.enum(["push", "pull", "both"]).default(D.sync.direction),
249
- conflictStrategy: z.enum(["local-first", "remote-first", "manual", "duplicate"]).default(D.sync.conflictStrategy),
258
+ conflictStrategy: z.enum(CONFLICT_STRATEGIES).default(D.sync.conflictStrategy),
250
259
  autoSync: z.boolean().default(D.sync.autoSync),
251
260
  autoSyncInterval: z.number().min(30).max(3600).default(D.sync.autoSyncInterval),
252
261
  // 전체 대조 주기(초) — 그 사이의 pull 은 바뀐 페이지 · 행 · DB 만 받는다. 원격 삭제는 전체 대조가
@@ -3274,7 +3283,8 @@ var NotionClient2 = class _NotionClient {
3274
3283
  cover: db.cover ?? null,
3275
3284
  description: Array.isArray(db.description) ? db.description : [],
3276
3285
  showTitle: db.show_data_source_title !== false
3277
- }
3286
+ },
3287
+ ...db.in_trash === true || db.archived === true ? { inTrash: true } : {}
3278
3288
  };
3279
3289
  return { meta, owners };
3280
3290
  }
@@ -4943,8 +4953,72 @@ function pairLocalMoves(missing, untracked, hints) {
4943
4953
  paired.add(note.path);
4944
4954
  pairs.push({ from: note.path, to: file.path });
4945
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
+ );
4946
4963
  return pairs;
4947
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
+ }
4948
5022
  function hintedFolderTarget(hints, folder) {
4949
5023
  let best = null;
4950
5024
  let ambiguous = false;
@@ -5042,10 +5116,16 @@ var ChangeDetector = class {
5042
5116
  for (const file of stats) {
5043
5117
  const record = this.stateDb.getByPath(file.path);
5044
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);
5045
5124
  observed.push({
5046
5125
  path: file.path,
5047
5126
  record,
5048
- hash: unchanged ? null : computeHash(await readFile3(file.path))
5127
+ hash: computeHash(text),
5128
+ ...record === null ? { text } : {}
5049
5129
  });
5050
5130
  }
5051
5131
  return this.assemble(observed, moveOrigins, options);
@@ -5055,7 +5135,12 @@ var ChangeDetector = class {
5055
5135
  const observed = currentFiles.map((file) => {
5056
5136
  const record = this.stateDb.getByPath(file.path);
5057
5137
  const unchanged = hasRemotePage(record) && !this.movePending(record, moveOrigins) && Boolean(record.localMtime) && record.localMtime === file.mtime;
5058
- 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
+ };
5059
5144
  });
5060
5145
  return this.assemble(observed, moveOrigins, options);
5061
5146
  }
@@ -5073,7 +5158,13 @@ var ChangeDetector = class {
5073
5158
  const { record, hash } = file;
5074
5159
  if (!hasRemotePage(record)) {
5075
5160
  if (record === null) {
5076
- 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
+ }
5077
5168
  } else
5078
5169
  changes.push({
5079
5170
  path: file.path,
@@ -5110,7 +5201,8 @@ var ChangeDetector = class {
5110
5201
  missing.map((record) => ({
5111
5202
  path: record.obsidianPath,
5112
5203
  hash: record.contentHash,
5113
- origin: moveOrigins.get(record.id)
5204
+ origin: moveOrigins.get(record.id),
5205
+ ...record.baseSnapshot ? { text: record.baseSnapshot.toString("utf-8") } : {}
5114
5206
  })),
5115
5207
  untracked,
5116
5208
  options?.hints ?? EMPTY_RENAME_HINTS
@@ -5602,6 +5694,69 @@ function decodeMarkerTarget(encoded) {
5602
5694
  var MARKER_URL_CAPTURE = "((?:[^()\\s]|\\([^()]*\\))+)";
5603
5695
  var MARKER_LABEL_CAPTURE = "([^[\\]]+)";
5604
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
+
5605
5760
  // src/converter/pre-processors/wikilink.ts
5606
5761
  var WIKILINK_REGEX2 = /(?<!!)\[\[([^[\]|]+)(?:\|([^[\]]+))?\]\]/g;
5607
5762
  function withoutTableAliasEscape(target) {
@@ -5626,6 +5781,8 @@ var WikilinkResolver = class {
5626
5781
  (_match, written, display, offset) => {
5627
5782
  const target = display === void 0 ? written : withoutTableAliasEscape(written);
5628
5783
  const label = display ?? target;
5784
+ const anchored = this.anchorLink(target, display, input.context.anchors);
5785
+ if (anchored) return anchored;
5629
5786
  if (this.resolve) {
5630
5787
  const entry = this.resolve(target);
5631
5788
  if (entry) {
@@ -5651,6 +5808,19 @@ var WikilinkResolver = class {
5651
5808
  metadata: { ...input.metadata, preserveMarkers }
5652
5809
  };
5653
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
+ }
5654
5824
  };
5655
5825
 
5656
5826
  // src/converter/callout-types.ts
@@ -12818,6 +12988,21 @@ function collectLocalMarkerBasenames(markdown) {
12818
12988
  function baseName(path) {
12819
12989
  return path.split("/").pop() ?? path;
12820
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
+ }
12821
13006
  function folderOf(notePath) {
12822
13007
  return notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
12823
13008
  }
@@ -12953,8 +13138,9 @@ var ImageHandler = class {
12953
13138
  *
12954
13139
  * @param pageId 본문을 받은 페이지 — 그 페이지에 올린 것만 되찾는다
12955
13140
  * @param notePath 노트 경로 — 옛 캡션의 파일을 push 와 같은 사다리로 찾는다
13141
+ * @param adoptFrom 상태 기록에 없던 같은 자리의 노트 글(P7 다시 잇기) — {@link adoptLocalEmbed}
12956
13142
  */
12957
- async restoreUploadedMedia(markdown, pageId, notePath) {
13143
+ async restoreUploadedMedia(markdown, pageId, notePath, adoptFrom) {
12958
13144
  const spans = notionMediaSpans(markdown);
12959
13145
  if (spans.length === 0) return markdown;
12960
13146
  const known = pageId ? recallUploadedMedia(this.stateDb, pageId) : /* @__PURE__ */ new Map();
@@ -12962,11 +13148,51 @@ var ImageHandler = class {
12962
13148
  let result = markdown;
12963
13149
  for (const span of spans) {
12964
13150
  const uploadedName = this.uploadedNameOf(span.url);
12965
- 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);
12966
13152
  if (target) result = result.replace(span.full, () => `![[${target}]]`);
12967
13153
  }
12968
13154
  return result;
12969
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
+ }
12970
13196
  /** 적어 둔 업로드면 되살릴 임베드 대상. 볼트에서 그 파일이 사라졌으면 null — 사본을 받는다. */
12971
13197
  async knownEmbedTarget(known, span, uploadedName) {
12972
13198
  const fileId = notionFileIdOf(span.url);
@@ -14785,6 +15011,136 @@ async function readLocalNote(vaultFs, path) {
14785
15011
  }
14786
15012
  }
14787
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
+
14788
15144
  // src/sync/conflict-detector.ts
14789
15145
  function sameNoteContent(a, b) {
14790
15146
  return a === b || a.replace(/\n+$/, "") === b.replace(/\n+$/, "");
@@ -14812,6 +15168,10 @@ function resolvePullConflict(input) {
14812
15168
  if (strategy === "local-first") {
14813
15169
  return { action: "skip", localHash };
14814
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
+ }
14815
15175
  const conflict = {
14816
15176
  syncRecord: record,
14817
15177
  localChange: {
@@ -14828,49 +15188,211 @@ function resolvePullConflict(input) {
14828
15188
  return { action: "conflict", conflict, localHash };
14829
15189
  }
14830
15190
 
14831
- // src/sync/remote-deletion.ts
14832
- async function remotePresence(client, pageId) {
14833
- let page;
14834
- try {
14835
- page = await client.getPage(pageId);
14836
- } catch (error) {
14837
- if (isNotionObjectNotFound(error)) return { kind: "gone" };
14838
- throw error;
14839
- }
14840
- return isTrashedOrArchived(page) ? { kind: "gone" } : { kind: "alive", page };
14841
- }
14842
- async function applyRemoteDeletion(stateDb, vaultFs, record, options) {
14843
- const decision = await decideRemoteDeletion(vaultFs, record, options);
14844
- if (decision.action === "kept") {
14845
- untrack(stateDb, record);
14846
- getLogger().info(
14847
- `[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)`
14848
- );
14849
- return { action: "kept" };
14850
- }
14851
- if (decision.action === "conflict") {
14852
- stateDb.updateStatus(record.id, "conflict");
14853
- return {
14854
- action: "conflict",
14855
- conflict: remoteDeletionConflict(record, decision.local, options.remoteChange)
14856
- };
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;
14857
15209
  }
14858
- await removeTrackedNote(stateDb, vaultFs, record, options.deleteFile);
14859
- return { action: "deleted" };
14860
- }
14861
- async function decideRemoteDeletion(vaultFs, record, options) {
14862
- const local = options.deleteFile ? await unsyncedLocalEdit(vaultFs, record) : null;
14863
- if (local === null || options.strategy === "remote-first") return { action: "deleted" };
14864
- 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+$/, ""));
14865
15212
  }
14866
- async function removeTrackedNote(stateDb, vaultFs, record, deleteFile) {
14867
- if (deleteFile) {
14868
- try {
14869
- await vaultFs.deleteFile(record.obsidianPath);
14870
- } catch {
14871
- }
14872
- }
14873
- 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);
14874
15396
  }
14875
15397
  function untrack(stateDb, record) {
14876
15398
  stateDb.transaction(() => {
@@ -15090,6 +15612,12 @@ function isDirectDbRowPath(localFolder, path) {
15090
15612
  const rel = path.slice(prefix.length);
15091
15613
  return rel.length > 0 && !rel.includes("/");
15092
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
+ }
15093
15621
 
15094
15622
  // src/sync/discovered-databases.ts
15095
15623
  var ROOT_DATABASES_FOLDER = "databases";
@@ -15706,6 +16234,25 @@ var DbBaseFiles = class {
15706
16234
  this.loadedRaw = JSON.stringify(Object.fromEntries(entries));
15707
16235
  this.stateDb.setMeta(DB_BASE_FILES_META_KEY, this.loadedRaw);
15708
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
+ }
15709
16256
  /** 상태 메타에서 읽는다 — 값이 그대로면 읽어 둔 것을 쓴다. 없거나 깨졌으면 비어 있다 — 이번 실행이 기록하는 것부터 다시 쌓는다. */
15710
16257
  load() {
15711
16258
  const raw = this.stateDb.getMeta(DB_BASE_FILES_META_KEY);
@@ -15726,7 +16273,9 @@ var DbBaseFiles = class {
15726
16273
  title: entry.title,
15727
16274
  ...typeof entry.hash === "string" ? { hash: entry.hash } : {},
15728
16275
  ...Array.isArray(entry.views) ? { views: entry.views } : {},
15729
- ...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 } : {}
15730
16279
  })
15731
16280
  );
15732
16281
  }
@@ -15742,7 +16291,9 @@ function normalize(info) {
15742
16291
  title: info.title,
15743
16292
  ...info.hash ? { hash: info.hash } : {},
15744
16293
  ...info.views && info.views.length > 0 ? { views: info.views } : {},
15745
- ...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 } : {}
15746
16297
  };
15747
16298
  }
15748
16299
 
@@ -15808,8 +16359,22 @@ function matchViews(records, views) {
15808
16359
  });
15809
16360
  return out;
15810
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
+ }
15811
16377
  function restSignature(parsed, matched) {
15812
- const without = (from, keys) => Object.fromEntries(Object.entries(from).filter(([key]) => !keys.includes(key)));
15813
16378
  const views = {};
15814
16379
  parsed.views.forEach((view, i) => {
15815
16380
  const key = matched[i] ? `id:${matched[i].viewId}` : `new:${String(view["name"])}`;
@@ -15841,10 +16406,11 @@ function recordBaseViews(content, written, views) {
15841
16406
  type: String(parsed.views[i]["type"]),
15842
16407
  ...pushableOf(parsed.views[i]),
15843
16408
  ...source.lossyFilter ? { lossyFilter: true } : {},
15844
- notion: viewFingerprint(view)
16409
+ notion: viewFingerprint(view),
16410
+ rest: viewRestOf(parsed.views[i])
15845
16411
  });
15846
16412
  }
15847
- return { views: records, rest: restSignature(parsed, records) };
16413
+ return { views: records, rest: restSignature(parsed, records), docRest: docRestOf(parsed) };
15848
16414
  }
15849
16415
  function canonical(value) {
15850
16416
  return JSON.stringify(sortKeys3(value)) ?? "null";
@@ -16091,6 +16657,7 @@ var SidecarSchemaEdits = class {
16091
16657
  hash,
16092
16658
  properties,
16093
16659
  payload: diff.payload,
16660
+ ...diff.staged ? { staged: diff.staged, optionRenames: diff.optionRenames } : {},
16094
16661
  edit: { path: base.path, ...diff.edit },
16095
16662
  seen: this.ledger.get(databaseId) ? null : seenSchemaOf(meta)
16096
16663
  });
@@ -16104,6 +16671,12 @@ var SidecarSchemaEdits = class {
16104
16671
  if (edit.seen) this.ledger.set(edit.databaseId, edit.seen);
16105
16672
  let after;
16106
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
+ }
16107
16680
  after = await this.notionClient.updateDataSourceProperties(edit.dataSourceId, edit.payload);
16108
16681
  } catch (error) {
16109
16682
  failed.push({
@@ -16125,6 +16698,30 @@ var SidecarSchemaEdits = class {
16125
16698
  }
16126
16699
  return { applied, failed };
16127
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
+ }
16128
16725
  };
16129
16726
  function sidecarPropertiesEdited(content, base) {
16130
16727
  let parsed;
@@ -16159,9 +16756,12 @@ function diffSidecarProperties(base, edited) {
16159
16756
  removed: [],
16160
16757
  renamed: [],
16161
16758
  retyped: [],
16162
- redefined: []
16759
+ redefined: [],
16760
+ renamedOptions: []
16163
16761
  };
16164
16762
  const droppedOptions = [];
16763
+ const optionRenames = [];
16764
+ const staged = {};
16165
16765
  const names = /* @__PURE__ */ new Set();
16166
16766
  const seenIds = /* @__PURE__ */ new Set();
16167
16767
  for (const prop of edited) {
@@ -16196,10 +16796,35 @@ function diffSidecarProperties(base, edited) {
16196
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`
16197
16797
  );
16198
16798
  }
16199
- change[prop.type] = definitionOf(prop, old);
16200
16799
  edit.redefined.push(prop.name);
16201
- 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
+ );
16202
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
+ }
16203
16828
  }
16204
16829
  if (Object.keys(change).length > 0) payload[old.id] = change;
16205
16830
  }
@@ -16213,19 +16838,50 @@ function diffSidecarProperties(base, edited) {
16213
16838
  if (kept >= 2 && edit.removed.length === kept && edit.added.length === 0) {
16214
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");
16215
16840
  }
16841
+ const { renamedOptions: renamedOptionList, ...rest } = edit;
16216
16842
  return {
16217
16843
  ok: true,
16218
16844
  payload,
16219
- 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
+ }
16220
16852
  };
16221
16853
  }
16222
- 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 = []) {
16223
16873
  if (!DEFINABLE_TYPES.has(prop.type)) return {};
16224
16874
  const definition = prop.definition ?? {};
16225
16875
  if (old && (prop.type === "select" || prop.type === "multi_select") && sameJson(prop.definition, old.definition) && !sameJson(prop.options, old.options)) {
16226
16876
  const known = old.definition?.["options"] ?? [];
16877
+ const renamedFrom = new Map(renames.map(([from, to]) => [to, from]));
16227
16878
  return {
16228
- 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
+ })
16229
16885
  };
16230
16886
  }
16231
16887
  return definition;
@@ -17534,12 +18190,60 @@ function rewriteCreatedDatabaseFolders(stateDb, folders) {
17534
18190
  async function retargetBaseFiles(vaultFs, moves, stateDb) {
17535
18191
  const folders = moves.filter((move) => !isMarkdownPath(move.from));
17536
18192
  if (folders.length === 0) return;
18193
+ const found2 = [];
17537
18194
  await rewriteBaseFilters(
17538
18195
  vaultFs,
17539
18196
  stateDb,
17540
18197
  (folder) => finalPath(folder, folders),
17541
- "\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)
17542
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;
17543
18247
  }
17544
18248
  var BASE_FOLDER_FILTER_UPGRADED_META_KEY = "base_folder_filter_v2";
17545
18249
  async function upgradeBaseFolderFilters(vaultFs, stateDb) {
@@ -17552,7 +18256,7 @@ async function upgradeBaseFolderFilters(vaultFs, stateDb) {
17552
18256
  );
17553
18257
  if (done) stateDb.setMeta(BASE_FOLDER_FILTER_UPGRADED_META_KEY, "1");
17554
18258
  }
17555
- async function rewriteBaseFilters(vaultFs, stateDb, retarget, failure) {
18259
+ async function rewriteBaseFilters(vaultFs, stateDb, retarget, failure, onUserBase) {
17556
18260
  const rehashed = /* @__PURE__ */ new Map();
17557
18261
  const viewed = stateDb ? recordedViewBases(stateDb) : /* @__PURE__ */ new Set();
17558
18262
  let done = true;
@@ -17560,7 +18264,16 @@ async function rewriteBaseFilters(vaultFs, stateDb, retarget, failure) {
17560
18264
  for (const file of await vaultFs.listNonMarkdownFiles()) {
17561
18265
  if (!file.path.endsWith(BASE_SUFFIX)) continue;
17562
18266
  const content = await vaultFs.readFile(file.path);
17563
- 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
+ }
17564
18277
  const next = content.replace(
17565
18278
  FOLDER_FILTER_RE,
17566
18279
  (whole, legacy, current) => {
@@ -17949,7 +18662,7 @@ function compareRemote(record, page, botUserId) {
17949
18662
  return "changed";
17950
18663
  }
17951
18664
  if (isSettled(record.notionLastEdited, record.notionSeenAt)) return "unchanged";
17952
- if (botUserId !== null && editor === botUserId && record.notionLastEditedBy === botUserId) {
18665
+ if (botUserId !== null && editor === botUserId && record.notionLastEditedBy === botUserId && (record.notionBodyFingerprint === null || record.baseSnapshot === null)) {
17953
18666
  return "unchanged";
17954
18667
  }
17955
18668
  return "unverified";
@@ -18152,6 +18865,7 @@ var DatabaseSyncer = class {
18152
18865
  this.observation = observation;
18153
18866
  this.noteRowBody = noteRowBody;
18154
18867
  this.baseFileInfo = new DbBaseFiles(stateDb);
18868
+ this.journal = new UpdateJournal(stateDb);
18155
18869
  this.sidecarBases = new SidecarBases(stateDb);
18156
18870
  this.schemaLedger = new SchemaLedger(stateDb);
18157
18871
  this.relocator = new NoteRelocator(stateDb, vaultFs, config.notion.databases ?? []);
@@ -18193,6 +18907,19 @@ var DatabaseSyncer = class {
18193
18907
  };
18194
18908
  /** 볼트에서 사라져 되살린 행의 경로 — pull 마감이 가져가 화면에 알린다(D8). */
18195
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();
18196
18923
  /** Notion 에서 바뀐 속성을 행 노트에 옮긴 것 — pull 마감이 가져가 화면에 알린다(ADR-045). */
18197
18924
  schemaFollowed = [];
18198
18925
  /** pull 이 마지막으로 쓴 사이드카 — 볼트에서 고친 것은 덮어쓰지 않는다(ADR-045). */
@@ -18201,6 +18928,10 @@ var DatabaseSyncer = class {
18201
18928
  heldSidecars = /* @__PURE__ */ new Set();
18202
18929
  /** 이번 pull 이 볼트에서 고친 것이라 덮어쓰지 않은 `.base` 경로(ADR-046). */
18203
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();
18204
18935
  /**
18205
18936
  * 실제로 기록한 .base 경로/DB 제목 — placeholder 임베드 재작성(F22)의 SSOT. 이번 pull 이 조회하지
18206
18937
  * 않은 DB 의 것도 남아 있다({@link DbBaseFiles}).
@@ -18270,10 +19001,6 @@ var DatabaseSyncer = class {
18270
19001
  }
18271
19002
  }
18272
19003
  if (target.kind === "linked") {
18273
- if (target.wholeDbInScope) {
18274
- const viewsConfig = await this.pullDatabaseViews(dbConfig);
18275
- await this.generateBaseFile(dbConfig, viewsConfig, target.meta, target.ownerFolder);
18276
- }
18277
19004
  return { ...emptyDatabaseSyncResult(), linkedOriginalDbId: target.ownerDbId };
18278
19005
  }
18279
19006
  const { pages, wholeDbInScope, meta } = target;
@@ -18284,7 +19011,9 @@ var DatabaseSyncer = class {
18284
19011
  const refreshedKeys = (page) => followed.changes.get(sourceKeyOf(page, dbConfig.databaseId))?.refreshed;
18285
19012
  if (wholeDbInScope) {
18286
19013
  const viewsConfig = await this.pullDatabaseViews(dbConfig);
18287
- 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);
18288
19017
  }
18289
19018
  let created = 0;
18290
19019
  let updated = followed.rekeyed.size;
@@ -18332,7 +19061,11 @@ var DatabaseSyncer = class {
18332
19061
  this.config.advanced.itemTimeoutMs,
18333
19062
  `pull ${dbConfig.localFolder}/${page.id}`
18334
19063
  );
18335
- 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) {
18336
19069
  created++;
18337
19070
  if (outcome.action === "written") writtenPaths.push(outcome.path);
18338
19071
  } else if (outcome.action === "written") {
@@ -18341,12 +19074,19 @@ var DatabaseSyncer = class {
18341
19074
  this.restoredRows.push(outcome.path);
18342
19075
  } else if (!followed.rekeyed.has(record.id)) updated++;
18343
19076
  writtenPaths.push(outcome.path);
19077
+ } else if (outcome.action === "merged") {
19078
+ updated++;
19079
+ this.mergedRows.push(outcome.path);
18344
19080
  } else if (outcome.action === "conflict") {
18345
19081
  conflicts.push(outcome.conflict);
18346
19082
  }
18347
19083
  opts?.progress?.done({
18348
19084
  path: outcome.path ?? record?.obsidianPath ?? `${dbConfig.localFolder}/${page.id}`,
18349
- 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 } : {}
18350
19090
  });
18351
19091
  } catch (error) {
18352
19092
  failed.push(rowFailure(page, error));
@@ -18406,6 +19146,72 @@ var DatabaseSyncer = class {
18406
19146
  ...planned.map((row) => ({ ...row, path: moved(row.path) }))
18407
19147
  ];
18408
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
+ }
18409
19215
  async planRows(dbConfig, target, paths, movingInVault = /* @__PURE__ */ new Set()) {
18410
19216
  const opts = { paths };
18411
19217
  const planned = [];
@@ -18451,11 +19257,18 @@ var DatabaseSyncer = class {
18451
19257
  moves: [{ from: record.obsidianPath, to: path }, ...children ? [children] : []]
18452
19258
  });
18453
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);
18454
19264
  planned.push({
18455
19265
  pageId: page.id,
18456
- path: record.obsidianPath,
19266
+ path: renamed?.to ?? record.obsidianPath,
18457
19267
  operation: "update",
18458
- restore: action === "restore"
19268
+ restore: action === "restore",
19269
+ ...resolution ? { resolution } : {},
19270
+ // 실제 pull 이 알리는 것과 같게 행만 — 하위 페이지 폴더는 함께 옮기지만 따로 알리지 않는다.
19271
+ ...renamed ? { moves: [{ from: record.obsidianPath, to: renamed.to, renamed: true }] } : {}
18459
19272
  });
18460
19273
  }
18461
19274
  }
@@ -18547,6 +19360,9 @@ var DatabaseSyncer = class {
18547
19360
  const changed = misplaced || this.remoteVerdict(record, page) !== "unchanged";
18548
19361
  if (!changed && await this.staleKeysToRewrite(record, page, refreshed)) return "reform";
18549
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
+ }
18550
19366
  return changed ? "pull" : "skip";
18551
19367
  }
18552
19368
  return "restore";
@@ -18795,10 +19611,10 @@ var DatabaseSyncer = class {
18795
19611
  }
18796
19612
  return ends;
18797
19613
  }
18798
- async generateBaseFile(dbConfig, viewsConfig, meta, rowsFolder, rangeEnds) {
19614
+ async generateBaseFile(dbConfig, viewsConfig, meta, rowsFolder, rangeEnds, linked) {
18799
19615
  try {
18800
19616
  const schemaFull = await this.notionClient.getDatabaseSchemaFull(dbConfig.databaseId, meta);
18801
- 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";
18802
19618
  const resolvedViews = viewsConfig ?? {
18803
19619
  databaseId: dbConfig.databaseId,
18804
19620
  databaseName: dbName,
@@ -18823,11 +19639,12 @@ var DatabaseSyncer = class {
18823
19639
  title: dbName,
18824
19640
  hash: computeHash(baseContent),
18825
19641
  // Notion 보기를 받지 못해 기본 표 하나를 쓴 파일은 올릴 보기가 없다.
18826
- ...viewsConfig ? recordBaseViews(baseContent, generated.views, viewsConfig.views) : null
19642
+ ...viewsConfig ? recordBaseViews(baseContent, generated.views, viewsConfig.views) : null,
19643
+ ...linked ? { linkedTo: compactNotionId(linked.ownerId) } : null
18827
19644
  };
18828
19645
  const ours = existing === null || isGeneratedDbArtifact(basePath, existing) || previous?.basePath === basePath && previous.views !== void 0;
18829
19646
  if (!ours) {
18830
- record = { basePath, title: dbName, hash: record.hash };
19647
+ record = { basePath, title: dbName, hash: record.hash, linkedTo: record.linkedTo };
18831
19648
  if (!this.keptUserBases.has(basePath)) {
18832
19649
  this.keptUserBases.add(basePath);
18833
19650
  getLogger().info(
@@ -18842,6 +19659,15 @@ var DatabaseSyncer = class {
18842
19659
  }
18843
19660
  this.baseFileInfo.set(dbConfig.databaseId, record);
18844
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
+ }
18845
19671
  await this.generateSidecar(
18846
19672
  dbConfig,
18847
19673
  dbName,
@@ -18851,7 +19677,15 @@ var DatabaseSyncer = class {
18851
19677
  meta.isInline,
18852
19678
  await this.appearanceOf(meta, safeName)
18853
19679
  );
18854
- 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
+ }
18855
19689
  } catch (error) {
18856
19690
  getLogger().warn(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
18857
19691
  }
@@ -18883,13 +19717,87 @@ var DatabaseSyncer = class {
18883
19717
  ...a.showTitle ? {} : { showTitle: false }
18884
19718
  };
18885
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
+ }
18886
19794
  /** 볼트에서 고친 `.base` 를 덮어쓰거나 지우지 않았다고 한 번 알린다. */
18887
19795
  warnEditedBase(path) {
18888
19796
  this.heldBases.add(path);
18889
19797
  if (this.keptUserBases.has(path)) return;
18890
19798
  this.keptUserBases.add(path);
18891
19799
  getLogger().warn(
18892
- `[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`
18893
19801
  );
18894
19802
  }
18895
19803
  /**
@@ -18897,11 +19805,13 @@ var DatabaseSyncer = class {
18897
19805
  * 같은 폴더에 고아로 잔존한다(drift·부활 원인). DB 폴더 **직속**의 산출물 중 현재 이름이
18898
19806
  * 아닌 것을 삭제해 멱등을 보장한다. 하위 폴더(중첩 DB)의 산출물은 보호한다.
18899
19807
  */
18900
- async cleanupStaleDbArtifacts(localFolder, safeName, previous) {
19808
+ async cleanupStaleDbArtifacts(databaseId, localFolder, safeName, previous) {
18901
19809
  try {
18902
19810
  const keep = /* @__PURE__ */ new Set([
18903
19811
  `${localFolder}/${safeName}${BASE_SUFFIX}`,
18904
- `${localFolder}/${safeName}${SIDECAR_SUFFIX}`
19812
+ `${localFolder}/${safeName}${SIDECAR_SUFFIX}`,
19813
+ // 이 DB 를 원본으로 둔 링크드 뷰의 `.base` — 같은 폴더에 놓인다.
19814
+ ...this.baseFileInfo.linkedBasesOf(databaseId)
18905
19815
  ]);
18906
19816
  const files = await this.vaultFs.listNonMarkdownFiles();
18907
19817
  const stale = selectStaleDbArtifacts(files, localFolder, keep);
@@ -19035,7 +19945,12 @@ var DatabaseSyncer = class {
19035
19945
  const bodyFingerprint = remoteBodyFingerprint(mdResult.markdown);
19036
19946
  const exportCompact = isCompactExport(mdResult.markdown);
19037
19947
  let markdown = await notionBodyToObsidian(this.notionClient, mdResult.markdown, page.id);
19038
- 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
+ );
19039
19954
  if (downloadMedia && this.config.conversion.imageDownload === "immediate" && markdown) {
19040
19955
  try {
19041
19956
  const imageResult = await this.imageHandler.downloadAllImages(markdown, title, page.id);
@@ -19117,6 +20032,105 @@ var DatabaseSyncer = class {
19117
20032
  this.restoredRows = [];
19118
20033
  return rows;
19119
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
+ }
19120
20134
  /**
19121
20135
  * Notion 에서 다른 페이지 아래로 옮긴 DB 의 폴더를 볼트에서도 옮긴다 — 옮겼으면 그 이동. 옮기지 못하면 까닭을 남기고
19122
20136
  * 옛 자리에서 받는다.
@@ -19130,7 +20144,7 @@ var DatabaseSyncer = class {
19130
20144
  this.renamedRows.renamed.push(move);
19131
20145
  this.renamedRows.applied.push(move);
19132
20146
  getLogger().info(
19133
- `[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}`
19134
20148
  );
19135
20149
  return move;
19136
20150
  }
@@ -19139,7 +20153,7 @@ var DatabaseSyncer = class {
19139
20153
  if (!outcome) return null;
19140
20154
  if ("reason" in outcome) {
19141
20155
  getLogger().warn(
19142
- `[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}`
19143
20157
  );
19144
20158
  return null;
19145
20159
  }
@@ -19151,18 +20165,17 @@ var DatabaseSyncer = class {
19151
20165
  return movingInVault.has(record.id) || this.stateDb.getIncompleteOpByState(record.id, "move") !== null;
19152
20166
  }
19153
20167
  /**
19154
- * Notion 에서 다른 페이지 아래로 옮긴 발견 DB 의 폴더를 옮길 곳(ADR-047) — 제자리면 null.
20168
+ * Notion 에서 다른 페이지 아래로 옮기거나 이름을 바꾼 발견 DB 의 폴더를 옮길 곳(ADR-047 · {@link retitledFolderName})
20169
+ * — 제자리면 null.
19155
20170
  *
19156
20171
  * Notion 에서 DB 를 옮겨도 DB · 행의 수정 시각이 그대로라 받은 DB 의 부모로만 안다 — 전체 대조는 모든 DB 를 받으니
19157
20172
  * 거기서 보인다. 새 자리는 처음 발견할 때와 같다 — 부모 페이지의 하위 폴더 이름 그대로, 루트면 `databases` 선반.
19158
20173
  * 맨 위 폴더도 루트 아래로 본다(볼트에서 옮긴 DB 를 push 가 루트 아래에 둔다 — {@link DatabaseMover}).
19159
20174
  * 다음은 옮기지 않는다.
19160
- * - 부모가 블록(토글 · 열) 안 · 볼트가 모르는 페이지 — 볼트에 그 자리가 없다
20175
+ * - 부모가 블록(토글 · 열) 안 · 볼트가 모르는 페이지 — 볼트에 그 자리가 없다(이름만 따른다)
19161
20176
  * - 볼트에서 옮긴 DB 폴더 · 부모 노트를 아직 올리지 않았다 — 볼트가 먼저다(push 가 Notion 을 옮긴다)
19162
20177
  */
19163
20178
  placementMoveOf(dbConfig, meta, movingInVault = /* @__PURE__ */ new Set()) {
19164
- const parentId = meta.parentPageId;
19165
- if (!parentId) return null;
19166
20179
  const id = dbConfig.databaseId;
19167
20180
  if ((this.config.notion.databases ?? []).some((db) => notionIdsEqual(db.databaseId, id))) {
19168
20181
  return null;
@@ -19171,23 +20184,52 @@ var DatabaseSyncer = class {
19171
20184
  return null;
19172
20185
  }
19173
20186
  const folder = dbConfig.localFolder;
19174
- let places;
19175
- if (notionIdsEqual(parentId, this.config.notion.rootPageId)) {
19176
- places = [ROOT_DATABASES_FOLDER, ""];
19177
- } else {
19178
- const parent = this.stateDb.getByNotionId(parentId);
19179
- if (!parent) return null;
19180
- if (this.movedInVault(parent, movingInVault)) {
19181
- return {
19182
- path: folder,
19183
- 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"
19184
- };
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
+ }
19185
20206
  }
19186
- places = [childFolderOf(parent, { databaseAt: (path) => this.databaseAt(path) })];
19187
- if (isFolderRecord(parent)) places.push(parent.obsidianPath);
20207
+ if (places && !places.includes(current)) place = places[0];
19188
20208
  }
19189
- if (places.includes(parentFolderOf(folder))) return null;
19190
- 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;
19191
20233
  }
19192
20234
  /** 이 폴더가 받는 DB — 설정한 DB 와 발견한 DB 에서. */
19193
20235
  databaseAt(folder) {
@@ -19198,6 +20240,12 @@ var DatabaseSyncer = class {
19198
20240
  return all.find((db) => db.localFolder === folder)?.databaseId ?? null;
19199
20241
  }
19200
20242
  /** 이름을 바꾼 행을 가져가고 비운다. */
20243
+ /** 이번 pull 이 Notion 에서 지운 링크드 뷰라 치운 `.base` — 그것을 넣은 노트의 임베드를 뺀다({@link LinkUpdater.dropEmbeds}). */
20244
+ takeRetiredBases() {
20245
+ const paths = [...this.retiredBases];
20246
+ this.retiredBases.clear();
20247
+ return paths;
20248
+ }
19201
20249
  takeRenamedRows() {
19202
20250
  const rows = this.renamedRows;
19203
20251
  this.renamedRows = { renamed: [], applied: [] };
@@ -19212,43 +20260,60 @@ var DatabaseSyncer = class {
19212
20260
  const hasChildren = this.stateDb.getAll().some((r) => r.id !== record.id && within(r.obsidianPath, from));
19213
20261
  return hasChildren ? { from, to: stripMarkdownExtension(rowPath) } : null;
19214
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
+ }
19215
20290
  async pullDatabasePage(page, dbConfig) {
19216
20291
  const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
19217
20292
  const existingRecord = this.stateDb.getByNotionId(page.id);
19218
20293
  const recordPath = existingRecord?.obsidianPath;
20294
+ const retitle = existingRecord && recordPath ? await this.retitledRowTarget(dbConfig.localFolder, existingRecord, page) : null;
19219
20295
  let filePath;
19220
- const retitled = !!existingRecord && !!recordPath && this.config.sync.renameOnTitleChange && isDirectDbRowPath(dbConfig.localFolder, recordPath) && retitledInNotion(
19221
- snapshotFrontmatter(existingRecord.baseSnapshot),
19222
- null,
19223
- recordPath,
19224
- this.notionClient.extractTitle(page)
19225
- ) && !this.stateDb.getIncompleteOpByState(existingRecord.id, "move");
19226
- 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)) {
19227
20302
  filePath = recordPath;
19228
20303
  } else {
19229
- filePath = resolveDbRowPath(
20304
+ filePath = (!existingRecord ? (adopt = await this.claimExistingRow(dbConfig.localFolder, safeName, page.id))?.path : void 0) ?? resolveDbRowPath(
19230
20305
  dbConfig.localFolder,
19231
20306
  safeName,
19232
20307
  page.id,
19233
20308
  await this.rowPathOwners(dbConfig.localFolder, safeName, page.id)
19234
20309
  );
19235
20310
  }
19236
- let childFolderMove = null;
19237
- if (retitled) {
19238
- const from = stripMarkdownExtension(recordPath);
19239
- if (this.stateDb.getAll().some((r) => within(r.obsidianPath, from))) {
19240
- childFolderMove = { from, to: stripMarkdownExtension(filePath) };
19241
- const blocked = await this.relocator.blocked(childFolderMove);
19242
- if (blocked) {
19243
- getLogger().warn(
19244
- `[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}`
19245
- );
19246
- filePath = recordPath;
19247
- childFolderMove = null;
19248
- }
19249
- }
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
+ );
19250
20315
  }
19251
- const renaming = retitled && filePath !== recordPath;
20316
+ const renaming = retitle !== null && "to" in retitle;
19252
20317
  const becomesRow = !!existingRecord && !!recordPath && wasPage(existingRecord);
19253
20318
  const arrives = becomesRow || !!existingRecord && !!recordPath && arrivesFromOtherDatabase(existingRecord, dbConfig);
19254
20319
  if (arrives && recordPath && this.stateDb.getIncompleteOpByState(existingRecord.id, "move")) {
@@ -19271,10 +20336,39 @@ var DatabaseSyncer = class {
19271
20336
  emptiedFolder = parentFolderOf(recordPath);
19272
20337
  }
19273
20338
  }
19274
- 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
+ }
19275
20368
  const { title, properties, bodyFingerprint } = rendered;
19276
20369
  const finalContent = becomesRow ? keepPageValues(rendered.content, await readLocalNote(this.vaultFs, recordPath)) : rendered.content;
19277
20370
  const seenAt = this.observation().seenAt;
20371
+ let merged = null;
19278
20372
  if (existingRecord) {
19279
20373
  let localContent = "";
19280
20374
  let localExists = true;
@@ -19285,8 +20379,7 @@ var DatabaseSyncer = class {
19285
20379
  localContent = "";
19286
20380
  localExists = await this.vaultFs.exists(readPath);
19287
20381
  }
19288
- const base = snapshotFrontmatter(existingRecord.baseSnapshot);
19289
- 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);
19290
20383
  const resolution = resolvePullConflict({
19291
20384
  record: existingRecord,
19292
20385
  localContent,
@@ -19337,9 +20430,10 @@ var DatabaseSyncer = class {
19337
20430
  });
19338
20431
  return { action: "unchanged", path: filePath };
19339
20432
  }
20433
+ if (resolution.action === "merge") merged = resolution.merged;
19340
20434
  }
19341
20435
  await this.vaultFs.ensureFolder(dbConfig.localFolder);
19342
- await this.vaultFs.writeFile(filePath, finalContent);
20436
+ await this.vaultFs.writeFile(filePath, merged ?? finalContent);
19343
20437
  const rehomed = existingRecord && recordPath && recordPath !== filePath;
19344
20438
  const outOfStep = becomesRow && (rendered.propertiesBlock || !this.rowOutsideBodyUnchanged(
19345
20439
  this.propertyMapper,
@@ -19370,9 +20464,9 @@ var DatabaseSyncer = class {
19370
20464
  fileType: "db-row",
19371
20465
  status: "synced",
19372
20466
  baseSnapshot: Buffer.from(finalContent, "utf-8"),
19373
- // 비운 해시를 변경 확인이 보게 — stat 이 맞으면 내용을 읽지 않는다(B5).
19374
- localMtime: outOfStep ? null : stat2?.mtime ?? null,
19375
- 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,
19376
20470
  // 빈 해시와 함께 — push 가 지난 사본 대신 원격의 지금 값과 견줘 다른 속성을 모두 보낸다(overwritesRemote).
19377
20471
  ...outOfStep ? { notionBodyFingerprint: null } : {}
19378
20472
  });
@@ -19410,7 +20504,7 @@ var DatabaseSyncer = class {
19410
20504
  );
19411
20505
  }
19412
20506
  }
19413
- return { action: "written", path: filePath };
20507
+ return merged !== null ? { action: "merged", path: filePath } : { action: "written", path: filePath, ...relinked ? { relinked: true } : {} };
19414
20508
  }
19415
20509
  };
19416
20510
  function arrivesFromOtherDatabase(record, dbConfig) {
@@ -19549,160 +20643,30 @@ function folderOf3(record) {
19549
20643
  if (record.fileType === "db-row") return null;
19550
20644
  if (isFolderRecord(record)) return record.obsidianPath;
19551
20645
  const slash = record.obsidianPath.lastIndexOf("/");
19552
- if (slash < 0) return null;
19553
- const folder = record.obsidianPath.slice(0, slash);
19554
- const stem = stripMarkdownExtension(record.obsidianPath.slice(slash + 1)).replace(ID_SUFFIX, "");
19555
- return stem === folder.slice(folder.lastIndexOf("/") + 1) ? folder : null;
19556
- }
19557
- function findNestedFolderPages(records) {
19558
- const byPage = /* @__PURE__ */ new Map();
19559
- for (const record of records) {
19560
- if (record.notionPageId !== null) byPage.set(compactNotionId(record.notionPageId), record);
19561
- }
19562
- const found2 = [];
19563
- for (const inner of records) {
19564
- if (inner.notionPageId === null || inner.notionParentId === null) continue;
19565
- const folder = folderOf3(inner);
19566
- if (folder === null) continue;
19567
- const outer = byPage.get(compactNotionId(inner.notionParentId));
19568
- if (!outer || outer === inner || folderOf3(outer) !== folder) continue;
19569
- found2.push({
19570
- folder,
19571
- outer: { path: outer.obsidianPath, notionPageId: outer.notionPageId },
19572
- inner: { path: inner.obsidianPath, notionPageId: inner.notionPageId }
19573
- });
19574
- }
19575
- return found2.sort((a, b) => a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0);
19576
- }
19577
-
19578
- // src/conflict/merger.ts
19579
- function threeWayMerge(base, local, remote) {
19580
- const baseLines = base.split("\n");
19581
- const localLines = local.split("\n");
19582
- const remoteLines = remote.split("\n");
19583
- const hunks = [
19584
- ...computeHunks(baseLines, localLines, 0),
19585
- ...computeHunks(baseLines, remoteLines, 2)
19586
- ];
19587
- hunks.sort((a, b) => a.baseStart - b.baseStart || a.side - b.side);
19588
- const merged = [];
19589
- const conflicts = [];
19590
- let cursor = 0;
19591
- let i = 0;
19592
- while (i < hunks.length) {
19593
- const first = hunks[i];
19594
- if (first.baseStart > cursor) {
19595
- merged.push(...baseLines.slice(cursor, first.baseStart));
19596
- }
19597
- const regionStart = first.baseStart;
19598
- let regionEnd = first.baseEnd;
19599
- const group2 = [first];
19600
- i++;
19601
- while (i < hunks.length) {
19602
- const next = hunks[i];
19603
- const overlaps = next.baseStart < regionEnd || regionEnd === regionStart && next.baseStart === regionStart;
19604
- if (!overlaps) break;
19605
- regionEnd = Math.max(regionEnd, next.baseEnd);
19606
- group2.push(next);
19607
- i++;
19608
- }
19609
- const baseRegion = baseLines.slice(regionStart, regionEnd);
19610
- const localRegion = applyHunks(baseLines, group2, 0, regionStart, regionEnd);
19611
- const remoteRegion = applyHunks(baseLines, group2, 2, regionStart, regionEnd);
19612
- if (arraysEqual(localRegion, baseRegion)) {
19613
- merged.push(...remoteRegion);
19614
- } else if (arraysEqual(remoteRegion, baseRegion)) {
19615
- merged.push(...localRegion);
19616
- } else if (arraysEqual(localRegion, remoteRegion)) {
19617
- merged.push(...localRegion);
19618
- } else {
19619
- conflicts.push({ localLines: localRegion, remoteLines: remoteRegion });
19620
- merged.push("<<<<<<< LOCAL");
19621
- merged.push(...localRegion);
19622
- merged.push("=======");
19623
- merged.push(...remoteRegion);
19624
- merged.push(">>>>>>> REMOTE");
19625
- }
19626
- cursor = regionEnd;
19627
- }
19628
- if (cursor < baseLines.length) {
19629
- merged.push(...baseLines.slice(cursor));
19630
- }
19631
- return {
19632
- success: conflicts.length === 0,
19633
- merged: merged.join("\n"),
19634
- conflicts
19635
- };
19636
- }
19637
- function computeHunks(base, modified, side) {
19638
- const matches = lcsMatches(base, modified);
19639
- const hunks = [];
19640
- let prevBase = -1;
19641
- let prevMod = -1;
19642
- const emitGap = (curBase, curMod) => {
19643
- const baseLen = curBase - (prevBase + 1);
19644
- const modLen = curMod - (prevMod + 1);
19645
- if (baseLen > 0 || modLen > 0) {
19646
- hunks.push({
19647
- baseStart: prevBase + 1,
19648
- baseEnd: curBase,
19649
- content: modified.slice(prevMod + 1, curMod),
19650
- side
19651
- });
19652
- }
19653
- };
19654
- for (const [b, m] of matches) {
19655
- emitGap(b, m);
19656
- prevBase = b;
19657
- prevMod = m;
19658
- }
19659
- emitGap(base.length, modified.length);
19660
- return hunks;
19661
- }
19662
- function applyHunks(baseLines, group2, side, regionStart, regionEnd) {
19663
- const out = [];
19664
- let b = regionStart;
19665
- for (const hunk of group2) {
19666
- if (hunk.side !== side) continue;
19667
- if (hunk.baseStart > b) out.push(...baseLines.slice(b, hunk.baseStart));
19668
- out.push(...hunk.content);
19669
- b = hunk.baseEnd;
19670
- }
19671
- if (b < regionEnd) out.push(...baseLines.slice(b, regionEnd));
19672
- 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;
19673
20650
  }
19674
- function lcsMatches(a, b) {
19675
- const n = a.length;
19676
- const m = b.length;
19677
- const dp = new Array(n + 1);
19678
- for (let i2 = 0; i2 <= n; i2++) dp[i2] = new Uint32Array(m + 1);
19679
- for (let i2 = n - 1; i2 >= 0; i2--) {
19680
- const row = dp[i2];
19681
- const next = dp[i2 + 1];
19682
- const ai = a[i2];
19683
- for (let j2 = m - 1; j2 >= 0; j2--) {
19684
- row[j2] = ai === b[j2] ? next[j2 + 1] + 1 : Math.max(next[j2], row[j2 + 1]);
19685
- }
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);
19686
20655
  }
19687
- const matches = [];
19688
- let i = 0;
19689
- let j = 0;
19690
- while (i < n && j < m) {
19691
- if (a[i] === b[j]) {
19692
- matches.push([i, j]);
19693
- i++;
19694
- j++;
19695
- } else if (dp[i + 1][j] >= dp[i][j + 1]) {
19696
- i++;
19697
- } else {
19698
- j++;
19699
- }
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
+ });
19700
20668
  }
19701
- return matches;
19702
- }
19703
- function arraysEqual(a, b) {
19704
- if (a.length !== b.length) return false;
19705
- 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);
19706
20670
  }
19707
20671
 
19708
20672
  // src/conflict/resolver.ts
@@ -19723,6 +20687,7 @@ function choiceForStrategy(conflict, strategy) {
19723
20687
  case "duplicate":
19724
20688
  return isRemoteDeletion(conflict) ? "local" : "duplicate";
19725
20689
  case "manual":
20690
+ case "merge":
19726
20691
  if (isRemoteDeletion(conflict)) {
19727
20692
  throw new Error(
19728
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}`
@@ -20691,6 +21656,12 @@ function depthOf(filter) {
20691
21656
  }
20692
21657
 
20693
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"];
20694
21665
  var PROPERTY_REQUEST_KEYS = [
20695
21666
  "property_id",
20696
21667
  "visible",
@@ -20702,6 +21673,9 @@ var PROPERTY_REQUEST_KEYS = [
20702
21673
  "time_format"
20703
21674
  ];
20704
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
+ }
20705
21679
  var BaseViewEdits = class {
20706
21680
  constructor(stateDb, vaultFs, notionClient) {
20707
21681
  this.vaultFs = vaultFs;
@@ -20741,7 +21715,9 @@ var BaseViewEdits = class {
20741
21715
  const changed = changedFields(record, edited);
20742
21716
  return changed.length > 0 ? [{ record, edited, changed }] : [];
20743
21717
  });
20744
- 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;
20745
21721
  const meta = await this.notionClient.getDatabaseMeta(databaseId);
20746
21722
  if (meta.dataSources.length > 1) {
20747
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");
@@ -20750,15 +21726,66 @@ var BaseViewEdits = class {
20750
21726
  const schema = await this.notionClient.getDatabaseSchemaFull(databaseId, meta);
20751
21727
  const views = [];
20752
21728
  let allPlanned = true;
20753
- for (const { record, edited, changed } of edits) {
20754
- const label = `\uBCF4\uAE30 '${record.name}'`;
20755
- let live;
21729
+ const liveOf = async (viewId) => {
20756
21730
  try {
20757
- live = await this.notionClient.getView(record.viewId);
21731
+ return await this.notionClient.getView(viewId);
20758
21732
  } catch (error) {
20759
21733
  if (!isNotionObjectNotFound(error)) throw error;
20760
- 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;
20761
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);
20762
21789
  if (!live) {
20763
21790
  refuse2(base.basePath, `${label}: Notion \uC5D0\uC11C \uC9C0\uC6B4 \uBCF4\uAE30\uC785\uB2C8\uB2E4`);
20764
21791
  allPlanned = false;
@@ -20792,18 +21819,25 @@ var BaseViewEdits = class {
20792
21819
  type: record.type,
20793
21820
  ...edited,
20794
21821
  ...record.lossyFilter && !changed.includes("filter") ? { lossyFilter: true } : {},
20795
- notion: record.notion
21822
+ notion: record.notion,
21823
+ ...record.rest !== void 0 ? { rest: record.rest } : {}
20796
21824
  }
20797
21825
  });
20798
21826
  }
20799
- if (views.length === 0) continue;
21827
+ if (views.length === 0 && created.length === 0 && deleted.length === 0 && gone.length === 0) {
21828
+ continue;
21829
+ }
20800
21830
  planned.push({
20801
21831
  databaseId,
20802
21832
  path: base.basePath,
20803
21833
  base,
20804
21834
  hash,
20805
- complete: allPlanned && matched.filter(Boolean).length === base.views.length && restSignature(parsed, matched) === base.rest,
20806
- views
21835
+ complete: allPlanned && restUnchanged(parsed, matched, base),
21836
+ views,
21837
+ created,
21838
+ deleted,
21839
+ gone,
21840
+ matched
20807
21841
  });
20808
21842
  }
20809
21843
  return { planned, refused };
@@ -20813,29 +21847,73 @@ var BaseViewEdits = class {
20813
21847
  const failed = [];
20814
21848
  for (const file of planned) {
20815
21849
  const records = new Map(file.base.views.map((record) => [record.viewId, record]));
21850
+ const made = /* @__PURE__ */ new Map();
20816
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
+ }
20817
21881
  for (const view of file.views) {
20818
21882
  try {
20819
21883
  await this.notionClient.updateView(view.viewId, view.request);
20820
21884
  } catch (error) {
20821
- allApplied = false;
20822
- failed.push({
20823
- path: file.path,
20824
- operation: "update",
20825
- error: `\uBCF4\uAE30 '${view.edit.view}' \uB97C Notion \uC5D0 \uC62C\uB9AC\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: ${error instanceof Error ? error.message : String(error)}`
20826
- });
21885
+ fail(`\uBCF4\uAE30 '${view.edit.view}' \uB97C Notion \uC5D0 \uC62C\uB9AC\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4`, error);
20827
21886
  continue;
20828
21887
  }
20829
21888
  applied.push(view.edit);
20830
- const after = await this.notionClient.getView(view.viewId).catch(() => null);
20831
21889
  records.set(view.viewId, {
20832
21890
  ...view.record,
20833
- notion: after ? viewFingerprint(after) : view.record.notion
21891
+ notion: await fingerprintOf(view.viewId, view.record.notion)
20834
21892
  });
20835
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
+ );
20836
21914
  this.bases.set(file.databaseId, {
20837
21915
  ...file.base,
20838
- views: file.base.views.map((record) => records.get(record.viewId) ?? record),
21916
+ views: [...inFile, ...left],
20839
21917
  // 고친 것을 모두 올렸으면 이 파일은 이제 pull 이 쓴 것과 같은 뜻이다 — 다음 pull 이 Notion 의 모습으로 다시 쓴다.
20840
21918
  // 남은 것이 있으면 계속 붙잡아 둔다.
20841
21919
  ...file.complete && allApplied ? { hash: file.hash } : {}
@@ -20843,15 +21921,55 @@ var BaseViewEdits = class {
20843
21921
  }
20844
21922
  return { applied, failed };
20845
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
+ }
20846
21929
  };
20847
- 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 = {}) {
20848
21964
  const request = {};
20849
21965
  try {
20850
21966
  if (changed.includes("name")) {
20851
21967
  if (!edited.name.trim()) throw new Error("\uBCF4\uAE30 \uC774\uB984\uC774 \uBE44\uC5C8\uC2B5\uB2C8\uB2E4");
20852
21968
  request.name = edited.name;
20853
21969
  }
20854
- 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
+ }
20855
21973
  if (changed.includes("sort")) {
20856
21974
  request.sorts = edited.sort ? edited.sort.map((s) => ({
20857
21975
  property: propertyIdOf(s.property, schema, "\uC815\uB82C"),
@@ -20877,7 +21995,7 @@ function buildRequest(record, edited, changed, live, schema) {
20877
21995
  }
20878
21996
  return { ok: true, request };
20879
21997
  }
20880
- function columnsOf(edited, live, schema) {
21998
+ function columnsOf(edited, live, schema, hideRest = false) {
20881
21999
  if (!edited.order) throw new Error("\uC5F4 \uBAA9\uB85D(order)\uC744 \uC9C0\uC6B4 \uBCF4\uAE30\uB294 \uC62C\uB9AC\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
20882
22000
  const configuration = live.configuration ?? {};
20883
22001
  const liveEntries = Array.isArray(configuration["properties"]) ? configuration["properties"] : [];
@@ -20906,7 +22024,7 @@ function columnsOf(edited, live, schema) {
20906
22024
  const properties = [
20907
22025
  ...titleId ? [entryOf(titleId)] : [],
20908
22026
  ...shown.map((id) => ({ ...entryOf(id), visible: true })),
20909
- ...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 }))
20910
22028
  ];
20911
22029
  return {
20912
22030
  type: configuration["type"] ?? live.type,
@@ -21023,7 +22141,8 @@ var OPERATION_LABELS = {
21023
22141
  fetch: "\uC6D0\uACA9 \uD655\uC778",
21024
22142
  resolve: "\uCDA9\uB3CC \uD574\uACB0",
21025
22143
  discard: "\uBCC0\uACBD \uB418\uB3CC\uB9AC\uAE30",
21026
- "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"
21027
22146
  };
21028
22147
  function operationLabel(operation) {
21029
22148
  return OPERATION_LABELS[operation];
@@ -21208,6 +22327,7 @@ var InterruptedSyncRecovery = class {
21208
22327
  if (ops.length === 0) return;
21209
22328
  for (const op of ops) {
21210
22329
  if (op.direction === "push" && op.operation === "move") continue;
22330
+ if (op.direction === "push" && op.operation === "update") continue;
21211
22331
  if (op.direction !== "push" || op.operation !== "create") {
21212
22332
  this.stateDb.markPendingFailed(op.id, "unsupported resume op");
21213
22333
  continue;
@@ -21329,6 +22449,101 @@ var InterruptedSyncRecovery = class {
21329
22449
  }
21330
22450
  };
21331
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
+
21332
22547
  // src/sync/db-placeholder-rewriter.ts
21333
22548
  var ID32 = "[a-f0-9]{32}";
21334
22549
  var IDUUID = "[a-f0-9-]{32,36}";
@@ -21515,6 +22730,7 @@ var DatabaseDiscovery = class {
21515
22730
  }
21516
22731
  const linkedMap = loadLinkedDbMap(this.stateDb);
21517
22732
  let linkedChanged = false;
22733
+ const linkedNow = /* @__PURE__ */ new Set();
21518
22734
  const retry = parseDiscoveryRetry(this.stateDb.getMeta(DISCOVERY_RETRY_META_KEY));
21519
22735
  const nextRetry = { parents: [], dbs: [] };
21520
22736
  const pathOf = (pageId) => notionIdsEqual(pageId, this.config.notion.rootPageId) ? "" : this.stateDb.getByNotionId(pageId)?.obsidianPath ?? pageId;
@@ -21568,6 +22784,7 @@ var DatabaseDiscovery = class {
21568
22784
  } else if (outcome.kind === "linked") {
21569
22785
  const origNohyph = outcome.originalDbId.replace(/-/g, "");
21570
22786
  linkedMap.set(nohyph, origNohyph);
22787
+ linkedNow.add(nohyph);
21571
22788
  linkedChanged = true;
21572
22789
  await registerOriginal(origNohyph);
21573
22790
  } else if (outcome.kind === "inaccessible") {
@@ -21602,6 +22819,7 @@ var DatabaseDiscovery = class {
21602
22819
  await register(normalizeNotionId(nohyph), parentPageId);
21603
22820
  }
21604
22821
  const stillSyncable = [];
22822
+ const pulledWhole = /* @__PURE__ */ new Set();
21605
22823
  let queue = [...dbConfigs];
21606
22824
  const MAX_DISCOVERY_ROUNDS = 4;
21607
22825
  const resolveDbFolder = (dbId) => {
@@ -21641,10 +22859,8 @@ var DatabaseDiscovery = class {
21641
22859
  }
21642
22860
  });
21643
22861
  if (dbResult.linkedOriginalDbId) {
21644
- linkedMap.set(
21645
- dbConfig.databaseId.replace(/-/g, ""),
21646
- dbResult.linkedOriginalDbId.replace(/-/g, "")
21647
- );
22862
+ linkedMap.set(nohyph, dbResult.linkedOriginalDbId.replace(/-/g, ""));
22863
+ linkedNow.add(nohyph);
21648
22864
  linkedChanged = true;
21649
22865
  changed = true;
21650
22866
  ledger?.settle(dbConfig.databaseId);
@@ -21653,6 +22869,7 @@ var DatabaseDiscovery = class {
21653
22869
  );
21654
22870
  continue;
21655
22871
  }
22872
+ if (!paths) pulledWhole.add(nohyph);
21656
22873
  created += dbResult.created;
21657
22874
  updated += dbResult.updated;
21658
22875
  deleted += dbResult.deleted;
@@ -21699,6 +22916,11 @@ var DatabaseDiscovery = class {
21699
22916
  );
21700
22917
  }
21701
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
+ }
21702
22924
  ledger?.keepOnly([...configuredIds, ...stillSyncable.map((c) => c.databaseId)]);
21703
22925
  if (changed || inaccessibleChanged || stillSyncable.length !== dbConfigs.length) {
21704
22926
  this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(stillSyncable));
@@ -21723,6 +22945,29 @@ var DatabaseDiscovery = class {
21723
22945
  }
21724
22946
  return { created, updated, deleted, restored };
21725
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
+ }
21726
22971
  async discoverChildDatabases(pageIds) {
21727
22972
  const found2 = [];
21728
22973
  const unread = [];
@@ -21836,6 +23081,12 @@ var DatabaseDiscovery = class {
21836
23081
  for (const [key, t] of [...targets]) {
21837
23082
  if (!await this.vaultFs.exists(t.basePath)) targets.delete(key);
21838
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
+ }
21839
23090
  if (targets.size === 0) return;
21840
23091
  const resolve2 = (nohyph) => targets.get(nohyph) ?? targets.get(linkedMap.get(nohyph) ?? "") ?? null;
21841
23092
  let total = 0;
@@ -21886,12 +23137,6 @@ var DatabaseDiscovery = class {
21886
23137
  return refs;
21887
23138
  }
21888
23139
  };
21889
- function databaseFolderName(title) {
21890
- return truncateBytes(
21891
- title.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-"),
21892
- FILE_SYSTEM_NAME_MAX_BYTES
21893
- );
21894
- }
21895
23140
 
21896
23141
  // src/sync/folder-placement.ts
21897
23142
  var FolderPlacement = class {
@@ -22980,12 +24225,14 @@ function baseName3(path) {
22980
24225
 
22981
24226
  // src/sync/link-updater.ts
22982
24227
  var LinkUpdater = class {
22983
- constructor(stateDb, vaultFs) {
24228
+ constructor(stateDb, vaultFs, outside) {
22984
24229
  this.stateDb = stateDb;
22985
24230
  this.vaultFs = vaultFs;
24231
+ this.outside = outside;
22986
24232
  }
22987
24233
  stateDb;
22988
24234
  vaultFs;
24235
+ outside;
22989
24236
  /**
22990
24237
  * @param moves 옮긴 차례 그대로의 이동 — 노트 하나거나 폴더 하나.
22991
24238
  * @returns 링크를 고친 노트.
@@ -23003,33 +24250,130 @@ var LinkUpdater = class {
23003
24250
  if (renames.size === 0) return [];
23004
24251
  const before = new Set([...after].map((path) => undo(path, moves)));
23005
24252
  const context = { before, after, renames };
24253
+ const noteRenames = new Map([...renames].filter(([from]) => isMarkdownPath(from)));
23006
24254
  const relinked = [];
23007
24255
  for (const note of notes) {
23008
24256
  const source = { from: undo(note.path, moves), to: note.path };
23009
24257
  const next = rewriteLinks(note.content, source, context);
23010
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;
23011
24302
  try {
23012
- await this.vaultFs.writeFile(note.path, next);
24303
+ await outside.fs.writeFile(note.path, next);
24304
+ relinked.push(note.path);
23013
24305
  } catch (error) {
23014
24306
  getLogger().warn(
23015
- `[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)}`
23016
24308
  );
23017
- continue;
23018
24309
  }
23019
- const record = this.stateDb.getByPath(note.path);
23020
- if (record && record.contentHash === computeHash(note.content)) {
23021
- this.stateDb.updateHash(record.id, computeHash(next), Buffer.from(next, "utf-8"));
23022
- const stat2 = await this.vaultFs.getFileStat(note.path);
23023
- if (stat2) this.stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
23024
- }
23025
- relinked.push(note.path);
23026
- }
23027
- if (relinked.length > 0) {
23028
- getLogger().info(`[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE68: \uB178\uD2B8 ${relinked.length}`);
23029
24310
  }
23030
24311
  return relinked;
23031
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
+ }
23032
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
+ }
23033
24377
  function undo(path, moves) {
23034
24378
  let current = path;
23035
24379
  for (let i = moves.length - 1; i >= 0; i--) {
@@ -23041,6 +24385,19 @@ function undo(path, moves) {
23041
24385
  return current;
23042
24386
  }
23043
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
+
23044
24401
  // src/sync/change-inspector.ts
23045
24402
  var ChangeInspector = class {
23046
24403
  constructor(stateDb, vaultFs, notionClient, planner, puller) {
@@ -23549,6 +24906,7 @@ var RemoteDriftChecker = class {
23549
24906
  this.databaseSyncer = databaseSyncer;
23550
24907
  this.rowSchemas = rowSchemas;
23551
24908
  this.observation = observation;
24909
+ this.journal = new UpdateJournal(stateDb);
23552
24910
  }
23553
24911
  config;
23554
24912
  stateDb;
@@ -23556,6 +24914,20 @@ var RemoteDriftChecker = class {
23556
24914
  databaseSyncer;
23557
24915
  rowSchemas;
23558
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
+ }
23559
24931
  /**
23560
24932
  * 원격이 바뀌었는지 확인하지 않고 로컬로 덮어써도 되는가 — 원격을 로컬로 맞추라는 요청이다.
23561
24933
  *
@@ -23702,6 +25074,7 @@ var PagePusher = class {
23702
25074
  this.drift = drift;
23703
25075
  this.placement = placement;
23704
25076
  this.recovery = recovery;
25077
+ this.journal = new UpdateJournal(stateDb);
23705
25078
  }
23706
25079
  config;
23707
25080
  stateDb;
@@ -23720,6 +25093,8 @@ var PagePusher = class {
23720
25093
  }
23721
25094
  /** 이번 실행에서 relation 을 보내지 못한 행 — 대상 노트가 아직 Notion 에 없었다. */
23722
25095
  unsentRelations = /* @__PURE__ */ new Set();
25096
+ /** 본문을 쓰다 끊긴 노트를 다음 실행이 알게 — {@link UpdateJournal}. */
25097
+ journal;
23723
25098
  /** 이번 실행에서 페이지 ↔ 행 · 다른 DB 의 행이 된 노트({@link takePlacementChanges}). */
23724
25099
  placementChanges = [];
23725
25100
  /**
@@ -23770,10 +25145,63 @@ var PagePusher = class {
23770
25145
  );
23771
25146
  if (readOnly.length > 0) skips.push({ path: change.path, properties: readOnly });
23772
25147
  } catch {
23773
- 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;
23774
25195
  }
25196
+ if (base?.body != null && base.body === body) return;
23775
25197
  }
23776
- 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);
23777
25205
  }
23778
25206
  /** dry-run 이 견줄 행 — 새 행은 기준 없이, 고친 행은 지난 동기화 사본과. 행이 아니거나 사본이 없으면 null. */
23779
25207
  previewRow(change) {
@@ -23838,7 +25266,8 @@ var PagePusher = class {
23838
25266
  direction: "push",
23839
25267
  path: selectedPath,
23840
25268
  filePath: path,
23841
- parentMode: this.config.notion.parentMode
25269
+ parentMode: this.config.notion.parentMode,
25270
+ anchors: await this.anchorLookup(content, null)
23842
25271
  });
23843
25272
  const title = noteTitle(conversionResult.properties, path);
23844
25273
  const placeholder2 = this.stateDb.upsert({
@@ -23948,13 +25377,40 @@ var PagePusher = class {
23948
25377
  }
23949
25378
  return uploads;
23950
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
+ }
23951
25409
  async livePage(record) {
23952
25410
  const presence = await remotePresence(this.notionClient, record.notionPageId);
23953
25411
  if (presence.kind === "alive") return presence.page;
23954
25412
  this.stateDb.updateStatus(record.id, "conflict");
23955
- throw new Error(
23956
- `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}`
23957
- );
25413
+ throw new Error(trashedPageRefusal(record.obsidianPath));
23958
25414
  }
23959
25415
  /**
23960
25416
  * @param options.overwriteRemote 원격을 로컬 내용으로 맞춘다(충돌 해소 결과 전파 · 입양한 행).
@@ -23982,15 +25438,15 @@ var PagePusher = class {
23982
25438
  direction: "push",
23983
25439
  path: updatePath,
23984
25440
  filePath: path,
23985
- parentMode: this.config.notion.parentMode
25441
+ parentMode: this.config.notion.parentMode,
25442
+ anchors: await this.anchorLookup(content, record.notionPageId)
23986
25443
  });
23987
25444
  const remote = await this.livePage(record);
23988
- const drift = this.drift.overwritesRemote(
23989
- record,
23990
- options?.overwriteRemote === true
23991
- ) ? "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);
23992
25446
  refuseUnpulledBody(drift, path);
23993
25447
  const settles = drift === "none";
25448
+ const hash = computeHash(content);
25449
+ this.journal.begin(record.id, hash);
23994
25450
  const pushed = await this.pushUpdatePage(record.notionPageId, conversionResult.content, path);
23995
25451
  let bodyFingerprint = pushed.fingerprint;
23996
25452
  let touched = await this.fillDeferredBlocks(
@@ -24025,11 +25481,11 @@ var PagePusher = class {
24025
25481
  } else if (settles) {
24026
25482
  written = await this.notionClient.getPage(record.notionPageId);
24027
25483
  }
24028
- const hash = computeHash(content);
24029
25484
  const fileStat = await this.vaultFs.getFileStat(path);
24030
25485
  const title = extractTitle(path);
24031
25486
  const aliases = extractAliases(conversionResult.properties);
24032
25487
  this.stateDb.transaction(() => {
25488
+ this.journal.finish(record.id);
24033
25489
  this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
24034
25490
  this.stateDb.updateStatus(record.id, "synced");
24035
25491
  if (written !== null && settles) {
@@ -24244,20 +25700,28 @@ var PagePusher = class {
24244
25700
  * ({@link RemoteDriftChecker.overwritesRemote}).
24245
25701
  *
24246
25702
  * 다시 보내면 Notion 을 덮으므로, 받지 않은 Notion 편집이 있으면 옮기지 않는다.
25703
+ *
25704
+ * 옮기기 전에 보낼 노트를 갱신 WAL 에 적는다(P7). 옮긴 뒤 응답을 잃으면 같은 실행의 재시도 · 다음 push 는 이미 그
25705
+ * 자리에 있고 마지막 편집자가 이 봇인 페이지를 본다 — 우리가 옮기다 끊긴 것이므로 옮기기를 건너뛰고 마저 보낸다.
25706
+ * 예전에는 옮기며 바뀐 수정 시각을 Notion 편집으로 보고 «pull 을 먼저 하라» 고 거절했다.
24247
25707
  */
24248
25708
  async changePlacement(record, move) {
24249
25709
  const { path, databaseId } = move;
24250
25710
  const pageId = record.notionPageId;
25711
+ let content;
24251
25712
  let current;
24252
25713
  try {
24253
- current = parseFrontmatter(await this.vaultFs.readFile(path)).data;
25714
+ content = await this.vaultFs.readFile(path);
25715
+ current = parseFrontmatter(content).data;
24254
25716
  } catch (error) {
24255
25717
  throw new Error(
24256
25718
  `frontmatter \uB97C \uC77D\uC9C0 \uBABB\uD574 \uC62E\uAE30\uC9C0 \uC54A\uC74C (${path}): ${error instanceof Error ? error.message : String(error)}`
24257
25719
  );
24258
25720
  }
24259
25721
  const remote = await this.livePage(record);
24260
- 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") {
24261
25725
  throw new Error(
24262
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}`
24263
25727
  );
@@ -24276,11 +25740,17 @@ var PagePusher = class {
24276
25740
  );
24277
25741
  addedColumns = columnsAddedByMove(pagePropertiesOf(remote), schema);
24278
25742
  }
24279
- 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
+ }
24280
25747
  parentId = databaseId;
24281
25748
  } else {
24282
- parentId = this.placement.moveParentOf(path);
24283
- 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
+ }
24284
25754
  }
24285
25755
  this.stateDb.transaction(() => {
24286
25756
  this.stateDb.upsert({
@@ -24443,7 +25913,8 @@ var PagePusher = class {
24443
25913
  direction: "push",
24444
25914
  path: selectedPath,
24445
25915
  filePath: path,
24446
- parentMode: "database"
25916
+ parentMode: "database",
25917
+ anchors: await this.anchorLookup(content, null)
24447
25918
  });
24448
25919
  const placeholder2 = this.stateDb.upsert({
24449
25920
  obsidianPath: path,
@@ -24620,7 +26091,7 @@ var PagePusher = class {
24620
26091
  }
24621
26092
  const remote = await this.livePage(record);
24622
26093
  const mapper = await this.rowSchemas.mapperFor(databaseId, dataSourceIdOf(remote));
24623
- 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);
24624
26095
  const against = options.overwriteRemote ? this.remoteRowState(mapper, remote) : this.baseRowState(record);
24625
26096
  const title = noteTitle(current.data, path);
24626
26097
  const changes = diffRowProperties(
@@ -24658,8 +26129,10 @@ var PagePusher = class {
24658
26129
  direction: "push",
24659
26130
  path: selectedPath,
24660
26131
  filePath: path,
24661
- parentMode: "database"
26132
+ parentMode: "database",
26133
+ anchors: await this.anchorLookup(content, pageId)
24662
26134
  });
26135
+ this.journal.begin(record.id, computeHash(content));
24663
26136
  const pushed = await this.pushUpdatePage(pageId, conversionResult.content, path);
24664
26137
  bodyFingerprint = pushed.fingerprint;
24665
26138
  let touched = await this.fillDeferredBlocks(
@@ -24709,6 +26182,7 @@ var PagePusher = class {
24709
26182
  const hash = computeHash(snapshot);
24710
26183
  const fileStat = unsent.length > 0 ? null : await this.vaultFs.getFileStat(path);
24711
26184
  this.stateDb.transaction(() => {
26185
+ this.journal.finish(record.id);
24712
26186
  this.stateDb.updateHash(record.id, hash, Buffer.from(snapshot, "utf-8"));
24713
26187
  this.stateDb.updateStatus(record.id, "synced");
24714
26188
  if (written !== null && settles) {
@@ -24885,11 +26359,13 @@ var PagePusher = class {
24885
26359
  }
24886
26360
  }
24887
26361
  /**
24888
- * `.base` 가 나타내는 Notion DB id 들 — 옆 사이드카(`<이름>.notion.json`)의 DB 와, 그 DB 를
24889
- * 원본으로 둔 링크드 뷰 컨테이너(pull 이 `.base` 를 원본 DB 로 몰아 만든다). 사이드카가
24890
- * 없거나 읽지 못하면 빈 목록 — 호출측이 제목으로 맞춘다.
26362
+ * `.base` 가 나타내는 Notion DB id 들 — 링크드 뷰의 `.base` 면 그 컨테이너. 아니면 옆 사이드카(`<이름>.notion.json`)의
26363
+ * DB 와, 그 DB 를 원본으로 둔 링크드 뷰 컨테이너(예전 pull 은 링크드 뷰를 원본의 `.base` 로 몰아 넣었다 — 그 노트를
26364
+ * 올릴 때도 링크드 뷰 블록을 지킨다). 사이드카가 없거나 읽지 못하면 빈 목록 — 호출측이 제목으로 맞춘다.
24891
26365
  */
24892
26366
  async databaseIdsOfBase(basePath) {
26367
+ const container = new DbBaseFiles(this.stateDb).linkedContainerAt(basePath);
26368
+ if (container) return [container];
24893
26369
  const sidecarPath = basePath.slice(0, -BASE_SUFFIX.length) + SIDECAR_SUFFIX;
24894
26370
  let databaseId;
24895
26371
  try {
@@ -24900,7 +26376,7 @@ var PagePusher = class {
24900
26376
  if (typeof databaseId !== "string") return [];
24901
26377
  const own = compactNotionId(databaseId);
24902
26378
  const made = loadCreatedDatabases(this.stateDb).get(own)?.databaseId;
24903
- 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);
24904
26380
  return [own, ...made ? [compactNotionId(made)] : [], ...linked];
24905
26381
  }
24906
26382
  /**
@@ -24994,69 +26470,13 @@ function blockModeBody(markdownContent) {
24994
26470
  function withoutDecorations(skipped) {
24995
26471
  return skipped.filter((key) => key !== "icon" && key !== "cover");
24996
26472
  }
24997
-
24998
- // src/converter/notion-id-links.ts
24999
- var NOTION_ID_WIKILINK_RE = /\[\[notion:([a-f0-9]{32}|[a-f0-9-]{36})(?:(\\?\|)([^[\]]+))?\]\]/g;
25000
- var compactId = (id) => id.replace(/-/g, "");
25001
- function resolveNotionIdWikilinks(markdown, lookup) {
25002
- let resolved = 0;
25003
- const out = markdown.replace(
25004
- NOTION_ID_WIKILINK_RE,
25005
- (match, id, separator, rawAlias) => {
25006
- const path = lookup(id);
25007
- if (!path) return match;
25008
- resolved++;
25009
- const base = wikilinkTitleFromPath(path);
25010
- const alias = rawAlias === compactId(id) ? void 0 : rawAlias;
25011
- const link = formatWikilink(base, alias);
25012
- return separator === "\\|" ? link.replace("|", separator) : link;
25013
- }
25014
- );
25015
- return { markdown: out, resolved };
25016
- }
25017
- function degradeUnresolvedNotionIdWikilinks(markdown, titles = /* @__PURE__ */ new Map()) {
25018
- let degraded = 0;
25019
- const out = markdown.replace(
25020
- NOTION_ID_WIKILINK_RE,
25021
- (_match, id, _separator, alias) => {
25022
- degraded++;
25023
- const compact = compactId(id);
25024
- return `[${alias ?? (titles.get(compact) || compact)}](https://www.notion.so/${compact})`;
25025
- }
25026
- );
25027
- return { markdown: out, degraded };
25028
- }
25029
- function unlabeledNotionIds(markdown) {
25030
- const ids = /* @__PURE__ */ new Set();
25031
- for (const m of markdown.matchAll(NOTION_ID_WIKILINK_RE)) {
25032
- if (m[3] === void 0) ids.add(compactId(m[1]));
25033
- }
25034
- return [...ids];
25035
- }
25036
- function linkLabelOfTitle(title) {
25037
- return title.replace(/[\r\n]+/g, " ").trim().replace(/\[/g, "(").replace(/\]/g, ")").replace(/[\\*_`~]/g, "\\$&");
25038
- }
25039
- var NOTION_RELATIVE_PAGE_LINK_RE = /\[([^\]]+)\]\(\/(?:p\/)?([a-f0-9]{32})(?:\?[^)#]*)?(?:#([^)]*))?\)/g;
25040
- function resolveNotionRelativePageLinks(markdown, lookup) {
25041
- let resolved = 0;
25042
- const out = markdown.replace(NOTION_RELATIVE_PAGE_LINK_RE, (match, label, id) => {
25043
- const path = lookup(id);
25044
- if (!path) return match;
25045
- resolved++;
25046
- return formatWikilink(wikilinkTitleFromPath(path), label);
25047
- });
25048
- 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}`;
25049
26475
  }
25050
- function degradeUnresolvedNotionRelativePageLinks(markdown) {
25051
- let degraded = 0;
25052
- const out = markdown.replace(
25053
- NOTION_RELATIVE_PAGE_LINK_RE,
25054
- (_match, label, id, anchor) => {
25055
- degraded++;
25056
- return `[${label}](https://www.notion.so/${id}${anchor === void 0 ? "" : `#${anchor}`})`;
25057
- }
25058
- );
25059
- 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);
25060
26480
  }
25061
26481
 
25062
26482
  // src/sync/notion-parent.ts
@@ -25129,6 +26549,15 @@ var PagePuller = class {
25129
26549
  claimTurns = /* @__PURE__ */ new Map();
25130
26550
  // Notion 에서 사라져 볼트에서 지운 페이지의 하위 폴더 — pull 이 마칠 때 비었으면 지운다({@link removeVacatedFolders}).
25131
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
+ }
25132
26561
  decorationsCache;
25133
26562
  get decorations() {
25134
26563
  return this.decorationsCache ??= new PageDecorations(this.notionClient, this.imageHandler);
@@ -25144,6 +26573,7 @@ var PagePuller = class {
25144
26573
  this._pullImageCount = 0;
25145
26574
  this._pullFileCount = 0;
25146
26575
  this.vacatedFolders.clear();
26576
+ this.unlinkedVault = !this.stateDb.getAll().some((record) => record.notionPageId !== null);
25147
26577
  }
25148
26578
  /**
25149
26579
  * 이번 pull 이 지운 페이지의 폴더가 비었으면 지운다 — 안쪽부터, 그래야 바깥이 빈다. 예전에는 노트만 지우고
@@ -25263,58 +26693,147 @@ var PagePuller = class {
25263
26693
  const fetched = await this.fetchPageMarkdown(pageId);
25264
26694
  const hasChildPages = this.hasChildContainers(pageId, fetched.children);
25265
26695
  if (hasChildPages && !fetched.hasBody) {
25266
- return {
25267
- path: await this.createFolderOnly(pageId, page, fetched.fingerprint, parentPath, safeName)
25268
- };
25269
- }
25270
- let filePath;
25271
- let fileType;
25272
- if (hasChildPages) {
25273
- const folderPath = parentPath ? `${parentPath}/${safeName}` : safeName;
25274
- filePath = await this.resolveUniqueFilePath(folderPath, safeName, pageId);
25275
- fileType = "folder-note";
25276
- await this.vaultFs.ensureFolder(folderPath);
25277
- } else {
25278
- filePath = await this.resolveUniqueFilePath(parentPath, safeName, pageId);
25279
- fileType = "file";
25280
- }
25281
- 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({
25282
26704
  pageId,
25283
26705
  page,
25284
26706
  fetched,
25285
- filePath,
26707
+ filePath: path,
25286
26708
  title,
25287
- titleFollowsName: extractTitle(filePath) === title,
25288
- downloadMedia: true
26709
+ titleFollowsName: extractTitle(path) === title,
26710
+ downloadMedia: true,
26711
+ ...local !== void 0 ? { local: { content: local, markers: [] }, adoptLocalMedia: true } : {}
25289
26712
  });
25290
26713
  const resolvedParentId = await extractParentId(this.notionClient, page);
25291
- await this.vaultFs.writeFile(filePath, finalContent);
25292
- const hash = computeHash(finalContent);
25293
- 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);
25294
26815
  this.stateDb.transaction(() => {
25295
26816
  this.stateDb.upsert({
25296
26817
  obsidianPath: filePath,
25297
- notionPageId: pageId,
25298
- notionParentId: resolvedParentId,
25299
- contentHash: hash,
25300
- ...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),
25301
26822
  localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
25302
26823
  syncDirection: "both",
25303
- fileType,
25304
- status: "synced",
25305
- 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"),
25306
26827
  localMtime: pullStat?.mtime ?? null,
25307
26828
  localFileSize: pullStat?.size ?? null
25308
26829
  });
25309
- const aliases = extractAliases(properties);
25310
26830
  this.stateDb.upsertWikilink({
25311
26831
  obsidianPath: filePath,
25312
- notionPageId: pageId,
25313
- title,
25314
- aliases
26832
+ notionPageId: note.pageId,
26833
+ title: note.title,
26834
+ aliases: extractAliases(input.properties)
25315
26835
  });
25316
26836
  });
25317
- return { path: filePath };
25318
26837
  }
25319
26838
  /**
25320
26839
  * 자식 말고 본문이 없는 페이지는 폴더로만 받는다 — 노트를 쓰지 않고 폴더 경로를 추적한다(폴더 레코드).
@@ -25325,9 +26844,10 @@ var PagePuller = class {
25325
26844
  */
25326
26845
  async createFolderOnly(pageId, page, fingerprint, parentPath, safeName) {
25327
26846
  const folderPath = stripMarkdownExtension(
25328
- await this.resolveUniqueFilePath(parentPath, safeName, pageId)
26847
+ (await this.resolveUniqueFilePath(parentPath, safeName, pageId)).path
25329
26848
  );
25330
26849
  const resolvedParentId = await extractParentId(this.notionClient, page);
26850
+ const existed = await this.vaultFs.exists(folderPath);
25331
26851
  await this.vaultFs.ensureFolder(folderPath);
25332
26852
  this.stateDb.upsert({
25333
26853
  obsidianPath: folderPath,
@@ -25340,7 +26860,39 @@ var PagePuller = class {
25340
26860
  fileType: "folder-only",
25341
26861
  status: "synced"
25342
26862
  });
25343
- 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 };
25344
26896
  }
25345
26897
  async pullUpdate(change) {
25346
26898
  let record = this.stateDb.getByNotionId(change.pageId);
@@ -25365,7 +26917,7 @@ var PagePuller = class {
25365
26917
  localContent = "";
25366
26918
  localExists = await this.vaultFs.exists(record.obsidianPath);
25367
26919
  }
25368
- 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));
25369
26921
  const resolution = resolvePullConflict({
25370
26922
  record,
25371
26923
  localContent,
@@ -25386,6 +26938,32 @@ var PagePuller = class {
25386
26938
  this.stateDb.updateStatus(record.id, "conflict");
25387
26939
  return { action: "conflict", path: record.obsidianPath, conflict: resolution.conflict };
25388
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
+ }
25389
26967
  if (localExists && sameNoteContent(remoteContent, localContent)) {
25390
26968
  const stat2 = await this.vaultFs.getFileStat(record.obsidianPath);
25391
26969
  this.stateDb.upsert({
@@ -25509,7 +27087,7 @@ var PagePuller = class {
25509
27087
  properties = this.propertyMapper.fromNotionProperties(raw);
25510
27088
  notionKeys = [...this.propertyMapper.ownedKeys(raw), "title", ...DECORATION_KEYS];
25511
27089
  } else {
25512
- properties = this.notionClient.extractProperties(page);
27090
+ properties = { ...this.notionClient.extractProperties(page) };
25513
27091
  }
25514
27092
  if (isDatabaseMode(this.config) || !input.titleFollowsName) {
25515
27093
  properties.title = title;
@@ -25521,7 +27099,8 @@ var PagePuller = class {
25521
27099
  let markdown = await this.imageHandler.restoreUploadedMedia(
25522
27100
  input.fetched.content,
25523
27101
  pageId,
25524
- filePath
27102
+ filePath,
27103
+ input.adoptLocalMedia ? input.local?.content : void 0
25525
27104
  );
25526
27105
  if (input.downloadMedia && this.config.conversion.imageDownload === "immediate") {
25527
27106
  const imageResult = await this.imageHandler.downloadAllImages(markdown, title, pageId);
@@ -25566,8 +27145,11 @@ var PagePuller = class {
25566
27145
  this.discovery.collectInlineDbRefs(pageId, result.markdown);
25567
27146
  this.detector.noteBodyChildren(pageId, result.markdown);
25568
27147
  return {
25569
- content: this.resolveNotionIdWikilinks(
25570
- 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
25571
27153
  ),
25572
27154
  // 압축형 판정은 반드시 원시 export 기준 — enhanced 변환이 <empty-block/> 을
25573
27155
  // 빈 줄로 바꾼 뒤에는 BlockSpacer 가 저작형과 구분할 수 없다(D1).
@@ -25586,7 +27168,10 @@ var PagePuller = class {
25586
27168
  this.discovery.collectInlineDbRefs(pageId, fallback.markdown);
25587
27169
  this.detector.noteBodyChildren(pageId, fallback.markdown);
25588
27170
  return {
25589
- content: this.resolveNotionIdWikilinks(fallback.content),
27171
+ content: await this.resolveBlockLinks(
27172
+ this.resolveNotionIdWikilinks(fallback.content),
27173
+ pageId
27174
+ ),
25590
27175
  compact: isCompactExport(fallback.markdown),
25591
27176
  children: extractChildTags(fallback.markdown),
25592
27177
  fingerprint: null,
@@ -25725,15 +27310,38 @@ var PagePuller = class {
25725
27310
  * 파일 1개·레코드 1건). 그래서 마지막 확인과 등록을 **await 없는 동기 구간**에 묶는다 —
25726
27311
  * 그 사이에는 다른 작업이 끼어들 수 없으므로 두 페이지가 같은 경로를 얻는 일이 없다.
25727
27312
  */
25728
- async resolveUniqueFilePath(dir, safeName, pageId) {
27313
+ async resolveUniqueFilePath(dir, safeName, pageId, options) {
25729
27314
  const turn = this.claimTurns.get(normalizeNotionId(pageId));
25730
27315
  await turn?.previous;
25731
27316
  try {
25732
- 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) };
25733
27322
  } finally {
25734
27323
  turn?.release();
25735
27324
  }
25736
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
+ }
25737
27345
  async claimFilePath(dir, safeName, pageId) {
25738
27346
  const candidates = pagePathCandidates(dir, safeName, pageId);
25739
27347
  for (const candidate of candidates) {
@@ -25764,6 +27372,17 @@ var PagePuller = class {
25764
27372
  (id) => this.stateDb.getByNotionId(normalizeNotionId(id))?.obsidianPath ?? null
25765
27373
  ).markdown;
25766
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
+ }
25767
27386
  async ensureDbSchema() {
25768
27387
  if (this.dbSchemaLoaded || !isDatabaseMode(this.config)) return;
25769
27388
  const schema = await this.notionClient.getDatabaseSchema(this.config.notion.databaseId);
@@ -25813,7 +27432,7 @@ function resolveFrontmatterRelations(content, idToTitle) {
25813
27432
  }
25814
27433
 
25815
27434
  // src/sync/notion-link-pass.ts
25816
- async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle) {
27435
+ async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle, blockAnchors) {
25817
27436
  let totalResolved = 0;
25818
27437
  let totalDegraded = 0;
25819
27438
  const titles = /* @__PURE__ */ new Map();
@@ -25848,6 +27467,21 @@ async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle) {
25848
27467
  totalResolved += urlPass.resolved;
25849
27468
  changed = true;
25850
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
+ }
25851
27485
  const fm = resolveFrontmatterRelations(content, idToTitle);
25852
27486
  if (fm.count > 0) {
25853
27487
  content = fm.content;
@@ -25933,19 +27567,38 @@ var PullPlanner = class {
25933
27567
  async planPull(filtered, restoreChanges, restoreIds, localPlan, options, startTime, scan, ledger) {
25934
27568
  const unchangedDropped = await this.drift.withoutUnchangedRemotes(filtered, options.signal);
25935
27569
  const planned = [];
27570
+ const failed = [];
27571
+ const conflicts = [];
27572
+ const mergedPaths = [];
25936
27573
  for (const change of unchangedDropped) {
25937
27574
  if (change.type === "deleted" && !await this.plannedRemoteDeletion(change)) continue;
25938
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
+ }
25939
27588
  planned.push(change);
25940
27589
  }
25941
- const failed = [];
25942
27590
  const queued = new Set(
25943
27591
  [...filtered, ...restoreChanges].map((change) => compactNotionId(change.pageId))
25944
27592
  );
25945
27593
  const movingInVault = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
25946
- const rows = (await this.planDatabaseRows(options.paths, failed, ledger, movingInVault)).filter(
25947
- (row) => !queued.has(compactNotionId(row.pageId))
25948
- );
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
+ }
25949
27602
  const items = [];
25950
27603
  const plannedPath = this.planner.plannedPaths(localPlan);
25951
27604
  for (const change of [...planned, ...restoreChanges]) {
@@ -25979,7 +27632,8 @@ var PullPlanner = class {
25979
27632
  restored: restoreChanges.length + restoredModified + rowCount("update", true),
25980
27633
  ...restoredPaths.length > 0 ? { restoredPaths } : {},
25981
27634
  ...movedRows.length > 0 ? { moved: movedRows.flatMap((row) => row.moves) } : {},
25982
- conflicts: [],
27635
+ conflicts,
27636
+ ...mergedPaths.length > 0 ? { mergedPaths } : {},
25983
27637
  writtenPaths: [],
25984
27638
  failed,
25985
27639
  duration: Date.now() - startTime,
@@ -26009,6 +27663,22 @@ var PullPlanner = class {
26009
27663
  return true;
26010
27664
  }
26011
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
+ }
26012
27682
  /** 원격에서 지운 페이지를 실제 pull 이 볼트에서 지우는가 — `PagePuller.pullDelete` 와 같게 가른다. */
26013
27683
  async plannedRemoteDeletion(change) {
26014
27684
  const record = this.stateDb.getByNotionId(change.pageId);
@@ -26733,7 +28403,7 @@ async function detectMissingLocalFiles(config, stateDb, vaultFs, paths) {
26733
28403
  }
26734
28404
  var LAST_SYNC_META_KEY = "last_sync_at";
26735
28405
  var SyncOrchestrator = class {
26736
- constructor(config, stateDb, notionClient, vaultFs, customFetch) {
28406
+ constructor(config, stateDb, notionClient, vaultFs, customFetch, outside) {
26737
28407
  this.config = config;
26738
28408
  this.stateDb = stateDb;
26739
28409
  this.notionClient = notionClient;
@@ -26866,7 +28536,7 @@ var SyncOrchestrator = class {
26866
28536
  async (pageId) => this.detector.discoveredPage(pageId) ?? await notionClient.getPage(pageId),
26867
28537
  config.notion.databases ?? []
26868
28538
  );
26869
- this.linkUpdater = new LinkUpdater(stateDb, vaultFs);
28539
+ this.linkUpdater = new LinkUpdater(stateDb, vaultFs, outside);
26870
28540
  this.puller = new PagePuller(
26871
28541
  config,
26872
28542
  stateDb,
@@ -26982,7 +28652,15 @@ var SyncOrchestrator = class {
26982
28652
  const foreign = this.stateDb.getAll().map((record) => record.obsidianPath).filter((path) => !vaultFs.ownsPath(path));
26983
28653
  if (foreign.length > 0) throw new ConnectionScopeError(foreign);
26984
28654
  }
26985
- 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) {
26986
28664
  const startTime = Date.now();
26987
28665
  this.assertConnectionScope();
26988
28666
  if (this.config.sync.direction === "pull") {
@@ -27013,13 +28691,25 @@ var SyncOrchestrator = class {
27013
28691
  const conflictPaths = new Set(this.stateDb.getByStatus("conflict").map((r) => r.obsidianPath));
27014
28692
  const eligible = options?.force ? changes : changes.filter((c) => !conflictPaths.has(c.path));
27015
28693
  const excludeSet = options?.excludePaths ? new Set(options.excludePaths) : null;
27016
- 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
+ }));
27017
28707
  const folderMoves = this.planner.pendingFolderMoves(plan).filter((move) => inAnyPathScope(move.to, options?.paths));
27018
28708
  const inScope = (path) => inAnyPathScope(path, options?.paths) && !excludeSet?.has(path);
27019
28709
  const sidecarPlan = await this.sidecarEdits.plan(inScope);
27020
28710
  const plannedEdits = sidecarPlan.planned.map((edit) => edit.edit);
27021
28711
  const viewPlan = await this.viewEdits.plan(inScope);
27022
- const plannedViews = viewPlan.planned.flatMap((file) => file.views.map((view2) => view2.edit));
28712
+ const plannedViews = viewPlan.planned.flatMap(plannedViewEdits);
27023
28713
  const preparedDatabases = loadPreparedDatabases(this.stateDb).size > 0;
27024
28714
  const recordedLookup = this.placement.folderLookup();
27025
28715
  const databaseMoves = mergeDatabaseMoves(
@@ -27045,7 +28735,13 @@ var SyncOrchestrator = class {
27045
28735
  updated: 0,
27046
28736
  deleted: 0,
27047
28737
  moved: 0,
27048
- 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
+ ],
27049
28745
  duration: Date.now() - startTime,
27050
28746
  ...edited2.applied.length > 0 ? { schemaEdits: edited2.applied } : {},
27051
28747
  ...viewed2.applied.length > 0 ? { viewEdits: viewed2.applied } : {}
@@ -27078,7 +28774,12 @@ var SyncOrchestrator = class {
27078
28774
  const applicable = placeable2.filter((c) => !renameRefused.has(c.path));
27079
28775
  const plannedRenames = renames.planned.map(({ folder, from, to }) => ({ folder, from, to }));
27080
28776
  if (options?.dryRun) {
27081
- 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));
27082
28783
  const items = [
27083
28784
  ...movableFolders.map(({ to }) => ({ path: to, operation: "move" })),
27084
28785
  ...planned.map((change) => ({ path: change.path, operation: pushOperationOf(change) }))
@@ -27109,7 +28810,12 @@ var SyncOrchestrator = class {
27109
28810
  updated: count("update") - convertedUpdates,
27110
28811
  deleted: count("delete"),
27111
28812
  moved: count("move") + preparedConversions.length + movableDatabases.length,
27112
- failed: [...refusedFailures, ...refusedDatabaseMoves],
28813
+ failed: [
28814
+ ...unverifiedFailures,
28815
+ ...refusedFailures,
28816
+ ...refusedDatabaseMoves,
28817
+ ...remoteRefusals
28818
+ ],
27113
28819
  duration: Date.now() - startTime,
27114
28820
  ...unsentDeletions2.length > 0 ? { unsentDeletions: unsentDeletions2 } : {},
27115
28821
  ...readOnlySkipped.length > 0 ? { readOnlySkipped } : {},
@@ -27123,7 +28829,7 @@ var SyncOrchestrator = class {
27123
28829
  this.stateDb.setMeta(PUSH_IN_PROGRESS_META_KEY, "true");
27124
28830
  await this.observation.resolveBotUserId();
27125
28831
  const counts = { created: 0, updated: 0, deleted: 0, moved: 0 };
27126
- const failed = [...refusedFailures];
28832
+ const failed = [...unverifiedFailures, ...refusedFailures];
27127
28833
  const edited = await this.sidecarEdits.apply(sidecarPlan.planned);
27128
28834
  failed.push(...edited.failed);
27129
28835
  const viewed = await this.viewEdits.apply(viewPlan.planned);
@@ -27418,8 +29124,10 @@ var SyncOrchestrator = class {
27418
29124
  const startTime = Date.now();
27419
29125
  this.assertConnectionScope();
27420
29126
  this.puller.beginPull();
29127
+ this.databaseSyncer.beginPull(this.puller.unlinkedVaultAtStart);
27421
29128
  this.rowSchemas.clear();
27422
29129
  this.databaseSyncer.takeRestoredRows();
29130
+ this.databaseSyncer.takeMergedRows();
27423
29131
  this.databaseSyncer.takeSchemaFollowed();
27424
29132
  this.databaseSyncer.takeHeldSidecars();
27425
29133
  this.databaseSyncer.takeHeldBases();
@@ -27459,6 +29167,8 @@ var SyncOrchestrator = class {
27459
29167
  const conflicts = [];
27460
29168
  const writtenPaths = [];
27461
29169
  const restoredPaths = [];
29170
+ const mergedPaths = [];
29171
+ const relinkedPaths = [];
27462
29172
  const excluded = [];
27463
29173
  const failed = [];
27464
29174
  let detected = [];
@@ -27505,10 +29215,15 @@ var SyncOrchestrator = class {
27505
29215
  await this.puller.removeVacatedFolders();
27506
29216
  const renamedRows = this.databaseSyncer.takeRenamedRows();
27507
29217
  restoredPaths.push(...this.databaseSyncer.takeRestoredRows());
29218
+ mergedPaths.push(...this.databaseSyncer.takeMergedRows());
29219
+ relinkedPaths.push(...this.databaseSyncer.takeRelinkedRows());
27508
29220
  const schemaFollowed = this.databaseSyncer.takeSchemaFollowed();
27509
29221
  const heldSidecars = this.databaseSyncer.takeHeldSidecars();
27510
29222
  const heldBases = this.databaseSyncer.takeHeldBases();
27511
- 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
+ ];
27512
29227
  const linkCount = writtenPaths.length > 0 ? await this.resolveNotionLinks(writtenPaths) : 0;
27513
29228
  if (!options?.paths) {
27514
29229
  const startedAt = new Date(startTime).toISOString();
@@ -27533,6 +29248,8 @@ var SyncOrchestrator = class {
27533
29248
  deleted,
27534
29249
  restored,
27535
29250
  ...restoredPaths.length > 0 ? { restoredPaths } : {},
29251
+ ...mergedPaths.length > 0 ? { mergedPaths } : {},
29252
+ ...relinkedPaths.length > 0 ? { relinkedPaths } : {},
27536
29253
  ...schemaFollowed.length > 0 ? { schemaFollowed } : {},
27537
29254
  ...heldSidecars.length > 0 ? { heldSidecars } : {},
27538
29255
  ...heldBases.length > 0 ? { heldBases } : {},
@@ -27605,7 +29322,8 @@ var SyncOrchestrator = class {
27605
29322
  },
27606
29323
  moved: [...relocated.moved, ...plan.moved ?? []],
27607
29324
  moveSkipped: relocated.skipped,
27608
- excluded: [...exclusions.values()]
29325
+ excluded: [...exclusions.values()],
29326
+ ...this.puller.unlinkedVaultAtStart && plan.created > 0 ? { relinkPending: true } : {}
27609
29327
  };
27610
29328
  }
27611
29329
  let pullTotal = workItems.length;
@@ -27687,6 +29405,14 @@ var SyncOrchestrator = class {
27687
29405
  excluded.push(created.path);
27688
29406
  return { path: created.path, unchanged: true };
27689
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
+ }
27690
29416
  writtenPaths.push(created.path);
27691
29417
  counts.created++;
27692
29418
  return { path: created.path, unchanged: false };
@@ -27695,6 +29421,9 @@ var SyncOrchestrator = class {
27695
29421
  const outcome = await this.puller.pullUpdate(change);
27696
29422
  if (outcome.action === "conflict") {
27697
29423
  conflicts.push(outcome.conflict);
29424
+ } else if (outcome.action === "merged") {
29425
+ counts.updated++;
29426
+ mergedPaths.push(outcome.path);
27698
29427
  } else if (outcome.action === "written") {
27699
29428
  writtenPaths.push(outcome.path);
27700
29429
  if (restoreIds.has(change.pageId)) {
@@ -27805,12 +29534,17 @@ var SyncOrchestrator = class {
27805
29534
  const onProgress = options?.onProgress;
27806
29535
  const tagged = (direction) => onProgress && ((current, total, item) => onProgress(current, total, { ...item, direction }));
27807
29536
  const pullResult = await this.executePull({ ...options, onProgress: tagged("pull") });
27808
- const pushResult = await this.executePush({
27809
- ...options,
27810
- onProgress: tagged("push"),
27811
- paths: options?.paths,
27812
- excludePaths: pullResult.writtenPaths
27813
- });
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
+ );
27814
29548
  return {
27815
29549
  pull: pullResult,
27816
29550
  push: pushResult,
@@ -27838,6 +29572,7 @@ var SyncOrchestrator = class {
27838
29572
  const detected = await this.drift.withoutUnchangedRemotes(detection.changes, signal);
27839
29573
  const moves = await this.followRemoteMoves({ dryRun: true }, plan);
27840
29574
  const renames = await this.followRemoteRenames(detected, { dryRun: true });
29575
+ const rowRenames = await this.previewRowRenames(detected, signal);
27841
29576
  const exclusions = await this.plannedExclusions(detected);
27842
29577
  const remoteChanges = detected.filter((change) => !exclusions.has(change.pageId));
27843
29578
  const conflictRecords = this.stateDb.getByStatus("conflict");
@@ -27858,11 +29593,45 @@ var SyncOrchestrator = class {
27858
29593
  lastSyncAt,
27859
29594
  lastFullScanAt,
27860
29595
  remoteScan: remoteScanInfo(scan, lastFullScanAt, this.config.sync.deleteSync),
27861
- remoteMoves: [...moves.moved, ...renames.renamed],
27862
- remoteMoveSkipped: [...moves.skipped, ...renames.skipped],
29596
+ remoteMoves: [...moves.moved, ...renames.renamed, ...rowRenames.renamed],
29597
+ remoteMoveSkipped: [...moves.skipped, ...renames.skipped, ...rowRenames.skipped],
27863
29598
  remoteExcluded: [...exclusions.values()]
27864
29599
  };
27865
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
+ }
27866
29635
  /**
27867
29636
  * 로컬만 본다 — 원격 기준을 정하지 않아 도는 작업과 겹쳐도 된다. 변경은 push 가 쓰는 판정 그대로다
27868
29637
  * (옮긴 노트 · 폴더 포함). 상태 DB 는 바꾸지 않는다.
@@ -27955,7 +29724,8 @@ var SyncOrchestrator = class {
27955
29724
  this.stateDb,
27956
29725
  this.vaultFs,
27957
29726
  paths,
27958
- 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)
27959
29729
  );
27960
29730
  }
27961
29731
  // ──────────────────────────────────────────────────────────────────────────
@@ -28010,6 +29780,19 @@ var SyncOrchestrator = class {
28010
29780
  async discardLocalChange(path) {
28011
29781
  return this.gate.run("discard", () => this.inspector.discardLocalChange(path));
28012
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
+ }
28013
29796
  /**
28014
29797
  * 볼트에서 만든 노트 폴더를 Notion DB 로 올릴 준비 — frontmatter · `.base` 로 사이드카를 써 둔다(#75).
28015
29798
  * Notion 에는 쓰지 않는다. 다음 push 가 DB 를 만들고 노트를 행으로 올린다(ADR-036 · ADR-037).
@@ -28141,10 +29924,14 @@ function matchesWithin(path, pattern) {
28141
29924
 
28142
29925
  // src/sync/node-vault-fs.ts
28143
29926
  var NodeVaultFS = class {
28144
- constructor(rootPath, pathConfig) {
29927
+ /**
29928
+ * @param ignoreFile 폴더의 `.im-nobsidian-ignore` 를 따른다 — 볼트 전체를 보는 쪽(다른 연결의 노트에서 링크를 고칠 때)은
29929
+ * 그 폴더를 맡은 연결의 제외 목록을 따르지 않는다.
29930
+ */
29931
+ constructor(rootPath, pathConfig, ignoreFile = true) {
28145
29932
  this.rootPath = rootPath;
28146
29933
  const configExclude = pathConfig?.exclude ?? [];
28147
- const ignorePatterns = this.loadImNobsidianIgnore();
29934
+ const ignorePatterns = ignoreFile ? this.loadImNobsidianIgnore() : [];
28148
29935
  this.filter = new PathFilter(pathConfig?.include ?? [], [...configExclude, ...ignorePatterns]);
28149
29936
  }
28150
29937
  rootPath;
@@ -29383,6 +31170,6 @@ function formatRatio(ratio) {
29383
31170
  return String(Math.round(ratio * 100) / 100);
29384
31171
  }
29385
31172
 
29386
- 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, 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, 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 };
29387
31174
  //# sourceMappingURL=index.js.map
29388
31175
  //# sourceMappingURL=index.js.map