@im-nobsidian/core 0.1.12 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/index.d.ts +492 -64
- package/dist/index.js +2103 -936
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import Database from 'better-sqlite3';
|
|
|
5
5
|
import { randomUUID, createHash } from 'crypto';
|
|
6
6
|
import { Client, LogLevel } from '@notionhq/client';
|
|
7
7
|
import { Sema } from 'async-sema';
|
|
8
|
-
import
|
|
8
|
+
import matter from 'gray-matter';
|
|
9
9
|
import { markdownToBlocks } from '@tryfabric/martian';
|
|
10
10
|
import { NotionToMarkdown } from 'notion-to-md';
|
|
11
11
|
import { readFileSync } from 'fs';
|
|
@@ -228,6 +228,9 @@ function normalizeNotionId(id) {
|
|
|
228
228
|
function notionIdsEqual(a, b) {
|
|
229
229
|
return a.replace(/-/g, "") === b.replace(/-/g, "");
|
|
230
230
|
}
|
|
231
|
+
function compactNotionId(id) {
|
|
232
|
+
return id.replace(/-/g, "").toLowerCase();
|
|
233
|
+
}
|
|
231
234
|
|
|
232
235
|
// src/state/migrations/001-initial.ts
|
|
233
236
|
var INITIAL_MIGRATION = `
|
|
@@ -553,6 +556,57 @@ var StateDB = class _StateDB {
|
|
|
553
556
|
fileSize: row.file_size
|
|
554
557
|
};
|
|
555
558
|
}
|
|
559
|
+
// --- pending_operations (I12 크래시 복구 WAL) ---
|
|
560
|
+
recordPendingOperation(input) {
|
|
561
|
+
const id = generateId();
|
|
562
|
+
this.db.prepare(
|
|
563
|
+
`INSERT INTO pending_operations
|
|
564
|
+
(id, sync_state_id, operation, direction, payload, status)
|
|
565
|
+
VALUES (?, ?, ?, ?, ?, 'pending')`
|
|
566
|
+
).run(id, input.syncStateId, input.operation, input.direction, input.payload ?? null);
|
|
567
|
+
return id;
|
|
568
|
+
}
|
|
569
|
+
getIncompletePendingOperations() {
|
|
570
|
+
const rows = this.db.prepare(
|
|
571
|
+
"SELECT * FROM pending_operations WHERE status IN ('pending', 'processing') ORDER BY created_at ASC"
|
|
572
|
+
).all();
|
|
573
|
+
return rows.map((r) => this.mapPendingRow(r));
|
|
574
|
+
}
|
|
575
|
+
getIncompleteOpByState(syncStateId, operation) {
|
|
576
|
+
const row = this.db.prepare(
|
|
577
|
+
`SELECT * FROM pending_operations
|
|
578
|
+
WHERE sync_state_id = ? AND operation = ? AND status IN ('pending', 'processing')
|
|
579
|
+
ORDER BY created_at ASC LIMIT 1`
|
|
580
|
+
).get(syncStateId, operation);
|
|
581
|
+
return row ? this.mapPendingRow(row) : null;
|
|
582
|
+
}
|
|
583
|
+
markPendingCompleted(id) {
|
|
584
|
+
this.db.prepare(
|
|
585
|
+
"UPDATE pending_operations SET status = 'completed', completed_at = datetime('now') WHERE id = ?"
|
|
586
|
+
).run(id);
|
|
587
|
+
}
|
|
588
|
+
markPendingFailed(id, errorMessage) {
|
|
589
|
+
this.db.prepare(
|
|
590
|
+
"UPDATE pending_operations SET status = 'failed', retry_count = retry_count + 1, error_message = ?, completed_at = datetime('now') WHERE id = ?"
|
|
591
|
+
).run(errorMessage, id);
|
|
592
|
+
}
|
|
593
|
+
clearCompletedOperations() {
|
|
594
|
+
this.db.prepare("DELETE FROM pending_operations WHERE status IN ('completed', 'failed')").run();
|
|
595
|
+
}
|
|
596
|
+
mapPendingRow(row) {
|
|
597
|
+
return {
|
|
598
|
+
id: row.id,
|
|
599
|
+
syncStateId: row.sync_state_id,
|
|
600
|
+
operation: row.operation,
|
|
601
|
+
direction: row.direction,
|
|
602
|
+
payload: row.payload,
|
|
603
|
+
retryCount: row.retry_count,
|
|
604
|
+
errorMessage: row.error_message,
|
|
605
|
+
status: row.status,
|
|
606
|
+
createdAt: row.created_at,
|
|
607
|
+
completedAt: row.completed_at
|
|
608
|
+
};
|
|
609
|
+
}
|
|
556
610
|
// --- preserve markers ---
|
|
557
611
|
storePreserveMarkers(path, markers) {
|
|
558
612
|
if (markers.length === 0) {
|
|
@@ -608,6 +662,9 @@ var StateDB = class _StateDB {
|
|
|
608
662
|
// src/notion/property-mapper.ts
|
|
609
663
|
var DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
610
664
|
var WIKILINK_REGEX = /^\[\[(.+?)(?:\|.+?)?\]\]$/;
|
|
665
|
+
function asArray(value) {
|
|
666
|
+
return Array.isArray(value) ? value : [];
|
|
667
|
+
}
|
|
611
668
|
var PropertyMapper = class {
|
|
612
669
|
schema = /* @__PURE__ */ new Map();
|
|
613
670
|
wikilinkResolver = null;
|
|
@@ -768,8 +825,8 @@ var PropertyMapper = class {
|
|
|
768
825
|
extractValue(prop) {
|
|
769
826
|
switch (prop.type) {
|
|
770
827
|
case "rich_text": {
|
|
771
|
-
const arr = prop.rich_text;
|
|
772
|
-
return arr
|
|
828
|
+
const arr = asArray(prop.rich_text);
|
|
829
|
+
return arr.map((t) => t.plain_text).join("") || null;
|
|
773
830
|
}
|
|
774
831
|
case "number":
|
|
775
832
|
return prop.number;
|
|
@@ -778,8 +835,7 @@ var PropertyMapper = class {
|
|
|
778
835
|
return sel?.name ?? null;
|
|
779
836
|
}
|
|
780
837
|
case "multi_select": {
|
|
781
|
-
|
|
782
|
-
return items?.map((s) => s.name) ?? [];
|
|
838
|
+
return asArray(prop.multi_select).map((s) => s.name);
|
|
783
839
|
}
|
|
784
840
|
case "checkbox":
|
|
785
841
|
return prop.checkbox;
|
|
@@ -804,12 +860,11 @@ var PropertyMapper = class {
|
|
|
804
860
|
case "last_edited_time":
|
|
805
861
|
return prop.last_edited_time;
|
|
806
862
|
case "people": {
|
|
807
|
-
|
|
808
|
-
return people?.map((p) => p.name ?? p.id) ?? [];
|
|
863
|
+
return asArray(prop.people).map((p) => p.name ?? p.id);
|
|
809
864
|
}
|
|
810
865
|
case "files": {
|
|
811
|
-
const files = prop.files;
|
|
812
|
-
const urls =
|
|
866
|
+
const files = asArray(prop.files);
|
|
867
|
+
const urls = files.map((f) => f.type === "file" ? f.file?.url : f.external?.url).filter((u) => typeof u === "string" && u.length > 0);
|
|
813
868
|
if (urls.length === 0) return [];
|
|
814
869
|
return urls.length === 1 ? urls[0] : urls;
|
|
815
870
|
}
|
|
@@ -819,8 +874,8 @@ var PropertyMapper = class {
|
|
|
819
874
|
return formula[formula.type];
|
|
820
875
|
}
|
|
821
876
|
case "relation": {
|
|
822
|
-
const rel = prop.relation;
|
|
823
|
-
if (
|
|
877
|
+
const rel = asArray(prop.relation);
|
|
878
|
+
if (rel.length === 0) return [];
|
|
824
879
|
return rel.map((r) => {
|
|
825
880
|
if (this.wikilinkResolver) {
|
|
826
881
|
const title = this.wikilinkResolver.resolvePageId(r.id);
|
|
@@ -833,14 +888,14 @@ var PropertyMapper = class {
|
|
|
833
888
|
const rollup = prop.rollup;
|
|
834
889
|
if (!rollup) return null;
|
|
835
890
|
if (rollup.type === "array") {
|
|
836
|
-
const arr = rollup.array;
|
|
891
|
+
const arr = asArray(rollup.array);
|
|
837
892
|
return arr.map((item) => this.extractValue(item));
|
|
838
893
|
}
|
|
839
894
|
return rollup[rollup.type] ?? null;
|
|
840
895
|
}
|
|
841
896
|
case "unique_id": {
|
|
842
897
|
const uid = prop.unique_id;
|
|
843
|
-
if (!uid) return null;
|
|
898
|
+
if (!uid || typeof uid.number !== "number") return null;
|
|
844
899
|
return uid.prefix ? `${uid.prefix}-${uid.number}` : String(uid.number);
|
|
845
900
|
}
|
|
846
901
|
case "created_by":
|
|
@@ -875,12 +930,21 @@ function getLogger() {
|
|
|
875
930
|
}
|
|
876
931
|
|
|
877
932
|
// src/notion/client.ts
|
|
933
|
+
function isNotionObjectNotFound(error) {
|
|
934
|
+
if (typeof error !== "object" || error === null) return false;
|
|
935
|
+
const e = error;
|
|
936
|
+
return e.code === "object_not_found" || e.status === 404;
|
|
937
|
+
}
|
|
938
|
+
var DiscoveryTooLargeError = class extends Error {
|
|
939
|
+
constructor(elapsedMs) {
|
|
940
|
+
super(`subtree discovery exceeded time budget (${elapsedMs}ms)`);
|
|
941
|
+
this.name = "DiscoveryTooLargeError";
|
|
942
|
+
}
|
|
943
|
+
};
|
|
878
944
|
var NotionClient = class _NotionClient {
|
|
879
945
|
client;
|
|
880
946
|
sema;
|
|
881
|
-
token;
|
|
882
947
|
propertyMapper = new PropertyMapper();
|
|
883
|
-
customFetch;
|
|
884
948
|
minRequestInterval;
|
|
885
949
|
maxRetries;
|
|
886
950
|
retryBaseDelayMs;
|
|
@@ -894,8 +958,6 @@ var NotionClient = class _NotionClient {
|
|
|
894
958
|
logLevel: LogLevel.ERROR,
|
|
895
959
|
...options.fetch ? { fetch: options.fetch } : {}
|
|
896
960
|
});
|
|
897
|
-
this.token = options.token;
|
|
898
|
-
this.customFetch = options.fetch;
|
|
899
961
|
this.sema = new Sema(options.concurrency ?? 3);
|
|
900
962
|
this.minRequestInterval = options.rateLimitIntervalMs ?? 350;
|
|
901
963
|
this.maxRetries = options.maxRetries ?? 5;
|
|
@@ -1006,30 +1068,61 @@ var NotionClient = class _NotionClient {
|
|
|
1006
1068
|
);
|
|
1007
1069
|
}
|
|
1008
1070
|
// ─── Database / DataSource ───
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1071
|
+
/**
|
|
1072
|
+
* 데이터베이스 1개를 신 모델(2025-09-03 data source 분리)로 조회한다.
|
|
1073
|
+
* - title: database 객체에 그대로 존재한다.
|
|
1074
|
+
* - properties(스키마): 신 모델에선 data source 에 있으므로 1차 data source 를 조회해 채운다.
|
|
1075
|
+
* data source 접근 불가(링크드 DB 등)면 database 객체의 properties 로 폴백한다(제목은 보존).
|
|
1076
|
+
*
|
|
1077
|
+
* 폐기한 레거시 raw fetch(`GET /v1/databases/{id}`, Notion-Version 2022-06-28)는 신 모델로
|
|
1078
|
+
* 업그레이드된 다수 DB에 400 을 반환해 자동 발견 DB 가 통째 드롭(내용 손실)되던 원인이었다.
|
|
1079
|
+
* SDK(`databases.retrieve`/`dataSources.retrieve`)는 동일 토큰으로 정상 동작한다.
|
|
1080
|
+
*/
|
|
1081
|
+
async fetchDatabaseModern(databaseId) {
|
|
1082
|
+
const db = await this.withRateLimit(
|
|
1083
|
+
() => this.client.databases.retrieve({ database_id: databaseId })
|
|
1084
|
+
);
|
|
1085
|
+
const dataSources = db.data_sources ?? [];
|
|
1086
|
+
const merged = {};
|
|
1087
|
+
let anyAccessible = false;
|
|
1088
|
+
for (const ds of dataSources) {
|
|
1089
|
+
try {
|
|
1090
|
+
const dsObj = await this.withRateLimit(
|
|
1091
|
+
() => this.client.dataSources.retrieve({ data_source_id: ds.id })
|
|
1092
|
+
);
|
|
1093
|
+
if (dsObj.properties && Object.keys(dsObj.properties).length > 0) {
|
|
1094
|
+
anyAccessible = true;
|
|
1095
|
+
for (const [name, prop] of Object.entries(dsObj.properties)) {
|
|
1096
|
+
if (!(name in merged)) merged[name] = prop;
|
|
1097
|
+
}
|
|
1019
1098
|
}
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
}
|
|
1024
|
-
return
|
|
1099
|
+
} catch {
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const properties = anyAccessible ? merged : db.properties ?? {};
|
|
1103
|
+
return { title: db.title, properties };
|
|
1025
1104
|
}
|
|
1026
1105
|
async getDatabaseTitle(databaseId) {
|
|
1027
|
-
const db = await this.
|
|
1106
|
+
const db = await this.fetchDatabaseModern(databaseId);
|
|
1028
1107
|
const titleArr = db.title;
|
|
1029
1108
|
return titleArr?.[0]?.plain_text ?? "";
|
|
1030
1109
|
}
|
|
1110
|
+
/**
|
|
1111
|
+
* 발견된 DB 가 **동기화 가능한지**(접근 가능한 data source 가 있는지) 1회 호출로 판별한다.
|
|
1112
|
+
* 신 모델(2025-09-03)에서 링크드 DB·미공유 데이터 소스·삭제 DB 는 `data_sources` 가
|
|
1113
|
+
* 비어, 행 조회(`dataSources.query`)가 404 로 실패하고 그 전에 생성된 빈 폴더/`.base` 만
|
|
1114
|
+
* 남긴다. 발견 단계에서 미리 걸러 **빈 폴더/.base 오염 + 매 pull 의 404 노이즈**를 차단한다.
|
|
1115
|
+
* 제목은 함께 반환해 호출처가 추가 조회 없이 폴더명을 잡게 한다.
|
|
1116
|
+
*/
|
|
1117
|
+
async getDatabaseSyncability(databaseId) {
|
|
1118
|
+
const db = await this.withRateLimit(
|
|
1119
|
+
() => this.client.databases.retrieve({ database_id: databaseId })
|
|
1120
|
+
);
|
|
1121
|
+
const title = Array.isArray(db.title) ? db.title[0]?.plain_text ?? "" : "";
|
|
1122
|
+
return { title, queryable: (db.data_sources?.length ?? 0) > 0 };
|
|
1123
|
+
}
|
|
1031
1124
|
async getDatabaseSchema(databaseId) {
|
|
1032
|
-
const db = await this.
|
|
1125
|
+
const db = await this.fetchDatabaseModern(databaseId);
|
|
1033
1126
|
const properties = db.properties;
|
|
1034
1127
|
if (!properties) return {};
|
|
1035
1128
|
const schema = {};
|
|
@@ -1039,7 +1132,7 @@ var NotionClient = class _NotionClient {
|
|
|
1039
1132
|
return schema;
|
|
1040
1133
|
}
|
|
1041
1134
|
async getDatabaseSchemaFull(databaseId) {
|
|
1042
|
-
const db = await this.
|
|
1135
|
+
const db = await this.fetchDatabaseModern(databaseId);
|
|
1043
1136
|
const properties = db.properties;
|
|
1044
1137
|
if (!properties) return {};
|
|
1045
1138
|
const schema = {};
|
|
@@ -1071,11 +1164,11 @@ var NotionClient = class _NotionClient {
|
|
|
1071
1164
|
}
|
|
1072
1165
|
return schema;
|
|
1073
1166
|
}
|
|
1074
|
-
|
|
1075
|
-
|
|
1167
|
+
/** 특정 data source 1개를 직접 쿼리한다(저수준). 다중 data source 순회의 빌딩블록. */
|
|
1168
|
+
async queryDataSource(dataSourceId, options) {
|
|
1076
1169
|
const response = await this.withRateLimit(
|
|
1077
1170
|
() => this.client.dataSources.query({
|
|
1078
|
-
data_source_id:
|
|
1171
|
+
data_source_id: dataSourceId,
|
|
1079
1172
|
start_cursor: options?.startCursor,
|
|
1080
1173
|
page_size: options?.pageSize ?? this.defaultPageSize,
|
|
1081
1174
|
filter: options?.filter
|
|
@@ -1086,26 +1179,66 @@ var NotionClient = class _NotionClient {
|
|
|
1086
1179
|
nextCursor: response.next_cursor
|
|
1087
1180
|
};
|
|
1088
1181
|
}
|
|
1182
|
+
/**
|
|
1183
|
+
* database 의 1차 data source 1페이지를 쿼리한다(증분 detect 등 단일 소스 경로용).
|
|
1184
|
+
* 전 행을 빠짐없이 받으려면 {@link queryAllDatabasePages} 를 쓴다(전 data source 순회).
|
|
1185
|
+
*/
|
|
1186
|
+
async queryDatabase(databaseId, options) {
|
|
1187
|
+
const dsId = await this.getDataSourceId(databaseId);
|
|
1188
|
+
return this.queryDataSource(dsId, options);
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* database 의 **모든 data source** 행을 빠짐없이 조회한다(2025-09-03 신 모델).
|
|
1192
|
+
* 한 database 가 2개 이상의 data source(각자 행 집합)를 가질 수 있어, 1차 data source 만
|
|
1193
|
+
* 페이지네이션하면 2번째+ 의 행이 통째 침묵 유실된다. 전 data source 를 순회·페이지네이션
|
|
1194
|
+
* 하고 page_id 로 디듀프(소스 간 동일 페이지 방어)한 뒤 합친다. 다중 소스면 1회 경고해
|
|
1195
|
+
* "소스별 탭 구분이 한 폴더로 병합"되는 점을 비침묵으로 알린다.
|
|
1196
|
+
*/
|
|
1089
1197
|
async queryAllDatabasePages(databaseId, filter) {
|
|
1198
|
+
const metas = await this.getDataSourceMetas(databaseId);
|
|
1199
|
+
if (metas.length > 1) {
|
|
1200
|
+
const label = metas.map((m) => m.name || m.id).join(", ");
|
|
1201
|
+
getLogger().warn(
|
|
1202
|
+
`[Notion] DB ${databaseId} \uC5D0 data source ${metas.length}\uAC1C \uBC1C\uACAC \u2014 \uC804 \uC18C\uC2A4 \uD589\uC744 \uD55C \uD3F4\uB354\uB85C \uBCD1\uD569 \uB3D9\uAE30\uD654\uD569\uB2C8\uB2E4 (${label}). \uC18C\uC2A4\uBCC4 \uD0ED \uAD6C\uBD84\uC740 .base \uBDF0\uC5D0 \uBCF4\uC874\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.`
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1090
1205
|
const all = [];
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1206
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1207
|
+
for (const meta of metas) {
|
|
1208
|
+
let cursor;
|
|
1209
|
+
do {
|
|
1210
|
+
const response = await this.queryDataSource(meta.id, {
|
|
1211
|
+
startCursor: cursor,
|
|
1212
|
+
pageSize: this.defaultPageSize,
|
|
1213
|
+
filter
|
|
1214
|
+
});
|
|
1215
|
+
for (const page of response.results) {
|
|
1216
|
+
const key = normalizeNotionId(page.id);
|
|
1217
|
+
if (seen.has(key)) continue;
|
|
1218
|
+
seen.add(key);
|
|
1219
|
+
all.push(page);
|
|
1220
|
+
}
|
|
1221
|
+
cursor = response.nextCursor ?? void 0;
|
|
1222
|
+
} while (cursor);
|
|
1223
|
+
}
|
|
1101
1224
|
return all;
|
|
1102
1225
|
}
|
|
1226
|
+
/** database 의 1차 data source id(없으면 databaseId 폴백). 단일 소스 경로 호환용. */
|
|
1103
1227
|
async getDataSourceId(databaseId) {
|
|
1228
|
+
const metas = await this.getDataSourceMetas(databaseId);
|
|
1229
|
+
return metas[0]?.id ?? databaseId;
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* database 의 전 data source 메타(id + 이름)를 반환한다. data source 가 없으면(레거시/단일)
|
|
1233
|
+
* databaseId 자체를 단일 소스로 폴백해 호출처가 항상 ≥1 개를 받게 한다.
|
|
1234
|
+
*/
|
|
1235
|
+
async getDataSourceMetas(databaseId) {
|
|
1104
1236
|
const db = await this.withRateLimit(
|
|
1105
1237
|
() => this.client.databases.retrieve({ database_id: databaseId })
|
|
1106
1238
|
);
|
|
1107
|
-
const
|
|
1108
|
-
return
|
|
1239
|
+
const list = db.data_sources ?? [];
|
|
1240
|
+
if (list.length === 0) return [{ id: databaseId, name: "" }];
|
|
1241
|
+
return list.map((ds) => ({ id: ds.id, name: ds.name ?? "" }));
|
|
1109
1242
|
}
|
|
1110
1243
|
async archivePage(pageId) {
|
|
1111
1244
|
await this.withRateLimit(() => this.client.pages.update({ page_id: pageId, archived: true }));
|
|
@@ -1254,32 +1387,6 @@ var NotionClient = class _NotionClient {
|
|
|
1254
1387
|
} while (cursor);
|
|
1255
1388
|
return results;
|
|
1256
1389
|
}
|
|
1257
|
-
/**
|
|
1258
|
-
* 통합이 접근 가능한 모든 페이지를 search API로 일괄 조회한다(요청당 최대 100건).
|
|
1259
|
-
* 블록 트리를 페이지별로 재귀 순회하는 getChildPagesRecursive 대비 API 호출 수가
|
|
1260
|
-
* 페이지 수의 수십분의 1로 줄어든다. search API는 휴지통(in_trash) 페이지를 반환하지 않는다.
|
|
1261
|
-
* 반환 객체에는 parent 정보가 포함되어 호출 측에서 root subtree ancestry 필터링이 가능하다.
|
|
1262
|
-
*/
|
|
1263
|
-
async searchAllPages() {
|
|
1264
|
-
const results = [];
|
|
1265
|
-
let cursor;
|
|
1266
|
-
do {
|
|
1267
|
-
const response = await this.withRateLimit(
|
|
1268
|
-
() => this.client.search({
|
|
1269
|
-
filter: { property: "object", value: "page" },
|
|
1270
|
-
start_cursor: cursor,
|
|
1271
|
-
page_size: this.defaultPageSize
|
|
1272
|
-
})
|
|
1273
|
-
);
|
|
1274
|
-
for (const page of response.results) {
|
|
1275
|
-
if ("properties" in page) {
|
|
1276
|
-
results.push(page);
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
cursor = response.next_cursor ?? void 0;
|
|
1280
|
-
} while (cursor);
|
|
1281
|
-
return results;
|
|
1282
|
-
}
|
|
1283
1390
|
async getChildPages(parentId) {
|
|
1284
1391
|
const blocks = await this.fetchAllChildrenDeep(parentId);
|
|
1285
1392
|
const childPageBlocks = blocks.filter((b) => b.type === "child_page");
|
|
@@ -1296,12 +1403,24 @@ var NotionClient = class _NotionClient {
|
|
|
1296
1403
|
const children = await this.fetchAllChildren(parentId);
|
|
1297
1404
|
return children.filter((b) => b.type === "child_database").map((b) => b.id);
|
|
1298
1405
|
}
|
|
1299
|
-
|
|
1406
|
+
/**
|
|
1407
|
+
* root 서브트리를 직접 BFS 순회해 하위 페이지를 모두 수집한다(중첩 깊이 무관·무손실).
|
|
1408
|
+
* 비용은 서브트리 전체 블록 수에 비례한다.
|
|
1409
|
+
*
|
|
1410
|
+
* @param opts.deadlineMs `Date.now()` 기준 마감 시각. 각 페이지 처리 전 초과를 검사해
|
|
1411
|
+
* 초과 시 {@link DiscoveryTooLargeError}를 던진다(대규모 서브트리 → search 폴백 유도).
|
|
1412
|
+
* 미지정 시 무제한(기존 동작 유지 — 테스트 mock 경로는 영향 없음).
|
|
1413
|
+
*/
|
|
1414
|
+
async getChildPagesRecursive(parentId, opts) {
|
|
1300
1415
|
const all = [];
|
|
1301
1416
|
let currentLevel = [parentId];
|
|
1417
|
+
const start = Date.now();
|
|
1302
1418
|
while (currentLevel.length > 0) {
|
|
1303
1419
|
const nextLevel = [];
|
|
1304
1420
|
for (const id of currentLevel) {
|
|
1421
|
+
if (opts?.deadlineMs !== void 0 && Date.now() > opts.deadlineMs) {
|
|
1422
|
+
throw new DiscoveryTooLargeError(Date.now() - start);
|
|
1423
|
+
}
|
|
1305
1424
|
let children;
|
|
1306
1425
|
try {
|
|
1307
1426
|
children = await this.getChildPages(id);
|
|
@@ -1320,6 +1439,112 @@ var NotionClient = class _NotionClient {
|
|
|
1320
1439
|
}
|
|
1321
1440
|
return all;
|
|
1322
1441
|
}
|
|
1442
|
+
/** 워크스페이스에서 통합에 공유된 모든 페이지를 bulk search 로 열거한다(100/요청). */
|
|
1443
|
+
async searchAllPages() {
|
|
1444
|
+
const pages = [];
|
|
1445
|
+
let cursor;
|
|
1446
|
+
do {
|
|
1447
|
+
const r = await this.search({
|
|
1448
|
+
filter: { property: "object", value: "page" },
|
|
1449
|
+
startCursor: cursor
|
|
1450
|
+
});
|
|
1451
|
+
pages.push(...r.results);
|
|
1452
|
+
cursor = r.nextCursor ?? void 0;
|
|
1453
|
+
} while (cursor);
|
|
1454
|
+
return pages;
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* root 하위 페이지를 **search 기반**으로 디스커버리한다(대규모 서브트리용 폴백).
|
|
1458
|
+
*
|
|
1459
|
+
* 전략: 워크스페이스 전체 페이지를 1회 bulk search 로 열거(비용 ∝ 워크스페이스 페이지 수,
|
|
1460
|
+
* 예측가능·유한)한 뒤, 각 페이지의 부모 체인을 따라 root 도달 여부를 판정한다. page_id
|
|
1461
|
+
* 부모는 메모리 맵으로 추가 호출 없이 즉시 해석하고, block_id 부모(컬럼·토글 등 레이아웃에
|
|
1462
|
+
* 중첩된 페이지)만 getBlock 으로 소유 페이지를 해석(memo)한다. search 는 중첩 깊이와 무관하게
|
|
1463
|
+
* 모든 페이지를 반환하므로 직접 순회와 동일하게 **무손실**이다.
|
|
1464
|
+
*
|
|
1465
|
+
* getChildPagesRecursive 와 동일 집합을 보장하기 위해:
|
|
1466
|
+
* - DB 행(parent=data_source_id/database_id)은 child_page 로 도달 불가 → 제외
|
|
1467
|
+
* - 휴지통/보관(in_trash/archived) 페이지 제외
|
|
1468
|
+
* - root 자신은 제외(직접 순회도 root 의 *자식*부터 수집)
|
|
1469
|
+
*/
|
|
1470
|
+
async getPagesUnderRootViaSearch(rootId) {
|
|
1471
|
+
const all = await this.searchAllPages();
|
|
1472
|
+
const rootN = normalizeNotionId(rootId);
|
|
1473
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1474
|
+
for (const p of all) byId.set(normalizeNotionId(p.id), p);
|
|
1475
|
+
const blockOwner = /* @__PURE__ */ new Map();
|
|
1476
|
+
const resolveBlockOwner = async (blockId) => {
|
|
1477
|
+
let bid = blockId;
|
|
1478
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1479
|
+
for (let i = 0; i < 20 && bid && !seen.has(bid); i++) {
|
|
1480
|
+
seen.add(bid);
|
|
1481
|
+
const cached = blockOwner.get(bid);
|
|
1482
|
+
if (cached !== void 0) return cached;
|
|
1483
|
+
let bp;
|
|
1484
|
+
try {
|
|
1485
|
+
bp = (await this.getBlock(bid)).parent;
|
|
1486
|
+
} catch {
|
|
1487
|
+
blockOwner.set(bid, null);
|
|
1488
|
+
return null;
|
|
1489
|
+
}
|
|
1490
|
+
if (bp.type === "page_id" && bp.page_id) {
|
|
1491
|
+
const owner = bp.page_id;
|
|
1492
|
+
blockOwner.set(blockId, owner);
|
|
1493
|
+
return owner;
|
|
1494
|
+
}
|
|
1495
|
+
if (bp.type === "block_id" && bp.block_id) {
|
|
1496
|
+
bid = bp.block_id;
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
blockOwner.set(blockId, null);
|
|
1500
|
+
return null;
|
|
1501
|
+
}
|
|
1502
|
+
return null;
|
|
1503
|
+
};
|
|
1504
|
+
const memo = /* @__PURE__ */ new Map();
|
|
1505
|
+
const isUnderRoot = async (startId) => {
|
|
1506
|
+
const chain = [];
|
|
1507
|
+
let cur = normalizeNotionId(startId);
|
|
1508
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1509
|
+
let result = false;
|
|
1510
|
+
while (cur && !seen.has(cur)) {
|
|
1511
|
+
if (cur === rootN) {
|
|
1512
|
+
result = true;
|
|
1513
|
+
break;
|
|
1514
|
+
}
|
|
1515
|
+
const cached = memo.get(cur);
|
|
1516
|
+
if (cached !== void 0) {
|
|
1517
|
+
result = cached;
|
|
1518
|
+
break;
|
|
1519
|
+
}
|
|
1520
|
+
seen.add(cur);
|
|
1521
|
+
chain.push(cur);
|
|
1522
|
+
const pg = byId.get(cur);
|
|
1523
|
+
if (!pg) break;
|
|
1524
|
+
const parent = pg.parent;
|
|
1525
|
+
if (parent.type === "page_id" && parent.page_id) {
|
|
1526
|
+
cur = normalizeNotionId(parent.page_id);
|
|
1527
|
+
} else if (parent.type === "block_id" && parent.block_id) {
|
|
1528
|
+
const owner = await resolveBlockOwner(parent.block_id);
|
|
1529
|
+
cur = owner ? normalizeNotionId(owner) : null;
|
|
1530
|
+
} else {
|
|
1531
|
+
cur = null;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
for (const c of chain) memo.set(c, result);
|
|
1535
|
+
return result;
|
|
1536
|
+
};
|
|
1537
|
+
const out = [];
|
|
1538
|
+
for (const p of all) {
|
|
1539
|
+
const pid = normalizeNotionId(p.id);
|
|
1540
|
+
if (pid === rootN) continue;
|
|
1541
|
+
const ptype = p.parent.type;
|
|
1542
|
+
if (ptype === "data_source_id" || ptype === "database_id") continue;
|
|
1543
|
+
if (_NotionClient.isTrashedOrArchived(p)) continue;
|
|
1544
|
+
if (await isUnderRoot(p.id)) out.push(p);
|
|
1545
|
+
}
|
|
1546
|
+
return out;
|
|
1547
|
+
}
|
|
1323
1548
|
// ─── Views API ───
|
|
1324
1549
|
async listDatabaseViews(databaseId) {
|
|
1325
1550
|
const allViews = [];
|
|
@@ -1394,13 +1619,23 @@ var NotionClient = class _NotionClient {
|
|
|
1394
1619
|
extractTitle(page) {
|
|
1395
1620
|
const props = page.properties;
|
|
1396
1621
|
for (const prop of Object.values(props)) {
|
|
1397
|
-
if (prop.type === "title" && prop.title) {
|
|
1622
|
+
if (prop.type === "title" && Array.isArray(prop.title)) {
|
|
1398
1623
|
const joined = prop.title.map((t) => t.plain_text ?? "").join("");
|
|
1399
1624
|
if (joined) return joined;
|
|
1400
1625
|
}
|
|
1401
1626
|
}
|
|
1402
1627
|
return "\uC81C\uBAA9 \uC5C6\uC74C";
|
|
1403
1628
|
}
|
|
1629
|
+
/**
|
|
1630
|
+
* 페이지 모드 pull(extractProperties)의 relation/people 속성을 raw Notion UUID 가
|
|
1631
|
+
* 아니라 [[제목]] 위키링크로 해소하도록 resolver 를 주입한다. 주입하지 않으면 매
|
|
1632
|
+
* pull 마다 relation 이 raw UUID 로 재생성돼, 후처리(resolveNotionLinks)에만 의존하는
|
|
1633
|
+
* 2-write churn 이 남는다(M1). DB 모드는 orchestrator/DatabaseSyncer 의 자체 mapper 가
|
|
1634
|
+
* 이미 resolver 를 주입받아 면역이다.
|
|
1635
|
+
*/
|
|
1636
|
+
setWikilinkResolver(resolver) {
|
|
1637
|
+
this.propertyMapper.setWikilinkResolver(resolver);
|
|
1638
|
+
}
|
|
1404
1639
|
extractProperties(page) {
|
|
1405
1640
|
return this.propertyMapper.fromNotionProperties(page.properties);
|
|
1406
1641
|
}
|
|
@@ -1980,7 +2215,6 @@ var EMBED_PROTOCOL = `${MARKER_BRAND}://embed/`;
|
|
|
1980
2215
|
function spacedMarker(body) {
|
|
1981
2216
|
return `%% ${MARKER_BRAND}:${body} %%`;
|
|
1982
2217
|
}
|
|
1983
|
-
var SPACED_END = spacedMarker("end");
|
|
1984
2218
|
function compactMarker(body) {
|
|
1985
2219
|
return `%%${MARKER_BRAND}:${body}%%`;
|
|
1986
2220
|
}
|
|
@@ -1990,6 +2224,7 @@ var COLUMN_LIST_START = compactMarker("column-list:start");
|
|
|
1990
2224
|
var COLUMN_LIST_END = compactMarker("column-list:end");
|
|
1991
2225
|
var COLUMN_SEP = compactMarker("column");
|
|
1992
2226
|
var TOC_MARKER = compactMarker("toc");
|
|
2227
|
+
var BREADCRUMB_MARKER = compactMarker("breadcrumb");
|
|
1993
2228
|
var PROPERTIES_TAG = `# ${MARKER_BRAND}:properties`;
|
|
1994
2229
|
var MARKER_BRAND_RE = MARKER_BRAND;
|
|
1995
2230
|
|
|
@@ -2066,7 +2301,7 @@ var FrontmatterExtractor = class {
|
|
|
2066
2301
|
name = "FrontmatterExtractor";
|
|
2067
2302
|
order = 10;
|
|
2068
2303
|
process(input) {
|
|
2069
|
-
const { data, content } =
|
|
2304
|
+
const { data, content } = matter(input.content);
|
|
2070
2305
|
return {
|
|
2071
2306
|
content: content.trim(),
|
|
2072
2307
|
metadata: {
|
|
@@ -2286,6 +2521,11 @@ var UnsupportedBlockStripper = class {
|
|
|
2286
2521
|
};
|
|
2287
2522
|
}
|
|
2288
2523
|
};
|
|
2524
|
+
function stringifyFrontmatter(content, data) {
|
|
2525
|
+
return matter.stringify({ content }, data);
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// src/converter/pre-processors/properties-table.ts
|
|
2289
2529
|
var PropertiesTableInjector = class {
|
|
2290
2530
|
name = "PropertiesTableInjector";
|
|
2291
2531
|
order = 15;
|
|
@@ -2307,7 +2547,7 @@ var PropertiesTableInjector = class {
|
|
|
2307
2547
|
if (Object.keys(filteredProps).length === 0) {
|
|
2308
2548
|
return { content: input.content, metadata: input.metadata };
|
|
2309
2549
|
}
|
|
2310
|
-
const yamlStr =
|
|
2550
|
+
const yamlStr = stringifyFrontmatter("", filteredProps).trim();
|
|
2311
2551
|
const yamlBody = yamlStr.slice(4, -3).trim();
|
|
2312
2552
|
const codeBlock = "```yaml\n" + PROPERTIES_TAG + "\n" + yamlBody + "\n```";
|
|
2313
2553
|
const content = codeBlock + "\n\n---\n\n" + input.content;
|
|
@@ -2317,51 +2557,66 @@ var PropertiesTableInjector = class {
|
|
|
2317
2557
|
|
|
2318
2558
|
// src/converter/pre-processors/html-annotation.ts
|
|
2319
2559
|
var UNDERLINE_REGEX = /<u>([\s\S]*?)<\/u>/g;
|
|
2320
|
-
var COLOR_SPAN_REGEX = /<span class="notion-(\w
|
|
2321
|
-
var
|
|
2560
|
+
var COLOR_SPAN_REGEX = /<span class="notion-(\w+)(-bg)?">([\s\S]*?)<\/span>/g;
|
|
2561
|
+
var SPACED_COLOR_REGEX = new RegExp(
|
|
2322
2562
|
`%% ${MARKER_BRAND_RE}:color:(\\w+(?:_background)?) %%([\\s\\S]*?)%% ${MARKER_BRAND_RE}:end %%`,
|
|
2323
2563
|
"g"
|
|
2324
2564
|
);
|
|
2325
|
-
var
|
|
2326
|
-
name = "
|
|
2565
|
+
var InlineAnnotationPreserver = class {
|
|
2566
|
+
name = "InlineAnnotationPreserver";
|
|
2327
2567
|
order = 5;
|
|
2328
2568
|
process(input) {
|
|
2329
2569
|
if (input.context.direction !== "push") {
|
|
2330
2570
|
return { content: input.content, metadata: input.metadata };
|
|
2331
2571
|
}
|
|
2332
2572
|
let content = input.content;
|
|
2333
|
-
content = content.replace(
|
|
2334
|
-
|
|
2335
|
-
|
|
2573
|
+
content = content.replace(
|
|
2574
|
+
SPACED_COLOR_REGEX,
|
|
2575
|
+
(_m, color, text) => `${compactMarker(`color:${color}`)}${text}%%/color%%`
|
|
2576
|
+
);
|
|
2577
|
+
content = content.replace(
|
|
2578
|
+
UNDERLINE_REGEX,
|
|
2579
|
+
(_m, text) => `${compactMarker("underline")}${text}%%/underline%%`
|
|
2580
|
+
);
|
|
2581
|
+
content = content.replace(
|
|
2582
|
+
COLOR_SPAN_REGEX,
|
|
2583
|
+
(_m, base, bg, text) => {
|
|
2584
|
+
const color = bg ? `${base}_background` : base;
|
|
2585
|
+
return `${compactMarker(`color:${color}`)}${text}%%/color%%`;
|
|
2586
|
+
}
|
|
2587
|
+
);
|
|
2336
2588
|
return { content, metadata: input.metadata };
|
|
2337
2589
|
}
|
|
2338
2590
|
};
|
|
2339
2591
|
|
|
2340
|
-
// src/converter/
|
|
2592
|
+
// src/converter/link-restore.ts
|
|
2341
2593
|
var NOTION_LINK_REGEX = /\[([^\]]+)\]\((?:https:\/\/(?:www\.)?notion\.so\/|notion:\/\/)([a-f0-9-]+)\)/g;
|
|
2342
2594
|
var WIKILINK_PRESERVE_REGEX = new RegExp(`\\[([^\\]]+)\\]\\(${WIKILINK_PROTOCOL}([^)]+)\\)`, "g");
|
|
2343
2595
|
var EMBED_PRESERVE_REGEX = new RegExp(`\\[([^\\]]+)\\]\\(${EMBED_PROTOCOL}([^)]+)\\)`, "g");
|
|
2596
|
+
function restoreLinkMarkers(text) {
|
|
2597
|
+
let out = text.replace(NOTION_LINK_REGEX, (_match, display) => `[[${display}]]`);
|
|
2598
|
+
out = out.replace(WIKILINK_PRESERVE_REGEX, (_match, display, encodedTarget) => {
|
|
2599
|
+
const target = decodeURIComponent(encodedTarget);
|
|
2600
|
+
return target === display ? `[[${target}]]` : `[[${target}|${display}]]`;
|
|
2601
|
+
});
|
|
2602
|
+
out = out.replace(EMBED_PRESERVE_REGEX, (_match, _display, encodedTarget) => {
|
|
2603
|
+
const target = decodeURIComponent(encodedTarget);
|
|
2604
|
+
return `![[${target}]]`;
|
|
2605
|
+
});
|
|
2606
|
+
return out;
|
|
2607
|
+
}
|
|
2608
|
+
function restoreLinkMarkersInValue(value) {
|
|
2609
|
+
if (typeof value === "string") return restoreLinkMarkers(value);
|
|
2610
|
+
if (Array.isArray(value)) return value.map((v) => restoreLinkMarkersInValue(v));
|
|
2611
|
+
return value;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
// src/converter/post-processors/mention-to-wikilink.ts
|
|
2344
2615
|
var MentionToWikilink = class {
|
|
2345
2616
|
name = "MentionToWikilink";
|
|
2346
2617
|
order = 10;
|
|
2347
2618
|
process(input) {
|
|
2348
|
-
|
|
2349
|
-
content = content.replace(NOTION_LINK_REGEX, (_match, display) => `[[${display}]]`);
|
|
2350
|
-
content = content.replace(
|
|
2351
|
-
WIKILINK_PRESERVE_REGEX,
|
|
2352
|
-
(_match, display, encodedTarget) => {
|
|
2353
|
-
const target = decodeURIComponent(encodedTarget);
|
|
2354
|
-
return target === display ? `[[${target}]]` : `[[${target}|${display}]]`;
|
|
2355
|
-
}
|
|
2356
|
-
);
|
|
2357
|
-
content = content.replace(
|
|
2358
|
-
EMBED_PRESERVE_REGEX,
|
|
2359
|
-
(_match, _display, encodedTarget) => {
|
|
2360
|
-
const target = decodeURIComponent(encodedTarget);
|
|
2361
|
-
return `![[${target}]]`;
|
|
2362
|
-
}
|
|
2363
|
-
);
|
|
2364
|
-
return { content, metadata: input.metadata };
|
|
2619
|
+
return { content: restoreLinkMarkers(input.content), metadata: input.metadata };
|
|
2365
2620
|
}
|
|
2366
2621
|
};
|
|
2367
2622
|
|
|
@@ -2382,9 +2637,12 @@ var PreserveMarkerInjector = class {
|
|
|
2382
2637
|
if (missingMarkers.length === 0) {
|
|
2383
2638
|
return { content, metadata: input.metadata };
|
|
2384
2639
|
}
|
|
2385
|
-
const
|
|
2386
|
-
const
|
|
2387
|
-
|
|
2640
|
+
const sorted = [...missingMarkers].sort((a, b) => a.startIndex - b.startIndex);
|
|
2641
|
+
for (const m of sorted) {
|
|
2642
|
+
const markerText = this.formatMarker(m);
|
|
2643
|
+
const at = Math.max(0, Math.min(m.startIndex, content.length));
|
|
2644
|
+
content = content.slice(0, at) + markerText + content.slice(at);
|
|
2645
|
+
}
|
|
2388
2646
|
return { content, metadata: input.metadata };
|
|
2389
2647
|
}
|
|
2390
2648
|
formatMarker(marker) {
|
|
@@ -2465,6 +2723,8 @@ var ColorAnnotator = class {
|
|
|
2465
2723
|
};
|
|
2466
2724
|
}
|
|
2467
2725
|
};
|
|
2726
|
+
|
|
2727
|
+
// src/converter/post-processors/frontmatter-generator.ts
|
|
2468
2728
|
var FrontmatterGenerator = class {
|
|
2469
2729
|
name = "FrontmatterGenerator";
|
|
2470
2730
|
order = 90;
|
|
@@ -2473,18 +2733,20 @@ var FrontmatterGenerator = class {
|
|
|
2473
2733
|
if (!properties || Object.keys(properties).length === 0) {
|
|
2474
2734
|
return { content: input.content, metadata: input.metadata };
|
|
2475
2735
|
}
|
|
2476
|
-
const
|
|
2477
|
-
const
|
|
2736
|
+
const restoreMarkers = input.context.direction === "pull";
|
|
2737
|
+
const normalized = normalizeProperties(properties, restoreMarkers);
|
|
2738
|
+
const content = stringifyFrontmatter(input.content, normalized);
|
|
2478
2739
|
return {
|
|
2479
2740
|
content,
|
|
2480
2741
|
metadata: input.metadata
|
|
2481
2742
|
};
|
|
2482
2743
|
}
|
|
2483
2744
|
};
|
|
2484
|
-
function normalizeProperties(props) {
|
|
2745
|
+
function normalizeProperties(props, restoreMarkers) {
|
|
2485
2746
|
const result = {};
|
|
2486
2747
|
for (const [key, value] of Object.entries(props)) {
|
|
2487
|
-
const
|
|
2748
|
+
const restored = restoreMarkers ? restoreLinkMarkersInValue(value) : value;
|
|
2749
|
+
const normalized = normalizeValue(restored);
|
|
2488
2750
|
if (normalized !== void 0) {
|
|
2489
2751
|
result[key] = normalized;
|
|
2490
2752
|
}
|
|
@@ -2567,7 +2829,7 @@ ${yamlContent}---
|
|
|
2567
2829
|
`;
|
|
2568
2830
|
let parsed;
|
|
2569
2831
|
try {
|
|
2570
|
-
const result =
|
|
2832
|
+
const result = matter(fakeDocument);
|
|
2571
2833
|
parsed = result.data;
|
|
2572
2834
|
} catch {
|
|
2573
2835
|
return { content: input.content, metadata: input.metadata };
|
|
@@ -2638,7 +2900,7 @@ function stripQuotes(s) {
|
|
|
2638
2900
|
// src/converter/pipeline-factory.ts
|
|
2639
2901
|
function createDefaultPipeline(options) {
|
|
2640
2902
|
const pipeline = new ConversionPipeline();
|
|
2641
|
-
pipeline.registerPreProcessor(new
|
|
2903
|
+
pipeline.registerPreProcessor(new InlineAnnotationPreserver());
|
|
2642
2904
|
pipeline.registerPreProcessor(new UnsupportedBlockStripper());
|
|
2643
2905
|
pipeline.registerPreProcessor(new FrontmatterExtractor());
|
|
2644
2906
|
pipeline.registerPreProcessor(new PropertiesTableInjector());
|
|
@@ -2656,30 +2918,8 @@ function createDefaultPipeline(options) {
|
|
|
2656
2918
|
pipeline.registerPostProcessor(new FrontmatterGenerator());
|
|
2657
2919
|
return pipeline;
|
|
2658
2920
|
}
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
var VIDEO_URL_PATTERNS = [
|
|
2662
|
-
/^https?:\/\/(www\.)?youtube\.com\/watch/,
|
|
2663
|
-
/^https?:\/\/youtu\.be\//,
|
|
2664
|
-
/^https?:\/\/(www\.)?vimeo\.com\//,
|
|
2665
|
-
/^https?:\/\/(www\.)?dailymotion\.com\//,
|
|
2666
|
-
/^https?:\/\/(www\.)?loom\.com\/share\//
|
|
2667
|
-
];
|
|
2668
|
-
var EMBED_URL_PATTERNS = [
|
|
2669
|
-
/^https?:\/\/(www\.)?figma\.com\//,
|
|
2670
|
-
/^https?:\/\/docs\.google\.com\//,
|
|
2671
|
-
/^https?:\/\/drive\.google\.com\//,
|
|
2672
|
-
/^https?:\/\/(www\.)?miro\.com\//,
|
|
2673
|
-
/^https?:\/\/(www\.)?twitter\.com\//,
|
|
2674
|
-
/^https?:\/\/(www\.)?x\.com\//,
|
|
2675
|
-
/^https?:\/\/codepen\.io\//,
|
|
2676
|
-
/^https?:\/\/gist\.github\.com\//,
|
|
2677
|
-
/^https?:\/\/(www\.)?spotify\.com\//,
|
|
2678
|
-
/^https?:\/\/(www\.)?soundcloud\.com\//
|
|
2679
|
-
];
|
|
2680
|
-
function escapeRegex2(str) {
|
|
2681
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2682
|
-
}
|
|
2921
|
+
|
|
2922
|
+
// src/converter/rich-text-converter.ts
|
|
2683
2923
|
function richTextToPlain(richText) {
|
|
2684
2924
|
if (!richText) return "";
|
|
2685
2925
|
return richText.map((t) => t.plain_text).join("");
|
|
@@ -2691,7 +2931,7 @@ function richTextToMarkdown(richText) {
|
|
|
2691
2931
|
return `$${t.equation.expression}$`;
|
|
2692
2932
|
}
|
|
2693
2933
|
if (t.type === "mention" && t.mention) {
|
|
2694
|
-
return formatMention(t.mention);
|
|
2934
|
+
return formatMention(t.mention, t.plain_text);
|
|
2695
2935
|
}
|
|
2696
2936
|
let text = t.plain_text;
|
|
2697
2937
|
const a = t.annotations;
|
|
@@ -2703,28 +2943,54 @@ function richTextToMarkdown(richText) {
|
|
|
2703
2943
|
if (t.href) text = `[${text}](${t.href})`;
|
|
2704
2944
|
const color = a?.color;
|
|
2705
2945
|
if (color && color !== "default") {
|
|
2706
|
-
text = `${
|
|
2946
|
+
text = `${compactMarker(`color:${color}`)}${text}%%/color%%`;
|
|
2707
2947
|
}
|
|
2708
2948
|
return text;
|
|
2709
2949
|
}).join("");
|
|
2710
2950
|
}
|
|
2711
|
-
function formatMention(mention) {
|
|
2951
|
+
function formatMention(mention, plainText) {
|
|
2712
2952
|
switch (mention.type) {
|
|
2713
2953
|
case "page":
|
|
2714
|
-
return mention.page ? `[[
|
|
2954
|
+
return mention.page ? `[[notion:${compactNotionId(mention.page.id)}]]` : plainText;
|
|
2715
2955
|
case "date": {
|
|
2716
|
-
if (!mention.date) return
|
|
2956
|
+
if (!mention.date) return plainText;
|
|
2717
2957
|
const start = mention.date.start;
|
|
2718
2958
|
return mention.date.end ? `${start} \u2192 ${mention.date.end}` : start;
|
|
2719
2959
|
}
|
|
2720
2960
|
case "user":
|
|
2721
|
-
return mention.user?.name ? `@${mention.user.name}` : "@user";
|
|
2961
|
+
return mention.user?.name ? `@${mention.user.name}` : plainText || "@user";
|
|
2722
2962
|
case "database":
|
|
2723
|
-
return mention.database ? `[[
|
|
2963
|
+
return mention.database ? `[[notion:${compactNotionId(mention.database.id)}]]` : plainText;
|
|
2724
2964
|
default:
|
|
2725
|
-
return
|
|
2965
|
+
return plainText;
|
|
2726
2966
|
}
|
|
2727
2967
|
}
|
|
2968
|
+
|
|
2969
|
+
// src/converter/block-converter.ts
|
|
2970
|
+
var TRULY_UNSUPPORTED_BLOCK_TYPES = ["unsupported", "template"];
|
|
2971
|
+
var DIVIDER_PLACEHOLDER = "\u200B%%IM-NOBSIDIAN_DIVIDER%%\u200B";
|
|
2972
|
+
var VIDEO_URL_PATTERNS = [
|
|
2973
|
+
/^https?:\/\/(www\.)?youtube\.com\/watch/,
|
|
2974
|
+
/^https?:\/\/youtu\.be\//,
|
|
2975
|
+
/^https?:\/\/(www\.)?vimeo\.com\//,
|
|
2976
|
+
/^https?:\/\/(www\.)?dailymotion\.com\//,
|
|
2977
|
+
/^https?:\/\/(www\.)?loom\.com\/share\//
|
|
2978
|
+
];
|
|
2979
|
+
var EMBED_URL_PATTERNS = [
|
|
2980
|
+
/^https?:\/\/(www\.)?figma\.com\//,
|
|
2981
|
+
/^https?:\/\/docs\.google\.com\//,
|
|
2982
|
+
/^https?:\/\/drive\.google\.com\//,
|
|
2983
|
+
/^https?:\/\/(www\.)?miro\.com\//,
|
|
2984
|
+
/^https?:\/\/(www\.)?twitter\.com\//,
|
|
2985
|
+
/^https?:\/\/(www\.)?x\.com\//,
|
|
2986
|
+
/^https?:\/\/codepen\.io\//,
|
|
2987
|
+
/^https?:\/\/gist\.github\.com\//,
|
|
2988
|
+
/^https?:\/\/(www\.)?spotify\.com\//,
|
|
2989
|
+
/^https?:\/\/(www\.)?soundcloud\.com\//
|
|
2990
|
+
];
|
|
2991
|
+
function escapeRegex2(str) {
|
|
2992
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2993
|
+
}
|
|
2728
2994
|
var BlockConverter = class {
|
|
2729
2995
|
n2m = null;
|
|
2730
2996
|
client = null;
|
|
@@ -2762,6 +3028,37 @@ var BlockConverter = class {
|
|
|
2762
3028
|
this.registerEmbedTransformer();
|
|
2763
3029
|
this.registerCalloutTransformer();
|
|
2764
3030
|
this.registerToggleHeadingTransformers();
|
|
3031
|
+
this.registerNumberedListTransformer();
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
3034
|
+
* `numbered_list_item` 을 `1.` 마크다운으로 내보내 **번호 목록 타입을 보존**한다(I2 블록 라운드트립).
|
|
3035
|
+
*
|
|
3036
|
+
* notion-to-md 기본 렌더는 `md.bullet(text, block.numbered_list_item.number)` 를 쓰는데
|
|
3037
|
+
* Notion API 는 list item 에 `.number` 를 채워주지 않아 `undefined` → 불릿(`- `)으로 **강등**된다.
|
|
3038
|
+
* 그러면 블록 폴백 경로(notionBlocksToMarkdown)로 pull 한 뒤 다시 push 하면 numbered_list_item 이
|
|
3039
|
+
* bulleted_list_item 으로 **타입 유실**된다(기본 markdown-api 경로는 영향 없음).
|
|
3040
|
+
*
|
|
3041
|
+
* 항상 `1.` 로 내보내면 마크다운이 시각적으로 자동 재번호하므로 표시는 동일하고, martian 이 다시
|
|
3042
|
+
* numbered_list_item 으로 파싱해 타입이 보존된다. 커스텀 트랜스포머 등록은 notion-to-md 의 기본
|
|
3043
|
+
* 자식 처리를 끄므로(라이브러리 설계), 자식은 toggle/callout 과 동일하게 직접 fetch·들여쓰기한다.
|
|
3044
|
+
*/
|
|
3045
|
+
registerNumberedListTransformer() {
|
|
3046
|
+
if (!this.n2m) return;
|
|
3047
|
+
const n2m = this.n2m;
|
|
3048
|
+
this.n2m.setCustomTransformer("numbered_list_item", async (block) => {
|
|
3049
|
+
const b = block;
|
|
3050
|
+
const text = richTextToMarkdown(b.numbered_list_item?.rich_text);
|
|
3051
|
+
let result = `1. ${text}`;
|
|
3052
|
+
if (b.has_children) {
|
|
3053
|
+
const children = await n2m.pageToMarkdown(b.id);
|
|
3054
|
+
const childMd = (Array.isArray(children) ? n2m.toMarkdownString(children).parent : "") ?? "";
|
|
3055
|
+
if (childMd.trim()) {
|
|
3056
|
+
const indented = childMd.split("\n").map((l) => l.trim() ? ` ${l}` : "").join("\n").trimEnd();
|
|
3057
|
+
result += "\n" + indented;
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
return result;
|
|
3061
|
+
});
|
|
2765
3062
|
}
|
|
2766
3063
|
registerToggleTransformer() {
|
|
2767
3064
|
if (!this.n2m) return;
|
|
@@ -2922,7 +3219,7 @@ ${COLUMN_LIST_END}`;
|
|
|
2922
3219
|
}
|
|
2923
3220
|
registerBreadcrumbTransformer() {
|
|
2924
3221
|
if (!this.n2m) return;
|
|
2925
|
-
this.n2m.setCustomTransformer("breadcrumb", async () =>
|
|
3222
|
+
this.n2m.setCustomTransformer("breadcrumb", async () => BREADCRUMB_MARKER);
|
|
2926
3223
|
}
|
|
2927
3224
|
registerLinkPreviewTransformer() {
|
|
2928
3225
|
if (!this.n2m) return;
|
|
@@ -3001,8 +3298,19 @@ ${COLUMN_LIST_END}`;
|
|
|
3001
3298
|
processed = this.extractColumnBlocks(processed);
|
|
3002
3299
|
processed = this.replaceDividers(processed);
|
|
3003
3300
|
processed = processed.replace(/<unknown[^>]*\/>/g, "");
|
|
3301
|
+
processed = this.stripInlineAnnotationMarkers(processed);
|
|
3004
3302
|
return processed;
|
|
3005
3303
|
}
|
|
3304
|
+
/**
|
|
3305
|
+
* compact underline/color 보존마커를 평문으로 강등 — block(martian) 폴백 경로 전용.
|
|
3306
|
+
*
|
|
3307
|
+
* 무손실 underline/color 복원은 기본 경로인 Markdown API({@link obsidianToNotionEnhanced})가
|
|
3308
|
+
* 전담한다. martian 은 이 서식을 표현할 수 없으므로, Markdown API 가 실패해 이 폴백을 탈 때는
|
|
3309
|
+
* 마커 텍스트가 본문에 리터럴로 새지 않도록 텍스트만 남기고 마커를 제거한다(서식 degrade).
|
|
3310
|
+
*/
|
|
3311
|
+
stripInlineAnnotationMarkers(markdown) {
|
|
3312
|
+
return markdown.replace(new RegExp(`%%${MARKER_BRAND}:underline%%([\\s\\S]*?)%%\\/underline%%`, "g"), "$1").replace(new RegExp(`%%${MARKER_BRAND}:color:[^%]+%%([\\s\\S]*?)%%\\/color%%`, "g"), "$1");
|
|
3313
|
+
}
|
|
3006
3314
|
replaceDividers(markdown) {
|
|
3007
3315
|
const lines = markdown.split("\n");
|
|
3008
3316
|
const result = [];
|
|
@@ -3074,12 +3382,18 @@ ${COLUMN_LIST_END}`;
|
|
|
3074
3382
|
result.push(dividerConverted);
|
|
3075
3383
|
continue;
|
|
3076
3384
|
}
|
|
3385
|
+
const markerConverted = this.convertSingleLineMarker(block);
|
|
3386
|
+
if (markerConverted) {
|
|
3387
|
+
result.push(markerConverted);
|
|
3388
|
+
continue;
|
|
3389
|
+
}
|
|
3077
3390
|
const videoConverted = this.convertImageToVideoOrEmbed(block);
|
|
3078
3391
|
if (videoConverted) {
|
|
3079
3392
|
result.push(videoConverted);
|
|
3080
3393
|
continue;
|
|
3081
3394
|
}
|
|
3082
|
-
const
|
|
3395
|
+
const flattened = this.flattenMartianBlockquote(block);
|
|
3396
|
+
const calloutConverted = this.convertQuoteToCallout(flattened);
|
|
3083
3397
|
result.push(calloutConverted);
|
|
3084
3398
|
}
|
|
3085
3399
|
return result;
|
|
@@ -3127,12 +3441,33 @@ ${COLUMN_LIST_END}`;
|
|
|
3127
3441
|
}
|
|
3128
3442
|
return null;
|
|
3129
3443
|
}
|
|
3444
|
+
/**
|
|
3445
|
+
* 단일 라인 보존 마커(목차·breadcrumb)를 원래 블록으로 복원한다.
|
|
3446
|
+
* pull 시 마크다운 표현이 없는 블록을 `%%im-nobsidian:toc%%` /
|
|
3447
|
+
* `%%im-nobsidian:breadcrumb%%` 마커 문단으로 남겨두므로, push 시 이 문단을
|
|
3448
|
+
* 감지해 table_of_contents / breadcrumb 블록으로 되돌린다. 그러지 않으면
|
|
3449
|
+
* 마커 텍스트가 일반 문단으로 Notion 에 기록되는 잠재 손실이 된다.
|
|
3450
|
+
*/
|
|
3451
|
+
convertSingleLineMarker(block) {
|
|
3452
|
+
if (block.type !== "paragraph") return null;
|
|
3453
|
+
const para = block.paragraph;
|
|
3454
|
+
const texts = para?.rich_text ?? [];
|
|
3455
|
+
if (texts.length !== 1) return null;
|
|
3456
|
+
const text = (texts[0]?.text?.content ?? "").trim();
|
|
3457
|
+
if (text === TOC_MARKER) {
|
|
3458
|
+
return NotionBlockBuilder.tableOfContents();
|
|
3459
|
+
}
|
|
3460
|
+
if (text === BREADCRUMB_MARKER) {
|
|
3461
|
+
return NotionBlockBuilder.breadcrumb();
|
|
3462
|
+
}
|
|
3463
|
+
return null;
|
|
3464
|
+
}
|
|
3130
3465
|
convertImageToVideoOrEmbed(block) {
|
|
3131
3466
|
let url = "";
|
|
3132
3467
|
let caption;
|
|
3133
3468
|
if (block.type === "image") {
|
|
3134
3469
|
const img = block.image;
|
|
3135
|
-
url = img?.external?.url ?? "";
|
|
3470
|
+
url = img?.external?.url ?? img?.file?.url ?? "";
|
|
3136
3471
|
caption = img?.caption?.[0]?.text?.content;
|
|
3137
3472
|
} else if (block.type === "paragraph") {
|
|
3138
3473
|
const para = block.paragraph;
|
|
@@ -3153,6 +3488,53 @@ ${COLUMN_LIST_END}`;
|
|
|
3153
3488
|
}
|
|
3154
3489
|
return null;
|
|
3155
3490
|
}
|
|
3491
|
+
/**
|
|
3492
|
+
* `@tryfabric/martian` 의 blockquote 표현을 **평탄화**한다(I2/I3 무손실 push).
|
|
3493
|
+
*
|
|
3494
|
+
* martian 의 parseBlockquote 는 `notion.blockquote([], children)` 로 본문을 quote 의
|
|
3495
|
+
* **자식 paragraph** 로 내려보내고 quote.rich_text 를 비운다(라이브러리 설계). 그 결과
|
|
3496
|
+
* `> 인용문` 을 push 하면 Notion 에 "빈 인용 줄 + 들여쓴 자식 문단"이 만들어지고, 다시
|
|
3497
|
+
* pull 하면 `> `(빈 줄) 아래에 자식 문단이 매달려 **원문과 구조가 갈라진다**(라운드트립 손실).
|
|
3498
|
+
* 또한 quote.rich_text[0] 이 항상 비어 callout 이모지 감지({@link convertQuoteToCallout})도
|
|
3499
|
+
* 무력화됐다.
|
|
3500
|
+
*
|
|
3501
|
+
* 선두의 연속된 paragraph 자식을 quote.rich_text 로 끌어올리고(문단 사이는 줄바꿈 rich_text
|
|
3502
|
+
* 로 연결 — Notion 네이티브 멀티라인 인용 표현), paragraph 가 아닌 자식(중첩 리스트 등)은
|
|
3503
|
+
* children 으로 남겨 구조를 보존한다. 이미 rich_text 에 본문이 있거나 끌어올릴 paragraph 가
|
|
3504
|
+
* 없으면 원본을 그대로 반환한다(안전 no-op).
|
|
3505
|
+
*/
|
|
3506
|
+
flattenMartianBlockquote(block) {
|
|
3507
|
+
if (block.type !== "quote") return block;
|
|
3508
|
+
const quote = block.quote;
|
|
3509
|
+
if (!quote) return block;
|
|
3510
|
+
const richText = quote.rich_text ?? [];
|
|
3511
|
+
const hasText = richText.some((r) => {
|
|
3512
|
+
const t = r;
|
|
3513
|
+
return (t.text?.content ?? t.plain_text ?? "") !== "";
|
|
3514
|
+
});
|
|
3515
|
+
const children = quote.children ?? [];
|
|
3516
|
+
if (hasText || children.length === 0) return block;
|
|
3517
|
+
const hoisted = [];
|
|
3518
|
+
let consumed = 0;
|
|
3519
|
+
for (; consumed < children.length; consumed++) {
|
|
3520
|
+
const child = children[consumed];
|
|
3521
|
+
if (child.type !== "paragraph") break;
|
|
3522
|
+
const para = child.paragraph;
|
|
3523
|
+
const paraRt = para?.rich_text ?? [];
|
|
3524
|
+
if (hoisted.length > 0) {
|
|
3525
|
+
hoisted.push(
|
|
3526
|
+
...NotionBlockBuilder.richText("\n")
|
|
3527
|
+
);
|
|
3528
|
+
}
|
|
3529
|
+
hoisted.push(...paraRt);
|
|
3530
|
+
}
|
|
3531
|
+
if (hoisted.length === 0) return block;
|
|
3532
|
+
const remaining = children.slice(consumed);
|
|
3533
|
+
const newQuote = { ...quote, rich_text: hoisted };
|
|
3534
|
+
if (remaining.length > 0) newQuote.children = remaining;
|
|
3535
|
+
else delete newQuote.children;
|
|
3536
|
+
return { ...block, quote: newQuote };
|
|
3537
|
+
}
|
|
3156
3538
|
convertQuoteToCallout(block) {
|
|
3157
3539
|
if (block.type !== "quote") return block;
|
|
3158
3540
|
const quote = block.quote;
|
|
@@ -3204,6 +3586,22 @@ ${COLUMN_LIST_END}`;
|
|
|
3204
3586
|
const result = this.n2m.toMarkdownString(mdBlocks);
|
|
3205
3587
|
return result.parent ?? "";
|
|
3206
3588
|
}
|
|
3589
|
+
/**
|
|
3590
|
+
* **in-memory** 블록 배열을 마크다운으로 변환한다(페이지 fetch 없음). notion-to-md 의
|
|
3591
|
+
* blocksToMarkdown 을 직접 호출하므로, `has_children:false` 인 평면 블록은 Notion 클라이언트를
|
|
3592
|
+
* 전혀 건드리지 않는다 → 고정 픽스처로 **오프라인 결정론** 블록 라운드트립(I2)을 잠글 수 있는
|
|
3593
|
+
* 정식 테스트 시임. children 이 있는 블록(table 행·callout 자식 등)은 클라이언트를 호출하므로
|
|
3594
|
+
* 호출 측이 평면 블록만 넣어야 오프라인이 보장된다.
|
|
3595
|
+
*/
|
|
3596
|
+
async notionBlockArrayToMarkdown(blocks) {
|
|
3597
|
+
if (!this.n2m) {
|
|
3598
|
+
throw new Error(
|
|
3599
|
+
"NotionToMarkdown\uC774 \uCD08\uAE30\uD654\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. initNotionToMd()\uB97C \uBA3C\uC800 \uD638\uCD9C\uD558\uC138\uC694."
|
|
3600
|
+
);
|
|
3601
|
+
}
|
|
3602
|
+
const mdBlocks = await this.n2m.blocksToMarkdown(blocks);
|
|
3603
|
+
return this.n2m.toMarkdownString(mdBlocks).parent ?? "";
|
|
3604
|
+
}
|
|
3207
3605
|
};
|
|
3208
3606
|
var NOTION_MAX_TABLE_ROWS = 100;
|
|
3209
3607
|
var NOTION_MAX_LIST_DEPTH = 3;
|
|
@@ -3331,10 +3729,11 @@ var EXTENSION_TO_MIME = {
|
|
|
3331
3729
|
".tiff": "image/tiff"
|
|
3332
3730
|
};
|
|
3333
3731
|
var ImageHandler = class {
|
|
3334
|
-
constructor(vaultFs, attachmentFolder = "attachments", notionClient, customFetch, options) {
|
|
3732
|
+
constructor(vaultFs, attachmentFolder = "attachments", notionClient, customFetch, options, stateDb) {
|
|
3335
3733
|
this.vaultFs = vaultFs;
|
|
3336
3734
|
this.attachmentFolder = attachmentFolder;
|
|
3337
3735
|
this.notionClient = notionClient;
|
|
3736
|
+
this.stateDb = stateDb;
|
|
3338
3737
|
this.customFetch = customFetch;
|
|
3339
3738
|
this.concurrency = options?.concurrency ?? 3;
|
|
3340
3739
|
this.maxRetries = options?.maxRetries ?? 3;
|
|
@@ -3344,6 +3743,7 @@ var ImageHandler = class {
|
|
|
3344
3743
|
vaultFs;
|
|
3345
3744
|
attachmentFolder;
|
|
3346
3745
|
notionClient;
|
|
3746
|
+
stateDb;
|
|
3347
3747
|
customFetch;
|
|
3348
3748
|
concurrency;
|
|
3349
3749
|
maxRetries;
|
|
@@ -3447,7 +3847,8 @@ var ImageHandler = class {
|
|
|
3447
3847
|
const contentType = this.getContentTypeFromPath(filename);
|
|
3448
3848
|
const blob = new Blob([buffer], { type: contentType });
|
|
3449
3849
|
const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
|
|
3450
|
-
|
|
3850
|
+
const fileHash = createHash("sha256").update(buffer).digest("hex");
|
|
3851
|
+
return { localPath, fileUploadId, fileHash, fileSize: buffer.length };
|
|
3451
3852
|
}
|
|
3452
3853
|
async uploadAndAppendImages(pageId, images) {
|
|
3453
3854
|
if (!this.notionClient) return [];
|
|
@@ -3472,10 +3873,26 @@ var ImageHandler = class {
|
|
|
3472
3873
|
}
|
|
3473
3874
|
if (imageBlocks.length > 0) {
|
|
3474
3875
|
await this.notionClient.appendChildren(pageId, imageBlocks);
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3876
|
+
if (this.stateDb) {
|
|
3877
|
+
for (const r of results) {
|
|
3878
|
+
try {
|
|
3879
|
+
this.stateDb.registerFile({
|
|
3880
|
+
localPath: r.localPath,
|
|
3881
|
+
notionPageId: pageId,
|
|
3882
|
+
fileUploadId: r.fileUploadId,
|
|
3883
|
+
fileType: "image",
|
|
3884
|
+
fileHash: r.fileHash,
|
|
3885
|
+
fileSize: r.fileSize
|
|
3886
|
+
});
|
|
3887
|
+
} catch (error) {
|
|
3888
|
+
getLogger().warn(`\uC774\uBBF8\uC9C0 \uB808\uC9C0\uC2A4\uD2B8\uB9AC \uB4F1\uB85D \uC2E4\uD328 (${r.localPath}):`, error);
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3892
|
+
}
|
|
3893
|
+
return results;
|
|
3894
|
+
}
|
|
3895
|
+
async readImageFromVault(localPath) {
|
|
3479
3896
|
try {
|
|
3480
3897
|
return await this.vaultFs.readBinary(localPath);
|
|
3481
3898
|
} catch {
|
|
@@ -3963,6 +4380,7 @@ function resolvePullConflict(input) {
|
|
|
3963
4380
|
|
|
3964
4381
|
// src/utils/sanitize.ts
|
|
3965
4382
|
var INVALID_CHARS = /[<>:"/\\|?*\x00-\x1f]/g;
|
|
4383
|
+
var WIKILINK_UNSAFE_CHARS = /[[\]]/g;
|
|
3966
4384
|
var TRAILING_DOTS = /\.+$/;
|
|
3967
4385
|
var RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
3968
4386
|
"CON",
|
|
@@ -3989,7 +4407,7 @@ var RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
|
3989
4407
|
"LPT9"
|
|
3990
4408
|
]);
|
|
3991
4409
|
function sanitizeFileName(name) {
|
|
3992
|
-
let sanitized = name.replace(INVALID_CHARS, "_").replace(TRAILING_DOTS, "").trim().slice(0, 200);
|
|
4410
|
+
let sanitized = name.replace(INVALID_CHARS, "_").replace(WIKILINK_UNSAFE_CHARS, "_").replace(TRAILING_DOTS, "").trim().slice(0, 200);
|
|
3993
4411
|
if (RESERVED_NAMES.has(sanitized.toUpperCase())) {
|
|
3994
4412
|
sanitized = `_${sanitized}`;
|
|
3995
4413
|
}
|
|
@@ -3999,6 +4417,36 @@ function sanitizeFileName(name) {
|
|
|
3999
4417
|
return sanitized;
|
|
4000
4418
|
}
|
|
4001
4419
|
|
|
4420
|
+
// src/utils/db-row-path.ts
|
|
4421
|
+
function selectDbRowFiles(files, localFolder) {
|
|
4422
|
+
const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
|
|
4423
|
+
return files.filter((file) => {
|
|
4424
|
+
if (!file.path.startsWith(prefix)) return false;
|
|
4425
|
+
const rel = file.path.slice(prefix.length);
|
|
4426
|
+
return rel.length > 0 && !rel.includes("/");
|
|
4427
|
+
});
|
|
4428
|
+
}
|
|
4429
|
+
function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
|
|
4430
|
+
const ownedBySelf = (candidate) => {
|
|
4431
|
+
const owner = lookupByPath(candidate);
|
|
4432
|
+
if (!owner) return true;
|
|
4433
|
+
return owner.notionPageId != null && notionIdsEqual(owner.notionPageId, pageId);
|
|
4434
|
+
};
|
|
4435
|
+
const natural = `${localFolder}/${safeName}.md`;
|
|
4436
|
+
if (ownedBySelf(natural)) return natural;
|
|
4437
|
+
const raw = pageId.replace(/-/g, "");
|
|
4438
|
+
for (const len of [8, 16, 32]) {
|
|
4439
|
+
const candidate = `${localFolder}/${safeName} (${raw.slice(0, len)}).md`;
|
|
4440
|
+
if (ownedBySelf(candidate)) return candidate;
|
|
4441
|
+
}
|
|
4442
|
+
return `${localFolder}/${safeName} (${raw}).md`;
|
|
4443
|
+
}
|
|
4444
|
+
|
|
4445
|
+
// src/utils/wikilink-title.ts
|
|
4446
|
+
function wikilinkTitleFromPath(obsidianPath) {
|
|
4447
|
+
return obsidianPath.replace(/\.md$/i, "").split("/").pop() ?? "";
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4002
4450
|
// src/view/base-file-generator.ts
|
|
4003
4451
|
var NOTION_TO_BASES_VIEW = {
|
|
4004
4452
|
table: "table",
|
|
@@ -4012,6 +4460,9 @@ var NOTION_TO_BASES_VIEW = {
|
|
|
4012
4460
|
map: null,
|
|
4013
4461
|
dashboard: null
|
|
4014
4462
|
};
|
|
4463
|
+
function basesViewTypeOf(notionType) {
|
|
4464
|
+
return NOTION_TO_BASES_VIEW[notionType] ?? null;
|
|
4465
|
+
}
|
|
4015
4466
|
var BaseFileGenerator = class {
|
|
4016
4467
|
generate(options) {
|
|
4017
4468
|
const lines = [];
|
|
@@ -4060,6 +4511,7 @@ var BaseFileGenerator = class {
|
|
|
4060
4511
|
lines.push(" name: Table");
|
|
4061
4512
|
return;
|
|
4062
4513
|
}
|
|
4514
|
+
this.dedupeViewNames(basesViews);
|
|
4063
4515
|
lines.push("views:");
|
|
4064
4516
|
for (const view of basesViews) {
|
|
4065
4517
|
lines.push(` - type: ${view.type}`);
|
|
@@ -4089,6 +4541,23 @@ var BaseFileGenerator = class {
|
|
|
4089
4541
|
}
|
|
4090
4542
|
}
|
|
4091
4543
|
}
|
|
4544
|
+
/**
|
|
4545
|
+
* 뷰 이름을 Bases 요구사항대로 유일화한다(in-place). 동명 충돌 시 ' 2',' 3'… 를 붙이되
|
|
4546
|
+
* 이미 존재하는 이름과 다시 충돌하지 않을 때까지 증가시킨다.
|
|
4547
|
+
*/
|
|
4548
|
+
dedupeViewNames(views) {
|
|
4549
|
+
const used = /* @__PURE__ */ new Set();
|
|
4550
|
+
for (const v of views) {
|
|
4551
|
+
let name = v.name;
|
|
4552
|
+
if (used.has(name)) {
|
|
4553
|
+
let n = 2;
|
|
4554
|
+
while (used.has(`${v.name} ${n}`)) n++;
|
|
4555
|
+
name = `${v.name} ${n}`;
|
|
4556
|
+
}
|
|
4557
|
+
used.add(name);
|
|
4558
|
+
v.name = name;
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4092
4561
|
convertView(view, schema) {
|
|
4093
4562
|
const basesType = NOTION_TO_BASES_VIEW[view.type];
|
|
4094
4563
|
if (!basesType) return null;
|
|
@@ -4181,10 +4650,156 @@ var BaseFileGenerator = class {
|
|
|
4181
4650
|
}
|
|
4182
4651
|
};
|
|
4183
4652
|
|
|
4653
|
+
// src/view/sidecar-generator.ts
|
|
4654
|
+
var DROPPED_SETTING_REASONS = {
|
|
4655
|
+
filter: "Notion \uD544\uD130\uC2DD\uC740 .base \uB85C \uD45C\uD604\uB418\uC9C0 \uC54A\uC74C(\uD3F4\uB354 \uD544\uD130\uB9CC \uC801\uC6A9)",
|
|
4656
|
+
coverSize: "\uAC24\uB7EC\uB9AC \uCEE4\uBC84 \uD06C\uAE30\uB294 Bases \uBBF8\uC9C0\uC6D0",
|
|
4657
|
+
coverAspect: "\uAC24\uB7EC\uB9AC \uCEE4\uBC84 \uBE44\uC728\uC740 Bases \uBBF8\uC9C0\uC6D0",
|
|
4658
|
+
viewRange: "\uCE98\uB9B0\uB354 \uD45C\uC2DC \uBC94\uC704(week/month)\uB294 Bases \uBBF8\uC9C0\uC6D0",
|
|
4659
|
+
columnWidths: "\uCEEC\uB7FC \uB108\uBE44(width)\uB294 Bases \uBBF8\uC9C0\uC6D0",
|
|
4660
|
+
columnWraps: "\uCEEC\uB7FC \uC904\uBC14\uAFC8(wrap)\uC740 Bases \uBBF8\uC9C0\uC6D0",
|
|
4661
|
+
hideEmptyGroups: "\uBE48 \uADF8\uB8F9 \uC228\uAE40\uC740 Bases \uBBF8\uC9C0\uC6D0"
|
|
4662
|
+
};
|
|
4663
|
+
var SidecarGenerator = class {
|
|
4664
|
+
/**
|
|
4665
|
+
* 사이드카 객체를 빌드한다(순수 — I/O 없음). 직렬화는 {@link serialize} 가 담당.
|
|
4666
|
+
*/
|
|
4667
|
+
build(options) {
|
|
4668
|
+
const degraded = [];
|
|
4669
|
+
const properties = this.buildProperties(options.schema);
|
|
4670
|
+
const views = options.viewsConfig.views.map((v) => this.buildView(v, degraded));
|
|
4671
|
+
degraded.sort(
|
|
4672
|
+
(a, b) => a.view.localeCompare(b.view) || a.kind.localeCompare(b.kind) || a.detail.localeCompare(b.detail)
|
|
4673
|
+
);
|
|
4674
|
+
return {
|
|
4675
|
+
generator: "im-nobsidian",
|
|
4676
|
+
schemaVersion: 1,
|
|
4677
|
+
databaseId: options.databaseId,
|
|
4678
|
+
databaseName: options.databaseName,
|
|
4679
|
+
properties,
|
|
4680
|
+
views,
|
|
4681
|
+
degraded
|
|
4682
|
+
};
|
|
4683
|
+
}
|
|
4684
|
+
/**
|
|
4685
|
+
* 사이드카를 결정적 JSON 문자열로 직렬화한다(키 재귀 정렬 + 말미 개행).
|
|
4686
|
+
* 동일 입력 → 동일 바이트 → 멱등(drift/churn 0).
|
|
4687
|
+
*/
|
|
4688
|
+
serialize(sidecar) {
|
|
4689
|
+
return stableStringify(sidecar) + "\n";
|
|
4690
|
+
}
|
|
4691
|
+
buildProperties(schema) {
|
|
4692
|
+
return Object.entries(schema).map(([name, prop]) => {
|
|
4693
|
+
const options = prop.options?.map((o) => o.name).filter((n) => n.length > 0);
|
|
4694
|
+
return {
|
|
4695
|
+
name,
|
|
4696
|
+
id: prop.id,
|
|
4697
|
+
type: prop.type,
|
|
4698
|
+
...options && options.length > 0 ? { options } : {}
|
|
4699
|
+
};
|
|
4700
|
+
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
4701
|
+
}
|
|
4702
|
+
buildView(view, degraded) {
|
|
4703
|
+
const basesType = basesViewTypeOf(view.type);
|
|
4704
|
+
const viewName = view.name || view.type;
|
|
4705
|
+
if (basesType === null) {
|
|
4706
|
+
degraded.push({
|
|
4707
|
+
view: viewName,
|
|
4708
|
+
kind: "view-unrepresentable",
|
|
4709
|
+
detail: `'${view.type}' \uBDF0\uB294 Obsidian Bases \uB300\uC751\uC774 \uC5C6\uC5B4 .base \uC5D0\uC11C \uB204\uB77D \u2014 \uC0AC\uC774\uB4DC\uCE74\uB85C\uB9CC \uBCF4\uC874`
|
|
4710
|
+
});
|
|
4711
|
+
}
|
|
4712
|
+
const config = this.extractConfig(view, basesType, viewName, degraded);
|
|
4713
|
+
return {
|
|
4714
|
+
id: view.id,
|
|
4715
|
+
name: viewName,
|
|
4716
|
+
notionType: view.type,
|
|
4717
|
+
basesType,
|
|
4718
|
+
config
|
|
4719
|
+
};
|
|
4720
|
+
}
|
|
4721
|
+
/**
|
|
4722
|
+
* 뷰 설정 중 의미 있는 항목을 추출한다. `.base` 가 표현 못하는 항목(필터·커버크기 등)은
|
|
4723
|
+
* config 에 보존하면서 동시에 degrade 리포트에 1줄씩 기록한다(표현되는 뷰에 한해 —
|
|
4724
|
+
* 미표현 뷰는 이미 view-unrepresentable 로 통째 보고됨).
|
|
4725
|
+
*/
|
|
4726
|
+
extractConfig(view, basesType, viewName, degraded) {
|
|
4727
|
+
const config = {};
|
|
4728
|
+
const noteDrop = (key) => {
|
|
4729
|
+
if (basesType !== null && DROPPED_SETTING_REASONS[key]) {
|
|
4730
|
+
degraded.push({
|
|
4731
|
+
view: viewName,
|
|
4732
|
+
kind: "setting-dropped",
|
|
4733
|
+
detail: DROPPED_SETTING_REASONS[key]
|
|
4734
|
+
});
|
|
4735
|
+
}
|
|
4736
|
+
};
|
|
4737
|
+
if (view.filter !== void 0 && view.filter !== null) {
|
|
4738
|
+
config["filter"] = view.filter;
|
|
4739
|
+
noteDrop("filter");
|
|
4740
|
+
}
|
|
4741
|
+
if (view.sorts && view.sorts.length > 0) config["sorts"] = view.sorts;
|
|
4742
|
+
if (view.groupBy) {
|
|
4743
|
+
config["groupBy"] = view.groupBy;
|
|
4744
|
+
if (view.groupBy.hideEmptyGroups) noteDrop("hideEmptyGroups");
|
|
4745
|
+
}
|
|
4746
|
+
if (view.cover) config["cover"] = view.cover;
|
|
4747
|
+
if (view.coverSize) {
|
|
4748
|
+
config["coverSize"] = view.coverSize;
|
|
4749
|
+
noteDrop("coverSize");
|
|
4750
|
+
}
|
|
4751
|
+
if (view.coverAspect) {
|
|
4752
|
+
config["coverAspect"] = view.coverAspect;
|
|
4753
|
+
noteDrop("coverAspect");
|
|
4754
|
+
}
|
|
4755
|
+
if (view.datePropertyId) config["datePropertyId"] = view.datePropertyId;
|
|
4756
|
+
if (view.datePropertyName) config["datePropertyName"] = view.datePropertyName;
|
|
4757
|
+
if (view.viewRange) {
|
|
4758
|
+
config["viewRange"] = view.viewRange;
|
|
4759
|
+
noteDrop("viewRange");
|
|
4760
|
+
}
|
|
4761
|
+
if (view.properties && view.properties.length > 0) {
|
|
4762
|
+
config["properties"] = view.properties;
|
|
4763
|
+
if (view.properties.some((p) => p.width !== void 0)) noteDrop("columnWidths");
|
|
4764
|
+
if (view.properties.some((p) => p.wrap !== void 0)) noteDrop("columnWraps");
|
|
4765
|
+
}
|
|
4766
|
+
return config;
|
|
4767
|
+
}
|
|
4768
|
+
};
|
|
4769
|
+
function stableStringify(value) {
|
|
4770
|
+
return JSON.stringify(sortKeys(value), null, 2);
|
|
4771
|
+
}
|
|
4772
|
+
function sortKeys(value) {
|
|
4773
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
4774
|
+
if (value !== null && typeof value === "object") {
|
|
4775
|
+
const sorted = {};
|
|
4776
|
+
for (const key of Object.keys(value).sort()) {
|
|
4777
|
+
sorted[key] = sortKeys(value[key]);
|
|
4778
|
+
}
|
|
4779
|
+
return sorted;
|
|
4780
|
+
}
|
|
4781
|
+
return value;
|
|
4782
|
+
}
|
|
4783
|
+
|
|
4784
|
+
// src/sync/stale-db-artifacts.ts
|
|
4785
|
+
var ARTIFACT_SUFFIXES = [".base", ".notion.json"];
|
|
4786
|
+
function selectStaleDbArtifacts(files, localFolder, keepPaths) {
|
|
4787
|
+
const prefix = `${localFolder}/`;
|
|
4788
|
+
const stale = [];
|
|
4789
|
+
for (const file of files) {
|
|
4790
|
+
const path = file.path;
|
|
4791
|
+
if (!path.startsWith(prefix)) continue;
|
|
4792
|
+
const rel = path.slice(prefix.length);
|
|
4793
|
+
if (rel.includes("/")) continue;
|
|
4794
|
+
if (!ARTIFACT_SUFFIXES.some((suffix) => rel.endsWith(suffix))) continue;
|
|
4795
|
+
if (keepPaths.has(path)) continue;
|
|
4796
|
+
stale.push(path);
|
|
4797
|
+
}
|
|
4798
|
+
return stale;
|
|
4799
|
+
}
|
|
4800
|
+
|
|
4184
4801
|
// src/converter/enhanced-md-converter.ts
|
|
4185
4802
|
var NOTION_CALLOUT_RE = /^::: callout\n([\s\S]*?)\n:::/gm;
|
|
4186
|
-
var NOTION_CALLOUT_TAG_RE = /<callout[^>]*>\n?([\s\S]*?)<\/callout>/g;
|
|
4187
|
-
var NOTION_TOGGLE_RE = /[\t ]*<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gs;
|
|
4188
4803
|
var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
|
|
4189
4804
|
var NOTION_USER_MENTION_RE = /<mention-user id="[^"]*">([^<]*)<\/mention-user>/g;
|
|
4190
4805
|
var NOTION_DATE_MENTION_RE = /<mention-date start="([^"]*)"(?: end="([^"]*)")?[^>]*\/>/g;
|
|
@@ -4195,13 +4810,12 @@ var NOTION_VIDEO_RE = /[\t ]*<video src="([^"]*)">([\s\S]*?)<\/video>/g;
|
|
|
4195
4810
|
var NOTION_PDF_RE = /[\t ]*<pdf src="([^"]*)">([\s\S]*?)<\/pdf>/g;
|
|
4196
4811
|
var NOTION_FILE_RE = /[\t ]*<file src="([^"]*)">([\s\S]*?)<\/file>/g;
|
|
4197
4812
|
var NOTION_TAB_RE = /<tab title="([^"]*)">([\s\S]*?)<\/tab>/g;
|
|
4198
|
-
var NOTION_UNDERLINE_RE = /<span underline="true">([\s\S]*?)<\/span>/g;
|
|
4199
4813
|
function notionEnhancedToObsidian(enhanced) {
|
|
4200
4814
|
let result = enhanced;
|
|
4201
4815
|
result = convertSyncedBlockRef(result);
|
|
4202
4816
|
result = normalizeCodeBlockToggles(result);
|
|
4203
|
-
result =
|
|
4204
|
-
result =
|
|
4817
|
+
result = convertContainers(result);
|
|
4818
|
+
result = convertFencedCallouts(result);
|
|
4205
4819
|
result = convertPageMentions(result);
|
|
4206
4820
|
result = convertPageLinks(result);
|
|
4207
4821
|
result = convertUserMentions(result);
|
|
@@ -4211,9 +4825,7 @@ function notionEnhancedToObsidian(enhanced) {
|
|
|
4211
4825
|
result = preserveUnknownBlocks(result);
|
|
4212
4826
|
result = convertNotionMath(result);
|
|
4213
4827
|
result = convertNotionTables(result);
|
|
4214
|
-
result =
|
|
4215
|
-
result = convertUnderlineSpans(result);
|
|
4216
|
-
result = convertColumnBlocks(result);
|
|
4828
|
+
result = convertSpans(result);
|
|
4217
4829
|
result = convertDatabaseBlocks(result);
|
|
4218
4830
|
result = cleanInlineColorAttrs(result);
|
|
4219
4831
|
result = removeEmptyBlocks(result);
|
|
@@ -4260,35 +4872,136 @@ function restoreWikilinkPreserveLinks(content) {
|
|
|
4260
4872
|
return target === label ? `[[${target}]]` : `[[${target}|${label}]]`;
|
|
4261
4873
|
});
|
|
4262
4874
|
}
|
|
4875
|
+
function fenceFor(body) {
|
|
4876
|
+
let longest = 0;
|
|
4877
|
+
const re = /`+/g;
|
|
4878
|
+
let m;
|
|
4879
|
+
while ((m = re.exec(body)) !== null) longest = Math.max(longest, m[0].length);
|
|
4880
|
+
return "`".repeat(Math.max(3, longest + 1));
|
|
4881
|
+
}
|
|
4882
|
+
function dedentContainerBody(text) {
|
|
4883
|
+
const lines = text.split("\n");
|
|
4884
|
+
while (lines.length && lines[0].trim() === "") lines.shift();
|
|
4885
|
+
while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
|
|
4886
|
+
const kinds = [];
|
|
4887
|
+
let inCode = false;
|
|
4888
|
+
let fenceChar = "";
|
|
4889
|
+
let fenceLen = 0;
|
|
4890
|
+
let inTable = false;
|
|
4891
|
+
for (const line of lines) {
|
|
4892
|
+
const fence = /^[\t ]*(`{3,}|~{3,})(.*)$/.exec(line);
|
|
4893
|
+
if (inCode) {
|
|
4894
|
+
if (fence && fence[1][0] === fenceChar && fence[1].length >= fenceLen && fence[2].trim() === "") {
|
|
4895
|
+
inCode = false;
|
|
4896
|
+
kinds.push("fence");
|
|
4897
|
+
} else {
|
|
4898
|
+
kinds.push("code");
|
|
4899
|
+
}
|
|
4900
|
+
} else if (inTable) {
|
|
4901
|
+
const closes = /<\/table>/.test(line);
|
|
4902
|
+
kinds.push(closes ? "fence" : "code");
|
|
4903
|
+
if (closes) inTable = false;
|
|
4904
|
+
} else if (fence) {
|
|
4905
|
+
inCode = true;
|
|
4906
|
+
fenceChar = fence[1][0];
|
|
4907
|
+
fenceLen = fence[1].length;
|
|
4908
|
+
kinds.push("fence");
|
|
4909
|
+
} else if (/^[\t ]*<table[^>]*>/.test(line)) {
|
|
4910
|
+
if (!/<\/table>/.test(line)) inTable = true;
|
|
4911
|
+
kinds.push("fence");
|
|
4912
|
+
} else {
|
|
4913
|
+
kinds.push("prose");
|
|
4914
|
+
}
|
|
4915
|
+
}
|
|
4916
|
+
let min = Infinity;
|
|
4917
|
+
lines.forEach((l, i) => {
|
|
4918
|
+
if (kinds[i] !== "prose" || l.trim() === "") return;
|
|
4919
|
+
min = Math.min(min, /^[\t ]*/.exec(l)[0].length);
|
|
4920
|
+
});
|
|
4921
|
+
if (!Number.isFinite(min)) min = 0;
|
|
4922
|
+
return lines.map((l, i) => {
|
|
4923
|
+
if (l.trim() === "") return "";
|
|
4924
|
+
if (kinds[i] === "code") return l;
|
|
4925
|
+
if (kinds[i] === "fence") return l.replace(/^[\t ]+/, "");
|
|
4926
|
+
return l.slice(min);
|
|
4927
|
+
}).join("\n");
|
|
4928
|
+
}
|
|
4263
4929
|
function normalizeCodeBlockToggles(content) {
|
|
4264
4930
|
return content.replace(
|
|
4265
|
-
|
|
4266
|
-
(_match, title, lang,
|
|
4267
|
-
const
|
|
4931
|
+
/^[\t ]*- (.+)\n[\t ]*(`{3,})(\w*)\n([\s\S]*?)\n[\t ]*\2[\t ]*$/gm,
|
|
4932
|
+
(_match, title, _open, lang, rawBody) => {
|
|
4933
|
+
const body = dedentContainerBody(rawBody);
|
|
4934
|
+
const fence = fenceFor(body);
|
|
4268
4935
|
return `<details>
|
|
4269
4936
|
<summary>${title.trim()}</summary>
|
|
4270
|
-
|
|
4937
|
+
${fence}${lang}
|
|
4271
4938
|
${body}
|
|
4272
|
-
|
|
4939
|
+
${fence}
|
|
4273
4940
|
</details>`;
|
|
4274
4941
|
}
|
|
4275
4942
|
);
|
|
4276
4943
|
}
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4944
|
+
var NO_CONTAINER_BODY = "(?:(?!<details>|<callout|<columns>)[\\s\\S])*?";
|
|
4945
|
+
var SUMMARY_TITLE = "((?:(?!<\\/summary>|<details>|<summary>)[\\s\\S])*?)";
|
|
4946
|
+
var INNERMOST_DETAILS_RE = new RegExp(
|
|
4947
|
+
`([\\t ]*)<details>\\s*<summary>${SUMMARY_TITLE}<\\/summary>(${NO_CONTAINER_BODY})<\\/details>`,
|
|
4948
|
+
"g"
|
|
4949
|
+
);
|
|
4950
|
+
var INNERMOST_CALLOUT_RE = new RegExp(
|
|
4951
|
+
`([\\t ]*)<callout[^>]*>\\n?(${NO_CONTAINER_BODY})<\\/callout>`,
|
|
4952
|
+
"g"
|
|
4953
|
+
);
|
|
4954
|
+
var INNERMOST_COLUMNS_RE = new RegExp(
|
|
4955
|
+
`([\\t ]*)<columns>\\n?(${NO_CONTAINER_BODY})<\\/columns>`,
|
|
4956
|
+
"g"
|
|
4957
|
+
);
|
|
4958
|
+
var COLUMN_WRAP_RE = /[\t ]*<column>\n?([\s\S]*?)[\t ]*<\/column>\n?/g;
|
|
4959
|
+
function reindentLines(text, indent) {
|
|
4960
|
+
if (!indent) return text;
|
|
4961
|
+
return text.split("\n").map((line) => line === "" ? line : indent + line).join("\n");
|
|
4962
|
+
}
|
|
4963
|
+
function toggleToCallout(title, rawBody) {
|
|
4964
|
+
const body = dedentContainerBody(rawBody);
|
|
4965
|
+
const calloutBody = body.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n");
|
|
4966
|
+
return `> [!toggle]- ${title.trim()}
|
|
4286
4967
|
${calloutBody}`;
|
|
4968
|
+
}
|
|
4969
|
+
function flattenColumns(body) {
|
|
4970
|
+
const cols = [];
|
|
4971
|
+
const re = new RegExp(COLUMN_WRAP_RE.source, COLUMN_WRAP_RE.flags);
|
|
4972
|
+
let m;
|
|
4973
|
+
let matched = false;
|
|
4974
|
+
while ((m = re.exec(body)) !== null) {
|
|
4975
|
+
matched = true;
|
|
4976
|
+
const col = dedentContainerBody(m[1]);
|
|
4977
|
+
if (col.trim() !== "") cols.push(col);
|
|
4978
|
+
}
|
|
4979
|
+
if (!matched) return dedentContainerBody(body);
|
|
4980
|
+
return cols.join("\n\n");
|
|
4981
|
+
}
|
|
4982
|
+
function convertContainers(content) {
|
|
4983
|
+
let result = content;
|
|
4984
|
+
let safety = 0;
|
|
4985
|
+
while (safety++ < 2e3 && (result.includes("<details>") || result.includes("<callout") || result.includes("<columns>"))) {
|
|
4986
|
+
const before = result;
|
|
4987
|
+
result = result.replace(
|
|
4988
|
+
INNERMOST_DETAILS_RE,
|
|
4989
|
+
(_m, indent, title, body) => reindentLines(toggleToCallout(title, body), indent)
|
|
4990
|
+
);
|
|
4991
|
+
result = result.replace(
|
|
4992
|
+
INNERMOST_CALLOUT_RE,
|
|
4993
|
+
(_m, indent, body) => reindentLines(calloutBodyToObsidian(body), indent)
|
|
4994
|
+
);
|
|
4995
|
+
result = result.replace(
|
|
4996
|
+
INNERMOST_COLUMNS_RE,
|
|
4997
|
+
(_m, indent, body) => reindentLines(flattenColumns(body), indent)
|
|
4998
|
+
);
|
|
4999
|
+
if (result === before) break;
|
|
4287
5000
|
}
|
|
4288
|
-
return
|
|
5001
|
+
return result;
|
|
4289
5002
|
}
|
|
4290
5003
|
function calloutBodyToObsidian(body) {
|
|
4291
|
-
const lines = body.split("\n").
|
|
5004
|
+
const lines = dedentContainerBody(body).split("\n").filter((l, i, arr) => !(i === 0 && l === "") && !(i === arr.length - 1 && l === ""));
|
|
4292
5005
|
const firstLine = lines[0] ?? "";
|
|
4293
5006
|
const emojiMatch = /^([\p{Emoji}️]+)\s*(.*)/u.exec(firstLine);
|
|
4294
5007
|
const type = emojiMatch ? emojiToCalloutType(emojiMatch[1]) : "note";
|
|
@@ -4298,13 +5011,8 @@ function calloutBodyToObsidian(body) {
|
|
|
4298
5011
|
const calloutBody = rest ? "\n" + rest.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n") : "";
|
|
4299
5012
|
return calloutTitle + calloutBody;
|
|
4300
5013
|
}
|
|
4301
|
-
function
|
|
4302
|
-
|
|
4303
|
-
NOTION_CALLOUT_TAG_RE,
|
|
4304
|
-
(_match, body) => calloutBodyToObsidian(body)
|
|
4305
|
-
);
|
|
4306
|
-
result = result.replace(NOTION_CALLOUT_RE, (_match, body) => calloutBodyToObsidian(body));
|
|
4307
|
-
return result;
|
|
5014
|
+
function convertFencedCallouts(content) {
|
|
5015
|
+
return content.replace(NOTION_CALLOUT_RE, (_match, body) => calloutBodyToObsidian(body));
|
|
4308
5016
|
}
|
|
4309
5017
|
function convertPageMentions(content) {
|
|
4310
5018
|
let result = content.replace(NOTION_PAGE_MENTION_RE, (_match, _id, text) => {
|
|
@@ -4312,7 +5020,7 @@ function convertPageMentions(content) {
|
|
|
4312
5020
|
return `[[${cleaned}]]`;
|
|
4313
5021
|
});
|
|
4314
5022
|
result = result.replace(
|
|
4315
|
-
/<mention-page\s+url="https?:\/\/(?:www\.)?notion\.so\/([a-f0-9]{32})"[^>]
|
|
5023
|
+
/<mention-page\s+url="https?:\/\/(?:www\.)?notion\.so\/([a-f0-9]{32})"[^>]*?(?:\/>|>[\s\S]*?<\/mention-page>)/g,
|
|
4316
5024
|
(_match, id) => `[[notion:${id}]]`
|
|
4317
5025
|
);
|
|
4318
5026
|
return result;
|
|
@@ -4383,10 +5091,19 @@ function convertTabBlocks(content) {
|
|
|
4383
5091
|
${indented}`;
|
|
4384
5092
|
});
|
|
4385
5093
|
}
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
5094
|
+
var INNERMOST_SPAN_RE = /<span (?:underline="true"|color="([^"]+)")>((?:(?!<span )[\s\S])*?)<\/span>/g;
|
|
5095
|
+
function convertSpans(content) {
|
|
5096
|
+
let result = content;
|
|
5097
|
+
let safety = 0;
|
|
5098
|
+
while (safety++ < 1e3) {
|
|
5099
|
+
const next = result.replace(
|
|
5100
|
+
INNERMOST_SPAN_RE,
|
|
5101
|
+
(_match, color, inner) => color !== void 0 ? `${compactMarker(`color:${color}`)}${inner}%%/color%%` : `${compactMarker("underline")}${inner}%%/underline%%`
|
|
5102
|
+
);
|
|
5103
|
+
if (next === result) break;
|
|
5104
|
+
result = next;
|
|
5105
|
+
}
|
|
5106
|
+
return result;
|
|
4390
5107
|
}
|
|
4391
5108
|
function convertTogglesToHtml(content) {
|
|
4392
5109
|
const calloutToggleRe = /^> \[!toggle\]-\s*(.+)\n((?:>.*\n?)*)/gm;
|
|
@@ -4502,7 +5219,6 @@ function escapeRegex3(str) {
|
|
|
4502
5219
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4503
5220
|
}
|
|
4504
5221
|
var NOTION_PAGE_LINK_RE = /<page url="[^"]*">([\s\S]*?)<\/page>/g;
|
|
4505
|
-
var NOTION_COLOR_SPAN_RE = /<span color="([^"]+)">([\s\S]*?)<\/span>/g;
|
|
4506
5222
|
var NOTION_EMPTY_BLOCK_RE = /^(?:>[\t ]*)*[\t ]*<empty-block\/>\n?/gm;
|
|
4507
5223
|
function convertPageLinks(content) {
|
|
4508
5224
|
return content.replace(NOTION_PAGE_LINK_RE, (_match, text) => {
|
|
@@ -4510,12 +5226,6 @@ function convertPageLinks(content) {
|
|
|
4510
5226
|
return `[[${cleaned}]]`;
|
|
4511
5227
|
});
|
|
4512
5228
|
}
|
|
4513
|
-
function convertColorSpans(content) {
|
|
4514
|
-
return content.replace(
|
|
4515
|
-
NOTION_COLOR_SPAN_RE,
|
|
4516
|
-
(_match, color, text) => `${compactMarker(`color:${color}`)}${text}%%/color%%`
|
|
4517
|
-
);
|
|
4518
|
-
}
|
|
4519
5229
|
function convertDatabaseBlocks(content) {
|
|
4520
5230
|
return content.replace(
|
|
4521
5231
|
/<database[^>]*>([\s\S]*?)<\/database>/g,
|
|
@@ -4525,11 +5235,6 @@ function convertDatabaseBlocks(content) {
|
|
|
4525
5235
|
function cleanInlineColorAttrs(content) {
|
|
4526
5236
|
return content.replace(/\s*\{color="[^"]*"\}/g, "");
|
|
4527
5237
|
}
|
|
4528
|
-
function convertColumnBlocks(content) {
|
|
4529
|
-
return content.replace(/<columns>\n?([\s\S]*?)<\/columns>/g, (_match, inner) => {
|
|
4530
|
-
return inner.replace(/\t?<column>\n?/g, "").replace(/\t?<\/column>\n?/g, "").trim();
|
|
4531
|
-
});
|
|
4532
|
-
}
|
|
4533
5238
|
function removeEmptyBlocks(content) {
|
|
4534
5239
|
return content.replace(NOTION_EMPTY_BLOCK_RE, "\n");
|
|
4535
5240
|
}
|
|
@@ -4691,7 +5396,12 @@ var DatabaseSyncer = class {
|
|
|
4691
5396
|
this.imageHandler = imageHandler;
|
|
4692
5397
|
this.propertyMapper.setWikilinkResolver({
|
|
4693
5398
|
resolve: (title) => stateDb.resolveWikilink(title)?.notionPageId ?? null,
|
|
4694
|
-
|
|
5399
|
+
// M4: 후처리 패스(resolveNotionLinks)와 동일하게 파일 basename 으로 해소 — 원시 제목과
|
|
5400
|
+
// sanitize 된 파일명의 불일치로 인한 깨진/이중 위키링크 차단.
|
|
5401
|
+
resolvePageId: (pageId) => {
|
|
5402
|
+
const entry = stateDb.resolvePageId(pageId);
|
|
5403
|
+
return entry ? wikilinkTitleFromPath(entry.obsidianPath) : null;
|
|
5404
|
+
}
|
|
4695
5405
|
});
|
|
4696
5406
|
}
|
|
4697
5407
|
config;
|
|
@@ -4702,15 +5412,17 @@ var DatabaseSyncer = class {
|
|
|
4702
5412
|
imageHandler;
|
|
4703
5413
|
propertyMapper = new PropertyMapper();
|
|
4704
5414
|
baseFileGenerator = new BaseFileGenerator();
|
|
5415
|
+
sidecarGenerator = new SidecarGenerator();
|
|
4705
5416
|
async pullAll() {
|
|
4706
5417
|
const databases = this.config.notion.databases;
|
|
4707
5418
|
if (!databases || databases.length === 0) {
|
|
4708
|
-
return { created: 0, updated: 0, conflicts: [], failed: [] };
|
|
5419
|
+
return { created: 0, updated: 0, conflicts: [], failed: [], writtenPaths: [] };
|
|
4709
5420
|
}
|
|
4710
5421
|
let created = 0;
|
|
4711
5422
|
let updated = 0;
|
|
4712
5423
|
const conflicts = [];
|
|
4713
5424
|
const failed = [];
|
|
5425
|
+
const writtenPaths = [];
|
|
4714
5426
|
for (const dbConfig of databases) {
|
|
4715
5427
|
try {
|
|
4716
5428
|
const result = await this.pullDatabase(dbConfig);
|
|
@@ -4718,6 +5430,7 @@ var DatabaseSyncer = class {
|
|
|
4718
5430
|
updated += result.updated;
|
|
4719
5431
|
conflicts.push(...result.conflicts);
|
|
4720
5432
|
failed.push(...result.failed);
|
|
5433
|
+
writtenPaths.push(...result.writtenPaths);
|
|
4721
5434
|
} catch (error) {
|
|
4722
5435
|
getLogger().warn(`[DB Sync] DB ${dbConfig.databaseId} pull \uC2E4\uD328:`, error);
|
|
4723
5436
|
failed.push({
|
|
@@ -4727,12 +5440,12 @@ var DatabaseSyncer = class {
|
|
|
4727
5440
|
});
|
|
4728
5441
|
}
|
|
4729
5442
|
}
|
|
4730
|
-
return { created, updated, conflicts, failed };
|
|
5443
|
+
return { created, updated, conflicts, failed, writtenPaths };
|
|
4731
5444
|
}
|
|
4732
5445
|
async pushAll() {
|
|
4733
5446
|
const databases = this.config.notion.databases;
|
|
4734
5447
|
if (!databases || databases.length === 0) {
|
|
4735
|
-
return { created: 0, updated: 0, conflicts: [], failed: [] };
|
|
5448
|
+
return { created: 0, updated: 0, conflicts: [], failed: [], writtenPaths: [] };
|
|
4736
5449
|
}
|
|
4737
5450
|
let created = 0;
|
|
4738
5451
|
let updated = 0;
|
|
@@ -4752,7 +5465,7 @@ var DatabaseSyncer = class {
|
|
|
4752
5465
|
});
|
|
4753
5466
|
}
|
|
4754
5467
|
}
|
|
4755
|
-
return { created, updated, conflicts: [], failed };
|
|
5468
|
+
return { created, updated, conflicts: [], failed, writtenPaths: [] };
|
|
4756
5469
|
}
|
|
4757
5470
|
async pullDatabase(dbConfig) {
|
|
4758
5471
|
const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
|
|
@@ -4768,6 +5481,7 @@ var DatabaseSyncer = class {
|
|
|
4768
5481
|
let updated = 0;
|
|
4769
5482
|
const conflicts = [];
|
|
4770
5483
|
const failed = [];
|
|
5484
|
+
const writtenPaths = [];
|
|
4771
5485
|
for (const page of pages) {
|
|
4772
5486
|
try {
|
|
4773
5487
|
const record = this.stateDb.getByNotionId(page.id);
|
|
@@ -4776,12 +5490,16 @@ var DatabaseSyncer = class {
|
|
|
4776
5490
|
const outcome = await this.pullDatabasePage(page, dbConfig);
|
|
4777
5491
|
if (outcome.action === "written") {
|
|
4778
5492
|
updated++;
|
|
5493
|
+
writtenPaths.push(outcome.path);
|
|
4779
5494
|
} else if (outcome.action === "conflict") {
|
|
4780
5495
|
conflicts.push(outcome.conflict);
|
|
4781
5496
|
}
|
|
4782
5497
|
} else {
|
|
4783
|
-
await this.pullDatabasePage(page, dbConfig);
|
|
5498
|
+
const outcome = await this.pullDatabasePage(page, dbConfig);
|
|
4784
5499
|
created++;
|
|
5500
|
+
if (outcome.action === "written") {
|
|
5501
|
+
writtenPaths.push(outcome.path);
|
|
5502
|
+
}
|
|
4785
5503
|
}
|
|
4786
5504
|
} catch (error) {
|
|
4787
5505
|
failed.push({
|
|
@@ -4794,7 +5512,7 @@ var DatabaseSyncer = class {
|
|
|
4794
5512
|
getLogger().debug(
|
|
4795
5513
|
`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8, ${conflicts.length} \uCDA9\uB3CC`
|
|
4796
5514
|
);
|
|
4797
|
-
return { created, updated, conflicts, failed };
|
|
5515
|
+
return { created, updated, conflicts, failed, writtenPaths };
|
|
4798
5516
|
}
|
|
4799
5517
|
async pullDatabaseViews(dbConfig, force = false) {
|
|
4800
5518
|
try {
|
|
@@ -4830,25 +5548,76 @@ var DatabaseSyncer = class {
|
|
|
4830
5548
|
try {
|
|
4831
5549
|
const schemaFull = await this.notionClient.getDatabaseSchemaFull(dbConfig.databaseId);
|
|
4832
5550
|
const dbName = viewsConfig?.databaseName || await this.notionClient.getDatabaseTitle(dbConfig.databaseId) || dbConfig.localFolder.split("/").pop() || "Database";
|
|
5551
|
+
const resolvedViews = viewsConfig ?? {
|
|
5552
|
+
databaseId: dbConfig.databaseId,
|
|
5553
|
+
databaseName: dbName,
|
|
5554
|
+
lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5555
|
+
views: [{ id: "default", name: "Table", type: "table" }]
|
|
5556
|
+
};
|
|
4833
5557
|
const baseContent = this.baseFileGenerator.generate({
|
|
4834
5558
|
databaseId: dbConfig.databaseId,
|
|
4835
5559
|
databaseName: dbName,
|
|
4836
5560
|
schema: schemaFull,
|
|
4837
|
-
viewsConfig:
|
|
4838
|
-
databaseId: dbConfig.databaseId,
|
|
4839
|
-
databaseName: dbName,
|
|
4840
|
-
lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4841
|
-
views: [{ id: "default", name: "Table", type: "table" }]
|
|
4842
|
-
},
|
|
5561
|
+
viewsConfig: resolvedViews,
|
|
4843
5562
|
folderPath: dbConfig.localFolder
|
|
4844
5563
|
});
|
|
4845
|
-
const
|
|
5564
|
+
const safeName = sanitizeFileName(dbName);
|
|
5565
|
+
const basePath = `${dbConfig.localFolder}/${safeName}.base`;
|
|
4846
5566
|
await this.vaultFs.writeFile(basePath, baseContent);
|
|
4847
5567
|
getLogger().debug(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131: ${basePath}`);
|
|
5568
|
+
await this.generateSidecar(dbConfig, dbName, safeName, schemaFull, resolvedViews);
|
|
5569
|
+
await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName);
|
|
4848
5570
|
} catch (error) {
|
|
4849
5571
|
getLogger().warn(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
|
|
4850
5572
|
}
|
|
4851
5573
|
}
|
|
5574
|
+
/**
|
|
5575
|
+
* DB 제목이 바뀌면 `.base`/`.notion.json` 파일명(safeName)이 바뀌어 옛 이름의 산출물이
|
|
5576
|
+
* 같은 폴더에 고아로 잔존한다(drift·부활 원인). DB 폴더 **직속**의 산출물 중 현재 이름이
|
|
5577
|
+
* 아닌 것을 삭제해 멱등을 보장한다. 하위 폴더(중첩 DB)의 산출물은 보호한다.
|
|
5578
|
+
*/
|
|
5579
|
+
async cleanupStaleDbArtifacts(localFolder, safeName) {
|
|
5580
|
+
try {
|
|
5581
|
+
const keep = /* @__PURE__ */ new Set([
|
|
5582
|
+
`${localFolder}/${safeName}.base`,
|
|
5583
|
+
`${localFolder}/${safeName}.notion.json`
|
|
5584
|
+
]);
|
|
5585
|
+
const files = await this.vaultFs.listNonMarkdownFiles();
|
|
5586
|
+
const stale = selectStaleDbArtifacts(files, localFolder, keep);
|
|
5587
|
+
for (const path of stale) {
|
|
5588
|
+
await this.vaultFs.deleteFile(path);
|
|
5589
|
+
getLogger().info(`[DB Sync] \uACE0\uC544 DB \uC0B0\uCD9C\uBB3C \uC0AD\uC81C(rename): ${path}`);
|
|
5590
|
+
}
|
|
5591
|
+
} catch (error) {
|
|
5592
|
+
getLogger().warn(`[DB Sync] \uACE0\uC544 .base/.notion.json \uC815\uB9AC \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
|
|
5593
|
+
}
|
|
5594
|
+
}
|
|
5595
|
+
/**
|
|
5596
|
+
* `.base` 가 표현하지 못하는 Notion DB 메타데이터(미지원 뷰·필터식·커버크기 등)를
|
|
5597
|
+
* 사이드카 `<db>.notion.json` 으로 무손실 보존하고, degrade 된 항목을 정직하게 로그한다.
|
|
5598
|
+
* 결정적 직렬화라 동일 DB 상태 → 동일 바이트 → 멱등(drift/churn 0).
|
|
5599
|
+
*/
|
|
5600
|
+
async generateSidecar(dbConfig, dbName, safeName, schemaFull, resolvedViews) {
|
|
5601
|
+
try {
|
|
5602
|
+
const sidecar = this.sidecarGenerator.build({
|
|
5603
|
+
databaseId: dbConfig.databaseId,
|
|
5604
|
+
databaseName: dbName,
|
|
5605
|
+
schema: schemaFull,
|
|
5606
|
+
viewsConfig: resolvedViews
|
|
5607
|
+
});
|
|
5608
|
+
const sidecarPath = `${dbConfig.localFolder}/${safeName}.notion.json`;
|
|
5609
|
+
await this.vaultFs.writeFile(sidecarPath, this.sidecarGenerator.serialize(sidecar));
|
|
5610
|
+
if (sidecar.degraded.length > 0) {
|
|
5611
|
+
const unrep = sidecar.degraded.filter((d) => d.kind === "view-unrepresentable").length;
|
|
5612
|
+
const dropped = sidecar.degraded.length - unrep;
|
|
5613
|
+
getLogger().info(
|
|
5614
|
+
`[DB Sync] '${dbName}' degrade ${sidecar.degraded.length}\uAC74 (\uBBF8\uD45C\uD604 \uBDF0 ${unrep} \xB7 \uBBF8\uD45C\uD604 \uC124\uC815 ${dropped}) \u2192 ${safeName}.notion.json \uBCF4\uC874`
|
|
5615
|
+
);
|
|
5616
|
+
}
|
|
5617
|
+
} catch (error) {
|
|
5618
|
+
getLogger().warn(`[DB Sync] \uC0AC\uC774\uB4DC\uCE74 \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
|
|
5619
|
+
}
|
|
5620
|
+
}
|
|
4852
5621
|
async pullDatabasePage(page, dbConfig) {
|
|
4853
5622
|
const title = this.notionClient.extractTitle(page);
|
|
4854
5623
|
const safeName = sanitizeFileName(title);
|
|
@@ -4864,9 +5633,9 @@ var DatabaseSyncer = class {
|
|
|
4864
5633
|
``,
|
|
4865
5634
|
`${safeName}-cover`
|
|
4866
5635
|
);
|
|
4867
|
-
const
|
|
4868
|
-
if (
|
|
4869
|
-
properties.cover = `[[${
|
|
5636
|
+
const resolved = coverResult.content.match(/!\[cover\]\((.+?)\)/)?.[1];
|
|
5637
|
+
if (resolved && !/^https?:\/\//i.test(resolved)) {
|
|
5638
|
+
properties.cover = `[[${resolved}]]`;
|
|
4870
5639
|
} else {
|
|
4871
5640
|
properties.cover = cover.url;
|
|
4872
5641
|
}
|
|
@@ -4904,11 +5673,12 @@ var DatabaseSyncer = class {
|
|
|
4904
5673
|
if (existingRecord?.obsidianPath) {
|
|
4905
5674
|
filePath = existingRecord.obsidianPath;
|
|
4906
5675
|
} else {
|
|
4907
|
-
filePath =
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
5676
|
+
filePath = resolveDbRowPath(
|
|
5677
|
+
dbConfig.localFolder,
|
|
5678
|
+
safeName,
|
|
5679
|
+
page.id,
|
|
5680
|
+
(p) => this.stateDb.getByPath(p)
|
|
5681
|
+
);
|
|
4912
5682
|
}
|
|
4913
5683
|
const finalContent = this.pipeline.convertToMarkdown(
|
|
4914
5684
|
markdown,
|
|
@@ -4976,8 +5746,7 @@ var DatabaseSyncer = class {
|
|
|
4976
5746
|
const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
|
|
4977
5747
|
this.propertyMapper.loadSchema(schema);
|
|
4978
5748
|
const allFiles = await this.vaultFs.listMarkdownFiles();
|
|
4979
|
-
const
|
|
4980
|
-
const dbFiles = allFiles.filter((f) => f.path.startsWith(prefix));
|
|
5749
|
+
const dbFiles = selectDbRowFiles(allFiles, dbConfig.localFolder);
|
|
4981
5750
|
const livePaths = new Set(dbFiles.map((f) => f.path));
|
|
4982
5751
|
const orphanByHash = this.buildOrphanHashIndex(dbConfig.databaseId, livePaths);
|
|
4983
5752
|
let created = 0;
|
|
@@ -4989,7 +5758,7 @@ var DatabaseSyncer = class {
|
|
|
4989
5758
|
const hash = computeHash(content);
|
|
4990
5759
|
const record = this.stateDb.getByPath(file.path);
|
|
4991
5760
|
if (record?.contentHash === hash) continue;
|
|
4992
|
-
const parsed =
|
|
5761
|
+
const parsed = matter(content);
|
|
4993
5762
|
const frontmatter = parsed.data;
|
|
4994
5763
|
const body = parsed.content;
|
|
4995
5764
|
const title = frontmatter.title ?? extractTitleFromPath(file.path);
|
|
@@ -5079,7 +5848,7 @@ var DatabaseSyncer = class {
|
|
|
5079
5848
|
}
|
|
5080
5849
|
}
|
|
5081
5850
|
getLogger().debug(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
|
|
5082
|
-
return { created, updated, conflicts: [], failed };
|
|
5851
|
+
return { created, updated, conflicts: [], failed, writtenPaths: [] };
|
|
5083
5852
|
}
|
|
5084
5853
|
/**
|
|
5085
5854
|
* rename 감지를 위한 고아 레코드 인덱스(내용 해시 → 레코드)를 만든다.
|
|
@@ -5110,82 +5879,381 @@ function extractTitleFromPath(filePath) {
|
|
|
5110
5879
|
return filename.replace(/\.md$/, "");
|
|
5111
5880
|
}
|
|
5112
5881
|
|
|
5113
|
-
// src/
|
|
5114
|
-
|
|
5115
|
-
const
|
|
5116
|
-
|
|
5117
|
-
const
|
|
5882
|
+
// src/conflict/merger.ts
|
|
5883
|
+
function threeWayMerge(base, local, remote) {
|
|
5884
|
+
const baseLines = base.split("\n");
|
|
5885
|
+
const localLines = local.split("\n");
|
|
5886
|
+
const remoteLines = remote.split("\n");
|
|
5887
|
+
const hunks = [
|
|
5888
|
+
...computeHunks(baseLines, localLines, 0),
|
|
5889
|
+
...computeHunks(baseLines, remoteLines, 2)
|
|
5890
|
+
];
|
|
5891
|
+
hunks.sort((a, b) => a.baseStart - b.baseStart || a.side - b.side);
|
|
5892
|
+
const merged = [];
|
|
5893
|
+
const conflicts = [];
|
|
5118
5894
|
let cursor = 0;
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5895
|
+
let i = 0;
|
|
5896
|
+
while (i < hunks.length) {
|
|
5897
|
+
const first = hunks[i];
|
|
5898
|
+
if (first.baseStart > cursor) {
|
|
5899
|
+
merged.push(...baseLines.slice(cursor, first.baseStart));
|
|
5900
|
+
}
|
|
5901
|
+
const regionStart = first.baseStart;
|
|
5902
|
+
let regionEnd = first.baseEnd;
|
|
5903
|
+
const group = [first];
|
|
5904
|
+
i++;
|
|
5905
|
+
while (i < hunks.length) {
|
|
5906
|
+
const next = hunks[i];
|
|
5907
|
+
const overlaps = next.baseStart < regionEnd || regionEnd === regionStart && next.baseStart === regionStart;
|
|
5908
|
+
if (!overlaps) break;
|
|
5909
|
+
regionEnd = Math.max(regionEnd, next.baseEnd);
|
|
5910
|
+
group.push(next);
|
|
5911
|
+
i++;
|
|
5912
|
+
}
|
|
5913
|
+
const baseRegion = baseLines.slice(regionStart, regionEnd);
|
|
5914
|
+
const localRegion = applyHunks(baseLines, group, 0, regionStart, regionEnd);
|
|
5915
|
+
const remoteRegion = applyHunks(baseLines, group, 2, regionStart, regionEnd);
|
|
5916
|
+
if (arraysEqual(localRegion, baseRegion)) {
|
|
5917
|
+
merged.push(...remoteRegion);
|
|
5918
|
+
} else if (arraysEqual(remoteRegion, baseRegion)) {
|
|
5919
|
+
merged.push(...localRegion);
|
|
5920
|
+
} else if (arraysEqual(localRegion, remoteRegion)) {
|
|
5921
|
+
merged.push(...localRegion);
|
|
5922
|
+
} else {
|
|
5923
|
+
conflicts.push({ localLines: localRegion, remoteLines: remoteRegion });
|
|
5924
|
+
merged.push("<<<<<<< LOCAL");
|
|
5925
|
+
merged.push(...localRegion);
|
|
5926
|
+
merged.push("=======");
|
|
5927
|
+
merged.push(...remoteRegion);
|
|
5928
|
+
merged.push(">>>>>>> REMOTE");
|
|
5128
5929
|
}
|
|
5930
|
+
cursor = regionEnd;
|
|
5931
|
+
}
|
|
5932
|
+
if (cursor < baseLines.length) {
|
|
5933
|
+
merged.push(...baseLines.slice(cursor));
|
|
5934
|
+
}
|
|
5935
|
+
return {
|
|
5936
|
+
success: conflicts.length === 0,
|
|
5937
|
+
merged: merged.join("\n"),
|
|
5938
|
+
conflicts
|
|
5129
5939
|
};
|
|
5130
|
-
await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
|
|
5131
5940
|
}
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
);
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5941
|
+
function computeHunks(base, modified, side) {
|
|
5942
|
+
const matches = lcsMatches(base, modified);
|
|
5943
|
+
const hunks = [];
|
|
5944
|
+
let prevBase = -1;
|
|
5945
|
+
let prevMod = -1;
|
|
5946
|
+
const emitGap = (curBase, curMod) => {
|
|
5947
|
+
const baseLen = curBase - (prevBase + 1);
|
|
5948
|
+
const modLen = curMod - (prevMod + 1);
|
|
5949
|
+
if (baseLen > 0 || modLen > 0) {
|
|
5950
|
+
hunks.push({
|
|
5951
|
+
baseStart: prevBase + 1,
|
|
5952
|
+
baseEnd: curBase,
|
|
5953
|
+
content: modified.slice(prevMod + 1, curMod),
|
|
5954
|
+
side
|
|
5955
|
+
});
|
|
5956
|
+
}
|
|
5957
|
+
};
|
|
5958
|
+
for (const [b, m] of matches) {
|
|
5959
|
+
emitGap(b, m);
|
|
5960
|
+
prevBase = b;
|
|
5961
|
+
prevMod = m;
|
|
5962
|
+
}
|
|
5963
|
+
emitGap(base.length, modified.length);
|
|
5964
|
+
return hunks;
|
|
5965
|
+
}
|
|
5966
|
+
function applyHunks(baseLines, group, side, regionStart, regionEnd) {
|
|
5967
|
+
const out = [];
|
|
5968
|
+
let b = regionStart;
|
|
5969
|
+
for (const hunk of group) {
|
|
5970
|
+
if (hunk.side !== side) continue;
|
|
5971
|
+
if (hunk.baseStart > b) out.push(...baseLines.slice(b, hunk.baseStart));
|
|
5972
|
+
out.push(...hunk.content);
|
|
5973
|
+
b = hunk.baseEnd;
|
|
5974
|
+
}
|
|
5975
|
+
if (b < regionEnd) out.push(...baseLines.slice(b, regionEnd));
|
|
5976
|
+
return out;
|
|
5977
|
+
}
|
|
5978
|
+
function lcsMatches(a, b) {
|
|
5979
|
+
const n = a.length;
|
|
5980
|
+
const m = b.length;
|
|
5981
|
+
const dp = new Array(n + 1);
|
|
5982
|
+
for (let i2 = 0; i2 <= n; i2++) dp[i2] = new Uint32Array(m + 1);
|
|
5983
|
+
for (let i2 = n - 1; i2 >= 0; i2--) {
|
|
5984
|
+
const row = dp[i2];
|
|
5985
|
+
const next = dp[i2 + 1];
|
|
5986
|
+
const ai = a[i2];
|
|
5987
|
+
for (let j2 = m - 1; j2 >= 0; j2--) {
|
|
5988
|
+
row[j2] = ai === b[j2] ? next[j2 + 1] + 1 : Math.max(next[j2], row[j2 + 1]);
|
|
5989
|
+
}
|
|
5990
|
+
}
|
|
5991
|
+
const matches = [];
|
|
5992
|
+
let i = 0;
|
|
5993
|
+
let j = 0;
|
|
5994
|
+
while (i < n && j < m) {
|
|
5995
|
+
if (a[i] === b[j]) {
|
|
5996
|
+
matches.push([i, j]);
|
|
5997
|
+
i++;
|
|
5998
|
+
j++;
|
|
5999
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
6000
|
+
i++;
|
|
6001
|
+
} else {
|
|
6002
|
+
j++;
|
|
6003
|
+
}
|
|
6004
|
+
}
|
|
6005
|
+
return matches;
|
|
6006
|
+
}
|
|
6007
|
+
function arraysEqual(a, b) {
|
|
6008
|
+
if (a.length !== b.length) return false;
|
|
6009
|
+
return a.every((val, idx) => val === b[idx]);
|
|
6010
|
+
}
|
|
6011
|
+
|
|
6012
|
+
// src/conflict/resolver.ts
|
|
6013
|
+
var ConflictResolver = class {
|
|
6014
|
+
constructor(stateDb, vaultFs) {
|
|
6015
|
+
this.stateDb = stateDb;
|
|
6016
|
+
this.vaultFs = vaultFs;
|
|
6017
|
+
}
|
|
6018
|
+
stateDb;
|
|
6019
|
+
vaultFs;
|
|
6020
|
+
async resolve(conflict, choice) {
|
|
6021
|
+
switch (choice) {
|
|
6022
|
+
case "local":
|
|
6023
|
+
return this.resolveLocal(conflict);
|
|
6024
|
+
case "remote":
|
|
6025
|
+
return this.resolveRemote(conflict);
|
|
6026
|
+
case "merge":
|
|
6027
|
+
return this.resolveMerge(conflict);
|
|
6028
|
+
case "duplicate":
|
|
6029
|
+
return this.resolveDuplicate(conflict);
|
|
6030
|
+
}
|
|
6031
|
+
}
|
|
6032
|
+
async resolveByStrategy(conflict, strategy) {
|
|
6033
|
+
switch (strategy) {
|
|
6034
|
+
case "local-first":
|
|
6035
|
+
return this.resolve(conflict, "local");
|
|
6036
|
+
case "remote-first":
|
|
6037
|
+
return this.resolve(conflict, "remote");
|
|
6038
|
+
case "duplicate":
|
|
6039
|
+
return this.resolve(conflict, "duplicate");
|
|
6040
|
+
case "manual":
|
|
6041
|
+
return this.resolve(conflict, "merge");
|
|
6042
|
+
}
|
|
6043
|
+
}
|
|
6044
|
+
async resolveAll(conflicts, strategy) {
|
|
6045
|
+
const results = [];
|
|
6046
|
+
for (const conflict of conflicts) {
|
|
6047
|
+
const result = await this.resolveByStrategy(conflict, strategy);
|
|
6048
|
+
results.push(result);
|
|
6049
|
+
}
|
|
6050
|
+
return results;
|
|
6051
|
+
}
|
|
6052
|
+
async resolveLocal(conflict) {
|
|
6053
|
+
const hash = computeHash(conflict.localContent);
|
|
6054
|
+
this.stateDb.updateHash(
|
|
6055
|
+
conflict.syncRecord.id,
|
|
6056
|
+
hash,
|
|
6057
|
+
Buffer.from(conflict.localContent, "utf-8")
|
|
6058
|
+
);
|
|
6059
|
+
this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
|
|
6060
|
+
return { path: conflict.syncRecord.obsidianPath, choice: "local", success: true };
|
|
6061
|
+
}
|
|
6062
|
+
async resolveRemote(conflict) {
|
|
6063
|
+
await this.vaultFs.writeFile(conflict.syncRecord.obsidianPath, conflict.remoteContent);
|
|
6064
|
+
const hash = computeHash(conflict.remoteContent);
|
|
6065
|
+
this.stateDb.updateHash(
|
|
6066
|
+
conflict.syncRecord.id,
|
|
6067
|
+
hash,
|
|
6068
|
+
Buffer.from(conflict.remoteContent, "utf-8")
|
|
6069
|
+
);
|
|
6070
|
+
this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
|
|
6071
|
+
return { path: conflict.syncRecord.obsidianPath, choice: "remote", success: true };
|
|
6072
|
+
}
|
|
6073
|
+
async resolveMerge(conflict) {
|
|
6074
|
+
const base = conflict.baseContent ?? "";
|
|
6075
|
+
const mergeResult = threeWayMerge(base, conflict.localContent, conflict.remoteContent);
|
|
6076
|
+
await this.vaultFs.writeFile(conflict.syncRecord.obsidianPath, mergeResult.merged);
|
|
6077
|
+
const hash = computeHash(mergeResult.merged);
|
|
6078
|
+
this.stateDb.updateHash(conflict.syncRecord.id, hash, Buffer.from(mergeResult.merged, "utf-8"));
|
|
6079
|
+
if (mergeResult.success) {
|
|
6080
|
+
this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
|
|
6081
|
+
}
|
|
6082
|
+
return {
|
|
6083
|
+
path: conflict.syncRecord.obsidianPath,
|
|
6084
|
+
choice: "merge",
|
|
6085
|
+
success: mergeResult.success,
|
|
6086
|
+
mergeHadConflicts: !mergeResult.success
|
|
6087
|
+
};
|
|
6088
|
+
}
|
|
6089
|
+
async resolveDuplicate(conflict) {
|
|
6090
|
+
const originalPath = conflict.syncRecord.obsidianPath;
|
|
6091
|
+
const ext = originalPath.endsWith(".md") ? ".md" : "";
|
|
6092
|
+
const baseName = originalPath.replace(/\.md$/, "");
|
|
6093
|
+
const conflictPath = `${baseName}.conflict${ext}`;
|
|
6094
|
+
await this.vaultFs.writeFile(conflictPath, conflict.remoteContent);
|
|
6095
|
+
const localHash = computeHash(conflict.localContent);
|
|
6096
|
+
this.stateDb.updateHash(
|
|
6097
|
+
conflict.syncRecord.id,
|
|
6098
|
+
localHash,
|
|
6099
|
+
Buffer.from(conflict.localContent, "utf-8")
|
|
6100
|
+
);
|
|
6101
|
+
this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
|
|
6102
|
+
return { path: originalPath, choice: "duplicate", success: true };
|
|
6103
|
+
}
|
|
6104
|
+
generateDiff(conflict) {
|
|
6105
|
+
const localLines = conflict.localContent.split("\n");
|
|
6106
|
+
const remoteLines = conflict.remoteContent.split("\n");
|
|
6107
|
+
const lines = [];
|
|
6108
|
+
lines.push(`--- local: ${conflict.syncRecord.obsidianPath}`);
|
|
6109
|
+
lines.push(`+++ remote: Notion (${conflict.remoteChange.pageId})`);
|
|
6110
|
+
lines.push("");
|
|
6111
|
+
const maxLen = Math.max(localLines.length, remoteLines.length);
|
|
6112
|
+
for (let i = 0; i < maxLen; i++) {
|
|
6113
|
+
const localLine = localLines[i];
|
|
6114
|
+
const remoteLine = remoteLines[i];
|
|
6115
|
+
if (localLine === remoteLine) {
|
|
6116
|
+
lines.push(` ${localLine ?? ""}`);
|
|
6117
|
+
} else {
|
|
6118
|
+
if (localLine !== void 0) lines.push(`- ${localLine}`);
|
|
6119
|
+
if (remoteLine !== void 0) lines.push(`+ ${remoteLine}`);
|
|
6120
|
+
}
|
|
6121
|
+
}
|
|
6122
|
+
return lines.join("\n");
|
|
6123
|
+
}
|
|
6124
|
+
};
|
|
6125
|
+
|
|
6126
|
+
// src/utils/pool.ts
|
|
6127
|
+
async function runPool(items, worker, options) {
|
|
6128
|
+
const total = items.length;
|
|
6129
|
+
if (total === 0) return;
|
|
6130
|
+
const workerCount = Math.max(1, Math.min(Math.floor(options.concurrency) || 1, total));
|
|
6131
|
+
let cursor = 0;
|
|
6132
|
+
const runWorker = async () => {
|
|
6133
|
+
for (; ; ) {
|
|
6134
|
+
if (options.signal?.aborted) return;
|
|
6135
|
+
const index = cursor++;
|
|
6136
|
+
if (index >= total) return;
|
|
6137
|
+
try {
|
|
6138
|
+
await worker(items[index], index);
|
|
6139
|
+
} catch {
|
|
6140
|
+
}
|
|
6141
|
+
}
|
|
6142
|
+
};
|
|
6143
|
+
await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
|
|
6144
|
+
}
|
|
6145
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$|^[0-9a-f]{32}$/i;
|
|
6146
|
+
function resolveFrontmatterRelations(content, idToTitle) {
|
|
6147
|
+
if (!content.startsWith("---") || idToTitle.size === 0) {
|
|
6148
|
+
return { content, count: 0 };
|
|
6149
|
+
}
|
|
6150
|
+
let parsed;
|
|
6151
|
+
try {
|
|
6152
|
+
parsed = matter(content);
|
|
6153
|
+
} catch {
|
|
6154
|
+
return { content, count: 0 };
|
|
6155
|
+
}
|
|
6156
|
+
const data = parsed.data;
|
|
6157
|
+
if (!data || Object.keys(data).length === 0) {
|
|
6158
|
+
return { content, count: 0 };
|
|
6159
|
+
}
|
|
6160
|
+
let count = 0;
|
|
6161
|
+
const resolveValue = (value) => {
|
|
6162
|
+
if (typeof value === "string") {
|
|
6163
|
+
if (!UUID_RE.test(value)) return value;
|
|
6164
|
+
const title = idToTitle.get(value.replace(/-/g, ""));
|
|
6165
|
+
if (!title) return value;
|
|
6166
|
+
count++;
|
|
6167
|
+
return `[[${title}]]`;
|
|
6168
|
+
}
|
|
6169
|
+
if (Array.isArray(value)) return value.map(resolveValue);
|
|
6170
|
+
return value;
|
|
6171
|
+
};
|
|
6172
|
+
const resolved = {};
|
|
6173
|
+
for (const key of Object.keys(data)) {
|
|
6174
|
+
resolved[key] = resolveValue(data[key]);
|
|
6175
|
+
}
|
|
6176
|
+
if (count === 0) return { content, count: 0 };
|
|
6177
|
+
return { content: stringifyFrontmatter(parsed.content, resolved), count };
|
|
6178
|
+
}
|
|
6179
|
+
|
|
6180
|
+
// src/sync/orchestrator.ts
|
|
6181
|
+
var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
|
|
6182
|
+
function strategyToChoice(strategy) {
|
|
6183
|
+
switch (strategy) {
|
|
6184
|
+
case "local-first":
|
|
6185
|
+
return "local";
|
|
6186
|
+
case "remote-first":
|
|
6187
|
+
return "remote";
|
|
6188
|
+
case "duplicate":
|
|
6189
|
+
return "duplicate";
|
|
6190
|
+
case "manual":
|
|
6191
|
+
return "merge";
|
|
6192
|
+
}
|
|
6193
|
+
}
|
|
6194
|
+
var SyncOrchestrator = class _SyncOrchestrator {
|
|
6195
|
+
constructor(config, stateDb, notionClient, vaultFs, customFetch) {
|
|
6196
|
+
this.config = config;
|
|
6197
|
+
this.stateDb = stateDb;
|
|
6198
|
+
this.notionClient = notionClient;
|
|
6199
|
+
this.vaultFs = vaultFs;
|
|
6200
|
+
this.changeDetector = new ChangeDetector(stateDb);
|
|
6201
|
+
this.pipeline = createDefaultPipeline({
|
|
6202
|
+
wikilinkResolver: (text) => stateDb.resolveWikilink(text)
|
|
6203
|
+
});
|
|
6204
|
+
this.blockConverter = new BlockConverter();
|
|
6205
|
+
this.imageHandler = new ImageHandler(
|
|
6206
|
+
vaultFs,
|
|
6207
|
+
config.paths.attachments,
|
|
6208
|
+
notionClient,
|
|
6209
|
+
customFetch,
|
|
6210
|
+
{
|
|
6211
|
+
concurrency: config.advanced.mediaConcurrency,
|
|
6212
|
+
maxRetries: config.advanced.mediaMaxRetries,
|
|
6213
|
+
retryBaseMs: config.advanced.mediaRetryBaseMs,
|
|
6214
|
+
maxFileSizeBytes: config.advanced.maxFileSizeBytes
|
|
6215
|
+
},
|
|
6216
|
+
stateDb
|
|
6217
|
+
);
|
|
6218
|
+
this.fileHandler = new FileHandler(
|
|
6219
|
+
vaultFs,
|
|
6220
|
+
notionClient,
|
|
6221
|
+
stateDb,
|
|
6222
|
+
config.advanced.fileConcurrency
|
|
6223
|
+
);
|
|
6224
|
+
this.propertyMapper = new PropertyMapper();
|
|
6225
|
+
this.databaseSyncer = new DatabaseSyncer(
|
|
6226
|
+
config,
|
|
6227
|
+
stateDb,
|
|
6228
|
+
notionClient,
|
|
6229
|
+
vaultFs,
|
|
6230
|
+
this.pipeline,
|
|
6231
|
+
this.imageHandler
|
|
6232
|
+
);
|
|
6233
|
+
const wikilinkResolver = {
|
|
6234
|
+
resolve: (title) => stateDb.resolveWikilink(title)?.notionPageId ?? null,
|
|
6235
|
+
resolvePageId: (pageId) => {
|
|
6236
|
+
const entry = stateDb.resolvePageId(pageId);
|
|
6237
|
+
return entry ? wikilinkTitleFromPath(entry.obsidianPath) : null;
|
|
6238
|
+
}
|
|
6239
|
+
};
|
|
6240
|
+
this.propertyMapper.setWikilinkResolver(wikilinkResolver);
|
|
6241
|
+
this.notionClient.setWikilinkResolver(wikilinkResolver);
|
|
6242
|
+
this.blockConverter.initNotionToMd(this.notionClient.getInternalClient());
|
|
6243
|
+
this.conflictResolver = new ConflictResolver(stateDb, vaultFs);
|
|
6244
|
+
}
|
|
6245
|
+
config;
|
|
6246
|
+
stateDb;
|
|
6247
|
+
notionClient;
|
|
6248
|
+
vaultFs;
|
|
6249
|
+
changeDetector;
|
|
6250
|
+
pipeline;
|
|
6251
|
+
blockConverter;
|
|
5185
6252
|
imageHandler;
|
|
5186
6253
|
fileHandler;
|
|
5187
6254
|
databaseSyncer;
|
|
5188
6255
|
propertyMapper;
|
|
6256
|
+
conflictResolver;
|
|
5189
6257
|
dbSchemaLoaded = false;
|
|
5190
6258
|
_pullImageCount = 0;
|
|
5191
6259
|
_pullFileCount = 0;
|
|
@@ -5194,6 +6262,16 @@ var SyncOrchestrator = class {
|
|
|
5194
6262
|
// 블록 트리 재귀 없이 markdown 신호만으로 컬럼/synced_block 등 깊이 중첩된
|
|
5195
6263
|
// child_database 까지 발견해 폴더+.base 동기화 대상으로 등록한다.
|
|
5196
6264
|
_inlineDbRefs = /* @__PURE__ */ new Map();
|
|
6265
|
+
// pull 발견 단계에서 모든 페이지의 부모를 해소하며 채우는 "자식을 가진 페이지" 집합
|
|
6266
|
+
// (정규화된 page id). 폴더노트/폴더 판정의 단일 신뢰 원천 — 얕은 블록 검사로 callout·
|
|
6267
|
+
// column 등 컨테이너에 중첩된 자식 페이지를 놓쳐 폴더노트를 file 로 오분류하던 결함
|
|
6268
|
+
// (본문이 최상위로 밀려 ' (1).md' 로 분리)을 차단한다. detectRemoteChanges* 진입 시 재구성.
|
|
6269
|
+
_childParentIds = /* @__PURE__ */ new Set();
|
|
6270
|
+
// 서브트리 직접 순회(getChildPagesRecursive) 시간 예산. 초과하면 search 기반 디스커버리로
|
|
6271
|
+
// 폴백한다. 분기점 근거: 워크스페이스 search 열거는 latency-bound 로 대략 이 수준(수천 페이지
|
|
6272
|
+
// 워크스페이스에서 ~100s)이므로, 순회가 이 시간을 넘기면 search 가 더 저렴해진다. 작은 볼트는
|
|
6273
|
+
// 이 예산 안에서 순회가 끝나 폴백 없이 빠르게 완료된다. (단위: ms)
|
|
6274
|
+
static DISCOVERY_RECURSIVE_BUDGET_MS = 9e4;
|
|
5197
6275
|
async push(options) {
|
|
5198
6276
|
const startTime = Date.now();
|
|
5199
6277
|
if (this.config.sync.direction === "pull") {
|
|
@@ -5201,6 +6279,7 @@ var SyncOrchestrator = class {
|
|
|
5201
6279
|
}
|
|
5202
6280
|
this.cleanupInterruptedSync();
|
|
5203
6281
|
this.repairFolderRecords();
|
|
6282
|
+
await this.recoverInterruptedPushOps();
|
|
5204
6283
|
const stats = await this.vaultFs.listMarkdownFileStats();
|
|
5205
6284
|
const changes = await this.changeDetector.detectLocalChangesFast(
|
|
5206
6285
|
stats,
|
|
@@ -5271,10 +6350,11 @@ var SyncOrchestrator = class {
|
|
|
5271
6350
|
await this.pushMove(change.path);
|
|
5272
6351
|
counts.updated++;
|
|
5273
6352
|
break;
|
|
5274
|
-
case "deleted":
|
|
5275
|
-
await this.pushDelete(change.path);
|
|
5276
|
-
counts.deleted++;
|
|
6353
|
+
case "deleted": {
|
|
6354
|
+
const propagated = await this.pushDelete(change.path);
|
|
6355
|
+
if (propagated) counts.deleted++;
|
|
5277
6356
|
break;
|
|
6357
|
+
}
|
|
5278
6358
|
}
|
|
5279
6359
|
};
|
|
5280
6360
|
const retryQueue = [];
|
|
@@ -5338,6 +6418,7 @@ var SyncOrchestrator = class {
|
|
|
5338
6418
|
this.stateDb.setMeta("last_push_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
5339
6419
|
this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
5340
6420
|
this.stateDb.setMeta("push_in_progress", "");
|
|
6421
|
+
this.stateDb.clearCompletedOperations();
|
|
5341
6422
|
return {
|
|
5342
6423
|
created: counts.created,
|
|
5343
6424
|
updated: counts.updated,
|
|
@@ -5371,36 +6452,35 @@ var SyncOrchestrator = class {
|
|
|
5371
6452
|
const conflicts = [];
|
|
5372
6453
|
const writtenPaths = [];
|
|
5373
6454
|
const failed = [];
|
|
5374
|
-
const
|
|
5375
|
-
|
|
5376
|
-
const remoteChanges = !this.isDatabaseMode && lastPull && trackedCount > 0 && !options?.force ? await this.detectRemoteChangesIncremental(lastPull) : await this.detectRemoteChanges();
|
|
5377
|
-
const filtered = options?.paths ? remoteChanges.filter((c) => {
|
|
5378
|
-
const record = this.stateDb.getByNotionId(c.pageId);
|
|
5379
|
-
return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
|
|
5380
|
-
}) : remoteChanges;
|
|
5381
|
-
if (filtered.length === 0 && (this.config.notion.databases?.length ?? 0) === 0) {
|
|
5382
|
-
const hasDbResults = await this.pullDiscoveredDatabases(writtenPaths, failed, conflicts);
|
|
5383
|
-
if (!hasDbResults.created && !hasDbResults.updated) {
|
|
5384
|
-
this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
5385
|
-
this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
5386
|
-
this.stateDb.setMeta("pull_in_progress", "");
|
|
5387
|
-
return emptyResult;
|
|
5388
|
-
}
|
|
6455
|
+
const finalize = async (created, updated, deleted) => {
|
|
6456
|
+
const linkCount = writtenPaths.length > 0 ? await this.resolveNotionLinks(writtenPaths) : 0;
|
|
5389
6457
|
this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
5390
6458
|
this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
5391
6459
|
this.stateDb.setMeta("pull_in_progress", "");
|
|
5392
6460
|
return {
|
|
5393
|
-
created
|
|
5394
|
-
updated
|
|
5395
|
-
deleted
|
|
6461
|
+
created,
|
|
6462
|
+
updated,
|
|
6463
|
+
deleted,
|
|
5396
6464
|
conflicts,
|
|
5397
6465
|
writtenPaths,
|
|
5398
6466
|
failed,
|
|
5399
6467
|
duration: Date.now() - startTime,
|
|
5400
|
-
imageCount:
|
|
5401
|
-
fileCount:
|
|
5402
|
-
linkCount
|
|
6468
|
+
imageCount: this._pullImageCount,
|
|
6469
|
+
fileCount: this._pullFileCount,
|
|
6470
|
+
linkCount
|
|
5403
6471
|
};
|
|
6472
|
+
};
|
|
6473
|
+
const lastPull = this.stateDb.getMeta("last_pull_at") ?? this.stateDb.getMeta("last_sync_at");
|
|
6474
|
+
const trackedCount = this.stateDb.getAll().length;
|
|
6475
|
+
const canUseIncremental = !this.isDatabaseMode && !!lastPull && trackedCount > 0 && !options?.force && !this.config.sync.deleteSync;
|
|
6476
|
+
const remoteChanges = canUseIncremental ? await this.detectRemoteChangesIncremental(lastPull) : await this.detectRemoteChanges();
|
|
6477
|
+
const filtered = options?.paths ? remoteChanges.filter((c) => {
|
|
6478
|
+
const record = this.stateDb.getByNotionId(c.pageId);
|
|
6479
|
+
return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
|
|
6480
|
+
}) : remoteChanges;
|
|
6481
|
+
if (filtered.length === 0 && (this.config.notion.databases?.length ?? 0) === 0) {
|
|
6482
|
+
const dbDiscovery = await this.pullDiscoveredDatabases(writtenPaths, failed, conflicts);
|
|
6483
|
+
return finalize(dbDiscovery.created, dbDiscovery.updated, 0);
|
|
5404
6484
|
}
|
|
5405
6485
|
if (filtered.length === 0) {
|
|
5406
6486
|
return emptyResult;
|
|
@@ -5453,7 +6533,7 @@ var SyncOrchestrator = class {
|
|
|
5453
6533
|
const result = await this.pullUpdate(change);
|
|
5454
6534
|
if (result.conflict) {
|
|
5455
6535
|
conflicts.push(result.conflict);
|
|
5456
|
-
} else if (result.path) {
|
|
6536
|
+
} else if (result.path && !result.unchanged) {
|
|
5457
6537
|
writtenPaths.push(result.path);
|
|
5458
6538
|
counts.updated++;
|
|
5459
6539
|
}
|
|
@@ -5520,10 +6600,7 @@ var SyncOrchestrator = class {
|
|
|
5520
6600
|
counts.updated += dbResult.updated;
|
|
5521
6601
|
conflicts.push(...dbResult.conflicts);
|
|
5522
6602
|
failed.push(...dbResult.failed);
|
|
5523
|
-
|
|
5524
|
-
const dbPaths = this.stateDb.getByStatus("synced").filter((r) => r.fileType === "db-row").map((r) => r.obsidianPath);
|
|
5525
|
-
writtenPaths.push(...dbPaths.slice(-dbResult.created - dbResult.updated));
|
|
5526
|
-
}
|
|
6603
|
+
writtenPaths.push(...dbResult.writtenPaths);
|
|
5527
6604
|
} catch (error) {
|
|
5528
6605
|
getLogger().warn("[Im-Nobsidian] DB Pull \uC911 \uC624\uB958:", error);
|
|
5529
6606
|
}
|
|
@@ -5533,23 +6610,7 @@ var SyncOrchestrator = class {
|
|
|
5533
6610
|
counts.created += dbDiscovery.created;
|
|
5534
6611
|
counts.updated += dbDiscovery.updated;
|
|
5535
6612
|
}
|
|
5536
|
-
|
|
5537
|
-
const linkCount = linkTargetPaths.length > 0 ? await this.resolveNotionLinks(linkTargetPaths) : 0;
|
|
5538
|
-
this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
5539
|
-
this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
5540
|
-
this.stateDb.setMeta("pull_in_progress", "");
|
|
5541
|
-
return {
|
|
5542
|
-
created: counts.created,
|
|
5543
|
-
updated: counts.updated,
|
|
5544
|
-
deleted: counts.deleted,
|
|
5545
|
-
conflicts,
|
|
5546
|
-
writtenPaths,
|
|
5547
|
-
failed,
|
|
5548
|
-
duration: Date.now() - startTime,
|
|
5549
|
-
imageCount: this._pullImageCount,
|
|
5550
|
-
fileCount: this._pullFileCount,
|
|
5551
|
-
linkCount
|
|
5552
|
-
};
|
|
6613
|
+
return finalize(counts.created, counts.updated, counts.deleted);
|
|
5553
6614
|
}
|
|
5554
6615
|
async sync(options) {
|
|
5555
6616
|
const startTime = Date.now();
|
|
@@ -5570,7 +6631,7 @@ var SyncOrchestrator = class {
|
|
|
5570
6631
|
const files = await this.vaultFs.listMarkdownFiles();
|
|
5571
6632
|
const localChanges = this.changeDetector.detectLocalChanges(files);
|
|
5572
6633
|
const lastSyncAt = this.stateDb.getMeta("last_sync_at");
|
|
5573
|
-
const remoteChanges = lastSyncAt ? await this.detectRemoteChangesIncremental(lastSyncAt) : await this.detectRemoteChanges();
|
|
6634
|
+
const remoteChanges = lastSyncAt && !this.config.sync.deleteSync ? await this.detectRemoteChangesIncremental(lastSyncAt) : await this.detectRemoteChanges();
|
|
5574
6635
|
const conflictRecords = this.stateDb.getByStatus("conflict");
|
|
5575
6636
|
const conflicts = await this.buildConflictsFromRecords(
|
|
5576
6637
|
conflictRecords,
|
|
@@ -5681,37 +6742,57 @@ var SyncOrchestrator = class {
|
|
|
5681
6742
|
* - 제목 취득 실패 시 부모 폴더명 기반 안전 이름으로 폴백
|
|
5682
6743
|
*/
|
|
5683
6744
|
async buildDiscoveredDbConfig(dbId, parentPageId) {
|
|
6745
|
+
let dbTitle;
|
|
5684
6746
|
try {
|
|
5685
|
-
const
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
if (parentEntry?.obsidianPath) {
|
|
5689
|
-
const obsPath = parentEntry.obsidianPath;
|
|
5690
|
-
if (obsPath.endsWith(".md")) {
|
|
5691
|
-
const parts = obsPath.split("/");
|
|
5692
|
-
parts.pop();
|
|
5693
|
-
parentFolder = parts.length > 0 ? parts.join("/") : obsPath.replace(/\.md$/, "");
|
|
5694
|
-
} else {
|
|
5695
|
-
parentFolder = obsPath;
|
|
5696
|
-
}
|
|
6747
|
+
const info = await this.notionClient.getDatabaseSyncability(dbId);
|
|
6748
|
+
if (!info.queryable) {
|
|
6749
|
+
return { kind: "inaccessible" };
|
|
5697
6750
|
}
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
6751
|
+
dbTitle = info.title;
|
|
6752
|
+
} catch (error) {
|
|
6753
|
+
if (isNotionObjectNotFound(error)) return { kind: "inaccessible" };
|
|
6754
|
+
getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uC124\uC815 \uC0DD\uC131 \uC2E4\uD328:`, error);
|
|
6755
|
+
return { kind: "error" };
|
|
6756
|
+
}
|
|
6757
|
+
const parentEntry = parentPageId ? this.stateDb.getByNotionId(parentPageId) : null;
|
|
6758
|
+
let parentFolder = "";
|
|
6759
|
+
if (parentEntry?.obsidianPath) {
|
|
6760
|
+
const obsPath = parentEntry.obsidianPath;
|
|
6761
|
+
if (obsPath.endsWith(".md")) {
|
|
6762
|
+
const parts = obsPath.split("/");
|
|
6763
|
+
parts.pop();
|
|
6764
|
+
parentFolder = parts.length > 0 ? parts.join("/") : obsPath.replace(/\.md$/, "");
|
|
5701
6765
|
} else {
|
|
5702
|
-
|
|
5703
|
-
safeName = parentName ? `${parentName}-DB` : `db-${dbId.replace(/-/g, "").slice(0, 8)}`;
|
|
6766
|
+
parentFolder = obsPath;
|
|
5704
6767
|
}
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
6768
|
+
}
|
|
6769
|
+
let safeName;
|
|
6770
|
+
if (dbTitle) {
|
|
6771
|
+
safeName = dbTitle.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-");
|
|
6772
|
+
} else {
|
|
6773
|
+
const parentName = parentFolder.split("/").pop() || "";
|
|
6774
|
+
safeName = parentName ? `${parentName}-DB` : `db-${dbId.replace(/-/g, "").slice(0, 8)}`;
|
|
6775
|
+
}
|
|
6776
|
+
if (!safeName) safeName = `db-${dbId.replace(/-/g, "").slice(0, 8)}`;
|
|
6777
|
+
if (!parentFolder) parentFolder = "databases";
|
|
6778
|
+
return {
|
|
6779
|
+
kind: "ok",
|
|
6780
|
+
config: {
|
|
5708
6781
|
databaseId: dbId,
|
|
5709
6782
|
localFolder: `${parentFolder}/${safeName}`,
|
|
5710
6783
|
titleProperty: "Name"
|
|
5711
|
-
}
|
|
5712
|
-
}
|
|
5713
|
-
|
|
5714
|
-
|
|
6784
|
+
}
|
|
6785
|
+
};
|
|
6786
|
+
}
|
|
6787
|
+
/** 접근 불가 DB denylist 를 상태 메타에서 로드한다(하이픈 정규화). */
|
|
6788
|
+
loadInaccessibleDbIds() {
|
|
6789
|
+
const raw = this.stateDb.getMeta(INACCESSIBLE_DBS_META_KEY);
|
|
6790
|
+
if (!raw) return /* @__PURE__ */ new Set();
|
|
6791
|
+
try {
|
|
6792
|
+
const arr = JSON.parse(raw);
|
|
6793
|
+
return new Set(arr.map((id) => id.replace(/-/g, "")));
|
|
6794
|
+
} catch {
|
|
6795
|
+
return /* @__PURE__ */ new Set();
|
|
5715
6796
|
}
|
|
5716
6797
|
}
|
|
5717
6798
|
async pullDiscoveredDatabases(writtenPaths, failed, conflicts) {
|
|
@@ -5736,46 +6817,71 @@ var SyncOrchestrator = class {
|
|
|
5736
6817
|
...dbConfigs.map((c) => c.databaseId.replace(/-/g, ""))
|
|
5737
6818
|
]);
|
|
5738
6819
|
let changed = false;
|
|
6820
|
+
const inaccessibleIds = this.loadInaccessibleDbIds();
|
|
6821
|
+
let inaccessibleChanged = false;
|
|
6822
|
+
const degrade = (rawId) => {
|
|
6823
|
+
const nohyph = rawId.replace(/-/g, "");
|
|
6824
|
+
if (!inaccessibleIds.has(nohyph)) {
|
|
6825
|
+
inaccessibleIds.add(nohyph);
|
|
6826
|
+
inaccessibleChanged = true;
|
|
6827
|
+
}
|
|
6828
|
+
getLogger().info(
|
|
6829
|
+
`[Im-Nobsidian] DB ${rawId}: \uC811\uADFC \uAC00\uB2A5\uD55C data source \uC5C6\uC74C(\uB9C1\uD06C\uB4DC/\uBBF8\uACF5\uC720/\uC0AD\uC81C \uCD94\uC815) \u2014 \uB3D9\uAE30\uD654 \uB300\uC0C1\uC5D0\uC11C \uC81C\uC678(\uC81C\uBAA9\uC740 \uBD80\uBAA8 \uD398\uC774\uC9C0\uC5D0 \uBCF4\uC874)`
|
|
6830
|
+
);
|
|
6831
|
+
};
|
|
5739
6832
|
if (dbConfigs.length === 0) {
|
|
5740
6833
|
const discovered = await this.discoverChildDatabases();
|
|
5741
6834
|
for (const { dbId, parentPageId } of discovered) {
|
|
5742
6835
|
const nohyph = dbId.replace(/-/g, "");
|
|
5743
|
-
if (knownIds.has(nohyph)) continue;
|
|
5744
|
-
const
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
6836
|
+
if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) continue;
|
|
6837
|
+
const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
|
|
6838
|
+
knownIds.add(nohyph);
|
|
6839
|
+
if (outcome.kind === "ok") {
|
|
6840
|
+
dbConfigs.push(outcome.config);
|
|
5748
6841
|
changed = true;
|
|
6842
|
+
} else if (outcome.kind === "inaccessible") {
|
|
6843
|
+
degrade(dbId);
|
|
5749
6844
|
}
|
|
5750
6845
|
}
|
|
5751
6846
|
}
|
|
5752
6847
|
for (const [nohyph, parentPageId] of this._inlineDbRefs) {
|
|
5753
|
-
if (knownIds.has(nohyph)) continue;
|
|
5754
|
-
const
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
6848
|
+
if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) continue;
|
|
6849
|
+
const dbId = normalizeNotionId(nohyph);
|
|
6850
|
+
const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
|
|
6851
|
+
knownIds.add(nohyph);
|
|
6852
|
+
if (outcome.kind === "ok") {
|
|
6853
|
+
dbConfigs.push(outcome.config);
|
|
5758
6854
|
changed = true;
|
|
6855
|
+
} else if (outcome.kind === "inaccessible") {
|
|
6856
|
+
degrade(dbId);
|
|
5759
6857
|
}
|
|
5760
6858
|
}
|
|
5761
|
-
|
|
5762
|
-
this.stateDb.setMeta("discovered_dbs", JSON.stringify(dbConfigs));
|
|
5763
|
-
}
|
|
6859
|
+
const stillSyncable = [];
|
|
5764
6860
|
for (const dbConfig of dbConfigs) {
|
|
6861
|
+
if (inaccessibleIds.has(dbConfig.databaseId.replace(/-/g, ""))) continue;
|
|
5765
6862
|
try {
|
|
5766
6863
|
const dbResult = await this.databaseSyncer.pullDatabase(dbConfig);
|
|
5767
6864
|
created += dbResult.created;
|
|
5768
6865
|
updated += dbResult.updated;
|
|
5769
6866
|
conflicts.push(...dbResult.conflicts);
|
|
5770
6867
|
failed.push(...dbResult.failed);
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
writtenPaths.push(...dbPaths.slice(-dbResult.created - dbResult.updated));
|
|
5774
|
-
}
|
|
6868
|
+
writtenPaths.push(...dbResult.writtenPaths);
|
|
6869
|
+
stillSyncable.push(dbConfig);
|
|
5775
6870
|
} catch (error) {
|
|
5776
|
-
|
|
6871
|
+
if (isNotionObjectNotFound(error)) {
|
|
6872
|
+
degrade(dbConfig.databaseId);
|
|
6873
|
+
} else {
|
|
6874
|
+
getLogger().warn(`[Im-Nobsidian] DB ${dbConfig.databaseId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
|
|
6875
|
+
stillSyncable.push(dbConfig);
|
|
6876
|
+
}
|
|
5777
6877
|
}
|
|
5778
6878
|
}
|
|
6879
|
+
if (changed || inaccessibleChanged || stillSyncable.length !== dbConfigs.length) {
|
|
6880
|
+
this.stateDb.setMeta("discovered_dbs", JSON.stringify(stillSyncable));
|
|
6881
|
+
}
|
|
6882
|
+
if (inaccessibleChanged) {
|
|
6883
|
+
this.stateDb.setMeta(INACCESSIBLE_DBS_META_KEY, JSON.stringify([...inaccessibleIds]));
|
|
6884
|
+
}
|
|
5779
6885
|
} catch (error) {
|
|
5780
6886
|
getLogger().warn("[Im-Nobsidian] child_database \uC790\uB3D9 \uBC1C\uACAC \uC2E4\uD328:", error);
|
|
5781
6887
|
}
|
|
@@ -5787,7 +6893,7 @@ var SyncOrchestrator = class {
|
|
|
5787
6893
|
const idToTitle = /* @__PURE__ */ new Map();
|
|
5788
6894
|
for (const r of allRecords) {
|
|
5789
6895
|
if (r.notionPageId && r.obsidianPath) {
|
|
5790
|
-
const title = r.obsidianPath
|
|
6896
|
+
const title = wikilinkTitleFromPath(r.obsidianPath);
|
|
5791
6897
|
const cleanId = r.notionPageId.replace(/-/g, "");
|
|
5792
6898
|
idToTitle.set(cleanId, title);
|
|
5793
6899
|
idToTitle.set(r.notionPageId, title);
|
|
@@ -5822,8 +6928,26 @@ var SyncOrchestrator = class {
|
|
|
5822
6928
|
}
|
|
5823
6929
|
);
|
|
5824
6930
|
content = resolved2;
|
|
6931
|
+
const fm = resolveFrontmatterRelations(content, idToTitle);
|
|
6932
|
+
if (fm.count > 0) {
|
|
6933
|
+
content = fm.content;
|
|
6934
|
+
totalResolved += fm.count;
|
|
6935
|
+
changed = true;
|
|
6936
|
+
}
|
|
5825
6937
|
if (changed) {
|
|
5826
6938
|
await this.vaultFs.writeFile(filePath, content);
|
|
6939
|
+
const record = this.stateDb.getByPath(filePath);
|
|
6940
|
+
if (record) {
|
|
6941
|
+
const stat2 = await this.vaultFs.getFileStat(filePath);
|
|
6942
|
+
this.stateDb.transaction(() => {
|
|
6943
|
+
this.stateDb.updateHash(
|
|
6944
|
+
record.id,
|
|
6945
|
+
computeHash(content),
|
|
6946
|
+
Buffer.from(content, "utf-8")
|
|
6947
|
+
);
|
|
6948
|
+
if (stat2) this.stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
|
|
6949
|
+
});
|
|
6950
|
+
}
|
|
5827
6951
|
}
|
|
5828
6952
|
} catch {
|
|
5829
6953
|
}
|
|
@@ -5869,6 +6993,27 @@ var SyncOrchestrator = class {
|
|
|
5869
6993
|
title
|
|
5870
6994
|
);
|
|
5871
6995
|
}
|
|
6996
|
+
const placeholder = this.stateDb.upsert({
|
|
6997
|
+
obsidianPath: path,
|
|
6998
|
+
notionPageId: null,
|
|
6999
|
+
notionParentId: parentId,
|
|
7000
|
+
contentHash: "",
|
|
7001
|
+
notionLastEdited: null,
|
|
7002
|
+
localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7003
|
+
syncDirection: "both",
|
|
7004
|
+
fileType: this.isFolderNote(path) ? "folder-note" : "file",
|
|
7005
|
+
status: "pending",
|
|
7006
|
+
baseSnapshot: null,
|
|
7007
|
+
localMtime: null,
|
|
7008
|
+
localFileSize: null
|
|
7009
|
+
});
|
|
7010
|
+
const existingOp = this.stateDb.getIncompleteOpByState(placeholder.id, "create");
|
|
7011
|
+
const walOpId = existingOp?.id ?? this.stateDb.recordPendingOperation({
|
|
7012
|
+
syncStateId: placeholder.id,
|
|
7013
|
+
operation: "create",
|
|
7014
|
+
direction: "push",
|
|
7015
|
+
payload: JSON.stringify({ path, parentId, title })
|
|
7016
|
+
});
|
|
5872
7017
|
const page = await this.pushCreatePage(
|
|
5873
7018
|
effectiveParentId,
|
|
5874
7019
|
effectiveParentType,
|
|
@@ -5917,6 +7062,7 @@ var SyncOrchestrator = class {
|
|
|
5917
7062
|
});
|
|
5918
7063
|
this.stateDb.storePreserveMarkers(path, conversionResult.preserveMarkers);
|
|
5919
7064
|
});
|
|
7065
|
+
this.stateDb.markPendingCompleted(walOpId);
|
|
5920
7066
|
}
|
|
5921
7067
|
async pushUpdate(path) {
|
|
5922
7068
|
const content = await this.vaultFs.readFile(path);
|
|
@@ -5947,13 +7093,16 @@ var SyncOrchestrator = class {
|
|
|
5947
7093
|
title: { title: [{ text: { content: titleStr } }] }
|
|
5948
7094
|
};
|
|
5949
7095
|
}
|
|
5950
|
-
let lastEditedTime
|
|
7096
|
+
let lastEditedTime;
|
|
5951
7097
|
if (propsToUpdate && Object.keys(propsToUpdate).length > 0) {
|
|
5952
7098
|
const updatedPage = await this.notionClient.updatePageProperties(
|
|
5953
7099
|
record.notionPageId,
|
|
5954
7100
|
propsToUpdate
|
|
5955
7101
|
);
|
|
5956
7102
|
lastEditedTime = updatedPage.last_edited_time;
|
|
7103
|
+
} else {
|
|
7104
|
+
const refreshed = await this.notionClient.getPage(record.notionPageId);
|
|
7105
|
+
lastEditedTime = refreshed.last_edited_time;
|
|
5957
7106
|
}
|
|
5958
7107
|
const hash = computeHash(content);
|
|
5959
7108
|
const fileStat = await this.vaultFs.getFileStat(path);
|
|
@@ -5975,25 +7124,78 @@ var SyncOrchestrator = class {
|
|
|
5975
7124
|
});
|
|
5976
7125
|
});
|
|
5977
7126
|
}
|
|
7127
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
7128
|
+
// 충돌 해소 (I8) — 해소 결과를 로컬에만 쓰지 않고 Notion 으로 재push + notionLastEdited
|
|
7129
|
+
// 재조정까지 한 트랜잭션으로 묶는다. ConflictResolver 단독은 로컬 write + updateHash 만
|
|
7130
|
+
// 수행하므로(merge 결과가 Notion 에 반영되지 않음) 다음 pull 이 원격으로 덮어써 영구
|
|
7131
|
+
// 유실·충돌 루프가 발생한다. 해소 → 전파(propagate)를 오케스트레이터에서 봉합해 무손실
|
|
7132
|
+
// 보장. 변환 파이프라인이 필요한 push 는 기존 엔터프라이즈 경로(pushUpdate)를 재사용한다.
|
|
7133
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
7134
|
+
/** 단일 충돌을 사용자가 고른 선택지(local/remote/merge/duplicate)로 해소 + Notion 전파. */
|
|
7135
|
+
async resolveConflict(conflict, choice) {
|
|
7136
|
+
const result = await this.conflictResolver.resolve(conflict, choice);
|
|
7137
|
+
await this.propagateResolution(conflict, choice, result);
|
|
7138
|
+
return result;
|
|
7139
|
+
}
|
|
7140
|
+
/** 단일 충돌을 전략(manual/local-first/remote-first/duplicate)으로 해소 + Notion 전파. */
|
|
7141
|
+
async resolveConflictByStrategy(conflict, strategy) {
|
|
7142
|
+
const result = await this.conflictResolver.resolveByStrategy(conflict, strategy);
|
|
7143
|
+
await this.propagateResolution(conflict, strategyToChoice(strategy), result);
|
|
7144
|
+
return result;
|
|
7145
|
+
}
|
|
7146
|
+
/** 여러 충돌을 동일 전략으로 일괄 해소 + Notion 전파. */
|
|
7147
|
+
async resolveAllConflicts(conflicts, strategy) {
|
|
7148
|
+
const results = [];
|
|
7149
|
+
for (const conflict of conflicts) {
|
|
7150
|
+
results.push(await this.resolveConflictByStrategy(conflict, strategy));
|
|
7151
|
+
}
|
|
7152
|
+
return results;
|
|
7153
|
+
}
|
|
7154
|
+
/** 충돌 미리보기용 통합 diff(원본 vs 로컬 vs 원격). 해소 없이 표시 전용. */
|
|
7155
|
+
generateConflictDiff(conflict) {
|
|
7156
|
+
return this.conflictResolver.generateDiff(conflict);
|
|
7157
|
+
}
|
|
7158
|
+
/**
|
|
7159
|
+
* 해소 결과를 Notion 으로 전파해 로컬↔원격 일관성을 봉합한다.
|
|
7160
|
+
* - remote 선택: 로컬이 원격으로 갱신됐을 뿐이므로 push 불필요. notionLastEdited 만
|
|
7161
|
+
* 원격 변경의 lastEdited 로 재조정 → 다음 pull 이 같은 변경을 재충돌로 보지 않음.
|
|
7162
|
+
* - merge 실패(충돌 마커 잔존): 사용자가 직접 풀어야 하므로 conflict 상태 유지·push 안 함.
|
|
7163
|
+
* - local / merge(성공) / duplicate: 해소된 로컬 내용을 Notion 에 재push(pushUpdate 가
|
|
7164
|
+
* 변환·이미지·속성·해시·notionLastEdited 를 한 트랜잭션으로 재조정) → 무손실 수렴.
|
|
7165
|
+
*/
|
|
7166
|
+
async propagateResolution(conflict, choice, result) {
|
|
7167
|
+
const record = conflict.syncRecord;
|
|
7168
|
+
if (!record.notionPageId) return;
|
|
7169
|
+
if (choice === "remote") {
|
|
7170
|
+
this.stateDb.setNotionLastEdited(record.id, conflict.remoteChange.lastEdited);
|
|
7171
|
+
return;
|
|
7172
|
+
}
|
|
7173
|
+
if (!result.success) return;
|
|
7174
|
+
await this.pushUpdate(record.obsidianPath);
|
|
7175
|
+
}
|
|
7176
|
+
// 반환값: 실제로 원격(Notion) 삭제가 전파되었는지 여부.
|
|
7177
|
+
// deleteSync=false 면 로컬 삭제를 pending 으로만 기록하고 Notion 은 보존하므로
|
|
7178
|
+
// false 를 돌려준다 → 호출부가 deleted 카운트를 올리지 않아 보고가 정직해진다.
|
|
5978
7179
|
async pushDelete(path) {
|
|
5979
7180
|
const record = this.stateDb.getByPath(path);
|
|
5980
|
-
if (!record?.notionPageId) return;
|
|
5981
|
-
if (this.config.sync.deleteSync) {
|
|
5982
|
-
try {
|
|
5983
|
-
await this.notionClient.archivePage(record.notionPageId);
|
|
5984
|
-
} catch (error) {
|
|
5985
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
5986
|
-
if (msg.includes("archived ancestor")) ; else {
|
|
5987
|
-
throw error;
|
|
5988
|
-
}
|
|
5989
|
-
}
|
|
5990
|
-
this.stateDb.transaction(() => {
|
|
5991
|
-
this.stateDb.delete(record.id);
|
|
5992
|
-
this.stateDb.deleteWikilink(record.obsidianPath);
|
|
5993
|
-
});
|
|
5994
|
-
} else {
|
|
7181
|
+
if (!record?.notionPageId) return false;
|
|
7182
|
+
if (!this.config.sync.deleteSync) {
|
|
5995
7183
|
this.stateDb.updateStatus(record.id, "pending");
|
|
7184
|
+
return false;
|
|
7185
|
+
}
|
|
7186
|
+
try {
|
|
7187
|
+
await this.notionClient.archivePage(record.notionPageId);
|
|
7188
|
+
} catch (error) {
|
|
7189
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
7190
|
+
if (msg.includes("archived ancestor")) ; else {
|
|
7191
|
+
throw error;
|
|
7192
|
+
}
|
|
5996
7193
|
}
|
|
7194
|
+
this.stateDb.transaction(() => {
|
|
7195
|
+
this.stateDb.delete(record.id);
|
|
7196
|
+
this.stateDb.deleteWikilink(record.obsidianPath);
|
|
7197
|
+
});
|
|
7198
|
+
return true;
|
|
5997
7199
|
}
|
|
5998
7200
|
async pushMove(path) {
|
|
5999
7201
|
const record = this.stateDb.getByPath(path);
|
|
@@ -6013,6 +7215,7 @@ var SyncOrchestrator = class {
|
|
|
6013
7215
|
}
|
|
6014
7216
|
async detectRemoteChanges() {
|
|
6015
7217
|
const changes = [];
|
|
7218
|
+
this._childParentIds.clear();
|
|
6016
7219
|
const lastPull = this.stateDb.getMeta("last_pull_at");
|
|
6017
7220
|
const syncedRecords = this.stateDb.getAll();
|
|
6018
7221
|
const trackedPageIds = new Set(
|
|
@@ -6033,11 +7236,32 @@ var SyncOrchestrator = class {
|
|
|
6033
7236
|
} while (cursor);
|
|
6034
7237
|
remotePages = allPages;
|
|
6035
7238
|
} else {
|
|
6036
|
-
|
|
6037
|
-
|
|
7239
|
+
let underRoot;
|
|
7240
|
+
try {
|
|
7241
|
+
underRoot = await this.notionClient.getChildPagesRecursive(this.config.notion.rootPageId, {
|
|
7242
|
+
deadlineMs: Date.now() + _SyncOrchestrator.DISCOVERY_RECURSIVE_BUDGET_MS
|
|
7243
|
+
});
|
|
7244
|
+
} catch (error) {
|
|
7245
|
+
if (!(error instanceof DiscoveryTooLargeError)) throw error;
|
|
7246
|
+
getLogger().info(
|
|
7247
|
+
`[Im-Nobsidian] \uC11C\uBE0C\uD2B8\uB9AC\uAC00 \uD07C(${error.message}) \u2192 \uC6CC\uD06C\uC2A4\uD398\uC774\uC2A4 search \uAE30\uBC18 \uB514\uC2A4\uCEE4\uBC84\uB9AC\uB85C \uC804\uD658`
|
|
7248
|
+
);
|
|
7249
|
+
underRoot = await this.notionClient.getPagesUnderRootViaSearch(
|
|
7250
|
+
this.config.notion.rootPageId
|
|
7251
|
+
);
|
|
7252
|
+
}
|
|
7253
|
+
this._childParentIds.add(normalizeNotionId(this.config.notion.rootPageId));
|
|
7254
|
+
for (const page of underRoot) {
|
|
7255
|
+
const parentId = await this.extractParentId(page);
|
|
7256
|
+
if (parentId) this._childParentIds.add(normalizeNotionId(parentId));
|
|
7257
|
+
}
|
|
6038
7258
|
remotePages = underRoot.map((p) => ({ id: p.id, last_edited_time: p.last_edited_time }));
|
|
6039
7259
|
}
|
|
7260
|
+
const seenRemoteIds = /* @__PURE__ */ new Set();
|
|
6040
7261
|
for (const page of remotePages) {
|
|
7262
|
+
const key = normalizeNotionId(page.id);
|
|
7263
|
+
if (seenRemoteIds.has(key)) continue;
|
|
7264
|
+
seenRemoteIds.add(key);
|
|
6041
7265
|
const record = this.stateDb.getByNotionId(page.id);
|
|
6042
7266
|
if (!record) {
|
|
6043
7267
|
changes.push({
|
|
@@ -6071,8 +7295,16 @@ var SyncOrchestrator = class {
|
|
|
6071
7295
|
}
|
|
6072
7296
|
return changes;
|
|
6073
7297
|
}
|
|
7298
|
+
/**
|
|
7299
|
+
* 증분 원격 변경 감지 — `since` 이후 수정된 페이지만 search 로 받아 created/modified 만
|
|
7300
|
+
* 만든다. **삭제는 의도적으로 감지하지 않는다**: search API 는 in_trash/archived 페이지를
|
|
7301
|
+
* 반환하지 않아(=사라진 것을 증분만으로는 구분 불가) 삭제 판정에는 전체 enumeration 이
|
|
7302
|
+
* 필수다. 따라서 호출부는 `deleteSync` 가 켜진 경우 이 fast-path 를 쓰지 않고
|
|
7303
|
+
* `detectRemoteChanges`(전체 스캔, 삭제 diff 포함)로 우회한다. (I10)
|
|
7304
|
+
*/
|
|
6074
7305
|
async detectRemoteChangesIncremental(since) {
|
|
6075
7306
|
const changes = [];
|
|
7307
|
+
this._childParentIds.clear();
|
|
6076
7308
|
const recentPages = await this.notionClient.searchRecentPages(since);
|
|
6077
7309
|
for (const page of recentPages) {
|
|
6078
7310
|
const record = this.stateDb.getByNotionId(page.id);
|
|
@@ -6080,6 +7312,7 @@ var SyncOrchestrator = class {
|
|
|
6080
7312
|
try {
|
|
6081
7313
|
const fullPage = await this.notionClient.getPage(page.id);
|
|
6082
7314
|
const parentId = await this.extractParentId(fullPage);
|
|
7315
|
+
if (parentId) this._childParentIds.add(normalizeNotionId(parentId));
|
|
6083
7316
|
if (parentId && this.isTrackedParent(parentId)) {
|
|
6084
7317
|
changes.push({
|
|
6085
7318
|
pageId: page.id,
|
|
@@ -6107,85 +7340,12 @@ var SyncOrchestrator = class {
|
|
|
6107
7340
|
if (notionIdsEqual(parentId, this.config.notion.rootPageId)) return true;
|
|
6108
7341
|
return !!this.stateDb.getByNotionId(parentId);
|
|
6109
7342
|
}
|
|
6110
|
-
/**
|
|
6111
|
-
* search API로 받은 전체 접근 가능 페이지 중 rootPageId 하위(자손)만 남긴다.
|
|
6112
|
-
* 각 페이지의 parent 체인을 root에 닿을 때까지 거슬러 올라가며 판정한다.
|
|
6113
|
-
* - 부모가 이미 받은 페이지면 추가 API 호출 없이 메모리에서 해석(공통 경로)
|
|
6114
|
-
* - 부모가 검색 결과에 없으면(데이터베이스·미공유 조상 등) getPage로 1회 조회 후 캐시
|
|
6115
|
-
* - 조회 불가/순환/깊이 초과 시 보수적으로 root 하위가 아님으로 판정
|
|
6116
|
-
* 판정 결과는 체인 전체에 메모이즈해 형제 페이지 처리 시 재사용한다.
|
|
6117
|
-
*/
|
|
6118
|
-
async filterPagesUnderRoot(allPages) {
|
|
6119
|
-
const root = normalizeNotionId(this.config.notion.rootPageId);
|
|
6120
|
-
const byId = /* @__PURE__ */ new Map();
|
|
6121
|
-
for (const p of allPages) byId.set(normalizeNotionId(p.id), p);
|
|
6122
|
-
const verdict = /* @__PURE__ */ new Map();
|
|
6123
|
-
const isUnderRoot = async (start) => {
|
|
6124
|
-
const chain = [];
|
|
6125
|
-
let current = start;
|
|
6126
|
-
let result = false;
|
|
6127
|
-
while (current) {
|
|
6128
|
-
const id = normalizeNotionId(current.id);
|
|
6129
|
-
if (id === root) {
|
|
6130
|
-
result = true;
|
|
6131
|
-
break;
|
|
6132
|
-
}
|
|
6133
|
-
const memo = verdict.get(id);
|
|
6134
|
-
if (memo !== void 0) {
|
|
6135
|
-
result = memo;
|
|
6136
|
-
break;
|
|
6137
|
-
}
|
|
6138
|
-
if (chain.includes(id)) {
|
|
6139
|
-
result = false;
|
|
6140
|
-
break;
|
|
6141
|
-
}
|
|
6142
|
-
chain.push(id);
|
|
6143
|
-
const parentId = await this.extractParentId(current);
|
|
6144
|
-
if (!parentId) {
|
|
6145
|
-
result = false;
|
|
6146
|
-
break;
|
|
6147
|
-
}
|
|
6148
|
-
const pid = normalizeNotionId(parentId);
|
|
6149
|
-
if (pid === root) {
|
|
6150
|
-
result = true;
|
|
6151
|
-
break;
|
|
6152
|
-
}
|
|
6153
|
-
const memoParent = verdict.get(pid);
|
|
6154
|
-
if (memoParent !== void 0) {
|
|
6155
|
-
result = memoParent;
|
|
6156
|
-
break;
|
|
6157
|
-
}
|
|
6158
|
-
let parentPage = byId.get(pid) ?? null;
|
|
6159
|
-
if (!parentPage) {
|
|
6160
|
-
try {
|
|
6161
|
-
parentPage = await this.notionClient.getPage(parentId);
|
|
6162
|
-
byId.set(pid, parentPage);
|
|
6163
|
-
} catch {
|
|
6164
|
-
result = false;
|
|
6165
|
-
break;
|
|
6166
|
-
}
|
|
6167
|
-
}
|
|
6168
|
-
current = parentPage;
|
|
6169
|
-
}
|
|
6170
|
-
for (const id of chain) verdict.set(id, result);
|
|
6171
|
-
return result;
|
|
6172
|
-
};
|
|
6173
|
-
const out = [];
|
|
6174
|
-
for (const p of allPages) {
|
|
6175
|
-
if (normalizeNotionId(p.id) === root) continue;
|
|
6176
|
-
if (await isUnderRoot(p)) out.push(p);
|
|
6177
|
-
}
|
|
6178
|
-
return out;
|
|
6179
|
-
}
|
|
6180
7343
|
async pullCreate(pageId) {
|
|
6181
7344
|
const page = await this.notionClient.getPage(pageId);
|
|
6182
7345
|
const title = this.notionClient.extractTitle(page);
|
|
6183
7346
|
const safeName = sanitizeFileName(title);
|
|
6184
7347
|
const parentPath = await this.resolveParentPath(page);
|
|
6185
|
-
const
|
|
6186
|
-
pageSize: this.config.advanced.pageSize
|
|
6187
|
-
});
|
|
6188
|
-
const hasChildPages = firstChildren.results.some((b) => "type" in b && b.type === "child_page");
|
|
7348
|
+
const hasChildPages = await this.pageHasChildContainers(pageId);
|
|
6189
7349
|
const markdown = await this.fetchPageMarkdown(pageId);
|
|
6190
7350
|
const hasContent = markdown.trim().length > 0;
|
|
6191
7351
|
let filePath;
|
|
@@ -6234,10 +7394,10 @@ var SyncOrchestrator = class {
|
|
|
6234
7394
|
},
|
|
6235
7395
|
{ properties }
|
|
6236
7396
|
);
|
|
7397
|
+
const resolvedParentId = await this.extractParentId(page);
|
|
6237
7398
|
await this.vaultFs.writeFile(filePath, finalContent);
|
|
6238
7399
|
const hash = computeHash(finalContent);
|
|
6239
7400
|
const pullStat = await this.vaultFs.getFileStat(filePath);
|
|
6240
|
-
const resolvedParentId = await this.extractParentId(page);
|
|
6241
7401
|
this.stateDb.transaction(() => {
|
|
6242
7402
|
this.stateDb.upsert({
|
|
6243
7403
|
obsidianPath: filePath,
|
|
@@ -6263,6 +7423,28 @@ var SyncOrchestrator = class {
|
|
|
6263
7423
|
});
|
|
6264
7424
|
return filePath;
|
|
6265
7425
|
}
|
|
7426
|
+
/**
|
|
7427
|
+
* 페이지가 폴더(자식 페이지·자식 DB 보유)인지 신뢰성 있게 판정한다.
|
|
7428
|
+
*
|
|
7429
|
+
* 결함(폴더노트 본문분리)의 근본 원인은 얕은 판정이었다 — 최상위 블록 첫 페이지에서
|
|
7430
|
+
* child_page 만 검사하면 callout·column·toggle 안에 중첩된 자식 페이지나 child_database 를
|
|
7431
|
+
* 놓쳐 폴더노트가 file 로 오분류되고, 본문이 폴더 밖 최상위로 밀려 resolveUniqueFilePath
|
|
7432
|
+
* 충돌(' (1).md')로 쪼개졌다.
|
|
7433
|
+
*
|
|
7434
|
+
* 1차: 발견 단계(서브트리 순회/증분)에서 전 페이지의 부모를 해소하며 만든
|
|
7435
|
+
* _childParentIds 집합으로 O(1) 판정(추가 API 호출 0, 처리 순서 무관) — 전체 pull 경로.
|
|
7436
|
+
* 폴백: 집합에 없을 때만(증분 pull 의 신규 폴더 등) fetchAllChildrenDeep 로 컨테이너를
|
|
7437
|
+
* 재귀 탐색해 child_page·child_database 를 직접 확인한다.
|
|
7438
|
+
*/
|
|
7439
|
+
async pageHasChildContainers(pageId) {
|
|
7440
|
+
if (this._childParentIds.has(normalizeNotionId(pageId))) return true;
|
|
7441
|
+
try {
|
|
7442
|
+
const deep = await this.notionClient.fetchAllChildrenDeep(pageId);
|
|
7443
|
+
return deep.some((b) => b.type === "child_page" || b.type === "child_database");
|
|
7444
|
+
} catch {
|
|
7445
|
+
return false;
|
|
7446
|
+
}
|
|
7447
|
+
}
|
|
6266
7448
|
async pullUpdate(change) {
|
|
6267
7449
|
const record = this.stateDb.getByNotionId(change.pageId);
|
|
6268
7450
|
if (!record) return {};
|
|
@@ -6318,6 +7500,24 @@ var SyncOrchestrator = class {
|
|
|
6318
7500
|
this.stateDb.updateStatus(record.id, "conflict");
|
|
6319
7501
|
return { conflict: resolution.conflict };
|
|
6320
7502
|
}
|
|
7503
|
+
if (remoteContent === localContent) {
|
|
7504
|
+
const stat2 = await this.vaultFs.getFileStat(record.obsidianPath);
|
|
7505
|
+
this.stateDb.upsert({
|
|
7506
|
+
obsidianPath: record.obsidianPath,
|
|
7507
|
+
notionPageId: change.pageId,
|
|
7508
|
+
notionParentId: record.notionParentId,
|
|
7509
|
+
contentHash: computeHash(remoteContent),
|
|
7510
|
+
notionLastEdited: page.last_edited_time,
|
|
7511
|
+
localLastModified: record.localLastModified,
|
|
7512
|
+
syncDirection: record.syncDirection,
|
|
7513
|
+
fileType: record.fileType,
|
|
7514
|
+
status: "synced",
|
|
7515
|
+
baseSnapshot: Buffer.from(remoteContent, "utf-8"),
|
|
7516
|
+
localMtime: stat2?.mtime ?? record.localMtime ?? null,
|
|
7517
|
+
localFileSize: stat2?.size ?? record.localFileSize ?? null
|
|
7518
|
+
});
|
|
7519
|
+
return { path: record.obsidianPath, unchanged: true };
|
|
7520
|
+
}
|
|
6321
7521
|
await this.vaultFs.writeFile(record.obsidianPath, remoteContent);
|
|
6322
7522
|
const updateStat = await this.vaultFs.getFileStat(record.obsidianPath);
|
|
6323
7523
|
const newHash = computeHash(remoteContent);
|
|
@@ -6492,7 +7692,26 @@ var SyncOrchestrator = class {
|
|
|
6492
7692
|
}
|
|
6493
7693
|
return page;
|
|
6494
7694
|
}
|
|
7695
|
+
// 페이지에 살아있는(휴지통 아님) child page/database 가 직속 블록으로 존재하는가.
|
|
7696
|
+
// 휴지통(in_trash) 자식은 children.list 에 잡히지 않으므로 "live" 는 암묵적이다.
|
|
7697
|
+
async pageHasLiveChildren(pageId) {
|
|
7698
|
+
try {
|
|
7699
|
+
const children = await this.notionClient.fetchAllChildren(pageId);
|
|
7700
|
+
return children.some((b) => b.type === "child_page" || b.type === "child_database");
|
|
7701
|
+
} catch (error) {
|
|
7702
|
+
getLogger().warn(
|
|
7703
|
+
`[Im-Nobsidian] \uC790\uC2DD \uD398\uC774\uC9C0 \uC870\uD68C \uC2E4\uD328 \u2014 \uBCF8\uBB38 push \uBCF4\uC218\uC801 \uC0DD\uB7B5(\uC790\uC2DD \uBCF4\uD638): ${pageId} (${error instanceof Error ? error.message : String(error)})`
|
|
7704
|
+
);
|
|
7705
|
+
return true;
|
|
7706
|
+
}
|
|
7707
|
+
}
|
|
6495
7708
|
async pushUpdatePage(pageId, markdownContent, _baseSnapshot) {
|
|
7709
|
+
if (await this.pageHasLiveChildren(pageId)) {
|
|
7710
|
+
getLogger().warn(
|
|
7711
|
+
`[Im-Nobsidian] \uC790\uC2DD \uD398\uC774\uC9C0 \uBCF4\uC720 \u2014 \uBCF8\uBB38 push \uC0DD\uB7B5(\uC790\uC2DD \uC0AD\uC81C \uBC29\uC9C0): ${pageId}. \uC790\uC2DD \uD398\uC774\uC9C0\uB294 \uAC01\uC790 \uB3D9\uAE30\uD654\uB418\uBA70, \uC774 \uD398\uC774\uC9C0 \uBCF8\uBB38 \uD3B8\uC9D1\uC740 Notion \uC5D0 \uBC18\uC601\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.`
|
|
7712
|
+
);
|
|
7713
|
+
return;
|
|
7714
|
+
}
|
|
6496
7715
|
if (this.config.conversion.preferMarkdownApi !== false) {
|
|
6497
7716
|
const enhanced = obsidianToNotionEnhanced(markdownContent);
|
|
6498
7717
|
await this.notionClient.replacePageMarkdown(pageId, enhanced);
|
|
@@ -6589,486 +7808,334 @@ var SyncOrchestrator = class {
|
|
|
6589
7808
|
this.stateDb.setMeta("pull_in_progress", "");
|
|
6590
7809
|
}
|
|
6591
7810
|
}
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
6615
|
-
return [];
|
|
6616
|
-
}
|
|
6617
|
-
var NodeVaultFS = class {
|
|
6618
|
-
constructor(rootPath, pathConfig) {
|
|
6619
|
-
this.rootPath = rootPath;
|
|
6620
|
-
this.includePatterns = pathConfig?.include ?? [];
|
|
6621
|
-
const configExclude = pathConfig?.exclude ?? [];
|
|
6622
|
-
const ignorePatterns = this.loadImNobsidianIgnore();
|
|
6623
|
-
this.excludePatterns = [...configExclude, ...ignorePatterns];
|
|
6624
|
-
}
|
|
6625
|
-
rootPath;
|
|
6626
|
-
includePatterns;
|
|
6627
|
-
excludePatterns;
|
|
6628
|
-
loadImNobsidianIgnore() {
|
|
6629
|
-
try {
|
|
6630
|
-
const ignorePath = join(this.rootPath, IGNORE_FILE);
|
|
6631
|
-
const content = readFileSync(ignorePath, "utf-8");
|
|
6632
|
-
return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
6633
|
-
} catch {
|
|
6634
|
-
return [];
|
|
6635
|
-
}
|
|
6636
|
-
}
|
|
6637
|
-
async readFile(path) {
|
|
6638
|
-
const fullPath = join(this.rootPath, path);
|
|
6639
|
-
return readFile(fullPath, "utf-8");
|
|
6640
|
-
}
|
|
6641
|
-
async readBinary(path) {
|
|
6642
|
-
const fullPath = join(this.rootPath, path);
|
|
6643
|
-
return readFile(fullPath);
|
|
6644
|
-
}
|
|
6645
|
-
async writeFile(path, content) {
|
|
6646
|
-
const fullPath = join(this.rootPath, path);
|
|
6647
|
-
await mkdir(dirname(fullPath), { recursive: true });
|
|
6648
|
-
await writeFile(fullPath, content, "utf-8");
|
|
6649
|
-
}
|
|
6650
|
-
async writeBinary(path, data) {
|
|
6651
|
-
const fullPath = join(this.rootPath, path);
|
|
6652
|
-
await mkdir(dirname(fullPath), { recursive: true });
|
|
6653
|
-
await writeFile(fullPath, data);
|
|
6654
|
-
}
|
|
6655
|
-
async deleteFile(path) {
|
|
6656
|
-
const fullPath = join(this.rootPath, path);
|
|
6657
|
-
await unlink(fullPath);
|
|
6658
|
-
}
|
|
6659
|
-
async moveFile(from, to) {
|
|
6660
|
-
const fromPath = join(this.rootPath, from);
|
|
6661
|
-
const toPath = join(this.rootPath, to);
|
|
6662
|
-
await mkdir(dirname(toPath), { recursive: true });
|
|
6663
|
-
await rename(fromPath, toPath);
|
|
6664
|
-
}
|
|
6665
|
-
async exists(path) {
|
|
6666
|
-
try {
|
|
6667
|
-
await stat(join(this.rootPath, path));
|
|
6668
|
-
return true;
|
|
6669
|
-
} catch {
|
|
6670
|
-
return false;
|
|
6671
|
-
}
|
|
6672
|
-
}
|
|
6673
|
-
async ensureFolder(path) {
|
|
6674
|
-
await mkdir(join(this.rootPath, path), { recursive: true });
|
|
6675
|
-
}
|
|
6676
|
-
async listMarkdownFiles() {
|
|
6677
|
-
const files = [];
|
|
6678
|
-
await this.walkDir(this.rootPath, files);
|
|
6679
|
-
return files;
|
|
6680
|
-
}
|
|
6681
|
-
async listMarkdownFileStats() {
|
|
6682
|
-
const stats = [];
|
|
6683
|
-
await this.walkDirStats(this.rootPath, stats);
|
|
6684
|
-
return stats;
|
|
6685
|
-
}
|
|
6686
|
-
async getFileStat(path) {
|
|
6687
|
-
try {
|
|
6688
|
-
const fullPath = join(this.rootPath, path);
|
|
6689
|
-
const fileStat = await stat(fullPath);
|
|
6690
|
-
return { path, mtime: fileStat.mtime.toISOString(), size: fileStat.size };
|
|
6691
|
-
} catch {
|
|
6692
|
-
return null;
|
|
6693
|
-
}
|
|
6694
|
-
}
|
|
6695
|
-
async listNonMarkdownFiles() {
|
|
6696
|
-
const files = [];
|
|
6697
|
-
await this.walkDirNonMd(this.rootPath, files);
|
|
6698
|
-
return files;
|
|
6699
|
-
}
|
|
6700
|
-
matchesPattern(relativePath, pattern) {
|
|
6701
|
-
if (pattern === "**/*" || pattern === "**") return true;
|
|
6702
|
-
if (pattern.endsWith("/**")) {
|
|
6703
|
-
const dir = pattern.slice(0, -3);
|
|
6704
|
-
return relativePath.startsWith(dir + "/") || relativePath === dir;
|
|
6705
|
-
} else if (pattern.startsWith("*.")) {
|
|
6706
|
-
const ext = pattern.slice(1);
|
|
6707
|
-
return relativePath.endsWith(ext);
|
|
6708
|
-
} else if (pattern.includes("*")) {
|
|
6709
|
-
const regexStr = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
|
|
6710
|
-
return new RegExp("^" + regexStr + "$").test(relativePath);
|
|
6711
|
-
} else {
|
|
6712
|
-
return relativePath === pattern || relativePath.startsWith(pattern + "/");
|
|
6713
|
-
}
|
|
6714
|
-
}
|
|
6715
|
-
isExcluded(relativePath) {
|
|
6716
|
-
if (this.includePatterns.length > 0) {
|
|
6717
|
-
const included = this.includePatterns.some((p) => this.matchesPattern(relativePath, p));
|
|
6718
|
-
if (!included) return true;
|
|
6719
|
-
}
|
|
6720
|
-
return this.excludePatterns.some((p) => this.matchesPattern(relativePath, p));
|
|
6721
|
-
}
|
|
6722
|
-
async walkDirNonMd(dir, result) {
|
|
6723
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
6724
|
-
for (const entry of entries) {
|
|
6725
|
-
const fullPath = join(dir, entry.name);
|
|
6726
|
-
if (entry.name.startsWith(".")) continue;
|
|
6727
|
-
const relativePath = relative(this.rootPath, fullPath);
|
|
6728
|
-
if (this.isExcluded(relativePath)) continue;
|
|
6729
|
-
if (entry.isDirectory()) {
|
|
6730
|
-
await this.walkDirNonMd(fullPath, result);
|
|
6731
|
-
} else if (entry.isFile() && !entry.name.endsWith(".md") && !entry.name.endsWith(".base")) {
|
|
6732
|
-
const fileStat = await stat(fullPath);
|
|
6733
|
-
result.push({
|
|
6734
|
-
path: relativePath,
|
|
6735
|
-
size: fileStat.size,
|
|
6736
|
-
mtime: fileStat.mtime.toISOString()
|
|
6737
|
-
});
|
|
7811
|
+
/**
|
|
7812
|
+
* I12 — 중단된 push create 작업 재개.
|
|
7813
|
+
*
|
|
7814
|
+
* pending_operations 에 미완료(create·push) 항목이 있으면:
|
|
7815
|
+
* - state 에 notion_page_id 가 이미 있으면 → 생성·매핑까지는 끝났고 markCompleted 직전에
|
|
7816
|
+
* 중단된 것 → 완료 처리(잔여 본문/이미지는 다음 변경감지가 pushUpdate 로 마무리).
|
|
7817
|
+
* - notion_page_id 가 비어 있으면(Window A: 생성 적용됐으나 매핑 기록 전 중단) → 부모에서
|
|
7818
|
+
* 제목으로 child_page 를 검색해 고아 페이지를 입양(중복 생성 차단). 없으면 자리표시
|
|
7819
|
+
* 레코드를 제거(FK CASCADE 로 op 도 삭제)해 다음 push 가 새로 생성하게 한다.
|
|
7820
|
+
*
|
|
7821
|
+
* 한계(문서화): Notion 은 idempotency key 가 없어 "생성 요청 적용 직후 같은 호출 내 재시도"
|
|
7822
|
+
* 로 인한 중복은 완전히 차단하지 못한다. 429 는 적용 전 거절이라 안전하고, 프로세스 재시작
|
|
7823
|
+
* 후 재개는 본 검색-입양으로 중복을 막는다. DB 모드(부모가 database)는 child_page 검색이
|
|
7824
|
+
* 불가하므로 자리표시 제거 후 재생성으로 폴백한다.
|
|
7825
|
+
*/
|
|
7826
|
+
async recoverInterruptedPushOps() {
|
|
7827
|
+
const ops = this.stateDb.getIncompletePendingOperations();
|
|
7828
|
+
if (ops.length === 0) return;
|
|
7829
|
+
for (const op of ops) {
|
|
7830
|
+
if (op.direction !== "push" || op.operation !== "create") {
|
|
7831
|
+
this.stateDb.markPendingFailed(op.id, "unsupported resume op");
|
|
7832
|
+
continue;
|
|
6738
7833
|
}
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
if (entry.name.startsWith(".")) continue;
|
|
6746
|
-
const relativePath = relative(this.rootPath, fullPath);
|
|
6747
|
-
if (this.isExcluded(relativePath)) continue;
|
|
6748
|
-
if (entry.isDirectory()) {
|
|
6749
|
-
await this.walkDirStats(fullPath, result);
|
|
6750
|
-
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
6751
|
-
const fileStat = await stat(fullPath);
|
|
6752
|
-
result.push({
|
|
6753
|
-
path: relativePath,
|
|
6754
|
-
mtime: fileStat.mtime.toISOString(),
|
|
6755
|
-
size: fileStat.size
|
|
6756
|
-
});
|
|
7834
|
+
let payload = {};
|
|
7835
|
+
try {
|
|
7836
|
+
payload = JSON.parse(op.payload ?? "{}");
|
|
7837
|
+
} catch {
|
|
7838
|
+
this.stateDb.markPendingFailed(op.id, "invalid payload json");
|
|
7839
|
+
continue;
|
|
6757
7840
|
}
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
|
|
6761
|
-
|
|
6762
|
-
|
|
6763
|
-
const
|
|
6764
|
-
if (
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
7841
|
+
const path = payload.path;
|
|
7842
|
+
if (!path) {
|
|
7843
|
+
this.stateDb.markPendingFailed(op.id, "missing payload.path");
|
|
7844
|
+
continue;
|
|
7845
|
+
}
|
|
7846
|
+
const state = this.stateDb.getByPath(path);
|
|
7847
|
+
if (state?.notionPageId) {
|
|
7848
|
+
this.stateDb.markPendingCompleted(op.id);
|
|
7849
|
+
continue;
|
|
7850
|
+
}
|
|
7851
|
+
const parentId = payload.parentId ?? state?.notionParentId ?? void 0;
|
|
7852
|
+
const title = payload.title;
|
|
7853
|
+
const adopted = parentId && title ? await this.findChildPageByTitle(parentId, title) : null;
|
|
7854
|
+
if (adopted) {
|
|
7855
|
+
this.stateDb.upsert({
|
|
7856
|
+
obsidianPath: path,
|
|
7857
|
+
notionPageId: adopted,
|
|
7858
|
+
notionParentId: parentId ?? null,
|
|
7859
|
+
contentHash: "",
|
|
7860
|
+
notionLastEdited: state?.notionLastEdited ?? null,
|
|
7861
|
+
localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7862
|
+
syncDirection: state?.syncDirection ?? "both",
|
|
7863
|
+
fileType: state?.fileType ?? (this.isFolderNote(path) ? "folder-note" : "file"),
|
|
7864
|
+
status: "pending",
|
|
7865
|
+
baseSnapshot: null,
|
|
7866
|
+
localMtime: null,
|
|
7867
|
+
localFileSize: null
|
|
6776
7868
|
});
|
|
7869
|
+
this.stateDb.markPendingCompleted(op.id);
|
|
7870
|
+
getLogger().info(`[Im-Nobsidian] \uC911\uB2E8\uB41C create \uC7AC\uAC1C \u2014 \uACE0\uC544 \uD398\uC774\uC9C0 \uC785\uC591: ${path}`);
|
|
7871
|
+
} else if (state) {
|
|
7872
|
+
this.stateDb.delete(state.id);
|
|
7873
|
+
getLogger().info(`[Im-Nobsidian] \uC911\uB2E8\uB41C create \uC7AC\uAC1C \u2014 \uBBF8\uC0DD\uC131 \uD655\uC778, \uC790\uB9AC\uD45C\uC2DC \uC81C\uAC70: ${path}`);
|
|
7874
|
+
} else {
|
|
7875
|
+
this.stateDb.markPendingCompleted(op.id);
|
|
6777
7876
|
}
|
|
6778
7877
|
}
|
|
6779
7878
|
}
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
6796
|
-
const endMatch = INLINE_DB_END.exec(content.slice(afterStart));
|
|
6797
|
-
if (endMatch) {
|
|
6798
|
-
const tableContent = content.slice(afterStart, afterStart + endMatch.index);
|
|
6799
|
-
inlineDbBlocks.push({
|
|
6800
|
-
id: params["id"] ?? "",
|
|
6801
|
-
title: params["title"] ?? "",
|
|
6802
|
-
tableMarkdown: tableContent.trim(),
|
|
6803
|
-
startIdx,
|
|
6804
|
-
endIdx: afterStart + endMatch.index + endMatch[0].length
|
|
6805
|
-
});
|
|
7879
|
+
/** 부모 페이지의 직속 자식 중 제목이 일치하는(보관/휴지통 제외) child_page id 를 찾는다. */
|
|
7880
|
+
async findChildPageByTitle(parentId, title) {
|
|
7881
|
+
try {
|
|
7882
|
+
const children = await this.notionClient.fetchAllChildren(parentId);
|
|
7883
|
+
for (const b of children) {
|
|
7884
|
+
if (b.type !== "child_page") continue;
|
|
7885
|
+
const childTitle = b.child_page?.title;
|
|
7886
|
+
if (childTitle !== title) continue;
|
|
7887
|
+
try {
|
|
7888
|
+
const page = await this.notionClient.getPage(b.id);
|
|
7889
|
+
const inTrash = page.in_trash === true;
|
|
7890
|
+
if (inTrash || page.archived) continue;
|
|
7891
|
+
} catch {
|
|
7892
|
+
continue;
|
|
7893
|
+
}
|
|
7894
|
+
return b.id;
|
|
6806
7895
|
}
|
|
6807
|
-
|
|
7896
|
+
} catch (error) {
|
|
7897
|
+
getLogger().warn(
|
|
7898
|
+
`[Im-Nobsidian] \uACE0\uC544 \uD398\uC774\uC9C0 \uAC80\uC0C9 \uC2E4\uD328 (${parentId}): ${error instanceof Error ? error.message : String(error)}`
|
|
7899
|
+
);
|
|
6808
7900
|
}
|
|
6809
|
-
return
|
|
6810
|
-
content,
|
|
6811
|
-
metadata: {
|
|
6812
|
-
...input.metadata,
|
|
6813
|
-
inlineDbBlocks
|
|
6814
|
-
}
|
|
6815
|
-
};
|
|
7901
|
+
return null;
|
|
6816
7902
|
}
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
const
|
|
6822
|
-
|
|
6823
|
-
|
|
7903
|
+
async resolveUniqueFilePath(basePath) {
|
|
7904
|
+
if (!await this.vaultFs.exists(basePath)) return basePath;
|
|
7905
|
+
const dir = basePath.lastIndexOf("/") >= 0 ? basePath.slice(0, basePath.lastIndexOf("/")) : "";
|
|
7906
|
+
const ext = ".md";
|
|
7907
|
+
const name = basePath.slice(dir ? dir.length + 1 : 0, -ext.length);
|
|
7908
|
+
for (let i = 1; i <= 99; i++) {
|
|
7909
|
+
const candidate = dir ? `${dir}/${name} (${i})${ext}` : `${name} (${i})${ext}`;
|
|
7910
|
+
if (!await this.vaultFs.exists(candidate)) return candidate;
|
|
6824
7911
|
}
|
|
7912
|
+
return basePath;
|
|
6825
7913
|
}
|
|
6826
|
-
|
|
7914
|
+
};
|
|
7915
|
+
function extractTitle(filePath) {
|
|
7916
|
+
const parts = filePath.split("/");
|
|
7917
|
+
const filename = parts[parts.length - 1] ?? "";
|
|
7918
|
+
return filename.replace(/\.md$/, "");
|
|
6827
7919
|
}
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
6833
|
-
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
6841
|
-
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
|
|
6847
|
-
|
|
6848
|
-
|
|
6849
|
-
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
const overlaps = next.baseStart < regionEnd || regionEnd === regionStart && next.baseStart === regionStart;
|
|
6855
|
-
if (!overlaps) break;
|
|
6856
|
-
regionEnd = Math.max(regionEnd, next.baseEnd);
|
|
6857
|
-
group.push(next);
|
|
6858
|
-
i++;
|
|
6859
|
-
}
|
|
6860
|
-
const baseRegion = baseLines.slice(regionStart, regionEnd);
|
|
6861
|
-
const localRegion = applyHunks(baseLines, group, 0, regionStart, regionEnd);
|
|
6862
|
-
const remoteRegion = applyHunks(baseLines, group, 2, regionStart, regionEnd);
|
|
6863
|
-
if (arraysEqual(localRegion, baseRegion)) {
|
|
6864
|
-
merged.push(...remoteRegion);
|
|
6865
|
-
} else if (arraysEqual(remoteRegion, baseRegion)) {
|
|
6866
|
-
merged.push(...localRegion);
|
|
6867
|
-
} else if (arraysEqual(localRegion, remoteRegion)) {
|
|
6868
|
-
merged.push(...localRegion);
|
|
6869
|
-
} else {
|
|
6870
|
-
conflicts.push({ localLines: localRegion, remoteLines: remoteRegion });
|
|
6871
|
-
merged.push("<<<<<<< LOCAL");
|
|
6872
|
-
merged.push(...localRegion);
|
|
6873
|
-
merged.push("=======");
|
|
6874
|
-
merged.push(...remoteRegion);
|
|
6875
|
-
merged.push(">>>>>>> REMOTE");
|
|
7920
|
+
function extractAliases2(properties) {
|
|
7921
|
+
const raw = properties.aliases ?? properties.alias;
|
|
7922
|
+
if (!raw) return [];
|
|
7923
|
+
if (Array.isArray(raw)) return raw.filter((a) => typeof a === "string");
|
|
7924
|
+
if (typeof raw === "string")
|
|
7925
|
+
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
7926
|
+
return [];
|
|
7927
|
+
}
|
|
7928
|
+
var NodeVaultFS = class {
|
|
7929
|
+
constructor(rootPath, pathConfig) {
|
|
7930
|
+
this.rootPath = rootPath;
|
|
7931
|
+
this.includePatterns = pathConfig?.include ?? [];
|
|
7932
|
+
const configExclude = pathConfig?.exclude ?? [];
|
|
7933
|
+
const ignorePatterns = this.loadImNobsidianIgnore();
|
|
7934
|
+
this.excludePatterns = [...configExclude, ...ignorePatterns];
|
|
7935
|
+
}
|
|
7936
|
+
rootPath;
|
|
7937
|
+
includePatterns;
|
|
7938
|
+
excludePatterns;
|
|
7939
|
+
loadImNobsidianIgnore() {
|
|
7940
|
+
try {
|
|
7941
|
+
const ignorePath = join(this.rootPath, IGNORE_FILE);
|
|
7942
|
+
const content = readFileSync(ignorePath, "utf-8");
|
|
7943
|
+
return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
7944
|
+
} catch {
|
|
7945
|
+
return [];
|
|
6876
7946
|
}
|
|
6877
|
-
cursor = regionEnd;
|
|
6878
7947
|
}
|
|
6879
|
-
|
|
6880
|
-
|
|
7948
|
+
async readFile(path) {
|
|
7949
|
+
const fullPath = join(this.rootPath, path);
|
|
7950
|
+
return readFile(fullPath, "utf-8");
|
|
6881
7951
|
}
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
conflicts
|
|
6886
|
-
};
|
|
6887
|
-
}
|
|
6888
|
-
function computeHunks(base, modified, side) {
|
|
6889
|
-
const matches = lcsMatches(base, modified);
|
|
6890
|
-
const hunks = [];
|
|
6891
|
-
let prevBase = -1;
|
|
6892
|
-
let prevMod = -1;
|
|
6893
|
-
const emitGap = (curBase, curMod) => {
|
|
6894
|
-
const baseLen = curBase - (prevBase + 1);
|
|
6895
|
-
const modLen = curMod - (prevMod + 1);
|
|
6896
|
-
if (baseLen > 0 || modLen > 0) {
|
|
6897
|
-
hunks.push({
|
|
6898
|
-
baseStart: prevBase + 1,
|
|
6899
|
-
baseEnd: curBase,
|
|
6900
|
-
content: modified.slice(prevMod + 1, curMod),
|
|
6901
|
-
side
|
|
6902
|
-
});
|
|
6903
|
-
}
|
|
6904
|
-
};
|
|
6905
|
-
for (const [b, m] of matches) {
|
|
6906
|
-
emitGap(b, m);
|
|
6907
|
-
prevBase = b;
|
|
6908
|
-
prevMod = m;
|
|
7952
|
+
async readBinary(path) {
|
|
7953
|
+
const fullPath = join(this.rootPath, path);
|
|
7954
|
+
return readFile(fullPath);
|
|
6909
7955
|
}
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
}
|
|
6913
|
-
|
|
6914
|
-
const out = [];
|
|
6915
|
-
let b = regionStart;
|
|
6916
|
-
for (const hunk of group) {
|
|
6917
|
-
if (hunk.side !== side) continue;
|
|
6918
|
-
if (hunk.baseStart > b) out.push(...baseLines.slice(b, hunk.baseStart));
|
|
6919
|
-
out.push(...hunk.content);
|
|
6920
|
-
b = hunk.baseEnd;
|
|
7956
|
+
async writeFile(path, content) {
|
|
7957
|
+
const fullPath = join(this.rootPath, path);
|
|
7958
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
7959
|
+
await writeFile(fullPath, content, "utf-8");
|
|
6921
7960
|
}
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
}
|
|
6925
|
-
|
|
6926
|
-
|
|
6927
|
-
|
|
6928
|
-
|
|
6929
|
-
|
|
6930
|
-
|
|
6931
|
-
|
|
6932
|
-
const
|
|
6933
|
-
const
|
|
6934
|
-
|
|
6935
|
-
|
|
7961
|
+
async writeBinary(path, data) {
|
|
7962
|
+
const fullPath = join(this.rootPath, path);
|
|
7963
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
7964
|
+
await writeFile(fullPath, data);
|
|
7965
|
+
}
|
|
7966
|
+
async deleteFile(path) {
|
|
7967
|
+
const fullPath = join(this.rootPath, path);
|
|
7968
|
+
await unlink(fullPath);
|
|
7969
|
+
}
|
|
7970
|
+
async moveFile(from, to) {
|
|
7971
|
+
const fromPath = join(this.rootPath, from);
|
|
7972
|
+
const toPath = join(this.rootPath, to);
|
|
7973
|
+
await mkdir(dirname(toPath), { recursive: true });
|
|
7974
|
+
await rename(fromPath, toPath);
|
|
7975
|
+
}
|
|
7976
|
+
async exists(path) {
|
|
7977
|
+
try {
|
|
7978
|
+
await stat(join(this.rootPath, path));
|
|
7979
|
+
return true;
|
|
7980
|
+
} catch {
|
|
7981
|
+
return false;
|
|
6936
7982
|
}
|
|
6937
7983
|
}
|
|
6938
|
-
|
|
6939
|
-
|
|
6940
|
-
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
|
|
7984
|
+
async ensureFolder(path) {
|
|
7985
|
+
await mkdir(join(this.rootPath, path), { recursive: true });
|
|
7986
|
+
}
|
|
7987
|
+
async listMarkdownFiles() {
|
|
7988
|
+
const files = [];
|
|
7989
|
+
await this.walkDir(this.rootPath, files);
|
|
7990
|
+
return files;
|
|
7991
|
+
}
|
|
7992
|
+
async listMarkdownFileStats() {
|
|
7993
|
+
const stats = [];
|
|
7994
|
+
await this.walkDirStats(this.rootPath, stats);
|
|
7995
|
+
return stats;
|
|
7996
|
+
}
|
|
7997
|
+
async getFileStat(path) {
|
|
7998
|
+
try {
|
|
7999
|
+
const fullPath = join(this.rootPath, path);
|
|
8000
|
+
const fileStat = await stat(fullPath);
|
|
8001
|
+
return { path, mtime: fileStat.mtime.toISOString(), size: fileStat.size };
|
|
8002
|
+
} catch {
|
|
8003
|
+
return null;
|
|
6950
8004
|
}
|
|
6951
8005
|
}
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
return a.every((val, idx) => val === b[idx]);
|
|
6957
|
-
}
|
|
6958
|
-
|
|
6959
|
-
// src/conflict/resolver.ts
|
|
6960
|
-
var ConflictResolver = class {
|
|
6961
|
-
constructor(stateDb, vaultFs) {
|
|
6962
|
-
this.stateDb = stateDb;
|
|
6963
|
-
this.vaultFs = vaultFs;
|
|
8006
|
+
async listNonMarkdownFiles() {
|
|
8007
|
+
const files = [];
|
|
8008
|
+
await this.walkDirNonMd(this.rootPath, files);
|
|
8009
|
+
return files;
|
|
6964
8010
|
}
|
|
6965
|
-
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
|
|
6974
|
-
|
|
6975
|
-
|
|
6976
|
-
|
|
8011
|
+
matchesPattern(relativePath, pattern) {
|
|
8012
|
+
if (pattern === "**/*" || pattern === "**") return true;
|
|
8013
|
+
if (pattern.endsWith("/**")) {
|
|
8014
|
+
const dir = pattern.slice(0, -3);
|
|
8015
|
+
return relativePath.startsWith(dir + "/") || relativePath === dir;
|
|
8016
|
+
} else if (pattern.startsWith("*.")) {
|
|
8017
|
+
const ext = pattern.slice(1);
|
|
8018
|
+
return relativePath.endsWith(ext);
|
|
8019
|
+
} else if (pattern.includes("*")) {
|
|
8020
|
+
const regexStr = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
|
|
8021
|
+
return new RegExp("^" + regexStr + "$").test(relativePath);
|
|
8022
|
+
} else {
|
|
8023
|
+
return relativePath === pattern || relativePath.startsWith(pattern + "/");
|
|
6977
8024
|
}
|
|
6978
8025
|
}
|
|
6979
|
-
|
|
6980
|
-
|
|
6981
|
-
|
|
6982
|
-
|
|
6983
|
-
case "remote-first":
|
|
6984
|
-
return this.resolve(conflict, "remote");
|
|
6985
|
-
case "duplicate":
|
|
6986
|
-
return this.resolve(conflict, "duplicate");
|
|
6987
|
-
case "manual":
|
|
6988
|
-
return this.resolve(conflict, "merge");
|
|
8026
|
+
isExcluded(relativePath) {
|
|
8027
|
+
if (this.includePatterns.length > 0) {
|
|
8028
|
+
const included = this.includePatterns.some((p) => this.matchesPattern(relativePath, p));
|
|
8029
|
+
if (!included) return true;
|
|
6989
8030
|
}
|
|
8031
|
+
return this.excludePatterns.some((p) => this.matchesPattern(relativePath, p));
|
|
6990
8032
|
}
|
|
6991
|
-
async
|
|
6992
|
-
const
|
|
6993
|
-
for (const
|
|
6994
|
-
const
|
|
6995
|
-
|
|
8033
|
+
async walkDirNonMd(dir, result) {
|
|
8034
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
8035
|
+
for (const entry of entries) {
|
|
8036
|
+
const fullPath = join(dir, entry.name);
|
|
8037
|
+
if (entry.name.startsWith(".")) continue;
|
|
8038
|
+
const relativePath = relative(this.rootPath, fullPath);
|
|
8039
|
+
if (this.isExcluded(relativePath)) continue;
|
|
8040
|
+
if (entry.isDirectory()) {
|
|
8041
|
+
await this.walkDirNonMd(fullPath, result);
|
|
8042
|
+
} else if (entry.isFile() && !entry.name.endsWith(".md") && !entry.name.endsWith(".base")) {
|
|
8043
|
+
const fileStat = await stat(fullPath);
|
|
8044
|
+
result.push({
|
|
8045
|
+
path: relativePath,
|
|
8046
|
+
size: fileStat.size,
|
|
8047
|
+
mtime: fileStat.mtime.toISOString()
|
|
8048
|
+
});
|
|
8049
|
+
}
|
|
6996
8050
|
}
|
|
6997
|
-
return results;
|
|
6998
8051
|
}
|
|
6999
|
-
async
|
|
7000
|
-
const
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
|
|
8052
|
+
async walkDirStats(dir, result) {
|
|
8053
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
8054
|
+
for (const entry of entries) {
|
|
8055
|
+
const fullPath = join(dir, entry.name);
|
|
8056
|
+
if (entry.name.startsWith(".")) continue;
|
|
8057
|
+
const relativePath = relative(this.rootPath, fullPath);
|
|
8058
|
+
if (this.isExcluded(relativePath)) continue;
|
|
8059
|
+
if (entry.isDirectory()) {
|
|
8060
|
+
await this.walkDirStats(fullPath, result);
|
|
8061
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
8062
|
+
const fileStat = await stat(fullPath);
|
|
8063
|
+
result.push({
|
|
8064
|
+
path: relativePath,
|
|
8065
|
+
mtime: fileStat.mtime.toISOString(),
|
|
8066
|
+
size: fileStat.size
|
|
8067
|
+
});
|
|
8068
|
+
}
|
|
8069
|
+
}
|
|
7008
8070
|
}
|
|
7009
|
-
async
|
|
7010
|
-
await
|
|
7011
|
-
const
|
|
7012
|
-
|
|
7013
|
-
|
|
7014
|
-
|
|
7015
|
-
|
|
7016
|
-
|
|
7017
|
-
|
|
7018
|
-
|
|
8071
|
+
async walkDir(dir, result) {
|
|
8072
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
8073
|
+
for (const entry of entries) {
|
|
8074
|
+
const fullPath = join(dir, entry.name);
|
|
8075
|
+
if (entry.name.startsWith(".")) continue;
|
|
8076
|
+
const relativePath = relative(this.rootPath, fullPath);
|
|
8077
|
+
if (this.isExcluded(relativePath)) continue;
|
|
8078
|
+
if (entry.isDirectory()) {
|
|
8079
|
+
await this.walkDir(fullPath, result);
|
|
8080
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
8081
|
+
const content = await readFile(fullPath, "utf-8");
|
|
8082
|
+
const fileStat = await stat(fullPath);
|
|
8083
|
+
result.push({
|
|
8084
|
+
path: relativePath,
|
|
8085
|
+
content,
|
|
8086
|
+
mtime: fileStat.mtime.toISOString()
|
|
8087
|
+
});
|
|
8088
|
+
}
|
|
8089
|
+
}
|
|
7019
8090
|
}
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
|
|
8091
|
+
};
|
|
8092
|
+
|
|
8093
|
+
// src/converter/pre-processors/inline-db.ts
|
|
8094
|
+
var INLINE_DB_START = new RegExp(`%% ${MARKER_BRAND_RE}:inline-db:([^\\s]+) %%`, "g");
|
|
8095
|
+
var INLINE_DB_END = new RegExp(`%% ${MARKER_BRAND_RE}:end %%`, "g");
|
|
8096
|
+
var InlineDBParser = class {
|
|
8097
|
+
name = "InlineDBParser";
|
|
8098
|
+
order = 40;
|
|
8099
|
+
process(input) {
|
|
8100
|
+
const inlineDbBlocks = [];
|
|
8101
|
+
const content = input.content;
|
|
8102
|
+
const startMatches = [...content.matchAll(INLINE_DB_START)];
|
|
8103
|
+
for (const match of startMatches) {
|
|
8104
|
+
const startIdx = match.index;
|
|
8105
|
+
const params = parseParams(match[1]);
|
|
8106
|
+
const afterStart = content.indexOf("\n", startIdx) + 1;
|
|
8107
|
+
const endMatch = INLINE_DB_END.exec(content.slice(afterStart));
|
|
8108
|
+
if (endMatch) {
|
|
8109
|
+
const tableContent = content.slice(afterStart, afterStart + endMatch.index);
|
|
8110
|
+
inlineDbBlocks.push({
|
|
8111
|
+
id: params["id"] ?? "",
|
|
8112
|
+
title: params["title"] ?? "",
|
|
8113
|
+
tableMarkdown: tableContent.trim(),
|
|
8114
|
+
startIdx,
|
|
8115
|
+
endIdx: afterStart + endMatch.index + endMatch[0].length
|
|
8116
|
+
});
|
|
8117
|
+
}
|
|
8118
|
+
INLINE_DB_END.lastIndex = 0;
|
|
7028
8119
|
}
|
|
7029
8120
|
return {
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
8121
|
+
content,
|
|
8122
|
+
metadata: {
|
|
8123
|
+
...input.metadata,
|
|
8124
|
+
inlineDbBlocks
|
|
8125
|
+
}
|
|
7034
8126
|
};
|
|
7035
8127
|
}
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
const
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
this.stateDb.updateHash(
|
|
7044
|
-
conflict.syncRecord.id,
|
|
7045
|
-
localHash,
|
|
7046
|
-
Buffer.from(conflict.localContent, "utf-8")
|
|
7047
|
-
);
|
|
7048
|
-
this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
|
|
7049
|
-
return { path: originalPath, choice: "duplicate", success: true };
|
|
7050
|
-
}
|
|
7051
|
-
generateDiff(conflict) {
|
|
7052
|
-
const localLines = conflict.localContent.split("\n");
|
|
7053
|
-
const remoteLines = conflict.remoteContent.split("\n");
|
|
7054
|
-
const lines = [];
|
|
7055
|
-
lines.push(`--- local: ${conflict.syncRecord.obsidianPath}`);
|
|
7056
|
-
lines.push(`+++ remote: Notion (${conflict.remoteChange.pageId})`);
|
|
7057
|
-
lines.push("");
|
|
7058
|
-
const maxLen = Math.max(localLines.length, remoteLines.length);
|
|
7059
|
-
for (let i = 0; i < maxLen; i++) {
|
|
7060
|
-
const localLine = localLines[i];
|
|
7061
|
-
const remoteLine = remoteLines[i];
|
|
7062
|
-
if (localLine === remoteLine) {
|
|
7063
|
-
lines.push(` ${localLine ?? ""}`);
|
|
7064
|
-
} else {
|
|
7065
|
-
if (localLine !== void 0) lines.push(`- ${localLine}`);
|
|
7066
|
-
if (remoteLine !== void 0) lines.push(`+ ${remoteLine}`);
|
|
7067
|
-
}
|
|
8128
|
+
};
|
|
8129
|
+
function parseParams(paramString) {
|
|
8130
|
+
const params = {};
|
|
8131
|
+
for (const pair of paramString.split("&")) {
|
|
8132
|
+
const [key, value] = pair.split("=");
|
|
8133
|
+
if (key && value) {
|
|
8134
|
+
params[key] = decodeURIComponent(value);
|
|
7068
8135
|
}
|
|
7069
|
-
return lines.join("\n");
|
|
7070
8136
|
}
|
|
7071
|
-
|
|
8137
|
+
return params;
|
|
8138
|
+
}
|
|
7072
8139
|
|
|
7073
8140
|
// src/structure/tree-mapper.ts
|
|
7074
8141
|
var TreeMapper = class {
|
|
@@ -7479,8 +8546,7 @@ var ViewDataProvider = class {
|
|
|
7479
8546
|
}
|
|
7480
8547
|
async collectEntries(folderPath) {
|
|
7481
8548
|
const allFiles = await this.vaultFs.listMarkdownFiles();
|
|
7482
|
-
const
|
|
7483
|
-
const dbFiles = allFiles.filter((f) => f.path.startsWith(prefix));
|
|
8549
|
+
const dbFiles = selectDbRowFiles(allFiles, folderPath);
|
|
7484
8550
|
const entries = [];
|
|
7485
8551
|
for (const file of dbFiles) {
|
|
7486
8552
|
try {
|
|
@@ -7542,11 +8608,11 @@ var ViewDataProvider = class {
|
|
|
7542
8608
|
};
|
|
7543
8609
|
function parseEntry(path, content) {
|
|
7544
8610
|
try {
|
|
7545
|
-
const parsed =
|
|
8611
|
+
const parsed = matter(content);
|
|
7546
8612
|
const fm = parsed.data;
|
|
7547
|
-
const title = fm.title ?? extractTitleFromPath2(path);
|
|
7548
|
-
const icon = fm.icon;
|
|
7549
|
-
const cover = fm.cover;
|
|
8613
|
+
const title = coerceString(fm.title) ?? extractTitleFromPath2(path);
|
|
8614
|
+
const icon = coerceString(fm.icon);
|
|
8615
|
+
const cover = coerceCover(fm.cover);
|
|
7550
8616
|
const properties = {};
|
|
7551
8617
|
for (const [key, value] of Object.entries(fm)) {
|
|
7552
8618
|
if (key === "title" || key === "icon" || key === "cover") continue;
|
|
@@ -7557,6 +8623,36 @@ function parseEntry(path, content) {
|
|
|
7557
8623
|
return null;
|
|
7558
8624
|
}
|
|
7559
8625
|
}
|
|
8626
|
+
function coerceCover(raw) {
|
|
8627
|
+
const firstUrl = (v) => {
|
|
8628
|
+
if (typeof v === "string") return v.trim() || void 0;
|
|
8629
|
+
if (v && typeof v === "object") {
|
|
8630
|
+
const o = v;
|
|
8631
|
+
const url = o.url ?? o.external?.url;
|
|
8632
|
+
if (typeof url === "string") return url.trim() || void 0;
|
|
8633
|
+
}
|
|
8634
|
+
return void 0;
|
|
8635
|
+
};
|
|
8636
|
+
if (Array.isArray(raw)) {
|
|
8637
|
+
for (const item of raw) {
|
|
8638
|
+
const url = firstUrl(item);
|
|
8639
|
+
if (url) return url;
|
|
8640
|
+
}
|
|
8641
|
+
return void 0;
|
|
8642
|
+
}
|
|
8643
|
+
return firstUrl(raw);
|
|
8644
|
+
}
|
|
8645
|
+
function coerceString(raw) {
|
|
8646
|
+
if (typeof raw === "string") return raw.trim() || void 0;
|
|
8647
|
+
if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
|
|
8648
|
+
if (Array.isArray(raw)) {
|
|
8649
|
+
for (const item of raw) {
|
|
8650
|
+
const s = coerceString(item);
|
|
8651
|
+
if (s) return s;
|
|
8652
|
+
}
|
|
8653
|
+
}
|
|
8654
|
+
return void 0;
|
|
8655
|
+
}
|
|
7560
8656
|
function normalizePropertyValue(value) {
|
|
7561
8657
|
if (value === null || value === void 0) return null;
|
|
7562
8658
|
if (typeof value === "string") return value;
|
|
@@ -7600,16 +8696,16 @@ var EntryEditor = class {
|
|
|
7600
8696
|
vaultFs;
|
|
7601
8697
|
async updateProperty(entryPath, propertyName, newValue) {
|
|
7602
8698
|
const content = await this.vaultFs.readFile(entryPath);
|
|
7603
|
-
const parsed =
|
|
8699
|
+
const parsed = matter(content);
|
|
7604
8700
|
parsed.data[propertyName] = newValue;
|
|
7605
|
-
const updated =
|
|
8701
|
+
const updated = stringifyFrontmatter(parsed.content, parsed.data);
|
|
7606
8702
|
await this.vaultFs.writeFile(entryPath, updated);
|
|
7607
8703
|
}
|
|
7608
8704
|
async createEntry(folderPath, title, properties) {
|
|
7609
8705
|
const safeName = title.replace(/[\\/:*?"<>|]/g, "_").trim() || "Untitled";
|
|
7610
8706
|
const filePath = `${folderPath}/${safeName}.md`;
|
|
7611
8707
|
const frontmatter = { title, ...properties };
|
|
7612
|
-
const content =
|
|
8708
|
+
const content = stringifyFrontmatter("", frontmatter);
|
|
7613
8709
|
await this.vaultFs.ensureFolder(folderPath);
|
|
7614
8710
|
await this.vaultFs.writeFile(filePath, content);
|
|
7615
8711
|
return filePath;
|
|
@@ -7666,6 +8762,77 @@ ${lines.join("\n")}
|
|
|
7666
8762
|
}`;
|
|
7667
8763
|
}
|
|
7668
8764
|
|
|
7669
|
-
|
|
8765
|
+
// src/audit/fidelity.ts
|
|
8766
|
+
var NOTION_ID_RE = /[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi;
|
|
8767
|
+
var MD_LINK_RE = new RegExp(
|
|
8768
|
+
`\\[[^\\]]*\\]\\((https?://[^)\\s]*notion\\.so/[^)\\s]*)\\)(\\s*%%\\s*${MARKER_BRAND}\\b)?`,
|
|
8769
|
+
"gi"
|
|
8770
|
+
);
|
|
8771
|
+
var MENTION_PAGE_RE = /<mention-page\b[\s\S]*?(?:\/>|>[\s\S]*?<\/mention-page>)/gi;
|
|
8772
|
+
function lastNotionId(text) {
|
|
8773
|
+
const matches = text.match(NOTION_ID_RE);
|
|
8774
|
+
if (!matches || matches.length === 0) return null;
|
|
8775
|
+
return compactNotionId(matches[matches.length - 1]);
|
|
8776
|
+
}
|
|
8777
|
+
function classifyBodyFidelity(body, ctx) {
|
|
8778
|
+
const result = {
|
|
8779
|
+
preservedMarkers: 0,
|
|
8780
|
+
externalLinks: 0,
|
|
8781
|
+
selfReferences: 0,
|
|
8782
|
+
defects: []
|
|
8783
|
+
};
|
|
8784
|
+
const own = ctx.ownPageId ? compactNotionId(ctx.ownPageId) : null;
|
|
8785
|
+
const titleOf = (id) => ctx.titleById?.get(id) ?? null;
|
|
8786
|
+
const classifyId = (id, form, raw) => {
|
|
8787
|
+
if (id === null) {
|
|
8788
|
+
result.externalLinks += 1;
|
|
8789
|
+
return;
|
|
8790
|
+
}
|
|
8791
|
+
if (own !== null && id === own) {
|
|
8792
|
+
result.selfReferences += 1;
|
|
8793
|
+
return;
|
|
8794
|
+
}
|
|
8795
|
+
if (ctx.knownPageIds.has(id)) {
|
|
8796
|
+
result.defects.push({ form, targetPageId: id, targetTitle: titleOf(id), raw: raw.trim() });
|
|
8797
|
+
return;
|
|
8798
|
+
}
|
|
8799
|
+
result.externalLinks += 1;
|
|
8800
|
+
};
|
|
8801
|
+
for (const m of body.matchAll(MD_LINK_RE)) {
|
|
8802
|
+
const url = m[1] ?? "";
|
|
8803
|
+
const hasMarker = Boolean(m[2]);
|
|
8804
|
+
if (hasMarker) {
|
|
8805
|
+
result.preservedMarkers += 1;
|
|
8806
|
+
continue;
|
|
8807
|
+
}
|
|
8808
|
+
classifyId(lastNotionId(url), "md-link", m[0]);
|
|
8809
|
+
}
|
|
8810
|
+
for (const m of body.matchAll(MENTION_PAGE_RE)) {
|
|
8811
|
+
classifyId(lastNotionId(m[0]), "mention-page", m[0]);
|
|
8812
|
+
}
|
|
8813
|
+
return result;
|
|
8814
|
+
}
|
|
8815
|
+
function summarizeFidelity(perFile) {
|
|
8816
|
+
const summary = {
|
|
8817
|
+
files: perFile.length,
|
|
8818
|
+
preservedMarkers: 0,
|
|
8819
|
+
externalLinks: 0,
|
|
8820
|
+
selfReferences: 0,
|
|
8821
|
+
defects: 0,
|
|
8822
|
+
defectsByFile: {}
|
|
8823
|
+
};
|
|
8824
|
+
for (const { path, classification } of perFile) {
|
|
8825
|
+
summary.preservedMarkers += classification.preservedMarkers;
|
|
8826
|
+
summary.externalLinks += classification.externalLinks;
|
|
8827
|
+
summary.selfReferences += classification.selfReferences;
|
|
8828
|
+
summary.defects += classification.defects.length;
|
|
8829
|
+
if (classification.defects.length > 0) {
|
|
8830
|
+
summary.defectsByFile[path] = classification.defects;
|
|
8831
|
+
}
|
|
8832
|
+
}
|
|
8833
|
+
return summary;
|
|
8834
|
+
}
|
|
8835
|
+
|
|
8836
|
+
export { BaseFileGenerator, BlockConverter, CONFIG_FILE, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConversionPipeline, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FrontmatterExtractor, FrontmatterGenerator, GITIGNORE_ENTRY, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, ImageHandler, InlineAnnotationPreserver, InlineDBParser, LocalImageRestorer, MARKER_BRAND, MathNormalizer, MentionToWikilink, NOTION_TO_BASES_VIEW, NodeVaultFS, NotionBlockBuilder, NotionClient, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, STATE_DB_FILE, STATE_DB_PATH, SidecarGenerator, StateDB, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WatchSyncService, 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 };
|
|
7670
8837
|
//# sourceMappingURL=index.js.map
|
|
7671
8838
|
//# sourceMappingURL=index.js.map
|