im-nobsidian 0.3.1 → 0.4.1

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
@@ -2,11 +2,16 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { createRequire } from "module";
5
- import { Command as Command10 } from "commander";
5
+ import { Command as Command12 } from "commander";
6
6
 
7
7
  // src/commands/init.ts
8
8
  import { Command } from "commander";
9
- import { ConfigManager, NotionClient } from "@im-nobsidian/core";
9
+ import {
10
+ ConfigManager,
11
+ NOTION_TOKEN_PREFIX,
12
+ NotionClient,
13
+ notionIdFromInput
14
+ } from "@im-nobsidian/core";
10
15
  import { input, confirm } from "@inquirer/prompts";
11
16
  import ora from "ora";
12
17
  import chalk2 from "chalk";
@@ -17,6 +22,7 @@ var icons = {
17
22
  create: chalk.green("+"),
18
23
  update: chalk.yellow("~"),
19
24
  delete: chalk.red("-"),
25
+ move: chalk.cyan("\u2192"),
20
26
  success: chalk.green("\u2713"),
21
27
  fail: chalk.red("\u2715"),
22
28
  conflict: chalk.magenta("!"),
@@ -42,31 +48,72 @@ function operationLabel(op) {
42
48
  return chalk.yellow("updated");
43
49
  case "delete":
44
50
  return chalk.red("deleted");
51
+ case "move":
52
+ return chalk.cyan("moved");
45
53
  }
46
54
  }
47
- function summary(created, updated, deleted) {
55
+ function summary(created, updated, deleted, moved = 0) {
48
56
  const parts = [];
49
57
  if (created > 0) parts.push(chalk.green(`${created} created`));
50
58
  if (updated > 0) parts.push(chalk.yellow(`${updated} updated`));
59
+ if (moved > 0) parts.push(chalk.cyan(`${moved} moved`));
51
60
  if (deleted > 0) parts.push(chalk.red(`${deleted} deleted`));
52
61
  if (parts.length === 0) parts.push(chalk.dim("no changes"));
53
62
  return parts.join(" ");
54
63
  }
64
+ function completionHeader(label, failedCount) {
65
+ return failedCount > 0 ? header(chalk.yellow(`${label} finished with errors`)) : header(chalk.green(`${label} complete`));
66
+ }
67
+ function failedCountText(count) {
68
+ return count > 0 ? chalk.red(`${count} failed`) : chalk.dim("0 failed");
69
+ }
55
70
  function duration(ms) {
56
71
  return chalk.dim(`Done in ${(ms / 1e3).toFixed(1)}s`);
57
72
  }
73
+ function localDateTime(iso) {
74
+ const date = new Date(iso);
75
+ if (Number.isNaN(date.getTime())) return iso;
76
+ const pad = (n) => String(n).padStart(2, "0");
77
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
78
+ }
79
+ var FULL_SCAN_REASON = {
80
+ first: "first pull",
81
+ forced: "--force",
82
+ "database-mode": "database mode",
83
+ "every-pull": "every pull",
84
+ due: "scheduled"
85
+ };
86
+ function remoteScanLines(scan) {
87
+ if (scan.kind === "full") {
88
+ return [dimText(scan.reason ? `Full scan (${FULL_SCAN_REASON[scan.reason]})` : "Full scan")];
89
+ }
90
+ const skipped = scan.skippedDatabases ?? 0;
91
+ const skippedText = skipped > 0 ? ` \xB7 ${skipped} unchanged ${skipped === 1 ? "database" : "databases"} skipped` : "";
92
+ const lines = [dimText(`Changes-only scan${skippedText}`)];
93
+ if (scan.deletionsDeferred) lines.push(remoteDeletionHint(scan));
94
+ return lines;
95
+ }
96
+ function remoteDeletionHint(scan) {
97
+ const when = scan.nextFullAt ? `the next full scan (${localDateTime(scan.nextFullAt)})` : "the next pull (full scan due)";
98
+ return `${dimText(`Deletions in Notion apply at ${when} \u2014 or run`)} ${chalk.cyan("nobsi pull --force")}`;
99
+ }
100
+ function lastFullScanText(lastFullScanAt) {
101
+ return lastFullScanAt ? localDateTime(lastFullScanAt) : chalk.yellow("never");
102
+ }
58
103
  function progress(current, total) {
59
104
  return chalk.dim(`[${current}/${total}]`);
60
105
  }
61
106
  function dimText(text) {
62
107
  return chalk.dim(text);
63
108
  }
64
- function failedItem(path, error) {
65
- console.log(` ${icons.fail} ${path}: ${chalk.red(error)}`);
109
+ function failedItem(path, error, direction) {
110
+ const where = `${direction ? `${direction} ` : ""}${path || "/"}`;
111
+ console.log(` ${icons.fail} ${where}: ${chalk.red(error)}`);
66
112
  }
67
113
 
68
114
  // src/commands/init.ts
69
- var initCommand = new Command("init").description("Im-Nobsidian \uCD08\uAE30 \uC124\uC815").option("--token <token>", "Notion Integration Token").option("--root-page-id <id>", "\uB8E8\uD2B8 \uD398\uC774\uC9C0 ID").option("--non-interactive", "\uBE44\uB300\uD654\uD615 \uBAA8\uB4DC").action(async (options) => {
115
+ var ROOT_PAGE_UNREADABLE = "\uB8E8\uD2B8 \uD398\uC774\uC9C0\uC5D0\uC11C Notion \uD398\uC774\uC9C0 ID(32\uC790\uB9AC)\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4 \u2014 \uD398\uC774\uC9C0 \uC8FC\uC18C\uB098 ID \uB97C \uB123\uC73C\uC138\uC694.";
116
+ var initCommand = new Command("init").description("Im-Nobsidian \uCD08\uAE30 \uC124\uC815").option("--token <token>", "Notion Integration Token").option("--root-page-id <id>", "\uB8E8\uD2B8 \uD398\uC774\uC9C0 ID \uB610\uB294 \uD398\uC774\uC9C0 \uC8FC\uC18C").option("--non-interactive", "\uBE44\uB300\uD654\uD615 \uBAA8\uB4DC").action(async (options) => {
70
117
  const cwd = process.cwd();
71
118
  const configManager = new ConfigManager(cwd);
72
119
  console.log(`
@@ -78,8 +125,14 @@ ${header(chalk2.cyan(" Im-Nobsidian Setup"))}`);
78
125
  process.exitCode = 1;
79
126
  return;
80
127
  }
81
- if (!options.token.startsWith("ntn_")) {
82
- console.error("\uD1A0\uD070\uC740 ntn_\uC73C\uB85C \uC2DC\uC791\uD574\uC57C \uD569\uB2C8\uB2E4.");
128
+ if (!options.token.startsWith(NOTION_TOKEN_PREFIX)) {
129
+ console.error(`\uD1A0\uD070\uC740 ${NOTION_TOKEN_PREFIX}\uC73C\uB85C \uC2DC\uC791\uD574\uC57C \uD569\uB2C8\uB2E4.`);
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ const rootPageId = notionIdFromInput(options.rootPageId);
134
+ if (rootPageId === null) {
135
+ console.error(ROOT_PAGE_UNREADABLE);
83
136
  process.exitCode = 1;
84
137
  return;
85
138
  }
@@ -89,10 +142,9 @@ ${header(chalk2.cyan(" Im-Nobsidian Setup"))}`);
89
142
  await client.search({ pageSize: 1, filter: { property: "object", value: "page" } });
90
143
  spinner2.stop();
91
144
  console.log(` ${icons.success} Token validated`);
92
- await configManager.init({ token: options.token, rootPageId: options.rootPageId });
145
+ await configManager.init({ token: options.token, rootPageId });
93
146
  console.log(`
94
147
  ${icons.success} Config saved to ${dimText(configManager.configPath)}`);
95
- console.log(` ${icons.success} State DB initialized`);
96
148
  console.log(
97
149
  `
98
150
  ${header(chalk2.green("Ready!"))} Run ${chalk2.cyan("nobsi sync")} to start syncing.`
@@ -114,7 +166,7 @@ ${header(chalk2.cyan(" Im-Nobsidian Setup"))}`);
114
166
  }
115
167
  const token = await input({
116
168
  message: "Notion Integration Token:",
117
- validate: (v) => v.startsWith("ntn_") || "ntn_\uC73C\uB85C \uC2DC\uC791\uD558\uB294 \uD1A0\uD070\uC744 \uC785\uB825\uD558\uC138\uC694"
169
+ validate: (v) => v.startsWith(NOTION_TOKEN_PREFIX) || `${NOTION_TOKEN_PREFIX}\uC73C\uB85C \uC2DC\uC791\uD558\uB294 \uD1A0\uD070\uC744 \uC785\uB825\uD558\uC138\uC694`
118
170
  });
119
171
  const spinner = ora("Notion \uC5F0\uACB0 \uD655\uC778 \uC911...").start();
120
172
  try {
@@ -138,14 +190,15 @@ ${header(chalk2.cyan(" Im-Nobsidian Setup"))}`);
138
190
  const title = extractPageTitle(page);
139
191
  console.log(` ${dimText(`${i + 1}.`)} ${title}`);
140
192
  }
141
- const rootPageId = await input({
142
- message: "\uB8E8\uD2B8 \uD398\uC774\uC9C0 ID (\uC704\uC5D0\uC11C \uC120\uD0DD\uD558\uAC70\uB098 \uC9C1\uC811 \uC785\uB825):",
143
- default: result.results[0]?.id
193
+ const rootPageInput = await input({
194
+ message: "\uB8E8\uD2B8 \uD398\uC774\uC9C0 ID \uB610\uB294 \uC8FC\uC18C (\uC704\uC5D0\uC11C \uC120\uD0DD\uD558\uAC70\uB098 \uC9C1\uC811 \uC785\uB825):",
195
+ default: result.results[0]?.id,
196
+ validate: (value) => notionIdFromInput(value) !== null || ROOT_PAGE_UNREADABLE
144
197
  });
198
+ const rootPageId = notionIdFromInput(rootPageInput);
145
199
  await configManager.init({ token, rootPageId });
146
200
  console.log(`
147
201
  ${icons.success} Config saved to ${dimText(configManager.configPath)}`);
148
- console.log(` ${icons.success} State DB initialized`);
149
202
  console.log(
150
203
  `
151
204
  ${header(chalk2.green("Ready!"))} Run ${chalk2.cyan("nobsi sync")} to start syncing.`
@@ -177,8 +230,51 @@ import {
177
230
  SyncOrchestrator,
178
231
  NodeVaultFS
179
232
  } from "@im-nobsidian/core";
180
- import chalk3 from "chalk";
181
- var pushCommand = new Command2("push").description("\uB85C\uCEEC \uBCC0\uACBD\uC0AC\uD56D\uC744 Notion\uC5D0 \uBC18\uC601").option("--dry-run", "\uC2E4\uC81C \uBC18\uC601 \uC5C6\uC774 \uBCC0\uACBD\uC0AC\uD56D\uB9CC \uD45C\uC2DC").option("-p, --path <paths...>", "\uD2B9\uC815 \uACBD\uB85C\uB9CC push").action(async (options) => {
233
+
234
+ // src/utils/json-output.ts
235
+ import { setLogger } from "@im-nobsidian/core";
236
+ function enableJsonMode() {
237
+ const toStderr = (message, ...args) => console.error(message, ...args);
238
+ setLogger({ warn: toStderr, error: toStderr, info: toStderr, debug: () => {
239
+ } });
240
+ }
241
+ function pushJson(result) {
242
+ return {
243
+ created: result.created,
244
+ updated: result.updated,
245
+ deleted: result.deleted,
246
+ moved: result.moved,
247
+ churn: result.created + result.updated + result.deleted + result.moved,
248
+ failed: result.failed,
249
+ durationMs: result.duration
250
+ };
251
+ }
252
+ function pullJson(result) {
253
+ return {
254
+ created: result.created,
255
+ updated: result.updated,
256
+ deleted: result.deleted,
257
+ restored: result.restored,
258
+ churn: result.created + result.updated + result.deleted + result.restored,
259
+ conflicts: result.conflicts.length,
260
+ failed: result.failed,
261
+ writtenPaths: result.writtenPaths.length,
262
+ images: result.imageCount,
263
+ files: result.fileCount,
264
+ links: result.linkCount,
265
+ durationMs: result.duration,
266
+ remoteScan: result.remoteScan ?? null
267
+ };
268
+ }
269
+ function printJson(value) {
270
+ process.stdout.write(`${JSON.stringify(value)}
271
+ `);
272
+ }
273
+
274
+ // src/commands/push.ts
275
+ var pushCommand = new Command2("push").description("\uB85C\uCEEC \uBCC0\uACBD\uC0AC\uD56D\uC744 Notion\uC5D0 \uBC18\uC601").option("--dry-run", "\uC2E4\uC81C \uBC18\uC601 \uC5C6\uC774 \uBCC0\uACBD\uC0AC\uD56D\uB9CC \uD45C\uC2DC").option("-p, --path <paths...>", "\uD2B9\uC815 \uACBD\uB85C\uB9CC push").option("--json", "\uACB0\uACFC\uB97C JSON \uD55C \uC904\uB85C \uCD9C\uB825 (\uC790\uB3D9\uD654\uC6A9 \u2014 \uB85C\uADF8\uB294 stderr)").action(async (options) => {
276
+ const json = options.json === true;
277
+ if (json) enableJsonMode();
182
278
  const cwd = process.cwd();
183
279
  const configManager = new ConfigManager2(cwd);
184
280
  const config = await configManager.load();
@@ -187,31 +283,40 @@ var pushCommand = new Command2("push").description("\uB85C\uCEEC \uBCC0\uACBD\uC
187
283
  const client = NotionClient2.fromConfig(config);
188
284
  const vaultFs = new NodeVaultFS(cwd, config.paths);
189
285
  const orchestrator = new SyncOrchestrator(config, stateDb, client, vaultFs);
190
- console.log(`
286
+ if (!json) {
287
+ console.log(`
191
288
  ${header(" Pushing to Notion...")}`);
192
- if (options.dryRun) console.log(dimText(" (dry-run mode)"));
193
- console.log("");
289
+ if (options.dryRun) console.log(dimText(" (dry-run mode)"));
290
+ console.log("");
291
+ }
194
292
  const result = await orchestrator.push({
195
293
  dryRun: options.dryRun,
196
294
  paths: options.path,
197
- onProgress: (current, total, item) => {
295
+ onProgress: json ? void 0 : (current, total, item) => {
198
296
  const icon = operationIcon(item.operation);
199
297
  const label = operationLabel(item.operation);
200
298
  const prog = progress(current, total);
201
299
  console.log(` ${icon} ${item.path} ${prog} ${label}`);
202
300
  }
203
301
  });
302
+ if (json) {
303
+ printJson(pushJson(result));
304
+ if (result.failed.length > 0) process.exitCode = 1;
305
+ return;
306
+ }
307
+ const failedCount = result.failed.length;
204
308
  console.log(`
205
- ${header(chalk3.green("Push complete"))}`);
309
+ ${completionHeader("Push", failedCount)}`);
206
310
  console.log(
207
- ` ${summary(result.created, result.updated, result.deleted)} ${dimText(`${result.failed.length} failed`)}`
311
+ ` ${summary(result.created, result.updated, result.deleted, result.moved)} ${failedCountText(failedCount)}`
208
312
  );
209
313
  console.log(` ${duration(result.duration)}`);
210
- if (result.failed.length > 0) {
314
+ if (failedCount > 0) {
211
315
  console.log("");
212
316
  for (const f of result.failed) {
213
317
  failedItem(f.path, f.error);
214
318
  }
319
+ process.exitCode = 1;
215
320
  }
216
321
  } finally {
217
322
  stateDb.close();
@@ -219,7 +324,7 @@ ${header(" Pushing to Notion...")}`);
219
324
  });
220
325
 
221
326
  // src/commands/pull.ts
222
- import { Command as Command3 } from "commander";
327
+ import { Command as Command3, Option } from "commander";
223
328
  import {
224
329
  ConfigManager as ConfigManager3,
225
330
  StateDB as StateDB2,
@@ -227,11 +332,18 @@ import {
227
332
  SyncOrchestrator as SyncOrchestrator2,
228
333
  NodeVaultFS as NodeVaultFS2
229
334
  } from "@im-nobsidian/core";
230
- import chalk4 from "chalk";
335
+ import chalk3 from "chalk";
231
336
  var pullCommand = new Command3("pull").description("Notion \uBCC0\uACBD\uC0AC\uD56D\uC744 \uB85C\uCEEC\uC5D0 \uBC18\uC601").option("--dry-run", "\uC2E4\uC81C \uBC18\uC601 \uC5C6\uC774 \uBCC0\uACBD\uC0AC\uD56D\uB9CC \uD45C\uC2DC").option("-p, --path <paths...>", "\uD2B9\uC815 \uACBD\uB85C\uB9CC pull").option(
232
337
  "--force",
233
338
  "\uC99D\uBD84 \uAC10\uC9C0\uB97C \uAC74\uB108\uB6F0\uACE0 \uC804\uCCB4 \uC2A4\uCE94 (search \uC778\uB371\uC2F1 \uC9C0\uC5F0\uC73C\uB85C \uB204\uB77D\uB41C \uC2E0\uADDC \uD398\uC774\uC9C0\xB7DB \uBCF5\uAD6C)"
234
- ).action(async (options) => {
339
+ ).addOption(
340
+ new Option(
341
+ "--rerender",
342
+ "Notion \uC5D0\uC11C \uBC14\uB00C\uC9C0 \uC54A\uC740 \uBC1B\uC544 \uB454 \uB178\uD2B8\uB3C4 \uC9C0\uAE08 \uBCC0\uD658\uAE30\uB85C \uB2E4\uC2DC \uADF8\uB9BC (\uBCFC\uD2B8\uC5D0\uC11C \uACE0\uCE5C \uB178\uD2B8\uB294 \uADF8\uB300\uB85C)"
343
+ ).conflicts("dryRun")
344
+ ).option("--json", "\uACB0\uACFC\uB97C JSON \uD55C \uC904\uB85C \uCD9C\uB825 (\uC790\uB3D9\uD654\uC6A9 \u2014 \uB85C\uADF8\uB294 stderr)").action(async (options) => {
345
+ const json = options.json === true;
346
+ if (json) enableJsonMode();
235
347
  const cwd = process.cwd();
236
348
  const configManager = new ConfigManager3(cwd);
237
349
  const config = await configManager.load();
@@ -240,50 +352,69 @@ var pullCommand = new Command3("pull").description("Notion \uBCC0\uACBD\uC0AC\uD
240
352
  const client = NotionClient3.fromConfig(config);
241
353
  const vaultFs = new NodeVaultFS2(cwd, config.paths);
242
354
  const orchestrator = new SyncOrchestrator2(config, stateDb, client, vaultFs);
243
- console.log(`
355
+ if (!json) {
356
+ console.log(`
244
357
  ${header(" Pulling from Notion...")}`);
245
- if (options.dryRun) console.log(dimText(" (dry-run mode)"));
246
- console.log("");
358
+ if (options.dryRun) console.log(dimText(" (dry-run mode)"));
359
+ console.log("");
360
+ }
247
361
  const result = await orchestrator.pull({
248
362
  dryRun: options.dryRun,
249
363
  paths: options.path,
250
364
  force: options.force,
251
- onProgress: (current, total, item) => {
365
+ rerender: options.rerender,
366
+ onProgress: json ? void 0 : (current, total, item) => {
367
+ if (item.unchanged) return;
252
368
  const icon = operationIcon(item.operation);
253
369
  const label = operationLabel(item.operation);
254
370
  const prog = progress(current, total);
255
371
  console.log(` ${icon} ${item.path} ${prog} ${label}`);
256
372
  }
257
373
  });
374
+ if (json) {
375
+ printJson(pullJson(result));
376
+ if (result.failed.length > 0) process.exitCode = 1;
377
+ return;
378
+ }
258
379
  if (result.imageCount > 0) {
259
380
  console.log(
260
381
  `
261
- ${icons.success} ${result.imageCount} images \u2192 ${dimText("attachments/")}`
382
+ ${icons.success} ${result.imageCount} images \u2192 ${dimText(`${config.paths.attachments}/`)}`
262
383
  );
263
384
  }
264
385
  if (result.fileCount > 0) {
265
- console.log(` ${icons.success} ${result.fileCount} files \u2192 ${dimText("attachments/")}`);
386
+ console.log(
387
+ ` ${icons.success} ${result.fileCount} files \u2192 ${dimText(`${config.paths.attachments}/`)}`
388
+ );
266
389
  }
267
390
  if (result.linkCount > 0) {
268
391
  console.log(` ${icons.success} ${result.linkCount} links resolved`);
269
392
  }
393
+ const failedCount = result.failed.length;
270
394
  console.log(`
271
- ${header(chalk4.green("Pull complete"))}`);
395
+ ${completionHeader("Pull", failedCount)}`);
272
396
  console.log(
273
- ` ${summary(result.created, result.updated, result.deleted)} ${dimText(`${result.failed.length} failed`)}`
397
+ ` ${summary(result.created, result.updated, result.deleted)} ${failedCountText(failedCount)}`
274
398
  );
399
+ if (result.restored > 0) {
400
+ console.log(` ${icons.success} ${chalk3.yellow(`${result.restored} restored`)}`);
401
+ }
275
402
  console.log(` ${duration(result.duration)}`);
403
+ if (result.remoteScan) {
404
+ for (const line of remoteScanLines(result.remoteScan)) console.log(` ${line}`);
405
+ }
276
406
  if (result.conflicts.length > 0) {
277
407
  console.log(
278
408
  `
279
- ${icons.conflict} ${chalk4.magenta(`${result.conflicts.length} conflicts`)} \u2014 run ${chalk4.cyan("nobsi resolve")}`
409
+ ${icons.conflict} ${chalk3.magenta(`${result.conflicts.length} conflicts`)} \u2014 run ${chalk3.cyan("nobsi resolve")}`
280
410
  );
281
411
  }
282
- if (result.failed.length > 0) {
412
+ if (failedCount > 0) {
283
413
  console.log("");
284
414
  for (const f of result.failed) {
285
415
  failedItem(f.path, f.error);
286
416
  }
417
+ process.exitCode = 1;
287
418
  }
288
419
  } finally {
289
420
  stateDb.close();
@@ -299,8 +430,12 @@ import {
299
430
  SyncOrchestrator as SyncOrchestrator3,
300
431
  NodeVaultFS as NodeVaultFS3
301
432
  } from "@im-nobsidian/core";
302
- import chalk5 from "chalk";
303
- var syncCommand = new Command4("sync").description("\uC591\uBC29\uD5A5 \uB3D9\uAE30\uD654 (Pull \u2192 Push)").option("--dry-run", "\uC2E4\uC81C \uBC18\uC601 \uC5C6\uC774 \uBCC0\uACBD\uC0AC\uD56D\uB9CC \uD45C\uC2DC").action(async (options) => {
433
+ import chalk4 from "chalk";
434
+ var syncCommand = new Command4("sync").description("\uC591\uBC29\uD5A5 \uB3D9\uAE30\uD654 (Pull \u2192 Push)").option("--dry-run", "\uC2E4\uC81C \uBC18\uC601 \uC5C6\uC774 \uBCC0\uACBD\uC0AC\uD56D\uB9CC \uD45C\uC2DC").option("--json", "\uACB0\uACFC\uB97C JSON \uD55C \uC904\uB85C \uCD9C\uB825 (\uC790\uB3D9\uD654\uC6A9 \u2014 \uB85C\uADF8\uB294 stderr)").action(async (options) => {
435
+ const json = options.json === true;
436
+ if (json) enableJsonMode();
437
+ const log = json ? () => {
438
+ } : (line) => console.log(line);
304
439
  const cwd = process.cwd();
305
440
  const configManager = new ConfigManager4(cwd);
306
441
  const config = await configManager.load();
@@ -309,52 +444,77 @@ var syncCommand = new Command4("sync").description("\uC591\uBC29\uD5A5 \uB3D9\uA
309
444
  const client = NotionClient4.fromConfig(config);
310
445
  const vaultFs = new NodeVaultFS3(cwd, config.paths);
311
446
  const orchestrator = new SyncOrchestrator3(config, stateDb, client, vaultFs);
312
- console.log(`
447
+ log(`
313
448
  ${header(" Bidirectional Sync")}`);
314
- console.log(` ${separator()}`);
315
- if (options.dryRun) console.log(dimText(" (dry-run mode)"));
316
- console.log(`
317
- ${header(chalk5.blue("\u25BC Pull"))} ${dimText("(Notion \u2192 Obsidian)")}`);
318
- const pullResult = await orchestrator.pull({
449
+ log(` ${separator()}`);
450
+ if (options.dryRun) log(dimText(" (dry-run mode)"));
451
+ log(`
452
+ ${header(chalk4.blue("\u25BC Pull"))} ${dimText("(Notion \u2192 Obsidian)")}`);
453
+ let pushShown = false;
454
+ const showPush = () => {
455
+ if (pushShown) return;
456
+ pushShown = true;
457
+ log(`
458
+ ${header(chalk4.magenta("\u25B2 Push"))} ${dimText("(Obsidian \u2192 Notion)")}`);
459
+ };
460
+ const result = await orchestrator.sync({
319
461
  dryRun: options.dryRun,
320
462
  onProgress: (_current, _total, item) => {
463
+ if (item.direction === "push") showPush();
464
+ if (item.unchanged) return;
321
465
  const icon = operationIcon(item.operation);
322
466
  const label = operationLabel(item.operation);
323
- console.log(` ${icon} ${item.path} ${label}`);
467
+ log(` ${icon} ${item.path} ${label}`);
324
468
  }
325
469
  });
470
+ showPush();
471
+ const { pull: pullResult, push: pushResult } = result;
472
+ if (json) {
473
+ const pull = pullJson(pullResult);
474
+ const push = pushJson(pushResult);
475
+ printJson({
476
+ pull,
477
+ push,
478
+ churn: pull.churn + push.churn,
479
+ conflicts: pull.conflicts,
480
+ durationMs: result.duration
481
+ });
482
+ if (pullResult.failed.length + pushResult.failed.length > 0) process.exitCode = 1;
483
+ return;
484
+ }
485
+ const pullFailed = pullResult.failed.length;
486
+ const pushFailed = pushResult.failed.length;
487
+ console.log(`
488
+ ${completionHeader("Sync", pullFailed + pushFailed)}`);
489
+ console.log(
490
+ ` ${chalk4.blue("Pull:")} ${summary(pullResult.created, pullResult.updated, pullResult.deleted)}` + // 사라졌던 파일이 되살아난 경우에만 덧붙인다 — 평상시 요약을 어지럽히지 않는다.
491
+ (pullResult.restored > 0 ? ` ${chalk4.yellow(`${pullResult.restored} restored`)}` : "") + (pullFailed > 0 ? ` ${chalk4.red(`${pullFailed} failed`)}` : "")
492
+ );
326
493
  if (pullResult.imageCount > 0) {
327
494
  console.log(` ${icons.success} ${pullResult.imageCount} images downloaded`);
328
495
  }
329
496
  if (pullResult.linkCount > 0) {
330
497
  console.log(` ${icons.success} ${pullResult.linkCount} links resolved`);
331
498
  }
332
- console.log(`
333
- ${header(chalk5.magenta("\u25B2 Push"))} ${dimText("(Obsidian \u2192 Notion)")}`);
334
- const pushResult = await orchestrator.push({
335
- dryRun: options.dryRun,
336
- onProgress: (_current, _total, item) => {
337
- const icon = operationIcon(item.operation);
338
- const label = operationLabel(item.operation);
339
- console.log(` ${icon} ${item.path} ${label}`);
340
- }
341
- });
342
- const totalDuration = pullResult.duration + pushResult.duration;
343
- console.log(`
344
- ${header(chalk5.green("Sync complete"))}`);
345
- console.log(
346
- ` ${chalk5.blue("Pull:")} ${summary(pullResult.created, pullResult.updated, pullResult.deleted)}`
347
- );
348
499
  console.log(
349
- ` ${chalk5.magenta("Push:")} ${summary(pushResult.created, pushResult.updated, pushResult.deleted)}`
500
+ ` ${chalk4.magenta("Push:")} ${summary(pushResult.created, pushResult.updated, pushResult.deleted, pushResult.moved)}` + (pushFailed > 0 ? ` ${chalk4.red(`${pushFailed} failed`)}` : "")
350
501
  );
351
- console.log(` ${duration(totalDuration)}`);
352
- if (pullResult.conflicts.length > 0) {
502
+ console.log(` ${duration(result.duration)}`);
503
+ if (pullResult.remoteScan) {
504
+ for (const line of remoteScanLines(pullResult.remoteScan)) console.log(` ${line}`);
505
+ }
506
+ if (result.conflicts.length > 0) {
353
507
  console.log(
354
508
  `
355
- ${icons.conflict} ${chalk5.magenta(`${pullResult.conflicts.length} conflicts`)} \u2014 run ${chalk5.cyan("nobsi resolve")}`
509
+ ${icons.conflict} ${chalk4.magenta(`${result.conflicts.length} conflicts`)} \u2014 run ${chalk4.cyan("nobsi resolve")}`
356
510
  );
357
511
  }
512
+ if (pullFailed + pushFailed > 0) {
513
+ console.log("");
514
+ for (const f of pullResult.failed) failedItem(f.path, f.error, "\u25BC");
515
+ for (const f of pushResult.failed) failedItem(f.path, f.error, "\u25B2");
516
+ process.exitCode = 1;
517
+ }
358
518
  } finally {
359
519
  stateDb.close();
360
520
  }
@@ -367,9 +527,10 @@ import {
367
527
  StateDB as StateDB4,
368
528
  NotionClient as NotionClient5,
369
529
  SyncOrchestrator as SyncOrchestrator4,
370
- NodeVaultFS as NodeVaultFS4
530
+ NodeVaultFS as NodeVaultFS4,
531
+ isRemoteDeletion
371
532
  } from "@im-nobsidian/core";
372
- import chalk6 from "chalk";
533
+ import chalk5 from "chalk";
373
534
  var statusCommand = new Command5("status").description("\uB3D9\uAE30\uD654 \uC0C1\uD0DC \uD655\uC778 (--full: \uC6D0\uACA9 \uBCC0\uACBD \uD3EC\uD568)").option("--full", "Notion \uC6D0\uACA9 \uBCC0\uACBD\uAE4C\uC9C0 \uC591\uBC29\uD5A5 \uD655\uC778 (API \uD638\uCD9C, \uB290\uB9BC)").action(async (opts) => {
374
535
  const cwd = process.cwd();
375
536
  const configManager = new ConfigManager5(cwd);
@@ -388,50 +549,92 @@ ${header(" Sync Status")}`);
388
549
  console.log(` ${dimText("Root page:")} ${rootId.slice(0, 8)}...`);
389
550
  console.log(` ${dimText("Direction:")} ${direction}`);
390
551
  if (status.lastSyncAt) {
391
- const date = new Date(status.lastSyncAt);
392
- const iso = date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0") + " " + String(date.getHours()).padStart(2, "0") + ":" + String(date.getMinutes()).padStart(2, "0") + ":" + String(date.getSeconds()).padStart(2, "0");
393
- console.log(` ${dimText("Last sync:")} ${iso}`);
552
+ console.log(` ${dimText("Last sync:")} ${localDateTime(status.lastSyncAt)}`);
394
553
  } else {
395
- console.log(` ${dimText("Last sync:")} ${chalk6.yellow("never")}`);
554
+ console.log(` ${dimText("Last sync:")} ${chalk5.yellow("never")}`);
396
555
  }
556
+ console.log(` ${dimText("Full scan:")} ${lastFullScanText(status.lastFullScanAt)}`);
397
557
  const allRecords = stateDb.getAll();
398
558
  const total = allRecords.length;
399
- const synced = allRecords.filter((r) => r.status === "synced").length;
400
559
  const conflictCount = status.conflictRecords.length;
401
560
  const createdChanges = status.localChanges.filter((c) => c.type === "created");
402
561
  const modifiedChanges = status.localChanges.filter((c) => c.type === "modified");
562
+ const deletedChanges = status.localChanges.filter((c) => c.type === "deleted");
563
+ const movedChanges = status.localChanges.filter((c) => c.type === "moved");
564
+ const dirtyPaths = /* @__PURE__ */ new Set([
565
+ ...modifiedChanges.map((c) => c.path),
566
+ ...deletedChanges.map((c) => c.path),
567
+ ...movedChanges.flatMap((c) => [c.path, c.movedFrom ?? c.path]),
568
+ ...status.conflictRecords.map((r) => r.obsidianPath)
569
+ ]);
570
+ const synced = allRecords.filter(
571
+ (r) => r.status === "synced" && !dirtyPaths.has(r.obsidianPath)
572
+ ).length;
403
573
  const remoteCreated = status.remoteChanges.filter((c) => c.type === "created");
404
574
  const remoteModified = status.remoteChanges.filter((c) => c.type === "modified");
405
575
  const remoteDeleted = status.remoteChanges.filter((c) => c.type === "deleted");
576
+ const remoteMoved = status.remoteChanges.filter((c) => c.type === "moved");
577
+ const { folderMoves } = status;
406
578
  console.log(`
407
579
  ${header(`Tracked files: ${total}`)}`);
408
- console.log(` ${icons.synced} ${chalk6.green("synced")} ${synced}`);
580
+ console.log(` ${icons.synced} ${chalk5.green("synced")} ${synced}`);
409
581
  if (modifiedChanges.length > 0) {
410
- console.log(` ${icons.modified} ${chalk6.yellow("modified")} ${modifiedChanges.length}`);
582
+ console.log(` ${icons.modified} ${chalk5.yellow("modified")} ${modifiedChanges.length}`);
411
583
  }
412
- if (createdChanges.length > 0) {
413
- console.log(` ${icons.newFile} ${chalk6.cyan("new")} ${createdChanges.length}`);
584
+ if (movedChanges.length > 0) {
585
+ console.log(` ${icons.move} ${chalk5.cyan("moved")} ${movedChanges.length}`);
586
+ }
587
+ if (deletedChanges.length > 0) {
588
+ console.log(` ${chalk5.red("-")} ${chalk5.red("deleted")} ${deletedChanges.length}`);
414
589
  }
415
590
  if (conflictCount > 0) {
416
- console.log(` ${icons.conflictDot} ${chalk6.magenta("conflict")} ${conflictCount}`);
591
+ console.log(` ${icons.conflictDot} ${chalk5.magenta("conflict")} ${conflictCount}`);
592
+ }
593
+ if (createdChanges.length > 0) {
594
+ console.log(
595
+ ` ${icons.newFile} ${chalk5.cyan("new")} ${createdChanges.length} ${dimText("(untracked)")}`
596
+ );
417
597
  }
418
598
  if (modifiedChanges.length > 0) {
419
599
  console.log(`
420
600
  ${header("Modified files:")}`);
421
601
  for (const c of modifiedChanges.slice(0, 10)) {
422
602
  console.log(
423
- ` ${chalk6.yellow("~")} ${c.path.padEnd(30)} ${dimText("(local changed)")}`
603
+ ` ${chalk5.yellow("~")} ${c.path.padEnd(30)} ${dimText("(local changed)")}`
424
604
  );
425
605
  }
426
606
  if (modifiedChanges.length > 10) {
427
607
  console.log(dimText(` ... and ${modifiedChanges.length - 10} more`));
428
608
  }
429
609
  }
610
+ if (folderMoves.length + movedChanges.length > 0) {
611
+ console.log(`
612
+ ${header("Moved:")}`);
613
+ for (const move of folderMoves) {
614
+ console.log(` ${icons.move} ${move.from}/ \u2192 ${move.to}/ ${dimText("(folder)")}`);
615
+ }
616
+ for (const c of movedChanges.slice(0, 10)) {
617
+ console.log(` ${icons.move} ${c.movedFrom ?? "?"} \u2192 ${c.path}`);
618
+ }
619
+ if (movedChanges.length > 10) {
620
+ console.log(dimText(` ... and ${movedChanges.length - 10} more`));
621
+ }
622
+ }
623
+ if (deletedChanges.length > 0) {
624
+ console.log(`
625
+ ${header("Deleted files:")}`);
626
+ for (const c of deletedChanges.slice(0, 10)) {
627
+ console.log(` ${chalk5.red("-")} ${c.path}`);
628
+ }
629
+ if (deletedChanges.length > 10) {
630
+ console.log(dimText(` ... and ${deletedChanges.length - 10} more`));
631
+ }
632
+ }
430
633
  if (createdChanges.length > 0) {
431
634
  console.log(`
432
635
  ${header("New files:")}`);
433
636
  for (const c of createdChanges.slice(0, 10)) {
434
- console.log(` ${chalk6.cyan("+")} ${c.path.padEnd(30)} ${dimText("(untracked)")}`);
637
+ console.log(` ${chalk5.cyan("+")} ${c.path.padEnd(30)} ${dimText("(untracked)")}`);
435
638
  }
436
639
  if (createdChanges.length > 10) {
437
640
  console.log(dimText(` ... and ${createdChanges.length - 10} more`));
@@ -441,19 +644,25 @@ ${header(" Sync Status")}`);
441
644
  console.log(`
442
645
  ${header(`Remote changes (Notion): ${status.remoteChanges.length}`)}`);
443
646
  if (remoteCreated.length > 0) {
444
- console.log(` ${chalk6.cyan("+")} ${chalk6.cyan("new")} ${remoteCreated.length}`);
647
+ console.log(` ${chalk5.cyan("+")} ${chalk5.cyan("new")} ${remoteCreated.length}`);
445
648
  }
446
649
  if (remoteModified.length > 0) {
447
650
  console.log(
448
- ` ${chalk6.yellow("~")} ${chalk6.yellow("modified")} ${remoteModified.length}`
651
+ ` ${chalk5.yellow("~")} ${chalk5.yellow("modified")} ${remoteModified.length}`
449
652
  );
450
653
  }
654
+ if (remoteMoved.length > 0) {
655
+ console.log(` ${icons.move} ${chalk5.cyan("moved")} ${remoteMoved.length}`);
656
+ }
451
657
  if (remoteDeleted.length > 0) {
452
- console.log(` ${chalk6.red("-")} ${chalk6.red("deleted")} ${remoteDeleted.length}`);
658
+ console.log(` ${chalk5.red("-")} ${chalk5.red("deleted")} ${remoteDeleted.length}`);
453
659
  }
454
660
  for (const c of status.remoteChanges.slice(0, 10)) {
455
- const icon = c.type === "created" ? chalk6.cyan("+") : c.type === "modified" ? chalk6.yellow("~") : chalk6.red("-");
456
- console.log(` ${icon} ${c.pageId.slice(0, 8)}... ${dimText(`(${c.type})`)}`);
661
+ const icon = c.type === "created" ? chalk5.cyan("+") : c.type === "modified" ? chalk5.yellow("~") : c.type === "moved" ? icons.move : chalk5.red("-");
662
+ const name = c.path ?? (c.title ? `${c.title} ${dimText("(new page)")}` : void 0);
663
+ console.log(
664
+ ` ${icon} ${name ?? `${c.pageId.slice(0, 8)}...`} ${dimText(`(${c.type})`)}`
665
+ );
457
666
  }
458
667
  if (status.remoteChanges.length > 10) {
459
668
  console.log(dimText(` ... and ${status.remoteChanges.length - 10} more`));
@@ -461,33 +670,43 @@ ${header(" Sync Status")}`);
461
670
  }
462
671
  if (conflictCount > 0) {
463
672
  console.log(`
464
- ${header(chalk6.magenta("Conflicts:"))}`);
673
+ ${header(chalk5.magenta("Conflicts:"))}`);
674
+ const remoteDeleted2 = new Set(
675
+ status.conflicts.filter(isRemoteDeletion).map((c) => c.syncRecord.obsidianPath)
676
+ );
465
677
  for (const r of status.conflictRecords) {
466
- console.log(
467
- ` ${icons.conflict} ${r.obsidianPath.padEnd(30)} ${dimText("(both sides changed)")}`
468
- );
678
+ const reason = remoteDeleted2.has(r.obsidianPath) ? "(deleted in Notion, local edits not pushed)" : "(both sides changed)";
679
+ console.log(` ${icons.conflict} ${r.obsidianPath.padEnd(30)} ${dimText(reason)}`);
469
680
  }
470
681
  }
471
- const hasChanges = status.localChanges.length > 0 || status.remoteChanges.length > 0 || conflictCount > 0;
682
+ const hasChanges = status.localChanges.length > 0 || folderMoves.length > 0 || status.remoteChanges.length > 0 || conflictCount > 0;
472
683
  if (hasChanges) {
473
684
  console.log("");
474
685
  if (conflictCount > 0) {
475
686
  console.log(
476
- ` ${dimText("Run")} ${chalk6.cyan("nobsi resolve")} ${dimText("to resolve conflicts")}`
687
+ ` ${dimText("Run")} ${chalk5.cyan("nobsi resolve")} ${dimText("to resolve conflicts")}`
477
688
  );
478
689
  }
479
690
  console.log(
480
- ` ${dimText("Run")} ${chalk6.cyan("nobsi sync")} ${dimText("to push/pull changes")}`
691
+ ` ${dimText("Run")} ${chalk5.cyan("nobsi sync")} ${dimText("to push/pull changes")}`
481
692
  );
693
+ if (modifiedChanges.length + deletedChanges.length > 0) {
694
+ console.log(
695
+ ` ${dimText("Run")} ${chalk5.cyan("nobsi discard <path>")} ${dimText("to discard local edits")}`
696
+ );
697
+ }
482
698
  } else {
483
699
  console.log(`
484
- ${chalk6.green("Everything up to date")} \u2713`);
700
+ ${chalk5.green("Everything up to date")} \u2713`);
485
701
  }
486
702
  if (!opts.full) {
487
703
  console.log(
488
704
  `
489
- ${dimText("Run")} ${chalk6.cyan("nobsi status --full")} ${dimText("to check Notion remote changes")}`
705
+ ${dimText("Run")} ${chalk5.cyan("nobsi status --full")} ${dimText("to check Notion remote changes")}`
490
706
  );
707
+ } else if (status.remoteScan?.deletionsDeferred) {
708
+ console.log(`
709
+ ${remoteDeletionHint(status.remoteScan)}`);
491
710
  }
492
711
  } finally {
493
712
  stateDb.close();
@@ -501,10 +720,35 @@ import {
501
720
  StateDB as StateDB5,
502
721
  NotionClient as NotionClient6,
503
722
  SyncOrchestrator as SyncOrchestrator5,
504
- NodeVaultFS as NodeVaultFS5
723
+ NodeVaultFS as NodeVaultFS5,
724
+ formatUnifiedDiff,
725
+ lineDiff,
726
+ matchesPathScope
505
727
  } from "@im-nobsidian/core";
506
- import { createTwoFilesPatch } from "diff";
507
- var diffCommand = new Command6("diff").description("\uB85C\uCEEC\uACFC Notion \uAC04\uC758 \uCC28\uC774 \uD45C\uC2DC").argument("[path]", "\uD2B9\uC815 \uD30C\uC77C \uACBD\uB85C (\uC0DD\uB7B5 \uC2DC \uBCC0\uACBD\uB41C \uBAA8\uB4E0 \uD30C\uC77C)").option("--color", "\uCEEC\uB7EC \uCD9C\uB825", true).action(async (targetPath, options) => {
728
+
729
+ // src/utils/diff-output.ts
730
+ import { DIFF_SIGN } from "@im-nobsidian/core";
731
+ var BOLD = "\x1B[1m";
732
+ var RED = "\x1B[31m";
733
+ var GREEN = "\x1B[32m";
734
+ var CYAN = "\x1B[36m";
735
+ var RESET = "\x1B[0m";
736
+ var LINE_COLOR = {
737
+ [DIFF_SIGN.added]: GREEN,
738
+ [DIFF_SIGN.removed]: RED,
739
+ "@": CYAN
740
+ };
741
+ function printUnifiedDiff(lines, color) {
742
+ lines.forEach((line, index) => {
743
+ const code = index < 2 ? BOLD : LINE_COLOR[line.charAt(0)];
744
+ console.log(color && code ? `${code}${line}${RESET}` : line);
745
+ });
746
+ }
747
+
748
+ // src/commands/diff.ts
749
+ var diffCommand = new Command6("diff").description(
750
+ "\uB85C\uCEEC \uBCC0\uACBD\uC0AC\uD56D \uD45C\uC2DC (\uAE30\uBCF8: \uB9C8\uC9C0\uB9C9 \uB3D9\uAE30\uD654 \uC2DC\uC810\uACFC \uBE44\uAD50, --remote: Notion \uD604\uC7AC\uBCF8\uACFC \uBE44\uAD50)"
751
+ ).argument("[path]", "\uD2B9\uC815 \uD30C\uC77C \uACBD\uB85C (\uC0DD\uB7B5 \uC2DC \uBCC0\uACBD\uB41C \uBAA8\uB4E0 \uD30C\uC77C)").option("--color", "\uCEEC\uB7EC \uCD9C\uB825", true).option("--remote", "Notion \uD604\uC7AC \uBCF8\uBB38\uACFC \uBE44\uAD50 (API \uD638\uCD9C, \uB290\uB9BC)").action(async (targetPath, options) => {
508
752
  const cwd = process.cwd();
509
753
  const configManager = new ConfigManager6(cwd);
510
754
  const config = await configManager.load();
@@ -514,73 +758,71 @@ var diffCommand = new Command6("diff").description("\uB85C\uCEEC\uACFC Notion \u
514
758
  const vaultFs = new NodeVaultFS5(cwd, config.paths);
515
759
  const orchestrator = new SyncOrchestrator5(config, stateDb, client, vaultFs);
516
760
  const status = await orchestrator.statusLocal();
517
- const changes = targetPath ? status.localChanges.filter((c) => c.path === targetPath || c.path.startsWith(targetPath)) : status.localChanges;
761
+ const changes = targetPath ? status.localChanges.filter((c) => matchesPathScope(c.path, targetPath)) : status.localChanges;
518
762
  if (changes.length === 0) {
519
763
  console.log("\uBCC0\uACBD\uC0AC\uD56D \uC5C6\uC74C");
520
764
  return;
521
765
  }
766
+ let failed = 0;
522
767
  for (const change of changes) {
523
- const record = stateDb.getByPath(change.path);
524
- if (change.type === "created") {
525
- const content = await vaultFs.readFile(change.path);
526
- const patch = createTwoFilesPatch("/dev/null", `b/${change.path}`, "", content);
527
- printPatch(patch, options.color, "/dev/null", `b/${change.path}`);
528
- } else if (change.type === "deleted") {
529
- if (record?.baseSnapshot) {
530
- const oldContent = record.baseSnapshot.toString("utf-8");
531
- const patch = createTwoFilesPatch(`a/${change.path}`, "/dev/null", oldContent, "");
532
- printPatch(patch, options.color, `a/${change.path}`, "/dev/null");
533
- } else {
534
- console.log(`--- a/${change.path}`);
535
- console.log(`+++ /dev/null`);
536
- console.log(`(\uC774\uC804 \uC2A4\uB0C5\uC0F7 \uC5C6\uC74C)`);
768
+ if (options.remote && change.type !== "deleted") {
769
+ let remotePath = change.path;
770
+ let remote = await orchestrator.renderRemoteSnapshot(remotePath);
771
+ if (remote === null && change.movedFrom) {
772
+ remotePath = change.movedFrom;
773
+ remote = await orchestrator.renderRemoteSnapshot(remotePath);
774
+ }
775
+ if (remote === null) {
776
+ console.log(`--- (\uC6D0\uACA9 \uC5C6\uC74C) ${change.path}`);
777
+ console.log(`+++ b/${change.path}`);
778
+ console.log(`(\uC544\uC9C1 Notion \uC5D0 \uC5C6\uB294 \uD30C\uC77C\uC785\uB2C8\uB2E4)`);
779
+ console.log("");
780
+ continue;
537
781
  }
538
- } else if (change.type === "modified" && record?.baseSnapshot) {
539
782
  const currentContent = await vaultFs.readFile(change.path);
540
- const baseContent = record.baseSnapshot.toString("utf-8");
541
- const patch = createTwoFilesPatch(
542
- `a/${change.path}`,
543
- `b/${change.path}`,
544
- baseContent,
545
- currentContent
546
- );
547
- printPatch(patch, options.color, `a/${change.path}`, `b/${change.path}`);
783
+ const hunks = lineDiff(remote, currentContent);
784
+ const lines = formatUnifiedDiff(hunks, `notion/${remotePath}`, `b/${change.path}`);
785
+ if (hunks.length === 0) lines.push("(Notion \uACFC \uB0B4\uC6A9\uC774 \uAC19\uC2B5\uB2C8\uB2E4)");
786
+ printUnifiedDiff(lines, options.color);
787
+ console.log("");
788
+ continue;
548
789
  }
790
+ let diff;
791
+ try {
792
+ diff = await orchestrator.localChangeDiff(change);
793
+ } catch (error) {
794
+ failed++;
795
+ console.log(`--- a/${change.path}`);
796
+ console.log(`(${error instanceof Error ? error.message : String(error)})`);
797
+ console.log("");
798
+ continue;
799
+ }
800
+ printChangeDiff(diff, options.color);
549
801
  console.log("");
550
802
  }
803
+ if (failed > 0) process.exitCode = 1;
551
804
  } finally {
552
805
  stateDb.close();
553
806
  }
554
807
  });
555
- function printPatch(patch, color, oldPath, newPath) {
556
- for (const line of patch.split("\n")) {
557
- let output = line;
558
- if (line.startsWith("===")) {
559
- output = "===================================================================";
560
- } else if (line.startsWith("--- ")) {
561
- output = `--- ${oldPath}`;
562
- } else if (line.startsWith("+++ ")) {
563
- output = `+++ ${newPath}`;
564
- }
565
- if (!color) {
566
- console.log(output);
567
- continue;
568
- }
569
- if (output.startsWith("+++") || output.startsWith("---")) {
570
- console.log(`\x1B[1m${output}\x1B[0m`);
571
- } else if (output.startsWith("+")) {
572
- console.log(`\x1B[32m${output}\x1B[0m`);
573
- } else if (output.startsWith("-")) {
574
- console.log(`\x1B[31m${output}\x1B[0m`);
575
- } else if (output.startsWith("@@")) {
576
- console.log(`\x1B[36m${output}\x1B[0m`);
577
- } else {
578
- console.log(output);
808
+ function printChangeDiff(diff, color) {
809
+ const oldPath = diff.before === null ? "/dev/null" : `a/${diff.movedFrom ?? diff.path}`;
810
+ const newPath = diff.after === null ? "/dev/null" : `b/${diff.path}`;
811
+ if (diff.movedFrom) {
812
+ console.log(`rename from ${diff.movedFrom}`);
813
+ console.log(`rename to ${diff.path}`);
814
+ if (diff.before === diff.after) {
815
+ console.log("(\uB0B4\uC6A9\uC740 \uADF8\uB300\uB85C\uC785\uB2C8\uB2E4 \u2014 \uC790\uB9AC\uB9CC \uC62E\uACBC\uC2B5\uB2C8\uB2E4)");
816
+ return;
579
817
  }
580
818
  }
819
+ printUnifiedDiff(
820
+ formatUnifiedDiff(lineDiff(diff.before ?? "", diff.after ?? ""), oldPath, newPath),
821
+ color
822
+ );
581
823
  }
582
824
 
583
- // src/commands/fetch.ts
825
+ // src/commands/discard.ts
584
826
  import { Command as Command7 } from "commander";
585
827
  import {
586
828
  ConfigManager as ConfigManager7,
@@ -589,8 +831,7 @@ import {
589
831
  SyncOrchestrator as SyncOrchestrator6,
590
832
  NodeVaultFS as NodeVaultFS6
591
833
  } from "@im-nobsidian/core";
592
- import chalk7 from "chalk";
593
- var fetchCommand = new Command7("fetch").description("Notion \uB9AC\uBAA8\uD2B8 \uC0C1\uD0DC \uC2A4\uCE94 (\uC0AD\uC81C \uAC10\uC9C0 \uD3EC\uD568)").action(async () => {
834
+ var discardCommand = new Command7("discard").description("\uB85C\uCEEC \uBCC0\uACBD\uC744 \uC9C0\uB09C \uB3D9\uAE30\uD654 \uB54C\uC758 \uAE00\uB85C \uB418\uB3CC\uB9AC\uAE30 (Notion \uC740 \uADF8\uB300\uB85C)").argument("<paths...>", "\uB418\uB3CC\uB9B4 \uB178\uD2B8 \uACBD\uB85C \u2014 status \uAC00 \uBCF4\uC778 \uBCFC\uD2B8 \uAE30\uC900 \uACBD\uB85C").action(async (paths) => {
594
835
  const cwd = process.cwd();
595
836
  const configManager = new ConfigManager7(cwd);
596
837
  const config = await configManager.load();
@@ -599,27 +840,62 @@ var fetchCommand = new Command7("fetch").description("Notion \uB9AC\uBAA8\uD2B8
599
840
  const client = NotionClient7.fromConfig(config);
600
841
  const vaultFs = new NodeVaultFS6(cwd, config.paths);
601
842
  const orchestrator = new SyncOrchestrator6(config, stateDb, client, vaultFs);
843
+ let failed = 0;
844
+ for (const path of paths) {
845
+ try {
846
+ await orchestrator.discardLocalChange(path);
847
+ console.log(` ${icons.success} ${path}`);
848
+ } catch (error) {
849
+ failed++;
850
+ failedItem(path, error instanceof Error ? error.message : String(error));
851
+ }
852
+ }
853
+ if (failed > 0) process.exitCode = 1;
854
+ } finally {
855
+ stateDb.close();
856
+ }
857
+ });
858
+
859
+ // src/commands/fetch.ts
860
+ import { Command as Command8 } from "commander";
861
+ import {
862
+ ConfigManager as ConfigManager8,
863
+ StateDB as StateDB7,
864
+ NotionClient as NotionClient8,
865
+ SyncOrchestrator as SyncOrchestrator7,
866
+ NodeVaultFS as NodeVaultFS7
867
+ } from "@im-nobsidian/core";
868
+ import chalk6 from "chalk";
869
+ var fetchCommand = new Command8("fetch").description("Notion \uB9AC\uBAA8\uD2B8 \uC0C1\uD0DC \uC2A4\uCE94 (\uC0AD\uC81C \uAC10\uC9C0 \uD3EC\uD568)").action(async () => {
870
+ const cwd = process.cwd();
871
+ const configManager = new ConfigManager8(cwd);
872
+ const config = await configManager.load();
873
+ const stateDb = StateDB7.open(configManager.dbPath);
874
+ try {
875
+ const client = NotionClient8.fromConfig(config);
876
+ const vaultFs = new NodeVaultFS7(cwd, config.paths);
877
+ const orchestrator = new SyncOrchestrator7(config, stateDb, client, vaultFs);
602
878
  console.log(dimText(" Scanning remote pages..."));
603
879
  const result = await orchestrator.fetch();
604
880
  console.log(`
605
881
  ${header(" Remote Scan")}`);
606
882
  console.log(` ${separator(40)}`);
607
883
  if (result.newPages > 0) {
608
- console.log(` ${chalk7.cyan("+")} New pages: ${result.newPages}`);
884
+ console.log(` ${chalk6.cyan("+")} New pages: ${result.newPages}`);
609
885
  }
610
886
  if (result.modifiedPages > 0) {
611
- console.log(` ${chalk7.yellow("~")} Modified pages: ${result.modifiedPages}`);
887
+ console.log(` ${chalk6.yellow("~")} Modified pages: ${result.modifiedPages}`);
612
888
  }
613
889
  if (result.deletedPages > 0) {
614
- console.log(` ${chalk7.red("-")} Deleted pages: ${result.deletedPages}`);
890
+ console.log(` ${chalk6.red("-")} Deleted pages: ${result.deletedPages}`);
615
891
  }
616
892
  const total = result.newPages + result.modifiedPages + result.deletedPages;
617
893
  if (total === 0) {
618
- console.log(` ${chalk7.green("Remote is up to date")} \u2713`);
894
+ console.log(` ${chalk6.green("Remote is up to date")} \u2713`);
619
895
  } else {
620
896
  console.log(
621
897
  `
622
- ${dimText("Run")} ${chalk7.cyan("nobsi pull")} ${dimText("to apply remote changes")}`
898
+ ${dimText("Run")} ${chalk6.cyan("nobsi pull")} ${dimText("to apply remote changes")}`
623
899
  );
624
900
  }
625
901
  console.log(dimText(`
@@ -630,113 +906,205 @@ ${header(" Remote Scan")}`);
630
906
  });
631
907
 
632
908
  // src/commands/resolve.ts
633
- import { Command as Command8 } from "commander";
909
+ import { Command as Command9 } from "commander";
634
910
  import { select, confirm as confirm2 } from "@inquirer/prompts";
635
911
  import {
636
- ConfigManager as ConfigManager8,
637
- StateDB as StateDB7,
638
- NotionClient as NotionClient8,
639
- SyncOrchestrator as SyncOrchestrator7,
640
- NodeVaultFS as NodeVaultFS7
912
+ ConfigManager as ConfigManager9,
913
+ StateDB as StateDB8,
914
+ NotionClient as NotionClient9,
915
+ SyncOrchestrator as SyncOrchestrator8,
916
+ NodeVaultFS as NodeVaultFS8,
917
+ applicableChoices,
918
+ isRemoteDeletion as isRemoteDeletion2
641
919
  } from "@im-nobsidian/core";
642
- var resolveCommand = new Command8("resolve").description("\uCDA9\uB3CC\uC744 \uD574\uACB0\uD569\uB2C8\uB2E4").option("-s, --strategy <strategy>", "\uC790\uB3D9 \uD574\uACB0 \uC804\uB7B5 (local-first, remote-first, duplicate)").action(async (options) => {
920
+
921
+ // src/utils/state-db-failure.ts
922
+ import { StateDbUnavailableError } from "@im-nobsidian/core";
923
+ function describeFailure(error) {
924
+ const message = error instanceof Error ? error.message : String(error);
925
+ if (!(error instanceof StateDbUnavailableError)) return message;
926
+ return `${message} \u2014 ${error.guidance("\uBA85\uB839\uC744 \uB2E4\uC2DC \uC2E4\uD589\uD558\uC138\uC694")}`;
927
+ }
928
+ function reportStateDbFailure(error, proc) {
929
+ if (!(error instanceof StateDbUnavailableError)) throw error;
930
+ console.error(`\uC624\uB958: ${describeFailure(error)}`);
931
+ proc.exitCode = 1;
932
+ }
933
+
934
+ // src/commands/resolve.ts
935
+ var STRATEGIES = ["local-first", "remote-first", "duplicate"];
936
+ var CHOICE_NAMES = {
937
+ local: "\uB85C\uCEEC \uC720\uC9C0 (Obsidian \uBC84\uC804 \uC720\uC9C0)",
938
+ remote: "\uC6D0\uACA9 \uC720\uC9C0 (Notion \uBC84\uC804\uC73C\uB85C \uB36E\uC5B4\uC4F0\uAE30)",
939
+ merge: "\uC790\uB3D9 \uBCD1\uD569 (3-way merge)",
940
+ duplicate: "\uBCF5\uC81C (\uC6D0\uBCF8 \uC720\uC9C0 + \uC62C\uB9AC\uC9C0 \uC54A\uB294 .conflict \uC0AC\uBCF8 \uC0DD\uC131)"
941
+ };
942
+ var REMOTE_DELETION_CHOICE_NAMES = {
943
+ local: "\uB85C\uCEEC \uC720\uC9C0 (Notion \uC5D0 \uC0C8 \uD398\uC774\uC9C0\uB85C \uB2E4\uC2DC \uB9CC\uB4E4\uAE30)",
944
+ remote: "\uC0AD\uC81C \uB530\uB974\uAE30 (Obsidian \uD30C\uC77C\uB3C4 \uC9C0\uC6B0\uAE30)"
945
+ };
946
+ var PULL_ONLY_REMOTE_DELETION_CHOICE_NAMES = {
947
+ local: "\uB85C\uCEEC \uC720\uC9C0 (Obsidian \uD30C\uC77C\uB9CC \uB450\uAE30 \u2014 \uBC1B\uAE30 \uC804\uC6A9\uC774\uB77C Notion \uC5D0 \uB9CC\uB4E4\uC9C0 \uC54A\uC74C)"
948
+ };
949
+ function choiceName(conflict, choice, uploads) {
950
+ const deleted = isRemoteDeletion2(conflict);
951
+ return (deleted && !uploads ? PULL_ONLY_REMOTE_DELETION_CHOICE_NAMES[choice] : void 0) ?? (deleted ? REMOTE_DELETION_CHOICE_NAMES[choice] : void 0) ?? CHOICE_NAMES[choice] ?? choice;
952
+ }
953
+ function isInteractive() {
954
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
955
+ }
956
+ var resolveCommand = new Command9("resolve").description("\uCDA9\uB3CC\uC744 \uD574\uACB0\uD569\uB2C8\uB2E4").option("-s, --strategy <strategy>", `\uC790\uB3D9 \uD574\uACB0 \uC804\uB7B5 (${STRATEGIES.join(" | ")})`).option("-y, --yes", "\uD655\uC778 \uD504\uB86C\uD504\uD2B8 \uC5C6\uC774 \uC9C4\uD589 (\uBE44\uB300\uD654\uD615/CI)").action(async (options) => {
957
+ if (options.strategy && !STRATEGIES.includes(options.strategy)) {
958
+ console.error(`\uC54C \uC218 \uC5C6\uB294 \uC804\uB7B5 '${options.strategy}' \u2014 \uC0AC\uC6A9 \uAC00\uB2A5: ${STRATEGIES.join(", ")}`);
959
+ process.exitCode = 1;
960
+ return;
961
+ }
643
962
  const cwd = process.cwd();
644
- const configManager = new ConfigManager8(cwd);
963
+ const configManager = new ConfigManager9(cwd);
964
+ let stateDb;
645
965
  try {
646
966
  const config = await configManager.load();
647
- const stateDb = StateDB7.open(configManager.dbPath);
648
- const client = NotionClient8.fromConfig(config);
649
- const vaultFs = new NodeVaultFS7(cwd, config.paths);
650
- const orchestrator = new SyncOrchestrator7(config, stateDb, client, vaultFs);
651
- await orchestrator.status();
652
- const conflictRecords = stateDb.getByStatus("conflict");
653
- if (conflictRecords.length === 0) {
967
+ stateDb = StateDB8.open(configManager.dbPath);
968
+ const client = NotionClient9.fromConfig(config);
969
+ const vaultFs = new NodeVaultFS8(cwd, config.paths);
970
+ const orchestrator = new SyncOrchestrator8(config, stateDb, client, vaultFs);
971
+ const conflicts = await orchestrator.listConflicts();
972
+ if (conflicts.length === 0) {
654
973
  console.log("\uCDA9\uB3CC\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
655
- stateDb.close();
974
+ return;
975
+ }
976
+ const cleared = new Set(orchestrator.clearStaleConflicts(conflicts));
977
+ for (const path of cleared) {
978
+ console.log(` \x1B[32m\u2713\x1B[0m ${path} \u2192 \uC591\uCABD \uB3D9\uC77C, \uCDA9\uB3CC \uD45C\uC2DC\uB9CC \uD574\uC81C`);
979
+ }
980
+ const pending = conflicts.filter((c) => !cleared.has(c.syncRecord.obsidianPath));
981
+ if (pending.length === 0) {
982
+ console.log(`
983
+ ${cleared.size}\uAC74 \uBAA8\uB450 \uC774\uBBF8 \uD574\uC18C\uB41C \uC0C1\uD0DC\uC600\uC2B5\uB2C8\uB2E4 \u2014 \uC0C1\uD0DC\uB9CC \uC815\uB9AC\uD588\uC2B5\uB2C8\uB2E4.`);
656
984
  return;
657
985
  }
658
986
  console.log(`
659
- \uCDA9\uB3CC ${conflictRecords.length}\uAC74 \uBC1C\uACAC
987
+ \uCDA9\uB3CC ${pending.length}\uAC74 \uBC1C\uACAC
660
988
  `);
989
+ const uploads = config.sync.direction !== "pull";
990
+ if (!uploads) {
991
+ console.log(
992
+ " \uBC1B\uAE30 \uC804\uC6A9(direction: pull) \u2014 \uD574\uACB0 \uACB0\uACFC\uB294 Obsidian \uC5D0\uB9CC \uB450\uACE0 Notion \uC5D0\uB294 \uC62C\uB9AC\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4\n"
993
+ );
994
+ }
661
995
  if (options.strategy) {
662
996
  const strategy = options.strategy;
663
- const shouldProceed = await confirm2({
664
- message: `${conflictRecords.length}\uAC74\uC758 \uCDA9\uB3CC\uC744 '${strategy}' \uC804\uB7B5\uC73C\uB85C \uC77C\uAD04 \uD574\uACB0\uD560\uAE4C\uC694?`
665
- });
666
- if (!shouldProceed) {
667
- stateDb.close();
668
- return;
997
+ const deletions = pending.filter(isRemoteDeletion2).length;
998
+ if (deletions > 0) {
999
+ console.log(
1000
+ ` Notion \uC5D0\uC11C \uC0AD\uC81C\uB41C \uB178\uD2B8 ${deletions}\uAC74 \u2014 ${strategy === "remote-first" ? "Obsidian \uD30C\uC77C\uB3C4 \uC9C0\uC6C1\uB2C8\uB2E4" : uploads ? "Notion \uC5D0 \uC0C8 \uD398\uC774\uC9C0\uB85C \uB2E4\uC2DC \uB9CC\uB4ED\uB2C8\uB2E4" : "Obsidian \uD30C\uC77C\uC744 \uADF8\uB300\uB85C \uB461\uB2C8\uB2E4"}`
1001
+ );
669
1002
  }
670
- const pullResult = await orchestrator.pull();
671
- const results = await orchestrator.resolveAllConflicts(pullResult.conflicts, strategy);
672
- for (const result of results) {
673
- const icon = result.success ? "\x1B[32m\u2713\x1B[0m" : "\x1B[31m\u2717\x1B[0m";
674
- console.log(` ${icon} ${result.path} \u2192 ${result.choice}`);
1003
+ if (!options.yes) {
1004
+ if (!isInteractive()) {
1005
+ console.error(
1006
+ `\uBE44\uB300\uD654\uD615 \uD658\uACBD\uC785\uB2C8\uB2E4 \u2014 '${strategy}' \uC804\uB7B5\uC73C\uB85C ${pending.length}\uAC74\uC744 \uC77C\uAD04 \uD574\uACB0\uD558\uB824\uBA74 --yes \uB97C \uD568\uAED8 \uC9C0\uC815\uD558\uC138\uC694.`
1007
+ );
1008
+ process.exitCode = 1;
1009
+ return;
1010
+ }
1011
+ const shouldProceed = await confirm2({
1012
+ message: `${pending.length}\uAC74\uC758 \uCDA9\uB3CC\uC744 '${strategy}' \uC804\uB7B5\uC73C\uB85C \uC77C\uAD04 \uD574\uACB0\uD560\uAE4C\uC694?`
1013
+ });
1014
+ if (!shouldProceed) return;
675
1015
  }
676
- } else {
677
- const pullResult = await orchestrator.pull();
678
- for (const conflict of pullResult.conflicts) {
679
- await resolveInteractive(conflict, orchestrator);
1016
+ const results = await orchestrator.resolveAllConflicts(pending, strategy);
1017
+ let failed2 = 0;
1018
+ for (const [index, result] of results.entries()) {
1019
+ if (!result.success) failed2++;
1020
+ const icon = result.success ? "\x1B[32m\u2713\x1B[0m" : "\x1B[31m\u2717\x1B[0m";
1021
+ const reason = result.error ? ` \u2014 ${result.error}` : "";
1022
+ console.log(
1023
+ ` ${icon} ${result.path} \u2192 ${choiceName(pending[index], result.choice, uploads)}${reason}`
1024
+ );
680
1025
  }
1026
+ report(results.length - failed2, failed2);
1027
+ return;
681
1028
  }
682
- stateDb.close();
683
- console.log("\n\uCDA9\uB3CC \uD574\uACB0 \uC644\uB8CC");
1029
+ if (!isInteractive()) {
1030
+ console.error(
1031
+ `\uBE44\uB300\uD654\uD615 \uD658\uACBD\uC5D0\uC11C\uB294 \uB300\uD654\uD615 \uD574\uACB0\uC744 \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4 \u2014 --strategy <${STRATEGIES.join("|")}> --yes \uB97C \uC9C0\uC815\uD558\uC138\uC694.`
1032
+ );
1033
+ process.exitCode = 1;
1034
+ return;
1035
+ }
1036
+ let resolved = 0;
1037
+ let failed = 0;
1038
+ for (const conflict of pending) {
1039
+ if (await resolveInteractive(conflict, orchestrator, uploads)) resolved++;
1040
+ else failed++;
1041
+ }
1042
+ report(resolved, failed);
684
1043
  } catch (error) {
685
- console.error("\uC624\uB958:", error instanceof Error ? error.message : String(error));
686
- process.exit(1);
1044
+ console.error("\uC624\uB958:", describeFailure(error));
1045
+ process.exitCode = 1;
1046
+ } finally {
1047
+ stateDb?.close();
687
1048
  }
688
1049
  });
689
- async function resolveInteractive(conflict, orchestrator) {
1050
+ function report(resolved, failed) {
1051
+ if (failed === 0) {
1052
+ console.log(`
1053
+ \uCDA9\uB3CC \uD574\uACB0 \uC644\uB8CC \u2014 ${resolved}\uAC74`);
1054
+ return;
1055
+ }
1056
+ console.log(`
1057
+ \x1B[33m\uCDA9\uB3CC ${resolved}\uAC74 \uD574\uACB0 \xB7 ${failed}\uAC74 \uBBF8\uD574\uACB0\x1B[0m`);
1058
+ process.exitCode = 1;
1059
+ }
1060
+ async function resolveInteractive(conflict, orchestrator, uploads) {
690
1061
  const path = conflict.syncRecord.obsidianPath;
691
1062
  console.log(`
692
1063
  ${"\u2500".repeat(60)}`);
693
1064
  console.log(`\uD30C\uC77C: \x1B[1m${path}\x1B[0m`);
694
1065
  console.log(`${"\u2500".repeat(60)}`);
695
- const diff = orchestrator.generateConflictDiff(conflict);
696
- const diffLines = diff.split("\n");
697
- for (const line of diffLines) {
698
- if (line.startsWith("- ")) {
699
- console.log(`\x1B[31m${line}\x1B[0m`);
700
- } else if (line.startsWith("+ ")) {
701
- console.log(`\x1B[32m${line}\x1B[0m`);
702
- } else if (line.startsWith("---") || line.startsWith("+++")) {
703
- console.log(`\x1B[1m${line}\x1B[0m`);
704
- } else {
705
- console.log(line);
706
- }
1066
+ if (isRemoteDeletion2(conflict)) {
1067
+ console.log(
1068
+ "\x1B[33mNotion \uC5D0\uC11C \uC0AD\uC81C\uB41C \uB178\uD2B8\uC785\uB2C8\uB2E4 \u2014 Notion \uC5D0 \uC62C\uB9AC\uC9C0 \uC54A\uC740 \uB85C\uCEEC \uD3B8\uC9D1\uC774 \uB0A8\uC544 \uC788\uC2B5\uB2C8\uB2E4.\x1B[0m"
1069
+ );
1070
+ } else {
1071
+ printUnifiedDiff(orchestrator.generateConflictDiff(conflict).split("\n"), true);
707
1072
  }
708
1073
  const choice = await select({
709
1074
  message: "\uC5B4\uB5BB\uAC8C \uD574\uACB0\uD560\uAE4C\uC694?",
710
- choices: [
711
- { name: "\uB85C\uCEEC \uC720\uC9C0 (Obsidian \uBC84\uC804 \uC720\uC9C0)", value: "local" },
712
- { name: "\uC6D0\uACA9 \uC720\uC9C0 (Notion \uBC84\uC804\uC73C\uB85C \uB36E\uC5B4\uC4F0\uAE30)", value: "remote" },
713
- { name: "\uC790\uB3D9 \uBCD1\uD569 (3-way merge)", value: "merge" },
714
- { name: "\uBCF5\uC81C (\uC6D0\uBCF8 \uC720\uC9C0 + .conflict \uD30C\uC77C \uC0DD\uC131)", value: "duplicate" }
715
- ]
1075
+ choices: applicableChoices(conflict).map((value) => ({
1076
+ name: choiceName(conflict, value, uploads),
1077
+ value
1078
+ }))
716
1079
  });
717
1080
  const result = await orchestrator.resolveConflict(conflict, choice);
718
1081
  if (result.success) {
719
- console.log(`\x1B[32m\u2713 ${path} \u2192 ${choice}\uB85C \uD574\uACB0\x1B[0m`);
720
- } else if (result.mergeHadConflicts) {
1082
+ console.log(`\x1B[32m\u2713 ${path} \u2192 ${choiceName(conflict, choice, uploads)}\x1B[0m`);
1083
+ return true;
1084
+ }
1085
+ if (result.mergeHadConflicts) {
721
1086
  console.log(`\x1B[33m\u26A0 ${path} \u2192 \uC790\uB3D9 \uBCD1\uD569 \uC644\uB8CC (\uC218\uB3D9 \uD655\uC778 \uD544\uC694\uD55C \uCDA9\uB3CC \uB9C8\uCEE4 \uC788\uC74C)\x1B[0m`);
722
- } else {
723
- console.log(`\x1B[31m\u2717 ${path} \u2192 \uD574\uACB0 \uC2E4\uD328\x1B[0m`);
1087
+ return false;
724
1088
  }
1089
+ console.log(`\x1B[31m\u2717 ${path} \u2192 \uD574\uACB0 \uC2E4\uD328\x1B[0m`);
1090
+ return false;
725
1091
  }
726
1092
 
727
1093
  // src/commands/watch.ts
728
- import { Command as Command9 } from "commander";
1094
+ import { Command as Command10 } from "commander";
729
1095
  import {
730
- ConfigManager as ConfigManager9,
731
- StateDB as StateDB8,
732
- NotionClient as NotionClient9,
733
- SyncOrchestrator as SyncOrchestrator8,
734
- NodeVaultFS as NodeVaultFS8,
735
- WatchSyncService
1096
+ ConfigManager as ConfigManager10,
1097
+ StateDB as StateDB9,
1098
+ NotionClient as NotionClient10,
1099
+ SyncOrchestrator as SyncOrchestrator9,
1100
+ NodeVaultFS as NodeVaultFS9,
1101
+ WatchSyncService,
1102
+ DEFAULT_WATCH_DEBOUNCE_MS
736
1103
  } from "@im-nobsidian/core";
737
1104
  function formatResult(result) {
738
1105
  const pull = `Pull: +${result.pull.created} ~${result.pull.updated} -${result.pull.deleted}`;
739
- const push = `Push: +${result.push.created} ~${result.push.updated} -${result.push.deleted}`;
1106
+ const moved = result.push.moved > 0 ? ` \u2192${result.push.moved}` : "";
1107
+ const push = `Push: +${result.push.created} ~${result.push.updated} -${result.push.deleted}${moved}`;
740
1108
  const parts = [pull, push];
741
1109
  if (result.conflicts.length > 0) {
742
1110
  parts.push(`\uCDA9\uB3CC ${result.conflicts.length}\uAC74`);
@@ -746,35 +1114,59 @@ function formatResult(result) {
746
1114
  function timestamp() {
747
1115
  return (/* @__PURE__ */ new Date()).toLocaleTimeString("ko-KR", { hour12: false });
748
1116
  }
749
- var watchCommand = new Command9("watch").description("\uD30C\uC77C \uBCC0\uACBD \uAC10\uC9C0 + \uC790\uB3D9 \uB3D9\uAE30\uD654").option("-d, --debounce <ms>", "\uB514\uBC14\uC6B4\uC2A4 \uB300\uAE30 \uC2DC\uAC04(ms)", "2000").option("-i, --interval <seconds>", "\uD480 \uB3D9\uAE30\uD654 \uC8FC\uAE30(\uCD08, 0\uC774\uBA74 \uBE44\uD65C\uC131)", "0").action(async (options) => {
1117
+ function syncLabel(scope) {
1118
+ return scope === "full" ? "\uC8FC\uAE30\uC801 \uB3D9\uAE30\uD654" : "\uB3D9\uAE30\uD654";
1119
+ }
1120
+ var watchCommand = new Command10("watch").description("\uD30C\uC77C \uBCC0\uACBD \uAC10\uC9C0 + \uC790\uB3D9 \uB3D9\uAE30\uD654").option("-d, --debounce <ms>", "\uB514\uBC14\uC6B4\uC2A4 \uB300\uAE30 \uC2DC\uAC04(ms)", String(DEFAULT_WATCH_DEBOUNCE_MS)).option("-i, --interval <seconds>", "\uD480 \uB3D9\uAE30\uD654 \uC8FC\uAE30(\uCD08, 0\uC774\uBA74 \uBE44\uD65C\uC131)", "0").action(async (options) => {
1121
+ const debounceMs = Number(options.debounce);
1122
+ const intervalSec = Number(options.interval);
1123
+ if (!Number.isInteger(debounceMs) || debounceMs < 0) {
1124
+ console.error(`--debounce \uB294 0 \uC774\uC0C1\uC758 \uC815\uC218(ms)\uC5EC\uC57C \uD569\uB2C8\uB2E4: ${options.debounce}`);
1125
+ process.exitCode = 1;
1126
+ return;
1127
+ }
1128
+ if (!Number.isInteger(intervalSec) || intervalSec < 0) {
1129
+ console.error(`--interval \uC740 0 \uC774\uC0C1\uC758 \uC815\uC218(\uCD08)\uC5EC\uC57C \uD569\uB2C8\uB2E4: ${options.interval}`);
1130
+ process.exitCode = 1;
1131
+ return;
1132
+ }
750
1133
  const cwd = process.cwd();
751
- const configManager = new ConfigManager9(cwd);
1134
+ const configManager = new ConfigManager10(cwd);
752
1135
  const config = await configManager.load();
753
- const stateDb = StateDB8.open(configManager.dbPath);
754
- const client = NotionClient9.fromConfig(config);
755
- const vaultFs = new NodeVaultFS8(cwd, config.paths);
756
- const orchestrator = new SyncOrchestrator8(config, stateDb, client, vaultFs);
757
- const debounceMs = parseInt(options.debounce, 10);
758
- const intervalSec = parseInt(options.interval, 10);
1136
+ const stateDb = StateDB9.open(configManager.dbPath);
1137
+ const client = NotionClient10.fromConfig(config);
1138
+ const vaultFs = new NodeVaultFS9(cwd, config.paths);
1139
+ const orchestrator = new SyncOrchestrator9(config, stateDb, client, vaultFs);
759
1140
  const service = new WatchSyncService(cwd, orchestrator, {
760
1141
  debounceMs,
761
1142
  onFileChange: (event, path) => {
762
1143
  console.log(`[${timestamp()}] ${event}: ${path}`);
763
1144
  },
764
- onSyncStart: () => {
765
- console.log(`[${timestamp()}] \uB3D9\uAE30\uD654 \uC2DC\uC791...`);
1145
+ onSyncStart: (scope) => {
1146
+ console.log(`[${timestamp()}] ${syncLabel(scope)} \uC2DC\uC791...`);
766
1147
  },
767
- onSyncComplete: (result) => {
768
- console.log(`[${timestamp()}] \uB3D9\uAE30\uD654 \uC644\uB8CC \u2014 ${formatResult(result)}`);
1148
+ onSyncComplete: (result, scope) => {
1149
+ console.log(`[${timestamp()}] ${syncLabel(scope)} \uC644\uB8CC \u2014 ${formatResult(result)}`);
1150
+ },
1151
+ onSyncCancelled: (result, scope) => {
1152
+ console.log(
1153
+ `[${timestamp()}] ${syncLabel(scope)} \uCDE8\uC18C \u2014 \uBA48\uCD94\uAE30 \uC804\uAE4C\uC9C0 ${formatResult(result)}`
1154
+ );
769
1155
  },
770
- onSyncError: (error) => {
771
- console.error(`[${timestamp()}] \uB3D9\uAE30\uD654 \uC2E4\uD328: ${error.message}`);
1156
+ onSyncError: (error, scope) => {
1157
+ console.error(`[${timestamp()}] ${syncLabel(scope)} \uC2E4\uD328: ${error.message}`);
772
1158
  }
773
1159
  });
774
1160
  let pullTimer = null;
1161
+ let shuttingDown = false;
775
1162
  const shutdown = async () => {
1163
+ if (shuttingDown) {
1164
+ console.log(`[${timestamp()}] \uAC15\uC81C \uC885\uB8CC`);
1165
+ return process.exit(1);
1166
+ }
1167
+ shuttingDown = true;
776
1168
  console.log(`
777
- [${timestamp()}] \uAC10\uC2DC \uC885\uB8CC \uC911...`);
1169
+ [${timestamp()}] \uAC10\uC2DC \uC885\uB8CC \uC911... (\uB3C4\uB294 \uB3D9\uAE30\uD654\uB97C \uBA48\uCD94\uACE0 \uAE30\uB2E4\uB9BD\uB2C8\uB2E4)`);
778
1170
  if (pullTimer) clearInterval(pullTimer);
779
1171
  await service.stop();
780
1172
  stateDb.close();
@@ -787,26 +1179,148 @@ var watchCommand = new Command9("watch").description("\uD30C\uC77C \uBCC0\uACBD
787
1179
  console.log(`[${timestamp()}] \uD30C\uC77C \uAC10\uC2DC \uC2DC\uC791 (debounce: ${debounceMs}ms)`);
788
1180
  if (intervalSec > 0) {
789
1181
  console.log(`[${timestamp()}] \uD480 \uB3D9\uAE30\uD654 \uC8FC\uAE30: ${intervalSec}\uCD08`);
790
- pullTimer = setInterval(async () => {
791
- if (service.isSyncing()) return;
792
- try {
793
- console.log(`[${timestamp()}] \uC8FC\uAE30\uC801 \uB3D9\uAE30\uD654 \uC2DC\uC791...`);
794
- const result = await orchestrator.sync();
795
- console.log(`[${timestamp()}] \uC8FC\uAE30\uC801 \uB3D9\uAE30\uD654 \uC644\uB8CC \u2014 ${formatResult(result)}`);
796
- } catch (error) {
797
- const msg = error instanceof Error ? error.message : String(error);
798
- console.error(`[${timestamp()}] \uC8FC\uAE30\uC801 \uB3D9\uAE30\uD654 \uC2E4\uD328: ${msg}`);
799
- }
800
- }, intervalSec * 1e3);
1182
+ pullTimer = setInterval(() => service.requestFullSync(), intervalSec * 1e3);
801
1183
  }
802
1184
  await new Promise(() => {
803
1185
  });
804
1186
  });
805
1187
 
1188
+ // src/commands/verify.ts
1189
+ import { Command as Command11 } from "commander";
1190
+ import {
1191
+ ConfigManager as ConfigManager11,
1192
+ StateDB as StateDB10,
1193
+ NotionClient as NotionClient11,
1194
+ SyncOrchestrator as SyncOrchestrator10,
1195
+ NodeVaultFS as NodeVaultFS10
1196
+ } from "@im-nobsidian/core";
1197
+ import chalk7 from "chalk";
1198
+ var SAMPLE_LIMIT = 10;
1199
+ function printIdSample(label, ids, color) {
1200
+ console.log(` ${color(label)} ${ids.length}`);
1201
+ for (const id of ids.slice(0, SAMPLE_LIMIT)) {
1202
+ console.log(` ${dimText(id)}`);
1203
+ }
1204
+ if (ids.length > SAMPLE_LIMIT) {
1205
+ console.log(dimText(` ... and ${ids.length - SAMPLE_LIMIT} more`));
1206
+ }
1207
+ }
1208
+ function printDatabaseSection(report2) {
1209
+ console.log(`
1210
+ ${header(" DB Completeness")}`);
1211
+ console.log(` ${separator(50)}`);
1212
+ console.log(` ${dimText("Databases:")} ${report2.databases.length}`);
1213
+ console.log(` ${dimText("Remote rows:")} ${report2.remoteTotal}`);
1214
+ console.log(` ${dimText("Vault rows:")} ${report2.localTotal}`);
1215
+ const broken = report2.databases.filter((d) => d.missingIds.length > 0 || d.extraIds.length > 0);
1216
+ if (broken.length > 0) {
1217
+ console.log(`
1218
+ ${header(chalk7.red(`Incomplete databases: ${broken.length}`))}`);
1219
+ for (const db of broken) {
1220
+ console.log(
1221
+ ` ${icons.fail} ${db.databaseId} ${dimText(`remote ${db.remoteRows} / vault ${db.localRows}`)}`
1222
+ );
1223
+ if (db.missingIds.length > 0) printIdSample("missing", db.missingIds, chalk7.red);
1224
+ if (db.extraIds.length > 0) printIdSample("extra", db.extraIds, chalk7.yellow);
1225
+ }
1226
+ }
1227
+ if (report2.failures.length > 0) {
1228
+ console.log(`
1229
+ ${header(chalk7.yellow(`Query failures: ${report2.failures.length}`))}`);
1230
+ for (const failure of report2.failures) {
1231
+ console.log(` ${icons.fail} ${failure.databaseId} ${dimText(failure.error)}`);
1232
+ }
1233
+ }
1234
+ if (report2.complete) {
1235
+ console.log(`
1236
+ ${chalk7.green("All database rows present in vault")} ${icons.success}`);
1237
+ }
1238
+ }
1239
+ function printPageSection(report2) {
1240
+ console.log(`
1241
+ ${header(" Page Completeness")}`);
1242
+ console.log(` ${separator(50)}`);
1243
+ console.log(` ${dimText("Remote pages:")} ${report2.remotePages}`);
1244
+ console.log(` ${dimText("Vault pages:")} ${report2.vaultPages}`);
1245
+ if (report2.error) {
1246
+ console.log(`
1247
+ ${icons.fail} ${chalk7.yellow("Page enumeration failed")}`);
1248
+ console.log(` ${dimText(report2.error)}`);
1249
+ return;
1250
+ }
1251
+ if (report2.missingIds.length > 0) {
1252
+ console.log(
1253
+ `
1254
+ ${header(chalk7.red(`Pages missing from vault: ${report2.missingIds.length}`))}`
1255
+ );
1256
+ printIdSample("missing", report2.missingIds, chalk7.red);
1257
+ }
1258
+ if (report2.localOnlyIds.length > 0) {
1259
+ console.log(
1260
+ `
1261
+ ${dimText(`local-only pages: ${report2.localOnlyIds.length} (informational)`)}`
1262
+ );
1263
+ printIdSample("local-only", report2.localOnlyIds, dimText);
1264
+ }
1265
+ if (report2.complete) {
1266
+ console.log(`
1267
+ ${chalk7.green("All remote pages present in vault")} ${icons.success}`);
1268
+ }
1269
+ }
1270
+ function printReport(report2) {
1271
+ printDatabaseSection(report2.databases);
1272
+ if (report2.pages) {
1273
+ printPageSection(report2.pages);
1274
+ } else {
1275
+ console.log(`
1276
+ ${dimText("Page completeness: skipped (database mode)")}`);
1277
+ }
1278
+ if (!report2.complete) {
1279
+ console.log(
1280
+ `
1281
+ ${dimText("Run")} ${chalk7.cyan("nobsi pull")} ${dimText("to fetch what is missing")}`
1282
+ );
1283
+ }
1284
+ }
1285
+ var verifyCommand = new Command11("verify").description("\uC644\uACB0\uC131 \uAC80\uC99D \u2014 \uC6D0\uACA9 DB \uD589\xB7\uD398\uC774\uC9C0\uAC00 \uBCFC\uD2B8\uC5D0 \uBE60\uC9D0\uC5C6\uC774 \uC788\uB294\uC9C0 \uB300\uC870 (API \uD638\uCD9C)").option("--json", "\uACB0\uACFC\uB97C JSON \uC73C\uB85C \uCD9C\uB825").action(async (opts) => {
1286
+ const cwd = process.cwd();
1287
+ const configManager = new ConfigManager11(cwd);
1288
+ const config = await configManager.load();
1289
+ const stateDb = StateDB10.open(configManager.dbPath);
1290
+ try {
1291
+ const client = NotionClient11.fromConfig(config);
1292
+ const vaultFs = new NodeVaultFS10(cwd, config.paths);
1293
+ const orchestrator = new SyncOrchestrator10(config, stateDb, client, vaultFs);
1294
+ const report2 = await orchestrator.verifyCompleteness();
1295
+ if (opts.json) {
1296
+ console.log(JSON.stringify(report2, null, 2));
1297
+ } else {
1298
+ printReport(report2);
1299
+ }
1300
+ if (!report2.complete) process.exitCode = 1;
1301
+ } finally {
1302
+ stateDb.close();
1303
+ }
1304
+ });
1305
+
1306
+ // src/utils/exit.ts
1307
+ var FORCED_EXIT_DELAY_MS = 100;
1308
+ function resolveExitCode(exitCode) {
1309
+ if (typeof exitCode === "number") return exitCode;
1310
+ if (typeof exitCode === "string") {
1311
+ const parsed = Number(exitCode);
1312
+ return Number.isFinite(parsed) ? parsed : 0;
1313
+ }
1314
+ return 0;
1315
+ }
1316
+ function scheduleForcedExit(proc, schedule = setTimeout) {
1317
+ schedule(() => proc.exit(resolveExitCode(proc.exitCode)), FORCED_EXIT_DELAY_MS);
1318
+ }
1319
+
806
1320
  // src/index.ts
807
1321
  var requireJson = createRequire(import.meta.url);
808
1322
  var { version } = requireJson("../package.json");
809
- var program = new Command10();
1323
+ var program = new Command12();
810
1324
  program.name("im-nobsidian").description("Im-Nobsidian: Obsidian \u2194 Notion \uC591\uBC29\uD5A5 \uB3D9\uAE30\uD654 CLI").version(version).option("--verbose", "\uC0C1\uC138 \uB85C\uADF8 \uCD9C\uB825").option("--quiet", "\uCD5C\uC18C \uCD9C\uB825");
811
1325
  program.addCommand(initCommand);
812
1326
  program.addCommand(pushCommand);
@@ -814,10 +1328,18 @@ program.addCommand(pullCommand);
814
1328
  program.addCommand(syncCommand);
815
1329
  program.addCommand(statusCommand);
816
1330
  program.addCommand(diffCommand);
1331
+ program.addCommand(discardCommand);
817
1332
  program.addCommand(fetchCommand);
818
1333
  program.addCommand(resolveCommand);
819
1334
  program.addCommand(watchCommand);
820
- program.parseAsync().then(() => {
821
- setTimeout(() => process.exit(0), 100);
822
- });
1335
+ program.addCommand(verifyCommand);
1336
+ program.parseAsync().then(
1337
+ () => {
1338
+ scheduleForcedExit(process);
1339
+ },
1340
+ (error) => {
1341
+ reportStateDbFailure(error, process);
1342
+ scheduleForcedExit(process);
1343
+ }
1344
+ );
823
1345
  //# sourceMappingURL=index.js.map