@im-nobsidian/core 0.1.12 → 0.2.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
@@ -534,6 +534,12 @@ interface IStateDB {
534
534
  getFilesByPageId(notionPageId: string): FileRegistryEntry[];
535
535
  registerFile(entry: RegisterFileInput): void;
536
536
  deleteFileRegistry(localPath: string): void;
537
+ recordPendingOperation(input: RecordPendingInput): string;
538
+ getIncompletePendingOperations(): PendingOperation[];
539
+ getIncompleteOpByState(syncStateId: string, operation: PendingOperation["operation"]): PendingOperation | null;
540
+ markPendingCompleted(id: string): void;
541
+ markPendingFailed(id: string, errorMessage: string): void;
542
+ clearCompletedOperations(): void;
537
543
  storePreserveMarkers(path: string, markers: PreserveMarker[]): void;
538
544
  getPreserveMarkers(path: string): PreserveMarker[];
539
545
  transaction<T>(fn: () => T): T;
@@ -570,6 +576,13 @@ declare class StateDB implements IStateDB {
570
576
  registerFile(entry: RegisterFileInput): void;
571
577
  deleteFileRegistry(localPath: string): void;
572
578
  private mapFileRegistryRow;
579
+ recordPendingOperation(input: RecordPendingInput): string;
580
+ getIncompletePendingOperations(): PendingOperation[];
581
+ getIncompleteOpByState(syncStateId: string, operation: PendingOperation["operation"]): PendingOperation | null;
582
+ markPendingCompleted(id: string): void;
583
+ markPendingFailed(id: string, errorMessage: string): void;
584
+ clearCompletedOperations(): void;
585
+ private mapPendingRow;
573
586
  storePreserveMarkers(path: string, markers: PreserveMarker[]): void;
574
587
  getPreserveMarkers(path: string): PreserveMarker[];
575
588
  transaction<T>(fn: () => T): T;
@@ -607,6 +620,45 @@ interface RegisterFileInput {
607
620
  readonly fileHash: string;
608
621
  readonly fileSize: number;
609
622
  }
623
+ /** I12 크래시 복구 WAL 항목. push/pull 적용 전 기록되고, 적용 완료 시 completed 로 마감된다. */
624
+ interface PendingOperation {
625
+ readonly id: string;
626
+ readonly syncStateId: string;
627
+ readonly operation: "create" | "update" | "delete" | "move";
628
+ readonly direction: "push" | "pull";
629
+ readonly payload: string | null;
630
+ readonly retryCount: number;
631
+ readonly errorMessage: string | null;
632
+ readonly status: "pending" | "processing" | "completed" | "failed";
633
+ readonly createdAt: string;
634
+ readonly completedAt: string | null;
635
+ }
636
+ interface RecordPendingInput {
637
+ readonly syncStateId: string;
638
+ readonly operation: PendingOperation["operation"];
639
+ readonly direction: PendingOperation["direction"];
640
+ readonly payload?: string | null;
641
+ }
642
+
643
+ type NotionPropertyValue = Record<string, unknown>;
644
+ interface WikilinkResolver$1 {
645
+ resolve(title: string): string | null;
646
+ resolvePageId(pageId: string): string | null;
647
+ }
648
+ declare class PropertyMapper {
649
+ private schema;
650
+ private wikilinkResolver;
651
+ setWikilinkResolver(resolver: WikilinkResolver$1): void;
652
+ loadSchema(properties: Record<string, {
653
+ id: string;
654
+ type: string;
655
+ }>): void;
656
+ toNotionProperties(frontmatter: Record<string, unknown>, title: string): Record<string, NotionPropertyValue>;
657
+ fromNotionProperties(notionProps: Record<string, unknown>): Record<string, unknown>;
658
+ private convertBySchema;
659
+ private inferAndConvert;
660
+ private extractValue;
661
+ }
610
662
 
611
663
  interface NotionClientOptions {
612
664
  readonly token: string;
@@ -623,9 +675,7 @@ interface NotionClientOptions {
623
675
  declare class NotionClient {
624
676
  private readonly client;
625
677
  private readonly sema;
626
- private readonly token;
627
678
  private readonly propertyMapper;
628
- private readonly customFetch?;
629
679
  private readonly minRequestInterval;
630
680
  private readonly maxRetries;
631
681
  private readonly retryBaseDelayMs;
@@ -660,8 +710,29 @@ declare class NotionClient {
660
710
  replaceAll?: boolean;
661
711
  }>): Promise<PageMarkdownResponse>;
662
712
  movePage(pageId: string, newParentId: string, newParentType: "page" | "database"): Promise<PageObjectResponse>;
663
- private fetchDatabaseLegacy;
713
+ /**
714
+ * 데이터베이스 1개를 신 모델(2025-09-03 data source 분리)로 조회한다.
715
+ * - title: database 객체에 그대로 존재한다.
716
+ * - properties(스키마): 신 모델에선 data source 에 있으므로 1차 data source 를 조회해 채운다.
717
+ * data source 접근 불가(링크드 DB 등)면 database 객체의 properties 로 폴백한다(제목은 보존).
718
+ *
719
+ * 폐기한 레거시 raw fetch(`GET /v1/databases/{id}`, Notion-Version 2022-06-28)는 신 모델로
720
+ * 업그레이드된 다수 DB에 400 을 반환해 자동 발견 DB 가 통째 드롭(내용 손실)되던 원인이었다.
721
+ * SDK(`databases.retrieve`/`dataSources.retrieve`)는 동일 토큰으로 정상 동작한다.
722
+ */
723
+ private fetchDatabaseModern;
664
724
  getDatabaseTitle(databaseId: string): Promise<string>;
725
+ /**
726
+ * 발견된 DB 가 **동기화 가능한지**(접근 가능한 data source 가 있는지) 1회 호출로 판별한다.
727
+ * 신 모델(2025-09-03)에서 링크드 DB·미공유 데이터 소스·삭제 DB 는 `data_sources` 가
728
+ * 비어, 행 조회(`dataSources.query`)가 404 로 실패하고 그 전에 생성된 빈 폴더/`.base` 만
729
+ * 남긴다. 발견 단계에서 미리 걸러 **빈 폴더/.base 오염 + 매 pull 의 404 노이즈**를 차단한다.
730
+ * 제목은 함께 반환해 호출처가 추가 조회 없이 폴더명을 잡게 한다.
731
+ */
732
+ getDatabaseSyncability(databaseId: string): Promise<{
733
+ title: string;
734
+ queryable: boolean;
735
+ }>;
665
736
  getDatabaseSchema(databaseId: string): Promise<Record<string, {
666
737
  id: string;
667
738
  type: string;
@@ -679,6 +750,12 @@ declare class NotionClient {
679
750
  optionIds?: string[];
680
751
  }>;
681
752
  }>>;
753
+ /** 특정 data source 1개를 직접 쿼리한다(저수준). 다중 data source 순회의 빌딩블록. */
754
+ private queryDataSource;
755
+ /**
756
+ * database 의 1차 data source 1페이지를 쿼리한다(증분 detect 등 단일 소스 경로용).
757
+ * 전 행을 빠짐없이 받으려면 {@link queryAllDatabasePages} 를 쓴다(전 data source 순회).
758
+ */
682
759
  queryDatabase(databaseId: string, options?: {
683
760
  startCursor?: string;
684
761
  pageSize?: number;
@@ -687,8 +764,24 @@ declare class NotionClient {
687
764
  results: PageObjectResponse[];
688
765
  nextCursor: string | null;
689
766
  }>;
767
+ /**
768
+ * database 의 **모든 data source** 행을 빠짐없이 조회한다(2025-09-03 신 모델).
769
+ * 한 database 가 2개 이상의 data source(각자 행 집합)를 가질 수 있어, 1차 data source 만
770
+ * 페이지네이션하면 2번째+ 의 행이 통째 침묵 유실된다. 전 data source 를 순회·페이지네이션
771
+ * 하고 page_id 로 디듀프(소스 간 동일 페이지 방어)한 뒤 합친다. 다중 소스면 1회 경고해
772
+ * "소스별 탭 구분이 한 폴더로 병합"되는 점을 비침묵으로 알린다.
773
+ */
690
774
  queryAllDatabasePages(databaseId: string, filter?: unknown): Promise<PageObjectResponse[]>;
775
+ /** database 의 1차 data source id(없으면 databaseId 폴백). 단일 소스 경로 호환용. */
691
776
  getDataSourceId(databaseId: string): Promise<string>;
777
+ /**
778
+ * database 의 전 data source 메타(id + 이름)를 반환한다. data source 가 없으면(레거시/단일)
779
+ * databaseId 자체를 단일 소스로 폴백해 호출처가 항상 ≥1 개를 받게 한다.
780
+ */
781
+ getDataSourceMetas(databaseId: string): Promise<Array<{
782
+ id: string;
783
+ name: string;
784
+ }>>;
692
785
  archivePage(pageId: string): Promise<void>;
693
786
  getFileBlockUrl(blockId: string): Promise<string | null>;
694
787
  getBlock(blockId: string): Promise<BlockObjectResponse>;
@@ -721,24 +814,52 @@ declare class NotionClient {
721
814
  id: string;
722
815
  last_edited_time: string;
723
816
  }>>;
724
- /**
725
- * 통합이 접근 가능한 모든 페이지를 search API로 일괄 조회한다(요청당 최대 100건).
726
- * 블록 트리를 페이지별로 재귀 순회하는 getChildPagesRecursive 대비 API 호출 수가
727
- * 페이지 수의 수십분의 1로 줄어든다. search API는 휴지통(in_trash) 페이지를 반환하지 않는다.
728
- * 반환 객체에는 parent 정보가 포함되어 호출 측에서 root subtree ancestry 필터링이 가능하다.
729
- */
730
- searchAllPages(): Promise<PageObjectResponse[]>;
731
817
  getChildPages(parentId: string): Promise<PageObjectResponse[]>;
732
818
  /** 페이지가 휴지통(in_trash)이거나 보관(archived) 상태인지 판정한다. */
733
819
  private static isTrashedOrArchived;
734
820
  getChildDatabaseIds(parentId: string): Promise<string[]>;
735
- getChildPagesRecursive(parentId: string): Promise<PageObjectResponse[]>;
821
+ /**
822
+ * root 서브트리를 직접 BFS 순회해 하위 페이지를 모두 수집한다(중첩 깊이 무관·무손실).
823
+ * 비용은 서브트리 전체 블록 수에 비례한다.
824
+ *
825
+ * @param opts.deadlineMs `Date.now()` 기준 마감 시각. 각 페이지 처리 전 초과를 검사해
826
+ * 초과 시 {@link DiscoveryTooLargeError}를 던진다(대규모 서브트리 → search 폴백 유도).
827
+ * 미지정 시 무제한(기존 동작 유지 — 테스트 mock 경로는 영향 없음).
828
+ */
829
+ getChildPagesRecursive(parentId: string, opts?: {
830
+ deadlineMs?: number;
831
+ }): Promise<PageObjectResponse[]>;
832
+ /** 워크스페이스에서 통합에 공유된 모든 페이지를 bulk search 로 열거한다(100/요청). */
833
+ searchAllPages(): Promise<PageObjectResponse[]>;
834
+ /**
835
+ * root 하위 페이지를 **search 기반**으로 디스커버리한다(대규모 서브트리용 폴백).
836
+ *
837
+ * 전략: 워크스페이스 전체 페이지를 1회 bulk search 로 열거(비용 ∝ 워크스페이스 페이지 수,
838
+ * 예측가능·유한)한 뒤, 각 페이지의 부모 체인을 따라 root 도달 여부를 판정한다. page_id
839
+ * 부모는 메모리 맵으로 추가 호출 없이 즉시 해석하고, block_id 부모(컬럼·토글 등 레이아웃에
840
+ * 중첩된 페이지)만 getBlock 으로 소유 페이지를 해석(memo)한다. search 는 중첩 깊이와 무관하게
841
+ * 모든 페이지를 반환하므로 직접 순회와 동일하게 **무손실**이다.
842
+ *
843
+ * getChildPagesRecursive 와 동일 집합을 보장하기 위해:
844
+ * - DB 행(parent=data_source_id/database_id)은 child_page 로 도달 불가 → 제외
845
+ * - 휴지통/보관(in_trash/archived) 페이지 제외
846
+ * - root 자신은 제외(직접 순회도 root 의 *자식*부터 수집)
847
+ */
848
+ getPagesUnderRootViaSearch(rootId: string): Promise<PageObjectResponse[]>;
736
849
  listDatabaseViews(databaseId: string): Promise<ViewConfig[]>;
737
850
  getView(viewId: string): Promise<ViewConfig | null>;
738
851
  getDatabaseViewsConfig(databaseId: string): Promise<DatabaseViewsConfig>;
739
852
  extractCover(page: PageObjectResponse): PageCover | null;
740
853
  extractIcon(page: PageObjectResponse): PageIcon | null;
741
854
  extractTitle(page: PageObjectResponse): string;
855
+ /**
856
+ * 페이지 모드 pull(extractProperties)의 relation/people 속성을 raw Notion UUID 가
857
+ * 아니라 [[제목]] 위키링크로 해소하도록 resolver 를 주입한다. 주입하지 않으면 매
858
+ * pull 마다 relation 이 raw UUID 로 재생성돼, 후처리(resolveNotionLinks)에만 의존하는
859
+ * 2-write churn 이 남는다(M1). DB 모드는 orchestrator/DatabaseSyncer 의 자체 mapper 가
860
+ * 이미 resolver 를 주입받아 면역이다.
861
+ */
862
+ setWikilinkResolver(resolver: WikilinkResolver$1): void;
742
863
  extractProperties(page: PageObjectResponse): Record<string, unknown>;
743
864
  private lastRequestTime;
744
865
  private withRateLimit;
@@ -849,26 +970,6 @@ declare class NotionBlockBuilder {
849
970
  static table(rows: string[][], hasColumnHeader?: boolean, hasRowHeader?: boolean): NotionBlock;
850
971
  }
851
972
 
852
- type NotionPropertyValue = Record<string, unknown>;
853
- interface WikilinkResolver$1 {
854
- resolve(title: string): string | null;
855
- resolvePageId(pageId: string): string | null;
856
- }
857
- declare class PropertyMapper {
858
- private schema;
859
- private wikilinkResolver;
860
- setWikilinkResolver(resolver: WikilinkResolver$1): void;
861
- loadSchema(properties: Record<string, {
862
- id: string;
863
- type: string;
864
- }>): void;
865
- toNotionProperties(frontmatter: Record<string, unknown>, title: string): Record<string, NotionPropertyValue>;
866
- fromNotionProperties(notionProps: Record<string, unknown>): Record<string, unknown>;
867
- private convertBySchema;
868
- private inferAndConvert;
869
- private extractValue;
870
- }
871
-
872
973
  interface FileInfo {
873
974
  readonly path: string;
874
975
  readonly content: string;
@@ -907,6 +1008,27 @@ interface VaultFS {
907
1008
  getFileStat(path: string): Promise<FileStatInfo | null>;
908
1009
  }
909
1010
 
1011
+ type ResolutionChoice = "local" | "remote" | "merge" | "duplicate";
1012
+ interface ResolutionResult {
1013
+ readonly path: string;
1014
+ readonly choice: ResolutionChoice;
1015
+ readonly success: boolean;
1016
+ readonly mergeHadConflicts?: boolean;
1017
+ }
1018
+ declare class ConflictResolver {
1019
+ private readonly stateDb;
1020
+ private readonly vaultFs;
1021
+ constructor(stateDb: IStateDB, vaultFs: VaultFS);
1022
+ resolve(conflict: Conflict, choice: ResolutionChoice): Promise<ResolutionResult>;
1023
+ resolveByStrategy(conflict: Conflict, strategy: ConflictStrategy): Promise<ResolutionResult>;
1024
+ resolveAll(conflicts: Conflict[], strategy: ConflictStrategy): Promise<ResolutionResult[]>;
1025
+ private resolveLocal;
1026
+ private resolveRemote;
1027
+ private resolveMerge;
1028
+ private resolveDuplicate;
1029
+ generateDiff(conflict: Conflict): string;
1030
+ }
1031
+
910
1032
  declare class SyncOrchestrator {
911
1033
  private readonly config;
912
1034
  private readonly stateDb;
@@ -919,10 +1041,13 @@ declare class SyncOrchestrator {
919
1041
  private readonly fileHandler;
920
1042
  private readonly databaseSyncer;
921
1043
  private readonly propertyMapper;
1044
+ private readonly conflictResolver;
922
1045
  private dbSchemaLoaded;
923
1046
  private _pullImageCount;
924
1047
  private _pullFileCount;
925
1048
  private _inlineDbRefs;
1049
+ private _childParentIds;
1050
+ private static readonly DISCOVERY_RECURSIVE_BUDGET_MS;
926
1051
  constructor(config: Config, stateDb: IStateDB, notionClient: NotionClient, vaultFs: VaultFS, customFetch?: typeof globalThis.fetch);
927
1052
  push(options?: PushOptions): Promise<PushResult>;
928
1053
  pull(options?: PullOptions): Promise<PullResult>;
@@ -946,26 +1071,57 @@ declare class SyncOrchestrator {
946
1071
  * - 제목 취득 실패 시 부모 폴더명 기반 안전 이름으로 폴백
947
1072
  */
948
1073
  private buildDiscoveredDbConfig;
1074
+ /** 접근 불가 DB denylist 를 상태 메타에서 로드한다(하이픈 정규화). */
1075
+ private loadInaccessibleDbIds;
949
1076
  private pullDiscoveredDatabases;
950
1077
  private resolveNotionLinks;
951
1078
  private ensureDbSchema;
952
1079
  private pushCreate;
953
1080
  private pushUpdate;
1081
+ /** 단일 충돌을 사용자가 고른 선택지(local/remote/merge/duplicate)로 해소 + Notion 전파. */
1082
+ resolveConflict(conflict: Conflict, choice: ResolutionChoice): Promise<ResolutionResult>;
1083
+ /** 단일 충돌을 전략(manual/local-first/remote-first/duplicate)으로 해소 + Notion 전파. */
1084
+ resolveConflictByStrategy(conflict: Conflict, strategy: ConflictStrategy): Promise<ResolutionResult>;
1085
+ /** 여러 충돌을 동일 전략으로 일괄 해소 + Notion 전파. */
1086
+ resolveAllConflicts(conflicts: Conflict[], strategy: ConflictStrategy): Promise<ResolutionResult[]>;
1087
+ /** 충돌 미리보기용 통합 diff(원본 vs 로컬 vs 원격). 해소 없이 표시 전용. */
1088
+ generateConflictDiff(conflict: Conflict): string;
1089
+ /**
1090
+ * 해소 결과를 Notion 으로 전파해 로컬↔원격 일관성을 봉합한다.
1091
+ * - remote 선택: 로컬이 원격으로 갱신됐을 뿐이므로 push 불필요. notionLastEdited 만
1092
+ * 원격 변경의 lastEdited 로 재조정 → 다음 pull 이 같은 변경을 재충돌로 보지 않음.
1093
+ * - merge 실패(충돌 마커 잔존): 사용자가 직접 풀어야 하므로 conflict 상태 유지·push 안 함.
1094
+ * - local / merge(성공) / duplicate: 해소된 로컬 내용을 Notion 에 재push(pushUpdate 가
1095
+ * 변환·이미지·속성·해시·notionLastEdited 를 한 트랜잭션으로 재조정) → 무손실 수렴.
1096
+ */
1097
+ private propagateResolution;
954
1098
  private pushDelete;
955
1099
  private pushMove;
956
1100
  private detectRemoteChanges;
1101
+ /**
1102
+ * 증분 원격 변경 감지 — `since` 이후 수정된 페이지만 search 로 받아 created/modified 만
1103
+ * 만든다. **삭제는 의도적으로 감지하지 않는다**: search API 는 in_trash/archived 페이지를
1104
+ * 반환하지 않아(=사라진 것을 증분만으로는 구분 불가) 삭제 판정에는 전체 enumeration 이
1105
+ * 필수다. 따라서 호출부는 `deleteSync` 가 켜진 경우 이 fast-path 를 쓰지 않고
1106
+ * `detectRemoteChanges`(전체 스캔, 삭제 diff 포함)로 우회한다. (I10)
1107
+ */
957
1108
  private detectRemoteChangesIncremental;
958
1109
  private isTrackedParent;
1110
+ private pullCreate;
959
1111
  /**
960
- * search API로 받은 전체 접근 가능 페이지 중 rootPageId 하위(자손)만 남긴다.
961
- * 각 페이지의 parent 체인을 root에 닿을 때까지 거슬러 올라가며 판정한다.
962
- * - 부모가 이미 받은 페이지면 추가 API 호출 없이 메모리에서 해석(공통 경로)
963
- * - 부모가 검색 결과에 없으면(데이터베이스·미공유 조상 등) getPage로 1회 조회 후 캐시
964
- * - 조회 불가/순환/깊이 초과 시 보수적으로 root 하위가 아님으로 판정
965
- * 판정 결과는 체인 전체에 메모이즈해 형제 페이지 처리 시 재사용한다.
1112
+ * 페이지가 폴더(자식 페이지·자식 DB 보유)인지 신뢰성 있게 판정한다.
1113
+ *
1114
+ * 결함(폴더노트 본문분리)의 근본 원인은 얕은 판정이었다 — 최상위 블록 첫 페이지에서
1115
+ * child_page 만 검사하면 callout·column·toggle 안에 중첩된 자식 페이지나 child_database 를
1116
+ * 놓쳐 폴더노트가 file 로 오분류되고, 본문이 폴더 밖 최상위로 밀려 resolveUniqueFilePath
1117
+ * 충돌(' (1).md')로 쪼개졌다.
1118
+ *
1119
+ * 1차: 발견 단계(서브트리 순회/증분)에서 전 페이지의 부모를 해소하며 만든
1120
+ * _childParentIds 집합으로 O(1) 판정(추가 API 호출 0, 처리 순서 무관) — 전체 pull 경로.
1121
+ * 폴백: 집합에 없을 때만(증분 pull 의 신규 폴더 등) fetchAllChildrenDeep 로 컨테이너를
1122
+ * 재귀 탐색해 child_page·child_database 를 직접 확인한다.
966
1123
  */
967
- private filterPagesUnderRoot;
968
- private pullCreate;
1124
+ private pageHasChildContainers;
969
1125
  private pullUpdate;
970
1126
  private pullDelete;
971
1127
  private ensureFolderPage;
@@ -974,6 +1130,7 @@ declare class SyncOrchestrator {
974
1130
  private isFolderNote;
975
1131
  private resolveParentPath;
976
1132
  private pushCreatePage;
1133
+ private pageHasLiveChildren;
977
1134
  private pushUpdatePage;
978
1135
  private fetchPageMarkdown;
979
1136
  private collectInlineDbRefs;
@@ -981,6 +1138,24 @@ declare class SyncOrchestrator {
981
1138
  private extractParentId;
982
1139
  private resolveBlockToPageId;
983
1140
  private cleanupInterruptedSync;
1141
+ /**
1142
+ * I12 — 중단된 push create 작업 재개.
1143
+ *
1144
+ * pending_operations 에 미완료(create·push) 항목이 있으면:
1145
+ * - state 에 notion_page_id 가 이미 있으면 → 생성·매핑까지는 끝났고 markCompleted 직전에
1146
+ * 중단된 것 → 완료 처리(잔여 본문/이미지는 다음 변경감지가 pushUpdate 로 마무리).
1147
+ * - notion_page_id 가 비어 있으면(Window A: 생성 적용됐으나 매핑 기록 전 중단) → 부모에서
1148
+ * 제목으로 child_page 를 검색해 고아 페이지를 입양(중복 생성 차단). 없으면 자리표시
1149
+ * 레코드를 제거(FK CASCADE 로 op 도 삭제)해 다음 push 가 새로 생성하게 한다.
1150
+ *
1151
+ * 한계(문서화): Notion 은 idempotency key 가 없어 "생성 요청 적용 직후 같은 호출 내 재시도"
1152
+ * 로 인한 중복은 완전히 차단하지 못한다. 429 는 적용 전 거절이라 안전하고, 프로세스 재시작
1153
+ * 후 재개는 본 검색-입양으로 중복을 막는다. DB 모드(부모가 database)는 child_page 검색이
1154
+ * 불가하므로 자리표시 제거 후 재생성으로 폴백한다.
1155
+ */
1156
+ private recoverInterruptedPushOps;
1157
+ /** 부모 페이지의 직속 자식 중 제목이 일치하는(보관/휴지통 제외) child_page id 를 찾는다. */
1158
+ private findChildPageByTitle;
984
1159
  private resolveUniqueFilePath;
985
1160
  }
986
1161
 
@@ -1029,17 +1204,21 @@ interface ImageDownloadResult {
1029
1204
  interface ImageUploadResult {
1030
1205
  readonly localPath: string;
1031
1206
  readonly fileUploadId: string;
1207
+ /** 업로드한 이미지의 전체 sha256 — file_registry 비교 키(FileHandler 와 동일 산식). */
1208
+ readonly fileHash: string;
1209
+ readonly fileSize: number;
1032
1210
  }
1033
1211
  declare class ImageHandler {
1034
1212
  private readonly vaultFs;
1035
1213
  private readonly attachmentFolder;
1036
1214
  private readonly notionClient?;
1215
+ private readonly stateDb?;
1037
1216
  private readonly customFetch?;
1038
1217
  private readonly concurrency;
1039
1218
  private readonly maxRetries;
1040
1219
  private readonly retryBaseMs;
1041
1220
  private readonly maxFileSizeBytes;
1042
- constructor(vaultFs: VaultFS, attachmentFolder?: string, notionClient?: NotionClient | undefined, customFetch?: typeof globalThis.fetch, options?: MediaOptions);
1221
+ constructor(vaultFs: VaultFS, attachmentFolder?: string, notionClient?: NotionClient | undefined, customFetch?: typeof globalThis.fetch, options?: MediaOptions, stateDb?: IStateDB | undefined);
1043
1222
  private get fetchFn();
1044
1223
  downloadImage(url: string, pageTitle: string): Promise<ImageDownloadResult>;
1045
1224
  downloadAllImages(markdown: string, pageTitle: string): Promise<{
@@ -1109,6 +1288,13 @@ interface DatabaseSyncResult {
1109
1288
  /** Pull 시 로컬·리모트 동시 수정으로 발생한 충돌 (push 는 항상 빈 배열). */
1110
1289
  conflicts: Conflict[];
1111
1290
  failed: FailedOperation[];
1291
+ /**
1292
+ * 이번 pull 에서 실제로 디스크에 기록한 db-row 파일 경로(SSOT).
1293
+ * 오케스트레이터의 링크 해소(resolveNotionLinks) 대상은 반드시 이 목록으로 정한다 —
1294
+ * 과거의 `getByStatus("synced").slice(-N)` 휴리스틱은 ORDER BY 가 없어 증분 pull 에서
1295
+ * 엉뚱한 행을 골라 정작 바뀐 행의 relation UUID 를 영영 해소하지 못했다(M3). push 경로는 항상 빈 배열.
1296
+ */
1297
+ writtenPaths: string[];
1112
1298
  }
1113
1299
  declare class DatabaseSyncer {
1114
1300
  private readonly config;
@@ -1119,12 +1305,25 @@ declare class DatabaseSyncer {
1119
1305
  private readonly imageHandler;
1120
1306
  private readonly propertyMapper;
1121
1307
  private readonly baseFileGenerator;
1308
+ private readonly sidecarGenerator;
1122
1309
  constructor(config: Config, stateDb: IStateDB, notionClient: NotionClient, vaultFs: VaultFS, pipeline: ConversionPipeline, imageHandler: ImageHandler);
1123
1310
  pullAll(): Promise<DatabaseSyncResult>;
1124
1311
  pushAll(): Promise<DatabaseSyncResult>;
1125
1312
  pullDatabase(dbConfig: DatabaseSyncConfig): Promise<DatabaseSyncResult>;
1126
1313
  private pullDatabaseViews;
1127
1314
  private generateBaseFile;
1315
+ /**
1316
+ * DB 제목이 바뀌면 `.base`/`.notion.json` 파일명(safeName)이 바뀌어 옛 이름의 산출물이
1317
+ * 같은 폴더에 고아로 잔존한다(drift·부활 원인). DB 폴더 **직속**의 산출물 중 현재 이름이
1318
+ * 아닌 것을 삭제해 멱등을 보장한다. 하위 폴더(중첩 DB)의 산출물은 보호한다.
1319
+ */
1320
+ private cleanupStaleDbArtifacts;
1321
+ /**
1322
+ * `.base` 가 표현하지 못하는 Notion DB 메타데이터(미지원 뷰·필터식·커버크기 등)를
1323
+ * 사이드카 `<db>.notion.json` 으로 무손실 보존하고, degrade 된 항목을 정직하게 로그한다.
1324
+ * 결정적 직렬화라 동일 DB 상태 → 동일 바이트 → 멱등(drift/churn 0).
1325
+ */
1326
+ private generateSidecar;
1128
1327
  private pullDatabasePage;
1129
1328
  private pushDatabase;
1130
1329
  /**
@@ -1140,6 +1339,19 @@ declare class BlockConverter {
1140
1339
  private client;
1141
1340
  initNotionToMd(client: Client): void;
1142
1341
  private registerCustomTransformers;
1342
+ /**
1343
+ * `numbered_list_item` 을 `1.` 마크다운으로 내보내 **번호 목록 타입을 보존**한다(I2 블록 라운드트립).
1344
+ *
1345
+ * notion-to-md 기본 렌더는 `md.bullet(text, block.numbered_list_item.number)` 를 쓰는데
1346
+ * Notion API 는 list item 에 `.number` 를 채워주지 않아 `undefined` → 불릿(`- `)으로 **강등**된다.
1347
+ * 그러면 블록 폴백 경로(notionBlocksToMarkdown)로 pull 한 뒤 다시 push 하면 numbered_list_item 이
1348
+ * bulleted_list_item 으로 **타입 유실**된다(기본 markdown-api 경로는 영향 없음).
1349
+ *
1350
+ * 항상 `1.` 로 내보내면 마크다운이 시각적으로 자동 재번호하므로 표시는 동일하고, martian 이 다시
1351
+ * numbered_list_item 으로 파싱해 타입이 보존된다. 커스텀 트랜스포머 등록은 notion-to-md 의 기본
1352
+ * 자식 처리를 끄므로(라이브러리 설계), 자식은 toggle/callout 과 동일하게 직접 fetch·들여쓰기한다.
1353
+ */
1354
+ private registerNumberedListTransformer;
1143
1355
  private registerToggleTransformer;
1144
1356
  private registerToggleHeadingTransformers;
1145
1357
  private registerColumnTransformer;
@@ -1163,6 +1375,14 @@ declare class BlockConverter {
1163
1375
  private toggleCounter;
1164
1376
  private columnCounter;
1165
1377
  private preProcessMarkdown;
1378
+ /**
1379
+ * compact underline/color 보존마커를 평문으로 강등 — block(martian) 폴백 경로 전용.
1380
+ *
1381
+ * 무손실 underline/color 복원은 기본 경로인 Markdown API({@link obsidianToNotionEnhanced})가
1382
+ * 전담한다. martian 은 이 서식을 표현할 수 없으므로, Markdown API 가 실패해 이 폴백을 탈 때는
1383
+ * 마커 텍스트가 본문에 리터럴로 새지 않도록 텍스트만 남기고 마커를 제거한다(서식 degrade).
1384
+ */
1385
+ private stripInlineAnnotationMarkers;
1166
1386
  private replaceDividers;
1167
1387
  private extractToggleBlocks;
1168
1388
  private extractColumnBlocks;
@@ -1170,9 +1390,41 @@ declare class BlockConverter {
1170
1390
  private convertTogglePlaceholder;
1171
1391
  private convertColumnPlaceholder;
1172
1392
  private convertDividerPlaceholder;
1393
+ /**
1394
+ * 단일 라인 보존 마커(목차·breadcrumb)를 원래 블록으로 복원한다.
1395
+ * pull 시 마크다운 표현이 없는 블록을 `%%im-nobsidian:toc%%` /
1396
+ * `%%im-nobsidian:breadcrumb%%` 마커 문단으로 남겨두므로, push 시 이 문단을
1397
+ * 감지해 table_of_contents / breadcrumb 블록으로 되돌린다. 그러지 않으면
1398
+ * 마커 텍스트가 일반 문단으로 Notion 에 기록되는 잠재 손실이 된다.
1399
+ */
1400
+ private convertSingleLineMarker;
1173
1401
  private convertImageToVideoOrEmbed;
1402
+ /**
1403
+ * `@tryfabric/martian` 의 blockquote 표현을 **평탄화**한다(I2/I3 무손실 push).
1404
+ *
1405
+ * martian 의 parseBlockquote 는 `notion.blockquote([], children)` 로 본문을 quote 의
1406
+ * **자식 paragraph** 로 내려보내고 quote.rich_text 를 비운다(라이브러리 설계). 그 결과
1407
+ * `> 인용문` 을 push 하면 Notion 에 "빈 인용 줄 + 들여쓴 자식 문단"이 만들어지고, 다시
1408
+ * pull 하면 `> `(빈 줄) 아래에 자식 문단이 매달려 **원문과 구조가 갈라진다**(라운드트립 손실).
1409
+ * 또한 quote.rich_text[0] 이 항상 비어 callout 이모지 감지({@link convertQuoteToCallout})도
1410
+ * 무력화됐다.
1411
+ *
1412
+ * 선두의 연속된 paragraph 자식을 quote.rich_text 로 끌어올리고(문단 사이는 줄바꿈 rich_text
1413
+ * 로 연결 — Notion 네이티브 멀티라인 인용 표현), paragraph 가 아닌 자식(중첩 리스트 등)은
1414
+ * children 으로 남겨 구조를 보존한다. 이미 rich_text 에 본문이 있거나 끌어올릴 paragraph 가
1415
+ * 없으면 원본을 그대로 반환한다(안전 no-op).
1416
+ */
1417
+ private flattenMartianBlockquote;
1174
1418
  private convertQuoteToCallout;
1175
1419
  notionBlocksToMarkdown(pageId: string): Promise<string>;
1420
+ /**
1421
+ * **in-memory** 블록 배열을 마크다운으로 변환한다(페이지 fetch 없음). notion-to-md 의
1422
+ * blocksToMarkdown 을 직접 호출하므로, `has_children:false` 인 평면 블록은 Notion 클라이언트를
1423
+ * 전혀 건드리지 않는다 → 고정 픽스처로 **오프라인 결정론** 블록 라운드트립(I2)을 잠글 수 있는
1424
+ * 정식 테스트 시임. children 이 있는 블록(table 행·callout 자식 등)은 클라이언트를 호출하므로
1425
+ * 호출 측이 평면 블록만 넣어야 오프라인이 보장된다.
1426
+ */
1427
+ notionBlockArrayToMarkdown(blocks: Array<Record<string, unknown>>): Promise<string>;
1176
1428
  }
1177
1429
 
1178
1430
  declare class FrontmatterExtractor implements Processor {
@@ -1232,8 +1484,26 @@ declare class PropertiesTableInjector implements Processor {
1232
1484
  process(input: ProcessorInput): ProcessorOutput;
1233
1485
  }
1234
1486
 
1235
- declare class HtmlAnnotationStripper implements Processor {
1236
- readonly name = "HtmlAnnotationStripper";
1487
+ /**
1488
+ * 인라인 annotation(underline·color) 무손실 보존기 — push 전처리(order 5).
1489
+ *
1490
+ * 과거에는 underline/color 표기를 **제거**(strip)했으나, 그러면 Notion 으로 push 할 때
1491
+ * 서식이 영구 소실됐다(I3 손실점). Notion 의 Enhanced Markdown 은 underline/color 를
1492
+ * `<span underline="true">` / `<span color="X">` 로 **무손실 표현**하므로, 여기서는 제거 대신
1493
+ * 프로젝트 정본인 **compact 보존마커**(`%%im-nobsidian:underline%%…%%/underline%%`,
1494
+ * `%%im-nobsidian:color:C%%…%%/color%%`)로 **승격**만 한다. 실제 Notion span 복원은
1495
+ * push 직전 단일 SSOT 인 {@link obsidianToNotionEnhanced} 가 담당한다.
1496
+ *
1497
+ * 입력 표기는 pull 경로별로 다르다.
1498
+ * - Markdown API 경로: 이미 compact 마커 → 무변경 통과(여기 정규식에 걸리지 않음).
1499
+ * - notion-to-md(block) 경로: `<u>` + 공백형 색상 마커 → compact 마커로 정규화.
1500
+ * - 사용자가 직접 작성한 `<u>` / 레거시 `<span class="notion-*">` → compact 마커로 승격.
1501
+ *
1502
+ * compact 마커는 공백이 없어 PreserveMarkerCollector(공백형만 수집)·기타 전처리기를
1503
+ * 무손상 통과한다(기존 underline/color 왕복이 이를 실증).
1504
+ */
1505
+ declare class InlineAnnotationPreserver implements Processor {
1506
+ readonly name = "InlineAnnotationPreserver";
1237
1507
  readonly order = 5;
1238
1508
  process(input: ProcessorInput): ProcessorOutput;
1239
1509
  }
@@ -1312,27 +1582,6 @@ interface MergeConflictRegion {
1312
1582
  */
1313
1583
  declare function threeWayMerge(base: string, local: string, remote: string): MergeResult;
1314
1584
 
1315
- type ResolutionChoice = "local" | "remote" | "merge" | "duplicate";
1316
- interface ResolutionResult {
1317
- readonly path: string;
1318
- readonly choice: ResolutionChoice;
1319
- readonly success: boolean;
1320
- readonly mergeHadConflicts?: boolean;
1321
- }
1322
- declare class ConflictResolver {
1323
- private readonly stateDb;
1324
- private readonly vaultFs;
1325
- constructor(stateDb: IStateDB, vaultFs: VaultFS);
1326
- resolve(conflict: Conflict, choice: ResolutionChoice): Promise<ResolutionResult>;
1327
- resolveByStrategy(conflict: Conflict, strategy: ConflictStrategy): Promise<ResolutionResult>;
1328
- resolveAll(conflicts: Conflict[], strategy: ConflictStrategy): Promise<ResolutionResult[]>;
1329
- private resolveLocal;
1330
- private resolveRemote;
1331
- private resolveMerge;
1332
- private resolveDuplicate;
1333
- generateDiff(conflict: Conflict): string;
1334
- }
1335
-
1336
1585
  interface NotionPageNode {
1337
1586
  readonly id: string;
1338
1587
  readonly title: string;
@@ -1502,6 +1751,18 @@ interface BaseFileOptions {
1502
1751
  readonly viewsConfig: DatabaseViewsConfig;
1503
1752
  readonly folderPath: string;
1504
1753
  }
1754
+ type BasesViewType = "table" | "cards" | "list";
1755
+ /**
1756
+ * Notion 뷰 타입 → Obsidian Bases 뷰 타입 매핑(SSOT).
1757
+ *
1758
+ * `null` = Bases 에 대응 뷰가 없어 `.base` 로 표현 불가. 이 경우 해당 뷰는 `.base` 에서
1759
+ * 누락되므로, 사이드카(`<db>.notion.json`)가 원본 뷰 설정을 무손실 보존한다
1760
+ * (Notion API 는 뷰 생성/수정을 지원하지 않아 뷰는 pull-authoritative — push 로 되돌릴 수
1761
+ * 없으므로 "조용한 유실 금지"는 곧 "보존 + 정직한 degrade 리포트"를 의미한다).
1762
+ */
1763
+ declare const NOTION_TO_BASES_VIEW: Record<string, BasesViewType | null>;
1764
+ /** Notion 뷰 타입의 Bases 대응 타입을 돌려준다. 미지원/미상 타입은 `null`. */
1765
+ declare function basesViewTypeOf(notionType: string): BasesViewType | null;
1505
1766
  declare class BaseFileGenerator {
1506
1767
  generate(options: BaseFileOptions): string;
1507
1768
  private writeFilters;
@@ -1509,6 +1770,11 @@ declare class BaseFileGenerator {
1509
1770
  private writeFormulas;
1510
1771
  private writePropertyDisplayNames;
1511
1772
  private writeViews;
1773
+ /**
1774
+ * 뷰 이름을 Bases 요구사항대로 유일화한다(in-place). 동명 충돌 시 ' 2',' 3'… 를 붙이되
1775
+ * 이미 존재하는 이름과 다시 충돌하지 않을 때까지 증가시킨다.
1776
+ */
1777
+ private dedupeViewNames;
1512
1778
  private convertView;
1513
1779
  private toNoteRef;
1514
1780
  /**
@@ -1523,6 +1789,85 @@ declare class BaseFileGenerator {
1523
1789
  private yamlValue;
1524
1790
  }
1525
1791
 
1792
+ /**
1793
+ * DB 사이드카(`<db>.notion.json`) — `.base` 로 표현할 수 없는 Notion DB 메타데이터를
1794
+ * 무손실 보존하는 단일 파일.
1795
+ *
1796
+ * ## 왜 필요한가 (I4·I7 "조용한 유실 금지")
1797
+ *
1798
+ * Obsidian Bases(`.base`)는 Notion DB 뷰의 일부만 표현한다:
1799
+ * · 미지원 뷰(calendar/timeline/form/chart/map/dashboard)는 `.base` 에서 통째 누락.
1800
+ * · 표현되는 뷰도 Notion **필터식·커버 크기/비율·컬럼 width/wrap·빈그룹 숨김** 등은
1801
+ * Bases 문법에 대응이 없어 손실된다.
1802
+ *
1803
+ * Notion API 는 뷰 생성/수정을 지원하지 않으므로(뷰는 pull-authoritative) 이 메타데이터는
1804
+ * push 로 Notion 에 되돌릴 수 없다. 따라서 "무손실"의 의미는 **로컬에 완전·정직하게
1805
+ * 보존하고, 무엇이 degrade 됐는지 명시 리포트**하는 것이다. 사이드카는 이를 `.base` 와
1806
+ * 같은 폴더에 co-locate 해 사람이 발견 가능하고 도구가 재사용 가능하게 만든다.
1807
+ *
1808
+ * ## 멱등성
1809
+ *
1810
+ * 매 pull 마다 재생성되므로 **결정적**이어야 한다(타임스탬프·실행시각 금지, 키 정렬).
1811
+ * 동일 DB 상태 → 동일 바이트 → drift 0 / churn 0.
1812
+ */
1813
+ interface NotionSidecar {
1814
+ readonly generator: "im-nobsidian";
1815
+ readonly schemaVersion: 1;
1816
+ readonly databaseId: string;
1817
+ readonly databaseName: string;
1818
+ /** 속성 스키마 — 이름 정렬. `.base` displayName 만으로는 잃는 타입/옵션을 보존. */
1819
+ readonly properties: SidecarProperty[];
1820
+ /** 모든 뷰(표현/미표현 공통) — Notion 순서 유지. */
1821
+ readonly views: SidecarView[];
1822
+ /** 정직한 손실 리포트 — 무엇이 `.base` 에서 누락/degrade 됐는가. */
1823
+ readonly degraded: DegradeNote[];
1824
+ }
1825
+ interface SidecarProperty {
1826
+ readonly name: string;
1827
+ readonly id: string;
1828
+ readonly type: string;
1829
+ /** select/multi_select/status 옵션명(있으면). */
1830
+ readonly options?: string[];
1831
+ }
1832
+ interface SidecarView {
1833
+ readonly id: string;
1834
+ readonly name: string;
1835
+ readonly notionType: string;
1836
+ /** Bases 대응 타입. `null` = `.base` 미표현(사이드카가 유일 보존처). */
1837
+ readonly basesType: BasesViewType | null;
1838
+ /** 원본 뷰 설정 중 의미 있는 항목(키 정렬). 빈 값은 생략. */
1839
+ readonly config: Record<string, unknown>;
1840
+ }
1841
+ interface DegradeNote {
1842
+ readonly view: string;
1843
+ readonly kind: "view-unrepresentable" | "setting-dropped";
1844
+ readonly detail: string;
1845
+ }
1846
+ declare class SidecarGenerator {
1847
+ /**
1848
+ * 사이드카 객체를 빌드한다(순수 — I/O 없음). 직렬화는 {@link serialize} 가 담당.
1849
+ */
1850
+ build(options: {
1851
+ databaseId: string;
1852
+ databaseName: string;
1853
+ schema: Record<string, BasePropertySchema>;
1854
+ viewsConfig: DatabaseViewsConfig;
1855
+ }): NotionSidecar;
1856
+ /**
1857
+ * 사이드카를 결정적 JSON 문자열로 직렬화한다(키 재귀 정렬 + 말미 개행).
1858
+ * 동일 입력 → 동일 바이트 → 멱등(drift/churn 0).
1859
+ */
1860
+ serialize(sidecar: NotionSidecar): string;
1861
+ private buildProperties;
1862
+ private buildView;
1863
+ /**
1864
+ * 뷰 설정 중 의미 있는 항목을 추출한다. `.base` 가 표현 못하는 항목(필터·커버크기 등)은
1865
+ * config 에 보존하면서 동시에 degrade 리포트에 1줄씩 기록한다(표현되는 뷰에 한해 —
1866
+ * 미표현 뷰는 이미 view-unrepresentable 로 통째 보고됨).
1867
+ */
1868
+ private extractConfig;
1869
+ }
1870
+
1526
1871
  declare function sortEntries(entries: DBEntry[], sorts: NonNullable<ViewConfig["sorts"]>): DBEntry[];
1527
1872
  declare function groupEntries(entries: DBEntry[], groupByProperty: string, options?: {
1528
1873
  sort?: "ascending" | "descending";
@@ -1547,12 +1892,95 @@ declare function getNotionColor(color: NotionColor | string): string;
1547
1892
  declare function getNotionBgColor(color: NotionColor | string): string;
1548
1893
  declare function generateColorCSS(): string;
1549
1894
 
1895
+ /**
1896
+ * 본문 링크 충실도(fidelity) 분류기 — 회귀 상시 잠금용 측정 인프라.
1897
+ *
1898
+ * pull 결과물(.md 본문)에 남은 notion 링크/멘션을 네 부류로 분류한다:
1899
+ *
1900
+ * 1. preservedMarkers — `[text](url)%%im-nobsidian:...%%` 형태의 의도된 무손실
1901
+ * 보존 마커(copy_indicator 등). push 라운드트립 복원용으로 *일부러* 남긴 것이라
1902
+ * 결함이 아니다.
1903
+ * 2. externalLinks — 동기화 코퍼스에 없는 외부 notion.so 링크. 위키링크로 환원할
1904
+ * 대상이 없으므로 맨 링크로 두는 게 정당하다.
1905
+ * 3. selfReferences — 자기 자신 페이지를 가리키는 링크/멘션(블록 앵커 등). 정당.
1906
+ * 4. defects — 동기화된 *다른* 페이지로의 미해소 링크/멘션. 이것만이 진짜
1907
+ * 충실도 결함이다(M2/M3/M4/M6 가 봉합한 대상).
1908
+ *
1909
+ * 변환 파이프라인이 완전하면 `defects.length === 0` 이어야 한다. 이 분류기를 픽스처로
1910
+ * 잠가(unit test) 두면 변환기 회귀가 즉시 드러나고, 실제 vault 에 대고 돌리면(아래
1911
+ * scripts/audit-vault.mjs) 코퍼스 전체의 충실도를 정량 측정할 수 있다.
1912
+ *
1913
+ * 마커/ID 판별은 SSOT 를 재사용한다 — 마커 브랜드는 {@link MARKER_BRAND},
1914
+ * 정규 id 는 {@link compactNotionId}. 별도 헬퍼를 만들지 않는다.
1915
+ */
1916
+ /** 미해소로 남은 링크/멘션의 형태. */
1917
+ type FidelityDefectForm = "md-link" | "mention-page";
1918
+ /** 진짜 충실도 결함 — 동기화된 다른 페이지로의 미해소 링크/멘션. */
1919
+ interface FidelityDefect {
1920
+ /** 미해소 형태(맨 마크다운 링크 / 미변환 mention-page 태그). */
1921
+ form: FidelityDefectForm;
1922
+ /** 대상 페이지의 정규(compact) id. */
1923
+ targetPageId: string;
1924
+ /** 대상 페이지 제목(맵에 있으면). */
1925
+ targetTitle: string | null;
1926
+ /** 결함 원문 조각(진단용). */
1927
+ raw: string;
1928
+ }
1929
+ /** {@link classifyBodyFidelity} 분류 결과. */
1930
+ interface FidelityClassification {
1931
+ /** `[text](url)%%im-nobsidian:...%%` — 의도된 무손실 보존 마커(결함 아님). */
1932
+ preservedMarkers: number;
1933
+ /** 코퍼스에 없는 외부 notion.so 링크(정당). */
1934
+ externalLinks: number;
1935
+ /** 자기 자신 페이지를 가리키는 링크/멘션(정당). */
1936
+ selfReferences: number;
1937
+ /** 동기화된 다른 페이지로의 미해소 링크/멘션 — 진짜 결함. */
1938
+ defects: FidelityDefect[];
1939
+ }
1940
+ /** 분류에 필요한 코퍼스 컨텍스트. */
1941
+ interface FidelityContext {
1942
+ /** 동기화된 전체 페이지의 정규(compact) id 집합. */
1943
+ knownPageIds: ReadonlySet<string>;
1944
+ /** 정규 id → 제목. 선택(결함 진단용). */
1945
+ titleById?: ReadonlyMap<string, string>;
1946
+ /** 이 본문이 속한 페이지의 정규 id. 자기참조 판별용. 선택. */
1947
+ ownPageId?: string;
1948
+ }
1949
+ /**
1950
+ * 본문 문자열의 notion 링크/멘션 충실도를 분류한다. 순수 함수 — I/O 없음.
1951
+ *
1952
+ * @param body 분석할 마크다운 본문(프론트매터 포함 가능; 보존 마커·위키링크는 무시됨).
1953
+ * @param ctx 코퍼스 컨텍스트(알려진 페이지 id 집합 등).
1954
+ */
1955
+ declare function classifyBodyFidelity(body: string, ctx: FidelityContext): FidelityClassification;
1956
+ /** 여러 본문의 분류 결과를 합산한 코퍼스 단위 요약. */
1957
+ interface FidelitySummary {
1958
+ files: number;
1959
+ preservedMarkers: number;
1960
+ externalLinks: number;
1961
+ selfReferences: number;
1962
+ defects: number;
1963
+ /** 결함이 1건 이상인 파일 경로 → 결함 목록. */
1964
+ defectsByFile: Record<string, FidelityDefect[]>;
1965
+ }
1966
+ /** 파일별 분류 결과(경로 + 분류)를 코퍼스 요약으로 합산한다. */
1967
+ declare function summarizeFidelity(perFile: ReadonlyArray<{
1968
+ path: string;
1969
+ classification: FidelityClassification;
1970
+ }>): FidelitySummary;
1971
+
1550
1972
  declare function computeHash(content: string): string;
1551
1973
  declare function computeBufferHash(buffer: Buffer): string;
1552
1974
 
1553
1975
  declare function generateId(): string;
1554
1976
  declare function normalizeNotionId(id: string): string;
1555
1977
  declare function notionIdsEqual(a: string, b: string): boolean;
1978
+ /**
1979
+ * 멘션 위키링크용 정규 id — 하이픈 제거 + 소문자. 오케스트레이터의 멘션 해소 정규식
1980
+ * (`[[notion:<32hex>]]`)이 인식하는 유일한 형태다. 블록 폴백 경로(formatMention)와
1981
+ * markdown-api 경로(convertPageMentions)가 동일 형태로 수렴하도록 한다(rank20/I3).
1982
+ */
1983
+ declare function compactNotionId(id: string): string;
1556
1984
 
1557
1985
  declare function sanitizeFileName(name: string): string;
1558
1986
 
@@ -1613,4 +2041,4 @@ declare function isInternalPath(path: string): boolean;
1613
2041
  /** 모든 보존 마커가 공유하는 브랜드 토큰. */
1614
2042
  declare const MARKER_BRAND = "im-nobsidian";
1615
2043
 
1616
- export { BaseFileGenerator, type BaseFileOptions, type BasePropertySchema, type BaseSyncOptions, 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, EmbedResolver, EntryEditor, type FailedOperation, 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, HtmlAnnotationStripper, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, type IStateDB, type ImageDownloadResult, ImageHandler, type ImageReference, InlineDBParser, type LocalChange, LocalImageRestorer, type Logger, MARKER_BRAND, type MappingResult, type MappingType, MathNormalizer, MentionToWikilink, type MergeConflictRegion, type MergeResult, NodeVaultFS, type NonMdFileInfo, type NotionBlock, NotionBlockBuilder, NotionClient, type NotionClientOptions, type NotionColor, type NotionPageNode, type NotionRichText, type RichTextItem as NotionRichTextItem, type OperationType, type PageCover, type PageCreationPlan, type PageIcon, type PathFilterConfig, 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 RegisterFileInput, type RemoteChange, type ResolutionChoice, type ResolutionResult, type RichTextSegment, STATE_DB_FILE, STATE_DB_PATH, 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, computeBufferHash, computeHash, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, isInternalPath, normalizeNotionId, notionIdsEqual, sanitizeFileName, setLogger, sortEntries, threeWayMerge };
2044
+ 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 };