@im-nobsidian/core 0.17.1 → 0.18.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
@@ -12897,6 +12897,76 @@ function createDefaultPipeline(options) {
12897
12897
  return pipeline;
12898
12898
  }
12899
12899
 
12900
+ // src/sync/vault-file-index.ts
12901
+ var INDEX_MAX_AGE_MS = 5e3;
12902
+ function keyOf(path) {
12903
+ return path.normalize("NFC").toLowerCase();
12904
+ }
12905
+ function baseNameOf(path) {
12906
+ return path.slice(path.lastIndexOf("/") + 1);
12907
+ }
12908
+ function folderOf(path) {
12909
+ const slash = path.lastIndexOf("/");
12910
+ return slash === -1 ? "" : path.slice(0, slash);
12911
+ }
12912
+ var VaultFileIndex = class {
12913
+ constructor(vaultFs, now = Date.now) {
12914
+ this.vaultFs = vaultFs;
12915
+ this.now = now;
12916
+ }
12917
+ vaultFs;
12918
+ now;
12919
+ byName = null;
12920
+ builtAt = 0;
12921
+ /**
12922
+ * `linkpath`(이름 또는 경로 끝)로 끝나는 볼트 파일 — 가까운 것부터: 노트 폴더 안(얕은 것 먼저) → 짧은 경로 → 가나다. 지금
12923
+ * 있는 것만 돌려준다.
12924
+ */
12925
+ async find(linkpath, noteFolder) {
12926
+ if (this.byName === null || this.now() - this.builtAt > INDEX_MAX_AGE_MS) await this.rebuild();
12927
+ const key = keyOf(linkpath.replace(/^\/+/, ""));
12928
+ const suffix = `/${key}`;
12929
+ const matches = (this.byName?.get(keyOf(baseNameOf(key))) ?? []).filter((path) => {
12930
+ const k = keyOf(path);
12931
+ return k === key || k.endsWith(suffix);
12932
+ });
12933
+ const existing = [];
12934
+ for (const path of rank(matches, noteFolder)) {
12935
+ if (await this.vaultFs.exists(path)) existing.push(path);
12936
+ }
12937
+ return existing;
12938
+ }
12939
+ async rebuild() {
12940
+ const byName2 = /* @__PURE__ */ new Map();
12941
+ let files = [];
12942
+ try {
12943
+ files = await this.vaultFs.listNonMarkdownFiles() ?? [];
12944
+ } catch {
12945
+ }
12946
+ for (const file of files) {
12947
+ const key = keyOf(baseNameOf(file.path));
12948
+ const list = byName2.get(key) ?? [];
12949
+ list.push(file.path);
12950
+ byName2.set(key, list);
12951
+ }
12952
+ this.byName = byName2;
12953
+ this.builtAt = this.now();
12954
+ }
12955
+ };
12956
+ function rank(paths, noteFolder) {
12957
+ const inside2 = (path) => {
12958
+ if (noteFolder === void 0) return Number.POSITIVE_INFINITY;
12959
+ const folder = folderOf(path);
12960
+ if (noteFolder === "") return folder.split("/").filter(Boolean).length;
12961
+ if (folder !== noteFolder && !folder.startsWith(`${noteFolder}/`))
12962
+ return Number.POSITIVE_INFINITY;
12963
+ return folder.slice(noteFolder.length).split("/").filter(Boolean).length;
12964
+ };
12965
+ return [...paths].sort(
12966
+ (a, b) => inside2(a) - inside2(b) || a.length - b.length || a.localeCompare(b)
12967
+ );
12968
+ }
12969
+
12900
12970
  // src/utils/mime.ts
12901
12971
  var EXTENSION_TO_BLOCK_TYPE = {
12902
12972
  ".png": "image",
@@ -13018,11 +13088,11 @@ function getMimeType(filename) {
13018
13088
  }
13019
13089
 
13020
13090
  // src/sync/uploaded-media.ts
13021
- function keyOf(pageId) {
13091
+ function keyOf2(pageId) {
13022
13092
  return `uploaded_media:${compactNotionId(pageId)}`;
13023
13093
  }
13024
13094
  function rememberUploadedMedia(stateDb, pageId, media) {
13025
- const key = keyOf(pageId);
13095
+ const key = keyOf2(pageId);
13026
13096
  if (media.length === 0 && stateDb.getMeta(key) === null) return;
13027
13097
  stateDb.setMeta(key, JSON.stringify(media.map(rowOf)));
13028
13098
  }
@@ -13030,14 +13100,14 @@ function rememberReceivedMedia(stateDb, pageId, media) {
13030
13100
  if (media.length === 0) return;
13031
13101
  const merged = recallUploadedMedia(stateDb, pageId);
13032
13102
  for (const item of media) merged.set(item.fileId, item);
13033
- stateDb.setMeta(keyOf(pageId), JSON.stringify([...merged.values()].map(rowOf)));
13103
+ stateDb.setMeta(keyOf2(pageId), JSON.stringify([...merged.values()].map(rowOf)));
13034
13104
  }
13035
13105
  function rowOf({ fileId, target, localPath, fileHash }) {
13036
13106
  return fileHash === void 0 ? { fileId, target, localPath } : { fileId, target, localPath, fileHash };
13037
13107
  }
13038
13108
  function recallUploadedMedia(stateDb, pageId) {
13039
13109
  const known = /* @__PURE__ */ new Map();
13040
- const value = stateDb?.getMeta(keyOf(pageId));
13110
+ const value = stateDb?.getMeta(keyOf2(pageId));
13041
13111
  if (!value) return known;
13042
13112
  try {
13043
13113
  for (const row of JSON.parse(value)) {
@@ -13226,8 +13296,11 @@ function nameHashOf(fullHash) {
13226
13296
  var IMAGE_STEM_MAX = 50;
13227
13297
  function withoutOwnHash(name, hash) {
13228
13298
  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;
13299
+ const tail = new RegExp(`-${hash}(\\.[^./]*)?$`).exec(name);
13300
+ const stem = tail ? name.slice(0, tail.index) : "";
13301
+ if (stem === "") return name;
13302
+ const extension = tail?.[1] ?? "";
13303
+ return extension && !stem.toLowerCase().endsWith(extension.toLowerCase()) ? `${stem}${extension}` : stem;
13231
13304
  }
13232
13305
  function imageStemOf(uploadedName, pageTitle) {
13233
13306
  const stem = safeImageStem(uploadedName.replace(/\.[^.]*$/, ""));
@@ -13270,7 +13343,7 @@ function localEmbedTargets(note) {
13270
13343
  }
13271
13344
  return targets;
13272
13345
  }
13273
- function folderOf(notePath) {
13346
+ function folderOf2(notePath) {
13274
13347
  return notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
13275
13348
  }
13276
13349
  function messageOf2(error) {
@@ -13371,7 +13444,7 @@ var ImageHandler = class {
13371
13444
  if (!target || /^https?:\/\//.test(target) || target.includes("\n")) return null;
13372
13445
  const vaultPath = stripAlias2(target);
13373
13446
  if (baseName(vaultPath) !== uploadedName) return null;
13374
- for (const candidate of this.vaultCandidates(vaultPath, noteFolder)) {
13447
+ for await (const candidate of this.vaultCandidates(vaultPath, noteFolder)) {
13375
13448
  try {
13376
13449
  if (await this.vaultFs.exists(candidate)) return target;
13377
13450
  } catch {
@@ -13411,7 +13484,7 @@ var ImageHandler = class {
13411
13484
  const spans = notionMediaSpans(markdown);
13412
13485
  if (spans.length === 0) return markdown;
13413
13486
  const known = pageId ? recallUploadedMedia(this.stateDb, pageId) : /* @__PURE__ */ new Map();
13414
- const noteFolder = folderOf(notePath);
13487
+ const noteFolder = folderOf2(notePath);
13415
13488
  let result = markdown;
13416
13489
  for (const span of spans) {
13417
13490
  const uploadedName = this.uploadedNameOf(span.url);
@@ -13441,7 +13514,7 @@ var ImageHandler = class {
13441
13514
  return null;
13442
13515
  }
13443
13516
  for (const target of targets) {
13444
- for (const candidate of this.vaultCandidates(stripAlias2(target), noteFolder)) {
13517
+ for await (const candidate of this.vaultCandidates(stripAlias2(target), noteFolder)) {
13445
13518
  let localHash;
13446
13519
  try {
13447
13520
  localHash = sha256Of(await this.vaultFs.readBinary(candidate));
@@ -13584,7 +13657,7 @@ var ImageHandler = class {
13584
13657
  * @returns 보일 이름은 받을 때 붙인 내용 해시를 뗀 것이다 — 다시 받으면 같은 이름 · 같은 경로가 된다.
13585
13658
  */
13586
13659
  async uploadNoteAttachment(target, notePath) {
13587
- const uploaded = await this.uploadLocalImage(target, folderOf(notePath));
13660
+ const uploaded = await this.uploadLocalImage(target, folderOf2(notePath));
13588
13661
  const base = uploaded.localPath.split("/").pop() ?? uploaded.localPath;
13589
13662
  const name = withoutOwnHash(base, nameHashOf(uploaded.fileHash));
13590
13663
  const extension = /\.[^./]+$/.exec(base)?.[0] ?? "";
@@ -13597,7 +13670,7 @@ var ImageHandler = class {
13597
13670
  if (!this.notionClient) return [];
13598
13671
  const localImages = images.filter((img) => !img.isExternal && img.localPath);
13599
13672
  if (localImages.length === 0) return [];
13600
- const noteFolder = folderOf(notePath);
13673
+ const noteFolder = folderOf2(notePath);
13601
13674
  const results = [];
13602
13675
  const imageBlocks = [];
13603
13676
  for (const img of localImages) {
@@ -13684,7 +13757,7 @@ var ImageHandler = class {
13684
13757
  }
13685
13758
  return segment;
13686
13759
  });
13687
- const noteFolder = folderOf(notePath);
13760
+ const noteFolder = folderOf2(notePath);
13688
13761
  const kept = [];
13689
13762
  let result = "";
13690
13763
  let cursor = 0;
@@ -13775,7 +13848,7 @@ var ImageHandler = class {
13775
13848
  if (kept.length > 0) this.rememberUploads(pageId, kept);
13776
13849
  return empty;
13777
13850
  }
13778
- const noteFolder = folderOf(notePath);
13851
+ const noteFolder = folderOf2(notePath);
13779
13852
  const uploaded = [];
13780
13853
  const remembered = [...kept];
13781
13854
  const unsupported2 = [];
@@ -13933,29 +14006,37 @@ var ImageHandler = class {
13933
14006
  /**
13934
14007
  * 임베드 경로가 가리킬 수 있는 볼트 실제 파일 후보를 우선순위대로 만든다(F23).
13935
14008
  * Obsidian 위키링크 임베드는 노트 옆 파일을 파일명만으로 참조하는 게 관례라
13936
- * 노트 폴더 → 볼트 루트 → attachments/ 순.
14009
+ * 노트 폴더 → 볼트 루트 → attachments/ 순. 세 곳에 없으면 볼트 어디서든 이름으로 찾는다({@link VaultFileIndex}).
13937
14010
  *
13938
14011
  * push(업로드)와 pull(원본 복원)이 **같은 사다리**를 봐야 왕복이 닫힌다. 예전에는
13939
14012
  * pull 쪽이 볼트 루트만 확인해서, `__e2e_probe__/노트.md` 안의 `![[t-img.png]]` 는
13940
14013
  * 올라갈 때는 노트 폴더에서 잘 찾아 올리고도 내려올 때는 원본을 못 찾아 사본을
13941
14014
  * 새로 받았다(실측) — 왕복할 때마다 첨부가 한 벌씩 늘어난다.
13942
14015
  */
13943
- vaultCandidates(localPath, noteFolder) {
13944
- const candidates = [];
14016
+ async *vaultCandidates(localPath, noteFolder) {
14017
+ const ladder = [];
13945
14018
  if (noteFolder && !localPath.includes("/")) {
13946
- candidates.push(`${noteFolder}/${localPath}`);
14019
+ ladder.push(`${noteFolder}/${localPath}`);
14020
+ }
14021
+ ladder.push(localPath, `${this.attachmentFolder}/${localPath}`);
14022
+ yield* ladder;
14023
+ for (const found2 of await this.fileIndex.find(localPath, noteFolder)) {
14024
+ if (!ladder.includes(found2)) yield found2;
13947
14025
  }
13948
- candidates.push(localPath, `${this.attachmentFolder}/${localPath}`);
13949
- return candidates;
14026
+ }
14027
+ indexOfFiles = null;
14028
+ /** 볼트의 첨부 이름표 — 처음 쓸 때 만든다. */
14029
+ get fileIndex() {
14030
+ this.indexOfFiles ??= new VaultFileIndex(this.vaultFs);
14031
+ return this.indexOfFiles;
13950
14032
  }
13951
14033
  /**
13952
14034
  * 임베드 경로를 볼트 실제 파일로 해석해 읽는다(F23).
13953
14035
  * 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
13954
14036
  */
13955
14037
  async readImageFromVault(localPath, noteFolder) {
13956
- const candidates = this.vaultCandidates(localPath, noteFolder);
13957
14038
  let lastError;
13958
- for (const candidate of candidates) {
14039
+ for await (const candidate of this.vaultCandidates(localPath, noteFolder)) {
13959
14040
  try {
13960
14041
  return { buffer: await this.vaultFs.readBinary(candidate), resolvedPath: candidate };
13961
14042
  } catch (error) {
@@ -14148,12 +14229,20 @@ var ImageHandler = class {
14148
14229
  const hash = nameHashOf(fileHash);
14149
14230
  const extension = this.guessFileExtension(url, response.headers.get("content-type"));
14150
14231
  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}`;
14232
+ const hasExtension2 = extension !== "" && name.toLowerCase().endsWith(extension.toLowerCase());
14233
+ const stem = hasExtension2 ? name.slice(0, -extension.length) : name;
14234
+ const localPath = `${this.attachmentFolder}/${this.fileNameOf(stem, hash, extension)}`;
14235
+ const legacyPath = `${this.attachmentFolder}/${this.fileNameOf(name, hash, extension)}`;
14236
+ if (hasExtension2 && await this.vaultFs.exists(legacyPath)) {
14237
+ return {
14238
+ originalUrl: url,
14239
+ localPath: legacyPath,
14240
+ hash,
14241
+ size: buffer.length,
14242
+ fileHash,
14243
+ name
14244
+ };
14245
+ }
14157
14246
  await this.vaultFs.ensureFolder(this.attachmentFolder);
14158
14247
  await this.vaultFs.writeBinary(localPath, buffer);
14159
14248
  return { originalUrl: url, localPath, hash, size: buffer.length, fileHash, name };
@@ -14163,6 +14252,14 @@ var ImageHandler = class {
14163
14252
  }
14164
14253
  throw lastError;
14165
14254
  }
14255
+ /** 받은 파일의 이름 — `<줄기>-<해시><확장자>`. */
14256
+ fileNameOf(stem, hash, extension) {
14257
+ const safeName = truncateBytes(
14258
+ stem.replace(/[^a-zA-Z0-9가-힣\s._-]/g, "").replace(/\s+/g, "-").slice(0, 80),
14259
+ FILE_SYSTEM_NAME_MAX_BYTES - utf8Bytes(`-${hash}${extension}`)
14260
+ );
14261
+ return `${safeName}-${hash}${extension}`;
14262
+ }
14166
14263
  isNotionImageUrl(url) {
14167
14264
  return this.isNotionFileUrl(url);
14168
14265
  }
@@ -15049,7 +15146,74 @@ function selectStaleDbArtifacts(files, localFolder, keepPaths) {
15049
15146
  return stale;
15050
15147
  }
15051
15148
 
15149
+ // src/sync/folder-files.ts
15150
+ function unescapeCaption(caption) {
15151
+ return caption.replace(/\\([\\`*_{}[\]()#+\-.!|~<>$&])/g, "$1").trim();
15152
+ }
15153
+ function folderFilesOf(markdown, namedOnly) {
15154
+ let rest = markdown.replace(CHILD_PAGE_TAG_RE, "").replace(CHILD_DATABASE_TAG_RE, "").replace(/<empty-block\/>/g, "");
15155
+ const files = [];
15156
+ for (const span of notionMediaSpans(rest)) {
15157
+ if (!/^https?:\/\//.test(span.url)) return null;
15158
+ const name = uploadedFileNameFromUrl(span.url);
15159
+ if (!name || namedOnly && unescapeCaption(span.label) !== name) return null;
15160
+ rest = rest.replace(span.full, "");
15161
+ files.push({ url: span.url, name });
15162
+ }
15163
+ return files.length > 0 && rest.trim() === "" ? files : null;
15164
+ }
15165
+ var WIKI_EMBED_RE = /!\[\[([^\]|#^\n]+)/g;
15166
+ var MD_EMBED_RE = /!\[[^\]\n]*\]\(<?([^)>\s]+)/g;
15167
+ var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
15168
+ var WIKILINK_TARGET_RE = /\[\[([^\]|#^\n]+)/g;
15169
+ function baseNameOf2(target) {
15170
+ let decoded = target.trim();
15171
+ try {
15172
+ decoded = decodeURI(decoded);
15173
+ } catch {
15174
+ }
15175
+ return decoded.slice(decoded.lastIndexOf("/") + 1).normalize("NFC");
15176
+ }
15177
+ function embeddedNamesOf(notes) {
15178
+ const names = /* @__PURE__ */ new Set();
15179
+ for (const text of notes) {
15180
+ for (const m of text.matchAll(WIKI_EMBED_RE)) names.add(baseNameOf2(m[1]));
15181
+ for (const m of text.matchAll(MD_EMBED_RE)) names.add(baseNameOf2(m[1]));
15182
+ const frontmatter = FRONTMATTER_RE.exec(text)?.[1];
15183
+ if (frontmatter) {
15184
+ for (const m of frontmatter.matchAll(WIKILINK_TARGET_RE)) names.add(baseNameOf2(m[1]));
15185
+ }
15186
+ }
15187
+ return names;
15188
+ }
15189
+ function keyOf3(pageId) {
15190
+ return `folder_files:${compactNotionId(pageId)}`;
15191
+ }
15192
+ function folderFileIdsOf(stateDb, pageId) {
15193
+ const raw = stateDb.getMeta(keyOf3(pageId));
15194
+ if (!raw) return /* @__PURE__ */ new Map();
15195
+ try {
15196
+ return new Map(Object.entries(JSON.parse(raw)));
15197
+ } catch {
15198
+ return /* @__PURE__ */ new Map();
15199
+ }
15200
+ }
15201
+ function forgetFolderFileId(stateDb, pageId, localPath) {
15202
+ const ids = folderFileIdsOf(stateDb, pageId);
15203
+ if (!ids.delete(localPath)) return;
15204
+ stateDb.setMeta(keyOf3(pageId), JSON.stringify(Object.fromEntries(ids)));
15205
+ }
15206
+ function rememberFolderFileId(stateDb, pageId, localPath, fileId) {
15207
+ const ids = folderFileIdsOf(stateDb, pageId);
15208
+ if (ids.get(localPath) === fileId) return;
15209
+ ids.set(localPath, fileId);
15210
+ stateDb.setMeta(keyOf3(pageId), JSON.stringify(Object.fromEntries(ids)));
15211
+ }
15212
+
15052
15213
  // src/sync/file-handler.ts
15214
+ function isHiddenPath(path) {
15215
+ return path.split("/").some((part) => part.startsWith("."));
15216
+ }
15053
15217
  function getFolderPath(filePath) {
15054
15218
  const parts = filePath.split("/");
15055
15219
  return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
@@ -15064,6 +15228,8 @@ var FileHandler = class {
15064
15228
  this.customFetch = options?.fetch;
15065
15229
  this.downloadTimeoutMs = options?.downloadTimeoutMs ?? DEFAULT_CONFIG.advanced.mediaDownloadTimeoutMs;
15066
15230
  this.itemTimeoutMs = options?.itemTimeoutMs ?? DEFAULT_CONFIG.advanced.itemTimeoutMs;
15231
+ this.deleteSync = options?.deleteSync ?? false;
15232
+ this.attachmentsFolder = (options?.attachmentsFolder ?? DEFAULT_CONFIG.paths.attachments).replace(/^\/+|\/+$/g, "");
15067
15233
  }
15068
15234
  vaultFs;
15069
15235
  notionClient;
@@ -15072,6 +15238,8 @@ var FileHandler = class {
15072
15238
  customFetch;
15073
15239
  downloadTimeoutMs;
15074
15240
  itemTimeoutMs;
15241
+ attachmentsFolder;
15242
+ deleteSync;
15075
15243
  get fetchFn() {
15076
15244
  return this.customFetch ?? globalThis.fetch;
15077
15245
  }
@@ -15081,7 +15249,9 @@ var FileHandler = class {
15081
15249
  if (folderFiles.length === 0) return [];
15082
15250
  const results = [];
15083
15251
  for (const file of folderFiles) {
15084
- const result = await this.pushSingleFile(file, folderPageId);
15252
+ const existing = this.stateDb.getFileRegistry(file.path);
15253
+ if (existing && await this.isUnchanged(existing, file)) continue;
15254
+ const result = await this.pushSingleFile(file, folderPageId, existing);
15085
15255
  if (result) results.push(result);
15086
15256
  }
15087
15257
  return results;
@@ -15102,18 +15272,11 @@ var FileHandler = class {
15102
15272
  * 내면 같은 말이 두 번 나온다). 없으면 경고 로그로만 남는다.
15103
15273
  * @returns 업로드 결과. 변경 없음(스킵)이거나 실패했으면 null.
15104
15274
  */
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
- }
15275
+ async pushSingleFile(file, folderPageId, existing, failed) {
15113
15276
  await this.sema.acquire();
15114
15277
  try {
15115
15278
  return await withDeadline(
15116
- () => this.uploadFile(file, folderPageId),
15279
+ () => this.uploadFile(file, folderPageId, existing),
15117
15280
  this.itemTimeoutMs,
15118
15281
  `\uCCA8\uBD80 \uC5C5\uB85C\uB4DC ${file.path}`
15119
15282
  );
@@ -15134,6 +15297,22 @@ var FileHandler = class {
15134
15297
  this.sema.release();
15135
15298
  }
15136
15299
  }
15300
+ /** 올린 뒤 바뀌지 않았나 — 크기가 같으면 읽지 않는다. */
15301
+ async isUnchanged(existing, file) {
15302
+ if (existing.fileSize === file.size) return true;
15303
+ const buffer = await this.vaultFs.readBinary(file.path);
15304
+ return existing.fileHash === createHash("sha256").update(buffer).digest("hex");
15305
+ }
15306
+ /** pull 이 받은 것을 두는 폴더 안인가. */
15307
+ inAttachmentsFolder(path) {
15308
+ return this.attachmentsFolder !== "" && path.startsWith(`${this.attachmentsFolder}/`);
15309
+ }
15310
+ /** 폴더 첨부를 둘 Notion 페이지 — 폴더 노트 없는 폴더의 페이지(폴더 레코드)만. 맨 위는 루트 기록이 있을 때만. */
15311
+ folderPageOf(folder) {
15312
+ const record = this.stateDb.getByPath(folder || "__root__");
15313
+ if (!record?.notionPageId) return null;
15314
+ return folder === "" || isFolderRecord(record) ? record.notionPageId : null;
15315
+ }
15137
15316
  /**
15138
15317
  * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 아예 빼고, Notion 이 받지 않는
15139
15318
  * 형식은 따로 모은다.
@@ -15160,7 +15339,7 @@ var FileHandler = class {
15160
15339
  * @param failed 올리지 못한 첨부를 담을 곳 — push 결과의 `failed` 를 넘기면 CLI · 플러그인이 처리하지 못한 것으로
15161
15340
  * 보인다. 예전엔 경고 로그로만 남아 push 가 「실패 0」 으로 끝났다.
15162
15341
  */
15163
- async pushAllFiles(failed) {
15342
+ async pushAllFiles(failed, folders) {
15164
15343
  const { uploadable: allFiles, unsupported: unsupported2 } = await this.listUploadableFiles();
15165
15344
  if (unsupported2.length > 0) {
15166
15345
  const names = unsupported2.slice(0, LOGGED_NAMES).map((f) => f.path);
@@ -15169,32 +15348,225 @@ var FileHandler = class {
15169
15348
  `[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
15349
  );
15171
15350
  }
15172
- if (allFiles.length === 0) return [];
15173
- const filesByFolder = /* @__PURE__ */ new Map();
15351
+ if (this.deleteSync) await this.removeDeletedFolderFiles();
15352
+ const pending = [];
15174
15353
  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);
15354
+ if (this.inAttachmentsFolder(file.path) || isHiddenPath(file.path)) continue;
15355
+ const existing = this.stateDb.getFileRegistry(file.path);
15356
+ if (existing) {
15357
+ if (existing.notionPageId !== this.folderPageOf(getFolderPath(file.path))) continue;
15358
+ if (await this.isUnchanged(existing, file)) continue;
15359
+ }
15360
+ pending.push({ file, existing });
15361
+ }
15362
+ if (pending.length === 0) return [];
15363
+ const embedded = pending.some((p) => !p.existing) ? await this.embeddedNames() : /* @__PURE__ */ new Set();
15364
+ const byFolder = /* @__PURE__ */ new Map();
15365
+ for (const item of pending) {
15366
+ if (!item.existing && embedded.has(item.file.path.slice(item.file.path.lastIndexOf("/") + 1).normalize("NFC"))) {
15367
+ continue;
15368
+ }
15369
+ const folder = getFolderPath(item.file.path);
15370
+ const list = byFolder.get(folder) ?? [];
15371
+ list.push(item);
15372
+ byFolder.set(folder, list);
15179
15373
  }
15180
15374
  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)`
15375
+ const stranded = [];
15376
+ for (const [folder, items] of byFolder) {
15377
+ const pageId = await this.provideFolderPage(folder, folders, items, stranded);
15378
+ if (!pageId) continue;
15379
+ for (const { file, existing } of items) {
15380
+ const result = await this.pushSingleFile(file, pageId, existing, failed);
15381
+ if (result) results.push(result);
15382
+ }
15383
+ }
15384
+ this.reportStranded(stranded);
15385
+ return results;
15386
+ }
15387
+ /**
15388
+ * 볼트에서 지운 폴더 첨부를 Notion 에서도 지운다(deleteSync) — 폴더 페이지에 적힌 파일 가운데 볼트에 없는 것의 블록과 기록.
15389
+ * 포함 · 제외 패턴으로 빠진 파일은 볼트에 있어 지우지 않는다.
15390
+ */
15391
+ async removeDeletedFolderFiles() {
15392
+ const removed = [];
15393
+ for (const record of this.stateDb.getAll()) {
15394
+ if (!record.notionPageId || !isFolderRecord(record)) continue;
15395
+ for (const entry of this.stateDb.getFilesByPageId(record.notionPageId)) {
15396
+ if (getFolderPath(entry.localPath) !== record.obsidianPath) continue;
15397
+ if (await this.vaultFs.exists(entry.localPath)) continue;
15398
+ try {
15399
+ const name = entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1);
15400
+ await this.removeOlderBlocks(record.notionPageId, name, void 0);
15401
+ this.stateDb.deleteFileRegistry(entry.localPath);
15402
+ forgetFolderFileId(this.stateDb, record.notionPageId, entry.localPath);
15403
+ removed.push(entry.localPath);
15404
+ } catch (error) {
15405
+ getLogger().warn(
15406
+ `[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
15407
  );
15189
15408
  }
15409
+ }
15410
+ }
15411
+ if (removed.length > 0) {
15412
+ getLogger().info(
15413
+ `[Im-Nobsidian] \uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80 ${removed.length}\uAC1C\uB97C Notion \uC5D0\uC11C \uC9C0\uC6C0`
15414
+ );
15415
+ }
15416
+ }
15417
+ /** 폴더 첨부를 둘 페이지 — 노트 없는 폴더면 마련한다. 둘 곳이 없으면 그 파일들을 `stranded` 에 담고 null. */
15418
+ async provideFolderPage(folder, folders, items, stranded) {
15419
+ let pageId = this.folderPageOf(folder);
15420
+ let reason = folder === "" ? "\uBCFC\uD2B8 \uB9E8 \uC704" : "\uD3F4\uB354 \uB178\uD2B8\uAC00 \uC788\uB294 \uD3F4\uB354 \xB7 DB \uD3F4\uB354";
15421
+ if (!pageId && folder !== "" && folders && !this.stateDb.getByPath(folder)) {
15422
+ try {
15423
+ await folders.ensureFolderPage(folder);
15424
+ pageId = this.folderPageOf(folder);
15425
+ } catch (error) {
15426
+ reason = error instanceof Error ? error.message : String(error);
15427
+ }
15428
+ }
15429
+ if (!pageId) stranded.push(...items.map(({ file }) => ({ path: file.path, reason })));
15430
+ return pageId;
15431
+ }
15432
+ /**
15433
+ * 둘 곳이 없어 올리지 않은 폴더 첨부 — 경고로 알린다. 예전에는 디버그 로그로만 남아 파일이 Notion 에 가지 않은 것을 알 수
15434
+ * 없었다. 노트에 붙이면 그 노트와 함께 올라간다.
15435
+ */
15436
+ reportStranded(stranded) {
15437
+ if (stranded.length === 0) return;
15438
+ const names = stranded.slice(0, LOGGED_NAMES).map((f) => `${f.path}(${f.reason})`);
15439
+ const rest = stranded.length - names.length;
15440
+ getLogger().warn(
15441
+ `[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` : ""}`
15442
+ );
15443
+ }
15444
+ /** 볼트의 노트가 붙인 파일 이름 — {@link embeddedNamesOf}. 읽지 못한 노트는 건너뛴다. */
15445
+ async embeddedNames() {
15446
+ const texts = [];
15447
+ for (const note of await this.vaultFs.listMarkdownFiles()) {
15448
+ try {
15449
+ texts.push(await this.vaultFs.readFile(note.path));
15450
+ } catch {
15451
+ }
15452
+ }
15453
+ return embeddedNamesOf(texts);
15454
+ }
15455
+ /**
15456
+ * 폴더 페이지의 폴더 첨부를 그 폴더에 받는다 — 올린 이름 그대로. Notion 파일 id 가 적어 둔 것과 같으면(올린 볼트 · 앞서
15457
+ * 받은 볼트) 받지 않는다. Notion 에서 바뀐 파일은 볼트 쪽이 그대로일 때만 덮는다 — 볼트에서도 고쳤으면 두고 알린다(다음
15458
+ * push 가 볼트의 것을 올린다).
15459
+ *
15460
+ * @param complete 본문을 끝까지 받았나 — 그럴 때만 Notion 에서 지운 파일을 볼트에서 지운다(deleteSync). 잘린 본문에 없는
15461
+ * 파일은 지운 것이 아니다.
15462
+ * @returns 새로 받거나 바꾼 파일 수
15463
+ */
15464
+ async receiveFolderFiles(folder, pageId, files, complete2 = false) {
15465
+ const known = new Map(
15466
+ this.stateDb.getFilesByPageId(pageId).map((entry) => [entry.localPath, entry])
15467
+ );
15468
+ const ids = folderFileIdsOf(this.stateDb, pageId);
15469
+ let received = 0;
15470
+ for (const file of files) {
15471
+ const localPath = folder ? `${folder}/${file.name}` : file.name;
15472
+ const fileId = notionFileIdOf(file.url);
15473
+ const entry = known.get(localPath);
15474
+ if (entry && fileId && ids.get(localPath) === fileId) continue;
15475
+ if (entry && fileId && !ids.has(localPath)) {
15476
+ rememberFolderFileId(this.stateDb, pageId, localPath, fileId);
15190
15477
  continue;
15191
15478
  }
15192
- for (const file of files) {
15193
- const result = await this.pushSingleFile(file, folderPageId, failed);
15194
- if (result) results.push(result);
15479
+ if (entry && !fileId) continue;
15480
+ await this.sema.acquire();
15481
+ try {
15482
+ if (await this.receiveOne(localPath, folder, pageId, file, entry ?? null)) received++;
15483
+ if (fileId) rememberFolderFileId(this.stateDb, pageId, localPath, fileId);
15484
+ } catch (error) {
15485
+ getLogger().warn(
15486
+ `[Im-Nobsidian] \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBC1B\uC9C0 \uBABB\uD568 (${localPath}): ${error instanceof Error ? error.message : String(error)}`
15487
+ );
15488
+ } finally {
15489
+ this.sema.release();
15195
15490
  }
15196
15491
  }
15197
- return results;
15492
+ if (complete2 && this.deleteSync) {
15493
+ const names = new Set(files.map((file) => file.name));
15494
+ for (const entry of known.values()) {
15495
+ const name = entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1);
15496
+ if (getFolderPath(entry.localPath) !== folder || names.has(name)) continue;
15497
+ if (!ids.has(entry.localPath)) continue;
15498
+ await this.removeLocalFolderFile(pageId, entry);
15499
+ }
15500
+ }
15501
+ return received;
15502
+ }
15503
+ /**
15504
+ * Notion 에서 지운 폴더 첨부를 볼트에서 지운다 — 지난 동기화 그대로일 때만. 볼트에서 고쳤으면 두고 기록만 놓는다 — 다음 push
15505
+ * 가 다시 올린다(노트의 원격 삭제와 같은 규칙).
15506
+ */
15507
+ async removeLocalFolderFile(pageId, entry) {
15508
+ try {
15509
+ if (await this.vaultFs.exists(entry.localPath)) {
15510
+ const local = createHash("sha256").update(await this.vaultFs.readBinary(entry.localPath)).digest("hex");
15511
+ if (local === entry.fileHash) {
15512
+ await this.vaultFs.deleteFile(entry.localPath);
15513
+ getLogger().info(
15514
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6C0: ${entry.localPath}`
15515
+ );
15516
+ } else {
15517
+ getLogger().warn(
15518
+ `[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}`
15519
+ );
15520
+ }
15521
+ }
15522
+ this.stateDb.deleteFileRegistry(entry.localPath);
15523
+ forgetFolderFileId(this.stateDb, pageId, entry.localPath);
15524
+ } catch (error) {
15525
+ getLogger().warn(
15526
+ `[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)}`
15527
+ );
15528
+ }
15529
+ }
15530
+ /**
15531
+ * 폴더 첨부 하나를 받는다. 볼트에 같은 경로가 있으면: 같은 내용이면 적기만 하고, 지난 동기화 뒤 볼트에서 그대로인 파일은
15532
+ * 덮고(`entry`), 그 밖(볼트에서 고친 것 · 처음 보는 다른 파일)은 두고 알린다.
15533
+ *
15534
+ * @returns 볼트에 썼는가
15535
+ */
15536
+ async receiveOne(localPath, folder, pageId, file, entry) {
15537
+ const response = await fetchForDownload(this.fetchFn, file.url, this.downloadTimeoutMs);
15538
+ if (!response.ok) {
15539
+ throw new Error(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
15540
+ }
15541
+ const buffer = Buffer.from(await response.arrayBuffer());
15542
+ const hash = createHash("sha256").update(buffer).digest("hex");
15543
+ let wrote = false;
15544
+ if (await this.vaultFs.exists(localPath)) {
15545
+ const localHash = createHash("sha256").update(await this.vaultFs.readBinary(localPath)).digest("hex");
15546
+ if (localHash !== hash) {
15547
+ if (entry?.fileHash !== localHash) {
15548
+ getLogger().warn(
15549
+ `[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)"}`
15550
+ );
15551
+ return false;
15552
+ }
15553
+ await this.vaultFs.writeBinary(localPath, buffer);
15554
+ wrote = true;
15555
+ }
15556
+ } else {
15557
+ await this.vaultFs.ensureFolder(folder || ".");
15558
+ await this.vaultFs.writeBinary(localPath, buffer);
15559
+ wrote = true;
15560
+ }
15561
+ this.stateDb.registerFile({
15562
+ localPath,
15563
+ notionPageId: pageId,
15564
+ fileUploadId: "",
15565
+ fileType: getBlockType(file.name),
15566
+ fileHash: hash,
15567
+ fileSize: buffer.length
15568
+ });
15569
+ return wrote;
15198
15570
  }
15199
15571
  async downloadFileBlock(url, filename, targetFolder) {
15200
15572
  const response = await fetchForDownload(this.fetchFn, url, this.downloadTimeoutMs);
@@ -15225,7 +15597,7 @@ var FileHandler = class {
15225
15597
  }
15226
15598
  return results;
15227
15599
  }
15228
- async uploadFile(file, parentPageId) {
15600
+ async uploadFile(file, parentPageId, previous) {
15229
15601
  const buffer = await this.vaultFs.readBinary(file.path);
15230
15602
  const filename = file.path.split("/").pop() ?? "file";
15231
15603
  const contentType = getMimeType(filename);
@@ -15233,8 +15605,12 @@ var FileHandler = class {
15233
15605
  const hash = createHash("sha256").update(buffer).digest("hex");
15234
15606
  const blob = new Blob([buffer], { type: contentType });
15235
15607
  const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
15236
- const block = this.createFileBlock(blockType, fileUploadId);
15237
- await this.notionClient.appendChildren(parentPageId, [block]);
15608
+ const block = this.createFileBlock(blockType, fileUploadId, filename);
15609
+ const [created] = await this.notionClient.appendChildBlocks(parentPageId, [block]);
15610
+ if (previous) await this.removeOlderBlocks(parentPageId, filename, created?.id);
15611
+ const url = created ? fileUrlOfBlock(created) : null;
15612
+ const fileId = url ? notionFileIdOf(url) : null;
15613
+ if (fileId) rememberFolderFileId(this.stateDb, parentPageId, file.path, fileId);
15238
15614
  this.stateDb.registerFile({
15239
15615
  localPath: file.path,
15240
15616
  notionPageId: parentPageId,
@@ -15246,10 +15622,25 @@ var FileHandler = class {
15246
15622
  getLogger().info(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC: ${file.path} \u2192 ${blockType} \uBE14\uB85D`);
15247
15623
  return { localPath: file.path, fileUploadId, blockType };
15248
15624
  }
15249
- createFileBlock(blockType, fileUploadId) {
15625
+ /** 이 페이지에서 같은 이름의 파일 블록 중 방금 붙인 것 말고를 지운다. */
15626
+ async removeOlderBlocks(pageId, name, keep) {
15627
+ for (const block of await this.notionClient.fetchAllChildren(pageId)) {
15628
+ if (block.id === keep) continue;
15629
+ const media = block[block.type];
15630
+ const url = media?.type === "file" ? media.file?.url : void 0;
15631
+ if (!url || uploadedFileNameFromUrl(url) !== name) continue;
15632
+ await this.notionClient.deleteBlock(block.id);
15633
+ }
15634
+ }
15635
+ /**
15636
+ * 폴더 첨부의 파일 블록 — 캡션에 파일 이름을 적는다. 받는 쪽이 그 표시로 폴더 첨부를 알아본다({@link folderFilesOf}) —
15637
+ * 자식 페이지가 없는 폴더 페이지는 PDF 하나만 붙인 노트와 모양이 같다.
15638
+ */
15639
+ createFileBlock(blockType, fileUploadId, name) {
15250
15640
  const fileRef = {
15251
15641
  type: "file_upload",
15252
- file_upload: { id: fileUploadId }
15642
+ file_upload: { id: fileUploadId },
15643
+ caption: [{ type: "text", text: { content: name } }]
15253
15644
  };
15254
15645
  switch (blockType) {
15255
15646
  case "image":
@@ -19114,7 +19505,7 @@ var PageDecorations = class {
19114
19505
  }
19115
19506
  case "local":
19116
19507
  try {
19117
- const uploaded = await this.imageHandler.uploadLocalImage(source.target, folderOf2(path));
19508
+ const uploaded = await this.imageHandler.uploadLocalImage(source.target, folderOf3(path));
19118
19509
  return { type: "file_upload", file_upload: { id: uploaded.fileUploadId } };
19119
19510
  } catch (error) {
19120
19511
  getLogger().warn(
@@ -19159,7 +19550,7 @@ function sameText(a, b) {
19159
19550
  if (isEmptyFrontmatterValue(before) && isEmptyFrontmatterValue(after)) return true;
19160
19551
  return String(before) === String(after);
19161
19552
  }
19162
- function folderOf2(notePath) {
19553
+ function folderOf3(notePath) {
19163
19554
  return notePath.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
19164
19555
  }
19165
19556
 
@@ -20963,7 +21354,7 @@ async function verifyPageCompleteness(remote, local, rootPageId) {
20963
21354
  var ID_SUFFIX = new RegExp(
20964
21355
  ` \\((?:${PAGE_ID_SUFFIX_LENGTHS.map((len) => `[0-9a-f]{${len}}`).join("|")})\\)$`
20965
21356
  );
20966
- function folderOf3(record) {
21357
+ function folderOf4(record) {
20967
21358
  if (record.fileType === "db-row") return null;
20968
21359
  if (isFolderRecord(record)) return record.obsidianPath;
20969
21360
  const slash = record.obsidianPath.lastIndexOf("/");
@@ -20980,10 +21371,10 @@ function findNestedFolderPages(records) {
20980
21371
  const found2 = [];
20981
21372
  for (const inner of records) {
20982
21373
  if (inner.notionPageId === null || inner.notionParentId === null) continue;
20983
- const folder = folderOf3(inner);
21374
+ const folder = folderOf4(inner);
20984
21375
  if (folder === null) continue;
20985
21376
  const outer = byPage.get(compactNotionId(inner.notionParentId));
20986
- if (!outer || outer === inner || folderOf3(outer) !== folder) continue;
21377
+ if (!outer || outer === inner || folderOf4(outer) !== folder) continue;
20987
21378
  found2.push({
20988
21379
  folder,
20989
21380
  outer: { path: outer.obsidianPath, notionPageId: outer.notionPageId },
@@ -21358,7 +21749,7 @@ var VaultSchemaRenames = class {
21358
21749
  const meta = await this.notionClient.getDatabaseMeta(databaseId);
21359
21750
  for (const [from, to] of pairs) {
21360
21751
  const carrying = rows.filter((row) => Object.hasOwn(row.current, to));
21361
- const folder = folderOf4(carrying[0].record.obsidianPath);
21752
+ const folder = folderOf5(carrying[0].record.obsidianPath);
21362
21753
  const owners = meta.dataSources.filter(
21363
21754
  (ds) => ds.properties?.[from] !== void 0
21364
21755
  );
@@ -21524,7 +21915,7 @@ function sameRow(a, b) {
21524
21915
  const keys = Object.keys(left.data);
21525
21916
  return left.content === right.content && keys.length === Object.keys(right.data).length && keys.every((key) => sameFrontmatterValue(left.data[key], right.data[key]));
21526
21917
  }
21527
- function folderOf4(path) {
21918
+ function folderOf5(path) {
21528
21919
  const at = path.lastIndexOf("/");
21529
21920
  return at === -1 ? "" : path.slice(0, at);
21530
21921
  }
@@ -24450,7 +24841,7 @@ function rewriteTarget(target, source, context, kind) {
24450
24841
  const shown = keepExtension ? destination : destination.slice(0, -".md".length);
24451
24842
  let next;
24452
24843
  if (resolved.relative) {
24453
- next = relativePath(folderOf5(source.to), shown);
24844
+ next = relativePath(folderOf6(source.to), shown);
24454
24845
  if (!linkpath.startsWith("./") && next.startsWith("./")) next = next.slice(2);
24455
24846
  } else if (style === "name") {
24456
24847
  const name = baseName3(shown);
@@ -24472,14 +24863,14 @@ function resolveLinkpath(linkpath, sourcePath, files) {
24472
24863
  const matches = byName(files, baseName3(candidate)).filter((file) => file.endsWith(suffix));
24473
24864
  if (matches.length === 1) return found(matches[0], linkpath);
24474
24865
  if (matches.length > 1) {
24475
- const local = `${folderPrefix(folderOf5(sourcePath))}${candidate}`;
24866
+ const local = `${folderPrefix(folderOf6(sourcePath))}${candidate}`;
24476
24867
  return matches.includes(local) ? found(local, linkpath) : null;
24477
24868
  }
24478
24869
  }
24479
24870
  return null;
24480
24871
  }
24481
24872
  function resolveRelative(linkpath, sourcePath, files) {
24482
- const joined = normalizeSegments(`${folderPrefix(folderOf5(sourcePath))}${linkpath}`);
24873
+ const joined = normalizeSegments(`${folderPrefix(folderOf6(sourcePath))}${linkpath}`);
24483
24874
  if (joined === null) return null;
24484
24875
  for (const candidate of withExtension(joined)) {
24485
24876
  if (files.has(candidate)) return { ...found(candidate, linkpath), relative: true };
@@ -24538,7 +24929,7 @@ function relativePath(from, to) {
24538
24929
  function encodeMarkdownTarget(next, original) {
24539
24930
  return original.includes("%") || next.includes(" ") ? next.replace(/ /g, "%20") : next;
24540
24931
  }
24541
- function folderOf5(path) {
24932
+ function folderOf6(path) {
24542
24933
  const slash = path.lastIndexOf("/");
24543
24934
  return slash === -1 ? "" : path.slice(0, slash);
24544
24935
  }
@@ -26841,7 +27232,7 @@ async function resolveBlockToPageId(notionClient, blockId) {
26841
27232
 
26842
27233
  // src/sync/page-puller.ts
26843
27234
  var PagePuller = class {
26844
- constructor(config, stateDb, notionClient, vaultFs, pipeline, imageHandler, propertyMapper, databaseSyncer, observation, drift, placement, detector, discovery) {
27235
+ constructor(config, stateDb, notionClient, vaultFs, pipeline, imageHandler, propertyMapper, databaseSyncer, observation, drift, placement, detector, discovery, fileHandler) {
26845
27236
  this.config = config;
26846
27237
  this.stateDb = stateDb;
26847
27238
  this.notionClient = notionClient;
@@ -26855,6 +27246,7 @@ var PagePuller = class {
26855
27246
  this.placement = placement;
26856
27247
  this.detector = detector;
26857
27248
  this.discovery = discovery;
27249
+ this.fileHandler = fileHandler;
26858
27250
  }
26859
27251
  config;
26860
27252
  stateDb;
@@ -26869,6 +27261,7 @@ var PagePuller = class {
26869
27261
  placement;
26870
27262
  detector;
26871
27263
  discovery;
27264
+ fileHandler;
26872
27265
  dbSchemaLoaded = false;
26873
27266
  _pullImageCount = 0;
26874
27267
  _pullFileCount = 0;
@@ -27025,8 +27418,17 @@ var PagePuller = class {
27025
27418
  if (excluded !== null) return { path: excluded, excluded: true };
27026
27419
  const fetched = await this.fetchPageMarkdown(pageId);
27027
27420
  const hasChildPages = this.hasChildContainers(pageId, fetched.children);
27028
- if (hasChildPages && !fetched.hasBody) {
27029
- return this.createFolderOnly(pageId, page, fetched.fingerprint, parentPath, safeName);
27421
+ const folderFiles = fetched.folderFiles && (hasChildPages || fetched.folderFiles.named) ? fetched.folderFiles.files : null;
27422
+ if (hasChildPages && !fetched.hasBody || folderFiles) {
27423
+ const created = await this.createFolderOnly(
27424
+ pageId,
27425
+ page,
27426
+ fetched.fingerprint,
27427
+ parentPath,
27428
+ safeName
27429
+ );
27430
+ if (folderFiles) await this.receiveFolderFiles(created.path, pageId, folderFiles);
27431
+ return created;
27030
27432
  }
27031
27433
  const fileType = hasChildPages ? "folder-note" : "file";
27032
27434
  const noteDir = hasChildPages ? parentPath ? `${parentPath}/${safeName}` : safeName : parentPath;
@@ -27489,7 +27891,8 @@ var PagePuller = class {
27489
27891
  compact: isCompactExport(result.markdown),
27490
27892
  children: extractChildTags(result.markdown),
27491
27893
  fingerprint: remoteBodyFingerprint(result.markdown),
27492
- hasBody: hasBodyBesidesChildren(result.markdown)
27894
+ hasBody: hasBodyBesidesChildren(result.markdown),
27895
+ folderFiles: folderFilesInBody(result.markdown)
27493
27896
  };
27494
27897
  } catch (error) {
27495
27898
  getLogger().warn(
@@ -27508,9 +27911,34 @@ var PagePuller = class {
27508
27911
  compact: isCompactExport(fallback.markdown),
27509
27912
  children: extractChildTags(fallback.markdown),
27510
27913
  fingerprint: null,
27511
- hasBody: hasBodyBesidesChildren(fallback.markdown)
27914
+ hasBody: hasBodyBesidesChildren(fallback.markdown),
27915
+ folderFiles: folderFilesInBody(fallback.markdown)
27512
27916
  };
27513
27917
  }
27918
+ /** 폴더 첨부를 받아 받은 파일 수에 더한다. */
27919
+ async receiveFolderFiles(folder, pageId, files, complete2 = false) {
27920
+ this._pullFileCount += await this.fileHandler.receiveFolderFiles(
27921
+ folder,
27922
+ pageId,
27923
+ files,
27924
+ complete2
27925
+ );
27926
+ }
27927
+ /**
27928
+ * 이 폴더 레코드의 Notion 페이지에서 받을 본문이 있나 — dry-run 이 실제 pull({@link pullFolderRecord})과 같게 센다.
27929
+ * 본문이 하위 페이지뿐이면 실제 pull 은 본 것만 적고 고친 것으로 세지 않는다.
27930
+ */
27931
+ async folderPageHasBody(record) {
27932
+ const page = await this.notionClient.getPage(record.notionPageId);
27933
+ if (isTrashedOrArchived(page)) return false;
27934
+ const { markdown } = await this.notionClient.getPageMarkdown(page.id);
27935
+ return hasBodyBesidesChildren(markdown) && folderFilesOf(markdown, false) === null && this.folderNoteRefusal(record.obsidianPath) === null;
27936
+ }
27937
+ /** 이 폴더에 폴더 노트를 둘 수 없는 까닭 — 둘 수 있으면 null. */
27938
+ folderNoteRefusal(folder) {
27939
+ if (isDatabaseMode(this.config)) return "DB \uBAA8\uB4DC\uB294 \uD3F4\uB354 \uB178\uD2B8\uB97C \uBC1B\uC9C0 \uC54A\uC74C";
27940
+ return this.placement.folderLookup().databaseAt(folder) ? "\uADF8 \uD3F4\uB354\uB294 DB \uB77C \uD3F4\uB354 \uB178\uD2B8\uB97C \uB458 \uC218 \uC5C6\uC74C" : null;
27941
+ }
27514
27942
  /**
27515
27943
  * push 가 만든 폴더 페이지(폴더 레코드)의 원격 변경을 받는다(S-17). 폴더 레코드에는 볼트 파일이
27516
27944
  * 없다 — 예전에는 폴더 경로를 파일로 읽어 «빈 로컬 파일» 과 원격 본문의 충돌로 남겼다.
@@ -27527,26 +27955,18 @@ var PagePuller = class {
27527
27955
  * @throws 받을 자리가 없을 때 — 이유와 함께 실패로 남고, 다음 pull 이 다시 본다(기준 시각이
27528
27956
  * 이 변경 앞에 묶인다).
27529
27957
  */
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
27958
  async pullFolderRecord(record, page) {
27546
27959
  const folder = record.obsidianPath;
27547
27960
  if (isTrashedOrArchived(page)) return null;
27548
- const { markdown } = await this.notionClient.getPageMarkdown(page.id);
27961
+ const { markdown, truncated } = await this.notionClient.getPageMarkdown(page.id);
27549
27962
  if (!hasBodyBesidesChildren(markdown)) {
27963
+ await this.receiveFolderFiles(folder, page.id, [], !truncated);
27964
+ this.observation.record(record.id, page, remoteBodyFingerprint(markdown));
27965
+ return null;
27966
+ }
27967
+ const files = folderFilesOf(markdown, false);
27968
+ if (files) {
27969
+ await this.receiveFolderFiles(folder, page.id, files, !truncated);
27550
27970
  this.observation.record(record.id, page, remoteBodyFingerprint(markdown));
27551
27971
  return null;
27552
27972
  }
@@ -27735,6 +28155,10 @@ var PagePuller = class {
27735
28155
  this.dbSchemaLoaded = true;
27736
28156
  }
27737
28157
  };
28158
+ function folderFilesInBody(markdown) {
28159
+ const files = folderFilesOf(markdown, false);
28160
+ return files ? { files, named: folderFilesOf(markdown, true) !== null } : null;
28161
+ }
27738
28162
 
27739
28163
  // src/sync/frontmatter-link-resolver.ts
27740
28164
  function resolveFrontmatterRelations(content, idToTitle) {
@@ -28844,7 +29268,9 @@ var SyncOrchestrator = class {
28844
29268
  {
28845
29269
  fetch: customFetch,
28846
29270
  downloadTimeoutMs: config.advanced.mediaDownloadTimeoutMs,
28847
- itemTimeoutMs: config.advanced.itemTimeoutMs
29271
+ itemTimeoutMs: config.advanced.itemTimeoutMs,
29272
+ attachmentsFolder: config.paths.attachments,
29273
+ deleteSync: config.sync.deleteSync
28848
29274
  }
28849
29275
  );
28850
29276
  const propertyMapper = PropertyMapper.fromConfig(config);
@@ -28954,7 +29380,8 @@ var SyncOrchestrator = class {
28954
29380
  this.drift,
28955
29381
  this.placement,
28956
29382
  this.detector,
28957
- this.discovery
29383
+ this.discovery,
29384
+ this.fileHandler
28958
29385
  );
28959
29386
  this.pullPlanner = new PullPlanner(
28960
29387
  config,
@@ -29074,14 +29501,16 @@ var SyncOrchestrator = class {
29074
29501
  * 있든 없든 push 마다 부른다 — 바뀌지 않은 첨부는 파일 기록이 건너뛴다.
29075
29502
  */
29076
29503
  async pushVaultFiles(failed) {
29077
- if (this.config.sync.syncFiles === false) return;
29504
+ if (this.config.sync.syncFiles === false) return 0;
29078
29505
  try {
29079
- const fileResults = await this.fileHandler.pushAllFiles(failed);
29506
+ const fileResults = await this.fileHandler.pushAllFiles(failed, this.placement);
29080
29507
  if (fileResults.length > 0) {
29081
29508
  getLogger().info(`[Im-Nobsidian] ${fileResults.length}\uAC1C \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC`);
29082
29509
  }
29510
+ return fileResults.length;
29083
29511
  } catch (error) {
29084
29512
  getLogger().warn("[Im-Nobsidian] \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC911 \uC624\uB958:", error);
29513
+ return 0;
29085
29514
  }
29086
29515
  }
29087
29516
  /**
@@ -29170,12 +29599,13 @@ var SyncOrchestrator = class {
29170
29599
  ...viewPlan.refused,
29171
29600
  ...viewed2.failed
29172
29601
  ];
29173
- if (!options?.dryRun && !options?.signal?.aborted) await this.pushVaultFiles(failed2);
29602
+ const uploadedFiles2 = !options?.dryRun && !options?.signal?.aborted ? await this.pushVaultFiles(failed2) : 0;
29174
29603
  return {
29175
29604
  created: 0,
29176
29605
  updated: 0,
29177
29606
  deleted: 0,
29178
29607
  moved: 0,
29608
+ ...uploadedFiles2 > 0 ? { uploadedFiles: uploadedFiles2 } : {},
29179
29609
  failed: failed2,
29180
29610
  duration: Date.now() - startTime,
29181
29611
  ...edited2.applied.length > 0 ? { schemaEdits: edited2.applied } : {},
@@ -29454,7 +29884,7 @@ var SyncOrchestrator = class {
29454
29884
  signal: options?.signal
29455
29885
  });
29456
29886
  }
29457
- await this.pushVaultFiles(failed);
29887
+ const uploadedFiles = await this.pushVaultFiles(failed);
29458
29888
  this.stateDb.setMeta("last_push_at", (/* @__PURE__ */ new Date()).toISOString());
29459
29889
  this.stateDb.setMeta(LAST_SYNC_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
29460
29890
  this.stateDb.setMeta(PUSH_IN_PROGRESS_META_KEY, "");
@@ -29466,6 +29896,7 @@ var SyncOrchestrator = class {
29466
29896
  updated: counts.updated,
29467
29897
  deleted: counts.deleted,
29468
29898
  moved: counts.moved,
29899
+ ...uploadedFiles > 0 ? { uploadedFiles } : {},
29469
29900
  failed,
29470
29901
  duration: Date.now() - startTime,
29471
29902
  readOnlySkipped: this.pusher.takeReadOnlySkips(),