@im-nobsidian/core 0.1.7 → 0.1.9

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
@@ -3,7 +3,7 @@ import { readFile, mkdir, writeFile, unlink, rename, stat, readdir } from 'fs/pr
3
3
  import { join, dirname, relative } from 'path';
4
4
  import Database from 'better-sqlite3';
5
5
  import { randomUUID, createHash } from 'crypto';
6
- import { Client } from '@notionhq/client';
6
+ import { Client, LogLevel } from '@notionhq/client';
7
7
  import { Sema } from 'async-sema';
8
8
  import matter7 from 'gray-matter';
9
9
  import { markdownToBlocks } from '@tryfabric/martian';
@@ -290,6 +290,14 @@ CREATE INDEX IF NOT EXISTS idx_file_registry_page ON file_registry(notion_page_i
290
290
  UPDATE sync_metadata SET value = '2' WHERE key = 'schema_version';
291
291
  `;
292
292
 
293
+ // src/state/migrations/003-stat-cache.ts
294
+ var STAT_CACHE_MIGRATION = `
295
+ ALTER TABLE sync_state ADD COLUMN local_mtime TEXT;
296
+ ALTER TABLE sync_state ADD COLUMN local_file_size INTEGER;
297
+
298
+ UPDATE sync_metadata SET value = '3' WHERE key = 'schema_version';
299
+ `;
300
+
293
301
  // src/state/state-db.ts
294
302
  var StateDB = class _StateDB {
295
303
  db;
@@ -312,6 +320,9 @@ var StateDB = class _StateDB {
312
320
  if (version < 2) {
313
321
  this.db.exec(FILE_REGISTRY_MIGRATION);
314
322
  }
323
+ if (version < 3) {
324
+ this.db.exec(STAT_CACHE_MIGRATION);
325
+ }
315
326
  }
316
327
  getSchemaVersion() {
317
328
  try {
@@ -349,8 +360,8 @@ var StateDB = class _StateDB {
349
360
  notion_page_id = ?, notion_parent_id = ?, content_hash = ?,
350
361
  notion_last_edited = ?, local_last_modified = ?,
351
362
  sync_direction = ?, file_type = ?, status = ?,
352
- base_snapshot = ?, version = version + 1,
353
- updated_at = datetime('now')
363
+ base_snapshot = ?, local_mtime = ?, local_file_size = ?,
364
+ version = version + 1, updated_at = datetime('now')
354
365
  WHERE id = ?`
355
366
  ).run(
356
367
  record.notionPageId ?? null,
@@ -362,6 +373,8 @@ var StateDB = class _StateDB {
362
373
  record.fileType,
363
374
  record.status,
364
375
  record.baseSnapshot ?? null,
376
+ record.localMtime ?? null,
377
+ record.localFileSize ?? null,
365
378
  existing.id
366
379
  );
367
380
  return this.getByPath(record.obsidianPath);
@@ -371,8 +384,8 @@ var StateDB = class _StateDB {
371
384
  `INSERT INTO sync_state
372
385
  (id, obsidian_path, notion_page_id, notion_parent_id, content_hash,
373
386
  notion_last_edited, local_last_modified, sync_direction, file_type,
374
- status, base_snapshot, version)
375
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
387
+ status, base_snapshot, local_mtime, local_file_size, version)
388
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
376
389
  ).run(
377
390
  id,
378
391
  record.obsidianPath,
@@ -384,7 +397,9 @@ var StateDB = class _StateDB {
384
397
  record.syncDirection,
385
398
  record.fileType,
386
399
  record.status,
387
- record.baseSnapshot ?? null
400
+ record.baseSnapshot ?? null,
401
+ record.localMtime ?? null,
402
+ record.localFileSize ?? null
388
403
  );
389
404
  return this.getByPath(record.obsidianPath);
390
405
  }
@@ -407,6 +422,11 @@ var StateDB = class _StateDB {
407
422
  "UPDATE sync_state SET notion_last_edited = ?, updated_at = datetime('now') WHERE id = ?"
408
423
  ).run(lastEdited, id);
409
424
  }
425
+ updateStatCache(id, mtime, fileSize) {
426
+ this.db.prepare(
427
+ "UPDATE sync_state SET local_mtime = ?, local_file_size = ?, updated_at = datetime('now') WHERE id = ?"
428
+ ).run(mtime, fileSize, id);
429
+ }
410
430
  setNotionParentId(id, parentId) {
411
431
  this.db.prepare(
412
432
  "UPDATE sync_state SET notion_parent_id = ?, updated_at = datetime('now') WHERE id = ?"
@@ -520,6 +540,8 @@ var StateDB = class _StateDB {
520
540
  fileType: row.file_type,
521
541
  status: row.status,
522
542
  baseSnapshot: row.base_snapshot,
543
+ localMtime: row.local_mtime,
544
+ localFileSize: row.local_file_size,
523
545
  version: row.version,
524
546
  createdAt: row.created_at,
525
547
  updatedAt: row.updated_at
@@ -788,12 +810,16 @@ var NotionClient = class _NotionClient {
788
810
  sema;
789
811
  token;
790
812
  propertyMapper = new PropertyMapper();
813
+ customFetch;
791
814
  constructor(options) {
792
815
  this.client = new Client({
793
816
  auth: options.token,
794
- timeoutMs: options.timeoutMs ?? 3e4
817
+ timeoutMs: options.timeoutMs ?? 3e4,
818
+ logLevel: LogLevel.ERROR,
819
+ ...options.fetch ? { fetch: options.fetch } : {}
795
820
  });
796
821
  this.token = options.token;
822
+ this.customFetch = options.fetch;
797
823
  this.sema = new Sema(options.concurrency ?? 3);
798
824
  }
799
825
  getInternalClient() {
@@ -886,7 +912,8 @@ var NotionClient = class _NotionClient {
886
912
  const cleanId = databaseId.replace(/-/g, "");
887
913
  const formatted = `${cleanId.slice(0, 8)}-${cleanId.slice(8, 12)}-${cleanId.slice(12, 16)}-${cleanId.slice(16, 20)}-${cleanId.slice(20)}`;
888
914
  const resp = await this.withRateLimit(async () => {
889
- const r = await fetch(`https://api.notion.com/v1/databases/${formatted}`, {
915
+ const doFetch = this.customFetch ?? globalThis.fetch;
916
+ const r = await doFetch(`https://api.notion.com/v1/databases/${formatted}`, {
890
917
  headers: {
891
918
  Authorization: `Bearer ${this.token}`,
892
919
  "Notion-Version": "2022-06-28",
@@ -1072,6 +1099,30 @@ var NotionClient = class _NotionClient {
1072
1099
  nextCursor: response.next_cursor
1073
1100
  };
1074
1101
  }
1102
+ async searchRecentPages(since) {
1103
+ const results = [];
1104
+ let cursor;
1105
+ const sinceDate = new Date(since);
1106
+ outer: do {
1107
+ const response = await this.withRateLimit(
1108
+ () => this.client.search({
1109
+ filter: { property: "object", value: "page" },
1110
+ sort: { direction: "descending", timestamp: "last_edited_time" },
1111
+ start_cursor: cursor,
1112
+ page_size: 100
1113
+ })
1114
+ );
1115
+ for (const page of response.results) {
1116
+ const p = page;
1117
+ if (new Date(p.last_edited_time) <= sinceDate) {
1118
+ break outer;
1119
+ }
1120
+ results.push({ id: p.id, last_edited_time: p.last_edited_time });
1121
+ }
1122
+ cursor = response.next_cursor ?? void 0;
1123
+ } while (cursor);
1124
+ return results;
1125
+ }
1075
1126
  async getChildPages(parentId) {
1076
1127
  const blocks = await this.fetchAllChildrenDeep(parentId);
1077
1128
  const childPageBlocks = blocks.filter((b) => b.type === "child_page");
@@ -1080,8 +1131,8 @@ var NotionClient = class _NotionClient {
1080
1131
  return pages;
1081
1132
  }
1082
1133
  async getChildDatabaseIds(parentId) {
1083
- const blocks = await this.fetchAllChildrenDeep(parentId);
1084
- return blocks.filter((b) => b.type === "child_database").map((b) => b.id);
1134
+ const children = await this.fetchAllChildren(parentId);
1135
+ return children.filter((b) => b.type === "child_database").map((b) => b.id);
1085
1136
  }
1086
1137
  async getChildPagesRecursive(parentId) {
1087
1138
  const all = [];
@@ -1629,6 +1680,52 @@ var ChangeDetector = class {
1629
1680
  this.stateDb = stateDb;
1630
1681
  }
1631
1682
  stateDb;
1683
+ async detectLocalChangesFast(stats, readFile3) {
1684
+ const changes = [];
1685
+ const existingPaths = /* @__PURE__ */ new Set();
1686
+ for (const file of stats) {
1687
+ existingPaths.add(file.path);
1688
+ const record = this.stateDb.getByPath(file.path);
1689
+ if (!record) {
1690
+ const content2 = await readFile3(file.path);
1691
+ changes.push({
1692
+ path: file.path,
1693
+ type: "created",
1694
+ currentHash: computeHash(content2),
1695
+ previousHash: null
1696
+ });
1697
+ continue;
1698
+ }
1699
+ if (record.localMtime === file.mtime && record.localFileSize === file.size) {
1700
+ continue;
1701
+ }
1702
+ const content = await readFile3(file.path);
1703
+ const currentHash = computeHash(content);
1704
+ if (record.contentHash !== currentHash) {
1705
+ changes.push({
1706
+ path: file.path,
1707
+ type: "modified",
1708
+ currentHash,
1709
+ previousHash: record.contentHash
1710
+ });
1711
+ }
1712
+ }
1713
+ const syncedRecords = this.stateDb.getByStatus("synced");
1714
+ for (const record of syncedRecords) {
1715
+ if (!existingPaths.has(record.obsidianPath)) {
1716
+ if (record.fileType === "folder-note" || record.fileType === "folder-only") {
1717
+ if (!record.obsidianPath.endsWith(".md")) continue;
1718
+ }
1719
+ changes.push({
1720
+ path: record.obsidianPath,
1721
+ type: "deleted",
1722
+ currentHash: "",
1723
+ previousHash: record.contentHash
1724
+ });
1725
+ }
1726
+ }
1727
+ return this.detectMoves(changes);
1728
+ }
1632
1729
  detectLocalChanges(currentFiles) {
1633
1730
  const changes = [];
1634
1731
  const existingPaths = /* @__PURE__ */ new Set();
@@ -1644,7 +1741,7 @@ var ChangeDetector = class {
1644
1741
  });
1645
1742
  continue;
1646
1743
  }
1647
- if (record.localLastModified && file.mtime === record.localLastModified) {
1744
+ if (record.localMtime && record.localMtime === file.mtime) {
1648
1745
  continue;
1649
1746
  }
1650
1747
  const currentHash = computeHash(file.content);
@@ -3049,14 +3146,19 @@ var EXTENSION_TO_MIME = {
3049
3146
  ".tiff": "image/tiff"
3050
3147
  };
3051
3148
  var ImageHandler = class {
3052
- constructor(vaultFs, attachmentFolder = "attachments", notionClient) {
3149
+ constructor(vaultFs, attachmentFolder = "attachments", notionClient, customFetch) {
3053
3150
  this.vaultFs = vaultFs;
3054
3151
  this.attachmentFolder = attachmentFolder;
3055
3152
  this.notionClient = notionClient;
3153
+ this.customFetch = customFetch;
3056
3154
  }
3057
3155
  vaultFs;
3058
3156
  attachmentFolder;
3059
3157
  notionClient;
3158
+ customFetch;
3159
+ get fetchFn() {
3160
+ return this.customFetch ?? globalThis.fetch;
3161
+ }
3060
3162
  async downloadImage(url, pageTitle) {
3061
3163
  const maxRetries = 3;
3062
3164
  let lastError = null;
@@ -3068,7 +3170,7 @@ var ImageHandler = class {
3068
3170
  `[Im-Nobsidian] \uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC7AC\uC2DC\uB3C4 (${attempt + 1}/${maxRetries}): ${pageTitle}`
3069
3171
  );
3070
3172
  }
3071
- const response = await fetch(url);
3173
+ const response = await this.fetchFn(url);
3072
3174
  if (!response.ok) {
3073
3175
  throw new Error(`\uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
3074
3176
  }
@@ -3215,6 +3317,7 @@ var ImageHandler = class {
3215
3317
  const url = match[2];
3216
3318
  const caption = match[1]?.trim() || "file";
3217
3319
  const download = await this.downloadFile(url, pageTitle, caption);
3320
+ if (!download.localPath) continue;
3218
3321
  downloads.push(download);
3219
3322
  const obsidianLink = `[[${download.localPath}|${caption}]]`;
3220
3323
  result = result.replace(match[0], obsidianLink);
@@ -3236,6 +3339,7 @@ var ImageHandler = class {
3236
3339
  if (!realUrl) continue;
3237
3340
  const caption = fileName || match[1]?.trim() || "file";
3238
3341
  const download = await this.downloadFile(realUrl, pageTitle, caption);
3342
+ if (!download.localPath) continue;
3239
3343
  downloads.push(download);
3240
3344
  const obsidianLink = `[[${download.localPath}|${caption}]]`;
3241
3345
  result = result.replace(match[0], obsidianLink);
@@ -3251,6 +3355,7 @@ var ImageHandler = class {
3251
3355
  const url = match[1];
3252
3356
  const caption = match[2]?.trim() || "file";
3253
3357
  const download = await this.downloadFile(url, pageTitle, caption);
3358
+ if (!download.localPath) continue;
3254
3359
  downloads.push(download);
3255
3360
  const obsidianLink = `[[${download.localPath}|${caption}]]`;
3256
3361
  result = result.replace(match[0], obsidianLink);
@@ -3272,6 +3377,7 @@ var ImageHandler = class {
3272
3377
  if (!realUrl) continue;
3273
3378
  const caption = fileName || match[2]?.trim() || "file";
3274
3379
  const download = await this.downloadFile(realUrl, pageTitle, caption);
3380
+ if (!download.localPath) continue;
3275
3381
  downloads.push(download);
3276
3382
  const obsidianLink = `[[${download.localPath}|${caption}]]`;
3277
3383
  result = result.replace(match[0], obsidianLink);
@@ -3307,10 +3413,17 @@ var ImageHandler = class {
3307
3413
  `[Im-Nobsidian] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC7AC\uC2DC\uB3C4 (${attempt + 1}/${maxRetries}): ${caption}`
3308
3414
  );
3309
3415
  }
3310
- const response = await fetch(url);
3416
+ const response = await this.fetchFn(url);
3311
3417
  if (!response.ok) {
3312
3418
  throw new Error(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
3313
3419
  }
3420
+ const contentLength = response.headers.get("content-length");
3421
+ const MAX_FILE_SIZE = 100 * 1024 * 1024;
3422
+ if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
3423
+ const sizeMB = Math.round(parseInt(contentLength, 10) / 1024 / 1024);
3424
+ getLogger().debug(`[Im-Nobsidian] \uD30C\uC77C \uC2A4\uD0B5 \u2014 \uB108\uBB34 \uD07C (${sizeMB}MB): ${caption}`);
3425
+ return { originalUrl: url, localPath: "", hash: "", size: 0 };
3426
+ }
3314
3427
  const buffer = Buffer.from(await response.arrayBuffer());
3315
3428
  const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 12);
3316
3429
  const extension = this.guessFileExtension(url, response.headers.get("content-type"));
@@ -3469,13 +3582,12 @@ var FileHandler = class {
3469
3582
  if (folderFiles.length === 0) return [];
3470
3583
  const results = [];
3471
3584
  for (const file of folderFiles) {
3472
- if (this.stateDb.isFileRegistered(file.path)) {
3473
- const existing = this.stateDb.getFileRegistry(file.path);
3474
- if (existing) {
3475
- const buffer = await this.vaultFs.readBinary(file.path);
3476
- const currentHash = createHash("sha256").update(buffer).digest("hex");
3477
- if (existing.fileHash === currentHash) continue;
3478
- }
3585
+ const existing = this.stateDb.getFileRegistry(file.path);
3586
+ if (existing) {
3587
+ if (existing.fileSize === file.size) continue;
3588
+ const buffer = await this.vaultFs.readBinary(file.path);
3589
+ const currentHash = createHash("sha256").update(buffer).digest("hex");
3590
+ if (existing.fileHash === currentHash) continue;
3479
3591
  }
3480
3592
  await this.sema.acquire();
3481
3593
  try {
@@ -3504,19 +3616,20 @@ var FileHandler = class {
3504
3616
  const folderRecord = folderPath ? this.stateDb.getByPath(folderPath) : this.stateDb.getByPath("__root__");
3505
3617
  const folderPageId = folderRecord?.notionPageId;
3506
3618
  if (!folderPageId) {
3507
- getLogger().warn(
3508
- `\uD3F4\uB354 \uD398\uC774\uC9C0 \uC5C6\uC74C, \uD30C\uC77C \uC2A4\uD0B5: ${folderPath || "(\uB8E8\uD2B8)"} (${files.length}\uAC1C)`
3509
- );
3619
+ if (files.length > 0) {
3620
+ getLogger().debug(
3621
+ `\uD3F4\uB354 \uD398\uC774\uC9C0 \uC5C6\uC74C, \uD30C\uC77C \uC2A4\uD0B5: ${folderPath || "(\uB8E8\uD2B8)"} (${files.length}\uAC1C)`
3622
+ );
3623
+ }
3510
3624
  continue;
3511
3625
  }
3512
3626
  for (const file of files) {
3513
- if (this.stateDb.isFileRegistered(file.path)) {
3514
- const existing = this.stateDb.getFileRegistry(file.path);
3515
- if (existing) {
3516
- const buffer = await this.vaultFs.readBinary(file.path);
3517
- const currentHash = createHash("sha256").update(buffer).digest("hex");
3518
- if (existing.fileHash === currentHash) continue;
3519
- }
3627
+ const existing = this.stateDb.getFileRegistry(file.path);
3628
+ if (existing) {
3629
+ if (existing.fileSize === file.size) continue;
3630
+ const buffer = await this.vaultFs.readBinary(file.path);
3631
+ const currentHash = createHash("sha256").update(buffer).digest("hex");
3632
+ if (existing.fileHash === currentHash) continue;
3520
3633
  }
3521
3634
  await this.sema.acquire();
3522
3635
  try {
@@ -4209,13 +4322,21 @@ var DatabaseSyncer = class {
4209
4322
  });
4210
4323
  }
4211
4324
  }
4212
- getLogger().info(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
4325
+ getLogger().debug(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
4213
4326
  return { created, updated, failed };
4214
4327
  }
4215
- async pullDatabaseViews(dbConfig) {
4328
+ async pullDatabaseViews(dbConfig, force = false) {
4216
4329
  try {
4217
- const viewsConfig = await this.notionClient.getDatabaseViewsConfig(dbConfig.databaseId);
4218
4330
  const configPath = ".im-nobsidian/db-views.json";
4331
+ if (!force) {
4332
+ try {
4333
+ const existing = await this.vaultFs.readFile(configPath);
4334
+ const parsed = JSON.parse(existing);
4335
+ if (parsed[dbConfig.databaseId]) return;
4336
+ } catch {
4337
+ }
4338
+ }
4339
+ const viewsConfig = await this.notionClient.getDatabaseViewsConfig(dbConfig.databaseId);
4219
4340
  await this.vaultFs.ensureFolder(".im-nobsidian");
4220
4341
  let allViewsConfigs = {};
4221
4342
  try {
@@ -4225,7 +4346,7 @@ var DatabaseSyncer = class {
4225
4346
  }
4226
4347
  allViewsConfigs[dbConfig.databaseId] = viewsConfig;
4227
4348
  await this.vaultFs.writeFile(configPath, JSON.stringify(allViewsConfigs, null, 2));
4228
- getLogger().info(
4349
+ getLogger().debug(
4229
4350
  `[DB Sync] \uBDF0 \uC124\uC815 ${viewsConfig.views.length}\uAC1C \uC800\uC7A5: ${dbConfig.databaseId}`
4230
4351
  );
4231
4352
  } catch (error) {
@@ -4267,15 +4388,32 @@ var DatabaseSyncer = class {
4267
4388
  } catch {
4268
4389
  }
4269
4390
  if (this.config.conversion.imageDownload === "immediate" && markdown) {
4270
- const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
4271
- markdown = imageResult.content;
4391
+ try {
4392
+ const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
4393
+ markdown = imageResult.content;
4394
+ } catch (error) {
4395
+ getLogger().warn(`[DB Sync] \uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328 (${title}):`, error);
4396
+ }
4272
4397
  }
4273
4398
  if (markdown) {
4274
- const fileResult = await this.imageHandler.downloadAllFiles(markdown, title);
4275
- markdown = fileResult.content;
4399
+ try {
4400
+ const fileResult = await this.imageHandler.downloadAllFiles(markdown, title);
4401
+ markdown = fileResult.content;
4402
+ } catch (error) {
4403
+ getLogger().warn(`[DB Sync] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328 (${title}):`, error);
4404
+ }
4276
4405
  }
4277
4406
  const existingRecord = this.stateDb.getByNotionId(page.id);
4278
- const filePath = existingRecord?.obsidianPath ?? `${dbConfig.localFolder}/${safeName}.md`;
4407
+ let filePath;
4408
+ if (existingRecord?.obsidianPath) {
4409
+ filePath = existingRecord.obsidianPath;
4410
+ } else {
4411
+ filePath = `${dbConfig.localFolder}/${safeName}.md`;
4412
+ const existingByPath = this.stateDb.getByPath(filePath);
4413
+ if (existingByPath && existingByPath.notionPageId !== page.id) {
4414
+ filePath = `${dbConfig.localFolder}/${safeName} (${page.id.slice(0, 8)}).md`;
4415
+ }
4416
+ }
4279
4417
  const finalContent = this.pipeline.convertToMarkdown(
4280
4418
  markdown,
4281
4419
  {
@@ -4384,7 +4522,7 @@ var DatabaseSyncer = class {
4384
4522
  });
4385
4523
  }
4386
4524
  }
4387
- getLogger().info(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
4525
+ getLogger().debug(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
4388
4526
  return { created, updated, failed };
4389
4527
  }
4390
4528
  };
@@ -4404,7 +4542,7 @@ function extractTitleFromPath(filePath) {
4404
4542
 
4405
4543
  // src/sync/orchestrator.ts
4406
4544
  var SyncOrchestrator = class {
4407
- constructor(config, stateDb, notionClient, vaultFs) {
4545
+ constructor(config, stateDb, notionClient, vaultFs, customFetch) {
4408
4546
  this.config = config;
4409
4547
  this.stateDb = stateDb;
4410
4548
  this.notionClient = notionClient;
@@ -4414,7 +4552,12 @@ var SyncOrchestrator = class {
4414
4552
  wikilinkResolver: (text) => stateDb.resolveWikilink(text)
4415
4553
  });
4416
4554
  this.blockConverter = new BlockConverter();
4417
- this.imageHandler = new ImageHandler(vaultFs, config.paths.attachments, notionClient);
4555
+ this.imageHandler = new ImageHandler(
4556
+ vaultFs,
4557
+ config.paths.attachments,
4558
+ notionClient,
4559
+ customFetch
4560
+ );
4418
4561
  this.fileHandler = new FileHandler(vaultFs, notionClient, stateDb);
4419
4562
  this.propertyMapper = new PropertyMapper();
4420
4563
  this.databaseSyncer = new DatabaseSyncer(
@@ -4451,15 +4594,20 @@ var SyncOrchestrator = class {
4451
4594
  return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
4452
4595
  }
4453
4596
  this.cleanupInterruptedSync();
4454
- const files = await this.vaultFs.listMarkdownFiles();
4455
- const changes = this.changeDetector.detectLocalChanges(files);
4597
+ this.repairFolderRecords();
4598
+ const stats = await this.vaultFs.listMarkdownFileStats();
4599
+ const changes = await this.changeDetector.detectLocalChangesFast(
4600
+ stats,
4601
+ (path) => this.vaultFs.readFile(path)
4602
+ );
4456
4603
  const conflictPaths = new Set(this.stateDb.getByStatus("conflict").map((r) => r.obsidianPath));
4457
4604
  const eligible = options?.force ? changes : changes.filter((c) => !conflictPaths.has(c.path));
4458
4605
  const dbFolders = (this.config.notion.databases ?? []).map(
4459
4606
  (d) => d.localFolder.endsWith("/") ? d.localFolder : d.localFolder + "/"
4460
4607
  );
4461
4608
  const isDbPath = (path) => dbFolders.some((prefix) => path.startsWith(prefix));
4462
- const filtered = (options?.paths ? eligible.filter((c) => options.paths.some((p) => c.path.startsWith(p))) : eligible).filter((c) => !isDbPath(c.path));
4609
+ const excludeSet = options?.excludePaths ? new Set(options.excludePaths) : null;
4610
+ const filtered = (options?.paths ? eligible.filter((c) => options.paths.some((p) => c.path.startsWith(p))) : eligible).filter((c) => !isDbPath(c.path) && (!excludeSet || !excludeSet.has(c.path)));
4463
4611
  const hasDbConfigs = (this.config.notion.databases?.length ?? 0) > 0;
4464
4612
  if (filtered.length === 0 && !hasDbConfigs) {
4465
4613
  return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
@@ -4508,6 +4656,7 @@ var SyncOrchestrator = class {
4508
4656
  const tasks = filtered.map((change) => async () => {
4509
4657
  await sema.acquire();
4510
4658
  try {
4659
+ if (options?.signal?.aborted) return;
4511
4660
  const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
4512
4661
  options?.onProgress?.(++completed, total, { path: change.path, operation: op });
4513
4662
  switch (change.type) {
@@ -4630,11 +4779,37 @@ var SyncOrchestrator = class {
4630
4779
  const conflicts = [];
4631
4780
  const writtenPaths = [];
4632
4781
  const failed = [];
4633
- const remoteChanges = await this.detectRemoteChanges();
4782
+ const lastPull = this.stateDb.getMeta("last_pull_at") ?? this.stateDb.getMeta("last_sync_at");
4783
+ const trackedCount = this.stateDb.getAll().length;
4784
+ const remoteChanges = !this.isDatabaseMode && lastPull && trackedCount > 0 && !options?.force ? await this.detectRemoteChangesIncremental(lastPull) : await this.detectRemoteChanges();
4634
4785
  const filtered = options?.paths ? remoteChanges.filter((c) => {
4635
4786
  const record = this.stateDb.getByNotionId(c.pageId);
4636
4787
  return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
4637
4788
  }) : remoteChanges;
4789
+ if (filtered.length === 0 && (this.config.notion.databases?.length ?? 0) === 0) {
4790
+ const hasDbResults = await this.pullDiscoveredDatabases(writtenPaths, failed);
4791
+ if (!hasDbResults.created && !hasDbResults.updated) {
4792
+ this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
4793
+ this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
4794
+ this.stateDb.setMeta("pull_in_progress", "");
4795
+ return emptyResult;
4796
+ }
4797
+ this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
4798
+ this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
4799
+ this.stateDb.setMeta("pull_in_progress", "");
4800
+ return {
4801
+ created: hasDbResults.created,
4802
+ updated: hasDbResults.updated,
4803
+ deleted: 0,
4804
+ conflicts: [],
4805
+ writtenPaths,
4806
+ failed,
4807
+ duration: Date.now() - startTime,
4808
+ imageCount: 0,
4809
+ fileCount: 0,
4810
+ linkCount: 0
4811
+ };
4812
+ }
4638
4813
  if (filtered.length === 0) {
4639
4814
  return emptyResult;
4640
4815
  }
@@ -4670,15 +4845,14 @@ var SyncOrchestrator = class {
4670
4845
  const tasks = filtered.map((change) => async () => {
4671
4846
  await sema.acquire();
4672
4847
  try {
4673
- const record = this.stateDb.getByNotionId(change.pageId);
4674
- const displayPath = record?.obsidianPath ?? change.pageId;
4675
- const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
4676
- options?.onProgress?.(++pullCompleted, pullTotal, { path: displayPath, operation: op });
4848
+ if (options?.signal?.aborted) return;
4849
+ let resultPath;
4677
4850
  switch (change.type) {
4678
4851
  case "created": {
4679
4852
  const path = await this.pullCreate(change.pageId);
4680
4853
  writtenPaths.push(path);
4681
4854
  created++;
4855
+ resultPath = path;
4682
4856
  break;
4683
4857
  }
4684
4858
  case "modified": {
@@ -4689,16 +4863,24 @@ var SyncOrchestrator = class {
4689
4863
  writtenPaths.push(result.path);
4690
4864
  updated++;
4691
4865
  }
4866
+ resultPath = result.path;
4692
4867
  break;
4693
4868
  }
4694
4869
  case "deleted": {
4870
+ const record2 = this.stateDb.getByNotionId(change.pageId);
4871
+ resultPath = record2?.obsidianPath;
4695
4872
  const path = await this.pullDelete(change.pageId);
4696
4873
  if (path) {
4697
4874
  deleted++;
4875
+ resultPath = path;
4698
4876
  }
4699
4877
  break;
4700
4878
  }
4701
4879
  }
4880
+ const record = this.stateDb.getByNotionId(change.pageId);
4881
+ const displayPath = resultPath ?? record?.obsidianPath ?? change.pageId;
4882
+ const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
4883
+ options?.onProgress?.(++pullCompleted, pullTotal, { path: displayPath, operation: op });
4702
4884
  } catch (error) {
4703
4885
  const record = this.stateDb.getByNotionId(change.pageId);
4704
4886
  failed.push({
@@ -4773,44 +4955,13 @@ var SyncOrchestrator = class {
4773
4955
  getLogger().warn("[Im-Nobsidian] DB Pull \uC911 \uC624\uB958:", error);
4774
4956
  }
4775
4957
  }
4776
- if (!this.isDatabaseMode) {
4777
- try {
4778
- const discoveredDbs = await this.discoverChildDatabases();
4779
- const configuredIds = new Set(
4780
- (this.config.notion.databases ?? []).map((d) => d.databaseId.replace(/-/g, ""))
4781
- );
4782
- const newDbs = discoveredDbs.filter((db) => !configuredIds.has(db.dbId.replace(/-/g, "")));
4783
- for (const { dbId, parentPageId } of newDbs) {
4784
- try {
4785
- const dbTitle = await this.notionClient.getDatabaseTitle(dbId);
4786
- const safeName = (dbTitle || dbId.slice(0, 8)).replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-");
4787
- const parentEntry = parentPageId ? this.stateDb.getByNotionId(parentPageId) : null;
4788
- let parentFolder = "databases";
4789
- if (parentEntry?.obsidianPath) {
4790
- const parts = parentEntry.obsidianPath.split("/");
4791
- parts.pop();
4792
- parentFolder = parts.length > 0 ? parts.join("/") : "databases";
4793
- }
4794
- const folderName = `${parentFolder}/${safeName}`;
4795
- const dbConfig = {
4796
- databaseId: dbId,
4797
- localFolder: folderName,
4798
- titleProperty: "Name"
4799
- };
4800
- const dbResult = await this.databaseSyncer.pullDatabase(dbConfig);
4801
- created += dbResult.created;
4802
- updated += dbResult.updated;
4803
- failed.push(...dbResult.failed);
4804
- } catch (error) {
4805
- getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
4806
- }
4807
- }
4808
- } catch (error) {
4809
- getLogger().warn("[Im-Nobsidian] child_database \uC790\uB3D9 \uBC1C\uACAC \uC2E4\uD328:", error);
4810
- }
4958
+ {
4959
+ const dbDiscovery = await this.pullDiscoveredDatabases(writtenPaths, failed);
4960
+ created += dbDiscovery.created;
4961
+ updated += dbDiscovery.updated;
4811
4962
  }
4812
- const allSyncedMdPaths = this.stateDb.getAll().filter((r) => r.obsidianPath?.endsWith(".md")).map((r) => r.obsidianPath);
4813
- const linkCount = await this.resolveNotionLinks(allSyncedMdPaths);
4963
+ const linkTargetPaths = writtenPaths.length > 0 ? writtenPaths : [];
4964
+ const linkCount = linkTargetPaths.length > 0 ? await this.resolveNotionLinks(linkTargetPaths) : 0;
4814
4965
  this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
4815
4966
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
4816
4967
  this.stateDb.setMeta("pull_in_progress", "");
@@ -4832,7 +4983,8 @@ var SyncOrchestrator = class {
4832
4983
  const pullResult = await this.pull(options);
4833
4984
  const pushResult = await this.push({
4834
4985
  ...options,
4835
- paths: options?.paths
4986
+ paths: options?.paths,
4987
+ excludePaths: pullResult.writtenPaths
4836
4988
  });
4837
4989
  return {
4838
4990
  pull: pullResult,
@@ -4844,9 +4996,9 @@ var SyncOrchestrator = class {
4844
4996
  async status() {
4845
4997
  const files = await this.vaultFs.listMarkdownFiles();
4846
4998
  const localChanges = this.changeDetector.detectLocalChanges(files);
4847
- const remoteChanges = await this.detectRemoteChanges();
4848
- const conflictRecords = this.stateDb.getByStatus("conflict");
4849
4999
  const lastSyncAt = this.stateDb.getMeta("last_sync_at");
5000
+ const remoteChanges = lastSyncAt ? await this.detectRemoteChangesIncremental(lastSyncAt) : await this.detectRemoteChanges();
5001
+ const conflictRecords = this.stateDb.getByStatus("conflict");
4850
5002
  const conflicts = await this.buildConflictsFromRecords(
4851
5003
  conflictRecords,
4852
5004
  localChanges,
@@ -4861,6 +5013,34 @@ var SyncOrchestrator = class {
4861
5013
  lastSyncAt
4862
5014
  };
4863
5015
  }
5016
+ async statusLocal() {
5017
+ const stats = await this.vaultFs.listMarkdownFileStats();
5018
+ const localChanges = await this.changeDetector.detectLocalChangesFast(
5019
+ stats,
5020
+ (path) => this.vaultFs.readFile(path)
5021
+ );
5022
+ const conflictRecords = this.stateDb.getByStatus("conflict");
5023
+ const lastSyncAt = this.stateDb.getMeta("last_sync_at");
5024
+ return {
5025
+ localChanges,
5026
+ remoteChanges: [],
5027
+ conflicts: [],
5028
+ conflictRecords,
5029
+ pendingOperations: conflictRecords.length,
5030
+ lastSyncAt
5031
+ };
5032
+ }
5033
+ async fetch() {
5034
+ const startTime = Date.now();
5035
+ const changes = await this.detectRemoteChanges();
5036
+ this.stateDb.setMeta("last_fetch_at", (/* @__PURE__ */ new Date()).toISOString());
5037
+ return {
5038
+ newPages: changes.filter((c) => c.type === "created").length,
5039
+ modifiedPages: changes.filter((c) => c.type === "modified").length,
5040
+ deletedPages: changes.filter((c) => c.type === "deleted").length,
5041
+ duration: Date.now() - startTime
5042
+ };
5043
+ }
4864
5044
  async buildConflictsFromRecords(records, localChanges, remoteChanges) {
4865
5045
  const conflicts = [];
4866
5046
  for (const record of records) {
@@ -4903,25 +5083,97 @@ var SyncOrchestrator = class {
4903
5083
  return this.config.notion.parentMode === "database" && !!this.config.notion.databaseId;
4904
5084
  }
4905
5085
  async discoverChildDatabases() {
4906
- const rootId = this.config.notion.rootPageId;
4907
5086
  const allDbs = [];
4908
- let currentLevel = [rootId];
4909
- while (currentLevel.length > 0) {
4910
- const nextLevel = [];
4911
- for (const parentId of currentLevel) {
5087
+ const trackedPages = this.stateDb.getAll().filter((r) => r.notionPageId);
5088
+ const pageIds = [this.config.notion.rootPageId, ...trackedPages.map((r) => r.notionPageId)];
5089
+ const seen = /* @__PURE__ */ new Set();
5090
+ for (const parentId of pageIds) {
5091
+ if (seen.has(parentId)) continue;
5092
+ seen.add(parentId);
5093
+ try {
5094
+ const dbIds = await this.notionClient.getChildDatabaseIds(parentId);
5095
+ for (const dbId of dbIds) {
5096
+ allDbs.push({ dbId, parentPageId: parentId });
5097
+ }
5098
+ } catch {
5099
+ }
5100
+ }
5101
+ return allDbs;
5102
+ }
5103
+ async pullDiscoveredDatabases(writtenPaths, failed) {
5104
+ if (this.isDatabaseMode) return { created: 0, updated: 0 };
5105
+ let created = 0;
5106
+ let updated = 0;
5107
+ try {
5108
+ const cachedRaw = this.stateDb.getMeta("discovered_dbs");
5109
+ let dbConfigs = [];
5110
+ if (cachedRaw) {
4912
5111
  try {
4913
- const dbIds = await this.notionClient.getChildDatabaseIds(parentId);
4914
- for (const dbId of dbIds) {
4915
- allDbs.push({ dbId, parentPageId: parentId });
4916
- }
4917
- const childPages = await this.notionClient.getChildPages(parentId);
4918
- nextLevel.push(...childPages.map((p) => p.id));
5112
+ dbConfigs = JSON.parse(cachedRaw);
4919
5113
  } catch {
5114
+ dbConfigs = [];
4920
5115
  }
4921
5116
  }
4922
- currentLevel = nextLevel;
5117
+ if (dbConfigs.length === 0) {
5118
+ const discoveredDbs = await this.discoverChildDatabases();
5119
+ const configuredIds = new Set(
5120
+ (this.config.notion.databases ?? []).map((d) => d.databaseId.replace(/-/g, ""))
5121
+ );
5122
+ const newDbs = discoveredDbs.filter((db) => !configuredIds.has(db.dbId.replace(/-/g, "")));
5123
+ for (const { dbId, parentPageId } of newDbs) {
5124
+ try {
5125
+ const dbTitle = await this.notionClient.getDatabaseTitle(dbId);
5126
+ const parentEntry = parentPageId ? this.stateDb.getByNotionId(parentPageId) : null;
5127
+ let parentFolder = "";
5128
+ if (parentEntry?.obsidianPath) {
5129
+ const obsPath = parentEntry.obsidianPath;
5130
+ if (obsPath.endsWith(".md")) {
5131
+ const parts = obsPath.split("/");
5132
+ parts.pop();
5133
+ parentFolder = parts.length > 0 ? parts.join("/") : obsPath.replace(/\.md$/, "");
5134
+ } else {
5135
+ parentFolder = obsPath;
5136
+ }
5137
+ }
5138
+ let safeName;
5139
+ if (dbTitle) {
5140
+ safeName = dbTitle.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-");
5141
+ } else {
5142
+ const parentName = parentFolder.split("/").pop() || "";
5143
+ safeName = parentName ? `${parentName}-DB` : `db-${dbId.slice(0, 8)}`;
5144
+ }
5145
+ if (!parentFolder) parentFolder = "databases";
5146
+ dbConfigs.push({
5147
+ databaseId: dbId,
5148
+ localFolder: `${parentFolder}/${safeName}`,
5149
+ titleProperty: "Name"
5150
+ });
5151
+ } catch (error) {
5152
+ getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
5153
+ }
5154
+ }
5155
+ if (dbConfigs.length > 0) {
5156
+ this.stateDb.setMeta("discovered_dbs", JSON.stringify(dbConfigs));
5157
+ }
5158
+ }
5159
+ for (const dbConfig of dbConfigs) {
5160
+ try {
5161
+ const dbResult = await this.databaseSyncer.pullDatabase(dbConfig);
5162
+ created += dbResult.created;
5163
+ updated += dbResult.updated;
5164
+ failed.push(...dbResult.failed);
5165
+ if (dbResult.created + dbResult.updated > 0) {
5166
+ const dbPaths = this.stateDb.getByStatus("synced").filter((r) => r.fileType === "db-row").map((r) => r.obsidianPath);
5167
+ writtenPaths.push(...dbPaths.slice(-dbResult.created - dbResult.updated));
5168
+ }
5169
+ } catch (error) {
5170
+ getLogger().warn(`[Im-Nobsidian] DB ${dbConfig.databaseId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
5171
+ }
5172
+ }
5173
+ } catch (error) {
5174
+ getLogger().warn("[Im-Nobsidian] child_database \uC790\uB3D9 \uBC1C\uACAC \uC2E4\uD328:", error);
4923
5175
  }
4924
- return allDbs;
5176
+ return { created, updated };
4925
5177
  }
4926
5178
  async resolveNotionLinks(paths) {
4927
5179
  let totalResolved = 0;
@@ -5015,6 +5267,7 @@ var SyncOrchestrator = class {
5015
5267
  );
5016
5268
  await this.imageHandler.uploadAndAppendImages(page.id, conversionResult.images);
5017
5269
  const hash = computeHash(content);
5270
+ const fileStat = await this.vaultFs.getFileStat(path);
5018
5271
  this.stateDb.transaction(() => {
5019
5272
  this.stateDb.upsert({
5020
5273
  obsidianPath: path,
@@ -5026,7 +5279,9 @@ var SyncOrchestrator = class {
5026
5279
  syncDirection: "both",
5027
5280
  fileType: this.isFolderNote(path) ? "folder-note" : "file",
5028
5281
  status: "synced",
5029
- baseSnapshot: Buffer.from(content, "utf-8")
5282
+ baseSnapshot: Buffer.from(content, "utf-8"),
5283
+ localMtime: fileStat?.mtime ?? null,
5284
+ localFileSize: fileStat?.size ?? null
5030
5285
  });
5031
5286
  const aliases = extractAliases2(conversionResult.properties);
5032
5287
  this.stateDb.upsertWikilink({
@@ -5056,23 +5311,36 @@ var SyncOrchestrator = class {
5056
5311
  });
5057
5312
  await this.pushUpdatePage(record.notionPageId, conversionResult.content, record.baseSnapshot);
5058
5313
  await this.imageHandler.uploadAndAppendImages(record.notionPageId, conversionResult.images);
5059
- let propsToUpdate = conversionResult.properties;
5060
- if (this.isDatabaseMode && propsToUpdate && Object.keys(propsToUpdate).length > 0) {
5314
+ let propsToUpdate;
5315
+ if (this.isDatabaseMode && conversionResult.properties && Object.keys(conversionResult.properties).length > 0) {
5061
5316
  await this.ensureDbSchema();
5062
5317
  const title2 = extractTitle(path);
5063
- propsToUpdate = this.propertyMapper.toNotionProperties(propsToUpdate, title2);
5318
+ propsToUpdate = this.propertyMapper.toNotionProperties(conversionResult.properties, title2);
5319
+ } else if (conversionResult.properties?.title) {
5320
+ const titleStr = String(conversionResult.properties.title);
5321
+ propsToUpdate = {
5322
+ title: { title: [{ text: { content: titleStr } }] }
5323
+ };
5064
5324
  }
5325
+ let lastEditedTime = (/* @__PURE__ */ new Date()).toISOString();
5065
5326
  if (propsToUpdate && Object.keys(propsToUpdate).length > 0) {
5066
- await this.notionClient.updatePageProperties(record.notionPageId, propsToUpdate);
5327
+ const updatedPage = await this.notionClient.updatePageProperties(
5328
+ record.notionPageId,
5329
+ propsToUpdate
5330
+ );
5331
+ lastEditedTime = updatedPage.last_edited_time;
5067
5332
  }
5068
- const updatedPage = await this.notionClient.getPage(record.notionPageId);
5069
5333
  const hash = computeHash(content);
5334
+ const fileStat = await this.vaultFs.getFileStat(path);
5070
5335
  const title = extractTitle(path);
5071
5336
  const aliases = extractAliases2(conversionResult.properties);
5072
5337
  this.stateDb.transaction(() => {
5073
5338
  this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
5074
5339
  this.stateDb.updateStatus(record.id, "synced");
5075
- this.stateDb.setNotionLastEdited(record.id, updatedPage.last_edited_time);
5340
+ this.stateDb.setNotionLastEdited(record.id, lastEditedTime);
5341
+ if (fileStat) {
5342
+ this.stateDb.updateStatCache(record.id, fileStat.mtime, fileStat.size);
5343
+ }
5076
5344
  this.stateDb.storePreserveMarkers(path, conversionResult.preserveMarkers);
5077
5345
  this.stateDb.upsertWikilink({
5078
5346
  obsidianPath: path,
@@ -5086,7 +5354,14 @@ var SyncOrchestrator = class {
5086
5354
  const record = this.stateDb.getByPath(path);
5087
5355
  if (!record?.notionPageId) return;
5088
5356
  if (this.config.sync.deleteSync) {
5089
- await this.notionClient.archivePage(record.notionPageId);
5357
+ try {
5358
+ await this.notionClient.archivePage(record.notionPageId);
5359
+ } catch (error) {
5360
+ const msg = error instanceof Error ? error.message : String(error);
5361
+ if (msg.includes("archived ancestor")) ; else {
5362
+ throw error;
5363
+ }
5364
+ }
5090
5365
  this.stateDb.delete(record.id);
5091
5366
  } else {
5092
5367
  this.stateDb.updateStatus(record.id, "pending");
@@ -5166,13 +5441,49 @@ var SyncOrchestrator = class {
5166
5441
  }
5167
5442
  return changes;
5168
5443
  }
5444
+ async detectRemoteChangesIncremental(since) {
5445
+ const changes = [];
5446
+ const recentPages = await this.notionClient.searchRecentPages(since);
5447
+ for (const page of recentPages) {
5448
+ const record = this.stateDb.getByNotionId(page.id);
5449
+ if (!record) {
5450
+ try {
5451
+ const fullPage = await this.notionClient.getPage(page.id);
5452
+ const parentId = await this.extractParentId(fullPage);
5453
+ if (parentId && this.isTrackedParent(parentId)) {
5454
+ changes.push({
5455
+ pageId: page.id,
5456
+ type: "created",
5457
+ lastEdited: page.last_edited_time,
5458
+ previousEdited: null
5459
+ });
5460
+ }
5461
+ } catch {
5462
+ }
5463
+ continue;
5464
+ }
5465
+ if (page.last_edited_time !== record.notionLastEdited) {
5466
+ changes.push({
5467
+ pageId: page.id,
5468
+ type: "modified",
5469
+ lastEdited: page.last_edited_time,
5470
+ previousEdited: record.notionLastEdited
5471
+ });
5472
+ }
5473
+ }
5474
+ return changes;
5475
+ }
5476
+ isTrackedParent(parentId) {
5477
+ if (notionIdsEqual(parentId, this.config.notion.rootPageId)) return true;
5478
+ return !!this.stateDb.getByNotionId(parentId);
5479
+ }
5169
5480
  async pullCreate(pageId) {
5170
5481
  const page = await this.notionClient.getPage(pageId);
5171
5482
  const title = this.notionClient.extractTitle(page);
5172
5483
  const safeName = sanitizeFileName(title);
5173
5484
  const parentPath = await this.resolveParentPath(page);
5174
- const allChildren = await this.notionClient.fetchAllChildrenDeep(pageId);
5175
- const hasChildPages = allChildren.some((b) => "type" in b && b.type === "child_page");
5485
+ const firstChildren = await this.notionClient.listChildren(pageId, { pageSize: 100 });
5486
+ const hasChildPages = firstChildren.results.some((b) => "type" in b && b.type === "child_page");
5176
5487
  const markdown = await this.fetchPageMarkdown(pageId);
5177
5488
  const hasContent = markdown.trim().length > 0;
5178
5489
  let filePath;
@@ -5223,6 +5534,7 @@ var SyncOrchestrator = class {
5223
5534
  );
5224
5535
  await this.vaultFs.writeFile(filePath, finalContent);
5225
5536
  const hash = computeHash(finalContent);
5537
+ const pullStat = await this.vaultFs.getFileStat(filePath);
5226
5538
  const resolvedParentId = await this.extractParentId(page);
5227
5539
  this.stateDb.transaction(() => {
5228
5540
  this.stateDb.upsert({
@@ -5235,7 +5547,9 @@ var SyncOrchestrator = class {
5235
5547
  syncDirection: "both",
5236
5548
  fileType,
5237
5549
  status: "synced",
5238
- baseSnapshot: Buffer.from(finalContent, "utf-8")
5550
+ baseSnapshot: Buffer.from(finalContent, "utf-8"),
5551
+ localMtime: pullStat?.mtime ?? null,
5552
+ localFileSize: pullStat?.size ?? null
5239
5553
  });
5240
5554
  const aliases = extractAliases2(properties);
5241
5555
  this.stateDb.upsertWikilink({
@@ -5313,6 +5627,7 @@ var SyncOrchestrator = class {
5313
5627
  }
5314
5628
  }
5315
5629
  await this.vaultFs.writeFile(record.obsidianPath, remoteContent);
5630
+ const updateStat = await this.vaultFs.getFileStat(record.obsidianPath);
5316
5631
  const newHash = computeHash(remoteContent);
5317
5632
  this.stateDb.upsert({
5318
5633
  obsidianPath: record.obsidianPath,
@@ -5324,7 +5639,9 @@ var SyncOrchestrator = class {
5324
5639
  syncDirection: record.syncDirection,
5325
5640
  fileType: record.fileType,
5326
5641
  status: "synced",
5327
- baseSnapshot: Buffer.from(remoteContent, "utf-8")
5642
+ baseSnapshot: Buffer.from(remoteContent, "utf-8"),
5643
+ localMtime: updateStat?.mtime ?? null,
5644
+ localFileSize: updateStat?.size ?? null
5328
5645
  });
5329
5646
  return { path: record.obsidianPath };
5330
5647
  }
@@ -5341,31 +5658,27 @@ var SyncOrchestrator = class {
5341
5658
  return record.obsidianPath;
5342
5659
  }
5343
5660
  async ensureFolderPage(folderPath) {
5344
- const existing = this.stateDb.getByPath(folderPath);
5345
- if (existing?.notionPageId) return;
5346
5661
  const folderNotePath = `${folderPath}/${folderPath.split("/").pop()}.md`;
5347
5662
  const folderNoteRecord = this.stateDb.getByPath(folderNotePath);
5348
- if (folderNoteRecord?.notionParentId) {
5349
- this.stateDb.upsert({
5350
- obsidianPath: folderPath,
5351
- notionPageId: folderNoteRecord.notionParentId,
5352
- notionParentId: "",
5353
- contentHash: "",
5354
- notionLastEdited: folderNoteRecord.notionLastEdited,
5355
- localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
5356
- syncDirection: "both",
5357
- fileType: "folder-note",
5358
- status: "synced"
5359
- });
5663
+ if (folderNoteRecord?.notionPageId) {
5664
+ const existing2 = this.stateDb.getByPath(folderPath);
5665
+ if (existing2) this.stateDb.delete(existing2.id);
5360
5666
  return;
5361
5667
  }
5668
+ const existing = this.stateDb.getByPath(folderPath);
5669
+ if (existing?.notionPageId) return;
5362
5670
  const parts = folderPath.split("/");
5363
5671
  const folderName = parts[parts.length - 1];
5364
5672
  let parentId = this.config.notion.rootPageId;
5365
5673
  if (parts.length > 1) {
5366
5674
  const parentPath = parts.slice(0, -1).join("/");
5675
+ await this.ensureFolderPage(parentPath);
5676
+ const parentNotePath = `${parentPath}/${parentPath.split("/").pop()}.md`;
5677
+ const parentNote = this.stateDb.getByPath(parentNotePath);
5367
5678
  const parentRecord = this.stateDb.getByPath(parentPath);
5368
- if (parentRecord?.notionPageId) {
5679
+ if (parentNote?.notionPageId) {
5680
+ parentId = parentNote.notionPageId;
5681
+ } else if (parentRecord?.notionPageId) {
5369
5682
  parentId = parentRecord.notionPageId;
5370
5683
  }
5371
5684
  }
@@ -5392,8 +5705,26 @@ var SyncOrchestrator = class {
5392
5705
  const folderPath = parts.slice(0, -1).join("/");
5393
5706
  const folderRecord = this.stateDb.getByPath(folderPath);
5394
5707
  if (folderRecord?.notionPageId) return folderRecord.notionPageId;
5708
+ const folderName = parts[parts.length - 2];
5709
+ const folderNotePath = `${folderPath}/${folderName}.md`;
5710
+ const folderNoteRecord = this.stateDb.getByPath(folderNotePath);
5711
+ if (folderNoteRecord?.notionPageId) return folderNoteRecord.notionPageId;
5395
5712
  return this.config.notion.rootPageId;
5396
5713
  }
5714
+ repairFolderRecords() {
5715
+ const allRecords = this.stateDb.getAll();
5716
+ const folderRecords = allRecords.filter(
5717
+ (r) => r.fileType === "folder-note" && !r.obsidianPath.endsWith(".md")
5718
+ );
5719
+ for (const folder of folderRecords) {
5720
+ const folderName = folder.obsidianPath.split("/").pop();
5721
+ const folderNotePath = `${folder.obsidianPath}/${folderName}.md`;
5722
+ const noteRecord = this.stateDb.getByPath(folderNotePath);
5723
+ if (noteRecord?.notionPageId) {
5724
+ this.stateDb.delete(folder.id);
5725
+ }
5726
+ }
5727
+ }
5397
5728
  isFolderNote(filePath) {
5398
5729
  const parts = filePath.split("/");
5399
5730
  if (parts.length < 2) return false;
@@ -5428,17 +5759,14 @@ var SyncOrchestrator = class {
5428
5759
  }
5429
5760
  async pushCreatePage(parentId, parentType, title, markdownContent, properties) {
5430
5761
  if (this.config.conversion.preferMarkdownApi !== false) {
5431
- try {
5432
- const enhanced = obsidianToNotionEnhanced(markdownContent);
5433
- return await this.notionClient.createPageWithMarkdown({
5434
- parentId,
5435
- parentType,
5436
- title,
5437
- markdown: enhanced,
5438
- properties
5439
- });
5440
- } catch {
5441
- }
5762
+ const enhanced = obsidianToNotionEnhanced(markdownContent);
5763
+ return await this.notionClient.createPageWithMarkdown({
5764
+ parentId,
5765
+ parentType,
5766
+ title,
5767
+ markdown: enhanced,
5768
+ properties
5769
+ });
5442
5770
  }
5443
5771
  const blocks = this.blockConverter.markdownToNotionBlocks(markdownContent);
5444
5772
  const page = await this.notionClient.createPage({
@@ -5452,27 +5780,11 @@ var SyncOrchestrator = class {
5452
5780
  }
5453
5781
  return page;
5454
5782
  }
5455
- async pushUpdatePage(pageId, markdownContent, baseSnapshot) {
5783
+ async pushUpdatePage(pageId, markdownContent, _baseSnapshot) {
5456
5784
  if (this.config.conversion.preferMarkdownApi !== false) {
5457
- try {
5458
- const enhanced = obsidianToNotionEnhanced(markdownContent);
5459
- if (baseSnapshot) {
5460
- const baseEnhanced = obsidianToNotionEnhanced(baseSnapshot.toString("utf-8"));
5461
- const patches = this.computePatches(baseEnhanced, enhanced);
5462
- if (patches.length > 0 && patches.length <= 20) {
5463
- try {
5464
- await this.notionClient.updatePageMarkdownPartial(pageId, patches);
5465
- return;
5466
- } catch {
5467
- await this.notionClient.replacePageMarkdown(pageId, enhanced);
5468
- return;
5469
- }
5470
- }
5471
- }
5472
- await this.notionClient.replacePageMarkdown(pageId, enhanced);
5473
- return;
5474
- } catch {
5475
- }
5785
+ const enhanced = obsidianToNotionEnhanced(markdownContent);
5786
+ await this.notionClient.replacePageMarkdown(pageId, enhanced);
5787
+ return;
5476
5788
  }
5477
5789
  const blocks = this.blockConverter.markdownToNotionBlocks(markdownContent);
5478
5790
  const existingBlocks = await this.notionClient.fetchAllChildren(pageId);
@@ -5491,51 +5803,6 @@ var SyncOrchestrator = class {
5491
5803
  })
5492
5804
  );
5493
5805
  }
5494
- computePatches(oldContent, newContent) {
5495
- const oldLines = oldContent.split("\n");
5496
- const newLines = newContent.split("\n");
5497
- const patches = [];
5498
- let i = 0;
5499
- let j = 0;
5500
- while (i < oldLines.length && j < newLines.length) {
5501
- if (oldLines[i] === newLines[j]) {
5502
- i++;
5503
- j++;
5504
- continue;
5505
- }
5506
- let oldEnd = i + 1;
5507
- let newEnd = j + 1;
5508
- const lookahead = Math.min(10, oldLines.length - i, newLines.length - j);
5509
- for (let k = 1; k <= lookahead; k++) {
5510
- if (i + k < oldLines.length && newLines[j] === oldLines[i + k]) {
5511
- oldEnd = i + k;
5512
- newEnd = j;
5513
- break;
5514
- }
5515
- if (j + k < newLines.length && oldLines[i] === newLines[j + k]) {
5516
- oldEnd = i;
5517
- newEnd = j + k;
5518
- break;
5519
- }
5520
- oldEnd = i + k;
5521
- newEnd = j + k;
5522
- }
5523
- const oldChunk = oldLines.slice(i, oldEnd).join("\n");
5524
- const newChunk = newLines.slice(j, newEnd).join("\n");
5525
- if (oldChunk || newChunk) {
5526
- patches.push({ oldStr: oldChunk, newStr: newChunk });
5527
- }
5528
- i = oldEnd;
5529
- j = newEnd;
5530
- }
5531
- if (i < oldLines.length || j < newLines.length) {
5532
- patches.push({
5533
- oldStr: oldLines.slice(i).join("\n"),
5534
- newStr: newLines.slice(j).join("\n")
5535
- });
5536
- }
5537
- return patches;
5538
- }
5539
5806
  async fetchPageMarkdown(pageId) {
5540
5807
  if (this.config.conversion.preferMarkdownApi !== false) {
5541
5808
  try {
@@ -5673,6 +5940,20 @@ var NodeVaultFS = class {
5673
5940
  await this.walkDir(this.rootPath, files);
5674
5941
  return files;
5675
5942
  }
5943
+ async listMarkdownFileStats() {
5944
+ const stats = [];
5945
+ await this.walkDirStats(this.rootPath, stats);
5946
+ return stats;
5947
+ }
5948
+ async getFileStat(path) {
5949
+ try {
5950
+ const fullPath = join(this.rootPath, path);
5951
+ const fileStat = await stat(fullPath);
5952
+ return { path, mtime: fileStat.mtime.toISOString(), size: fileStat.size };
5953
+ } catch {
5954
+ return null;
5955
+ }
5956
+ }
5676
5957
  async listNonMarkdownFiles() {
5677
5958
  const files = [];
5678
5959
  await this.walkDirNonMd(this.rootPath, files);
@@ -5719,6 +6000,25 @@ var NodeVaultFS = class {
5719
6000
  }
5720
6001
  }
5721
6002
  }
6003
+ async walkDirStats(dir, result) {
6004
+ const entries = await readdir(dir, { withFileTypes: true });
6005
+ for (const entry of entries) {
6006
+ const fullPath = join(dir, entry.name);
6007
+ if (entry.name.startsWith(".")) continue;
6008
+ const relativePath = relative(this.rootPath, fullPath);
6009
+ if (this.isExcluded(relativePath)) continue;
6010
+ if (entry.isDirectory()) {
6011
+ await this.walkDirStats(fullPath, result);
6012
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
6013
+ const fileStat = await stat(fullPath);
6014
+ result.push({
6015
+ path: relativePath,
6016
+ mtime: fileStat.mtime.toISOString(),
6017
+ size: fileStat.size
6018
+ });
6019
+ }
6020
+ }
6021
+ }
5722
6022
  async walkDir(dir, result) {
5723
6023
  const entries = await readdir(dir, { withFileTypes: true });
5724
6024
  for (const entry of entries) {
@@ -6307,6 +6607,59 @@ function extractGroupName(val) {
6307
6607
  if (typeof val === "object" && "start" in val) return val.start;
6308
6608
  return null;
6309
6609
  }
6610
+ function filterEntries(entries, searchQuery, filters) {
6611
+ let result = entries;
6612
+ if (searchQuery && searchQuery.trim()) {
6613
+ const q = searchQuery.trim().toLowerCase();
6614
+ result = result.filter((entry) => {
6615
+ if (entry.title.toLowerCase().includes(q)) return true;
6616
+ return Object.values(entry.properties).some((val) => {
6617
+ if (val === null || val === void 0) return false;
6618
+ if (typeof val === "string") return val.toLowerCase().includes(q);
6619
+ if (Array.isArray(val)) return val.some((v) => v.toLowerCase().includes(q));
6620
+ if (typeof val === "object" && "start" in val) return val.start.includes(q);
6621
+ return String(val).toLowerCase().includes(q);
6622
+ });
6623
+ });
6624
+ }
6625
+ if (filters && filters.length > 0) {
6626
+ for (const filter of filters) {
6627
+ result = result.filter((entry) => matchFilter(entry, filter));
6628
+ }
6629
+ }
6630
+ return result;
6631
+ }
6632
+ function matchFilter(entry, filter) {
6633
+ const val = filter.property === "title" ? entry.title : entry.properties[filter.property] ?? null;
6634
+ switch (filter.operator) {
6635
+ case "isEmpty":
6636
+ return val === null || val === void 0 || val === "" || Array.isArray(val) && val.length === 0;
6637
+ case "isNotEmpty":
6638
+ return val !== null && val !== void 0 && val !== "" && !(Array.isArray(val) && val.length === 0);
6639
+ case "equals":
6640
+ if (typeof val === "boolean") return val === filter.value;
6641
+ return String(val ?? "") === String(filter.value ?? "");
6642
+ case "contains": {
6643
+ const needle = String(filter.value ?? "").toLowerCase();
6644
+ if (typeof val === "string") return val.toLowerCase().includes(needle);
6645
+ if (Array.isArray(val)) return val.some((v) => v.toLowerCase().includes(needle));
6646
+ return String(val ?? "").toLowerCase().includes(needle);
6647
+ }
6648
+ case "startsWith":
6649
+ return String(val ?? "").startsWith(String(filter.value ?? ""));
6650
+ case "gt":
6651
+ return typeof val === "number" && typeof filter.value === "number" && val > filter.value;
6652
+ case "lt":
6653
+ return typeof val === "number" && typeof filter.value === "number" && val < filter.value;
6654
+ case "between": {
6655
+ if (typeof val !== "number" || typeof filter.value !== "number" || typeof filter.valueTo !== "number")
6656
+ return false;
6657
+ return val >= filter.value && val <= filter.valueTo;
6658
+ }
6659
+ default:
6660
+ return true;
6661
+ }
6662
+ }
6310
6663
  function parseDate(value) {
6311
6664
  const match = /^(\d{4}-\d{2}-\d{2})/.exec(value);
6312
6665
  return match?.[1] ?? null;
@@ -6518,6 +6871,6 @@ ${lines.join("\n")}
6518
6871
  }`;
6519
6872
  }
6520
6873
 
6521
- export { BlockConverter, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConversionPipeline, DEFAULT_CONFIG, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FrontmatterExtractor, FrontmatterGenerator, HtmlAnnotationStripper, ImageHandler, InlineDBParser, LocalImageRestorer, MathNormalizer, MentionToWikilink, NodeVaultFS, NotionBlockBuilder, NotionClient, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, StateDB, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WatchSyncService, WikilinkResolver, computeBufferHash, computeHash, createDefaultPipeline, extractCalendarEntries, filterByMonth, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, normalizeNotionId, notionIdsEqual, sanitizeFileName, setLogger, sortEntries, threeWayMerge };
6874
+ export { BlockConverter, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConversionPipeline, DEFAULT_CONFIG, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FrontmatterExtractor, FrontmatterGenerator, HtmlAnnotationStripper, ImageHandler, InlineDBParser, LocalImageRestorer, MathNormalizer, MentionToWikilink, NodeVaultFS, NotionBlockBuilder, NotionClient, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, StateDB, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WatchSyncService, WikilinkResolver, computeBufferHash, computeHash, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, normalizeNotionId, notionIdsEqual, sanitizeFileName, setLogger, sortEntries, threeWayMerge };
6522
6875
  //# sourceMappingURL=index.js.map
6523
6876
  //# sourceMappingURL=index.js.map