@im-nobsidian/core 0.17.0 → 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
@@ -196,9 +196,7 @@ var DEFAULT_TITLE_PROPERTY = "Name";
196
196
  var DatabaseSyncSchema = z.object({
197
197
  databaseId: z.string(),
198
198
  localFolder: z.string(),
199
- propertyMapping: z.record(z.string()).optional(),
200
- pullFilter: z.unknown().optional(),
201
- titleProperty: z.string().default(DEFAULT_TITLE_PROPERTY)
199
+ pullFilter: z.unknown().optional()
202
200
  });
203
201
  var DEFAULTS = {
204
202
  version: 1,
@@ -268,12 +266,36 @@ var RETIRED_SETTINGS = [
268
266
  note: "\uAEBC\uB3C4 \uBC14\uB010 \uAC83\uC774 \uC5C6\uC5C8\uC2B5\uB2C8\uB2E4 \u2014 frontmatter \uB294 \uB298 Notion \uC18D\uC131\uC73C\uB85C \uC8FC\uACE0\uBC1B\uC2B5\uB2C8\uB2E4"
269
267
  }
270
268
  ];
269
+ var RETIRED_DATABASE_SETTINGS = [
270
+ {
271
+ key: "propertyMapping",
272
+ meant: (value) => value !== void 0,
273
+ note: "\uC5B4\uB514\uC5D0\uB3C4 \uC4F0\uC774\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4 \u2014 \uC18D\uC131\uC740 frontmatter \uC758 \uC774\uB984 \uADF8\uB300\uB85C Notion \uC18D\uC131\uACFC \uC9DD\uC9D3\uC2B5\uB2C8\uB2E4"
274
+ },
275
+ {
276
+ key: "titleProperty",
277
+ meant: (value) => value !== void 0 && value !== DEFAULT_TITLE_PROPERTY,
278
+ note: "\uC5B4\uB514\uC5D0\uB3C4 \uC4F0\uC774\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4 \u2014 \uC81C\uBAA9 \uC18D\uC131\uC740 Notion DB \uC758 title \uC18D\uC131\uC744 \uC77D\uC5B4 \uCC3E\uC2B5\uB2C8\uB2E4"
279
+ }
280
+ ];
271
281
  function retiredSettings(raw) {
272
- return RETIRED_SETTINGS.flatMap(({ path: [section, key], meant, note }) => {
273
- const group2 = raw?.[section];
282
+ const root = raw;
283
+ const lines = RETIRED_SETTINGS.flatMap(({ path: [section, key], meant, note }) => {
284
+ const group2 = root?.[section];
274
285
  const value = group2 && typeof group2 === "object" ? group2[key] : void 0;
275
286
  return meant(value) ? [`${section}.${key}: ${note}`] : [];
276
287
  });
288
+ const notion = root?.notion;
289
+ const databases = Array.isArray(notion?.databases) ? notion.databases : [];
290
+ databases.forEach((database, index) => {
291
+ if (!database || typeof database !== "object") return;
292
+ for (const { key, meant, note } of RETIRED_DATABASE_SETTINGS) {
293
+ if (meant(database[key])) {
294
+ lines.push(`notion.databases[${index}].${key}: ${note}`);
295
+ }
296
+ }
297
+ });
298
+ return lines;
277
299
  }
278
300
  var ConfigSchema = z.object({
279
301
  version: z.literal(1),
@@ -12875,6 +12897,76 @@ function createDefaultPipeline(options) {
12875
12897
  return pipeline;
12876
12898
  }
12877
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
+
12878
12970
  // src/utils/mime.ts
12879
12971
  var EXTENSION_TO_BLOCK_TYPE = {
12880
12972
  ".png": "image",
@@ -12996,11 +13088,11 @@ function getMimeType(filename) {
12996
13088
  }
12997
13089
 
12998
13090
  // src/sync/uploaded-media.ts
12999
- function keyOf(pageId) {
13091
+ function keyOf2(pageId) {
13000
13092
  return `uploaded_media:${compactNotionId(pageId)}`;
13001
13093
  }
13002
13094
  function rememberUploadedMedia(stateDb, pageId, media) {
13003
- const key = keyOf(pageId);
13095
+ const key = keyOf2(pageId);
13004
13096
  if (media.length === 0 && stateDb.getMeta(key) === null) return;
13005
13097
  stateDb.setMeta(key, JSON.stringify(media.map(rowOf)));
13006
13098
  }
@@ -13008,14 +13100,14 @@ function rememberReceivedMedia(stateDb, pageId, media) {
13008
13100
  if (media.length === 0) return;
13009
13101
  const merged = recallUploadedMedia(stateDb, pageId);
13010
13102
  for (const item of media) merged.set(item.fileId, item);
13011
- stateDb.setMeta(keyOf(pageId), JSON.stringify([...merged.values()].map(rowOf)));
13103
+ stateDb.setMeta(keyOf2(pageId), JSON.stringify([...merged.values()].map(rowOf)));
13012
13104
  }
13013
13105
  function rowOf({ fileId, target, localPath, fileHash }) {
13014
13106
  return fileHash === void 0 ? { fileId, target, localPath } : { fileId, target, localPath, fileHash };
13015
13107
  }
13016
13108
  function recallUploadedMedia(stateDb, pageId) {
13017
13109
  const known = /* @__PURE__ */ new Map();
13018
- const value = stateDb?.getMeta(keyOf(pageId));
13110
+ const value = stateDb?.getMeta(keyOf2(pageId));
13019
13111
  if (!value) return known;
13020
13112
  try {
13021
13113
  for (const row of JSON.parse(value)) {
@@ -13204,8 +13296,11 @@ function nameHashOf(fullHash) {
13204
13296
  var IMAGE_STEM_MAX = 50;
13205
13297
  function withoutOwnHash(name, hash) {
13206
13298
  if (hash.length !== NAME_HASH_LENGTH || !/^[0-9a-f]+$/.test(hash)) return name;
13207
- const stripped = name.replace(new RegExp(`-${hash}(\\.[^./]*)?$`), "");
13208
- 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;
13209
13304
  }
13210
13305
  function imageStemOf(uploadedName, pageTitle) {
13211
13306
  const stem = safeImageStem(uploadedName.replace(/\.[^.]*$/, ""));
@@ -13248,7 +13343,7 @@ function localEmbedTargets(note) {
13248
13343
  }
13249
13344
  return targets;
13250
13345
  }
13251
- function folderOf(notePath) {
13346
+ function folderOf2(notePath) {
13252
13347
  return notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
13253
13348
  }
13254
13349
  function messageOf2(error) {
@@ -13349,7 +13444,7 @@ var ImageHandler = class {
13349
13444
  if (!target || /^https?:\/\//.test(target) || target.includes("\n")) return null;
13350
13445
  const vaultPath = stripAlias2(target);
13351
13446
  if (baseName(vaultPath) !== uploadedName) return null;
13352
- for (const candidate of this.vaultCandidates(vaultPath, noteFolder)) {
13447
+ for await (const candidate of this.vaultCandidates(vaultPath, noteFolder)) {
13353
13448
  try {
13354
13449
  if (await this.vaultFs.exists(candidate)) return target;
13355
13450
  } catch {
@@ -13389,7 +13484,7 @@ var ImageHandler = class {
13389
13484
  const spans = notionMediaSpans(markdown);
13390
13485
  if (spans.length === 0) return markdown;
13391
13486
  const known = pageId ? recallUploadedMedia(this.stateDb, pageId) : /* @__PURE__ */ new Map();
13392
- const noteFolder = folderOf(notePath);
13487
+ const noteFolder = folderOf2(notePath);
13393
13488
  let result = markdown;
13394
13489
  for (const span of spans) {
13395
13490
  const uploadedName = this.uploadedNameOf(span.url);
@@ -13419,7 +13514,7 @@ var ImageHandler = class {
13419
13514
  return null;
13420
13515
  }
13421
13516
  for (const target of targets) {
13422
- for (const candidate of this.vaultCandidates(stripAlias2(target), noteFolder)) {
13517
+ for await (const candidate of this.vaultCandidates(stripAlias2(target), noteFolder)) {
13423
13518
  let localHash;
13424
13519
  try {
13425
13520
  localHash = sha256Of(await this.vaultFs.readBinary(candidate));
@@ -13562,7 +13657,7 @@ var ImageHandler = class {
13562
13657
  * @returns 보일 이름은 받을 때 붙인 내용 해시를 뗀 것이다 — 다시 받으면 같은 이름 · 같은 경로가 된다.
13563
13658
  */
13564
13659
  async uploadNoteAttachment(target, notePath) {
13565
- const uploaded = await this.uploadLocalImage(target, folderOf(notePath));
13660
+ const uploaded = await this.uploadLocalImage(target, folderOf2(notePath));
13566
13661
  const base = uploaded.localPath.split("/").pop() ?? uploaded.localPath;
13567
13662
  const name = withoutOwnHash(base, nameHashOf(uploaded.fileHash));
13568
13663
  const extension = /\.[^./]+$/.exec(base)?.[0] ?? "";
@@ -13575,7 +13670,7 @@ var ImageHandler = class {
13575
13670
  if (!this.notionClient) return [];
13576
13671
  const localImages = images.filter((img) => !img.isExternal && img.localPath);
13577
13672
  if (localImages.length === 0) return [];
13578
- const noteFolder = folderOf(notePath);
13673
+ const noteFolder = folderOf2(notePath);
13579
13674
  const results = [];
13580
13675
  const imageBlocks = [];
13581
13676
  for (const img of localImages) {
@@ -13662,7 +13757,7 @@ var ImageHandler = class {
13662
13757
  }
13663
13758
  return segment;
13664
13759
  });
13665
- const noteFolder = folderOf(notePath);
13760
+ const noteFolder = folderOf2(notePath);
13666
13761
  const kept = [];
13667
13762
  let result = "";
13668
13763
  let cursor = 0;
@@ -13753,7 +13848,7 @@ var ImageHandler = class {
13753
13848
  if (kept.length > 0) this.rememberUploads(pageId, kept);
13754
13849
  return empty;
13755
13850
  }
13756
- const noteFolder = folderOf(notePath);
13851
+ const noteFolder = folderOf2(notePath);
13757
13852
  const uploaded = [];
13758
13853
  const remembered = [...kept];
13759
13854
  const unsupported2 = [];
@@ -13911,29 +14006,37 @@ var ImageHandler = class {
13911
14006
  /**
13912
14007
  * 임베드 경로가 가리킬 수 있는 볼트 실제 파일 후보를 우선순위대로 만든다(F23).
13913
14008
  * Obsidian 위키링크 임베드는 노트 옆 파일을 파일명만으로 참조하는 게 관례라
13914
- * 노트 폴더 → 볼트 루트 → attachments/ 순.
14009
+ * 노트 폴더 → 볼트 루트 → attachments/ 순. 세 곳에 없으면 볼트 어디서든 이름으로 찾는다({@link VaultFileIndex}).
13915
14010
  *
13916
14011
  * push(업로드)와 pull(원본 복원)이 **같은 사다리**를 봐야 왕복이 닫힌다. 예전에는
13917
14012
  * pull 쪽이 볼트 루트만 확인해서, `__e2e_probe__/노트.md` 안의 `![[t-img.png]]` 는
13918
14013
  * 올라갈 때는 노트 폴더에서 잘 찾아 올리고도 내려올 때는 원본을 못 찾아 사본을
13919
14014
  * 새로 받았다(실측) — 왕복할 때마다 첨부가 한 벌씩 늘어난다.
13920
14015
  */
13921
- vaultCandidates(localPath, noteFolder) {
13922
- const candidates = [];
14016
+ async *vaultCandidates(localPath, noteFolder) {
14017
+ const ladder = [];
13923
14018
  if (noteFolder && !localPath.includes("/")) {
13924
- 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;
13925
14025
  }
13926
- candidates.push(localPath, `${this.attachmentFolder}/${localPath}`);
13927
- return candidates;
14026
+ }
14027
+ indexOfFiles = null;
14028
+ /** 볼트의 첨부 이름표 — 처음 쓸 때 만든다. */
14029
+ get fileIndex() {
14030
+ this.indexOfFiles ??= new VaultFileIndex(this.vaultFs);
14031
+ return this.indexOfFiles;
13928
14032
  }
13929
14033
  /**
13930
14034
  * 임베드 경로를 볼트 실제 파일로 해석해 읽는다(F23).
13931
14035
  * 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
13932
14036
  */
13933
14037
  async readImageFromVault(localPath, noteFolder) {
13934
- const candidates = this.vaultCandidates(localPath, noteFolder);
13935
14038
  let lastError;
13936
- for (const candidate of candidates) {
14039
+ for await (const candidate of this.vaultCandidates(localPath, noteFolder)) {
13937
14040
  try {
13938
14041
  return { buffer: await this.vaultFs.readBinary(candidate), resolvedPath: candidate };
13939
14042
  } catch (error) {
@@ -14126,12 +14229,20 @@ var ImageHandler = class {
14126
14229
  const hash = nameHashOf(fileHash);
14127
14230
  const extension = this.guessFileExtension(url, response.headers.get("content-type"));
14128
14231
  const name = withoutOwnHash(caption, hash);
14129
- const safeName = truncateBytes(
14130
- name.replace(/[^a-zA-Z0-9가-힣\s._-]/g, "").replace(/\s+/g, "-").slice(0, 80),
14131
- FILE_SYSTEM_NAME_MAX_BYTES - utf8Bytes(`-${hash}${extension}`)
14132
- );
14133
- const fileName2 = `${safeName}-${hash}${extension}`;
14134
- 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
+ }
14135
14246
  await this.vaultFs.ensureFolder(this.attachmentFolder);
14136
14247
  await this.vaultFs.writeBinary(localPath, buffer);
14137
14248
  return { originalUrl: url, localPath, hash, size: buffer.length, fileHash, name };
@@ -14141,6 +14252,14 @@ var ImageHandler = class {
14141
14252
  }
14142
14253
  throw lastError;
14143
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
+ }
14144
14263
  isNotionImageUrl(url) {
14145
14264
  return this.isNotionFileUrl(url);
14146
14265
  }
@@ -15027,7 +15146,74 @@ function selectStaleDbArtifacts(files, localFolder, keepPaths) {
15027
15146
  return stale;
15028
15147
  }
15029
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
+
15030
15213
  // src/sync/file-handler.ts
15214
+ function isHiddenPath(path) {
15215
+ return path.split("/").some((part) => part.startsWith("."));
15216
+ }
15031
15217
  function getFolderPath(filePath) {
15032
15218
  const parts = filePath.split("/");
15033
15219
  return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
@@ -15042,6 +15228,8 @@ var FileHandler = class {
15042
15228
  this.customFetch = options?.fetch;
15043
15229
  this.downloadTimeoutMs = options?.downloadTimeoutMs ?? DEFAULT_CONFIG.advanced.mediaDownloadTimeoutMs;
15044
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, "");
15045
15233
  }
15046
15234
  vaultFs;
15047
15235
  notionClient;
@@ -15050,6 +15238,8 @@ var FileHandler = class {
15050
15238
  customFetch;
15051
15239
  downloadTimeoutMs;
15052
15240
  itemTimeoutMs;
15241
+ attachmentsFolder;
15242
+ deleteSync;
15053
15243
  get fetchFn() {
15054
15244
  return this.customFetch ?? globalThis.fetch;
15055
15245
  }
@@ -15059,7 +15249,9 @@ var FileHandler = class {
15059
15249
  if (folderFiles.length === 0) return [];
15060
15250
  const results = [];
15061
15251
  for (const file of folderFiles) {
15062
- 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);
15063
15255
  if (result) results.push(result);
15064
15256
  }
15065
15257
  return results;
@@ -15080,18 +15272,11 @@ var FileHandler = class {
15080
15272
  * 내면 같은 말이 두 번 나온다). 없으면 경고 로그로만 남는다.
15081
15273
  * @returns 업로드 결과. 변경 없음(스킵)이거나 실패했으면 null.
15082
15274
  */
15083
- async pushSingleFile(file, folderPageId, failed) {
15084
- const existing = this.stateDb.getFileRegistry(file.path);
15085
- if (existing) {
15086
- if (existing.fileSize === file.size) return null;
15087
- const buffer = await this.vaultFs.readBinary(file.path);
15088
- const currentHash = createHash("sha256").update(buffer).digest("hex");
15089
- if (existing.fileHash === currentHash) return null;
15090
- }
15275
+ async pushSingleFile(file, folderPageId, existing, failed) {
15091
15276
  await this.sema.acquire();
15092
15277
  try {
15093
15278
  return await withDeadline(
15094
- () => this.uploadFile(file, folderPageId),
15279
+ () => this.uploadFile(file, folderPageId, existing),
15095
15280
  this.itemTimeoutMs,
15096
15281
  `\uCCA8\uBD80 \uC5C5\uB85C\uB4DC ${file.path}`
15097
15282
  );
@@ -15112,6 +15297,22 @@ var FileHandler = class {
15112
15297
  this.sema.release();
15113
15298
  }
15114
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
+ }
15115
15316
  /**
15116
15317
  * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 아예 빼고, Notion 이 받지 않는
15117
15318
  * 형식은 따로 모은다.
@@ -15138,7 +15339,7 @@ var FileHandler = class {
15138
15339
  * @param failed 올리지 못한 첨부를 담을 곳 — push 결과의 `failed` 를 넘기면 CLI · 플러그인이 처리하지 못한 것으로
15139
15340
  * 보인다. 예전엔 경고 로그로만 남아 push 가 「실패 0」 으로 끝났다.
15140
15341
  */
15141
- async pushAllFiles(failed) {
15342
+ async pushAllFiles(failed, folders) {
15142
15343
  const { uploadable: allFiles, unsupported: unsupported2 } = await this.listUploadableFiles();
15143
15344
  if (unsupported2.length > 0) {
15144
15345
  const names = unsupported2.slice(0, LOGGED_NAMES).map((f) => f.path);
@@ -15147,32 +15348,225 @@ var FileHandler = class {
15147
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` : ""}`
15148
15349
  );
15149
15350
  }
15150
- if (allFiles.length === 0) return [];
15151
- const filesByFolder = /* @__PURE__ */ new Map();
15351
+ if (this.deleteSync) await this.removeDeletedFolderFiles();
15352
+ const pending = [];
15152
15353
  for (const file of allFiles) {
15153
- const folder = getFolderPath(file.path);
15154
- const list = filesByFolder.get(folder) ?? [];
15155
- list.push(file);
15156
- 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);
15157
15373
  }
15158
15374
  const results = [];
15159
- for (const [folderPath, files] of filesByFolder) {
15160
- const folderRecord = folderPath ? this.stateDb.getByPath(folderPath) : this.stateDb.getByPath("__root__");
15161
- const folderPageId = folderRecord?.notionPageId;
15162
- if (!folderPageId) {
15163
- if (files.length > 0) {
15164
- getLogger().debug(
15165
- `\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)}`
15166
15407
  );
15167
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);
15168
15477
  continue;
15169
15478
  }
15170
- for (const file of files) {
15171
- const result = await this.pushSingleFile(file, folderPageId, failed);
15172
- 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();
15173
15490
  }
15174
15491
  }
15175
- 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;
15176
15570
  }
15177
15571
  async downloadFileBlock(url, filename, targetFolder) {
15178
15572
  const response = await fetchForDownload(this.fetchFn, url, this.downloadTimeoutMs);
@@ -15203,7 +15597,7 @@ var FileHandler = class {
15203
15597
  }
15204
15598
  return results;
15205
15599
  }
15206
- async uploadFile(file, parentPageId) {
15600
+ async uploadFile(file, parentPageId, previous) {
15207
15601
  const buffer = await this.vaultFs.readBinary(file.path);
15208
15602
  const filename = file.path.split("/").pop() ?? "file";
15209
15603
  const contentType = getMimeType(filename);
@@ -15211,8 +15605,12 @@ var FileHandler = class {
15211
15605
  const hash = createHash("sha256").update(buffer).digest("hex");
15212
15606
  const blob = new Blob([buffer], { type: contentType });
15213
15607
  const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
15214
- const block = this.createFileBlock(blockType, fileUploadId);
15215
- 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);
15216
15614
  this.stateDb.registerFile({
15217
15615
  localPath: file.path,
15218
15616
  notionPageId: parentPageId,
@@ -15224,10 +15622,25 @@ var FileHandler = class {
15224
15622
  getLogger().info(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC: ${file.path} \u2192 ${blockType} \uBE14\uB85D`);
15225
15623
  return { localPath: file.path, fileUploadId, blockType };
15226
15624
  }
15227
- 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) {
15228
15640
  const fileRef = {
15229
15641
  type: "file_upload",
15230
- file_upload: { id: fileUploadId }
15642
+ file_upload: { id: fileUploadId },
15643
+ caption: [{ type: "text", text: { content: name } }]
15231
15644
  };
15232
15645
  switch (blockType) {
15233
15646
  case "image":
@@ -19092,7 +19505,7 @@ var PageDecorations = class {
19092
19505
  }
19093
19506
  case "local":
19094
19507
  try {
19095
- const uploaded = await this.imageHandler.uploadLocalImage(source.target, folderOf2(path));
19508
+ const uploaded = await this.imageHandler.uploadLocalImage(source.target, folderOf3(path));
19096
19509
  return { type: "file_upload", file_upload: { id: uploaded.fileUploadId } };
19097
19510
  } catch (error) {
19098
19511
  getLogger().warn(
@@ -19137,7 +19550,7 @@ function sameText(a, b) {
19137
19550
  if (isEmptyFrontmatterValue(before) && isEmptyFrontmatterValue(after)) return true;
19138
19551
  return String(before) === String(after);
19139
19552
  }
19140
- function folderOf2(notePath) {
19553
+ function folderOf3(notePath) {
19141
19554
  return notePath.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
19142
19555
  }
19143
19556
 
@@ -20941,7 +21354,7 @@ async function verifyPageCompleteness(remote, local, rootPageId) {
20941
21354
  var ID_SUFFIX = new RegExp(
20942
21355
  ` \\((?:${PAGE_ID_SUFFIX_LENGTHS.map((len) => `[0-9a-f]{${len}}`).join("|")})\\)$`
20943
21356
  );
20944
- function folderOf3(record) {
21357
+ function folderOf4(record) {
20945
21358
  if (record.fileType === "db-row") return null;
20946
21359
  if (isFolderRecord(record)) return record.obsidianPath;
20947
21360
  const slash = record.obsidianPath.lastIndexOf("/");
@@ -20958,10 +21371,10 @@ function findNestedFolderPages(records) {
20958
21371
  const found2 = [];
20959
21372
  for (const inner of records) {
20960
21373
  if (inner.notionPageId === null || inner.notionParentId === null) continue;
20961
- const folder = folderOf3(inner);
21374
+ const folder = folderOf4(inner);
20962
21375
  if (folder === null) continue;
20963
21376
  const outer = byPage.get(compactNotionId(inner.notionParentId));
20964
- if (!outer || outer === inner || folderOf3(outer) !== folder) continue;
21377
+ if (!outer || outer === inner || folderOf4(outer) !== folder) continue;
20965
21378
  found2.push({
20966
21379
  folder,
20967
21380
  outer: { path: outer.obsidianPath, notionPageId: outer.notionPageId },
@@ -21336,7 +21749,7 @@ var VaultSchemaRenames = class {
21336
21749
  const meta = await this.notionClient.getDatabaseMeta(databaseId);
21337
21750
  for (const [from, to] of pairs) {
21338
21751
  const carrying = rows.filter((row) => Object.hasOwn(row.current, to));
21339
- const folder = folderOf4(carrying[0].record.obsidianPath);
21752
+ const folder = folderOf5(carrying[0].record.obsidianPath);
21340
21753
  const owners = meta.dataSources.filter(
21341
21754
  (ds) => ds.properties?.[from] !== void 0
21342
21755
  );
@@ -21502,7 +21915,7 @@ function sameRow(a, b) {
21502
21915
  const keys = Object.keys(left.data);
21503
21916
  return left.content === right.content && keys.length === Object.keys(right.data).length && keys.every((key) => sameFrontmatterValue(left.data[key], right.data[key]));
21504
21917
  }
21505
- function folderOf4(path) {
21918
+ function folderOf5(path) {
21506
21919
  const at = path.lastIndexOf("/");
21507
21920
  return at === -1 ? "" : path.slice(0, at);
21508
21921
  }
@@ -23354,8 +23767,7 @@ var DatabaseDiscovery = class {
23354
23767
  kind: "ok",
23355
23768
  config: {
23356
23769
  databaseId: dbId,
23357
- localFolder: `${parentFolder}/${safeName}`,
23358
- titleProperty: DEFAULT_TITLE_PROPERTY
23770
+ localFolder: `${parentFolder}/${safeName}`
23359
23771
  }
23360
23772
  };
23361
23773
  }
@@ -24429,7 +24841,7 @@ function rewriteTarget(target, source, context, kind) {
24429
24841
  const shown = keepExtension ? destination : destination.slice(0, -".md".length);
24430
24842
  let next;
24431
24843
  if (resolved.relative) {
24432
- next = relativePath(folderOf5(source.to), shown);
24844
+ next = relativePath(folderOf6(source.to), shown);
24433
24845
  if (!linkpath.startsWith("./") && next.startsWith("./")) next = next.slice(2);
24434
24846
  } else if (style === "name") {
24435
24847
  const name = baseName3(shown);
@@ -24451,14 +24863,14 @@ function resolveLinkpath(linkpath, sourcePath, files) {
24451
24863
  const matches = byName(files, baseName3(candidate)).filter((file) => file.endsWith(suffix));
24452
24864
  if (matches.length === 1) return found(matches[0], linkpath);
24453
24865
  if (matches.length > 1) {
24454
- const local = `${folderPrefix(folderOf5(sourcePath))}${candidate}`;
24866
+ const local = `${folderPrefix(folderOf6(sourcePath))}${candidate}`;
24455
24867
  return matches.includes(local) ? found(local, linkpath) : null;
24456
24868
  }
24457
24869
  }
24458
24870
  return null;
24459
24871
  }
24460
24872
  function resolveRelative(linkpath, sourcePath, files) {
24461
- const joined = normalizeSegments(`${folderPrefix(folderOf5(sourcePath))}${linkpath}`);
24873
+ const joined = normalizeSegments(`${folderPrefix(folderOf6(sourcePath))}${linkpath}`);
24462
24874
  if (joined === null) return null;
24463
24875
  for (const candidate of withExtension(joined)) {
24464
24876
  if (files.has(candidate)) return { ...found(candidate, linkpath), relative: true };
@@ -24517,7 +24929,7 @@ function relativePath(from, to) {
24517
24929
  function encodeMarkdownTarget(next, original) {
24518
24930
  return original.includes("%") || next.includes(" ") ? next.replace(/ /g, "%20") : next;
24519
24931
  }
24520
- function folderOf5(path) {
24932
+ function folderOf6(path) {
24521
24933
  const slash = path.lastIndexOf("/");
24522
24934
  return slash === -1 ? "" : path.slice(0, slash);
24523
24935
  }
@@ -26820,7 +27232,7 @@ async function resolveBlockToPageId(notionClient, blockId) {
26820
27232
 
26821
27233
  // src/sync/page-puller.ts
26822
27234
  var PagePuller = class {
26823
- 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) {
26824
27236
  this.config = config;
26825
27237
  this.stateDb = stateDb;
26826
27238
  this.notionClient = notionClient;
@@ -26834,6 +27246,7 @@ var PagePuller = class {
26834
27246
  this.placement = placement;
26835
27247
  this.detector = detector;
26836
27248
  this.discovery = discovery;
27249
+ this.fileHandler = fileHandler;
26837
27250
  }
26838
27251
  config;
26839
27252
  stateDb;
@@ -26848,6 +27261,7 @@ var PagePuller = class {
26848
27261
  placement;
26849
27262
  detector;
26850
27263
  discovery;
27264
+ fileHandler;
26851
27265
  dbSchemaLoaded = false;
26852
27266
  _pullImageCount = 0;
26853
27267
  _pullFileCount = 0;
@@ -27004,8 +27418,17 @@ var PagePuller = class {
27004
27418
  if (excluded !== null) return { path: excluded, excluded: true };
27005
27419
  const fetched = await this.fetchPageMarkdown(pageId);
27006
27420
  const hasChildPages = this.hasChildContainers(pageId, fetched.children);
27007
- if (hasChildPages && !fetched.hasBody) {
27008
- 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;
27009
27432
  }
27010
27433
  const fileType = hasChildPages ? "folder-note" : "file";
27011
27434
  const noteDir = hasChildPages ? parentPath ? `${parentPath}/${safeName}` : safeName : parentPath;
@@ -27468,7 +27891,8 @@ var PagePuller = class {
27468
27891
  compact: isCompactExport(result.markdown),
27469
27892
  children: extractChildTags(result.markdown),
27470
27893
  fingerprint: remoteBodyFingerprint(result.markdown),
27471
- hasBody: hasBodyBesidesChildren(result.markdown)
27894
+ hasBody: hasBodyBesidesChildren(result.markdown),
27895
+ folderFiles: folderFilesInBody(result.markdown)
27472
27896
  };
27473
27897
  } catch (error) {
27474
27898
  getLogger().warn(
@@ -27487,9 +27911,34 @@ var PagePuller = class {
27487
27911
  compact: isCompactExport(fallback.markdown),
27488
27912
  children: extractChildTags(fallback.markdown),
27489
27913
  fingerprint: null,
27490
- hasBody: hasBodyBesidesChildren(fallback.markdown)
27914
+ hasBody: hasBodyBesidesChildren(fallback.markdown),
27915
+ folderFiles: folderFilesInBody(fallback.markdown)
27491
27916
  };
27492
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
+ }
27493
27942
  /**
27494
27943
  * push 가 만든 폴더 페이지(폴더 레코드)의 원격 변경을 받는다(S-17). 폴더 레코드에는 볼트 파일이
27495
27944
  * 없다 — 예전에는 폴더 경로를 파일로 읽어 «빈 로컬 파일» 과 원격 본문의 충돌로 남겼다.
@@ -27506,26 +27955,18 @@ var PagePuller = class {
27506
27955
  * @throws 받을 자리가 없을 때 — 이유와 함께 실패로 남고, 다음 pull 이 다시 본다(기준 시각이
27507
27956
  * 이 변경 앞에 묶인다).
27508
27957
  */
27509
- /**
27510
- * 이 폴더 레코드의 Notion 페이지에서 받을 본문이 있나 — dry-run 이 실제 pull({@link pullFolderRecord})과 같게 센다.
27511
- * 본문이 하위 페이지뿐이면 실제 pull 은 본 것만 적고 고친 것으로 세지 않는다.
27512
- */
27513
- async folderPageHasBody(record) {
27514
- const page = await this.notionClient.getPage(record.notionPageId);
27515
- if (isTrashedOrArchived(page)) return false;
27516
- const { markdown } = await this.notionClient.getPageMarkdown(page.id);
27517
- return hasBodyBesidesChildren(markdown) && this.folderNoteRefusal(record.obsidianPath) === null;
27518
- }
27519
- /** 이 폴더에 폴더 노트를 둘 수 없는 까닭 — 둘 수 있으면 null. */
27520
- folderNoteRefusal(folder) {
27521
- if (isDatabaseMode(this.config)) return "DB \uBAA8\uB4DC\uB294 \uD3F4\uB354 \uB178\uD2B8\uB97C \uBC1B\uC9C0 \uC54A\uC74C";
27522
- return this.placement.folderLookup().databaseAt(folder) ? "\uADF8 \uD3F4\uB354\uB294 DB \uB77C \uD3F4\uB354 \uB178\uD2B8\uB97C \uB458 \uC218 \uC5C6\uC74C" : null;
27523
- }
27524
27958
  async pullFolderRecord(record, page) {
27525
27959
  const folder = record.obsidianPath;
27526
27960
  if (isTrashedOrArchived(page)) return null;
27527
- const { markdown } = await this.notionClient.getPageMarkdown(page.id);
27961
+ const { markdown, truncated } = await this.notionClient.getPageMarkdown(page.id);
27528
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);
27529
27970
  this.observation.record(record.id, page, remoteBodyFingerprint(markdown));
27530
27971
  return null;
27531
27972
  }
@@ -27714,6 +28155,10 @@ var PagePuller = class {
27714
28155
  this.dbSchemaLoaded = true;
27715
28156
  }
27716
28157
  };
28158
+ function folderFilesInBody(markdown) {
28159
+ const files = folderFilesOf(markdown, false);
28160
+ return files ? { files, named: folderFilesOf(markdown, true) !== null } : null;
28161
+ }
27717
28162
 
27718
28163
  // src/sync/frontmatter-link-resolver.ts
27719
28164
  function resolveFrontmatterRelations(content, idToTitle) {
@@ -28823,7 +29268,9 @@ var SyncOrchestrator = class {
28823
29268
  {
28824
29269
  fetch: customFetch,
28825
29270
  downloadTimeoutMs: config.advanced.mediaDownloadTimeoutMs,
28826
- itemTimeoutMs: config.advanced.itemTimeoutMs
29271
+ itemTimeoutMs: config.advanced.itemTimeoutMs,
29272
+ attachmentsFolder: config.paths.attachments,
29273
+ deleteSync: config.sync.deleteSync
28827
29274
  }
28828
29275
  );
28829
29276
  const propertyMapper = PropertyMapper.fromConfig(config);
@@ -28933,7 +29380,8 @@ var SyncOrchestrator = class {
28933
29380
  this.drift,
28934
29381
  this.placement,
28935
29382
  this.detector,
28936
- this.discovery
29383
+ this.discovery,
29384
+ this.fileHandler
28937
29385
  );
28938
29386
  this.pullPlanner = new PullPlanner(
28939
29387
  config,
@@ -29053,14 +29501,16 @@ var SyncOrchestrator = class {
29053
29501
  * 있든 없든 push 마다 부른다 — 바뀌지 않은 첨부는 파일 기록이 건너뛴다.
29054
29502
  */
29055
29503
  async pushVaultFiles(failed) {
29056
- if (this.config.sync.syncFiles === false) return;
29504
+ if (this.config.sync.syncFiles === false) return 0;
29057
29505
  try {
29058
- const fileResults = await this.fileHandler.pushAllFiles(failed);
29506
+ const fileResults = await this.fileHandler.pushAllFiles(failed, this.placement);
29059
29507
  if (fileResults.length > 0) {
29060
29508
  getLogger().info(`[Im-Nobsidian] ${fileResults.length}\uAC1C \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC`);
29061
29509
  }
29510
+ return fileResults.length;
29062
29511
  } catch (error) {
29063
29512
  getLogger().warn("[Im-Nobsidian] \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC911 \uC624\uB958:", error);
29513
+ return 0;
29064
29514
  }
29065
29515
  }
29066
29516
  /**
@@ -29149,12 +29599,13 @@ var SyncOrchestrator = class {
29149
29599
  ...viewPlan.refused,
29150
29600
  ...viewed2.failed
29151
29601
  ];
29152
- if (!options?.dryRun && !options?.signal?.aborted) await this.pushVaultFiles(failed2);
29602
+ const uploadedFiles2 = !options?.dryRun && !options?.signal?.aborted ? await this.pushVaultFiles(failed2) : 0;
29153
29603
  return {
29154
29604
  created: 0,
29155
29605
  updated: 0,
29156
29606
  deleted: 0,
29157
29607
  moved: 0,
29608
+ ...uploadedFiles2 > 0 ? { uploadedFiles: uploadedFiles2 } : {},
29158
29609
  failed: failed2,
29159
29610
  duration: Date.now() - startTime,
29160
29611
  ...edited2.applied.length > 0 ? { schemaEdits: edited2.applied } : {},
@@ -29433,7 +29884,7 @@ var SyncOrchestrator = class {
29433
29884
  signal: options?.signal
29434
29885
  });
29435
29886
  }
29436
- await this.pushVaultFiles(failed);
29887
+ const uploadedFiles = await this.pushVaultFiles(failed);
29437
29888
  this.stateDb.setMeta("last_push_at", (/* @__PURE__ */ new Date()).toISOString());
29438
29889
  this.stateDb.setMeta(LAST_SYNC_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
29439
29890
  this.stateDb.setMeta(PUSH_IN_PROGRESS_META_KEY, "");
@@ -29445,6 +29896,7 @@ var SyncOrchestrator = class {
29445
29896
  updated: counts.updated,
29446
29897
  deleted: counts.deleted,
29447
29898
  moved: counts.moved,
29899
+ ...uploadedFiles > 0 ? { uploadedFiles } : {},
29448
29900
  failed,
29449
29901
  duration: Date.now() - startTime,
29450
29902
  readOnlySkipped: this.pusher.takeReadOnlySkips(),