@im-nobsidian/core 0.1.2 → 0.1.4

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
@@ -12,6 +12,13 @@ import { readFileSync } from 'fs';
12
12
  import { watch } from 'chokidar';
13
13
 
14
14
  // src/types/config.ts
15
+ var DatabaseSyncSchema = z.object({
16
+ databaseId: z.string(),
17
+ localFolder: z.string(),
18
+ propertyMapping: z.record(z.string()).optional(),
19
+ pullFilter: z.unknown().optional(),
20
+ titleProperty: z.string().default("Name")
21
+ });
15
22
  var ConfigSchema = z.object({
16
23
  version: z.literal(1),
17
24
  notion: z.object({
@@ -19,14 +26,16 @@ var ConfigSchema = z.object({
19
26
  rootPageId: z.string(),
20
27
  workspaceId: z.string().optional(),
21
28
  parentMode: z.enum(["page", "database"]).default("page"),
22
- databaseId: z.string().optional()
29
+ databaseId: z.string().optional(),
30
+ databases: z.array(DatabaseSyncSchema).default([])
23
31
  }),
24
32
  sync: z.object({
25
33
  direction: z.enum(["push", "pull", "both"]).default("both"),
26
34
  conflictStrategy: z.enum(["local-first", "remote-first", "manual", "duplicate"]).default("manual"),
27
35
  autoSync: z.boolean().default(false),
28
36
  autoSyncInterval: z.number().min(30).max(3600).default(300),
29
- deleteSync: z.boolean().default(false)
37
+ deleteSync: z.boolean().default(false),
38
+ syncFiles: z.boolean().default(true)
30
39
  }),
31
40
  paths: z.object({
32
41
  include: z.array(z.string()).default(["**/*"]),
@@ -51,14 +60,16 @@ var DEFAULT_CONFIG = {
51
60
  notion: {
52
61
  token: "",
53
62
  rootPageId: "",
54
- parentMode: "page"
63
+ parentMode: "page",
64
+ databases: []
55
65
  },
56
66
  sync: {
57
67
  direction: "both",
58
68
  conflictStrategy: "manual",
59
69
  autoSync: false,
60
70
  autoSyncInterval: 300,
61
- deleteSync: false
71
+ deleteSync: false,
72
+ syncFiles: true
62
73
  },
63
74
  paths: {
64
75
  include: ["**/*"],
@@ -257,6 +268,28 @@ CREATE TABLE IF NOT EXISTS sync_metadata (
257
268
  INSERT OR IGNORE INTO sync_metadata (key, value) VALUES ('schema_version', '1');
258
269
  `;
259
270
 
271
+ // src/state/migrations/002-file-registry.ts
272
+ var FILE_REGISTRY_MIGRATION = `
273
+ CREATE TABLE IF NOT EXISTS file_registry (
274
+ id TEXT PRIMARY KEY,
275
+ local_path TEXT NOT NULL,
276
+ notion_page_id TEXT NOT NULL,
277
+ file_upload_id TEXT NOT NULL,
278
+ file_type TEXT NOT NULL DEFAULT 'file',
279
+ file_hash TEXT NOT NULL,
280
+ file_size INTEGER NOT NULL DEFAULT 0,
281
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
282
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
283
+
284
+ CONSTRAINT chk_file_type CHECK (file_type IN ('image', 'pdf', 'video', 'audio', 'file'))
285
+ );
286
+
287
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_file_registry_path ON file_registry(local_path);
288
+ CREATE INDEX IF NOT EXISTS idx_file_registry_page ON file_registry(notion_page_id);
289
+
290
+ UPDATE sync_metadata SET value = '2' WHERE key = 'schema_version';
291
+ `;
292
+
260
293
  // src/state/state-db.ts
261
294
  var StateDB = class _StateDB {
262
295
  db;
@@ -276,6 +309,9 @@ var StateDB = class _StateDB {
276
309
  if (version < 1) {
277
310
  this.db.exec(INITIAL_MIGRATION);
278
311
  }
312
+ if (version < 2) {
313
+ this.db.exec(FILE_REGISTRY_MIGRATION);
314
+ }
279
315
  }
280
316
  getSchemaVersion() {
281
317
  try {
@@ -371,6 +407,11 @@ var StateDB = class _StateDB {
371
407
  "UPDATE sync_state SET notion_last_edited = ?, updated_at = datetime('now') WHERE id = ?"
372
408
  ).run(lastEdited, id);
373
409
  }
410
+ setNotionParentId(id, parentId) {
411
+ this.db.prepare(
412
+ "UPDATE sync_state SET notion_parent_id = ?, updated_at = datetime('now') WHERE id = ?"
413
+ ).run(parentId, id);
414
+ }
374
415
  delete(id) {
375
416
  this.db.prepare("DELETE FROM sync_state WHERE id = ?").run(id);
376
417
  }
@@ -401,6 +442,49 @@ var StateDB = class _StateDB {
401
442
  setMeta(key, value) {
402
443
  this.db.prepare("INSERT OR REPLACE INTO sync_metadata (key, value) VALUES (?, ?)").run(key, value);
403
444
  }
445
+ // --- file_registry ---
446
+ isFileRegistered(localPath) {
447
+ const row = this.db.prepare("SELECT 1 FROM file_registry WHERE local_path = ?").get(localPath);
448
+ return !!row;
449
+ }
450
+ getFileRegistry(localPath) {
451
+ const row = this.db.prepare("SELECT * FROM file_registry WHERE local_path = ?").get(localPath);
452
+ return row ? this.mapFileRegistryRow(row) : null;
453
+ }
454
+ getFilesByPageId(notionPageId) {
455
+ const rows = this.db.prepare("SELECT * FROM file_registry WHERE notion_page_id = ?").all(notionPageId);
456
+ return rows.map((r) => this.mapFileRegistryRow(r));
457
+ }
458
+ registerFile(entry) {
459
+ const id = generateId();
460
+ this.db.prepare(
461
+ `INSERT OR REPLACE INTO file_registry
462
+ (id, local_path, notion_page_id, file_upload_id, file_type, file_hash, file_size)
463
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
464
+ ).run(
465
+ id,
466
+ entry.localPath,
467
+ entry.notionPageId,
468
+ entry.fileUploadId,
469
+ entry.fileType,
470
+ entry.fileHash,
471
+ entry.fileSize
472
+ );
473
+ }
474
+ deleteFileRegistry(localPath) {
475
+ this.db.prepare("DELETE FROM file_registry WHERE local_path = ?").run(localPath);
476
+ }
477
+ mapFileRegistryRow(row) {
478
+ return {
479
+ id: row.id,
480
+ localPath: row.local_path,
481
+ notionPageId: row.notion_page_id,
482
+ fileUploadId: row.file_upload_id,
483
+ fileType: row.file_type,
484
+ fileHash: row.file_hash,
485
+ fileSize: row.file_size
486
+ };
487
+ }
404
488
  // --- preserve markers ---
405
489
  storePreserveMarkers(path, markers) {
406
490
  if (markers.length === 0) {
@@ -568,6 +652,15 @@ var PropertyMapper = class {
568
652
  });
569
653
  return { files: filesArr };
570
654
  }
655
+ case "created_time":
656
+ case "last_edited_time":
657
+ case "created_by":
658
+ case "last_edited_by":
659
+ case "formula":
660
+ case "rollup":
661
+ case "unique_id":
662
+ case "verification":
663
+ return null;
571
664
  default:
572
665
  return { rich_text: [{ text: { content: String(value) } }] };
573
666
  }
@@ -760,6 +853,32 @@ var NotionClient = class {
760
853
  })
761
854
  );
762
855
  }
856
+ async updatePageMarkdownPartial(pageId, patches) {
857
+ return this.withRateLimit(
858
+ () => this.client.pages.updateMarkdown({
859
+ page_id: pageId,
860
+ type: "update_content",
861
+ update_content: {
862
+ content_updates: patches.map((p) => ({
863
+ old_str: p.oldStr,
864
+ new_str: p.newStr,
865
+ replace_all_matches: p.replaceAll ?? false
866
+ })),
867
+ allow_deleting_content: true
868
+ }
869
+ })
870
+ );
871
+ }
872
+ // ─── Page Move ───
873
+ async movePage(pageId, newParentId, newParentType) {
874
+ const parent = newParentType === "database" ? { database_id: newParentId } : { page_id: newParentId };
875
+ return this.withRateLimit(
876
+ () => this.client.pages.update({
877
+ page_id: pageId,
878
+ parent
879
+ })
880
+ );
881
+ }
763
882
  // ─── Database / DataSource ───
764
883
  async getDatabaseSchema(databaseId) {
765
884
  const db = await this.withRateLimit(
@@ -777,7 +896,8 @@ var NotionClient = class {
777
896
  () => this.client.dataSources.query({
778
897
  data_source_id: databaseId,
779
898
  start_cursor: options?.startCursor,
780
- page_size: options?.pageSize ?? 100
899
+ page_size: options?.pageSize ?? 100,
900
+ filter: options?.filter
781
901
  })
782
902
  );
783
903
  return {
@@ -785,6 +905,27 @@ var NotionClient = class {
785
905
  nextCursor: response.next_cursor
786
906
  };
787
907
  }
908
+ async queryAllDatabasePages(databaseId, filter) {
909
+ const all = [];
910
+ let cursor;
911
+ do {
912
+ const response = await this.queryDatabase(databaseId, {
913
+ startCursor: cursor,
914
+ pageSize: 100,
915
+ filter
916
+ });
917
+ all.push(...response.results);
918
+ cursor = response.nextCursor ?? void 0;
919
+ } while (cursor);
920
+ return all;
921
+ }
922
+ async getDataSourceId(databaseId) {
923
+ const db = await this.withRateLimit(
924
+ () => this.client.databases.retrieve({ database_id: databaseId })
925
+ );
926
+ const dataSources = db.data_sources;
927
+ return dataSources?.[0]?.id ?? databaseId;
928
+ }
788
929
  async archivePage(pageId) {
789
930
  await this.withRateLimit(() => this.client.pages.update({ page_id: pageId, archived: true }));
790
931
  }
@@ -833,15 +974,18 @@ var NotionClient = class {
833
974
  () => this.client.fileUploads.create({ filename, content_type: contentType })
834
975
  );
835
976
  const fileUploadId = upload.id;
836
- await this.withRateLimit(
977
+ const sendResult = await this.withRateLimit(
837
978
  () => this.client.fileUploads.send({
838
979
  file_upload_id: fileUploadId,
839
980
  file: { data: fileData, filename }
840
981
  })
841
982
  );
842
- await this.withRateLimit(
843
- () => this.client.fileUploads.complete({ file_upload_id: fileUploadId })
844
- );
983
+ const status = sendResult.status;
984
+ if (status === "pending") {
985
+ await this.withRateLimit(
986
+ () => this.client.fileUploads.complete({ file_upload_id: fileUploadId })
987
+ );
988
+ }
845
989
  return fileUploadId;
846
990
  }
847
991
  // ─── Search & Navigation ───
@@ -1854,7 +1998,10 @@ var FrontmatterGenerator = class {
1854
1998
  function normalizeProperties(props) {
1855
1999
  const result = {};
1856
2000
  for (const [key, value] of Object.entries(props)) {
1857
- result[key] = normalizeValue(value);
2001
+ const normalized = normalizeValue(value);
2002
+ if (normalized !== void 0) {
2003
+ result[key] = normalized;
2004
+ }
1858
2005
  }
1859
2006
  return result;
1860
2007
  }
@@ -1862,6 +2009,10 @@ function normalizeValue(value) {
1862
2009
  if (typeof value === "string") {
1863
2010
  return normalizeDate(value);
1864
2011
  }
2012
+ if (Array.isArray(value)) {
2013
+ if (value.length === 0) return void 0;
2014
+ return value;
2015
+ }
1865
2016
  if (typeof value === "object" && value !== null && "start" in value) {
1866
2017
  const dateObj = value;
1867
2018
  const normalized = {
@@ -1872,12 +2023,10 @@ function normalizeValue(value) {
1872
2023
  }
1873
2024
  return value;
1874
2025
  }
2026
+ var MIDNIGHT_RE = /T00:00:00(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})/;
1875
2027
  function normalizeDate(dateStr) {
1876
2028
  if (typeof dateStr !== "string") return dateStr;
1877
- if (dateStr.match(/T00:00:00\.000[Z+]/)) {
1878
- return dateStr.split("T")[0] ?? dateStr;
1879
- }
1880
- if (dateStr.match(/T00:00:00[Z+]/)) {
2029
+ if (MIDNIGHT_RE.test(dateStr)) {
1881
2030
  return dateStr.split("T")[0] ?? dateStr;
1882
2031
  }
1883
2032
  return dateStr;
@@ -2810,6 +2959,265 @@ var ImageHandler = class {
2810
2959
  return ext ? `.${ext[1].toLowerCase()}` : null;
2811
2960
  }
2812
2961
  };
2962
+ var EXTENSION_TO_BLOCK_TYPE = {
2963
+ ".png": "image",
2964
+ ".jpg": "image",
2965
+ ".jpeg": "image",
2966
+ ".gif": "image",
2967
+ ".svg": "image",
2968
+ ".webp": "image",
2969
+ ".ico": "image",
2970
+ ".bmp": "image",
2971
+ ".tiff": "image",
2972
+ ".tif": "image",
2973
+ ".avif": "image",
2974
+ ".apng": "image",
2975
+ ".heic": "image",
2976
+ ".pdf": "pdf",
2977
+ ".mp4": "video",
2978
+ ".mov": "video",
2979
+ ".webm": "video",
2980
+ ".avi": "video",
2981
+ ".mkv": "video",
2982
+ ".flv": "video",
2983
+ ".wmv": "video",
2984
+ ".m4v": "video",
2985
+ ".mpeg": "video",
2986
+ ".ogv": "video",
2987
+ ".3gp": "video",
2988
+ ".mp3": "audio",
2989
+ ".wav": "audio",
2990
+ ".ogg": "audio",
2991
+ ".m4a": "audio",
2992
+ ".flac": "audio",
2993
+ ".aac": "audio",
2994
+ ".wma": "audio",
2995
+ ".opus": "audio",
2996
+ ".weba": "audio"
2997
+ };
2998
+ var EXTENSION_TO_MIME2 = {
2999
+ ".png": "image/png",
3000
+ ".jpg": "image/jpeg",
3001
+ ".jpeg": "image/jpeg",
3002
+ ".gif": "image/gif",
3003
+ ".webp": "image/webp",
3004
+ ".svg": "image/svg+xml",
3005
+ ".bmp": "image/bmp",
3006
+ ".ico": "image/x-icon",
3007
+ ".tiff": "image/tiff",
3008
+ ".tif": "image/tiff",
3009
+ ".avif": "image/avif",
3010
+ ".heic": "image/heic",
3011
+ ".pdf": "application/pdf",
3012
+ ".mp4": "video/mp4",
3013
+ ".mov": "video/quicktime",
3014
+ ".webm": "video/webm",
3015
+ ".avi": "video/x-msvideo",
3016
+ ".mkv": "video/x-matroska",
3017
+ ".mp3": "audio/mpeg",
3018
+ ".wav": "audio/wav",
3019
+ ".ogg": "audio/ogg",
3020
+ ".m4a": "audio/mp4",
3021
+ ".flac": "audio/flac",
3022
+ ".aac": "audio/aac",
3023
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
3024
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
3025
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
3026
+ ".zip": "application/zip",
3027
+ ".gz": "application/gzip",
3028
+ ".tar": "application/x-tar",
3029
+ ".rar": "application/vnd.rar",
3030
+ ".7z": "application/x-7z-compressed",
3031
+ ".py": "text/x-python",
3032
+ ".js": "text/javascript",
3033
+ ".ts": "text/typescript",
3034
+ ".json": "application/json",
3035
+ ".csv": "text/csv",
3036
+ ".txt": "text/plain",
3037
+ ".html": "text/html",
3038
+ ".xml": "application/xml",
3039
+ ".hwp": "application/x-hwp",
3040
+ ".xls": "application/vnd.ms-excel",
3041
+ ".doc": "application/msword",
3042
+ ".ppt": "application/vnd.ms-powerpoint"
3043
+ };
3044
+ function getBlockType(filename) {
3045
+ const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
3046
+ if (!ext) return "file";
3047
+ return EXTENSION_TO_BLOCK_TYPE[ext] ?? "file";
3048
+ }
3049
+ function getMimeType(filename) {
3050
+ const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
3051
+ if (!ext) return "application/octet-stream";
3052
+ return EXTENSION_TO_MIME2[ext] ?? "application/octet-stream";
3053
+ }
3054
+ function getFolderPath(filePath) {
3055
+ const parts = filePath.split("/");
3056
+ return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
3057
+ }
3058
+ var FileHandler = class {
3059
+ constructor(vaultFs, notionClient, stateDb) {
3060
+ this.vaultFs = vaultFs;
3061
+ this.notionClient = notionClient;
3062
+ this.stateDb = stateDb;
3063
+ }
3064
+ vaultFs;
3065
+ notionClient;
3066
+ stateDb;
3067
+ sema = new Sema(2);
3068
+ async pushFilesForFolder(folderPageId, folderPath) {
3069
+ const allFiles = await this.vaultFs.listNonMarkdownFiles();
3070
+ const folderFiles = allFiles.filter((f) => getFolderPath(f.path) === folderPath);
3071
+ if (folderFiles.length === 0) return [];
3072
+ const results = [];
3073
+ for (const file of folderFiles) {
3074
+ if (this.stateDb.isFileRegistered(file.path)) {
3075
+ const existing = this.stateDb.getFileRegistry(file.path);
3076
+ if (existing) {
3077
+ const buffer = await this.vaultFs.readBinary(file.path);
3078
+ const currentHash = createHash("sha256").update(buffer).digest("hex");
3079
+ if (existing.fileHash === currentHash) continue;
3080
+ }
3081
+ }
3082
+ await this.sema.acquire();
3083
+ try {
3084
+ const result = await this.uploadFile(file, folderPageId);
3085
+ if (result) results.push(result);
3086
+ } catch (error) {
3087
+ getLogger().warn(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${file.path}):`, error);
3088
+ } finally {
3089
+ this.sema.release();
3090
+ }
3091
+ }
3092
+ return results;
3093
+ }
3094
+ async pushAllFiles() {
3095
+ const allFiles = await this.vaultFs.listNonMarkdownFiles();
3096
+ if (allFiles.length === 0) return [];
3097
+ const filesByFolder = /* @__PURE__ */ new Map();
3098
+ for (const file of allFiles) {
3099
+ const folder = getFolderPath(file.path);
3100
+ const list = filesByFolder.get(folder) ?? [];
3101
+ list.push(file);
3102
+ filesByFolder.set(folder, list);
3103
+ }
3104
+ const results = [];
3105
+ for (const [folderPath, files] of filesByFolder) {
3106
+ const folderRecord = folderPath ? this.stateDb.getByPath(folderPath) : this.stateDb.getByPath("__root__");
3107
+ const folderPageId = folderRecord?.notionPageId;
3108
+ if (!folderPageId) {
3109
+ getLogger().warn(
3110
+ `\uD3F4\uB354 \uD398\uC774\uC9C0 \uC5C6\uC74C, \uD30C\uC77C \uC2A4\uD0B5: ${folderPath || "(\uB8E8\uD2B8)"} (${files.length}\uAC1C)`
3111
+ );
3112
+ continue;
3113
+ }
3114
+ for (const file of files) {
3115
+ if (this.stateDb.isFileRegistered(file.path)) {
3116
+ const existing = this.stateDb.getFileRegistry(file.path);
3117
+ if (existing) {
3118
+ const buffer = await this.vaultFs.readBinary(file.path);
3119
+ const currentHash = createHash("sha256").update(buffer).digest("hex");
3120
+ if (existing.fileHash === currentHash) continue;
3121
+ }
3122
+ }
3123
+ await this.sema.acquire();
3124
+ try {
3125
+ const result = await this.uploadFile(file, folderPageId);
3126
+ if (result) results.push(result);
3127
+ } catch (error) {
3128
+ getLogger().warn(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${file.path}):`, error);
3129
+ } finally {
3130
+ this.sema.release();
3131
+ }
3132
+ }
3133
+ }
3134
+ return results;
3135
+ }
3136
+ async downloadFileBlock(url, filename, targetFolder) {
3137
+ const response = await fetch(url);
3138
+ if (!response.ok) {
3139
+ throw new Error(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
3140
+ }
3141
+ const buffer = Buffer.from(await response.arrayBuffer());
3142
+ const hash = createHash("sha256").update(buffer).digest("hex");
3143
+ const localPath = targetFolder ? `${targetFolder}/${filename}` : filename;
3144
+ await this.vaultFs.ensureFolder(targetFolder || ".");
3145
+ await this.vaultFs.writeBinary(localPath, buffer);
3146
+ return { url, localPath, hash, size: buffer.length };
3147
+ }
3148
+ async downloadFileBlocks(blocks, targetFolder) {
3149
+ const results = [];
3150
+ for (const block of blocks) {
3151
+ const filename = this.extractFilename(block.url, block.caption);
3152
+ if (this.stateDb.isFileRegistered(`${targetFolder}/${filename}`)) continue;
3153
+ await this.sema.acquire();
3154
+ try {
3155
+ const result = await this.downloadFileBlock(block.url, filename, targetFolder);
3156
+ results.push(result);
3157
+ } catch (error) {
3158
+ getLogger().warn(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328 (${block.url}):`, error);
3159
+ } finally {
3160
+ this.sema.release();
3161
+ }
3162
+ }
3163
+ return results;
3164
+ }
3165
+ async uploadFile(file, parentPageId) {
3166
+ const buffer = await this.vaultFs.readBinary(file.path);
3167
+ const filename = file.path.split("/").pop() ?? "file";
3168
+ const contentType = getMimeType(filename);
3169
+ const blockType = getBlockType(filename);
3170
+ const hash = createHash("sha256").update(buffer).digest("hex");
3171
+ const blob = new Blob([buffer], { type: contentType });
3172
+ const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
3173
+ const block = this.createFileBlock(blockType, fileUploadId);
3174
+ await this.notionClient.appendChildren(parentPageId, [block]);
3175
+ this.stateDb.registerFile({
3176
+ localPath: file.path,
3177
+ notionPageId: parentPageId,
3178
+ fileUploadId,
3179
+ fileType: blockType,
3180
+ fileHash: hash,
3181
+ fileSize: file.size
3182
+ });
3183
+ getLogger().info(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC: ${file.path} \u2192 ${blockType} \uBE14\uB85D`);
3184
+ return { localPath: file.path, fileUploadId, blockType };
3185
+ }
3186
+ createFileBlock(blockType, fileUploadId) {
3187
+ const fileRef = {
3188
+ type: "file_upload",
3189
+ file_upload: { id: fileUploadId }
3190
+ };
3191
+ switch (blockType) {
3192
+ case "image":
3193
+ return { type: "image", image: fileRef };
3194
+ case "pdf":
3195
+ return { type: "pdf", pdf: fileRef };
3196
+ case "video":
3197
+ return { type: "video", video: fileRef };
3198
+ case "audio":
3199
+ return { type: "audio", audio: fileRef };
3200
+ case "file":
3201
+ default:
3202
+ return { type: "file", file: fileRef };
3203
+ }
3204
+ }
3205
+ extractFilename(url, caption) {
3206
+ if (caption) {
3207
+ const safe = caption.replace(/[^a-zA-Z0-9가-힣._\s-]/g, "").trim();
3208
+ if (safe) return safe;
3209
+ }
3210
+ try {
3211
+ const pathname = new URL(url).pathname;
3212
+ const segments = pathname.split("/");
3213
+ const last = segments[segments.length - 1];
3214
+ if (last && last.includes(".")) return decodeURIComponent(last);
3215
+ } catch {
3216
+ }
3217
+ const ext = url.match(/\.(png|jpg|jpeg|gif|pdf|mp4|mp3|docx|xlsx|zip|webp)(\?|$)/i);
3218
+ return `file-${Date.now()}${ext ? `.${ext[1]}` : ""}`;
3219
+ }
3220
+ };
2813
3221
 
2814
3222
  // src/utils/sanitize.ts
2815
3223
  var INVALID_CHARS = /[<>:"/\\|?*\x00-\x1f]/g;
@@ -2855,7 +3263,13 @@ var NOTION_TOGGLE_RE = /<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/d
2855
3263
  var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
2856
3264
  var NOTION_USER_MENTION_RE = /<mention-user id="[^"]*">([^<]*)<\/mention-user>/g;
2857
3265
  var NOTION_DATE_MENTION_RE = /<mention-date start="([^"]*)"(?: end="([^"]*)")?[^>]*\/>/g;
2858
- var NOTION_UNKNOWN_RE = /<unknown id="[^"]*"[^>]*\/>/g;
3266
+ var NOTION_UNKNOWN_RE = /<unknown id="([^"]*)"([^>]*)\/>/g;
3267
+ var NOTION_AUDIO_RE = /<audio src="([^"]*)">([\s\S]*?)<\/audio>/g;
3268
+ var NOTION_VIDEO_RE = /<video src="([^"]*)">([\s\S]*?)<\/video>/g;
3269
+ var NOTION_PDF_RE = /<pdf src="([^"]*)">([\s\S]*?)<\/pdf>/g;
3270
+ var NOTION_FILE_RE = /<file src="([^"]*)">([\s\S]*?)<\/file>/g;
3271
+ var NOTION_TAB_RE = /<tab title="([^"]*)">([\s\S]*?)<\/tab>/g;
3272
+ var NOTION_UNDERLINE_RE = /<span underline="true">([\s\S]*?)<\/span>/g;
2859
3273
  var TOGGLE_START2 = "%%im-nobsidian:toggle:start%%";
2860
3274
  var TOGGLE_END2 = "%%im-nobsidian:toggle:end%%";
2861
3275
  function notionEnhancedToObsidian(enhanced) {
@@ -2866,10 +3280,13 @@ function notionEnhancedToObsidian(enhanced) {
2866
3280
  result = convertPageLinks(result);
2867
3281
  result = convertUserMentions(result);
2868
3282
  result = convertDateMentions(result);
2869
- result = removeUnknownBlocks(result);
3283
+ result = convertMediaTags(result);
3284
+ result = convertTabBlocks(result);
3285
+ result = preserveUnknownBlocks(result);
2870
3286
  result = convertNotionMath(result);
2871
3287
  result = convertNotionTables(result);
2872
3288
  result = convertColorSpans(result);
3289
+ result = convertUnderlineSpans(result);
2873
3290
  result = removeEmptyBlocks(result);
2874
3291
  result = unescapePipes(result);
2875
3292
  return result;
@@ -2877,7 +3294,12 @@ function notionEnhancedToObsidian(enhanced) {
2877
3294
  function obsidianToNotionEnhanced(obsidian) {
2878
3295
  let result = obsidian;
2879
3296
  result = convertTogglesToHtml(result);
3297
+ result = restoreTabBlocks(result);
2880
3298
  result = convertObsidianCallouts(result);
3299
+ result = restoreMediaTags(result);
3300
+ result = restoreUnknownBlocks(result);
3301
+ result = restoreColorSpans(result);
3302
+ result = restoreUnderlineSpans(result);
2881
3303
  return result;
2882
3304
  }
2883
3305
  function convertToggles(content) {
@@ -2922,8 +3344,46 @@ function convertDateMentions(content) {
2922
3344
  return end ? `${start} \u2192 ${end}` : start;
2923
3345
  });
2924
3346
  }
2925
- function removeUnknownBlocks(content) {
2926
- return content.replace(NOTION_UNKNOWN_RE, "");
3347
+ function preserveUnknownBlocks(content) {
3348
+ return content.replace(NOTION_UNKNOWN_RE, (_match, id, attrs) => {
3349
+ const typeMatch = /type="([^"]*)"/.exec(attrs);
3350
+ const altMatch = /alt="([^"]*)"/.exec(attrs);
3351
+ const blockType = altMatch?.[1] ?? typeMatch?.[1] ?? "unknown";
3352
+ return `%%im-nobsidian:unknown:id=${id}&type=${blockType}%%`;
3353
+ });
3354
+ }
3355
+ function convertMediaTags(content) {
3356
+ let result = content;
3357
+ result = result.replace(NOTION_AUDIO_RE, (_match, src, caption) => {
3358
+ const cap = caption.trim();
3359
+ return cap ? `[\u{1F50A} ${cap}](${src})` : `[\u{1F50A} audio](${src})`;
3360
+ });
3361
+ result = result.replace(NOTION_VIDEO_RE, (_match, src, caption) => {
3362
+ const cap = caption.trim();
3363
+ return cap ? `[\u{1F3AC} ${cap}](${src})` : `[\u{1F3AC} video](${src})`;
3364
+ });
3365
+ result = result.replace(NOTION_PDF_RE, (_match, src, caption) => {
3366
+ const cap = caption.trim();
3367
+ return cap ? `[\u{1F4C4} ${cap}](${src})` : `[\u{1F4C4} pdf](${src})`;
3368
+ });
3369
+ result = result.replace(NOTION_FILE_RE, (_match, src, caption) => {
3370
+ const cap = caption.trim();
3371
+ return cap ? `[\u{1F4CE} ${cap}](${src})` : `[\u{1F4CE} file](${src})`;
3372
+ });
3373
+ return result;
3374
+ }
3375
+ function convertTabBlocks(content) {
3376
+ return content.replace(NOTION_TAB_RE, (_match, title, body) => {
3377
+ const trimmed = body.trim();
3378
+ const indented = trimmed.split("\n").map((line) => `> ${line}`).join("\n");
3379
+ return `> [!tab] ${title}
3380
+ ${indented}`;
3381
+ });
3382
+ }
3383
+ function convertUnderlineSpans(content) {
3384
+ return content.replace(NOTION_UNDERLINE_RE, (_match, text) => {
3385
+ return `%%im-nobsidian:underline%%${text}%%/underline%%`;
3386
+ });
2927
3387
  }
2928
3388
  function convertTogglesToHtml(content) {
2929
3389
  const startRe = new RegExp(escapeRegex3(TOGGLE_START2), "g");
@@ -3033,7 +3493,10 @@ function convertPageLinks(content) {
3033
3493
  });
3034
3494
  }
3035
3495
  function convertColorSpans(content) {
3036
- return content.replace(NOTION_COLOR_SPAN_RE, (_match, _color, text) => text);
3496
+ return content.replace(
3497
+ NOTION_COLOR_SPAN_RE,
3498
+ (_match, color, text) => `%%im-nobsidian:color:${color}%%${text}%%/color%%`
3499
+ );
3037
3500
  }
3038
3501
  function removeEmptyBlocks(content) {
3039
3502
  return content.replace(NOTION_EMPTY_BLOCK_RE, "\n");
@@ -3080,6 +3543,51 @@ function convertNotionTables(content) {
3080
3543
  return lines.join("\n");
3081
3544
  });
3082
3545
  }
3546
+ var OBSIDIAN_MEDIA_AUDIO_RE = /\[🔊\s*([^\]]*)\]\(([^)]+)\)/g;
3547
+ var OBSIDIAN_MEDIA_VIDEO_RE = /\[🎬\s*([^\]]*)\]\(([^)]+)\)/g;
3548
+ var OBSIDIAN_MEDIA_PDF_RE = /\[📄\s*([^\]]*)\]\(([^)]+)\)/g;
3549
+ var OBSIDIAN_MEDIA_FILE_RE = /\[📎\s*([^\]]*)\]\(([^)]+)\)/g;
3550
+ function restoreMediaTags(content) {
3551
+ let result = content;
3552
+ result = result.replace(OBSIDIAN_MEDIA_AUDIO_RE, (_match, caption, src) => {
3553
+ return `<audio src="${src}">${caption}</audio>`;
3554
+ });
3555
+ result = result.replace(OBSIDIAN_MEDIA_VIDEO_RE, (_match, caption, src) => {
3556
+ return `<video src="${src}">${caption}</video>`;
3557
+ });
3558
+ result = result.replace(OBSIDIAN_MEDIA_PDF_RE, (_match, caption, src) => {
3559
+ return `<pdf src="${src}">${caption}</pdf>`;
3560
+ });
3561
+ result = result.replace(OBSIDIAN_MEDIA_FILE_RE, (_match, caption, src) => {
3562
+ return `<file src="${src}">${caption}</file>`;
3563
+ });
3564
+ return result;
3565
+ }
3566
+ var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)\n((?:> .*\n?)*)/gm;
3567
+ function restoreTabBlocks(content) {
3568
+ return content.replace(OBSIDIAN_TAB_RE, (_match, title, body) => {
3569
+ const unquoted = body.split("\n").map((line) => line.replace(/^> /, "")).join("\n").trim();
3570
+ return `<tab title="${title}">${unquoted}</tab>`;
3571
+ });
3572
+ }
3573
+ var OBSIDIAN_UNKNOWN_RE = /%%im-nobsidian:unknown:id=([^&]+)&type=([^%]+)%%/g;
3574
+ function restoreUnknownBlocks(content) {
3575
+ return content.replace(OBSIDIAN_UNKNOWN_RE, (_match, id, type) => {
3576
+ return `<unknown id="${id}" type="${type}"/>`;
3577
+ });
3578
+ }
3579
+ var OBSIDIAN_COLOR_RE = /%%im-nobsidian:color:([^%]+)%%([\s\S]*?)%%\/color%%/g;
3580
+ function restoreColorSpans(content) {
3581
+ return content.replace(OBSIDIAN_COLOR_RE, (_match, color, text) => {
3582
+ return `<span color="${color}">${text}</span>`;
3583
+ });
3584
+ }
3585
+ var OBSIDIAN_UNDERLINE_RE = /%%im-nobsidian:underline%%([\s\S]*?)%%\/underline%%/g;
3586
+ function restoreUnderlineSpans(content) {
3587
+ return content.replace(OBSIDIAN_UNDERLINE_RE, (_match, text) => {
3588
+ return `<span underline="true">${text}</span>`;
3589
+ });
3590
+ }
3083
3591
  function unescapePipes(content) {
3084
3592
  const lines = content.split("\n");
3085
3593
  const result = [];
@@ -3099,6 +3607,253 @@ function unescapePipes(content) {
3099
3607
  }
3100
3608
  return result.join("\n");
3101
3609
  }
3610
+ var DatabaseSyncer = class {
3611
+ constructor(config, stateDb, notionClient, vaultFs, pipeline, imageHandler) {
3612
+ this.config = config;
3613
+ this.stateDb = stateDb;
3614
+ this.notionClient = notionClient;
3615
+ this.vaultFs = vaultFs;
3616
+ this.pipeline = pipeline;
3617
+ this.imageHandler = imageHandler;
3618
+ this.propertyMapper.setWikilinkResolver({
3619
+ resolve: (title) => stateDb.resolveWikilink(title)?.notionPageId ?? null,
3620
+ resolvePageId: (pageId) => stateDb.resolvePageId(pageId)?.title ?? null
3621
+ });
3622
+ }
3623
+ config;
3624
+ stateDb;
3625
+ notionClient;
3626
+ vaultFs;
3627
+ pipeline;
3628
+ imageHandler;
3629
+ propertyMapper = new PropertyMapper();
3630
+ async pullAll() {
3631
+ const databases = this.config.notion.databases;
3632
+ if (!databases || databases.length === 0) {
3633
+ return { created: 0, updated: 0, failed: [] };
3634
+ }
3635
+ let created = 0;
3636
+ let updated = 0;
3637
+ const failed = [];
3638
+ for (const dbConfig of databases) {
3639
+ try {
3640
+ const result = await this.pullDatabase(dbConfig);
3641
+ created += result.created;
3642
+ updated += result.updated;
3643
+ failed.push(...result.failed);
3644
+ } catch (error) {
3645
+ getLogger().warn(`[DB Sync] DB ${dbConfig.databaseId} pull \uC2E4\uD328:`, error);
3646
+ failed.push({
3647
+ path: dbConfig.localFolder,
3648
+ operation: "create",
3649
+ error: error instanceof Error ? error.message : String(error)
3650
+ });
3651
+ }
3652
+ }
3653
+ return { created, updated, failed };
3654
+ }
3655
+ async pushAll() {
3656
+ const databases = this.config.notion.databases;
3657
+ if (!databases || databases.length === 0) {
3658
+ return { created: 0, updated: 0, failed: [] };
3659
+ }
3660
+ let created = 0;
3661
+ let updated = 0;
3662
+ const failed = [];
3663
+ for (const dbConfig of databases) {
3664
+ try {
3665
+ const result = await this.pushDatabase(dbConfig);
3666
+ created += result.created;
3667
+ updated += result.updated;
3668
+ failed.push(...result.failed);
3669
+ } catch (error) {
3670
+ getLogger().warn(`[DB Sync] DB ${dbConfig.databaseId} push \uC2E4\uD328:`, error);
3671
+ failed.push({
3672
+ path: dbConfig.localFolder,
3673
+ operation: "create",
3674
+ error: error instanceof Error ? error.message : String(error)
3675
+ });
3676
+ }
3677
+ }
3678
+ return { created, updated, failed };
3679
+ }
3680
+ async pullDatabase(dbConfig) {
3681
+ const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
3682
+ this.propertyMapper.loadSchema(schema);
3683
+ await this.vaultFs.ensureFolder(dbConfig.localFolder);
3684
+ const pages = await this.notionClient.queryAllDatabasePages(
3685
+ dbConfig.databaseId,
3686
+ dbConfig.pullFilter
3687
+ );
3688
+ let created = 0;
3689
+ let updated = 0;
3690
+ const failed = [];
3691
+ for (const page of pages) {
3692
+ try {
3693
+ const record = this.stateDb.getByNotionId(page.id);
3694
+ if (record) {
3695
+ if (page.last_edited_time === record.notionLastEdited) continue;
3696
+ await this.pullDatabasePage(page, dbConfig);
3697
+ updated++;
3698
+ } else {
3699
+ await this.pullDatabasePage(page, dbConfig);
3700
+ created++;
3701
+ }
3702
+ } catch (error) {
3703
+ failed.push({
3704
+ path: `${dbConfig.localFolder}/${page.id}`,
3705
+ operation: "create",
3706
+ error: error instanceof Error ? error.message : String(error)
3707
+ });
3708
+ }
3709
+ }
3710
+ getLogger().info(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
3711
+ return { created, updated, failed };
3712
+ }
3713
+ async pullDatabasePage(page, dbConfig) {
3714
+ const title = this.notionClient.extractTitle(page);
3715
+ const safeName = sanitizeFileName(title);
3716
+ const properties = this.propertyMapper.fromNotionProperties(
3717
+ page.properties
3718
+ );
3719
+ properties.title = title;
3720
+ let markdown = "";
3721
+ try {
3722
+ const mdResult = await this.notionClient.getPageMarkdown(page.id);
3723
+ markdown = notionEnhancedToObsidian(mdResult.markdown);
3724
+ } catch {
3725
+ }
3726
+ if (this.config.conversion.imageDownload === "immediate" && markdown) {
3727
+ const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
3728
+ markdown = imageResult.content;
3729
+ }
3730
+ const existingRecord = this.stateDb.getByNotionId(page.id);
3731
+ const filePath = existingRecord?.obsidianPath ?? `${dbConfig.localFolder}/${safeName}.md`;
3732
+ const finalContent = this.pipeline.convertToMarkdown(
3733
+ markdown,
3734
+ {
3735
+ direction: "pull",
3736
+ path: "markdown-api",
3737
+ filePath,
3738
+ parentMode: "database"
3739
+ },
3740
+ { properties }
3741
+ );
3742
+ await this.vaultFs.ensureFolder(dbConfig.localFolder);
3743
+ await this.vaultFs.writeFile(filePath, finalContent);
3744
+ const hash = computeHash(finalContent);
3745
+ this.stateDb.transaction(() => {
3746
+ this.stateDb.upsert({
3747
+ obsidianPath: filePath,
3748
+ notionPageId: page.id,
3749
+ notionParentId: dbConfig.databaseId,
3750
+ contentHash: hash,
3751
+ notionLastEdited: page.last_edited_time,
3752
+ localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
3753
+ syncDirection: "both",
3754
+ fileType: "db-row",
3755
+ status: "synced",
3756
+ baseSnapshot: Buffer.from(finalContent, "utf-8")
3757
+ });
3758
+ this.stateDb.upsertWikilink({
3759
+ obsidianPath: filePath,
3760
+ notionPageId: page.id,
3761
+ title,
3762
+ aliases: extractAliases(properties)
3763
+ });
3764
+ });
3765
+ return filePath;
3766
+ }
3767
+ async pushDatabase(dbConfig) {
3768
+ const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
3769
+ this.propertyMapper.loadSchema(schema);
3770
+ const allFiles = await this.vaultFs.listMarkdownFiles();
3771
+ const prefix = dbConfig.localFolder.endsWith("/") ? dbConfig.localFolder : dbConfig.localFolder + "/";
3772
+ const dbFiles = allFiles.filter((f) => f.path.startsWith(prefix));
3773
+ let created = 0;
3774
+ let updated = 0;
3775
+ const failed = [];
3776
+ for (const file of dbFiles) {
3777
+ try {
3778
+ const content = await this.vaultFs.readFile(file.path);
3779
+ const hash = computeHash(content);
3780
+ const record = this.stateDb.getByPath(file.path);
3781
+ if (record?.contentHash === hash) continue;
3782
+ const parsed = matter(content);
3783
+ const frontmatter = parsed.data;
3784
+ const body = parsed.content;
3785
+ const title = frontmatter.title ?? extractTitleFromPath(file.path);
3786
+ delete frontmatter.title;
3787
+ const notionProps = this.propertyMapper.toNotionProperties(frontmatter, title);
3788
+ const enhanced = obsidianToNotionEnhanced(body.trim());
3789
+ if (record?.notionPageId) {
3790
+ await this.notionClient.updatePageProperties(record.notionPageId, notionProps);
3791
+ try {
3792
+ await this.notionClient.replacePageMarkdown(record.notionPageId, enhanced);
3793
+ } catch {
3794
+ }
3795
+ const updatedPage = await this.notionClient.getPage(record.notionPageId);
3796
+ this.stateDb.transaction(() => {
3797
+ this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
3798
+ this.stateDb.updateStatus(record.id, "synced");
3799
+ this.stateDb.setNotionLastEdited(record.id, updatedPage.last_edited_time);
3800
+ });
3801
+ updated++;
3802
+ } else {
3803
+ const page = await this.notionClient.createPageWithMarkdown({
3804
+ parentId: dbConfig.databaseId,
3805
+ parentType: "database",
3806
+ title,
3807
+ markdown: enhanced,
3808
+ properties: notionProps
3809
+ });
3810
+ this.stateDb.transaction(() => {
3811
+ this.stateDb.upsert({
3812
+ obsidianPath: file.path,
3813
+ notionPageId: page.id,
3814
+ notionParentId: dbConfig.databaseId,
3815
+ contentHash: hash,
3816
+ notionLastEdited: page.last_edited_time,
3817
+ localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
3818
+ syncDirection: "both",
3819
+ fileType: "db-row",
3820
+ status: "synced",
3821
+ baseSnapshot: Buffer.from(content, "utf-8")
3822
+ });
3823
+ this.stateDb.upsertWikilink({
3824
+ obsidianPath: file.path,
3825
+ notionPageId: page.id,
3826
+ title,
3827
+ aliases: extractAliases(frontmatter)
3828
+ });
3829
+ });
3830
+ created++;
3831
+ }
3832
+ } catch (error) {
3833
+ failed.push({
3834
+ path: file.path,
3835
+ operation: "create",
3836
+ error: error instanceof Error ? error.message : String(error)
3837
+ });
3838
+ }
3839
+ }
3840
+ getLogger().info(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
3841
+ return { created, updated, failed };
3842
+ }
3843
+ };
3844
+ function extractAliases(properties) {
3845
+ const raw = properties.aliases ?? properties.alias;
3846
+ if (!raw) return [];
3847
+ if (Array.isArray(raw)) return raw.filter((a) => typeof a === "string");
3848
+ if (typeof raw === "string")
3849
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
3850
+ return [];
3851
+ }
3852
+ function extractTitleFromPath(filePath) {
3853
+ const parts = filePath.split("/");
3854
+ const filename = parts[parts.length - 1] ?? "";
3855
+ return filename.replace(/\.md$/, "");
3856
+ }
3102
3857
 
3103
3858
  // src/sync/orchestrator.ts
3104
3859
  var SyncOrchestrator = class {
@@ -3113,7 +3868,16 @@ var SyncOrchestrator = class {
3113
3868
  });
3114
3869
  this.blockConverter = new BlockConverter();
3115
3870
  this.imageHandler = new ImageHandler(vaultFs, config.paths.attachments, notionClient);
3871
+ this.fileHandler = new FileHandler(vaultFs, notionClient, stateDb);
3116
3872
  this.propertyMapper = new PropertyMapper();
3873
+ this.databaseSyncer = new DatabaseSyncer(
3874
+ config,
3875
+ stateDb,
3876
+ notionClient,
3877
+ vaultFs,
3878
+ this.pipeline,
3879
+ this.imageHandler
3880
+ );
3117
3881
  this.propertyMapper.setWikilinkResolver({
3118
3882
  resolve: (title) => stateDb.resolveWikilink(title)?.notionPageId ?? null,
3119
3883
  resolvePageId: (pageId) => stateDb.resolvePageId(pageId)?.title ?? null
@@ -3128,6 +3892,8 @@ var SyncOrchestrator = class {
3128
3892
  pipeline;
3129
3893
  blockConverter;
3130
3894
  imageHandler;
3895
+ fileHandler;
3896
+ databaseSyncer;
3131
3897
  propertyMapper;
3132
3898
  dbSchemaLoaded = false;
3133
3899
  async push(options) {
@@ -3189,10 +3955,13 @@ var SyncOrchestrator = class {
3189
3955
  created++;
3190
3956
  break;
3191
3957
  case "modified":
3192
- case "moved":
3193
3958
  await this.pushUpdate(change.path);
3194
3959
  updated++;
3195
3960
  break;
3961
+ case "moved":
3962
+ await this.pushMove(change.path);
3963
+ updated++;
3964
+ break;
3196
3965
  case "deleted":
3197
3966
  await this.pushDelete(change.path);
3198
3967
  deleted++;
@@ -3209,6 +3978,26 @@ var SyncOrchestrator = class {
3209
3978
  }
3210
3979
  });
3211
3980
  await Promise.all(tasks.map((t) => t()));
3981
+ if (this.config.sync.syncFiles !== false) {
3982
+ try {
3983
+ const fileResults = await this.fileHandler.pushAllFiles();
3984
+ if (fileResults.length > 0) {
3985
+ getLogger().info(`[Im-Nobsidian] ${fileResults.length}\uAC1C \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC`);
3986
+ }
3987
+ } catch (error) {
3988
+ getLogger().warn("[Im-Nobsidian] \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC911 \uC624\uB958:", error);
3989
+ }
3990
+ }
3991
+ if ((this.config.notion.databases?.length ?? 0) > 0) {
3992
+ try {
3993
+ const dbResult = await this.databaseSyncer.pushAll();
3994
+ created += dbResult.created;
3995
+ updated += dbResult.updated;
3996
+ failed.push(...dbResult.failed);
3997
+ } catch (error) {
3998
+ getLogger().warn("[Im-Nobsidian] DB Push \uC911 \uC624\uB958:", error);
3999
+ }
4000
+ }
3212
4001
  this.stateDb.setMeta("last_push_at", (/* @__PURE__ */ new Date()).toISOString());
3213
4002
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
3214
4003
  this.stateDb.setMeta("push_in_progress", "");
@@ -3302,6 +4091,20 @@ var SyncOrchestrator = class {
3302
4091
  }
3303
4092
  });
3304
4093
  await Promise.all(tasks.map((t) => t()));
4094
+ if ((this.config.notion.databases?.length ?? 0) > 0) {
4095
+ try {
4096
+ const dbResult = await this.databaseSyncer.pullAll();
4097
+ created += dbResult.created;
4098
+ updated += dbResult.updated;
4099
+ failed.push(...dbResult.failed);
4100
+ if (dbResult.created + dbResult.updated > 0) {
4101
+ const dbPaths = this.stateDb.getByStatus("synced").filter((r) => r.fileType === "db-row").map((r) => r.obsidianPath);
4102
+ writtenPaths.push(...dbPaths.slice(-dbResult.created - dbResult.updated));
4103
+ }
4104
+ } catch (error) {
4105
+ getLogger().warn("[Im-Nobsidian] DB Pull \uC911 \uC624\uB958:", error);
4106
+ }
4107
+ }
3305
4108
  this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
3306
4109
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
3307
4110
  this.stateDb.setMeta("pull_in_progress", "");
@@ -3369,13 +4172,20 @@ var SyncOrchestrator = class {
3369
4172
  localContent = await this.vaultFs.readFile(record.obsidianPath);
3370
4173
  } catch {
3371
4174
  }
4175
+ let remoteContent = "";
4176
+ if (record.notionPageId) {
4177
+ try {
4178
+ remoteContent = await this.fetchPageMarkdown(record.notionPageId);
4179
+ } catch {
4180
+ }
4181
+ }
3372
4182
  conflicts.push({
3373
4183
  syncRecord: record,
3374
4184
  localChange,
3375
4185
  remoteChange,
3376
4186
  baseContent: record.baseSnapshot?.toString("utf-8") ?? null,
3377
4187
  localContent,
3378
- remoteContent: ""
4188
+ remoteContent
3379
4189
  });
3380
4190
  }
3381
4191
  return conflicts;
@@ -3439,7 +4249,7 @@ var SyncOrchestrator = class {
3439
4249
  status: "synced",
3440
4250
  baseSnapshot: Buffer.from(content, "utf-8")
3441
4251
  });
3442
- const aliases = extractAliases(conversionResult.properties);
4252
+ const aliases = extractAliases2(conversionResult.properties);
3443
4253
  this.stateDb.upsertWikilink({
3444
4254
  obsidianPath: path,
3445
4255
  notionPageId: page.id,
@@ -3465,7 +4275,7 @@ var SyncOrchestrator = class {
3465
4275
  filePath: path,
3466
4276
  parentMode: this.config.notion.parentMode
3467
4277
  });
3468
- await this.pushUpdatePage(record.notionPageId, conversionResult.content);
4278
+ await this.pushUpdatePage(record.notionPageId, conversionResult.content, record.baseSnapshot);
3469
4279
  await this.imageHandler.uploadAndAppendImages(record.notionPageId, conversionResult.images);
3470
4280
  let propsToUpdate = conversionResult.properties;
3471
4281
  if (this.isDatabaseMode && propsToUpdate && Object.keys(propsToUpdate).length > 0) {
@@ -3479,7 +4289,7 @@ var SyncOrchestrator = class {
3479
4289
  const updatedPage = await this.notionClient.getPage(record.notionPageId);
3480
4290
  const hash = computeHash(content);
3481
4291
  const title = extractTitle(path);
3482
- const aliases = extractAliases(conversionResult.properties);
4292
+ const aliases = extractAliases2(conversionResult.properties);
3483
4293
  this.stateDb.transaction(() => {
3484
4294
  this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
3485
4295
  this.stateDb.updateStatus(record.id, "synced");
@@ -3503,6 +4313,22 @@ var SyncOrchestrator = class {
3503
4313
  this.stateDb.updateStatus(record.id, "pending");
3504
4314
  }
3505
4315
  }
4316
+ async pushMove(path) {
4317
+ const record = this.stateDb.getByPath(path);
4318
+ if (!record?.notionPageId) return;
4319
+ const newParentId = await this.resolveNotionParent(path);
4320
+ const currentParentId = record.notionParentId;
4321
+ if (currentParentId && !notionIdsEqual(newParentId, currentParentId)) {
4322
+ try {
4323
+ const parentType = this.isDatabaseMode ? "database" : "page";
4324
+ await this.notionClient.movePage(record.notionPageId, newParentId, parentType);
4325
+ this.stateDb.setNotionParentId(record.id, newParentId);
4326
+ } catch {
4327
+ getLogger().warn(`[Im-Nobsidian] Move API \uC2E4\uD328 \u2014 "${path}" \uB0B4\uC6A9\uB9CC \uC5C5\uB370\uC774\uD2B8\uD569\uB2C8\uB2E4`);
4328
+ }
4329
+ }
4330
+ await this.pushUpdate(path);
4331
+ }
3506
4332
  async detectRemoteChanges() {
3507
4333
  const changes = [];
3508
4334
  const lastPull = this.stateDb.getMeta("last_pull_at");
@@ -3627,7 +4453,7 @@ var SyncOrchestrator = class {
3627
4453
  status: "synced",
3628
4454
  baseSnapshot: Buffer.from(finalContent, "utf-8")
3629
4455
  });
3630
- const aliases = extractAliases(properties);
4456
+ const aliases = extractAliases2(properties);
3631
4457
  this.stateDb.upsertWikilink({
3632
4458
  obsidianPath: filePath,
3633
4459
  notionPageId: pageId,
@@ -3838,10 +4664,23 @@ var SyncOrchestrator = class {
3838
4664
  }
3839
4665
  return page;
3840
4666
  }
3841
- async pushUpdatePage(pageId, markdownContent) {
4667
+ async pushUpdatePage(pageId, markdownContent, baseSnapshot) {
3842
4668
  if (this.config.conversion.preferMarkdownApi !== false) {
3843
4669
  try {
3844
4670
  const enhanced = obsidianToNotionEnhanced(markdownContent);
4671
+ if (baseSnapshot) {
4672
+ const baseEnhanced = obsidianToNotionEnhanced(baseSnapshot.toString("utf-8"));
4673
+ const patches = this.computePatches(baseEnhanced, enhanced);
4674
+ if (patches.length > 0 && patches.length <= 20) {
4675
+ try {
4676
+ await this.notionClient.updatePageMarkdownPartial(pageId, patches);
4677
+ return;
4678
+ } catch {
4679
+ await this.notionClient.replacePageMarkdown(pageId, enhanced);
4680
+ return;
4681
+ }
4682
+ }
4683
+ }
3845
4684
  await this.notionClient.replacePageMarkdown(pageId, enhanced);
3846
4685
  return;
3847
4686
  } catch {
@@ -3864,6 +4703,51 @@ var SyncOrchestrator = class {
3864
4703
  })
3865
4704
  );
3866
4705
  }
4706
+ computePatches(oldContent, newContent) {
4707
+ const oldLines = oldContent.split("\n");
4708
+ const newLines = newContent.split("\n");
4709
+ const patches = [];
4710
+ let i = 0;
4711
+ let j = 0;
4712
+ while (i < oldLines.length && j < newLines.length) {
4713
+ if (oldLines[i] === newLines[j]) {
4714
+ i++;
4715
+ j++;
4716
+ continue;
4717
+ }
4718
+ let oldEnd = i + 1;
4719
+ let newEnd = j + 1;
4720
+ const lookahead = Math.min(10, oldLines.length - i, newLines.length - j);
4721
+ for (let k = 1; k <= lookahead; k++) {
4722
+ if (i + k < oldLines.length && newLines[j] === oldLines[i + k]) {
4723
+ oldEnd = i + k;
4724
+ newEnd = j;
4725
+ break;
4726
+ }
4727
+ if (j + k < newLines.length && oldLines[i] === newLines[j + k]) {
4728
+ oldEnd = i;
4729
+ newEnd = j + k;
4730
+ break;
4731
+ }
4732
+ oldEnd = i + k;
4733
+ newEnd = j + k;
4734
+ }
4735
+ const oldChunk = oldLines.slice(i, oldEnd).join("\n");
4736
+ const newChunk = newLines.slice(j, newEnd).join("\n");
4737
+ if (oldChunk || newChunk) {
4738
+ patches.push({ oldStr: oldChunk, newStr: newChunk });
4739
+ }
4740
+ i = oldEnd;
4741
+ j = newEnd;
4742
+ }
4743
+ if (i < oldLines.length || j < newLines.length) {
4744
+ patches.push({
4745
+ oldStr: oldLines.slice(i).join("\n"),
4746
+ newStr: newLines.slice(j).join("\n")
4747
+ });
4748
+ }
4749
+ return patches;
4750
+ }
3867
4751
  async fetchPageMarkdown(pageId) {
3868
4752
  if (this.config.conversion.preferMarkdownApi !== false) {
3869
4753
  try {
@@ -3909,7 +4793,7 @@ function extractTitle(filePath) {
3909
4793
  const filename = parts[parts.length - 1] ?? "";
3910
4794
  return filename.replace(/\.md$/, "");
3911
4795
  }
3912
- function extractAliases(properties) {
4796
+ function extractAliases2(properties) {
3913
4797
  const raw = properties.aliases ?? properties.alias;
3914
4798
  if (!raw) return [];
3915
4799
  if (Array.isArray(raw)) return raw.filter((a) => typeof a === "string");
@@ -3981,6 +4865,11 @@ var NodeVaultFS = class {
3981
4865
  await this.walkDir(this.rootPath, files);
3982
4866
  return files;
3983
4867
  }
4868
+ async listNonMarkdownFiles() {
4869
+ const files = [];
4870
+ await this.walkDirNonMd(this.rootPath, files);
4871
+ return files;
4872
+ }
3984
4873
  matchesPattern(relativePath, pattern) {
3985
4874
  if (pattern === "**/*" || pattern === "**") return true;
3986
4875
  if (pattern.endsWith("/**")) {
@@ -4003,6 +4892,25 @@ var NodeVaultFS = class {
4003
4892
  }
4004
4893
  return this.excludePatterns.some((p) => this.matchesPattern(relativePath, p));
4005
4894
  }
4895
+ async walkDirNonMd(dir, result) {
4896
+ const entries = await readdir(dir, { withFileTypes: true });
4897
+ for (const entry of entries) {
4898
+ const fullPath = join(dir, entry.name);
4899
+ if (entry.name.startsWith(".")) continue;
4900
+ const relativePath = relative(this.rootPath, fullPath);
4901
+ if (this.isExcluded(relativePath)) continue;
4902
+ if (entry.isDirectory()) {
4903
+ await this.walkDirNonMd(fullPath, result);
4904
+ } else if (entry.isFile() && !entry.name.endsWith(".md")) {
4905
+ const fileStat = await stat(fullPath);
4906
+ result.push({
4907
+ path: relativePath,
4908
+ size: fileStat.size,
4909
+ mtime: fileStat.mtime.toISOString()
4910
+ });
4911
+ }
4912
+ }
4913
+ }
4006
4914
  async walkDir(dir, result) {
4007
4915
  const entries = await readdir(dir, { withFileTypes: true });
4008
4916
  for (const entry of entries) {
@@ -4483,6 +5391,6 @@ var WatchSyncService = class {
4483
5391
  }
4484
5392
  };
4485
5393
 
4486
- export { BlockConverter, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConversionPipeline, DEFAULT_CONFIG, EmbedResolver, FileWatcher, FrontmatterExtractor, FrontmatterGenerator, HtmlAnnotationStripper, ImageHandler, InlineDBParser, LocalImageRestorer, MathNormalizer, MentionToWikilink, NodeVaultFS, NotionBlockBuilder, NotionClient, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, StateDB, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, WatchSyncService, WikilinkResolver, computeBufferHash, computeHash, createDefaultPipeline, generateId, getLogger, normalizeNotionId, notionIdsEqual, sanitizeFileName, setLogger, threeWayMerge };
5394
+ export { BlockConverter, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConversionPipeline, DEFAULT_CONFIG, DatabaseSyncer, EmbedResolver, FileHandler, FileWatcher, FrontmatterExtractor, FrontmatterGenerator, HtmlAnnotationStripper, ImageHandler, InlineDBParser, LocalImageRestorer, MathNormalizer, MentionToWikilink, NodeVaultFS, NotionBlockBuilder, NotionClient, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, StateDB, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, WatchSyncService, WikilinkResolver, computeBufferHash, computeHash, createDefaultPipeline, generateId, getLogger, normalizeNotionId, notionIdsEqual, sanitizeFileName, setLogger, threeWayMerge };
4487
5395
  //# sourceMappingURL=index.js.map
4488
5396
  //# sourceMappingURL=index.js.map