@im-nobsidian/core 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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,20 +85,23 @@ 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>;
86
91
  }, "strip", z.ZodTypeAny, {
87
- direction: "push" | "pull" | "both";
88
- conflictStrategy: "local-first" | "remote-first" | "manual" | "duplicate";
92
+ direction: "push" | "both" | "pull";
93
+ conflictStrategy: "manual" | "local-first" | "remote-first" | "duplicate";
89
94
  autoSync: boolean;
90
95
  autoSyncInterval: number;
96
+ fullReconcileInterval: number;
91
97
  deleteSync: boolean;
92
98
  syncFiles: boolean;
93
99
  }, {
94
- direction?: "push" | "pull" | "both" | undefined;
95
- conflictStrategy?: "local-first" | "remote-first" | "manual" | "duplicate" | undefined;
100
+ direction?: "push" | "both" | "pull" | undefined;
101
+ conflictStrategy?: "manual" | "local-first" | "remote-first" | "duplicate" | undefined;
96
102
  autoSync?: boolean | undefined;
97
103
  autoSyncInterval?: number | undefined;
104
+ fullReconcileInterval?: number | undefined;
98
105
  deleteSync?: boolean | undefined;
99
106
  syncFiles?: boolean | undefined;
100
107
  }>;
@@ -103,25 +110,28 @@ declare const ConfigSchema: z.ZodObject<{
103
110
  exclude: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
104
111
  attachments: z.ZodDefault<z.ZodString>;
105
112
  }, "strip", z.ZodTypeAny, {
113
+ attachments: string;
106
114
  include: string[];
107
115
  exclude: string[];
108
- attachments: string;
109
116
  }, {
117
+ attachments?: string | undefined;
110
118
  include?: string[] | undefined;
111
119
  exclude?: string[] | undefined;
112
- attachments?: string | undefined;
113
120
  }>;
114
121
  conversion: z.ZodObject<{
115
122
  preferMarkdownApi: z.ZodDefault<z.ZodBoolean>;
116
123
  preserveMarkers: z.ZodDefault<z.ZodBoolean>;
117
124
  frontmatterMapping: z.ZodDefault<z.ZodBoolean>;
118
125
  imageDownload: z.ZodDefault<z.ZodEnum<["immediate", "lazy", "skip"]>>;
126
+ timeZone: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
119
127
  }, "strip", z.ZodTypeAny, {
120
128
  preferMarkdownApi: boolean;
121
129
  preserveMarkers: boolean;
122
130
  frontmatterMapping: boolean;
123
131
  imageDownload: "immediate" | "lazy" | "skip";
132
+ timeZone?: string | undefined;
124
133
  }, {
134
+ timeZone?: string | undefined;
125
135
  preferMarkdownApi?: boolean | undefined;
126
136
  preserveMarkers?: boolean | undefined;
127
137
  frontmatterMapping?: boolean | undefined;
@@ -144,6 +154,7 @@ declare const ConfigSchema: z.ZodObject<{
144
154
  maxFileSizeBytes: z.ZodDefault<z.ZodNumber>;
145
155
  mediaDownloadTimeoutMs: z.ZodDefault<z.ZodNumber>;
146
156
  itemTimeoutMs: z.ZodDefault<z.ZodNumber>;
157
+ markdownCompletionMaxBlocks: z.ZodDefault<z.ZodNumber>;
147
158
  }, "strip", z.ZodTypeAny, {
148
159
  concurrency: number;
149
160
  timeoutMs: number;
@@ -161,6 +172,7 @@ declare const ConfigSchema: z.ZodObject<{
161
172
  maxFileSizeBytes: number;
162
173
  mediaDownloadTimeoutMs: number;
163
174
  itemTimeoutMs: number;
175
+ markdownCompletionMaxBlocks: number;
164
176
  }, {
165
177
  concurrency?: number | undefined;
166
178
  timeoutMs?: number | undefined;
@@ -178,6 +190,7 @@ declare const ConfigSchema: z.ZodObject<{
178
190
  maxFileSizeBytes?: number | undefined;
179
191
  mediaDownloadTimeoutMs?: number | undefined;
180
192
  itemTimeoutMs?: number | undefined;
193
+ markdownCompletionMaxBlocks?: number | undefined;
181
194
  }>;
182
195
  }, "strip", z.ZodTypeAny, {
183
196
  version: 1;
@@ -196,23 +209,25 @@ declare const ConfigSchema: z.ZodObject<{
196
209
  workspaceId?: string | undefined;
197
210
  };
198
211
  sync: {
199
- direction: "push" | "pull" | "both";
200
- conflictStrategy: "local-first" | "remote-first" | "manual" | "duplicate";
212
+ direction: "push" | "both" | "pull";
213
+ conflictStrategy: "manual" | "local-first" | "remote-first" | "duplicate";
201
214
  autoSync: boolean;
202
215
  autoSyncInterval: number;
216
+ fullReconcileInterval: number;
203
217
  deleteSync: boolean;
204
218
  syncFiles: boolean;
205
219
  };
206
220
  paths: {
221
+ attachments: string;
207
222
  include: string[];
208
223
  exclude: string[];
209
- attachments: string;
210
224
  };
211
225
  conversion: {
212
226
  preferMarkdownApi: boolean;
213
227
  preserveMarkers: boolean;
214
228
  frontmatterMapping: boolean;
215
229
  imageDownload: "immediate" | "lazy" | "skip";
230
+ timeZone?: string | undefined;
216
231
  };
217
232
  advanced: {
218
233
  concurrency: number;
@@ -231,6 +246,7 @@ declare const ConfigSchema: z.ZodObject<{
231
246
  maxFileSizeBytes: number;
232
247
  mediaDownloadTimeoutMs: number;
233
248
  itemTimeoutMs: number;
249
+ markdownCompletionMaxBlocks: number;
234
250
  };
235
251
  }, {
236
252
  version: 1;
@@ -249,19 +265,21 @@ declare const ConfigSchema: z.ZodObject<{
249
265
  }[] | undefined;
250
266
  };
251
267
  sync: {
252
- direction?: "push" | "pull" | "both" | undefined;
253
- conflictStrategy?: "local-first" | "remote-first" | "manual" | "duplicate" | undefined;
268
+ direction?: "push" | "both" | "pull" | undefined;
269
+ conflictStrategy?: "manual" | "local-first" | "remote-first" | "duplicate" | undefined;
254
270
  autoSync?: boolean | undefined;
255
271
  autoSyncInterval?: number | undefined;
272
+ fullReconcileInterval?: number | undefined;
256
273
  deleteSync?: boolean | undefined;
257
274
  syncFiles?: boolean | undefined;
258
275
  };
259
276
  paths: {
277
+ attachments?: string | undefined;
260
278
  include?: string[] | undefined;
261
279
  exclude?: string[] | undefined;
262
- attachments?: string | undefined;
263
280
  };
264
281
  conversion: {
282
+ timeZone?: string | undefined;
265
283
  preferMarkdownApi?: boolean | undefined;
266
284
  preserveMarkers?: boolean | undefined;
267
285
  frontmatterMapping?: boolean | undefined;
@@ -284,6 +302,7 @@ declare const ConfigSchema: z.ZodObject<{
284
302
  maxFileSizeBytes?: number | undefined;
285
303
  mediaDownloadTimeoutMs?: number | undefined;
286
304
  itemTimeoutMs?: number | undefined;
305
+ markdownCompletionMaxBlocks?: number | undefined;
287
306
  };
288
307
  }>;
289
308
  type Config = z.infer<typeof ConfigSchema>;
@@ -301,6 +320,12 @@ interface SyncRecord {
301
320
  readonly notionParentId: string | null;
302
321
  readonly contentHash: string;
303
322
  readonly notionLastEdited: string | null;
323
+ /** {@link RemoteObservation.lastEditedBy} — 모르면 null. */
324
+ readonly notionLastEditedBy: string | null;
325
+ /** {@link RemoteObservation.seenAt} — 모르면 null. */
326
+ readonly notionSeenAt: string | null;
327
+ /** 지난 동기화 사본에 해당하는 원격 본문의 지문(`remoteBodyFingerprint`). 모르면 null. */
328
+ readonly notionBodyFingerprint: string | null;
304
329
  readonly localLastModified: string;
305
330
  readonly syncDirection: SyncDirection;
306
331
  readonly fileType: FileType;
@@ -312,6 +337,23 @@ interface SyncRecord {
312
337
  readonly createdAt: string;
313
338
  readonly updatedAt: string;
314
339
  }
340
+ /**
341
+ * 원격 페이지를 본 기록(N-05). Notion 의 수정 시각은 분 단위로 잘려, 시각만으로는 우리가 본 뒤
342
+ * 같은 분 안에서 고친 것을 알 수 없다 — 누가 고쳤는지와 언제 봤는지를 함께 적는다.
343
+ */
344
+ interface RemoteObservation {
345
+ /** Notion `last_edited_time` — 분 단위로 잘린 값. */
346
+ readonly lastEdited: string;
347
+ /** Notion `last_edited_by.id`. 응답에 없으면 null. */
348
+ readonly lastEditedBy: string | null;
349
+ /**
350
+ * 이 기기 시계로 본 때 — 그 실행을 시작한 때(실제로 읽은 때보다 이르다). 이때까지의 원격
351
+ * 편집은 레코드에 들어 있다. 모르면 null — 다음에 내용으로 확인한다.
352
+ */
353
+ readonly seenAt: string | null;
354
+ /** 원격 본문 지문. 적지 않으면(undefined) 있던 값을 둔다 — 본문을 건드리지 않은 관측. */
355
+ readonly bodyFingerprint?: string | null;
356
+ }
315
357
  interface LocalChange {
316
358
  readonly path: string;
317
359
  readonly type: "created" | "modified" | "deleted" | "moved";
@@ -319,12 +361,34 @@ interface LocalChange {
319
361
  readonly previousHash: string | null;
320
362
  readonly movedFrom?: string;
321
363
  }
364
+ /**
365
+ * DB 행 frontmatter 의 속성 차이 — 지난 동기화 시점과 비교해 바뀐 것만(S-01).
366
+ * `title` 은 속성이 아니라 행 제목이라 여기 들어가지 않는다.
367
+ */
368
+ interface RowPropertyChanges {
369
+ /** 새로 생겼거나 값이 바뀐 속성과 그 새 값. 날짜는 적힌 모양의 문자열로 되돌려 둔다. */
370
+ readonly changed: Readonly<Record<string, unknown>>;
371
+ /** 로컬에서 비웠거나 지운 속성 이름. */
372
+ readonly cleared: readonly string[];
373
+ }
322
374
  interface RemoteChange {
323
375
  readonly pageId: string;
324
376
  readonly type: "created" | "modified" | "deleted" | "moved";
325
377
  readonly lastEdited: string;
326
378
  readonly previousEdited: string | null;
327
379
  readonly movedFromParent?: string;
380
+ /**
381
+ * 추적 중인 노트의 볼트 경로. 화면이 내부 id 대신 노트 이름을 보이고, 그 노트만 받을 때 쓴다.
382
+ * 아직 볼트에 없는 새 페이지는 없다.
383
+ */
384
+ readonly path?: string;
385
+ /** Notion 제목 — 볼트 경로가 아직 없는 새 페이지를 화면이 이름으로 보일 때 쓴다. */
386
+ readonly title?: string;
387
+ /**
388
+ * 수정 시각 · 편집자로는 바뀌었는지 가를 수 없다 — 같은 분 안의 편집일 수 있다(N-05).
389
+ * 받는 쪽이 내용으로 확인한다. `modified` 에만 붙는다.
390
+ */
391
+ readonly unverified?: boolean;
328
392
  }
329
393
  interface Conflict {
330
394
  readonly syncRecord: SyncRecord;
@@ -336,7 +400,16 @@ interface Conflict {
336
400
  }
337
401
  interface ProgressItem {
338
402
  readonly path: string;
339
- readonly operation: "create" | "update" | "delete";
403
+ /** `move` — 볼트에서 옮기거나 이름을 바꾼 노트 · 폴더를 Notion 에 반영한다(push). */
404
+ readonly operation: "create" | "update" | "delete" | "move";
405
+ /** 받기(pull) · 올리기(push) 중 어느 쪽 항목인가 — `sync` 만 채운다. 둘을 한 번에 돌려 가를 수 없다. */
406
+ readonly direction?: "pull" | "push";
407
+ /**
408
+ * 받아 봤지만 볼트에 쓰지 않았다(pull) — 바뀐 것이 없거나 로컬을 지켰다. 진행 수에는 들지만 화면이 바뀐
409
+ * 노트로 적지 않는다. 다시 그리기(B14)는
410
+ * 받아 둔 노트를 모두 받아 보므로, 적으면 바뀌지 않은 노트마다 「updated」 줄이 찍혔다.
411
+ */
412
+ readonly unchanged?: boolean;
340
413
  }
341
414
  type ProgressCallback = (current: number, total: number, item: ProgressItem) => void;
342
415
  /** push/pull/sync 가 공통으로 받는 동기화 옵션. */
@@ -347,18 +420,66 @@ interface BaseSyncOptions {
347
420
  readonly onProgress?: ProgressCallback;
348
421
  readonly signal?: AbortSignal;
349
422
  }
423
+ /** 원격까지 보는 상태 확인(`status`)의 옵션. */
424
+ interface StatusOptions {
425
+ /** 취소하면 원격을 훑다 멈추고 {@link OperationAbortedError} 를 던진다 — 반쯤 본 상태를 돌려주지 않는다. */
426
+ readonly signal?: AbortSignal;
427
+ }
350
428
  interface PushOptions extends BaseSyncOptions {
351
429
  readonly excludePaths?: string[];
352
430
  }
353
- type PullOptions = BaseSyncOptions;
431
+ interface PullOptions extends BaseSyncOptions {
432
+ /**
433
+ * Notion 에서 바뀌지 않은 받아 둔 노트도 지금 변환기로 다시 그린다(B14, `sync/rerender-targets.ts`).
434
+ * 볼트에서 고친 노트는 건드리지 않는다. dry-run 에서는 쓰지 않는다 — 다시 그려 봐야 바뀌는지 안다.
435
+ */
436
+ readonly rerender?: boolean;
437
+ }
354
438
  type SyncOptions = BaseSyncOptions;
355
439
  interface PushResult {
356
440
  readonly created: number;
357
441
  readonly updated: number;
358
442
  readonly deleted: number;
443
+ /**
444
+ * Notion 에 옮김을 반영한 노트 · 폴더 수. 옮기며 본문도 고친 노트도 여기서만 센다 — updated 와
445
+ * 겹치지 않는다. 예전에는 updated 에 섞여, 이름만 바꾼 노트도 「수정」 으로 보였다.
446
+ */
447
+ readonly moved: number;
359
448
  readonly failed: FailedOperation[];
360
449
  readonly duration: number;
361
450
  }
451
+ /**
452
+ * 전체 대조를 한 이유 (ADR-027).
453
+ *
454
+ * - `first` — 받은 것이 없다(처음 pull · 빈 상태 DB).
455
+ * - `forced` — `--force`.
456
+ * - `database-mode` — 루트가 DB 하나라 그 DB 조회가 곧 전체 대조다.
457
+ * - `every-pull` — 주기(`sync.fullReconcileInterval`)가 0 이다.
458
+ * - `due` — 주기가 됐다.
459
+ */
460
+ type FullScanReason = "first" | "forced" | "database-mode" | "every-pull" | "due";
461
+ /**
462
+ * 이번 실행이 원격을 얼마나 훑었나 (ADR-027). `incremental` 은 지난 pull 뒤에 바뀐 페이지 · 행 · DB 만
463
+ * 찾았다는 뜻이다 — 원격에서 지운 것 · 범위 밖으로 옮긴 것은 다음 전체 대조가 반영한다. 화면은 이것으로
464
+ * 그 사실과 다음 전체 대조 시각을 알린다.
465
+ */
466
+ interface RemoteScanInfo {
467
+ readonly kind: "full" | "incremental";
468
+ /** 전체 대조한 이유 — `full` 에만. */
469
+ readonly reason?: FullScanReason;
470
+ /** 마지막으로 전체 대조를 마친 때. 한 번도 없으면 null. */
471
+ readonly lastFullAt: string | null;
472
+ /** 다음 전체 대조 예정 시각 — `incremental` 에만. null 이면 이미 때가 됐다(다음 pull 이 한다). */
473
+ readonly nextFullAt: string | null;
474
+ /**
475
+ * Notion 에서 지운 노트가 아직 볼트에 반영되지 않았을 수 있나 — 바뀐 것만 찾았고 원격 삭제를 볼트에
476
+ * 반영하는 설정(`sync.deleteSync`)일 때. 다음 전체 대조가 반영한다. 설정이 꺼져 있으면 원격 삭제는
477
+ * 볼트에 반영하지 않으므로 늘 false 다.
478
+ */
479
+ readonly deletionsDeferred: boolean;
480
+ /** 바뀐 것이 보이지 않아 조회하지 않은 DB 수 — pull 에만. */
481
+ readonly skippedDatabases?: number;
482
+ }
362
483
  interface PullResult {
363
484
  readonly created: number;
364
485
  readonly updated: number;
@@ -376,6 +497,8 @@ interface PullResult {
376
497
  readonly imageCount: number;
377
498
  readonly fileCount: number;
378
499
  readonly linkCount: number;
500
+ /** 원격을 얼마나 훑었나. 원격을 보지 않은 실행(받기 방향이 아님 · 취소)에는 없다. */
501
+ readonly remoteScan?: RemoteScanInfo;
379
502
  }
380
503
  interface SyncResult {
381
504
  readonly push: PushResult;
@@ -383,13 +506,42 @@ interface SyncResult {
383
506
  readonly conflicts: Conflict[];
384
507
  readonly duration: number;
385
508
  }
509
+ /** 볼트에서 옮기거나 이름을 바꾼 폴더 — 다음 push 가 Notion 에 반영한다(DB 폴더는 볼트 쪽만). */
510
+ interface FolderMoveChange {
511
+ readonly from: string;
512
+ readonly to: string;
513
+ }
514
+ /**
515
+ * 변경 하나를 줄 비교로 보이는 두 글 — `SyncOrchestrator.localChangeDiff` · `remoteChangeDiff` 가 내고,
516
+ * `lineDiff` 로 견준다.
517
+ */
518
+ interface ChangeDiff {
519
+ /** 볼트 경로 — 옮긴 노트는 새 자리. */
520
+ readonly path: string;
521
+ readonly type: LocalChange["type"];
522
+ /** 옮긴 노트의 옛 자리. */
523
+ readonly movedFrom?: string;
524
+ /** 지난 동기화 때의 글. 새 노트는 null — 견줄 옛 글이 없다. */
525
+ readonly before: string | null;
526
+ /** 지금 글 — 로컬 변경은 볼트의 글, 원격 변경은 Notion 의 글. 지운 노트 · 페이지는 null. */
527
+ readonly after: string | null;
528
+ }
386
529
  interface StatusResult {
387
530
  readonly localChanges: LocalChange[];
531
+ /** 옮긴 폴더. 그 안의 노트는 `localChanges` 에 `moved` 로 따로 있다. */
532
+ readonly folderMoves: readonly FolderMoveChange[];
388
533
  readonly remoteChanges: RemoteChange[];
389
534
  readonly conflicts: Conflict[];
390
535
  readonly conflictRecords: SyncRecord[];
391
536
  readonly pendingOperations: number;
392
537
  readonly lastSyncAt: string | null;
538
+ /**
539
+ * 마지막으로 원격을 전체 대조한 pull 이 시작한 때. 한 번도 없으면 null. Notion 에서 지운 노트는 전체
540
+ * 대조 때 볼트에 반영된다 — 화면은 이것으로 그때를 알린다 (ADR-027).
541
+ */
542
+ readonly lastFullScanAt: string | null;
543
+ /** 원격을 얼마나 훑었나. 로컬만 본 확인(`statusLocal`)에는 없다. */
544
+ readonly remoteScan?: RemoteScanInfo;
393
545
  }
394
546
  interface FailedOperation {
395
547
  readonly path: string;
@@ -397,6 +549,17 @@ interface FailedOperation {
397
549
  readonly error: string;
398
550
  }
399
551
 
552
+ /** Notion 이 돌려준 본문에 실린 자식 하나. */
553
+ interface ChildTag {
554
+ readonly kind: "page" | "database";
555
+ /** 하이픈 없는 32자리 hex. */
556
+ readonly id: string;
557
+ /** 태그 안의 제목 그대로(굵게 `**` 가 섞여 있을 수 있다). */
558
+ readonly title: string;
559
+ /** 태그 원문 — 복원은 받은 그대로 다시 싣는다(실측: 받은 태그를 보내면 자식이 그대로 남는다). */
560
+ readonly tag: string;
561
+ }
562
+
400
563
  type ConversionPath = "markdown-api" | "block-api";
401
564
  interface ProcessorInput {
402
565
  readonly content: string;
@@ -418,6 +581,23 @@ interface ProcessorMetadata {
418
581
  * true: 무조건 재간격 / false: 무동작 / 미지정: 내용 기반 휴리스틱 폴백.
419
582
  */
420
583
  readonly notionExportCompact?: boolean;
584
+ /**
585
+ * pull 직전 로컬 노트 원문 — 있을 때만. Notion 에 남길 자리가 없는 표기(코드 펜스의 원래 언어 ·
586
+ * 펜스 기호 S-20, 주석 S-29)를 되살리는 근거다. 호출측이 로컬 파일을 읽어 넘긴다.
587
+ */
588
+ readonly localContent?: string;
589
+ /**
590
+ * 행 노트에서 Notion 이 정하는 frontmatter 키 — 행을 받을 때만. 받은 속성을 로컬 frontmatter 에 합칠 때
591
+ * 이 밖의 키(`aliases` 등)는 로컬 것으로 두고, 키 차례 · 적은 모양도 로컬을 따른다(F-08).
592
+ */
593
+ readonly notionKeys?: readonly string[];
594
+ /**
595
+ * 받은 본문(원시 글)에 실린 자식 페이지 · 자식 DB — 페이지를 받을 때만. push 는 가리키는 줄이 없는 자식을
596
+ * 본문 끝에 되돌려 놓으므로(`restoreChildTags`), 로컬 표기를 되살릴 때 보낼 꼴을 그와 같이 만든다.
597
+ */
598
+ readonly remoteChildren?: readonly ChildTag[];
599
+ /** Markdown API 로 보낼 수 없어 본문을 쓴 뒤 블록으로 채울 코드({@link DeferredCode}). */
600
+ readonly deferredCode?: DeferredCode[];
421
601
  readonly [key: string]: unknown;
422
602
  }
423
603
  interface ConversionContext {
@@ -436,6 +616,27 @@ interface ConversionResult {
436
616
  readonly properties: Record<string, unknown>;
437
617
  readonly images: ImageReference[];
438
618
  readonly preserveMarkers: PreserveMarker[];
619
+ readonly deferredCode: DeferredCode[];
620
+ }
621
+ /**
622
+ * 본문을 쓴 뒤 블록으로 채울 코드(S-22). Notion Markdown API 는 코드에 ``` 로 시작하는 줄이 있으면
623
+ * 어떤 펜스로 보내도 그 줄에서 블록을 가른다 — 코드 자리에 자리표시(`token`)만 보내고, 페이지를
624
+ * 쓴 뒤 그 자리표시를 가진 코드 블록의 글을 `code` 로 바꾼다.
625
+ */
626
+ interface DeferredCode {
627
+ /** 코드 블록에 대신 보낸 자리표시 — 페이지 안에서 하나뿐이다. */
628
+ readonly token: string;
629
+ /** Obsidian 이 보여 주는 그대로의 코드 — 컨테이너 접두 · 펜스 들여쓰기를 뗀 것. */
630
+ readonly code: string;
631
+ }
632
+ /**
633
+ * 북마크 블록의 주소와 캡션(F-09). Markdown API 는 북마크를 주소 · 캡션 없는 자리 태그로만 보내,
634
+ * 블록으로 따로 읽는다(`NotionClient.getBookmarks`).
635
+ */
636
+ interface BookmarkTarget {
637
+ readonly url: string;
638
+ /** 캡션의 글 — 없으면 빈 문자열. */
639
+ readonly caption: string;
439
640
  }
440
641
  interface PreserveMarker {
441
642
  readonly type: string;
@@ -562,7 +763,8 @@ declare class ConfigManager {
562
763
  }
563
764
 
564
765
  interface IStateDB {
565
- close(): void;
766
+ /** 닫는다 — 파일에 남은 쓰기를 마쳐야 하는 구현(플러그인의 sql.js)은 그 일을 Promise 로 돌려준다. */
767
+ close(): void | Promise<void>;
566
768
  getByPath(path: string): SyncRecord | null;
567
769
  getByNotionId(pageId: string): SyncRecord | null;
568
770
  getByStatus(status: SyncStatus): SyncRecord[];
@@ -570,7 +772,10 @@ interface IStateDB {
570
772
  upsert(record: UpsertSyncRecord): SyncRecord;
571
773
  updateStatus(id: string, status: SyncStatus): void;
572
774
  updateHash(id: string, hash: string, snapshot?: Buffer | Uint8Array | null): void;
573
- setNotionLastEdited(id: string, lastEdited: string): void;
775
+ /** 원격을 본 기록을 적는다(N-05). 지문을 적지 않으면 있던 지문을 둔다. */
776
+ setRemoteObservation(id: string, observation: RemoteObservation): void;
777
+ /** 원격 본문 지문만 바꾼다 — 본문을 보냈지만 수정 시각은 올리지 않을 때. */
778
+ setNotionBodyFingerprint(id: string, fingerprint: string | null): void;
574
779
  updateStatCache(id: string, mtime: string, fileSize: number): void;
575
780
  setNotionParentId(id: string, parentId: string): void;
576
781
  /** 레코드의 로컬 경로를 갱신한다 (파일 rename/move 추적용). */
@@ -600,11 +805,19 @@ interface IStateDB {
600
805
  }
601
806
 
602
807
  declare class StateDB implements IStateDB {
808
+ private readonly lock;
603
809
  private readonly db;
604
810
  private constructor();
811
+ /**
812
+ * 상태 DB 파일을 연다 — 없으면 새로 만든다. 한 볼트의 상태 DB 는 한 곳만 연다 — 플러그인이나 다른 CLI 가 쓰는
813
+ * 중이면 {@link StateDbLockedError} 를 던진다(ADR-026). 저장된 파일이 비었거나 잘렸으면 빈 DB 로 열지 않고
814
+ * {@link SavedStateDbError} 를 던진다. 예전에는 0 · 1바이트 파일을 빈 DB 로 열고 닫을 때 빈 DB 로 덮었다.
815
+ */
605
816
  static open(dbPath: string): StateDB;
817
+ private static openLocked;
606
818
  private migrate;
607
819
  private getSchemaVersion;
820
+ /** 닫고 잠금을 푼다 — 닫는 사이 다른 곳이 열지 않게 닫은 뒤에 푼다. */
608
821
  close(): void;
609
822
  getByPath(path: string): SyncRecord | null;
610
823
  getByNotionId(pageId: string): SyncRecord | null;
@@ -613,7 +826,8 @@ declare class StateDB implements IStateDB {
613
826
  upsert(record: UpsertSyncRecord): SyncRecord;
614
827
  updateStatus(id: string, status: SyncStatus): void;
615
828
  updateHash(id: string, hash: string, snapshot?: Buffer | null): void;
616
- setNotionLastEdited(id: string, lastEdited: string): void;
829
+ setRemoteObservation(id: string, observation: RemoteObservation): void;
830
+ setNotionBodyFingerprint(id: string, fingerprint: string | null): void;
617
831
  updateStatCache(id: string, mtime: string, fileSize: number): void;
618
832
  setNotionParentId(id: string, parentId: string): void;
619
833
  updatePath(id: string, newPath: string): void;
@@ -649,6 +863,9 @@ interface UpsertSyncRecord {
649
863
  readonly notionParentId?: string | null;
650
864
  readonly contentHash: string;
651
865
  readonly notionLastEdited?: string | null;
866
+ readonly notionLastEditedBy?: string | null;
867
+ readonly notionSeenAt?: string | null;
868
+ readonly notionBodyFingerprint?: string | null;
652
869
  readonly localLastModified: string;
653
870
  readonly syncDirection: SyncRecord["syncDirection"];
654
871
  readonly fileType: SyncRecord["fileType"];
@@ -694,26 +911,339 @@ interface RecordPendingInput {
694
911
  readonly payload?: string | null;
695
912
  }
696
913
 
914
+ /** 스키마 하나 — 이 버전보다 낮은 DB 에 `sql` 을 돌린다. `sql` 은 끝에 `schema_version` 을 올린다. */
915
+ interface StateDbMigration {
916
+ readonly version: number;
917
+ readonly sql: string;
918
+ }
919
+ /**
920
+ * 상태 DB 스키마 — 차례대로. CLI(better-sqlite3)와 플러그인(sql.js)이 이 목록 하나를 돈다. 예전에는 플러그인이
921
+ * SQL 을 손으로 베껴, 처음 두 스키마의 CHECK 제약이 플러그인 DB 에만 없었다.
922
+ */
923
+ declare const STATE_DB_MIGRATIONS: readonly StateDbMigration[];
924
+
925
+ /**
926
+ * 상태 DB 를 지금 쓸 수 없다 — 사용자가 풀 수 있는 실패다. 파일이 깨졌거나({@link SavedStateDbError}), 다른 곳이
927
+ * 쓰는 중이거나({@link StateDbLockedError}), CLI 가 남긴 기록이 아직 WAL 파일에만 있다({@link PendingWalError}).
928
+ *
929
+ * 할 일(`guidance`)을 같이 싣는다. 엔진을 띄우지 못한 것 같은 다른 실패와 가른다 — 거기에 할 일을 붙이면 멀쩡한
930
+ * 기록을 치우게 된다. 화면(CLI · 플러그인)은 이 종류인지만 보고, 다시 해 보는 법만 넘긴다.
931
+ *
932
+ * @see ./saved-state-db-error.ts
933
+ * @see ./state-lock.ts
934
+ */
935
+ declare abstract class StateDbUnavailableError extends Error {
936
+ /** 할 일 — 화면마다 다시 해 보는 법(`retry`, 예: 「명령을 다시 실행하세요」)만 다르다. */
937
+ abstract guidance(retry: string): string;
938
+ }
939
+
940
+ /**
941
+ * 저장된 상태 DB 파일로 DB 를 열지 못했다 — 비었거나 잘렸거나 상태 DB 가 아닌 파일이다. 두 구현(CLI 의
942
+ * better-sqlite3 · 플러그인의 sql.js)이 같이 던진다. 엔진을 띄우지 못한 것 같은 다른 실패와 가른다 — 파일을
943
+ * 치우라는 안내({@link SavedStateDbError.guidance})는 이 경우에만 맞다.
944
+ */
945
+ declare class SavedStateDbError extends StateDbUnavailableError {
946
+ constructor(message: string);
947
+ /**
948
+ * 동기화 기록 표가 없다 — SQLite 는 비었거나(0바이트) 앞 한 바이트만 남은 파일을 «빈 DB» 로 연다. 보지 않으면
949
+ * 기록을 잃은 채 처음부터 시작하고, 다음 push 가 모든 노트의 페이지를 또 만든다.
950
+ */
951
+ static noTables(bytes: number): SavedStateDbError;
952
+ /** SQLite 가 파일을 열지 못했다 — 잘렸거나(malformed) 상태 DB 가 아니다(not a database). */
953
+ static unreadable(error: unknown): SavedStateDbError;
954
+ /**
955
+ * 파일을 사본으로 바꾸거나 치운다. 폴더 이름이 점으로 시작해 Obsidian 의 파일 탐색기에는 보이지 않으므로 볼트
956
+ * 폴더에서 찾으라고 적는다.
957
+ */
958
+ guidance(retry: string): string;
959
+ }
960
+ /**
961
+ * 저장된 파일에 동기화 기록 표가 있는지 묻는 조회 — 한 번이라도 쓴 파일에는 마이그레이션이 만든 표가 있다. 한
962
+ * 페이지 이상 잘린 파일은 SQLite 가 이 조회에서 던진다(malformed).
963
+ */
964
+ declare const SAVED_STATE_TABLES_QUERY = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sync_metadata'";
965
+
966
+ /**
967
+ * 위키링크 글자 → `wikilink_map` 행을 찾는 조회들 — 앞에서부터 처음 맞는 행을 쓴다.
968
+ *
969
+ * CLI 의 better-sqlite3(`StateDB`)와 플러그인의 sql.js(`SqlJsStateDB`)가 같은 차례 · 같은 SQL 을 쓰도록
970
+ * 여기 한 벌만 둔다. 예전에는 두 곳에 손으로 베껴 있었다.
971
+ *
972
+ * - 제목 → 별칭 → 파일 이름 차례. 파일 이름은 `[[노트]]` 와 `[[폴더/노트]]` 둘 다 받는다.
973
+ * 예전 조회(`LIKE '%/노트.md'`)는 슬래시가 앞에 있어야 맞아서 볼트 맨 위 노트와 경로 붙은 링크를 못 찾았다 —
974
+ * 제목이 파일 이름과 다른 노트(파일 이름에 못 쓰는 글자를 바꾼 노트)로 가는 링크가 Notion 에서 글로 남았다.
975
+ * - LIKE 의 `%` · `_` 는 글자로 견준다. 예전에는 `a_b` 가 `a-b.md` 에도 맞았다.
976
+ */
977
+ interface WikilinkLookup {
978
+ readonly sql: string;
979
+ readonly params: (text: string) => string[];
980
+ }
981
+ declare const WIKILINK_LOOKUPS: readonly WikilinkLookup[];
982
+
983
+ /** 상태 DB 파일 옆의 잠금 파일. */
984
+ declare function stateDbLockPath(dbPath: string): string;
985
+ /** 상태 DB 를 여는 도구. */
986
+ type StateDbTool = "cli" | "plugin";
987
+ /** 잠금을 잡는 쪽 — 화면에 보일 이름(`label`, 예: 플러그인 이름)은 있으면 적는다. */
988
+ interface StateLockClaim {
989
+ readonly tool: StateDbTool;
990
+ readonly label?: string;
991
+ }
992
+ /** 잠금 파일에 적는 주인. */
993
+ interface StateLockOwner extends StateLockClaim {
994
+ readonly pid: number;
995
+ /** PID 가 같은 뜻을 갖는 범위 — {@link processScope}. */
996
+ readonly scope: string;
997
+ /** 잠금을 잡은 실행 환경 — 같은 PID 의 옛 환경이 남긴 잠금을 가른다({@link processContext}). */
998
+ readonly context: string;
999
+ readonly startedAt: string;
1000
+ /** 잠금마다 새로 만드는 표 — 넘겨받힌 뒤 남의 잠금을 지우지 않도록 내 것인지 가른다. */
1001
+ readonly token: string;
1002
+ }
1003
+ /** 잠금이 보는 바깥 — 시험이 갈아 끼운다. */
1004
+ interface StateLockEnv {
1005
+ readonly pid: number;
1006
+ readonly scope: string;
1007
+ readonly context: string;
1008
+ now(): number;
1009
+ isAlive(pid: number): boolean;
1010
+ }
1011
+ declare class StateLock {
1012
+ private readonly path;
1013
+ readonly owner: StateLockOwner;
1014
+ private readonly env;
1015
+ private heartbeat;
1016
+ private constructor();
1017
+ /**
1018
+ * 잠금을 잡는다. 다른 곳이 쥐고 있으면 {@link StateDbLockedError} 를 던진다. 주인이 끝나지 않고 죽었으면 넘겨받는다
1019
+ * — 같은 PID 공간이면 PID 가 없거나 이 프로세스의 옛 실행 환경일 때, 아니면 신호가
1020
+ * {@link STATE_LOCK_STALE_MS} 넘게 끊겼을 때다({@link isAbandoned}).
1021
+ */
1022
+ static acquire(path: string, claim: StateLockClaim, env?: StateLockEnv): StateLock;
1023
+ /** 아직 내 잠금인가 — 잠금 파일에 내 표가 적혀 있다. */
1024
+ isOwned(): boolean;
1025
+ /**
1026
+ * 내 잠금이 아니면 던진다 — 파일에 쓰기 전에 부른다. 멈춘 줄 알고 다른 곳이 넘겨받았는데 옛 사본을 쓰면 그쪽
1027
+ * 기록을 지운다.
1028
+ */
1029
+ assertOwned(): void;
1030
+ /**
1031
+ * 잠금을 푼다 — 내 잠금일 때만 파일을 지운다. 여러 번 불러도 된다. 풀지 못해도 던지지 않는다 — 할 일은 이미
1032
+ * 끝났고, 남은 잠금은 이 프로세스가 끝나면 다음에 여는 곳이 넘겨받는다.
1033
+ */
1034
+ release(): void;
1035
+ private beat;
1036
+ }
1037
+ /**
1038
+ * 상태 DB 를 다른 곳이 쓰는 중이다 — 기다렸다가 다시 하면 된다. 쓰는 곳이 없는데도 계속 막히면(PID 를 다른
1039
+ * 프로세스가 물려받음) 잠금 파일을 지우는 법까지 알린다.
1040
+ */
1041
+ declare class StateDbLockedError extends StateDbUnavailableError {
1042
+ readonly holder: StateLockOwner | null;
1043
+ private constructor();
1044
+ /** 다른 곳이 잠금을 쥐고 있다. */
1045
+ static held(holder: StateLockOwner | null, signalAgeMs: number): StateDbLockedError;
1046
+ /** 쥐고 있던 잠금을 다른 곳이 넘겨받았다 — 멈춘 것으로 보였다. 옛 사본을 파일에 쓰지 않는다. */
1047
+ static lost(holder: StateLockOwner | null): StateDbLockedError;
1048
+ guidance(retry: string): string;
1049
+ }
1050
+
1051
+ /** SQLite 가 WAL 모드에서 쓰는 옆 파일 — 이름은 SQLite 가 정한다. */
1052
+ declare function stateDbWalPath(dbPath: string): string;
1053
+ /**
1054
+ * 최근 기록이 아직 WAL 파일에만 있다 — CLI 가 쓰는 중이거나 끝까지 닫지 못했다. CLI 가 닫으면(끝났으면 명령 하나를
1055
+ * 다시 실행하면) 본 파일로 합쳐진다.
1056
+ */
1057
+ declare class PendingWalError extends StateDbUnavailableError {
1058
+ readonly walBytes: number;
1059
+ constructor(walBytes: number);
1060
+ guidance(retry: string): string;
1061
+ }
1062
+ /** WAL 파일에 쌓인 바이트 — 파일이 없으면 0. */
1063
+ declare function pendingWalBytes(dbPath: string): number;
1064
+ /**
1065
+ * WAL 파일에 기록이 남았으면 던진다. 잠금을 잡은 뒤에 부른다 — 지금 CLI 는 잠금이 먼저 막으므로, 여기서 본 WAL 은
1066
+ * 끝나지 않고 죽은 CLI 나 잠금을 모르는 옛 CLI 의 것이다.
1067
+ */
1068
+ declare function assertNoPendingWal(dbPath: string): void;
1069
+
697
1070
  type NotionPropertyValue = Record<string, unknown>;
698
1071
  interface WikilinkResolver$1 {
699
1072
  resolve(title: string): string | null;
700
1073
  resolvePageId(pageId: string): string | null;
701
1074
  }
1075
+ interface PropertyMapperOptions {
1076
+ /**
1077
+ * 날짜시각을 적고 보낼 시간대(IANA 이름, 설정 `conversion.timeZone`). 없으면 이 컴퓨터의 시간대.
1078
+ */
1079
+ readonly timeZone?: string;
1080
+ }
1081
+ /** {@link PropertyMapper.toNotionPropertyChanges} 가 기준으로 삼는 지금 값들. */
1082
+ interface PropertyChangeContext {
1083
+ /**
1084
+ * 지금 로컬 frontmatter — 기간의 시작과 끝 중 하나만 바뀌어도 둘 다 보내야 해서 읽는다. 없으면 바뀐
1085
+ * 값만 본다(새 행처럼 비교할 기준이 없을 때와 같다).
1086
+ */
1087
+ readonly current?: Readonly<Record<string, unknown>>;
1088
+ /**
1089
+ * 원격 페이지의 지금 속성(`page.properties`) — 글 속성 · 제목은 이 서식 위에 바뀐 글자만 고친다
1090
+ * ({@link patchRichText}). 없으면 평문으로 보낸다.
1091
+ */
1092
+ readonly remote?: Readonly<Record<string, unknown>>;
1093
+ }
702
1094
  declare class PropertyMapper {
1095
+ private readonly options;
703
1096
  private schema;
704
1097
  private wikilinkResolver;
1098
+ constructor(options?: PropertyMapperOptions);
1099
+ /** 설정의 변환 옵션으로 만든다 — 시간대를 이 한 곳에서 읽는다. */
1100
+ static fromConfig(config: Pick<Config, "conversion">): PropertyMapper;
705
1101
  setWikilinkResolver(resolver: WikilinkResolver$1): void;
706
1102
  loadSchema(properties: Record<string, {
707
1103
  id: string;
708
1104
  type: string;
709
1105
  }>): void;
1106
+ /**
1107
+ * frontmatter 전체를 Notion 속성 값으로 바꾼다 — 비교할 기준이 없는 새 행을 만들 때 쓴다.
1108
+ *
1109
+ * 스키마를 읽었으면 스키마에 없는 키는 보내지 않는다. pull 이 적는 `cover` · `icon` 이나
1110
+ * 사용자가 붙인 `aliases` 처럼 DB 속성이 아닌 키를 보내면 Notion 이 «그런 속성은 없다»
1111
+ * 며 요청 전체를 거부한다 — 행의 다른 속성까지 하나도 반영되지 않는다.
1112
+ */
710
1113
  toNotionProperties(frontmatter: Record<string, unknown>, title: string): Record<string, NotionPropertyValue>;
1114
+ /**
1115
+ * 바뀐 속성만 Notion 속성 값으로 바꾼다(S-01).
1116
+ *
1117
+ * 비운 속성은 그 타입의 빈 값으로 보낸다. 되돌릴 수 없는 것은 비우지 않는다 — `files`
1118
+ * 를 비우면 Notion 에 올라간 첨부가 지워지고 볼트에는 그 파일이 없다. `status` 는 빈 값이
1119
+ * 없다. 스키마에 없는 키와 읽기 전용 속성(수식 · 롤업 · 생성일 …)도 보내지 않는다.
1120
+ *
1121
+ * 날짜는 오프셋 없는 시각에 시간대의 오프셋을 붙여 보낸다 — Notion 은 오프셋 없는 시각을 UTC 로 읽는다.
1122
+ * 기간은 시작과 끝(짝 키) 중 하나만 바뀌어도 둘을 함께 보낸다 — 날짜 값은 통째로 바뀐다. 글 속성 ·
1123
+ * 제목은 원격 서식 위에 바뀐 글자만 고친다(`context.remote`).
1124
+ *
1125
+ * @param title 제목이 바뀌었을 때만 새 제목, 아니면 null.
1126
+ * @returns `skipped` — 바뀌었지만 보내지 않은 속성 이름. 호출측이 알린다.
1127
+ */
1128
+ toNotionPropertyChanges(changes: RowPropertyChanges, title: string | null, context?: PropertyChangeContext): {
1129
+ properties: Record<string, NotionPropertyValue>;
1130
+ skipped: string[];
1131
+ };
1132
+ /**
1133
+ * 값을 보낼 수 있는 속성만, pull 이 적는 모양으로 — 원격 값과 로컬 값을 견줄 때 쓴다.
1134
+ *
1135
+ * 고르는 것은 스키마에 있고 읽기 전용이 아니고 제목이 아닌 키와 날짜 속성의 짝 키다. 수식 · 롤업 같은
1136
+ * 값은 달라도 보낼 수 없으니 견주지 않는다. 날짜는 예전 버전이 적은 모양(`…+09:00` · `{start, end}`)도
1137
+ * 시간대의 벽시계 시각 · 짝 키로 바꿔 같은 순간이면 같게 본다(F-08).
1138
+ */
1139
+ writableValues(values: Readonly<Record<string, unknown>>): Record<string, unknown>;
1140
+ /**
1141
+ * `written` 에서 예전 버전이 적은 날짜 모양이 남은 키(F-08) — 날짜 · 생성일 · 수정일 · 날짜 수식 · 날짜
1142
+ * 롤업 가운데, 지금 받으면 적을 값과 같은 순간인데 적힌 모양이 다른 것(`…Z` · `…+09:00` · `{start, end}`).
1143
+ * 기간은 짝 키(`<이름>_end`)도 함께 — 둘 중 하나라도 적혀 있거나 받으면 적을 키만. 원격이 그대로인 행은
1144
+ * 다시 받지 않으므로, 이런 행은 따로 골라 이 키들을 새 모양으로 고쳐 써야 한다.
1145
+ *
1146
+ * 값이 다른 키는 보지 않는다 — 수식 · 롤업은 행의 수정 시각을 바꾸지 않고 다시 계산되고, 시간대 설정을
1147
+ * 바꾸면 벽시계 시각이 달라진다. 모양 때문이 아니다.
1148
+ *
1149
+ * @param written 행 노트에 적힌 frontmatter.
1150
+ * @param notionProps 원격 행의 속성(`page.properties`).
1151
+ * @returns 고쳐 쓸 키. 없으면 빈 배열.
1152
+ */
1153
+ outdatedDateKeys(written: Readonly<Record<string, unknown>>, notionProps: Record<string, unknown>): string[];
711
1154
  fromNotionProperties(notionProps: Record<string, unknown>): Record<string, unknown>;
1155
+ /**
1156
+ * 이 행에서 기간의 끝을 적는 짝 키 — 날짜 속성 이름 → 짝 키. 값이 기간인 속성만이다
1157
+ * ({@link fromNotionProperties} 가 끝을 짝 키에 적는 것과 같다).
1158
+ */
1159
+ rangeEndKeysOf(notionProps: Readonly<Record<string, unknown>>): Map<string, string>;
1160
+ /**
1161
+ * 이 행에서 Notion 이 정하는 frontmatter 키 — {@link fromNotionProperties} 가 적을 수 있는 키 전부다.
1162
+ * 값이 비어 적지 않은 속성과 지금은 기간이 아니어서 적지 않은 짝 키도 든다. pull 이 받은 속성을 로컬
1163
+ * frontmatter 와 합칠 때 이 밖의 키는 로컬 것으로 둔다.
1164
+ */
1165
+ ownedKeys(notionProps: Readonly<Record<string, unknown>>): string[];
1166
+ private zone;
1167
+ /** `key` 가 날짜 속성의 짝 키면 그 날짜 속성 이름. */
1168
+ private rangeOwnerOf;
1169
+ /** 날짜 속성인데 짝 키를 쓰는가 — 같은 이름의 속성이 DB 에 따로 없을 때. */
1170
+ private hasRangeKey;
1171
+ /**
1172
+ * 짝 키를 쓰는 날짜 속성에 보낼 값 — 시작(`name`)과 끝(`name_end`)을 함께. 둘 다 비었으면 지운다.
1173
+ * 끝만 있으면 보낼 수 없다(Notion 의 날짜는 시작이 있어야 한다) — null.
1174
+ */
1175
+ private rangeRequest;
712
1176
  private convertBySchema;
713
1177
  private inferAndConvert;
714
1178
  private extractValue;
715
1179
  }
716
1180
 
1181
+ /**
1182
+ * 취소 신호. `AbortSignal` 과 호환되며, 테스트에서 가벼운 목 객체로도 쓸 수 있다.
1183
+ */
1184
+ interface AbortLike {
1185
+ readonly aborted: boolean;
1186
+ }
1187
+
1188
+ /** 만들 블록 — 블록 API 요청 모양. 자식은 몸통의 `children` 에 깊이 제한 없이 담는다. */
1189
+ interface BlockRequest {
1190
+ readonly type: string;
1191
+ readonly [body: string]: unknown;
1192
+ }
1193
+
1194
+ /** {@link NotionClient.searchRecentPages} 의 한 줄. */
1195
+ interface RecentPage {
1196
+ readonly id: string;
1197
+ readonly last_edited_time: string;
1198
+ readonly last_edited_by: {
1199
+ readonly id: string;
1200
+ } | null;
1201
+ /**
1202
+ * DB 행이면 그 DB 의 id, 페이지면 null — 바뀐 행이 있는 DB 만 조회하는 데 쓴다(ADR-027). search 는 행의
1203
+ * 부모를 `data_source_id` 로 주면서 DB id 를 함께 싣는다(실측 2026-09-28).
1204
+ */
1205
+ readonly parentDatabaseId: string | null;
1206
+ /**
1207
+ * 부모와 제목 — search 는 페이지 객체를 통째로 준다(실측 2026-10-21: 부모 · 제목 속성 포함). 새 페이지가 받을
1208
+ * 것인지 가르는 데 쓴다. 예전에는 추적하지 않는 페이지마다 다시 읽어 이 둘을 얻었다 — 워크스페이스에서 최근
1209
+ * 고친 페이지가 많을수록 pull 마다 요청이 그만큼 늘었다(B8).
1210
+ */
1211
+ readonly parent: PageObjectResponse["parent"];
1212
+ readonly title: string;
1213
+ }
1214
+ /**
1215
+ * {@link NotionClient.searchRecentDataSources} 의 한 줄 — 속성(스키마) · 제목이 바뀐 data source. 행을 고쳐도
1216
+ * data source 의 수정 시각은 오르지 않는다(실측 2026-09-28) — 행의 변경은 {@link RecentPage} 로 본다.
1217
+ */
1218
+ interface RecentDataSource {
1219
+ readonly id: string;
1220
+ readonly last_edited_time: string;
1221
+ /** 이 data source 가 속한 DB. 모르면 null. */
1222
+ readonly databaseId: string | null;
1223
+ }
1224
+ /** 블록 API 의 블록 하나 — 자식이 있으면 `children` 에 담겨 온다({@link NotionClient.getBlockTree}). */
1225
+ type BlockTreeNode = BlockObjectResponse & {
1226
+ children?: BlockTreeNode[];
1227
+ };
1228
+ /**
1229
+ * DB 하나를 한 번 받은 것 — 행 조회 · 스키마 · 제목이 같은 것을 다시 받지 않고 이것을 읽는다. 예전에는 DB 하나를
1230
+ * 받는 데 같은 DB 를 서너 번 다시 받아(요청 6~8회) 발견 DB 166개의 pull 이 분 단위였다.
1231
+ */
1232
+ interface DatabaseMeta {
1233
+ readonly databaseId: string;
1234
+ /** DB 제목. 없으면 "". */
1235
+ readonly title: string;
1236
+ /** 행을 조회할 data source. 비었으면 옛 단일 소스 DB 다 — DB id 로 조회한다. */
1237
+ readonly dataSources: ReadonlyArray<{
1238
+ readonly id: string;
1239
+ readonly name: string;
1240
+ }>;
1241
+ /**
1242
+ * 속성 스키마 — 읽을 수 있는 모든 data source 의 속성을 합친 것(이름이 겹치면 앞의 것). 읽을 수 있는 data
1243
+ * source 가 없으면 DB 객체의 속성.
1244
+ */
1245
+ readonly properties: Readonly<Record<string, unknown>>;
1246
+ }
717
1247
  interface NotionClientOptions {
718
1248
  readonly token: string;
719
1249
  readonly concurrency?: number;
@@ -724,6 +1254,10 @@ interface NotionClientOptions {
724
1254
  readonly retryBackoffFactor?: number;
725
1255
  readonly pageSize?: number;
726
1256
  readonly batchSize?: number;
1257
+ /** 잘린 Markdown 을 채우려고 다시 받는 블록 수 상한 (S-06) */
1258
+ readonly markdownCompletionMaxBlocks?: number;
1259
+ /** 페이지 속성의 날짜시각을 적을 시간대(`conversion.timeZone`) — 없으면 이 컴퓨터의 시간대. */
1260
+ readonly timeZone?: string;
727
1261
  readonly fetch?: typeof globalThis.fetch;
728
1262
  }
729
1263
  declare class NotionClient {
@@ -736,10 +1270,30 @@ declare class NotionClient {
736
1270
  private readonly retryBackoffFactor;
737
1271
  private readonly defaultPageSize;
738
1272
  private readonly batchSize;
1273
+ private readonly markdownCompletionMaxBlocks;
1274
+ /** {@link getBotUserId} 가 받은 값 — 받는 중이면 그 약속. */
1275
+ private botUserId;
1276
+ /** {@link getUserNames} 가 물은 사용자 이름 — 읽지 못한 사용자는 null. */
1277
+ private readonly userNames;
739
1278
  constructor(options: NotionClientOptions);
740
1279
  /** config.advanced 의 운영 튜닝값으로 클라이언트를 생성한다 (매직넘버 단일 진실원). */
741
1280
  static fromConfig(config: Config, fetch?: typeof globalThis.fetch): NotionClient;
742
1281
  getInternalClient(): Client;
1282
+ /**
1283
+ * 이 토큰의 봇 사용자 id(`GET /v1/users/me`). 이 통합이 고친 페이지는 `last_edited_by` 가 이
1284
+ * id 다 — 같은 분 안에서 우리 뒤에 누가 고쳤는지 가르는 데 쓴다(`sync/remote-observation.ts`).
1285
+ * 받은 값은 기억한다. 받지 못하면 던지고 기억하지 않는다 — 다음 호출이 다시 묻는다.
1286
+ */
1287
+ getBotUserId(): Promise<string>;
1288
+ /**
1289
+ * 사용자 멘션에 보일 이름(`GET /v1/users/:id`). Markdown API 는 사용자 멘션을 이름 없는 태그로
1290
+ * 내보낸다(`converter/mention.ts`).
1291
+ *
1292
+ * 물은 사용자는 기억한다 — 같은 사람이 여러 노트에 나와도 한 번만 묻는다. 읽지 못한 사용자(권한 없는
1293
+ * 통합 · 나간 사용자)는 이유를 남기고 맵에서 뺀다. 그 멘션은 이름 대신 정해 둔 글로 보이지만, 멘션
1294
+ * 자체는 id 로 보존된다.
1295
+ */
1296
+ getUserNames(ids: readonly string[]): Promise<Map<string, string>>;
743
1297
  getPage(pageId: string): Promise<PageObjectResponse>;
744
1298
  createPage(params: {
745
1299
  parentId: string;
@@ -755,27 +1309,63 @@ declare class NotionClient {
755
1309
  markdown: string;
756
1310
  properties?: Record<string, unknown>;
757
1311
  }): Promise<PageObjectResponse>;
1312
+ /**
1313
+ * {@link createPageWithMarkdown} 이 버린 맨 앞 `# H1` 을 되살린다 — 그 markdown 이 H1 으로
1314
+ * 시작할 때만 같은 markdown 으로 본문을 한 번 바꾼다({@link startsWithHeading1}).
1315
+ *
1316
+ * 만든 직후, 하위 페이지를 만들기 전에 부른다 — 교체가 지울 자식이 없다. 호출측은 먼저 페이지
1317
+ * 매핑을 적어 둔다: 여기서 실패해도 다음 push 가 새로 만들지 않고 본문을 다시 보낸다.
1318
+ *
1319
+ * @returns 본문을 바꿨으면 true
1320
+ */
1321
+ restoreLeadingHeading(pageId: string, markdown: string): Promise<boolean>;
758
1322
  updatePageProperties(pageId: string, properties: Record<string, unknown>): Promise<PageObjectResponse>;
1323
+ /**
1324
+ * 페이지 마크다운. 블록이 많아 잘린 부분은 블록 ID 로 다시 받아 채운다(S-06 —
1325
+ * {@link completeTruncatedMarkdown}). 돌려준 `unknown_block_ids` 는 권한이 닿지 않아 끝내
1326
+ * 읽지 못한 블록이고, 그 자리는 원래 블록을 지키는 태그로 남아 있다.
1327
+ */
759
1328
  getPageMarkdown(pageId: string): Promise<PageMarkdownResponse>;
1329
+ private retrieveMarkdown;
1330
+ /**
1331
+ * 본문을 통째로 바꾼다. 자식 페이지 · 자식 DB 는 **지우지 않는다**(S-03).
1332
+ *
1333
+ * `allow_deleting_content: true` 로 보내면 새 본문에 `<page>` · `<database>` 태그가 없는
1334
+ * 자식을 Notion 이 휴지통으로 보낸다. 삭제를 허용하지 않으면 무엇이 지워질지 나열하며
1335
+ * `validation_error` 로 거절하고 아무것도 바꾸지 않는다(2026-09-27 실측) — 호출측이
1336
+ * 자식 태그를 되돌려 놓고 다시 보낸다(`sync/page-body.ts`).
1337
+ */
760
1338
  replacePageMarkdown(pageId: string, markdown: string): Promise<PageMarkdownResponse>;
761
1339
  updatePageMarkdownPartial(pageId: string, patches: Array<{
762
1340
  oldStr: string;
763
1341
  newStr: string;
764
1342
  replaceAll?: boolean;
765
1343
  }>): Promise<PageMarkdownResponse>;
766
- movePage(pageId: string, newParentId: string, newParentType: "page" | "database"): Promise<PageObjectResponse>;
767
1344
  /**
768
- * 데이터베이스 1개를 신 모델(2025-09-03 data source 분리)로 조회한다.
769
- * - title: database 객체에 그대로 존재한다.
770
- * - properties(스키마): 신 모델에선 data source 에 있으므로 1차 data source 를 조회해 채운다.
1345
+ * 페이지를 다른 페이지 아래로 옮긴다 — `POST /v1/pages/{id}/move`.
1346
+ *
1347
+ * 페이지 갱신(pages.update)은 부모를 바꾸지 않는다 — `parent` 를 보내도 오류 없이 무시한다.
1348
+ * 예전 movePage 가 그 길을 써서, 옮긴 노트의 Notion 부모가 한 번도 바뀌지 않았다(S-11).
1349
+ * 같은 부모로 다시 옮겨도 결과가 같아 모호한 실패는 다시 보낸다.
1350
+ */
1351
+ movePage(pageId: string, parentPageId: string): Promise<void>;
1352
+ /**
1353
+ * 데이터베이스 1개를 신 모델(2025-09-03 data source 분리)로 조회한다 — DB 1회 + data source 마다 1회.
1354
+ * - title · data source 목록: database 객체에 그대로 존재한다.
1355
+ * - properties(스키마): 신 모델에선 data source 에 있으므로 data source 를 조회해 채운다.
771
1356
  * data source 접근 불가(링크드 DB 등)면 database 객체의 properties 로 폴백한다(제목은 보존).
772
1357
  *
1358
+ * 같은 DB 의 행 조회 · 스키마 · 제목은 받은 것({@link DatabaseMeta})을 넘겨 다시 받지 않는다.
1359
+ *
773
1360
  * 폐기한 레거시 raw fetch(`GET /v1/databases/{id}`, Notion-Version 2022-06-28)는 신 모델로
774
1361
  * 업그레이드된 다수 DB에 400 을 반환해 자동 발견 DB 가 통째 드롭(내용 손실)되던 원인이었다.
775
1362
  * SDK(`databases.retrieve`/`dataSources.retrieve`)는 동일 토큰으로 정상 동작한다.
776
1363
  */
777
- private fetchDatabaseModern;
778
- getDatabaseTitle(databaseId: string): Promise<string>;
1364
+ getDatabaseMeta(databaseId: string): Promise<DatabaseMeta>;
1365
+ /** `from` 이 이 DB 를 받은 것이면 그것을, 아니면 새로 받는다. */
1366
+ private metaOf;
1367
+ /** @param from 이미 받은 이 DB — 주면 다시 받지 않는다. */
1368
+ getDatabaseTitle(databaseId: string, from?: DatabaseMeta): Promise<string>;
779
1369
  /**
780
1370
  * 발견된 DB 가 **동기화 가능한지**(접근 가능한 data source 가 있는지) 판별한다.
781
1371
  * 신 모델(2025-09-03)에서 링크드 DB·미공유 데이터 소스·삭제 DB 는 `data_sources` 가
@@ -804,7 +1394,8 @@ declare class NotionClient {
804
1394
  originalDbId: string;
805
1395
  viewName: string;
806
1396
  } | null>;
807
- getDatabaseSchema(databaseId: string): Promise<Record<string, {
1397
+ /** @param from 이미 받은 이 DB — 주면 다시 받지 않는다. */
1398
+ getDatabaseSchema(databaseId: string, from?: DatabaseMeta): Promise<Record<string, {
808
1399
  id: string;
809
1400
  type: string;
810
1401
  }>>;
@@ -815,8 +1406,10 @@ declare class NotionClient {
815
1406
  * 옵션명이 아니라 **그룹명**(`To-do` 등)으로 오기 때문에 `groups[].optionIds` 를
816
1407
  * 옵션명으로 되돌리려면 이 id 가 있어야 한다. 예전엔 여기서 id 를 떨궈,
817
1408
  * 그룹으로 필터링된 뷰를 Bases 로 옮길 방법이 아예 없었다.
1409
+ *
1410
+ * @param from 이미 받은 이 DB — 주면 다시 받지 않는다.
818
1411
  */
819
- getDatabaseSchemaFull(databaseId: string): Promise<Record<string, BasePropertySchema>>;
1412
+ getDatabaseSchemaFull(databaseId: string, from?: DatabaseMeta): Promise<Record<string, BasePropertySchema>>;
820
1413
  /** 특정 data source 1개를 직접 쿼리한다(저수준). 다중 data source 순회의 빌딩블록. */
821
1414
  private queryDataSource;
822
1415
  /**
@@ -837,8 +1430,10 @@ declare class NotionClient {
837
1430
  * 페이지네이션하면 2번째+ 의 행이 통째 침묵 유실된다. 전 data source 를 순회·페이지네이션
838
1431
  * 하고 page_id 로 디듀프(소스 간 동일 페이지 방어)한 뒤 합친다. 다중 소스면 1회 경고해
839
1432
  * "소스별 탭 구분이 한 폴더로 병합"되는 점을 비침묵으로 알린다.
1433
+ *
1434
+ * @param from 이미 받은 이 DB — 주면 data source 목록을 다시 받지 않는다.
840
1435
  */
841
- queryAllDatabasePages(databaseId: string, filter?: unknown): Promise<PageObjectResponse[]>;
1436
+ queryAllDatabasePages(databaseId: string, filter?: unknown, from?: DatabaseMeta): Promise<PageObjectResponse[]>;
842
1437
  /** database 의 1차 data source id(없으면 databaseId 폴백). 단일 소스 경로 호환용. */
843
1438
  getDataSourceId(databaseId: string): Promise<string>;
844
1439
  /**
@@ -850,7 +1445,19 @@ declare class NotionClient {
850
1445
  name: string;
851
1446
  }>>;
852
1447
  archivePage(pageId: string): Promise<void>;
1448
+ /**
1449
+ * 파일 블록의 지금 받을 수 있는 주소. 블록이 없거나 볼 수 없으면 null 이다.
1450
+ * 그 밖의 실패(일시 오류 등)는 던진다 — 예전에는 모두 null 로 삼켜 이유가 남지 않았다.
1451
+ */
853
1452
  getFileBlockUrl(blockId: string): Promise<string | null>;
1453
+ /**
1454
+ * 북마크 블록의 주소와 캡션(F-09). Markdown API 는 북마크를 주소 없는 자리 태그로만 보낸다
1455
+ * (`converter/bookmark.ts`) — 블록마다 한 번 읽는다.
1456
+ *
1457
+ * 읽지 못한 블록은 이유를 남기고 맵에서 뺀다. 그 북마크는 Notion 의 블록을 가리키는 링크로 보이고,
1458
+ * 블록 자체는 마커로 보존된다. 북마크가 아닌 블록도 뺀다.
1459
+ */
1460
+ getBookmarks(blockIds: readonly string[]): Promise<Map<string, BookmarkTarget>>;
854
1461
  getBlock(blockId: string): Promise<BlockObjectResponse>;
855
1462
  listChildren(blockId: string, options?: {
856
1463
  startCursor?: string;
@@ -862,6 +1469,13 @@ declare class NotionClient {
862
1469
  fetchAllChildren(blockId: string): Promise<BlockObjectResponse[]>;
863
1470
  private static readonly CONTAINER_BLOCK_TYPES;
864
1471
  fetchAllChildrenDeep(blockId: string): Promise<BlockObjectResponse[]>;
1472
+ /**
1473
+ * 블록 트리 — 자식이 있는 블록은 `children` 에 자식을 담는다. 자식 페이지 · DB 는 열지 않는다. 동기화
1474
+ * 참조 블록은 Notion 이 원본의 자식을 돌려주므로 그대로 담는다(Markdown API 도 원본 내용을 싣는다).
1475
+ *
1476
+ * 블록 하나라도 읽지 못하면 던진다 — 빠진 채로 노트를 쓰면 다음 push 가 그 블록을 Notion 에서 지운다.
1477
+ */
1478
+ getBlockTree(blockId: string): Promise<BlockTreeNode[]>;
865
1479
  /**
866
1480
  * 자식 블록을 덧붙인다. `options.after` 를 주면 그 블록 **바로 뒤**에 삽입한다
867
1481
  * (기본은 맨 끝). 자리표시자를 실제 미디어 블록으로 제자리 교체할 때 필요하다.
@@ -873,7 +1487,33 @@ declare class NotionClient {
873
1487
  appendChildren(blockId: string, children: unknown[], options?: {
874
1488
  readonly after?: string;
875
1489
  }): Promise<string[]>;
1490
+ /**
1491
+ * {@link appendChildren} 과 같되 새로 만든 블록을 통째로 돌려준다. 파일을 붙인 미디어 블록은
1492
+ * 응답에 Notion 이 저장한 파일의 URL 이 실려 온다 — 다시 조회하지 않고 그 파일을 알아 둔다(S-05).
1493
+ */
1494
+ appendChildBlocks(blockId: string, children: unknown[], options?: {
1495
+ readonly after?: string;
1496
+ }): Promise<(BlockObjectResponse | PartialBlockObjectResponse)[]>;
1497
+ /**
1498
+ * 깊이 제한 없는 블록 트리를 맨 끝에 덧붙인다 — 요청 한도(깊이 두 단계 · 100 개 · 1000 블록)를 넘는
1499
+ * 자식은 부모를 만든 뒤 그 아래에 마저 붙인다({@link shapeAppendRequests}). 더 깊은 블록의 id 는
1500
+ * 응답에 없어 부모의 자식 목록을 읽어 찾는다.
1501
+ *
1502
+ * 중간에 실패하면 그때까지 만든 블록은 남는다 — 호출측이 실패로 알린다.
1503
+ */
1504
+ appendBlockTree(parentId: string, blocks: readonly BlockRequest[]): Promise<void>;
876
1505
  deleteBlock(blockId: string): Promise<void>;
1506
+ /**
1507
+ * 코드 블록의 글을 바꾼다 — Markdown API 로 보낼 수 없는 코드(코드 속 ``` 줄, S-22)를 블록으로
1508
+ * 채운다. 긴 코드는 rich text 여럿에 나눠 담는다. 같은 글로 다시 보내도 결과가 같아 모호한 실패는
1509
+ * 다시 보낸다.
1510
+ */
1511
+ updateCodeBlockText(blockId: string, code: string, language: LanguageRequest): Promise<void>;
1512
+ /**
1513
+ * 페이지 코드 블록의 글 — 컨테이너 안까지. 받은 markdown 만으로 코드 범위를 가를 수 없을 때(코드 속
1514
+ * ``` 줄, S-22) 블록의 글로 맞춘다.
1515
+ */
1516
+ getCodeBlockTexts(pageId: string): Promise<string[]>;
877
1517
  /** single_part 업로드 상한 (Notion API 규격 20MB) */
878
1518
  private static readonly SINGLE_PART_MAX_BYTES;
879
1519
  /** multi_part 파트 크기 — 규격상 마지막 파트 제외 5~20MB, 10MB 고정 사용 */
@@ -893,13 +1533,17 @@ declare class NotionClient {
893
1533
  results: PageObjectResponse[];
894
1534
  nextCursor: string | null;
895
1535
  }>;
896
- searchRecentPages(since: string): Promise<Array<{
897
- id: string;
898
- last_edited_time: string;
899
- }>>;
1536
+ /**
1537
+ * `since` 뒤에 고친 페이지 — 최근 것부터. DB 행도 온다. 누가 마지막으로 고쳤는지도 싣는다: 수정
1538
+ * 시각은 분 단위라 같은 분 안의 편집은 편집자로만 가를 수 있다(N-05).
1539
+ */
1540
+ searchRecentPages(since: string, signal?: AbortLike): Promise<RecentPage[]>;
1541
+ /**
1542
+ * `since` 뒤에 속성(스키마) · 제목이 바뀐 data source — 최근 것부터. 속성을 더하거나 이름을 바꾸면 행은
1543
+ * 그대로라도 그 DB 의 `.base` 와 행의 frontmatter 가 바뀐다.
1544
+ */
1545
+ searchRecentDataSources(since: string, signal?: AbortLike): Promise<RecentDataSource[]>;
900
1546
  getChildPages(parentId: string): Promise<PageObjectResponse[]>;
901
- /** 페이지가 휴지통(in_trash)이거나 보관(archived) 상태인지 판정한다. */
902
- private static isTrashedOrArchived;
903
1547
  getChildDatabaseIds(parentId: string): Promise<string[]>;
904
1548
  /**
905
1549
  * root 서브트리를 직접 BFS 순회해 하위 페이지를 모두 수집한다(중첩 깊이 무관·무손실).
@@ -910,12 +1554,14 @@ declare class NotionClient {
910
1554
  * **그때까지 찾은 페이지는 에러의 `partial` 에 실어 보낸다** — 버리면 호출측이 두
911
1555
  * 경로를 경합시키게 되고, 어느 쪽이 이기는지를 벽시계가 정하게 된다(R12-A).
912
1556
  * 미지정 시 무제한(기존 동작 유지 — 테스트 mock 경로는 영향 없음).
1557
+ * @param opts.signal 취소되면 다음 페이지로 가기 전에 `OperationAbortedError` 로 멈춘다.
913
1558
  */
914
1559
  getChildPagesRecursive(parentId: string, opts?: {
915
1560
  deadlineMs?: number;
1561
+ signal?: AbortLike;
916
1562
  }): Promise<PageObjectResponse[]>;
917
1563
  /** 워크스페이스에서 통합에 공유된 모든 페이지를 bulk search 로 열거한다(100/요청). */
918
- searchAllPages(): Promise<PageObjectResponse[]>;
1564
+ searchAllPages(signal?: AbortLike): Promise<PageObjectResponse[]>;
919
1565
  /**
920
1566
  * root 하위 페이지를 **search 기반**으로 디스커버리한다(대규모 서브트리용 폴백).
921
1567
  *
@@ -930,7 +1576,7 @@ declare class NotionClient {
930
1576
  * - 휴지통/보관(in_trash/archived) 페이지 제외
931
1577
  * - root 자신은 제외(직접 순회도 root 의 *자식*부터 수집)
932
1578
  */
933
- getPagesUnderRootViaSearch(rootId: string): Promise<PageObjectResponse[]>;
1579
+ getPagesUnderRootViaSearch(rootId: string, signal?: AbortLike): Promise<PageObjectResponse[]>;
934
1580
  listDatabaseViews(databaseId: string): Promise<ViewConfig[]>;
935
1581
  getView(viewId: string): Promise<ViewConfig | null>;
936
1582
  getDatabaseViewsConfig(databaseId: string): Promise<DatabaseViewsConfig>;
@@ -965,6 +1611,13 @@ declare class NotionClient {
965
1611
  * 268페이지 pull 이 진행 로그가 멈춘 채 수 분씩 정지하는 것으로 나타났다(R9).
966
1612
  * 그래서 (1) 대기 중에는 슬롯을 반납하고, (2) 429 는 {@link cooldownUntil} 전역
967
1613
  * 게이트로 함께 쉬고, (3) 모든 재시도를 경고로 남긴다.
1614
+ *
1615
+ * `idempotent: false` 는 두 번 적용되면 결과가 달라지는 쓰기다(페이지 생성 · 블록
1616
+ * 덧붙이기 · 부분 치환). 이런 요청은 서버가 «처리하지 않았다» 고 분명히 말한 실패만
1617
+ * 다시 보낸다. 타임아웃 · 연결 끊김 · 5xx 는 요청이 이미 적용됐을 수 있고 Notion 은
1618
+ * 멱등 키를 받지 않으므로, 다시 보내면 같은 페이지 · 같은 블록이 두 벌 생긴다.
1619
+ * 그런 실패는 호출측으로 올린다 — 페이지 생성은 WAL(pending_operations)이 부모에서
1620
+ * 제목으로 찾아 입양한다.
968
1621
  */
969
1622
  private withRateLimit;
970
1623
  /** 슬롯을 쥔 뒤의 발사 대기 — 전역 쿨다운(429)과 최소 요청 간격을 함께 지킨다. */
@@ -1214,17 +1867,49 @@ interface VaultCompletenessReport {
1214
1867
  */
1215
1868
  declare function verifyPageCompleteness(remote: PageCompletenessRemoteSource, local: CompletenessLocalSource, rootPageId: string): Promise<PageCompletenessReport>;
1216
1869
 
1870
+ /** 이름 변경 힌트 — 지금 경로 → 마지막 동기화 때 알던 경로. */
1871
+ interface RenameHints {
1872
+ readonly files: Readonly<Record<string, string>>;
1873
+ readonly folders: Readonly<Record<string, string>>;
1874
+ }
1875
+ type RenameKind = "file" | "folder";
1876
+
1217
1877
  interface FileInfo {
1218
1878
  readonly path: string;
1219
1879
  readonly content: string;
1220
1880
  readonly mtime: string;
1221
1881
  }
1882
+ /** 변경 감지 옵션 — 옮긴 노트를 찾는 데 쓴다(S-11). */
1883
+ interface LocalScanOptions {
1884
+ /** 플러그인이 적어 둔 이름 변경 힌트. */
1885
+ readonly hints?: RenameHints;
1886
+ }
1887
+ /**
1888
+ * 옮긴 것으로 짝지은 추적 노트. 상태 DB 는 아직 옛 경로(`record.obsidianPath`)를 적고 있다.
1889
+ */
1890
+ interface LocalMoveAdoption {
1891
+ readonly record: SyncRecord;
1892
+ /** 파일의 지금 경로. */
1893
+ readonly to: string;
1894
+ /** 마지막으로 Notion 에 반영한 경로 — 앞서 입양해 둔 이동이면 그 WAL 의 옛 경로. */
1895
+ readonly origin: string;
1896
+ }
1897
+ interface LocalScan {
1898
+ /** 입양을 마친 것으로 보고 가른 변경 — 옮긴 노트는 새 경로의 `moved` 다. */
1899
+ readonly changes: LocalChange[];
1900
+ readonly adoptions: readonly LocalMoveAdoption[];
1901
+ }
1222
1902
  declare class ChangeDetector {
1223
1903
  private readonly stateDb;
1224
1904
  constructor(stateDb: IStateDB);
1225
- detectLocalChangesFast(stats: FileStatInfo[], readFile: (path: string) => Promise<string>): Promise<LocalChange[]>;
1226
- detectLocalChanges(currentFiles: FileInfo[]): LocalChange[];
1227
- private detectMoves;
1905
+ detectLocalChangesFast(stats: FileStatInfo[], readFile: (path: string) => Promise<string>, options?: LocalScanOptions): Promise<LocalChange[]>;
1906
+ detectLocalChanges(currentFiles: FileInfo[], options?: LocalScanOptions): LocalChange[];
1907
+ /** 파일 크기 · 수정 시각이 지난 동기화와 같으면 내용을 읽지 않는다. */
1908
+ scanLocalChangesFast(stats: readonly FileStatInfo[], readFile: (path: string) => Promise<string>, options?: LocalScanOptions): Promise<LocalScan>;
1909
+ scanLocalChanges(currentFiles: readonly FileInfo[], options?: LocalScanOptions): LocalScan;
1910
+ /** 이름을 바꿔도 파일 크기 · 수정 시각은 그대로다 — 옮긴 노트는 그것으로 건너뛰지 않는다. */
1911
+ private movePending;
1912
+ private assemble;
1228
1913
  }
1229
1914
 
1230
1915
  interface NonMdFileInfo {
@@ -1253,12 +1938,34 @@ interface VaultFS {
1253
1938
  }
1254
1939
 
1255
1940
  type ResolutionChoice = "local" | "remote" | "merge" | "duplicate";
1941
+ /**
1942
+ * Notion 에서 지운 노트의 충돌인가 — 올리지 않은 로컬 편집이 남은 채 원격이 휴지통 · 보관 · 없음이다.
1943
+ * 풀 수 있는 것은 «로컬 유지»(Notion 에 새 페이지로 다시 만든다)와 «원격 유지»(볼트에서도 지운다)
1944
+ * 뿐이다 — 병합하거나 사본으로 남길 원격 본문이 없다.
1945
+ */
1946
+ declare function isRemoteDeletion(conflict: Conflict): boolean;
1947
+ /** 이 충돌을 풀 수 있는 선택지 — 앱은 이것만 보여 준다. */
1948
+ declare function applicableChoices(conflict: Conflict): readonly ResolutionChoice[];
1949
+ /**
1950
+ * 전략이 이 충돌에 고르는 것. 원격에서 지운 노트는 복제할 원격이 없어 duplicate 도 «로컬 유지» —
1951
+ * 둘 다 잃는 것이 없다. manual 은 자동 병합인데 지운 원격과는 병합할 수 없어 던진다.
1952
+ */
1953
+ declare function choiceForStrategy(conflict: Conflict, strategy: ConflictStrategy): ResolutionChoice;
1256
1954
  interface ResolutionResult {
1257
1955
  readonly path: string;
1258
1956
  readonly choice: ResolutionChoice;
1259
1957
  readonly success: boolean;
1260
1958
  readonly mergeHadConflicts?: boolean;
1959
+ /** 풀지 못한 이유 — 일괄 해결(`resolveAllConflicts`)이 실패를 던지지 않고 싣는다. */
1960
+ readonly error?: string;
1261
1961
  }
1962
+ /**
1963
+ * 충돌을 볼트 파일과 상태 DB 에서 푼다. **Notion 에는 올리지 않는다.**
1964
+ *
1965
+ * 앱은 `SyncOrchestrator.resolveConflict` 를 쓴다 — 이 클래스로 푼 뒤 고른 결과를 Notion 에 올리고,
1966
+ * 올리지 못하면 충돌로 되돌린다. 이 클래스만 부르면 지난 동기화 사본만 해결 결과로 바뀌어, 다음
1967
+ * pull 이 바뀐 원격으로 고른 로컬 · 병합 결과를 덮는다(N-06).
1968
+ */
1262
1969
  declare class ConflictResolver {
1263
1970
  private readonly stateDb;
1264
1971
  private readonly vaultFs;
@@ -1266,39 +1973,105 @@ declare class ConflictResolver {
1266
1973
  resolve(conflict: Conflict, choice: ResolutionChoice): Promise<ResolutionResult>;
1267
1974
  resolveByStrategy(conflict: Conflict, strategy: ConflictStrategy): Promise<ResolutionResult>;
1268
1975
  resolveAll(conflicts: Conflict[], strategy: ConflictStrategy): Promise<ResolutionResult[]>;
1976
+ /**
1977
+ * Notion 에서 지운 노트 — «로컬 유지» 는 추적만 놓아 파일을 새 노트로 만든다(다음 push 가 새 페이지로
1978
+ * 올린다. 앱은 오케스트레이터가 곧바로 올린다). «원격 유지» 는 볼트에서도 지운다.
1979
+ */
1980
+ private resolveRemoteDeletion;
1269
1981
  private resolveLocal;
1270
1982
  private resolveRemote;
1271
1983
  private resolveMerge;
1272
1984
  private resolveDuplicate;
1985
+ /**
1986
+ * 충돌 미리보기 — 로컬(`-`)과 원격(`+`)의 줄 비교. 바뀐 곳과 앞뒤 세 줄을 `@@` 묶음으로 보인다.
1987
+ *
1988
+ * 예전에는 같은 번호의 줄끼리 견줬다 — 한쪽 앞에 한 줄만 더해도 그 뒤의 모든 줄이 바뀐 것으로 보여,
1989
+ * 무엇이 실제로 다른지 찾을 수 없었다.
1990
+ */
1273
1991
  generateDiff(conflict: Conflict): string;
1274
1992
  }
1275
1993
 
1994
+ /**
1995
+ * 한 오케스트레이터의 작업은 한 번에 하나만 돈다(S-09).
1996
+ *
1997
+ * 오케스트레이터는 실행마다 상태를 쥔다 — 원격을 본 시각(N-05, `RunObservation`) · 이번 실행이 고른
1998
+ * 새 경로(`PagePuller` 의 `claimedPaths`) · 준비하지 못한 폴더(`FolderPlacement`). 두 실행이 겹치면
1999
+ * 뒤 실행이 앞 실행의 것을 덮는다. 앞 pull 이 원격을 본 시각이 뒤로 밀려 그 사이의 Notion 편집을 본
2000
+ * 것으로 적고(영영 받지 않는다), 두 실행이 같은 새 경로를 골라 서로의 파일을 덮는다. 플러그인의
2001
+ * 수동 · 자동 · 볼트 이벤트 sync 와 CLI `watch` 의 파일 변경 · 주기 sync 가 이렇게 겹쳤다.
2002
+ *
2003
+ * 겹친 요청은 기다리게 하지 않고 거절한다. 무엇을 할지는 부른 쪽마다 다르다 — 사용자가 누른
2004
+ * 것은 알리고, 자동 sync 는 건너뛰고, 파일 감시는 끝난 뒤 다시 돌린다. 여기서 줄을 세우면
2005
+ * 긴 sync(실볼트 pull 500초) 동안 주기 sync 가 쌓인다.
2006
+ */
2007
+ /** 잠그는 작업 — 볼트 · 상태 DB · 원격을 바꾸거나 이번 실행의 기준을 정하는 것. */
2008
+ type GatedOperation = "push" | "pull" | "sync" | "status" | "fetch" | "resolve" | "discard";
2009
+ declare function operationLabel(operation: GatedOperation): string;
2010
+ declare class SyncBusyError extends Error {
2011
+ /** 이미 도는 작업. */
2012
+ readonly running: GatedOperation;
2013
+ /** 거절한 작업. */
2014
+ readonly requested: GatedOperation;
2015
+ constructor(
2016
+ /** 이미 도는 작업. */
2017
+ running: GatedOperation,
2018
+ /** 거절한 작업. */
2019
+ requested: GatedOperation);
2020
+ }
2021
+
1276
2022
  declare class SyncOrchestrator {
1277
2023
  private readonly config;
1278
2024
  private readonly stateDb;
1279
2025
  private readonly notionClient;
1280
2026
  private readonly vaultFs;
1281
- private readonly changeDetector;
1282
- private readonly pipeline;
1283
- private readonly blockConverter;
1284
- private readonly imageHandler;
1285
2027
  private readonly fileHandler;
1286
2028
  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;
2029
+ /** DB 행 push 용 — DB 마다 스키마를 읽은 매퍼. 실행마다 비운다(S-01). */
2030
+ private readonly rowSchemas;
2031
+ private readonly observation;
2032
+ private readonly placement;
2033
+ private readonly planner;
2034
+ private readonly detector;
2035
+ private readonly drift;
2036
+ private readonly discovery;
2037
+ private readonly puller;
2038
+ private readonly pullPlanner;
2039
+ private readonly pusher;
2040
+ private readonly conflictWorkflow;
2041
+ private readonly inspector;
2042
+ private readonly recovery;
2043
+ private readonly gate;
1296
2044
  constructor(config: Config, stateDb: IStateDB, notionClient: NotionClient, vaultFs: VaultFS, customFetch?: typeof globalThis.fetch);
2045
+ /** 도는 작업 — 없으면 null(S-09). 부른 쪽이 겹칠 요청을 미리 거를 때 쓴다. */
2046
+ get runningOperation(): GatedOperation | null;
1297
2047
  push(options?: PushOptions): Promise<PushResult>;
2048
+ private executePush;
2049
+ /** 볼트의 노트가 마지막으로 올린 내용 그대로인가 — 읽지 못하면(지움 · 옮김) 아니다. */
2050
+ private unchangedSincePush;
1298
2051
  pull(options?: PullOptions): Promise<PullResult>;
2052
+ private executePull;
1299
2053
  sync(options?: SyncOptions): Promise<SyncResult>;
1300
- status(): Promise<StatusResult>;
2054
+ private executeSync;
2055
+ /**
2056
+ * 원격까지 본다 — 이번 실행의 원격 기준을 정하므로 다른 작업과 겹치지 않는다. 원격을 훑는 데 분 단위가
2057
+ * 걸릴 수 있어 취소를 받는다. 취소하면 {@link OperationAbortedError} 를 던진다.
2058
+ */
2059
+ status(options?: StatusOptions): Promise<StatusResult>;
2060
+ private executeStatus;
2061
+ /**
2062
+ * 로컬만 본다 — 원격 기준을 정하지 않아 도는 작업과 겹쳐도 된다. 변경은 push 가 쓰는 판정 그대로다
2063
+ * (옮긴 노트 · 폴더 포함). 상태 DB 는 바꾸지 않는다.
2064
+ */
1301
2065
  statusLocal(): Promise<StatusResult>;
2066
+ /**
2067
+ * 볼트에서 노트 · 폴더의 이름을 바꿨다는 기록 — 플러그인이 볼트의 rename 이벤트로 알린다(S-11).
2068
+ *
2069
+ * 다음 실행이 옮긴 노트를 추적 레코드와 짝지을 때 쓴다. 이름과 내용을 함께 바꾼 노트는 이
2070
+ * 기록 없이는 짝을 찾지 못해, 옛 페이지를 지우고 새 페이지를 만든다.
2071
+ */
2072
+ recordLocalRename(from: string, to: string, kind: RenameKind): void;
2073
+ /** 볼트에서 지운 노트 · 폴더 — 그 자리를 새 경로로 적은 힌트를 버린다. */
2074
+ recordLocalDelete(path: string): void;
1302
2075
  /**
1303
2076
  * 볼트 완결성 검증 — 원격에 있는 것이 볼트에 빠짐없이 있는지 대조한다(R11-B · R12-C).
1304
2077
  *
@@ -1307,8 +2080,8 @@ declare class SyncOrchestrator {
1307
2080
  * 그렇게 침묵 유실됐다. 여기서만 "빠짐없다"를 본다.
1308
2081
  *
1309
2082
  * 대조 대상 database id 는 이 클래스가 조립한다: 설정에 적힌 것 + 디스커버리 캐시가
1310
- * 아는 것 + DB 모드의 루트 DB. `discovered_dbs` 메타 키를 아는 곳을 이 파일 하나로
1311
- * 유지해, 호출부(CLI·E2E)가 캐시 표현을 각자 다시 해석하지 않게 한다.
2083
+ * 아는 것 + DB 모드의 루트 DB. 캐시 표현(`discovered_dbs` 메타 키)은 `discovered-databases.ts`
2084
+ * 한 곳이 정하고 여기서 조립한다 — 호출부(CLI·E2E)가 그 표현을 각자 다시 해석하지 않게.
1312
2085
  *
1313
2086
  * 페이지(R12-C)는 pull 이 쓴 경로가 아니라 **search 열거**로 대조한다. 페이지는 열거
1314
2087
  * 경로가 둘이고 둘이 같은 집합을 내지 않는 게 애초의 결함이므로(R12-A), 독립된 두 번째
@@ -1321,210 +2094,38 @@ declare class SyncOrchestrator {
1321
2094
  modifiedPages: number;
1322
2095
  duration: number;
1323
2096
  }>;
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;
2097
+ private executeFetch;
2098
+ /** pull 이 쓴 노트의 Notion 링크를 볼트 링크로 푼다 — 푼 링크 수를 돌려준다. */
1355
2099
  private resolveNotionLinks;
1356
- private ensureDbSchema;
1357
- private pushCreate;
1358
2100
  /**
1359
- * 노트에 박힌 로컬 미디어를 Notion 에 올린다(R1).
1360
- *
1361
- * 먼저 본문 자리표시자를 실제 image/file 블록으로 **제자리** 교체한다. 그러고도 남은
1362
- * 이미지만 페이지 끝에 덧붙이는 예전 경로로 흘린다 — 자리표시자가 없는 경우는 본문
1363
- * push 가 통째로 스킵됐을 때(pageHasLiveChildren) 정도이고, 그때도 이미지 자체는
1364
- * 올라가야 하므로 폴백을 남긴다.
1365
- */
1366
- private syncEmbeddedMedia;
1367
- private pushUpdate;
1368
- /**
1369
- * 해소 대상 충돌 목록 — 원격 본문을 pull 과 **같은 파이프라인**으로 렌더해 담는다.
1370
- *
1371
- * 해소는 실제로 볼트 파일을 덮어쓰므로 `status()` 의 경량 미리보기 렌더를 쓰면 안 된다
1372
- * (프론트매터·첨부가 빠진 반쪽 본문이 덮인다). 반대로 전체 pull 을 먼저 돌려 목록을
1373
- * 얻는 것도 안 된다 — 해소하겠다고 볼트를 먼저 원격으로 덮어쓰는 셈이라 순서가 거꾸로다.
1374
- * 그래서 충돌 레코드만 좁혀 그 페이지들만 읽어 온다.
2101
+ * 해소 대상 충돌 목록 — 원격 본문을 pull 과 같은 파이프라인으로 렌더해 담는다. 렌더가 첨부를 볼트에
2102
+ * 내려받고 경로를 고르므로 다른 작업과 겹치지 않게 돌린다 — 도는 sync 와 같은 첨부 · 경로를 두고 다툰다.
1375
2103
  */
1376
2104
  listConflicts(): Promise<Conflict[]>;
1377
2105
  /**
1378
- * 추적 파일의 **현재 원격 본문**을 pull 과 같은 변환으로 렌더한다 — 표시 전용.
1379
- *
1380
- * 첨부는 내려받지 않는다: 비교를 보려다 볼트에 파일이 생기면 안 된다. 그 대가로 아직
1381
- * 내려받지 않은 미디어는 원격 URL 로 남아 비교 화면에만 차이로 보인다.
1382
- *
1383
- * @returns 추적되지 않았거나 원격 페이지가 없으면 null.
2106
+ * 추적 파일의 현재 원격 본문을 pull 과 같은 변환으로 렌더한다 — 표시 전용이라 첨부를 내려받지
2107
+ * 않는다. 추적되지 않았거나 원격 페이지가 없으면 null.
1384
2108
  */
1385
2109
  renderRemoteSnapshot(path: string): Promise<string | null>;
1386
- /**
1387
- * 해소할 게 남지 않은 충돌 레코드를 `synced` 로 되돌리고, 되돌린 경로를 반환한다.
1388
- *
1389
- * 충돌로 표시된 파일은 push 대상에서 통째로 빠진다(양쪽 덮어쓰기 방지). 그래서 사용자가
1390
- * 손으로 양쪽을 맞춰 둬 이미 같은 내용이 됐는데도 레코드만 남으면, 그 파일의 이후 편집이
1391
- * **영원히 Notion 에 올라가지 않는다** — 아무 경고 없이 정체된다. 내용이 이미 동일한
1392
- * 건만 골라 상태를 되돌린다(진짜 충돌은 손대지 않는다).
1393
- */
2110
+ /** 로컬 변경 하나의 두 글 — 지난 동기화 때의 글과 지금 볼트의 글. 표시 전용이라 잠그지 않는다. */
2111
+ localChangeDiff(change: LocalChange): Promise<ChangeDiff>;
2112
+ /** 원격 변경 하나의 두 글 — 지난 동기화 때의 글과 Notion 의 지금 글. 표시 전용이라 잠그지 않는다. */
2113
+ remoteChangeDiff(change: RemoteChange): Promise<ChangeDiff>;
2114
+ /** 해소할 게 남지 않은 충돌 레코드를 `synced` 로 되돌리고, 되돌린 경로를 반환한다. */
1394
2115
  clearStaleConflicts(conflicts: readonly Conflict[]): string[];
1395
2116
  /** 단일 충돌을 사용자가 고른 선택지(local/remote/merge/duplicate)로 해소 + Notion 전파. */
1396
2117
  resolveConflict(conflict: Conflict, choice: ResolutionChoice): Promise<ResolutionResult>;
1397
2118
  /** 단일 충돌을 전략(manual/local-first/remote-first/duplicate)으로 해소 + Notion 전파. */
1398
2119
  resolveConflictByStrategy(conflict: Conflict, strategy: ConflictStrategy): Promise<ResolutionResult>;
1399
- /** 여러 충돌을 동일 전략으로 일괄 해소 + Notion 전파. */
2120
+ /** 여러 충돌을 동일 전략으로 일괄 해소 + Notion 전파. 하나를 올리지 못해도 나머지를 푼다. */
1400
2121
  resolveAllConflicts(conflicts: Conflict[], strategy: ConflictStrategy): Promise<ResolutionResult[]>;
1401
- /** 충돌 미리보기용 통합 diff(원본 vs 로컬 vs 원격). 해소 없이 표시 전용. */
1402
- generateConflictDiff(conflict: Conflict): string;
1403
2122
  /**
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
- * · 볼트 파일 — 추적되지 않는 사용자 노트가 놓여 있으면 피한다.
1520
- *
1521
- * 선점 장부가 필요한 이유는 pull 이 워커 풀로 **동시 실행**되기 때문이다. 조회와 기록
1522
- * 사이에 await 가 끼면 동명 페이지 여럿이 나란히 "비어 있음"을 보고 같은 경로를 고른다.
1523
- * 그러면 마지막에 쓴 페이지만 남고 나머지 본문이 사라진다(실측 재현: 동명 3페이지 →
1524
- * 파일 1개·레코드 1건). 그래서 마지막 확인과 등록을 **await 없는 동기 구간**에 묶는다 —
1525
- * 그 사이에는 다른 작업이 끼어들 수 없으므로 두 페이지가 같은 경로를 얻는 일이 없다.
1526
- */
1527
- private resolveUniqueFilePath;
2123
+ * 로컬 변경 하나를 지난 동기화 때의 글로 되돌린다 — Git 의 `restore` 와 같다. Notion 은 건드리지
2124
+ * 않는다. 되돌릴 원본이 없는 것은 이유와 함께 거절한다.
2125
+ */
2126
+ discardLocalChange(path: string): Promise<void>;
2127
+ /** 충돌 미리보기용 줄 비교(로컬 vs 원격). 해소 없이 표시 전용. */
2128
+ generateConflictDiff(conflict: Conflict): string;
1528
2129
  }
1529
2130
 
1530
2131
  interface PathFilterConfig {
@@ -1556,6 +2157,32 @@ declare class NodeVaultFS implements VaultFS {
1556
2157
  private walkDir;
1557
2158
  }
1558
2159
 
2160
+ /**
2161
+ * push 가 노트 임베드를 올려 만든 미디어 블록 하나 — Notion 에 저장된 파일과 노트의 임베드를
2162
+ * 잇는다(S-05).
2163
+ *
2164
+ * 예전(R1)에는 올린 블록의 캡션에 임베드 대상을 그대로 적고, pull 이 그 캡션으로 임베드를
2165
+ * 되찾았다. 그래서 Notion 에서 보는 캡션이 `assets/사진.png|300` 같은 경로 · 크기 · 파일
2166
+ * 이름이었다. 이제 캡션에는 사람이 적은 설명만 싣고, 어느 임베드였는지는 여기 적어 둔다.
2167
+ *
2168
+ * 파일 id 로 잇는 이유: Notion 에서 같은 이름의 다른 파일로 바꾸면 id 가 바뀐다. 이름으로 이으면
2169
+ * 바뀐 파일을 옛 로컬 파일로 되돌려 Notion 의 편집을 잃는다.
2170
+ */
2171
+ interface UploadedMedia {
2172
+ /** Notion 이 그 파일을 저장한 id(`notionFileIdOf`) — 블록 id · file_upload id 와 다르다. */
2173
+ readonly fileId: string;
2174
+ /** 노트에 적혀 있던 임베드 대상 그대로 — `경로|별칭`. */
2175
+ readonly target: string;
2176
+ /** 올린 볼트 파일(해석한 경로) — pull 이 아직 있는지 보고 되살린다. */
2177
+ readonly localPath: string;
2178
+ /**
2179
+ * 올린 · 받은 파일의 전체 sha256. 볼트의 그 파일이 아직 이 바이트면 본문 push 가 다시 올리지 않고
2180
+ * Notion 의 그 블록을 둔다(`ImageHandler.keepUploadedMedia`). 이 값을 적기 전에 적은 기록에는
2181
+ * 없다 — 그 미디어는 한 번 더 올리고 그때 적는다.
2182
+ */
2183
+ readonly fileHash?: string;
2184
+ }
2185
+
1559
2186
  /** 미디어(이미지/파일) 다운로드 운영 튜닝값. 미지정 시 기존 동작과 동일한 기본값 사용. */
1560
2187
  interface MediaOptions {
1561
2188
  readonly concurrency?: number;
@@ -1570,6 +2197,8 @@ interface ImageDownloadResult {
1570
2197
  readonly localPath: string;
1571
2198
  readonly hash: string;
1572
2199
  readonly size: number;
2200
+ /** 받은 파일의 전체 sha256 — 받은 미디어 기록에 적어 push 가 그 파일이 그대로인지 본다. */
2201
+ readonly fileHash?: string;
1573
2202
  }
1574
2203
  interface ImageUploadResult {
1575
2204
  readonly localPath: string;
@@ -1578,11 +2207,23 @@ interface ImageUploadResult {
1578
2207
  readonly fileHash: string;
1579
2208
  readonly fileSize: number;
1580
2209
  }
2210
+ /** {@link ImageHandler.keepUploadedMedia} 의 결과. */
2211
+ interface KeptMedia {
2212
+ /** 보낼 본문 — 다시 올리지 않아도 되는 자리표시자를 Notion 의 그 파일로 바꿨다. */
2213
+ readonly markdown: string;
2214
+ /** 그대로 둔 미디어 — 본문을 보낸 뒤 올린 미디어 기록에 다시 적는다. */
2215
+ readonly kept: readonly UploadedMedia[];
2216
+ }
1581
2217
  /** 제자리 교체 결과 — 어떤 임베드 대상이 처리됐는지 호출자가 알아야 중복 업로드를 막는다. */
1582
2218
  interface MaterializeResult {
1583
2219
  readonly uploaded: ImageUploadResult[];
1584
2220
  /** 실제 블록으로 바뀐 임베드 대상(원본 문자열 + 별칭 제거형 둘 다 담는다). */
1585
2221
  readonly handledTargets: Set<string>;
2222
+ /**
2223
+ * 자리표시자를 찾아 본문을 고치려 했다 — 도중에 실패했어도 페이지가 바뀌었을 수 있다. 호출측은
2224
+ * 보낸 본문으로 만든 지문을 버리고 다시 받는다.
2225
+ */
2226
+ readonly touched: boolean;
1586
2227
  }
1587
2228
  declare class ImageHandler {
1588
2229
  private readonly vaultFs;
@@ -1614,12 +2255,64 @@ declare class ImageHandler {
1614
2255
  */
1615
2256
  private resolveOriginalTarget;
1616
2257
  private persistImage;
1617
- downloadAllImages(markdown: string, pageTitle: string, pageId?: string, notePath?: string): Promise<{
2258
+ /**
2259
+ * push 가 올린 미디어를 노트에 적혀 있던 임베드로 되돌린다 — 아무것도 내려받지 않는다(S-05).
2260
+ *
2261
+ * push 는 임베드 하나를 제자리 블록 하나로 올리고, Notion 이 그 파일을 저장한 id 를 임베드
2262
+ * 대상과 함께 적어 둔다({@link rememberUploadedMedia}). 여기서는 받은 본문의 미디어 URL 에서
2263
+ * 그 id 를 읽어 대상을 되찾는다. 캡션에 경로를 적지 않아도 되고, Notion 에서 같은 이름의 다른
2264
+ * 파일로 바꾼 것(id 가 다르다)을 옛 로컬 파일로 되돌리지도 않는다.
2265
+ *
2266
+ * 적어 둔 것이 없으면 옛 push 처럼 캡션이 임베드 대상인지 본다(R1). 둘 다 아니면 그대로 두고,
2267
+ * 뒤따르는 {@link downloadAllImages} · {@link downloadAllFiles} 가 사본을 받는다.
2268
+ *
2269
+ * 내려받지 않으므로 표시 전용 렌더(diff)나 이미지 다운로드를 끈 설정에서도 부른다.
2270
+ *
2271
+ * @param pageId 본문을 받은 페이지 — 그 페이지에 올린 것만 되찾는다
2272
+ * @param notePath 노트 경로 — 옛 캡션의 파일을 push 와 같은 사다리로 찾는다
2273
+ */
2274
+ restoreUploadedMedia(markdown: string, pageId?: string, notePath?: string): Promise<string>;
2275
+ /** 적어 둔 업로드면 되살릴 임베드 대상. 볼트에서 그 파일이 사라졌으면 null — 사본을 받는다. */
2276
+ private knownEmbedTarget;
2277
+ /** 미디어 URL 이 가리키는 파일 이름 — 서명 URL 은 경로 끝, 내부 참조는 `attachment:` 뒤. */
2278
+ private uploadedNameOf;
2279
+ downloadAllImages(markdown: string, pageTitle: string, pageId?: string): Promise<{
1618
2280
  content: string;
1619
2281
  downloads: ImageDownloadResult[];
1620
2282
  }>;
1621
2283
  uploadLocalImage(localPath: string, noteFolder?: string): Promise<ImageUploadResult>;
1622
2284
  uploadAndAppendImages(pageId: string, images: ImageReference[], notePath?: string): Promise<ImageUploadResult[]>;
2285
+ /**
2286
+ * 본문을 보내기 전에, 다시 올리지 않아도 되는 미디어 자리표시자를 Notion 에 이미 있는 그 파일로
2287
+ * 바꾼다.
2288
+ *
2289
+ * 본문 push 는 자리표시자를 심어 본문을 통째로 바꾸고, 바꾼 뒤 자리표시자를 하나씩 올려 블록으로
2290
+ * 바꾼다({@link materializeLocalMedia}). 그래서 글 한 줄만 고쳐도 그 페이지의 미디어를 모두 다시
2291
+ * 올렸다 — 업로드 요청이 미디어마다 셋이고, Notion 의 블록 · 파일이 새로 바뀌었다. 받은 본문의 미디어
2292
+ * URL 을 그대로 실어 본문을 바꾸면 Notion 은 그 블록을 그대로 둔다 — 블록 id · 파일이 같고 캡션만
2293
+ * 실은 글로 바뀐다. 자리를 옮겨도, 서명(쿼리)을 떼도 같다(2026-10-12 실측).
2294
+ *
2295
+ * 둘 다 맞는 자리표시자만 바꾼다 — 나머지는 예전처럼 올린다.
2296
+ * - 그 페이지의 미디어 기록에 같은 경로의 임베드가 있고, 받은 본문에 그 파일이 지금 있다
2297
+ * - 볼트의 그 파일이 적어 둔 지문과 같다 — 같은 이름에 다른 내용이면 올려야 한다
2298
+ *
2299
+ * 기록이 없거나(이 기능 이전에 올린 미디어) 본문을 읽지 못하면 아무것도 바꾸지 않는다 — 다시 올릴
2300
+ * 뿐 잃는 것은 없다.
2301
+ *
2302
+ * @param markdown 보낼 본문(Notion markdown) — 자리표시자가 줄 단독으로 있다
2303
+ */
2304
+ keepUploadedMedia(pageId: string, markdown: string, notePath?: string): Promise<KeptMedia>;
2305
+ /**
2306
+ * 자리표시자 자리에 실을 Notion 미디어 한 줄. 종류 · 캡션 · URL 을 그대로 실을 수 없거나 볼트의 파일이
2307
+ * 올린 것과 다르면 null — 그 자리표시자는 다시 올린다.
2308
+ */
2309
+ private keptMediaLine;
2310
+ /**
2311
+ * 그대로 둔 미디어에 실을 캡션 — 임베드의 설명. 캡션 없는 파일을 받으면 올라간 이름을 별칭으로
2312
+ * 적으므로({@link fileNameOf}), Notion 에 캡션이 없고 별칭이 그 이름이면 캡션을 비운 채 둔다 — 실으면
2313
+ * 받은 파일마다 이름이 캡션으로 붙는다.
2314
+ */
2315
+ private keptCaptionOf;
1623
2316
  /**
1624
2317
  * push 가 심어 둔 자리표시자 quote 를 **제자리에서** 실제 image/file 블록으로 바꾼다(R1).
1625
2318
  *
@@ -1629,13 +2322,20 @@ declare class ImageHandler {
1629
2322
  * 텍스트로 새고 있었고 그중 964건(98.6%)이 이 미디어 쌍이었다. 사용자가 Notion 에서
1630
2323
  * 보는 화면이 깨질 뿐 아니라 이미지가 본문 흐름에서 이탈한다.
1631
2324
  *
1632
- * 캡션에는 원본 임베드 대상을 그대로 적는다. pull 이 경로(와 `|별칭`)를 되찾는
1633
- * 진실원이 되고, 사용자에게도 출처가 보인다.
2325
+ * 캡션에는 사람이 적은 설명만 단다({@link captionOfEmbed}). 어느 임베드였는지는 Notion 이
2326
+ * 그 파일을 저장한 id 와 함께 적어 두고({@link rememberUploadedMedia}), pull 이 그것으로
2327
+ * 임베드를 되찾는다(S-05). 예전에는 캡션에 임베드 대상을 그대로 적어 Notion 에 경로 · 크기 ·
2328
+ * 파일 이름이 보였다.
1634
2329
  *
1635
2330
  * 볼트에서 파일을 못 찾으면 자리표시자를 그대로 둔다 — 마커가 남아야 pull 이
1636
2331
  * 임베드를 복원할 수 있으므로, 반쯤 지우는 것보다 안전하다.
1637
2332
  */
1638
- materializeLocalMedia(pageId: string, pushedMarkdown: string, notePath?: string): Promise<MaterializeResult>;
2333
+ materializeLocalMedia(pageId: string, pushedMarkdown: string, notePath?: string, kept?: readonly UploadedMedia[]): Promise<MaterializeResult>;
2334
+ /**
2335
+ * 방금 만든 미디어 블록의 파일을 Notion 이 저장한 id. 만든 응답에 파일 URL 이 실려 오고,
2336
+ * 없으면 블록을 한 번 조회한다. 끝내 모르면 null — 던지지 않는다(블록은 이미 제자리에 있다).
2337
+ */
2338
+ private storedFileIdOf;
1639
2339
  /**
1640
2340
  * 페이지 블록 트리를 훑어 자리표시자를 부모와 함께 모은다. 콜아웃·토글·목록 안에
1641
2341
  * 들어간 임베드도 제자리 교체하려면 부모 id 가 필요하다(append 의 after_block 은
@@ -1648,6 +2348,17 @@ declare class ImageHandler {
1648
2348
  * 재업로드하는 중복을 공유 레지스트리로 차단한다.
1649
2349
  */
1650
2350
  private registerUploads;
2351
+ /**
2352
+ * 올린 미디어를 임베드와 함께 적어 둔다(S-05). 적지 못해도 push 는 깨뜨리지 않는다 — 블록은
2353
+ * 이미 제자리에 있고, 다음 pull 이 그 파일을 알아보지 못해 사본을 받을 뿐이다.
2354
+ */
2355
+ private rememberUploads;
2356
+ /**
2357
+ * 받은 미디어를 그 페이지의 미디어 기록에 더한다 — 그 노트를 고쳐 push 할 때 Notion 의 원래 블록을
2358
+ * 둔다({@link keepUploadedMedia}). 적지 못해도 pull 은 깨뜨리지 않는다 — 그 노트를 push 하면 예전처럼
2359
+ * 다시 올릴 뿐이다.
2360
+ */
2361
+ private rememberReceived;
1651
2362
  /**
1652
2363
  * 임베드 경로가 가리킬 수 있는 볼트 실제 파일 후보를 우선순위대로 만든다(F23).
1653
2364
  * Obsidian 위키링크 임베드는 노트 옆 파일을 파일명만으로 참조하는 게 관례라
@@ -1664,7 +2375,7 @@ declare class ImageHandler {
1664
2375
  * 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
1665
2376
  */
1666
2377
  private readImageFromVault;
1667
- downloadAllFiles(markdown: string, pageTitle: string, notePath?: string): Promise<{
2378
+ downloadAllFiles(markdown: string, pageTitle: string, pageId?: string): Promise<{
1668
2379
  content: string;
1669
2380
  downloads: ImageDownloadResult[];
1670
2381
  }>;
@@ -1743,10 +2454,15 @@ declare class FileHandler {
1743
2454
  */
1744
2455
  private pushSingleFile;
1745
2456
  /**
1746
- * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 제외한다.
1747
- * node 측 VaultFS 는 과거 `.base` 를 워커에서 빼는 식으로 우회했지만, Obsidian
1748
- * 어댑터는 모든 파일을 반환하므로 플러그인 push 가 사이드카를 Notion 첨부로
1749
- * 오염시켰다. 소비자인 여기서 일괄 차단해 구현체 간 동작을 통일한다.
2457
+ * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 아예 빼고, Notion 이 받지 않는
2458
+ * 형식은 따로 모은다.
2459
+ *
2460
+ * 내부 산출물: node 측 VaultFS 는 과거 `.base` 를 워커에서 빼는 식으로 우회했지만, Obsidian
2461
+ * 어댑터는 모든 파일을 반환하므로 플러그인 push 가 사이드카를 Notion 첨부로 오염시켰다.
2462
+ * 소비자인 여기서 일괄 차단해 구현체 간 동작을 통일한다.
2463
+ *
2464
+ * 받지 않는 형식(S-14): 올리면 업로드를 만들 때 400 으로 거절된다. 등록되지 않으니 push 할
2465
+ * 때마다 같은 요청을 다시 보내 같은 이유로 실패했다(실볼트 `.ipynb` 22개).
1750
2466
  */
1751
2467
  private listUploadableFiles;
1752
2468
  pushAllFiles(): Promise<FileUploadResult[]>;
@@ -1771,17 +2487,112 @@ declare class ConversionPipeline {
1771
2487
  convertToMarkdown(content: string, context: ConversionContext, metadata?: ProcessorMetadata): string;
1772
2488
  }
1773
2489
 
2490
+ interface DbBaseFile {
2491
+ readonly basePath: string;
2492
+ readonly title: string;
2493
+ }
2494
+ /**
2495
+ * DB 마다 실제로 기록한 `.base` 경로와 DB 제목 — 인라인 DB placeholder 를 `.base` 임베드로 바꿀 때
2496
+ * 쓴다(F22). 폴더명(하이픈 새니타이즈)과 `.base` 파일명(sanitizeFileName: 공백 · 점 보존)은 규칙이
2497
+ * 달라, 폴더로 추측한 경로는 깨진 임베드가 된다(E2E 실측 91/158건).
2498
+ *
2499
+ * 예전에는 이번 실행이 기록한 것만 메모리에 두었다 — pull 마다 모든 DB 의 `.base` 를 다시 썼기
2500
+ * 때문이다. 이제는 바뀐 DB 만 조회하므로(ADR-027) 이번에 조회하지 않은 DB 의 것도 알아야 한다. 그래서
2501
+ * 상태 DB 에 남긴다. 값이 바뀔 때만 쓴다 — 전체 대조는 DB 마다 한 번씩 부른다.
2502
+ */
2503
+ declare class DbBaseFiles {
2504
+ private readonly stateDb;
2505
+ private entries;
2506
+ constructor(stateDb: Pick<IStateDB, "getMeta" | "setMeta">);
2507
+ get(databaseId: string): DbBaseFile | undefined;
2508
+ set(databaseId: string, info: DbBaseFile): void;
2509
+ /** 상태 메타에서 한 번 읽는다. 없거나 깨졌으면 비어 있다 — 이번 실행이 기록하는 것부터 다시 쌓는다. */
2510
+ private load;
2511
+ }
2512
+
2513
+ /**
2514
+ * 원격을 얼마나 훑을지 — 전체 대조인가, 바뀐 것만인가 (ADR-027).
2515
+ *
2516
+ * 전체 대조는 루트 아래 페이지를 모두 순회하고 동기화하는 DB 를 모두 조회한다. 원격에서 지운 것 ·
2517
+ * 범위 밖으로 옮긴 것은 이것으로만 보인다 — search 는 휴지통을 돌려주지 않는다. 대신 비싸다: 실볼트
2518
+ * (발견 DB 166개)에서 원격이 그대로인데도 재pull 이 523.5초 걸렸다(실측 2026-09-28). 예전에는
2519
+ * `deleteSync` 가 켜져 있으면 pull · 상태 확인마다 전체 대조를 했고, 플러그인은 늘 켠다.
2520
+ *
2521
+ * 그래서 전체 대조는 주기(`sync.fullReconcileInterval`)마다만 하고, 그 사이에는 지난 pull 뒤에 바뀐
2522
+ * 페이지 · 행 · DB 스키마만 search 로 찾아 그 DB 만 조회한다. 원격 삭제는 다음 전체 대조가 반영한다.
2523
+ */
2524
+
2525
+ /**
2526
+ * 이번 pull 이 조회할 DB. `all` — 전체 대조. `changed` — 바뀐 것이 보인 DB 만(id 는
2527
+ * {@link compactNotionId}). 새로 발견한 DB 는 어느 쪽이든 조회한다 — 받은 적이 없다.
2528
+ */
2529
+ type DatabaseSelection = {
2530
+ readonly kind: "all";
2531
+ } | {
2532
+ readonly kind: "changed";
2533
+ readonly ids: ReadonlySet<string>;
2534
+ };
2535
+ /**
2536
+ * DB 마다 이번 pull 이 어떻게 끝났나 — 다음 pull 이 바뀐 것이 없어도 다시 조회할 DB
2537
+ * ({@link PENDING_DATABASES_META_KEY})를 가른다.
2538
+ *
2539
+ * 조회하다 실패했거나 · 받지 못한 행이 있거나 · 취소로 닿지 못한 DB 는 대기로 남는다. 받았거나 · 이제
2540
+ * 없어서 뺀 DB 는 대기에서 빠진다. 이번에 조회하지 않은 DB 의 대기는 그대로 남는다.
2541
+ */
2542
+ declare class DatabasePullLedger {
2543
+ private readonly selection;
2544
+ private readonly pending;
2545
+ private readonly settled;
2546
+ private readonly unfinished;
2547
+ private skippedCount;
2548
+ /** 이제 동기화하는 DB — 알면 대기를 이것으로 좁힌다. */
2549
+ private syncing;
2550
+ constructor(selection: DatabaseSelection, pending: ReadonlySet<string>);
2551
+ /** 이 DB 를 조회하나 — 새로 발견한 DB 는 부른 쪽이 따로 조회한다(받은 적이 없다). */
2552
+ selects(databaseId: string): boolean;
2553
+ /** 바뀐 것이 보이지 않아 조회하지 않았다. */
2554
+ skip(): void;
2555
+ /** 받았거나 · 이제 없어서 뺐다 — 다시 조회할 것이 없다. */
2556
+ settle(databaseId: string): void;
2557
+ /** 조회하다 실패했거나 · 받지 못한 행이 있거나 · 취소로 닿지 못했다 — 다음 pull 이 다시 조회한다. */
2558
+ retry(databaseId: string): void;
2559
+ get skipped(): number;
2560
+ /**
2561
+ * 이제 동기화하는 DB 를 알린다 — 대기를 이것으로 좁힌다. 설정에서 뺐거나 접근 불가로 뺀 DB 를 영영
2562
+ * 들고 있지 않는다. 알리지 않으면(발견이 멈춤 등) 좁히지 않는다 — 모르는 것을 버리지 않는다.
2563
+ */
2564
+ keepOnly(databaseIds: Iterable<string>): void;
2565
+ /** 다음 pull 의 대기 — 지난 대기에서 받은 것을 빼고 이번에 받지 못한 것을 더한다. */
2566
+ nextPending(): Set<string>;
2567
+ }
2568
+
2569
+ /** 한 번의 동기화가 원격을 보는 기준 — 판정 · 기록에 함께 쓴다. */
2570
+ interface ObservationContext {
2571
+ /**
2572
+ * 이번 동기화가 시작한 시각. 원격을 «본» 시각으로 적는다 — 실제로 본 시각보다 이르거나 같아서
2573
+ * 가라앉았다고 서둘러 보지 않는다. 동기화 밖이면 null(가라앉았다고 보지 않는다).
2574
+ */
2575
+ readonly seenAt: string | null;
2576
+ /** 이 토큰의 봇 id. 받지 못했으면 null — 봇 규칙 없이 가른다. */
2577
+ readonly botUserId: string | null;
2578
+ }
2579
+
1774
2580
  interface DatabaseSyncResult {
1775
2581
  created: number;
1776
2582
  updated: number;
1777
- /** Pull 시 로컬·리모트 동시 수정으로 발생한 충돌 (push 는 항상 빈 배열). */
2583
+ /**
2584
+ * Notion 에서 지워져 볼트에서도 지운 행 수(deleteSync). 행은 DB 조회로만 보여, 행의 삭제는 전체
2585
+ * 대조가 아니라 여기서 가른다(S-12).
2586
+ */
2587
+ deleted: number;
2588
+ /** Pull 시 로컬·리모트 동시 수정으로 발생한 충돌. */
1778
2589
  conflicts: Conflict[];
1779
2590
  failed: FailedOperation[];
1780
2591
  /**
1781
2592
  * 이번 pull 에서 실제로 디스크에 기록한 db-row 파일 경로(SSOT).
1782
2593
  * 오케스트레이터의 링크 해소(resolveNotionLinks) 대상은 반드시 이 목록으로 정한다 —
1783
2594
  * 과거의 `getByStatus("synced").slice(-N)` 휴리스틱은 ORDER BY 가 없어 증분 pull 에서
1784
- * 엉뚱한 행을 골라 정작 바뀐 행의 relation UUID 를 영영 해소하지 못했다(M3). push 경로는 항상 빈 배열.
2595
+ * 엉뚱한 행을 골라 정작 바뀐 행의 relation UUID 를 영영 해소하지 못했다(M3).
1785
2596
  */
1786
2597
  writtenPaths: string[];
1787
2598
  /**
@@ -1797,6 +2608,22 @@ interface DatabaseSyncResult {
1797
2608
  */
1798
2609
  linkedOriginalDbId?: string;
1799
2610
  }
2611
+ /** dry-run 이 센 행 하나 — 경로와 할 일. `restore` 는 볼트에서 사라진 행을 되살리는 것이다. */
2612
+ interface PlannedRow {
2613
+ readonly pageId: string;
2614
+ readonly path: string;
2615
+ readonly operation: "create" | "update" | "delete";
2616
+ readonly restore?: boolean;
2617
+ }
2618
+ /**
2619
+ * 행을 받는 진행 — 진행 표시가 페이지와 행을 한 목록 · 한 수로 보이게 한다. 행은 DB 를 조회해야 몇
2620
+ * 개인지 알아, 받기 전에 이 DB 에서 받을 행 수를 `planned` 로 알리고 행 하나를 마칠 때마다 `done` 을
2621
+ * 부른다. 지운 행은 조회에 없어 미리 셀 수 없다 — 지울 때 `planned(1)` 뒤에 `done` 을 부른다.
2622
+ */
2623
+ interface RowProgress {
2624
+ planned(count: number): void;
2625
+ done(item: ProgressItem): void;
2626
+ }
1800
2627
  declare class DatabaseSyncer {
1801
2628
  private readonly config;
1802
2629
  private readonly stateDb;
@@ -1804,23 +2631,33 @@ declare class DatabaseSyncer {
1804
2631
  private readonly vaultFs;
1805
2632
  private readonly pipeline;
1806
2633
  private readonly imageHandler;
2634
+ /**
2635
+ * 이번 동기화가 원격을 보는 기준(N-05) — 오케스트레이터가 실행마다 정한다. 따로 쓰면(시험)
2636
+ * 아무것도 가라앉았다고 보지 않는다 — 수정 시각이 같은 행도 받아서 견준다.
2637
+ */
2638
+ private readonly observation;
1807
2639
  private readonly propertyMapper;
1808
2640
  private readonly baseFileGenerator;
1809
2641
  private readonly sidecarGenerator;
1810
2642
  /**
1811
- * 이번 프로세스가 실제로 기록한 .base 경로/DB 제목 (databaseId nohyph → info).
1812
- * placeholder 임베드 재작성(F22)의 SSOT — 폴더명(하이픈 새니타이즈)과 .base 파일명
1813
- * (sanitizeFileName: 공백·점 보존)은 규칙이 달라 localFolder 로 추측한 경로는 깨진
1814
- * 임베드가 된다. generateBaseFile 은 매 pull 모든 DB 에 대해 실행되므로 pull 종료
1815
- * 시점에는 성공한 DB 전체가 채워져 있다.
2643
+ * 실제로 기록한 .base 경로/DB 제목 — placeholder 임베드 재작성(F22)의 SSOT. 이번 pull 이 조회하지
2644
+ * 않은 DB 의 것도 남아 있다({@link DbBaseFiles}).
1816
2645
  */
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>;
2646
+ readonly baseFileInfo: DbBaseFiles;
2647
+ constructor(config: Config, stateDb: IStateDB, notionClient: NotionClient, vaultFs: VaultFS, pipeline: ConversionPipeline, imageHandler: ImageHandler,
2648
+ /**
2649
+ * 이번 동기화가 원격을 보는 기준(N-05) — 오케스트레이터가 실행마다 정한다. 따로 쓰면(시험)
2650
+ * 아무것도 가라앉았다고 보지 않는다 — 수정 시각이 같은 행도 받아서 견준다.
2651
+ */
2652
+ observation?: () => ObservationContext);
2653
+ pullAll(opts?: {
2654
+ readonly paths?: readonly string[];
2655
+ readonly progress?: RowProgress;
2656
+ /** 조회할 DB 와 DB 마다의 결과(ADR-027). 없으면 모든 DB 를 조회한다. */
2657
+ readonly ledger?: DatabasePullLedger;
2658
+ /** 취소하면 다음 DB 를 조회하지 않는다 — 닿지 못한 DB 는 다음 pull 이 조회한다. */
2659
+ readonly signal?: AbortLike;
2660
+ }): Promise<DatabaseSyncResult>;
1824
2661
  pullDatabase(dbConfig: DatabaseSyncConfig, opts?: {
1825
2662
  /**
1826
2663
  * F25: database id(하이픈 유무 무관) → 그 DB 행이 사는 로컬 폴더. 디스커버리 캐시가
@@ -1828,8 +2665,100 @@ declare class DatabaseSyncer {
1828
2665
  * 경우에만 행 소유를 양보한다 — 원본이 미발견이면 이 컨테이너가 유일한 접근 통로다.
1829
2666
  */
1830
2667
  resolveDbFolder?: (databaseId: string) => string | null;
2668
+ /**
2669
+ * 이 범위의 행만 받고 지운다(`pull --path` · 변경 패널의 항목별 받기). 없으면 모든 행.
2670
+ * 새 행은 DB 폴더 전체가 범위일 때만 만든다 — 받기 전에는 어느 경로에 쓸지 모른다.
2671
+ */
2672
+ paths?: readonly string[];
2673
+ /** 받은 행 · 지운 행을 하나씩 알린다. 예전에는 pull 진행 표시에 행이 하나도 보이지 않았다. */
2674
+ progress?: RowProgress;
1831
2675
  }): Promise<DatabaseSyncResult>;
2676
+ /**
2677
+ * 받을 행을 세기만 한다(dry-run) — 볼트 · 상태 DB · Notion 을 바꾸지 않는다. 조회 · 범위 · 받을
2678
+ * 행 판정 · 삭제 확인은 {@link pullDatabase} 와 같다.
2679
+ *
2680
+ * 행 본문은 읽지 않는다. 그래서 같은 분 안의 편집(«확인 안 됨»)은 고침으로 세고, 받아 보고 같으면
2681
+ * 쓰지 않을 행 · local-first 로 지킬 행 · 충돌로 남길 행도 고침으로 센다. 새 행의 경로는 pull 과 같은
2682
+ * 차례(ID 순)로 고른다 — 이번에 만들 행끼리 이름이 겹치면 뒤 차례에 id 가 붙는다.
2683
+ */
2684
+ planDatabase(dbConfig: DatabaseSyncConfig, opts?: {
2685
+ resolveDbFolder?: (databaseId: string) => string | null;
2686
+ paths?: readonly string[];
2687
+ }): Promise<PlannedRow[]>;
2688
+ /**
2689
+ * 이 DB 에서 볼 행을 조회한다. 범위(`paths`)가 이 DB 폴더에 닿지 않으면 조회하지 않는다 — 예전에는
2690
+ * 범위와 상관없이 모든 행을 만들고 고쳤다.
2691
+ *
2692
+ * F25: 행 parent 로 data source 의 원본 database 를 검증한다(추가 API 0회). 신 모델에선
2693
+ * linked view 컨테이너도 data_sources 가 채워져 retrieve 만으론 원본과 구분되지 않아
2694
+ * 캐시에 오등록될 수 있는데, 그대로 두면 같은 행 집합을 여러 config 가 각자 자기 폴더로
2695
+ * 릴레이 재배치해 원격 무변경에도 매 pull 재작성이 쌓인다(실측 66건/pull, 행당 최대 4중).
2696
+ * 컨테이너로 판정되면 행은 원본에 양보하고 .base 만 원본 폴더 필터로 재지향해
2697
+ * "같은 데이터의 다른 뷰"라는 Notion 의미를 보존한다.
2698
+ *
2699
+ * DB 는 한 번만 받는다(`meta`) — 행 조회 · 스키마 · `.base` 제목이 이것을 같이 읽는다. 예전에는 같은 DB 를
2700
+ * 세 번 넘게 다시 받아 DB 하나에 요청 6~8회가 들었다(ADR-027).
2701
+ */
2702
+ private queryRows;
2703
+ /**
2704
+ * 조회한 행 하나에 할 일 — 건너뜀 · 되살림 · 받음. 범위가 행 몇 개면 그 행만 받고 새 행은
2705
+ * 만들지 않는다(어느 경로에 쓸지 받기 전에는 모른다).
2706
+ *
2707
+ * 리모트가 지난번에 본 그대로면 건너뜀. 수정 시각이 같아도 같은 분 안의 편집일 수 있으면
2708
+ * («확인 안 됨», N-05) 받아서 견준다 — 같으면 파일을 쓰지 않는다. 단 두 가지 예외가 있다.
2709
+ * · 레코드 경로가 현재 DB 폴더 직속이 아니면(F24 동명 DB 폴더 분리 후 옛 공유 폴더 잔류)
2710
+ * 원격 무변경이어도 재처리해 현 폴더로 재배치한다.
2711
+ * · 로컬 파일이 사라졌으면 되살린다(R13). 이 확인이 없으면 pullDatabasePage 안의 복원
2712
+ * 판정(localExists → resolvePullConflict)에 영영 도달하지 못해 지운 행이 조용히 영구
2713
+ * 소실된다. 페이지 경로는 R0 으로 이미 마감한 계약인데 행 경로만 빠져 있었다 —
2714
+ * 오케스트레이터의 detectMissingLocalFiles 가 "행은 여기서 이미 같은 판정을 거친다"는
2715
+ * (틀린) 전제로 db-row 를 제외해 두었기에 양쪽 어디에도 복원 경로가 없는 상태였다.
2716
+ * deleteSync 가 켜져 있으면 되살리지 않는다 — 지운 것은 Notion 에서도 지우라는 뜻이고, sync 는
2717
+ * pull 을 먼저 돌려 되살리면 뒤이은 push 가 지울 것을 잃는다(detectMissingLocalFiles 와 같다).
2718
+ * · 예전 버전이 적은 날짜 모양이 남은 행은 그 줄만 새 모양으로 고쳐 쓴다({@link outdatedDateForm},
2719
+ * F-08).
2720
+ */
2721
+ private rowAction;
2722
+ /**
2723
+ * 원격이 그대로인 행이지만 예전 버전이 적은 날짜 모양(`…Z` · `…+09:00` · `{start, end}`)이 남아 새
2724
+ * 모양으로 고쳐 쓸 행인가(F-08, {@link PropertyMapper.outdatedDateKeys}). 고쳐 쓰면 사본도 새 모양이
2725
+ * 되어 다음 pull 은 건너뛴다.
2726
+ *
2727
+ * 지난 동기화 뒤 로컬을 고친 행은 고르지 않는다 — 로컬 편집을 지키느라 쓰지 않으니 pull 마다 고르기만
2728
+ * 한다. 그 편집을 올린 뒤의 pull 이 고른다. 로컬 파일을 읽지 못하면 고르지 않는다.
2729
+ */
2730
+ private outdatedDateForm;
2731
+ /**
2732
+ * 예전 버전이 적은 날짜 모양이 남은 행에서 그 키들만 새 모양으로 고쳐 쓴다(F-08) — 원격이 그대로라 본문 ·
2733
+ * 첨부는 다시 받지 않는다. 본문은 한 글자도 바꾸지 않고, 다른 키의 줄은 그대로 둔다(키 단위로 나눠 읽을
2734
+ * 수 없는 frontmatter 는 값만 지킨다 — {@link mergeRowFrontmatter}).
2735
+ *
2736
+ * 예전에는 행을 통째로 다시 받았다. 행마다 본문을 읽어 요청이 늘고, 다시 그릴 때마다 달라지는 본문(빈
2737
+ * 블록 · 첨부 이름)까지 바뀌었다 — 실볼트 사본에서 다시 받은 149행 중 99행.
2738
+ *
2739
+ * 고른 뒤 로컬을 고쳤으면 쓰지 않는다(`skipped`) — 이어지는 push 가 올린다.
2740
+ */
2741
+ private rewriteDateForms;
2742
+ /** 이 DB 의 추적 행 가운데 이번 조회에 없던 것 — 지워졌는지는 아직 모른다. */
2743
+ private unlistedRows;
2744
+ /**
2745
+ * 조회에 없던 이 DB 의 추적 행 가운데 Notion 에서 정말 지워진 것만 볼트에서 지운다(deleteSync, S-12).
2746
+ *
2747
+ * 행은 DB 조회로만 보인다 — 전체 대조(페이지 순회)는 행을 보지 못해 행의 삭제는 여기서 가른다.
2748
+ * 조회에 없다는 것만으로 지우지 않고 행마다 원격에 묻는다({@link remotePresence}): 살아 있는 행은
2749
+ * 다른 DB 로 옮겨졌을 수 있다(그 DB 의 pull 이 옮겨 받는다). 묻지 못한 행은 이번에는 둔다 — 다음
2750
+ * pull 이 다시 묻는다.
2751
+ *
2752
+ * 조회 필터(`pullFilter`)가 있으면 가르지 않는다 — 조회에 없는 행이 필터 밖인지 지워진 것인지
2753
+ * 모르고, 필터 밖으로 나간 행을 매번 물으면 요청이 끝없이 는다.
2754
+ *
2755
+ * 올리지 않은 로컬 편집이 있는 행은 페이지와 같은 규칙을 따른다({@link applyRemoteDeletion}) —
2756
+ * 지우지 않고 충돌로 남기거나 파일을 둔다.
2757
+ */
2758
+ private pullDeletedRows;
1832
2759
  private pullDatabaseViews;
2760
+ /** 행들 가운데 한 번이라도 기간인 날짜 속성 → 그 짝 키. */
2761
+ private rangeEndsOf;
1833
2762
  private generateBaseFile;
1834
2763
  /**
1835
2764
  * DB 제목이 바뀌면 `.base`/`.notion.json` 파일명(safeName)이 바뀌어 옛 이름의 산출물이
@@ -1852,119 +2781,58 @@ declare class DatabaseSyncer {
1852
2781
  * (extractValue 는 URL 만 남긴다). 사용자가 넣은 진짜 외부 URL 은 그대로 둔다.
1853
2782
  */
1854
2783
  private localizeFileProperties;
2784
+ /**
2785
+ * 행 하나를 pull 이 쓰는 모양 그대로 렌더한다 — 파일은 쓰지 않는다.
2786
+ *
2787
+ * pull(pullDatabasePage)과 충돌 비교 · 원격 선택 해소 · 원격 미리보기(오케스트레이터의
2788
+ * renderRemotePage)가 같은 렌더를 봐야 한다. 행을 페이지처럼 렌더하면 커버 · 아이콘 ·
2789
+ * 첨부 속성이 빠지고, 본문 첫머리의 옛 속성 블록 값이 실제 속성을 덮는다(S-02).
2790
+ *
2791
+ * 본문을 읽지 못하면 던진다 — 행은 실패로 남고 레코드가 그대로라 다음 pull 이 다시
2792
+ * 받는다. 예전에는 빈 본문으로 넘어가, 로컬이 그대로인 행은 본문이 지워진 채 «동기화
2793
+ * 완료» 로 기록됐다(S-10). 읽지 못한 것은 비어 있는 것이 아니다.
2794
+ *
2795
+ * @param options.downloadMedia false 면 첨부를 내려받지 않는다(표시 전용). 비교를 보려다
2796
+ * 볼트에 파일이 생기면 안 된다 — 그 대가로 아직 내려받지 않은 첨부는 원격 URL 로 남는다.
2797
+ * @param options.localPath 로컬 노트가 지금 있는 자리 — 코드 펜스 표기를 되살리는 근거(S-20).
2798
+ * 다른 폴더로 옮기기 전이면 옛 경로다. 기본은 `filePath`.
2799
+ */
2800
+ renderRow(page: PageObjectResponse, filePath: string, options?: {
2801
+ downloadMedia?: boolean;
2802
+ localPath?: string;
2803
+ }): Promise<{
2804
+ content: string;
2805
+ title: string;
2806
+ properties: Record<string, unknown>;
2807
+ /** 받은 원격 본문의 지문(`remoteBodyFingerprint`). */
2808
+ bodyFingerprint: string;
2809
+ }>;
2810
+ /**
2811
+ * 행의 본문 밖이 지난 동기화 사본 그대로인가(N-05) — 제목과 보낼 수 있는 속성, 그리고
2812
+ * {@link renderRow} 로 받는 행이면 아이콘 · 커버. push 는 원격을 덮어써도 되는지, pull 은 받을
2813
+ * 것이 있는지를 이 규칙으로 가른다. 읽기 전용 속성(수식 · 롤업 · 수정 시각)은 보지 않는다 —
2814
+ * 로컬에서 고칠 수 없고 push 가 보내지 않는다.
2815
+ *
2816
+ * @param mapper 그 행의 DB 스키마를 읽은 매퍼.
2817
+ * @param base 지난 동기화 시점의 frontmatter.
2818
+ * @param path 지난 동기화 시점의 경로 — 파일 이름을 따르던 제목을 가른다.
2819
+ * @param decorated 아이콘 · 커버도 견주나 — {@link renderRow} 로 받는 행만 그것을 노트에 적는다.
2820
+ */
2821
+ rowOutsideBodyUnchanged(mapper: PropertyMapper, page: PageObjectResponse, base: Readonly<Record<string, unknown>>, path: string, decorated: boolean): boolean;
2822
+ /**
2823
+ * 원격 행의 아이콘 · 커버가 `frontmatter` 에 적힌 그대로인가 — {@link renderRow} 가 적는 모양으로
2824
+ * 견준다(N-05). Notion 에 올린 파일은 읽을 때마다 주소가 바뀌고(서명) 커버는 볼트로 내려받아
2825
+ * 적으므로, 있는지만 견준다.
2826
+ */
2827
+ private decorationsUnchanged;
2828
+ /** 레코드가 지난번에 본 원격 행과 지금 행을 견준다 — 이번 동기화의 기준으로(N-05). */
2829
+ private remoteVerdict;
2830
+ /**
2831
+ * 행 경로 후보의 주인 — 상태 DB 의 레코드, 없으면 볼트에 이미 있는 파일(주인 모름). 추적하지 않는 로컬 파일
2832
+ * (아직 push 하지 않은 노트)을 새 행이 덮어쓰면 그 글이 사라졌다. 일반 페이지 받기(`claimFilePath`)처럼 피한다.
2833
+ */
2834
+ private rowPathOwners;
1855
2835
  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
2836
  }
1969
2837
 
1970
2838
  declare class FrontmatterExtractor implements Processor {
@@ -1994,6 +2862,28 @@ declare class InlineDBParser implements Processor {
1994
2862
  process(input: ProcessorInput): ProcessorOutput;
1995
2863
  }
1996
2864
 
2865
+ /**
2866
+ * 블록 수식(`$$…$$`)의 두 `$$` 를 저마다 제 줄에 세운다 — Notion 은 `$$` 가 줄에 혼자 있을 때만 블록
2867
+ * 수식으로 읽는다. 한 줄로 쓴 `$$ x^2 $$` · 식이 `$$` 줄에 붙은 `$$ a` … `b $$` 는 빈 인라인 수식과
2868
+ * 글이 된다(실측).
2869
+ *
2870
+ * 콜아웃 · 목록 안의 수식은 새 줄에도 줄머리(`> ` · 들여쓰기)를 단다. 예전에는 닫는 `$$` 를 줄머리
2871
+ * 없이 세워 콜아웃 밖으로 꺼냈고, Notion 은 그 `$$` 부터 뒤 문단을 수식으로 삼켰다(실측).
2872
+ * 글 사이의 `$$x$$` 는 둔다 — Notion 은 인라인 수식으로 읽는데, 줄을 가르면 빈 수식과 글이 된다.
2873
+ *
2874
+ * 코드 펜스 · 인라인 코드 안의 `$` 는 코드다. 셸의 `$$`(PID) · `$HOME` 을 수식으로 보면 Notion 의
2875
+ * 코드가 바뀌고, 코드 속 `$$` 가 코드 밖 수식의 `$$` 와 짝지어져 그 사이 본문까지 옮겨졌다(S-21).
2876
+ *
2877
+ * 블록 수식으로 서지 못하는 `$$` 는 Notion 이 빈 인라인 수식으로 읽는다(2026-10-21 실측) — 짝이 없는 `$$` 는
2878
+ * Obsidian 이 글로 그리는데 Notion 에는 빈 수식 상자가 생겼다. 그래서 줄 안에서 짝이 없는 `$$` 는 `\$\$`(두 쪽 모두
2879
+ * 글 `$$`)로, 글과 한 줄인 `$$ x $$` 는 안쪽 공백을 떼어 `$$x$$`(Notion 의 인라인 수식)로 보낸다
2880
+ * ({@link settleLineDollars}). 제 줄의 `$$` 는 블록 수식의 경계라 둔다.
2881
+ *
2882
+ * 인라인 수식(`$…$`)의 안쪽은 건드리지 않는다. 예전에는 안쪽 공백을 잘라 `$ x $` 가 수식이 되고
2883
+ * `$5 and $10` 이 `$5 and$10` 으로 바뀌었다. Obsidian 과 Notion 은 여는 `$` 뒤 · 닫는 `$` 앞이
2884
+ * 공백이거나 닫는 `$` 뒤가 숫자면 수식으로 읽지 않는다(Obsidian 1.13.7 의 토크나이저 · Notion 실측).
2885
+ * 다만 Notion 은 글자에 붙은 수식(`w$^9$가`)도 글로 읽는다 — 그런 수식만 `` $`식`$ `` 로 보낸다({@link quoteGluedMath}).
2886
+ */
1997
2887
  declare class MathNormalizer implements Processor {
1998
2888
  readonly name = "MathNormalizer";
1999
2889
  readonly order = 50;
@@ -2004,6 +2894,10 @@ declare class EmbedResolver implements Processor {
2004
2894
  readonly name = "EmbedResolver";
2005
2895
  readonly order = 60;
2006
2896
  process(input: ProcessorInput): ProcessorOutput;
2897
+ /** `![[대상]]` — 첨부는 자리표시자로(push), 노트 임베드는 원문 그대로. */
2898
+ private resolveEmbeds;
2899
+ /** `![설명](url)` — 동영상 URL 은 임베드 마커로, 외부 이미지는 목록에 모은다. */
2900
+ private resolveMarkdownImages;
2007
2901
  }
2008
2902
 
2009
2903
  declare class PreserveMarkerCollector implements Processor {
@@ -2018,6 +2912,20 @@ declare class UnsupportedBlockStripper implements Processor {
2018
2912
  process(input: ProcessorInput): ProcessorOutput;
2019
2913
  }
2020
2914
 
2915
+ /**
2916
+ * 보내기 직전에 코드 밖의 이어진 빈 줄을 한 줄로 줄인다 — Notion 마크다운 가져오기는 남는 빈 줄마다 빈
2917
+ * 블록을 만든다(실측: 주석 한 줄을 지운 자리의 빈 줄 셋이 빈 블록 셋). 받으면 그 빈 블록이 `<br>` 줄이 된다.
2918
+ *
2919
+ * 노트의 빈 줄과, 앞 전처리가 지운 주석 · 지원하지 않는 블록이 남긴 빈 줄을 함께 줄인다. 그래서 내용을
2920
+ * 지우거나 끼우는 전처리 뒤, 보존 마커 수집(70) 앞에 돈다. 예전에는 지원하지 않는 블록 제거(5)가 줄였다 —
2921
+ * 주석 제거(11)보다 먼저라 주석 자리가 빈 블록이 됐고, 코드 속 빈 줄까지 줄였다(S-27).
2922
+ */
2923
+ declare class BlankLineCollapser implements Processor {
2924
+ readonly name = "BlankLineCollapser";
2925
+ readonly order = 65;
2926
+ process(input: ProcessorInput): ProcessorOutput;
2927
+ }
2928
+
2021
2929
  declare class PropertiesTableInjector implements Processor {
2022
2930
  readonly name = "PropertiesTableInjector";
2023
2931
  readonly order = 15;
@@ -2025,7 +2933,7 @@ declare class PropertiesTableInjector implements Processor {
2025
2933
  }
2026
2934
 
2027
2935
  /**
2028
- * 인라인 annotation(underline·color) 무손실 보존기 — push 전처리(order 5).
2936
+ * 인라인 annotation(underline·color) 무손실 보존기 — push 전처리(order 13).
2029
2937
  *
2030
2938
  * 과거에는 underline/color 표기를 **제거**(strip)했으나, 그러면 Notion 으로 push 할 때
2031
2939
  * 서식이 영구 소실됐다(I3 손실점). Notion 의 Enhanced Markdown 은 underline/color 를
@@ -2044,7 +2952,7 @@ declare class PropertiesTableInjector implements Processor {
2044
2952
  */
2045
2953
  declare class InlineAnnotationPreserver implements Processor {
2046
2954
  readonly name = "InlineAnnotationPreserver";
2047
- readonly order = 5;
2955
+ readonly order = 13;
2048
2956
  process(input: ProcessorInput): ProcessorOutput;
2049
2957
  }
2050
2958
 
@@ -2110,6 +3018,13 @@ declare class PropertiesTableRestorer implements Processor {
2110
3018
  readonly name = "PropertiesTableRestorer";
2111
3019
  readonly order = 5;
2112
3020
  process(input: ProcessorInput): ProcessorOutput;
3021
+ /**
3022
+ * DB 행 본문 첫머리의 속성 블록은 예전 push 가 행을 페이지로 밀며 잘못 끼운 것이다(S-02).
3023
+ * 행의 속성은 Notion 속성이 정본이다 — 블록 값으로 덮으면 Notion 에서 고친 속성이 옛 값으로
3024
+ * 보인다. 그래서 값은 버리고 블록만 걷어 낸다. Notion 쪽 블록은 이 행의 본문을 다음에
3025
+ * push 할 때 사라진다.
3026
+ */
3027
+ private dropFromRow;
2113
3028
  private restoreFromYaml;
2114
3029
  private restoreFromTable;
2115
3030
  }
@@ -2200,13 +3115,19 @@ declare class FileWatcher {
2200
3115
  stop(): Promise<void>;
2201
3116
  }
2202
3117
 
3118
+ /** 한 번의 sync 가 무엇을 보나 — 바뀐 파일만(파일 변경) · 볼트 전체(주기 sync). */
3119
+ type WatchSyncScope = "changes" | "full";
2203
3120
  interface WatchSyncOptions {
2204
3121
  readonly debounceMs?: number;
2205
- readonly onSyncStart?: () => void;
2206
- readonly onSyncComplete?: (result: SyncResult) => void;
2207
- readonly onSyncError?: (error: Error) => void;
3122
+ readonly onSyncStart?: (scope: WatchSyncScope) => void;
3123
+ readonly onSyncComplete?: (result: SyncResult, scope: WatchSyncScope) => void;
3124
+ /** 감시를 멈춰 도중에 취소한 sync — `result` 는 그때까지 처리한 것이다. */
3125
+ readonly onSyncCancelled?: (result: SyncResult, scope: WatchSyncScope) => void;
3126
+ readonly onSyncError?: (error: Error, scope: WatchSyncScope) => void;
2208
3127
  readonly onFileChange?: (event: WatchEvent, path: string) => void;
2209
3128
  }
3129
+ /** 마지막 변경 뒤 동기화까지 기다리는 시간 — CLI `watch` 와 플러그인 자동 동기화가 함께 쓴다. */
3130
+ declare const DEFAULT_WATCH_DEBOUNCE_MS = 2000;
2210
3131
  declare class WatchSyncService {
2211
3132
  private readonly rootPath;
2212
3133
  private readonly orchestrator;
@@ -2217,10 +3138,26 @@ declare class WatchSyncService {
2217
3138
  private pendingChanges;
2218
3139
  private syncing;
2219
3140
  private syncQueued;
3141
+ private fullSyncQueued;
2220
3142
  private running;
3143
+ private inFlight;
3144
+ private stopping;
2221
3145
  constructor(rootPath: string, orchestrator: SyncOrchestrator, options?: WatchSyncOptions);
2222
3146
  start(): void;
3147
+ /**
3148
+ * 감시를 멈춘다. 도는 sync 는 취소하고 끝나기를 기다린다 — 예전에는 기다리지 않아, 부른 쪽이
3149
+ * sync 도중에 상태 DB 를 닫았다(S-09).
3150
+ */
2223
3151
  stop(): Promise<void>;
3152
+ private halt;
3153
+ /**
3154
+ * 볼트 전체 sync 를 요청한다 — CLI `watch --interval` 의 주기 sync(S-09).
3155
+ *
3156
+ * 파일 변경 sync 와 같은 줄에 선다. 예전에는 주기 sync 가 이 서비스 밖에서 오케스트레이터를
3157
+ * 불러, 도는 중에 들어온 파일 변경 sync · 다음 주기 sync 와 겹쳤다. 도는 sync 가 있으면 끝난 뒤
3158
+ * 한 번만 돈다 — 요청이 쌓여도 한 번이다.
3159
+ */
3160
+ requestFullSync(): void;
2224
3161
  isRunning(): boolean;
2225
3162
  isSyncing(): boolean;
2226
3163
  getPendingCount(): number;
@@ -2228,6 +3165,7 @@ declare class WatchSyncService {
2228
3165
  private scheduleSync;
2229
3166
  private clearDebounce;
2230
3167
  private executeSync;
3168
+ private runBatch;
2231
3169
  }
2232
3170
 
2233
3171
  interface DBEntry {
@@ -2275,6 +3213,7 @@ interface PropertyOption {
2275
3213
  declare class ViewDataProvider {
2276
3214
  private readonly vaultFs;
2277
3215
  constructor(vaultFs: VaultFS);
3216
+ /** 파일이 없으면 빈 객체. 있는데 읽지 못하면 던진다 — 「보기 설정이 없다」 와 「읽지 못했다」 는 다른 말이다. */
2278
3217
  loadAllViewConfigs(): Promise<Record<string, DatabaseViewsConfig>>;
2279
3218
  getViewConfigs(databaseId: string): Promise<DatabaseViewsConfig | null>;
2280
3219
  collectEntries(folderPath: string): Promise<DBEntry[]>;
@@ -2298,6 +3237,12 @@ interface BaseFileOptions {
2298
3237
  readonly schema: Record<string, BasePropertySchema>;
2299
3238
  readonly viewsConfig: DatabaseViewsConfig;
2300
3239
  readonly folderPath: string;
3240
+ /**
3241
+ * 날짜 속성 이름 → 기간의 끝을 적는 짝 키(`<이름>_end`). 행 노트는 기간을 두 키로 적으므로 표에 그 속성이
3242
+ * 보이면 끝 열도 바로 뒤에 둔다(B13). 받은 행 가운데 기간이 있는 속성만 — 기간을 쓰지 않는 날짜 속성에
3243
+ * 늘 빈 열을 두지 않는다.
3244
+ */
3245
+ readonly rangeEnds?: ReadonlyMap<string, string>;
2301
3246
  }
2302
3247
  type BasesViewType = "table" | "cards" | "list";
2303
3248
  /** Notion 뷰 하나를 Bases 로 옮길 때의 판정. */
@@ -2540,6 +3485,8 @@ interface FilterCondition {
2540
3485
  }
2541
3486
  declare function filterEntries(entries: DBEntry[], searchQuery?: string, filters?: FilterCondition[]): DBEntry[];
2542
3487
 
3488
+ /** Notion 이 쓰는 색 이름 전부(`default` 포함) — 색을 다루는 다른 곳은 이 목록을 읽는다. */
3489
+ declare const NOTION_COLOR_NAMES: readonly NotionColor[];
2543
3490
  declare function getNotionColor(color: NotionColor | string): string;
2544
3491
  declare function getNotionBgColor(color: NotionColor | string): string;
2545
3492
  declare function generateColorCSS(): string;
@@ -2621,20 +3568,6 @@ declare function summarizeFidelity(perFile: ReadonlyArray<{
2621
3568
  classification: FidelityClassification;
2622
3569
  }>): FidelitySummary;
2623
3570
 
2624
- /**
2625
- * 렌더 감사 — "볼트에 쓰인 마크다운이 Obsidian 에서 깨져 보이는가"를 코드로 고정한다.
2626
- *
2627
- * {@link classifyBodyFidelity} 가 **내용이 남았는가**를 보고 {@link verifyPageCompleteness}
2628
- * 가 **빠짐없이 왔는가**를 본다면, 여기는 **사람 눈에 어떻게 보이는가**를 본다. 셋은 서로의
2629
- * 사각지대를 맡는다 — 실제로 이 트랙의 결함은 내용도 다 있고 개수도 맞는데 콜아웃이
2630
- * 코드블록으로 오파싱되거나 표 구분행만 덩그러니 남는 형태였다.
2631
- *
2632
- * 원본 대조가 필요 없는 것만 담는다. 한 파일만 보고 판정할 수 있어야 동기화 하니스가
2633
- * 볼트를 훑는 데 쓸 수 있다. 원본 NFM 과 견주는 지표(구조 드리프트·코드블록 경계)는
2634
- * 회귀 코퍼스를 쥔 테스트 쪽에 남는다.
2635
- *
2636
- * 규칙 번호(①~⑧)는 트랙 초기 실측 조사에서 붙인 결함 분류를 그대로 잇는다.
2637
- */
2638
3571
  /** 한 줄만 보고 판정할 수 있는 렌더 결함. */
2639
3572
  interface RenderLineRule {
2640
3573
  readonly code: string;
@@ -2663,16 +3596,43 @@ declare function computeBufferHash(buffer: Buffer): string;
2663
3596
 
2664
3597
  declare function generateId(): string;
2665
3598
  declare function normalizeNotionId(id: string): string;
3599
+ /**
3600
+ * 사용자가 넣은 페이지 — ID(대시 있거나 없거나) 또는 Notion 주소(브라우저 주소창 · 「링크 복사」). 주소창에
3601
+ * 보이는 것을 그대로 붙여 넣어도 되게 한다. 못 찾으면 null.
3602
+ *
3603
+ * 데이터베이스 보기에서 연 페이지 주소는 `…/<DB>?v=<보기>&p=<페이지>` 다 — 연 페이지는 `p` 이고, `v` 는 보기다.
3604
+ */
3605
+ declare function notionIdFromInput(input: string): string | null;
2666
3606
  declare function notionIdsEqual(a: string, b: string): boolean;
2667
3607
  /**
2668
3608
  * 멘션 위키링크용 정규 id — 하이픈 제거 + 소문자. 오케스트레이터의 멘션 해소 정규식
2669
- * (`[[notion:<32hex>]]`)이 인식하는 유일한 형태다. 블록 폴백 경로(formatMention)와
3609
+ * (`[[notion:<32hex>]]`)이 인식하는 유일한 형태다. 블록 방식 받기(`blocks-to-nfm`)와
2670
3610
  * markdown-api 경로(convertPageMentions)가 동일 형태로 수렴하도록 한다(rank20/I3).
2671
3611
  */
2672
3612
  declare function compactNotionId(id: string): string;
2673
3613
 
2674
3614
  declare function sanitizeFileName(name: string): string;
2675
3615
 
3616
+ /** 노트(마크다운 파일)의 경로 · 이름인가. */
3617
+ declare function isMarkdownPath(path: string): boolean;
3618
+ /** 노트 확장자를 뗀다 — 노트가 아니면 그대로. */
3619
+ declare function stripMarkdownExtension(path: string): string;
3620
+ /** 경로의 노트 이름 — 마지막 조각에서 노트 확장자를 뗀다(`폴더/노트.MD` → `노트`). */
3621
+ declare function noteNameFromPath(path: string): string;
3622
+
3623
+ /**
3624
+ * 시각을 시간대의 벽시계 시각으로, 벽시계 시각을 오프셋이 붙은 시각으로 바꾼다(F-08).
3625
+ *
3626
+ * Obsidian 의 날짜시각 속성은 시간대가 없는 `YYYY-MM-DDTHH:mm` 이다 — 초 · 밀리초 · `Z` · `+09:00` 이
3627
+ * 붙으면 글로 본다(2026-10-04 Obsidian 1.12.4 실측). Notion 은 오프셋 없는 시각을 UTC 로 읽는다(같은 날
3628
+ * 실측: `2026-10-01T10:00` 을 보내면 `2026-10-01T10:00:00.000+00:00`). 그래서 pull 은 시각을 시간대의
3629
+ * 벽시계 시각으로 적고, push 는 그 시간대의 오프셋을 붙여 보낸다 — 같은 순간이다.
3630
+ */
3631
+ /**
3632
+ * 이 컴퓨터의 시간대(IANA 이름). 부를 때마다 읽는다 — 앱이 켜진 채 시간대가 바뀔 수 있다.
3633
+ */
3634
+ declare function systemTimeZone(): string;
3635
+
2676
3636
  /**
2677
3637
  * `--path` 범위 판정 — 볼트 상대경로가 사용자가 지정한 범위 안에 있는지.
2678
3638
  *
@@ -2691,6 +3651,48 @@ declare function matchesPathScope(path: string, scope: string): boolean;
2691
3651
  /** 범위 목록 중 하나라도 걸리면 true. 목록이 비어 있으면(범위 미지정) 항상 true. */
2692
3652
  declare function inAnyPathScope(path: string, scopes: readonly string[] | undefined): boolean;
2693
3653
 
3654
+ /** 줄 비교의 한 줄. 번호는 1부터 — 더한 줄은 옛 번호가, 지운 줄은 새 번호가 없다. */
3655
+ interface DiffLine {
3656
+ readonly kind: "same" | "added" | "removed";
3657
+ readonly text: string;
3658
+ readonly oldNumber: number | null;
3659
+ readonly newNumber: number | null;
3660
+ /**
3661
+ * 글 끝에 줄바꿈이 없는 줄. 바뀐 줄에만 붙인다 — 끝 줄바꿈만 다른 두 줄이 같은 줄을 지우고 다시 쓴 것처럼
3662
+ * 보이지 않게. 같은 줄에 붙은 것은 양쪽 다 없다는 뜻이라 차이가 아니다.
3663
+ */
3664
+ readonly noNewlineAtEnd?: true;
3665
+ }
3666
+ /** 바뀐 줄과 그 앞뒤의 같은 줄 묶음 — Git 의 `@@ -oldStart,oldLines +newStart,newLines @@`. */
3667
+ interface DiffHunk {
3668
+ /** 묶음의 첫 옛 줄 번호(1부터). 옛 줄이 없는 묶음(`oldLines` 0)도 1부터 센다. */
3669
+ readonly oldStart: number;
3670
+ readonly oldLines: number;
3671
+ readonly newStart: number;
3672
+ readonly newLines: number;
3673
+ readonly lines: readonly DiffLine[];
3674
+ }
3675
+ /** 줄 종류마다 앞에 붙이는 부호 — Git 의 통합 diff 와 같다. 줄 비교를 글로 보이는 곳은 모두 이것을 쓴다. */
3676
+ declare const DIFF_SIGN: Readonly<Record<DiffLine["kind"], string>>;
3677
+ interface LineDiffOptions {
3678
+ /** 바뀐 줄 앞뒤로 함께 보일 같은 줄 수. 기본 3 — Git 과 같다. */
3679
+ readonly context?: number;
3680
+ }
3681
+ /**
3682
+ * 두 글의 줄 비교 — 바뀐 곳과 그 앞뒤 몇 줄을 묶음으로 낸다. 같은 글이면 빈 배열.
3683
+ *
3684
+ * 예전의 충돌 비교는 같은 번호의 줄끼리 견줬다 — 한쪽 앞에 한 줄만 더해도 그 뒤의 모든 줄이 바뀐 것으로
3685
+ * 보였다. 여기는 가장 긴 공통 줄을 맞춰 실제로 더하고 지운 줄만 가른다.
3686
+ */
3687
+ declare function lineDiff(before: string, after: string, options?: LineDiffOptions): DiffHunk[];
3688
+ /**
3689
+ * 통합 diff 글의 줄(Git 의 `diff -u` 와 같은 모양) — 옛 글 · 새 글 이름 두 줄, 묶음마다 `@@` 머리와 부호를
3690
+ * 붙인 줄. 같은 글이면 이름 두 줄뿐이다. 끝 줄바꿈이 없다는 표시는 Git 과 달리 바뀐 줄 뒤에만 적는다
3691
+ * (`DiffLine.noNewlineAtEnd`). 글로 보이는 곳(CLI `diff` · `resolve`)이 모두 이것을 쓴다 — 저마다 만들면
3692
+ * 앞뒤 줄 수 · 큰 글 상한이 곳마다 달라진다.
3693
+ */
3694
+ declare function formatUnifiedDiff(hunks: readonly DiffHunk[], oldName: string, newName: string): string[];
3695
+
2694
3696
  interface Logger {
2695
3697
  warn(message: string, ...args: unknown[]): void;
2696
3698
  error(message: string, ...args: unknown[]): void;
@@ -2748,4 +3750,77 @@ declare function isInternalPath(path: string): boolean;
2748
3750
  /** 모든 보존 마커가 공유하는 브랜드 토큰. */
2749
3751
  declare const MARKER_BRAND = "im-nobsidian";
2750
3752
 
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 };
3753
+ /**
3754
+ * 디자인 마커 읽기 — 볼트 노트 한 줄에 실린 마커가 «무엇을 어디에» 칠하는지 (U-01).
3755
+ *
3756
+ * pull 은 Obsidian 에 문법이 없는 Notion 서식(글자색 · 밑줄 · 멘션 · 블록 색 · 콜아웃/토글 스타일 ·
3757
+ * 표 속성)을 `%%…%%` 마커로 싣는다(ADR-008 · F-01 · F-04). 마커는 Obsidian 주석이라 읽기 보기에서는
3758
+ * 지워지고, 편집 화면(Live Preview)에서는 글자로 보인다 — 서식은 어느 쪽에서도 보이지 않는다.
3759
+ * 플러그인 렌더러가 이 결과로 칠한다. 마커 모양의 주인이 이 패키지(`constants/markers.ts`)라 읽기도
3760
+ * 여기 둔다 — 모양을 바꾸면 이 파일과 그 시험이 같이 깨진다.
3761
+ */
3762
+ /** Notion 색 하나 — `red` · `red_bg` · `red_background` 를 같은 값으로 읽는다. */
3763
+ interface DesignColor {
3764
+ readonly name: Exclude<NotionColor, "default">;
3765
+ readonly background: boolean;
3766
+ }
3767
+ /** 글 구간에 걸리는 서식. */
3768
+ type InlineStyle = {
3769
+ readonly kind: "color";
3770
+ readonly color: DesignColor;
3771
+ } | {
3772
+ readonly kind: "underline";
3773
+ } | {
3774
+ readonly kind: "mention";
3775
+ };
3776
+ /** 줄 안의 글자 위치 `[from, to)`. */
3777
+ interface LineRange {
3778
+ readonly from: number;
3779
+ readonly to: number;
3780
+ }
3781
+ interface StyledRange extends LineRange {
3782
+ readonly style: InlineStyle;
3783
+ }
3784
+ /** 마커를 걷어 낸 글 조각과 그 조각에 걸린 서식 — 마커가 없는 글도 조각이 된다(서식 없음). */
3785
+ interface DesignSegment extends LineRange {
3786
+ readonly text: string;
3787
+ readonly styles: readonly InlineStyle[];
3788
+ }
3789
+ /**
3790
+ * 콜아웃 · 토글 머리 줄의 스타일. `icon` 이 null 이면 Notion 에서 아이콘이 없는 콜아웃이다
3791
+ * (마커에 icon 이 없다). 토글은 아이콘을 싣지 않는다(undefined).
3792
+ */
3793
+ interface CalloutDesign {
3794
+ readonly color?: DesignColor;
3795
+ readonly icon?: string | null;
3796
+ }
3797
+ /** 표 한 줄(행)의 속성 — 셀 번호는 0 부터, 파이프 사이 칸 차례다. */
3798
+ interface TableLineDesign {
3799
+ readonly headerRow?: boolean;
3800
+ readonly headerColumn?: boolean;
3801
+ readonly rowColor?: DesignColor;
3802
+ /** 셀 번호 → 셀 색. */
3803
+ readonly cellColors: ReadonlyMap<number, DesignColor>;
3804
+ /** 셀 번호 → 열 색(머리 행의 `table-col` 마커가 그 열 전체에 건다). */
3805
+ readonly columnColors: ReadonlyMap<number, DesignColor>;
3806
+ }
3807
+ interface DesignLine {
3808
+ /** 줄에 있는 우리 마커 전부 — 편집 화면이 숨긴다. 짝이 맞은 닫는 토큰(`%%/color%%` 등)도 든다. */
3809
+ readonly markers: readonly LineRange[];
3810
+ /** 짝 마커 사이의 글 — 편집 화면이 칠한다. */
3811
+ readonly styled: readonly StyledRange[];
3812
+ /** 마커를 걷어 낸 차례대로의 글 조각 — 읽기 보기가 그려진 글과 맞춰 칠한다. */
3813
+ readonly segments: readonly DesignSegment[];
3814
+ readonly blockColor?: DesignColor;
3815
+ /** 콜아웃 · 토글 머리 줄의 스타일. */
3816
+ readonly callout?: CalloutDesign;
3817
+ readonly table?: TableLineDesign;
3818
+ }
3819
+ /** 이 글에 우리 마커가 하나라도 있나 — 렌더러가 마커 없는 블록을 건너뛰는 데 쓴다. */
3820
+ declare function hasDesignMarker(text: string): boolean;
3821
+ /** Notion 색 토큰을 읽는다. `default` · 모르는 색은 칠할 것이 없다(undefined). */
3822
+ declare function parseDesignColor(token: string | undefined): DesignColor | undefined;
3823
+ /** 한 줄을 읽는다. 마커가 없는 줄은 글 조각 하나뿐인 결과를 돌려준다. */
3824
+ declare function readDesignLine(line: string): DesignLine;
3825
+
3826
+ 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 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, 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 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, 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, 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, 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, compactNotionId, computeBufferHash, computeHash, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, formatUnifiedDiff, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, hasDesignMarker, inAnyPathScope, isInternalPath, isMarkdownPath, isRemoteDeletion, lineDiff, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, noteNameFromPath, notionIdFromInput, notionIdsEqual, operationLabel, parseDesignColor, pendingWalBytes, readDesignLine, resolvePropertyName, sanitizeFileName, setLogger, sortEntries, stateDbLockPath, stateDbWalPath, stripMarkdownExtension, summarizeFidelity, systemTimeZone, threeWayMerge, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness };