@im-nobsidian/core 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.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  import { Client } from '@notionhq/client';
3
- import { PageObjectResponse, BlockObjectResponse, PageMarkdownResponse, PartialBlockObjectResponse } from '@notionhq/client/build/src/api-endpoints.js';
3
+ import { PageObjectResponse, BlockObjectResponse, PageMarkdownResponse, PartialBlockObjectResponse, LanguageRequest } from '@notionhq/client/build/src/api-endpoints.js';
4
4
 
5
+ /** Notion 내부 통합 토큰의 머리 — 설정 검증과 CLI 의 입력 검사가 함께 읽는다. */
6
+ declare const NOTION_TOKEN_PREFIX = "ntn_";
7
+ /** DB 의 제목 속성 이름 기본값 — Notion 이 새 DB 에 붙이는 이름. */
8
+ declare const DEFAULT_TITLE_PROPERTY = "Name";
5
9
  declare const DatabaseSyncSchema: z.ZodObject<{
6
10
  databaseId: z.ZodString;
7
11
  localFolder: z.ZodString;
@@ -81,47 +85,59 @@ declare const ConfigSchema: z.ZodObject<{
81
85
  conflictStrategy: z.ZodDefault<z.ZodEnum<["local-first", "remote-first", "manual", "duplicate"]>>;
82
86
  autoSync: z.ZodDefault<z.ZodBoolean>;
83
87
  autoSyncInterval: z.ZodDefault<z.ZodNumber>;
88
+ fullReconcileInterval: z.ZodDefault<z.ZodNumber>;
84
89
  deleteSync: z.ZodDefault<z.ZodBoolean>;
85
90
  syncFiles: z.ZodDefault<z.ZodBoolean>;
91
+ createDatabases: z.ZodDefault<z.ZodBoolean>;
92
+ notifications: z.ZodDefault<z.ZodEnum<["silent", "default"]>>;
86
93
  }, "strip", z.ZodTypeAny, {
87
- direction: "push" | "pull" | "both";
88
- conflictStrategy: "local-first" | "remote-first" | "manual" | "duplicate";
94
+ notifications: "silent" | "default";
95
+ direction: "push" | "both" | "pull";
96
+ conflictStrategy: "manual" | "local-first" | "remote-first" | "duplicate";
89
97
  autoSync: boolean;
90
98
  autoSyncInterval: number;
99
+ fullReconcileInterval: number;
91
100
  deleteSync: boolean;
92
101
  syncFiles: boolean;
102
+ createDatabases: boolean;
93
103
  }, {
94
- direction?: "push" | "pull" | "both" | undefined;
95
- conflictStrategy?: "local-first" | "remote-first" | "manual" | "duplicate" | undefined;
104
+ notifications?: "silent" | "default" | undefined;
105
+ direction?: "push" | "both" | "pull" | undefined;
106
+ conflictStrategy?: "manual" | "local-first" | "remote-first" | "duplicate" | undefined;
96
107
  autoSync?: boolean | undefined;
97
108
  autoSyncInterval?: number | undefined;
109
+ fullReconcileInterval?: number | undefined;
98
110
  deleteSync?: boolean | undefined;
99
111
  syncFiles?: boolean | undefined;
112
+ createDatabases?: boolean | undefined;
100
113
  }>;
101
114
  paths: z.ZodObject<{
102
115
  include: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
103
116
  exclude: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
104
117
  attachments: z.ZodDefault<z.ZodString>;
105
118
  }, "strip", z.ZodTypeAny, {
119
+ attachments: string;
106
120
  include: string[];
107
121
  exclude: string[];
108
- attachments: string;
109
122
  }, {
123
+ attachments?: string | undefined;
110
124
  include?: string[] | undefined;
111
125
  exclude?: string[] | undefined;
112
- attachments?: string | undefined;
113
126
  }>;
114
127
  conversion: z.ZodObject<{
115
128
  preferMarkdownApi: z.ZodDefault<z.ZodBoolean>;
116
129
  preserveMarkers: z.ZodDefault<z.ZodBoolean>;
117
130
  frontmatterMapping: z.ZodDefault<z.ZodBoolean>;
118
131
  imageDownload: z.ZodDefault<z.ZodEnum<["immediate", "lazy", "skip"]>>;
132
+ timeZone: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
119
133
  }, "strip", z.ZodTypeAny, {
120
134
  preferMarkdownApi: boolean;
121
135
  preserveMarkers: boolean;
122
136
  frontmatterMapping: boolean;
123
137
  imageDownload: "immediate" | "lazy" | "skip";
138
+ timeZone?: string | undefined;
124
139
  }, {
140
+ timeZone?: string | undefined;
125
141
  preferMarkdownApi?: boolean | undefined;
126
142
  preserveMarkers?: boolean | undefined;
127
143
  frontmatterMapping?: boolean | undefined;
@@ -144,6 +160,7 @@ declare const ConfigSchema: z.ZodObject<{
144
160
  maxFileSizeBytes: z.ZodDefault<z.ZodNumber>;
145
161
  mediaDownloadTimeoutMs: z.ZodDefault<z.ZodNumber>;
146
162
  itemTimeoutMs: z.ZodDefault<z.ZodNumber>;
163
+ markdownCompletionMaxBlocks: z.ZodDefault<z.ZodNumber>;
147
164
  }, "strip", z.ZodTypeAny, {
148
165
  concurrency: number;
149
166
  timeoutMs: number;
@@ -161,6 +178,7 @@ declare const ConfigSchema: z.ZodObject<{
161
178
  maxFileSizeBytes: number;
162
179
  mediaDownloadTimeoutMs: number;
163
180
  itemTimeoutMs: number;
181
+ markdownCompletionMaxBlocks: number;
164
182
  }, {
165
183
  concurrency?: number | undefined;
166
184
  timeoutMs?: number | undefined;
@@ -178,6 +196,7 @@ declare const ConfigSchema: z.ZodObject<{
178
196
  maxFileSizeBytes?: number | undefined;
179
197
  mediaDownloadTimeoutMs?: number | undefined;
180
198
  itemTimeoutMs?: number | undefined;
199
+ markdownCompletionMaxBlocks?: number | undefined;
181
200
  }>;
182
201
  }, "strip", z.ZodTypeAny, {
183
202
  version: 1;
@@ -196,23 +215,27 @@ declare const ConfigSchema: z.ZodObject<{
196
215
  workspaceId?: string | undefined;
197
216
  };
198
217
  sync: {
199
- direction: "push" | "pull" | "both";
200
- conflictStrategy: "local-first" | "remote-first" | "manual" | "duplicate";
218
+ notifications: "silent" | "default";
219
+ direction: "push" | "both" | "pull";
220
+ conflictStrategy: "manual" | "local-first" | "remote-first" | "duplicate";
201
221
  autoSync: boolean;
202
222
  autoSyncInterval: number;
223
+ fullReconcileInterval: number;
203
224
  deleteSync: boolean;
204
225
  syncFiles: boolean;
226
+ createDatabases: boolean;
205
227
  };
206
228
  paths: {
229
+ attachments: string;
207
230
  include: string[];
208
231
  exclude: string[];
209
- attachments: string;
210
232
  };
211
233
  conversion: {
212
234
  preferMarkdownApi: boolean;
213
235
  preserveMarkers: boolean;
214
236
  frontmatterMapping: boolean;
215
237
  imageDownload: "immediate" | "lazy" | "skip";
238
+ timeZone?: string | undefined;
216
239
  };
217
240
  advanced: {
218
241
  concurrency: number;
@@ -231,6 +254,7 @@ declare const ConfigSchema: z.ZodObject<{
231
254
  maxFileSizeBytes: number;
232
255
  mediaDownloadTimeoutMs: number;
233
256
  itemTimeoutMs: number;
257
+ markdownCompletionMaxBlocks: number;
234
258
  };
235
259
  }, {
236
260
  version: 1;
@@ -249,19 +273,23 @@ declare const ConfigSchema: z.ZodObject<{
249
273
  }[] | undefined;
250
274
  };
251
275
  sync: {
252
- direction?: "push" | "pull" | "both" | undefined;
253
- conflictStrategy?: "local-first" | "remote-first" | "manual" | "duplicate" | undefined;
276
+ notifications?: "silent" | "default" | undefined;
277
+ direction?: "push" | "both" | "pull" | undefined;
278
+ conflictStrategy?: "manual" | "local-first" | "remote-first" | "duplicate" | undefined;
254
279
  autoSync?: boolean | undefined;
255
280
  autoSyncInterval?: number | undefined;
281
+ fullReconcileInterval?: number | undefined;
256
282
  deleteSync?: boolean | undefined;
257
283
  syncFiles?: boolean | undefined;
284
+ createDatabases?: boolean | undefined;
258
285
  };
259
286
  paths: {
287
+ attachments?: string | undefined;
260
288
  include?: string[] | undefined;
261
289
  exclude?: string[] | undefined;
262
- attachments?: string | undefined;
263
290
  };
264
291
  conversion: {
292
+ timeZone?: string | undefined;
265
293
  preferMarkdownApi?: boolean | undefined;
266
294
  preserveMarkers?: boolean | undefined;
267
295
  frontmatterMapping?: boolean | undefined;
@@ -284,6 +312,7 @@ declare const ConfigSchema: z.ZodObject<{
284
312
  maxFileSizeBytes?: number | undefined;
285
313
  mediaDownloadTimeoutMs?: number | undefined;
286
314
  itemTimeoutMs?: number | undefined;
315
+ markdownCompletionMaxBlocks?: number | undefined;
287
316
  };
288
317
  }>;
289
318
  type Config = z.infer<typeof ConfigSchema>;
@@ -301,6 +330,12 @@ interface SyncRecord {
301
330
  readonly notionParentId: string | null;
302
331
  readonly contentHash: string;
303
332
  readonly notionLastEdited: string | null;
333
+ /** {@link RemoteObservation.lastEditedBy} — 모르면 null. */
334
+ readonly notionLastEditedBy: string | null;
335
+ /** {@link RemoteObservation.seenAt} — 모르면 null. */
336
+ readonly notionSeenAt: string | null;
337
+ /** 지난 동기화 사본에 해당하는 원격 본문의 지문(`remoteBodyFingerprint`). 모르면 null. */
338
+ readonly notionBodyFingerprint: string | null;
304
339
  readonly localLastModified: string;
305
340
  readonly syncDirection: SyncDirection;
306
341
  readonly fileType: FileType;
@@ -312,6 +347,23 @@ interface SyncRecord {
312
347
  readonly createdAt: string;
313
348
  readonly updatedAt: string;
314
349
  }
350
+ /**
351
+ * 원격 페이지를 본 기록(N-05). Notion 의 수정 시각은 분 단위로 잘려, 시각만으로는 우리가 본 뒤
352
+ * 같은 분 안에서 고친 것을 알 수 없다 — 누가 고쳤는지와 언제 봤는지를 함께 적는다.
353
+ */
354
+ interface RemoteObservation {
355
+ /** Notion `last_edited_time` — 분 단위로 잘린 값. */
356
+ readonly lastEdited: string;
357
+ /** Notion `last_edited_by.id`. 응답에 없으면 null. */
358
+ readonly lastEditedBy: string | null;
359
+ /**
360
+ * 이 기기 시계로 본 때 — 그 실행을 시작한 때(실제로 읽은 때보다 이르다). 이때까지의 원격
361
+ * 편집은 레코드에 들어 있다. 모르면 null — 다음에 내용으로 확인한다.
362
+ */
363
+ readonly seenAt: string | null;
364
+ /** 원격 본문 지문. 적지 않으면(undefined) 있던 값을 둔다 — 본문을 건드리지 않은 관측. */
365
+ readonly bodyFingerprint?: string | null;
366
+ }
315
367
  interface LocalChange {
316
368
  readonly path: string;
317
369
  readonly type: "created" | "modified" | "deleted" | "moved";
@@ -319,12 +371,34 @@ interface LocalChange {
319
371
  readonly previousHash: string | null;
320
372
  readonly movedFrom?: string;
321
373
  }
374
+ /**
375
+ * DB 행 frontmatter 의 속성 차이 — 지난 동기화 시점과 비교해 바뀐 것만(S-01).
376
+ * `title` 은 속성이 아니라 행 제목이라 여기 들어가지 않는다.
377
+ */
378
+ interface RowPropertyChanges {
379
+ /** 새로 생겼거나 값이 바뀐 속성과 그 새 값. 날짜는 적힌 모양의 문자열로 되돌려 둔다. */
380
+ readonly changed: Readonly<Record<string, unknown>>;
381
+ /** 로컬에서 비웠거나 지운 속성 이름. */
382
+ readonly cleared: readonly string[];
383
+ }
322
384
  interface RemoteChange {
323
385
  readonly pageId: string;
324
386
  readonly type: "created" | "modified" | "deleted" | "moved";
325
387
  readonly lastEdited: string;
326
388
  readonly previousEdited: string | null;
327
389
  readonly movedFromParent?: string;
390
+ /**
391
+ * 추적 중인 노트의 볼트 경로. 화면이 내부 id 대신 노트 이름을 보이고, 그 노트만 받을 때 쓴다.
392
+ * 아직 볼트에 없는 새 페이지는 없다.
393
+ */
394
+ readonly path?: string;
395
+ /** Notion 제목 — 볼트 경로가 아직 없는 새 페이지를 화면이 이름으로 보일 때 쓴다. */
396
+ readonly title?: string;
397
+ /**
398
+ * 수정 시각 · 편집자로는 바뀌었는지 가를 수 없다 — 같은 분 안의 편집일 수 있다(N-05).
399
+ * 받는 쪽이 내용으로 확인한다. `modified` 에만 붙는다.
400
+ */
401
+ readonly unverified?: boolean;
328
402
  }
329
403
  interface Conflict {
330
404
  readonly syncRecord: SyncRecord;
@@ -336,7 +410,16 @@ interface Conflict {
336
410
  }
337
411
  interface ProgressItem {
338
412
  readonly path: string;
339
- readonly operation: "create" | "update" | "delete";
413
+ /** `move` — 볼트에서 옮기거나 이름을 바꾼 노트 · 폴더를 Notion 에 반영한다(push). */
414
+ readonly operation: "create" | "update" | "delete" | "move";
415
+ /** 받기(pull) · 올리기(push) 중 어느 쪽 항목인가 — `sync` 만 채운다. 둘을 한 번에 돌려 가를 수 없다. */
416
+ readonly direction?: "pull" | "push";
417
+ /**
418
+ * 받아 봤지만 볼트에 쓰지 않았다(pull) — 바뀐 것이 없거나 로컬을 지켰다. 진행 수에는 들지만 화면이 바뀐
419
+ * 노트로 적지 않는다. 다시 그리기(B14)는
420
+ * 받아 둔 노트를 모두 받아 보므로, 적으면 바뀌지 않은 노트마다 「updated」 줄이 찍혔다.
421
+ */
422
+ readonly unchanged?: boolean;
340
423
  }
341
424
  type ProgressCallback = (current: number, total: number, item: ProgressItem) => void;
342
425
  /** push/pull/sync 가 공통으로 받는 동기화 옵션. */
@@ -347,18 +430,68 @@ interface BaseSyncOptions {
347
430
  readonly onProgress?: ProgressCallback;
348
431
  readonly signal?: AbortSignal;
349
432
  }
433
+ /** 원격까지 보는 상태 확인(`status`)의 옵션. */
434
+ interface StatusOptions {
435
+ /** 취소하면 원격을 훑다 멈추고 {@link OperationAbortedError} 를 던진다 — 반쯤 본 상태를 돌려주지 않는다. */
436
+ readonly signal?: AbortSignal;
437
+ }
350
438
  interface PushOptions extends BaseSyncOptions {
351
439
  readonly excludePaths?: string[];
352
440
  }
353
- type PullOptions = BaseSyncOptions;
441
+ interface PullOptions extends BaseSyncOptions {
442
+ /**
443
+ * Notion 에서 바뀌지 않은 받아 둔 노트도 지금 변환기로 다시 그린다(B14, `sync/rerender-targets.ts`).
444
+ * 볼트에서 고친 노트는 건드리지 않는다. dry-run 에서는 쓰지 않는다 — 다시 그려 봐야 바뀌는지 안다.
445
+ */
446
+ readonly rerender?: boolean;
447
+ }
354
448
  type SyncOptions = BaseSyncOptions;
355
449
  interface PushResult {
450
+ /** 사이드카로 만든(dry-run 은 만들) Notion DB 수(ADR-036). 없으면 0. */
451
+ readonly createdDatabases?: number;
356
452
  readonly created: number;
357
453
  readonly updated: number;
358
454
  readonly deleted: number;
455
+ /**
456
+ * Notion 에 옮김을 반영한 노트 · 폴더 수. 옮기며 본문도 고친 노트도 여기서만 센다 — updated 와
457
+ * 겹치지 않는다. 예전에는 updated 에 섞여, 이름만 바꾼 노트도 「수정」 으로 보였다.
458
+ */
459
+ readonly moved: number;
359
460
  readonly failed: FailedOperation[];
360
461
  readonly duration: number;
361
462
  }
463
+ /**
464
+ * 전체 대조를 한 이유 (ADR-027).
465
+ *
466
+ * - `first` — 받은 것이 없다(처음 pull · 빈 상태 DB).
467
+ * - `forced` — `--force`.
468
+ * - `database-mode` — 루트가 DB 하나라 그 DB 조회가 곧 전체 대조다.
469
+ * - `every-pull` — 주기(`sync.fullReconcileInterval`)가 0 이다.
470
+ * - `due` — 주기가 됐다.
471
+ */
472
+ type FullScanReason = "first" | "forced" | "database-mode" | "every-pull" | "due";
473
+ /**
474
+ * 이번 실행이 원격을 얼마나 훑었나 (ADR-027). `incremental` 은 지난 pull 뒤에 바뀐 페이지 · 행 · DB 만
475
+ * 찾았다는 뜻이다 — 원격에서 지운 것 · 범위 밖으로 옮긴 것은 다음 전체 대조가 반영한다. 화면은 이것으로
476
+ * 그 사실과 다음 전체 대조 시각을 알린다.
477
+ */
478
+ interface RemoteScanInfo {
479
+ readonly kind: "full" | "incremental";
480
+ /** 전체 대조한 이유 — `full` 에만. */
481
+ readonly reason?: FullScanReason;
482
+ /** 마지막으로 전체 대조를 마친 때. 한 번도 없으면 null. */
483
+ readonly lastFullAt: string | null;
484
+ /** 다음 전체 대조 예정 시각 — `incremental` 에만. null 이면 이미 때가 됐다(다음 pull 이 한다). */
485
+ readonly nextFullAt: string | null;
486
+ /**
487
+ * Notion 에서 지운 노트가 아직 볼트에 반영되지 않았을 수 있나 — 바뀐 것만 찾았고 원격 삭제를 볼트에
488
+ * 반영하는 설정(`sync.deleteSync`)일 때. 다음 전체 대조가 반영한다. 설정이 꺼져 있으면 원격 삭제는
489
+ * 볼트에 반영하지 않으므로 늘 false 다.
490
+ */
491
+ readonly deletionsDeferred: boolean;
492
+ /** 바뀐 것이 보이지 않아 조회하지 않은 DB 수 — pull 에만. */
493
+ readonly skippedDatabases?: number;
494
+ }
362
495
  interface PullResult {
363
496
  readonly created: number;
364
497
  readonly updated: number;
@@ -376,6 +509,8 @@ interface PullResult {
376
509
  readonly imageCount: number;
377
510
  readonly fileCount: number;
378
511
  readonly linkCount: number;
512
+ /** 원격을 얼마나 훑었나. 원격을 보지 않은 실행(받기 방향이 아님 · 취소)에는 없다. */
513
+ readonly remoteScan?: RemoteScanInfo;
379
514
  }
380
515
  interface SyncResult {
381
516
  readonly push: PushResult;
@@ -383,13 +518,42 @@ interface SyncResult {
383
518
  readonly conflicts: Conflict[];
384
519
  readonly duration: number;
385
520
  }
521
+ /** 볼트에서 옮기거나 이름을 바꾼 폴더 — 다음 push 가 Notion 에 반영한다(DB 폴더는 볼트 쪽만). */
522
+ interface FolderMoveChange {
523
+ readonly from: string;
524
+ readonly to: string;
525
+ }
526
+ /**
527
+ * 변경 하나를 줄 비교로 보이는 두 글 — `SyncOrchestrator.localChangeDiff` · `remoteChangeDiff` 가 내고,
528
+ * `lineDiff` 로 견준다.
529
+ */
530
+ interface ChangeDiff {
531
+ /** 볼트 경로 — 옮긴 노트는 새 자리. */
532
+ readonly path: string;
533
+ readonly type: LocalChange["type"];
534
+ /** 옮긴 노트의 옛 자리. */
535
+ readonly movedFrom?: string;
536
+ /** 지난 동기화 때의 글. 새 노트는 null — 견줄 옛 글이 없다. */
537
+ readonly before: string | null;
538
+ /** 지금 글 — 로컬 변경은 볼트의 글, 원격 변경은 Notion 의 글. 지운 노트 · 페이지는 null. */
539
+ readonly after: string | null;
540
+ }
386
541
  interface StatusResult {
387
542
  readonly localChanges: LocalChange[];
543
+ /** 옮긴 폴더. 그 안의 노트는 `localChanges` 에 `moved` 로 따로 있다. */
544
+ readonly folderMoves: readonly FolderMoveChange[];
388
545
  readonly remoteChanges: RemoteChange[];
389
546
  readonly conflicts: Conflict[];
390
547
  readonly conflictRecords: SyncRecord[];
391
548
  readonly pendingOperations: number;
392
549
  readonly lastSyncAt: string | null;
550
+ /**
551
+ * 마지막으로 원격을 전체 대조한 pull 이 시작한 때. 한 번도 없으면 null. Notion 에서 지운 노트는 전체
552
+ * 대조 때 볼트에 반영된다 — 화면은 이것으로 그때를 알린다 (ADR-027).
553
+ */
554
+ readonly lastFullScanAt: string | null;
555
+ /** 원격을 얼마나 훑었나. 로컬만 본 확인(`statusLocal`)에는 없다. */
556
+ readonly remoteScan?: RemoteScanInfo;
393
557
  }
394
558
  interface FailedOperation {
395
559
  readonly path: string;
@@ -397,6 +561,17 @@ interface FailedOperation {
397
561
  readonly error: string;
398
562
  }
399
563
 
564
+ /** Notion 이 돌려준 본문에 실린 자식 하나. */
565
+ interface ChildTag {
566
+ readonly kind: "page" | "database";
567
+ /** 하이픈 없는 32자리 hex. */
568
+ readonly id: string;
569
+ /** 태그 안의 제목 그대로(굵게 `**` 가 섞여 있을 수 있다). */
570
+ readonly title: string;
571
+ /** 태그 원문 — 복원은 받은 그대로 다시 싣는다(실측: 받은 태그를 보내면 자식이 그대로 남는다). */
572
+ readonly tag: string;
573
+ }
574
+
400
575
  type ConversionPath = "markdown-api" | "block-api";
401
576
  interface ProcessorInput {
402
577
  readonly content: string;
@@ -418,6 +593,23 @@ interface ProcessorMetadata {
418
593
  * true: 무조건 재간격 / false: 무동작 / 미지정: 내용 기반 휴리스틱 폴백.
419
594
  */
420
595
  readonly notionExportCompact?: boolean;
596
+ /**
597
+ * pull 직전 로컬 노트 원문 — 있을 때만. Notion 에 남길 자리가 없는 표기(코드 펜스의 원래 언어 ·
598
+ * 펜스 기호 S-20, 주석 S-29)를 되살리는 근거다. 호출측이 로컬 파일을 읽어 넘긴다.
599
+ */
600
+ readonly localContent?: string;
601
+ /**
602
+ * 행 노트에서 Notion 이 정하는 frontmatter 키 — 행을 받을 때만. 받은 속성을 로컬 frontmatter 에 합칠 때
603
+ * 이 밖의 키(`aliases` 등)는 로컬 것으로 두고, 키 차례 · 적은 모양도 로컬을 따른다(F-08).
604
+ */
605
+ readonly notionKeys?: readonly string[];
606
+ /**
607
+ * 받은 본문(원시 글)에 실린 자식 페이지 · 자식 DB — 페이지를 받을 때만. push 는 가리키는 줄이 없는 자식을
608
+ * 본문 끝에 되돌려 놓으므로(`restoreChildTags`), 로컬 표기를 되살릴 때 보낼 꼴을 그와 같이 만든다.
609
+ */
610
+ readonly remoteChildren?: readonly ChildTag[];
611
+ /** Markdown API 로 보낼 수 없어 본문을 쓴 뒤 블록으로 채울 코드({@link DeferredCode}). */
612
+ readonly deferredCode?: DeferredCode[];
421
613
  readonly [key: string]: unknown;
422
614
  }
423
615
  interface ConversionContext {
@@ -436,6 +628,27 @@ interface ConversionResult {
436
628
  readonly properties: Record<string, unknown>;
437
629
  readonly images: ImageReference[];
438
630
  readonly preserveMarkers: PreserveMarker[];
631
+ readonly deferredCode: DeferredCode[];
632
+ }
633
+ /**
634
+ * 본문을 쓴 뒤 블록으로 채울 코드(S-22). Notion Markdown API 는 코드에 ``` 로 시작하는 줄이 있으면
635
+ * 어떤 펜스로 보내도 그 줄에서 블록을 가른다 — 코드 자리에 자리표시(`token`)만 보내고, 페이지를
636
+ * 쓴 뒤 그 자리표시를 가진 코드 블록의 글을 `code` 로 바꾼다.
637
+ */
638
+ interface DeferredCode {
639
+ /** 코드 블록에 대신 보낸 자리표시 — 페이지 안에서 하나뿐이다. */
640
+ readonly token: string;
641
+ /** Obsidian 이 보여 주는 그대로의 코드 — 컨테이너 접두 · 펜스 들여쓰기를 뗀 것. */
642
+ readonly code: string;
643
+ }
644
+ /**
645
+ * 북마크 블록의 주소와 캡션(F-09). Markdown API 는 북마크를 주소 · 캡션 없는 자리 태그로만 보내,
646
+ * 블록으로 따로 읽는다(`NotionClient.getBookmarks`).
647
+ */
648
+ interface BookmarkTarget {
649
+ readonly url: string;
650
+ /** 캡션의 글 — 없으면 빈 문자열. */
651
+ readonly caption: string;
439
652
  }
440
653
  interface PreserveMarker {
441
654
  readonly type: string;
@@ -494,6 +707,22 @@ interface ViewConfig {
494
707
  readonly datePropertyId?: string;
495
708
  readonly datePropertyName?: string;
496
709
  readonly viewRange?: "week" | "month";
710
+ /**
711
+ * 보기 설정 원본(`views.retrieve` 의 `configuration`) — push 가 새 DB 에 같은 보기를 만들 때 속성 id 만
712
+ * 바꿔 그대로 보낸다(ADR-036). 위의 낱낱 항목은 `.base` 와 격하 보고가 읽는다.
713
+ */
714
+ readonly configuration?: Readonly<Record<string, unknown>>;
715
+ /** 빠른 필터 — 속성 id → 조건. */
716
+ readonly quickFilters?: Readonly<Record<string, unknown>>;
717
+ }
718
+ /** `views.create` 에 보낼 보기 — `database_id` · `data_source_id` 는 보내는 쪽이 붙인다. */
719
+ interface ViewRequest {
720
+ readonly name: string;
721
+ readonly type: string;
722
+ readonly filter?: unknown;
723
+ readonly sorts?: unknown[];
724
+ readonly quick_filters?: Record<string, unknown>;
725
+ readonly configuration?: Record<string, unknown>;
497
726
  }
498
727
  interface DatabaseViewsConfig {
499
728
  readonly databaseId: string;
@@ -531,6 +760,8 @@ interface BasePropertySchema {
531
760
  readonly type: string;
532
761
  readonly options?: BasePropertyOption[];
533
762
  readonly groups?: BaseStatusGroup[];
763
+ /** 이 속성을 다시 만드는 데 드는 타입별 설정(`view/property-definition.ts`) — 사이드카가 적는다. */
764
+ readonly definition?: Readonly<Record<string, unknown>>;
534
765
  }
535
766
  interface PageCover {
536
767
  readonly type: "file" | "external";
@@ -538,7 +769,7 @@ interface PageCover {
538
769
  readonly expiryTime?: string;
539
770
  }
540
771
  interface PageIcon {
541
- readonly type: "emoji" | "external" | "file" | "icon";
772
+ readonly type: "emoji" | "external" | "file" | "icon" | "custom_emoji";
542
773
  readonly value: string;
543
774
  readonly color?: string;
544
775
  }
@@ -562,7 +793,8 @@ declare class ConfigManager {
562
793
  }
563
794
 
564
795
  interface IStateDB {
565
- close(): void;
796
+ /** 닫는다 — 파일에 남은 쓰기를 마쳐야 하는 구현(플러그인의 sql.js)은 그 일을 Promise 로 돌려준다. */
797
+ close(): void | Promise<void>;
566
798
  getByPath(path: string): SyncRecord | null;
567
799
  getByNotionId(pageId: string): SyncRecord | null;
568
800
  getByStatus(status: SyncStatus): SyncRecord[];
@@ -570,13 +802,18 @@ interface IStateDB {
570
802
  upsert(record: UpsertSyncRecord): SyncRecord;
571
803
  updateStatus(id: string, status: SyncStatus): void;
572
804
  updateHash(id: string, hash: string, snapshot?: Buffer | Uint8Array | null): void;
573
- setNotionLastEdited(id: string, lastEdited: string): void;
805
+ /** 원격을 본 기록을 적는다(N-05). 지문을 적지 않으면 있던 지문을 둔다. */
806
+ setRemoteObservation(id: string, observation: RemoteObservation): void;
807
+ /** 원격 본문 지문만 바꾼다 — 본문을 보냈지만 수정 시각은 올리지 않을 때. */
808
+ setNotionBodyFingerprint(id: string, fingerprint: string | null): void;
574
809
  updateStatCache(id: string, mtime: string, fileSize: number): void;
575
810
  setNotionParentId(id: string, parentId: string): void;
576
811
  /** 레코드의 로컬 경로를 갱신한다 (파일 rename/move 추적용). */
577
812
  updatePath(id: string, newPath: string): void;
578
813
  delete(id: string): void;
579
814
  resolveWikilink(text: string): WikilinkEntry | null;
815
+ /** relation 값의 위키링크 — 대상 DB(`parentIds`)의 행 가운데서만 찾는다(`wikilinkLookupsIn`). */
816
+ resolveWikilinkIn(text: string, parentIds: readonly string[]): WikilinkEntry | null;
580
817
  resolvePageId(pageId: string): WikilinkEntry | null;
581
818
  upsertWikilink(entry: WikilinkEntry): void;
582
819
  /** 레코드 삭제 시 해당 경로의 wikilink 항목을 제거한다 (stale 링크 방지). */
@@ -600,11 +837,19 @@ interface IStateDB {
600
837
  }
601
838
 
602
839
  declare class StateDB implements IStateDB {
840
+ private readonly lock;
603
841
  private readonly db;
604
842
  private constructor();
843
+ /**
844
+ * 상태 DB 파일을 연다 — 없으면 새로 만든다. 한 볼트의 상태 DB 는 한 곳만 연다 — 플러그인이나 다른 CLI 가 쓰는
845
+ * 중이면 {@link StateDbLockedError} 를 던진다(ADR-026). 저장된 파일이 비었거나 잘렸으면 빈 DB 로 열지 않고
846
+ * {@link SavedStateDbError} 를 던진다. 예전에는 0 · 1바이트 파일을 빈 DB 로 열고 닫을 때 빈 DB 로 덮었다.
847
+ */
605
848
  static open(dbPath: string): StateDB;
849
+ private static openLocked;
606
850
  private migrate;
607
851
  private getSchemaVersion;
852
+ /** 닫고 잠금을 푼다 — 닫는 사이 다른 곳이 열지 않게 닫은 뒤에 푼다. */
608
853
  close(): void;
609
854
  getByPath(path: string): SyncRecord | null;
610
855
  getByNotionId(pageId: string): SyncRecord | null;
@@ -613,12 +858,14 @@ declare class StateDB implements IStateDB {
613
858
  upsert(record: UpsertSyncRecord): SyncRecord;
614
859
  updateStatus(id: string, status: SyncStatus): void;
615
860
  updateHash(id: string, hash: string, snapshot?: Buffer | null): void;
616
- setNotionLastEdited(id: string, lastEdited: string): void;
861
+ setRemoteObservation(id: string, observation: RemoteObservation): void;
862
+ setNotionBodyFingerprint(id: string, fingerprint: string | null): void;
617
863
  updateStatCache(id: string, mtime: string, fileSize: number): void;
618
864
  setNotionParentId(id: string, parentId: string): void;
619
865
  updatePath(id: string, newPath: string): void;
620
866
  delete(id: string): void;
621
867
  resolveWikilink(text: string): WikilinkEntry | null;
868
+ resolveWikilinkIn(text: string, parentIds: readonly string[]): WikilinkEntry | null;
622
869
  resolvePageId(pageId: string): WikilinkEntry | null;
623
870
  upsertWikilink(entry: WikilinkEntry): void;
624
871
  deleteWikilink(obsidianPath: string): void;
@@ -649,6 +896,9 @@ interface UpsertSyncRecord {
649
896
  readonly notionParentId?: string | null;
650
897
  readonly contentHash: string;
651
898
  readonly notionLastEdited?: string | null;
899
+ readonly notionLastEditedBy?: string | null;
900
+ readonly notionSeenAt?: string | null;
901
+ readonly notionBodyFingerprint?: string | null;
652
902
  readonly localLastModified: string;
653
903
  readonly syncDirection: SyncRecord["syncDirection"];
654
904
  readonly fileType: SyncRecord["fileType"];
@@ -694,26 +944,413 @@ interface RecordPendingInput {
694
944
  readonly payload?: string | null;
695
945
  }
696
946
 
947
+ /** 스키마 하나 — 이 버전보다 낮은 DB 에 `sql` 을 돌린다. `sql` 은 끝에 `schema_version` 을 올린다. */
948
+ interface StateDbMigration {
949
+ readonly version: number;
950
+ readonly sql: string;
951
+ }
952
+ /**
953
+ * 상태 DB 스키마 — 차례대로. CLI(better-sqlite3)와 플러그인(sql.js)이 이 목록 하나를 돈다. 예전에는 플러그인이
954
+ * SQL 을 손으로 베껴, 처음 두 스키마의 CHECK 제약이 플러그인 DB 에만 없었다.
955
+ */
956
+ declare const STATE_DB_MIGRATIONS: readonly StateDbMigration[];
957
+
958
+ /**
959
+ * 상태 DB 를 지금 쓸 수 없다 — 사용자가 풀 수 있는 실패다. 파일이 깨졌거나({@link SavedStateDbError}), 다른 곳이
960
+ * 쓰는 중이거나({@link StateDbLockedError}), CLI 가 남긴 기록이 아직 WAL 파일에만 있다({@link PendingWalError}).
961
+ *
962
+ * 할 일(`guidance`)을 같이 싣는다. 엔진을 띄우지 못한 것 같은 다른 실패와 가른다 — 거기에 할 일을 붙이면 멀쩡한
963
+ * 기록을 치우게 된다. 화면(CLI · 플러그인)은 이 종류인지만 보고, 다시 해 보는 법만 넘긴다.
964
+ *
965
+ * @see ./saved-state-db-error.ts
966
+ * @see ./state-lock.ts
967
+ */
968
+ declare abstract class StateDbUnavailableError extends Error {
969
+ /** 할 일 — 화면마다 다시 해 보는 법(`retry`, 예: 「명령을 다시 실행하세요」)만 다르다. */
970
+ abstract guidance(retry: string): string;
971
+ }
972
+
973
+ /**
974
+ * 저장된 상태 DB 파일로 DB 를 열지 못했다 — 비었거나 잘렸거나 상태 DB 가 아닌 파일이다. 두 구현(CLI 의
975
+ * better-sqlite3 · 플러그인의 sql.js)이 같이 던진다. 엔진을 띄우지 못한 것 같은 다른 실패와 가른다 — 파일을
976
+ * 치우라는 안내({@link SavedStateDbError.guidance})는 이 경우에만 맞다.
977
+ */
978
+ declare class SavedStateDbError extends StateDbUnavailableError {
979
+ constructor(message: string);
980
+ /**
981
+ * 동기화 기록 표가 없다 — SQLite 는 비었거나(0바이트) 앞 한 바이트만 남은 파일을 «빈 DB» 로 연다. 보지 않으면
982
+ * 기록을 잃은 채 처음부터 시작하고, 다음 push 가 모든 노트의 페이지를 또 만든다.
983
+ */
984
+ static noTables(bytes: number): SavedStateDbError;
985
+ /** SQLite 가 파일을 열지 못했다 — 잘렸거나(malformed) 상태 DB 가 아니다(not a database). */
986
+ static unreadable(error: unknown): SavedStateDbError;
987
+ /**
988
+ * 파일을 사본으로 바꾸거나 치운다. 폴더 이름이 점으로 시작해 Obsidian 의 파일 탐색기에는 보이지 않으므로 볼트
989
+ * 폴더에서 찾으라고 적는다.
990
+ */
991
+ guidance(retry: string): string;
992
+ }
993
+ /**
994
+ * 저장된 파일에 동기화 기록 표가 있는지 묻는 조회 — 한 번이라도 쓴 파일에는 마이그레이션이 만든 표가 있다. 한
995
+ * 페이지 이상 잘린 파일은 SQLite 가 이 조회에서 던진다(malformed).
996
+ */
997
+ declare const SAVED_STATE_TABLES_QUERY = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sync_metadata'";
998
+
999
+ /**
1000
+ * 위키링크 글자 → `wikilink_map` 행을 찾는 조회들 — 앞에서부터 처음 맞는 행을 쓴다.
1001
+ *
1002
+ * CLI 의 better-sqlite3(`StateDB`)와 플러그인의 sql.js(`SqlJsStateDB`)가 같은 차례 · 같은 SQL 을 쓰도록
1003
+ * 여기 한 벌만 둔다. 예전에는 두 곳에 손으로 베껴 있었다.
1004
+ *
1005
+ * - 제목 → 별칭 → 파일 이름 차례. 파일 이름은 `[[노트]]` 와 `[[폴더/노트]]` 둘 다 받는다.
1006
+ * 예전 조회(`LIKE '%/노트.md'`)는 슬래시가 앞에 있어야 맞아서 볼트 맨 위 노트와 경로 붙은 링크를 못 찾았다 —
1007
+ * 제목이 파일 이름과 다른 노트(파일 이름에 못 쓰는 글자를 바꾼 노트)로 가는 링크가 Notion 에서 글로 남았다.
1008
+ * - LIKE 의 `%` · `_` 는 글자로 견준다. 예전에는 `a_b` 가 `a-b.md` 에도 맞았다.
1009
+ */
1010
+ interface WikilinkLookup {
1011
+ readonly sql: string;
1012
+ readonly params: (text: string) => string[];
1013
+ }
1014
+ declare const WIKILINK_LOOKUPS: readonly WikilinkLookup[];
1015
+ /**
1016
+ * relation 값의 위키링크 → 대상 DB(`parentIds` — DB id · data source id)의 행만 보는 조회들.
1017
+ *
1018
+ * - 대상 DB 밖 페이지는 고르지 않는다. Notion 은 relation 에 다른 DB 의 페이지를 넣으면 오류 없이 빼 버린다
1019
+ * (2026-10-23 실측: 200 응답 · 값만 빠짐) — 제목이 같은 다른 DB 의 행으로 풀리면 relation 이 조용히 사라졌다.
1020
+ * - 파일 이름 → 제목 → 별칭 차례. pull 은 relation 값을 파일 이름으로 적는다. 제목을 먼저 보면 제목이 같은
1021
+ * 두 행(한쪽 파일 이름에 `(id)` 가 붙은) 가운데 엉뚱한 행을 걸었다.
1022
+ */
1023
+ declare function wikilinkLookupsIn(parentIds: readonly string[]): WikilinkLookup[];
1024
+
1025
+ /** 상태 DB 파일 옆의 잠금 파일. */
1026
+ declare function stateDbLockPath(dbPath: string): string;
1027
+ /** 상태 DB 를 여는 도구. */
1028
+ type StateDbTool = "cli" | "plugin";
1029
+ /** 잠금을 잡는 쪽 — 화면에 보일 이름(`label`, 예: 플러그인 이름)은 있으면 적는다. */
1030
+ interface StateLockClaim {
1031
+ readonly tool: StateDbTool;
1032
+ readonly label?: string;
1033
+ }
1034
+ /** 잠금 파일에 적는 주인. */
1035
+ interface StateLockOwner extends StateLockClaim {
1036
+ readonly pid: number;
1037
+ /** PID 가 같은 뜻을 갖는 범위 — {@link processScope}. */
1038
+ readonly scope: string;
1039
+ /** 잠금을 잡은 실행 환경 — 같은 PID 의 옛 환경이 남긴 잠금을 가른다({@link processContext}). */
1040
+ readonly context: string;
1041
+ readonly startedAt: string;
1042
+ /** 잠금마다 새로 만드는 표 — 넘겨받힌 뒤 남의 잠금을 지우지 않도록 내 것인지 가른다. */
1043
+ readonly token: string;
1044
+ }
1045
+ /** 잠금이 보는 바깥 — 시험이 갈아 끼운다. */
1046
+ interface StateLockEnv {
1047
+ readonly pid: number;
1048
+ readonly scope: string;
1049
+ readonly context: string;
1050
+ now(): number;
1051
+ isAlive(pid: number): boolean;
1052
+ }
1053
+ declare class StateLock {
1054
+ private readonly path;
1055
+ readonly owner: StateLockOwner;
1056
+ private readonly env;
1057
+ private heartbeat;
1058
+ private constructor();
1059
+ /**
1060
+ * 잠금을 잡는다. 다른 곳이 쥐고 있으면 {@link StateDbLockedError} 를 던진다. 주인이 끝나지 않고 죽었으면 넘겨받는다
1061
+ * — 같은 PID 공간이면 PID 가 없거나 이 프로세스의 옛 실행 환경일 때, 아니면 신호가
1062
+ * {@link STATE_LOCK_STALE_MS} 넘게 끊겼을 때다({@link isAbandoned}).
1063
+ */
1064
+ static acquire(path: string, claim: StateLockClaim, env?: StateLockEnv): StateLock;
1065
+ /** 아직 내 잠금인가 — 잠금 파일에 내 표가 적혀 있다. */
1066
+ isOwned(): boolean;
1067
+ /**
1068
+ * 내 잠금이 아니면 던진다 — 파일에 쓰기 전에 부른다. 멈춘 줄 알고 다른 곳이 넘겨받았는데 옛 사본을 쓰면 그쪽
1069
+ * 기록을 지운다.
1070
+ */
1071
+ assertOwned(): void;
1072
+ /**
1073
+ * 잠금을 푼다 — 내 잠금일 때만 파일을 지운다. 여러 번 불러도 된다. 풀지 못해도 던지지 않는다 — 할 일은 이미
1074
+ * 끝났고, 남은 잠금은 이 프로세스가 끝나면 다음에 여는 곳이 넘겨받는다.
1075
+ */
1076
+ release(): void;
1077
+ private beat;
1078
+ }
1079
+ /**
1080
+ * 상태 DB 를 다른 곳이 쓰는 중이다 — 기다렸다가 다시 하면 된다. 쓰는 곳이 없는데도 계속 막히면(PID 를 다른
1081
+ * 프로세스가 물려받음) 잠금 파일을 지우는 법까지 알린다.
1082
+ */
1083
+ declare class StateDbLockedError extends StateDbUnavailableError {
1084
+ readonly holder: StateLockOwner | null;
1085
+ private constructor();
1086
+ /** 다른 곳이 잠금을 쥐고 있다. */
1087
+ static held(holder: StateLockOwner | null, signalAgeMs: number): StateDbLockedError;
1088
+ /** 쥐고 있던 잠금을 다른 곳이 넘겨받았다 — 멈춘 것으로 보였다. 옛 사본을 파일에 쓰지 않는다. */
1089
+ static lost(holder: StateLockOwner | null): StateDbLockedError;
1090
+ guidance(retry: string): string;
1091
+ }
1092
+
1093
+ /** SQLite 가 WAL 모드에서 쓰는 옆 파일 — 이름은 SQLite 가 정한다. */
1094
+ declare function stateDbWalPath(dbPath: string): string;
1095
+ /**
1096
+ * 최근 기록이 아직 WAL 파일에만 있다 — CLI 가 쓰는 중이거나 끝까지 닫지 못했다. CLI 가 닫으면(끝났으면 명령 하나를
1097
+ * 다시 실행하면) 본 파일로 합쳐진다.
1098
+ */
1099
+ declare class PendingWalError extends StateDbUnavailableError {
1100
+ readonly walBytes: number;
1101
+ constructor(walBytes: number);
1102
+ guidance(retry: string): string;
1103
+ }
1104
+ /** WAL 파일에 쌓인 바이트 — 파일이 없으면 0. */
1105
+ declare function pendingWalBytes(dbPath: string): number;
1106
+ /**
1107
+ * WAL 파일에 기록이 남았으면 던진다. 잠금을 잡은 뒤에 부른다 — 지금 CLI 는 잠금이 먼저 막으므로, 여기서 본 WAL 은
1108
+ * 끝나지 않고 죽은 CLI 나 잠금을 모르는 옛 CLI 의 것이다.
1109
+ */
1110
+ declare function assertNoPendingWal(dbPath: string): void;
1111
+
1112
+ /** DB 속성 스키마 한 칸 — {@link NotionClient.getDatabaseSchema} 가 내는 꼴. */
1113
+ interface DatabasePropertySchema {
1114
+ readonly id: string;
1115
+ readonly type: string;
1116
+ /** relation 이 가리키는 DB — DB id · data source id. */
1117
+ readonly relationTargets?: readonly string[];
1118
+ }
697
1119
  type NotionPropertyValue = Record<string, unknown>;
698
1120
  interface WikilinkResolver$1 {
699
1121
  resolve(title: string): string | null;
1122
+ /** 대상 DB(`parentIds`)의 행 가운데서만 — relation 값. 없으면 {@link resolve} 로 찾는다. */
1123
+ resolveIn?(title: string, parentIds: readonly string[]): string | null;
700
1124
  resolvePageId(pageId: string): string | null;
701
1125
  }
1126
+ /** 이름으로 적힌 사람의 사용자 id — 워크스페이스에 없거나 같은 이름이 여럿이면 null. */
1127
+ type PeopleResolver = (name: string) => string | null;
1128
+ interface PropertyMapperOptions {
1129
+ /**
1130
+ * 날짜시각을 적고 보낼 시간대(IANA 이름, 설정 `conversion.timeZone`). 없으면 이 컴퓨터의 시간대.
1131
+ */
1132
+ readonly timeZone?: string;
1133
+ }
1134
+ /** {@link PropertyMapper.toNotionPropertyChanges} 가 기준으로 삼는 지금 값들. */
1135
+ interface PropertyChangeContext {
1136
+ /**
1137
+ * 지금 로컬 frontmatter — 기간의 시작과 끝 중 하나만 바뀌어도 둘 다 보내야 해서 읽는다. 없으면 바뀐
1138
+ * 값만 본다(새 행처럼 비교할 기준이 없을 때와 같다).
1139
+ */
1140
+ readonly current?: Readonly<Record<string, unknown>>;
1141
+ /**
1142
+ * 원격 페이지의 지금 속성(`page.properties`) — 글 속성 · 제목은 이 서식 위에 바뀐 글자만 고친다
1143
+ * ({@link patchRichText}). 없으면 평문으로 보낸다.
1144
+ */
1145
+ readonly remote?: Readonly<Record<string, unknown>>;
1146
+ /**
1147
+ * 파일 속성의 볼트 첨부(`[[attachments/…]]` 의 경로)를 올린 것 — 있으면 그 첨부를 올린 파일로 보낸다
1148
+ * ({@link PropertyMapper.localFileTargets}). 없는 첨부가 섞인 값은 보내지 않는다.
1149
+ */
1150
+ readonly uploads?: ReadonlyMap<string, UploadedFile>;
1151
+ }
1152
+ /** Notion 에 올린 볼트 파일 — 파일 속성 값으로 보낸다. */
1153
+ interface UploadedFile {
1154
+ readonly fileUploadId: string;
1155
+ /** Notion 에 보일 이름 — 받을 때 붙인 내용 해시를 뗀 것. */
1156
+ readonly name: string;
1157
+ }
702
1158
  declare class PropertyMapper {
1159
+ private readonly options;
703
1160
  private schema;
704
1161
  private wikilinkResolver;
1162
+ private peopleResolver;
1163
+ constructor(options?: PropertyMapperOptions);
1164
+ /** 설정의 변환 옵션으로 만든다 — 시간대를 이 한 곳에서 읽는다. */
1165
+ static fromConfig(config: Pick<Config, "conversion">): PropertyMapper;
705
1166
  setWikilinkResolver(resolver: WikilinkResolver$1): void;
706
- loadSchema(properties: Record<string, {
707
- id: string;
708
- type: string;
709
- }>): void;
1167
+ setPeopleResolver(resolver: PeopleResolver): void;
1168
+ /** 스키마에 사람 속성이 있는가 — 있을 때만 사용자 목록을 읽는다. */
1169
+ hasPeopleProperty(): boolean;
1170
+ loadSchema(properties: Record<string, DatabasePropertySchema>): void;
1171
+ /**
1172
+ * frontmatter 전체를 Notion 속성 값으로 바꾼다 — 비교할 기준이 없는 새 행을 만들 때 쓴다.
1173
+ *
1174
+ * 스키마를 읽었으면 스키마에 없는 키는 보내지 않는다. pull 이 적는 `cover` · `icon` 이나
1175
+ * 사용자가 붙인 `aliases` 처럼 DB 속성이 아닌 키를 보내면 Notion 이 «그런 속성은 없다»
1176
+ * 며 요청 전체를 거부한다 — 행의 다른 속성까지 하나도 반영되지 않는다.
1177
+ */
710
1178
  toNotionProperties(frontmatter: Record<string, unknown>, title: string): Record<string, NotionPropertyValue>;
1179
+ /**
1180
+ * 바뀐 속성만 Notion 속성 값으로 바꾼다(S-01).
1181
+ *
1182
+ * 비운 속성은 그 타입의 빈 값으로 보낸다. 되돌릴 수 없는 것은 비우지 않는다 — `files`
1183
+ * 를 비우면 Notion 에 올라간 첨부가 지워지고 볼트에는 그 파일이 없다. `status` 는 빈 값이
1184
+ * 없다. 스키마에 없는 키와 읽기 전용 속성(수식 · 롤업 · 생성일 …)도 보내지 않는다.
1185
+ *
1186
+ * 날짜는 오프셋 없는 시각에 시간대의 오프셋을 붙여 보낸다 — Notion 은 오프셋 없는 시각을 UTC 로 읽는다.
1187
+ * 기간은 시작과 끝(짝 키) 중 하나만 바뀌어도 둘을 함께 보낸다 — 날짜 값은 통째로 바뀐다. 글 속성 ·
1188
+ * 제목은 원격 서식 위에 바뀐 글자만 고친다(`context.remote`).
1189
+ *
1190
+ * @param title 제목이 바뀌었을 때만 새 제목, 아니면 null.
1191
+ * @returns `skipped` — 바뀌었지만 보내지 않은 속성 이름. 호출측이 알린다.
1192
+ */
1193
+ toNotionPropertyChanges(changes: RowPropertyChanges, title: string | null, context?: PropertyChangeContext): {
1194
+ properties: Record<string, NotionPropertyValue>;
1195
+ skipped: string[];
1196
+ };
1197
+ /** Notion 이 값을 정하는 속성(수식 · 롤업 · ID · 생성일 …)이 스키마에 있는가 — 버튼은 값이 없다. */
1198
+ hasComputedProperties(): boolean;
1199
+ /**
1200
+ * 바뀐 값 가운데 파일 속성이 가리키는 볼트 첨부의 경로 — 호출측이 올려 {@link PropertyChangeContext.uploads} 로 준다.
1201
+ *
1202
+ * pull 은 Notion 에 올라간 파일을 받아 `[[attachments/…]]` 로 적는다. 그 값을 새 행이나 바뀐 값으로 보낼 때
1203
+ * 첨부를 다시 올리지 않으면 보낼 수가 없다 — 다른 페이지로 옮겨 올린 행의 파일 속성이 비었다(2026-10-23 E2E 9건).
1204
+ */
1205
+ localFileTargets(changed: Readonly<Record<string, unknown>>): string[];
1206
+ /** relation 값(위키링크 · 페이지 id 목록)이 가리키는 페이지 가운데 알아본 것. */
1207
+ relationTargets(value: unknown): string[];
1208
+ /**
1209
+ * @param targets relation 이 가리키는 DB. 알면 그 DB 의 행으로만 푼다 — 다른 DB 의 페이지는 Notion 이 오류
1210
+ * 없이 빼 버리므로, 못 찾은 것으로 두어 보내지 않고 알린다.
1211
+ */
1212
+ private relationTargetOf;
1213
+ /**
1214
+ * 이 속성들 가운데 relation. 보내지 못한 relation 은 대상 노트가 아직 Notion 에 없었을 수 있다 —
1215
+ * 호출측이 기준에서 빼 두고 다시 보낸다.
1216
+ */
1217
+ relationKeys(keys: readonly string[]): string[];
1218
+ /**
1219
+ * 값을 보낼 수 있는 속성만, pull 이 적는 모양으로 — 원격 값과 로컬 값을 견줄 때 쓴다.
1220
+ *
1221
+ * 고르는 것은 스키마에 있고 읽기 전용이 아니고 제목이 아닌 키와 날짜 속성의 짝 키다. 수식 · 롤업 같은
1222
+ * 값은 달라도 보낼 수 없으니 견주지 않는다. 날짜는 예전 버전이 적은 모양(`…+09:00` · `{start, end}`)도
1223
+ * 시간대의 벽시계 시각 · 짝 키로 바꿔 같은 순간이면 같게 본다(F-08).
1224
+ */
1225
+ writableValues(values: Readonly<Record<string, unknown>>): Record<string, unknown>;
1226
+ /**
1227
+ * `written` 에서 예전 버전이 적은 날짜 모양이 남은 키(F-08) — 날짜 · 생성일 · 수정일 · 날짜 수식 · 날짜
1228
+ * 롤업 가운데, 지금 받으면 적을 값과 같은 순간인데 적힌 모양이 다른 것(`…Z` · `…+09:00` · `{start, end}`).
1229
+ * 기간은 짝 키(`<이름>_end`)도 함께 — 둘 중 하나라도 적혀 있거나 받으면 적을 키만. 원격이 그대로인 행은
1230
+ * 다시 받지 않으므로, 이런 행은 따로 골라 이 키들을 새 모양으로 고쳐 써야 한다.
1231
+ *
1232
+ * 값이 다른 키는 보지 않는다 — 수식 · 롤업은 행의 수정 시각을 바꾸지 않고 다시 계산되고, 시간대 설정을
1233
+ * 바꾸면 벽시계 시각이 달라진다. 모양 때문이 아니다.
1234
+ *
1235
+ * @param written 행 노트에 적힌 frontmatter.
1236
+ * @param notionProps 원격 행의 속성(`page.properties`).
1237
+ * @returns 고쳐 쓸 키. 없으면 빈 배열.
1238
+ */
1239
+ outdatedDateKeys(written: Readonly<Record<string, unknown>>, notionProps: Record<string, unknown>): string[];
711
1240
  fromNotionProperties(notionProps: Record<string, unknown>): Record<string, unknown>;
1241
+ /**
1242
+ * 이 행에서 기간의 끝을 적는 짝 키 — 날짜 속성 이름 → 짝 키. 값이 기간인 속성만이다
1243
+ * ({@link fromNotionProperties} 가 끝을 짝 키에 적는 것과 같다).
1244
+ */
1245
+ rangeEndKeysOf(notionProps: Readonly<Record<string, unknown>>): Map<string, string>;
1246
+ /**
1247
+ * 이 행에서 Notion 이 정하는 frontmatter 키 — {@link fromNotionProperties} 가 적을 수 있는 키 전부다.
1248
+ * 값이 비어 적지 않은 속성과 지금은 기간이 아니어서 적지 않은 짝 키도 든다. pull 이 받은 속성을 로컬
1249
+ * frontmatter 와 합칠 때 이 밖의 키는 로컬 것으로 둔다.
1250
+ */
1251
+ ownedKeys(notionProps: Readonly<Record<string, unknown>>): string[];
1252
+ private zone;
1253
+ /** `key` 가 날짜 속성의 짝 키면 그 날짜 속성 이름. */
1254
+ private rangeOwnerOf;
1255
+ /** 날짜 속성인데 짝 키를 쓰는가 — 같은 이름의 속성이 DB 에 따로 없을 때. */
1256
+ private hasRangeKey;
1257
+ /**
1258
+ * 짝 키를 쓰는 날짜 속성에 보낼 값 — 시작(`name`)과 끝(`name_end`)을 함께. 둘 다 비었으면 지운다.
1259
+ * 끝만 있으면 보낼 수 없다(Notion 의 날짜는 시작이 있어야 한다) — null.
1260
+ */
1261
+ private rangeRequest;
712
1262
  private convertBySchema;
713
1263
  private inferAndConvert;
714
1264
  private extractValue;
715
1265
  }
716
1266
 
1267
+ /**
1268
+ * 동기화가 쓴 페이지가 사람에게 알림을 보내나(2026-10-08 changelog «Control notifications from page writes»).
1269
+ *
1270
+ * - `silent` — 페이지 수정 · @멘션 · 사람 속성 알림을 보내지 않는다. DB 자동화 · 리마인더 · 웹훅은 그대로다
1271
+ * - `default` — Notion 이 정한 대로 보낸다
1272
+ */
1273
+ declare const NOTIFICATION_MODES: readonly ["silent", "default"];
1274
+ type NotificationMode = (typeof NOTIFICATION_MODES)[number];
1275
+
1276
+ /**
1277
+ * 취소 신호. `AbortSignal` 과 호환되며, 테스트에서 가벼운 목 객체로도 쓸 수 있다.
1278
+ */
1279
+ interface AbortLike {
1280
+ readonly aborted: boolean;
1281
+ }
1282
+
1283
+ /** 만들 블록 — 블록 API 요청 모양. 자식은 몸통의 `children` 에 깊이 제한 없이 담는다. */
1284
+ interface BlockRequest {
1285
+ readonly type: string;
1286
+ readonly [body: string]: unknown;
1287
+ }
1288
+
1289
+ /** {@link NotionClient.searchRecentPages} 의 한 줄. */
1290
+ interface RecentPage {
1291
+ readonly id: string;
1292
+ readonly last_edited_time: string;
1293
+ readonly last_edited_by: {
1294
+ readonly id: string;
1295
+ } | null;
1296
+ /**
1297
+ * DB 행이면 그 DB 의 id, 페이지면 null — 바뀐 행이 있는 DB 만 조회하는 데 쓴다(ADR-027). search 는 행의
1298
+ * 부모를 `data_source_id` 로 주면서 DB id 를 함께 싣는다(실측 2026-09-28).
1299
+ */
1300
+ readonly parentDatabaseId: string | null;
1301
+ /**
1302
+ * 부모와 제목 — search 는 페이지 객체를 통째로 준다(실측 2026-10-21: 부모 · 제목 속성 포함). 새 페이지가 받을
1303
+ * 것인지 가르는 데 쓴다. 예전에는 추적하지 않는 페이지마다 다시 읽어 이 둘을 얻었다 — 워크스페이스에서 최근
1304
+ * 고친 페이지가 많을수록 pull 마다 요청이 그만큼 늘었다(B8).
1305
+ */
1306
+ readonly parent: PageObjectResponse["parent"];
1307
+ readonly title: string;
1308
+ }
1309
+ /**
1310
+ * {@link NotionClient.searchRecentDataSources} 의 한 줄 — 속성(스키마) · 제목이 바뀐 data source. 행을 고쳐도
1311
+ * data source 의 수정 시각은 오르지 않는다(실측 2026-09-28) — 행의 변경은 {@link RecentPage} 로 본다.
1312
+ */
1313
+ interface RecentDataSource {
1314
+ readonly id: string;
1315
+ readonly last_edited_time: string;
1316
+ /** 이 data source 가 속한 DB. 모르면 null. */
1317
+ readonly databaseId: string | null;
1318
+ }
1319
+ /** 블록 API 의 블록 하나 — 자식이 있으면 `children` 에 담겨 온다({@link NotionClient.getBlockTree}). */
1320
+ type BlockTreeNode = BlockObjectResponse & {
1321
+ children?: BlockTreeNode[];
1322
+ };
1323
+ /**
1324
+ * DB 하나를 한 번 받은 것 — 행 조회 · 스키마 · 제목이 같은 것을 다시 받지 않고 이것을 읽는다. 예전에는 DB 하나를
1325
+ * 받는 데 같은 DB 를 서너 번 다시 받아(요청 6~8회) 발견 DB 166개의 pull 이 분 단위였다.
1326
+ */
1327
+ interface DatabaseMeta {
1328
+ readonly databaseId: string;
1329
+ /** DB 제목. 없으면 "". */
1330
+ readonly title: string;
1331
+ /** 행을 조회할 data source. 비었으면 옛 단일 소스 DB 다 — DB id 로 조회한다. */
1332
+ readonly dataSources: ReadonlyArray<{
1333
+ readonly id: string;
1334
+ readonly name: string;
1335
+ }>;
1336
+ /**
1337
+ * 속성 스키마 — 읽을 수 있는 모든 data source 의 속성을 합친 것(이름이 겹치면 앞의 것). 읽을 수 있는 data
1338
+ * source 가 없으면 DB 객체의 속성.
1339
+ */
1340
+ readonly properties: Readonly<Record<string, unknown>>;
1341
+ /** 페이지 본문 안에 놓인 인라인 DB 인가. */
1342
+ readonly isInline: boolean;
1343
+ /** DB 의 아이콘 · 커버 · 설명 · 제목 표시 — 받은 그대로. */
1344
+ readonly appearance?: DatabaseAppearance;
1345
+ }
1346
+ /** DB 자체의 꾸밈 — 행이 아니라 DB 의 것이다. 값은 Notion 이 준 그대로(`icon` · `cover` 는 페이지와 같은 꼴). */
1347
+ interface DatabaseAppearance {
1348
+ readonly icon: unknown;
1349
+ readonly cover: unknown;
1350
+ readonly description: readonly unknown[];
1351
+ /** 보기 위에 DB 제목을 보이나(2026-10-20 changelog). */
1352
+ readonly showTitle: boolean;
1353
+ }
717
1354
  interface NotionClientOptions {
718
1355
  readonly token: string;
719
1356
  readonly concurrency?: number;
@@ -724,6 +1361,12 @@ interface NotionClientOptions {
724
1361
  readonly retryBackoffFactor?: number;
725
1362
  readonly pageSize?: number;
726
1363
  readonly batchSize?: number;
1364
+ /** 잘린 Markdown 을 채우려고 다시 받는 블록 수 상한 (S-06) */
1365
+ readonly markdownCompletionMaxBlocks?: number;
1366
+ /** 페이지 속성의 날짜시각을 적을 시간대(`conversion.timeZone`) — 없으면 이 컴퓨터의 시간대. */
1367
+ readonly timeZone?: string;
1368
+ /** 쓰기가 알림을 보내나(`sync.notifications`). */
1369
+ readonly notifications?: NotificationMode;
727
1370
  readonly fetch?: typeof globalThis.fetch;
728
1371
  }
729
1372
  declare class NotionClient {
@@ -736,10 +1379,42 @@ declare class NotionClient {
736
1379
  private readonly retryBackoffFactor;
737
1380
  private readonly defaultPageSize;
738
1381
  private readonly batchSize;
1382
+ private readonly markdownCompletionMaxBlocks;
1383
+ /** {@link getBotUserId} 가 받은 값 — 받는 중이면 그 약속. */
1384
+ private botUserId;
1385
+ /** {@link getUserNames} 가 물은 사용자 이름 — 읽지 못한 사용자는 null. */
1386
+ private readonly userNames;
1387
+ /**
1388
+ * {@link getDatabaseSyncability} 가 읽은 DB — 바로 뒤 {@link getDatabaseMeta} 가 한 번 쓰고 버린다.
1389
+ * 발견한 DB 는 곧장 행을 받으므로 같은 두 요청(DB · data source)을 되풀이하지 않는다.
1390
+ */
1391
+ private readonly checkedDatabases;
1392
+ /** 보낸 요청 수(재시도는 세지 않는다) — 순회 예산을 잰다. */
1393
+ private requestsSent;
739
1394
  constructor(options: NotionClientOptions);
740
1395
  /** config.advanced 의 운영 튜닝값으로 클라이언트를 생성한다 (매직넘버 단일 진실원). */
741
1396
  static fromConfig(config: Config, fetch?: typeof globalThis.fetch): NotionClient;
742
1397
  getInternalClient(): Client;
1398
+ /**
1399
+ * 이 토큰의 봇 사용자 id(`GET /v1/users/me`). 이 통합이 고친 페이지는 `last_edited_by` 가 이
1400
+ * id 다 — 같은 분 안에서 우리 뒤에 누가 고쳤는지 가르는 데 쓴다(`sync/remote-observation.ts`).
1401
+ * 받은 값은 기억한다. 받지 못하면 던지고 기억하지 않는다 — 다음 호출이 다시 묻는다.
1402
+ */
1403
+ getBotUserId(): Promise<string>;
1404
+ /**
1405
+ * 사용자 멘션에 보일 이름(`GET /v1/users/:id`). Markdown API 는 사용자 멘션을 이름 없는 태그로
1406
+ * 내보낸다(`converter/mention.ts`).
1407
+ *
1408
+ * 물은 사용자는 기억한다 — 같은 사람이 여러 노트에 나와도 한 번만 묻는다. 읽지 못한 사용자(권한 없는
1409
+ * 통합 · 나간 사용자)는 이유를 남기고 맵에서 뺀다. 그 멘션은 이름 대신 정해 둔 글로 보이지만, 멘션
1410
+ * 자체는 id 로 보존된다.
1411
+ */
1412
+ getUserNames(ids: readonly string[]): Promise<Map<string, string>>;
1413
+ /** 워크스페이스 사용자 전부(사람 · 봇) — 손님은 API 가 돌려주지 않는다. */
1414
+ listUsers(): Promise<Array<{
1415
+ id: string;
1416
+ name?: string | null;
1417
+ }>>;
743
1418
  getPage(pageId: string): Promise<PageObjectResponse>;
744
1419
  createPage(params: {
745
1420
  parentId: string;
@@ -748,6 +1423,33 @@ declare class NotionClient {
748
1423
  properties?: Record<string, unknown>;
749
1424
  children?: BlockObjectResponse[];
750
1425
  }): Promise<PageObjectResponse>;
1426
+ /**
1427
+ * 페이지 아래에 DB 를 만든다(ADR-036). 같은 요청이 두 번 적용되면 DB 가 둘이 된다 — 다시 보내지 않는다.
1428
+ * 부른 쪽이 만들기 전에 자리를 적어 두고, 응답을 잃으면 다음 push 가 부모에서 찾는다.
1429
+ *
1430
+ * @returns 만든 DB 와 그 data source(DB 를 만들면 하나 생긴다)
1431
+ */
1432
+ createDatabase(params: {
1433
+ parentPageId: string;
1434
+ title: string;
1435
+ isInline: boolean;
1436
+ properties: Record<string, unknown>;
1437
+ /** 아이콘 · 커버 · 설명 · 제목 표시 — 요청 꼴 그대로(`databases.create` 의 같은 이름 칸). */
1438
+ appearance?: Readonly<Record<string, unknown>>;
1439
+ }): Promise<{
1440
+ databaseId: string;
1441
+ dataSourceId: string;
1442
+ }>;
1443
+ /** DB 의 첫 data source — 행 · 스키마가 사는 곳. 없으면 null. */
1444
+ primaryDataSourceId(databaseId: string): Promise<string | null>;
1445
+ /**
1446
+ * data source 에 속성을 더하거나 바꾼다. 같은 이름이면 그 속성을 바꾼다 — 다시 보내도 결과가 같다.
1447
+ *
1448
+ * @returns 바꾼 뒤의 속성 전체(이름 → 정의)
1449
+ */
1450
+ updateDataSourceProperties(dataSourceId: string, properties: Record<string, unknown>): Promise<Record<string, Record<string, unknown>>>;
1451
+ /** data source 의 속성(이름 → 정의). */
1452
+ getDataSourceProperties(dataSourceId: string): Promise<Record<string, Record<string, unknown>>>;
751
1453
  createPageWithMarkdown(params: {
752
1454
  parentId: string;
753
1455
  parentType: "page" | "database";
@@ -755,27 +1457,75 @@ declare class NotionClient {
755
1457
  markdown: string;
756
1458
  properties?: Record<string, unknown>;
757
1459
  }): Promise<PageObjectResponse>;
1460
+ /**
1461
+ * {@link createPageWithMarkdown} 이 버린 맨 앞 `# H1` 을 되살린다 — 그 markdown 이 H1 으로
1462
+ * 시작할 때만 같은 markdown 으로 본문을 한 번 바꾼다({@link startsWithHeading1}).
1463
+ *
1464
+ * 만든 직후, 하위 페이지를 만들기 전에 부른다 — 교체가 지울 자식이 없다. 호출측은 먼저 페이지
1465
+ * 매핑을 적어 둔다: 여기서 실패해도 다음 push 가 새로 만들지 않고 본문을 다시 보낸다.
1466
+ *
1467
+ * @returns 본문을 바꿨으면 true
1468
+ */
1469
+ restoreLeadingHeading(pageId: string, markdown: string): Promise<boolean>;
758
1470
  updatePageProperties(pageId: string, properties: Record<string, unknown>): Promise<PageObjectResponse>;
1471
+ /**
1472
+ * 페이지의 아이콘 · 커버만 바꾼다 — `null` 은 지운다. 값은 `notion/decoration-values.ts` 가 만든 객체다.
1473
+ * 모르는 아이콘 이름 · 색이면 Notion 이 `validation_error` 로 거절하고 아무것도 바꾸지 않는다.
1474
+ */
1475
+ updatePageDecorations(pageId: string, decorations: {
1476
+ readonly icon?: unknown;
1477
+ readonly cover?: unknown;
1478
+ }): Promise<PageObjectResponse>;
1479
+ /** 워크스페이스 사용자 이모지의 id — 이름이 꼭 같은 것. 없으면 null. */
1480
+ findCustomEmojiId(name: string): Promise<string | null>;
1481
+ /**
1482
+ * 페이지 마크다운. 블록이 많아 잘린 부분은 블록 ID 로 다시 받아 채운다(S-06 —
1483
+ * {@link completeTruncatedMarkdown}). 돌려준 `unknown_block_ids` 는 권한이 닿지 않아 끝내
1484
+ * 읽지 못한 블록이고, 그 자리는 원래 블록을 지키는 태그로 남아 있다.
1485
+ */
759
1486
  getPageMarkdown(pageId: string): Promise<PageMarkdownResponse>;
1487
+ private retrieveMarkdown;
1488
+ /**
1489
+ * 본문을 통째로 바꾼다. 자식 페이지 · 자식 DB 는 **지우지 않는다**(S-03).
1490
+ *
1491
+ * `allow_deleting_content: true` 로 보내면 새 본문에 `<page>` · `<database>` 태그가 없는
1492
+ * 자식을 Notion 이 휴지통으로 보낸다. 삭제를 허용하지 않으면 무엇이 지워질지 나열하며
1493
+ * `validation_error` 로 거절하고 아무것도 바꾸지 않는다(2026-09-27 실측) — 호출측이
1494
+ * 자식 태그를 되돌려 놓고 다시 보낸다(`sync/page-body.ts`).
1495
+ */
760
1496
  replacePageMarkdown(pageId: string, markdown: string): Promise<PageMarkdownResponse>;
761
1497
  updatePageMarkdownPartial(pageId: string, patches: Array<{
762
1498
  oldStr: string;
763
1499
  newStr: string;
764
1500
  replaceAll?: boolean;
765
1501
  }>): Promise<PageMarkdownResponse>;
766
- movePage(pageId: string, newParentId: string, newParentType: "page" | "database"): Promise<PageObjectResponse>;
767
1502
  /**
768
- * 데이터베이스 1개를 신 모델(2025-09-03 data source 분리)로 조회한다.
769
- * - title: database 객체에 그대로 존재한다.
770
- * - properties(스키마): 신 모델에선 data source 에 있으므로 1차 data source 를 조회해 채운다.
1503
+ * 페이지를 다른 페이지 아래로 옮긴다 — `POST /v1/pages/{id}/move`.
1504
+ *
1505
+ * 페이지 갱신(pages.update)은 부모를 바꾸지 않는다 — `parent` 를 보내도 오류 없이 무시한다.
1506
+ * 예전 movePage 가 그 길을 써서, 옮긴 노트의 Notion 부모가 한 번도 바뀌지 않았다(S-11).
1507
+ * 같은 부모로 다시 옮겨도 결과가 같아 모호한 실패는 다시 보낸다.
1508
+ */
1509
+ movePage(pageId: string, parentPageId: string): Promise<void>;
1510
+ /**
1511
+ * 데이터베이스 1개를 신 모델(2025-09-03 data source 분리)로 조회한다 — DB 1회 + data source 마다 1회.
1512
+ * - title · data source 목록: database 객체에 그대로 존재한다.
1513
+ * - properties(스키마): 신 모델에선 data source 에 있으므로 data source 를 조회해 채운다.
771
1514
  * data source 접근 불가(링크드 DB 등)면 database 객체의 properties 로 폴백한다(제목은 보존).
772
1515
  *
1516
+ * 같은 DB 의 행 조회 · 스키마 · 제목은 받은 것({@link DatabaseMeta})을 넘겨 다시 받지 않는다.
1517
+ *
773
1518
  * 폐기한 레거시 raw fetch(`GET /v1/databases/{id}`, Notion-Version 2022-06-28)는 신 모델로
774
1519
  * 업그레이드된 다수 DB에 400 을 반환해 자동 발견 DB 가 통째 드롭(내용 손실)되던 원인이었다.
775
1520
  * SDK(`databases.retrieve`/`dataSources.retrieve`)는 동일 토큰으로 정상 동작한다.
776
1521
  */
777
- private fetchDatabaseModern;
778
- getDatabaseTitle(databaseId: string): Promise<string>;
1522
+ getDatabaseMeta(databaseId: string): Promise<DatabaseMeta>;
1523
+ /** DB 와 그 data source 들을 읽는다 — `owners` 는 data source 마다 그 부모 DB(읽지 못했으면 undefined). */
1524
+ private readDatabase;
1525
+ /** `from` 이 이 DB 를 받은 것이면 그것을, 아니면 새로 받는다. */
1526
+ private metaOf;
1527
+ /** @param from 이미 받은 이 DB — 주면 다시 받지 않는다. */
1528
+ getDatabaseTitle(databaseId: string, from?: DatabaseMeta): Promise<string>;
779
1529
  /**
780
1530
  * 발견된 DB 가 **동기화 가능한지**(접근 가능한 data source 가 있는지) 판별한다.
781
1531
  * 신 모델(2025-09-03)에서 링크드 DB·미공유 데이터 소스·삭제 DB 는 `data_sources` 가
@@ -804,10 +1554,8 @@ declare class NotionClient {
804
1554
  originalDbId: string;
805
1555
  viewName: string;
806
1556
  } | null>;
807
- getDatabaseSchema(databaseId: string): Promise<Record<string, {
808
- id: string;
809
- type: string;
810
- }>>;
1557
+ /** @param from 이미 받은 이 DB — 주면 다시 받지 않는다. */
1558
+ getDatabaseSchema(databaseId: string, from?: DatabaseMeta): Promise<Record<string, DatabasePropertySchema>>;
811
1559
  /**
812
1560
  * DB 속성 스키마 전체(선택지·status 그룹 포함).
813
1561
  *
@@ -815,10 +1563,20 @@ declare class NotionClient {
815
1563
  * 옵션명이 아니라 **그룹명**(`To-do` 등)으로 오기 때문에 `groups[].optionIds` 를
816
1564
  * 옵션명으로 되돌리려면 이 id 가 있어야 한다. 예전엔 여기서 id 를 떨궈,
817
1565
  * 그룹으로 필터링된 뷰를 Bases 로 옮길 방법이 아예 없었다.
1566
+ *
1567
+ * @param from 이미 받은 이 DB — 주면 다시 받지 않는다.
818
1568
  */
819
- getDatabaseSchemaFull(databaseId: string): Promise<Record<string, BasePropertySchema>>;
1569
+ getDatabaseSchemaFull(databaseId: string, from?: DatabaseMeta): Promise<Record<string, BasePropertySchema>>;
820
1570
  /** 특정 data source 1개를 직접 쿼리한다(저수준). 다중 data source 순회의 빌딩블록. */
821
1571
  private queryDataSource;
1572
+ /**
1573
+ * data source 하나의 행을 모두 읽는다. 조회 하나는 결과 10,000건에서 멈추고 `request_status.incomplete` 를
1574
+ * 단다(API 2026-04-20) — 그러면 만든 시각 차례로 처음부터 다시 읽되, 한도에 걸릴 때마다 마지막 행의 만든 시각부터
1575
+ * 새 조회를 연다(SDK `iterateAllDataSourceRows` 와 같은 방법 — 그 도우미는 속도 제한을 거치지 않는다).
1576
+ * 한도 밑이면 예전처럼 정렬 없이 한 번 읽는다 — 행 차례(같은 제목의 파일 이름)가 그대로다.
1577
+ */
1578
+ private queryAllRowsOf;
1579
+ private queryRowsByCreatedTime;
822
1580
  /**
823
1581
  * database 의 1차 data source 1페이지를 쿼리한다(증분 detect 등 단일 소스 경로용).
824
1582
  * 전 행을 빠짐없이 받으려면 {@link queryAllDatabasePages} 를 쓴다(전 data source 순회).
@@ -837,8 +1595,10 @@ declare class NotionClient {
837
1595
  * 페이지네이션하면 2번째+ 의 행이 통째 침묵 유실된다. 전 data source 를 순회·페이지네이션
838
1596
  * 하고 page_id 로 디듀프(소스 간 동일 페이지 방어)한 뒤 합친다. 다중 소스면 1회 경고해
839
1597
  * "소스별 탭 구분이 한 폴더로 병합"되는 점을 비침묵으로 알린다.
1598
+ *
1599
+ * @param from 이미 받은 이 DB — 주면 data source 목록을 다시 받지 않는다.
840
1600
  */
841
- queryAllDatabasePages(databaseId: string, filter?: unknown): Promise<PageObjectResponse[]>;
1601
+ queryAllDatabasePages(databaseId: string, filter?: unknown, from?: DatabaseMeta): Promise<PageObjectResponse[]>;
842
1602
  /** database 의 1차 data source id(없으면 databaseId 폴백). 단일 소스 경로 호환용. */
843
1603
  getDataSourceId(databaseId: string): Promise<string>;
844
1604
  /**
@@ -850,7 +1610,19 @@ declare class NotionClient {
850
1610
  name: string;
851
1611
  }>>;
852
1612
  archivePage(pageId: string): Promise<void>;
1613
+ /**
1614
+ * 파일 블록의 지금 받을 수 있는 주소. 블록이 없거나 볼 수 없으면 null 이다.
1615
+ * 그 밖의 실패(일시 오류 등)는 던진다 — 예전에는 모두 null 로 삼켜 이유가 남지 않았다.
1616
+ */
853
1617
  getFileBlockUrl(blockId: string): Promise<string | null>;
1618
+ /**
1619
+ * 북마크 블록의 주소와 캡션(F-09). Markdown API 는 북마크를 주소 없는 자리 태그로만 보낸다
1620
+ * (`converter/bookmark.ts`) — 블록마다 한 번 읽는다.
1621
+ *
1622
+ * 읽지 못한 블록은 이유를 남기고 맵에서 뺀다. 그 북마크는 Notion 의 블록을 가리키는 링크로 보이고,
1623
+ * 블록 자체는 마커로 보존된다. 북마크가 아닌 블록도 뺀다.
1624
+ */
1625
+ getBookmarks(blockIds: readonly string[]): Promise<Map<string, BookmarkTarget>>;
854
1626
  getBlock(blockId: string): Promise<BlockObjectResponse>;
855
1627
  listChildren(blockId: string, options?: {
856
1628
  startCursor?: string;
@@ -862,6 +1634,13 @@ declare class NotionClient {
862
1634
  fetchAllChildren(blockId: string): Promise<BlockObjectResponse[]>;
863
1635
  private static readonly CONTAINER_BLOCK_TYPES;
864
1636
  fetchAllChildrenDeep(blockId: string): Promise<BlockObjectResponse[]>;
1637
+ /**
1638
+ * 블록 트리 — 자식이 있는 블록은 `children` 에 자식을 담는다. 자식 페이지 · DB 는 열지 않는다. 동기화
1639
+ * 참조 블록은 Notion 이 원본의 자식을 돌려주므로 그대로 담는다(Markdown API 도 원본 내용을 싣는다).
1640
+ *
1641
+ * 블록 하나라도 읽지 못하면 던진다 — 빠진 채로 노트를 쓰면 다음 push 가 그 블록을 Notion 에서 지운다.
1642
+ */
1643
+ getBlockTree(blockId: string): Promise<BlockTreeNode[]>;
865
1644
  /**
866
1645
  * 자식 블록을 덧붙인다. `options.after` 를 주면 그 블록 **바로 뒤**에 삽입한다
867
1646
  * (기본은 맨 끝). 자리표시자를 실제 미디어 블록으로 제자리 교체할 때 필요하다.
@@ -873,7 +1652,33 @@ declare class NotionClient {
873
1652
  appendChildren(blockId: string, children: unknown[], options?: {
874
1653
  readonly after?: string;
875
1654
  }): Promise<string[]>;
1655
+ /**
1656
+ * {@link appendChildren} 과 같되 새로 만든 블록을 통째로 돌려준다. 파일을 붙인 미디어 블록은
1657
+ * 응답에 Notion 이 저장한 파일의 URL 이 실려 온다 — 다시 조회하지 않고 그 파일을 알아 둔다(S-05).
1658
+ */
1659
+ appendChildBlocks(blockId: string, children: unknown[], options?: {
1660
+ readonly after?: string;
1661
+ }): Promise<(BlockObjectResponse | PartialBlockObjectResponse)[]>;
1662
+ /**
1663
+ * 깊이 제한 없는 블록 트리를 맨 끝에 덧붙인다 — 요청 한도(깊이 두 단계 · 100 개 · 1000 블록)를 넘는
1664
+ * 자식은 부모를 만든 뒤 그 아래에 마저 붙인다({@link shapeAppendRequests}). 더 깊은 블록의 id 는
1665
+ * 응답에 없어 부모의 자식 목록을 읽어 찾는다.
1666
+ *
1667
+ * 중간에 실패하면 그때까지 만든 블록은 남는다 — 호출측이 실패로 알린다.
1668
+ */
1669
+ appendBlockTree(parentId: string, blocks: readonly BlockRequest[]): Promise<void>;
876
1670
  deleteBlock(blockId: string): Promise<void>;
1671
+ /**
1672
+ * 코드 블록의 글을 바꾼다 — Markdown API 로 보낼 수 없는 코드(코드 속 ``` 줄, S-22)를 블록으로
1673
+ * 채운다. 긴 코드는 rich text 여럿에 나눠 담는다. 같은 글로 다시 보내도 결과가 같아 모호한 실패는
1674
+ * 다시 보낸다.
1675
+ */
1676
+ updateCodeBlockText(blockId: string, code: string, language: LanguageRequest): Promise<void>;
1677
+ /**
1678
+ * 페이지 코드 블록의 글 — 컨테이너 안까지. 받은 markdown 만으로 코드 범위를 가를 수 없을 때(코드 속
1679
+ * ``` 줄, S-22) 블록의 글로 맞춘다.
1680
+ */
1681
+ getCodeBlockTexts(pageId: string): Promise<string[]>;
877
1682
  /** single_part 업로드 상한 (Notion API 규격 20MB) */
878
1683
  private static readonly SINGLE_PART_MAX_BYTES;
879
1684
  /** multi_part 파트 크기 — 규격상 마지막 파트 제외 5~20MB, 10MB 고정 사용 */
@@ -893,29 +1698,35 @@ declare class NotionClient {
893
1698
  results: PageObjectResponse[];
894
1699
  nextCursor: string | null;
895
1700
  }>;
896
- searchRecentPages(since: string): Promise<Array<{
897
- id: string;
898
- last_edited_time: string;
899
- }>>;
1701
+ /**
1702
+ * `since` 뒤에 고친 페이지 — 최근 것부터. DB 행도 온다. 누가 마지막으로 고쳤는지도 싣는다: 수정
1703
+ * 시각은 분 단위라 같은 분 안의 편집은 편집자로만 가를 수 있다(N-05).
1704
+ */
1705
+ searchRecentPages(since: string, signal?: AbortLike): Promise<RecentPage[]>;
1706
+ /**
1707
+ * `since` 뒤에 속성(스키마) · 제목이 바뀐 data source — 최근 것부터. 속성을 더하거나 이름을 바꾸면 행은
1708
+ * 그대로라도 그 DB 의 `.base` 와 행의 frontmatter 가 바뀐다.
1709
+ */
1710
+ searchRecentDataSources(since: string, signal?: AbortLike): Promise<RecentDataSource[]>;
900
1711
  getChildPages(parentId: string): Promise<PageObjectResponse[]>;
901
- /** 페이지가 휴지통(in_trash)이거나 보관(archived) 상태인지 판정한다. */
902
- private static isTrashedOrArchived;
903
1712
  getChildDatabaseIds(parentId: string): Promise<string[]>;
904
1713
  /**
905
1714
  * root 서브트리를 직접 BFS 순회해 하위 페이지를 모두 수집한다(중첩 깊이 무관·무손실).
906
1715
  * 비용은 서브트리 전체 블록 수에 비례한다.
907
1716
  *
908
- * @param opts.deadlineMs `Date.now()` 기준 마감 시각. 각 페이지 처리 전 초과를 검사해
1717
+ * @param opts.maxRequests 이 순회가 쓸 요청 수. 각 페이지 처리 전 초과를 검사해
909
1718
  * 초과 시 {@link DiscoveryTooLargeError}를 던진다(대규모 서브트리 → search 폴백 유도).
910
1719
  * **그때까지 찾은 페이지는 에러의 `partial` 에 실어 보낸다** — 버리면 호출측이 두
911
1720
  * 경로를 경합시키게 되고, 어느 쪽이 이기는지를 벽시계가 정하게 된다(R12-A).
912
1721
  * 미지정 시 무제한(기존 동작 유지 — 테스트 mock 경로는 영향 없음).
1722
+ * @param opts.signal 취소되면 다음 페이지로 가기 전에 `OperationAbortedError` 로 멈춘다.
913
1723
  */
914
1724
  getChildPagesRecursive(parentId: string, opts?: {
915
- deadlineMs?: number;
1725
+ maxRequests?: number;
1726
+ signal?: AbortLike;
916
1727
  }): Promise<PageObjectResponse[]>;
917
1728
  /** 워크스페이스에서 통합에 공유된 모든 페이지를 bulk search 로 열거한다(100/요청). */
918
- searchAllPages(): Promise<PageObjectResponse[]>;
1729
+ searchAllPages(signal?: AbortLike): Promise<PageObjectResponse[]>;
919
1730
  /**
920
1731
  * root 하위 페이지를 **search 기반**으로 디스커버리한다(대규모 서브트리용 폴백).
921
1732
  *
@@ -930,11 +1741,20 @@ declare class NotionClient {
930
1741
  * - 휴지통/보관(in_trash/archived) 페이지 제외
931
1742
  * - root 자신은 제외(직접 순회도 root 의 *자식*부터 수집)
932
1743
  */
933
- getPagesUnderRootViaSearch(rootId: string): Promise<PageObjectResponse[]>;
1744
+ getPagesUnderRootViaSearch(rootId: string, signal?: AbortLike): Promise<PageObjectResponse[]>;
934
1745
  listDatabaseViews(databaseId: string): Promise<ViewConfig[]>;
1746
+ /** DB 에 보기를 만든다. 다시 보내면 같은 보기가 둘 생긴다 — 재시도하지 않는다. @returns 만든 보기 id */
1747
+ createView(params: {
1748
+ databaseId: string;
1749
+ dataSourceId: string;
1750
+ request: ViewRequest;
1751
+ }): Promise<string>;
1752
+ /** 보기를 지운다. DB 의 마지막 보기는 지우지 못한다(400). */
1753
+ deleteView(viewId: string): Promise<void>;
935
1754
  getView(viewId: string): Promise<ViewConfig | null>;
936
1755
  getDatabaseViewsConfig(databaseId: string): Promise<DatabaseViewsConfig>;
937
1756
  extractCover(page: PageObjectResponse): PageCover | null;
1757
+ /** 페이지 아이콘 — `value` 는 frontmatter 에 적는 꼴이다(`notion/decoration-values.ts`). */
938
1758
  extractIcon(page: PageObjectResponse): PageIcon | null;
939
1759
  extractTitle(page: PageObjectResponse): string;
940
1760
  /**
@@ -965,6 +1785,13 @@ declare class NotionClient {
965
1785
  * 268페이지 pull 이 진행 로그가 멈춘 채 수 분씩 정지하는 것으로 나타났다(R9).
966
1786
  * 그래서 (1) 대기 중에는 슬롯을 반납하고, (2) 429 는 {@link cooldownUntil} 전역
967
1787
  * 게이트로 함께 쉬고, (3) 모든 재시도를 경고로 남긴다.
1788
+ *
1789
+ * `idempotent: false` 는 두 번 적용되면 결과가 달라지는 쓰기다(페이지 생성 · 블록
1790
+ * 덧붙이기 · 부분 치환). 이런 요청은 서버가 «처리하지 않았다» 고 분명히 말한 실패만
1791
+ * 다시 보낸다. 타임아웃 · 연결 끊김 · 5xx 는 요청이 이미 적용됐을 수 있고 Notion 은
1792
+ * 멱등 키를 받지 않으므로, 다시 보내면 같은 페이지 · 같은 블록이 두 벌 생긴다.
1793
+ * 그런 실패는 호출측으로 올린다 — 페이지 생성은 WAL(pending_operations)이 부모에서
1794
+ * 제목으로 찾아 입양한다.
968
1795
  */
969
1796
  private withRateLimit;
970
1797
  /** 슬롯을 쥔 뒤의 발사 대기 — 전역 쿨다운(429)과 최소 요청 간격을 함께 지킨다. */
@@ -1214,17 +2041,49 @@ interface VaultCompletenessReport {
1214
2041
  */
1215
2042
  declare function verifyPageCompleteness(remote: PageCompletenessRemoteSource, local: CompletenessLocalSource, rootPageId: string): Promise<PageCompletenessReport>;
1216
2043
 
2044
+ /** 이름 변경 힌트 — 지금 경로 → 마지막 동기화 때 알던 경로. */
2045
+ interface RenameHints {
2046
+ readonly files: Readonly<Record<string, string>>;
2047
+ readonly folders: Readonly<Record<string, string>>;
2048
+ }
2049
+ type RenameKind = "file" | "folder";
2050
+
1217
2051
  interface FileInfo {
1218
2052
  readonly path: string;
1219
2053
  readonly content: string;
1220
2054
  readonly mtime: string;
1221
2055
  }
2056
+ /** 변경 감지 옵션 — 옮긴 노트를 찾는 데 쓴다(S-11). */
2057
+ interface LocalScanOptions {
2058
+ /** 플러그인이 적어 둔 이름 변경 힌트. */
2059
+ readonly hints?: RenameHints;
2060
+ }
2061
+ /**
2062
+ * 옮긴 것으로 짝지은 추적 노트. 상태 DB 는 아직 옛 경로(`record.obsidianPath`)를 적고 있다.
2063
+ */
2064
+ interface LocalMoveAdoption {
2065
+ readonly record: SyncRecord;
2066
+ /** 파일의 지금 경로. */
2067
+ readonly to: string;
2068
+ /** 마지막으로 Notion 에 반영한 경로 — 앞서 입양해 둔 이동이면 그 WAL 의 옛 경로. */
2069
+ readonly origin: string;
2070
+ }
2071
+ interface LocalScan {
2072
+ /** 입양을 마친 것으로 보고 가른 변경 — 옮긴 노트는 새 경로의 `moved` 다. */
2073
+ readonly changes: LocalChange[];
2074
+ readonly adoptions: readonly LocalMoveAdoption[];
2075
+ }
1222
2076
  declare class ChangeDetector {
1223
2077
  private readonly stateDb;
1224
2078
  constructor(stateDb: IStateDB);
1225
- detectLocalChangesFast(stats: FileStatInfo[], readFile: (path: string) => Promise<string>): Promise<LocalChange[]>;
1226
- detectLocalChanges(currentFiles: FileInfo[]): LocalChange[];
1227
- private detectMoves;
2079
+ detectLocalChangesFast(stats: FileStatInfo[], readFile: (path: string) => Promise<string>, options?: LocalScanOptions): Promise<LocalChange[]>;
2080
+ detectLocalChanges(currentFiles: FileInfo[], options?: LocalScanOptions): LocalChange[];
2081
+ /** 파일 크기 · 수정 시각이 지난 동기화와 같으면 내용을 읽지 않는다. */
2082
+ scanLocalChangesFast(stats: readonly FileStatInfo[], readFile: (path: string) => Promise<string>, options?: LocalScanOptions): Promise<LocalScan>;
2083
+ scanLocalChanges(currentFiles: readonly FileInfo[], options?: LocalScanOptions): LocalScan;
2084
+ /** 이름을 바꿔도 파일 크기 · 수정 시각은 그대로다 — 옮긴 노트는 그것으로 건너뛰지 않는다. */
2085
+ private movePending;
2086
+ private assemble;
1228
2087
  }
1229
2088
 
1230
2089
  interface NonMdFileInfo {
@@ -1250,15 +2109,41 @@ interface VaultFS {
1250
2109
  listMarkdownFileStats(): Promise<FileStatInfo[]>;
1251
2110
  listNonMarkdownFiles(): Promise<NonMdFileInfo[]>;
1252
2111
  getFileStat(path: string): Promise<FileStatInfo | null>;
2112
+ /**
2113
+ * 이 볼트(연결)가 맡은 경로인가 — 볼트 안의 다른 연결 폴더(ADR-038)는 false. 없으면 전부 맡는다.
2114
+ */
2115
+ ownsPath?(path: string): boolean;
1253
2116
  }
1254
2117
 
1255
2118
  type ResolutionChoice = "local" | "remote" | "merge" | "duplicate";
2119
+ /**
2120
+ * Notion 에서 지운 노트의 충돌인가 — 올리지 않은 로컬 편집이 남은 채 원격이 휴지통 · 보관 · 없음이다.
2121
+ * 풀 수 있는 것은 «로컬 유지»(Notion 에 새 페이지로 다시 만든다)와 «원격 유지»(볼트에서도 지운다)
2122
+ * 뿐이다 — 병합하거나 사본으로 남길 원격 본문이 없다.
2123
+ */
2124
+ declare function isRemoteDeletion(conflict: Conflict): boolean;
2125
+ /** 이 충돌을 풀 수 있는 선택지 — 앱은 이것만 보여 준다. */
2126
+ declare function applicableChoices(conflict: Conflict): readonly ResolutionChoice[];
2127
+ /**
2128
+ * 전략이 이 충돌에 고르는 것. 원격에서 지운 노트는 복제할 원격이 없어 duplicate 도 «로컬 유지» —
2129
+ * 둘 다 잃는 것이 없다. manual 은 자동 병합인데 지운 원격과는 병합할 수 없어 던진다.
2130
+ */
2131
+ declare function choiceForStrategy(conflict: Conflict, strategy: ConflictStrategy): ResolutionChoice;
1256
2132
  interface ResolutionResult {
1257
2133
  readonly path: string;
1258
2134
  readonly choice: ResolutionChoice;
1259
2135
  readonly success: boolean;
1260
2136
  readonly mergeHadConflicts?: boolean;
2137
+ /** 풀지 못한 이유 — 일괄 해결(`resolveAllConflicts`)이 실패를 던지지 않고 싣는다. */
2138
+ readonly error?: string;
1261
2139
  }
2140
+ /**
2141
+ * 충돌을 볼트 파일과 상태 DB 에서 푼다. **Notion 에는 올리지 않는다.**
2142
+ *
2143
+ * 앱은 `SyncOrchestrator.resolveConflict` 를 쓴다 — 이 클래스로 푼 뒤 고른 결과를 Notion 에 올리고,
2144
+ * 올리지 못하면 충돌로 되돌린다. 이 클래스만 부르면 지난 동기화 사본만 해결 결과로 바뀌어, 다음
2145
+ * pull 이 바뀐 원격으로 고른 로컬 · 병합 결과를 덮는다(N-06).
2146
+ */
1262
2147
  declare class ConflictResolver {
1263
2148
  private readonly stateDb;
1264
2149
  private readonly vaultFs;
@@ -1266,39 +2151,335 @@ declare class ConflictResolver {
1266
2151
  resolve(conflict: Conflict, choice: ResolutionChoice): Promise<ResolutionResult>;
1267
2152
  resolveByStrategy(conflict: Conflict, strategy: ConflictStrategy): Promise<ResolutionResult>;
1268
2153
  resolveAll(conflicts: Conflict[], strategy: ConflictStrategy): Promise<ResolutionResult[]>;
2154
+ /**
2155
+ * Notion 에서 지운 노트 — «로컬 유지» 는 추적만 놓아 파일을 새 노트로 만든다(다음 push 가 새 페이지로
2156
+ * 올린다. 앱은 오케스트레이터가 곧바로 올린다). «원격 유지» 는 볼트에서도 지운다.
2157
+ */
2158
+ private resolveRemoteDeletion;
1269
2159
  private resolveLocal;
1270
2160
  private resolveRemote;
1271
2161
  private resolveMerge;
1272
2162
  private resolveDuplicate;
2163
+ /**
2164
+ * 충돌 미리보기 — 로컬(`-`)과 원격(`+`)의 줄 비교. 바뀐 곳과 앞뒤 세 줄을 `@@` 묶음으로 보인다.
2165
+ *
2166
+ * 예전에는 같은 번호의 줄끼리 견줬다 — 한쪽 앞에 한 줄만 더해도 그 뒤의 모든 줄이 바뀐 것으로 보여,
2167
+ * 무엇이 실제로 다른지 찾을 수 없었다.
2168
+ */
1273
2169
  generateDiff(conflict: Conflict): string;
1274
2170
  }
1275
2171
 
2172
+ interface BaseFileOptions {
2173
+ readonly databaseId: string;
2174
+ readonly databaseName: string;
2175
+ readonly schema: Record<string, BasePropertySchema>;
2176
+ readonly viewsConfig: DatabaseViewsConfig;
2177
+ readonly folderPath: string;
2178
+ /**
2179
+ * 날짜 속성 이름 → 기간의 끝을 적는 짝 키(`<이름>_end`). 행 노트는 기간을 두 키로 적으므로 표에 그 속성이
2180
+ * 보이면 끝 열도 바로 뒤에 둔다(B13). 받은 행 가운데 기간이 있는 속성만 — 기간을 쓰지 않는 날짜 속성에
2181
+ * 늘 빈 열을 두지 않는다.
2182
+ */
2183
+ readonly rangeEnds?: ReadonlyMap<string, string>;
2184
+ }
2185
+ type BasesViewType = "table" | "cards" | "list";
2186
+ /** Notion 뷰 하나를 Bases 로 옮길 때의 판정. */
2187
+ interface BasesViewMapping {
2188
+ /** Bases 뷰 타입. `null` = 대응이 없어 `.base` 에 실을 수 없음. */
2189
+ readonly type: BasesViewType | null;
2190
+ /**
2191
+ * 값이 있으면 **정확한 대응이 아니라 격하**라는 뜻(사람용 사유).
2192
+ * 뷰 자체는 살아남지만 원래 뷰의 표현 일부(달력 격자·타임라인 축)는 재현되지 않는다.
2193
+ */
2194
+ readonly degradeReason?: string;
2195
+ }
2196
+ /**
2197
+ * Notion 뷰 타입 → Obsidian Bases 뷰 매핑(SSOT).
2198
+ *
2199
+ * 뷰는 현재 pull-authoritative — Notion API 가 2026-03-19 뷰 생성/수정/삭제를 열었지만
2200
+ * 본 프로젝트는 아직 뷰 write-back 을 구현하지 않았다(로드맵 항목). push 로 되돌릴 수
2201
+ * 없는 동안 "조용한 유실 금지"는 곧 "보존 + 정직한 degrade 리포트"를 의미한다.
2202
+ *
2203
+ * 달력·타임라인은 예전에 `null` 이었다 — 즉 `.base` 에서 **통째로 사라졌다**. 사이드카에는
2204
+ * 남지만 Obsidian 을 여는 사람 눈에는 그 뷰가 애초에 없었던 것처럼 보인다. 격자·축은
2205
+ * 못 살려도 "날짜 순으로 늘어놓은 목록"이라는 본래 의도는 표로 남길 수 있으므로,
2206
+ * 버리는 대신 격하해 살리고 사유를 사이드카에 적는다.
2207
+ */
2208
+ declare const NOTION_TO_BASES_VIEW: Record<string, BasesViewMapping>;
2209
+ /** Notion 뷰 타입의 Bases 매핑. 미상 타입은 표현 불가로 본다. */
2210
+ declare function basesViewMappingOf(notionType: string): BasesViewMapping;
2211
+ /** Notion 뷰 타입의 Bases 대응 타입을 돌려준다. 미지원/미상 타입은 `null`. */
2212
+ declare function basesViewTypeOf(notionType: string): BasesViewType | null;
2213
+ declare class BaseFileGenerator {
2214
+ generate(options: BaseFileOptions): string;
2215
+ private writeFilters;
2216
+ private needsContentCoverFormula;
2217
+ private writeFormulas;
2218
+ private writePropertyDisplayNames;
2219
+ private writeViews;
2220
+ /** `filters:` 키와 그 값을 쓴다. 단일 식이면 한 줄, 그룹이면 블록. */
2221
+ private writeFilterValue;
2222
+ /** 필터 노드를 시퀀스 항목(`- …`)으로 쓴다. 그룹이면 재귀적으로 중첩한다. */
2223
+ private writeFilterItem;
2224
+ /**
2225
+ * 필터 식을 YAML 스칼라로 쓴다.
2226
+ *
2227
+ * 식은 대개 `note["상태"] == "진행 중"` 처럼 큰따옴표를 품는다. 평문 스칼라는 큰따옴표를
2228
+ * 안에 담을 수 있으므로(선두만 아니면 된다) 기본은 평문으로 두어 기존 `file.ext == "md"`
2229
+ * 라인과 모양을 맞춘다. 다만 `: `·` #`·선두 지시문자는 평문에서 의미가 달라지므로
2230
+ * 그때만 작은따옴표로 감싼다(속성명에 콜론이나 해시가 들어갈 수 있다).
2231
+ */
2232
+ private yamlExpr;
2233
+ /**
2234
+ * 뷰 이름을 Bases 요구사항대로 유일화한다(in-place). 동명 충돌 시 ' 2',' 3'… 를 붙이되
2235
+ * 이미 존재하는 이름과 다시 충돌하지 않을 때까지 증가시킨다.
2236
+ */
2237
+ private dedupeViewNames;
2238
+ private convertView;
2239
+ private toNoteRef;
2240
+ /**
2241
+ * 속성 참조(order/sort/groupBy/image)를 YAML로 안전하게 출력한다.
2242
+ * `file.name`·`formula.coverImage`·`note.cover` 같은 접두사 식별자와 공백 포함 일반 속성명은
2243
+ * 평문 스칼라로 안전하므로 그대로 두고, 콜론·해시·따옴표 등 YAML 메타문자가 있을 때만 인용한다.
2244
+ */
2245
+ private propRef;
2246
+ private resolvePropertyName;
2247
+ private yamlKey;
2248
+ private yamlValue;
2249
+ }
2250
+
2251
+ /**
2252
+ * DB 사이드카(`<db>.notion.json`) — `.base` 로 표현할 수 없는 Notion DB 메타데이터를
2253
+ * 무손실 보존하는 단일 파일.
2254
+ *
2255
+ * ## 왜 필요한가 (I4·I7 "조용한 유실 금지")
2256
+ *
2257
+ * Obsidian Bases(`.base`)는 Notion DB 뷰의 일부만 표현한다:
2258
+ * · 미지원 뷰(calendar/timeline/form/chart/map/dashboard)는 `.base` 에서 통째 누락.
2259
+ * · 표현되는 뷰도 Notion **필터식·커버 크기/비율·컬럼 width/wrap·빈그룹 숨김** 등은
2260
+ * Bases 문법에 대응이 없어 손실된다.
2261
+ *
2262
+ * 사이드카는 DB 스키마의 원본이기도 하다(ADR-036, v2) — 새 루트로 올린 DB 폴더를 push 가 이것으로
2263
+ * Notion DB 로 다시 만든다. 그래서 속성마다 다시 만드는 데 드는 설정(`definition`)과 인라인 여부를 적는다.
2264
+ * v1(옵션 이름만)도 읽는다(`sync/database-schema.ts`). `.base` 와 같은 폴더에 두어 사람이 발견할 수 있고
2265
+ * 도구가 다시 쓸 수 있다.
2266
+ *
2267
+ * ## 멱등성
2268
+ *
2269
+ * 매 pull 마다 재생성되므로 **결정적**이어야 한다(타임스탬프·실행시각 금지, 키 정렬).
2270
+ * 동일 DB 상태 → 동일 바이트 → drift 0 / churn 0.
2271
+ */
2272
+ /** 이 도구가 쓴 사이드카의 표지 — 읽는 쪽은 이 값으로 사용자가 만든 JSON 과 가린다. */
2273
+ declare const SIDECAR_GENERATOR = "im-nobsidian";
2274
+ /** 지금 쓰는 사이드카 형식 판. 판이 적히지 않은 옛 파일은 1 로 읽는다. */
2275
+ declare const SIDECAR_SCHEMA_VERSION = 2;
2276
+ interface NotionSidecar {
2277
+ readonly generator: typeof SIDECAR_GENERATOR;
2278
+ readonly schemaVersion: typeof SIDECAR_SCHEMA_VERSION;
2279
+ readonly databaseId: string;
2280
+ readonly databaseName: string;
2281
+ /** 페이지 본문 안에 놓인 인라인 DB 인가 — 다시 만들 때 같게. */
2282
+ readonly isInline: boolean;
2283
+ /** 속성 스키마 — 이름 정렬. `.base` displayName 만으로는 잃는 타입/옵션을 보존. */
2284
+ readonly properties: SidecarProperty[];
2285
+ /** 모든 뷰(표현/미표현 공통) — Notion 순서 유지. */
2286
+ readonly views: SidecarView[];
2287
+ /** 정직한 손실 리포트 — 무엇이 `.base` 에서 누락/degrade 됐는가. */
2288
+ readonly degraded: DegradeNote[];
2289
+ /** DB 자체의 꾸밈 — 있는 것만. 없으면 키가 없다(꾸밈 없는 DB 의 사이드카는 예전과 같은 바이트). */
2290
+ readonly appearance?: SidecarAppearance;
2291
+ }
2292
+ /**
2293
+ * DB 의 아이콘 · 커버 · 설명 · 제목 표시. `icon` · `cover` 는 페이지 frontmatter 와 같은 꼴
2294
+ * (`notion/decoration-values.ts`), `description` 은 NFM 한 줄.
2295
+ */
2296
+ interface SidecarAppearance {
2297
+ readonly icon?: string;
2298
+ readonly cover?: string;
2299
+ readonly description?: string;
2300
+ /** 보기 위에 DB 제목을 숨겼으면 false. 보이면(기본) 없다. */
2301
+ readonly showTitle?: false;
2302
+ }
2303
+ interface SidecarProperty {
2304
+ readonly name: string;
2305
+ readonly id: string;
2306
+ readonly type: string;
2307
+ /** select/multi_select/status 옵션명(있으면). */
2308
+ readonly options?: string[];
2309
+ /** 다시 만드는 데 드는 타입별 설정(`view/property-definition.ts`). 타입 이름만으로 되면 없다. */
2310
+ readonly definition?: Readonly<Record<string, unknown>>;
2311
+ }
2312
+ interface SidecarView {
2313
+ readonly id: string;
2314
+ readonly name: string;
2315
+ readonly notionType: string;
2316
+ /** Bases 대응 타입. `null` = `.base` 미표현(사이드카가 유일 보존처). */
2317
+ readonly basesType: BasesViewType | null;
2318
+ /** 원본 뷰 설정 중 의미 있는 항목(키 정렬). 빈 값은 생략. */
2319
+ readonly config: Record<string, unknown>;
2320
+ }
2321
+ interface DegradeNote {
2322
+ readonly view: string;
2323
+ /**
2324
+ * `view-unrepresentable` = 뷰가 `.base` 에서 통째로 누락.
2325
+ * `view-degraded` = 뷰는 남지만 다른 타입으로 격하(달력→표 등).
2326
+ * `setting-dropped` = 뷰는 그대로지만 설정 하나가 빠짐.
2327
+ */
2328
+ readonly kind: "view-unrepresentable" | "view-degraded" | "setting-dropped";
2329
+ readonly detail: string;
2330
+ }
2331
+ declare class SidecarGenerator {
2332
+ /**
2333
+ * 사이드카 객체를 빌드한다(순수 — I/O 없음). 직렬화는 {@link serialize} 가 담당.
2334
+ */
2335
+ build(options: {
2336
+ databaseId: string;
2337
+ databaseName: string;
2338
+ schema: Record<string, BasePropertySchema>;
2339
+ viewsConfig: DatabaseViewsConfig;
2340
+ isInline?: boolean;
2341
+ appearance?: SidecarAppearance;
2342
+ }): NotionSidecar;
2343
+ /**
2344
+ * 사이드카를 결정적 JSON 문자열로 직렬화한다(키 재귀 정렬 + 말미 개행).
2345
+ * 동일 입력 → 동일 바이트 → 멱등(drift/churn 0).
2346
+ */
2347
+ serialize(sidecar: NotionSidecar): string;
2348
+ private buildProperties;
2349
+ private buildView;
2350
+ /**
2351
+ * 뷰 설정 중 의미 있는 항목을 추출한다. `.base` 가 표현 못하는 항목(필터·커버크기 등)은
2352
+ * config 에 보존하면서 동시에 degrade 리포트에 1줄씩 기록한다(표현되는 뷰에 한해 —
2353
+ * 미표현 뷰는 이미 view-unrepresentable 로 통째 보고됨).
2354
+ */
2355
+ private extractConfig;
2356
+ /**
2357
+ * 필터 손실을 **실제로 못 옮긴 것만** 보고한다.
2358
+ *
2359
+ * 예전엔 필터가 있기만 하면 "Notion 필터식은 .base 로 표현되지 않음"을 무조건 찍었다.
2360
+ * 이제 `.base` 가 뷰별 `filters:` 를 내보내므로 그 문구는 대부분의 경우 거짓이다.
2361
+ * 거짓 degrade 는 진짜 degrade 를 묻어 버리므로, `.base` 와 **같은 번역기**를 돌려
2362
+ * 남은 것만 적는다(두 산출물이 어긋날 여지를 없앤다).
2363
+ */
2364
+ private noteFilterDegrade;
2365
+ }
2366
+
2367
+ /** 속성 하나를 어떻게 정했나. */
2368
+ interface InferredProperty {
2369
+ readonly name: string;
2370
+ readonly type: string;
2371
+ /** 사람이 읽는 까닭 — `Obsidian 속성 타입 number` · `값 12개가 모두 날짜` … */
2372
+ readonly reason: string;
2373
+ }
2374
+ interface FolderDatabaseDraft {
2375
+ readonly sidecar: NotionSidecar;
2376
+ readonly properties: readonly InferredProperty[];
2377
+ /** 옮긴 보기 이름. */
2378
+ readonly views: readonly string[];
2379
+ /** 옮기지 않은 것과 까닭 — 키 · 보기 · 필터. */
2380
+ readonly skipped: ReadonlyArray<{
2381
+ readonly what: string;
2382
+ readonly reason: string;
2383
+ }>;
2384
+ }
2385
+ interface FolderDatabaseReport extends FolderDatabaseDraft {
2386
+ readonly folder: string;
2387
+ readonly sidecarPath: string;
2388
+ readonly rows: number;
2389
+ }
2390
+
2391
+ /**
2392
+ * 한 오케스트레이터의 작업은 한 번에 하나만 돈다(S-09).
2393
+ *
2394
+ * 오케스트레이터는 실행마다 상태를 쥔다 — 원격을 본 시각(N-05, `RunObservation`) · 이번 실행이 고른
2395
+ * 새 경로(`PagePuller` 의 `claimedPaths`) · 준비하지 못한 폴더(`FolderPlacement`). 두 실행이 겹치면
2396
+ * 뒤 실행이 앞 실행의 것을 덮는다. 앞 pull 이 원격을 본 시각이 뒤로 밀려 그 사이의 Notion 편집을 본
2397
+ * 것으로 적고(영영 받지 않는다), 두 실행이 같은 새 경로를 골라 서로의 파일을 덮는다. 플러그인의
2398
+ * 수동 · 자동 · 볼트 이벤트 sync 와 CLI `watch` 의 파일 변경 · 주기 sync 가 이렇게 겹쳤다.
2399
+ *
2400
+ * 겹친 요청은 기다리게 하지 않고 거절한다. 무엇을 할지는 부른 쪽마다 다르다 — 사용자가 누른
2401
+ * 것은 알리고, 자동 sync 는 건너뛰고, 파일 감시는 끝난 뒤 다시 돌린다. 여기서 줄을 세우면
2402
+ * 긴 sync(실볼트 pull 500초) 동안 주기 sync 가 쌓인다.
2403
+ */
2404
+ /** 잠그는 작업 — 볼트 · 상태 DB · 원격을 바꾸거나 이번 실행의 기준을 정하는 것. */
2405
+ type GatedOperation = "push" | "pull" | "sync" | "status" | "fetch" | "resolve" | "discard" | "prepare-database";
2406
+ declare function operationLabel(operation: GatedOperation): string;
2407
+ declare class SyncBusyError extends Error {
2408
+ /** 이미 도는 작업. */
2409
+ readonly running: GatedOperation;
2410
+ /** 거절한 작업. */
2411
+ readonly requested: GatedOperation;
2412
+ constructor(
2413
+ /** 이미 도는 작업. */
2414
+ running: GatedOperation,
2415
+ /** 거절한 작업. */
2416
+ requested: GatedOperation);
2417
+ }
2418
+
1276
2419
  declare class SyncOrchestrator {
1277
2420
  private readonly config;
1278
2421
  private readonly stateDb;
1279
2422
  private readonly notionClient;
1280
2423
  private readonly vaultFs;
1281
- private readonly changeDetector;
1282
- private readonly pipeline;
1283
- private readonly blockConverter;
1284
- private readonly imageHandler;
1285
2424
  private readonly fileHandler;
1286
2425
  private readonly databaseSyncer;
1287
- private readonly propertyMapper;
1288
- private readonly conflictResolver;
1289
- private dbSchemaLoaded;
1290
- private _pullImageCount;
1291
- private _pullFileCount;
1292
- private _inlineDbRefs;
1293
- private _childParentIds;
1294
- private readonly claimedPaths;
1295
- private static readonly DISCOVERY_RECURSIVE_BUDGET_MS;
2426
+ /** DB 행 push 용 — DB 마다 스키마를 읽은 매퍼. 실행마다 비운다(S-01). */
2427
+ private readonly rowSchemas;
2428
+ private readonly observation;
2429
+ private readonly placement;
2430
+ private readonly databaseCreator;
2431
+ private readonly planner;
2432
+ private readonly detector;
2433
+ private readonly drift;
2434
+ private readonly discovery;
2435
+ private readonly puller;
2436
+ private readonly pullPlanner;
2437
+ private readonly pusher;
2438
+ private readonly conflictWorkflow;
2439
+ private readonly inspector;
2440
+ private readonly recovery;
2441
+ private readonly gate;
1296
2442
  constructor(config: Config, stateDb: IStateDB, notionClient: NotionClient, vaultFs: VaultFS, customFetch?: typeof globalThis.fetch);
2443
+ /** 도는 작업 — 없으면 null(S-09). 부른 쪽이 겹칠 요청을 미리 거를 때 쓴다. */
2444
+ get runningOperation(): GatedOperation | null;
1297
2445
  push(options?: PushOptions): Promise<PushResult>;
2446
+ /**
2447
+ * 다른 연결 폴더 안의 노트를 추적하고 있으면 아무것도 하지 않고 멈춘다(ADR-038) — 그 노트들은 이 연결의
2448
+ * 목록에 없으므로, 그대로 돌리면 「볼트에서 지움」 으로 보고 Notion 페이지를 지우거나 되살리려 한다.
2449
+ */
2450
+ private assertConnectionScope;
2451
+ private executePush;
2452
+ /**
2453
+ * 만든 DB 에 relation · rollup · formula 를 붙이고, 행 스키마를 다시 읽게 한 뒤 보기를 만든다. 실패는
2454
+ * 다음 push 가 이어 붙인다 — 행은 그대로 올린다.
2455
+ */
2456
+ private completeDatabaseSchemas;
2457
+ /** 볼트의 노트가 마지막으로 올린 내용 그대로인가 — 읽지 못하면(지움 · 옮김) 아니다. */
2458
+ private unchangedSincePush;
1298
2459
  pull(options?: PullOptions): Promise<PullResult>;
2460
+ private executePull;
1299
2461
  sync(options?: SyncOptions): Promise<SyncResult>;
1300
- status(): Promise<StatusResult>;
2462
+ private executeSync;
2463
+ /**
2464
+ * 원격까지 본다 — 이번 실행의 원격 기준을 정하므로 다른 작업과 겹치지 않는다. 원격을 훑는 데 분 단위가
2465
+ * 걸릴 수 있어 취소를 받는다. 취소하면 {@link OperationAbortedError} 를 던진다.
2466
+ */
2467
+ status(options?: StatusOptions): Promise<StatusResult>;
2468
+ private executeStatus;
2469
+ /**
2470
+ * 로컬만 본다 — 원격 기준을 정하지 않아 도는 작업과 겹쳐도 된다. 변경은 push 가 쓰는 판정 그대로다
2471
+ * (옮긴 노트 · 폴더 포함). 상태 DB 는 바꾸지 않는다.
2472
+ */
1301
2473
  statusLocal(): Promise<StatusResult>;
2474
+ /**
2475
+ * 볼트에서 노트 · 폴더의 이름을 바꿨다는 기록 — 플러그인이 볼트의 rename 이벤트로 알린다(S-11).
2476
+ *
2477
+ * 다음 실행이 옮긴 노트를 추적 레코드와 짝지을 때 쓴다. 이름과 내용을 함께 바꾼 노트는 이
2478
+ * 기록 없이는 짝을 찾지 못해, 옛 페이지를 지우고 새 페이지를 만든다.
2479
+ */
2480
+ recordLocalRename(from: string, to: string, kind: RenameKind): void;
2481
+ /** 볼트에서 지운 노트 · 폴더 — 그 자리를 새 경로로 적은 힌트를 버린다. */
2482
+ recordLocalDelete(path: string): void;
1302
2483
  /**
1303
2484
  * 볼트 완결성 검증 — 원격에 있는 것이 볼트에 빠짐없이 있는지 대조한다(R11-B · R12-C).
1304
2485
  *
@@ -1307,8 +2488,8 @@ declare class SyncOrchestrator {
1307
2488
  * 그렇게 침묵 유실됐다. 여기서만 "빠짐없다"를 본다.
1308
2489
  *
1309
2490
  * 대조 대상 database id 는 이 클래스가 조립한다: 설정에 적힌 것 + 디스커버리 캐시가
1310
- * 아는 것 + DB 모드의 루트 DB. `discovered_dbs` 메타 키를 아는 곳을 이 파일 하나로
1311
- * 유지해, 호출부(CLI·E2E)가 캐시 표현을 각자 다시 해석하지 않게 한다.
2491
+ * 아는 것 + DB 모드의 루트 DB. 캐시 표현(`discovered_dbs` 메타 키)은 `discovered-databases.ts`
2492
+ * 한 곳이 정하고 여기서 조립한다 — 호출부(CLI·E2E)가 그 표현을 각자 다시 해석하지 않게.
1312
2493
  *
1313
2494
  * 페이지(R12-C)는 pull 이 쓴 경로가 아니라 **search 열거**로 대조한다. 페이지는 열거
1314
2495
  * 경로가 둘이고 둘이 같은 집합을 내지 않는 게 애초의 결함이므로(R12-A), 독립된 두 번째
@@ -1321,210 +2502,45 @@ declare class SyncOrchestrator {
1321
2502
  modifiedPages: number;
1322
2503
  duration: number;
1323
2504
  }>;
1324
- /**
1325
- * @param options.fullRender 원격 본문을 pull 과 동일한 파이프라인으로 렌더할지.
1326
- * 기본(false)은 화면 미리보기용 경량 렌더 — 첨부를 내려받지 않으므로 `status` 처럼
1327
- * 읽기만 하는 경로가 쓴다. 볼트에 덮어쓸 본문이 필요한 해소 경로는 반드시 켠다.
1328
- */
1329
- private buildConflictsFromRecords;
1330
- private get isDatabaseMode();
1331
- private discoverChildDatabases;
1332
- /**
1333
- * 자동 발견된 child_database 1개를 DatabaseSyncer 설정으로 변환한다.
1334
- * 블록 스캔/markdown 양쪽 발견 경로에서 공통으로 사용한다.
1335
- * - localFolder: 부모 페이지의 obsidianPath 기준으로 폴더를 잡아 원본 중첩 구조를 보존
1336
- * (예: 부모 "AI Engineer (1).md" + DB "Minirecord Project" → "AI Engineer (1)/Minirecord-Project")
1337
- * - 제목 취득 실패 시 부모 폴더명 기반 안전 이름으로 폴백
1338
- */
1339
- private buildDiscoveredDbConfig;
1340
- /** 접근 불가 DB denylist 를 상태 메타에서 로드한다(하이픈 정규화). */
1341
- private loadInaccessibleDbIds;
1342
- /** linked view 컨테이너 → 원본 DB 매핑을 상태 메타에서 로드한다(nohyph → nohyph). */
1343
- private loadLinkedDbMap;
1344
- private pullDiscoveredDatabases;
1345
- /**
1346
- * pull 산출 md 의 인라인 DB placeholder(`**제목** *(Notion DB)*` + 보존 마커)를
1347
- * `![[<실제 .base 경로>|제목]]` 임베드로 재작성한다(F22) — folder note 본문에서
1348
- * DB 가 텍스트 한 줄이 아니라 실제 Bases 뷰로 보이게 하는 마지막 조각. linked view
1349
- * 컨테이너는 linkedMap 을 거쳐 원본 DB 의 .base 로 향한다. .base 경로는 DatabaseSyncer
1350
- * 가 실제 기록한 경로(baseFileInfo)가 SSOT — 폴더명과 .base 파일명은 새니타이즈 규칙이
1351
- * 달라(공백→하이픈 vs 공백 보존) localFolder 로 추측하면 깨진 임베드가 된다(E2E 실측
1352
- * 91/158건). 최종적으로 .base 실존까지 확인해, 없으면 placeholder 를 마커째 보존한다.
1353
- */
1354
- private rewriteDbPlaceholderEmbeds;
2505
+ private executeFetch;
2506
+ /** pull 이 쓴 노트의 Notion 링크를 볼트 링크로 푼다 — 푼 링크 수를 돌려준다. */
1355
2507
  private resolveNotionLinks;
1356
- private ensureDbSchema;
1357
- private pushCreate;
1358
- /**
1359
- * 노트에 박힌 로컬 미디어를 Notion 에 올린다(R1).
1360
- *
1361
- * 먼저 본문 자리표시자를 실제 image/file 블록으로 **제자리** 교체한다. 그러고도 남은
1362
- * 이미지만 페이지 끝에 덧붙이는 예전 경로로 흘린다 — 자리표시자가 없는 경우는 본문
1363
- * push 가 통째로 스킵됐을 때(pageHasLiveChildren) 정도이고, 그때도 이미지 자체는
1364
- * 올라가야 하므로 폴백을 남긴다.
1365
- */
1366
- private syncEmbeddedMedia;
1367
- private pushUpdate;
1368
2508
  /**
1369
- * 해소 대상 충돌 목록 — 원격 본문을 pull 과 **같은 파이프라인**으로 렌더해 담는다.
1370
- *
1371
- * 해소는 실제로 볼트 파일을 덮어쓰므로 `status()` 의 경량 미리보기 렌더를 쓰면 안 된다
1372
- * (프론트매터·첨부가 빠진 반쪽 본문이 덮인다). 반대로 전체 pull 을 먼저 돌려 목록을
1373
- * 얻는 것도 안 된다 — 해소하겠다고 볼트를 먼저 원격으로 덮어쓰는 셈이라 순서가 거꾸로다.
1374
- * 그래서 충돌 레코드만 좁혀 그 페이지들만 읽어 온다.
2509
+ * 해소 대상 충돌 목록 — 원격 본문을 pull 과 같은 파이프라인으로 렌더해 담는다. 렌더가 첨부를 볼트에
2510
+ * 내려받고 경로를 고르므로 다른 작업과 겹치지 않게 돌린다 — 도는 sync 와 같은 첨부 · 경로를 두고 다툰다.
1375
2511
  */
1376
2512
  listConflicts(): Promise<Conflict[]>;
1377
2513
  /**
1378
- * 추적 파일의 **현재 원격 본문**을 pull 과 같은 변환으로 렌더한다 — 표시 전용.
1379
- *
1380
- * 첨부는 내려받지 않는다: 비교를 보려다 볼트에 파일이 생기면 안 된다. 그 대가로 아직
1381
- * 내려받지 않은 미디어는 원격 URL 로 남아 비교 화면에만 차이로 보인다.
1382
- *
1383
- * @returns 추적되지 않았거나 원격 페이지가 없으면 null.
2514
+ * 추적 파일의 현재 원격 본문을 pull 과 같은 변환으로 렌더한다 — 표시 전용이라 첨부를 내려받지
2515
+ * 않는다. 추적되지 않았거나 원격 페이지가 없으면 null.
1384
2516
  */
1385
2517
  renderRemoteSnapshot(path: string): Promise<string | null>;
1386
- /**
1387
- * 해소할 게 남지 않은 충돌 레코드를 `synced` 로 되돌리고, 되돌린 경로를 반환한다.
1388
- *
1389
- * 충돌로 표시된 파일은 push 대상에서 통째로 빠진다(양쪽 덮어쓰기 방지). 그래서 사용자가
1390
- * 손으로 양쪽을 맞춰 둬 이미 같은 내용이 됐는데도 레코드만 남으면, 그 파일의 이후 편집이
1391
- * **영원히 Notion 에 올라가지 않는다** — 아무 경고 없이 정체된다. 내용이 이미 동일한
1392
- * 건만 골라 상태를 되돌린다(진짜 충돌은 손대지 않는다).
1393
- */
2518
+ /** 로컬 변경 하나의 두 글 — 지난 동기화 때의 글과 지금 볼트의 글. 표시 전용이라 잠그지 않는다. */
2519
+ localChangeDiff(change: LocalChange): Promise<ChangeDiff>;
2520
+ /** 원격 변경 하나의 두 글 — 지난 동기화 때의 글과 Notion 의 지금 글. 표시 전용이라 잠그지 않는다. */
2521
+ remoteChangeDiff(change: RemoteChange): Promise<ChangeDiff>;
2522
+ /** 해소할 게 남지 않은 충돌 레코드를 `synced` 로 되돌리고, 되돌린 경로를 반환한다. */
1394
2523
  clearStaleConflicts(conflicts: readonly Conflict[]): string[];
1395
2524
  /** 단일 충돌을 사용자가 고른 선택지(local/remote/merge/duplicate)로 해소 + Notion 전파. */
1396
2525
  resolveConflict(conflict: Conflict, choice: ResolutionChoice): Promise<ResolutionResult>;
1397
2526
  /** 단일 충돌을 전략(manual/local-first/remote-first/duplicate)으로 해소 + Notion 전파. */
1398
2527
  resolveConflictByStrategy(conflict: Conflict, strategy: ConflictStrategy): Promise<ResolutionResult>;
1399
- /** 여러 충돌을 동일 전략으로 일괄 해소 + Notion 전파. */
2528
+ /** 여러 충돌을 동일 전략으로 일괄 해소 + Notion 전파. 하나를 올리지 못해도 나머지를 푼다. */
1400
2529
  resolveAllConflicts(conflicts: Conflict[], strategy: ConflictStrategy): Promise<ResolutionResult[]>;
1401
- /** 충돌 미리보기용 통합 diff(원본 vs 로컬 vs 원격). 해소 없이 표시 전용. */
1402
- generateConflictDiff(conflict: Conflict): string;
1403
2530
  /**
1404
- * 해소 결과를 Notion 으로 전파해 로컬↔원격 일관성을 봉합한다.
1405
- * - remote 선택: 로컬이 원격으로 갱신됐을 뿐이므로 push 불필요. notionLastEdited 만
1406
- * 원격 변경의 lastEdited 로 재조정 → 다음 pull 이 같은 변경을 재충돌로 보지 않음.
1407
- * - merge 실패(충돌 마커 잔존): 사용자가 직접 풀어야 하므로 conflict 상태 유지·push 안 함.
1408
- * - local / merge(성공) / duplicate: 해소된 로컬 내용을 Notion 에 재push(pushUpdate 가
1409
- * 변환·이미지·속성·해시·notionLastEdited 를 한 트랜잭션으로 재조정) → 무손실 수렴.
1410
- */
1411
- private propagateResolution;
1412
- private pushDelete;
1413
- private pushMove;
1414
- private detectRemoteChanges;
1415
- /**
1416
- * 증분 원격 변경 감지 — `since` 이후 수정된 페이지만 search 로 받아 created/modified 만
1417
- * 만든다. **삭제는 의도적으로 감지하지 않는다**: search API 는 in_trash/archived 페이지를
1418
- * 반환하지 않아(=사라진 것을 증분만으로는 구분 불가) 삭제 판정에는 전체 enumeration 이
1419
- * 필수다. 따라서 호출부는 `deleteSync` 가 켜진 경우 이 fast-path 를 쓰지 않고
1420
- * `detectRemoteChanges`(전체 스캔, 삭제 diff 포함)로 우회한다. (I10)
1421
- */
1422
- private detectRemoteChangesIncremental;
1423
- private isTrackedParent;
1424
- /**
1425
- * 추적 중(sync_state)인데 디스크에서 사라진 파일을 찾는다.
1426
- *
1427
- * 원격 변경 감지(detectRemoteChanges*)는 "Notion 에서 바뀐 것"만 본다. 로컬에서 파일이
1428
- * 지워진 경우는 어느 경로로도 잡히지 않아, 재pull 해도 `--force` 로도 되살아나지 않고
1429
- * 영구히 발산했다. push 는 deleteSync=false 면 원격을 지우지 않으므로 사용자에겐 복구
1430
- * 수단이 볼트 전체 초기화밖에 남지 않는다 — 이 스캔이 그 마지막 구멍을 막는다.
1431
- *
1432
- * 레코드마다 stat 을 던지면 1000건 규모에서 수 초가 든다. 볼트 워크 1회로 실재 목록을
1433
- * 만든 뒤 차집합을 취한다(md). md 가 아닌 추적 레코드는 수가 적어 개별 확인으로 남긴다.
1434
- *
1435
- * 로컬 이름변경·이동은 "사라짐" 과 구별해야 한다 — 되살리면 원본과 새 이름의 사본이
1436
- * 둘 다 남는다. push 의 detectMoves 와 같은 기준(내용 해시 일치)을 쓰되, 볼트 전체를
1437
- * 해시하지 않도록 크기가 같은 미추적 파일만 후보로 좁혀 확인한다.
1438
- */
1439
- private detectMissingLocalFiles;
1440
- private pullCreate;
1441
- /**
1442
- * 페이지가 폴더(자식 페이지·자식 DB 보유)인지 신뢰성 있게 판정한다.
1443
- *
1444
- * 결함(폴더노트 본문분리)의 근본 원인은 얕은 판정이었다 — 최상위 블록 첫 페이지에서
1445
- * child_page 만 검사하면 callout·column·toggle 안에 중첩된 자식 페이지나 child_database 를
1446
- * 놓쳐 폴더노트가 file 로 오분류되고, 본문이 폴더 밖 최상위로 밀려 resolveUniqueFilePath
1447
- * 충돌(' (1).md')로 쪼개졌다.
1448
- *
1449
- * 1차: 발견 단계(서브트리 순회/증분)에서 전 페이지의 부모를 해소하며 만든
1450
- * _childParentIds 집합으로 O(1) 판정(추가 API 호출 0, 처리 순서 무관) — 전체 pull 경로.
1451
- * 폴백: 집합에 없을 때만(증분 pull 의 신규 폴더 등) fetchAllChildrenDeep 로 컨테이너를
1452
- * 재귀 탐색해 child_page·child_database 를 직접 확인한다.
1453
- */
1454
- private pageHasChildContainers;
1455
- /**
1456
- * 원격 페이지를 **pull 과 동일한 파이프라인**으로 볼트에 쓸 수 있는 본문까지 렌더한다.
1457
- * (본문 변환 → 이미지/첨부 내려받기 → 속성 주입 → preserve marker → 압축형 간격 판정)
1458
- *
1459
- * pull 경로와 충돌 해소 경로가 이 한 곳을 공유해야 한다. 예전엔 충돌 목록만
1460
- * {@link fetchPageMarkdown} 원문을 그대로 담아, `nobsi resolve` 에서 "원격 유지"를 고르면
1461
- * 프론트매터도 첨부도 없는 반쪽 본문이 볼트에 덮여 썼다 — 해소가 곧 손실이었다.
1462
- *
1463
- * @returns 렌더된 본문과 함께, 호출자가 위키링크 레지스트리 등에 쓰는 제목·속성.
1464
- * 여기서 이미 읽은 값을 되돌려 줘야 호출자가 같은 페이지를 다시 파싱하지 않는다.
1465
- */
1466
- private renderRemotePage;
1467
- private pullUpdate;
1468
- private pullDelete;
1469
- private ensureFolderPage;
1470
- private resolveNotionParent;
1471
- private repairFolderRecords;
1472
- private isFolderNote;
1473
- private resolveParentPath;
1474
- private pushCreatePage;
1475
- private pageHasLiveChildren;
1476
- private pushUpdatePage;
1477
- private fetchPageMarkdown;
1478
- private collectInlineDbRefs;
1479
- /**
1480
- * 이번 라운드에 기록된 DB row md 를 읽어 본문의 인라인 DB 참조를 수집한다.
1481
- * row 페이지는 DatabaseSyncer 자체 파이프라인으로 변환돼 fetchPageMarkdown 의
1482
- * _inlineDbRefs 수집을 타지 않는다 — 디스크에 남은 보존 마커가 유일한 발견 신호다.
1483
- * 부모 Notion 페이지 id 는 state DB 역조회(getByPath)로 얻는다(row upsert 직후라 항상 존재).
1484
- */
1485
- private collectRowInlineRefs;
1486
- private resolveNotionIdWikilinks;
1487
- private extractParentId;
1488
- private resolveBlockToPageId;
1489
- private cleanupInterruptedSync;
1490
- /**
1491
- * I12 — 중단된 push create 작업 재개.
1492
- *
1493
- * pending_operations 에 미완료(create·push) 항목이 있으면:
1494
- * - state 에 notion_page_id 가 이미 있으면 → 생성·매핑까지는 끝났고 markCompleted 직전에
1495
- * 중단된 것 → 완료 처리(잔여 본문/이미지는 다음 변경감지가 pushUpdate 로 마무리).
1496
- * - notion_page_id 가 비어 있으면(Window A: 생성 적용됐으나 매핑 기록 전 중단) → 부모에서
1497
- * 제목으로 child_page 를 검색해 고아 페이지를 입양(중복 생성 차단). 없으면 자리표시
1498
- * 레코드를 제거(FK CASCADE 로 op 도 삭제)해 다음 push 가 새로 생성하게 한다.
1499
- *
1500
- * 한계(문서화): Notion 은 idempotency key 가 없어 "생성 요청 적용 직후 같은 호출 내 재시도"
1501
- * 로 인한 중복은 완전히 차단하지 못한다. 429 는 적용 전 거절이라 안전하고, 프로세스 재시작
1502
- * 후 재개는 본 검색-입양으로 중복을 막는다. DB 모드(부모가 database)는 child_page 검색이
1503
- * 불가하므로 자리표시 제거 후 재생성으로 폴백한다.
1504
- */
1505
- private recoverInterruptedPushOps;
1506
- /** 부모 페이지의 직속 자식 중 제목이 일치하는(보관/휴지통 제외) child_page id 를 찾는다. */
1507
- private findChildPageByTitle;
1508
- /**
1509
- * 신규 pull 페이지가 쓸 파일 경로를 충돌 없이 결정하고 **그 자리에서 선점**한다.
1510
- *
1511
- * DB 행과 **같은 규칙**({@link pagePathCandidates})을 쓴다. 예전에는 `(1)`, `(2)` …
1512
- * 순번을 99 까지 훑고 고갈되면 **원본 경로를 그대로 돌려줬는데**, 그러면 남의 노트를
1513
- * 조용히 덮어써 내용이 사라진다. 순번은 그때의 볼트 상태로 정해져 실행마다 페이지끼리
1514
- * 접미사가 뒤바뀔 수도 있었다(pull 마다 파일이 갈아엎히는 churn).
1515
- *
1516
- * 점유 판정은 세 가지를 모두 본다:
1517
- * · 이번 실행의 선점 장부 — 아래 참조.
1518
- * · 추적 레코드 — 다른 페이지가 소유한 경로면 피하고, 자기 소유면 그대로 재사용한다.
1519
- * · 볼트 파일 — 추적되지 않는 사용자 노트가 놓여 있으면 피한다.
2531
+ * 로컬 변경 하나를 지난 동기화 때의 글로 되돌린다 — Git 의 `restore` 와 같다. Notion 은 건드리지
2532
+ * 않는다. 되돌릴 원본이 없는 것은 이유와 함께 거절한다.
2533
+ */
2534
+ discardLocalChange(path: string): Promise<void>;
2535
+ /**
2536
+ * 볼트에서 만든 노트 폴더를 Notion DB 로 올릴 준비 — frontmatter · `.base` 로 사이드카를 써 둔다(#75).
2537
+ * Notion 에는 쓰지 않는다. 다음 push 가 DB 를 만들고 노트를 행으로 올린다(ADR-036 · ADR-037).
1520
2538
  *
1521
- * 선점 장부가 필요한 이유는 pull 이 워커 풀로 **동시 실행**되기 때문이다. 조회와 기록
1522
- * 사이에 await 가 끼면 동명 페이지 여럿이 나란히 "비어 있음"을 보고 같은 경로를 고른다.
1523
- * 그러면 마지막에 쓴 페이지만 남고 나머지 본문이 사라진다(실측 재현: 동명 3페이지 →
1524
- * 파일 1개·레코드 1건). 그래서 마지막 확인과 등록을 **await 없는 동기 구간**에 묶는다 —
1525
- * 그 사이에는 다른 작업이 끼어들 수 없으므로 두 페이지가 같은 경로를 얻는 일이 없다.
1526
- */
1527
- private resolveUniqueFilePath;
2539
+ * @param configDir Obsidian 설정 폴더 — 속성 타입(`types.json`)을 읽는다.
2540
+ */
2541
+ prepareFolderDatabase(folder: string, configDir?: string): Promise<FolderDatabaseReport>;
2542
+ /** 충돌 미리보기용 줄 비교(로컬 vs 원격). 해소 없이 표시 전용. */
2543
+ generateConflictDiff(conflict: Conflict): string;
1528
2544
  }
1529
2545
 
1530
2546
  interface PathFilterConfig {
@@ -1556,6 +2572,32 @@ declare class NodeVaultFS implements VaultFS {
1556
2572
  private walkDir;
1557
2573
  }
1558
2574
 
2575
+ /**
2576
+ * push 가 노트 임베드를 올려 만든 미디어 블록 하나 — Notion 에 저장된 파일과 노트의 임베드를
2577
+ * 잇는다(S-05).
2578
+ *
2579
+ * 예전(R1)에는 올린 블록의 캡션에 임베드 대상을 그대로 적고, pull 이 그 캡션으로 임베드를
2580
+ * 되찾았다. 그래서 Notion 에서 보는 캡션이 `assets/사진.png|300` 같은 경로 · 크기 · 파일
2581
+ * 이름이었다. 이제 캡션에는 사람이 적은 설명만 싣고, 어느 임베드였는지는 여기 적어 둔다.
2582
+ *
2583
+ * 파일 id 로 잇는 이유: Notion 에서 같은 이름의 다른 파일로 바꾸면 id 가 바뀐다. 이름으로 이으면
2584
+ * 바뀐 파일을 옛 로컬 파일로 되돌려 Notion 의 편집을 잃는다.
2585
+ */
2586
+ interface UploadedMedia {
2587
+ /** Notion 이 그 파일을 저장한 id(`notionFileIdOf`) — 블록 id · file_upload id 와 다르다. */
2588
+ readonly fileId: string;
2589
+ /** 노트에 적혀 있던 임베드 대상 그대로 — `경로|별칭`. */
2590
+ readonly target: string;
2591
+ /** 올린 볼트 파일(해석한 경로) — pull 이 아직 있는지 보고 되살린다. */
2592
+ readonly localPath: string;
2593
+ /**
2594
+ * 올린 · 받은 파일의 전체 sha256. 볼트의 그 파일이 아직 이 바이트면 본문 push 가 다시 올리지 않고
2595
+ * Notion 의 그 블록을 둔다(`ImageHandler.keepUploadedMedia`). 이 값을 적기 전에 적은 기록에는
2596
+ * 없다 — 그 미디어는 한 번 더 올리고 그때 적는다.
2597
+ */
2598
+ readonly fileHash?: string;
2599
+ }
2600
+
1559
2601
  /** 미디어(이미지/파일) 다운로드 운영 튜닝값. 미지정 시 기존 동작과 동일한 기본값 사용. */
1560
2602
  interface MediaOptions {
1561
2603
  readonly concurrency?: number;
@@ -1570,6 +2612,10 @@ interface ImageDownloadResult {
1570
2612
  readonly localPath: string;
1571
2613
  readonly hash: string;
1572
2614
  readonly size: number;
2615
+ /** 받은 파일의 전체 sha256 — 받은 미디어 기록에 적어 push 가 그 파일이 그대로인지 본다. */
2616
+ readonly fileHash?: string;
2617
+ /** 임베드 별칭으로 쓸 이름 — 받은 이름에서 이 볼트가 붙인 내용 해시를 뗀 것({@link withoutOwnHash}). */
2618
+ readonly name?: string;
1573
2619
  }
1574
2620
  interface ImageUploadResult {
1575
2621
  readonly localPath: string;
@@ -1578,11 +2624,23 @@ interface ImageUploadResult {
1578
2624
  readonly fileHash: string;
1579
2625
  readonly fileSize: number;
1580
2626
  }
2627
+ /** {@link ImageHandler.keepUploadedMedia} 의 결과. */
2628
+ interface KeptMedia {
2629
+ /** 보낼 본문 — 다시 올리지 않아도 되는 자리표시자를 Notion 의 그 파일로 바꿨다. */
2630
+ readonly markdown: string;
2631
+ /** 그대로 둔 미디어 — 본문을 보낸 뒤 올린 미디어 기록에 다시 적는다. */
2632
+ readonly kept: readonly UploadedMedia[];
2633
+ }
1581
2634
  /** 제자리 교체 결과 — 어떤 임베드 대상이 처리됐는지 호출자가 알아야 중복 업로드를 막는다. */
1582
2635
  interface MaterializeResult {
1583
2636
  readonly uploaded: ImageUploadResult[];
1584
2637
  /** 실제 블록으로 바뀐 임베드 대상(원본 문자열 + 별칭 제거형 둘 다 담는다). */
1585
2638
  readonly handledTargets: Set<string>;
2639
+ /**
2640
+ * 자리표시자를 찾아 본문을 고치려 했다 — 도중에 실패했어도 페이지가 바뀌었을 수 있다. 호출측은
2641
+ * 보낸 본문으로 만든 지문을 버리고 다시 받는다.
2642
+ */
2643
+ readonly touched: boolean;
1586
2644
  }
1587
2645
  declare class ImageHandler {
1588
2646
  private readonly vaultFs;
@@ -1600,6 +2658,7 @@ declare class ImageHandler {
1600
2658
  /** 상한 로직은 첨부 경로와 공유한다 — 한쪽만 빠뜨리지 않도록(utils/download-fetch). */
1601
2659
  private fetchForDownload;
1602
2660
  downloadImage(url: string, pageTitle: string): Promise<ImageDownloadResult>;
2661
+ /** @param uploadedName Notion 에 올라간 파일 이름 — 받은 파일의 이름이 된다({@link imageStemOf}). */
1603
2662
  private fetchImage;
1604
2663
  /**
1605
2664
  * 캡션이 push 가 적어 둔 원본 임베드 대상인지 판정한다(R1). 맞으면 그 문자열을
@@ -1614,12 +2673,70 @@ declare class ImageHandler {
1614
2673
  */
1615
2674
  private resolveOriginalTarget;
1616
2675
  private persistImage;
1617
- downloadAllImages(markdown: string, pageTitle: string, pageId?: string, notePath?: string): Promise<{
2676
+ /**
2677
+ * push 가 올린 미디어를 노트에 적혀 있던 임베드로 되돌린다 — 아무것도 내려받지 않는다(S-05).
2678
+ *
2679
+ * push 는 임베드 하나를 제자리 블록 하나로 올리고, Notion 이 그 파일을 저장한 id 를 임베드
2680
+ * 대상과 함께 적어 둔다({@link rememberUploadedMedia}). 여기서는 받은 본문의 미디어 URL 에서
2681
+ * 그 id 를 읽어 대상을 되찾는다. 캡션에 경로를 적지 않아도 되고, Notion 에서 같은 이름의 다른
2682
+ * 파일로 바꾼 것(id 가 다르다)을 옛 로컬 파일로 되돌리지도 않는다.
2683
+ *
2684
+ * 적어 둔 것이 없으면 옛 push 처럼 캡션이 임베드 대상인지 본다(R1). 둘 다 아니면 그대로 두고,
2685
+ * 뒤따르는 {@link downloadAllImages} · {@link downloadAllFiles} 가 사본을 받는다.
2686
+ *
2687
+ * 내려받지 않으므로 표시 전용 렌더(diff)나 이미지 다운로드를 끈 설정에서도 부른다.
2688
+ *
2689
+ * @param pageId 본문을 받은 페이지 — 그 페이지에 올린 것만 되찾는다
2690
+ * @param notePath 노트 경로 — 옛 캡션의 파일을 push 와 같은 사다리로 찾는다
2691
+ */
2692
+ restoreUploadedMedia(markdown: string, pageId?: string, notePath?: string): Promise<string>;
2693
+ /** 적어 둔 업로드면 되살릴 임베드 대상. 볼트에서 그 파일이 사라졌으면 null — 사본을 받는다. */
2694
+ private knownEmbedTarget;
2695
+ /** 미디어 URL 이 가리키는 파일 이름 — 서명 URL 은 경로 끝, 내부 참조는 `attachment:` 뒤. */
2696
+ private uploadedNameOf;
2697
+ downloadAllImages(markdown: string, pageTitle: string, pageId?: string): Promise<{
1618
2698
  content: string;
1619
2699
  downloads: ImageDownloadResult[];
1620
2700
  }>;
1621
2701
  uploadLocalImage(localPath: string, noteFolder?: string): Promise<ImageUploadResult>;
2702
+ /**
2703
+ * 노트가 가리키는 볼트 첨부 하나를 올린다 — 파일 속성 값으로 보낼 것.
2704
+ *
2705
+ * @returns 보일 이름은 받을 때 붙인 내용 해시를 뗀 것이다 — 다시 받으면 같은 이름 · 같은 경로가 된다.
2706
+ */
2707
+ uploadNoteAttachment(target: string, notePath: string): Promise<UploadedFile>;
1622
2708
  uploadAndAppendImages(pageId: string, images: ImageReference[], notePath?: string): Promise<ImageUploadResult[]>;
2709
+ /**
2710
+ * 본문을 보내기 전에, 다시 올리지 않아도 되는 미디어 자리표시자를 Notion 에 이미 있는 그 파일로
2711
+ * 바꾼다.
2712
+ *
2713
+ * 본문 push 는 자리표시자를 심어 본문을 통째로 바꾸고, 바꾼 뒤 자리표시자를 하나씩 올려 블록으로
2714
+ * 바꾼다({@link materializeLocalMedia}). 그래서 글 한 줄만 고쳐도 그 페이지의 미디어를 모두 다시
2715
+ * 올렸다 — 업로드 요청이 미디어마다 셋이고, Notion 의 블록 · 파일이 새로 바뀌었다. 받은 본문의 미디어
2716
+ * URL 을 그대로 실어 본문을 바꾸면 Notion 은 그 블록을 그대로 둔다 — 블록 id · 파일이 같고 캡션만
2717
+ * 실은 글로 바뀐다. 자리를 옮겨도, 서명(쿼리)을 떼도 같다(2026-10-12 실측).
2718
+ *
2719
+ * 둘 다 맞는 자리표시자만 바꾼다 — 나머지는 예전처럼 올린다.
2720
+ * - 그 페이지의 미디어 기록에 같은 경로의 임베드가 있고, 받은 본문에 그 파일이 지금 있다
2721
+ * - 볼트의 그 파일이 적어 둔 지문과 같다 — 같은 이름에 다른 내용이면 올려야 한다
2722
+ *
2723
+ * 기록이 없거나(이 기능 이전에 올린 미디어) 본문을 읽지 못하면 아무것도 바꾸지 않는다 — 다시 올릴
2724
+ * 뿐 잃는 것은 없다.
2725
+ *
2726
+ * @param markdown 보낼 본문(Notion markdown) — 자리표시자가 줄 단독으로 있다
2727
+ */
2728
+ keepUploadedMedia(pageId: string, markdown: string, notePath?: string): Promise<KeptMedia>;
2729
+ /**
2730
+ * 자리표시자 자리에 실을 Notion 미디어 한 줄. 종류 · 캡션 · URL 을 그대로 실을 수 없거나 볼트의 파일이
2731
+ * 올린 것과 다르면 null — 그 자리표시자는 다시 올린다.
2732
+ */
2733
+ private keptMediaLine;
2734
+ /**
2735
+ * 그대로 둔 미디어에 실을 캡션 — 임베드의 설명. 캡션 없는 파일을 받으면 올라간 이름을 별칭으로
2736
+ * 적으므로({@link fileNameOf}), Notion 에 캡션이 없고 별칭이 그 이름이면 캡션을 비운 채 둔다 — 실으면
2737
+ * 받은 파일마다 이름이 캡션으로 붙는다.
2738
+ */
2739
+ private keptCaptionOf;
1623
2740
  /**
1624
2741
  * push 가 심어 둔 자리표시자 quote 를 **제자리에서** 실제 image/file 블록으로 바꾼다(R1).
1625
2742
  *
@@ -1629,13 +2746,20 @@ declare class ImageHandler {
1629
2746
  * 텍스트로 새고 있었고 그중 964건(98.6%)이 이 미디어 쌍이었다. 사용자가 Notion 에서
1630
2747
  * 보는 화면이 깨질 뿐 아니라 이미지가 본문 흐름에서 이탈한다.
1631
2748
  *
1632
- * 캡션에는 원본 임베드 대상을 그대로 적는다. pull 이 경로(와 `|별칭`)를 되찾는
1633
- * 진실원이 되고, 사용자에게도 출처가 보인다.
2749
+ * 캡션에는 사람이 적은 설명만 단다({@link captionOfEmbed}). 어느 임베드였는지는 Notion 이
2750
+ * 그 파일을 저장한 id 와 함께 적어 두고({@link rememberUploadedMedia}), pull 이 그것으로
2751
+ * 임베드를 되찾는다(S-05). 예전에는 캡션에 임베드 대상을 그대로 적어 Notion 에 경로 · 크기 ·
2752
+ * 파일 이름이 보였다.
1634
2753
  *
1635
2754
  * 볼트에서 파일을 못 찾으면 자리표시자를 그대로 둔다 — 마커가 남아야 pull 이
1636
2755
  * 임베드를 복원할 수 있으므로, 반쯤 지우는 것보다 안전하다.
1637
2756
  */
1638
- materializeLocalMedia(pageId: string, pushedMarkdown: string, notePath?: string): Promise<MaterializeResult>;
2757
+ materializeLocalMedia(pageId: string, pushedMarkdown: string, notePath?: string, kept?: readonly UploadedMedia[]): Promise<MaterializeResult>;
2758
+ /**
2759
+ * 방금 만든 미디어 블록의 파일을 Notion 이 저장한 id. 만든 응답에 파일 URL 이 실려 오고,
2760
+ * 없으면 블록을 한 번 조회한다. 끝내 모르면 null — 던지지 않는다(블록은 이미 제자리에 있다).
2761
+ */
2762
+ private storedFileIdOf;
1639
2763
  /**
1640
2764
  * 페이지 블록 트리를 훑어 자리표시자를 부모와 함께 모은다. 콜아웃·토글·목록 안에
1641
2765
  * 들어간 임베드도 제자리 교체하려면 부모 id 가 필요하다(append 의 after_block 은
@@ -1648,6 +2772,17 @@ declare class ImageHandler {
1648
2772
  * 재업로드하는 중복을 공유 레지스트리로 차단한다.
1649
2773
  */
1650
2774
  private registerUploads;
2775
+ /**
2776
+ * 올린 미디어를 임베드와 함께 적어 둔다(S-05). 적지 못해도 push 는 깨뜨리지 않는다 — 블록은
2777
+ * 이미 제자리에 있고, 다음 pull 이 그 파일을 알아보지 못해 사본을 받을 뿐이다.
2778
+ */
2779
+ private rememberUploads;
2780
+ /**
2781
+ * 받은 미디어를 그 페이지의 미디어 기록에 더한다 — 그 노트를 고쳐 push 할 때 Notion 의 원래 블록을
2782
+ * 둔다({@link keepUploadedMedia}). 적지 못해도 pull 은 깨뜨리지 않는다 — 그 노트를 push 하면 예전처럼
2783
+ * 다시 올릴 뿐이다.
2784
+ */
2785
+ private rememberReceived;
1651
2786
  /**
1652
2787
  * 임베드 경로가 가리킬 수 있는 볼트 실제 파일 후보를 우선순위대로 만든다(F23).
1653
2788
  * Obsidian 위키링크 임베드는 노트 옆 파일을 파일명만으로 참조하는 게 관례라
@@ -1664,7 +2799,7 @@ declare class ImageHandler {
1664
2799
  * 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
1665
2800
  */
1666
2801
  private readImageFromVault;
1667
- downloadAllFiles(markdown: string, pageTitle: string, notePath?: string): Promise<{
2802
+ downloadAllFiles(markdown: string, pageTitle: string, pageId?: string): Promise<{
1668
2803
  content: string;
1669
2804
  downloads: ImageDownloadResult[];
1670
2805
  }>;
@@ -1743,10 +2878,15 @@ declare class FileHandler {
1743
2878
  */
1744
2879
  private pushSingleFile;
1745
2880
  /**
1746
- * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 제외한다.
1747
- * node 측 VaultFS 는 과거 `.base` 를 워커에서 빼는 식으로 우회했지만, Obsidian
1748
- * 어댑터는 모든 파일을 반환하므로 플러그인 push 가 사이드카를 Notion 첨부로
1749
- * 오염시켰다. 소비자인 여기서 일괄 차단해 구현체 간 동작을 통일한다.
2881
+ * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 아예 빼고, Notion 이 받지 않는
2882
+ * 형식은 따로 모은다.
2883
+ *
2884
+ * 내부 산출물: node 측 VaultFS 는 과거 `.base` 를 워커에서 빼는 식으로 우회했지만, Obsidian
2885
+ * 어댑터는 모든 파일을 반환하므로 플러그인 push 가 사이드카를 Notion 첨부로 오염시켰다.
2886
+ * 소비자인 여기서 일괄 차단해 구현체 간 동작을 통일한다.
2887
+ *
2888
+ * 받지 않는 형식(S-14): 올리면 업로드를 만들 때 400 으로 거절된다. 등록되지 않으니 push 할
2889
+ * 때마다 같은 요청을 다시 보내 같은 이유로 실패했다(실볼트 `.ipynb` 22개).
1750
2890
  */
1751
2891
  private listUploadableFiles;
1752
2892
  pushAllFiles(): Promise<FileUploadResult[]>;
@@ -1771,17 +2911,112 @@ declare class ConversionPipeline {
1771
2911
  convertToMarkdown(content: string, context: ConversionContext, metadata?: ProcessorMetadata): string;
1772
2912
  }
1773
2913
 
2914
+ interface DbBaseFile {
2915
+ readonly basePath: string;
2916
+ readonly title: string;
2917
+ }
2918
+ /**
2919
+ * DB 마다 실제로 기록한 `.base` 경로와 DB 제목 — 인라인 DB placeholder 를 `.base` 임베드로 바꿀 때
2920
+ * 쓴다(F22). 폴더명(하이픈 새니타이즈)과 `.base` 파일명(sanitizeFileName: 공백 · 점 보존)은 규칙이
2921
+ * 달라, 폴더로 추측한 경로는 깨진 임베드가 된다(E2E 실측 91/158건).
2922
+ *
2923
+ * 예전에는 이번 실행이 기록한 것만 메모리에 두었다 — pull 마다 모든 DB 의 `.base` 를 다시 썼기
2924
+ * 때문이다. 이제는 바뀐 DB 만 조회하므로(ADR-027) 이번에 조회하지 않은 DB 의 것도 알아야 한다. 그래서
2925
+ * 상태 DB 에 남긴다. 값이 바뀔 때만 쓴다 — 전체 대조는 DB 마다 한 번씩 부른다.
2926
+ */
2927
+ declare class DbBaseFiles {
2928
+ private readonly stateDb;
2929
+ private entries;
2930
+ constructor(stateDb: Pick<IStateDB, "getMeta" | "setMeta">);
2931
+ get(databaseId: string): DbBaseFile | undefined;
2932
+ set(databaseId: string, info: DbBaseFile): void;
2933
+ /** 상태 메타에서 한 번 읽는다. 없거나 깨졌으면 비어 있다 — 이번 실행이 기록하는 것부터 다시 쌓는다. */
2934
+ private load;
2935
+ }
2936
+
2937
+ /**
2938
+ * 원격을 얼마나 훑을지 — 전체 대조인가, 바뀐 것만인가 (ADR-027).
2939
+ *
2940
+ * 전체 대조는 루트 아래 페이지를 모두 순회하고 동기화하는 DB 를 모두 조회한다. 원격에서 지운 것 ·
2941
+ * 범위 밖으로 옮긴 것은 이것으로만 보인다 — search 는 휴지통을 돌려주지 않는다. 대신 비싸다: 실볼트
2942
+ * (발견 DB 166개)에서 원격이 그대로인데도 재pull 이 523.5초 걸렸다(실측 2026-09-28). 예전에는
2943
+ * `deleteSync` 가 켜져 있으면 pull · 상태 확인마다 전체 대조를 했고, 플러그인은 늘 켠다.
2944
+ *
2945
+ * 그래서 전체 대조는 주기(`sync.fullReconcileInterval`)마다만 하고, 그 사이에는 지난 pull 뒤에 바뀐
2946
+ * 페이지 · 행 · DB 스키마만 search 로 찾아 그 DB 만 조회한다. 원격 삭제는 다음 전체 대조가 반영한다.
2947
+ */
2948
+
2949
+ /**
2950
+ * 이번 pull 이 조회할 DB. `all` — 전체 대조. `changed` — 바뀐 것이 보인 DB 만(id 는
2951
+ * {@link compactNotionId}). 새로 발견한 DB 는 어느 쪽이든 조회한다 — 받은 적이 없다.
2952
+ */
2953
+ type DatabaseSelection = {
2954
+ readonly kind: "all";
2955
+ } | {
2956
+ readonly kind: "changed";
2957
+ readonly ids: ReadonlySet<string>;
2958
+ };
2959
+ /**
2960
+ * DB 마다 이번 pull 이 어떻게 끝났나 — 다음 pull 이 바뀐 것이 없어도 다시 조회할 DB
2961
+ * ({@link PENDING_DATABASES_META_KEY})를 가른다.
2962
+ *
2963
+ * 조회하다 실패했거나 · 받지 못한 행이 있거나 · 취소로 닿지 못한 DB 는 대기로 남는다. 받았거나 · 이제
2964
+ * 없어서 뺀 DB 는 대기에서 빠진다. 이번에 조회하지 않은 DB 의 대기는 그대로 남는다.
2965
+ */
2966
+ declare class DatabasePullLedger {
2967
+ private readonly selection;
2968
+ private readonly pending;
2969
+ private readonly settled;
2970
+ private readonly unfinished;
2971
+ private skippedCount;
2972
+ /** 이제 동기화하는 DB — 알면 대기를 이것으로 좁힌다. */
2973
+ private syncing;
2974
+ constructor(selection: DatabaseSelection, pending: ReadonlySet<string>);
2975
+ /** 이 DB 를 조회하나 — 새로 발견한 DB 는 부른 쪽이 따로 조회한다(받은 적이 없다). */
2976
+ selects(databaseId: string): boolean;
2977
+ /** 바뀐 것이 보이지 않아 조회하지 않았다. */
2978
+ skip(): void;
2979
+ /** 받았거나 · 이제 없어서 뺐다 — 다시 조회할 것이 없다. */
2980
+ settle(databaseId: string): void;
2981
+ /** 조회하다 실패했거나 · 받지 못한 행이 있거나 · 취소로 닿지 못했다 — 다음 pull 이 다시 조회한다. */
2982
+ retry(databaseId: string): void;
2983
+ get skipped(): number;
2984
+ /**
2985
+ * 이제 동기화하는 DB 를 알린다 — 대기를 이것으로 좁힌다. 설정에서 뺐거나 접근 불가로 뺀 DB 를 영영
2986
+ * 들고 있지 않는다. 알리지 않으면(발견이 멈춤 등) 좁히지 않는다 — 모르는 것을 버리지 않는다.
2987
+ */
2988
+ keepOnly(databaseIds: Iterable<string>): void;
2989
+ /** 다음 pull 의 대기 — 지난 대기에서 받은 것을 빼고 이번에 받지 못한 것을 더한다. */
2990
+ nextPending(): Set<string>;
2991
+ }
2992
+
2993
+ /** 한 번의 동기화가 원격을 보는 기준 — 판정 · 기록에 함께 쓴다. */
2994
+ interface ObservationContext {
2995
+ /**
2996
+ * 이번 동기화가 시작한 시각. 원격을 «본» 시각으로 적는다 — 실제로 본 시각보다 이르거나 같아서
2997
+ * 가라앉았다고 서둘러 보지 않는다. 동기화 밖이면 null(가라앉았다고 보지 않는다).
2998
+ */
2999
+ readonly seenAt: string | null;
3000
+ /** 이 토큰의 봇 id. 받지 못했으면 null — 봇 규칙 없이 가른다. */
3001
+ readonly botUserId: string | null;
3002
+ }
3003
+
1774
3004
  interface DatabaseSyncResult {
1775
3005
  created: number;
1776
3006
  updated: number;
1777
- /** Pull 시 로컬·리모트 동시 수정으로 발생한 충돌 (push 는 항상 빈 배열). */
3007
+ /**
3008
+ * Notion 에서 지워져 볼트에서도 지운 행 수(deleteSync). 행은 DB 조회로만 보여, 행의 삭제는 전체
3009
+ * 대조가 아니라 여기서 가른다(S-12).
3010
+ */
3011
+ deleted: number;
3012
+ /** Pull 시 로컬·리모트 동시 수정으로 발생한 충돌. */
1778
3013
  conflicts: Conflict[];
1779
3014
  failed: FailedOperation[];
1780
3015
  /**
1781
3016
  * 이번 pull 에서 실제로 디스크에 기록한 db-row 파일 경로(SSOT).
1782
3017
  * 오케스트레이터의 링크 해소(resolveNotionLinks) 대상은 반드시 이 목록으로 정한다 —
1783
3018
  * 과거의 `getByStatus("synced").slice(-N)` 휴리스틱은 ORDER BY 가 없어 증분 pull 에서
1784
- * 엉뚱한 행을 골라 정작 바뀐 행의 relation UUID 를 영영 해소하지 못했다(M3). push 경로는 항상 빈 배열.
3019
+ * 엉뚱한 행을 골라 정작 바뀐 행의 relation UUID 를 영영 해소하지 못했다(M3).
1785
3020
  */
1786
3021
  writtenPaths: string[];
1787
3022
  /**
@@ -1797,6 +3032,22 @@ interface DatabaseSyncResult {
1797
3032
  */
1798
3033
  linkedOriginalDbId?: string;
1799
3034
  }
3035
+ /** dry-run 이 센 행 하나 — 경로와 할 일. `restore` 는 볼트에서 사라진 행을 되살리는 것이다. */
3036
+ interface PlannedRow {
3037
+ readonly pageId: string;
3038
+ readonly path: string;
3039
+ readonly operation: "create" | "update" | "delete";
3040
+ readonly restore?: boolean;
3041
+ }
3042
+ /**
3043
+ * 행을 받는 진행 — 진행 표시가 페이지와 행을 한 목록 · 한 수로 보이게 한다. 행은 DB 를 조회해야 몇
3044
+ * 개인지 알아, 받기 전에 이 DB 에서 받을 행 수를 `planned` 로 알리고 행 하나를 마칠 때마다 `done` 을
3045
+ * 부른다. 지운 행은 조회에 없어 미리 셀 수 없다 — 지울 때 `planned(1)` 뒤에 `done` 을 부른다.
3046
+ */
3047
+ interface RowProgress {
3048
+ planned(count: number): void;
3049
+ done(item: ProgressItem): void;
3050
+ }
1800
3051
  declare class DatabaseSyncer {
1801
3052
  private readonly config;
1802
3053
  private readonly stateDb;
@@ -1804,23 +3055,54 @@ declare class DatabaseSyncer {
1804
3055
  private readonly vaultFs;
1805
3056
  private readonly pipeline;
1806
3057
  private readonly imageHandler;
3058
+ /**
3059
+ * 이번 동기화가 원격을 보는 기준(N-05) — 오케스트레이터가 실행마다 정한다. 따로 쓰면(시험)
3060
+ * 아무것도 가라앉았다고 보지 않는다 — 수정 시각이 같은 행도 받아서 견준다.
3061
+ */
3062
+ private readonly observation;
3063
+ /**
3064
+ * 받은 행 본문(Notion 원문)을 알린다 — 감지가 행의 하위 페이지를 빠뜨렸을 때 그 본문의 자식 태그로
3065
+ * 채운다({@link RemoteDetector.noteBodyChildren}).
3066
+ */
3067
+ private readonly noteRowBody;
1807
3068
  private readonly propertyMapper;
3069
+ private decorationsCache?;
3070
+ /**
3071
+ * 받은 DB 마다 사이드카에 적을 꾸밈 — 한 pull 이 같은 DB 의 사이드카를 여러 번 다시 만든다(임베드 재작성).
3072
+ * 올린 파일 아이콘 · 커버를 그때마다 내려받지 않는다.
3073
+ */
3074
+ private readonly appearances;
1808
3075
  private readonly baseFileGenerator;
3076
+ /** 사용자가 만든 `.base` 라 덮어쓰지 않았다고 이미 알린 경로 — 한 pull 이 같은 DB 를 여러 번 다시 쓴다. */
3077
+ private readonly keptUserBases;
1809
3078
  private readonly sidecarGenerator;
1810
3079
  /**
1811
- * 이번 프로세스가 실제로 기록한 .base 경로/DB 제목 (databaseId nohyph → info).
1812
- * placeholder 임베드 재작성(F22)의 SSOT — 폴더명(하이픈 새니타이즈)과 .base 파일명
1813
- * (sanitizeFileName: 공백·점 보존)은 규칙이 달라 localFolder 로 추측한 경로는 깨진
1814
- * 임베드가 된다. generateBaseFile 은 매 pull 모든 DB 에 대해 실행되므로 pull 종료
1815
- * 시점에는 성공한 DB 전체가 채워져 있다.
3080
+ * 실제로 기록한 .base 경로/DB 제목 — placeholder 임베드 재작성(F22)의 SSOT. 이번 pull 이 조회하지
3081
+ * 않은 DB 의 것도 남아 있다({@link DbBaseFiles}).
1816
3082
  */
1817
- readonly baseFileInfo: Map<string, {
1818
- basePath: string;
1819
- title: string;
1820
- }>;
1821
- constructor(config: Config, stateDb: IStateDB, notionClient: NotionClient, vaultFs: VaultFS, pipeline: ConversionPipeline, imageHandler: ImageHandler);
1822
- pullAll(): Promise<DatabaseSyncResult>;
1823
- pushAll(): Promise<DatabaseSyncResult>;
3083
+ readonly baseFileInfo: DbBaseFiles;
3084
+ private get decorations();
3085
+ constructor(config: Config, stateDb: IStateDB, notionClient: NotionClient, vaultFs: VaultFS, pipeline: ConversionPipeline, imageHandler: ImageHandler,
3086
+ /**
3087
+ * 이번 동기화가 원격을 보는 기준(N-05) — 오케스트레이터가 실행마다 정한다. 따로 쓰면(시험)
3088
+ * 아무것도 가라앉았다고 보지 않는다 — 수정 시각이 같은 행도 받아서 견준다.
3089
+ */
3090
+ observation?: () => ObservationContext,
3091
+ /**
3092
+ * 받은 행 본문(Notion 원문)을 알린다 — 감지가 행의 하위 페이지를 빠뜨렸을 때 그 본문의 자식 태그로
3093
+ * 채운다({@link RemoteDetector.noteBodyChildren}).
3094
+ */
3095
+ noteRowBody?: (pageId: string, rawMarkdown: string) => void);
3096
+ pullAll(opts?: {
3097
+ readonly paths?: readonly string[];
3098
+ readonly progress?: RowProgress;
3099
+ /** 조회할 DB 와 DB 마다의 결과(ADR-027). 없으면 모든 DB 를 조회한다. */
3100
+ readonly ledger?: DatabasePullLedger;
3101
+ /** 취소하면 다음 DB 를 조회하지 않는다 — 닿지 못한 DB 는 다음 pull 이 조회한다. */
3102
+ readonly signal?: AbortLike;
3103
+ /** {@link pullDatabase} 의 같은 이름 옵션. */
3104
+ readonly rescanRowChildren?: boolean;
3105
+ }): Promise<DatabaseSyncResult>;
1824
3106
  pullDatabase(dbConfig: DatabaseSyncConfig, opts?: {
1825
3107
  /**
1826
3108
  * F25: database id(하이픈 유무 무관) → 그 DB 행이 사는 로컬 폴더. 디스커버리 캐시가
@@ -1828,9 +3110,126 @@ declare class DatabaseSyncer {
1828
3110
  * 경우에만 행 소유를 양보한다 — 원본이 미발견이면 이 컨테이너가 유일한 접근 통로다.
1829
3111
  */
1830
3112
  resolveDbFolder?: (databaseId: string) => string | null;
3113
+ /**
3114
+ * 이 범위의 행만 받고 지운다(`pull --path` · 변경 패널의 항목별 받기). 없으면 모든 행.
3115
+ * 새 행은 DB 폴더 전체가 범위일 때만 만든다 — 받기 전에는 어느 경로에 쓸지 모른다.
3116
+ */
3117
+ paths?: readonly string[];
3118
+ /** 받은 행 · 지운 행을 하나씩 알린다. 예전에는 pull 진행 표시에 행이 하나도 보이지 않았다. */
3119
+ progress?: RowProgress;
3120
+ /**
3121
+ * `--force` — 그대로라 받지 않는 행도 본문을 읽어 하위 페이지를 알린다({@link noteRowBody}). 감지가 빠뜨린
3122
+ * 행의 하위 페이지는 행 본문으로만 보이는데, 이미 받은 행은 바뀌기 전까지 본문을 다시 읽지 않는다.
3123
+ * 행 파일은 쓰지 않는다.
3124
+ */
3125
+ rescanRowChildren?: boolean;
1831
3126
  }): Promise<DatabaseSyncResult>;
3127
+ /**
3128
+ * 받지 않는 행의 본문을 읽어 하위 페이지만 알린다(`--force`). 읽지 못한 행은 경고만 남긴다 — 그 행은 받을 것이
3129
+ * 없었고, 하위 페이지는 다음 `--force` 가 다시 찾는다.
3130
+ */
3131
+ private noteUnchangedRowBodies;
3132
+ /**
3133
+ * 받을 행을 세기만 한다(dry-run) — 볼트 · 상태 DB · Notion 을 바꾸지 않는다. 조회 · 범위 · 받을
3134
+ * 행 판정 · 삭제 확인은 {@link pullDatabase} 와 같다.
3135
+ *
3136
+ * 행 본문은 읽지 않는다. 그래서 같은 분 안의 편집(«확인 안 됨»)은 고침으로 세고, 받아 보고 같으면
3137
+ * 쓰지 않을 행 · local-first 로 지킬 행 · 충돌로 남길 행도 고침으로 센다. 새 행의 경로는 pull 과 같은
3138
+ * 차례(ID 순)로 고른다 — 이번에 만들 행끼리 이름이 겹치면 뒤 차례에 id 가 붙는다.
3139
+ */
3140
+ planDatabase(dbConfig: DatabaseSyncConfig, opts?: {
3141
+ resolveDbFolder?: (databaseId: string) => string | null;
3142
+ paths?: readonly string[];
3143
+ }): Promise<PlannedRow[]>;
3144
+ /**
3145
+ * 이 DB 에서 볼 행을 조회한다. 범위(`paths`)가 이 DB 폴더에 닿지 않으면 조회하지 않는다 — 예전에는
3146
+ * 범위와 상관없이 모든 행을 만들고 고쳤다.
3147
+ *
3148
+ * F25: 행 parent 로 data source 의 원본 database 를 검증한다(추가 API 0회). 신 모델에선
3149
+ * linked view 컨테이너도 data_sources 가 채워져 retrieve 만으론 원본과 구분되지 않아
3150
+ * 캐시에 오등록될 수 있는데, 그대로 두면 같은 행 집합을 여러 config 가 각자 자기 폴더로
3151
+ * 릴레이 재배치해 원격 무변경에도 매 pull 재작성이 쌓인다(실측 66건/pull, 행당 최대 4중).
3152
+ * 컨테이너로 판정되면 행은 원본에 양보하고 .base 만 원본 폴더 필터로 재지향해
3153
+ * "같은 데이터의 다른 뷰"라는 Notion 의미를 보존한다.
3154
+ *
3155
+ * DB 는 한 번만 받는다(`meta`) — 행 조회 · 스키마 · `.base` 제목이 이것을 같이 읽는다. 예전에는 같은 DB 를
3156
+ * 세 번 넘게 다시 받아 DB 하나에 요청 6~8회가 들었다(ADR-027).
3157
+ */
3158
+ private queryRows;
3159
+ /**
3160
+ * 조회한 행 하나에 할 일 — 건너뜀 · 되살림 · 받음. 범위가 행 몇 개면 그 행만 받고 새 행은
3161
+ * 만들지 않는다(어느 경로에 쓸지 받기 전에는 모른다).
3162
+ *
3163
+ * 리모트가 지난번에 본 그대로면 건너뜀. 수정 시각이 같아도 같은 분 안의 편집일 수 있으면
3164
+ * («확인 안 됨», N-05) 받아서 견준다 — 같으면 파일을 쓰지 않는다. 단 두 가지 예외가 있다.
3165
+ * · 레코드 경로가 현재 DB 폴더 직속이 아니면(F24 동명 DB 폴더 분리 후 옛 공유 폴더 잔류)
3166
+ * 원격 무변경이어도 재처리해 현 폴더로 재배치한다.
3167
+ * · 로컬 파일이 사라졌으면 되살린다(R13). 이 확인이 없으면 pullDatabasePage 안의 복원
3168
+ * 판정(localExists → resolvePullConflict)에 영영 도달하지 못해 지운 행이 조용히 영구
3169
+ * 소실된다. 페이지 경로는 R0 으로 이미 마감한 계약인데 행 경로만 빠져 있었다 —
3170
+ * 오케스트레이터의 detectMissingLocalFiles 가 "행은 여기서 이미 같은 판정을 거친다"는
3171
+ * (틀린) 전제로 db-row 를 제외해 두었기에 양쪽 어디에도 복원 경로가 없는 상태였다.
3172
+ * deleteSync 가 켜져 있으면 되살리지 않는다 — 지운 것은 Notion 에서도 지우라는 뜻이고, sync 는
3173
+ * pull 을 먼저 돌려 되살리면 뒤이은 push 가 지울 것을 잃는다(detectMissingLocalFiles 와 같다).
3174
+ * · 예전 버전이 적은 날짜 모양이 남은 행은 그 줄만 새 모양으로 고쳐 쓴다({@link outdatedDateForm},
3175
+ * F-08).
3176
+ */
3177
+ private rowAction;
3178
+ /**
3179
+ * 원격이 그대로인 행이지만 예전 버전이 적은 날짜 모양(`…Z` · `…+09:00` · `{start, end}`)이 남아 새
3180
+ * 모양으로 고쳐 쓸 행인가(F-08, {@link PropertyMapper.outdatedDateKeys}). 고쳐 쓰면 사본도 새 모양이
3181
+ * 되어 다음 pull 은 건너뛴다.
3182
+ *
3183
+ * 지난 동기화 뒤 로컬을 고친 행은 고르지 않는다 — 로컬 편집을 지키느라 쓰지 않으니 pull 마다 고르기만
3184
+ * 한다. 그 편집을 올린 뒤의 pull 이 고른다. 로컬 파일을 읽지 못하면 고르지 않는다.
3185
+ */
3186
+ private outdatedDateForm;
3187
+ /**
3188
+ * 예전 버전이 적은 날짜 모양이 남은 행에서 그 키들만 새 모양으로 고쳐 쓴다(F-08) — 원격이 그대로라 본문 ·
3189
+ * 첨부는 다시 받지 않는다. 본문은 한 글자도 바꾸지 않고, 다른 키의 줄은 그대로 둔다(키 단위로 나눠 읽을
3190
+ * 수 없는 frontmatter 는 값만 지킨다 — {@link mergeRowFrontmatter}).
3191
+ *
3192
+ * 예전에는 행을 통째로 다시 받았다. 행마다 본문을 읽어 요청이 늘고, 다시 그릴 때마다 달라지는 본문(빈
3193
+ * 블록 · 첨부 이름)까지 바뀌었다 — 실볼트 사본에서 다시 받은 149행 중 99행.
3194
+ *
3195
+ * 고른 뒤 로컬을 고쳤으면 쓰지 않는다(`skipped`) — 이어지는 push 가 올린다.
3196
+ */
3197
+ private rewriteDateForms;
3198
+ /** 이 DB 의 추적 행 가운데 이번 조회에 없던 것 — 지워졌는지는 아직 모른다. */
3199
+ private unlistedRows;
3200
+ /**
3201
+ * 조회에 없던 이 DB 의 추적 행 가운데 Notion 에서 정말 지워진 것만 볼트에서 지운다(deleteSync, S-12).
3202
+ *
3203
+ * 행은 DB 조회로만 보인다 — 전체 대조(페이지 순회)는 행을 보지 못해 행의 삭제는 여기서 가른다.
3204
+ * 조회에 없다는 것만으로 지우지 않고 행마다 원격에 묻는다({@link remotePresence}): 살아 있는 행은
3205
+ * 다른 DB 로 옮겨졌을 수 있다(그 DB 의 pull 이 옮겨 받는다). 묻지 못한 행은 이번에는 둔다 — 다음
3206
+ * pull 이 다시 묻는다.
3207
+ *
3208
+ * 조회 필터(`pullFilter`)가 있으면 가르지 않는다 — 조회에 없는 행이 필터 밖인지 지워진 것인지
3209
+ * 모르고, 필터 밖으로 나간 행을 매번 물으면 요청이 끝없이 는다.
3210
+ *
3211
+ * 올리지 않은 로컬 편집이 있는 행은 페이지와 같은 규칙을 따른다({@link applyRemoteDeletion}) —
3212
+ * 지우지 않고 충돌로 남기거나 파일을 둔다.
3213
+ */
3214
+ private pullDeletedRows;
3215
+ /**
3216
+ * DB 의 보기를 Notion 에서 다시 읽어 `db-views.json` 에 적는다. DB 를 통째로 받을 때(행이 바뀐 DB · 전체 대조)
3217
+ * 마다 부른다 — 보기만 고친 것은 DB 의 수정 시각을 바꾸지 않아(2026-10-23 실측) 다음 전체 대조가 받는다.
3218
+ * 예전에는 처음 받은 보기를 끝까지 썼다 — Notion 에서 더하거나 고친 보기가 `.base` 에 오지 않았다.
3219
+ * 읽지 못하면 받아 둔 보기를 쓴다 — 보기가 빠진 `.base` 를 쓰지 않는다.
3220
+ */
1832
3221
  private pullDatabaseViews;
3222
+ /** 행들 가운데 한 번이라도 기간인 날짜 속성 → 그 짝 키. */
3223
+ private rangeEndsOf;
1833
3224
  private generateBaseFile;
3225
+ /**
3226
+ * DB 의 아이콘 · 커버 · 설명 · 제목 표시 — push 가 DB 를 다시 만들 때 쓴다(ADR-036). 아이콘 · 커버는 페이지와
3227
+ * 같은 꼴로 적고 올린 파일은 볼트로 내려받는다 — 서명 주소를 적으면 pull 마다 사이드카가 바뀐다.
3228
+ */
3229
+ private appearanceOf;
3230
+ private readAppearance;
3231
+ /** pull 이 쓰지 않은 `.base` · 사이드카가 이미 있는가 — 없거나 읽지 못하면 false. */
3232
+ private isUserFile;
1834
3233
  /**
1835
3234
  * DB 제목이 바뀌면 `.base`/`.notion.json` 파일명(safeName)이 바뀌어 옛 이름의 산출물이
1836
3235
  * 같은 폴더에 고아로 잔존한다(drift·부활 원인). DB 폴더 **직속**의 산출물 중 현재 이름이
@@ -1852,119 +3251,51 @@ declare class DatabaseSyncer {
1852
3251
  * (extractValue 는 URL 만 남긴다). 사용자가 넣은 진짜 외부 URL 은 그대로 둔다.
1853
3252
  */
1854
3253
  private localizeFileProperties;
3254
+ /**
3255
+ * 행 하나를 pull 이 쓰는 모양 그대로 렌더한다 — 파일은 쓰지 않는다.
3256
+ *
3257
+ * pull(pullDatabasePage)과 충돌 비교 · 원격 선택 해소 · 원격 미리보기(오케스트레이터의
3258
+ * renderRemotePage)가 같은 렌더를 봐야 한다. 행을 페이지처럼 렌더하면 커버 · 아이콘 ·
3259
+ * 첨부 속성이 빠지고, 본문 첫머리의 옛 속성 블록 값이 실제 속성을 덮는다(S-02).
3260
+ *
3261
+ * 본문을 읽지 못하면 던진다 — 행은 실패로 남고 레코드가 그대로라 다음 pull 이 다시
3262
+ * 받는다. 예전에는 빈 본문으로 넘어가, 로컬이 그대로인 행은 본문이 지워진 채 «동기화
3263
+ * 완료» 로 기록됐다(S-10). 읽지 못한 것은 비어 있는 것이 아니다.
3264
+ *
3265
+ * @param options.downloadMedia false 면 첨부를 내려받지 않는다(표시 전용). 비교를 보려다
3266
+ * 볼트에 파일이 생기면 안 된다 — 그 대가로 아직 내려받지 않은 첨부는 원격 URL 로 남는다.
3267
+ * @param options.localPath 로컬 노트가 지금 있는 자리 — 코드 펜스 표기를 되살리는 근거(S-20).
3268
+ * 다른 폴더로 옮기기 전이면 옛 경로다. 기본은 `filePath`.
3269
+ */
3270
+ renderRow(page: PageObjectResponse, filePath: string, options?: {
3271
+ downloadMedia?: boolean;
3272
+ localPath?: string;
3273
+ }): Promise<{
3274
+ content: string;
3275
+ title: string;
3276
+ properties: Record<string, unknown>;
3277
+ /** 받은 원격 본문의 지문(`remoteBodyFingerprint`). */
3278
+ bodyFingerprint: string;
3279
+ }>;
3280
+ /**
3281
+ * 행의 본문 밖이 지난 동기화 사본 그대로인가(N-05) — 제목과 보낼 수 있는 속성, 아이콘 · 커버.
3282
+ * push 는 원격을 덮어써도 되는지, pull 은 받을
3283
+ * 것이 있는지를 이 규칙으로 가른다. 읽기 전용 속성(수식 · 롤업 · 수정 시각)은 보지 않는다 —
3284
+ * 로컬에서 고칠 수 없고 push 가 보내지 않는다.
3285
+ *
3286
+ * @param mapper 그 행의 DB 스키마를 읽은 매퍼.
3287
+ * @param base 지난 동기화 시점의 frontmatter.
3288
+ * @param path 지난 동기화 시점의 경로 — 파일 이름을 따르던 제목을 가른다.
3289
+ */
3290
+ rowOutsideBodyUnchanged(mapper: PropertyMapper, page: PageObjectResponse, base: Readonly<Record<string, unknown>>, path: string): boolean;
3291
+ /** 레코드가 지난번에 본 원격 행과 지금 행을 견준다 — 이번 동기화의 기준으로(N-05). */
3292
+ private remoteVerdict;
3293
+ /**
3294
+ * 행 경로 후보의 주인 — 상태 DB 의 레코드, 없으면 볼트에 이미 있는 파일(주인 모름). 추적하지 않는 로컬 파일
3295
+ * (아직 push 하지 않은 노트)을 새 행이 덮어쓰면 그 글이 사라졌다. 일반 페이지 받기(`claimFilePath`)처럼 피한다.
3296
+ */
3297
+ private rowPathOwners;
1855
3298
  private pullDatabasePage;
1856
- private pushDatabase;
1857
- /**
1858
- * db-row 파일 1건을 Notion 으로 밀어 올린다 — 생성·갱신·rename 재매핑·스킵을 모두 담당.
1859
- *
1860
- * 호출부의 루프에서 떼어낸 이유는 상한(`withDeadline`)을 걸 단위가 필요해서다(R9e).
1861
- * 그래서 결과는 카운터를 직접 건드리지 않고 {@link PushRowOutcome} 으로 돌려준다 —
1862
- * 상한에 걸린 행이 카운터를 절반만 올린 채 끊기는 일이 구조적으로 불가능해진다.
1863
- *
1864
- * 본문 push 실패만 `failed` 를 **반환**하고 나머지 예외는 그대로 던진다. 둘의 차이는
1865
- * 상태 처리에 있다: 전자는 레코드를 `error` 로 내려 해시 전진을 막아야 하므로 여기서
1866
- * 처리하고, 후자는 호출부의 catch 가 일괄 기록한다(기존 동작 그대로).
1867
- */
1868
- private pushDatabaseRow;
1869
- /**
1870
- * rename 감지를 위한 고아 레코드 인덱스(내용 해시 → 레코드)를 만든다.
1871
- * 고아 = 해당 DB 소속 db-row 레코드 중 로컬 파일이 더 이상 존재하지 않는(livePaths 에 없는) 것.
1872
- * 같은 해시가 여러 고아에 걸리면 첫 항목을 유지한다(희박한 케이스).
1873
- */
1874
- private buildOrphanHashIndex;
1875
- }
1876
-
1877
- declare class BlockConverter {
1878
- private n2m;
1879
- private client;
1880
- initNotionToMd(client: Client): void;
1881
- private registerCustomTransformers;
1882
- /**
1883
- * `numbered_list_item` 을 `1.` 마크다운으로 내보내 **번호 목록 타입을 보존**한다(I2 블록 라운드트립).
1884
- *
1885
- * notion-to-md 기본 렌더는 `md.bullet(text, block.numbered_list_item.number)` 를 쓰는데
1886
- * Notion API 는 list item 에 `.number` 를 채워주지 않아 `undefined` → 불릿(`- `)으로 **강등**된다.
1887
- * 그러면 블록 폴백 경로(notionBlocksToMarkdown)로 pull 한 뒤 다시 push 하면 numbered_list_item 이
1888
- * bulleted_list_item 으로 **타입 유실**된다(기본 markdown-api 경로는 영향 없음).
1889
- *
1890
- * 항상 `1.` 로 내보내면 마크다운이 시각적으로 자동 재번호하므로 표시는 동일하고, martian 이 다시
1891
- * numbered_list_item 으로 파싱해 타입이 보존된다. 커스텀 트랜스포머 등록은 notion-to-md 의 기본
1892
- * 자식 처리를 끄므로(라이브러리 설계), 자식은 toggle/callout 과 동일하게 직접 fetch·들여쓰기한다.
1893
- */
1894
- private registerNumberedListTransformer;
1895
- private registerToggleTransformer;
1896
- private registerToggleHeadingTransformers;
1897
- private registerColumnTransformer;
1898
- private registerSyncedBlockTransformer;
1899
- private registerBookmarkTransformer;
1900
- private registerVideoTransformer;
1901
- private registerAudioTransformer;
1902
- private registerFileTransformer;
1903
- private registerChildPageTransformer;
1904
- private registerChildDatabaseTransformer;
1905
- private registerLinkToPageTransformer;
1906
- private registerTocTransformer;
1907
- private registerBreadcrumbTransformer;
1908
- private registerLinkPreviewTransformer;
1909
- private registerPdfTransformer;
1910
- private registerEmbedTransformer;
1911
- private registerCalloutTransformer;
1912
- markdownToNotionBlocks(markdown: string): unknown[];
1913
- private toggleContents;
1914
- private columnContents;
1915
- private toggleCounter;
1916
- private columnCounter;
1917
- private preProcessMarkdown;
1918
- /**
1919
- * compact underline/color 보존마커를 평문으로 강등 — block(martian) 폴백 경로 전용.
1920
- *
1921
- * 무손실 underline/color 복원은 기본 경로인 Markdown API({@link obsidianToNotionEnhanced})가
1922
- * 전담한다. martian 은 이 서식을 표현할 수 없으므로, Markdown API 가 실패해 이 폴백을 탈 때는
1923
- * 마커 텍스트가 본문에 리터럴로 새지 않도록 텍스트만 남기고 마커를 제거한다(서식 degrade).
1924
- */
1925
- private stripInlineAnnotationMarkers;
1926
- private replaceDividers;
1927
- private extractToggleBlocks;
1928
- private extractColumnBlocks;
1929
- private postProcessBlocks;
1930
- private convertTogglePlaceholder;
1931
- private convertColumnPlaceholder;
1932
- private convertDividerPlaceholder;
1933
- /**
1934
- * 단일 라인 보존 마커(목차·breadcrumb)를 원래 블록으로 복원한다.
1935
- * pull 시 마크다운 표현이 없는 블록을 `%%im-nobsidian:toc%%` /
1936
- * `%%im-nobsidian:breadcrumb%%` 마커 문단으로 남겨두므로, push 시 이 문단을
1937
- * 감지해 table_of_contents / breadcrumb 블록으로 되돌린다. 그러지 않으면
1938
- * 마커 텍스트가 일반 문단으로 Notion 에 기록되는 잠재 손실이 된다.
1939
- */
1940
- private convertSingleLineMarker;
1941
- private convertImageToVideoOrEmbed;
1942
- /**
1943
- * `@tryfabric/martian` 의 blockquote 표현을 **평탄화**한다(I2/I3 무손실 push).
1944
- *
1945
- * martian 의 parseBlockquote 는 `notion.blockquote([], children)` 로 본문을 quote 의
1946
- * **자식 paragraph** 로 내려보내고 quote.rich_text 를 비운다(라이브러리 설계). 그 결과
1947
- * `> 인용문` 을 push 하면 Notion 에 "빈 인용 줄 + 들여쓴 자식 문단"이 만들어지고, 다시
1948
- * pull 하면 `> `(빈 줄) 아래에 자식 문단이 매달려 **원문과 구조가 갈라진다**(라운드트립 손실).
1949
- * 또한 quote.rich_text[0] 이 항상 비어 callout 이모지 감지({@link convertQuoteToCallout})도
1950
- * 무력화됐다.
1951
- *
1952
- * 선두의 연속된 paragraph 자식을 quote.rich_text 로 끌어올리고(문단 사이는 줄바꿈 rich_text
1953
- * 로 연결 — Notion 네이티브 멀티라인 인용 표현), paragraph 가 아닌 자식(중첩 리스트 등)은
1954
- * children 으로 남겨 구조를 보존한다. 이미 rich_text 에 본문이 있거나 끌어올릴 paragraph 가
1955
- * 없으면 원본을 그대로 반환한다(안전 no-op).
1956
- */
1957
- private flattenMartianBlockquote;
1958
- private convertQuoteToCallout;
1959
- notionBlocksToMarkdown(pageId: string): Promise<string>;
1960
- /**
1961
- * **in-memory** 블록 배열을 마크다운으로 변환한다(페이지 fetch 없음). notion-to-md 의
1962
- * blocksToMarkdown 을 직접 호출하므로, `has_children:false` 인 평면 블록은 Notion 클라이언트를
1963
- * 전혀 건드리지 않는다 → 고정 픽스처로 **오프라인 결정론** 블록 라운드트립(I2)을 잠글 수 있는
1964
- * 정식 테스트 시임. children 이 있는 블록(table 행·callout 자식 등)은 클라이언트를 호출하므로
1965
- * 호출 측이 평면 블록만 넣어야 오프라인이 보장된다.
1966
- */
1967
- notionBlockArrayToMarkdown(blocks: Array<Record<string, unknown>>): Promise<string>;
1968
3299
  }
1969
3300
 
1970
3301
  declare class FrontmatterExtractor implements Processor {
@@ -1994,6 +3325,28 @@ declare class InlineDBParser implements Processor {
1994
3325
  process(input: ProcessorInput): ProcessorOutput;
1995
3326
  }
1996
3327
 
3328
+ /**
3329
+ * 블록 수식(`$$…$$`)의 두 `$$` 를 저마다 제 줄에 세운다 — Notion 은 `$$` 가 줄에 혼자 있을 때만 블록
3330
+ * 수식으로 읽는다. 한 줄로 쓴 `$$ x^2 $$` · 식이 `$$` 줄에 붙은 `$$ a` … `b $$` 는 빈 인라인 수식과
3331
+ * 글이 된다(실측).
3332
+ *
3333
+ * 콜아웃 · 목록 안의 수식은 새 줄에도 줄머리(`> ` · 들여쓰기)를 단다. 예전에는 닫는 `$$` 를 줄머리
3334
+ * 없이 세워 콜아웃 밖으로 꺼냈고, Notion 은 그 `$$` 부터 뒤 문단을 수식으로 삼켰다(실측).
3335
+ * 글 사이의 `$$x$$` 는 둔다 — Notion 은 인라인 수식으로 읽는데, 줄을 가르면 빈 수식과 글이 된다.
3336
+ *
3337
+ * 코드 펜스 · 인라인 코드 안의 `$` 는 코드다. 셸의 `$$`(PID) · `$HOME` 을 수식으로 보면 Notion 의
3338
+ * 코드가 바뀌고, 코드 속 `$$` 가 코드 밖 수식의 `$$` 와 짝지어져 그 사이 본문까지 옮겨졌다(S-21).
3339
+ *
3340
+ * 블록 수식으로 서지 못하는 `$$` 는 Notion 이 빈 인라인 수식으로 읽는다(2026-10-21 실측) — 짝이 없는 `$$` 는
3341
+ * Obsidian 이 글로 그리는데 Notion 에는 빈 수식 상자가 생겼다. 그래서 줄 안에서 짝이 없는 `$$` 는 `\$\$`(두 쪽 모두
3342
+ * 글 `$$`)로, 글과 한 줄인 `$$ x $$` 는 안쪽 공백을 떼어 `$$x$$`(Notion 의 인라인 수식)로 보낸다
3343
+ * ({@link settleLineDollars}). 제 줄의 `$$` 는 블록 수식의 경계라 둔다.
3344
+ *
3345
+ * 인라인 수식(`$…$`)의 안쪽은 건드리지 않는다. 예전에는 안쪽 공백을 잘라 `$ x $` 가 수식이 되고
3346
+ * `$5 and $10` 이 `$5 and$10` 으로 바뀌었다. Obsidian 과 Notion 은 여는 `$` 뒤 · 닫는 `$` 앞이
3347
+ * 공백이거나 닫는 `$` 뒤가 숫자면 수식으로 읽지 않는다(Obsidian 1.13.7 의 토크나이저 · Notion 실측).
3348
+ * 다만 Notion 은 글자에 붙은 수식(`w$^9$가`)도 글로 읽는다 — 그런 수식만 `` $`식`$ `` 로 보낸다({@link quoteGluedMath}).
3349
+ */
1997
3350
  declare class MathNormalizer implements Processor {
1998
3351
  readonly name = "MathNormalizer";
1999
3352
  readonly order = 50;
@@ -2004,6 +3357,10 @@ declare class EmbedResolver implements Processor {
2004
3357
  readonly name = "EmbedResolver";
2005
3358
  readonly order = 60;
2006
3359
  process(input: ProcessorInput): ProcessorOutput;
3360
+ /** `![[대상]]` — 첨부는 자리표시자로(push), 노트 임베드는 원문 그대로. */
3361
+ private resolveEmbeds;
3362
+ /** `![설명](url)` — 동영상 URL 은 임베드 마커로, 외부 이미지는 목록에 모은다. */
3363
+ private resolveMarkdownImages;
2007
3364
  }
2008
3365
 
2009
3366
  declare class PreserveMarkerCollector implements Processor {
@@ -2018,6 +3375,20 @@ declare class UnsupportedBlockStripper implements Processor {
2018
3375
  process(input: ProcessorInput): ProcessorOutput;
2019
3376
  }
2020
3377
 
3378
+ /**
3379
+ * 보내기 직전에 코드 밖의 이어진 빈 줄을 한 줄로 줄인다 — Notion 마크다운 가져오기는 남는 빈 줄마다 빈
3380
+ * 블록을 만든다(실측: 주석 한 줄을 지운 자리의 빈 줄 셋이 빈 블록 셋). 받으면 그 빈 블록이 `<br>` 줄이 된다.
3381
+ *
3382
+ * 노트의 빈 줄과, 앞 전처리가 지운 주석 · 지원하지 않는 블록이 남긴 빈 줄을 함께 줄인다. 그래서 내용을
3383
+ * 지우거나 끼우는 전처리 뒤, 보존 마커 수집(70) 앞에 돈다. 예전에는 지원하지 않는 블록 제거(5)가 줄였다 —
3384
+ * 주석 제거(11)보다 먼저라 주석 자리가 빈 블록이 됐고, 코드 속 빈 줄까지 줄였다(S-27).
3385
+ */
3386
+ declare class BlankLineCollapser implements Processor {
3387
+ readonly name = "BlankLineCollapser";
3388
+ readonly order = 65;
3389
+ process(input: ProcessorInput): ProcessorOutput;
3390
+ }
3391
+
2021
3392
  declare class PropertiesTableInjector implements Processor {
2022
3393
  readonly name = "PropertiesTableInjector";
2023
3394
  readonly order = 15;
@@ -2025,7 +3396,7 @@ declare class PropertiesTableInjector implements Processor {
2025
3396
  }
2026
3397
 
2027
3398
  /**
2028
- * 인라인 annotation(underline·color) 무손실 보존기 — push 전처리(order 5).
3399
+ * 인라인 annotation(underline·color) 무손실 보존기 — push 전처리(order 13).
2029
3400
  *
2030
3401
  * 과거에는 underline/color 표기를 **제거**(strip)했으나, 그러면 Notion 으로 push 할 때
2031
3402
  * 서식이 영구 소실됐다(I3 손실점). Notion 의 Enhanced Markdown 은 underline/color 를
@@ -2044,7 +3415,7 @@ declare class PropertiesTableInjector implements Processor {
2044
3415
  */
2045
3416
  declare class InlineAnnotationPreserver implements Processor {
2046
3417
  readonly name = "InlineAnnotationPreserver";
2047
- readonly order = 5;
3418
+ readonly order = 13;
2048
3419
  process(input: ProcessorInput): ProcessorOutput;
2049
3420
  }
2050
3421
 
@@ -2110,6 +3481,13 @@ declare class PropertiesTableRestorer implements Processor {
2110
3481
  readonly name = "PropertiesTableRestorer";
2111
3482
  readonly order = 5;
2112
3483
  process(input: ProcessorInput): ProcessorOutput;
3484
+ /**
3485
+ * DB 행 본문 첫머리의 속성 블록은 예전 push 가 행을 페이지로 밀며 잘못 끼운 것이다(S-02).
3486
+ * 행의 속성은 Notion 속성이 정본이다 — 블록 값으로 덮으면 Notion 에서 고친 속성이 옛 값으로
3487
+ * 보인다. 그래서 값은 버리고 블록만 걷어 낸다. Notion 쪽 블록은 이 행의 본문을 다음에
3488
+ * push 할 때 사라진다.
3489
+ */
3490
+ private dropFromRow;
2113
3491
  private restoreFromYaml;
2114
3492
  private restoreFromTable;
2115
3493
  }
@@ -2200,13 +3578,19 @@ declare class FileWatcher {
2200
3578
  stop(): Promise<void>;
2201
3579
  }
2202
3580
 
3581
+ /** 한 번의 sync 가 무엇을 보나 — 바뀐 파일만(파일 변경) · 볼트 전체(주기 sync). */
3582
+ type WatchSyncScope = "changes" | "full";
2203
3583
  interface WatchSyncOptions {
2204
3584
  readonly debounceMs?: number;
2205
- readonly onSyncStart?: () => void;
2206
- readonly onSyncComplete?: (result: SyncResult) => void;
2207
- readonly onSyncError?: (error: Error) => void;
3585
+ readonly onSyncStart?: (scope: WatchSyncScope) => void;
3586
+ readonly onSyncComplete?: (result: SyncResult, scope: WatchSyncScope) => void;
3587
+ /** 감시를 멈춰 도중에 취소한 sync — `result` 는 그때까지 처리한 것이다. */
3588
+ readonly onSyncCancelled?: (result: SyncResult, scope: WatchSyncScope) => void;
3589
+ readonly onSyncError?: (error: Error, scope: WatchSyncScope) => void;
2208
3590
  readonly onFileChange?: (event: WatchEvent, path: string) => void;
2209
3591
  }
3592
+ /** 마지막 변경 뒤 동기화까지 기다리는 시간 — CLI `watch` 와 플러그인 자동 동기화가 함께 쓴다. */
3593
+ declare const DEFAULT_WATCH_DEBOUNCE_MS = 2000;
2210
3594
  declare class WatchSyncService {
2211
3595
  private readonly rootPath;
2212
3596
  private readonly orchestrator;
@@ -2217,10 +3601,26 @@ declare class WatchSyncService {
2217
3601
  private pendingChanges;
2218
3602
  private syncing;
2219
3603
  private syncQueued;
3604
+ private fullSyncQueued;
2220
3605
  private running;
3606
+ private inFlight;
3607
+ private stopping;
2221
3608
  constructor(rootPath: string, orchestrator: SyncOrchestrator, options?: WatchSyncOptions);
2222
3609
  start(): void;
3610
+ /**
3611
+ * 감시를 멈춘다. 도는 sync 는 취소하고 끝나기를 기다린다 — 예전에는 기다리지 않아, 부른 쪽이
3612
+ * sync 도중에 상태 DB 를 닫았다(S-09).
3613
+ */
2223
3614
  stop(): Promise<void>;
3615
+ private halt;
3616
+ /**
3617
+ * 볼트 전체 sync 를 요청한다 — CLI `watch --interval` 의 주기 sync(S-09).
3618
+ *
3619
+ * 파일 변경 sync 와 같은 줄에 선다. 예전에는 주기 sync 가 이 서비스 밖에서 오케스트레이터를
3620
+ * 불러, 도는 중에 들어온 파일 변경 sync · 다음 주기 sync 와 겹쳤다. 도는 sync 가 있으면 끝난 뒤
3621
+ * 한 번만 돈다 — 요청이 쌓여도 한 번이다.
3622
+ */
3623
+ requestFullSync(): void;
2224
3624
  isRunning(): boolean;
2225
3625
  isSyncing(): boolean;
2226
3626
  getPendingCount(): number;
@@ -2228,6 +3628,7 @@ declare class WatchSyncService {
2228
3628
  private scheduleSync;
2229
3629
  private clearDebounce;
2230
3630
  private executeSync;
3631
+ private runBatch;
2231
3632
  }
2232
3633
 
2233
3634
  interface DBEntry {
@@ -2275,6 +3676,7 @@ interface PropertyOption {
2275
3676
  declare class ViewDataProvider {
2276
3677
  private readonly vaultFs;
2277
3678
  constructor(vaultFs: VaultFS);
3679
+ /** 파일이 없으면 빈 객체. 있는데 읽지 못하면 던진다 — 「보기 설정이 없다」 와 「읽지 못했다」 는 다른 말이다. */
2278
3680
  loadAllViewConfigs(): Promise<Record<string, DatabaseViewsConfig>>;
2279
3681
  getViewConfigs(databaseId: string): Promise<DatabaseViewsConfig | null>;
2280
3682
  collectEntries(folderPath: string): Promise<DBEntry[]>;
@@ -2292,79 +3694,6 @@ declare class EntryEditor {
2292
3694
  updateDate(entryPath: string, dateProperty: string, newDate: string): Promise<void>;
2293
3695
  }
2294
3696
 
2295
- interface BaseFileOptions {
2296
- readonly databaseId: string;
2297
- readonly databaseName: string;
2298
- readonly schema: Record<string, BasePropertySchema>;
2299
- readonly viewsConfig: DatabaseViewsConfig;
2300
- readonly folderPath: string;
2301
- }
2302
- type BasesViewType = "table" | "cards" | "list";
2303
- /** Notion 뷰 하나를 Bases 로 옮길 때의 판정. */
2304
- interface BasesViewMapping {
2305
- /** Bases 뷰 타입. `null` = 대응이 없어 `.base` 에 실을 수 없음. */
2306
- readonly type: BasesViewType | null;
2307
- /**
2308
- * 값이 있으면 **정확한 대응이 아니라 격하**라는 뜻(사람용 사유).
2309
- * 뷰 자체는 살아남지만 원래 뷰의 표현 일부(달력 격자·타임라인 축)는 재현되지 않는다.
2310
- */
2311
- readonly degradeReason?: string;
2312
- }
2313
- /**
2314
- * Notion 뷰 타입 → Obsidian Bases 뷰 매핑(SSOT).
2315
- *
2316
- * 뷰는 현재 pull-authoritative — Notion API 가 2026-03-19 뷰 생성/수정/삭제를 열었지만
2317
- * 본 프로젝트는 아직 뷰 write-back 을 구현하지 않았다(로드맵 항목). push 로 되돌릴 수
2318
- * 없는 동안 "조용한 유실 금지"는 곧 "보존 + 정직한 degrade 리포트"를 의미한다.
2319
- *
2320
- * 달력·타임라인은 예전에 `null` 이었다 — 즉 `.base` 에서 **통째로 사라졌다**. 사이드카에는
2321
- * 남지만 Obsidian 을 여는 사람 눈에는 그 뷰가 애초에 없었던 것처럼 보인다. 격자·축은
2322
- * 못 살려도 "날짜 순으로 늘어놓은 목록"이라는 본래 의도는 표로 남길 수 있으므로,
2323
- * 버리는 대신 격하해 살리고 사유를 사이드카에 적는다.
2324
- */
2325
- declare const NOTION_TO_BASES_VIEW: Record<string, BasesViewMapping>;
2326
- /** Notion 뷰 타입의 Bases 매핑. 미상 타입은 표현 불가로 본다. */
2327
- declare function basesViewMappingOf(notionType: string): BasesViewMapping;
2328
- /** Notion 뷰 타입의 Bases 대응 타입을 돌려준다. 미지원/미상 타입은 `null`. */
2329
- declare function basesViewTypeOf(notionType: string): BasesViewType | null;
2330
- declare class BaseFileGenerator {
2331
- generate(options: BaseFileOptions): string;
2332
- private writeFilters;
2333
- private needsContentCoverFormula;
2334
- private writeFormulas;
2335
- private writePropertyDisplayNames;
2336
- private writeViews;
2337
- /** `filters:` 키와 그 값을 쓴다. 단일 식이면 한 줄, 그룹이면 블록. */
2338
- private writeFilterValue;
2339
- /** 필터 노드를 시퀀스 항목(`- …`)으로 쓴다. 그룹이면 재귀적으로 중첩한다. */
2340
- private writeFilterItem;
2341
- /**
2342
- * 필터 식을 YAML 스칼라로 쓴다.
2343
- *
2344
- * 식은 대개 `note["상태"] == "진행 중"` 처럼 큰따옴표를 품는다. 평문 스칼라는 큰따옴표를
2345
- * 안에 담을 수 있으므로(선두만 아니면 된다) 기본은 평문으로 두어 기존 `file.ext == "md"`
2346
- * 라인과 모양을 맞춘다. 다만 `: `·` #`·선두 지시문자는 평문에서 의미가 달라지므로
2347
- * 그때만 작은따옴표로 감싼다(속성명에 콜론이나 해시가 들어갈 수 있다).
2348
- */
2349
- private yamlExpr;
2350
- /**
2351
- * 뷰 이름을 Bases 요구사항대로 유일화한다(in-place). 동명 충돌 시 ' 2',' 3'… 를 붙이되
2352
- * 이미 존재하는 이름과 다시 충돌하지 않을 때까지 증가시킨다.
2353
- */
2354
- private dedupeViewNames;
2355
- private convertView;
2356
- private toNoteRef;
2357
- /**
2358
- * 속성 참조(order/sort/groupBy/image)를 YAML로 안전하게 출력한다.
2359
- * `file.name`·`formula.coverImage`·`note.cover` 같은 접두사 식별자와 공백 포함 일반 속성명은
2360
- * 평문 스칼라로 안전하므로 그대로 두고, 콜론·해시·따옴표 등 YAML 메타문자가 있을 때만 인용한다.
2361
- */
2362
- private propRef;
2363
- private resolvePropertyName;
2364
- private yamlKey;
2365
- private yamlValue;
2366
- }
2367
-
2368
3697
  /**
2369
3698
  * 속성 id → 프론트매터 키 해소.
2370
3699
  *
@@ -2427,99 +3756,6 @@ type PropertyNameResolver = (idOrName: string) => string | undefined;
2427
3756
  */
2428
3757
  declare function translateNotionFilter(filter: unknown, schema: Record<string, BasePropertySchema>, resolveName: PropertyNameResolver): TranslationResult;
2429
3758
 
2430
- /**
2431
- * DB 사이드카(`<db>.notion.json`) — `.base` 로 표현할 수 없는 Notion DB 메타데이터를
2432
- * 무손실 보존하는 단일 파일.
2433
- *
2434
- * ## 왜 필요한가 (I4·I7 "조용한 유실 금지")
2435
- *
2436
- * Obsidian Bases(`.base`)는 Notion DB 뷰의 일부만 표현한다:
2437
- * · 미지원 뷰(calendar/timeline/form/chart/map/dashboard)는 `.base` 에서 통째 누락.
2438
- * · 표현되는 뷰도 Notion **필터식·커버 크기/비율·컬럼 width/wrap·빈그룹 숨김** 등은
2439
- * Bases 문법에 대응이 없어 손실된다.
2440
- *
2441
- * Notion API 는 뷰 생성/수정을 지원하지 않으므로(뷰는 pull-authoritative) 이 메타데이터는
2442
- * push 로 Notion 에 되돌릴 수 없다. 따라서 "무손실"의 의미는 **로컬에 완전·정직하게
2443
- * 보존하고, 무엇이 degrade 됐는지 명시 리포트**하는 것이다. 사이드카는 이를 `.base` 와
2444
- * 같은 폴더에 co-locate 해 사람이 발견 가능하고 도구가 재사용 가능하게 만든다.
2445
- *
2446
- * ## 멱등성
2447
- *
2448
- * 매 pull 마다 재생성되므로 **결정적**이어야 한다(타임스탬프·실행시각 금지, 키 정렬).
2449
- * 동일 DB 상태 → 동일 바이트 → drift 0 / churn 0.
2450
- */
2451
- interface NotionSidecar {
2452
- readonly generator: "im-nobsidian";
2453
- readonly schemaVersion: 1;
2454
- readonly databaseId: string;
2455
- readonly databaseName: string;
2456
- /** 속성 스키마 — 이름 정렬. `.base` displayName 만으로는 잃는 타입/옵션을 보존. */
2457
- readonly properties: SidecarProperty[];
2458
- /** 모든 뷰(표현/미표현 공통) — Notion 순서 유지. */
2459
- readonly views: SidecarView[];
2460
- /** 정직한 손실 리포트 — 무엇이 `.base` 에서 누락/degrade 됐는가. */
2461
- readonly degraded: DegradeNote[];
2462
- }
2463
- interface SidecarProperty {
2464
- readonly name: string;
2465
- readonly id: string;
2466
- readonly type: string;
2467
- /** select/multi_select/status 옵션명(있으면). */
2468
- readonly options?: string[];
2469
- }
2470
- interface SidecarView {
2471
- readonly id: string;
2472
- readonly name: string;
2473
- readonly notionType: string;
2474
- /** Bases 대응 타입. `null` = `.base` 미표현(사이드카가 유일 보존처). */
2475
- readonly basesType: BasesViewType | null;
2476
- /** 원본 뷰 설정 중 의미 있는 항목(키 정렬). 빈 값은 생략. */
2477
- readonly config: Record<string, unknown>;
2478
- }
2479
- interface DegradeNote {
2480
- readonly view: string;
2481
- /**
2482
- * `view-unrepresentable` = 뷰가 `.base` 에서 통째로 누락.
2483
- * `view-degraded` = 뷰는 남지만 다른 타입으로 격하(달력→표 등).
2484
- * `setting-dropped` = 뷰는 그대로지만 설정 하나가 빠짐.
2485
- */
2486
- readonly kind: "view-unrepresentable" | "view-degraded" | "setting-dropped";
2487
- readonly detail: string;
2488
- }
2489
- declare class SidecarGenerator {
2490
- /**
2491
- * 사이드카 객체를 빌드한다(순수 — I/O 없음). 직렬화는 {@link serialize} 가 담당.
2492
- */
2493
- build(options: {
2494
- databaseId: string;
2495
- databaseName: string;
2496
- schema: Record<string, BasePropertySchema>;
2497
- viewsConfig: DatabaseViewsConfig;
2498
- }): NotionSidecar;
2499
- /**
2500
- * 사이드카를 결정적 JSON 문자열로 직렬화한다(키 재귀 정렬 + 말미 개행).
2501
- * 동일 입력 → 동일 바이트 → 멱등(drift/churn 0).
2502
- */
2503
- serialize(sidecar: NotionSidecar): string;
2504
- private buildProperties;
2505
- private buildView;
2506
- /**
2507
- * 뷰 설정 중 의미 있는 항목을 추출한다. `.base` 가 표현 못하는 항목(필터·커버크기 등)은
2508
- * config 에 보존하면서 동시에 degrade 리포트에 1줄씩 기록한다(표현되는 뷰에 한해 —
2509
- * 미표현 뷰는 이미 view-unrepresentable 로 통째 보고됨).
2510
- */
2511
- private extractConfig;
2512
- /**
2513
- * 필터 손실을 **실제로 못 옮긴 것만** 보고한다.
2514
- *
2515
- * 예전엔 필터가 있기만 하면 "Notion 필터식은 .base 로 표현되지 않음"을 무조건 찍었다.
2516
- * 이제 `.base` 가 뷰별 `filters:` 를 내보내므로 그 문구는 대부분의 경우 거짓이다.
2517
- * 거짓 degrade 는 진짜 degrade 를 묻어 버리므로, `.base` 와 **같은 번역기**를 돌려
2518
- * 남은 것만 적는다(두 산출물이 어긋날 여지를 없앤다).
2519
- */
2520
- private noteFilterDegrade;
2521
- }
2522
-
2523
3759
  declare function sortEntries(entries: DBEntry[], sorts: NonNullable<ViewConfig["sorts"]>): DBEntry[];
2524
3760
  declare function groupEntries(entries: DBEntry[], groupByProperty: string, options?: {
2525
3761
  sort?: "ascending" | "descending";
@@ -2540,6 +3776,8 @@ interface FilterCondition {
2540
3776
  }
2541
3777
  declare function filterEntries(entries: DBEntry[], searchQuery?: string, filters?: FilterCondition[]): DBEntry[];
2542
3778
 
3779
+ /** Notion 이 쓰는 색 이름 전부(`default` 포함) — 색을 다루는 다른 곳은 이 목록을 읽는다. */
3780
+ declare const NOTION_COLOR_NAMES: readonly NotionColor[];
2543
3781
  declare function getNotionColor(color: NotionColor | string): string;
2544
3782
  declare function getNotionBgColor(color: NotionColor | string): string;
2545
3783
  declare function generateColorCSS(): string;
@@ -2621,20 +3859,6 @@ declare function summarizeFidelity(perFile: ReadonlyArray<{
2621
3859
  classification: FidelityClassification;
2622
3860
  }>): FidelitySummary;
2623
3861
 
2624
- /**
2625
- * 렌더 감사 — "볼트에 쓰인 마크다운이 Obsidian 에서 깨져 보이는가"를 코드로 고정한다.
2626
- *
2627
- * {@link classifyBodyFidelity} 가 **내용이 남았는가**를 보고 {@link verifyPageCompleteness}
2628
- * 가 **빠짐없이 왔는가**를 본다면, 여기는 **사람 눈에 어떻게 보이는가**를 본다. 셋은 서로의
2629
- * 사각지대를 맡는다 — 실제로 이 트랙의 결함은 내용도 다 있고 개수도 맞는데 콜아웃이
2630
- * 코드블록으로 오파싱되거나 표 구분행만 덩그러니 남는 형태였다.
2631
- *
2632
- * 원본 대조가 필요 없는 것만 담는다. 한 파일만 보고 판정할 수 있어야 동기화 하니스가
2633
- * 볼트를 훑는 데 쓸 수 있다. 원본 NFM 과 견주는 지표(구조 드리프트·코드블록 경계)는
2634
- * 회귀 코퍼스를 쥔 테스트 쪽에 남는다.
2635
- *
2636
- * 규칙 번호(①~⑧)는 트랙 초기 실측 조사에서 붙인 결함 분류를 그대로 잇는다.
2637
- */
2638
3862
  /** 한 줄만 보고 판정할 수 있는 렌더 결함. */
2639
3863
  interface RenderLineRule {
2640
3864
  readonly code: string;
@@ -2663,16 +3887,43 @@ declare function computeBufferHash(buffer: Buffer): string;
2663
3887
 
2664
3888
  declare function generateId(): string;
2665
3889
  declare function normalizeNotionId(id: string): string;
3890
+ /**
3891
+ * 사용자가 넣은 페이지 — ID(대시 있거나 없거나) 또는 Notion 주소(브라우저 주소창 · 「링크 복사」). 주소창에
3892
+ * 보이는 것을 그대로 붙여 넣어도 되게 한다. 못 찾으면 null.
3893
+ *
3894
+ * 데이터베이스 보기에서 연 페이지 주소는 `…/<DB>?v=<보기>&p=<페이지>` 다 — 연 페이지는 `p` 이고, `v` 는 보기다.
3895
+ */
3896
+ declare function notionIdFromInput(input: string): string | null;
2666
3897
  declare function notionIdsEqual(a: string, b: string): boolean;
2667
3898
  /**
2668
3899
  * 멘션 위키링크용 정규 id — 하이픈 제거 + 소문자. 오케스트레이터의 멘션 해소 정규식
2669
- * (`[[notion:<32hex>]]`)이 인식하는 유일한 형태다. 블록 폴백 경로(formatMention)와
3900
+ * (`[[notion:<32hex>]]`)이 인식하는 유일한 형태다. 블록 방식 받기(`blocks-to-nfm`)와
2670
3901
  * markdown-api 경로(convertPageMentions)가 동일 형태로 수렴하도록 한다(rank20/I3).
2671
3902
  */
2672
3903
  declare function compactNotionId(id: string): string;
2673
3904
 
2674
3905
  declare function sanitizeFileName(name: string): string;
2675
3906
 
3907
+ /** 노트(마크다운 파일)의 경로 · 이름인가. */
3908
+ declare function isMarkdownPath(path: string): boolean;
3909
+ /** 노트 확장자를 뗀다 — 노트가 아니면 그대로. */
3910
+ declare function stripMarkdownExtension(path: string): string;
3911
+ /** 경로의 노트 이름 — 마지막 조각에서 노트 확장자를 뗀다(`폴더/노트.MD` → `노트`). */
3912
+ declare function noteNameFromPath(path: string): string;
3913
+
3914
+ /**
3915
+ * 시각을 시간대의 벽시계 시각으로, 벽시계 시각을 오프셋이 붙은 시각으로 바꾼다(F-08).
3916
+ *
3917
+ * Obsidian 의 날짜시각 속성은 시간대가 없는 `YYYY-MM-DDTHH:mm` 이다 — 초 · 밀리초 · `Z` · `+09:00` 이
3918
+ * 붙으면 글로 본다(2026-10-04 Obsidian 1.12.4 실측). Notion 은 오프셋 없는 시각을 UTC 로 읽는다(같은 날
3919
+ * 실측: `2026-10-01T10:00` 을 보내면 `2026-10-01T10:00:00.000+00:00`). 그래서 pull 은 시각을 시간대의
3920
+ * 벽시계 시각으로 적고, push 는 그 시간대의 오프셋을 붙여 보낸다 — 같은 순간이다.
3921
+ */
3922
+ /**
3923
+ * 이 컴퓨터의 시간대(IANA 이름). 부를 때마다 읽는다 — 앱이 켜진 채 시간대가 바뀔 수 있다.
3924
+ */
3925
+ declare function systemTimeZone(): string;
3926
+
2676
3927
  /**
2677
3928
  * `--path` 범위 판정 — 볼트 상대경로가 사용자가 지정한 범위 안에 있는지.
2678
3929
  *
@@ -2691,6 +3942,48 @@ declare function matchesPathScope(path: string, scope: string): boolean;
2691
3942
  /** 범위 목록 중 하나라도 걸리면 true. 목록이 비어 있으면(범위 미지정) 항상 true. */
2692
3943
  declare function inAnyPathScope(path: string, scopes: readonly string[] | undefined): boolean;
2693
3944
 
3945
+ /** 줄 비교의 한 줄. 번호는 1부터 — 더한 줄은 옛 번호가, 지운 줄은 새 번호가 없다. */
3946
+ interface DiffLine {
3947
+ readonly kind: "same" | "added" | "removed";
3948
+ readonly text: string;
3949
+ readonly oldNumber: number | null;
3950
+ readonly newNumber: number | null;
3951
+ /**
3952
+ * 글 끝에 줄바꿈이 없는 줄. 바뀐 줄에만 붙인다 — 끝 줄바꿈만 다른 두 줄이 같은 줄을 지우고 다시 쓴 것처럼
3953
+ * 보이지 않게. 같은 줄에 붙은 것은 양쪽 다 없다는 뜻이라 차이가 아니다.
3954
+ */
3955
+ readonly noNewlineAtEnd?: true;
3956
+ }
3957
+ /** 바뀐 줄과 그 앞뒤의 같은 줄 묶음 — Git 의 `@@ -oldStart,oldLines +newStart,newLines @@`. */
3958
+ interface DiffHunk {
3959
+ /** 묶음의 첫 옛 줄 번호(1부터). 옛 줄이 없는 묶음(`oldLines` 0)도 1부터 센다. */
3960
+ readonly oldStart: number;
3961
+ readonly oldLines: number;
3962
+ readonly newStart: number;
3963
+ readonly newLines: number;
3964
+ readonly lines: readonly DiffLine[];
3965
+ }
3966
+ /** 줄 종류마다 앞에 붙이는 부호 — Git 의 통합 diff 와 같다. 줄 비교를 글로 보이는 곳은 모두 이것을 쓴다. */
3967
+ declare const DIFF_SIGN: Readonly<Record<DiffLine["kind"], string>>;
3968
+ interface LineDiffOptions {
3969
+ /** 바뀐 줄 앞뒤로 함께 보일 같은 줄 수. 기본 3 — Git 과 같다. */
3970
+ readonly context?: number;
3971
+ }
3972
+ /**
3973
+ * 두 글의 줄 비교 — 바뀐 곳과 그 앞뒤 몇 줄을 묶음으로 낸다. 같은 글이면 빈 배열.
3974
+ *
3975
+ * 예전의 충돌 비교는 같은 번호의 줄끼리 견줬다 — 한쪽 앞에 한 줄만 더해도 그 뒤의 모든 줄이 바뀐 것으로
3976
+ * 보였다. 여기는 가장 긴 공통 줄을 맞춰 실제로 더하고 지운 줄만 가른다.
3977
+ */
3978
+ declare function lineDiff(before: string, after: string, options?: LineDiffOptions): DiffHunk[];
3979
+ /**
3980
+ * 통합 diff 글의 줄(Git 의 `diff -u` 와 같은 모양) — 옛 글 · 새 글 이름 두 줄, 묶음마다 `@@` 머리와 부호를
3981
+ * 붙인 줄. 같은 글이면 이름 두 줄뿐이다. 끝 줄바꿈이 없다는 표시는 Git 과 달리 바뀐 줄 뒤에만 적는다
3982
+ * (`DiffLine.noNewlineAtEnd`). 글로 보이는 곳(CLI `diff` · `resolve`)이 모두 이것을 쓴다 — 저마다 만들면
3983
+ * 앞뒤 줄 수 · 큰 글 상한이 곳마다 달라진다.
3984
+ */
3985
+ declare function formatUnifiedDiff(hunks: readonly DiffHunk[], oldName: string, newName: string): string[];
3986
+
2694
3987
  interface Logger {
2695
3988
  warn(message: string, ...args: unknown[]): void;
2696
3989
  error(message: string, ...args: unknown[]): void;
@@ -2700,6 +3993,84 @@ interface Logger {
2700
3993
  declare function setLogger(logger: Logger): void;
2701
3994
  declare function getLogger(): Logger;
2702
3995
 
3996
+ /**
3997
+ * 볼트 하나에 연결 여럿 (ADR-038) — 연결마다 볼트 안의 폴더 하나를 맡는다.
3998
+ *
3999
+ * 연결 폴더는 자기 `.im-nobsidian/`(설정 · 상태 DB · 보기 설정)를 둔다. 엔진은 그 폴더를 볼트처럼 다루고
4000
+ * ({@link ScopedVaultFS}), 바깥 연결은 안쪽 연결 폴더를 읽어 올리지도 쓰지도 않는다. 볼트 맨 위의 연결은
4001
+ * 폴더 `""` 이다 — 예전부터 쓰던 단일 연결이 그대로 그것이다.
4002
+ */
4003
+
4004
+ /** 이 연결이 맡지 않은 곳에 쓰려 했다 — 폴더 밖이거나 다른 연결 폴더 안이다. */
4005
+ declare class ConnectionBoundaryError extends Error {
4006
+ readonly path: string;
4007
+ readonly folder: string;
4008
+ readonly where: "inside" | "outside";
4009
+ /**
4010
+ * @param folder 막은 연결 폴더 — `inside` 면 안쪽의 다른 연결, `outside` 면 이 연결.
4011
+ */
4012
+ constructor(path: string, folder: string, where: "inside" | "outside");
4013
+ }
4014
+ /**
4015
+ * 이 연결의 상태 DB 가 다른 연결 폴더 안의 노트를 추적하고 있다 — 이미 동기화하던 폴더를 따로 연결했다.
4016
+ *
4017
+ * 그대로 돌리면 바깥 연결은 그 노트들을 「볼트에서 지움」 으로 보고(목록에 없으므로) Notion 페이지를 지우거나,
4018
+ * 되살리려고 그 폴더에 쓰려 한다. 둘 다 사용자가 바란 것이 아니므로 아무것도 하지 않고 멈춘다.
4019
+ */
4020
+ declare class ConnectionScopeError extends Error {
4021
+ readonly paths: readonly string[];
4022
+ constructor(paths: readonly string[]);
4023
+ }
4024
+ /** 앞뒤 `/` 를 떼고 `.` · `..` 를 푼 POSIX 경로. 볼트 맨 위를 벗어나면 null. */
4025
+ declare function normalizeVaultPath(path: string): string | null;
4026
+ /** `path` 를 맡은 연결 폴더 — 가장 깊은 것. 어느 것도 아니면 null(`""` 연결이 있으면 그것). */
4027
+ declare function connectionFolderOf(path: string, folders: readonly string[]): string | null;
4028
+ /** 한 연결 폴더에서 Obsidian 설정 폴더까지의 상대 경로 — `Work/Team` 이면 `../../.obsidian`. */
4029
+ declare function configDirFrom(folder: string, configDir: string): string;
4030
+ /**
4031
+ * 볼트의 한 연결 폴더를 볼트처럼 보이는 VaultFS. 경로는 그 폴더 기준이다.
4032
+ *
4033
+ * - 목록은 폴더 안 · 안쪽 연결 폴더 밖의 파일만, 이 연결의 내부 폴더(`.im-nobsidian/`)는 빼고 낸다
4034
+ * - 쓰기(쓰기 · 지우기 · 옮기기 · 폴더 만들기)는 폴더를 벗어나거나 안쪽 연결 폴더에 닿으면
4035
+ * {@link ConnectionBoundaryError} 로 거절한다 — pull 이 다른 연결의 노트를 덮어쓰지 않는다
4036
+ * - 읽기는 볼트 안이면 폴더 밖도 된다(`../.obsidian/types.json` — Obsidian 설정은 볼트 맨 위에 하나다)
4037
+ */
4038
+ declare class ScopedVaultFS implements VaultFS {
4039
+ private readonly base;
4040
+ private readonly folder;
4041
+ private readonly nested;
4042
+ /**
4043
+ * @param folder 이 연결의 폴더(볼트 상대). `""` 이면 볼트 맨 위.
4044
+ * @param connections 볼트의 연결 폴더 전부 — 이 폴더 안쪽 것만 골라 비켜 간다.
4045
+ */
4046
+ constructor(base: VaultFS, folder: string, connections: readonly string[]);
4047
+ ownsPath(path: string): boolean;
4048
+ /** 이 연결의 경로 → 볼트 경로. */
4049
+ private toBase;
4050
+ /** 볼트 경로 → 이 연결의 경로. 이 연결이 맡지 않으면 null. */
4051
+ private fromBase;
4052
+ readFile(path: string): Promise<string>;
4053
+ readBinary(path: string): Promise<Buffer>;
4054
+ writeFile(path: string, content: string): Promise<void>;
4055
+ writeBinary(path: string, data: Buffer): Promise<void>;
4056
+ deleteFile(path: string): Promise<void>;
4057
+ moveFile(from: string, to: string): Promise<void>;
4058
+ exists(path: string): Promise<boolean>;
4059
+ ensureFolder(path: string): Promise<void>;
4060
+ /** 맡은 노트만 읽는다 — 볼트 전체의 본문을 읽지 않는다. */
4061
+ listMarkdownFiles(): Promise<FileInfo[]>;
4062
+ listMarkdownFileStats(): Promise<FileStatInfo[]>;
4063
+ listNonMarkdownFiles(): Promise<NonMdFileInfo[]>;
4064
+ getFileStat(path: string): Promise<FileStatInfo | null>;
4065
+ private rebase;
4066
+ }
4067
+
4068
+ /**
4069
+ * 볼트의 연결 폴더(ADR-038) — 자기 `.im-nobsidian/` 를 둔 폴더. 볼트 맨 위(`""`)는 그 폴더가 있으면 든다.
4070
+ * 점으로 시작하는 폴더(`.obsidian` · `.git` · `.trash` 등)는 들여다보지 않는다 — 노트 목록도 그렇게 걷는다.
4071
+ */
4072
+ declare function findConnectionFolders(vaultRoot: string): Promise<string[]>;
4073
+
2703
4074
  interface PipelineOptions {
2704
4075
  readonly wikilinkResolver?: WikilinkResolverFn;
2705
4076
  }
@@ -2729,6 +4100,8 @@ declare const DB_VIEWS_PATH = ".im-nobsidian/db-views.json";
2729
4100
  declare const STATE_DB_PATH = ".im-nobsidian/sync.db";
2730
4101
  /** 파일 감시에서 제외할 내부 디렉터리 glob. */
2731
4102
  declare const INTERNAL_DIR_GLOB = "**/.im-nobsidian/**";
4103
+ /** Obsidian 설정 폴더 기본 이름(볼트 맨 위). 사용자가 바꿀 수 있어 플러그인은 `vault.configDir` 를 쓴다. */
4104
+ declare const OBSIDIAN_CONFIG_DIR = ".obsidian";
2732
4105
  /**
2733
4106
  * 주어진 vault 상대 경로(POSIX)가 내부 메타데이터 디렉터리에 속하는지 판별한다.
2734
4107
  */
@@ -2748,4 +4121,109 @@ declare function isInternalPath(path: string): boolean;
2748
4121
  /** 모든 보존 마커가 공유하는 브랜드 토큰. */
2749
4122
  declare const MARKER_BRAND = "im-nobsidian";
2750
4123
 
2751
- export { BaseFileGenerator, type BaseFileOptions, type BasePropertyOption, type BasePropertySchema, type BaseStatusGroup, type BaseSyncOptions, type BasesFilterNode, type BasesViewMapping, type BasesViewType, BlockConverter, CONFIG_FILE, type CalendarEntry, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, type CompletenessFailure, type CompletenessLocalSource, type CompletenessOptions, type CompletenessRemoteSource, type CompletenessReport, type Config, ConfigManager, ConfigSchema, type Conflict, ConflictResolver, type ConflictStrategy, type ConversionContext, type ConversionPath, ConversionPipeline, type ConversionResult, type CoverConfig, type DBEntry, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, type DatabaseCompleteness, type DatabaseSyncConfig, type DatabaseSyncResult, DatabaseSyncer, type DatabaseViewsConfig, type DegradeNote, EmbedResolver, EntryEditor, type FailedOperation, type FidelityClassification, type FidelityContext, type FidelityDefect, type FidelityDefectForm, type FidelitySummary, type FileDownloadResult, FileHandler, type FileInfo, type FileRegistryEntry, type FileStatInfo, type FileSystemEntry, type FileType, type FileUploadResult, FileWatcher, type FilterCondition, FrontmatterExtractor, FrontmatterGenerator, GITIGNORE_ENTRY, type GroupByConfig, type GroupedEntries, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, type IStateDB, type ImageDownloadResult, ImageHandler, type ImageReference, InlineAnnotationPreserver, InlineDBParser, type LocalChange, LocalImageRestorer, type Logger, MARKER_BRAND, type MappingResult, type MappingType, MathNormalizer, MentionToWikilink, type MergeConflictRegion, type MergeResult, NOTION_TO_BASES_VIEW, NodeVaultFS, type NonMdFileInfo, type NotionBlock, NotionBlockBuilder, NotionClient, type NotionClientOptions, type NotionColor, type NotionPageNode, type NotionRichText, type RichTextItem as NotionRichTextItem, type NotionSidecar, type OperationType, type PageCompletenessRemoteSource, type PageCompletenessReport, type PageCover, type PageCreationPlan, type PageIcon, type PathFilterConfig, type PendingOperation, type PreserveMarker, PreserveMarkerCollector, PreserveMarkerInjector, type Processor, type ProcessorInput, type ProcessorMetadata, type ProcessorOutput, type ProgressCallback, type ProgressItem, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, type PropertyOption, type PropertySchema, type PropertyValue, type PullOptions, type PullResult, type PushOptions, type PushResult, RENDER_LINE_RULES, type RecordPendingInput, type RegisterFileInput, type RemoteChange, type RenderFinding, type RenderLineRule, type ResolutionChoice, type ResolutionResult, type RichTextSegment, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_PATH, SidecarGenerator, type SidecarProperty, type SidecarView, StateDB, type StatusResult, type SyncDirection, type SyncOptions, SyncOrchestrator, type SyncRecord, type SyncResult, type SyncStatus, type TranslationResult, TreeMapper, UnsupportedBlockStripper, type UpsertSyncRecord, type VaultCompletenessReport, type VaultFS, type ViewConfig, ViewDataProvider, type ViewPropertyConfig, type ViewRenderData, type ViewType, type WatchSyncOptions, WatchSyncService, type WikilinkEntry, WikilinkResolver, basesViewMappingOf, basesViewTypeOf, classifyBodyFidelity, compactNotionId, computeBufferHash, computeHash, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, inAnyPathScope, isInternalPath, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, notionIdsEqual, resolvePropertyName, sanitizeFileName, setLogger, sortEntries, summarizeFidelity, threeWayMerge, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness };
4124
+ /**
4125
+ * 디자인 마커 읽기 — 볼트 노트 한 줄에 실린 마커가 «무엇을 어디에» 칠하는지 (U-01).
4126
+ *
4127
+ * pull 은 Obsidian 에 문법이 없는 Notion 서식(글자색 · 밑줄 · 멘션 · 블록 색 · 콜아웃/토글 스타일 ·
4128
+ * 표 속성)을 `%%…%%` 마커로 싣는다(ADR-008 · F-01 · F-04). 마커는 Obsidian 주석이라 읽기 보기에서는
4129
+ * 지워지고, 편집 화면(Live Preview)에서는 글자로 보인다 — 서식은 어느 쪽에서도 보이지 않는다.
4130
+ * 플러그인 렌더러가 이 결과로 칠한다. 마커 모양의 주인이 이 패키지(`constants/markers.ts`)라 읽기도
4131
+ * 여기 둔다 — 모양을 바꾸면 이 파일과 그 시험이 같이 깨진다.
4132
+ */
4133
+ /** Notion 색 하나 — `red` · `red_bg` · `red_background` 를 같은 값으로 읽는다. */
4134
+ interface DesignColor {
4135
+ readonly name: Exclude<NotionColor, "default">;
4136
+ readonly background: boolean;
4137
+ }
4138
+ /** 글 구간에 걸리는 서식. */
4139
+ type InlineStyle = {
4140
+ readonly kind: "color";
4141
+ readonly color: DesignColor;
4142
+ } | {
4143
+ readonly kind: "underline";
4144
+ } | {
4145
+ readonly kind: "mention";
4146
+ };
4147
+ /** 줄 안의 글자 위치 `[from, to)`. */
4148
+ interface LineRange {
4149
+ readonly from: number;
4150
+ readonly to: number;
4151
+ }
4152
+ interface StyledRange extends LineRange {
4153
+ readonly style: InlineStyle;
4154
+ }
4155
+ /** 마커를 걷어 낸 글 조각과 그 조각에 걸린 서식 — 마커가 없는 글도 조각이 된다(서식 없음). */
4156
+ interface DesignSegment extends LineRange {
4157
+ readonly text: string;
4158
+ readonly styles: readonly InlineStyle[];
4159
+ }
4160
+ /**
4161
+ * 콜아웃 · 토글 머리 줄의 스타일. `icon` 이 null 이면 Notion 에서 아이콘이 없는 콜아웃이다
4162
+ * (마커에 icon 이 없다). 토글은 아이콘을 싣지 않는다(undefined).
4163
+ */
4164
+ interface CalloutDesign {
4165
+ readonly color?: DesignColor;
4166
+ readonly icon?: string | null;
4167
+ }
4168
+ /** 표 한 줄(행)의 속성 — 셀 번호는 0 부터, 파이프 사이 칸 차례다. */
4169
+ interface TableLineDesign {
4170
+ readonly headerRow?: boolean;
4171
+ readonly headerColumn?: boolean;
4172
+ readonly rowColor?: DesignColor;
4173
+ /** 셀 번호 → 셀 색. */
4174
+ readonly cellColors: ReadonlyMap<number, DesignColor>;
4175
+ /** 셀 번호 → 열 색(머리 행의 `table-col` 마커가 그 열 전체에 건다). */
4176
+ readonly columnColors: ReadonlyMap<number, DesignColor>;
4177
+ }
4178
+ interface DesignLine {
4179
+ /** 줄에 있는 우리 마커 전부 — 편집 화면이 숨긴다. 짝이 맞은 닫는 토큰(`%%/color%%` 등)도 든다. */
4180
+ readonly markers: readonly LineRange[];
4181
+ /** 짝 마커 사이의 글 — 편집 화면이 칠한다. */
4182
+ readonly styled: readonly StyledRange[];
4183
+ /** 마커를 걷어 낸 차례대로의 글 조각 — 읽기 보기가 그려진 글과 맞춰 칠한다. */
4184
+ readonly segments: readonly DesignSegment[];
4185
+ readonly blockColor?: DesignColor;
4186
+ /** 콜아웃 · 토글 머리 줄의 스타일. */
4187
+ readonly callout?: CalloutDesign;
4188
+ readonly table?: TableLineDesign;
4189
+ }
4190
+ /** 이 글에 우리 마커가 하나라도 있나 — 렌더러가 마커 없는 블록을 건너뛰는 데 쓴다. */
4191
+ declare function hasDesignMarker(text: string): boolean;
4192
+ /** Notion 색 토큰을 읽는다. `default` · 모르는 색은 칠할 것이 없다(undefined). */
4193
+ declare function parseDesignColor(token: string | undefined): DesignColor | undefined;
4194
+ /** 한 줄을 읽는다. 마커가 없는 줄은 글 조각 하나뿐인 결과를 돌려준다. */
4195
+ declare function readDesignLine(line: string): DesignLine;
4196
+
4197
+ /**
4198
+ * Notion 컬럼이 볼트 노트의 어느 줄에 걸치나 (#74).
4199
+ *
4200
+ * pull 은 컬럼을 위아래로 펼치고 경계를 마커 줄로 남긴다(ADR-008) — `column-list:start` · 열마다
4201
+ * `column[:ratio=N]` · `column-list:end`. Obsidian 은 마크다운을 옆으로 나란히 그리지 못하므로, 플러그인은
4202
+ * 이 결과로 열의 경계를 보이게 칠한다. 마커 모양의 주인이 이 패키지라 읽기도 여기 둔다.
4203
+ *
4204
+ * 컨테이너(콜아웃 · 토글) 안의 컬럼은 pull 이 마커를 지우고 펼친다 — 마커 줄은 맨 앞 들여쓰기만 받는다.
4205
+ */
4206
+ /** 컬럼 마커가 있을 수 있는가 — 없으면 줄을 나누어 읽지 않아도 된다. */
4207
+ declare function hasColumnLayout(text: string): boolean;
4208
+ /** 컬럼 안의 줄 하나가 놓인 자리. */
4209
+ interface ColumnPlace {
4210
+ /** 노트 안에서 몇 번째 컬럼 묶음인가 — 0 부터. */
4211
+ readonly group: number;
4212
+ /** 묶음 안에서 몇 번째 열인가 — 0 부터. */
4213
+ readonly index: number;
4214
+ /** 묶음의 열 수. */
4215
+ readonly count: number;
4216
+ /** 열 너비(%) — Notion 이 비율을 주지 않았으면 undefined. */
4217
+ readonly ratio?: number;
4218
+ /** 이 열의 첫 줄인가 — 열 구분 마커 줄이다. */
4219
+ readonly head: boolean;
4220
+ }
4221
+ /**
4222
+ * 줄마다 놓인 컬럼 자리 — 컬럼 밖 줄 · 묶음의 시작/끝 마커 줄은 undefined. 닫히지 않은 묶음은 노트 끝까지다.
4223
+ * 코드 블록 안의 마커 모양 글은 마커가 아니다.
4224
+ */
4225
+ declare function readColumnLayout(lines: readonly string[]): (ColumnPlace | undefined)[];
4226
+ /** 열 이름표 — `열 2/3 · 50%`. 비율이 없으면 `열 2/3`. */
4227
+ declare function columnLabel(place: ColumnPlace): string;
4228
+
4229
+ export { BaseFileGenerator, type BaseFileOptions, type BasePropertyOption, type BasePropertySchema, type BaseStatusGroup, type BaseSyncOptions, type BasesFilterNode, type BasesViewMapping, type BasesViewType, BlankLineCollapser, type BookmarkTarget, CONFIG_FILE, type CalendarEntry, type CalloutDesign, CalloutRestorer, CalloutTransformer, ChangeDetector, type ChangeDiff, ColorAnnotator, type ColumnPlace, type CompletenessFailure, type CompletenessLocalSource, type CompletenessOptions, type CompletenessRemoteSource, type CompletenessReport, type Config, ConfigManager, ConfigSchema, type Conflict, ConflictResolver, type ConflictStrategy, ConnectionBoundaryError, ConnectionScopeError, type ConversionContext, type ConversionPath, ConversionPipeline, type ConversionResult, type CoverConfig, type DBEntry, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DEFAULT_TITLE_PROPERTY, DEFAULT_WATCH_DEBOUNCE_MS, DIFF_SIGN, type DatabaseCompleteness, type DatabaseSyncConfig, type DatabaseSyncResult, DatabaseSyncer, type DatabaseViewsConfig, type DeferredCode, type DegradeNote, type DesignColor, type DesignLine, type DesignSegment, type DiffHunk, type DiffLine, EmbedResolver, EntryEditor, type FailedOperation, type FidelityClassification, type FidelityContext, type FidelityDefect, type FidelityDefectForm, type FidelitySummary, type FileDownloadResult, FileHandler, type FileInfo, type FileRegistryEntry, type FileStatInfo, type FileSystemEntry, type FileType, type FileUploadResult, FileWatcher, type FilterCondition, type FolderDatabaseReport, type FolderMoveChange, FrontmatterExtractor, FrontmatterGenerator, type FullScanReason, GITIGNORE_ENTRY, type GatedOperation, type GroupByConfig, type GroupedEntries, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, type IStateDB, type ImageDownloadResult, ImageHandler, type ImageReference, type InferredProperty, InlineAnnotationPreserver, InlineDBParser, type InlineStyle, type LineDiffOptions, type LineRange, type LocalChange, LocalImageRestorer, type LocalMoveAdoption, type LocalScan, type LocalScanOptions, type Logger, MARKER_BRAND, type MappingResult, type MappingType, MathNormalizer, MentionToWikilink, type MergeConflictRegion, type MergeResult, NOTION_COLOR_NAMES, NOTION_TOKEN_PREFIX, NOTION_TO_BASES_VIEW, NodeVaultFS, type NonMdFileInfo, type NotionBlock, NotionBlockBuilder, NotionClient, type NotionClientOptions, type NotionColor, type NotionPageNode, type NotionRichText, type RichTextItem as NotionRichTextItem, type NotionSidecar, OBSIDIAN_CONFIG_DIR, type OperationType, type PageCompletenessRemoteSource, type PageCompletenessReport, type PageCover, type PageCreationPlan, type PageIcon, type PathFilterConfig, type PendingOperation, PendingWalError, type PlannedRow, type PreserveMarker, PreserveMarkerCollector, PreserveMarkerInjector, type Processor, type ProcessorInput, type ProcessorMetadata, type ProcessorOutput, type ProgressCallback, type ProgressItem, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, type PropertyOption, type PropertySchema, type PropertyValue, type PullOptions, type PullResult, type PushOptions, type PushResult, RENDER_LINE_RULES, type RecordPendingInput, type RegisterFileInput, type RemoteChange, type RemoteObservation, type RemoteScanInfo, type RenameHints, type RenameKind, type RenderFinding, type RenderLineRule, type ResolutionChoice, type ResolutionResult, type RichTextSegment, type RowProgress, SAVED_STATE_TABLES_QUERY, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_MIGRATIONS, STATE_DB_PATH, SavedStateDbError, ScopedVaultFS, SidecarGenerator, type SidecarProperty, type SidecarView, StateDB, StateDbLockedError, type StateDbMigration, type StateDbTool, StateDbUnavailableError, StateLock, type StateLockClaim, type StateLockOwner, type StatusOptions, type StatusResult, type StyledRange, SyncBusyError, type SyncDirection, type SyncOptions, SyncOrchestrator, type SyncRecord, type SyncResult, type SyncStatus, type TableLineDesign, type TranslationResult, TreeMapper, UnsupportedBlockStripper, type UpsertSyncRecord, type VaultCompletenessReport, type VaultFS, type ViewConfig, ViewDataProvider, type ViewPropertyConfig, type ViewRenderData, type ViewType, WIKILINK_LOOKUPS, type WatchSyncOptions, type WatchSyncScope, WatchSyncService, type WikilinkEntry, type WikilinkLookup, WikilinkResolver, applicableChoices, assertNoPendingWal, basesViewMappingOf, basesViewTypeOf, choiceForStrategy, classifyBodyFidelity, columnLabel, compactNotionId, computeBufferHash, computeHash, configDirFrom, connectionFolderOf, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, findConnectionFolders, formatUnifiedDiff, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, hasColumnLayout, hasDesignMarker, inAnyPathScope, isInternalPath, isMarkdownPath, isRemoteDeletion, lineDiff, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, normalizeVaultPath, noteNameFromPath, notionIdFromInput, notionIdsEqual, operationLabel, parseDesignColor, pendingWalBytes, readColumnLayout, readDesignLine, resolvePropertyName, sanitizeFileName, setLogger, sortEntries, stateDbLockPath, stateDbWalPath, stripMarkdownExtension, summarizeFidelity, systemTimeZone, threeWayMerge, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness, wikilinkLookupsIn };