@im-nobsidian/core 0.3.1 → 0.3.2

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
@@ -142,6 +142,8 @@ declare const ConfigSchema: z.ZodObject<{
142
142
  mediaRetryBaseMs: z.ZodDefault<z.ZodNumber>;
143
143
  fileConcurrency: z.ZodDefault<z.ZodNumber>;
144
144
  maxFileSizeBytes: z.ZodDefault<z.ZodNumber>;
145
+ mediaDownloadTimeoutMs: z.ZodDefault<z.ZodNumber>;
146
+ itemTimeoutMs: z.ZodDefault<z.ZodNumber>;
145
147
  }, "strip", z.ZodTypeAny, {
146
148
  concurrency: number;
147
149
  timeoutMs: number;
@@ -157,6 +159,8 @@ declare const ConfigSchema: z.ZodObject<{
157
159
  mediaRetryBaseMs: number;
158
160
  fileConcurrency: number;
159
161
  maxFileSizeBytes: number;
162
+ mediaDownloadTimeoutMs: number;
163
+ itemTimeoutMs: number;
160
164
  }, {
161
165
  concurrency?: number | undefined;
162
166
  timeoutMs?: number | undefined;
@@ -172,6 +176,8 @@ declare const ConfigSchema: z.ZodObject<{
172
176
  mediaRetryBaseMs?: number | undefined;
173
177
  fileConcurrency?: number | undefined;
174
178
  maxFileSizeBytes?: number | undefined;
179
+ mediaDownloadTimeoutMs?: number | undefined;
180
+ itemTimeoutMs?: number | undefined;
175
181
  }>;
176
182
  }, "strip", z.ZodTypeAny, {
177
183
  version: 1;
@@ -223,6 +229,8 @@ declare const ConfigSchema: z.ZodObject<{
223
229
  mediaRetryBaseMs: number;
224
230
  fileConcurrency: number;
225
231
  maxFileSizeBytes: number;
232
+ mediaDownloadTimeoutMs: number;
233
+ itemTimeoutMs: number;
226
234
  };
227
235
  }, {
228
236
  version: 1;
@@ -274,6 +282,8 @@ declare const ConfigSchema: z.ZodObject<{
274
282
  mediaRetryBaseMs?: number | undefined;
275
283
  fileConcurrency?: number | undefined;
276
284
  maxFileSizeBytes?: number | undefined;
285
+ mediaDownloadTimeoutMs?: number | undefined;
286
+ itemTimeoutMs?: number | undefined;
277
287
  };
278
288
  }>;
279
289
  type Config = z.infer<typeof ConfigSchema>;
@@ -353,6 +363,12 @@ interface PullResult {
353
363
  readonly created: number;
354
364
  readonly updated: number;
355
365
  readonly deleted: number;
366
+ /**
367
+ * 로컬에서 사라졌다가 리모트 원본으로 되살린 파일 수.
368
+ * updated 와 분리해 보고한다 — "수정 N건" 에 섞이면 사용자는 자기 볼트에서
369
+ * 파일이 없어졌다가 복구됐다는 사실 자체를 알 수 없다.
370
+ */
371
+ readonly restored: number;
356
372
  readonly conflicts: Conflict[];
357
373
  readonly writtenPaths: string[];
358
374
  readonly failed: FailedOperation[];
@@ -485,6 +501,37 @@ interface DatabaseViewsConfig {
485
501
  readonly lastSynced: string;
486
502
  readonly views: ViewConfig[];
487
503
  }
504
+ /** select/multi_select/status 의 선택지 하나. */
505
+ interface BasePropertyOption {
506
+ readonly name: string;
507
+ readonly color?: string;
508
+ /**
509
+ * Notion 내부 옵션 id. 사람이 보는 산출물에는 절대 나가지 않지만,
510
+ * status 그룹(`BaseStatusGroup.optionIds`)을 옵션명으로 되돌리는 유일한 열쇠다 —
511
+ * 뷰 필터가 옵션명 대신 그룹명으로 오기 때문에(예: `["To-do"]`) 이게 없으면
512
+ * 필터를 옮길 수 없다.
513
+ */
514
+ readonly id?: string;
515
+ }
516
+ /** status 속성의 그룹(To-do/In progress/Complete). 소속 옵션을 id 로 가리킨다. */
517
+ interface BaseStatusGroup {
518
+ readonly name: string;
519
+ readonly color?: string;
520
+ readonly optionIds?: string[];
521
+ }
522
+ /**
523
+ * Notion DB 속성 스키마 — `.base` 생성·사이드카·필터 번역이 함께 쓰는 SSOT.
524
+ *
525
+ * 같은 모양을 세 군데(클라이언트 반환형·생성기 입력형)에 따로 적어 두면 한쪽만
526
+ * 넓혔을 때 조용히 어긋난다. 실제로 옵션 `id` 가 클라이언트에서만 빠져 있어
527
+ * status 그룹 필터를 옮길 수 없었다.
528
+ */
529
+ interface BasePropertySchema {
530
+ readonly id: string;
531
+ readonly type: string;
532
+ readonly options?: BasePropertyOption[];
533
+ readonly groups?: BaseStatusGroup[];
534
+ }
488
535
  interface PageCover {
489
536
  readonly type: "file" | "external";
490
537
  readonly url: string;
@@ -761,19 +808,15 @@ declare class NotionClient {
761
808
  id: string;
762
809
  type: string;
763
810
  }>>;
764
- getDatabaseSchemaFull(databaseId: string): Promise<Record<string, {
765
- id: string;
766
- type: string;
767
- options?: Array<{
768
- name: string;
769
- color?: string;
770
- }>;
771
- groups?: Array<{
772
- name: string;
773
- color?: string;
774
- optionIds?: string[];
775
- }>;
776
- }>>;
811
+ /**
812
+ * DB 속성 스키마 전체(선택지·status 그룹 포함).
813
+ *
814
+ * 옵션 `id` 까지 그대로 실어 보낸다 — 산출물에는 안 나가지만, status 뷰 필터가
815
+ * 옵션명이 아니라 **그룹명**(`To-do` 등)으로 오기 때문에 `groups[].optionIds` 를
816
+ * 옵션명으로 되돌리려면 이 id 가 있어야 한다. 예전엔 여기서 id 를 떨궈,
817
+ * 그룹으로 필터링된 뷰를 Bases 로 옮길 방법이 아예 없었다.
818
+ */
819
+ getDatabaseSchemaFull(databaseId: string): Promise<Record<string, BasePropertySchema>>;
777
820
  /** 특정 data source 1개를 직접 쿼리한다(저수준). 다중 data source 순회의 빌딩블록. */
778
821
  private queryDataSource;
779
822
  /**
@@ -819,7 +862,17 @@ declare class NotionClient {
819
862
  fetchAllChildren(blockId: string): Promise<BlockObjectResponse[]>;
820
863
  private static readonly CONTAINER_BLOCK_TYPES;
821
864
  fetchAllChildrenDeep(blockId: string): Promise<BlockObjectResponse[]>;
822
- appendChildren(blockId: string, children: unknown[]): Promise<void>;
865
+ /**
866
+ * 자식 블록을 덧붙인다. `options.after` 를 주면 그 블록 **바로 뒤**에 삽입한다
867
+ * (기본은 맨 끝). 자리표시자를 실제 미디어 블록으로 제자리 교체할 때 필요하다.
868
+ *
869
+ * 실측(2025-09-03): position 삽입 응답의 `results` 는 방금 넣은 블록뿐 아니라
870
+ * 삽입 지점 이후의 형제까지 함께 돌려준다. 앞에서부터 batch 길이만큼이 새 블록이므로
871
+ * 배치를 이어 붙일 때는 그 마지막 id 를 다음 기준점으로 삼는다.
872
+ */
873
+ appendChildren(blockId: string, children: unknown[], options?: {
874
+ readonly after?: string;
875
+ }): Promise<string[]>;
823
876
  deleteBlock(blockId: string): Promise<void>;
824
877
  /** single_part 업로드 상한 (Notion API 규격 20MB) */
825
878
  private static readonly SINGLE_PART_MAX_BYTES;
@@ -854,6 +907,8 @@ declare class NotionClient {
854
907
  *
855
908
  * @param opts.deadlineMs `Date.now()` 기준 마감 시각. 각 페이지 처리 전 초과를 검사해
856
909
  * 초과 시 {@link DiscoveryTooLargeError}를 던진다(대규모 서브트리 → search 폴백 유도).
910
+ * **그때까지 찾은 페이지는 에러의 `partial` 에 실어 보낸다** — 버리면 호출측이 두
911
+ * 경로를 경합시키게 되고, 어느 쪽이 이기는지를 벽시계가 정하게 된다(R12-A).
857
912
  * 미지정 시 무제한(기존 동작 유지 — 테스트 mock 경로는 영향 없음).
858
913
  */
859
914
  getChildPagesRecursive(parentId: string, opts?: {
@@ -892,8 +947,28 @@ declare class NotionClient {
892
947
  setWikilinkResolver(resolver: WikilinkResolver$1): void;
893
948
  extractProperties(page: PageObjectResponse): Record<string, unknown>;
894
949
  private lastRequestTime;
950
+ /**
951
+ * 429 이후 **클라이언트 전체**가 쉬어야 하는 시각(epoch ms).
952
+ *
953
+ * rate limit 은 요청 하나의 문제가 아니라 워크스페이스 전체에 걸린 신호다. 그러니
954
+ * 429 를 맞은 요청만 기다리게 두면 나머지 슬롯이 곧바로 다시 두드려 429 를 재생산한다.
955
+ * 슬롯을 붙잡아 남을 막는 대신, 다 같이 보는 이 게이트로 쉬게 한다.
956
+ */
957
+ private cooldownUntil;
958
+ /**
959
+ * 요청 1건을 rate limit 슬롯 안에서 실행하고, 재시도 가능한 실패는 백오프 후 다시 시도한다.
960
+ *
961
+ * 백오프 대기는 **슬롯을 놓은 뒤** 한다. 예전 구현은 `sema.acquire()` 안에서 재시도
962
+ * 전체(최대 60초 x 5회)를 돌려, 불운한 요청 하나가 동시성 한 칸을 최대 5분 점유했다.
963
+ * 동시성 기본값이 3이므로 그런 요청 3건이면 클라이언트 전체가 멈춘다. 게다가 재시도는
964
+ * 로그를 한 줄도 남기지 않아, 겉보기에는 죽은 프로세스와 구분되지 않았다 — 실제로
965
+ * 268페이지 pull 이 진행 로그가 멈춘 채 수 분씩 정지하는 것으로 나타났다(R9).
966
+ * 그래서 (1) 대기 중에는 슬롯을 반납하고, (2) 429 는 {@link cooldownUntil} 전역
967
+ * 게이트로 함께 쉬고, (3) 모든 재시도를 경고로 남긴다.
968
+ */
895
969
  private withRateLimit;
896
- private executeWithRetry;
970
+ /** 슬롯을 쥔 뒤의 발사 대기 — 전역 쿨다운(429)과 최소 요청 간격을 함께 지킨다. */
971
+ private waitForTurn;
897
972
  }
898
973
 
899
974
  interface RichTextSegment {
@@ -1000,6 +1075,145 @@ declare class NotionBlockBuilder {
1000
1075
  static table(rows: string[][], hasColumnHeader?: boolean, hasRowHeader?: boolean): NotionBlock;
1001
1076
  }
1002
1077
 
1078
+ /**
1079
+ * DB 완결성 게이트 — "원격에 있는 행이 볼트에도 전부 있는가"를 대조한다.
1080
+ *
1081
+ * ## 왜 필요한가 (R11)
1082
+ *
1083
+ * 기존 게이트는 전부 **멱등성**(같은 입력에 같은 출력)을 본다 — 해시 불일치 0, repull
1084
+ * 바이트 동일, churn 0. 그런데 멱등성은 *체계적 미발견*을 구조적으로 잡지 못한다:
1085
+ * 디스커버리가 매번 **같은 행을 똑같이 놓치면** 두 번째 pull 도 첫 번째와 동일한
1086
+ * 결과를 내므로 churn 은 0 이고 해시도 전부 일치한다. 실제로 2026-07-17 pull 은 DB 행
1087
+ * 296개를 침묵 유실한 채 `audit-vault 결함 0 · 해시 불일치 0 · byte-identical repull`
1088
+ * 을 전부 통과했다. "안 바뀐다"와 "빠짐없다"는 다른 성질이고, 후자를 보는 게이트가
1089
+ * 없었던 것이다.
1090
+ *
1091
+ * 이 모듈은 후자만 본다. 원격 행 id 집합과 볼트가 추적 중인 db-row id 집합을 직접
1092
+ * 대조해, 어느 쪽에만 있는지를 센다. 카운트가 아니라 **집합**을 비교하는 이유는
1093
+ * 카운트만 보면 "1건 미발견 + 1건 잔재"가 상쇄돼 통과하기 때문이다.
1094
+ *
1095
+ * ## 범위와 한계
1096
+ *
1097
+ * 볼트가 **아는** database 에 대해 "그 DB 의 모든 행이 있는가"를 보장한다. database
1098
+ * 자체를 통째로 못 찾은 경우는 볼트에 흔적이 없어 여기서 알 수 없으므로, 호출부가
1099
+ * 설정/디스커버리가 아는 database id 를 {@link CompletenessOptions.databaseIds} 로
1100
+ * 넘겨 그 구멍까지 덮는다(행 0개로 대조되어 미발견이 그대로 드러난다).
1101
+ *
1102
+ * 원격 열거는 {@link CompletenessRemoteSource.queryAllDatabasePages} — 전 data source
1103
+ * 를 순회하는 SSOT — 하나만 쓴다. 여기서 별도 쿼리를 짜면 검증기가 피검증 대상과 다른
1104
+ * 열거 규칙을 갖게 돼, 게이트가 통과해도 실제 pull 은 놓치는 상황이 생긴다.
1105
+ */
1106
+ /** 원격 행 열거 — {@link NotionClient} 가 그대로 만족한다(전 data source 순회 SSOT). */
1107
+ interface CompletenessRemoteSource {
1108
+ queryAllDatabasePages(databaseId: string, filter?: unknown): Promise<Array<{
1109
+ id: string;
1110
+ }>>;
1111
+ }
1112
+ /** 볼트가 추적 중인 레코드 열거 — {@link IStateDB} 가 그대로 만족한다. */
1113
+ interface CompletenessLocalSource {
1114
+ getAll(): ReadonlyArray<{
1115
+ readonly notionPageId: string | null;
1116
+ readonly notionParentId: string | null;
1117
+ readonly fileType: string;
1118
+ }>;
1119
+ }
1120
+ /** database 1개의 대조 결과. */
1121
+ interface DatabaseCompleteness {
1122
+ /** 대조 대상 database id(입력 표기 그대로). */
1123
+ readonly databaseId: string;
1124
+ /** 원격 행 수(전 data source 합·page id 디듀프 후). */
1125
+ readonly remoteRows: number;
1126
+ /** 볼트가 이 DB 소속으로 추적 중인 db-row 수. */
1127
+ readonly localRows: number;
1128
+ /** 원격에만 있는 행 id — **침묵 미발견**. 비어야 정상. */
1129
+ readonly missingIds: readonly string[];
1130
+ /** 볼트에만 있는 행 id — 원격 삭제 잔재/부모 오배치. 비어야 정상. */
1131
+ readonly extraIds: readonly string[];
1132
+ }
1133
+ /** 원격 열거가 실패한 database — 결과를 "이상 없음"으로 접지 않기 위해 따로 싣는다. */
1134
+ interface CompletenessFailure {
1135
+ readonly databaseId: string;
1136
+ readonly error: string;
1137
+ }
1138
+ /** {@link verifyDatabaseCompleteness} 결과. */
1139
+ interface CompletenessReport {
1140
+ /** database 별 대조 결과(입력 순서). */
1141
+ readonly databases: readonly DatabaseCompleteness[];
1142
+ /** 원격 열거 실패 목록. 하나라도 있으면 `complete` 는 false. */
1143
+ readonly failures: readonly CompletenessFailure[];
1144
+ /** 대조에 성공한 database 들의 원격 행 총합. */
1145
+ readonly remoteTotal: number;
1146
+ /** 같은 database 들의 볼트 db-row 총합. */
1147
+ readonly localTotal: number;
1148
+ /** 미발견·잔재·조회 실패가 모두 0. */
1149
+ readonly complete: boolean;
1150
+ }
1151
+ interface CompletenessOptions {
1152
+ /**
1153
+ * 볼트에 행이 하나도 없어도 반드시 대조할 database id(설정 `notion.databases[]` ·
1154
+ * 디스커버리 캐시 등). 통째로 미발견된 DB 를 드러내는 유일한 경로다.
1155
+ */
1156
+ readonly databaseIds?: readonly string[];
1157
+ }
1158
+ /**
1159
+ * 원격 행 집합과 볼트 db-row 집합을 database 별로 대조한다.
1160
+ *
1161
+ * 대조 대상 = 볼트가 db-row 를 추적 중인 모든 database ∪
1162
+ * {@link CompletenessOptions.databaseIds}. 한 database 의 열거가 실패해도 나머지는
1163
+ * 계속 대조하고, 실패는 {@link CompletenessReport.failures} 로 비침묵 보고한다.
1164
+ */
1165
+ declare function verifyDatabaseCompleteness(remote: CompletenessRemoteSource, local: CompletenessLocalSource, options?: CompletenessOptions): Promise<CompletenessReport>;
1166
+ /**
1167
+ * 원격 페이지 열거 — {@link NotionClient} 가 그대로 만족한다.
1168
+ *
1169
+ * DB 쪽과 달리 여기서는 **일부러 pull 과 다른 열거를 쓴다**. DB 행은 열거 방법이 하나뿐이라
1170
+ * 검증기가 다른 규칙을 쓰면 게이트가 통과해도 pull 은 놓치는 상황이 생기지만, 페이지는
1171
+ * 열거 경로가 둘(직접 순회 · search)이고 **둘이 같은 집합을 내지 않는다**는 게 애초의
1172
+ * 결함이다(R12-A). pull 이 어느 경로로 돌았든 독립된 두 번째 열거와 대조해야 의미가 있다.
1173
+ */
1174
+ interface PageCompletenessRemoteSource {
1175
+ getPagesUnderRootViaSearch(rootId: string): Promise<Array<{
1176
+ id: string;
1177
+ }>>;
1178
+ }
1179
+ /** {@link verifyPageCompleteness} 결과. */
1180
+ interface PageCompletenessReport {
1181
+ /** 원격(search 열거) root 하위 페이지 수. */
1182
+ readonly remotePages: number;
1183
+ /** 볼트가 추적 중인 페이지 수(db-row 제외). */
1184
+ readonly vaultPages: number;
1185
+ /** 원격에만 있는 페이지 id — **침묵 미발견**. 비어야 정상. */
1186
+ readonly missingIds: readonly string[];
1187
+ /**
1188
+ * 볼트에만 있는 페이지 id — **정보성**(판정에 쓰지 않는다).
1189
+ *
1190
+ * DB 행과 달리 페이지는 볼트에만 있는 게 정상일 수 있다: 아직 push 하지 않은 로컬 노트,
1191
+ * root 페이지 자신(search 열거는 root 를 제외한다), search 색인이 아직 못 따라온 신규
1192
+ * 페이지. 이걸 실패로 접으면 게이트가 거짓 적색을 내며 신뢰를 잃는다.
1193
+ */
1194
+ readonly localOnlyIds: readonly string[];
1195
+ /** 열거 실패 사유. 있으면 `complete` 는 false(실패를 통과로 접지 않는다). */
1196
+ readonly error?: string;
1197
+ /** 미발견 0 이고 열거도 성공. */
1198
+ readonly complete: boolean;
1199
+ }
1200
+ /** {@link SyncOrchestrator.verifyCompleteness} 결과 — DB 와 페이지 대조를 한 판정으로 묶는다. */
1201
+ interface VaultCompletenessReport {
1202
+ readonly databases: CompletenessReport;
1203
+ /** 페이지 모드에서만 채워진다. DB 모드에는 root 서브트리가 없어 `null`. */
1204
+ readonly pages: PageCompletenessReport | null;
1205
+ /** 두 대조가 모두 통과. */
1206
+ readonly complete: boolean;
1207
+ }
1208
+ /**
1209
+ * 원격 root 하위 페이지 집합과 볼트가 추적 중인 페이지 집합을 대조한다.
1210
+ *
1211
+ * 볼트 쪽은 `fileType !== "db-row"` 로 고른다 — 페이지형 타입을 열거하면
1212
+ * (`file`·`folder-note`·`folder-only`) 새 타입이 생겼을 때 한쪽만 낡아 조용히 게이트를
1213
+ * 빠져나간다. "행이 아닌 것은 전부 페이지"가 이 대조가 실제로 뜻하는 바다.
1214
+ */
1215
+ declare function verifyPageCompleteness(remote: PageCompletenessRemoteSource, local: CompletenessLocalSource, rootPageId: string): Promise<PageCompletenessReport>;
1216
+
1003
1217
  interface FileInfo {
1004
1218
  readonly path: string;
1005
1219
  readonly content: string;
@@ -1077,6 +1291,7 @@ declare class SyncOrchestrator {
1077
1291
  private _pullFileCount;
1078
1292
  private _inlineDbRefs;
1079
1293
  private _childParentIds;
1294
+ private readonly claimedPaths;
1080
1295
  private static readonly DISCOVERY_RECURSIVE_BUDGET_MS;
1081
1296
  constructor(config: Config, stateDb: IStateDB, notionClient: NotionClient, vaultFs: VaultFS, customFetch?: typeof globalThis.fetch);
1082
1297
  push(options?: PushOptions): Promise<PushResult>;
@@ -1084,12 +1299,33 @@ declare class SyncOrchestrator {
1084
1299
  sync(options?: SyncOptions): Promise<SyncResult>;
1085
1300
  status(): Promise<StatusResult>;
1086
1301
  statusLocal(): Promise<StatusResult>;
1302
+ /**
1303
+ * 볼트 완결성 검증 — 원격에 있는 것이 볼트에 빠짐없이 있는지 대조한다(R11-B · R12-C).
1304
+ *
1305
+ * 기존 게이트(해시 일치·repull 바이트 동일·churn 0)는 전부 **멱등성**을 본다. 매번
1306
+ * 같은 것을 놓치는 체계적 미발견은 그 게이트를 전부 통과한다 — 실제로 296개 DB 행이
1307
+ * 그렇게 침묵 유실됐다. 여기서만 "빠짐없다"를 본다.
1308
+ *
1309
+ * 대조 대상 database id 는 이 클래스가 조립한다: 설정에 적힌 것 + 디스커버리 캐시가
1310
+ * 아는 것 + DB 모드의 루트 DB. `discovered_dbs` 메타 키를 아는 곳을 이 파일 하나로
1311
+ * 유지해, 호출부(CLI·E2E)가 캐시 표현을 각자 다시 해석하지 않게 한다.
1312
+ *
1313
+ * 페이지(R12-C)는 pull 이 쓴 경로가 아니라 **search 열거**로 대조한다. 페이지는 열거
1314
+ * 경로가 둘이고 둘이 같은 집합을 내지 않는 게 애초의 결함이므로(R12-A), 독립된 두 번째
1315
+ * 열거와 맞대 봐야 의미가 있다.
1316
+ */
1317
+ verifyCompleteness(): Promise<VaultCompletenessReport>;
1087
1318
  fetch(): Promise<{
1088
1319
  newPages: number;
1089
1320
  deletedPages: number;
1090
1321
  modifiedPages: number;
1091
1322
  duration: number;
1092
1323
  }>;
1324
+ /**
1325
+ * @param options.fullRender 원격 본문을 pull 과 동일한 파이프라인으로 렌더할지.
1326
+ * 기본(false)은 화면 미리보기용 경량 렌더 — 첨부를 내려받지 않으므로 `status` 처럼
1327
+ * 읽기만 하는 경로가 쓴다. 볼트에 덮어쓸 본문이 필요한 해소 경로는 반드시 켠다.
1328
+ */
1093
1329
  private buildConflictsFromRecords;
1094
1330
  private get isDatabaseMode();
1095
1331
  private discoverChildDatabases;
@@ -1119,7 +1355,43 @@ declare class SyncOrchestrator {
1119
1355
  private resolveNotionLinks;
1120
1356
  private ensureDbSchema;
1121
1357
  private pushCreate;
1358
+ /**
1359
+ * 노트에 박힌 로컬 미디어를 Notion 에 올린다(R1).
1360
+ *
1361
+ * 먼저 본문 자리표시자를 실제 image/file 블록으로 **제자리** 교체한다. 그러고도 남은
1362
+ * 이미지만 페이지 끝에 덧붙이는 예전 경로로 흘린다 — 자리표시자가 없는 경우는 본문
1363
+ * push 가 통째로 스킵됐을 때(pageHasLiveChildren) 정도이고, 그때도 이미지 자체는
1364
+ * 올라가야 하므로 폴백을 남긴다.
1365
+ */
1366
+ private syncEmbeddedMedia;
1122
1367
  private pushUpdate;
1368
+ /**
1369
+ * 해소 대상 충돌 목록 — 원격 본문을 pull 과 **같은 파이프라인**으로 렌더해 담는다.
1370
+ *
1371
+ * 해소는 실제로 볼트 파일을 덮어쓰므로 `status()` 의 경량 미리보기 렌더를 쓰면 안 된다
1372
+ * (프론트매터·첨부가 빠진 반쪽 본문이 덮인다). 반대로 전체 pull 을 먼저 돌려 목록을
1373
+ * 얻는 것도 안 된다 — 해소하겠다고 볼트를 먼저 원격으로 덮어쓰는 셈이라 순서가 거꾸로다.
1374
+ * 그래서 충돌 레코드만 좁혀 그 페이지들만 읽어 온다.
1375
+ */
1376
+ listConflicts(): Promise<Conflict[]>;
1377
+ /**
1378
+ * 추적 파일의 **현재 원격 본문**을 pull 과 같은 변환으로 렌더한다 — 표시 전용.
1379
+ *
1380
+ * 첨부는 내려받지 않는다: 비교를 보려다 볼트에 파일이 생기면 안 된다. 그 대가로 아직
1381
+ * 내려받지 않은 미디어는 원격 URL 로 남아 비교 화면에만 차이로 보인다.
1382
+ *
1383
+ * @returns 추적되지 않았거나 원격 페이지가 없으면 null.
1384
+ */
1385
+ renderRemoteSnapshot(path: string): Promise<string | null>;
1386
+ /**
1387
+ * 해소할 게 남지 않은 충돌 레코드를 `synced` 로 되돌리고, 되돌린 경로를 반환한다.
1388
+ *
1389
+ * 충돌로 표시된 파일은 push 대상에서 통째로 빠진다(양쪽 덮어쓰기 방지). 그래서 사용자가
1390
+ * 손으로 양쪽을 맞춰 둬 이미 같은 내용이 됐는데도 레코드만 남으면, 그 파일의 이후 편집이
1391
+ * **영원히 Notion 에 올라가지 않는다** — 아무 경고 없이 정체된다. 내용이 이미 동일한
1392
+ * 건만 골라 상태를 되돌린다(진짜 충돌은 손대지 않는다).
1393
+ */
1394
+ clearStaleConflicts(conflicts: readonly Conflict[]): string[];
1123
1395
  /** 단일 충돌을 사용자가 고른 선택지(local/remote/merge/duplicate)로 해소 + Notion 전파. */
1124
1396
  resolveConflict(conflict: Conflict, choice: ResolutionChoice): Promise<ResolutionResult>;
1125
1397
  /** 단일 충돌을 전략(manual/local-first/remote-first/duplicate)으로 해소 + Notion 전파. */
@@ -1149,6 +1421,22 @@ declare class SyncOrchestrator {
1149
1421
  */
1150
1422
  private detectRemoteChangesIncremental;
1151
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;
1152
1440
  private pullCreate;
1153
1441
  /**
1154
1442
  * 페이지가 폴더(자식 페이지·자식 DB 보유)인지 신뢰성 있게 판정한다.
@@ -1164,6 +1452,18 @@ declare class SyncOrchestrator {
1164
1452
  * 재귀 탐색해 child_page·child_database 를 직접 확인한다.
1165
1453
  */
1166
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;
1167
1467
  private pullUpdate;
1168
1468
  private pullDelete;
1169
1469
  private ensureFolderPage;
@@ -1205,6 +1505,25 @@ declare class SyncOrchestrator {
1205
1505
  private recoverInterruptedPushOps;
1206
1506
  /** 부모 페이지의 직속 자식 중 제목이 일치하는(보관/휴지통 제외) child_page id 를 찾는다. */
1207
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
+ */
1208
1527
  private resolveUniqueFilePath;
1209
1528
  }
1210
1529
 
@@ -1243,6 +1562,8 @@ interface MediaOptions {
1243
1562
  readonly maxRetries?: number;
1244
1563
  readonly retryBaseMs?: number;
1245
1564
  readonly maxFileSizeBytes?: number;
1565
+ /** 다운로드 1회 시도의 시간 상한(ms). 기본 300초. 0 이하면 상한 없음(권장하지 않음). */
1566
+ readonly downloadTimeoutMs?: number;
1246
1567
  }
1247
1568
  interface ImageDownloadResult {
1248
1569
  readonly originalUrl: string;
@@ -1257,6 +1578,12 @@ interface ImageUploadResult {
1257
1578
  readonly fileHash: string;
1258
1579
  readonly fileSize: number;
1259
1580
  }
1581
+ /** 제자리 교체 결과 — 어떤 임베드 대상이 처리됐는지 호출자가 알아야 중복 업로드를 막는다. */
1582
+ interface MaterializeResult {
1583
+ readonly uploaded: ImageUploadResult[];
1584
+ /** 실제 블록으로 바뀐 임베드 대상(원본 문자열 + 별칭 제거형 둘 다 담는다). */
1585
+ readonly handledTargets: Set<string>;
1586
+ }
1260
1587
  declare class ImageHandler {
1261
1588
  private readonly vaultFs;
1262
1589
  private readonly attachmentFolder;
@@ -1267,25 +1594,77 @@ declare class ImageHandler {
1267
1594
  private readonly maxRetries;
1268
1595
  private readonly retryBaseMs;
1269
1596
  private readonly maxFileSizeBytes;
1597
+ private readonly downloadTimeoutMs;
1270
1598
  constructor(vaultFs: VaultFS, attachmentFolder?: string, notionClient?: NotionClient | undefined, customFetch?: typeof globalThis.fetch, options?: MediaOptions, stateDb?: IStateDB | undefined);
1271
1599
  private get fetchFn();
1600
+ /** 상한 로직은 첨부 경로와 공유한다 — 한쪽만 빠뜨리지 않도록(utils/download-fetch). */
1601
+ private fetchForDownload;
1272
1602
  downloadImage(url: string, pageTitle: string): Promise<ImageDownloadResult>;
1273
1603
  private fetchImage;
1604
+ /**
1605
+ * 캡션이 push 가 적어 둔 원본 임베드 대상인지 판정한다(R1). 맞으면 그 문자열을
1606
+ * 그대로 돌려주고, 호출자는 사본을 내려받는 대신 `![[원본]]` 으로 되살린다.
1607
+ *
1608
+ * 사용자가 Notion 에서 손으로 쓴 캡션을 경로로 오인하면 깨진 위키링크가 되므로,
1609
+ * ① 파일명이 실제 업로드된 파일명과 같고 ② 볼트에 그 파일이 실재할 때만 인정한다.
1610
+ * 둘 중 하나라도 어긋나면 null 을 돌려 기존 다운로드 경로로 떨어진다.
1611
+ *
1612
+ * ②의 실재 확인은 push 와 **같은 후보 사다리**(노트 폴더 → 루트 → attachments/)를
1613
+ * 쓴다 — 위키링크는 파일명만 적는 게 관례라 볼트 루트만 보면 노트 옆 파일을 놓친다.
1614
+ */
1615
+ private resolveOriginalTarget;
1274
1616
  private persistImage;
1275
- downloadAllImages(markdown: string, pageTitle: string, pageId?: string): Promise<{
1617
+ downloadAllImages(markdown: string, pageTitle: string, pageId?: string, notePath?: string): Promise<{
1276
1618
  content: string;
1277
1619
  downloads: ImageDownloadResult[];
1278
1620
  }>;
1279
1621
  uploadLocalImage(localPath: string, noteFolder?: string): Promise<ImageUploadResult>;
1280
1622
  uploadAndAppendImages(pageId: string, images: ImageReference[], notePath?: string): Promise<ImageUploadResult[]>;
1281
1623
  /**
1282
- * 임베드 경로를 볼트 실제 파일로 해석한다(F23). Obsidian 위키링크 임베드는 노트 옆
1283
- * 파일을 파일명만으로 참조하는 게 관례라, 노트 폴더 → 볼트 루트 → attachments/ 순으로
1284
- * 시도한다. 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
1624
+ * push 가 심어 둔 자리표시자 quote 를 **제자리에서** 실제 image/file 블록으로 바꾼다(R1).
1625
+ *
1626
+ * 그 전에는 임베드 하나가 Notion 에서 두 조각으로 갈라졌다 — 본문 자리에는
1627
+ * `> 📎 foo.png` + `%% im-nobsidian:local-image:… %%` 리터럴 마커가, 실제 이미지는
1628
+ * 페이지 맨 끝에 따로. 실측: 볼트 175개 파일에서 마커 978건이 Notion 에 그대로
1629
+ * 텍스트로 새고 있었고 그중 964건(98.6%)이 이 미디어 쌍이었다. 사용자가 Notion 에서
1630
+ * 보는 화면이 깨질 뿐 아니라 이미지가 본문 흐름에서 이탈한다.
1631
+ *
1632
+ * 캡션에는 원본 임베드 대상을 그대로 적는다. pull 이 경로(와 `|별칭`)를 되찾는
1633
+ * 진실원이 되고, 사용자에게도 출처가 보인다.
1634
+ *
1635
+ * 볼트에서 파일을 못 찾으면 자리표시자를 그대로 둔다 — 마커가 남아야 pull 이
1636
+ * 임베드를 복원할 수 있으므로, 반쯤 지우는 것보다 안전하다.
1637
+ */
1638
+ materializeLocalMedia(pageId: string, pushedMarkdown: string, notePath?: string): Promise<MaterializeResult>;
1639
+ /**
1640
+ * 페이지 블록 트리를 훑어 자리표시자를 부모와 함께 모은다. 콜아웃·토글·목록 안에
1641
+ * 들어간 임베드도 제자리 교체하려면 부모 id 가 필요하다(append 의 after_block 은
1642
+ * 같은 부모의 자식만 기준으로 삼는다).
1643
+ */
1644
+ private collectPlaceholders;
1645
+ /**
1646
+ * 업로드분을 file_registry 에 남긴다. 노트 임베드는 여기서 페이지에 붙으므로,
1647
+ * 이후 FileHandler.pushAllFiles 가 같은 첨부를 폴더 페이지에 standalone 으로
1648
+ * 재업로드하는 중복을 공유 레지스트리로 차단한다.
1649
+ */
1650
+ private registerUploads;
1651
+ /**
1652
+ * 임베드 경로가 가리킬 수 있는 볼트 실제 파일 후보를 우선순위대로 만든다(F23).
1653
+ * Obsidian 위키링크 임베드는 노트 옆 파일을 파일명만으로 참조하는 게 관례라
1654
+ * 노트 폴더 → 볼트 루트 → attachments/ 순.
1655
+ *
1656
+ * push(업로드)와 pull(원본 복원)이 **같은 사다리**를 봐야 왕복이 닫힌다. 예전에는
1657
+ * pull 쪽이 볼트 루트만 확인해서, `__e2e_probe__/노트.md` 안의 `![[t-img.png]]` 는
1658
+ * 올라갈 때는 노트 폴더에서 잘 찾아 올리고도 내려올 때는 원본을 못 찾아 사본을
1659
+ * 새로 받았다(실측) — 왕복할 때마다 첨부가 한 벌씩 늘어난다.
1660
+ */
1661
+ private vaultCandidates;
1662
+ /**
1663
+ * 임베드 경로를 볼트 실제 파일로 해석해 읽는다(F23).
1664
+ * 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
1285
1665
  */
1286
1666
  private readImageFromVault;
1287
- private getContentTypeFromPath;
1288
- downloadAllFiles(markdown: string, pageTitle: string): Promise<{
1667
+ downloadAllFiles(markdown: string, pageTitle: string, notePath?: string): Promise<{
1289
1668
  content: string;
1290
1669
  downloads: ImageDownloadResult[];
1291
1670
  }>;
@@ -1307,7 +1686,25 @@ declare class ImageHandler {
1307
1686
  private guessFileExtension;
1308
1687
  }
1309
1688
 
1689
+ /**
1690
+ * 확장자 → MIME / Notion 블록타입 매핑의 단일 출처.
1691
+ *
1692
+ * 예전에는 ImageHandler 가 이미지 확장자만 담은 축소판 테이블을 따로 들고 있었다.
1693
+ * 그 결과 노트에 박힌 `![[문서.txt]]` 같은 임베드를 올릴 때 `application/octet-stream`
1694
+ * 으로 요청해 Notion File Upload API 가 400 으로 거절했다(실측). 업로드 경로가 둘인데
1695
+ * 테이블이 둘이면 한쪽만 고쳐지므로 여기로 합친다.
1696
+ */
1310
1697
  type NotionBlockType = "image" | "pdf" | "video" | "audio" | "file";
1698
+
1699
+ /** 첨부 다운로드 운영 튜닝값. 미지정 시 기존 동작과 동일한 기본값 사용. */
1700
+ interface FileHandlerOptions {
1701
+ /** 다운로드에 쓸 fetch 구현(테스트 주입용). 기본 전역 fetch. */
1702
+ readonly fetch?: typeof globalThis.fetch;
1703
+ /** 다운로드 1회 시도의 시간 상한(ms). 기본 300초. 0 이하면 상한 없음(권장하지 않음). */
1704
+ readonly downloadTimeoutMs?: number;
1705
+ /** 첨부 1건 업로드 전체의 시간 상한(ms). 기본 30분. 0 이하면 상한 없음. */
1706
+ readonly itemTimeoutMs?: number;
1707
+ }
1311
1708
  interface FileUploadResult {
1312
1709
  readonly localPath: string;
1313
1710
  readonly fileUploadId: string;
@@ -1324,8 +1721,27 @@ declare class FileHandler {
1324
1721
  private readonly notionClient;
1325
1722
  private readonly stateDb;
1326
1723
  private readonly sema;
1327
- constructor(vaultFs: VaultFS, notionClient: NotionClient, stateDb: IStateDB, concurrency?: number);
1724
+ private readonly customFetch?;
1725
+ private readonly downloadTimeoutMs;
1726
+ private readonly itemTimeoutMs;
1727
+ constructor(vaultFs: VaultFS, notionClient: NotionClient, stateDb: IStateDB, concurrency?: number, options?: FileHandlerOptions);
1728
+ private get fetchFn();
1328
1729
  pushFilesForFolder(folderPageId: string, folderPath: string): Promise<FileUploadResult[]>;
1730
+ /**
1731
+ * 첨부 1건을 폴더 페이지에 올린다 — 두 push 경로(`pushFilesForFolder` · `pushAllFiles`)의
1732
+ * 공통 단위. 변경 없음 판정·동시성 슬롯·시간 상한·실패 격리를 모두 여기 한 곳에 둔다.
1733
+ *
1734
+ * 한 곳에 모은 이유는 R9a 에서 실제로 데인 적이 있어서다. 같은 다운로드 로직이
1735
+ * image/file 핸들러에 두 벌 존재한 탓에 한쪽에만 상한이 걸린 채로 남았고, 상한 없는
1736
+ * 쪽이 세마포어를 쥔 채 영원히 매달렸다. 여기도 같은 코드가 두 루프에 나뉘어 있었다.
1737
+ *
1738
+ * 상한(R9f): 업로드는 호출 1건마다 상한이 있어도(SDK 30초) 멀티파트는 파트 수만큼
1739
+ * 호출이 늘어나는 **합성 경로**라 전체로는 여전히 무한대다. 상한을 넘긴 첨부는 경고만
1740
+ * 남기고 나머지 첨부는 계속 올린다 — 첨부 하나가 push 전체를 멎게 하지 않는다.
1741
+ *
1742
+ * @returns 업로드 결과. 변경 없음(스킵)이거나 실패했으면 null.
1743
+ */
1744
+ private pushSingleFile;
1329
1745
  /**
1330
1746
  * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 제외한다.
1331
1747
  * node 측 VaultFS 는 과거 `.base` 를 워커에서 빼는 식으로 우회했지만, Obsidian
@@ -1368,6 +1784,12 @@ interface DatabaseSyncResult {
1368
1784
  * 엉뚱한 행을 골라 정작 바뀐 행의 relation UUID 를 영영 해소하지 못했다(M3). push 경로는 항상 빈 배열.
1369
1785
  */
1370
1786
  writtenPaths: string[];
1787
+ /**
1788
+ * R13: 볼트에서 사라졌기에 되살린 행 수. `updated` 와 섞지 않는다 — 리모트는 그대로이고
1789
+ * 바뀐 것은 로컬의 부재이므로, 합쳐 세면 dry-run 과 멱등성 게이트(churn)가 "원격이
1790
+ * 바뀌었다"고 거짓말한다. 페이지 경로가 이미 같은 이유로 `restored` 를 분리해 보고한다.
1791
+ */
1792
+ restored: number;
1371
1793
  /**
1372
1794
  * F25: 이 config 가 linked view 컨테이너로 판정된 경우 data source 원본 database id.
1373
1795
  * 행 처리는 원본 config 에 양보하고 건너뛰었으므로, 호출처(디스커버리 캐시)는 이 config
@@ -1432,6 +1854,18 @@ declare class DatabaseSyncer {
1432
1854
  private localizeFileProperties;
1433
1855
  private pullDatabasePage;
1434
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;
1435
1869
  /**
1436
1870
  * rename 감지를 위한 고아 레코드 인덱스(내용 해시 → 레코드)를 만든다.
1437
1871
  * 고아 = 해당 DB 소속 db-row 레코드 중 로컬 파일이 더 이상 존재하지 않는(livePaths 에 없는) 것.
@@ -1858,19 +2292,6 @@ declare class EntryEditor {
1858
2292
  updateDate(entryPath: string, dateProperty: string, newDate: string): Promise<void>;
1859
2293
  }
1860
2294
 
1861
- interface BasePropertySchema {
1862
- readonly id: string;
1863
- readonly type: string;
1864
- readonly options?: Array<{
1865
- name: string;
1866
- color?: string;
1867
- }>;
1868
- readonly groups?: Array<{
1869
- name: string;
1870
- color?: string;
1871
- optionIds?: string[];
1872
- }>;
1873
- }
1874
2295
  interface BaseFileOptions {
1875
2296
  readonly databaseId: string;
1876
2297
  readonly databaseName: string;
@@ -1879,16 +2300,31 @@ interface BaseFileOptions {
1879
2300
  readonly folderPath: string;
1880
2301
  }
1881
2302
  type BasesViewType = "table" | "cards" | "list";
2303
+ /** Notion 뷰 하나를 Bases 로 옮길 때의 판정. */
2304
+ interface BasesViewMapping {
2305
+ /** Bases 뷰 타입. `null` = 대응이 없어 `.base` 에 실을 수 없음. */
2306
+ readonly type: BasesViewType | null;
2307
+ /**
2308
+ * 값이 있으면 **정확한 대응이 아니라 격하**라는 뜻(사람용 사유).
2309
+ * 뷰 자체는 살아남지만 원래 뷰의 표현 일부(달력 격자·타임라인 축)는 재현되지 않는다.
2310
+ */
2311
+ readonly degradeReason?: string;
2312
+ }
1882
2313
  /**
1883
- * Notion 뷰 타입 → Obsidian Bases 뷰 타입 매핑(SSOT).
2314
+ * Notion 뷰 타입 → Obsidian Bases 뷰 매핑(SSOT).
1884
2315
  *
1885
- * `null` = Bases 에 대응 뷰가 없어 `.base` 로 표현 불가. 이 경우 해당 뷰는 `.base` 에서
1886
- * 누락되므로, 사이드카(`<db>.notion.json`)가 원본 뷰 설정을 무손실 보존한다.
1887
2316
  * 뷰는 현재 pull-authoritative — Notion API 가 2026-03-19 뷰 생성/수정/삭제를 열었지만
1888
2317
  * 본 프로젝트는 아직 뷰 write-back 을 구현하지 않았다(로드맵 항목). push 로 되돌릴 수
1889
2318
  * 없는 동안 "조용한 유실 금지"는 곧 "보존 + 정직한 degrade 리포트"를 의미한다.
2319
+ *
2320
+ * 달력·타임라인은 예전에 `null` 이었다 — 즉 `.base` 에서 **통째로 사라졌다**. 사이드카에는
2321
+ * 남지만 Obsidian 을 여는 사람 눈에는 그 뷰가 애초에 없었던 것처럼 보인다. 격자·축은
2322
+ * 못 살려도 "날짜 순으로 늘어놓은 목록"이라는 본래 의도는 표로 남길 수 있으므로,
2323
+ * 버리는 대신 격하해 살리고 사유를 사이드카에 적는다.
1890
2324
  */
1891
- declare const NOTION_TO_BASES_VIEW: Record<string, BasesViewType | null>;
2325
+ declare const NOTION_TO_BASES_VIEW: Record<string, BasesViewMapping>;
2326
+ /** Notion 뷰 타입의 Bases 매핑. 미상 타입은 표현 불가로 본다. */
2327
+ declare function basesViewMappingOf(notionType: string): BasesViewMapping;
1892
2328
  /** Notion 뷰 타입의 Bases 대응 타입을 돌려준다. 미지원/미상 타입은 `null`. */
1893
2329
  declare function basesViewTypeOf(notionType: string): BasesViewType | null;
1894
2330
  declare class BaseFileGenerator {
@@ -1898,6 +2334,19 @@ declare class BaseFileGenerator {
1898
2334
  private writeFormulas;
1899
2335
  private writePropertyDisplayNames;
1900
2336
  private writeViews;
2337
+ /** `filters:` 키와 그 값을 쓴다. 단일 식이면 한 줄, 그룹이면 블록. */
2338
+ private writeFilterValue;
2339
+ /** 필터 노드를 시퀀스 항목(`- …`)으로 쓴다. 그룹이면 재귀적으로 중첩한다. */
2340
+ private writeFilterItem;
2341
+ /**
2342
+ * 필터 식을 YAML 스칼라로 쓴다.
2343
+ *
2344
+ * 식은 대개 `note["상태"] == "진행 중"` 처럼 큰따옴표를 품는다. 평문 스칼라는 큰따옴표를
2345
+ * 안에 담을 수 있으므로(선두만 아니면 된다) 기본은 평문으로 두어 기존 `file.ext == "md"`
2346
+ * 라인과 모양을 맞춘다. 다만 `: `·` #`·선두 지시문자는 평문에서 의미가 달라지므로
2347
+ * 그때만 작은따옴표로 감싼다(속성명에 콜론이나 해시가 들어갈 수 있다).
2348
+ */
2349
+ private yamlExpr;
1901
2350
  /**
1902
2351
  * 뷰 이름을 Bases 요구사항대로 유일화한다(in-place). 동명 충돌 시 ' 2',' 3'… 를 붙이되
1903
2352
  * 이미 존재하는 이름과 다시 충돌하지 않을 때까지 증가시킨다.
@@ -1912,11 +2361,72 @@ declare class BaseFileGenerator {
1912
2361
  */
1913
2362
  private propRef;
1914
2363
  private resolvePropertyName;
1915
- private decodeId;
1916
2364
  private yamlKey;
1917
2365
  private yamlValue;
1918
2366
  }
1919
2367
 
2368
+ /**
2369
+ * 속성 id → 프론트매터 키 해소.
2370
+ *
2371
+ * Notion `views.retrieve` 는 RAW 속성 id(예: `[jiM`)를 주지만 `databases.retrieve` 스키마는
2372
+ * URL-인코딩된 id(예: `%5BjiM`)를 준다. 디코드 후 비교하지 않으면 title 외 모든 속성
2373
+ * (cover/order/sort/groupBy/filter)이 매칭에 실패해 조용히 사라진다.
2374
+ *
2375
+ * `.base` 생성기와 사이드카 생성기가 같은 규칙을 써야 하므로 여기 한 곳에 둔다.
2376
+ */
2377
+ declare function resolvePropertyName(propertyId: string, schema: Record<string, BasePropertySchema>): string | undefined;
2378
+
2379
+ /**
2380
+ * Notion 뷰 필터식 → Obsidian Bases 필터식 번역기.
2381
+ *
2382
+ * ## 왜 필요한가
2383
+ *
2384
+ * Notion 뷰의 절반 가까이는 필터가 걸려 있다. 그런데 `.base` 는 폴더 필터만 내보냈고,
2385
+ * 사이드카는 "Notion 필터식은 .base 로 표현되지 않음"이라 적어 두고 끝냈다. 그래서
2386
+ * '시작전, 진행중' 이라는 이름의 뷰가 Obsidian 에서는 **완료된 행까지 전부** 보여 줬다 —
2387
+ * 이름과 내용이 어긋나는, 사용자가 오해하기 딱 좋은 상태였다. Bases 는 뷰마다 `filters:`
2388
+ * 를 지원하므로(공식 문서 `views[].filters`) 표현 가능한 조건은 실제로 옮길 수 있다.
2389
+ *
2390
+ * ## 부분 번역의 안전 규칙 — 이 파일의 핵심
2391
+ *
2392
+ * Notion 필터 문법은 Bases 보다 넓다(rollup any/every/none, 상대 날짜, formula 등).
2393
+ * 못 옮기는 리프를 만났을 때 **어느 방향으로 틀릴지**는 논리 위치에 따라 다르다:
2394
+ *
2395
+ * - `and` 안에서 리프 하나를 버리면 조건이 느슨해진다 → 결과는 **상위집합**.
2396
+ * Notion 보다 행이 더 보인다. 눈에 보이는 과다 표시이고, 데이터는 잃지 않는다.
2397
+ * - `or` 안에서 가지 하나를 버리면 조건이 조여진다 → 결과는 **부분집합**.
2398
+ * Notion 에 있던 행이 Obsidian 에서 **조용히 사라진다**. 이건 유실이다.
2399
+ * - `not` 은 안쪽을 버리면 부정의 의미 자체가 뒤집힌다.
2400
+ *
2401
+ * 그래서 `and` 는 번역 가능한 리프만 남기고, `or`/`not` 은 하나라도 못 옮기면 그룹
2402
+ * 전체를 포기한다. 포기한 것은 전부 {@link TranslationResult.untranslated} 로 보고돼
2403
+ * 사이드카 degrade 리포트에 실린다 — 조용히 넘어가는 경로는 없다.
2404
+ */
2405
+ /** Bases 필터 노드 — 문자열 표현식이거나 and/or/not 그룹. */
2406
+ type BasesFilterNode = string | {
2407
+ readonly and: BasesFilterNode[];
2408
+ } | {
2409
+ readonly or: BasesFilterNode[];
2410
+ } | {
2411
+ readonly not: BasesFilterNode[];
2412
+ };
2413
+ interface TranslationResult {
2414
+ /** 번역된 필터. 옮길 수 있는 조건이 하나도 없으면 null. */
2415
+ readonly node: BasesFilterNode | null;
2416
+ /** 옮기지 못한 조건의 사람용 설명(중복 제거·정렬됨). */
2417
+ readonly untranslated: string[];
2418
+ }
2419
+ /** 속성 id/이름 → 프론트매터 키. 스키마에서 못 찾으면 undefined. */
2420
+ type PropertyNameResolver = (idOrName: string) => string | undefined;
2421
+ /**
2422
+ * Notion 필터 객체를 Bases 필터 노드로 옮긴다.
2423
+ *
2424
+ * @param filter Notion `views.retrieve` 가 준 필터 객체(`unknown` — 문법이 계속 늘어난다).
2425
+ * @param schema DB 속성 스키마. 속성 id 해소와 status 그룹 전개에 쓴다.
2426
+ * @param resolveName 속성 id(또는 이름) → 프론트매터 키 해소기.
2427
+ */
2428
+ declare function translateNotionFilter(filter: unknown, schema: Record<string, BasePropertySchema>, resolveName: PropertyNameResolver): TranslationResult;
2429
+
1920
2430
  /**
1921
2431
  * DB 사이드카(`<db>.notion.json`) — `.base` 로 표현할 수 없는 Notion DB 메타데이터를
1922
2432
  * 무손실 보존하는 단일 파일.
@@ -1968,7 +2478,12 @@ interface SidecarView {
1968
2478
  }
1969
2479
  interface DegradeNote {
1970
2480
  readonly view: string;
1971
- readonly kind: "view-unrepresentable" | "setting-dropped";
2481
+ /**
2482
+ * `view-unrepresentable` = 뷰가 `.base` 에서 통째로 누락.
2483
+ * `view-degraded` = 뷰는 남지만 다른 타입으로 격하(달력→표 등).
2484
+ * `setting-dropped` = 뷰는 그대로지만 설정 하나가 빠짐.
2485
+ */
2486
+ readonly kind: "view-unrepresentable" | "view-degraded" | "setting-dropped";
1972
2487
  readonly detail: string;
1973
2488
  }
1974
2489
  declare class SidecarGenerator {
@@ -1994,6 +2509,15 @@ declare class SidecarGenerator {
1994
2509
  * 미표현 뷰는 이미 view-unrepresentable 로 통째 보고됨).
1995
2510
  */
1996
2511
  private extractConfig;
2512
+ /**
2513
+ * 필터 손실을 **실제로 못 옮긴 것만** 보고한다.
2514
+ *
2515
+ * 예전엔 필터가 있기만 하면 "Notion 필터식은 .base 로 표현되지 않음"을 무조건 찍었다.
2516
+ * 이제 `.base` 가 뷰별 `filters:` 를 내보내므로 그 문구는 대부분의 경우 거짓이다.
2517
+ * 거짓 degrade 는 진짜 degrade 를 묻어 버리므로, `.base` 와 **같은 번역기**를 돌려
2518
+ * 남은 것만 적는다(두 산출물이 어긋날 여지를 없앤다).
2519
+ */
2520
+ private noteFilterDegrade;
1997
2521
  }
1998
2522
 
1999
2523
  declare function sortEntries(entries: DBEntry[], sorts: NonNullable<ViewConfig["sorts"]>): DBEntry[];
@@ -2097,6 +2621,43 @@ declare function summarizeFidelity(perFile: ReadonlyArray<{
2097
2621
  classification: FidelityClassification;
2098
2622
  }>): FidelitySummary;
2099
2623
 
2624
+ /**
2625
+ * 렌더 감사 — "볼트에 쓰인 마크다운이 Obsidian 에서 깨져 보이는가"를 코드로 고정한다.
2626
+ *
2627
+ * {@link classifyBodyFidelity} 가 **내용이 남았는가**를 보고 {@link verifyPageCompleteness}
2628
+ * 가 **빠짐없이 왔는가**를 본다면, 여기는 **사람 눈에 어떻게 보이는가**를 본다. 셋은 서로의
2629
+ * 사각지대를 맡는다 — 실제로 이 트랙의 결함은 내용도 다 있고 개수도 맞는데 콜아웃이
2630
+ * 코드블록으로 오파싱되거나 표 구분행만 덩그러니 남는 형태였다.
2631
+ *
2632
+ * 원본 대조가 필요 없는 것만 담는다. 한 파일만 보고 판정할 수 있어야 동기화 하니스가
2633
+ * 볼트를 훑는 데 쓸 수 있다. 원본 NFM 과 견주는 지표(구조 드리프트·코드블록 경계)는
2634
+ * 회귀 코퍼스를 쥔 테스트 쪽에 남는다.
2635
+ *
2636
+ * 규칙 번호(①~⑧)는 트랙 초기 실측 조사에서 붙인 결함 분류를 그대로 잇는다.
2637
+ */
2638
+ /** 한 줄만 보고 판정할 수 있는 렌더 결함. */
2639
+ interface RenderLineRule {
2640
+ readonly code: string;
2641
+ readonly label: string;
2642
+ readonly re: RegExp;
2643
+ }
2644
+ declare const RENDER_LINE_RULES: readonly RenderLineRule[];
2645
+ /** 발견된 렌더 결함 하나. */
2646
+ interface RenderFinding {
2647
+ readonly code: string;
2648
+ readonly label: string;
2649
+ readonly line: number;
2650
+ }
2651
+ /**
2652
+ * 표 구분행 — 가로 공백만 허용한다. `\s` 를 쓰면 **줄바꿈까지 삼킨다**.
2653
+ *
2654
+ * 그러면 빈 표 행(`| | | |`) 다음의 수평선(`---`)이 한 덩어리로 잡혀 구분행 하나가
2655
+ * 날조되고, 데이터 행이 하나 줄어든 것처럼 보인다.
2656
+ */
2657
+ declare const SEPARATOR_BODY = "[ \\t:|-]*-{3,}";
2658
+ /** 볼트에 실제로 쓰인 마크다운을 훑어 렌더 결함을 모은다. */
2659
+ declare function lintRenderedMarkdown(markdown: string): RenderFinding[];
2660
+
2100
2661
  declare function computeHash(content: string): string;
2101
2662
  declare function computeBufferHash(buffer: Buffer): string;
2102
2663
 
@@ -2112,6 +2673,24 @@ declare function compactNotionId(id: string): string;
2112
2673
 
2113
2674
  declare function sanitizeFileName(name: string): string;
2114
2675
 
2676
+ /**
2677
+ * `--path` 범위 판정 — 볼트 상대경로가 사용자가 지정한 범위 안에 있는지.
2678
+ *
2679
+ * 단순 `startsWith` 는 **경로 조각 경계를 보지 않아** 범위를 넘겨 짚는다.
2680
+ * `--path Notes` 가 `Notes-archive/비밀.md` · `Notes백업.md` 까지 끌어와, 사용자가
2681
+ * 의도한 폴더 밖 파일을 push/pull 대상에 올린다(범위 지정의 의미가 무너진다).
2682
+ *
2683
+ * 규칙은 셋뿐이다.
2684
+ * 1. 완전 일치 — `--path a/b.md` ↔ `a/b.md`
2685
+ * 2. 폴더 하위 — `--path a` ↔ `a/b.md` (경계 `/` 를 반드시 소비)
2686
+ * 3. 그 외는 불일치 — `--path a` ↔ `ab.md` ✗
2687
+ *
2688
+ * 사용자가 `--path a/` 처럼 슬래시를 붙여 쓰는 경우도 같은 범위로 본다.
2689
+ */
2690
+ declare function matchesPathScope(path: string, scope: string): boolean;
2691
+ /** 범위 목록 중 하나라도 걸리면 true. 목록이 비어 있으면(범위 미지정) 항상 true. */
2692
+ declare function inAnyPathScope(path: string, scopes: readonly string[] | undefined): boolean;
2693
+
2115
2694
  interface Logger {
2116
2695
  warn(message: string, ...args: unknown[]): void;
2117
2696
  error(message: string, ...args: unknown[]): void;
@@ -2169,4 +2748,4 @@ declare function isInternalPath(path: string): boolean;
2169
2748
  /** 모든 보존 마커가 공유하는 브랜드 토큰. */
2170
2749
  declare const MARKER_BRAND = "im-nobsidian";
2171
2750
 
2172
- export { BaseFileGenerator, type BaseFileOptions, type BasePropertySchema, type BaseSyncOptions, type BasesViewType, BlockConverter, CONFIG_FILE, type CalendarEntry, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, 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 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 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, type RecordPendingInput, type RegisterFileInput, type RemoteChange, type ResolutionChoice, type ResolutionResult, type RichTextSegment, 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, TreeMapper, UnsupportedBlockStripper, type UpsertSyncRecord, type VaultFS, type ViewConfig, ViewDataProvider, type ViewPropertyConfig, type ViewRenderData, type ViewType, type WatchSyncOptions, WatchSyncService, type WikilinkEntry, WikilinkResolver, basesViewTypeOf, classifyBodyFidelity, compactNotionId, computeBufferHash, computeHash, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, isInternalPath, normalizeNotionId, notionIdsEqual, sanitizeFileName, setLogger, sortEntries, summarizeFidelity, threeWayMerge };
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 };