@im-nobsidian/core 0.1.7 → 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
  };
@@ -4451,15 +4580,20 @@ var SyncOrchestrator = class {
4451
4580
  return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
4452
4581
  }
4453
4582
  this.cleanupInterruptedSync();
4454
- const files = await this.vaultFs.listMarkdownFiles();
4455
- 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
+ );
4456
4589
  const conflictPaths = new Set(this.stateDb.getByStatus("conflict").map((r) => r.obsidianPath));
4457
4590
  const eligible = options?.force ? changes : changes.filter((c) => !conflictPaths.has(c.path));
4458
4591
  const dbFolders = (this.config.notion.databases ?? []).map(
4459
4592
  (d) => d.localFolder.endsWith("/") ? d.localFolder : d.localFolder + "/"
4460
4593
  );
4461
4594
  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));
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)));
4463
4597
  const hasDbConfigs = (this.config.notion.databases?.length ?? 0) > 0;
4464
4598
  if (filtered.length === 0 && !hasDbConfigs) {
4465
4599
  return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
@@ -4630,11 +4764,37 @@ var SyncOrchestrator = class {
4630
4764
  const conflicts = [];
4631
4765
  const writtenPaths = [];
4632
4766
  const failed = [];
4633
- 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();
4634
4770
  const filtered = options?.paths ? remoteChanges.filter((c) => {
4635
4771
  const record = this.stateDb.getByNotionId(c.pageId);
4636
4772
  return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
4637
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
+ }
4638
4798
  if (filtered.length === 0) {
4639
4799
  return emptyResult;
4640
4800
  }
@@ -4670,15 +4830,13 @@ var SyncOrchestrator = class {
4670
4830
  const tasks = filtered.map((change) => async () => {
4671
4831
  await sema.acquire();
4672
4832
  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 });
4833
+ let resultPath;
4677
4834
  switch (change.type) {
4678
4835
  case "created": {
4679
4836
  const path = await this.pullCreate(change.pageId);
4680
4837
  writtenPaths.push(path);
4681
4838
  created++;
4839
+ resultPath = path;
4682
4840
  break;
4683
4841
  }
4684
4842
  case "modified": {
@@ -4689,16 +4847,24 @@ var SyncOrchestrator = class {
4689
4847
  writtenPaths.push(result.path);
4690
4848
  updated++;
4691
4849
  }
4850
+ resultPath = result.path;
4692
4851
  break;
4693
4852
  }
4694
4853
  case "deleted": {
4854
+ const record2 = this.stateDb.getByNotionId(change.pageId);
4855
+ resultPath = record2?.obsidianPath;
4695
4856
  const path = await this.pullDelete(change.pageId);
4696
4857
  if (path) {
4697
4858
  deleted++;
4859
+ resultPath = path;
4698
4860
  }
4699
4861
  break;
4700
4862
  }
4701
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 });
4702
4868
  } catch (error) {
4703
4869
  const record = this.stateDb.getByNotionId(change.pageId);
4704
4870
  failed.push({
@@ -4773,44 +4939,13 @@ var SyncOrchestrator = class {
4773
4939
  getLogger().warn("[Im-Nobsidian] DB Pull \uC911 \uC624\uB958:", error);
4774
4940
  }
4775
4941
  }
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
- }
4942
+ {
4943
+ const dbDiscovery = await this.pullDiscoveredDatabases(writtenPaths, failed);
4944
+ created += dbDiscovery.created;
4945
+ updated += dbDiscovery.updated;
4811
4946
  }
4812
- const allSyncedMdPaths = this.stateDb.getAll().filter((r) => r.obsidianPath?.endsWith(".md")).map((r) => r.obsidianPath);
4813
- const linkCount = await this.resolveNotionLinks(allSyncedMdPaths);
4947
+ const linkTargetPaths = writtenPaths.length > 0 ? writtenPaths : [];
4948
+ const linkCount = linkTargetPaths.length > 0 ? await this.resolveNotionLinks(linkTargetPaths) : 0;
4814
4949
  this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
4815
4950
  this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
4816
4951
  this.stateDb.setMeta("pull_in_progress", "");
@@ -4832,7 +4967,8 @@ var SyncOrchestrator = class {
4832
4967
  const pullResult = await this.pull(options);
4833
4968
  const pushResult = await this.push({
4834
4969
  ...options,
4835
- paths: options?.paths
4970
+ paths: options?.paths,
4971
+ excludePaths: pullResult.writtenPaths
4836
4972
  });
4837
4973
  return {
4838
4974
  pull: pullResult,
@@ -4861,6 +4997,34 @@ var SyncOrchestrator = class {
4861
4997
  lastSyncAt
4862
4998
  };
4863
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
+ }
4864
5028
  async buildConflictsFromRecords(records, localChanges, remoteChanges) {
4865
5029
  const conflicts = [];
4866
5030
  for (const record of records) {
@@ -4903,25 +5067,97 @@ var SyncOrchestrator = class {
4903
5067
  return this.config.notion.parentMode === "database" && !!this.config.notion.databaseId;
4904
5068
  }
4905
5069
  async discoverChildDatabases() {
4906
- const rootId = this.config.notion.rootPageId;
4907
5070
  const allDbs = [];
4908
- let currentLevel = [rootId];
4909
- while (currentLevel.length > 0) {
4910
- const nextLevel = [];
4911
- 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) {
4912
5095
  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));
5096
+ dbConfigs = JSON.parse(cachedRaw);
4919
5097
  } catch {
5098
+ dbConfigs = [];
4920
5099
  }
4921
5100
  }
4922
- 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);
4923
5159
  }
4924
- return allDbs;
5160
+ return { created, updated };
4925
5161
  }
4926
5162
  async resolveNotionLinks(paths) {
4927
5163
  let totalResolved = 0;
@@ -5015,6 +5251,7 @@ var SyncOrchestrator = class {
5015
5251
  );
5016
5252
  await this.imageHandler.uploadAndAppendImages(page.id, conversionResult.images);
5017
5253
  const hash = computeHash(content);
5254
+ const fileStat = await this.vaultFs.getFileStat(path);
5018
5255
  this.stateDb.transaction(() => {
5019
5256
  this.stateDb.upsert({
5020
5257
  obsidianPath: path,
@@ -5026,7 +5263,9 @@ var SyncOrchestrator = class {
5026
5263
  syncDirection: "both",
5027
5264
  fileType: this.isFolderNote(path) ? "folder-note" : "file",
5028
5265
  status: "synced",
5029
- baseSnapshot: Buffer.from(content, "utf-8")
5266
+ baseSnapshot: Buffer.from(content, "utf-8"),
5267
+ localMtime: fileStat?.mtime ?? null,
5268
+ localFileSize: fileStat?.size ?? null
5030
5269
  });
5031
5270
  const aliases = extractAliases2(conversionResult.properties);
5032
5271
  this.stateDb.upsertWikilink({
@@ -5056,23 +5295,36 @@ var SyncOrchestrator = class {
5056
5295
  });
5057
5296
  await this.pushUpdatePage(record.notionPageId, conversionResult.content, record.baseSnapshot);
5058
5297
  await this.imageHandler.uploadAndAppendImages(record.notionPageId, conversionResult.images);
5059
- let propsToUpdate = conversionResult.properties;
5060
- if (this.isDatabaseMode && propsToUpdate && Object.keys(propsToUpdate).length > 0) {
5298
+ let propsToUpdate;
5299
+ if (this.isDatabaseMode && conversionResult.properties && Object.keys(conversionResult.properties).length > 0) {
5061
5300
  await this.ensureDbSchema();
5062
5301
  const title2 = extractTitle(path);
5063
- 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
+ };
5064
5308
  }
5309
+ let lastEditedTime = (/* @__PURE__ */ new Date()).toISOString();
5065
5310
  if (propsToUpdate && Object.keys(propsToUpdate).length > 0) {
5066
- 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;
5067
5316
  }
5068
- const updatedPage = await this.notionClient.getPage(record.notionPageId);
5069
5317
  const hash = computeHash(content);
5318
+ const fileStat = await this.vaultFs.getFileStat(path);
5070
5319
  const title = extractTitle(path);
5071
5320
  const aliases = extractAliases2(conversionResult.properties);
5072
5321
  this.stateDb.transaction(() => {
5073
5322
  this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
5074
5323
  this.stateDb.updateStatus(record.id, "synced");
5075
- 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
+ }
5076
5328
  this.stateDb.storePreserveMarkers(path, conversionResult.preserveMarkers);
5077
5329
  this.stateDb.upsertWikilink({
5078
5330
  obsidianPath: path,
@@ -5166,13 +5418,49 @@ var SyncOrchestrator = class {
5166
5418
  }
5167
5419
  return changes;
5168
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
+ }
5169
5457
  async pullCreate(pageId) {
5170
5458
  const page = await this.notionClient.getPage(pageId);
5171
5459
  const title = this.notionClient.extractTitle(page);
5172
5460
  const safeName = sanitizeFileName(title);
5173
5461
  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");
5462
+ const firstChildren = await this.notionClient.listChildren(pageId, { pageSize: 100 });
5463
+ const hasChildPages = firstChildren.results.some((b) => "type" in b && b.type === "child_page");
5176
5464
  const markdown = await this.fetchPageMarkdown(pageId);
5177
5465
  const hasContent = markdown.trim().length > 0;
5178
5466
  let filePath;
@@ -5223,6 +5511,7 @@ var SyncOrchestrator = class {
5223
5511
  );
5224
5512
  await this.vaultFs.writeFile(filePath, finalContent);
5225
5513
  const hash = computeHash(finalContent);
5514
+ const pullStat = await this.vaultFs.getFileStat(filePath);
5226
5515
  const resolvedParentId = await this.extractParentId(page);
5227
5516
  this.stateDb.transaction(() => {
5228
5517
  this.stateDb.upsert({
@@ -5235,7 +5524,9 @@ var SyncOrchestrator = class {
5235
5524
  syncDirection: "both",
5236
5525
  fileType,
5237
5526
  status: "synced",
5238
- baseSnapshot: Buffer.from(finalContent, "utf-8")
5527
+ baseSnapshot: Buffer.from(finalContent, "utf-8"),
5528
+ localMtime: pullStat?.mtime ?? null,
5529
+ localFileSize: pullStat?.size ?? null
5239
5530
  });
5240
5531
  const aliases = extractAliases2(properties);
5241
5532
  this.stateDb.upsertWikilink({
@@ -5313,6 +5604,7 @@ var SyncOrchestrator = class {
5313
5604
  }
5314
5605
  }
5315
5606
  await this.vaultFs.writeFile(record.obsidianPath, remoteContent);
5607
+ const updateStat = await this.vaultFs.getFileStat(record.obsidianPath);
5316
5608
  const newHash = computeHash(remoteContent);
5317
5609
  this.stateDb.upsert({
5318
5610
  obsidianPath: record.obsidianPath,
@@ -5324,7 +5616,9 @@ var SyncOrchestrator = class {
5324
5616
  syncDirection: record.syncDirection,
5325
5617
  fileType: record.fileType,
5326
5618
  status: "synced",
5327
- baseSnapshot: Buffer.from(remoteContent, "utf-8")
5619
+ baseSnapshot: Buffer.from(remoteContent, "utf-8"),
5620
+ localMtime: updateStat?.mtime ?? null,
5621
+ localFileSize: updateStat?.size ?? null
5328
5622
  });
5329
5623
  return { path: record.obsidianPath };
5330
5624
  }
@@ -5341,31 +5635,27 @@ var SyncOrchestrator = class {
5341
5635
  return record.obsidianPath;
5342
5636
  }
5343
5637
  async ensureFolderPage(folderPath) {
5344
- const existing = this.stateDb.getByPath(folderPath);
5345
- if (existing?.notionPageId) return;
5346
5638
  const folderNotePath = `${folderPath}/${folderPath.split("/").pop()}.md`;
5347
5639
  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
- });
5640
+ if (folderNoteRecord?.notionPageId) {
5641
+ const existing2 = this.stateDb.getByPath(folderPath);
5642
+ if (existing2) this.stateDb.delete(existing2.id);
5360
5643
  return;
5361
5644
  }
5645
+ const existing = this.stateDb.getByPath(folderPath);
5646
+ if (existing?.notionPageId) return;
5362
5647
  const parts = folderPath.split("/");
5363
5648
  const folderName = parts[parts.length - 1];
5364
5649
  let parentId = this.config.notion.rootPageId;
5365
5650
  if (parts.length > 1) {
5366
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);
5367
5655
  const parentRecord = this.stateDb.getByPath(parentPath);
5368
- if (parentRecord?.notionPageId) {
5656
+ if (parentNote?.notionPageId) {
5657
+ parentId = parentNote.notionPageId;
5658
+ } else if (parentRecord?.notionPageId) {
5369
5659
  parentId = parentRecord.notionPageId;
5370
5660
  }
5371
5661
  }
@@ -5392,8 +5682,26 @@ var SyncOrchestrator = class {
5392
5682
  const folderPath = parts.slice(0, -1).join("/");
5393
5683
  const folderRecord = this.stateDb.getByPath(folderPath);
5394
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;
5395
5689
  return this.config.notion.rootPageId;
5396
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
+ }
5397
5705
  isFolderNote(filePath) {
5398
5706
  const parts = filePath.split("/");
5399
5707
  if (parts.length < 2) return false;
@@ -5428,17 +5736,14 @@ var SyncOrchestrator = class {
5428
5736
  }
5429
5737
  async pushCreatePage(parentId, parentType, title, markdownContent, properties) {
5430
5738
  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
- }
5739
+ const enhanced = obsidianToNotionEnhanced(markdownContent);
5740
+ return await this.notionClient.createPageWithMarkdown({
5741
+ parentId,
5742
+ parentType,
5743
+ title,
5744
+ markdown: enhanced,
5745
+ properties
5746
+ });
5442
5747
  }
5443
5748
  const blocks = this.blockConverter.markdownToNotionBlocks(markdownContent);
5444
5749
  const page = await this.notionClient.createPage({
@@ -5452,27 +5757,11 @@ var SyncOrchestrator = class {
5452
5757
  }
5453
5758
  return page;
5454
5759
  }
5455
- async pushUpdatePage(pageId, markdownContent, baseSnapshot) {
5760
+ async pushUpdatePage(pageId, markdownContent, _baseSnapshot) {
5456
5761
  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
- }
5762
+ const enhanced = obsidianToNotionEnhanced(markdownContent);
5763
+ await this.notionClient.replacePageMarkdown(pageId, enhanced);
5764
+ return;
5476
5765
  }
5477
5766
  const blocks = this.blockConverter.markdownToNotionBlocks(markdownContent);
5478
5767
  const existingBlocks = await this.notionClient.fetchAllChildren(pageId);
@@ -5491,51 +5780,6 @@ var SyncOrchestrator = class {
5491
5780
  })
5492
5781
  );
5493
5782
  }
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
5783
  async fetchPageMarkdown(pageId) {
5540
5784
  if (this.config.conversion.preferMarkdownApi !== false) {
5541
5785
  try {
@@ -5673,6 +5917,20 @@ var NodeVaultFS = class {
5673
5917
  await this.walkDir(this.rootPath, files);
5674
5918
  return files;
5675
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
+ }
5676
5934
  async listNonMarkdownFiles() {
5677
5935
  const files = [];
5678
5936
  await this.walkDirNonMd(this.rootPath, files);
@@ -5719,6 +5977,25 @@ var NodeVaultFS = class {
5719
5977
  }
5720
5978
  }
5721
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
+ }
5722
5999
  async walkDir(dir, result) {
5723
6000
  const entries = await readdir(dir, { withFileTypes: true });
5724
6001
  for (const entry of entries) {