@im-nobsidian/core 0.1.6 → 0.1.8

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
@@ -791,7 +813,8 @@ var NotionClient = class _NotionClient {
791
813
  constructor(options) {
792
814
  this.client = new Client({
793
815
  auth: options.token,
794
- timeoutMs: options.timeoutMs ?? 3e4
816
+ timeoutMs: options.timeoutMs ?? 3e4,
817
+ logLevel: LogLevel.ERROR
795
818
  });
796
819
  this.token = options.token;
797
820
  this.sema = new Sema(options.concurrency ?? 3);
@@ -1072,6 +1095,30 @@ var NotionClient = class _NotionClient {
1072
1095
  nextCursor: response.next_cursor
1073
1096
  };
1074
1097
  }
1098
+ async searchRecentPages(since) {
1099
+ const results = [];
1100
+ let cursor;
1101
+ const sinceDate = new Date(since);
1102
+ outer: do {
1103
+ const response = await this.withRateLimit(
1104
+ () => this.client.search({
1105
+ filter: { property: "object", value: "page" },
1106
+ sort: { direction: "descending", timestamp: "last_edited_time" },
1107
+ start_cursor: cursor,
1108
+ page_size: 100
1109
+ })
1110
+ );
1111
+ for (const page of response.results) {
1112
+ const p = page;
1113
+ if (new Date(p.last_edited_time) <= sinceDate) {
1114
+ break outer;
1115
+ }
1116
+ results.push({ id: p.id, last_edited_time: p.last_edited_time });
1117
+ }
1118
+ cursor = response.next_cursor ?? void 0;
1119
+ } while (cursor);
1120
+ return results;
1121
+ }
1075
1122
  async getChildPages(parentId) {
1076
1123
  const blocks = await this.fetchAllChildrenDeep(parentId);
1077
1124
  const childPageBlocks = blocks.filter((b) => b.type === "child_page");
@@ -1080,8 +1127,8 @@ var NotionClient = class _NotionClient {
1080
1127
  return pages;
1081
1128
  }
1082
1129
  async getChildDatabaseIds(parentId) {
1083
- const blocks = await this.fetchAllChildrenDeep(parentId);
1084
- return blocks.filter((b) => b.type === "child_database").map((b) => b.id);
1130
+ const children = await this.fetchAllChildren(parentId);
1131
+ return children.filter((b) => b.type === "child_database").map((b) => b.id);
1085
1132
  }
1086
1133
  async getChildPagesRecursive(parentId) {
1087
1134
  const all = [];
@@ -1629,6 +1676,52 @@ var ChangeDetector = class {
1629
1676
  this.stateDb = stateDb;
1630
1677
  }
1631
1678
  stateDb;
1679
+ async detectLocalChangesFast(stats, readFile3) {
1680
+ const changes = [];
1681
+ const existingPaths = /* @__PURE__ */ new Set();
1682
+ for (const file of stats) {
1683
+ existingPaths.add(file.path);
1684
+ const record = this.stateDb.getByPath(file.path);
1685
+ if (!record) {
1686
+ const content2 = await readFile3(file.path);
1687
+ changes.push({
1688
+ path: file.path,
1689
+ type: "created",
1690
+ currentHash: computeHash(content2),
1691
+ previousHash: null
1692
+ });
1693
+ continue;
1694
+ }
1695
+ if (record.localMtime === file.mtime && record.localFileSize === file.size) {
1696
+ continue;
1697
+ }
1698
+ const content = await readFile3(file.path);
1699
+ const currentHash = computeHash(content);
1700
+ if (record.contentHash !== currentHash) {
1701
+ changes.push({
1702
+ path: file.path,
1703
+ type: "modified",
1704
+ currentHash,
1705
+ previousHash: record.contentHash
1706
+ });
1707
+ }
1708
+ }
1709
+ const syncedRecords = this.stateDb.getByStatus("synced");
1710
+ for (const record of syncedRecords) {
1711
+ if (!existingPaths.has(record.obsidianPath)) {
1712
+ if (record.fileType === "folder-note" || record.fileType === "folder-only") {
1713
+ if (!record.obsidianPath.endsWith(".md")) continue;
1714
+ }
1715
+ changes.push({
1716
+ path: record.obsidianPath,
1717
+ type: "deleted",
1718
+ currentHash: "",
1719
+ previousHash: record.contentHash
1720
+ });
1721
+ }
1722
+ }
1723
+ return this.detectMoves(changes);
1724
+ }
1632
1725
  detectLocalChanges(currentFiles) {
1633
1726
  const changes = [];
1634
1727
  const existingPaths = /* @__PURE__ */ new Set();
@@ -1644,7 +1737,7 @@ var ChangeDetector = class {
1644
1737
  });
1645
1738
  continue;
1646
1739
  }
1647
- if (record.localLastModified && file.mtime === record.localLastModified) {
1740
+ if (record.localMtime && record.localMtime === file.mtime) {
1648
1741
  continue;
1649
1742
  }
1650
1743
  const currentHash = computeHash(file.content);
@@ -3215,6 +3308,7 @@ var ImageHandler = class {
3215
3308
  const url = match[2];
3216
3309
  const caption = match[1]?.trim() || "file";
3217
3310
  const download = await this.downloadFile(url, pageTitle, caption);
3311
+ if (!download.localPath) continue;
3218
3312
  downloads.push(download);
3219
3313
  const obsidianLink = `[[${download.localPath}|${caption}]]`;
3220
3314
  result = result.replace(match[0], obsidianLink);
@@ -3236,6 +3330,7 @@ var ImageHandler = class {
3236
3330
  if (!realUrl) continue;
3237
3331
  const caption = fileName || match[1]?.trim() || "file";
3238
3332
  const download = await this.downloadFile(realUrl, pageTitle, caption);
3333
+ if (!download.localPath) continue;
3239
3334
  downloads.push(download);
3240
3335
  const obsidianLink = `[[${download.localPath}|${caption}]]`;
3241
3336
  result = result.replace(match[0], obsidianLink);
@@ -3251,6 +3346,7 @@ var ImageHandler = class {
3251
3346
  const url = match[1];
3252
3347
  const caption = match[2]?.trim() || "file";
3253
3348
  const download = await this.downloadFile(url, pageTitle, caption);
3349
+ if (!download.localPath) continue;
3254
3350
  downloads.push(download);
3255
3351
  const obsidianLink = `[[${download.localPath}|${caption}]]`;
3256
3352
  result = result.replace(match[0], obsidianLink);
@@ -3272,6 +3368,7 @@ var ImageHandler = class {
3272
3368
  if (!realUrl) continue;
3273
3369
  const caption = fileName || match[2]?.trim() || "file";
3274
3370
  const download = await this.downloadFile(realUrl, pageTitle, caption);
3371
+ if (!download.localPath) continue;
3275
3372
  downloads.push(download);
3276
3373
  const obsidianLink = `[[${download.localPath}|${caption}]]`;
3277
3374
  result = result.replace(match[0], obsidianLink);
@@ -3311,6 +3408,13 @@ var ImageHandler = class {
3311
3408
  if (!response.ok) {
3312
3409
  throw new Error(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
3313
3410
  }
3411
+ const contentLength = response.headers.get("content-length");
3412
+ const MAX_FILE_SIZE = 100 * 1024 * 1024;
3413
+ if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
3414
+ const sizeMB = Math.round(parseInt(contentLength, 10) / 1024 / 1024);
3415
+ getLogger().debug(`[Im-Nobsidian] \uD30C\uC77C \uC2A4\uD0B5 \u2014 \uB108\uBB34 \uD07C (${sizeMB}MB): ${caption}`);
3416
+ return { originalUrl: url, localPath: "", hash: "", size: 0 };
3417
+ }
3314
3418
  const buffer = Buffer.from(await response.arrayBuffer());
3315
3419
  const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 12);
3316
3420
  const extension = this.guessFileExtension(url, response.headers.get("content-type"));
@@ -3469,13 +3573,12 @@ var FileHandler = class {
3469
3573
  if (folderFiles.length === 0) return [];
3470
3574
  const results = [];
3471
3575
  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
- }
3576
+ const existing = this.stateDb.getFileRegistry(file.path);
3577
+ if (existing) {
3578
+ if (existing.fileSize === file.size) continue;
3579
+ const buffer = await this.vaultFs.readBinary(file.path);
3580
+ const currentHash = createHash("sha256").update(buffer).digest("hex");
3581
+ if (existing.fileHash === currentHash) continue;
3479
3582
  }
3480
3583
  await this.sema.acquire();
3481
3584
  try {
@@ -3504,19 +3607,20 @@ var FileHandler = class {
3504
3607
  const folderRecord = folderPath ? this.stateDb.getByPath(folderPath) : this.stateDb.getByPath("__root__");
3505
3608
  const folderPageId = folderRecord?.notionPageId;
3506
3609
  if (!folderPageId) {
3507
- getLogger().warn(
3508
- `\uD3F4\uB354 \uD398\uC774\uC9C0 \uC5C6\uC74C, \uD30C\uC77C \uC2A4\uD0B5: ${folderPath || "(\uB8E8\uD2B8)"} (${files.length}\uAC1C)`
3509
- );
3610
+ if (files.length > 0) {
3611
+ getLogger().debug(
3612
+ `\uD3F4\uB354 \uD398\uC774\uC9C0 \uC5C6\uC74C, \uD30C\uC77C \uC2A4\uD0B5: ${folderPath || "(\uB8E8\uD2B8)"} (${files.length}\uAC1C)`
3613
+ );
3614
+ }
3510
3615
  continue;
3511
3616
  }
3512
3617
  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
- }
3618
+ const existing = this.stateDb.getFileRegistry(file.path);
3619
+ if (existing) {
3620
+ if (existing.fileSize === file.size) continue;
3621
+ const buffer = await this.vaultFs.readBinary(file.path);
3622
+ const currentHash = createHash("sha256").update(buffer).digest("hex");
3623
+ if (existing.fileHash === currentHash) continue;
3520
3624
  }
3521
3625
  await this.sema.acquire();
3522
3626
  try {
@@ -4209,13 +4313,21 @@ var DatabaseSyncer = class {
4209
4313
  });
4210
4314
  }
4211
4315
  }
4212
- getLogger().info(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
4316
+ getLogger().debug(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
4213
4317
  return { created, updated, failed };
4214
4318
  }
4215
- async pullDatabaseViews(dbConfig) {
4319
+ async pullDatabaseViews(dbConfig, force = false) {
4216
4320
  try {
4217
- const viewsConfig = await this.notionClient.getDatabaseViewsConfig(dbConfig.databaseId);
4218
4321
  const configPath = ".im-nobsidian/db-views.json";
4322
+ if (!force) {
4323
+ try {
4324
+ const existing = await this.vaultFs.readFile(configPath);
4325
+ const parsed = JSON.parse(existing);
4326
+ if (parsed[dbConfig.databaseId]) return;
4327
+ } catch {
4328
+ }
4329
+ }
4330
+ const viewsConfig = await this.notionClient.getDatabaseViewsConfig(dbConfig.databaseId);
4219
4331
  await this.vaultFs.ensureFolder(".im-nobsidian");
4220
4332
  let allViewsConfigs = {};
4221
4333
  try {
@@ -4225,7 +4337,7 @@ var DatabaseSyncer = class {
4225
4337
  }
4226
4338
  allViewsConfigs[dbConfig.databaseId] = viewsConfig;
4227
4339
  await this.vaultFs.writeFile(configPath, JSON.stringify(allViewsConfigs, null, 2));
4228
- getLogger().info(
4340
+ getLogger().debug(
4229
4341
  `[DB Sync] \uBDF0 \uC124\uC815 ${viewsConfig.views.length}\uAC1C \uC800\uC7A5: ${dbConfig.databaseId}`
4230
4342
  );
4231
4343
  } catch (error) {
@@ -4267,15 +4379,32 @@ var DatabaseSyncer = class {
4267
4379
  } catch {
4268
4380
  }
4269
4381
  if (this.config.conversion.imageDownload === "immediate" && markdown) {
4270
- const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
4271
- markdown = imageResult.content;
4382
+ try {
4383
+ const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
4384
+ markdown = imageResult.content;
4385
+ } catch (error) {
4386
+ getLogger().warn(`[DB Sync] \uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328 (${title}):`, error);
4387
+ }
4272
4388
  }
4273
4389
  if (markdown) {
4274
- const fileResult = await this.imageHandler.downloadAllFiles(markdown, title);
4275
- markdown = fileResult.content;
4390
+ try {
4391
+ const fileResult = await this.imageHandler.downloadAllFiles(markdown, title);
4392
+ markdown = fileResult.content;
4393
+ } catch (error) {
4394
+ getLogger().warn(`[DB Sync] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328 (${title}):`, error);
4395
+ }
4276
4396
  }
4277
4397
  const existingRecord = this.stateDb.getByNotionId(page.id);
4278
- const filePath = existingRecord?.obsidianPath ?? `${dbConfig.localFolder}/${safeName}.md`;
4398
+ let filePath;
4399
+ if (existingRecord?.obsidianPath) {
4400
+ filePath = existingRecord.obsidianPath;
4401
+ } else {
4402
+ filePath = `${dbConfig.localFolder}/${safeName}.md`;
4403
+ const existingByPath = this.stateDb.getByPath(filePath);
4404
+ if (existingByPath && existingByPath.notionPageId !== page.id) {
4405
+ filePath = `${dbConfig.localFolder}/${safeName} (${page.id.slice(0, 8)}).md`;
4406
+ }
4407
+ }
4279
4408
  const finalContent = this.pipeline.convertToMarkdown(
4280
4409
  markdown,
4281
4410
  {
@@ -4384,7 +4513,7 @@ var DatabaseSyncer = class {
4384
4513
  });
4385
4514
  }
4386
4515
  }
4387
- getLogger().info(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
4516
+ getLogger().debug(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
4388
4517
  return { created, updated, failed };
4389
4518
  }
4390
4519
  };
@@ -4443,21 +4572,28 @@ var SyncOrchestrator = class {
4443
4572
  databaseSyncer;
4444
4573
  propertyMapper;
4445
4574
  dbSchemaLoaded = false;
4575
+ _pullImageCount = 0;
4576
+ _pullFileCount = 0;
4446
4577
  async push(options) {
4447
4578
  const startTime = Date.now();
4448
4579
  if (this.config.sync.direction === "pull") {
4449
4580
  return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
4450
4581
  }
4451
4582
  this.cleanupInterruptedSync();
4452
- const files = await this.vaultFs.listMarkdownFiles();
4453
- const changes = this.changeDetector.detectLocalChanges(files);
4583
+ this.repairFolderRecords();
4584
+ const stats = await this.vaultFs.listMarkdownFileStats();
4585
+ const changes = await this.changeDetector.detectLocalChangesFast(
4586
+ stats,
4587
+ (path) => this.vaultFs.readFile(path)
4588
+ );
4454
4589
  const conflictPaths = new Set(this.stateDb.getByStatus("conflict").map((r) => r.obsidianPath));
4455
4590
  const eligible = options?.force ? changes : changes.filter((c) => !conflictPaths.has(c.path));
4456
4591
  const dbFolders = (this.config.notion.databases ?? []).map(
4457
4592
  (d) => d.localFolder.endsWith("/") ? d.localFolder : d.localFolder + "/"
4458
4593
  );
4459
4594
  const isDbPath = (path) => dbFolders.some((prefix) => path.startsWith(prefix));
4460
- const filtered = (options?.paths ? eligible.filter((c) => options.paths.some((p) => c.path.startsWith(p))) : eligible).filter((c) => !isDbPath(c.path));
4595
+ const excludeSet = options?.excludePaths ? new Set(options.excludePaths) : null;
4596
+ 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)));
4461
4597
  const hasDbConfigs = (this.config.notion.databases?.length ?? 0) > 0;
4462
4598
  if (filtered.length === 0 && !hasDbConfigs) {
4463
4599
  return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
@@ -4466,6 +4602,12 @@ var SyncOrchestrator = class {
4466
4602
  const dryCreated = filtered.filter((c) => c.type === "created").length;
4467
4603
  const dryUpdated = filtered.filter((c) => c.type === "modified" || c.type === "moved").length;
4468
4604
  const dryDeleted = filtered.filter((c) => c.type === "deleted").length;
4605
+ let dryProgress = 0;
4606
+ const dryTotal = filtered.length;
4607
+ for (const change of filtered) {
4608
+ const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
4609
+ options?.onProgress?.(++dryProgress, dryTotal, { path: change.path, operation: op });
4610
+ }
4469
4611
  return {
4470
4612
  created: dryCreated,
4471
4613
  updated: dryUpdated,
@@ -4500,7 +4642,8 @@ var SyncOrchestrator = class {
4500
4642
  const tasks = filtered.map((change) => async () => {
4501
4643
  await sema.acquire();
4502
4644
  try {
4503
- options?.onProgress?.(++completed, total, change.path);
4645
+ const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
4646
+ options?.onProgress?.(++completed, total, { path: change.path, operation: op });
4504
4647
  switch (change.type) {
4505
4648
  case "created":
4506
4649
  await this.pushCreate(change.path);
@@ -4604,7 +4747,10 @@ var SyncOrchestrator = class {
4604
4747
  conflicts: [],
4605
4748
  writtenPaths: [],
4606
4749
  failed: [],
4607
- duration: Date.now() - startTime
4750
+ duration: Date.now() - startTime,
4751
+ imageCount: 0,
4752
+ fileCount: 0,
4753
+ linkCount: 0
4608
4754
  };
4609
4755
  if (this.config.sync.direction === "push") {
4610
4756
  return emptyResult;
@@ -4613,14 +4759,42 @@ var SyncOrchestrator = class {
4613
4759
  let created = 0;
4614
4760
  let updated = 0;
4615
4761
  let deleted = 0;
4762
+ this._pullImageCount = 0;
4763
+ this._pullFileCount = 0;
4616
4764
  const conflicts = [];
4617
4765
  const writtenPaths = [];
4618
4766
  const failed = [];
4619
- const remoteChanges = await this.detectRemoteChanges();
4767
+ const lastPull = this.stateDb.getMeta("last_pull_at") ?? this.stateDb.getMeta("last_sync_at");
4768
+ const trackedCount = this.stateDb.getAll().length;
4769
+ const remoteChanges = !this.isDatabaseMode && lastPull && trackedCount > 0 && !options?.force ? await this.detectRemoteChangesIncremental(lastPull) : await this.detectRemoteChanges();
4620
4770
  const filtered = options?.paths ? remoteChanges.filter((c) => {
4621
4771
  const record = this.stateDb.getByNotionId(c.pageId);
4622
4772
  return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
4623
4773
  }) : remoteChanges;
4774
+ if (filtered.length === 0 && (this.config.notion.databases?.length ?? 0) === 0) {
4775
+ const hasDbResults = await this.pullDiscoveredDatabases(writtenPaths, failed);
4776
+ if (!hasDbResults.created && !hasDbResults.updated) {
4777
+ this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
4778
+ this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
4779
+ this.stateDb.setMeta("pull_in_progress", "");
4780
+ return emptyResult;
4781
+ }
4782
+ this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
4783
+ this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
4784
+ this.stateDb.setMeta("pull_in_progress", "");
4785
+ return {
4786
+ created: hasDbResults.created,
4787
+ updated: hasDbResults.updated,
4788
+ deleted: 0,
4789
+ conflicts: [],
4790
+ writtenPaths,
4791
+ failed,
4792
+ duration: Date.now() - startTime,
4793
+ imageCount: 0,
4794
+ fileCount: 0,
4795
+ linkCount: 0
4796
+ };
4797
+ }
4624
4798
  if (filtered.length === 0) {
4625
4799
  return emptyResult;
4626
4800
  }
@@ -4628,6 +4802,14 @@ var SyncOrchestrator = class {
4628
4802
  const dryCreated = filtered.filter((c) => c.type === "created").length;
4629
4803
  const dryUpdated = filtered.filter((c) => c.type === "modified").length;
4630
4804
  const dryDeleted = filtered.filter((c) => c.type === "deleted").length;
4805
+ let dryProgress = 0;
4806
+ const dryTotal = filtered.length;
4807
+ for (const change of filtered) {
4808
+ const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
4809
+ const record = this.stateDb.getByNotionId(change.pageId);
4810
+ const displayPath = record?.obsidianPath ?? change.pageId;
4811
+ options?.onProgress?.(++dryProgress, dryTotal, { path: displayPath, operation: op });
4812
+ }
4631
4813
  return {
4632
4814
  created: dryCreated,
4633
4815
  updated: dryUpdated,
@@ -4635,7 +4817,10 @@ var SyncOrchestrator = class {
4635
4817
  conflicts: [],
4636
4818
  writtenPaths: [],
4637
4819
  failed: [],
4638
- duration: Date.now() - startTime
4820
+ duration: Date.now() - startTime,
4821
+ imageCount: 0,
4822
+ fileCount: 0,
4823
+ linkCount: 0
4639
4824
  };
4640
4825
  }
4641
4826
  this.stateDb.setMeta("pull_in_progress", "true");
@@ -4645,12 +4830,13 @@ var SyncOrchestrator = class {
4645
4830
  const tasks = filtered.map((change) => async () => {
4646
4831
  await sema.acquire();
4647
4832
  try {
4648
- options?.onProgress?.(++pullCompleted, pullTotal, change.pageId);
4833
+ let resultPath;
4649
4834
  switch (change.type) {
4650
4835
  case "created": {
4651
4836
  const path = await this.pullCreate(change.pageId);
4652
4837
  writtenPaths.push(path);
4653
4838
  created++;
4839
+ resultPath = path;
4654
4840
  break;
4655
4841
  }
4656
4842
  case "modified": {
@@ -4661,16 +4847,24 @@ var SyncOrchestrator = class {
4661
4847
  writtenPaths.push(result.path);
4662
4848
  updated++;
4663
4849
  }
4850
+ resultPath = result.path;
4664
4851
  break;
4665
4852
  }
4666
4853
  case "deleted": {
4854
+ const record2 = this.stateDb.getByNotionId(change.pageId);
4855
+ resultPath = record2?.obsidianPath;
4667
4856
  const path = await this.pullDelete(change.pageId);
4668
4857
  if (path) {
4669
4858
  deleted++;
4859
+ resultPath = path;
4670
4860
  }
4671
4861
  break;
4672
4862
  }
4673
4863
  }
4864
+ const record = this.stateDb.getByNotionId(change.pageId);
4865
+ const displayPath = resultPath ?? record?.obsidianPath ?? change.pageId;
4866
+ const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
4867
+ options?.onProgress?.(++pullCompleted, pullTotal, { path: displayPath, operation: op });
4674
4868
  } catch (error) {
4675
4869
  const record = this.stateDb.getByNotionId(change.pageId);
4676
4870
  failed.push({
@@ -4745,44 +4939,13 @@ var SyncOrchestrator = class {
4745
4939
  getLogger().warn("[Im-Nobsidian] DB Pull \uC911 \uC624\uB958:", error);
4746
4940
  }
4747
4941
  }
4748
- if (!this.isDatabaseMode) {
4749
- try {
4750
- const discoveredDbs = await this.discoverChildDatabases();
4751
- const configuredIds = new Set(
4752
- (this.config.notion.databases ?? []).map((d) => d.databaseId.replace(/-/g, ""))
4753
- );
4754
- const newDbs = discoveredDbs.filter((db) => !configuredIds.has(db.dbId.replace(/-/g, "")));
4755
- for (const { dbId, parentPageId } of newDbs) {
4756
- try {
4757
- const dbTitle = await this.notionClient.getDatabaseTitle(dbId);
4758
- const safeName = (dbTitle || dbId.slice(0, 8)).replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-");
4759
- const parentEntry = parentPageId ? this.stateDb.getByNotionId(parentPageId) : null;
4760
- let parentFolder = "databases";
4761
- if (parentEntry?.obsidianPath) {
4762
- const parts = parentEntry.obsidianPath.split("/");
4763
- parts.pop();
4764
- parentFolder = parts.length > 0 ? parts.join("/") : "databases";
4765
- }
4766
- const folderName = `${parentFolder}/${safeName}`;
4767
- const dbConfig = {
4768
- databaseId: dbId,
4769
- localFolder: folderName,
4770
- titleProperty: "Name"
4771
- };
4772
- const dbResult = await this.databaseSyncer.pullDatabase(dbConfig);
4773
- created += dbResult.created;
4774
- updated += dbResult.updated;
4775
- failed.push(...dbResult.failed);
4776
- } catch (error) {
4777
- getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
4778
- }
4779
- }
4780
- } catch (error) {
4781
- getLogger().warn("[Im-Nobsidian] child_database \uC790\uB3D9 \uBC1C\uACAC \uC2E4\uD328:", error);
4782
- }
4942
+ {
4943
+ const dbDiscovery = await this.pullDiscoveredDatabases(writtenPaths, failed);
4944
+ created += dbDiscovery.created;
4945
+ updated += dbDiscovery.updated;
4783
4946
  }
4784
- const allSyncedMdPaths = this.stateDb.getAll().filter((r) => r.obsidianPath?.endsWith(".md")).map((r) => r.obsidianPath);
4785
- await this.resolveNotionLinks(allSyncedMdPaths);
4947
+ const linkTargetPaths = writtenPaths.length > 0 ? writtenPaths : [];
4948
+ const linkCount = linkTargetPaths.length > 0 ? await this.resolveNotionLinks(linkTargetPaths) : 0;
4786
4949
  this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
4787
4950
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
4788
4951
  this.stateDb.setMeta("pull_in_progress", "");
@@ -4793,7 +4956,10 @@ var SyncOrchestrator = class {
4793
4956
  conflicts,
4794
4957
  writtenPaths,
4795
4958
  failed,
4796
- duration: Date.now() - startTime
4959
+ duration: Date.now() - startTime,
4960
+ imageCount: this._pullImageCount,
4961
+ fileCount: this._pullFileCount,
4962
+ linkCount
4797
4963
  };
4798
4964
  }
4799
4965
  async sync(options) {
@@ -4801,7 +4967,8 @@ var SyncOrchestrator = class {
4801
4967
  const pullResult = await this.pull(options);
4802
4968
  const pushResult = await this.push({
4803
4969
  ...options,
4804
- paths: options?.paths
4970
+ paths: options?.paths,
4971
+ excludePaths: pullResult.writtenPaths
4805
4972
  });
4806
4973
  return {
4807
4974
  pull: pullResult,
@@ -4830,6 +4997,34 @@ var SyncOrchestrator = class {
4830
4997
  lastSyncAt
4831
4998
  };
4832
4999
  }
5000
+ async statusLocal() {
5001
+ const stats = await this.vaultFs.listMarkdownFileStats();
5002
+ const localChanges = await this.changeDetector.detectLocalChangesFast(
5003
+ stats,
5004
+ (path) => this.vaultFs.readFile(path)
5005
+ );
5006
+ const conflictRecords = this.stateDb.getByStatus("conflict");
5007
+ const lastSyncAt = this.stateDb.getMeta("last_sync_at");
5008
+ return {
5009
+ localChanges,
5010
+ remoteChanges: [],
5011
+ conflicts: [],
5012
+ conflictRecords,
5013
+ pendingOperations: conflictRecords.length,
5014
+ lastSyncAt
5015
+ };
5016
+ }
5017
+ async fetch() {
5018
+ const startTime = Date.now();
5019
+ const changes = await this.detectRemoteChanges();
5020
+ this.stateDb.setMeta("last_fetch_at", (/* @__PURE__ */ new Date()).toISOString());
5021
+ return {
5022
+ newPages: changes.filter((c) => c.type === "created").length,
5023
+ modifiedPages: changes.filter((c) => c.type === "modified").length,
5024
+ deletedPages: changes.filter((c) => c.type === "deleted").length,
5025
+ duration: Date.now() - startTime
5026
+ };
5027
+ }
4833
5028
  async buildConflictsFromRecords(records, localChanges, remoteChanges) {
4834
5029
  const conflicts = [];
4835
5030
  for (const record of records) {
@@ -4872,27 +5067,100 @@ var SyncOrchestrator = class {
4872
5067
  return this.config.notion.parentMode === "database" && !!this.config.notion.databaseId;
4873
5068
  }
4874
5069
  async discoverChildDatabases() {
4875
- const rootId = this.config.notion.rootPageId;
4876
5070
  const allDbs = [];
4877
- let currentLevel = [rootId];
4878
- while (currentLevel.length > 0) {
4879
- const nextLevel = [];
4880
- for (const parentId of currentLevel) {
5071
+ const trackedPages = this.stateDb.getAll().filter((r) => r.notionPageId);
5072
+ const pageIds = [this.config.notion.rootPageId, ...trackedPages.map((r) => r.notionPageId)];
5073
+ const seen = /* @__PURE__ */ new Set();
5074
+ for (const parentId of pageIds) {
5075
+ if (seen.has(parentId)) continue;
5076
+ seen.add(parentId);
5077
+ try {
5078
+ const dbIds = await this.notionClient.getChildDatabaseIds(parentId);
5079
+ for (const dbId of dbIds) {
5080
+ allDbs.push({ dbId, parentPageId: parentId });
5081
+ }
5082
+ } catch {
5083
+ }
5084
+ }
5085
+ return allDbs;
5086
+ }
5087
+ async pullDiscoveredDatabases(writtenPaths, failed) {
5088
+ if (this.isDatabaseMode) return { created: 0, updated: 0 };
5089
+ let created = 0;
5090
+ let updated = 0;
5091
+ try {
5092
+ const cachedRaw = this.stateDb.getMeta("discovered_dbs");
5093
+ let dbConfigs = [];
5094
+ if (cachedRaw) {
4881
5095
  try {
4882
- const dbIds = await this.notionClient.getChildDatabaseIds(parentId);
4883
- for (const dbId of dbIds) {
4884
- allDbs.push({ dbId, parentPageId: parentId });
4885
- }
4886
- const childPages = await this.notionClient.getChildPages(parentId);
4887
- nextLevel.push(...childPages.map((p) => p.id));
5096
+ dbConfigs = JSON.parse(cachedRaw);
4888
5097
  } catch {
5098
+ dbConfigs = [];
4889
5099
  }
4890
5100
  }
4891
- currentLevel = nextLevel;
5101
+ if (dbConfigs.length === 0) {
5102
+ const discoveredDbs = await this.discoverChildDatabases();
5103
+ const configuredIds = new Set(
5104
+ (this.config.notion.databases ?? []).map((d) => d.databaseId.replace(/-/g, ""))
5105
+ );
5106
+ const newDbs = discoveredDbs.filter((db) => !configuredIds.has(db.dbId.replace(/-/g, "")));
5107
+ for (const { dbId, parentPageId } of newDbs) {
5108
+ try {
5109
+ const dbTitle = await this.notionClient.getDatabaseTitle(dbId);
5110
+ const parentEntry = parentPageId ? this.stateDb.getByNotionId(parentPageId) : null;
5111
+ let parentFolder = "";
5112
+ if (parentEntry?.obsidianPath) {
5113
+ const obsPath = parentEntry.obsidianPath;
5114
+ if (obsPath.endsWith(".md")) {
5115
+ const parts = obsPath.split("/");
5116
+ parts.pop();
5117
+ parentFolder = parts.length > 0 ? parts.join("/") : obsPath.replace(/\.md$/, "");
5118
+ } else {
5119
+ parentFolder = obsPath;
5120
+ }
5121
+ }
5122
+ let safeName;
5123
+ if (dbTitle) {
5124
+ safeName = dbTitle.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-");
5125
+ } else {
5126
+ const parentName = parentFolder.split("/").pop() || "";
5127
+ safeName = parentName ? `${parentName}-DB` : `db-${dbId.slice(0, 8)}`;
5128
+ }
5129
+ if (!parentFolder) parentFolder = "databases";
5130
+ dbConfigs.push({
5131
+ databaseId: dbId,
5132
+ localFolder: `${parentFolder}/${safeName}`,
5133
+ titleProperty: "Name"
5134
+ });
5135
+ } catch (error) {
5136
+ getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
5137
+ }
5138
+ }
5139
+ if (dbConfigs.length > 0) {
5140
+ this.stateDb.setMeta("discovered_dbs", JSON.stringify(dbConfigs));
5141
+ }
5142
+ }
5143
+ for (const dbConfig of dbConfigs) {
5144
+ try {
5145
+ const dbResult = await this.databaseSyncer.pullDatabase(dbConfig);
5146
+ created += dbResult.created;
5147
+ updated += dbResult.updated;
5148
+ failed.push(...dbResult.failed);
5149
+ if (dbResult.created + dbResult.updated > 0) {
5150
+ const dbPaths = this.stateDb.getByStatus("synced").filter((r) => r.fileType === "db-row").map((r) => r.obsidianPath);
5151
+ writtenPaths.push(...dbPaths.slice(-dbResult.created - dbResult.updated));
5152
+ }
5153
+ } catch (error) {
5154
+ getLogger().warn(`[Im-Nobsidian] DB ${dbConfig.databaseId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
5155
+ }
5156
+ }
5157
+ } catch (error) {
5158
+ getLogger().warn("[Im-Nobsidian] child_database \uC790\uB3D9 \uBC1C\uACAC \uC2E4\uD328:", error);
4892
5159
  }
4893
- return allDbs;
5160
+ return { created, updated };
4894
5161
  }
4895
5162
  async resolveNotionLinks(paths) {
5163
+ let totalResolved = 0;
4896
5164
  const allRecords = this.stateDb.getAll();
4897
5165
  const idToTitle = /* @__PURE__ */ new Map();
4898
5166
  for (const r of allRecords) {
@@ -4903,7 +5171,7 @@ var SyncOrchestrator = class {
4903
5171
  idToTitle.set(r.notionPageId, title);
4904
5172
  }
4905
5173
  }
4906
- if (idToTitle.size === 0) return;
5174
+ if (idToTitle.size === 0) return 0;
4907
5175
  for (const filePath of paths) {
4908
5176
  if (!filePath.endsWith(".md")) continue;
4909
5177
  try {
@@ -4912,6 +5180,7 @@ var SyncOrchestrator = class {
4912
5180
  const resolved = content.replace(/\[\[notion:([a-f0-9-]+)\]\]/g, (_match, id) => {
4913
5181
  const title = idToTitle.get(id.replace(/-/g, ""));
4914
5182
  if (title) {
5183
+ totalResolved++;
4915
5184
  changed = true;
4916
5185
  return `[[${title}]]`;
4917
5186
  }
@@ -4923,6 +5192,7 @@ var SyncOrchestrator = class {
4923
5192
  (_match, text, id) => {
4924
5193
  const title = idToTitle.get(id);
4925
5194
  if (title) {
5195
+ totalResolved++;
4926
5196
  changed = true;
4927
5197
  return `[[${title}|${text}]]`;
4928
5198
  }
@@ -4936,6 +5206,7 @@ var SyncOrchestrator = class {
4936
5206
  } catch {
4937
5207
  }
4938
5208
  }
5209
+ return totalResolved;
4939
5210
  }
4940
5211
  async ensureDbSchema() {
4941
5212
  if (this.dbSchemaLoaded || !this.isDatabaseMode) return;
@@ -4980,6 +5251,7 @@ var SyncOrchestrator = class {
4980
5251
  );
4981
5252
  await this.imageHandler.uploadAndAppendImages(page.id, conversionResult.images);
4982
5253
  const hash = computeHash(content);
5254
+ const fileStat = await this.vaultFs.getFileStat(path);
4983
5255
  this.stateDb.transaction(() => {
4984
5256
  this.stateDb.upsert({
4985
5257
  obsidianPath: path,
@@ -4991,7 +5263,9 @@ var SyncOrchestrator = class {
4991
5263
  syncDirection: "both",
4992
5264
  fileType: this.isFolderNote(path) ? "folder-note" : "file",
4993
5265
  status: "synced",
4994
- baseSnapshot: Buffer.from(content, "utf-8")
5266
+ baseSnapshot: Buffer.from(content, "utf-8"),
5267
+ localMtime: fileStat?.mtime ?? null,
5268
+ localFileSize: fileStat?.size ?? null
4995
5269
  });
4996
5270
  const aliases = extractAliases2(conversionResult.properties);
4997
5271
  this.stateDb.upsertWikilink({
@@ -5021,23 +5295,36 @@ var SyncOrchestrator = class {
5021
5295
  });
5022
5296
  await this.pushUpdatePage(record.notionPageId, conversionResult.content, record.baseSnapshot);
5023
5297
  await this.imageHandler.uploadAndAppendImages(record.notionPageId, conversionResult.images);
5024
- let propsToUpdate = conversionResult.properties;
5025
- if (this.isDatabaseMode && propsToUpdate && Object.keys(propsToUpdate).length > 0) {
5298
+ let propsToUpdate;
5299
+ if (this.isDatabaseMode && conversionResult.properties && Object.keys(conversionResult.properties).length > 0) {
5026
5300
  await this.ensureDbSchema();
5027
5301
  const title2 = extractTitle(path);
5028
- propsToUpdate = this.propertyMapper.toNotionProperties(propsToUpdate, title2);
5302
+ propsToUpdate = this.propertyMapper.toNotionProperties(conversionResult.properties, title2);
5303
+ } else if (conversionResult.properties?.title) {
5304
+ const titleStr = String(conversionResult.properties.title);
5305
+ propsToUpdate = {
5306
+ title: { title: [{ text: { content: titleStr } }] }
5307
+ };
5029
5308
  }
5309
+ let lastEditedTime = (/* @__PURE__ */ new Date()).toISOString();
5030
5310
  if (propsToUpdate && Object.keys(propsToUpdate).length > 0) {
5031
- await this.notionClient.updatePageProperties(record.notionPageId, propsToUpdate);
5311
+ const updatedPage = await this.notionClient.updatePageProperties(
5312
+ record.notionPageId,
5313
+ propsToUpdate
5314
+ );
5315
+ lastEditedTime = updatedPage.last_edited_time;
5032
5316
  }
5033
- const updatedPage = await this.notionClient.getPage(record.notionPageId);
5034
5317
  const hash = computeHash(content);
5318
+ const fileStat = await this.vaultFs.getFileStat(path);
5035
5319
  const title = extractTitle(path);
5036
5320
  const aliases = extractAliases2(conversionResult.properties);
5037
5321
  this.stateDb.transaction(() => {
5038
5322
  this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
5039
5323
  this.stateDb.updateStatus(record.id, "synced");
5040
- this.stateDb.setNotionLastEdited(record.id, updatedPage.last_edited_time);
5324
+ this.stateDb.setNotionLastEdited(record.id, lastEditedTime);
5325
+ if (fileStat) {
5326
+ this.stateDb.updateStatCache(record.id, fileStat.mtime, fileStat.size);
5327
+ }
5041
5328
  this.stateDb.storePreserveMarkers(path, conversionResult.preserveMarkers);
5042
5329
  this.stateDb.upsertWikilink({
5043
5330
  obsidianPath: path,
@@ -5131,13 +5418,49 @@ var SyncOrchestrator = class {
5131
5418
  }
5132
5419
  return changes;
5133
5420
  }
5421
+ async detectRemoteChangesIncremental(since) {
5422
+ const changes = [];
5423
+ const recentPages = await this.notionClient.searchRecentPages(since);
5424
+ for (const page of recentPages) {
5425
+ const record = this.stateDb.getByNotionId(page.id);
5426
+ if (!record) {
5427
+ try {
5428
+ const fullPage = await this.notionClient.getPage(page.id);
5429
+ const parentId = await this.extractParentId(fullPage);
5430
+ if (parentId && this.isTrackedParent(parentId)) {
5431
+ changes.push({
5432
+ pageId: page.id,
5433
+ type: "created",
5434
+ lastEdited: page.last_edited_time,
5435
+ previousEdited: null
5436
+ });
5437
+ }
5438
+ } catch {
5439
+ }
5440
+ continue;
5441
+ }
5442
+ if (page.last_edited_time !== record.notionLastEdited) {
5443
+ changes.push({
5444
+ pageId: page.id,
5445
+ type: "modified",
5446
+ lastEdited: page.last_edited_time,
5447
+ previousEdited: record.notionLastEdited
5448
+ });
5449
+ }
5450
+ }
5451
+ return changes;
5452
+ }
5453
+ isTrackedParent(parentId) {
5454
+ if (notionIdsEqual(parentId, this.config.notion.rootPageId)) return true;
5455
+ return !!this.stateDb.getByNotionId(parentId);
5456
+ }
5134
5457
  async pullCreate(pageId) {
5135
5458
  const page = await this.notionClient.getPage(pageId);
5136
5459
  const title = this.notionClient.extractTitle(page);
5137
5460
  const safeName = sanitizeFileName(title);
5138
5461
  const parentPath = await this.resolveParentPath(page);
5139
- const allChildren = await this.notionClient.fetchAllChildrenDeep(pageId);
5140
- const hasChildPages = allChildren.some((b) => "type" in b && b.type === "child_page");
5462
+ const firstChildren = await this.notionClient.listChildren(pageId, { pageSize: 100 });
5463
+ const hasChildPages = firstChildren.results.some((b) => "type" in b && b.type === "child_page");
5141
5464
  const markdown = await this.fetchPageMarkdown(pageId);
5142
5465
  const hasContent = markdown.trim().length > 0;
5143
5466
  let filePath;
@@ -5171,9 +5494,11 @@ var SyncOrchestrator = class {
5171
5494
  if (this.config.conversion.imageDownload === "immediate") {
5172
5495
  const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
5173
5496
  processedMarkdown = imageResult.content;
5497
+ this._pullImageCount += imageResult.downloads.length;
5174
5498
  }
5175
5499
  const fileResult = await this.imageHandler.downloadAllFiles(processedMarkdown, title);
5176
5500
  processedMarkdown = fileResult.content;
5501
+ this._pullFileCount += fileResult.downloads.length;
5177
5502
  const finalContent = this.pipeline.convertToMarkdown(
5178
5503
  processedMarkdown,
5179
5504
  {
@@ -5186,6 +5511,7 @@ var SyncOrchestrator = class {
5186
5511
  );
5187
5512
  await this.vaultFs.writeFile(filePath, finalContent);
5188
5513
  const hash = computeHash(finalContent);
5514
+ const pullStat = await this.vaultFs.getFileStat(filePath);
5189
5515
  const resolvedParentId = await this.extractParentId(page);
5190
5516
  this.stateDb.transaction(() => {
5191
5517
  this.stateDb.upsert({
@@ -5198,7 +5524,9 @@ var SyncOrchestrator = class {
5198
5524
  syncDirection: "both",
5199
5525
  fileType,
5200
5526
  status: "synced",
5201
- baseSnapshot: Buffer.from(finalContent, "utf-8")
5527
+ baseSnapshot: Buffer.from(finalContent, "utf-8"),
5528
+ localMtime: pullStat?.mtime ?? null,
5529
+ localFileSize: pullStat?.size ?? null
5202
5530
  });
5203
5531
  const aliases = extractAliases2(properties);
5204
5532
  this.stateDb.upsertWikilink({
@@ -5229,9 +5557,11 @@ var SyncOrchestrator = class {
5229
5557
  if (this.config.conversion.imageDownload === "immediate") {
5230
5558
  const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
5231
5559
  markdown = imageResult.content;
5560
+ this._pullImageCount += imageResult.downloads.length;
5232
5561
  }
5233
5562
  const fileResult = await this.imageHandler.downloadAllFiles(markdown, title);
5234
5563
  markdown = fileResult.content;
5564
+ this._pullFileCount += fileResult.downloads.length;
5235
5565
  const savedMarkers = this.stateDb.getPreserveMarkers(record.obsidianPath);
5236
5566
  const remoteContent = this.pipeline.convertToMarkdown(
5237
5567
  markdown,
@@ -5274,6 +5604,7 @@ var SyncOrchestrator = class {
5274
5604
  }
5275
5605
  }
5276
5606
  await this.vaultFs.writeFile(record.obsidianPath, remoteContent);
5607
+ const updateStat = await this.vaultFs.getFileStat(record.obsidianPath);
5277
5608
  const newHash = computeHash(remoteContent);
5278
5609
  this.stateDb.upsert({
5279
5610
  obsidianPath: record.obsidianPath,
@@ -5285,7 +5616,9 @@ var SyncOrchestrator = class {
5285
5616
  syncDirection: record.syncDirection,
5286
5617
  fileType: record.fileType,
5287
5618
  status: "synced",
5288
- baseSnapshot: Buffer.from(remoteContent, "utf-8")
5619
+ baseSnapshot: Buffer.from(remoteContent, "utf-8"),
5620
+ localMtime: updateStat?.mtime ?? null,
5621
+ localFileSize: updateStat?.size ?? null
5289
5622
  });
5290
5623
  return { path: record.obsidianPath };
5291
5624
  }
@@ -5302,31 +5635,27 @@ var SyncOrchestrator = class {
5302
5635
  return record.obsidianPath;
5303
5636
  }
5304
5637
  async ensureFolderPage(folderPath) {
5305
- const existing = this.stateDb.getByPath(folderPath);
5306
- if (existing?.notionPageId) return;
5307
5638
  const folderNotePath = `${folderPath}/${folderPath.split("/").pop()}.md`;
5308
5639
  const folderNoteRecord = this.stateDb.getByPath(folderNotePath);
5309
- if (folderNoteRecord?.notionParentId) {
5310
- this.stateDb.upsert({
5311
- obsidianPath: folderPath,
5312
- notionPageId: folderNoteRecord.notionParentId,
5313
- notionParentId: "",
5314
- contentHash: "",
5315
- notionLastEdited: folderNoteRecord.notionLastEdited,
5316
- localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
5317
- syncDirection: "both",
5318
- fileType: "folder-note",
5319
- status: "synced"
5320
- });
5640
+ if (folderNoteRecord?.notionPageId) {
5641
+ const existing2 = this.stateDb.getByPath(folderPath);
5642
+ if (existing2) this.stateDb.delete(existing2.id);
5321
5643
  return;
5322
5644
  }
5645
+ const existing = this.stateDb.getByPath(folderPath);
5646
+ if (existing?.notionPageId) return;
5323
5647
  const parts = folderPath.split("/");
5324
5648
  const folderName = parts[parts.length - 1];
5325
5649
  let parentId = this.config.notion.rootPageId;
5326
5650
  if (parts.length > 1) {
5327
5651
  const parentPath = parts.slice(0, -1).join("/");
5652
+ await this.ensureFolderPage(parentPath);
5653
+ const parentNotePath = `${parentPath}/${parentPath.split("/").pop()}.md`;
5654
+ const parentNote = this.stateDb.getByPath(parentNotePath);
5328
5655
  const parentRecord = this.stateDb.getByPath(parentPath);
5329
- if (parentRecord?.notionPageId) {
5656
+ if (parentNote?.notionPageId) {
5657
+ parentId = parentNote.notionPageId;
5658
+ } else if (parentRecord?.notionPageId) {
5330
5659
  parentId = parentRecord.notionPageId;
5331
5660
  }
5332
5661
  }
@@ -5353,8 +5682,26 @@ var SyncOrchestrator = class {
5353
5682
  const folderPath = parts.slice(0, -1).join("/");
5354
5683
  const folderRecord = this.stateDb.getByPath(folderPath);
5355
5684
  if (folderRecord?.notionPageId) return folderRecord.notionPageId;
5685
+ const folderName = parts[parts.length - 2];
5686
+ const folderNotePath = `${folderPath}/${folderName}.md`;
5687
+ const folderNoteRecord = this.stateDb.getByPath(folderNotePath);
5688
+ if (folderNoteRecord?.notionPageId) return folderNoteRecord.notionPageId;
5356
5689
  return this.config.notion.rootPageId;
5357
5690
  }
5691
+ repairFolderRecords() {
5692
+ const allRecords = this.stateDb.getAll();
5693
+ const folderRecords = allRecords.filter(
5694
+ (r) => r.fileType === "folder-note" && !r.obsidianPath.endsWith(".md")
5695
+ );
5696
+ for (const folder of folderRecords) {
5697
+ const folderName = folder.obsidianPath.split("/").pop();
5698
+ const folderNotePath = `${folder.obsidianPath}/${folderName}.md`;
5699
+ const noteRecord = this.stateDb.getByPath(folderNotePath);
5700
+ if (noteRecord?.notionPageId) {
5701
+ this.stateDb.delete(folder.id);
5702
+ }
5703
+ }
5704
+ }
5358
5705
  isFolderNote(filePath) {
5359
5706
  const parts = filePath.split("/");
5360
5707
  if (parts.length < 2) return false;
@@ -5389,17 +5736,14 @@ var SyncOrchestrator = class {
5389
5736
  }
5390
5737
  async pushCreatePage(parentId, parentType, title, markdownContent, properties) {
5391
5738
  if (this.config.conversion.preferMarkdownApi !== false) {
5392
- try {
5393
- const enhanced = obsidianToNotionEnhanced(markdownContent);
5394
- return await this.notionClient.createPageWithMarkdown({
5395
- parentId,
5396
- parentType,
5397
- title,
5398
- markdown: enhanced,
5399
- properties
5400
- });
5401
- } catch {
5402
- }
5739
+ const enhanced = obsidianToNotionEnhanced(markdownContent);
5740
+ return await this.notionClient.createPageWithMarkdown({
5741
+ parentId,
5742
+ parentType,
5743
+ title,
5744
+ markdown: enhanced,
5745
+ properties
5746
+ });
5403
5747
  }
5404
5748
  const blocks = this.blockConverter.markdownToNotionBlocks(markdownContent);
5405
5749
  const page = await this.notionClient.createPage({
@@ -5413,27 +5757,11 @@ var SyncOrchestrator = class {
5413
5757
  }
5414
5758
  return page;
5415
5759
  }
5416
- async pushUpdatePage(pageId, markdownContent, baseSnapshot) {
5760
+ async pushUpdatePage(pageId, markdownContent, _baseSnapshot) {
5417
5761
  if (this.config.conversion.preferMarkdownApi !== false) {
5418
- try {
5419
- const enhanced = obsidianToNotionEnhanced(markdownContent);
5420
- if (baseSnapshot) {
5421
- const baseEnhanced = obsidianToNotionEnhanced(baseSnapshot.toString("utf-8"));
5422
- const patches = this.computePatches(baseEnhanced, enhanced);
5423
- if (patches.length > 0 && patches.length <= 20) {
5424
- try {
5425
- await this.notionClient.updatePageMarkdownPartial(pageId, patches);
5426
- return;
5427
- } catch {
5428
- await this.notionClient.replacePageMarkdown(pageId, enhanced);
5429
- return;
5430
- }
5431
- }
5432
- }
5433
- await this.notionClient.replacePageMarkdown(pageId, enhanced);
5434
- return;
5435
- } catch {
5436
- }
5762
+ const enhanced = obsidianToNotionEnhanced(markdownContent);
5763
+ await this.notionClient.replacePageMarkdown(pageId, enhanced);
5764
+ return;
5437
5765
  }
5438
5766
  const blocks = this.blockConverter.markdownToNotionBlocks(markdownContent);
5439
5767
  const existingBlocks = await this.notionClient.fetchAllChildren(pageId);
@@ -5452,51 +5780,6 @@ var SyncOrchestrator = class {
5452
5780
  })
5453
5781
  );
5454
5782
  }
5455
- computePatches(oldContent, newContent) {
5456
- const oldLines = oldContent.split("\n");
5457
- const newLines = newContent.split("\n");
5458
- const patches = [];
5459
- let i = 0;
5460
- let j = 0;
5461
- while (i < oldLines.length && j < newLines.length) {
5462
- if (oldLines[i] === newLines[j]) {
5463
- i++;
5464
- j++;
5465
- continue;
5466
- }
5467
- let oldEnd = i + 1;
5468
- let newEnd = j + 1;
5469
- const lookahead = Math.min(10, oldLines.length - i, newLines.length - j);
5470
- for (let k = 1; k <= lookahead; k++) {
5471
- if (i + k < oldLines.length && newLines[j] === oldLines[i + k]) {
5472
- oldEnd = i + k;
5473
- newEnd = j;
5474
- break;
5475
- }
5476
- if (j + k < newLines.length && oldLines[i] === newLines[j + k]) {
5477
- oldEnd = i;
5478
- newEnd = j + k;
5479
- break;
5480
- }
5481
- oldEnd = i + k;
5482
- newEnd = j + k;
5483
- }
5484
- const oldChunk = oldLines.slice(i, oldEnd).join("\n");
5485
- const newChunk = newLines.slice(j, newEnd).join("\n");
5486
- if (oldChunk || newChunk) {
5487
- patches.push({ oldStr: oldChunk, newStr: newChunk });
5488
- }
5489
- i = oldEnd;
5490
- j = newEnd;
5491
- }
5492
- if (i < oldLines.length || j < newLines.length) {
5493
- patches.push({
5494
- oldStr: oldLines.slice(i).join("\n"),
5495
- newStr: newLines.slice(j).join("\n")
5496
- });
5497
- }
5498
- return patches;
5499
- }
5500
5783
  async fetchPageMarkdown(pageId) {
5501
5784
  if (this.config.conversion.preferMarkdownApi !== false) {
5502
5785
  try {
@@ -5634,6 +5917,20 @@ var NodeVaultFS = class {
5634
5917
  await this.walkDir(this.rootPath, files);
5635
5918
  return files;
5636
5919
  }
5920
+ async listMarkdownFileStats() {
5921
+ const stats = [];
5922
+ await this.walkDirStats(this.rootPath, stats);
5923
+ return stats;
5924
+ }
5925
+ async getFileStat(path) {
5926
+ try {
5927
+ const fullPath = join(this.rootPath, path);
5928
+ const fileStat = await stat(fullPath);
5929
+ return { path, mtime: fileStat.mtime.toISOString(), size: fileStat.size };
5930
+ } catch {
5931
+ return null;
5932
+ }
5933
+ }
5637
5934
  async listNonMarkdownFiles() {
5638
5935
  const files = [];
5639
5936
  await this.walkDirNonMd(this.rootPath, files);
@@ -5680,6 +5977,25 @@ var NodeVaultFS = class {
5680
5977
  }
5681
5978
  }
5682
5979
  }
5980
+ async walkDirStats(dir, result) {
5981
+ const entries = await readdir(dir, { withFileTypes: true });
5982
+ for (const entry of entries) {
5983
+ const fullPath = join(dir, entry.name);
5984
+ if (entry.name.startsWith(".")) continue;
5985
+ const relativePath = relative(this.rootPath, fullPath);
5986
+ if (this.isExcluded(relativePath)) continue;
5987
+ if (entry.isDirectory()) {
5988
+ await this.walkDirStats(fullPath, result);
5989
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
5990
+ const fileStat = await stat(fullPath);
5991
+ result.push({
5992
+ path: relativePath,
5993
+ mtime: fileStat.mtime.toISOString(),
5994
+ size: fileStat.size
5995
+ });
5996
+ }
5997
+ }
5998
+ }
5683
5999
  async walkDir(dir, result) {
5684
6000
  const entries = await readdir(dir, { withFileTypes: true });
5685
6001
  for (const entry of entries) {