@im-nobsidian/core 0.17.1 → 0.19.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
@@ -5994,6 +5994,9 @@ function anchorBlockOf(block) {
5994
5994
  text: (body?.rich_text ?? []).map((t) => t.plain_text ?? "").join("")
5995
5995
  };
5996
5996
  }
5997
+ function flattenBlockTree(blocks) {
5998
+ return blocks.flatMap((block) => [block, ...flattenBlockTree(block.children ?? [])]);
5999
+ }
5997
6000
  var ANCHORED_WIKILINK_RE = new LookbehindRegExp(
5998
6001
  /\[\[([^[\]|]*#[^[\]|]*?)\\?(?:\|[^[\]]+)?\]\]/g,
5999
6002
  void 0,
@@ -6037,7 +6040,7 @@ var WikilinkResolver = class {
6037
6040
  const target = display === void 0 ? written : withoutTableAliasEscape(written);
6038
6041
  const label = display ?? target;
6039
6042
  const anchored = this.anchorLink(target, display, input.context.anchors);
6040
- if (anchored) return anchored;
6043
+ if (anchored?.link) return anchored.link;
6041
6044
  if (this.resolve) {
6042
6045
  const entry = this.resolve(target);
6043
6046
  if (entry) {
@@ -6048,6 +6051,7 @@ var WikilinkResolver = class {
6048
6051
  }
6049
6052
  }
6050
6053
  const params = display ? { text: target, display } : { text: target };
6054
+ if (anchored?.miss) params.anchor = anchored.miss;
6051
6055
  params.__anchor = computeAnchor(input.content, base + offset);
6052
6056
  preserveMarkers.push({
6053
6057
  type: "wikilink",
@@ -6065,16 +6069,19 @@ var WikilinkResolver = class {
6065
6069
  }
6066
6070
  /**
6067
6071
  * `[[노트#제목]]` · `[[노트#^블록]]` · `[[#제목]]` → `[글자](https://www.notion.so/<페이지>#<블록>)`. 글자는 별칭,
6068
- * 없으면 Obsidian 이 보이는 「노트 > 제목」 — pull 이 그 글자면 별칭을 붙이지 않는다. 대상 페이지 · 블록을 못 찾으면 null.
6072
+ * 없으면 Obsidian 이 보이는 「노트 > 제목」 — pull 이 그 글자면 별칭을 붙이지 않는다. 앵커 링크가 아니면 null, 못 풀면
6073
+ * 그 까닭({@link AnchorMiss}).
6069
6074
  */
6070
6075
  anchorLink(target, display, anchors) {
6071
6076
  const split = anchors ? splitAnchor(target) : null;
6072
6077
  if (!anchors || !split) return null;
6073
6078
  const pageId = split.note === "" ? anchors.selfPageId : this.resolve?.(split.note)?.notionPageId ?? null;
6074
- if (!pageId) return null;
6079
+ if (!pageId) return { miss: "page" };
6075
6080
  const blockId = anchors.blockOf(pageId, split.anchor);
6076
- if (!blockId) return null;
6077
- return `[${display ?? anchorDisplay(split.note, split.anchor)}](${notionBlockUrl(pageId, blockId)})`;
6081
+ if (!blockId) return { miss: "block" };
6082
+ return {
6083
+ link: `[${display ?? anchorDisplay(split.note, split.anchor)}](${notionBlockUrl(pageId, blockId)})`
6084
+ };
6078
6085
  }
6079
6086
  };
6080
6087
 
@@ -12897,6 +12904,76 @@ function createDefaultPipeline(options) {
12897
12904
  return pipeline;
12898
12905
  }
12899
12906
 
12907
+ // src/sync/vault-file-index.ts
12908
+ var INDEX_MAX_AGE_MS = 5e3;
12909
+ function keyOf(path) {
12910
+ return path.normalize("NFC").toLowerCase();
12911
+ }
12912
+ function baseNameOf(path) {
12913
+ return path.slice(path.lastIndexOf("/") + 1);
12914
+ }
12915
+ function folderOf(path) {
12916
+ const slash = path.lastIndexOf("/");
12917
+ return slash === -1 ? "" : path.slice(0, slash);
12918
+ }
12919
+ var VaultFileIndex = class {
12920
+ constructor(vaultFs, now = Date.now) {
12921
+ this.vaultFs = vaultFs;
12922
+ this.now = now;
12923
+ }
12924
+ vaultFs;
12925
+ now;
12926
+ byName = null;
12927
+ builtAt = 0;
12928
+ /**
12929
+ * `linkpath`(이름 또는 경로 끝)로 끝나는 볼트 파일 — 가까운 것부터: 노트 폴더 안(얕은 것 먼저) → 짧은 경로 → 가나다. 지금
12930
+ * 있는 것만 돌려준다.
12931
+ */
12932
+ async find(linkpath, noteFolder) {
12933
+ if (this.byName === null || this.now() - this.builtAt > INDEX_MAX_AGE_MS) await this.rebuild();
12934
+ const key = keyOf(linkpath.replace(/^\/+/, ""));
12935
+ const suffix = `/${key}`;
12936
+ const matches = (this.byName?.get(keyOf(baseNameOf(key))) ?? []).filter((path) => {
12937
+ const k = keyOf(path);
12938
+ return k === key || k.endsWith(suffix);
12939
+ });
12940
+ const existing = [];
12941
+ for (const path of rank(matches, noteFolder)) {
12942
+ if (await this.vaultFs.exists(path)) existing.push(path);
12943
+ }
12944
+ return existing;
12945
+ }
12946
+ async rebuild() {
12947
+ const byName2 = /* @__PURE__ */ new Map();
12948
+ let files = [];
12949
+ try {
12950
+ files = await this.vaultFs.listNonMarkdownFiles() ?? [];
12951
+ } catch {
12952
+ }
12953
+ for (const file of files) {
12954
+ const key = keyOf(baseNameOf(file.path));
12955
+ const list = byName2.get(key) ?? [];
12956
+ list.push(file.path);
12957
+ byName2.set(key, list);
12958
+ }
12959
+ this.byName = byName2;
12960
+ this.builtAt = this.now();
12961
+ }
12962
+ };
12963
+ function rank(paths, noteFolder) {
12964
+ const inside2 = (path) => {
12965
+ if (noteFolder === void 0) return Number.POSITIVE_INFINITY;
12966
+ const folder = folderOf(path);
12967
+ if (noteFolder === "") return folder.split("/").filter(Boolean).length;
12968
+ if (folder !== noteFolder && !folder.startsWith(`${noteFolder}/`))
12969
+ return Number.POSITIVE_INFINITY;
12970
+ return folder.slice(noteFolder.length).split("/").filter(Boolean).length;
12971
+ };
12972
+ return [...paths].sort(
12973
+ (a, b) => inside2(a) - inside2(b) || a.length - b.length || a.localeCompare(b)
12974
+ );
12975
+ }
12976
+
12900
12977
  // src/utils/mime.ts
12901
12978
  var EXTENSION_TO_BLOCK_TYPE = {
12902
12979
  ".png": "image",
@@ -13018,11 +13095,11 @@ function getMimeType(filename) {
13018
13095
  }
13019
13096
 
13020
13097
  // src/sync/uploaded-media.ts
13021
- function keyOf(pageId) {
13098
+ function keyOf2(pageId) {
13022
13099
  return `uploaded_media:${compactNotionId(pageId)}`;
13023
13100
  }
13024
13101
  function rememberUploadedMedia(stateDb, pageId, media) {
13025
- const key = keyOf(pageId);
13102
+ const key = keyOf2(pageId);
13026
13103
  if (media.length === 0 && stateDb.getMeta(key) === null) return;
13027
13104
  stateDb.setMeta(key, JSON.stringify(media.map(rowOf)));
13028
13105
  }
@@ -13030,14 +13107,14 @@ function rememberReceivedMedia(stateDb, pageId, media) {
13030
13107
  if (media.length === 0) return;
13031
13108
  const merged = recallUploadedMedia(stateDb, pageId);
13032
13109
  for (const item of media) merged.set(item.fileId, item);
13033
- stateDb.setMeta(keyOf(pageId), JSON.stringify([...merged.values()].map(rowOf)));
13110
+ stateDb.setMeta(keyOf2(pageId), JSON.stringify([...merged.values()].map(rowOf)));
13034
13111
  }
13035
13112
  function rowOf({ fileId, target, localPath, fileHash }) {
13036
13113
  return fileHash === void 0 ? { fileId, target, localPath } : { fileId, target, localPath, fileHash };
13037
13114
  }
13038
13115
  function recallUploadedMedia(stateDb, pageId) {
13039
13116
  const known = /* @__PURE__ */ new Map();
13040
- const value = stateDb?.getMeta(keyOf(pageId));
13117
+ const value = stateDb?.getMeta(keyOf2(pageId));
13041
13118
  if (!value) return known;
13042
13119
  try {
13043
13120
  for (const row of JSON.parse(value)) {
@@ -13226,8 +13303,11 @@ function nameHashOf(fullHash) {
13226
13303
  var IMAGE_STEM_MAX = 50;
13227
13304
  function withoutOwnHash(name, hash) {
13228
13305
  if (hash.length !== NAME_HASH_LENGTH || !/^[0-9a-f]+$/.test(hash)) return name;
13229
- const stripped = name.replace(new RegExp(`-${hash}(\\.[^./]*)?$`), "");
13230
- return stripped === "" ? name : stripped;
13306
+ const tail = new RegExp(`-${hash}(\\.[^./]*)?$`).exec(name);
13307
+ const stem = tail ? name.slice(0, tail.index) : "";
13308
+ if (stem === "") return name;
13309
+ const extension = tail?.[1] ?? "";
13310
+ return extension && !stem.toLowerCase().endsWith(extension.toLowerCase()) ? `${stem}${extension}` : stem;
13231
13311
  }
13232
13312
  function imageStemOf(uploadedName, pageTitle) {
13233
13313
  const stem = safeImageStem(uploadedName.replace(/\.[^.]*$/, ""));
@@ -13270,7 +13350,7 @@ function localEmbedTargets(note) {
13270
13350
  }
13271
13351
  return targets;
13272
13352
  }
13273
- function folderOf(notePath) {
13353
+ function folderOf2(notePath) {
13274
13354
  return notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
13275
13355
  }
13276
13356
  function messageOf2(error) {
@@ -13371,7 +13451,7 @@ var ImageHandler = class {
13371
13451
  if (!target || /^https?:\/\//.test(target) || target.includes("\n")) return null;
13372
13452
  const vaultPath = stripAlias2(target);
13373
13453
  if (baseName(vaultPath) !== uploadedName) return null;
13374
- for (const candidate of this.vaultCandidates(vaultPath, noteFolder)) {
13454
+ for await (const candidate of this.vaultCandidates(vaultPath, noteFolder)) {
13375
13455
  try {
13376
13456
  if (await this.vaultFs.exists(candidate)) return target;
13377
13457
  } catch {
@@ -13411,7 +13491,7 @@ var ImageHandler = class {
13411
13491
  const spans = notionMediaSpans(markdown);
13412
13492
  if (spans.length === 0) return markdown;
13413
13493
  const known = pageId ? recallUploadedMedia(this.stateDb, pageId) : /* @__PURE__ */ new Map();
13414
- const noteFolder = folderOf(notePath);
13494
+ const noteFolder = folderOf2(notePath);
13415
13495
  let result = markdown;
13416
13496
  for (const span of spans) {
13417
13497
  const uploadedName = this.uploadedNameOf(span.url);
@@ -13441,7 +13521,7 @@ var ImageHandler = class {
13441
13521
  return null;
13442
13522
  }
13443
13523
  for (const target of targets) {
13444
- for (const candidate of this.vaultCandidates(stripAlias2(target), noteFolder)) {
13524
+ for await (const candidate of this.vaultCandidates(stripAlias2(target), noteFolder)) {
13445
13525
  let localHash;
13446
13526
  try {
13447
13527
  localHash = sha256Of(await this.vaultFs.readBinary(candidate));
@@ -13584,7 +13664,7 @@ var ImageHandler = class {
13584
13664
  * @returns 보일 이름은 받을 때 붙인 내용 해시를 뗀 것이다 — 다시 받으면 같은 이름 · 같은 경로가 된다.
13585
13665
  */
13586
13666
  async uploadNoteAttachment(target, notePath) {
13587
- const uploaded = await this.uploadLocalImage(target, folderOf(notePath));
13667
+ const uploaded = await this.uploadLocalImage(target, folderOf2(notePath));
13588
13668
  const base = uploaded.localPath.split("/").pop() ?? uploaded.localPath;
13589
13669
  const name = withoutOwnHash(base, nameHashOf(uploaded.fileHash));
13590
13670
  const extension = /\.[^./]+$/.exec(base)?.[0] ?? "";
@@ -13597,7 +13677,7 @@ var ImageHandler = class {
13597
13677
  if (!this.notionClient) return [];
13598
13678
  const localImages = images.filter((img) => !img.isExternal && img.localPath);
13599
13679
  if (localImages.length === 0) return [];
13600
- const noteFolder = folderOf(notePath);
13680
+ const noteFolder = folderOf2(notePath);
13601
13681
  const results = [];
13602
13682
  const imageBlocks = [];
13603
13683
  for (const img of localImages) {
@@ -13684,7 +13764,7 @@ var ImageHandler = class {
13684
13764
  }
13685
13765
  return segment;
13686
13766
  });
13687
- const noteFolder = folderOf(notePath);
13767
+ const noteFolder = folderOf2(notePath);
13688
13768
  const kept = [];
13689
13769
  let result = "";
13690
13770
  let cursor = 0;
@@ -13775,7 +13855,7 @@ var ImageHandler = class {
13775
13855
  if (kept.length > 0) this.rememberUploads(pageId, kept);
13776
13856
  return empty;
13777
13857
  }
13778
- const noteFolder = folderOf(notePath);
13858
+ const noteFolder = folderOf2(notePath);
13779
13859
  const uploaded = [];
13780
13860
  const remembered = [...kept];
13781
13861
  const unsupported2 = [];
@@ -13933,29 +14013,37 @@ var ImageHandler = class {
13933
14013
  /**
13934
14014
  * 임베드 경로가 가리킬 수 있는 볼트 실제 파일 후보를 우선순위대로 만든다(F23).
13935
14015
  * Obsidian 위키링크 임베드는 노트 옆 파일을 파일명만으로 참조하는 게 관례라
13936
- * 노트 폴더 → 볼트 루트 → attachments/ 순.
14016
+ * 노트 폴더 → 볼트 루트 → attachments/ 순. 세 곳에 없으면 볼트 어디서든 이름으로 찾는다({@link VaultFileIndex}).
13937
14017
  *
13938
14018
  * push(업로드)와 pull(원본 복원)이 **같은 사다리**를 봐야 왕복이 닫힌다. 예전에는
13939
14019
  * pull 쪽이 볼트 루트만 확인해서, `__e2e_probe__/노트.md` 안의 `![[t-img.png]]` 는
13940
14020
  * 올라갈 때는 노트 폴더에서 잘 찾아 올리고도 내려올 때는 원본을 못 찾아 사본을
13941
14021
  * 새로 받았다(실측) — 왕복할 때마다 첨부가 한 벌씩 늘어난다.
13942
14022
  */
13943
- vaultCandidates(localPath, noteFolder) {
13944
- const candidates = [];
14023
+ async *vaultCandidates(localPath, noteFolder) {
14024
+ const ladder = [];
13945
14025
  if (noteFolder && !localPath.includes("/")) {
13946
- candidates.push(`${noteFolder}/${localPath}`);
14026
+ ladder.push(`${noteFolder}/${localPath}`);
14027
+ }
14028
+ ladder.push(localPath, `${this.attachmentFolder}/${localPath}`);
14029
+ yield* ladder;
14030
+ for (const found2 of await this.fileIndex.find(localPath, noteFolder)) {
14031
+ if (!ladder.includes(found2)) yield found2;
13947
14032
  }
13948
- candidates.push(localPath, `${this.attachmentFolder}/${localPath}`);
13949
- return candidates;
14033
+ }
14034
+ indexOfFiles = null;
14035
+ /** 볼트의 첨부 이름표 — 처음 쓸 때 만든다. */
14036
+ get fileIndex() {
14037
+ this.indexOfFiles ??= new VaultFileIndex(this.vaultFs);
14038
+ return this.indexOfFiles;
13950
14039
  }
13951
14040
  /**
13952
14041
  * 임베드 경로를 볼트 실제 파일로 해석해 읽는다(F23).
13953
14042
  * 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
13954
14043
  */
13955
14044
  async readImageFromVault(localPath, noteFolder) {
13956
- const candidates = this.vaultCandidates(localPath, noteFolder);
13957
14045
  let lastError;
13958
- for (const candidate of candidates) {
14046
+ for await (const candidate of this.vaultCandidates(localPath, noteFolder)) {
13959
14047
  try {
13960
14048
  return { buffer: await this.vaultFs.readBinary(candidate), resolvedPath: candidate };
13961
14049
  } catch (error) {
@@ -14148,12 +14236,20 @@ var ImageHandler = class {
14148
14236
  const hash = nameHashOf(fileHash);
14149
14237
  const extension = this.guessFileExtension(url, response.headers.get("content-type"));
14150
14238
  const name = withoutOwnHash(caption, hash);
14151
- const safeName = truncateBytes(
14152
- name.replace(/[^a-zA-Z0-9가-힣\s._-]/g, "").replace(/\s+/g, "-").slice(0, 80),
14153
- FILE_SYSTEM_NAME_MAX_BYTES - utf8Bytes(`-${hash}${extension}`)
14154
- );
14155
- const fileName2 = `${safeName}-${hash}${extension}`;
14156
- const localPath = `${this.attachmentFolder}/${fileName2}`;
14239
+ const hasExtension2 = extension !== "" && name.toLowerCase().endsWith(extension.toLowerCase());
14240
+ const stem = hasExtension2 ? name.slice(0, -extension.length) : name;
14241
+ const localPath = `${this.attachmentFolder}/${this.fileNameOf(stem, hash, extension)}`;
14242
+ const legacyPath = `${this.attachmentFolder}/${this.fileNameOf(name, hash, extension)}`;
14243
+ if (hasExtension2 && await this.vaultFs.exists(legacyPath)) {
14244
+ return {
14245
+ originalUrl: url,
14246
+ localPath: legacyPath,
14247
+ hash,
14248
+ size: buffer.length,
14249
+ fileHash,
14250
+ name
14251
+ };
14252
+ }
14157
14253
  await this.vaultFs.ensureFolder(this.attachmentFolder);
14158
14254
  await this.vaultFs.writeBinary(localPath, buffer);
14159
14255
  return { originalUrl: url, localPath, hash, size: buffer.length, fileHash, name };
@@ -14163,6 +14259,14 @@ var ImageHandler = class {
14163
14259
  }
14164
14260
  throw lastError;
14165
14261
  }
14262
+ /** 받은 파일의 이름 — `<줄기>-<해시><확장자>`. */
14263
+ fileNameOf(stem, hash, extension) {
14264
+ const safeName = truncateBytes(
14265
+ stem.replace(/[^a-zA-Z0-9가-힣\s._-]/g, "").replace(/\s+/g, "-").slice(0, 80),
14266
+ FILE_SYSTEM_NAME_MAX_BYTES - utf8Bytes(`-${hash}${extension}`)
14267
+ );
14268
+ return `${safeName}-${hash}${extension}`;
14269
+ }
14166
14270
  isNotionImageUrl(url) {
14167
14271
  return this.isNotionFileUrl(url);
14168
14272
  }
@@ -15049,7 +15153,74 @@ function selectStaleDbArtifacts(files, localFolder, keepPaths) {
15049
15153
  return stale;
15050
15154
  }
15051
15155
 
15156
+ // src/sync/folder-files.ts
15157
+ function unescapeCaption(caption) {
15158
+ return caption.replace(/\\([\\`*_{}[\]()#+\-.!|~<>$&])/g, "$1").trim();
15159
+ }
15160
+ function folderFilesOf(markdown, namedOnly) {
15161
+ let rest = markdown.replace(CHILD_PAGE_TAG_RE, "").replace(CHILD_DATABASE_TAG_RE, "").replace(/<empty-block\/>/g, "");
15162
+ const files = [];
15163
+ for (const span of notionMediaSpans(rest)) {
15164
+ if (!/^https?:\/\//.test(span.url)) return null;
15165
+ const name = uploadedFileNameFromUrl(span.url);
15166
+ if (!name || namedOnly && unescapeCaption(span.label) !== name) return null;
15167
+ rest = rest.replace(span.full, "");
15168
+ files.push({ url: span.url, name });
15169
+ }
15170
+ return files.length > 0 && rest.trim() === "" ? files : null;
15171
+ }
15172
+ var WIKI_EMBED_RE = /!\[\[([^\]|#^\n]+)/g;
15173
+ var MD_EMBED_RE = /!\[[^\]\n]*\]\(<?([^)>\s]+)/g;
15174
+ var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
15175
+ var WIKILINK_TARGET_RE = /\[\[([^\]|#^\n]+)/g;
15176
+ function baseNameOf2(target) {
15177
+ let decoded = target.trim();
15178
+ try {
15179
+ decoded = decodeURI(decoded);
15180
+ } catch {
15181
+ }
15182
+ return decoded.slice(decoded.lastIndexOf("/") + 1).normalize("NFC");
15183
+ }
15184
+ function embeddedNamesOf(notes) {
15185
+ const names = /* @__PURE__ */ new Set();
15186
+ for (const text of notes) {
15187
+ for (const m of text.matchAll(WIKI_EMBED_RE)) names.add(baseNameOf2(m[1]));
15188
+ for (const m of text.matchAll(MD_EMBED_RE)) names.add(baseNameOf2(m[1]));
15189
+ const frontmatter = FRONTMATTER_RE.exec(text)?.[1];
15190
+ if (frontmatter) {
15191
+ for (const m of frontmatter.matchAll(WIKILINK_TARGET_RE)) names.add(baseNameOf2(m[1]));
15192
+ }
15193
+ }
15194
+ return names;
15195
+ }
15196
+ function keyOf3(pageId) {
15197
+ return `folder_files:${compactNotionId(pageId)}`;
15198
+ }
15199
+ function folderFileIdsOf(stateDb, pageId) {
15200
+ const raw = stateDb.getMeta(keyOf3(pageId));
15201
+ if (!raw) return /* @__PURE__ */ new Map();
15202
+ try {
15203
+ return new Map(Object.entries(JSON.parse(raw)));
15204
+ } catch {
15205
+ return /* @__PURE__ */ new Map();
15206
+ }
15207
+ }
15208
+ function forgetFolderFileId(stateDb, pageId, localPath) {
15209
+ const ids = folderFileIdsOf(stateDb, pageId);
15210
+ if (!ids.delete(localPath)) return;
15211
+ stateDb.setMeta(keyOf3(pageId), JSON.stringify(Object.fromEntries(ids)));
15212
+ }
15213
+ function rememberFolderFileId(stateDb, pageId, localPath, fileId) {
15214
+ const ids = folderFileIdsOf(stateDb, pageId);
15215
+ if (ids.get(localPath) === fileId) return;
15216
+ ids.set(localPath, fileId);
15217
+ stateDb.setMeta(keyOf3(pageId), JSON.stringify(Object.fromEntries(ids)));
15218
+ }
15219
+
15052
15220
  // src/sync/file-handler.ts
15221
+ function isHiddenPath(path) {
15222
+ return path.split("/").some((part) => part.startsWith("."));
15223
+ }
15053
15224
  function getFolderPath(filePath) {
15054
15225
  const parts = filePath.split("/");
15055
15226
  return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
@@ -15064,6 +15235,8 @@ var FileHandler = class {
15064
15235
  this.customFetch = options?.fetch;
15065
15236
  this.downloadTimeoutMs = options?.downloadTimeoutMs ?? DEFAULT_CONFIG.advanced.mediaDownloadTimeoutMs;
15066
15237
  this.itemTimeoutMs = options?.itemTimeoutMs ?? DEFAULT_CONFIG.advanced.itemTimeoutMs;
15238
+ this.deleteSync = options?.deleteSync ?? false;
15239
+ this.attachmentsFolder = (options?.attachmentsFolder ?? DEFAULT_CONFIG.paths.attachments).replace(/^\/+|\/+$/g, "");
15067
15240
  }
15068
15241
  vaultFs;
15069
15242
  notionClient;
@@ -15072,6 +15245,8 @@ var FileHandler = class {
15072
15245
  customFetch;
15073
15246
  downloadTimeoutMs;
15074
15247
  itemTimeoutMs;
15248
+ attachmentsFolder;
15249
+ deleteSync;
15075
15250
  get fetchFn() {
15076
15251
  return this.customFetch ?? globalThis.fetch;
15077
15252
  }
@@ -15081,7 +15256,9 @@ var FileHandler = class {
15081
15256
  if (folderFiles.length === 0) return [];
15082
15257
  const results = [];
15083
15258
  for (const file of folderFiles) {
15084
- const result = await this.pushSingleFile(file, folderPageId);
15259
+ const existing = this.stateDb.getFileRegistry(file.path);
15260
+ if (existing && await this.isUnchanged(existing, file)) continue;
15261
+ const result = await this.pushSingleFile(file, folderPageId, existing);
15085
15262
  if (result) results.push(result);
15086
15263
  }
15087
15264
  return results;
@@ -15102,18 +15279,11 @@ var FileHandler = class {
15102
15279
  * 내면 같은 말이 두 번 나온다). 없으면 경고 로그로만 남는다.
15103
15280
  * @returns 업로드 결과. 변경 없음(스킵)이거나 실패했으면 null.
15104
15281
  */
15105
- async pushSingleFile(file, folderPageId, failed) {
15106
- const existing = this.stateDb.getFileRegistry(file.path);
15107
- if (existing) {
15108
- if (existing.fileSize === file.size) return null;
15109
- const buffer = await this.vaultFs.readBinary(file.path);
15110
- const currentHash = createHash("sha256").update(buffer).digest("hex");
15111
- if (existing.fileHash === currentHash) return null;
15112
- }
15282
+ async pushSingleFile(file, folderPageId, existing, failed) {
15113
15283
  await this.sema.acquire();
15114
15284
  try {
15115
15285
  return await withDeadline(
15116
- () => this.uploadFile(file, folderPageId),
15286
+ () => this.uploadFile(file, folderPageId, existing),
15117
15287
  this.itemTimeoutMs,
15118
15288
  `\uCCA8\uBD80 \uC5C5\uB85C\uB4DC ${file.path}`
15119
15289
  );
@@ -15134,6 +15304,22 @@ var FileHandler = class {
15134
15304
  this.sema.release();
15135
15305
  }
15136
15306
  }
15307
+ /** 올린 뒤 바뀌지 않았나 — 크기가 같으면 읽지 않는다. */
15308
+ async isUnchanged(existing, file) {
15309
+ if (existing.fileSize === file.size) return true;
15310
+ const buffer = await this.vaultFs.readBinary(file.path);
15311
+ return existing.fileHash === createHash("sha256").update(buffer).digest("hex");
15312
+ }
15313
+ /** pull 이 받은 것을 두는 폴더 안인가. */
15314
+ inAttachmentsFolder(path) {
15315
+ return this.attachmentsFolder !== "" && path.startsWith(`${this.attachmentsFolder}/`);
15316
+ }
15317
+ /** 폴더 첨부를 둘 Notion 페이지 — 폴더 노트 없는 폴더의 페이지(폴더 레코드)만. 맨 위는 루트 기록이 있을 때만. */
15318
+ folderPageOf(folder) {
15319
+ const record = this.stateDb.getByPath(folder || "__root__");
15320
+ if (!record?.notionPageId) return null;
15321
+ return folder === "" || isFolderRecord(record) ? record.notionPageId : null;
15322
+ }
15137
15323
  /**
15138
15324
  * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 아예 빼고, Notion 이 받지 않는
15139
15325
  * 형식은 따로 모은다.
@@ -15160,7 +15346,7 @@ var FileHandler = class {
15160
15346
  * @param failed 올리지 못한 첨부를 담을 곳 — push 결과의 `failed` 를 넘기면 CLI · 플러그인이 처리하지 못한 것으로
15161
15347
  * 보인다. 예전엔 경고 로그로만 남아 push 가 「실패 0」 으로 끝났다.
15162
15348
  */
15163
- async pushAllFiles(failed) {
15349
+ async pushAllFiles(failed, folders) {
15164
15350
  const { uploadable: allFiles, unsupported: unsupported2 } = await this.listUploadableFiles();
15165
15351
  if (unsupported2.length > 0) {
15166
15352
  const names = unsupported2.slice(0, LOGGED_NAMES).map((f) => f.path);
@@ -15169,32 +15355,225 @@ var FileHandler = class {
15169
15355
  `[Im-Nobsidian] Notion \uC774 \uBC1B\uC9C0 \uC54A\uB294 \uD615\uC2DD\uC774\uB77C \uC62C\uB9AC\uC9C0 \uC54A\uC740 \uCCA8\uBD80 ${unsupported2.length}\uAC1C: ${names.join(", ")}${rest > 0 ? ` \uC678 ${rest}\uAC1C` : ""}`
15170
15356
  );
15171
15357
  }
15172
- if (allFiles.length === 0) return [];
15173
- const filesByFolder = /* @__PURE__ */ new Map();
15358
+ if (this.deleteSync) await this.removeDeletedFolderFiles();
15359
+ const pending = [];
15174
15360
  for (const file of allFiles) {
15175
- const folder = getFolderPath(file.path);
15176
- const list = filesByFolder.get(folder) ?? [];
15177
- list.push(file);
15178
- filesByFolder.set(folder, list);
15361
+ if (this.inAttachmentsFolder(file.path) || isHiddenPath(file.path)) continue;
15362
+ const existing = this.stateDb.getFileRegistry(file.path);
15363
+ if (existing) {
15364
+ if (existing.notionPageId !== this.folderPageOf(getFolderPath(file.path))) continue;
15365
+ if (await this.isUnchanged(existing, file)) continue;
15366
+ }
15367
+ pending.push({ file, existing });
15368
+ }
15369
+ if (pending.length === 0) return [];
15370
+ const embedded = pending.some((p) => !p.existing) ? await this.embeddedNames() : /* @__PURE__ */ new Set();
15371
+ const byFolder = /* @__PURE__ */ new Map();
15372
+ for (const item of pending) {
15373
+ if (!item.existing && embedded.has(item.file.path.slice(item.file.path.lastIndexOf("/") + 1).normalize("NFC"))) {
15374
+ continue;
15375
+ }
15376
+ const folder = getFolderPath(item.file.path);
15377
+ const list = byFolder.get(folder) ?? [];
15378
+ list.push(item);
15379
+ byFolder.set(folder, list);
15179
15380
  }
15180
15381
  const results = [];
15181
- for (const [folderPath, files] of filesByFolder) {
15182
- const folderRecord = folderPath ? this.stateDb.getByPath(folderPath) : this.stateDb.getByPath("__root__");
15183
- const folderPageId = folderRecord?.notionPageId;
15184
- if (!folderPageId) {
15185
- if (files.length > 0) {
15186
- getLogger().debug(
15187
- `\uD3F4\uB354 \uD398\uC774\uC9C0 \uC5C6\uC74C, \uD30C\uC77C \uC2A4\uD0B5: ${folderPath || "(\uB8E8\uD2B8)"} (${files.length}\uAC1C)`
15382
+ const stranded = [];
15383
+ for (const [folder, items] of byFolder) {
15384
+ const pageId = await this.provideFolderPage(folder, folders, items, stranded);
15385
+ if (!pageId) continue;
15386
+ for (const { file, existing } of items) {
15387
+ const result = await this.pushSingleFile(file, pageId, existing, failed);
15388
+ if (result) results.push(result);
15389
+ }
15390
+ }
15391
+ this.reportStranded(stranded);
15392
+ return results;
15393
+ }
15394
+ /**
15395
+ * 볼트에서 지운 폴더 첨부를 Notion 에서도 지운다(deleteSync) — 폴더 페이지에 적힌 파일 가운데 볼트에 없는 것의 블록과 기록.
15396
+ * 포함 · 제외 패턴으로 빠진 파일은 볼트에 있어 지우지 않는다.
15397
+ */
15398
+ async removeDeletedFolderFiles() {
15399
+ const removed = [];
15400
+ for (const record of this.stateDb.getAll()) {
15401
+ if (!record.notionPageId || !isFolderRecord(record)) continue;
15402
+ for (const entry of this.stateDb.getFilesByPageId(record.notionPageId)) {
15403
+ if (getFolderPath(entry.localPath) !== record.obsidianPath) continue;
15404
+ if (await this.vaultFs.exists(entry.localPath)) continue;
15405
+ try {
15406
+ const name = entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1);
15407
+ await this.removeOlderBlocks(record.notionPageId, name, void 0);
15408
+ this.stateDb.deleteFileRegistry(entry.localPath);
15409
+ forgetFolderFileId(this.stateDb, record.notionPageId, entry.localPath);
15410
+ removed.push(entry.localPath);
15411
+ } catch (error) {
15412
+ getLogger().warn(
15413
+ `[Im-Nobsidian] \uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80\uB97C Notion \uC5D0\uC11C \uC9C0\uC6B0\uC9C0 \uBABB\uD568 (${entry.localPath}): ${error instanceof Error ? error.message : String(error)}`
15188
15414
  );
15189
15415
  }
15416
+ }
15417
+ }
15418
+ if (removed.length > 0) {
15419
+ getLogger().info(
15420
+ `[Im-Nobsidian] \uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80 ${removed.length}\uAC1C\uB97C Notion \uC5D0\uC11C \uC9C0\uC6C0`
15421
+ );
15422
+ }
15423
+ }
15424
+ /** 폴더 첨부를 둘 페이지 — 노트 없는 폴더면 마련한다. 둘 곳이 없으면 그 파일들을 `stranded` 에 담고 null. */
15425
+ async provideFolderPage(folder, folders, items, stranded) {
15426
+ let pageId = this.folderPageOf(folder);
15427
+ let reason = folder === "" ? "\uBCFC\uD2B8 \uB9E8 \uC704" : "\uD3F4\uB354 \uB178\uD2B8\uAC00 \uC788\uB294 \uD3F4\uB354 \xB7 DB \uD3F4\uB354";
15428
+ if (!pageId && folder !== "" && folders && !this.stateDb.getByPath(folder)) {
15429
+ try {
15430
+ await folders.ensureFolderPage(folder);
15431
+ pageId = this.folderPageOf(folder);
15432
+ } catch (error) {
15433
+ reason = error instanceof Error ? error.message : String(error);
15434
+ }
15435
+ }
15436
+ if (!pageId) stranded.push(...items.map(({ file }) => ({ path: file.path, reason })));
15437
+ return pageId;
15438
+ }
15439
+ /**
15440
+ * 둘 곳이 없어 올리지 않은 폴더 첨부 — 경고로 알린다. 예전에는 디버그 로그로만 남아 파일이 Notion 에 가지 않은 것을 알 수
15441
+ * 없었다. 노트에 붙이면 그 노트와 함께 올라간다.
15442
+ */
15443
+ reportStranded(stranded) {
15444
+ if (stranded.length === 0) return;
15445
+ const names = stranded.slice(0, LOGGED_NAMES).map((f) => `${f.path}(${f.reason})`);
15446
+ const rest = stranded.length - names.length;
15447
+ getLogger().warn(
15448
+ `[Im-Nobsidian] \uD3F4\uB354\uC5D0 Notion \uC790\uB9AC\uAC00 \uC5C6\uC5B4 \uC62C\uB9AC\uC9C0 \uC54A\uC740 \uCCA8\uBD80 ${stranded.length}\uAC1C \u2014 \uB178\uD2B8\uC5D0 \uBD99\uC774\uBA74 \uD568\uAED8 \uC62C\uB77C\uAC04\uB2E4: ${names.join(", ")}${rest > 0 ? ` \uC678 ${rest}\uAC1C` : ""}`
15449
+ );
15450
+ }
15451
+ /** 볼트의 노트가 붙인 파일 이름 — {@link embeddedNamesOf}. 읽지 못한 노트는 건너뛴다. */
15452
+ async embeddedNames() {
15453
+ const texts = [];
15454
+ for (const note of await this.vaultFs.listMarkdownFiles()) {
15455
+ try {
15456
+ texts.push(await this.vaultFs.readFile(note.path));
15457
+ } catch {
15458
+ }
15459
+ }
15460
+ return embeddedNamesOf(texts);
15461
+ }
15462
+ /**
15463
+ * 폴더 페이지의 폴더 첨부를 그 폴더에 받는다 — 올린 이름 그대로. Notion 파일 id 가 적어 둔 것과 같으면(올린 볼트 · 앞서
15464
+ * 받은 볼트) 받지 않는다. Notion 에서 바뀐 파일은 볼트 쪽이 그대로일 때만 덮는다 — 볼트에서도 고쳤으면 두고 알린다(다음
15465
+ * push 가 볼트의 것을 올린다).
15466
+ *
15467
+ * @param complete 본문을 끝까지 받았나 — 그럴 때만 Notion 에서 지운 파일을 볼트에서 지운다(deleteSync). 잘린 본문에 없는
15468
+ * 파일은 지운 것이 아니다.
15469
+ * @returns 새로 받거나 바꾼 파일 수
15470
+ */
15471
+ async receiveFolderFiles(folder, pageId, files, complete2 = false) {
15472
+ const known = new Map(
15473
+ this.stateDb.getFilesByPageId(pageId).map((entry) => [entry.localPath, entry])
15474
+ );
15475
+ const ids = folderFileIdsOf(this.stateDb, pageId);
15476
+ let received = 0;
15477
+ for (const file of files) {
15478
+ const localPath = folder ? `${folder}/${file.name}` : file.name;
15479
+ const fileId = notionFileIdOf(file.url);
15480
+ const entry = known.get(localPath);
15481
+ if (entry && fileId && ids.get(localPath) === fileId) continue;
15482
+ if (entry && fileId && !ids.has(localPath)) {
15483
+ rememberFolderFileId(this.stateDb, pageId, localPath, fileId);
15190
15484
  continue;
15191
15485
  }
15192
- for (const file of files) {
15193
- const result = await this.pushSingleFile(file, folderPageId, failed);
15194
- if (result) results.push(result);
15486
+ if (entry && !fileId) continue;
15487
+ await this.sema.acquire();
15488
+ try {
15489
+ if (await this.receiveOne(localPath, folder, pageId, file, entry ?? null)) received++;
15490
+ if (fileId) rememberFolderFileId(this.stateDb, pageId, localPath, fileId);
15491
+ } catch (error) {
15492
+ getLogger().warn(
15493
+ `[Im-Nobsidian] \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBC1B\uC9C0 \uBABB\uD568 (${localPath}): ${error instanceof Error ? error.message : String(error)}`
15494
+ );
15495
+ } finally {
15496
+ this.sema.release();
15195
15497
  }
15196
15498
  }
15197
- return results;
15499
+ if (complete2 && this.deleteSync) {
15500
+ const names = new Set(files.map((file) => file.name));
15501
+ for (const entry of known.values()) {
15502
+ const name = entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1);
15503
+ if (getFolderPath(entry.localPath) !== folder || names.has(name)) continue;
15504
+ if (!ids.has(entry.localPath)) continue;
15505
+ await this.removeLocalFolderFile(pageId, entry);
15506
+ }
15507
+ }
15508
+ return received;
15509
+ }
15510
+ /**
15511
+ * Notion 에서 지운 폴더 첨부를 볼트에서 지운다 — 지난 동기화 그대로일 때만. 볼트에서 고쳤으면 두고 기록만 놓는다 — 다음 push
15512
+ * 가 다시 올린다(노트의 원격 삭제와 같은 규칙).
15513
+ */
15514
+ async removeLocalFolderFile(pageId, entry) {
15515
+ try {
15516
+ if (await this.vaultFs.exists(entry.localPath)) {
15517
+ const local = createHash("sha256").update(await this.vaultFs.readBinary(entry.localPath)).digest("hex");
15518
+ if (local === entry.fileHash) {
15519
+ await this.vaultFs.deleteFile(entry.localPath);
15520
+ getLogger().info(
15521
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6C0: ${entry.localPath}`
15522
+ );
15523
+ } else {
15524
+ getLogger().warn(
15525
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uACE0\uCCD0 \uB450\uC5C8\uC74C \u2014 \uB2E4\uC74C push \uAC00 \uB2E4\uC2DC \uC62C\uB9B0\uB2E4: ${entry.localPath}`
15526
+ );
15527
+ }
15528
+ }
15529
+ this.stateDb.deleteFileRegistry(entry.localPath);
15530
+ forgetFolderFileId(this.stateDb, pageId, entry.localPath);
15531
+ } catch (error) {
15532
+ getLogger().warn(
15533
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B0\uC9C0 \uBABB\uD568 (${entry.localPath}): ${error instanceof Error ? error.message : String(error)}`
15534
+ );
15535
+ }
15536
+ }
15537
+ /**
15538
+ * 폴더 첨부 하나를 받는다. 볼트에 같은 경로가 있으면: 같은 내용이면 적기만 하고, 지난 동기화 뒤 볼트에서 그대로인 파일은
15539
+ * 덮고(`entry`), 그 밖(볼트에서 고친 것 · 처음 보는 다른 파일)은 두고 알린다.
15540
+ *
15541
+ * @returns 볼트에 썼는가
15542
+ */
15543
+ async receiveOne(localPath, folder, pageId, file, entry) {
15544
+ const response = await fetchForDownload(this.fetchFn, file.url, this.downloadTimeoutMs);
15545
+ if (!response.ok) {
15546
+ throw new Error(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
15547
+ }
15548
+ const buffer = Buffer.from(await response.arrayBuffer());
15549
+ const hash = createHash("sha256").update(buffer).digest("hex");
15550
+ let wrote = false;
15551
+ if (await this.vaultFs.exists(localPath)) {
15552
+ const localHash = createHash("sha256").update(await this.vaultFs.readBinary(localPath)).digest("hex");
15553
+ if (localHash !== hash) {
15554
+ if (entry?.fileHash !== localHash) {
15555
+ getLogger().warn(
15556
+ `[Im-Nobsidian] \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBC1B\uC9C0 \uC54A\uC74C \u2014 \uBCFC\uD2B8\uC758 ${localPath} \uAC00 Notion \uC758 \uAC83\uACFC \uB2E4\uB974\uB2E4${entry ? "(\uC591\uCABD\uC5D0\uC11C \uACE0\uCE68 \u2014 \uB2E4\uC74C push \uAC00 \uBCFC\uD2B8\uC758 \uAC83\uC744 \uC62C\uB9B0\uB2E4)" : "(\uAC19\uC740 \uC774\uB984\uC758 \uB2E4\uB978 \uD30C\uC77C)"}`
15557
+ );
15558
+ return false;
15559
+ }
15560
+ await this.vaultFs.writeBinary(localPath, buffer);
15561
+ wrote = true;
15562
+ }
15563
+ } else {
15564
+ await this.vaultFs.ensureFolder(folder || ".");
15565
+ await this.vaultFs.writeBinary(localPath, buffer);
15566
+ wrote = true;
15567
+ }
15568
+ this.stateDb.registerFile({
15569
+ localPath,
15570
+ notionPageId: pageId,
15571
+ fileUploadId: "",
15572
+ fileType: getBlockType(file.name),
15573
+ fileHash: hash,
15574
+ fileSize: buffer.length
15575
+ });
15576
+ return wrote;
15198
15577
  }
15199
15578
  async downloadFileBlock(url, filename, targetFolder) {
15200
15579
  const response = await fetchForDownload(this.fetchFn, url, this.downloadTimeoutMs);
@@ -15225,7 +15604,7 @@ var FileHandler = class {
15225
15604
  }
15226
15605
  return results;
15227
15606
  }
15228
- async uploadFile(file, parentPageId) {
15607
+ async uploadFile(file, parentPageId, previous) {
15229
15608
  const buffer = await this.vaultFs.readBinary(file.path);
15230
15609
  const filename = file.path.split("/").pop() ?? "file";
15231
15610
  const contentType = getMimeType(filename);
@@ -15233,8 +15612,12 @@ var FileHandler = class {
15233
15612
  const hash = createHash("sha256").update(buffer).digest("hex");
15234
15613
  const blob = new Blob([buffer], { type: contentType });
15235
15614
  const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
15236
- const block = this.createFileBlock(blockType, fileUploadId);
15237
- await this.notionClient.appendChildren(parentPageId, [block]);
15615
+ const block = this.createFileBlock(blockType, fileUploadId, filename);
15616
+ const [created] = await this.notionClient.appendChildBlocks(parentPageId, [block]);
15617
+ if (previous) await this.removeOlderBlocks(parentPageId, filename, created?.id);
15618
+ const url = created ? fileUrlOfBlock(created) : null;
15619
+ const fileId = url ? notionFileIdOf(url) : null;
15620
+ if (fileId) rememberFolderFileId(this.stateDb, parentPageId, file.path, fileId);
15238
15621
  this.stateDb.registerFile({
15239
15622
  localPath: file.path,
15240
15623
  notionPageId: parentPageId,
@@ -15246,10 +15629,25 @@ var FileHandler = class {
15246
15629
  getLogger().info(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC: ${file.path} \u2192 ${blockType} \uBE14\uB85D`);
15247
15630
  return { localPath: file.path, fileUploadId, blockType };
15248
15631
  }
15249
- createFileBlock(blockType, fileUploadId) {
15632
+ /** 이 페이지에서 같은 이름의 파일 블록 중 방금 붙인 것 말고를 지운다. */
15633
+ async removeOlderBlocks(pageId, name, keep) {
15634
+ for (const block of await this.notionClient.fetchAllChildren(pageId)) {
15635
+ if (block.id === keep) continue;
15636
+ const media = block[block.type];
15637
+ const url = media?.type === "file" ? media.file?.url : void 0;
15638
+ if (!url || uploadedFileNameFromUrl(url) !== name) continue;
15639
+ await this.notionClient.deleteBlock(block.id);
15640
+ }
15641
+ }
15642
+ /**
15643
+ * 폴더 첨부의 파일 블록 — 캡션에 파일 이름을 적는다. 받는 쪽이 그 표시로 폴더 첨부를 알아본다({@link folderFilesOf}) —
15644
+ * 자식 페이지가 없는 폴더 페이지는 PDF 하나만 붙인 노트와 모양이 같다.
15645
+ */
15646
+ createFileBlock(blockType, fileUploadId, name) {
15250
15647
  const fileRef = {
15251
15648
  type: "file_upload",
15252
- file_upload: { id: fileUploadId }
15649
+ file_upload: { id: fileUploadId },
15650
+ caption: [{ type: "text", text: { content: name } }]
15253
15651
  };
15254
15652
  switch (blockType) {
15255
15653
  case "image":
@@ -15295,6 +15693,77 @@ async function readLocalNote(vaultFs, path) {
15295
15693
  }
15296
15694
  }
15297
15695
 
15696
+ // src/utils/db-row-path.ts
15697
+ function selectDbRowFiles(files, localFolder) {
15698
+ const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
15699
+ return files.filter((file) => {
15700
+ if (!file.path.startsWith(prefix)) return false;
15701
+ const rel = file.path.slice(prefix.length);
15702
+ return rel.length > 0 && !rel.includes("/");
15703
+ });
15704
+ }
15705
+ var PAGE_ID_SUFFIX_LENGTHS = [8, 16, 32];
15706
+ var PAGE_ID_SUFFIX = new RegExp(
15707
+ ` \\((?:${PAGE_ID_SUFFIX_LENGTHS.map((length) => `[0-9a-f]{${length}}`).join("|")})\\)$`,
15708
+ "i"
15709
+ );
15710
+ function withoutPageIdSuffix(name) {
15711
+ return name.replace(PAGE_ID_SUFFIX, "");
15712
+ }
15713
+ function pagePathCandidates(dir, safeName, pageId) {
15714
+ const base = dir ? `${dir}/${safeName}` : safeName;
15715
+ const raw = pageId.replace(/-/g, "");
15716
+ return [
15717
+ `${base}.md`,
15718
+ ...PAGE_ID_SUFFIX_LENGTHS.map((len) => `${base} (${raw.slice(0, len)}).md`)
15719
+ ];
15720
+ }
15721
+ function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
15722
+ const ownedBySelf = (candidate) => {
15723
+ const owner = lookupByPath(candidate);
15724
+ if (!owner) return true;
15725
+ return owner.notionPageId != null && notionIdsEqual(owner.notionPageId, pageId);
15726
+ };
15727
+ const candidates = pagePathCandidates(localFolder, safeName, pageId);
15728
+ return candidates.find(ownedBySelf) ?? candidates[candidates.length - 1];
15729
+ }
15730
+ function inClaimOrder(items, chosen, idOf2) {
15731
+ const slots = items.flatMap((item, index) => chosen(item) ? [index] : []);
15732
+ const key = (item) => compactNotionId(idOf2(item));
15733
+ const members = slots.map((index) => items[index]).sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
15734
+ const ordered = [...items];
15735
+ slots.forEach((slot, k) => ordered[slot] = members[k]);
15736
+ return ordered;
15737
+ }
15738
+
15739
+ // src/utils/path-key.ts
15740
+ function pathKey(path) {
15741
+ return path.normalize("NFC").toLowerCase();
15742
+ }
15743
+ function foldedOwnerOf(stateDb, candidate, pageId) {
15744
+ return stateDb.getByPathKey(candidate).some(
15745
+ (record) => record.obsidianPath !== candidate && !(record.notionPageId != null && notionIdsEqual(record.notionPageId, pageId))
15746
+ );
15747
+ }
15748
+
15749
+ // src/sync/untracked-note.ts
15750
+ async function findUntrackedNote(stateDb, vaultFs, dir, safeName, pageId, taken) {
15751
+ const [natural, ...suffixed] = pagePathCandidates(dir, safeName, pageId);
15752
+ const order = [
15753
+ ...suffixed.map((path) => ({ path, existing: "own" })),
15754
+ { path: natural, existing: "name" }
15755
+ ];
15756
+ for (const candidate of order) {
15757
+ if (taken(pathKey(candidate.path)) || stateDb.getByPath(candidate.path)) continue;
15758
+ if (foldedOwnerOf(stateDb, candidate.path, pageId)) continue;
15759
+ if (await vaultFs.exists(candidate.path)) return candidate;
15760
+ }
15761
+ return null;
15762
+ }
15763
+ function relinksWithoutContent(note, unlinkedVault) {
15764
+ return note.existing === "own" || unlinkedVault;
15765
+ }
15766
+
15298
15767
  // src/conflict/merger.ts
15299
15768
  function threeWayMerge(base, local, remote) {
15300
15769
  const baseLines = base.split("\n");
@@ -15760,49 +16229,6 @@ function withUnsentProperties(content, keys, before) {
15760
16229
  return (lines && replaceFrontmatterLines(content, lines)) ?? content;
15761
16230
  }
15762
16231
 
15763
- // src/utils/db-row-path.ts
15764
- function selectDbRowFiles(files, localFolder) {
15765
- const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
15766
- return files.filter((file) => {
15767
- if (!file.path.startsWith(prefix)) return false;
15768
- const rel = file.path.slice(prefix.length);
15769
- return rel.length > 0 && !rel.includes("/");
15770
- });
15771
- }
15772
- var PAGE_ID_SUFFIX_LENGTHS = [8, 16, 32];
15773
- var PAGE_ID_SUFFIX = new RegExp(
15774
- ` \\((?:${PAGE_ID_SUFFIX_LENGTHS.map((length) => `[0-9a-f]{${length}}`).join("|")})\\)$`,
15775
- "i"
15776
- );
15777
- function withoutPageIdSuffix(name) {
15778
- return name.replace(PAGE_ID_SUFFIX, "");
15779
- }
15780
- function pagePathCandidates(dir, safeName, pageId) {
15781
- const base = dir ? `${dir}/${safeName}` : safeName;
15782
- const raw = pageId.replace(/-/g, "");
15783
- return [
15784
- `${base}.md`,
15785
- ...PAGE_ID_SUFFIX_LENGTHS.map((len) => `${base} (${raw.slice(0, len)}).md`)
15786
- ];
15787
- }
15788
- function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
15789
- const ownedBySelf = (candidate) => {
15790
- const owner = lookupByPath(candidate);
15791
- if (!owner) return true;
15792
- return owner.notionPageId != null && notionIdsEqual(owner.notionPageId, pageId);
15793
- };
15794
- const candidates = pagePathCandidates(localFolder, safeName, pageId);
15795
- return candidates.find(ownedBySelf) ?? candidates[candidates.length - 1];
15796
- }
15797
- function inClaimOrder(items, chosen, idOf2) {
15798
- const slots = items.flatMap((item, index) => chosen(item) ? [index] : []);
15799
- const key = (item) => compactNotionId(idOf2(item));
15800
- const members = slots.map((index) => items[index]).sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
15801
- const ordered = [...items];
15802
- slots.forEach((slot, k) => ordered[slot] = members[k]);
15803
- return ordered;
15804
- }
15805
-
15806
16232
  // src/sync/note-title.ts
15807
16233
  function explicitTitle(frontmatter) {
15808
16234
  const title = frontmatter.title;
@@ -18770,16 +19196,6 @@ function joinPath(folder, name) {
18770
19196
  return folder === "" ? name : `${folder}/${name}`;
18771
19197
  }
18772
19198
 
18773
- // src/utils/path-key.ts
18774
- function pathKey(path) {
18775
- return path.normalize("NFC").toLowerCase();
18776
- }
18777
- function foldedOwnerOf(stateDb, candidate, pageId) {
18778
- return stateDb.getByPathKey(candidate).some(
18779
- (record) => record.obsidianPath !== candidate && !(record.notionPageId != null && notionIdsEqual(record.notionPageId, pageId))
18780
- );
18781
- }
18782
-
18783
19199
  // src/converter/unknown-tag-fallback.ts
18784
19200
  var UNKNOWN_BLOCK_TAG_RE2 = /<unknown (url|id)="([^"]*)"[^>]*\/>/g;
18785
19201
  var UNKNOWN_MENTION_TAG_RE = /<unknown_mention\s+url="([^"]*)"[^>]*?\/>/g;
@@ -19114,7 +19530,7 @@ var PageDecorations = class {
19114
19530
  }
19115
19531
  case "local":
19116
19532
  try {
19117
- const uploaded = await this.imageHandler.uploadLocalImage(source.target, folderOf2(path));
19533
+ const uploaded = await this.imageHandler.uploadLocalImage(source.target, folderOf3(path));
19118
19534
  return { type: "file_upload", file_upload: { id: uploaded.fileUploadId } };
19119
19535
  } catch (error) {
19120
19536
  getLogger().warn(
@@ -19159,7 +19575,7 @@ function sameText(a, b) {
19159
19575
  if (isEmptyFrontmatterValue(before) && isEmptyFrontmatterValue(after)) return true;
19160
19576
  return String(before) === String(after);
19161
19577
  }
19162
- function folderOf2(notePath) {
19578
+ function folderOf3(notePath) {
19163
19579
  return notePath.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
19164
19580
  }
19165
19581
 
@@ -19455,7 +19871,13 @@ var DatabaseSyncer = class {
19455
19871
  const target = await this.queryRows(dbConfig, opts);
19456
19872
  if (target.kind !== "rows") return [];
19457
19873
  const folderMove = opts?.followPlacement && !opts.paths ? await this.unblocked(this.placementMoveOf(dbConfig, target.meta, opts.movingInVault)) : null;
19458
- const planned = await this.planRows(dbConfig, target, opts?.paths, opts?.movingInVault);
19874
+ const planned = await this.planRows(
19875
+ dbConfig,
19876
+ target,
19877
+ opts?.paths,
19878
+ opts?.movingInVault,
19879
+ opts?.relinkTaken
19880
+ );
19459
19881
  if (!folderMove) return planned;
19460
19882
  const moved = (path) => within(path, folderMove.from) ? folderMove.to + path.slice(folderMove.from.length) : path;
19461
19883
  return [
@@ -19534,7 +19956,7 @@ var DatabaseSyncer = class {
19534
19956
  return false;
19535
19957
  }
19536
19958
  }
19537
- async planRows(dbConfig, target, paths, movingInVault = /* @__PURE__ */ new Set()) {
19959
+ async planRows(dbConfig, target, paths, movingInVault = /* @__PURE__ */ new Set(), relinkTaken = /* @__PURE__ */ new Set()) {
19538
19960
  const opts = { paths };
19539
19961
  const planned = [];
19540
19962
  const claimed = /* @__PURE__ */ new Map();
@@ -19555,6 +19977,24 @@ var DatabaseSyncer = class {
19555
19977
  if (action === "skip") continue;
19556
19978
  if (!record) {
19557
19979
  const name = sanitizeFileName(this.notionClient.extractTitle(page));
19980
+ const untracked = await findUntrackedNote(
19981
+ this.stateDb,
19982
+ this.vaultFs,
19983
+ dbConfig.localFolder,
19984
+ name,
19985
+ page.id,
19986
+ (key) => relinkTaken.has(key) || claimed.has(key)
19987
+ );
19988
+ if (untracked && relinksWithoutContent(untracked, this.unlinkedVault)) {
19989
+ relinkTaken.add(pathKey(untracked.path));
19990
+ planned.push({
19991
+ pageId: page.id,
19992
+ path: untracked.path,
19993
+ operation: "create",
19994
+ relink: true
19995
+ });
19996
+ continue;
19997
+ }
19558
19998
  const ownerOf = await this.rowPathOwners(dbConfig.localFolder, name, page.id);
19559
19999
  const path = resolveDbRowPath(dbConfig.localFolder, name, page.id, (p) => {
19560
20000
  const owner = claimed.get(pathKey(p));
@@ -20370,20 +20810,16 @@ var DatabaseSyncer = class {
20370
20810
  * `claimExistingNote` 와 같은 규칙이다.
20371
20811
  */
20372
20812
  async claimExistingRow(localFolder, safeName, pageId) {
20373
- const [natural, ...suffixed] = pagePathCandidates(localFolder, safeName, pageId);
20374
- const order = [
20375
- ...suffixed.map((path) => [path, "own"]),
20376
- [natural, "name"]
20377
- ];
20378
- for (const [candidate, existing] of order) {
20379
- const key = pathKey(candidate);
20380
- if (this.relinkClaims.has(key) || this.stateDb.getByPath(candidate)) continue;
20381
- if (foldedOwnerOf(this.stateDb, candidate, pageId)) continue;
20382
- if (!await this.vaultFs.exists(candidate)) continue;
20383
- this.relinkClaims.add(key);
20384
- return { path: candidate, existing };
20385
- }
20386
- return null;
20813
+ const found2 = await findUntrackedNote(
20814
+ this.stateDb,
20815
+ this.vaultFs,
20816
+ localFolder,
20817
+ safeName,
20818
+ pageId,
20819
+ (key) => this.relinkClaims.has(key)
20820
+ );
20821
+ if (found2) this.relinkClaims.add(pathKey(found2.path));
20822
+ return found2;
20387
20823
  }
20388
20824
  /**
20389
20825
  * 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 페이지의 `relinkExistingNote` 와 같은 규칙. 글이 같으면 잇고, 다르면
@@ -20963,7 +21399,7 @@ async function verifyPageCompleteness(remote, local, rootPageId) {
20963
21399
  var ID_SUFFIX = new RegExp(
20964
21400
  ` \\((?:${PAGE_ID_SUFFIX_LENGTHS.map((len) => `[0-9a-f]{${len}}`).join("|")})\\)$`
20965
21401
  );
20966
- function folderOf3(record) {
21402
+ function folderOf4(record) {
20967
21403
  if (record.fileType === "db-row") return null;
20968
21404
  if (isFolderRecord(record)) return record.obsidianPath;
20969
21405
  const slash = record.obsidianPath.lastIndexOf("/");
@@ -20980,10 +21416,10 @@ function findNestedFolderPages(records) {
20980
21416
  const found2 = [];
20981
21417
  for (const inner of records) {
20982
21418
  if (inner.notionPageId === null || inner.notionParentId === null) continue;
20983
- const folder = folderOf3(inner);
21419
+ const folder = folderOf4(inner);
20984
21420
  if (folder === null) continue;
20985
21421
  const outer = byPage.get(compactNotionId(inner.notionParentId));
20986
- if (!outer || outer === inner || folderOf3(outer) !== folder) continue;
21422
+ if (!outer || outer === inner || folderOf4(outer) !== folder) continue;
20987
21423
  found2.push({
20988
21424
  folder,
20989
21425
  outer: { path: outer.obsidianPath, notionPageId: outer.notionPageId },
@@ -21358,7 +21794,7 @@ var VaultSchemaRenames = class {
21358
21794
  const meta = await this.notionClient.getDatabaseMeta(databaseId);
21359
21795
  for (const [from, to] of pairs) {
21360
21796
  const carrying = rows.filter((row) => Object.hasOwn(row.current, to));
21361
- const folder = folderOf4(carrying[0].record.obsidianPath);
21797
+ const folder = folderOf5(carrying[0].record.obsidianPath);
21362
21798
  const owners = meta.dataSources.filter(
21363
21799
  (ds) => ds.properties?.[from] !== void 0
21364
21800
  );
@@ -21524,7 +21960,7 @@ function sameRow(a, b) {
21524
21960
  const keys = Object.keys(left.data);
21525
21961
  return left.content === right.content && keys.length === Object.keys(right.data).length && keys.every((key) => sameFrontmatterValue(left.data[key], right.data[key]));
21526
21962
  }
21527
- function folderOf4(path) {
21963
+ function folderOf5(path) {
21528
21964
  const at = path.lastIndexOf("/");
21529
21965
  return at === -1 ? "" : path.slice(0, at);
21530
21966
  }
@@ -24450,7 +24886,7 @@ function rewriteTarget(target, source, context, kind) {
24450
24886
  const shown = keepExtension ? destination : destination.slice(0, -".md".length);
24451
24887
  let next;
24452
24888
  if (resolved.relative) {
24453
- next = relativePath(folderOf5(source.to), shown);
24889
+ next = relativePath(folderOf6(source.to), shown);
24454
24890
  if (!linkpath.startsWith("./") && next.startsWith("./")) next = next.slice(2);
24455
24891
  } else if (style === "name") {
24456
24892
  const name = baseName3(shown);
@@ -24472,14 +24908,14 @@ function resolveLinkpath(linkpath, sourcePath, files) {
24472
24908
  const matches = byName(files, baseName3(candidate)).filter((file) => file.endsWith(suffix));
24473
24909
  if (matches.length === 1) return found(matches[0], linkpath);
24474
24910
  if (matches.length > 1) {
24475
- const local = `${folderPrefix(folderOf5(sourcePath))}${candidate}`;
24911
+ const local = `${folderPrefix(folderOf6(sourcePath))}${candidate}`;
24476
24912
  return matches.includes(local) ? found(local, linkpath) : null;
24477
24913
  }
24478
24914
  }
24479
24915
  return null;
24480
24916
  }
24481
24917
  function resolveRelative(linkpath, sourcePath, files) {
24482
- const joined = normalizeSegments(`${folderPrefix(folderOf5(sourcePath))}${linkpath}`);
24918
+ const joined = normalizeSegments(`${folderPrefix(folderOf6(sourcePath))}${linkpath}`);
24483
24919
  if (joined === null) return null;
24484
24920
  for (const candidate of withExtension(joined)) {
24485
24921
  if (files.has(candidate)) return { ...found(candidate, linkpath), relative: true };
@@ -24538,7 +24974,7 @@ function relativePath(from, to) {
24538
24974
  function encodeMarkdownTarget(next, original) {
24539
24975
  return original.includes("%") || next.includes(" ") ? next.replace(/ /g, "%20") : next;
24540
24976
  }
24541
- function folderOf5(path) {
24977
+ function folderOf6(path) {
24542
24978
  const slash = path.lastIndexOf("/");
24543
24979
  return slash === -1 ? "" : path.slice(0, slash);
24544
24980
  }
@@ -25708,23 +26144,32 @@ var PagePusher = class {
25708
26144
  return uploads;
25709
26145
  }
25710
26146
  /**
25711
- * 노트의 제목 · 블록 링크(`[[노트#제목]]`)가 가리키는 페이지의 맨 위 블록을 읽어 둔다 — 변환은 기다리지 않는다
25712
- * (anchor-links). 그런 링크가 없으면 읽지 않는다. 읽지 못한 페이지의 링크는 예전처럼 간다.
26147
+ * 노트의 제목 · 블록 링크(`[[노트#제목]]`)가 가리키는 페이지의 블록을 읽어 둔다 — 변환은 기다리지 않는다
26148
+ * (anchor-links). 맨 위 블록에서 모두 찾으면 거기서 멈추고, 못 찾은 앵커가 있으면 콜아웃 · 토글 · 목록 자식까지 읽는다.
26149
+ * 그런 링크가 없으면 읽지 않는다. 읽지 못한 페이지의 링크는 예전처럼 간다.
25713
26150
  *
25714
- * @param selfPageId 이 노트의 페이지 — `[[#제목]]` 의 대상. 새로 만드는 노트는 아직 없다(그 링크는 다음 push 에 걸린다).
26151
+ * @param selfPageId 이 노트의 페이지 — `[[#제목]]` 의 대상. 새로 만드는 노트는 아직 없다(그 링크는 push 뒤 패스가 다시 보낸다 — `push-link-pass`).
25715
26152
  */
25716
26153
  async anchorLookup(content, selfPageId) {
25717
- const pages = /* @__PURE__ */ new Set();
26154
+ const pages = /* @__PURE__ */ new Map();
25718
26155
  for (const target of anchoredWikilinkTargets(content)) {
25719
- const { note } = splitAnchor(target);
26156
+ const { note, anchor } = splitAnchor(target);
25720
26157
  const pageId = note === "" ? selfPageId : this.stateDb.resolveWikilink(note)?.notionPageId ?? null;
25721
- if (pageId) pages.add(compactNotionId(pageId));
26158
+ if (!pageId) continue;
26159
+ const key = compactNotionId(pageId);
26160
+ pages.set(key, [...pages.get(key) ?? [], anchor]);
25722
26161
  }
25723
26162
  if (pages.size === 0) return void 0;
25724
26163
  const blocks = /* @__PURE__ */ new Map();
25725
- for (const pageId of pages) {
26164
+ for (const [pageId, anchors] of pages) {
25726
26165
  try {
25727
- blocks.set(pageId, (await this.notionClient.fetchAllChildren(pageId)).map(anchorBlockOf));
26166
+ const top = (await this.notionClient.fetchAllChildren(pageId)).map(anchorBlockOf);
26167
+ blocks.set(pageId, top);
26168
+ if (anchors.every((anchor) => anchorBlockId(top, anchor) !== null)) continue;
26169
+ blocks.set(
26170
+ pageId,
26171
+ flattenBlockTree(await this.notionClient.getBlockTree(pageId)).map(anchorBlockOf)
26172
+ );
25728
26173
  } catch (error) {
25729
26174
  getLogger().debug(
25730
26175
  `[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)}`
@@ -26841,7 +27286,7 @@ async function resolveBlockToPageId(notionClient, blockId) {
26841
27286
 
26842
27287
  // src/sync/page-puller.ts
26843
27288
  var PagePuller = class {
26844
- constructor(config, stateDb, notionClient, vaultFs, pipeline, imageHandler, propertyMapper, databaseSyncer, observation, drift, placement, detector, discovery) {
27289
+ constructor(config, stateDb, notionClient, vaultFs, pipeline, imageHandler, propertyMapper, databaseSyncer, observation, drift, placement, detector, discovery, fileHandler) {
26845
27290
  this.config = config;
26846
27291
  this.stateDb = stateDb;
26847
27292
  this.notionClient = notionClient;
@@ -26855,6 +27300,7 @@ var PagePuller = class {
26855
27300
  this.placement = placement;
26856
27301
  this.detector = detector;
26857
27302
  this.discovery = discovery;
27303
+ this.fileHandler = fileHandler;
26858
27304
  }
26859
27305
  config;
26860
27306
  stateDb;
@@ -26869,6 +27315,7 @@ var PagePuller = class {
26869
27315
  placement;
26870
27316
  detector;
26871
27317
  discovery;
27318
+ fileHandler;
26872
27319
  dbSchemaLoaded = false;
26873
27320
  _pullImageCount = 0;
26874
27321
  _pullFileCount = 0;
@@ -27003,6 +27450,36 @@ var PagePuller = class {
27003
27450
  sanitizeFileName(this.notionClient.extractTitle(page))
27004
27451
  );
27005
27452
  }
27453
+ /**
27454
+ * dry-run — 실제 pull 이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7). 다시 이을 노트(`note`) ·
27455
+ * 이미 있는 폴더(`folder` — 이은 노트가 없는 볼트에서 폴더로만 받으면 만들지 않는다), 아니면 null. 받기 전에는 폴더 노트가 될지
27456
+ * 모른다 — 노트 자리 · 폴더 노트 자리를 차례로 본다. `taken` 은 이번 미리 보기가 이미 이은 자리({@link pathKey}) — 고르면
27457
+ * 더한다. 볼트도 상태 DB 도 바꾸지 않는다.
27458
+ */
27459
+ async plannedRelink(pageId, taken) {
27460
+ const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
27461
+ const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
27462
+ const parentPath = await this.resolveParentPath(page);
27463
+ const folder = parentPath ? `${parentPath}/${safeName}` : safeName;
27464
+ for (const dir of [parentPath, folder]) {
27465
+ const found2 = await findUntrackedNote(
27466
+ this.stateDb,
27467
+ this.vaultFs,
27468
+ dir,
27469
+ safeName,
27470
+ pageId,
27471
+ (key) => taken.has(key)
27472
+ );
27473
+ if (found2 && relinksWithoutContent(found2, this.unlinkedVault)) {
27474
+ taken.add(pathKey(found2.path));
27475
+ return { path: found2.path, kind: "note" };
27476
+ }
27477
+ }
27478
+ const folderExists = this.unlinkedVault && !taken.has(pathKey(folder)) && this.stateDb.getByPath(folder) === null && await this.vaultFs.exists(folder);
27479
+ if (!folderExists) return null;
27480
+ taken.add(pathKey(folder));
27481
+ return { path: folder, kind: "folder" };
27482
+ }
27006
27483
  /**
27007
27484
  * 새 페이지가 놓일 자리가 뺀 자리인가 — 부모 폴더 · 노트 이름 · 폴더 노트가 될 때의 폴더를 본다. 받기 전에는 폴더 노트가
27008
27485
  * 될지 모른다(본문을 받아 봐야 안다) — 어느 쪽이든 빠지면 뺀다. 자연 이름으로 보고, 같은 이름이 있을 때 붙는 id 접미사는
@@ -27025,8 +27502,17 @@ var PagePuller = class {
27025
27502
  if (excluded !== null) return { path: excluded, excluded: true };
27026
27503
  const fetched = await this.fetchPageMarkdown(pageId);
27027
27504
  const hasChildPages = this.hasChildContainers(pageId, fetched.children);
27028
- if (hasChildPages && !fetched.hasBody) {
27029
- return this.createFolderOnly(pageId, page, fetched.fingerprint, parentPath, safeName);
27505
+ const folderFiles = fetched.folderFiles && (hasChildPages || fetched.folderFiles.named) ? fetched.folderFiles.files : null;
27506
+ if (hasChildPages && !fetched.hasBody || folderFiles) {
27507
+ const created = await this.createFolderOnly(
27508
+ pageId,
27509
+ page,
27510
+ fetched.fingerprint,
27511
+ parentPath,
27512
+ safeName
27513
+ );
27514
+ if (folderFiles) await this.receiveFolderFiles(created.path, pageId, folderFiles);
27515
+ return created;
27030
27516
  }
27031
27517
  const fileType = hasChildPages ? "folder-note" : "file";
27032
27518
  const noteDir = hasChildPages ? parentPath ? `${parentPath}/${safeName}` : safeName : parentPath;
@@ -27489,7 +27975,8 @@ var PagePuller = class {
27489
27975
  compact: isCompactExport(result.markdown),
27490
27976
  children: extractChildTags(result.markdown),
27491
27977
  fingerprint: remoteBodyFingerprint(result.markdown),
27492
- hasBody: hasBodyBesidesChildren(result.markdown)
27978
+ hasBody: hasBodyBesidesChildren(result.markdown),
27979
+ folderFiles: folderFilesInBody(result.markdown)
27493
27980
  };
27494
27981
  } catch (error) {
27495
27982
  getLogger().warn(
@@ -27508,9 +27995,34 @@ var PagePuller = class {
27508
27995
  compact: isCompactExport(fallback.markdown),
27509
27996
  children: extractChildTags(fallback.markdown),
27510
27997
  fingerprint: null,
27511
- hasBody: hasBodyBesidesChildren(fallback.markdown)
27998
+ hasBody: hasBodyBesidesChildren(fallback.markdown),
27999
+ folderFiles: folderFilesInBody(fallback.markdown)
27512
28000
  };
27513
28001
  }
28002
+ /** 폴더 첨부를 받아 받은 파일 수에 더한다. */
28003
+ async receiveFolderFiles(folder, pageId, files, complete2 = false) {
28004
+ this._pullFileCount += await this.fileHandler.receiveFolderFiles(
28005
+ folder,
28006
+ pageId,
28007
+ files,
28008
+ complete2
28009
+ );
28010
+ }
28011
+ /**
28012
+ * 이 폴더 레코드의 Notion 페이지에서 받을 본문이 있나 — dry-run 이 실제 pull({@link pullFolderRecord})과 같게 센다.
28013
+ * 본문이 하위 페이지뿐이면 실제 pull 은 본 것만 적고 고친 것으로 세지 않는다.
28014
+ */
28015
+ async folderPageHasBody(record) {
28016
+ const page = await this.notionClient.getPage(record.notionPageId);
28017
+ if (isTrashedOrArchived(page)) return false;
28018
+ const { markdown } = await this.notionClient.getPageMarkdown(page.id);
28019
+ return hasBodyBesidesChildren(markdown) && folderFilesOf(markdown, false) === null && this.folderNoteRefusal(record.obsidianPath) === null;
28020
+ }
28021
+ /** 이 폴더에 폴더 노트를 둘 수 없는 까닭 — 둘 수 있으면 null. */
28022
+ folderNoteRefusal(folder) {
28023
+ if (isDatabaseMode(this.config)) return "DB \uBAA8\uB4DC\uB294 \uD3F4\uB354 \uB178\uD2B8\uB97C \uBC1B\uC9C0 \uC54A\uC74C";
28024
+ return this.placement.folderLookup().databaseAt(folder) ? "\uADF8 \uD3F4\uB354\uB294 DB \uB77C \uD3F4\uB354 \uB178\uD2B8\uB97C \uB458 \uC218 \uC5C6\uC74C" : null;
28025
+ }
27514
28026
  /**
27515
28027
  * push 가 만든 폴더 페이지(폴더 레코드)의 원격 변경을 받는다(S-17). 폴더 레코드에는 볼트 파일이
27516
28028
  * 없다 — 예전에는 폴더 경로를 파일로 읽어 «빈 로컬 파일» 과 원격 본문의 충돌로 남겼다.
@@ -27527,26 +28039,18 @@ var PagePuller = class {
27527
28039
  * @throws 받을 자리가 없을 때 — 이유와 함께 실패로 남고, 다음 pull 이 다시 본다(기준 시각이
27528
28040
  * 이 변경 앞에 묶인다).
27529
28041
  */
27530
- /**
27531
- * 이 폴더 레코드의 Notion 페이지에서 받을 본문이 있나 — dry-run 이 실제 pull({@link pullFolderRecord})과 같게 센다.
27532
- * 본문이 하위 페이지뿐이면 실제 pull 은 본 것만 적고 고친 것으로 세지 않는다.
27533
- */
27534
- async folderPageHasBody(record) {
27535
- const page = await this.notionClient.getPage(record.notionPageId);
27536
- if (isTrashedOrArchived(page)) return false;
27537
- const { markdown } = await this.notionClient.getPageMarkdown(page.id);
27538
- return hasBodyBesidesChildren(markdown) && this.folderNoteRefusal(record.obsidianPath) === null;
27539
- }
27540
- /** 이 폴더에 폴더 노트를 둘 수 없는 까닭 — 둘 수 있으면 null. */
27541
- folderNoteRefusal(folder) {
27542
- if (isDatabaseMode(this.config)) return "DB \uBAA8\uB4DC\uB294 \uD3F4\uB354 \uB178\uD2B8\uB97C \uBC1B\uC9C0 \uC54A\uC74C";
27543
- return this.placement.folderLookup().databaseAt(folder) ? "\uADF8 \uD3F4\uB354\uB294 DB \uB77C \uD3F4\uB354 \uB178\uD2B8\uB97C \uB458 \uC218 \uC5C6\uC74C" : null;
27544
- }
27545
28042
  async pullFolderRecord(record, page) {
27546
28043
  const folder = record.obsidianPath;
27547
28044
  if (isTrashedOrArchived(page)) return null;
27548
- const { markdown } = await this.notionClient.getPageMarkdown(page.id);
28045
+ const { markdown, truncated } = await this.notionClient.getPageMarkdown(page.id);
27549
28046
  if (!hasBodyBesidesChildren(markdown)) {
28047
+ await this.receiveFolderFiles(folder, page.id, [], !truncated);
28048
+ this.observation.record(record.id, page, remoteBodyFingerprint(markdown));
28049
+ return null;
28050
+ }
28051
+ const files = folderFilesOf(markdown, false);
28052
+ if (files) {
28053
+ await this.receiveFolderFiles(folder, page.id, files, !truncated);
27550
28054
  this.observation.record(record.id, page, remoteBodyFingerprint(markdown));
27551
28055
  return null;
27552
28056
  }
@@ -27661,21 +28165,22 @@ var PagePuller = class {
27661
28165
  * 이름. 다시 이을지는 받은 글과 견줘 호출측이 정한다({@link relinkExistingNote}) — 잇지 않으면 장부에서 지운다.
27662
28166
  */
27663
28167
  async claimExistingNote(dir, safeName, pageId) {
27664
- const [natural, ...suffixed] = pagePathCandidates(dir, safeName, pageId);
27665
- const order = [
27666
- ...suffixed.map((path) => [path, "own"]),
27667
- [natural, "name"]
27668
- ];
27669
- for (const [candidate, existing] of order) {
27670
- const key = pathKey(candidate);
27671
- if (this.claimedPaths.has(key) || this.stateDb.getByPath(candidate)) continue;
27672
- if (this.foldedOwner(candidate, pageId)) continue;
27673
- if (!await this.vaultFs.exists(candidate)) continue;
28168
+ const taken = (key) => this.claimedPaths.has(key);
28169
+ for (; ; ) {
28170
+ const found2 = await findUntrackedNote(
28171
+ this.stateDb,
28172
+ this.vaultFs,
28173
+ dir,
28174
+ safeName,
28175
+ pageId,
28176
+ taken
28177
+ );
28178
+ if (!found2) return null;
28179
+ const key = pathKey(found2.path);
27674
28180
  if (this.claimedPaths.has(key)) continue;
27675
- this.claimedPaths.set(key, candidate);
27676
- return { path: candidate, existing };
28181
+ this.claimedPaths.set(key, found2.path);
28182
+ return found2;
27677
28183
  }
27678
- return null;
27679
28184
  }
27680
28185
  async claimFilePath(dir, safeName, pageId) {
27681
28186
  const candidates = pagePathCandidates(dir, safeName, pageId);
@@ -27735,6 +28240,10 @@ var PagePuller = class {
27735
28240
  this.dbSchemaLoaded = true;
27736
28241
  }
27737
28242
  };
28243
+ function folderFilesInBody(markdown) {
28244
+ const files = folderFilesOf(markdown, false);
28245
+ return files ? { files, named: folderFilesOf(markdown, true) !== null } : null;
28246
+ }
27738
28247
 
27739
28248
  // src/sync/frontmatter-link-resolver.ts
27740
28249
  function resolveFrontmatterRelations(content, idToTitle) {
@@ -27873,17 +28382,31 @@ async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle, blockAncho
27873
28382
  }
27874
28383
 
27875
28384
  // src/sync/push-link-pass.ts
27876
- function notesWithResolvableWikilinks(stateDb) {
28385
+ function notesWithResolvableWikilinks(stateDb, pushed = /* @__PURE__ */ new Set()) {
27877
28386
  const paths = [];
27878
28387
  for (const record of stateDb.getAll()) {
27879
28388
  if (!record.notionPageId || record.status !== "synced") continue;
27880
- const resolvable = stateDb.getPreserveMarkers(record.obsidianPath).some(
27881
- (marker) => marker.type === "wikilink" && marker.params.text !== void 0 && stateDb.resolveWikilink(marker.params.text) !== null
27882
- );
28389
+ const resolvable = stateDb.getPreserveMarkers(record.obsidianPath).some((marker) => marker.type === "wikilink" && resolvesNow(stateDb, record, marker, pushed));
27883
28390
  if (resolvable) paths.push(record.obsidianPath);
27884
28391
  }
27885
28392
  return paths.sort();
27886
28393
  }
28394
+ function resolvesNow(stateDb, record, marker, pushed) {
28395
+ const text = marker.params.text;
28396
+ if (text === void 0) return false;
28397
+ const split = splitAnchor(text);
28398
+ if (!split) return stateDb.resolveWikilink(text) !== null;
28399
+ const target = split.note === "" ? null : stateDb.resolveWikilink(split.note);
28400
+ if (split.note !== "" && target === null) return false;
28401
+ switch (marker.params.anchor) {
28402
+ case "page":
28403
+ return true;
28404
+ case "block":
28405
+ return target !== null && target.obsidianPath !== record.obsidianPath && pushed.has(target.obsidianPath);
28406
+ default:
28407
+ return true;
28408
+ }
28409
+ }
27887
28410
 
27888
28411
  // src/sync/pull-planner.ts
27889
28412
  var PullPlanner = class {
@@ -27932,12 +28455,19 @@ var PullPlanner = class {
27932
28455
  }
27933
28456
  planned.push(change);
27934
28457
  }
28458
+ const relinks = await this.plannedRelinks(planned);
27935
28459
  const queued = new Set(
27936
28460
  [...filtered, ...restoreChanges].map((change) => compactNotionId(change.pageId))
27937
28461
  );
27938
28462
  const movingInVault = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
27939
28463
  const rows = [];
27940
- for (const row of await this.planDatabaseRows(options.paths, failed, ledger, movingInVault)) {
28464
+ for (const row of await this.planDatabaseRows(
28465
+ options.paths,
28466
+ failed,
28467
+ ledger,
28468
+ movingInVault,
28469
+ relinks.taken
28470
+ )) {
27941
28471
  if (queued.has(compactNotionId(row.pageId))) continue;
27942
28472
  if (row.resolution?.action === "conflict") conflicts.push(row.resolution.conflict);
27943
28473
  else if (row.resolution?.action === "merge") mergedPaths.push(row.path);
@@ -27947,16 +28477,27 @@ var PullPlanner = class {
27947
28477
  const items = [];
27948
28478
  const plannedPath = this.planner.plannedPaths(localPlan);
27949
28479
  for (const change of [...planned, ...restoreChanges]) {
28480
+ const relink = relinks.byPage.get(change.pageId);
28481
+ if (relink) {
28482
+ items.push({ path: relink.path, operation: "create", unchanged: true });
28483
+ continue;
28484
+ }
27950
28485
  const record = this.stateDb.getByNotionId(change.pageId);
27951
28486
  items.push({
27952
28487
  path: (record && plannedPath.get(record.id)) ?? record?.obsidianPath ?? change.title ?? change.pageId,
27953
28488
  operation: change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update"
27954
28489
  });
27955
28490
  }
27956
- for (const row of rows) items.push({ path: row.path, operation: row.operation });
28491
+ for (const row of rows) {
28492
+ items.push({
28493
+ path: row.path,
28494
+ operation: row.operation,
28495
+ ...row.relink ? { unchanged: true } : {}
28496
+ });
28497
+ }
27957
28498
  const movedRows = rows.filter((row) => row.moves !== void 0);
27958
28499
  items.forEach((item, index) => options.onProgress?.(index + 1, items.length, item));
27959
- const count = (type) => planned.filter((c) => c.type === type).length;
28500
+ const count = (type) => planned.filter((c) => c.type === type && !relinks.byPage.has(c.pageId)).length;
27960
28501
  const restoredModified = planned.filter(
27961
28502
  (c) => c.type === "modified" && restoreIds.has(c.pageId)
27962
28503
  ).length;
@@ -27967,7 +28508,13 @@ var PullPlanner = class {
27967
28508
  ].map((change) => this.stateDb.getByNotionId(change.pageId)?.obsidianPath).filter((path) => path !== void 0),
27968
28509
  ...rows.filter((row) => row.operation === "update" && row.restore).map((row) => row.path)
27969
28510
  ];
27970
- const rowCount = (operation, restore = false) => rows.filter((row) => row.operation === operation && (row.restore ?? false) === restore).length;
28511
+ const rowCount = (operation, restore = false) => rows.filter(
28512
+ (row) => row.operation === operation && (row.restore ?? false) === restore && !row.relink
28513
+ ).length;
28514
+ const relinkedPaths = [
28515
+ ...[...relinks.byPage.values()].filter((relink) => relink.kind === "note").map((relink) => relink.path),
28516
+ ...rows.filter((row) => row.relink).map((row) => row.path)
28517
+ ];
27971
28518
  return {
27972
28519
  created: count("created") + rowCount("create"),
27973
28520
  updated: count("modified") - restoredModified + rowCount("update"),
@@ -27979,6 +28526,7 @@ var PullPlanner = class {
27979
28526
  ...movedRows.length > 0 ? { moved: movedRows.flatMap((row) => row.moves) } : {},
27980
28527
  conflicts,
27981
28528
  ...mergedPaths.length > 0 ? { mergedPaths } : {},
28529
+ ...relinkedPaths.length > 0 ? { relinkedPaths, relinkPending: true } : {},
27982
28530
  writtenPaths: [],
27983
28531
  failed,
27984
28532
  duration: Date.now() - startTime,
@@ -27995,6 +28543,27 @@ var PullPlanner = class {
27995
28543
  }
27996
28544
  };
27997
28545
  }
28546
+ /**
28547
+ * 새 페이지 가운데 실제 pull 이 상태 기록에 없던 노트 · 폴더와 다시 이을 것({@link PagePuller.plannedRelink}) — 페이지 id →
28548
+ * 자리. `taken` 은 고른 자리라 행도 피한다.
28549
+ */
28550
+ async plannedRelinks(planned) {
28551
+ const byPage = /* @__PURE__ */ new Map();
28552
+ const taken = /* @__PURE__ */ new Set();
28553
+ for (const change of planned) {
28554
+ if (change.type !== "created") continue;
28555
+ try {
28556
+ const relink = await this.puller.plannedRelink(change.pageId, taken);
28557
+ if (relink) byPage.set(change.pageId, relink);
28558
+ } catch (error) {
28559
+ getLogger().debug(
28560
+ `[Im-Nobsidian] \uB2E4\uC2DC \uC774\uC744 \uC790\uB9AC\uB97C \uBBF8\uB9AC \uBCF4\uC9C0 \uBABB\uD568 (${change.pageId}):`,
28561
+ error
28562
+ );
28563
+ }
28564
+ }
28565
+ return { byPage, taken };
28566
+ }
27998
28567
  /**
27999
28568
  * 고친 페이지를 실제 pull 이 받는가 — 폴더로만 추적하는 페이지는 본문이 생겼을 때만 폴더 노트로 받는다. 하위 페이지 ·
28000
28569
  * DB 를 옮기기만 해도 그 페이지의 수정 시각이 바뀌어 고친 것으로 보였다. 확인하지 못하면 센다(실제 pull 이 받아 본다).
@@ -28043,7 +28612,7 @@ var PullPlanner = class {
28043
28612
  * (`DatabaseDiscovery.pullDiscoveredDatabases`)를 같은 규칙으로 고른다. 세지 못한 DB 는 이유와
28044
28613
  * 함께 `failed` 에 싣는다 — 세지 못한 것을 없다고 하지 않는다.
28045
28614
  */
28046
- async planDatabaseRows(paths, failed, ledger, movingInVault) {
28615
+ async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken) {
28047
28616
  const configured = this.config.notion.databases ?? [];
28048
28617
  const rows = [];
28049
28618
  const plan = async (dbConfig, resolveDbFolder2) => {
@@ -28057,7 +28626,8 @@ var PullPlanner = class {
28057
28626
  paths,
28058
28627
  resolveDbFolder: resolveDbFolder2,
28059
28628
  followPlacement: resolveDbFolder2 !== void 0,
28060
- movingInVault
28629
+ movingInVault,
28630
+ relinkTaken
28061
28631
  })
28062
28632
  );
28063
28633
  } catch (error) {
@@ -28844,7 +29414,9 @@ var SyncOrchestrator = class {
28844
29414
  {
28845
29415
  fetch: customFetch,
28846
29416
  downloadTimeoutMs: config.advanced.mediaDownloadTimeoutMs,
28847
- itemTimeoutMs: config.advanced.itemTimeoutMs
29417
+ itemTimeoutMs: config.advanced.itemTimeoutMs,
29418
+ attachmentsFolder: config.paths.attachments,
29419
+ deleteSync: config.sync.deleteSync
28848
29420
  }
28849
29421
  );
28850
29422
  const propertyMapper = PropertyMapper.fromConfig(config);
@@ -28954,7 +29526,8 @@ var SyncOrchestrator = class {
28954
29526
  this.drift,
28955
29527
  this.placement,
28956
29528
  this.detector,
28957
- this.discovery
29529
+ this.discovery,
29530
+ this.fileHandler
28958
29531
  );
28959
29532
  this.pullPlanner = new PullPlanner(
28960
29533
  config,
@@ -29074,14 +29647,16 @@ var SyncOrchestrator = class {
29074
29647
  * 있든 없든 push 마다 부른다 — 바뀌지 않은 첨부는 파일 기록이 건너뛴다.
29075
29648
  */
29076
29649
  async pushVaultFiles(failed) {
29077
- if (this.config.sync.syncFiles === false) return;
29650
+ if (this.config.sync.syncFiles === false) return 0;
29078
29651
  try {
29079
- const fileResults = await this.fileHandler.pushAllFiles(failed);
29652
+ const fileResults = await this.fileHandler.pushAllFiles(failed, this.placement);
29080
29653
  if (fileResults.length > 0) {
29081
29654
  getLogger().info(`[Im-Nobsidian] ${fileResults.length}\uAC1C \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC`);
29082
29655
  }
29656
+ return fileResults.length;
29083
29657
  } catch (error) {
29084
29658
  getLogger().warn("[Im-Nobsidian] \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC911 \uC624\uB958:", error);
29659
+ return 0;
29085
29660
  }
29086
29661
  }
29087
29662
  /**
@@ -29089,7 +29664,7 @@ var SyncOrchestrator = class {
29089
29664
  * 적어 push 가 올린다 — dry-run 은 받지 않아 원격이 바뀐 그대로라, 원격을 보고 거절하지 않는다.
29090
29665
  */
29091
29666
  /**
29092
- * @param relinkedByPull 같은 sync 의 dry-run pull 이 다시 이을 것이라고 미리 봤다 — 실제 sync 는 받으며 먼저 잇고 올리므로
29667
+ * @param relinkedByPull 같은 sync 의 dry-run pull 이 받거나 다시 이을 것이 있다 — 실제 sync 는 받으며 먼저 잇고 올리므로
29093
29668
  * 상태 기록이 비었어도 막지 않는다({@link assertVaultLinked}).
29094
29669
  */
29095
29670
  async executePush(options, mergedByPull = /* @__PURE__ */ new Set(), relinkedByPull = false) {
@@ -29170,12 +29745,13 @@ var SyncOrchestrator = class {
29170
29745
  ...viewPlan.refused,
29171
29746
  ...viewed2.failed
29172
29747
  ];
29173
- if (!options?.dryRun && !options?.signal?.aborted) await this.pushVaultFiles(failed2);
29748
+ const uploadedFiles2 = !options?.dryRun && !options?.signal?.aborted ? await this.pushVaultFiles(failed2) : 0;
29174
29749
  return {
29175
29750
  created: 0,
29176
29751
  updated: 0,
29177
29752
  deleted: 0,
29178
29753
  moved: 0,
29754
+ ...uploadedFiles2 > 0 ? { uploadedFiles: uploadedFiles2 } : {},
29179
29755
  failed: failed2,
29180
29756
  duration: Date.now() - startTime,
29181
29757
  ...edited2.applied.length > 0 ? { schemaEdits: edited2.applied } : {},
@@ -29446,7 +30022,7 @@ var SyncOrchestrator = class {
29446
30022
  );
29447
30023
  }
29448
30024
  };
29449
- const relinkable = notesWithResolvableWikilinks(this.stateDb).filter(
30025
+ const relinkable = notesWithResolvableWikilinks(this.stateDb, pushedBodies).filter(
29450
30026
  (path) => inAnyPathScope(path, options?.paths) && !excludeSet?.has(path)
29451
30027
  );
29452
30028
  await runPool(relinkable, relink, {
@@ -29454,7 +30030,7 @@ var SyncOrchestrator = class {
29454
30030
  signal: options?.signal
29455
30031
  });
29456
30032
  }
29457
- await this.pushVaultFiles(failed);
30033
+ const uploadedFiles = await this.pushVaultFiles(failed);
29458
30034
  this.stateDb.setMeta("last_push_at", (/* @__PURE__ */ new Date()).toISOString());
29459
30035
  this.stateDb.setMeta(LAST_SYNC_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
29460
30036
  this.stateDb.setMeta(PUSH_IN_PROGRESS_META_KEY, "");
@@ -29466,6 +30042,7 @@ var SyncOrchestrator = class {
29466
30042
  updated: counts.updated,
29467
30043
  deleted: counts.deleted,
29468
30044
  moved: counts.moved,
30045
+ ...uploadedFiles > 0 ? { uploadedFiles } : {},
29469
30046
  failed,
29470
30047
  duration: Date.now() - startTime,
29471
30048
  readOnlySkipped: this.pusher.takeReadOnlySkips(),
@@ -29759,8 +30336,7 @@ var SyncOrchestrator = class {
29759
30336
  },
29760
30337
  moved: [...relocated.moved, ...plan.moved ?? []],
29761
30338
  moveSkipped: relocated.skipped,
29762
- excluded: [...exclusions.values()],
29763
- ...this.puller.unlinkedVaultAtStart && plan.created > 0 ? { relinkPending: true } : {}
30339
+ excluded: [...exclusions.values()]
29764
30340
  };
29765
30341
  }
29766
30342
  let pullTotal = workItems.length;
@@ -29972,15 +30548,16 @@ var SyncOrchestrator = class {
29972
30548
  const tagged = (direction) => onProgress && ((current, total, item) => onProgress(current, total, { ...item, direction }));
29973
30549
  const pullResult = await this.executePull({ ...options, onProgress: tagged("pull") });
29974
30550
  const conflicted = options?.dryRun ? pullResult.conflicts.map((conflict) => conflict.syncRecord.obsidianPath) : [];
30551
+ const relinked = options?.dryRun ? pullResult.relinkedPaths ?? [] : [];
29975
30552
  const pushResult = await this.executePush(
29976
30553
  {
29977
30554
  ...options,
29978
30555
  onProgress: tagged("push"),
29979
30556
  paths: options?.paths,
29980
- excludePaths: [...pullResult.writtenPaths, ...conflicted]
30557
+ excludePaths: [...pullResult.writtenPaths, ...conflicted, ...relinked]
29981
30558
  },
29982
30559
  new Set(options?.dryRun ? pullResult.mergedPaths ?? [] : []),
29983
- pullResult.relinkPending === true
30560
+ options?.dryRun === true && (pullResult.created > 0 || relinked.length > 0)
29984
30561
  );
29985
30562
  return {
29986
30563
  pull: pullResult,