im-nobsidian 0.3.2 → 0.9.0

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 Command11 } from "commander";
5
+ import { Command as Command13 } 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 as ConfigManager2,
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,12 +48,15 @@ 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(" ");
@@ -61,31 +70,124 @@ function failedCountText(count) {
61
70
  function duration(ms) {
62
71
  return chalk.dim(`Done in ${(ms / 1e3).toFixed(1)}s`);
63
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
+ }
64
103
  function progress(current, total) {
65
104
  return chalk.dim(`[${current}/${total}]`);
66
105
  }
67
106
  function dimText(text) {
68
107
  return chalk.dim(text);
69
108
  }
70
- function failedItem(path, error) {
71
- 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)}`);
112
+ }
113
+
114
+ // src/utils/vault.ts
115
+ import { existsSync } from "fs";
116
+ import { dirname, join, relative } from "path";
117
+ import {
118
+ ConfigManager,
119
+ NodeVaultFS,
120
+ StateDB,
121
+ matchesPathScope,
122
+ ScopedVaultFS,
123
+ findConnectionFolders,
124
+ OBSIDIAN_CONFIG_DIR
125
+ } from "@im-nobsidian/core";
126
+ async function nestedConnections(cwd) {
127
+ return (await findConnectionFolders(cwd)).filter((folder) => folder !== "");
128
+ }
129
+ async function openVaultFs(cwd, paths) {
130
+ const base = new NodeVaultFS(cwd, paths);
131
+ const nested = await nestedConnections(cwd);
132
+ return nested.length === 0 ? base : new ScopedVaultFS(base, "", ["", ...nested]);
133
+ }
134
+ function obsidianConfigDir(cwd) {
135
+ for (let dir = cwd; ; dir = dirname(dir)) {
136
+ if (existsSync(join(dir, OBSIDIAN_CONFIG_DIR))) {
137
+ return relative(cwd, join(dir, OBSIDIAN_CONFIG_DIR)).split("\\").join("/");
138
+ }
139
+ if (dirname(dir) === dir) return OBSIDIAN_CONFIG_DIR;
140
+ }
141
+ }
142
+ function outerConnectionTracking(cwd) {
143
+ for (let dir = dirname(cwd); ; dir = dirname(dir)) {
144
+ const manager = new ConfigManager(dir);
145
+ if (existsSync(manager.dbPath)) {
146
+ const scope = relative(dir, cwd).split("\\").join("/");
147
+ const db = StateDB.open(manager.dbPath);
148
+ try {
149
+ const notes = db.getAll().filter(
150
+ (r) => r.obsidianPath !== scope && matchesPathScope(r.obsidianPath, scope)
151
+ ).length;
152
+ return notes > 0 ? { folder: dir, notes } : null;
153
+ } finally {
154
+ db.close();
155
+ }
156
+ }
157
+ if (dirname(dir) === dir) return null;
158
+ }
72
159
  }
73
160
 
74
161
  // src/commands/init.ts
75
- 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) => {
162
+ 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.";
163
+ 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) => {
76
164
  const cwd = process.cwd();
77
- const configManager = new ConfigManager(cwd);
165
+ const configManager = new ConfigManager2(cwd);
78
166
  console.log(`
79
167
  ${header(chalk2.cyan(" Im-Nobsidian Setup"))}`);
80
168
  console.log(` ${separator()}`);
169
+ const outer = outerConnectionTracking(cwd);
170
+ if (outer) {
171
+ console.error(
172
+ `\uC774 \uD3F4\uB354\uB294 \uC774\uBBF8 ${outer.folder} \uC758 \uC5F0\uACB0\uC774 \uB178\uD2B8 ${outer.notes}\uAC1C\uB97C \uB3D9\uAE30\uD654\uD558\uACE0 \uC788\uC5B4 \uB530\uB85C \uC5F0\uACB0\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`
173
+ );
174
+ process.exitCode = 1;
175
+ return;
176
+ }
81
177
  if (options.nonInteractive) {
82
178
  if (!options.token || !options.rootPageId) {
83
179
  console.error("\uBE44\uB300\uD654\uD615 \uBAA8\uB4DC\uC5D0\uC11C\uB294 --token\uACFC --root-page-id\uAC00 \uD544\uC218\uC785\uB2C8\uB2E4.");
84
180
  process.exitCode = 1;
85
181
  return;
86
182
  }
87
- if (!options.token.startsWith("ntn_")) {
88
- console.error("\uD1A0\uD070\uC740 ntn_\uC73C\uB85C \uC2DC\uC791\uD574\uC57C \uD569\uB2C8\uB2E4.");
183
+ if (!options.token.startsWith(NOTION_TOKEN_PREFIX)) {
184
+ console.error(`\uD1A0\uD070\uC740 ${NOTION_TOKEN_PREFIX}\uC73C\uB85C \uC2DC\uC791\uD574\uC57C \uD569\uB2C8\uB2E4.`);
185
+ process.exitCode = 1;
186
+ return;
187
+ }
188
+ const rootPageId = notionIdFromInput(options.rootPageId);
189
+ if (rootPageId === null) {
190
+ console.error(ROOT_PAGE_UNREADABLE);
89
191
  process.exitCode = 1;
90
192
  return;
91
193
  }
@@ -95,10 +197,9 @@ ${header(chalk2.cyan(" Im-Nobsidian Setup"))}`);
95
197
  await client.search({ pageSize: 1, filter: { property: "object", value: "page" } });
96
198
  spinner2.stop();
97
199
  console.log(` ${icons.success} Token validated`);
98
- await configManager.init({ token: options.token, rootPageId: options.rootPageId });
200
+ await configManager.init({ token: options.token, rootPageId });
99
201
  console.log(`
100
202
  ${icons.success} Config saved to ${dimText(configManager.configPath)}`);
101
- console.log(` ${icons.success} State DB initialized`);
102
203
  console.log(
103
204
  `
104
205
  ${header(chalk2.green("Ready!"))} Run ${chalk2.cyan("nobsi sync")} to start syncing.`
@@ -120,7 +221,7 @@ ${header(chalk2.cyan(" Im-Nobsidian Setup"))}`);
120
221
  }
121
222
  const token = await input({
122
223
  message: "Notion Integration Token:",
123
- validate: (v) => v.startsWith("ntn_") || "ntn_\uC73C\uB85C \uC2DC\uC791\uD558\uB294 \uD1A0\uD070\uC744 \uC785\uB825\uD558\uC138\uC694"
224
+ validate: (v) => v.startsWith(NOTION_TOKEN_PREFIX) || `${NOTION_TOKEN_PREFIX}\uC73C\uB85C \uC2DC\uC791\uD558\uB294 \uD1A0\uD070\uC744 \uC785\uB825\uD558\uC138\uC694`
124
225
  });
125
226
  const spinner = ora("Notion \uC5F0\uACB0 \uD655\uC778 \uC911...").start();
126
227
  try {
@@ -144,14 +245,15 @@ ${header(chalk2.cyan(" Im-Nobsidian Setup"))}`);
144
245
  const title = extractPageTitle(page);
145
246
  console.log(` ${dimText(`${i + 1}.`)} ${title}`);
146
247
  }
147
- const rootPageId = await input({
148
- message: "\uB8E8\uD2B8 \uD398\uC774\uC9C0 ID (\uC704\uC5D0\uC11C \uC120\uD0DD\uD558\uAC70\uB098 \uC9C1\uC811 \uC785\uB825):",
149
- default: result.results[0]?.id
248
+ const rootPageInput = await input({
249
+ message: "\uB8E8\uD2B8 \uD398\uC774\uC9C0 ID \uB610\uB294 \uC8FC\uC18C (\uC704\uC5D0\uC11C \uC120\uD0DD\uD558\uAC70\uB098 \uC9C1\uC811 \uC785\uB825):",
250
+ default: result.results[0]?.id,
251
+ validate: (value) => notionIdFromInput(value) !== null || ROOT_PAGE_UNREADABLE
150
252
  });
253
+ const rootPageId = notionIdFromInput(rootPageInput);
151
254
  await configManager.init({ token, rootPageId });
152
255
  console.log(`
153
256
  ${icons.success} Config saved to ${dimText(configManager.configPath)}`);
154
- console.log(` ${icons.success} State DB initialized`);
155
257
  console.log(
156
258
  `
157
259
  ${header(chalk2.green("Ready!"))} Run ${chalk2.cyan("nobsi sync")} to start syncing.`
@@ -176,42 +278,91 @@ function extractPageTitle(page) {
176
278
 
177
279
  // src/commands/push.ts
178
280
  import { Command as Command2 } from "commander";
179
- import {
180
- ConfigManager as ConfigManager2,
181
- StateDB,
182
- NotionClient as NotionClient2,
183
- SyncOrchestrator,
184
- NodeVaultFS
185
- } from "@im-nobsidian/core";
186
- 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) => {
281
+ import { ConfigManager as ConfigManager3, StateDB as StateDB2, NotionClient as NotionClient2, SyncOrchestrator } from "@im-nobsidian/core";
282
+
283
+ // src/utils/json-output.ts
284
+ import { setLogger } from "@im-nobsidian/core";
285
+ function enableJsonMode() {
286
+ const toStderr = (message, ...args) => console.error(message, ...args);
287
+ setLogger({ warn: toStderr, error: toStderr, info: toStderr, debug: () => {
288
+ } });
289
+ }
290
+ function pushJson(result) {
291
+ return {
292
+ created: result.created,
293
+ updated: result.updated,
294
+ deleted: result.deleted,
295
+ moved: result.moved,
296
+ churn: result.created + result.updated + result.deleted + result.moved + (result.createdDatabases ?? 0),
297
+ createdDatabases: result.createdDatabases ?? 0,
298
+ failed: result.failed,
299
+ durationMs: result.duration
300
+ };
301
+ }
302
+ function pullJson(result) {
303
+ return {
304
+ created: result.created,
305
+ updated: result.updated,
306
+ deleted: result.deleted,
307
+ restored: result.restored,
308
+ churn: result.created + result.updated + result.deleted + result.restored,
309
+ conflicts: result.conflicts.length,
310
+ failed: result.failed,
311
+ writtenPaths: result.writtenPaths.length,
312
+ images: result.imageCount,
313
+ files: result.fileCount,
314
+ links: result.linkCount,
315
+ durationMs: result.duration,
316
+ remoteScan: result.remoteScan ?? null
317
+ };
318
+ }
319
+ function printJson(value) {
320
+ process.stdout.write(`${JSON.stringify(value)}
321
+ `);
322
+ }
323
+
324
+ // src/commands/push.ts
325
+ 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) => {
326
+ const json = options.json === true;
327
+ if (json) enableJsonMode();
187
328
  const cwd = process.cwd();
188
- const configManager = new ConfigManager2(cwd);
329
+ const configManager = new ConfigManager3(cwd);
189
330
  const config = await configManager.load();
190
- const stateDb = StateDB.open(configManager.dbPath);
331
+ const stateDb = StateDB2.open(configManager.dbPath);
191
332
  try {
192
333
  const client = NotionClient2.fromConfig(config);
193
- const vaultFs = new NodeVaultFS(cwd, config.paths);
334
+ const vaultFs = await openVaultFs(cwd, config.paths);
194
335
  const orchestrator = new SyncOrchestrator(config, stateDb, client, vaultFs);
195
- console.log(`
336
+ if (!json) {
337
+ console.log(`
196
338
  ${header(" Pushing to Notion...")}`);
197
- if (options.dryRun) console.log(dimText(" (dry-run mode)"));
198
- console.log("");
339
+ if (options.dryRun) console.log(dimText(" (dry-run mode)"));
340
+ console.log("");
341
+ }
199
342
  const result = await orchestrator.push({
200
343
  dryRun: options.dryRun,
201
344
  paths: options.path,
202
- onProgress: (current, total, item) => {
345
+ onProgress: json ? void 0 : (current, total, item) => {
203
346
  const icon = operationIcon(item.operation);
204
347
  const label = operationLabel(item.operation);
205
348
  const prog = progress(current, total);
206
349
  console.log(` ${icon} ${item.path} ${prog} ${label}`);
207
350
  }
208
351
  });
352
+ if (json) {
353
+ printJson(pushJson(result));
354
+ if (result.failed.length > 0) process.exitCode = 1;
355
+ return;
356
+ }
209
357
  const failedCount = result.failed.length;
210
358
  console.log(`
211
359
  ${completionHeader("Push", failedCount)}`);
212
360
  console.log(
213
- ` ${summary(result.created, result.updated, result.deleted)} ${failedCountText(failedCount)}`
361
+ ` ${summary(result.created, result.updated, result.deleted, result.moved)} ${failedCountText(failedCount)}`
214
362
  );
363
+ if (result.createdDatabases) {
364
+ console.log(` ${options.dryRun ? "\uB9CC\uB4E4 DB" : "\uB9CC\uB4E0 DB"} ${result.createdDatabases}`);
365
+ }
215
366
  console.log(` ${duration(result.duration)}`);
216
367
  if (failedCount > 0) {
217
368
  console.log("");
@@ -226,50 +377,62 @@ ${header(" Pushing to Notion...")}`);
226
377
  });
227
378
 
228
379
  // src/commands/pull.ts
229
- import { Command as Command3 } from "commander";
230
- import {
231
- ConfigManager as ConfigManager3,
232
- StateDB as StateDB2,
233
- NotionClient as NotionClient3,
234
- SyncOrchestrator as SyncOrchestrator2,
235
- NodeVaultFS as NodeVaultFS2
236
- } from "@im-nobsidian/core";
380
+ import { Command as Command3, Option } from "commander";
381
+ import { ConfigManager as ConfigManager4, StateDB as StateDB3, NotionClient as NotionClient3, SyncOrchestrator as SyncOrchestrator2 } from "@im-nobsidian/core";
237
382
  import chalk3 from "chalk";
238
383
  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(
239
384
  "--force",
240
385
  "\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)"
241
- ).action(async (options) => {
386
+ ).addOption(
387
+ new Option(
388
+ "--rerender",
389
+ "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)"
390
+ ).conflicts("dryRun")
391
+ ).option("--json", "\uACB0\uACFC\uB97C JSON \uD55C \uC904\uB85C \uCD9C\uB825 (\uC790\uB3D9\uD654\uC6A9 \u2014 \uB85C\uADF8\uB294 stderr)").action(async (options) => {
392
+ const json = options.json === true;
393
+ if (json) enableJsonMode();
242
394
  const cwd = process.cwd();
243
- const configManager = new ConfigManager3(cwd);
395
+ const configManager = new ConfigManager4(cwd);
244
396
  const config = await configManager.load();
245
- const stateDb = StateDB2.open(configManager.dbPath);
397
+ const stateDb = StateDB3.open(configManager.dbPath);
246
398
  try {
247
399
  const client = NotionClient3.fromConfig(config);
248
- const vaultFs = new NodeVaultFS2(cwd, config.paths);
400
+ const vaultFs = await openVaultFs(cwd, config.paths);
249
401
  const orchestrator = new SyncOrchestrator2(config, stateDb, client, vaultFs);
250
- console.log(`
402
+ if (!json) {
403
+ console.log(`
251
404
  ${header(" Pulling from Notion...")}`);
252
- if (options.dryRun) console.log(dimText(" (dry-run mode)"));
253
- console.log("");
405
+ if (options.dryRun) console.log(dimText(" (dry-run mode)"));
406
+ console.log("");
407
+ }
254
408
  const result = await orchestrator.pull({
255
409
  dryRun: options.dryRun,
256
410
  paths: options.path,
257
411
  force: options.force,
258
- onProgress: (current, total, item) => {
412
+ rerender: options.rerender,
413
+ onProgress: json ? void 0 : (current, total, item) => {
414
+ if (item.unchanged) return;
259
415
  const icon = operationIcon(item.operation);
260
416
  const label = operationLabel(item.operation);
261
417
  const prog = progress(current, total);
262
418
  console.log(` ${icon} ${item.path} ${prog} ${label}`);
263
419
  }
264
420
  });
421
+ if (json) {
422
+ printJson(pullJson(result));
423
+ if (result.failed.length > 0) process.exitCode = 1;
424
+ return;
425
+ }
265
426
  if (result.imageCount > 0) {
266
427
  console.log(
267
428
  `
268
- ${icons.success} ${result.imageCount} images \u2192 ${dimText("attachments/")}`
429
+ ${icons.success} ${result.imageCount} images \u2192 ${dimText(`${config.paths.attachments}/`)}`
269
430
  );
270
431
  }
271
432
  if (result.fileCount > 0) {
272
- console.log(` ${icons.success} ${result.fileCount} files \u2192 ${dimText("attachments/")}`);
433
+ console.log(
434
+ ` ${icons.success} ${result.fileCount} files \u2192 ${dimText(`${config.paths.attachments}/`)}`
435
+ );
273
436
  }
274
437
  if (result.linkCount > 0) {
275
438
  console.log(` ${icons.success} ${result.linkCount} links resolved`);
@@ -284,6 +447,9 @@ ${header(" Pulling from Notion...")}`);
284
447
  console.log(` ${icons.success} ${chalk3.yellow(`${result.restored} restored`)}`);
285
448
  }
286
449
  console.log(` ${duration(result.duration)}`);
450
+ if (result.remoteScan) {
451
+ for (const line of remoteScanLines(result.remoteScan)) console.log(` ${line}`);
452
+ }
287
453
  if (result.conflicts.length > 0) {
288
454
  console.log(
289
455
  `
@@ -304,54 +470,59 @@ ${header(" Pulling from Notion...")}`);
304
470
 
305
471
  // src/commands/sync.ts
306
472
  import { Command as Command4 } from "commander";
307
- import {
308
- ConfigManager as ConfigManager4,
309
- StateDB as StateDB3,
310
- NotionClient as NotionClient4,
311
- SyncOrchestrator as SyncOrchestrator3,
312
- NodeVaultFS as NodeVaultFS3
313
- } from "@im-nobsidian/core";
473
+ import { ConfigManager as ConfigManager5, StateDB as StateDB4, NotionClient as NotionClient4, SyncOrchestrator as SyncOrchestrator3 } from "@im-nobsidian/core";
314
474
  import chalk4 from "chalk";
315
- 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) => {
475
+ 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) => {
476
+ const json = options.json === true;
477
+ if (json) enableJsonMode();
478
+ const log = json ? () => {
479
+ } : (line) => console.log(line);
316
480
  const cwd = process.cwd();
317
- const configManager = new ConfigManager4(cwd);
481
+ const configManager = new ConfigManager5(cwd);
318
482
  const config = await configManager.load();
319
- const stateDb = StateDB3.open(configManager.dbPath);
483
+ const stateDb = StateDB4.open(configManager.dbPath);
320
484
  try {
321
485
  const client = NotionClient4.fromConfig(config);
322
- const vaultFs = new NodeVaultFS3(cwd, config.paths);
486
+ const vaultFs = await openVaultFs(cwd, config.paths);
323
487
  const orchestrator = new SyncOrchestrator3(config, stateDb, client, vaultFs);
324
- console.log(`
488
+ log(`
325
489
  ${header(" Bidirectional Sync")}`);
326
- console.log(` ${separator()}`);
327
- if (options.dryRun) console.log(dimText(" (dry-run mode)"));
328
- console.log(`
490
+ log(` ${separator()}`);
491
+ if (options.dryRun) log(dimText(" (dry-run mode)"));
492
+ log(`
329
493
  ${header(chalk4.blue("\u25BC Pull"))} ${dimText("(Notion \u2192 Obsidian)")}`);
330
- const pullResult = await orchestrator.pull({
331
- dryRun: options.dryRun,
332
- onProgress: (_current, _total, item) => {
333
- const icon = operationIcon(item.operation);
334
- const label = operationLabel(item.operation);
335
- console.log(` ${icon} ${item.path} ${label}`);
336
- }
337
- });
338
- if (pullResult.imageCount > 0) {
339
- console.log(` ${icons.success} ${pullResult.imageCount} images downloaded`);
340
- }
341
- if (pullResult.linkCount > 0) {
342
- console.log(` ${icons.success} ${pullResult.linkCount} links resolved`);
343
- }
344
- console.log(`
494
+ let pushShown = false;
495
+ const showPush = () => {
496
+ if (pushShown) return;
497
+ pushShown = true;
498
+ log(`
345
499
  ${header(chalk4.magenta("\u25B2 Push"))} ${dimText("(Obsidian \u2192 Notion)")}`);
346
- const pushResult = await orchestrator.push({
500
+ };
501
+ const result = await orchestrator.sync({
347
502
  dryRun: options.dryRun,
348
503
  onProgress: (_current, _total, item) => {
504
+ if (item.direction === "push") showPush();
505
+ if (item.unchanged) return;
349
506
  const icon = operationIcon(item.operation);
350
507
  const label = operationLabel(item.operation);
351
- console.log(` ${icon} ${item.path} ${label}`);
508
+ log(` ${icon} ${item.path} ${label}`);
352
509
  }
353
510
  });
354
- const totalDuration = pullResult.duration + pushResult.duration;
511
+ showPush();
512
+ const { pull: pullResult, push: pushResult } = result;
513
+ if (json) {
514
+ const pull = pullJson(pullResult);
515
+ const push = pushJson(pushResult);
516
+ printJson({
517
+ pull,
518
+ push,
519
+ churn: pull.churn + push.churn,
520
+ conflicts: pull.conflicts,
521
+ durationMs: result.duration
522
+ });
523
+ if (pullResult.failed.length + pushResult.failed.length > 0) process.exitCode = 1;
524
+ return;
525
+ }
355
526
  const pullFailed = pullResult.failed.length;
356
527
  const pushFailed = pushResult.failed.length;
357
528
  console.log(`
@@ -360,20 +531,29 @@ ${header(" Bidirectional Sync")}`);
360
531
  ` ${chalk4.blue("Pull:")} ${summary(pullResult.created, pullResult.updated, pullResult.deleted)}` + // 사라졌던 파일이 되살아난 경우에만 덧붙인다 — 평상시 요약을 어지럽히지 않는다.
361
532
  (pullResult.restored > 0 ? ` ${chalk4.yellow(`${pullResult.restored} restored`)}` : "") + (pullFailed > 0 ? ` ${chalk4.red(`${pullFailed} failed`)}` : "")
362
533
  );
534
+ if (pullResult.imageCount > 0) {
535
+ console.log(` ${icons.success} ${pullResult.imageCount} images downloaded`);
536
+ }
537
+ if (pullResult.linkCount > 0) {
538
+ console.log(` ${icons.success} ${pullResult.linkCount} links resolved`);
539
+ }
363
540
  console.log(
364
- ` ${chalk4.magenta("Push:")} ${summary(pushResult.created, pushResult.updated, pushResult.deleted)}` + (pushFailed > 0 ? ` ${chalk4.red(`${pushFailed} failed`)}` : "")
541
+ ` ${chalk4.magenta("Push:")} ${summary(pushResult.created, pushResult.updated, pushResult.deleted, pushResult.moved)}` + (pushFailed > 0 ? ` ${chalk4.red(`${pushFailed} failed`)}` : "")
365
542
  );
366
- console.log(` ${duration(totalDuration)}`);
367
- if (pullResult.conflicts.length > 0) {
543
+ console.log(` ${duration(result.duration)}`);
544
+ if (pullResult.remoteScan) {
545
+ for (const line of remoteScanLines(pullResult.remoteScan)) console.log(` ${line}`);
546
+ }
547
+ if (result.conflicts.length > 0) {
368
548
  console.log(
369
549
  `
370
- ${icons.conflict} ${chalk4.magenta(`${pullResult.conflicts.length} conflicts`)} \u2014 run ${chalk4.cyan("nobsi resolve")}`
550
+ ${icons.conflict} ${chalk4.magenta(`${result.conflicts.length} conflicts`)} \u2014 run ${chalk4.cyan("nobsi resolve")}`
371
551
  );
372
552
  }
373
553
  if (pullFailed + pushFailed > 0) {
374
554
  console.log("");
375
- for (const f of pullResult.failed) failedItem(`\u25BC ${f.path}`, f.error);
376
- for (const f of pushResult.failed) failedItem(`\u25B2 ${f.path}`, f.error);
555
+ for (const f of pullResult.failed) failedItem(f.path, f.error, "\u25BC");
556
+ for (const f of pushResult.failed) failedItem(f.path, f.error, "\u25B2");
377
557
  process.exitCode = 1;
378
558
  }
379
559
  } finally {
@@ -384,21 +564,21 @@ ${header(" Bidirectional Sync")}`);
384
564
  // src/commands/status.ts
385
565
  import { Command as Command5 } from "commander";
386
566
  import {
387
- ConfigManager as ConfigManager5,
388
- StateDB as StateDB4,
567
+ ConfigManager as ConfigManager6,
568
+ StateDB as StateDB5,
389
569
  NotionClient as NotionClient5,
390
570
  SyncOrchestrator as SyncOrchestrator4,
391
- NodeVaultFS as NodeVaultFS4
571
+ isRemoteDeletion
392
572
  } from "@im-nobsidian/core";
393
573
  import chalk5 from "chalk";
394
574
  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) => {
395
575
  const cwd = process.cwd();
396
- const configManager = new ConfigManager5(cwd);
576
+ const configManager = new ConfigManager6(cwd);
397
577
  const config = await configManager.load();
398
- const stateDb = StateDB4.open(configManager.dbPath);
578
+ const stateDb = StateDB5.open(configManager.dbPath);
399
579
  try {
400
580
  const client = NotionClient5.fromConfig(config);
401
- const vaultFs = new NodeVaultFS4(cwd, config.paths);
581
+ const vaultFs = await openVaultFs(cwd, config.paths);
402
582
  const orchestrator = new SyncOrchestrator4(config, stateDb, client, vaultFs);
403
583
  const status = opts.full ? await orchestrator.status() : await orchestrator.statusLocal();
404
584
  console.log(`
@@ -409,21 +589,27 @@ ${header(" Sync Status")}`);
409
589
  console.log(` ${dimText("Root page:")} ${rootId.slice(0, 8)}...`);
410
590
  console.log(` ${dimText("Direction:")} ${direction}`);
411
591
  if (status.lastSyncAt) {
412
- const date = new Date(status.lastSyncAt);
413
- 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");
414
- console.log(` ${dimText("Last sync:")} ${iso}`);
592
+ console.log(` ${dimText("Last sync:")} ${localDateTime(status.lastSyncAt)}`);
415
593
  } else {
416
594
  console.log(` ${dimText("Last sync:")} ${chalk5.yellow("never")}`);
417
595
  }
596
+ console.log(` ${dimText("Full scan:")} ${lastFullScanText(status.lastFullScanAt)}`);
597
+ for (const folder of await nestedConnections(cwd)) {
598
+ console.log(
599
+ ` ${dimText("Connection:")} ${folder}/ ${dimText(`(separate \u2014 cd ${folder} && nobsi status)`)}`
600
+ );
601
+ }
418
602
  const allRecords = stateDb.getAll();
419
603
  const total = allRecords.length;
420
604
  const conflictCount = status.conflictRecords.length;
421
605
  const createdChanges = status.localChanges.filter((c) => c.type === "created");
422
606
  const modifiedChanges = status.localChanges.filter((c) => c.type === "modified");
423
607
  const deletedChanges = status.localChanges.filter((c) => c.type === "deleted");
608
+ const movedChanges = status.localChanges.filter((c) => c.type === "moved");
424
609
  const dirtyPaths = /* @__PURE__ */ new Set([
425
610
  ...modifiedChanges.map((c) => c.path),
426
611
  ...deletedChanges.map((c) => c.path),
612
+ ...movedChanges.flatMap((c) => [c.path, c.movedFrom ?? c.path]),
427
613
  ...status.conflictRecords.map((r) => r.obsidianPath)
428
614
  ]);
429
615
  const synced = allRecords.filter(
@@ -432,12 +618,17 @@ ${header(" Sync Status")}`);
432
618
  const remoteCreated = status.remoteChanges.filter((c) => c.type === "created");
433
619
  const remoteModified = status.remoteChanges.filter((c) => c.type === "modified");
434
620
  const remoteDeleted = status.remoteChanges.filter((c) => c.type === "deleted");
621
+ const remoteMoved = status.remoteChanges.filter((c) => c.type === "moved");
622
+ const { folderMoves } = status;
435
623
  console.log(`
436
624
  ${header(`Tracked files: ${total}`)}`);
437
625
  console.log(` ${icons.synced} ${chalk5.green("synced")} ${synced}`);
438
626
  if (modifiedChanges.length > 0) {
439
627
  console.log(` ${icons.modified} ${chalk5.yellow("modified")} ${modifiedChanges.length}`);
440
628
  }
629
+ if (movedChanges.length > 0) {
630
+ console.log(` ${icons.move} ${chalk5.cyan("moved")} ${movedChanges.length}`);
631
+ }
441
632
  if (deletedChanges.length > 0) {
442
633
  console.log(` ${chalk5.red("-")} ${chalk5.red("deleted")} ${deletedChanges.length}`);
443
634
  }
@@ -461,6 +652,29 @@ ${header(" Sync Status")}`);
461
652
  console.log(dimText(` ... and ${modifiedChanges.length - 10} more`));
462
653
  }
463
654
  }
655
+ if (folderMoves.length + movedChanges.length > 0) {
656
+ console.log(`
657
+ ${header("Moved:")}`);
658
+ for (const move of folderMoves) {
659
+ console.log(` ${icons.move} ${move.from}/ \u2192 ${move.to}/ ${dimText("(folder)")}`);
660
+ }
661
+ for (const c of movedChanges.slice(0, 10)) {
662
+ console.log(` ${icons.move} ${c.movedFrom ?? "?"} \u2192 ${c.path}`);
663
+ }
664
+ if (movedChanges.length > 10) {
665
+ console.log(dimText(` ... and ${movedChanges.length - 10} more`));
666
+ }
667
+ }
668
+ if (deletedChanges.length > 0) {
669
+ console.log(`
670
+ ${header("Deleted files:")}`);
671
+ for (const c of deletedChanges.slice(0, 10)) {
672
+ console.log(` ${chalk5.red("-")} ${c.path}`);
673
+ }
674
+ if (deletedChanges.length > 10) {
675
+ console.log(dimText(` ... and ${deletedChanges.length - 10} more`));
676
+ }
677
+ }
464
678
  if (createdChanges.length > 0) {
465
679
  console.log(`
466
680
  ${header("New files:")}`);
@@ -482,12 +696,18 @@ ${header(" Sync Status")}`);
482
696
  ` ${chalk5.yellow("~")} ${chalk5.yellow("modified")} ${remoteModified.length}`
483
697
  );
484
698
  }
699
+ if (remoteMoved.length > 0) {
700
+ console.log(` ${icons.move} ${chalk5.cyan("moved")} ${remoteMoved.length}`);
701
+ }
485
702
  if (remoteDeleted.length > 0) {
486
703
  console.log(` ${chalk5.red("-")} ${chalk5.red("deleted")} ${remoteDeleted.length}`);
487
704
  }
488
705
  for (const c of status.remoteChanges.slice(0, 10)) {
489
- const icon = c.type === "created" ? chalk5.cyan("+") : c.type === "modified" ? chalk5.yellow("~") : chalk5.red("-");
490
- console.log(` ${icon} ${c.pageId.slice(0, 8)}... ${dimText(`(${c.type})`)}`);
706
+ const icon = c.type === "created" ? chalk5.cyan("+") : c.type === "modified" ? chalk5.yellow("~") : c.type === "moved" ? icons.move : chalk5.red("-");
707
+ const name = c.path ?? (c.title ? `${c.title} ${dimText("(new page)")}` : void 0);
708
+ console.log(
709
+ ` ${icon} ${name ?? `${c.pageId.slice(0, 8)}...`} ${dimText(`(${c.type})`)}`
710
+ );
491
711
  }
492
712
  if (status.remoteChanges.length > 10) {
493
713
  console.log(dimText(` ... and ${status.remoteChanges.length - 10} more`));
@@ -496,13 +716,15 @@ ${header(" Sync Status")}`);
496
716
  if (conflictCount > 0) {
497
717
  console.log(`
498
718
  ${header(chalk5.magenta("Conflicts:"))}`);
719
+ const remoteDeleted2 = new Set(
720
+ status.conflicts.filter(isRemoteDeletion).map((c) => c.syncRecord.obsidianPath)
721
+ );
499
722
  for (const r of status.conflictRecords) {
500
- console.log(
501
- ` ${icons.conflict} ${r.obsidianPath.padEnd(30)} ${dimText("(both sides changed)")}`
502
- );
723
+ const reason = remoteDeleted2.has(r.obsidianPath) ? "(deleted in Notion, local edits not pushed)" : "(both sides changed)";
724
+ console.log(` ${icons.conflict} ${r.obsidianPath.padEnd(30)} ${dimText(reason)}`);
503
725
  }
504
726
  }
505
- const hasChanges = status.localChanges.length > 0 || status.remoteChanges.length > 0 || conflictCount > 0;
727
+ const hasChanges = status.localChanges.length > 0 || folderMoves.length > 0 || status.remoteChanges.length > 0 || conflictCount > 0;
506
728
  if (hasChanges) {
507
729
  console.log("");
508
730
  if (conflictCount > 0) {
@@ -513,6 +735,11 @@ ${header(" Sync Status")}`);
513
735
  console.log(
514
736
  ` ${dimText("Run")} ${chalk5.cyan("nobsi sync")} ${dimText("to push/pull changes")}`
515
737
  );
738
+ if (modifiedChanges.length + deletedChanges.length > 0) {
739
+ console.log(
740
+ ` ${dimText("Run")} ${chalk5.cyan("nobsi discard <path>")} ${dimText("to discard local edits")}`
741
+ );
742
+ }
516
743
  } else {
517
744
  console.log(`
518
745
  ${chalk5.green("Everything up to date")} \u2713`);
@@ -522,6 +749,9 @@ ${header(" Sync Status")}`);
522
749
  `
523
750
  ${dimText("Run")} ${chalk5.cyan("nobsi status --full")} ${dimText("to check Notion remote changes")}`
524
751
  );
752
+ } else if (status.remoteScan?.deletionsDeferred) {
753
+ console.log(`
754
+ ${remoteDeletionHint(status.remoteScan)}`);
525
755
  }
526
756
  } finally {
527
757
  stateDb.close();
@@ -531,35 +761,61 @@ ${header(" Sync Status")}`);
531
761
  // src/commands/diff.ts
532
762
  import { Command as Command6 } from "commander";
533
763
  import {
534
- ConfigManager as ConfigManager6,
535
- StateDB as StateDB5,
764
+ ConfigManager as ConfigManager7,
765
+ StateDB as StateDB6,
536
766
  NotionClient as NotionClient6,
537
767
  SyncOrchestrator as SyncOrchestrator5,
538
- NodeVaultFS as NodeVaultFS5,
539
- matchesPathScope
768
+ formatUnifiedDiff,
769
+ lineDiff,
770
+ matchesPathScope as matchesPathScope2
540
771
  } from "@im-nobsidian/core";
541
- import { createTwoFilesPatch } from "diff";
772
+
773
+ // src/utils/diff-output.ts
774
+ import { DIFF_SIGN } from "@im-nobsidian/core";
775
+ var BOLD = "\x1B[1m";
776
+ var RED = "\x1B[31m";
777
+ var GREEN = "\x1B[32m";
778
+ var CYAN = "\x1B[36m";
779
+ var RESET = "\x1B[0m";
780
+ var LINE_COLOR = {
781
+ [DIFF_SIGN.added]: GREEN,
782
+ [DIFF_SIGN.removed]: RED,
783
+ "@": CYAN
784
+ };
785
+ function printUnifiedDiff(lines, color) {
786
+ lines.forEach((line, index) => {
787
+ const code = index < 2 ? BOLD : LINE_COLOR[line.charAt(0)];
788
+ console.log(color && code ? `${code}${line}${RESET}` : line);
789
+ });
790
+ }
791
+
792
+ // src/commands/diff.ts
542
793
  var diffCommand = new Command6("diff").description(
543
794
  "\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)"
544
795
  ).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) => {
545
796
  const cwd = process.cwd();
546
- const configManager = new ConfigManager6(cwd);
797
+ const configManager = new ConfigManager7(cwd);
547
798
  const config = await configManager.load();
548
- const stateDb = StateDB5.open(configManager.dbPath);
799
+ const stateDb = StateDB6.open(configManager.dbPath);
549
800
  try {
550
801
  const client = NotionClient6.fromConfig(config);
551
- const vaultFs = new NodeVaultFS5(cwd, config.paths);
802
+ const vaultFs = await openVaultFs(cwd, config.paths);
552
803
  const orchestrator = new SyncOrchestrator5(config, stateDb, client, vaultFs);
553
804
  const status = await orchestrator.statusLocal();
554
- const changes = targetPath ? status.localChanges.filter((c) => matchesPathScope(c.path, targetPath)) : status.localChanges;
805
+ const changes = targetPath ? status.localChanges.filter((c) => matchesPathScope2(c.path, targetPath)) : status.localChanges;
555
806
  if (changes.length === 0) {
556
807
  console.log("\uBCC0\uACBD\uC0AC\uD56D \uC5C6\uC74C");
557
808
  return;
558
809
  }
810
+ let failed = 0;
559
811
  for (const change of changes) {
560
- const record = stateDb.getByPath(change.path);
561
812
  if (options.remote && change.type !== "deleted") {
562
- const remote = await orchestrator.renderRemoteSnapshot(change.path);
813
+ let remotePath = change.path;
814
+ let remote = await orchestrator.renderRemoteSnapshot(remotePath);
815
+ if (remote === null && change.movedFrom) {
816
+ remotePath = change.movedFrom;
817
+ remote = await orchestrator.renderRemoteSnapshot(remotePath);
818
+ }
563
819
  if (remote === null) {
564
820
  console.log(`--- (\uC6D0\uACA9 \uC5C6\uC74C) ${change.path}`);
565
821
  console.log(`+++ b/${change.path}`);
@@ -568,94 +824,89 @@ var diffCommand = new Command6("diff").description(
568
824
  continue;
569
825
  }
570
826
  const currentContent = await vaultFs.readFile(change.path);
571
- const patch = createTwoFilesPatch(
572
- `notion/${change.path}`,
573
- `b/${change.path}`,
574
- remote,
575
- currentContent
576
- );
577
- printPatch(patch, options.color, `notion/${change.path}`, `b/${change.path}`);
827
+ const hunks = lineDiff(remote, currentContent);
828
+ const lines = formatUnifiedDiff(hunks, `notion/${remotePath}`, `b/${change.path}`);
829
+ if (hunks.length === 0) lines.push("(Notion \uACFC \uB0B4\uC6A9\uC774 \uAC19\uC2B5\uB2C8\uB2E4)");
830
+ printUnifiedDiff(lines, options.color);
578
831
  console.log("");
579
832
  continue;
580
833
  }
581
- if (change.type === "created") {
582
- const content = await vaultFs.readFile(change.path);
583
- const patch = createTwoFilesPatch("/dev/null", `b/${change.path}`, "", content);
584
- printPatch(patch, options.color, "/dev/null", `b/${change.path}`);
585
- } else if (change.type === "deleted") {
586
- if (record?.baseSnapshot) {
587
- const oldContent = record.baseSnapshot.toString("utf-8");
588
- const patch = createTwoFilesPatch(`a/${change.path}`, "/dev/null", oldContent, "");
589
- printPatch(patch, options.color, `a/${change.path}`, "/dev/null");
590
- } else {
591
- console.log(`--- a/${change.path}`);
592
- console.log(`+++ /dev/null`);
593
- console.log(`(\uC774\uC804 \uC2A4\uB0C5\uC0F7 \uC5C6\uC74C)`);
594
- }
595
- } else if (change.type === "modified" && record?.baseSnapshot) {
596
- const currentContent = await vaultFs.readFile(change.path);
597
- const baseContent = record.baseSnapshot.toString("utf-8");
598
- const patch = createTwoFilesPatch(
599
- `a/${change.path}`,
600
- `b/${change.path}`,
601
- baseContent,
602
- currentContent
603
- );
604
- printPatch(patch, options.color, `a/${change.path}`, `b/${change.path}`);
834
+ let diff;
835
+ try {
836
+ diff = await orchestrator.localChangeDiff(change);
837
+ } catch (error) {
838
+ failed++;
839
+ console.log(`--- a/${change.path}`);
840
+ console.log(`(${error instanceof Error ? error.message : String(error)})`);
841
+ console.log("");
842
+ continue;
605
843
  }
844
+ printChangeDiff(diff, options.color);
606
845
  console.log("");
607
846
  }
847
+ if (failed > 0) process.exitCode = 1;
608
848
  } finally {
609
849
  stateDb.close();
610
850
  }
611
851
  });
612
- function printPatch(patch, color, oldPath, newPath) {
613
- for (const line of patch.split("\n")) {
614
- let output = line;
615
- if (line.startsWith("===")) {
616
- output = "===================================================================";
617
- } else if (line.startsWith("--- ")) {
618
- output = `--- ${oldPath}`;
619
- } else if (line.startsWith("+++ ")) {
620
- output = `+++ ${newPath}`;
621
- }
622
- if (!color) {
623
- console.log(output);
624
- continue;
625
- }
626
- if (output.startsWith("+++") || output.startsWith("---")) {
627
- console.log(`\x1B[1m${output}\x1B[0m`);
628
- } else if (output.startsWith("+")) {
629
- console.log(`\x1B[32m${output}\x1B[0m`);
630
- } else if (output.startsWith("-")) {
631
- console.log(`\x1B[31m${output}\x1B[0m`);
632
- } else if (output.startsWith("@@")) {
633
- console.log(`\x1B[36m${output}\x1B[0m`);
634
- } else {
635
- console.log(output);
852
+ function printChangeDiff(diff, color) {
853
+ const oldPath = diff.before === null ? "/dev/null" : `a/${diff.movedFrom ?? diff.path}`;
854
+ const newPath = diff.after === null ? "/dev/null" : `b/${diff.path}`;
855
+ if (diff.movedFrom) {
856
+ console.log(`rename from ${diff.movedFrom}`);
857
+ console.log(`rename to ${diff.path}`);
858
+ if (diff.before === diff.after) {
859
+ console.log("(\uB0B4\uC6A9\uC740 \uADF8\uB300\uB85C\uC785\uB2C8\uB2E4 \u2014 \uC790\uB9AC\uB9CC \uC62E\uACBC\uC2B5\uB2C8\uB2E4)");
860
+ return;
636
861
  }
637
862
  }
863
+ printUnifiedDiff(
864
+ formatUnifiedDiff(lineDiff(diff.before ?? "", diff.after ?? ""), oldPath, newPath),
865
+ color
866
+ );
638
867
  }
639
868
 
640
- // src/commands/fetch.ts
869
+ // src/commands/discard.ts
641
870
  import { Command as Command7 } from "commander";
642
- import {
643
- ConfigManager as ConfigManager7,
644
- StateDB as StateDB6,
645
- NotionClient as NotionClient7,
646
- SyncOrchestrator as SyncOrchestrator6,
647
- NodeVaultFS as NodeVaultFS6
648
- } from "@im-nobsidian/core";
649
- import chalk6 from "chalk";
650
- var fetchCommand = new Command7("fetch").description("Notion \uB9AC\uBAA8\uD2B8 \uC0C1\uD0DC \uC2A4\uCE94 (\uC0AD\uC81C \uAC10\uC9C0 \uD3EC\uD568)").action(async () => {
871
+ import { ConfigManager as ConfigManager8, StateDB as StateDB7, NotionClient as NotionClient7, SyncOrchestrator as SyncOrchestrator6 } from "@im-nobsidian/core";
872
+ 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) => {
651
873
  const cwd = process.cwd();
652
- const configManager = new ConfigManager7(cwd);
874
+ const configManager = new ConfigManager8(cwd);
653
875
  const config = await configManager.load();
654
- const stateDb = StateDB6.open(configManager.dbPath);
876
+ const stateDb = StateDB7.open(configManager.dbPath);
655
877
  try {
656
878
  const client = NotionClient7.fromConfig(config);
657
- const vaultFs = new NodeVaultFS6(cwd, config.paths);
879
+ const vaultFs = await openVaultFs(cwd, config.paths);
658
880
  const orchestrator = new SyncOrchestrator6(config, stateDb, client, vaultFs);
881
+ let failed = 0;
882
+ for (const path of paths) {
883
+ try {
884
+ await orchestrator.discardLocalChange(path);
885
+ console.log(` ${icons.success} ${path}`);
886
+ } catch (error) {
887
+ failed++;
888
+ failedItem(path, error instanceof Error ? error.message : String(error));
889
+ }
890
+ }
891
+ if (failed > 0) process.exitCode = 1;
892
+ } finally {
893
+ stateDb.close();
894
+ }
895
+ });
896
+
897
+ // src/commands/fetch.ts
898
+ import { Command as Command8 } from "commander";
899
+ import { ConfigManager as ConfigManager9, StateDB as StateDB8, NotionClient as NotionClient8, SyncOrchestrator as SyncOrchestrator7 } from "@im-nobsidian/core";
900
+ import chalk6 from "chalk";
901
+ var fetchCommand = new Command8("fetch").description("Notion \uB9AC\uBAA8\uD2B8 \uC0C1\uD0DC \uC2A4\uCE94 (\uC0AD\uC81C \uAC10\uC9C0 \uD3EC\uD568)").action(async () => {
902
+ const cwd = process.cwd();
903
+ const configManager = new ConfigManager9(cwd);
904
+ const config = await configManager.load();
905
+ const stateDb = StateDB8.open(configManager.dbPath);
906
+ try {
907
+ const client = NotionClient8.fromConfig(config);
908
+ const vaultFs = await openVaultFs(cwd, config.paths);
909
+ const orchestrator = new SyncOrchestrator7(config, stateDb, client, vaultFs);
659
910
  console.log(dimText(" Scanning remote pages..."));
660
911
  const result = await orchestrator.fetch();
661
912
  console.log(`
@@ -687,34 +938,67 @@ ${header(" Remote Scan")}`);
687
938
  });
688
939
 
689
940
  // src/commands/resolve.ts
690
- import { Command as Command8 } from "commander";
941
+ import { Command as Command9 } from "commander";
691
942
  import { select, confirm as confirm2 } from "@inquirer/prompts";
692
943
  import {
693
- ConfigManager as ConfigManager8,
694
- StateDB as StateDB7,
695
- NotionClient as NotionClient8,
696
- SyncOrchestrator as SyncOrchestrator7,
697
- NodeVaultFS as NodeVaultFS7
944
+ ConfigManager as ConfigManager10,
945
+ StateDB as StateDB9,
946
+ NotionClient as NotionClient9,
947
+ SyncOrchestrator as SyncOrchestrator8,
948
+ applicableChoices,
949
+ isRemoteDeletion as isRemoteDeletion2
698
950
  } from "@im-nobsidian/core";
951
+
952
+ // src/utils/state-db-failure.ts
953
+ import { StateDbUnavailableError } from "@im-nobsidian/core";
954
+ function describeFailure(error) {
955
+ const message = error instanceof Error ? error.message : String(error);
956
+ if (!(error instanceof StateDbUnavailableError)) return message;
957
+ return `${message} \u2014 ${error.guidance("\uBA85\uB839\uC744 \uB2E4\uC2DC \uC2E4\uD589\uD558\uC138\uC694")}`;
958
+ }
959
+ function reportStateDbFailure(error, proc) {
960
+ if (!(error instanceof StateDbUnavailableError)) throw error;
961
+ console.error(`\uC624\uB958: ${describeFailure(error)}`);
962
+ proc.exitCode = 1;
963
+ }
964
+
965
+ // src/commands/resolve.ts
699
966
  var STRATEGIES = ["local-first", "remote-first", "duplicate"];
967
+ var CHOICE_NAMES = {
968
+ local: "\uB85C\uCEEC \uC720\uC9C0 (Obsidian \uBC84\uC804 \uC720\uC9C0)",
969
+ remote: "\uC6D0\uACA9 \uC720\uC9C0 (Notion \uBC84\uC804\uC73C\uB85C \uB36E\uC5B4\uC4F0\uAE30)",
970
+ merge: "\uC790\uB3D9 \uBCD1\uD569 (3-way merge)",
971
+ duplicate: "\uBCF5\uC81C (\uC6D0\uBCF8 \uC720\uC9C0 + \uC62C\uB9AC\uC9C0 \uC54A\uB294 .conflict \uC0AC\uBCF8 \uC0DD\uC131)"
972
+ };
973
+ var REMOTE_DELETION_CHOICE_NAMES = {
974
+ local: "\uB85C\uCEEC \uC720\uC9C0 (Notion \uC5D0 \uC0C8 \uD398\uC774\uC9C0\uB85C \uB2E4\uC2DC \uB9CC\uB4E4\uAE30)",
975
+ remote: "\uC0AD\uC81C \uB530\uB974\uAE30 (Obsidian \uD30C\uC77C\uB3C4 \uC9C0\uC6B0\uAE30)"
976
+ };
977
+ var PULL_ONLY_REMOTE_DELETION_CHOICE_NAMES = {
978
+ 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)"
979
+ };
980
+ function choiceName(conflict, choice, uploads) {
981
+ const deleted = isRemoteDeletion2(conflict);
982
+ return (deleted && !uploads ? PULL_ONLY_REMOTE_DELETION_CHOICE_NAMES[choice] : void 0) ?? (deleted ? REMOTE_DELETION_CHOICE_NAMES[choice] : void 0) ?? CHOICE_NAMES[choice] ?? choice;
983
+ }
700
984
  function isInteractive() {
701
985
  return process.stdin.isTTY === true && process.stdout.isTTY === true;
702
986
  }
703
- var resolveCommand = new Command8("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) => {
987
+ 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) => {
704
988
  if (options.strategy && !STRATEGIES.includes(options.strategy)) {
705
989
  console.error(`\uC54C \uC218 \uC5C6\uB294 \uC804\uB7B5 '${options.strategy}' \u2014 \uC0AC\uC6A9 \uAC00\uB2A5: ${STRATEGIES.join(", ")}`);
706
990
  process.exitCode = 1;
707
991
  return;
708
992
  }
709
993
  const cwd = process.cwd();
710
- const configManager = new ConfigManager8(cwd);
994
+ const configManager = new ConfigManager10(cwd);
711
995
  let stateDb;
712
996
  try {
713
997
  const config = await configManager.load();
714
- stateDb = StateDB7.open(configManager.dbPath);
715
- const client = NotionClient8.fromConfig(config);
716
- const vaultFs = new NodeVaultFS7(cwd, config.paths);
717
- const orchestrator = new SyncOrchestrator7(config, stateDb, client, vaultFs);
998
+ stateDb = StateDB9.open(configManager.dbPath);
999
+ const client = NotionClient9.fromConfig(config);
1000
+ const vaultFs = await openVaultFs(cwd, config.paths);
1001
+ const orchestrator = new SyncOrchestrator8(config, stateDb, client, vaultFs);
718
1002
  const conflicts = await orchestrator.listConflicts();
719
1003
  if (conflicts.length === 0) {
720
1004
  console.log("\uCDA9\uB3CC\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
@@ -733,8 +1017,20 @@ ${cleared.size}\uAC74 \uBAA8\uB450 \uC774\uBBF8 \uD574\uC18C\uB41C \uC0C1\uD0DC\
733
1017
  console.log(`
734
1018
  \uCDA9\uB3CC ${pending.length}\uAC74 \uBC1C\uACAC
735
1019
  `);
1020
+ const uploads = config.sync.direction !== "pull";
1021
+ if (!uploads) {
1022
+ console.log(
1023
+ " \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"
1024
+ );
1025
+ }
736
1026
  if (options.strategy) {
737
1027
  const strategy = options.strategy;
1028
+ const deletions = pending.filter(isRemoteDeletion2).length;
1029
+ if (deletions > 0) {
1030
+ console.log(
1031
+ ` 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"}`
1032
+ );
1033
+ }
738
1034
  if (!options.yes) {
739
1035
  if (!isInteractive()) {
740
1036
  console.error(
@@ -750,17 +1046,20 @@ ${cleared.size}\uAC74 \uBAA8\uB450 \uC774\uBBF8 \uD574\uC18C\uB41C \uC0C1\uD0DC\
750
1046
  }
751
1047
  const results = await orchestrator.resolveAllConflicts(pending, strategy);
752
1048
  let failed2 = 0;
753
- for (const result of results) {
1049
+ for (const [index, result] of results.entries()) {
754
1050
  if (!result.success) failed2++;
755
1051
  const icon = result.success ? "\x1B[32m\u2713\x1B[0m" : "\x1B[31m\u2717\x1B[0m";
756
- console.log(` ${icon} ${result.path} \u2192 ${result.choice}`);
1052
+ const reason = result.error ? ` \u2014 ${result.error}` : "";
1053
+ console.log(
1054
+ ` ${icon} ${result.path} \u2192 ${choiceName(pending[index], result.choice, uploads)}${reason}`
1055
+ );
757
1056
  }
758
1057
  report(results.length - failed2, failed2);
759
1058
  return;
760
1059
  }
761
1060
  if (!isInteractive()) {
762
1061
  console.error(
763
- "\uBE44\uB300\uD654\uD615 \uD658\uACBD\uC5D0\uC11C\uB294 \uB300\uD654\uD615 \uD574\uACB0\uC744 \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4 \u2014 --strategy <local-first|remote-first|duplicate> --yes \uB97C \uC9C0\uC815\uD558\uC138\uC694."
1062
+ `\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.`
764
1063
  );
765
1064
  process.exitCode = 1;
766
1065
  return;
@@ -768,12 +1067,12 @@ ${cleared.size}\uAC74 \uBAA8\uB450 \uC774\uBBF8 \uD574\uC18C\uB41C \uC0C1\uD0DC\
768
1067
  let resolved = 0;
769
1068
  let failed = 0;
770
1069
  for (const conflict of pending) {
771
- if (await resolveInteractive(conflict, orchestrator)) resolved++;
1070
+ if (await resolveInteractive(conflict, orchestrator, uploads)) resolved++;
772
1071
  else failed++;
773
1072
  }
774
1073
  report(resolved, failed);
775
1074
  } catch (error) {
776
- console.error("\uC624\uB958:", error instanceof Error ? error.message : String(error));
1075
+ console.error("\uC624\uB958:", describeFailure(error));
777
1076
  process.exitCode = 1;
778
1077
  } finally {
779
1078
  stateDb?.close();
@@ -789,37 +1088,29 @@ function report(resolved, failed) {
789
1088
  \x1B[33m\uCDA9\uB3CC ${resolved}\uAC74 \uD574\uACB0 \xB7 ${failed}\uAC74 \uBBF8\uD574\uACB0\x1B[0m`);
790
1089
  process.exitCode = 1;
791
1090
  }
792
- async function resolveInteractive(conflict, orchestrator) {
1091
+ async function resolveInteractive(conflict, orchestrator, uploads) {
793
1092
  const path = conflict.syncRecord.obsidianPath;
794
1093
  console.log(`
795
1094
  ${"\u2500".repeat(60)}`);
796
1095
  console.log(`\uD30C\uC77C: \x1B[1m${path}\x1B[0m`);
797
1096
  console.log(`${"\u2500".repeat(60)}`);
798
- const diff = orchestrator.generateConflictDiff(conflict);
799
- const diffLines = diff.split("\n");
800
- for (const line of diffLines) {
801
- if (line.startsWith("- ")) {
802
- console.log(`\x1B[31m${line}\x1B[0m`);
803
- } else if (line.startsWith("+ ")) {
804
- console.log(`\x1B[32m${line}\x1B[0m`);
805
- } else if (line.startsWith("---") || line.startsWith("+++")) {
806
- console.log(`\x1B[1m${line}\x1B[0m`);
807
- } else {
808
- console.log(line);
809
- }
1097
+ if (isRemoteDeletion2(conflict)) {
1098
+ console.log(
1099
+ "\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"
1100
+ );
1101
+ } else {
1102
+ printUnifiedDiff(orchestrator.generateConflictDiff(conflict).split("\n"), true);
810
1103
  }
811
1104
  const choice = await select({
812
1105
  message: "\uC5B4\uB5BB\uAC8C \uD574\uACB0\uD560\uAE4C\uC694?",
813
- choices: [
814
- { name: "\uB85C\uCEEC \uC720\uC9C0 (Obsidian \uBC84\uC804 \uC720\uC9C0)", value: "local" },
815
- { name: "\uC6D0\uACA9 \uC720\uC9C0 (Notion \uBC84\uC804\uC73C\uB85C \uB36E\uC5B4\uC4F0\uAE30)", value: "remote" },
816
- { name: "\uC790\uB3D9 \uBCD1\uD569 (3-way merge)", value: "merge" },
817
- { name: "\uBCF5\uC81C (\uC6D0\uBCF8 \uC720\uC9C0 + .conflict \uD30C\uC77C \uC0DD\uC131)", value: "duplicate" }
818
- ]
1106
+ choices: applicableChoices(conflict).map((value) => ({
1107
+ name: choiceName(conflict, value, uploads),
1108
+ value
1109
+ }))
819
1110
  });
820
1111
  const result = await orchestrator.resolveConflict(conflict, choice);
821
1112
  if (result.success) {
822
- console.log(`\x1B[32m\u2713 ${path} \u2192 ${choice}\uB85C \uD574\uACB0\x1B[0m`);
1113
+ console.log(`\x1B[32m\u2713 ${path} \u2192 ${choiceName(conflict, choice, uploads)}\x1B[0m`);
823
1114
  return true;
824
1115
  }
825
1116
  if (result.mergeHadConflicts) {
@@ -831,18 +1122,19 @@ ${"\u2500".repeat(60)}`);
831
1122
  }
832
1123
 
833
1124
  // src/commands/watch.ts
834
- import { Command as Command9 } from "commander";
1125
+ import { Command as Command10 } from "commander";
835
1126
  import {
836
- ConfigManager as ConfigManager9,
837
- StateDB as StateDB8,
838
- NotionClient as NotionClient9,
839
- SyncOrchestrator as SyncOrchestrator8,
840
- NodeVaultFS as NodeVaultFS8,
841
- WatchSyncService
1127
+ ConfigManager as ConfigManager11,
1128
+ StateDB as StateDB10,
1129
+ NotionClient as NotionClient10,
1130
+ SyncOrchestrator as SyncOrchestrator9,
1131
+ WatchSyncService,
1132
+ DEFAULT_WATCH_DEBOUNCE_MS
842
1133
  } from "@im-nobsidian/core";
843
1134
  function formatResult(result) {
844
1135
  const pull = `Pull: +${result.pull.created} ~${result.pull.updated} -${result.pull.deleted}`;
845
- const push = `Push: +${result.push.created} ~${result.push.updated} -${result.push.deleted}`;
1136
+ const moved = result.push.moved > 0 ? ` \u2192${result.push.moved}` : "";
1137
+ const push = `Push: +${result.push.created} ~${result.push.updated} -${result.push.deleted}${moved}`;
846
1138
  const parts = [pull, push];
847
1139
  if (result.conflicts.length > 0) {
848
1140
  parts.push(`\uCDA9\uB3CC ${result.conflicts.length}\uAC74`);
@@ -852,35 +1144,59 @@ function formatResult(result) {
852
1144
  function timestamp() {
853
1145
  return (/* @__PURE__ */ new Date()).toLocaleTimeString("ko-KR", { hour12: false });
854
1146
  }
855
- 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) => {
1147
+ function syncLabel(scope) {
1148
+ return scope === "full" ? "\uC8FC\uAE30\uC801 \uB3D9\uAE30\uD654" : "\uB3D9\uAE30\uD654";
1149
+ }
1150
+ 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) => {
1151
+ const debounceMs = Number(options.debounce);
1152
+ const intervalSec = Number(options.interval);
1153
+ if (!Number.isInteger(debounceMs) || debounceMs < 0) {
1154
+ console.error(`--debounce \uB294 0 \uC774\uC0C1\uC758 \uC815\uC218(ms)\uC5EC\uC57C \uD569\uB2C8\uB2E4: ${options.debounce}`);
1155
+ process.exitCode = 1;
1156
+ return;
1157
+ }
1158
+ if (!Number.isInteger(intervalSec) || intervalSec < 0) {
1159
+ console.error(`--interval \uC740 0 \uC774\uC0C1\uC758 \uC815\uC218(\uCD08)\uC5EC\uC57C \uD569\uB2C8\uB2E4: ${options.interval}`);
1160
+ process.exitCode = 1;
1161
+ return;
1162
+ }
856
1163
  const cwd = process.cwd();
857
- const configManager = new ConfigManager9(cwd);
1164
+ const configManager = new ConfigManager11(cwd);
858
1165
  const config = await configManager.load();
859
- const stateDb = StateDB8.open(configManager.dbPath);
860
- const client = NotionClient9.fromConfig(config);
861
- const vaultFs = new NodeVaultFS8(cwd, config.paths);
862
- const orchestrator = new SyncOrchestrator8(config, stateDb, client, vaultFs);
863
- const debounceMs = parseInt(options.debounce, 10);
864
- const intervalSec = parseInt(options.interval, 10);
1166
+ const stateDb = StateDB10.open(configManager.dbPath);
1167
+ const client = NotionClient10.fromConfig(config);
1168
+ const vaultFs = await openVaultFs(cwd, config.paths);
1169
+ const orchestrator = new SyncOrchestrator9(config, stateDb, client, vaultFs);
865
1170
  const service = new WatchSyncService(cwd, orchestrator, {
866
1171
  debounceMs,
867
1172
  onFileChange: (event, path) => {
868
1173
  console.log(`[${timestamp()}] ${event}: ${path}`);
869
1174
  },
870
- onSyncStart: () => {
871
- console.log(`[${timestamp()}] \uB3D9\uAE30\uD654 \uC2DC\uC791...`);
1175
+ onSyncStart: (scope) => {
1176
+ console.log(`[${timestamp()}] ${syncLabel(scope)} \uC2DC\uC791...`);
872
1177
  },
873
- onSyncComplete: (result) => {
874
- console.log(`[${timestamp()}] \uB3D9\uAE30\uD654 \uC644\uB8CC \u2014 ${formatResult(result)}`);
1178
+ onSyncComplete: (result, scope) => {
1179
+ console.log(`[${timestamp()}] ${syncLabel(scope)} \uC644\uB8CC \u2014 ${formatResult(result)}`);
875
1180
  },
876
- onSyncError: (error) => {
877
- console.error(`[${timestamp()}] \uB3D9\uAE30\uD654 \uC2E4\uD328: ${error.message}`);
1181
+ onSyncCancelled: (result, scope) => {
1182
+ console.log(
1183
+ `[${timestamp()}] ${syncLabel(scope)} \uCDE8\uC18C \u2014 \uBA48\uCD94\uAE30 \uC804\uAE4C\uC9C0 ${formatResult(result)}`
1184
+ );
1185
+ },
1186
+ onSyncError: (error, scope) => {
1187
+ console.error(`[${timestamp()}] ${syncLabel(scope)} \uC2E4\uD328: ${error.message}`);
878
1188
  }
879
1189
  });
880
1190
  let pullTimer = null;
1191
+ let shuttingDown = false;
881
1192
  const shutdown = async () => {
1193
+ if (shuttingDown) {
1194
+ console.log(`[${timestamp()}] \uAC15\uC81C \uC885\uB8CC`);
1195
+ return process.exit(1);
1196
+ }
1197
+ shuttingDown = true;
882
1198
  console.log(`
883
- [${timestamp()}] \uAC10\uC2DC \uC885\uB8CC \uC911...`);
1199
+ [${timestamp()}] \uAC10\uC2DC \uC885\uB8CC \uC911... (\uB3C4\uB294 \uB3D9\uAE30\uD654\uB97C \uBA48\uCD94\uACE0 \uAE30\uB2E4\uB9BD\uB2C8\uB2E4)`);
884
1200
  if (pullTimer) clearInterval(pullTimer);
885
1201
  await service.stop();
886
1202
  stateDb.close();
@@ -893,31 +1209,15 @@ var watchCommand = new Command9("watch").description("\uD30C\uC77C \uBCC0\uACBD
893
1209
  console.log(`[${timestamp()}] \uD30C\uC77C \uAC10\uC2DC \uC2DC\uC791 (debounce: ${debounceMs}ms)`);
894
1210
  if (intervalSec > 0) {
895
1211
  console.log(`[${timestamp()}] \uD480 \uB3D9\uAE30\uD654 \uC8FC\uAE30: ${intervalSec}\uCD08`);
896
- pullTimer = setInterval(async () => {
897
- if (service.isSyncing()) return;
898
- try {
899
- console.log(`[${timestamp()}] \uC8FC\uAE30\uC801 \uB3D9\uAE30\uD654 \uC2DC\uC791...`);
900
- const result = await orchestrator.sync();
901
- console.log(`[${timestamp()}] \uC8FC\uAE30\uC801 \uB3D9\uAE30\uD654 \uC644\uB8CC \u2014 ${formatResult(result)}`);
902
- } catch (error) {
903
- const msg = error instanceof Error ? error.message : String(error);
904
- console.error(`[${timestamp()}] \uC8FC\uAE30\uC801 \uB3D9\uAE30\uD654 \uC2E4\uD328: ${msg}`);
905
- }
906
- }, intervalSec * 1e3);
1212
+ pullTimer = setInterval(() => service.requestFullSync(), intervalSec * 1e3);
907
1213
  }
908
1214
  await new Promise(() => {
909
1215
  });
910
1216
  });
911
1217
 
912
1218
  // src/commands/verify.ts
913
- import { Command as Command10 } from "commander";
914
- import {
915
- ConfigManager as ConfigManager10,
916
- StateDB as StateDB9,
917
- NotionClient as NotionClient10,
918
- SyncOrchestrator as SyncOrchestrator9,
919
- NodeVaultFS as NodeVaultFS9
920
- } from "@im-nobsidian/core";
1219
+ import { Command as Command11 } from "commander";
1220
+ import { ConfigManager as ConfigManager12, StateDB as StateDB11, NotionClient as NotionClient11, SyncOrchestrator as SyncOrchestrator10 } from "@im-nobsidian/core";
921
1221
  import chalk7 from "chalk";
922
1222
  var SAMPLE_LIMIT = 10;
923
1223
  function printIdSample(label, ids, color) {
@@ -1006,15 +1306,15 @@ function printReport(report2) {
1006
1306
  );
1007
1307
  }
1008
1308
  }
1009
- var verifyCommand = new Command10("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) => {
1309
+ 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) => {
1010
1310
  const cwd = process.cwd();
1011
- const configManager = new ConfigManager10(cwd);
1311
+ const configManager = new ConfigManager12(cwd);
1012
1312
  const config = await configManager.load();
1013
- const stateDb = StateDB9.open(configManager.dbPath);
1313
+ const stateDb = StateDB11.open(configManager.dbPath);
1014
1314
  try {
1015
- const client = NotionClient10.fromConfig(config);
1016
- const vaultFs = new NodeVaultFS9(cwd, config.paths);
1017
- const orchestrator = new SyncOrchestrator9(config, stateDb, client, vaultFs);
1315
+ const client = NotionClient11.fromConfig(config);
1316
+ const vaultFs = await openVaultFs(cwd, config.paths);
1317
+ const orchestrator = new SyncOrchestrator10(config, stateDb, client, vaultFs);
1018
1318
  const report2 = await orchestrator.verifyCompleteness();
1019
1319
  if (opts.json) {
1020
1320
  console.log(JSON.stringify(report2, null, 2));
@@ -1027,6 +1327,37 @@ var verifyCommand = new Command10("verify").description("\uC644\uACB0\uC131 \uAC
1027
1327
  }
1028
1328
  });
1029
1329
 
1330
+ // src/commands/prepare-db.ts
1331
+ import { Command as Command12 } from "commander";
1332
+ import { ConfigManager as ConfigManager13, StateDB as StateDB12, NotionClient as NotionClient12, SyncOrchestrator as SyncOrchestrator11 } from "@im-nobsidian/core";
1333
+ import chalk8 from "chalk";
1334
+ var prepareDbCommand = new Command12("prepare-db").description("\uBCFC\uD2B8\uC758 \uB178\uD2B8 \uD3F4\uB354\uB97C \uB2E4\uC74C push \uC5D0\uC11C Notion DB \uB85C \uB9CC\uB4E4\uB3C4\uB85D \uC900\uBE44 (Notion \uC740 \uADF8\uB300\uB85C)").argument("<folder>", "DB \uB85C \uB9CC\uB4E4 \uD3F4\uB354 \u2014 \uBCFC\uD2B8 \uAE30\uC900 \uACBD\uB85C. \uBC14\uB85C \uC544\uB798 \uB178\uD2B8\uAC00 \uD589\uC774 \uB41C\uB2E4").action(async (folder) => {
1335
+ const cwd = process.cwd();
1336
+ const configManager = new ConfigManager13(cwd);
1337
+ const config = await configManager.load();
1338
+ const stateDb = StateDB12.open(configManager.dbPath);
1339
+ try {
1340
+ const client = NotionClient12.fromConfig(config);
1341
+ const vaultFs = await openVaultFs(cwd, config.paths);
1342
+ const orchestrator = new SyncOrchestrator11(config, stateDb, client, vaultFs);
1343
+ const report2 = await orchestrator.prepareFolderDatabase(folder, obsidianConfigDir(cwd));
1344
+ console.log(` ${icons.success} ${report2.sidecarPath} \u2014 \uD589 ${report2.rows}\uAC1C`);
1345
+ for (const property of report2.properties) {
1346
+ console.log(` ${property.name}: ${property.type} ${chalk8.dim(`(${property.reason})`)}`);
1347
+ }
1348
+ if (report2.views.length > 0) console.log(` \uBCF4\uAE30: ${report2.views.join(", ")}`);
1349
+ for (const skip of report2.skipped) {
1350
+ console.log(chalk8.dim(` \uC62E\uAE30\uC9C0 \uC54A\uC74C \u2014 ${skip.what}: ${skip.reason}`));
1351
+ }
1352
+ console.log(chalk8.dim(" \uD0C0\uC785\uC744 \uBC14\uAFB8\uB824\uBA74 \uC0AC\uC774\uB4DC\uCE74\uB97C \uACE0\uCE5C \uB4A4 push \uD558\uC138\uC694."));
1353
+ } catch (error) {
1354
+ failedItem(folder, error instanceof Error ? error.message : String(error));
1355
+ process.exitCode = 1;
1356
+ } finally {
1357
+ stateDb.close();
1358
+ }
1359
+ });
1360
+
1030
1361
  // src/utils/exit.ts
1031
1362
  var FORCED_EXIT_DELAY_MS = 100;
1032
1363
  function resolveExitCode(exitCode) {
@@ -1044,7 +1375,7 @@ function scheduleForcedExit(proc, schedule = setTimeout) {
1044
1375
  // src/index.ts
1045
1376
  var requireJson = createRequire(import.meta.url);
1046
1377
  var { version } = requireJson("../package.json");
1047
- var program = new Command11();
1378
+ var program = new Command13();
1048
1379
  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");
1049
1380
  program.addCommand(initCommand);
1050
1381
  program.addCommand(pushCommand);
@@ -1052,11 +1383,19 @@ program.addCommand(pullCommand);
1052
1383
  program.addCommand(syncCommand);
1053
1384
  program.addCommand(statusCommand);
1054
1385
  program.addCommand(diffCommand);
1386
+ program.addCommand(discardCommand);
1055
1387
  program.addCommand(fetchCommand);
1056
1388
  program.addCommand(resolveCommand);
1057
1389
  program.addCommand(watchCommand);
1058
1390
  program.addCommand(verifyCommand);
1059
- program.parseAsync().then(() => {
1060
- scheduleForcedExit(process);
1061
- });
1391
+ program.addCommand(prepareDbCommand);
1392
+ program.parseAsync().then(
1393
+ () => {
1394
+ scheduleForcedExit(process);
1395
+ },
1396
+ (error) => {
1397
+ reportStateDbFailure(error, process);
1398
+ scheduleForcedExit(process);
1399
+ }
1400
+ );
1062
1401
  //# sourceMappingURL=index.js.map