@im-nobsidian/core 0.1.3 → 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 {
@@ -406,6 +442,49 @@ var StateDB = class _StateDB {
406
442
  setMeta(key, value) {
407
443
  this.db.prepare("INSERT OR REPLACE INTO sync_metadata (key, value) VALUES (?, ?)").run(key, value);
408
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
+ }
409
488
  // --- preserve markers ---
410
489
  storePreserveMarkers(path, markers) {
411
490
  if (markers.length === 0) {
@@ -817,7 +896,8 @@ var NotionClient = class {
817
896
  () => this.client.dataSources.query({
818
897
  data_source_id: databaseId,
819
898
  start_cursor: options?.startCursor,
820
- page_size: options?.pageSize ?? 100
899
+ page_size: options?.pageSize ?? 100,
900
+ filter: options?.filter
821
901
  })
822
902
  );
823
903
  return {
@@ -825,6 +905,27 @@ var NotionClient = class {
825
905
  nextCursor: response.next_cursor
826
906
  };
827
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
+ }
828
929
  async archivePage(pageId) {
829
930
  await this.withRateLimit(() => this.client.pages.update({ page_id: pageId, archived: true }));
830
931
  }
@@ -873,15 +974,18 @@ var NotionClient = class {
873
974
  () => this.client.fileUploads.create({ filename, content_type: contentType })
874
975
  );
875
976
  const fileUploadId = upload.id;
876
- await this.withRateLimit(
977
+ const sendResult = await this.withRateLimit(
877
978
  () => this.client.fileUploads.send({
878
979
  file_upload_id: fileUploadId,
879
980
  file: { data: fileData, filename }
880
981
  })
881
982
  );
882
- await this.withRateLimit(
883
- () => this.client.fileUploads.complete({ file_upload_id: fileUploadId })
884
- );
983
+ const status = sendResult.status;
984
+ if (status === "pending") {
985
+ await this.withRateLimit(
986
+ () => this.client.fileUploads.complete({ file_upload_id: fileUploadId })
987
+ );
988
+ }
885
989
  return fileUploadId;
886
990
  }
887
991
  // ─── Search & Navigation ───
@@ -2855,6 +2959,265 @@ var ImageHandler = class {
2855
2959
  return ext ? `.${ext[1].toLowerCase()}` : null;
2856
2960
  }
2857
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
+ };
2858
3221
 
2859
3222
  // src/utils/sanitize.ts
2860
3223
  var INVALID_CHARS = /[<>:"/\\|?*\x00-\x1f]/g;
@@ -3244,6 +3607,253 @@ function unescapePipes(content) {
3244
3607
  }
3245
3608
  return result.join("\n");
3246
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
+ }
3247
3857
 
3248
3858
  // src/sync/orchestrator.ts
3249
3859
  var SyncOrchestrator = class {
@@ -3258,7 +3868,16 @@ var SyncOrchestrator = class {
3258
3868
  });
3259
3869
  this.blockConverter = new BlockConverter();
3260
3870
  this.imageHandler = new ImageHandler(vaultFs, config.paths.attachments, notionClient);
3871
+ this.fileHandler = new FileHandler(vaultFs, notionClient, stateDb);
3261
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
+ );
3262
3881
  this.propertyMapper.setWikilinkResolver({
3263
3882
  resolve: (title) => stateDb.resolveWikilink(title)?.notionPageId ?? null,
3264
3883
  resolvePageId: (pageId) => stateDb.resolvePageId(pageId)?.title ?? null
@@ -3273,6 +3892,8 @@ var SyncOrchestrator = class {
3273
3892
  pipeline;
3274
3893
  blockConverter;
3275
3894
  imageHandler;
3895
+ fileHandler;
3896
+ databaseSyncer;
3276
3897
  propertyMapper;
3277
3898
  dbSchemaLoaded = false;
3278
3899
  async push(options) {
@@ -3357,6 +3978,26 @@ var SyncOrchestrator = class {
3357
3978
  }
3358
3979
  });
3359
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
+ }
3360
4001
  this.stateDb.setMeta("last_push_at", (/* @__PURE__ */ new Date()).toISOString());
3361
4002
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
3362
4003
  this.stateDb.setMeta("push_in_progress", "");
@@ -3450,6 +4091,20 @@ var SyncOrchestrator = class {
3450
4091
  }
3451
4092
  });
3452
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
+ }
3453
4108
  this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
3454
4109
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
3455
4110
  this.stateDb.setMeta("pull_in_progress", "");
@@ -3594,7 +4249,7 @@ var SyncOrchestrator = class {
3594
4249
  status: "synced",
3595
4250
  baseSnapshot: Buffer.from(content, "utf-8")
3596
4251
  });
3597
- const aliases = extractAliases(conversionResult.properties);
4252
+ const aliases = extractAliases2(conversionResult.properties);
3598
4253
  this.stateDb.upsertWikilink({
3599
4254
  obsidianPath: path,
3600
4255
  notionPageId: page.id,
@@ -3634,7 +4289,7 @@ var SyncOrchestrator = class {
3634
4289
  const updatedPage = await this.notionClient.getPage(record.notionPageId);
3635
4290
  const hash = computeHash(content);
3636
4291
  const title = extractTitle(path);
3637
- const aliases = extractAliases(conversionResult.properties);
4292
+ const aliases = extractAliases2(conversionResult.properties);
3638
4293
  this.stateDb.transaction(() => {
3639
4294
  this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
3640
4295
  this.stateDb.updateStatus(record.id, "synced");
@@ -3798,7 +4453,7 @@ var SyncOrchestrator = class {
3798
4453
  status: "synced",
3799
4454
  baseSnapshot: Buffer.from(finalContent, "utf-8")
3800
4455
  });
3801
- const aliases = extractAliases(properties);
4456
+ const aliases = extractAliases2(properties);
3802
4457
  this.stateDb.upsertWikilink({
3803
4458
  obsidianPath: filePath,
3804
4459
  notionPageId: pageId,
@@ -4017,8 +4672,13 @@ var SyncOrchestrator = class {
4017
4672
  const baseEnhanced = obsidianToNotionEnhanced(baseSnapshot.toString("utf-8"));
4018
4673
  const patches = this.computePatches(baseEnhanced, enhanced);
4019
4674
  if (patches.length > 0 && patches.length <= 20) {
4020
- await this.notionClient.updatePageMarkdownPartial(pageId, patches);
4021
- return;
4675
+ try {
4676
+ await this.notionClient.updatePageMarkdownPartial(pageId, patches);
4677
+ return;
4678
+ } catch {
4679
+ await this.notionClient.replacePageMarkdown(pageId, enhanced);
4680
+ return;
4681
+ }
4022
4682
  }
4023
4683
  }
4024
4684
  await this.notionClient.replacePageMarkdown(pageId, enhanced);
@@ -4133,7 +4793,7 @@ function extractTitle(filePath) {
4133
4793
  const filename = parts[parts.length - 1] ?? "";
4134
4794
  return filename.replace(/\.md$/, "");
4135
4795
  }
4136
- function extractAliases(properties) {
4796
+ function extractAliases2(properties) {
4137
4797
  const raw = properties.aliases ?? properties.alias;
4138
4798
  if (!raw) return [];
4139
4799
  if (Array.isArray(raw)) return raw.filter((a) => typeof a === "string");
@@ -4205,6 +4865,11 @@ var NodeVaultFS = class {
4205
4865
  await this.walkDir(this.rootPath, files);
4206
4866
  return files;
4207
4867
  }
4868
+ async listNonMarkdownFiles() {
4869
+ const files = [];
4870
+ await this.walkDirNonMd(this.rootPath, files);
4871
+ return files;
4872
+ }
4208
4873
  matchesPattern(relativePath, pattern) {
4209
4874
  if (pattern === "**/*" || pattern === "**") return true;
4210
4875
  if (pattern.endsWith("/**")) {
@@ -4227,6 +4892,25 @@ var NodeVaultFS = class {
4227
4892
  }
4228
4893
  return this.excludePatterns.some((p) => this.matchesPattern(relativePath, p));
4229
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
+ }
4230
4914
  async walkDir(dir, result) {
4231
4915
  const entries = await readdir(dir, { withFileTypes: true });
4232
4916
  for (const entry of entries) {
@@ -4707,6 +5391,6 @@ var WatchSyncService = class {
4707
5391
  }
4708
5392
  };
4709
5393
 
4710
- 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 };
4711
5395
  //# sourceMappingURL=index.js.map
4712
5396
  //# sourceMappingURL=index.js.map