@im-nobsidian/core 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +137 -9
- package/dist/index.js +1621 -249
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -659,6 +659,16 @@ var StateDB = class _StateDB {
|
|
|
659
659
|
}
|
|
660
660
|
};
|
|
661
661
|
|
|
662
|
+
// src/utils/notion-file-url.ts
|
|
663
|
+
function isNotionHostedFileUrl(url) {
|
|
664
|
+
return url.includes("secure.notion-static.com") || url.includes("prod-files-secure") || // 신형 파일 URL (2024+): https://file.notion.so/f/f/<space>/<file>/<name>?table=block&...
|
|
665
|
+
url.includes("file.notion.so");
|
|
666
|
+
}
|
|
667
|
+
var NOTION_ATTACHMENT_URI_RE = /^attachment:([0-9a-f-]+):(.+)$/;
|
|
668
|
+
function isNotionAttachmentUri(url) {
|
|
669
|
+
return NOTION_ATTACHMENT_URI_RE.test(url);
|
|
670
|
+
}
|
|
671
|
+
|
|
662
672
|
// src/notion/property-mapper.ts
|
|
663
673
|
var DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
664
674
|
var WIKILINK_REGEX = /^\[\[(.+?)(?:\|.+?)?\]\]$/;
|
|
@@ -773,6 +783,16 @@ var PropertyMapper = class {
|
|
|
773
783
|
}
|
|
774
784
|
case "files": {
|
|
775
785
|
const fileList = Array.isArray(value) ? value : [value];
|
|
786
|
+
const urls = fileList.map((f) => {
|
|
787
|
+
if (typeof f === "object" && f !== null && "url" in f) {
|
|
788
|
+
return String(f.url ?? "");
|
|
789
|
+
}
|
|
790
|
+
return String(f);
|
|
791
|
+
});
|
|
792
|
+
const hasProtected = urls.some(
|
|
793
|
+
(u) => WIKILINK_REGEX.test(u.trim()) || isNotionHostedFileUrl(u)
|
|
794
|
+
);
|
|
795
|
+
if (hasProtected) return null;
|
|
776
796
|
const filesArr = fileList.map((f) => {
|
|
777
797
|
if (typeof f === "object" && f !== null && "url" in f) {
|
|
778
798
|
const obj = f;
|
|
@@ -1108,18 +1128,60 @@ var NotionClient = class _NotionClient {
|
|
|
1108
1128
|
return titleArr?.[0]?.plain_text ?? "";
|
|
1109
1129
|
}
|
|
1110
1130
|
/**
|
|
1111
|
-
* 발견된 DB 가 **동기화 가능한지**(접근 가능한 data source 가 있는지)
|
|
1131
|
+
* 발견된 DB 가 **동기화 가능한지**(접근 가능한 data source 가 있는지) 판별한다.
|
|
1112
1132
|
* 신 모델(2025-09-03)에서 링크드 DB·미공유 데이터 소스·삭제 DB 는 `data_sources` 가
|
|
1113
1133
|
* 비어, 행 조회(`dataSources.query`)가 404 로 실패하고 그 전에 생성된 빈 폴더/`.base` 만
|
|
1114
1134
|
* 남긴다. 발견 단계에서 미리 걸러 **빈 폴더/.base 오염 + 매 pull 의 404 노이즈**를 차단한다.
|
|
1115
1135
|
* 제목은 함께 반환해 호출처가 추가 조회 없이 폴더명을 잡게 한다.
|
|
1136
|
+
*
|
|
1137
|
+
* F25: 원본이 같은 공유 범위에 있으면 linked view 컨테이너도 `data_sources` 가 **채워져**
|
|
1138
|
+
* 온다(실측 — 워크스페이스 8건). 이때 data source 의 parent database 는 원본이므로,
|
|
1139
|
+
* 첫 소스의 parent 가 자신이 아니면 `linkedOriginalDbId` 로 알려 컨테이너가 원본과 같은
|
|
1140
|
+
* 행 집합을 이중 pull(폴더 릴레이 재배치 churn)하는 것을 발견 단계에서 차단한다.
|
|
1116
1141
|
*/
|
|
1117
1142
|
async getDatabaseSyncability(databaseId) {
|
|
1118
1143
|
const db = await this.withRateLimit(
|
|
1119
1144
|
() => this.client.databases.retrieve({ database_id: databaseId })
|
|
1120
1145
|
);
|
|
1121
1146
|
const title = Array.isArray(db.title) ? db.title[0]?.plain_text ?? "" : "";
|
|
1122
|
-
|
|
1147
|
+
const firstDsId = db.data_sources?.[0]?.id;
|
|
1148
|
+
if (!firstDsId) return { title, queryable: false };
|
|
1149
|
+
try {
|
|
1150
|
+
const ds = await this.withRateLimit(
|
|
1151
|
+
() => this.client.dataSources.retrieve({ data_source_id: firstDsId })
|
|
1152
|
+
);
|
|
1153
|
+
const ownerDbId = ds.parent?.database_id;
|
|
1154
|
+
if (ownerDbId && normalizeNotionId(ownerDbId) !== normalizeNotionId(databaseId)) {
|
|
1155
|
+
return { title, queryable: true, linkedOriginalDbId: ownerDbId };
|
|
1156
|
+
}
|
|
1157
|
+
} catch {
|
|
1158
|
+
}
|
|
1159
|
+
return { title, queryable: true };
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* linked database view 컨테이너를 원본 DB 로 해소한다.
|
|
1163
|
+
* 컨테이너 자체는 `data_sources` 가 비어 행 조회가 불가능하지만(getDatabaseSyncability
|
|
1164
|
+
* queryable=false), Views API 의 뷰 상세에는 원본 `data_source_id` 가 실리고 그 data source
|
|
1165
|
+
* 의 parent 가 원본 database 다. 해소 실패(뷰 없음/권한 없음/자기참조)는 null — 호출처가
|
|
1166
|
+
* 기존대로 접근 불가 처리한다.
|
|
1167
|
+
*/
|
|
1168
|
+
async resolveLinkedDatabase(databaseId) {
|
|
1169
|
+
try {
|
|
1170
|
+
const views = await this.listDatabaseViews(databaseId);
|
|
1171
|
+
for (const view of views) {
|
|
1172
|
+
if (!view.dataSourceId) continue;
|
|
1173
|
+
const ds = await this.withRateLimit(
|
|
1174
|
+
() => this.client.dataSources.retrieve({ data_source_id: view.dataSourceId })
|
|
1175
|
+
);
|
|
1176
|
+
const originalDbId = ds.parent?.database_id;
|
|
1177
|
+
if (!originalDbId) continue;
|
|
1178
|
+
if (originalDbId.replace(/-/g, "") === databaseId.replace(/-/g, "")) continue;
|
|
1179
|
+
return { originalDbId, viewName: view.name ?? "" };
|
|
1180
|
+
}
|
|
1181
|
+
return null;
|
|
1182
|
+
} catch {
|
|
1183
|
+
return null;
|
|
1184
|
+
}
|
|
1123
1185
|
}
|
|
1124
1186
|
async getDatabaseSchema(databaseId) {
|
|
1125
1187
|
const db = await this.fetchDatabaseModern(databaseId);
|
|
@@ -1329,7 +1391,17 @@ var NotionClient = class _NotionClient {
|
|
|
1329
1391
|
await this.withRateLimit(() => this.client.blocks.delete({ block_id: blockId }));
|
|
1330
1392
|
}
|
|
1331
1393
|
// ─── File Upload ───
|
|
1394
|
+
/** single_part 업로드 상한 (Notion API 규격 20MB) */
|
|
1395
|
+
static SINGLE_PART_MAX_BYTES = 20 * 1024 * 1024;
|
|
1396
|
+
/** multi_part 파트 크기 — 규격상 마지막 파트 제외 5~20MB, 10MB 고정 사용 */
|
|
1397
|
+
static MULTI_PART_CHUNK_BYTES = 10 * 1024 * 1024;
|
|
1332
1398
|
async uploadFile(fileData, filename, contentType) {
|
|
1399
|
+
if (fileData.type !== contentType) {
|
|
1400
|
+
fileData = new Blob([fileData], { type: contentType });
|
|
1401
|
+
}
|
|
1402
|
+
if (fileData.size > _NotionClient.SINGLE_PART_MAX_BYTES) {
|
|
1403
|
+
return this.uploadFileMultiPart(fileData, filename, contentType);
|
|
1404
|
+
}
|
|
1333
1405
|
const upload = await this.withRateLimit(
|
|
1334
1406
|
() => this.client.fileUploads.create({ filename, content_type: contentType })
|
|
1335
1407
|
);
|
|
@@ -1348,6 +1420,34 @@ var NotionClient = class _NotionClient {
|
|
|
1348
1420
|
}
|
|
1349
1421
|
return fileUploadId;
|
|
1350
1422
|
}
|
|
1423
|
+
/** 20MB 초과 파일의 multi_part 업로드 — part_number 는 API 규격상 문자열("1"부터) */
|
|
1424
|
+
async uploadFileMultiPart(fileData, filename, contentType) {
|
|
1425
|
+
const chunkSize = _NotionClient.MULTI_PART_CHUNK_BYTES;
|
|
1426
|
+
const numberOfParts = Math.ceil(fileData.size / chunkSize);
|
|
1427
|
+
const upload = await this.withRateLimit(
|
|
1428
|
+
() => this.client.fileUploads.create({
|
|
1429
|
+
mode: "multi_part",
|
|
1430
|
+
number_of_parts: numberOfParts,
|
|
1431
|
+
filename,
|
|
1432
|
+
content_type: contentType
|
|
1433
|
+
})
|
|
1434
|
+
);
|
|
1435
|
+
const fileUploadId = upload.id;
|
|
1436
|
+
for (let part = 1; part <= numberOfParts; part++) {
|
|
1437
|
+
const chunk = fileData.slice((part - 1) * chunkSize, part * chunkSize, contentType);
|
|
1438
|
+
await this.withRateLimit(
|
|
1439
|
+
() => this.client.fileUploads.send({
|
|
1440
|
+
file_upload_id: fileUploadId,
|
|
1441
|
+
part_number: String(part),
|
|
1442
|
+
file: { data: chunk, filename }
|
|
1443
|
+
})
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
await this.withRateLimit(
|
|
1447
|
+
() => this.client.fileUploads.complete({ file_upload_id: fileUploadId })
|
|
1448
|
+
);
|
|
1449
|
+
return fileUploadId;
|
|
1450
|
+
}
|
|
1351
1451
|
// ─── Search & Navigation ───
|
|
1352
1452
|
async search(params) {
|
|
1353
1453
|
const response = await this.withRateLimit(
|
|
@@ -2224,7 +2324,23 @@ var COLUMN_LIST_START = compactMarker("column-list:start");
|
|
|
2224
2324
|
var COLUMN_LIST_END = compactMarker("column-list:end");
|
|
2225
2325
|
var COLUMN_SEP = compactMarker("column");
|
|
2226
2326
|
var TOC_MARKER = compactMarker("toc");
|
|
2327
|
+
function syncedStartMarker(kind, url) {
|
|
2328
|
+
return compactMarker(`synced:start:kind=${kind}&url=${encodeURIComponent(url)}`);
|
|
2329
|
+
}
|
|
2330
|
+
var SYNCED_END = compactMarker("synced:end");
|
|
2227
2331
|
var BREADCRUMB_MARKER = compactMarker("breadcrumb");
|
|
2332
|
+
function calloutStyleMarker(params) {
|
|
2333
|
+
const kv = [];
|
|
2334
|
+
if (params.icon) kv.push(`icon=${encodeURIComponent(params.icon)}`);
|
|
2335
|
+
if (params.color) kv.push(`color=${encodeURIComponent(params.color)}`);
|
|
2336
|
+
return compactMarker(`callout-style:${kv.join("&")}`);
|
|
2337
|
+
}
|
|
2338
|
+
function toggleColorMarker(color) {
|
|
2339
|
+
return compactMarker(`toggle-color:${color}`);
|
|
2340
|
+
}
|
|
2341
|
+
function blockColorMarker(color) {
|
|
2342
|
+
return compactMarker(`block-color:${color}`);
|
|
2343
|
+
}
|
|
2228
2344
|
var PROPERTIES_TAG = `# ${MARKER_BRAND}:properties`;
|
|
2229
2345
|
var MARKER_BRAND_RE = MARKER_BRAND;
|
|
2230
2346
|
|
|
@@ -2312,6 +2428,50 @@ var FrontmatterExtractor = class {
|
|
|
2312
2428
|
}
|
|
2313
2429
|
};
|
|
2314
2430
|
|
|
2431
|
+
// src/utils/md-regions.ts
|
|
2432
|
+
function computeAnchor(content, idx) {
|
|
2433
|
+
const before = content.slice(Math.max(0, idx - 32), idx);
|
|
2434
|
+
const sameLine = before.split("\n").pop() ?? "";
|
|
2435
|
+
if (sameLine.trim().length >= 4) return sameLine;
|
|
2436
|
+
const prevLines = content.slice(0, idx).split("\n");
|
|
2437
|
+
for (let i = prevLines.length - 2; i >= 0; i--) {
|
|
2438
|
+
const line = prevLines[i].trim();
|
|
2439
|
+
if (line.length > 0) return line.slice(0, 48);
|
|
2440
|
+
}
|
|
2441
|
+
return "";
|
|
2442
|
+
}
|
|
2443
|
+
function mapOutsideCodeFences(content, fn) {
|
|
2444
|
+
const lines = content.split("\n");
|
|
2445
|
+
const out = [];
|
|
2446
|
+
let buffer = [];
|
|
2447
|
+
let fence = null;
|
|
2448
|
+
const flush = () => {
|
|
2449
|
+
if (buffer.length > 0) {
|
|
2450
|
+
out.push(fn(buffer.join("\n")));
|
|
2451
|
+
buffer = [];
|
|
2452
|
+
}
|
|
2453
|
+
};
|
|
2454
|
+
for (const line of lines) {
|
|
2455
|
+
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
|
2456
|
+
if (fence === null && fenceMatch) {
|
|
2457
|
+
flush();
|
|
2458
|
+
fence = fenceMatch[1][0].repeat(3);
|
|
2459
|
+
out.push(line);
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
2462
|
+
if (fence !== null) {
|
|
2463
|
+
out.push(line);
|
|
2464
|
+
if (fenceMatch && fenceMatch[1].startsWith(fence)) {
|
|
2465
|
+
fence = null;
|
|
2466
|
+
}
|
|
2467
|
+
continue;
|
|
2468
|
+
}
|
|
2469
|
+
buffer.push(line);
|
|
2470
|
+
}
|
|
2471
|
+
flush();
|
|
2472
|
+
return out.join("\n");
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2315
2475
|
// src/converter/pre-processors/wikilink.ts
|
|
2316
2476
|
var WIKILINK_REGEX2 = /(?<!!)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
|
2317
2477
|
var WikilinkResolver = class {
|
|
@@ -2328,7 +2488,7 @@ var WikilinkResolver = class {
|
|
|
2328
2488
|
const preserveMarkers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
|
|
2329
2489
|
const resolved = input.content.replace(
|
|
2330
2490
|
WIKILINK_REGEX2,
|
|
2331
|
-
(_match, target, display) => {
|
|
2491
|
+
(_match, target, display, offset) => {
|
|
2332
2492
|
const label = display ?? target;
|
|
2333
2493
|
if (this.resolve) {
|
|
2334
2494
|
const entry = this.resolve(target);
|
|
@@ -2336,10 +2496,12 @@ var WikilinkResolver = class {
|
|
|
2336
2496
|
return `<mention-page id="${entry.notionPageId}">${label}</mention-page>`;
|
|
2337
2497
|
}
|
|
2338
2498
|
}
|
|
2499
|
+
const params = display ? { text: target, display } : { text: target };
|
|
2500
|
+
params.__anchor = computeAnchor(input.content, offset);
|
|
2339
2501
|
preserveMarkers.push({
|
|
2340
2502
|
type: "wikilink",
|
|
2341
|
-
params
|
|
2342
|
-
startIndex:
|
|
2503
|
+
params,
|
|
2504
|
+
startIndex: offset
|
|
2343
2505
|
});
|
|
2344
2506
|
const encodedTarget = encodeURIComponent(target);
|
|
2345
2507
|
return `[${label}](${WIKILINK_PROTOCOL}${encodedTarget})`;
|
|
@@ -2444,6 +2606,11 @@ var EmbedResolver = class {
|
|
|
2444
2606
|
}
|
|
2445
2607
|
return `})`;
|
|
2446
2608
|
}
|
|
2609
|
+
if (isPush && !isExternalUrl(target) && isAttachmentFile(target)) {
|
|
2610
|
+
const fileName = target.split("/").pop() ?? target;
|
|
2611
|
+
return `> \u{1F4CE} ${fileName}
|
|
2612
|
+
> ${spacedMarker(`local-file:${target}`)}`;
|
|
2613
|
+
}
|
|
2447
2614
|
const encoded = encodeURIComponent(target);
|
|
2448
2615
|
return `[${target}](${EMBED_PROTOCOL}${encoded})`;
|
|
2449
2616
|
});
|
|
@@ -2466,6 +2633,11 @@ var EmbedResolver = class {
|
|
|
2466
2633
|
function isImageFile(path) {
|
|
2467
2634
|
return /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(path);
|
|
2468
2635
|
}
|
|
2636
|
+
function isAttachmentFile(target) {
|
|
2637
|
+
const path = target.split("|")[0];
|
|
2638
|
+
const ext = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase();
|
|
2639
|
+
return ext !== void 0 && ext !== "md" && ext !== "canvas";
|
|
2640
|
+
}
|
|
2469
2641
|
function isVideoUrl(url) {
|
|
2470
2642
|
return VIDEO_HOSTS.some((host) => url.includes(host));
|
|
2471
2643
|
}
|
|
@@ -2484,7 +2656,7 @@ var PreserveMarkerCollector = class {
|
|
|
2484
2656
|
for (const match of matches) {
|
|
2485
2657
|
markers.push({
|
|
2486
2658
|
type: match[1],
|
|
2487
|
-
params: parseMarkerParams(match[2]),
|
|
2659
|
+
params: parseMarkerParams(match[2], input.content, match.index),
|
|
2488
2660
|
startIndex: match.index
|
|
2489
2661
|
});
|
|
2490
2662
|
}
|
|
@@ -2494,14 +2666,19 @@ var PreserveMarkerCollector = class {
|
|
|
2494
2666
|
};
|
|
2495
2667
|
}
|
|
2496
2668
|
};
|
|
2497
|
-
function parseMarkerParams(paramString) {
|
|
2669
|
+
function parseMarkerParams(paramString, content, index) {
|
|
2498
2670
|
const params = {};
|
|
2499
|
-
|
|
2500
|
-
const
|
|
2501
|
-
|
|
2502
|
-
|
|
2671
|
+
if (paramString.includes("=")) {
|
|
2672
|
+
for (const pair of paramString.split("&")) {
|
|
2673
|
+
const eqIdx = pair.indexOf("=");
|
|
2674
|
+
if (eqIdx > 0) {
|
|
2675
|
+
params[pair.slice(0, eqIdx)] = decodeURIComponent(pair.slice(eqIdx + 1));
|
|
2676
|
+
}
|
|
2503
2677
|
}
|
|
2678
|
+
} else {
|
|
2679
|
+
params.__raw = paramString;
|
|
2504
2680
|
}
|
|
2681
|
+
params.__anchor = computeAnchor(content, index);
|
|
2505
2682
|
return params;
|
|
2506
2683
|
}
|
|
2507
2684
|
|
|
@@ -2522,7 +2699,20 @@ var UnsupportedBlockStripper = class {
|
|
|
2522
2699
|
}
|
|
2523
2700
|
};
|
|
2524
2701
|
function stringifyFrontmatter(content, data) {
|
|
2525
|
-
|
|
2702
|
+
const out = matter.stringify(
|
|
2703
|
+
{ content },
|
|
2704
|
+
data
|
|
2705
|
+
);
|
|
2706
|
+
return unquoteFrontmatterDates(out);
|
|
2707
|
+
}
|
|
2708
|
+
var QUOTED_DATE_LINE_RE = /^([ \t]*[^:\n]+:[ \t]*)'(\d{4}-\d{2}-\d{2})'([ \t]*)$/gm;
|
|
2709
|
+
function unquoteFrontmatterDates(out) {
|
|
2710
|
+
if (!out.startsWith("---\n")) return out;
|
|
2711
|
+
const close = out.indexOf("\n---\n", 3);
|
|
2712
|
+
if (close === -1) return out;
|
|
2713
|
+
const fmEnd = close + "\n---\n".length;
|
|
2714
|
+
const fm = out.slice(0, fmEnd).replace(QUOTED_DATE_LINE_RE, "$1$2$3");
|
|
2715
|
+
return fm + out.slice(fmEnd);
|
|
2526
2716
|
}
|
|
2527
2717
|
|
|
2528
2718
|
// src/converter/pre-processors/properties-table.ts
|
|
@@ -2557,6 +2747,7 @@ var PropertiesTableInjector = class {
|
|
|
2557
2747
|
|
|
2558
2748
|
// src/converter/pre-processors/html-annotation.ts
|
|
2559
2749
|
var UNDERLINE_REGEX = /<u>([\s\S]*?)<\/u>/g;
|
|
2750
|
+
var HIGHLIGHT_REGEX = /(?<!=)==([^=\n]+?)==(?!=)/g;
|
|
2560
2751
|
var COLOR_SPAN_REGEX = /<span class="notion-(\w+)(-bg)?">([\s\S]*?)<\/span>/g;
|
|
2561
2752
|
var SPACED_COLOR_REGEX = new RegExp(
|
|
2562
2753
|
`%% ${MARKER_BRAND_RE}:color:(\\w+(?:_background)?) %%([\\s\\S]*?)%% ${MARKER_BRAND_RE}:end %%`,
|
|
@@ -2585,10 +2776,147 @@ var InlineAnnotationPreserver = class {
|
|
|
2585
2776
|
return `${compactMarker(`color:${color}`)}${text}%%/color%%`;
|
|
2586
2777
|
}
|
|
2587
2778
|
);
|
|
2779
|
+
content = mapOutsideCodeFences(
|
|
2780
|
+
content,
|
|
2781
|
+
(segment) => segment.replace(
|
|
2782
|
+
HIGHLIGHT_REGEX,
|
|
2783
|
+
(_m, text) => `${compactMarker("color:yellow_bg")}${text}%%/color%%`
|
|
2784
|
+
)
|
|
2785
|
+
);
|
|
2588
2786
|
return { content, metadata: input.metadata };
|
|
2589
2787
|
}
|
|
2590
2788
|
};
|
|
2591
2789
|
|
|
2790
|
+
// src/converter/pre-processors/comment-stripper.ts
|
|
2791
|
+
var COMMENT_REGEX = /%%([\s\S]*?)%%/g;
|
|
2792
|
+
var HTML_COMMENT_REGEX = /<!--([\s\S]*?)-->/g;
|
|
2793
|
+
var CommentStripper = class {
|
|
2794
|
+
name = "CommentStripper";
|
|
2795
|
+
// FrontmatterExtractor(10) 뒤 — YAML 값 속 `%%` 를 본문 주석으로 오인하지 않는다.
|
|
2796
|
+
order = 11;
|
|
2797
|
+
process(input) {
|
|
2798
|
+
if (input.context.direction !== "push") {
|
|
2799
|
+
return { content: input.content, metadata: input.metadata };
|
|
2800
|
+
}
|
|
2801
|
+
const markers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
|
|
2802
|
+
const content = mapOutsideCodeFences(input.content, (segment) => {
|
|
2803
|
+
const afterObsidian = segment.replace(
|
|
2804
|
+
COMMENT_REGEX,
|
|
2805
|
+
(match, body, offset) => {
|
|
2806
|
+
const trimmed = body.trimStart();
|
|
2807
|
+
if (trimmed.startsWith(`${MARKER_BRAND}:`) || trimmed.startsWith("/")) {
|
|
2808
|
+
return match;
|
|
2809
|
+
}
|
|
2810
|
+
markers.push({
|
|
2811
|
+
type: "comment",
|
|
2812
|
+
params: { text: body, __anchor: computeAnchor(segment, offset) },
|
|
2813
|
+
startIndex: offset
|
|
2814
|
+
});
|
|
2815
|
+
return "";
|
|
2816
|
+
}
|
|
2817
|
+
);
|
|
2818
|
+
return afterObsidian.replace(HTML_COMMENT_REGEX, (_match, body, offset) => {
|
|
2819
|
+
markers.push({
|
|
2820
|
+
type: "comment",
|
|
2821
|
+
params: { text: body, style: "html", __anchor: computeAnchor(afterObsidian, offset) },
|
|
2822
|
+
startIndex: offset
|
|
2823
|
+
});
|
|
2824
|
+
return "";
|
|
2825
|
+
});
|
|
2826
|
+
});
|
|
2827
|
+
return {
|
|
2828
|
+
content,
|
|
2829
|
+
metadata: { ...input.metadata, preserveMarkers: markers }
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
};
|
|
2833
|
+
|
|
2834
|
+
// src/converter/pre-processors/footnote-guard.ts
|
|
2835
|
+
var FOOTNOTE_TOKEN_REGEX = /(?<!\\)\[\^([^\]\s]+)\]/g;
|
|
2836
|
+
var FootnoteGuard = class {
|
|
2837
|
+
name = "FootnoteGuard";
|
|
2838
|
+
order = 12;
|
|
2839
|
+
process(input) {
|
|
2840
|
+
if (input.context.direction !== "push") {
|
|
2841
|
+
return { content: input.content, metadata: input.metadata };
|
|
2842
|
+
}
|
|
2843
|
+
const content = mapOutsideCodeFences(
|
|
2844
|
+
input.content,
|
|
2845
|
+
(segment) => segment.replace(FOOTNOTE_TOKEN_REGEX, "\\[^$1]")
|
|
2846
|
+
);
|
|
2847
|
+
return { content, metadata: input.metadata };
|
|
2848
|
+
}
|
|
2849
|
+
};
|
|
2850
|
+
|
|
2851
|
+
// src/converter/pre-processors/table-alignment.ts
|
|
2852
|
+
var DELIMITER_CELL_RE = /^:?-{1,}:?$/;
|
|
2853
|
+
function parseDelimiterRow(line) {
|
|
2854
|
+
const trimmed = line.trim();
|
|
2855
|
+
if (!trimmed.includes("-") || !trimmed.includes("|")) return null;
|
|
2856
|
+
const inner = trimmed.replace(/^\|/, "").replace(/\|$/, "");
|
|
2857
|
+
const cells = inner.split("|").map((c) => c.trim());
|
|
2858
|
+
if (cells.length === 0) return null;
|
|
2859
|
+
return cells.every((c) => DELIMITER_CELL_RE.test(c)) ? cells : null;
|
|
2860
|
+
}
|
|
2861
|
+
function cellAlignment(cell) {
|
|
2862
|
+
const l = cell.startsWith(":");
|
|
2863
|
+
const r = cell.endsWith(":");
|
|
2864
|
+
if (l && r) return "center";
|
|
2865
|
+
if (r) return "right";
|
|
2866
|
+
if (l) return "left";
|
|
2867
|
+
return "none";
|
|
2868
|
+
}
|
|
2869
|
+
function alignmentsToDelimiter(alignments) {
|
|
2870
|
+
const cells = alignments.map((a) => {
|
|
2871
|
+
switch (a) {
|
|
2872
|
+
case "center":
|
|
2873
|
+
return ":---:";
|
|
2874
|
+
case "right":
|
|
2875
|
+
return "---:";
|
|
2876
|
+
case "left":
|
|
2877
|
+
return ":---";
|
|
2878
|
+
default:
|
|
2879
|
+
return "---";
|
|
2880
|
+
}
|
|
2881
|
+
});
|
|
2882
|
+
return `| ${cells.join(" | ")} |`;
|
|
2883
|
+
}
|
|
2884
|
+
var TableAlignmentGuard = class {
|
|
2885
|
+
name = "TableAlignmentGuard";
|
|
2886
|
+
order = 45;
|
|
2887
|
+
process(input) {
|
|
2888
|
+
if (input.context.direction !== "push") {
|
|
2889
|
+
return { content: input.content, metadata: input.metadata };
|
|
2890
|
+
}
|
|
2891
|
+
const markers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
|
|
2892
|
+
const lines = input.content.split("\n");
|
|
2893
|
+
let changed = false;
|
|
2894
|
+
for (let i = 1; i < lines.length; i++) {
|
|
2895
|
+
const cells = parseDelimiterRow(lines[i]);
|
|
2896
|
+
if (!cells || !lines[i].includes(":")) continue;
|
|
2897
|
+
const prev = lines[i - 1];
|
|
2898
|
+
if (!prev.includes("|")) continue;
|
|
2899
|
+
markers.push({
|
|
2900
|
+
type: "table-align",
|
|
2901
|
+
params: {
|
|
2902
|
+
align: cells.map(cellAlignment).join(","),
|
|
2903
|
+
__anchor: prev.trim().slice(0, 64),
|
|
2904
|
+
// 원본 구분행 원문. 복원 시 정렬뿐 아니라 셀 폭 패딩까지 그대로 되살려
|
|
2905
|
+
// 라운드트립 delta-0 을 만족시킨다(__ 접두라 마커 렌더링에서 제외).
|
|
2906
|
+
__line: lines[i]
|
|
2907
|
+
},
|
|
2908
|
+
startIndex: 0
|
|
2909
|
+
});
|
|
2910
|
+
lines[i] = alignmentsToDelimiter(cells.map(() => "none"));
|
|
2911
|
+
changed = true;
|
|
2912
|
+
}
|
|
2913
|
+
return {
|
|
2914
|
+
content: changed ? lines.join("\n") : input.content,
|
|
2915
|
+
metadata: { ...input.metadata, preserveMarkers: markers }
|
|
2916
|
+
};
|
|
2917
|
+
}
|
|
2918
|
+
};
|
|
2919
|
+
|
|
2592
2920
|
// src/converter/link-restore.ts
|
|
2593
2921
|
var NOTION_LINK_REGEX = /\[([^\]]+)\]\((?:https:\/\/(?:www\.)?notion\.so\/|notion:\/\/)([a-f0-9-]+)\)/g;
|
|
2594
2922
|
var WIKILINK_PRESERVE_REGEX = new RegExp(`\\[([^\\]]+)\\]\\(${WIKILINK_PROTOCOL}([^)]+)\\)`, "g");
|
|
@@ -2620,6 +2948,91 @@ var MentionToWikilink = class {
|
|
|
2620
2948
|
}
|
|
2621
2949
|
};
|
|
2622
2950
|
|
|
2951
|
+
// src/converter/post-processors/escape-normalizer.ts
|
|
2952
|
+
var EscapeNormalizer = class {
|
|
2953
|
+
name = "EscapeNormalizer";
|
|
2954
|
+
order = 3;
|
|
2955
|
+
process(input) {
|
|
2956
|
+
if (input.context.direction !== "pull") {
|
|
2957
|
+
return { content: input.content, metadata: input.metadata };
|
|
2958
|
+
}
|
|
2959
|
+
const content = mapOutsideCodeFences(
|
|
2960
|
+
input.content,
|
|
2961
|
+
(segment) => segment.replace(/\\\[\^([^\]\\\n]+)\\\]/g, "[^$1]").replace(/\\\[\^/g, "[^").replace(/\\:/g, ":")
|
|
2962
|
+
);
|
|
2963
|
+
return { content, metadata: input.metadata };
|
|
2964
|
+
}
|
|
2965
|
+
};
|
|
2966
|
+
|
|
2967
|
+
// src/converter/post-processors/highlight-restorer.ts
|
|
2968
|
+
var COMPACT_YELLOW_RE = new RegExp(
|
|
2969
|
+
`%%${MARKER_BRAND_RE}:color:yellow(?:_background|_bg)%%([^\\n]*?)%%\\/color%%`,
|
|
2970
|
+
"g"
|
|
2971
|
+
);
|
|
2972
|
+
var SPACED_YELLOW_RE = new RegExp(
|
|
2973
|
+
`%% ${MARKER_BRAND_RE}:color:yellow(?:_background|_bg) %%([^\\n]*?)%% ${MARKER_BRAND_RE}:end %%`,
|
|
2974
|
+
"g"
|
|
2975
|
+
);
|
|
2976
|
+
var HighlightRestorer = class {
|
|
2977
|
+
name = "HighlightRestorer";
|
|
2978
|
+
order = 25;
|
|
2979
|
+
process(input) {
|
|
2980
|
+
if (input.context.direction !== "pull") {
|
|
2981
|
+
return { content: input.content, metadata: input.metadata };
|
|
2982
|
+
}
|
|
2983
|
+
const content = input.content.replace(COMPACT_YELLOW_RE, (_m, text) => text ? `==${text}==` : "").replace(SPACED_YELLOW_RE, (_m, text) => text ? `==${text}==` : "");
|
|
2984
|
+
return { content, metadata: input.metadata };
|
|
2985
|
+
}
|
|
2986
|
+
};
|
|
2987
|
+
|
|
2988
|
+
// src/converter/post-processors/table-alignment-restorer.ts
|
|
2989
|
+
var TableAlignmentRestorer = class {
|
|
2990
|
+
name = "TableAlignmentRestorer";
|
|
2991
|
+
order = 35;
|
|
2992
|
+
process(input) {
|
|
2993
|
+
if (input.context.direction !== "pull") {
|
|
2994
|
+
return { content: input.content, metadata: input.metadata };
|
|
2995
|
+
}
|
|
2996
|
+
const lines = input.content.split("\n");
|
|
2997
|
+
for (let i = 1; i < lines.length - 1; i++) {
|
|
2998
|
+
const delim = parseDelimiterRow(lines[i]);
|
|
2999
|
+
if (!delim || lines[i].includes(":")) continue;
|
|
3000
|
+
const phantom = parseDelimiterRow(lines[i + 1]);
|
|
3001
|
+
if (!phantom || !lines[i + 1].includes(":") || phantom.length !== delim.length) continue;
|
|
3002
|
+
if (!lines[i - 1].includes("|")) continue;
|
|
3003
|
+
lines[i] = alignmentsToDelimiter(phantom.map(cellAlignment));
|
|
3004
|
+
lines.splice(i + 1, 1);
|
|
3005
|
+
}
|
|
3006
|
+
const markers = input.metadata.preserveMarkers ?? [];
|
|
3007
|
+
const remaining = [];
|
|
3008
|
+
for (const marker of markers) {
|
|
3009
|
+
if (marker.type !== "table-align" || !marker.params.align || !marker.params.__anchor) {
|
|
3010
|
+
remaining.push(marker);
|
|
3011
|
+
continue;
|
|
3012
|
+
}
|
|
3013
|
+
const anchor = marker.params.__anchor;
|
|
3014
|
+
const idx = lines.findIndex(
|
|
3015
|
+
(line, i) => i < lines.length - 1 && line.trim().startsWith(anchor) && parseDelimiterRow(lines[i + 1]) !== null
|
|
3016
|
+
);
|
|
3017
|
+
if (idx === -1) continue;
|
|
3018
|
+
const delimCells = parseDelimiterRow(lines[idx + 1]);
|
|
3019
|
+
if (lines[idx + 1].includes(":")) continue;
|
|
3020
|
+
const originalLine = marker.params.__line;
|
|
3021
|
+
if (originalLine && parseDelimiterRow(originalLine)?.length === delimCells.length) {
|
|
3022
|
+
lines[idx + 1] = originalLine;
|
|
3023
|
+
continue;
|
|
3024
|
+
}
|
|
3025
|
+
const alignments = marker.params.align.split(",");
|
|
3026
|
+
while (alignments.length < delimCells.length) alignments.push("none");
|
|
3027
|
+
lines[idx + 1] = alignmentsToDelimiter(alignments.slice(0, delimCells.length));
|
|
3028
|
+
}
|
|
3029
|
+
return {
|
|
3030
|
+
content: lines.join("\n"),
|
|
3031
|
+
metadata: { ...input.metadata, preserveMarkers: remaining }
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
3034
|
+
};
|
|
3035
|
+
|
|
2623
3036
|
// src/converter/post-processors/preserve-marker-injector.ts
|
|
2624
3037
|
var PreserveMarkerInjector = class {
|
|
2625
3038
|
name = "PreserveMarkerInjector";
|
|
@@ -2633,23 +3046,90 @@ var PreserveMarkerInjector = class {
|
|
|
2633
3046
|
return { content: input.content, metadata: input.metadata };
|
|
2634
3047
|
}
|
|
2635
3048
|
let content = input.content;
|
|
2636
|
-
const
|
|
2637
|
-
|
|
3049
|
+
const missing = markers.filter((m) => {
|
|
3050
|
+
const rendered = this.renderMarker(m);
|
|
3051
|
+
if (rendered === null) return false;
|
|
3052
|
+
if (content.includes(rendered)) return false;
|
|
3053
|
+
return !this.isSemanticallyRestored(m, content);
|
|
3054
|
+
});
|
|
3055
|
+
if (missing.length === 0) {
|
|
2638
3056
|
return { content, metadata: input.metadata };
|
|
2639
3057
|
}
|
|
2640
|
-
const sorted = [...
|
|
2641
|
-
for (const
|
|
2642
|
-
const
|
|
2643
|
-
|
|
2644
|
-
|
|
3058
|
+
const sorted = [...missing].sort((a, b) => a.startIndex - b.startIndex);
|
|
3059
|
+
for (const marker of sorted) {
|
|
3060
|
+
const rendered = this.renderMarker(marker);
|
|
3061
|
+
if (marker.params?.__anchor === void 0) {
|
|
3062
|
+
const at2 = Math.max(frontmatterEnd(content), Math.min(marker.startIndex, content.length));
|
|
3063
|
+
content = content.slice(0, at2) + rendered + content.slice(at2);
|
|
3064
|
+
continue;
|
|
3065
|
+
}
|
|
3066
|
+
const at = this.findInsertionPoint(marker, content);
|
|
3067
|
+
const before = content.slice(0, at);
|
|
3068
|
+
const needsNewline = before.length > 0 && !before.endsWith("\n");
|
|
3069
|
+
content = before + (needsNewline ? "\n" : "") + rendered + "\n" + content.slice(at);
|
|
2645
3070
|
}
|
|
2646
3071
|
return { content, metadata: input.metadata };
|
|
2647
3072
|
}
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
3073
|
+
/** 마커의 본문 표현. 복원 불가능한(빈) 마커는 null — 삽입하지 않고 폐기한다. */
|
|
3074
|
+
renderMarker(marker) {
|
|
3075
|
+
const params = marker.params ?? {};
|
|
3076
|
+
if (marker.type === "comment") {
|
|
3077
|
+
if (!params.text) return null;
|
|
3078
|
+
return params.style === "html" ? `<!--${params.text}-->` : `%%${params.text}%%`;
|
|
3079
|
+
}
|
|
3080
|
+
if (params.__raw !== void 0) {
|
|
3081
|
+
return spacedMarker(`${marker.type}:${params.__raw}`);
|
|
3082
|
+
}
|
|
3083
|
+
const entries = Object.entries(params).filter(([key]) => !key.startsWith("__"));
|
|
3084
|
+
if (entries.length === 0) return null;
|
|
3085
|
+
const body = entries.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
|
|
3086
|
+
return spacedMarker(`${marker.type}:${body}`);
|
|
3087
|
+
}
|
|
3088
|
+
/** 마커가 이미 원래 문법으로 복원돼 있으면 재삽입은 중복·오염이다. */
|
|
3089
|
+
isSemanticallyRestored(marker, content) {
|
|
3090
|
+
const params = marker.params ?? {};
|
|
3091
|
+
switch (marker.type) {
|
|
3092
|
+
case "wikilink": {
|
|
3093
|
+
const text = params.text;
|
|
3094
|
+
if (!text) return false;
|
|
3095
|
+
return content.includes(`[[${text}]]`) || content.includes(`[[${text}|`);
|
|
3096
|
+
}
|
|
3097
|
+
case "local-image":
|
|
3098
|
+
case "local-file": {
|
|
3099
|
+
const target = params.__raw ?? params.path;
|
|
3100
|
+
return target ? content.includes(`![[${target}]]`) : false;
|
|
3101
|
+
}
|
|
3102
|
+
case "table-align":
|
|
3103
|
+
return true;
|
|
3104
|
+
default:
|
|
3105
|
+
return false;
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
/** 삽입 위치: 앵커 → startIndex(줄 경계 보정) 순. 항상 프론트매터 뒤. */
|
|
3109
|
+
findInsertionPoint(marker, content) {
|
|
3110
|
+
const fmEnd = frontmatterEnd(content);
|
|
3111
|
+
const anchor = marker.params?.__anchor;
|
|
3112
|
+
if (anchor && anchor.trim().length >= 4) {
|
|
3113
|
+
const idx = content.indexOf(anchor, fmEnd);
|
|
3114
|
+
if (idx !== -1) {
|
|
3115
|
+
return nextLineStart(content, idx + anchor.length);
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
const clamped = Math.max(fmEnd, Math.min(marker.startIndex, content.length));
|
|
3119
|
+
return nextLineStart(content, clamped);
|
|
2651
3120
|
}
|
|
2652
3121
|
};
|
|
3122
|
+
function frontmatterEnd(content) {
|
|
3123
|
+
if (!content.startsWith("---\n")) return 0;
|
|
3124
|
+
const close = content.indexOf("\n---", 3);
|
|
3125
|
+
if (close === -1) return 0;
|
|
3126
|
+
const lineEnd = content.indexOf("\n", close + 4);
|
|
3127
|
+
return lineEnd === -1 ? content.length : lineEnd + 1;
|
|
3128
|
+
}
|
|
3129
|
+
function nextLineStart(content, pos) {
|
|
3130
|
+
const nl = content.indexOf("\n", pos);
|
|
3131
|
+
return nl === -1 ? content.length : nl + 1;
|
|
3132
|
+
}
|
|
2653
3133
|
|
|
2654
3134
|
// src/converter/post-processors/callout-restorer.ts
|
|
2655
3135
|
var EMOJI_TYPE_MAP = {
|
|
@@ -2785,7 +3265,7 @@ function normalizeDate(dateStr) {
|
|
|
2785
3265
|
|
|
2786
3266
|
// src/converter/post-processors/local-image-restorer.ts
|
|
2787
3267
|
var LOCAL_IMAGE_MARKER_REGEX = new RegExp(
|
|
2788
|
-
`>\\s*\u{1F4CE}\\s*[^\\n]*\\n>\\s*%%\\s*${MARKER_BRAND_RE}:local-image:([^\\s]+)\\s*%%`,
|
|
3268
|
+
`>\\s*\u{1F4CE}\\s*[^\\n]*\\n>\\s*%%\\s*${MARKER_BRAND_RE}:local-(?:image|file):([^\\s]+)\\s*%%`,
|
|
2789
3269
|
"g"
|
|
2790
3270
|
);
|
|
2791
3271
|
var LocalImageRestorer = class {
|
|
@@ -2897,25 +3377,178 @@ function stripQuotes(s) {
|
|
|
2897
3377
|
return s;
|
|
2898
3378
|
}
|
|
2899
3379
|
|
|
3380
|
+
// src/converter/post-processors/block-spacer.ts
|
|
3381
|
+
var BlockSpacer = class {
|
|
3382
|
+
name = "BlockSpacer";
|
|
3383
|
+
order = 110;
|
|
3384
|
+
process(input) {
|
|
3385
|
+
if (input.context.direction !== "pull") {
|
|
3386
|
+
return { content: input.content, metadata: input.metadata };
|
|
3387
|
+
}
|
|
3388
|
+
return {
|
|
3389
|
+
content: respace(input.content, input.metadata.notionExportCompact),
|
|
3390
|
+
metadata: input.metadata
|
|
3391
|
+
};
|
|
3392
|
+
}
|
|
3393
|
+
};
|
|
3394
|
+
var LIST_ITEM_RE = /^[ \t]*(?:[-*+]|\d+[.)])\s/;
|
|
3395
|
+
var QUOTE_RE = /^[ \t]*>/;
|
|
3396
|
+
var CALLOUT_START_RE = /^[ \t]*>\s*\[![^\]]*\]/;
|
|
3397
|
+
var TABLE_ROW_RE = /^[ \t]*\|/;
|
|
3398
|
+
var FOOTNOTE_DEF_RE = /^\[\^[^\]]+\]:/;
|
|
3399
|
+
var FENCE_RE = /^[ \t]*(```+|~~~+)/;
|
|
3400
|
+
var MATH_FENCE_RE = /^[ \t]*\$\$\s*$/;
|
|
3401
|
+
var BRAND_MARKER_LINE_RE = new RegExp(`^[ \\t]*%%\\s*${MARKER_BRAND}:`);
|
|
3402
|
+
var INDENTED_CONTINUATION_RE = /^(?:\t| {2,})\S/;
|
|
3403
|
+
function respace(content, sourceCompact) {
|
|
3404
|
+
if (sourceCompact === false) return content;
|
|
3405
|
+
const lines = content.split("\n");
|
|
3406
|
+
const head = [];
|
|
3407
|
+
let start = 0;
|
|
3408
|
+
if (lines[0]?.trim() === "---") {
|
|
3409
|
+
for (let i = 1; i < lines.length; i++) {
|
|
3410
|
+
if (lines[i].trim() === "---") {
|
|
3411
|
+
for (let j = 0; j <= i; j++) head.push(lines[j]);
|
|
3412
|
+
start = i + 1;
|
|
3413
|
+
break;
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
if (sourceCompact === void 0 && hasBlankOutsideFences(lines, start)) return content;
|
|
3418
|
+
const blocks = [];
|
|
3419
|
+
let fence = null;
|
|
3420
|
+
let fenceToken = "";
|
|
3421
|
+
let boundary = false;
|
|
3422
|
+
const push = (kind, line) => {
|
|
3423
|
+
blocks.push({ kind, lines: [line] });
|
|
3424
|
+
};
|
|
3425
|
+
const appendToLast = (line) => {
|
|
3426
|
+
blocks[blocks.length - 1].lines.push(line);
|
|
3427
|
+
};
|
|
3428
|
+
const last = () => blocks[blocks.length - 1];
|
|
3429
|
+
for (let i = start; i < lines.length; i++) {
|
|
3430
|
+
const raw = lines[i];
|
|
3431
|
+
if (fence !== null) {
|
|
3432
|
+
appendToLast(raw);
|
|
3433
|
+
const closed = fence === "code" ? FENCE_RE.test(raw) && raw.trim().startsWith(fenceToken) : MATH_FENCE_RE.test(raw);
|
|
3434
|
+
if (closed) fence = null;
|
|
3435
|
+
continue;
|
|
3436
|
+
}
|
|
3437
|
+
if (raw.trim() === "") {
|
|
3438
|
+
boundary = true;
|
|
3439
|
+
continue;
|
|
3440
|
+
}
|
|
3441
|
+
const atBoundary = boundary;
|
|
3442
|
+
boundary = false;
|
|
3443
|
+
const fenceMatch = raw.match(FENCE_RE);
|
|
3444
|
+
if (fenceMatch) {
|
|
3445
|
+
push("fence", raw);
|
|
3446
|
+
fence = "code";
|
|
3447
|
+
fenceToken = fenceMatch[1][0].repeat(3);
|
|
3448
|
+
continue;
|
|
3449
|
+
}
|
|
3450
|
+
if (MATH_FENCE_RE.test(raw)) {
|
|
3451
|
+
push("math", raw);
|
|
3452
|
+
fence = "math";
|
|
3453
|
+
continue;
|
|
3454
|
+
}
|
|
3455
|
+
const line = LIST_ITEM_RE.test(raw) || INDENTED_CONTINUATION_RE.test(raw) ? raw.replace(/^\t+/, (t) => " ".repeat(t.length)) : raw;
|
|
3456
|
+
if (BRAND_MARKER_LINE_RE.test(line) && blocks.length > 0) {
|
|
3457
|
+
appendToLast(line);
|
|
3458
|
+
continue;
|
|
3459
|
+
}
|
|
3460
|
+
if (LIST_ITEM_RE.test(line)) {
|
|
3461
|
+
if (last()?.kind === "list" && !atBoundary) appendToLast(line);
|
|
3462
|
+
else push("list", line);
|
|
3463
|
+
continue;
|
|
3464
|
+
}
|
|
3465
|
+
if (last()?.kind === "list" && INDENTED_CONTINUATION_RE.test(line)) {
|
|
3466
|
+
appendToLast(line);
|
|
3467
|
+
continue;
|
|
3468
|
+
}
|
|
3469
|
+
if (QUOTE_RE.test(line)) {
|
|
3470
|
+
if (last()?.kind === "quote" && !CALLOUT_START_RE.test(line) && !atBoundary) {
|
|
3471
|
+
appendToLast(line);
|
|
3472
|
+
} else {
|
|
3473
|
+
push("quote", line);
|
|
3474
|
+
}
|
|
3475
|
+
continue;
|
|
3476
|
+
}
|
|
3477
|
+
if (TABLE_ROW_RE.test(line)) {
|
|
3478
|
+
if (last()?.kind === "table" && !atBoundary) appendToLast(line);
|
|
3479
|
+
else push("table", line);
|
|
3480
|
+
continue;
|
|
3481
|
+
}
|
|
3482
|
+
if (FOOTNOTE_DEF_RE.test(line)) {
|
|
3483
|
+
if (last()?.kind === "footnote" && !atBoundary) appendToLast(line);
|
|
3484
|
+
else push("footnote", line);
|
|
3485
|
+
continue;
|
|
3486
|
+
}
|
|
3487
|
+
push("other", line);
|
|
3488
|
+
}
|
|
3489
|
+
const body = blocks.map((b) => b.lines.join("\n")).join("\n\n");
|
|
3490
|
+
const tail = content.endsWith("\n") ? "\n" : "";
|
|
3491
|
+
if (head.length === 0) return body + tail;
|
|
3492
|
+
return `${head.join("\n")}
|
|
3493
|
+
|
|
3494
|
+
${body}${tail}`;
|
|
3495
|
+
}
|
|
3496
|
+
function isCompactExport(rawMarkdown) {
|
|
3497
|
+
return !hasBlankOutsideFences(rawMarkdown.split("\n"), 0);
|
|
3498
|
+
}
|
|
3499
|
+
function hasBlankOutsideFences(lines, start) {
|
|
3500
|
+
let end = lines.length - 1;
|
|
3501
|
+
while (end >= start && lines[end].trim() === "") end--;
|
|
3502
|
+
let fence = null;
|
|
3503
|
+
let fenceToken = "";
|
|
3504
|
+
for (let i = start; i <= end; i++) {
|
|
3505
|
+
const raw = lines[i];
|
|
3506
|
+
if (fence !== null) {
|
|
3507
|
+
const closed = fence === "code" ? FENCE_RE.test(raw) && raw.trim().startsWith(fenceToken) : MATH_FENCE_RE.test(raw);
|
|
3508
|
+
if (closed) fence = null;
|
|
3509
|
+
continue;
|
|
3510
|
+
}
|
|
3511
|
+
const fenceMatch = raw.match(FENCE_RE);
|
|
3512
|
+
if (fenceMatch) {
|
|
3513
|
+
fence = "code";
|
|
3514
|
+
fenceToken = fenceMatch[1][0].repeat(3);
|
|
3515
|
+
continue;
|
|
3516
|
+
}
|
|
3517
|
+
if (MATH_FENCE_RE.test(raw)) {
|
|
3518
|
+
fence = "math";
|
|
3519
|
+
continue;
|
|
3520
|
+
}
|
|
3521
|
+
if (raw.trim() === "") return true;
|
|
3522
|
+
}
|
|
3523
|
+
return false;
|
|
3524
|
+
}
|
|
3525
|
+
|
|
2900
3526
|
// src/converter/pipeline-factory.ts
|
|
2901
3527
|
function createDefaultPipeline(options) {
|
|
2902
3528
|
const pipeline = new ConversionPipeline();
|
|
2903
3529
|
pipeline.registerPreProcessor(new InlineAnnotationPreserver());
|
|
2904
3530
|
pipeline.registerPreProcessor(new UnsupportedBlockStripper());
|
|
3531
|
+
pipeline.registerPreProcessor(new CommentStripper());
|
|
2905
3532
|
pipeline.registerPreProcessor(new FrontmatterExtractor());
|
|
3533
|
+
pipeline.registerPreProcessor(new FootnoteGuard());
|
|
2906
3534
|
pipeline.registerPreProcessor(new PropertiesTableInjector());
|
|
2907
3535
|
pipeline.registerPreProcessor(new WikilinkResolver(options?.wikilinkResolver));
|
|
2908
3536
|
pipeline.registerPreProcessor(new CalloutTransformer());
|
|
3537
|
+
pipeline.registerPreProcessor(new TableAlignmentGuard());
|
|
2909
3538
|
pipeline.registerPreProcessor(new MathNormalizer());
|
|
2910
3539
|
pipeline.registerPreProcessor(new EmbedResolver());
|
|
2911
3540
|
pipeline.registerPreProcessor(new PreserveMarkerCollector());
|
|
3541
|
+
pipeline.registerPostProcessor(new EscapeNormalizer());
|
|
2912
3542
|
pipeline.registerPostProcessor(new PropertiesTableRestorer());
|
|
2913
3543
|
pipeline.registerPostProcessor(new LocalImageRestorer());
|
|
3544
|
+
pipeline.registerPostProcessor(new HighlightRestorer());
|
|
3545
|
+
pipeline.registerPostProcessor(new TableAlignmentRestorer());
|
|
2914
3546
|
pipeline.registerPostProcessor(new PreserveMarkerInjector());
|
|
2915
3547
|
pipeline.registerPostProcessor(new MentionToWikilink());
|
|
2916
3548
|
pipeline.registerPostProcessor(new CalloutRestorer());
|
|
2917
3549
|
pipeline.registerPostProcessor(new ColorAnnotator());
|
|
2918
3550
|
pipeline.registerPostProcessor(new FrontmatterGenerator());
|
|
3551
|
+
pipeline.registerPostProcessor(new BlockSpacer());
|
|
2919
3552
|
return pipeline;
|
|
2920
3553
|
}
|
|
2921
3554
|
|
|
@@ -3728,6 +4361,87 @@ var EXTENSION_TO_MIME = {
|
|
|
3728
4361
|
".ico": "image/x-icon",
|
|
3729
4362
|
".tiff": "image/tiff"
|
|
3730
4363
|
};
|
|
4364
|
+
function scanMarkdownLinks(markdown) {
|
|
4365
|
+
const spans = [];
|
|
4366
|
+
let i = 0;
|
|
4367
|
+
while (i < markdown.length) {
|
|
4368
|
+
const open = markdown.indexOf("[", i);
|
|
4369
|
+
if (open === -1) break;
|
|
4370
|
+
if (markdown[open - 1] === "\\") {
|
|
4371
|
+
i = open + 1;
|
|
4372
|
+
continue;
|
|
4373
|
+
}
|
|
4374
|
+
const isEmbed = markdown[open - 1] === "!" && markdown[open - 2] !== "\\";
|
|
4375
|
+
let depth = 1;
|
|
4376
|
+
let j = open + 1;
|
|
4377
|
+
let broken = false;
|
|
4378
|
+
while (j < markdown.length && depth > 0) {
|
|
4379
|
+
const ch = markdown[j];
|
|
4380
|
+
if (ch === "\\") {
|
|
4381
|
+
j += 2;
|
|
4382
|
+
continue;
|
|
4383
|
+
}
|
|
4384
|
+
if (ch === "\n" && markdown[j + 1] === "\n") {
|
|
4385
|
+
broken = true;
|
|
4386
|
+
break;
|
|
4387
|
+
}
|
|
4388
|
+
if (ch === "[") depth++;
|
|
4389
|
+
else if (ch === "]") depth--;
|
|
4390
|
+
j++;
|
|
4391
|
+
}
|
|
4392
|
+
if (broken || depth !== 0 || markdown[j] !== "(") {
|
|
4393
|
+
i = open + 1;
|
|
4394
|
+
continue;
|
|
4395
|
+
}
|
|
4396
|
+
const label = markdown.slice(open + 1, j - 1);
|
|
4397
|
+
let parenDepth = 1;
|
|
4398
|
+
let k = j + 1;
|
|
4399
|
+
while (k < markdown.length && parenDepth > 0) {
|
|
4400
|
+
const ch = markdown[k];
|
|
4401
|
+
if (ch === "\\") {
|
|
4402
|
+
k += 2;
|
|
4403
|
+
continue;
|
|
4404
|
+
}
|
|
4405
|
+
if (ch === "\n") {
|
|
4406
|
+
broken = true;
|
|
4407
|
+
break;
|
|
4408
|
+
}
|
|
4409
|
+
if (ch === "(") parenDepth++;
|
|
4410
|
+
else if (ch === ")") parenDepth--;
|
|
4411
|
+
k++;
|
|
4412
|
+
}
|
|
4413
|
+
if (broken || parenDepth !== 0) {
|
|
4414
|
+
i = open + 1;
|
|
4415
|
+
continue;
|
|
4416
|
+
}
|
|
4417
|
+
const start = isEmbed ? open - 1 : open;
|
|
4418
|
+
spans.push({
|
|
4419
|
+
full: markdown.slice(start, k),
|
|
4420
|
+
label,
|
|
4421
|
+
url: markdown.slice(j + 1, k - 1),
|
|
4422
|
+
isEmbed
|
|
4423
|
+
});
|
|
4424
|
+
i = k;
|
|
4425
|
+
}
|
|
4426
|
+
return spans;
|
|
4427
|
+
}
|
|
4428
|
+
function toWikilinkAlias(label) {
|
|
4429
|
+
return label.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, "$1").replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, t, d) => d ?? t).replace(/[\r\n]+/g, " ").replace(/\]\]/g, ")").replace(/\|/g, "-").trim();
|
|
4430
|
+
}
|
|
4431
|
+
var INTERNAL_FILE_URL_RE = /^file:\/\/%7B.*%7D%7D$/;
|
|
4432
|
+
var FILE_LABEL_PREFIX_RE = /^(?:📎|🎬|🎞|📄|🔊)\s*/u;
|
|
4433
|
+
var LOCAL_MARKER_RE = new RegExp(`${MARKER_BRAND_RE}:local-(?:image|file):([^\\s%]+)`, "g");
|
|
4434
|
+
function collectLocalMarkerBasenames(markdown) {
|
|
4435
|
+
const names = /* @__PURE__ */ new Set();
|
|
4436
|
+
const unescaped = markdown.replace(/\\/g, "");
|
|
4437
|
+
for (const m of unescaped.matchAll(LOCAL_MARKER_RE)) {
|
|
4438
|
+
names.add(baseName(m[1]));
|
|
4439
|
+
}
|
|
4440
|
+
return names;
|
|
4441
|
+
}
|
|
4442
|
+
function baseName(path) {
|
|
4443
|
+
return path.split("/").pop() ?? path;
|
|
4444
|
+
}
|
|
3731
4445
|
var ImageHandler = class {
|
|
3732
4446
|
constructor(vaultFs, attachmentFolder = "attachments", notionClient, customFetch, options, stateDb) {
|
|
3733
4447
|
this.vaultFs = vaultFs;
|
|
@@ -3753,6 +4467,9 @@ var ImageHandler = class {
|
|
|
3753
4467
|
return this.customFetch ?? globalThis.fetch;
|
|
3754
4468
|
}
|
|
3755
4469
|
async downloadImage(url, pageTitle) {
|
|
4470
|
+
return this.persistImage(await this.fetchImage(url, pageTitle));
|
|
4471
|
+
}
|
|
4472
|
+
async fetchImage(url, pageTitle) {
|
|
3756
4473
|
const maxRetries = this.maxRetries;
|
|
3757
4474
|
let lastError = null;
|
|
3758
4475
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
@@ -3770,38 +4487,67 @@ var ImageHandler = class {
|
|
|
3770
4487
|
const contentType = response.headers.get("content-type") ?? "image/png";
|
|
3771
4488
|
const extension = IMAGE_EXTENSIONS[contentType] ?? this.guessExtension(url) ?? ".png";
|
|
3772
4489
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
3773
|
-
const
|
|
4490
|
+
const fullHash = createHash("sha256").update(buffer).digest("hex");
|
|
4491
|
+
const hash = fullHash.slice(0, 12);
|
|
3774
4492
|
const safeName = pageTitle.replace(/[^a-zA-Z0-9가-힣\s-]/g, "").replace(/\s+/g, "-").slice(0, 50);
|
|
3775
4493
|
const fileName = `${safeName}-${hash}${extension}`;
|
|
3776
4494
|
const localPath = `${this.attachmentFolder}/${fileName}`;
|
|
3777
|
-
|
|
3778
|
-
await this.vaultFs.writeBinary(localPath, buffer);
|
|
3779
|
-
return { originalUrl: url, localPath, hash, size: buffer.length };
|
|
4495
|
+
return { originalUrl: url, localPath, hash, fullHash, buffer };
|
|
3780
4496
|
} catch (error) {
|
|
3781
4497
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
3782
4498
|
}
|
|
3783
4499
|
}
|
|
3784
4500
|
throw lastError;
|
|
3785
4501
|
}
|
|
3786
|
-
async
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
4502
|
+
async persistImage(fetched) {
|
|
4503
|
+
await this.vaultFs.ensureFolder(this.attachmentFolder);
|
|
4504
|
+
await this.vaultFs.writeBinary(fetched.localPath, fetched.buffer);
|
|
4505
|
+
return {
|
|
4506
|
+
originalUrl: fetched.originalUrl,
|
|
4507
|
+
localPath: fetched.localPath,
|
|
4508
|
+
hash: fetched.hash,
|
|
4509
|
+
size: fetched.buffer.length
|
|
4510
|
+
};
|
|
4511
|
+
}
|
|
4512
|
+
async downloadAllImages(markdown, pageTitle, pageId) {
|
|
4513
|
+
const embeds = scanMarkdownLinks(markdown).filter((s) => s.isEmbed);
|
|
4514
|
+
const notionMatches = embeds.filter(
|
|
4515
|
+
(s) => /^https?:\/\//.test(s.url) && this.isNotionImageUrl(s.url)
|
|
4516
|
+
);
|
|
4517
|
+
const internalMatches = embeds.filter((s) => INTERNAL_FILE_URL_RE.test(s.url));
|
|
4518
|
+
for (const span of embeds) {
|
|
4519
|
+
if (isNotionAttachmentUri(span.url)) {
|
|
4520
|
+
getLogger().warn(
|
|
4521
|
+
`[Im-Nobsidian] \uC811\uADFC \uBD88\uAC00 \uCCA8\uBD80(attachment:) \uAC10\uC9C0 \u2014 Notion \uD1B5\uD569\uC5D0 \uACF5\uC720\uB418\uC9C0 \uC54A\uC740 \uC6D0\uBCF8\uC758 \uD30C\uC77C\uC774\uB77C \uB2E4\uC6B4\uB85C\uB4DC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uC6D0\uBB38\uC744 \uBCF4\uC874\uD569\uB2C8\uB2E4: ${pageTitle}`
|
|
4522
|
+
);
|
|
4523
|
+
}
|
|
4524
|
+
}
|
|
3792
4525
|
if (notionMatches.length === 0 && internalMatches.length === 0) {
|
|
3793
4526
|
return { content: markdown, downloads: [] };
|
|
3794
4527
|
}
|
|
4528
|
+
const registryByHash = /* @__PURE__ */ new Map();
|
|
4529
|
+
if (pageId && this.stateDb) {
|
|
4530
|
+
for (const entry of this.stateDb.getFilesByPageId(pageId)) {
|
|
4531
|
+
const list = registryByHash.get(entry.fileHash) ?? [];
|
|
4532
|
+
list.push(entry.localPath);
|
|
4533
|
+
registryByHash.set(entry.fileHash, list);
|
|
4534
|
+
}
|
|
4535
|
+
}
|
|
4536
|
+
const markerBasenames = collectLocalMarkerBasenames(markdown);
|
|
4537
|
+
const isAppendedCopy = (fetched) => {
|
|
4538
|
+
const paths = registryByHash.get(fetched.fullHash);
|
|
4539
|
+
return paths !== void 0 && paths.some((p) => markerBasenames.has(baseName(p)));
|
|
4540
|
+
};
|
|
3795
4541
|
const sema = new Sema(this.concurrency);
|
|
3796
4542
|
const downloads = [];
|
|
3797
4543
|
let result = markdown;
|
|
3798
4544
|
const httpResults = await Promise.allSettled(
|
|
3799
|
-
notionMatches.map(async (
|
|
4545
|
+
notionMatches.map(async (span) => {
|
|
3800
4546
|
await sema.acquire();
|
|
3801
4547
|
try {
|
|
3802
4548
|
return {
|
|
3803
|
-
|
|
3804
|
-
|
|
4549
|
+
span,
|
|
4550
|
+
fetched: await this.fetchImage(span.url, pageTitle)
|
|
3805
4551
|
};
|
|
3806
4552
|
} finally {
|
|
3807
4553
|
sema.release();
|
|
@@ -3810,26 +4556,37 @@ var ImageHandler = class {
|
|
|
3810
4556
|
);
|
|
3811
4557
|
for (const settled of httpResults) {
|
|
3812
4558
|
if (settled.status !== "fulfilled") continue;
|
|
3813
|
-
const {
|
|
4559
|
+
const { span, fetched } = settled.value;
|
|
4560
|
+
if (isAppendedCopy(fetched)) {
|
|
4561
|
+
result = result.replace(span.full, "");
|
|
4562
|
+
continue;
|
|
4563
|
+
}
|
|
4564
|
+
const download = await this.persistImage(fetched);
|
|
3814
4565
|
downloads.push(download);
|
|
3815
|
-
const
|
|
3816
|
-
|
|
4566
|
+
const alias = toWikilinkAlias(span.label);
|
|
4567
|
+
const obsidianEmbed = `![[${download.localPath}${alias ? `|${alias}` : ""}]]`;
|
|
4568
|
+
result = result.replace(span.full, obsidianEmbed);
|
|
3817
4569
|
}
|
|
3818
|
-
for (const
|
|
4570
|
+
for (const span of internalMatches) {
|
|
3819
4571
|
await sema.acquire();
|
|
3820
4572
|
try {
|
|
3821
|
-
const
|
|
3822
|
-
const parsed = this.parseNotionFileUrl(fileUrl);
|
|
4573
|
+
const parsed = this.parseNotionFileUrl(span.url);
|
|
3823
4574
|
if (!parsed || !this.notionClient) continue;
|
|
3824
4575
|
const realUrl = await this.notionClient.getFileBlockUrl(parsed.blockId);
|
|
3825
4576
|
if (!realUrl) {
|
|
3826
4577
|
getLogger().warn(`[Im-Nobsidian] \uC774\uBBF8\uC9C0 Block URL \uC870\uD68C \uC2E4\uD328: ${parsed.fileName}`);
|
|
3827
4578
|
continue;
|
|
3828
4579
|
}
|
|
3829
|
-
const
|
|
4580
|
+
const fetched = await this.fetchImage(realUrl, pageTitle);
|
|
4581
|
+
if (isAppendedCopy(fetched)) {
|
|
4582
|
+
result = result.replace(span.full, "");
|
|
4583
|
+
continue;
|
|
4584
|
+
}
|
|
4585
|
+
const download = await this.persistImage(fetched);
|
|
3830
4586
|
downloads.push(download);
|
|
3831
|
-
const
|
|
3832
|
-
|
|
4587
|
+
const alias = toWikilinkAlias(span.label);
|
|
4588
|
+
const obsidianEmbed = `![[${download.localPath}${alias ? `|${alias}` : ""}]]`;
|
|
4589
|
+
result = result.replace(span.full, obsidianEmbed);
|
|
3833
4590
|
} catch (error) {
|
|
3834
4591
|
getLogger().warn(`\uB0B4\uBD80 \uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
|
|
3835
4592
|
} finally {
|
|
@@ -3838,27 +4595,28 @@ var ImageHandler = class {
|
|
|
3838
4595
|
}
|
|
3839
4596
|
return { content: result, downloads };
|
|
3840
4597
|
}
|
|
3841
|
-
async uploadLocalImage(localPath) {
|
|
4598
|
+
async uploadLocalImage(localPath, noteFolder) {
|
|
3842
4599
|
if (!this.notionClient) {
|
|
3843
4600
|
throw new Error("NotionClient required for image upload");
|
|
3844
4601
|
}
|
|
3845
|
-
const buffer = await this.readImageFromVault(localPath);
|
|
4602
|
+
const { buffer, resolvedPath } = await this.readImageFromVault(localPath, noteFolder);
|
|
3846
4603
|
const filename = localPath.split("/").pop() ?? "image.png";
|
|
3847
4604
|
const contentType = this.getContentTypeFromPath(filename);
|
|
3848
4605
|
const blob = new Blob([buffer], { type: contentType });
|
|
3849
4606
|
const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
|
|
3850
4607
|
const fileHash = createHash("sha256").update(buffer).digest("hex");
|
|
3851
|
-
return { localPath, fileUploadId, fileHash, fileSize: buffer.length };
|
|
4608
|
+
return { localPath: resolvedPath, fileUploadId, fileHash, fileSize: buffer.length };
|
|
3852
4609
|
}
|
|
3853
|
-
async uploadAndAppendImages(pageId, images) {
|
|
4610
|
+
async uploadAndAppendImages(pageId, images, notePath) {
|
|
3854
4611
|
if (!this.notionClient) return [];
|
|
3855
4612
|
const localImages = images.filter((img) => !img.isExternal && img.localPath);
|
|
3856
4613
|
if (localImages.length === 0) return [];
|
|
4614
|
+
const noteFolder = notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
|
|
3857
4615
|
const results = [];
|
|
3858
4616
|
const imageBlocks = [];
|
|
3859
4617
|
for (const img of localImages) {
|
|
3860
4618
|
try {
|
|
3861
|
-
const result = await this.uploadLocalImage(img.localPath);
|
|
4619
|
+
const result = await this.uploadLocalImage(img.localPath, noteFolder);
|
|
3862
4620
|
results.push(result);
|
|
3863
4621
|
imageBlocks.push({
|
|
3864
4622
|
type: "image",
|
|
@@ -3868,7 +4626,11 @@ var ImageHandler = class {
|
|
|
3868
4626
|
}
|
|
3869
4627
|
});
|
|
3870
4628
|
} catch (error) {
|
|
3871
|
-
|
|
4629
|
+
if (error?.code === "ENOENT") {
|
|
4630
|
+
getLogger().warn(`\uC774\uBBF8\uC9C0 \uC5C5\uB85C\uB4DC \uAC74\uB108\uB700 \u2014 \uBCFC\uD2B8\uC5D0\uC11C \uD30C\uC77C\uC744 \uCC3E\uC9C0 \uBABB\uD568: ${img.localPath}`);
|
|
4631
|
+
} else {
|
|
4632
|
+
getLogger().warn(`\uC774\uBBF8\uC9C0 \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${img.localPath}):`, error);
|
|
4633
|
+
}
|
|
3872
4634
|
}
|
|
3873
4635
|
}
|
|
3874
4636
|
if (imageBlocks.length > 0) {
|
|
@@ -3892,23 +4654,39 @@ var ImageHandler = class {
|
|
|
3892
4654
|
}
|
|
3893
4655
|
return results;
|
|
3894
4656
|
}
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
4657
|
+
/**
|
|
4658
|
+
* 임베드 경로를 볼트 실제 파일로 해석한다(F23). Obsidian 위키링크 임베드는 노트 옆
|
|
4659
|
+
* 파일을 파일명만으로 참조하는 게 관례라, 노트 폴더 → 볼트 루트 → attachments/ 순으로
|
|
4660
|
+
* 시도한다. 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
|
|
4661
|
+
*/
|
|
4662
|
+
async readImageFromVault(localPath, noteFolder) {
|
|
4663
|
+
const candidates = [];
|
|
4664
|
+
if (noteFolder && !localPath.includes("/")) {
|
|
4665
|
+
candidates.push(`${noteFolder}/${localPath}`);
|
|
4666
|
+
}
|
|
4667
|
+
candidates.push(localPath, `${this.attachmentFolder}/${localPath}`);
|
|
4668
|
+
let lastError;
|
|
4669
|
+
for (const candidate of candidates) {
|
|
4670
|
+
try {
|
|
4671
|
+
return { buffer: await this.vaultFs.readBinary(candidate), resolvedPath: candidate };
|
|
4672
|
+
} catch (error) {
|
|
4673
|
+
lastError = error;
|
|
4674
|
+
}
|
|
3900
4675
|
}
|
|
4676
|
+
throw lastError;
|
|
3901
4677
|
}
|
|
3902
4678
|
getContentTypeFromPath(filename) {
|
|
3903
4679
|
const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
|
|
3904
4680
|
return (ext && EXTENSION_TO_MIME[ext]) ?? "application/octet-stream";
|
|
3905
4681
|
}
|
|
3906
4682
|
async downloadAllFiles(markdown, pageTitle) {
|
|
3907
|
-
const
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
const
|
|
3911
|
-
|
|
4683
|
+
const fileLinks = scanMarkdownLinks(markdown).filter(
|
|
4684
|
+
(s) => !s.isEmbed && FILE_LABEL_PREFIX_RE.test(s.label)
|
|
4685
|
+
);
|
|
4686
|
+
const notionHttpMatches = fileLinks.filter(
|
|
4687
|
+
(s) => /^https?:\/\//.test(s.url) && this.isNotionFileUrl(s.url)
|
|
4688
|
+
);
|
|
4689
|
+
const mdInternalMatches = fileLinks.filter((s) => INTERNAL_FILE_URL_RE.test(s.url));
|
|
3912
4690
|
const xmlHttpRegex = /<(?:file|video|audio|pdf)\s+src="(https?:\/\/[^"]+)">([\s\S]*?)<\/(?:file|video|audio|pdf)>/g;
|
|
3913
4691
|
const xmlHttpMatches = [...markdown.matchAll(xmlHttpRegex)];
|
|
3914
4692
|
const notionXmlHttpMatches = xmlHttpMatches.filter((m) => m[1] && this.isNotionFileUrl(m[1]));
|
|
@@ -3921,38 +4699,36 @@ var ImageHandler = class {
|
|
|
3921
4699
|
const sema = new Sema(this.concurrency);
|
|
3922
4700
|
const downloads = [];
|
|
3923
4701
|
let result = markdown;
|
|
3924
|
-
for (const
|
|
4702
|
+
for (const span of notionHttpMatches) {
|
|
3925
4703
|
await sema.acquire();
|
|
3926
4704
|
try {
|
|
3927
|
-
const
|
|
3928
|
-
const
|
|
3929
|
-
const download = await this.downloadFile(url, pageTitle, caption);
|
|
4705
|
+
const caption = toWikilinkAlias(span.label.replace(FILE_LABEL_PREFIX_RE, "")) || "file";
|
|
4706
|
+
const download = await this.downloadFile(span.url, pageTitle, caption);
|
|
3930
4707
|
if (!download.localPath) continue;
|
|
3931
4708
|
downloads.push(download);
|
|
3932
|
-
const obsidianLink =
|
|
3933
|
-
result = result.replace(
|
|
4709
|
+
const obsidianLink = `![[${download.localPath}|${caption}]]`;
|
|
4710
|
+
result = result.replace(span.full, obsidianLink);
|
|
3934
4711
|
} catch (error) {
|
|
3935
4712
|
getLogger().warn(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
|
|
3936
4713
|
} finally {
|
|
3937
4714
|
sema.release();
|
|
3938
4715
|
}
|
|
3939
4716
|
}
|
|
3940
|
-
for (const
|
|
4717
|
+
for (const span of mdInternalMatches) {
|
|
3941
4718
|
await sema.acquire();
|
|
3942
4719
|
try {
|
|
3943
|
-
const
|
|
3944
|
-
const parsed = this.parseNotionFileUrl(fileUrl);
|
|
4720
|
+
const parsed = this.parseNotionFileUrl(span.url);
|
|
3945
4721
|
if (!parsed || !this.notionClient) continue;
|
|
3946
4722
|
const blockId = parsed.blockId;
|
|
3947
4723
|
const fileName = parsed.fileName;
|
|
3948
4724
|
const realUrl = await this.notionClient.getFileBlockUrl(blockId);
|
|
3949
4725
|
if (!realUrl) continue;
|
|
3950
|
-
const caption = fileName ||
|
|
4726
|
+
const caption = fileName || toWikilinkAlias(span.label.replace(FILE_LABEL_PREFIX_RE, "")) || "file";
|
|
3951
4727
|
const download = await this.downloadFile(realUrl, pageTitle, caption);
|
|
3952
4728
|
if (!download.localPath) continue;
|
|
3953
4729
|
downloads.push(download);
|
|
3954
|
-
const obsidianLink =
|
|
3955
|
-
result = result.replace(
|
|
4730
|
+
const obsidianLink = `![[${download.localPath}|${caption}]]`;
|
|
4731
|
+
result = result.replace(span.full, obsidianLink);
|
|
3956
4732
|
} catch (error) {
|
|
3957
4733
|
getLogger().warn(`\uB0B4\uBD80 \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
|
|
3958
4734
|
} finally {
|
|
@@ -3963,11 +4739,11 @@ var ImageHandler = class {
|
|
|
3963
4739
|
await sema.acquire();
|
|
3964
4740
|
try {
|
|
3965
4741
|
const url = match[1];
|
|
3966
|
-
const caption = match[2]
|
|
4742
|
+
const caption = toWikilinkAlias(match[2] ?? "") || "file";
|
|
3967
4743
|
const download = await this.downloadFile(url, pageTitle, caption);
|
|
3968
4744
|
if (!download.localPath) continue;
|
|
3969
4745
|
downloads.push(download);
|
|
3970
|
-
const obsidianLink =
|
|
4746
|
+
const obsidianLink = `![[${download.localPath}|${caption}]]`;
|
|
3971
4747
|
result = result.replace(match[0], obsidianLink);
|
|
3972
4748
|
} catch (error) {
|
|
3973
4749
|
getLogger().warn(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
|
|
@@ -3985,11 +4761,11 @@ var ImageHandler = class {
|
|
|
3985
4761
|
const fileName = parsed.fileName;
|
|
3986
4762
|
const realUrl = await this.notionClient.getFileBlockUrl(blockId);
|
|
3987
4763
|
if (!realUrl) continue;
|
|
3988
|
-
const caption = fileName || match[2]
|
|
4764
|
+
const caption = fileName || toWikilinkAlias(match[2] ?? "") || "file";
|
|
3989
4765
|
const download = await this.downloadFile(realUrl, pageTitle, caption);
|
|
3990
4766
|
if (!download.localPath) continue;
|
|
3991
4767
|
downloads.push(download);
|
|
3992
|
-
const obsidianLink =
|
|
4768
|
+
const obsidianLink = `![[${download.localPath}|${caption}]]`;
|
|
3993
4769
|
result = result.replace(match[0], obsidianLink);
|
|
3994
4770
|
} catch (error) {
|
|
3995
4771
|
getLogger().warn(`\uB0B4\uBD80 \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${error}`);
|
|
@@ -4031,7 +4807,9 @@ var ImageHandler = class {
|
|
|
4031
4807
|
const MAX_FILE_SIZE = this.maxFileSizeBytes;
|
|
4032
4808
|
if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
|
|
4033
4809
|
const sizeMB = Math.round(parseInt(contentLength, 10) / 1024 / 1024);
|
|
4034
|
-
getLogger().
|
|
4810
|
+
getLogger().info(
|
|
4811
|
+
`[Im-Nobsidian] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uAC74\uB108\uB700 \u2014 \uD06C\uAE30 \uC0C1\uD55C \uCD08\uACFC (${sizeMB}MB > ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB), Notion \uC6D0\uBCF8 \uB9C1\uD06C \uC720\uC9C0: ${caption}`
|
|
4812
|
+
);
|
|
4035
4813
|
return { originalUrl: url, localPath: "", hash: "", size: 0 };
|
|
4036
4814
|
}
|
|
4037
4815
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
@@ -4053,7 +4831,26 @@ var ImageHandler = class {
|
|
|
4053
4831
|
return this.isNotionFileUrl(url);
|
|
4054
4832
|
}
|
|
4055
4833
|
isNotionFileUrl(url) {
|
|
4056
|
-
return
|
|
4834
|
+
return isNotionHostedFileUrl(url);
|
|
4835
|
+
}
|
|
4836
|
+
/**
|
|
4837
|
+
* notion-hosted 파일 URL 하나를 로컬 첨부로 로컬라이즈한다(P3-A).
|
|
4838
|
+
* DB 행의 `files` 속성처럼 본문 밖에 있는 서명 URL 용 — 서명 URL 은 약 1시간 뒤
|
|
4839
|
+
* 만료되어 frontmatter 에 남으면 깨진 링크가 되고, push 로 되밀면 Notion 원본이
|
|
4840
|
+
* external(만료 URL)로 오염된다(실측). notion-hosted 가 아닌 URL(사용자가 넣은
|
|
4841
|
+
* 진짜 외부 링크)은 다운로드 대상이 아니므로 null 을 반환한다.
|
|
4842
|
+
*
|
|
4843
|
+
* @returns 로컬 첨부 경로(`attachments/...`), 다운로드 대상이 아니거나 실패 시 null
|
|
4844
|
+
*/
|
|
4845
|
+
async localizeNotionFileUrl(url, caption) {
|
|
4846
|
+
if (!/^https?:\/\//.test(url) || !this.isNotionFileUrl(url)) return null;
|
|
4847
|
+
try {
|
|
4848
|
+
const download = await this.downloadFile(url, caption, caption);
|
|
4849
|
+
return download.localPath || null;
|
|
4850
|
+
} catch (error) {
|
|
4851
|
+
getLogger().warn(`[Im-Nobsidian] \uC18D\uC131 \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328 (${caption}): ${error}`);
|
|
4852
|
+
return null;
|
|
4853
|
+
}
|
|
4057
4854
|
}
|
|
4058
4855
|
guessExtension(url) {
|
|
4059
4856
|
const pathname = new URL(url).pathname;
|
|
@@ -4080,6 +4877,28 @@ var ImageHandler = class {
|
|
|
4080
4877
|
return "";
|
|
4081
4878
|
}
|
|
4082
4879
|
};
|
|
4880
|
+
|
|
4881
|
+
// src/sync/stale-db-artifacts.ts
|
|
4882
|
+
var ARTIFACT_SUFFIXES = [".base", ".notion.json"];
|
|
4883
|
+
function isDbArtifactPath(path) {
|
|
4884
|
+
return ARTIFACT_SUFFIXES.some((suffix) => path.endsWith(suffix));
|
|
4885
|
+
}
|
|
4886
|
+
function selectStaleDbArtifacts(files, localFolder, keepPaths) {
|
|
4887
|
+
const prefix = `${localFolder}/`;
|
|
4888
|
+
const stale = [];
|
|
4889
|
+
for (const file of files) {
|
|
4890
|
+
const path = file.path;
|
|
4891
|
+
if (!path.startsWith(prefix)) continue;
|
|
4892
|
+
const rel = path.slice(prefix.length);
|
|
4893
|
+
if (rel.includes("/")) continue;
|
|
4894
|
+
if (!ARTIFACT_SUFFIXES.some((suffix) => rel.endsWith(suffix))) continue;
|
|
4895
|
+
if (keepPaths.has(path)) continue;
|
|
4896
|
+
stale.push(path);
|
|
4897
|
+
}
|
|
4898
|
+
return stale;
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4901
|
+
// src/sync/file-handler.ts
|
|
4083
4902
|
var EXTENSION_TO_BLOCK_TYPE = {
|
|
4084
4903
|
".png": "image",
|
|
4085
4904
|
".jpg": "image",
|
|
@@ -4188,7 +5007,7 @@ var FileHandler = class {
|
|
|
4188
5007
|
stateDb;
|
|
4189
5008
|
sema;
|
|
4190
5009
|
async pushFilesForFolder(folderPageId, folderPath) {
|
|
4191
|
-
const allFiles = await this.
|
|
5010
|
+
const allFiles = await this.listUploadableFiles();
|
|
4192
5011
|
const folderFiles = allFiles.filter((f) => getFolderPath(f.path) === folderPath);
|
|
4193
5012
|
if (folderFiles.length === 0) return [];
|
|
4194
5013
|
const results = [];
|
|
@@ -4212,8 +5031,18 @@ var FileHandler = class {
|
|
|
4212
5031
|
}
|
|
4213
5032
|
return results;
|
|
4214
5033
|
}
|
|
4215
|
-
|
|
5034
|
+
/**
|
|
5035
|
+
* 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 제외한다.
|
|
5036
|
+
* node 측 VaultFS 는 과거 `.base` 를 워커에서 빼는 식으로 우회했지만, Obsidian
|
|
5037
|
+
* 어댑터는 모든 파일을 반환하므로 플러그인 push 가 사이드카를 Notion 첨부로
|
|
5038
|
+
* 오염시켰다. 소비자인 여기서 일괄 차단해 구현체 간 동작을 통일한다.
|
|
5039
|
+
*/
|
|
5040
|
+
async listUploadableFiles() {
|
|
4216
5041
|
const allFiles = await this.vaultFs.listNonMarkdownFiles();
|
|
5042
|
+
return allFiles.filter((f) => !isDbArtifactPath(f.path));
|
|
5043
|
+
}
|
|
5044
|
+
async pushAllFiles() {
|
|
5045
|
+
const allFiles = await this.listUploadableFiles();
|
|
4217
5046
|
if (allFiles.length === 0) return [];
|
|
4218
5047
|
const filesByFolder = /* @__PURE__ */ new Map();
|
|
4219
5048
|
for (const file of allFiles) {
|
|
@@ -4442,6 +5271,44 @@ function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
|
|
|
4442
5271
|
return `${localFolder}/${safeName} (${raw}).md`;
|
|
4443
5272
|
}
|
|
4444
5273
|
|
|
5274
|
+
// src/utils/db-folder-path.ts
|
|
5275
|
+
function resolveDbFolderPath(folder, dbId, ownerOf) {
|
|
5276
|
+
const usable = (candidate) => {
|
|
5277
|
+
const owner = ownerOf(candidate);
|
|
5278
|
+
return owner === null || notionIdsEqual(owner, dbId);
|
|
5279
|
+
};
|
|
5280
|
+
if (usable(folder)) return folder;
|
|
5281
|
+
const raw = dbId.replace(/-/g, "");
|
|
5282
|
+
for (const len of [8, 16, 32]) {
|
|
5283
|
+
const candidate = `${folder} (${raw.slice(0, len)})`;
|
|
5284
|
+
if (usable(candidate)) return candidate;
|
|
5285
|
+
}
|
|
5286
|
+
return `${folder} (${raw})`;
|
|
5287
|
+
}
|
|
5288
|
+
function repairDbFolderCollisions(configs, takenFolders) {
|
|
5289
|
+
const owner = new Map(takenFolders ?? []);
|
|
5290
|
+
let repaired = 0;
|
|
5291
|
+
for (const config of configs) {
|
|
5292
|
+
const resolved = resolveDbFolderPath(
|
|
5293
|
+
config.localFolder,
|
|
5294
|
+
config.databaseId,
|
|
5295
|
+
(f) => owner.get(f) ?? null
|
|
5296
|
+
);
|
|
5297
|
+
if (resolved !== config.localFolder) {
|
|
5298
|
+
config.localFolder = resolved;
|
|
5299
|
+
repaired++;
|
|
5300
|
+
}
|
|
5301
|
+
owner.set(resolved, config.databaseId);
|
|
5302
|
+
}
|
|
5303
|
+
return repaired;
|
|
5304
|
+
}
|
|
5305
|
+
function isDirectDbRowPath(localFolder, path) {
|
|
5306
|
+
const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
|
|
5307
|
+
if (!path.startsWith(prefix)) return false;
|
|
5308
|
+
const rel = path.slice(prefix.length);
|
|
5309
|
+
return rel.length > 0 && !rel.includes("/");
|
|
5310
|
+
}
|
|
5311
|
+
|
|
4445
5312
|
// src/utils/wikilink-title.ts
|
|
4446
5313
|
function wikilinkTitleFromPath(obsidianPath) {
|
|
4447
5314
|
return obsidianPath.replace(/\.md$/i, "").split("/").pop() ?? "";
|
|
@@ -4781,23 +5648,6 @@ function sortKeys(value) {
|
|
|
4781
5648
|
return value;
|
|
4782
5649
|
}
|
|
4783
5650
|
|
|
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
|
-
|
|
4801
5651
|
// src/converter/enhanced-md-converter.ts
|
|
4802
5652
|
var NOTION_CALLOUT_RE = /^::: callout\n([\s\S]*?)\n:::/gm;
|
|
4803
5653
|
var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
|
|
@@ -4827,12 +5677,13 @@ function notionEnhancedToObsidian(enhanced) {
|
|
|
4827
5677
|
result = convertNotionTables(result);
|
|
4828
5678
|
result = convertSpans(result);
|
|
4829
5679
|
result = convertDatabaseBlocks(result);
|
|
4830
|
-
result =
|
|
5680
|
+
result = convertBlockColorAttrs(result);
|
|
4831
5681
|
result = removeEmptyBlocks(result);
|
|
4832
5682
|
result = unescapePipes(result);
|
|
4833
5683
|
result = unescapeNotionChars(result);
|
|
4834
5684
|
result = unescapeWikilinkBrackets(result);
|
|
4835
5685
|
result = ensureCalloutContinuity(result);
|
|
5686
|
+
result = separateAdjacentCallouts(result);
|
|
4836
5687
|
return result;
|
|
4837
5688
|
}
|
|
4838
5689
|
function unescapeWikilinkBrackets(content) {
|
|
@@ -4846,9 +5697,12 @@ function obsidianToNotionEnhanced(obsidian) {
|
|
|
4846
5697
|
result = restoreMediaTags(result);
|
|
4847
5698
|
result = restoreUnknownBlocks(result);
|
|
4848
5699
|
result = restoreColorSpans(result);
|
|
5700
|
+
result = restoreBlockColorMarkers(result);
|
|
4849
5701
|
result = restoreUnderlineSpans(result);
|
|
4850
5702
|
result = convertMentionPageIdToUrl(result);
|
|
4851
5703
|
result = restoreWikilinkPreserveLinks(result);
|
|
5704
|
+
result = restoreSyncedBlocks(result);
|
|
5705
|
+
result = reassembleColumns(result);
|
|
4852
5706
|
return result;
|
|
4853
5707
|
}
|
|
4854
5708
|
var MENTION_PAGE_ID_RE = /<mention-page id="([^"]+)">[\s\S]*?<\/mention-page>/g;
|
|
@@ -4941,29 +5795,36 @@ ${fence}
|
|
|
4941
5795
|
}
|
|
4942
5796
|
);
|
|
4943
5797
|
}
|
|
4944
|
-
var NO_CONTAINER_BODY = "(?:(?!<details
|
|
4945
|
-
var SUMMARY_TITLE = "((?:(?!<\\/summary>|<details
|
|
5798
|
+
var NO_CONTAINER_BODY = "(?:(?!<details|<callout|<columns)[\\s\\S])*?";
|
|
5799
|
+
var SUMMARY_TITLE = "((?:(?!<\\/summary>|<details|<summary>)[\\s\\S])*?)";
|
|
4946
5800
|
var INNERMOST_DETAILS_RE = new RegExp(
|
|
4947
|
-
`([\\t ]*)<details
|
|
5801
|
+
`([\\t ]*)<details([^>]*)>[ \\t]*\\n?(?:\\s*<summary>${SUMMARY_TITLE}<\\/summary>)?(${NO_CONTAINER_BODY})<\\/details>`,
|
|
4948
5802
|
"g"
|
|
4949
5803
|
);
|
|
4950
5804
|
var INNERMOST_CALLOUT_RE = new RegExp(
|
|
4951
|
-
`([\\t ]*)<callout[^>]
|
|
5805
|
+
`([\\t ]*)<callout([^>]*)>\\n?(${NO_CONTAINER_BODY})<\\/callout>`,
|
|
4952
5806
|
"g"
|
|
4953
5807
|
);
|
|
4954
5808
|
var INNERMOST_COLUMNS_RE = new RegExp(
|
|
4955
|
-
`([\\t ]*)<columns
|
|
5809
|
+
`([\\t ]*)<columns[^>]*>\\n?(${NO_CONTAINER_BODY})<\\/columns>`,
|
|
4956
5810
|
"g"
|
|
4957
5811
|
);
|
|
4958
|
-
var COLUMN_WRAP_RE = /[\t ]*<column
|
|
5812
|
+
var COLUMN_WRAP_RE = /[\t ]*<column(?!s)[^>]*>\n?([\s\S]*?)[\t ]*<\/column>\n?/g;
|
|
4959
5813
|
function reindentLines(text, indent) {
|
|
4960
5814
|
if (!indent) return text;
|
|
4961
5815
|
return text.split("\n").map((line) => line === "" ? line : indent + line).join("\n");
|
|
4962
5816
|
}
|
|
4963
|
-
function
|
|
5817
|
+
function parseDesignAttrs(attrs) {
|
|
5818
|
+
const icon = /\bicon="([^"]*)"/.exec(attrs)?.[1];
|
|
5819
|
+
const color = /\bcolor="([^"]*)"/.exec(attrs)?.[1];
|
|
5820
|
+
return { icon: icon || void 0, color: color || void 0 };
|
|
5821
|
+
}
|
|
5822
|
+
function toggleToCallout(title, rawBody, color) {
|
|
4964
5823
|
const body = dedentContainerBody(rawBody);
|
|
5824
|
+
const head = ["> [!toggle]-", title.trim(), color ? toggleColorMarker(color) : ""].filter((part) => part !== "").join(" ");
|
|
5825
|
+
if (body.trim() === "") return head;
|
|
4965
5826
|
const calloutBody = body.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n");
|
|
4966
|
-
return
|
|
5827
|
+
return `${head}
|
|
4967
5828
|
${calloutBody}`;
|
|
4968
5829
|
}
|
|
4969
5830
|
function flattenColumns(body) {
|
|
@@ -4977,20 +5838,22 @@ function flattenColumns(body) {
|
|
|
4977
5838
|
if (col.trim() !== "") cols.push(col);
|
|
4978
5839
|
}
|
|
4979
5840
|
if (!matched) return dedentContainerBody(body);
|
|
4980
|
-
|
|
5841
|
+
if (cols.length === 0) return "";
|
|
5842
|
+
return [COLUMN_LIST_START, ...cols.map((c) => `${COLUMN_SEP}
|
|
5843
|
+
${c}`), COLUMN_LIST_END].join("\n");
|
|
4981
5844
|
}
|
|
4982
5845
|
function convertContainers(content) {
|
|
4983
5846
|
let result = content;
|
|
4984
5847
|
let safety = 0;
|
|
4985
|
-
while (safety++ < 2e3 && (result.includes("<details
|
|
5848
|
+
while (safety++ < 2e3 && (result.includes("<details") || result.includes("<callout") || result.includes("<columns"))) {
|
|
4986
5849
|
const before = result;
|
|
4987
5850
|
result = result.replace(
|
|
4988
5851
|
INNERMOST_DETAILS_RE,
|
|
4989
|
-
(_m, indent, title, body) => reindentLines(toggleToCallout(title, body), indent)
|
|
5852
|
+
(_m, indent, attrs, title, body) => reindentLines(toggleToCallout(title ?? "", body, parseDesignAttrs(attrs).color), indent)
|
|
4990
5853
|
);
|
|
4991
5854
|
result = result.replace(
|
|
4992
5855
|
INNERMOST_CALLOUT_RE,
|
|
4993
|
-
(_m, indent, body) => reindentLines(calloutBodyToObsidian(body), indent)
|
|
5856
|
+
(_m, indent, attrs, body) => reindentLines(calloutBodyToObsidian(body, parseDesignAttrs(attrs)), indent)
|
|
4994
5857
|
);
|
|
4995
5858
|
result = result.replace(
|
|
4996
5859
|
INNERMOST_COLUMNS_RE,
|
|
@@ -4998,16 +5861,38 @@ function convertContainers(content) {
|
|
|
4998
5861
|
);
|
|
4999
5862
|
if (result === before) break;
|
|
5000
5863
|
}
|
|
5001
|
-
return result;
|
|
5864
|
+
return stripQuotedColumnMarkers(result);
|
|
5865
|
+
}
|
|
5866
|
+
var QUOTED_COLUMN_EDGE_RE = new RegExp(
|
|
5867
|
+
`^(?:>[ \\t]?)+[ \\t]*%%${MARKER_BRAND_RE}:column-list:(?:start|end)%%[ \\t]*\\n?`,
|
|
5868
|
+
"gm"
|
|
5869
|
+
);
|
|
5870
|
+
var QUOTED_COLUMN_SEP_RE = new RegExp(
|
|
5871
|
+
`^((?:>[ \\t]?)+)[ \\t]*%%${MARKER_BRAND_RE}:column%%[ \\t]*$`,
|
|
5872
|
+
"gm"
|
|
5873
|
+
);
|
|
5874
|
+
var QUOTED_COLUMN_INLINE_RE = new RegExp(
|
|
5875
|
+
`[ \\t]*%%${MARKER_BRAND_RE}:column(?:-list:(?:start|end))?%%`,
|
|
5876
|
+
"g"
|
|
5877
|
+
);
|
|
5878
|
+
function stripQuotedColumnMarkers(content) {
|
|
5879
|
+
return content.replace(QUOTED_COLUMN_EDGE_RE, "").replace(QUOTED_COLUMN_SEP_RE, (_m, prefix) => prefix.trimEnd()).replace(
|
|
5880
|
+
/^(?:>[ \t]?).*%%.*$/gm,
|
|
5881
|
+
(line) => line.replace(QUOTED_COLUMN_INLINE_RE, "").trimEnd()
|
|
5882
|
+
);
|
|
5002
5883
|
}
|
|
5003
|
-
function calloutBodyToObsidian(body) {
|
|
5884
|
+
function calloutBodyToObsidian(body, style) {
|
|
5004
5885
|
const lines = dedentContainerBody(body).split("\n").filter((l, i, arr) => !(i === 0 && l === "") && !(i === arr.length - 1 && l === ""));
|
|
5005
5886
|
const firstLine = lines[0] ?? "";
|
|
5006
|
-
const emojiMatch = /^([\p{Emoji}️]+)\s*(.*)/u.exec(firstLine);
|
|
5007
|
-
const
|
|
5887
|
+
const emojiMatch = style?.icon || style?.color ? null : /^([\p{Emoji}️]+)\s*(.*)/u.exec(firstLine);
|
|
5888
|
+
const rawIcon = style?.icon ?? (emojiMatch ? emojiMatch[1] : void 0);
|
|
5889
|
+
const icon = rawIcon !== void 0 && /^https?:\/\//i.test(rawIcon) ? void 0 : rawIcon;
|
|
5890
|
+
const type = icon ? emojiToCalloutType(icon) : "note";
|
|
5008
5891
|
const title = emojiMatch ? emojiMatch[2] : firstLine;
|
|
5009
5892
|
const rest = lines.slice(1).join("\n").trim();
|
|
5010
|
-
const
|
|
5893
|
+
const needsMarker = Boolean(style?.color) || icon !== void 0 && calloutTypeToEmoji(type) !== icon;
|
|
5894
|
+
const styleTail = needsMarker ? ` ${calloutStyleMarker({ icon, color: style?.color })}` : "";
|
|
5895
|
+
const calloutTitle = title ? `> [!${type}] ${title}${styleTail}` : `> [!${type}]${styleTail}`;
|
|
5011
5896
|
const calloutBody = rest ? "\n" + rest.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n") : "";
|
|
5012
5897
|
return calloutTitle + calloutBody;
|
|
5013
5898
|
}
|
|
@@ -5020,7 +5905,7 @@ function convertPageMentions(content) {
|
|
|
5020
5905
|
return `[[${cleaned}]]`;
|
|
5021
5906
|
});
|
|
5022
5907
|
result = result.replace(
|
|
5023
|
-
/<mention-page\s+url="https?:\/\/(?:
|
|
5908
|
+
/<mention-page\s+url="https?:\/\/(?:[a-z]+\.)?notion\.(?:so|com)\/(?:p\/)?([a-f0-9]{32})"[^>]*?(?:\/>|>[\s\S]*?<\/mention-page>)/g,
|
|
5024
5909
|
(_match, id) => `[[notion:${id}]]`
|
|
5025
5910
|
);
|
|
5026
5911
|
return result;
|
|
@@ -5050,19 +5935,52 @@ function preserveUnknownBlocks(content) {
|
|
|
5050
5935
|
return result;
|
|
5051
5936
|
}
|
|
5052
5937
|
function convertSyncedBlockRef(content) {
|
|
5938
|
+
const unwrap = (match, openRe, closeRe) => match.replace(openRe, "").replace(closeRe, "").split("\n").map((l) => l.replace(/^\t/, "")).join("\n").trim();
|
|
5053
5939
|
let result = content.replace(
|
|
5054
|
-
/<synced_block_reference[^>]
|
|
5055
|
-
(match) => {
|
|
5056
|
-
const inner = match
|
|
5057
|
-
|
|
5940
|
+
/<synced_block_reference([^>]*)>[\s\S]*?<\/synced_block_reference>/g,
|
|
5941
|
+
(match, attrs) => {
|
|
5942
|
+
const inner = unwrap(match, /<synced_block_reference[^>]*>\n?/, /<\/synced_block_reference>/);
|
|
5943
|
+
const url = /url="([^"]*)"/.exec(attrs)?.[1];
|
|
5944
|
+
if (!url) return inner;
|
|
5945
|
+
return `${syncedStartMarker("ref", url)}
|
|
5946
|
+
${inner}
|
|
5947
|
+
${SYNCED_END}`;
|
|
5058
5948
|
}
|
|
5059
5949
|
);
|
|
5060
5950
|
result = result.replace(
|
|
5061
|
-
/<synced_block[^>]
|
|
5062
|
-
(
|
|
5951
|
+
/<synced_block([^>]*)>[\s\S]*?<\/synced_block>/g,
|
|
5952
|
+
(match, attrs) => {
|
|
5953
|
+
const inner = unwrap(match, /<synced_block[^>]*>\n?/, /<\/synced_block>/);
|
|
5954
|
+
const url = /url="([^"]*)"/.exec(attrs)?.[1];
|
|
5955
|
+
if (!url) return inner;
|
|
5956
|
+
return `${syncedStartMarker("orig", url)}
|
|
5957
|
+
${inner}
|
|
5958
|
+
${SYNCED_END}`;
|
|
5959
|
+
}
|
|
5063
5960
|
);
|
|
5064
5961
|
return result;
|
|
5065
5962
|
}
|
|
5963
|
+
var SYNCED_MARKER_PAIR_RE = new RegExp(
|
|
5964
|
+
`%%${MARKER_BRAND_RE}:synced:start:(.*?)%%\\n?([\\s\\S]*?)%%${MARKER_BRAND_RE}:synced:end%%`,
|
|
5965
|
+
"g"
|
|
5966
|
+
);
|
|
5967
|
+
function restoreSyncedBlocks(content) {
|
|
5968
|
+
return content.replace(SYNCED_MARKER_PAIR_RE, (_match, params, body) => {
|
|
5969
|
+
const kind = /kind=(ref|orig)/.exec(params)?.[1] ?? "orig";
|
|
5970
|
+
const encoded = /url=([^&]*)/.exec(params)?.[1] ?? "";
|
|
5971
|
+
let url = encoded;
|
|
5972
|
+
try {
|
|
5973
|
+
url = decodeURIComponent(encoded);
|
|
5974
|
+
} catch {
|
|
5975
|
+
}
|
|
5976
|
+
if (!url) return body.trim();
|
|
5977
|
+
const tag = kind === "ref" ? "synced_block_reference" : "synced_block";
|
|
5978
|
+
const indented = body.trim().split("\n").map((l) => l.length > 0 ? ` ${l}` : l).join("\n");
|
|
5979
|
+
return `<${tag} url="${url}">
|
|
5980
|
+
${indented}
|
|
5981
|
+
</${tag}>`;
|
|
5982
|
+
});
|
|
5983
|
+
}
|
|
5066
5984
|
function convertMediaTags(content) {
|
|
5067
5985
|
let result = content;
|
|
5068
5986
|
result = result.replace(NOTION_AUDIO_RE, (_match, src, caption) => {
|
|
@@ -5105,17 +6023,21 @@ function convertSpans(content) {
|
|
|
5105
6023
|
}
|
|
5106
6024
|
return result;
|
|
5107
6025
|
}
|
|
6026
|
+
var TOGGLE_COLOR_MARKER_RE = new RegExp(`\\s*%%${MARKER_BRAND_RE}:toggle-color:([a-z_]+)%%`);
|
|
5108
6027
|
function convertTogglesToHtml(content) {
|
|
5109
|
-
const calloutToggleRe = /^> \[!toggle\]
|
|
6028
|
+
const calloutToggleRe = /^> \[!toggle\]-[ \t]*(.*)\n?((?:>.*\n?)*)/gm;
|
|
5110
6029
|
let result = content;
|
|
5111
6030
|
let prev = "";
|
|
5112
6031
|
let safety = 0;
|
|
5113
6032
|
while (result !== prev && safety++ < 100) {
|
|
5114
6033
|
prev = result;
|
|
5115
6034
|
result = result.replace(calloutToggleRe, (_match, title, body) => {
|
|
6035
|
+
const colorMatch = TOGGLE_COLOR_MARKER_RE.exec(title);
|
|
6036
|
+
const cleanTitle = colorMatch ? title.replace(TOGGLE_COLOR_MARKER_RE, "") : title;
|
|
6037
|
+
const attrs = colorMatch ? ` color="${colorMatch[1]}"` : "";
|
|
5116
6038
|
const bodyText = body.split("\n").map((line) => line.replace(/^>\s?/, "")).join("\n").trim();
|
|
5117
|
-
return `<details>
|
|
5118
|
-
<summary>${
|
|
6039
|
+
return `<details${attrs}>
|
|
6040
|
+
<summary>${cleanTitle.trim()}</summary>
|
|
5119
6041
|
|
|
5120
6042
|
${bodyText}
|
|
5121
6043
|
|
|
@@ -5142,6 +6064,7 @@ ${body}
|
|
|
5142
6064
|
result = result.slice(0, startIdx) + html + result.slice(endIdx + TOGGLE_END.length);
|
|
5143
6065
|
}
|
|
5144
6066
|
result = result.replace(startRe, "").replace(endRe, "");
|
|
6067
|
+
result = result.replace(/^(<\/details>)(?!\n|$)/gm, "$1\n");
|
|
5145
6068
|
return result;
|
|
5146
6069
|
}
|
|
5147
6070
|
function findMatchingEnd(content, startFrom) {
|
|
@@ -5162,29 +6085,53 @@ function findMatchingEnd(content, startFrom) {
|
|
|
5162
6085
|
}
|
|
5163
6086
|
return -1;
|
|
5164
6087
|
}
|
|
6088
|
+
var CALLOUT_STYLE_MARKER_RE = new RegExp(`\\s*%%${MARKER_BRAND_RE}:callout-style:(.*?)%%`);
|
|
6089
|
+
function parseCalloutStyleParams(params) {
|
|
6090
|
+
const style = {};
|
|
6091
|
+
for (const kv of params.split("&")) {
|
|
6092
|
+
const eq = kv.indexOf("=");
|
|
6093
|
+
if (eq <= 0) continue;
|
|
6094
|
+
const key = kv.slice(0, eq);
|
|
6095
|
+
const value = decodeURIComponent(kv.slice(eq + 1));
|
|
6096
|
+
if (key === "icon") style.icon = value;
|
|
6097
|
+
else if (key === "color") style.color = value;
|
|
6098
|
+
}
|
|
6099
|
+
return style;
|
|
6100
|
+
}
|
|
5165
6101
|
function convertObsidianCallouts(content) {
|
|
5166
6102
|
const lines = content.split("\n");
|
|
5167
6103
|
const result = [];
|
|
5168
6104
|
let i = 0;
|
|
5169
6105
|
while (i < lines.length) {
|
|
5170
|
-
const headerMatch = /^> \[!(\w+)\]([-+])
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
const
|
|
6106
|
+
const headerMatch = /^> \[!(\w+)\]([-+])?[ \t]*(.*)$/.exec(lines[i]);
|
|
6107
|
+
const type = headerMatch?.[1]?.toLowerCase();
|
|
6108
|
+
if (headerMatch && type !== "toggle" && type !== "tab") {
|
|
6109
|
+
let title = headerMatch[3];
|
|
6110
|
+
let style = {};
|
|
6111
|
+
const styleMatch = CALLOUT_STYLE_MARKER_RE.exec(title);
|
|
6112
|
+
if (styleMatch) {
|
|
6113
|
+
style = parseCalloutStyleParams(styleMatch[1]);
|
|
6114
|
+
title = title.replace(CALLOUT_STYLE_MARKER_RE, "");
|
|
6115
|
+
} else {
|
|
6116
|
+
style.icon = calloutTypeToEmoji(type);
|
|
6117
|
+
}
|
|
5176
6118
|
const bodyLines = [];
|
|
5177
6119
|
i++;
|
|
5178
|
-
while (i < lines.length && lines[i].startsWith("> ")) {
|
|
5179
|
-
bodyLines.push(lines[i].slice(2));
|
|
6120
|
+
while (i < lines.length && (lines[i] === ">" || lines[i].startsWith("> "))) {
|
|
6121
|
+
bodyLines.push(lines[i] === ">" ? "" : lines[i].slice(2));
|
|
5180
6122
|
i++;
|
|
5181
6123
|
}
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
6124
|
+
const innerConverted = convertObsidianCallouts(convertTogglesToHtml(bodyLines.join("\n")));
|
|
6125
|
+
const attrs = `${style.icon ? ` icon="${style.icon}"` : ""}${style.color ? ` color="${style.color}"` : ""}`;
|
|
6126
|
+
result.push(`<callout${attrs}>`);
|
|
6127
|
+
const cleanTitle = title.trim();
|
|
6128
|
+
if (cleanTitle) result.push(` ${cleanTitle}`);
|
|
6129
|
+
if (innerConverted.trim() !== "") {
|
|
6130
|
+
for (const line of innerConverted.split("\n")) {
|
|
6131
|
+
result.push(line === "" ? "" : ` ${line}`);
|
|
6132
|
+
}
|
|
5186
6133
|
}
|
|
5187
|
-
result.push("
|
|
6134
|
+
result.push("</callout>");
|
|
5188
6135
|
} else {
|
|
5189
6136
|
result.push(lines[i]);
|
|
5190
6137
|
i++;
|
|
@@ -5228,12 +6175,38 @@ function convertPageLinks(content) {
|
|
|
5228
6175
|
}
|
|
5229
6176
|
function convertDatabaseBlocks(content) {
|
|
5230
6177
|
return content.replace(
|
|
5231
|
-
/<database[^>]
|
|
5232
|
-
(_match, title) =>
|
|
6178
|
+
/<database\b([^>]*)>([\s\S]*?)<\/database>/g,
|
|
6179
|
+
(_match, attrs, title) => {
|
|
6180
|
+
const clean = title.trim();
|
|
6181
|
+
const placeholder = `**${clean}** *(Notion DB)*`;
|
|
6182
|
+
const idMatch = /\burl="[^"]*?([a-f0-9]{32})[^"]*"/.exec(attrs);
|
|
6183
|
+
if (!idMatch?.[1]) return placeholder;
|
|
6184
|
+
return `${placeholder}${compactMarker(
|
|
6185
|
+
`child-database:id=${idMatch[1]}&title=${encodeURIComponent(clean)}`
|
|
6186
|
+
)}`;
|
|
6187
|
+
}
|
|
5233
6188
|
);
|
|
5234
6189
|
}
|
|
5235
|
-
|
|
5236
|
-
|
|
6190
|
+
var TRAILING_BLOCK_COLOR_RE = /[ \t]*\{color="([a-z_]+)"\}(?=[ \t]*$)/gm;
|
|
6191
|
+
function convertBlockColorAttrs(content) {
|
|
6192
|
+
return content.replace(TRAILING_BLOCK_COLOR_RE, (_m, color) => ` ${blockColorMarker(color)}`).replace(/\s*\{color="[^"]*"\}/g, "");
|
|
6193
|
+
}
|
|
6194
|
+
var CALLOUT_HEAD_RE = /^((?:> )*)> \[!\w+\][-+]?/;
|
|
6195
|
+
function separateAdjacentCallouts(content) {
|
|
6196
|
+
const lines = content.split("\n");
|
|
6197
|
+
const out = [];
|
|
6198
|
+
for (const line of lines) {
|
|
6199
|
+
const head = CALLOUT_HEAD_RE.exec(line);
|
|
6200
|
+
if (head && out.length > 0) {
|
|
6201
|
+
const parentPrefix = head[1];
|
|
6202
|
+
const prev = out[out.length - 1];
|
|
6203
|
+
if (prev.startsWith(`${parentPrefix}>`)) {
|
|
6204
|
+
out.push(parentPrefix.trimEnd());
|
|
6205
|
+
}
|
|
6206
|
+
}
|
|
6207
|
+
out.push(line);
|
|
6208
|
+
}
|
|
6209
|
+
return out.join("\n");
|
|
5237
6210
|
}
|
|
5238
6211
|
function removeEmptyBlocks(content) {
|
|
5239
6212
|
return content.replace(NOTION_EMPTY_BLOCK_RE, "\n");
|
|
@@ -5257,7 +6230,7 @@ function unescapeNotionChars(content) {
|
|
|
5257
6230
|
return content.replace(/\\~/g, "~").replace(/\\\^/g, "^");
|
|
5258
6231
|
}
|
|
5259
6232
|
var NOTION_TABLE_RE = /<table[^>]*>([\s\S]*?)<\/table>/g;
|
|
5260
|
-
var
|
|
6233
|
+
var TABLE_ROW_RE2 = /<tr>([\s\S]*?)<\/tr>/g;
|
|
5261
6234
|
var TABLE_CELL_RE = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/g;
|
|
5262
6235
|
function isAlignmentRow(cells) {
|
|
5263
6236
|
return cells.every((c) => /^:?-{2,}:?$/.test(c.trim()));
|
|
@@ -5266,7 +6239,7 @@ function convertNotionTables(content) {
|
|
|
5266
6239
|
return content.replace(NOTION_TABLE_RE, (_match, tableBody) => {
|
|
5267
6240
|
const rows = [];
|
|
5268
6241
|
let rowMatch;
|
|
5269
|
-
const rowRe = new RegExp(
|
|
6242
|
+
const rowRe = new RegExp(TABLE_ROW_RE2.source, TABLE_ROW_RE2.flags);
|
|
5270
6243
|
while ((rowMatch = rowRe.exec(tableBody)) !== null) {
|
|
5271
6244
|
const cells = [];
|
|
5272
6245
|
let cellMatch;
|
|
@@ -5298,26 +6271,23 @@ var OBSIDIAN_MEDIA_PDF_RE = /\[📄\s*([^\]]*)\]\(([^)]+)\)/g;
|
|
|
5298
6271
|
var OBSIDIAN_MEDIA_FILE_RE = /\[📎\s*([^\]]*)\]\(([^)]+)\)/g;
|
|
5299
6272
|
function restoreMediaTags(content) {
|
|
5300
6273
|
let result = content;
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
result = result.replace(OBSIDIAN_MEDIA_PDF_RE, (
|
|
5308
|
-
|
|
5309
|
-
});
|
|
5310
|
-
result = result.replace(OBSIDIAN_MEDIA_FILE_RE, (_match, caption, src) => {
|
|
5311
|
-
return `<file src="${src}">${caption}</file>`;
|
|
5312
|
-
});
|
|
6274
|
+
const restore = (tag) => (_match, caption, src) => {
|
|
6275
|
+
const cap = caption.trim() === tag ? "" : caption;
|
|
6276
|
+
return `<${tag} src="${src}">${cap}</${tag}>`;
|
|
6277
|
+
};
|
|
6278
|
+
result = result.replace(OBSIDIAN_MEDIA_AUDIO_RE, restore("audio"));
|
|
6279
|
+
result = result.replace(OBSIDIAN_MEDIA_VIDEO_RE, restore("video"));
|
|
6280
|
+
result = result.replace(OBSIDIAN_MEDIA_PDF_RE, restore("pdf"));
|
|
6281
|
+
result = result.replace(OBSIDIAN_MEDIA_FILE_RE, restore("file"));
|
|
5313
6282
|
return result;
|
|
5314
6283
|
}
|
|
5315
6284
|
var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)\n((?:> .*\n?)*)/gm;
|
|
5316
6285
|
function restoreTabBlocks(content) {
|
|
5317
|
-
|
|
6286
|
+
const result = content.replace(OBSIDIAN_TAB_RE, (_match, title, body) => {
|
|
5318
6287
|
const unquoted = body.split("\n").map((line) => line.replace(/^> /, "")).join("\n").trim();
|
|
5319
6288
|
return `<tab title="${title}">${unquoted}</tab>`;
|
|
5320
6289
|
});
|
|
6290
|
+
return result.replace(/(<\/tab>)(?!\n|$)/g, "$1\n");
|
|
5321
6291
|
}
|
|
5322
6292
|
var OBSIDIAN_UNKNOWN_RE = new RegExp(
|
|
5323
6293
|
`(?:\\[[^\\]]*\\]\\([^)]*\\))?%%${MARKER_BRAND_RE}:unknown:id=([^&]+)&type=([^%]+)%%`,
|
|
@@ -5350,6 +6320,42 @@ function restoreUnderlineSpans(content) {
|
|
|
5350
6320
|
return `<span underline="true">${text}</span>`;
|
|
5351
6321
|
});
|
|
5352
6322
|
}
|
|
6323
|
+
var OBSIDIAN_BLOCK_COLOR_RE = new RegExp(
|
|
6324
|
+
`[ \\t]*%%${MARKER_BRAND_RE}:block-color:([a-z_]+)%%`,
|
|
6325
|
+
"g"
|
|
6326
|
+
);
|
|
6327
|
+
function restoreBlockColorMarkers(content) {
|
|
6328
|
+
return content.replace(OBSIDIAN_BLOCK_COLOR_RE, (_m, color) => ` {color="${color}"}`);
|
|
6329
|
+
}
|
|
6330
|
+
var COLUMN_REGION_RE = new RegExp(
|
|
6331
|
+
`^${escapeRegex3(COLUMN_LIST_START)}[ \\t]*\\n([\\s\\S]*?)\\n?^${escapeRegex3(COLUMN_LIST_END)}[ \\t]*$\\n?`,
|
|
6332
|
+
"gm"
|
|
6333
|
+
);
|
|
6334
|
+
function indentColumnLines(text) {
|
|
6335
|
+
return text.split("\n").map((line) => line === "" ? "" : ` ${line}`).join("\n");
|
|
6336
|
+
}
|
|
6337
|
+
function reassembleColumns(content) {
|
|
6338
|
+
const sepRe = new RegExp(`^${escapeRegex3(COLUMN_SEP)}[ \\t]*$`, "m");
|
|
6339
|
+
let result = content.replace(COLUMN_REGION_RE, (_m, inner) => {
|
|
6340
|
+
const cols = inner.split(new RegExp(sepRe.source, "gm")).map((c) => c.replace(/^\n/, "").replace(/\n$/, "")).filter((c) => c.trim() !== "");
|
|
6341
|
+
if (cols.length === 0) return "";
|
|
6342
|
+
const parts = cols.map((c) => `<column>
|
|
6343
|
+
${indentColumnLines(c)}
|
|
6344
|
+
</column>`).join("\n");
|
|
6345
|
+
return `<columns>
|
|
6346
|
+
${indentColumnLines(parts)}
|
|
6347
|
+
</columns>
|
|
6348
|
+
`;
|
|
6349
|
+
});
|
|
6350
|
+
result = result.replace(
|
|
6351
|
+
new RegExp(
|
|
6352
|
+
`^[>\\t ]*%%${MARKER_BRAND_RE}:(?:column-list:start|column-list:end|column)%%[ \\t]*\\n?`,
|
|
6353
|
+
"gm"
|
|
6354
|
+
),
|
|
6355
|
+
""
|
|
6356
|
+
);
|
|
6357
|
+
return result;
|
|
6358
|
+
}
|
|
5353
6359
|
function unescapePipes(content) {
|
|
5354
6360
|
const lines = content.split("\n");
|
|
5355
6361
|
const result = [];
|
|
@@ -5413,6 +6419,14 @@ var DatabaseSyncer = class {
|
|
|
5413
6419
|
propertyMapper = new PropertyMapper();
|
|
5414
6420
|
baseFileGenerator = new BaseFileGenerator();
|
|
5415
6421
|
sidecarGenerator = new SidecarGenerator();
|
|
6422
|
+
/**
|
|
6423
|
+
* 이번 프로세스가 실제로 기록한 .base 경로/DB 제목 (databaseId nohyph → info).
|
|
6424
|
+
* placeholder 임베드 재작성(F22)의 SSOT — 폴더명(하이픈 새니타이즈)과 .base 파일명
|
|
6425
|
+
* (sanitizeFileName: 공백·점 보존)은 규칙이 달라 localFolder 로 추측한 경로는 깨진
|
|
6426
|
+
* 임베드가 된다. generateBaseFile 은 매 pull 모든 DB 에 대해 실행되므로 pull 종료
|
|
6427
|
+
* 시점에는 성공한 DB 전체가 채워져 있다.
|
|
6428
|
+
*/
|
|
6429
|
+
baseFileInfo = /* @__PURE__ */ new Map();
|
|
5416
6430
|
async pullAll() {
|
|
5417
6431
|
const databases = this.config.notion.databases;
|
|
5418
6432
|
if (!databases || databases.length === 0) {
|
|
@@ -5467,16 +6481,33 @@ var DatabaseSyncer = class {
|
|
|
5467
6481
|
}
|
|
5468
6482
|
return { created, updated, conflicts: [], failed, writtenPaths: [] };
|
|
5469
6483
|
}
|
|
5470
|
-
async pullDatabase(dbConfig) {
|
|
6484
|
+
async pullDatabase(dbConfig, opts) {
|
|
6485
|
+
const pages = await this.notionClient.queryAllDatabasePages(
|
|
6486
|
+
dbConfig.databaseId,
|
|
6487
|
+
dbConfig.pullFilter
|
|
6488
|
+
);
|
|
6489
|
+
const parent = pages[0]?.parent;
|
|
6490
|
+
const ownerDbId = parent?.database_id;
|
|
6491
|
+
if (ownerDbId && !notionIdsEqual(ownerDbId, dbConfig.databaseId)) {
|
|
6492
|
+
const ownerFolder = opts?.resolveDbFolder?.(ownerDbId) ?? null;
|
|
6493
|
+
if (ownerFolder !== null) {
|
|
6494
|
+
const viewsConfig2 = await this.pullDatabaseViews(dbConfig);
|
|
6495
|
+
await this.generateBaseFile(dbConfig, viewsConfig2, ownerFolder);
|
|
6496
|
+
return {
|
|
6497
|
+
created: 0,
|
|
6498
|
+
updated: 0,
|
|
6499
|
+
conflicts: [],
|
|
6500
|
+
failed: [],
|
|
6501
|
+
writtenPaths: [],
|
|
6502
|
+
linkedOriginalDbId: ownerDbId
|
|
6503
|
+
};
|
|
6504
|
+
}
|
|
6505
|
+
}
|
|
5471
6506
|
const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
|
|
5472
6507
|
this.propertyMapper.loadSchema(schema);
|
|
5473
6508
|
await this.vaultFs.ensureFolder(dbConfig.localFolder);
|
|
5474
6509
|
const viewsConfig = await this.pullDatabaseViews(dbConfig);
|
|
5475
6510
|
await this.generateBaseFile(dbConfig, viewsConfig);
|
|
5476
|
-
const pages = await this.notionClient.queryAllDatabasePages(
|
|
5477
|
-
dbConfig.databaseId,
|
|
5478
|
-
dbConfig.pullFilter
|
|
5479
|
-
);
|
|
5480
6511
|
let created = 0;
|
|
5481
6512
|
let updated = 0;
|
|
5482
6513
|
const conflicts = [];
|
|
@@ -5486,7 +6517,8 @@ var DatabaseSyncer = class {
|
|
|
5486
6517
|
try {
|
|
5487
6518
|
const record = this.stateDb.getByNotionId(page.id);
|
|
5488
6519
|
if (record) {
|
|
5489
|
-
|
|
6520
|
+
const misplaced = !isDirectDbRowPath(dbConfig.localFolder, record.obsidianPath);
|
|
6521
|
+
if (page.last_edited_time === record.notionLastEdited && !misplaced) continue;
|
|
5490
6522
|
const outcome = await this.pullDatabasePage(page, dbConfig);
|
|
5491
6523
|
if (outcome.action === "written") {
|
|
5492
6524
|
updated++;
|
|
@@ -5544,7 +6576,7 @@ var DatabaseSyncer = class {
|
|
|
5544
6576
|
return null;
|
|
5545
6577
|
}
|
|
5546
6578
|
}
|
|
5547
|
-
async generateBaseFile(dbConfig, viewsConfig) {
|
|
6579
|
+
async generateBaseFile(dbConfig, viewsConfig, rowsFolder) {
|
|
5548
6580
|
try {
|
|
5549
6581
|
const schemaFull = await this.notionClient.getDatabaseSchemaFull(dbConfig.databaseId);
|
|
5550
6582
|
const dbName = viewsConfig?.databaseName || await this.notionClient.getDatabaseTitle(dbConfig.databaseId) || dbConfig.localFolder.split("/").pop() || "Database";
|
|
@@ -5559,11 +6591,12 @@ var DatabaseSyncer = class {
|
|
|
5559
6591
|
databaseName: dbName,
|
|
5560
6592
|
schema: schemaFull,
|
|
5561
6593
|
viewsConfig: resolvedViews,
|
|
5562
|
-
folderPath: dbConfig.localFolder
|
|
6594
|
+
folderPath: rowsFolder ?? dbConfig.localFolder
|
|
5563
6595
|
});
|
|
5564
6596
|
const safeName = sanitizeFileName(dbName);
|
|
5565
6597
|
const basePath = `${dbConfig.localFolder}/${safeName}.base`;
|
|
5566
6598
|
await this.vaultFs.writeFile(basePath, baseContent);
|
|
6599
|
+
this.baseFileInfo.set(dbConfig.databaseId.replace(/-/g, ""), { basePath, title: dbName });
|
|
5567
6600
|
getLogger().debug(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131: ${basePath}`);
|
|
5568
6601
|
await this.generateSidecar(dbConfig, dbName, safeName, schemaFull, resolvedViews);
|
|
5569
6602
|
await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName);
|
|
@@ -5606,7 +6639,10 @@ var DatabaseSyncer = class {
|
|
|
5606
6639
|
viewsConfig: resolvedViews
|
|
5607
6640
|
});
|
|
5608
6641
|
const sidecarPath = `${dbConfig.localFolder}/${safeName}.notion.json`;
|
|
5609
|
-
|
|
6642
|
+
const serialized = this.sidecarGenerator.serialize(sidecar);
|
|
6643
|
+
const existing = await this.vaultFs.readFile(sidecarPath).catch(() => null);
|
|
6644
|
+
if (existing === serialized) return;
|
|
6645
|
+
await this.vaultFs.writeFile(sidecarPath, serialized);
|
|
5610
6646
|
if (sidecar.degraded.length > 0) {
|
|
5611
6647
|
const unrep = sidecar.degraded.filter((d) => d.kind === "view-unrepresentable").length;
|
|
5612
6648
|
const dropped = sidecar.degraded.length - unrep;
|
|
@@ -5618,6 +6654,42 @@ var DatabaseSyncer = class {
|
|
|
5618
6654
|
getLogger().warn(`[DB Sync] \uC0AC\uC774\uB4DC\uCE74 \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
|
|
5619
6655
|
}
|
|
5620
6656
|
}
|
|
6657
|
+
/**
|
|
6658
|
+
* DB 행 `files` 속성의 notion-hosted 서명 URL 을 로컬 첨부로 로컬라이즈한다(P3-A).
|
|
6659
|
+
* 서명 URL 은 약 1시간 뒤 만료되어 frontmatter 에 남으면 깨진 링크가 되고, push 로
|
|
6660
|
+
* 되밀면 Notion 원본이 만료 URL 의 external 파일로 오염된다(실측). 다운로드 성공분은
|
|
6661
|
+
* `[[attachments/..]]` 위키링크로 대체한다 — Bases 카드 `image:` 는 위키링크 스칼라를
|
|
6662
|
+
* 렌더하므로 갤러리 커버도 유지된다. 원본 파일명은 raw 속성의 `name` 에서 취한다
|
|
6663
|
+
* (extractValue 는 URL 만 남긴다). 사용자가 넣은 진짜 외부 URL 은 그대로 둔다.
|
|
6664
|
+
*/
|
|
6665
|
+
async localizeFileProperties(rawProps, properties, safeName) {
|
|
6666
|
+
for (const [key, rawProp] of Object.entries(rawProps)) {
|
|
6667
|
+
const prop = rawProp;
|
|
6668
|
+
if (prop?.type !== "files" || !Array.isArray(prop.files) || prop.files.length === 0) {
|
|
6669
|
+
continue;
|
|
6670
|
+
}
|
|
6671
|
+
const localized = [];
|
|
6672
|
+
let changed = false;
|
|
6673
|
+
for (const f of prop.files) {
|
|
6674
|
+
const url = f.type === "file" ? f.file?.url : f.external?.url;
|
|
6675
|
+
if (!url) continue;
|
|
6676
|
+
const caption = f.name?.trim() || `${safeName}-${key}`;
|
|
6677
|
+
try {
|
|
6678
|
+
const localPath = await this.imageHandler.localizeNotionFileUrl(url, caption);
|
|
6679
|
+
if (localPath) {
|
|
6680
|
+
localized.push(`[[${localPath}]]`);
|
|
6681
|
+
changed = true;
|
|
6682
|
+
} else {
|
|
6683
|
+
localized.push(url);
|
|
6684
|
+
}
|
|
6685
|
+
} catch {
|
|
6686
|
+
localized.push(url);
|
|
6687
|
+
}
|
|
6688
|
+
}
|
|
6689
|
+
if (!changed || localized.length === 0) continue;
|
|
6690
|
+
properties[key] = localized.length === 1 ? localized[0] : localized;
|
|
6691
|
+
}
|
|
6692
|
+
}
|
|
5621
6693
|
async pullDatabasePage(page, dbConfig) {
|
|
5622
6694
|
const title = this.notionClient.extractTitle(page);
|
|
5623
6695
|
const safeName = sanitizeFileName(title);
|
|
@@ -5625,30 +6697,25 @@ var DatabaseSyncer = class {
|
|
|
5625
6697
|
page.properties
|
|
5626
6698
|
);
|
|
5627
6699
|
properties.title = title;
|
|
6700
|
+
await this.localizeFileProperties(
|
|
6701
|
+
page.properties,
|
|
6702
|
+
properties,
|
|
6703
|
+
safeName
|
|
6704
|
+
);
|
|
5628
6705
|
const cover = this.notionClient.extractCover(page);
|
|
5629
6706
|
const icon = this.notionClient.extractIcon(page);
|
|
5630
6707
|
if (cover) {
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
``,
|
|
5634
|
-
`${safeName}-cover`
|
|
5635
|
-
);
|
|
5636
|
-
const resolved = coverResult.content.match(/!\[cover\]\((.+?)\)/)?.[1];
|
|
5637
|
-
if (resolved && !/^https?:\/\//i.test(resolved)) {
|
|
5638
|
-
properties.cover = `[[${resolved}]]`;
|
|
5639
|
-
} else {
|
|
5640
|
-
properties.cover = cover.url;
|
|
5641
|
-
}
|
|
5642
|
-
} catch {
|
|
5643
|
-
properties.cover = cover.url;
|
|
5644
|
-
}
|
|
6708
|
+
const local = await this.imageHandler.localizeNotionFileUrl(cover.url, `${safeName}-cover`);
|
|
6709
|
+
properties.cover = local ? `[[${local}]]` : cover.url;
|
|
5645
6710
|
}
|
|
5646
6711
|
if (icon) {
|
|
5647
6712
|
properties.icon = icon.value;
|
|
5648
6713
|
}
|
|
5649
6714
|
let markdown = "";
|
|
6715
|
+
let exportCompact = false;
|
|
5650
6716
|
try {
|
|
5651
6717
|
const mdResult = await this.notionClient.getPageMarkdown(page.id);
|
|
6718
|
+
exportCompact = isCompactExport(mdResult.markdown);
|
|
5652
6719
|
markdown = notionEnhancedToObsidian(mdResult.markdown);
|
|
5653
6720
|
} catch {
|
|
5654
6721
|
}
|
|
@@ -5669,9 +6736,10 @@ var DatabaseSyncer = class {
|
|
|
5669
6736
|
}
|
|
5670
6737
|
}
|
|
5671
6738
|
const existingRecord = this.stateDb.getByNotionId(page.id);
|
|
6739
|
+
const recordPath = existingRecord?.obsidianPath;
|
|
5672
6740
|
let filePath;
|
|
5673
|
-
if (
|
|
5674
|
-
filePath =
|
|
6741
|
+
if (recordPath && isDirectDbRowPath(dbConfig.localFolder, recordPath)) {
|
|
6742
|
+
filePath = recordPath;
|
|
5675
6743
|
} else {
|
|
5676
6744
|
filePath = resolveDbRowPath(
|
|
5677
6745
|
dbConfig.localFolder,
|
|
@@ -5688,12 +6756,12 @@ var DatabaseSyncer = class {
|
|
|
5688
6756
|
filePath,
|
|
5689
6757
|
parentMode: "database"
|
|
5690
6758
|
},
|
|
5691
|
-
{ properties }
|
|
6759
|
+
{ properties, notionExportCompact: exportCompact }
|
|
5692
6760
|
);
|
|
5693
6761
|
if (existingRecord) {
|
|
5694
6762
|
let localContent = "";
|
|
5695
6763
|
try {
|
|
5696
|
-
localContent = await this.vaultFs.readFile(filePath);
|
|
6764
|
+
localContent = await this.vaultFs.readFile(recordPath ?? filePath);
|
|
5697
6765
|
} catch {
|
|
5698
6766
|
localContent = "";
|
|
5699
6767
|
}
|
|
@@ -5710,17 +6778,26 @@ var DatabaseSyncer = class {
|
|
|
5710
6778
|
strategy: this.config.sync.conflictStrategy
|
|
5711
6779
|
});
|
|
5712
6780
|
if (resolution.action === "skip") {
|
|
5713
|
-
return { action: "skipped", path: filePath };
|
|
6781
|
+
return { action: "skipped", path: recordPath ?? filePath };
|
|
5714
6782
|
}
|
|
5715
6783
|
if (resolution.action === "conflict") {
|
|
5716
6784
|
this.stateDb.updateStatus(existingRecord.id, "conflict");
|
|
5717
|
-
return {
|
|
6785
|
+
return {
|
|
6786
|
+
action: "conflict",
|
|
6787
|
+
path: recordPath ?? filePath,
|
|
6788
|
+
conflict: resolution.conflict
|
|
6789
|
+
};
|
|
5718
6790
|
}
|
|
5719
6791
|
}
|
|
5720
6792
|
await this.vaultFs.ensureFolder(dbConfig.localFolder);
|
|
5721
6793
|
await this.vaultFs.writeFile(filePath, finalContent);
|
|
6794
|
+
const rehomed = existingRecord && recordPath && recordPath !== filePath;
|
|
5722
6795
|
const hash = computeHash(finalContent);
|
|
5723
6796
|
this.stateDb.transaction(() => {
|
|
6797
|
+
if (rehomed) {
|
|
6798
|
+
this.stateDb.updatePath(existingRecord.id, filePath);
|
|
6799
|
+
this.stateDb.deleteWikilink(recordPath);
|
|
6800
|
+
}
|
|
5724
6801
|
this.stateDb.upsert({
|
|
5725
6802
|
obsidianPath: filePath,
|
|
5726
6803
|
notionPageId: page.id,
|
|
@@ -5740,6 +6817,12 @@ var DatabaseSyncer = class {
|
|
|
5740
6817
|
aliases: extractAliases(properties)
|
|
5741
6818
|
});
|
|
5742
6819
|
});
|
|
6820
|
+
if (rehomed) {
|
|
6821
|
+
try {
|
|
6822
|
+
await this.vaultFs.deleteFile(recordPath);
|
|
6823
|
+
} catch {
|
|
6824
|
+
}
|
|
6825
|
+
}
|
|
5743
6826
|
return { action: "written", path: filePath };
|
|
5744
6827
|
}
|
|
5745
6828
|
async pushDatabase(dbConfig) {
|
|
@@ -6089,8 +7172,8 @@ var ConflictResolver = class {
|
|
|
6089
7172
|
async resolveDuplicate(conflict) {
|
|
6090
7173
|
const originalPath = conflict.syncRecord.obsidianPath;
|
|
6091
7174
|
const ext = originalPath.endsWith(".md") ? ".md" : "";
|
|
6092
|
-
const
|
|
6093
|
-
const conflictPath = `${
|
|
7175
|
+
const baseName2 = originalPath.replace(/\.md$/, "");
|
|
7176
|
+
const conflictPath = `${baseName2}.conflict${ext}`;
|
|
6094
7177
|
await this.vaultFs.writeFile(conflictPath, conflict.remoteContent);
|
|
6095
7178
|
const localHash = computeHash(conflict.localContent);
|
|
6096
7179
|
this.stateDb.updateHash(
|
|
@@ -6177,8 +7260,77 @@ function resolveFrontmatterRelations(content, idToTitle) {
|
|
|
6177
7260
|
return { content: stringifyFrontmatter(parsed.content, resolved), count };
|
|
6178
7261
|
}
|
|
6179
7262
|
|
|
7263
|
+
// src/utils/inline-db-refs.ts
|
|
7264
|
+
function extractInlineDbIds(markdown) {
|
|
7265
|
+
const ids = /* @__PURE__ */ new Set();
|
|
7266
|
+
const tagRe = /<database\b[^>]*\burl="[^"]*?([a-f0-9]{32})[^"]*"[^>]*>/g;
|
|
7267
|
+
let m;
|
|
7268
|
+
while ((m = tagRe.exec(markdown)) !== null) {
|
|
7269
|
+
if (m[1]) ids.add(m[1]);
|
|
7270
|
+
}
|
|
7271
|
+
const markerRe = new RegExp(`${MARKER_BRAND_RE}:child-database:id=([a-f0-9-]{32,36})`, "g");
|
|
7272
|
+
while ((m = markerRe.exec(markdown)) !== null) {
|
|
7273
|
+
if (m[1]) ids.add(m[1].replace(/-/g, ""));
|
|
7274
|
+
}
|
|
7275
|
+
return [...ids];
|
|
7276
|
+
}
|
|
7277
|
+
|
|
7278
|
+
// src/sync/db-placeholder-rewriter.ts
|
|
7279
|
+
var ID32 = "[a-f0-9]{32}";
|
|
7280
|
+
var IDUUID = "[a-f0-9-]{32,36}";
|
|
7281
|
+
var ENC_TITLE = "(?:&title=(.*?))?";
|
|
7282
|
+
var INLINE_PLACEHOLDER_RE = new RegExp(
|
|
7283
|
+
`\\*\\*([^\\n*]*)\\*\\*\\s*\\*\\(Notion DB\\)\\*\\s*%%\\s*${MARKER_BRAND_RE}:child-database:id=(${ID32})${ENC_TITLE}\\s*%%`,
|
|
7284
|
+
"g"
|
|
7285
|
+
);
|
|
7286
|
+
var CALLOUT_PLACEHOLDER_RE = new RegExp(
|
|
7287
|
+
`^((?:[ \\t]*>)+[ \\t]*)\\[!database\\][ \\t]*([^\\n]*)\\n(?:[ \\t]*>)+[ \\t]*%%\\s*${MARKER_BRAND_RE}:child-database:id=(${IDUUID})${ENC_TITLE}\\s*%%[ \\t]*$`,
|
|
7288
|
+
"gm"
|
|
7289
|
+
);
|
|
7290
|
+
function decodeTitle(encoded) {
|
|
7291
|
+
if (!encoded) return "";
|
|
7292
|
+
try {
|
|
7293
|
+
return decodeURIComponent(encoded);
|
|
7294
|
+
} catch {
|
|
7295
|
+
return encoded;
|
|
7296
|
+
}
|
|
7297
|
+
}
|
|
7298
|
+
function toAlias(...candidates) {
|
|
7299
|
+
for (const c of candidates) {
|
|
7300
|
+
const cleaned = (c ?? "").replace(/[|[\]\n]/g, "").trim();
|
|
7301
|
+
if (cleaned) return cleaned;
|
|
7302
|
+
}
|
|
7303
|
+
return "";
|
|
7304
|
+
}
|
|
7305
|
+
function rewriteDbPlaceholders(content, resolve) {
|
|
7306
|
+
let rewrites = 0;
|
|
7307
|
+
let result = content.replace(
|
|
7308
|
+
INLINE_PLACEHOLDER_RE,
|
|
7309
|
+
(match, inlineTitle, id, encTitle) => {
|
|
7310
|
+
const target = resolve(id);
|
|
7311
|
+
if (!target) return match;
|
|
7312
|
+
const alias = toAlias(decodeTitle(encTitle), inlineTitle, target.title, "Database");
|
|
7313
|
+
rewrites++;
|
|
7314
|
+
return `![[${target.basePath}|${alias}]]`;
|
|
7315
|
+
}
|
|
7316
|
+
);
|
|
7317
|
+
result = result.replace(
|
|
7318
|
+
CALLOUT_PLACEHOLDER_RE,
|
|
7319
|
+
(match, prefix, calloutTitle, id, encTitle) => {
|
|
7320
|
+
const target = resolve(id.replace(/-/g, ""));
|
|
7321
|
+
if (!target) return match;
|
|
7322
|
+
const alias = toAlias(decodeTitle(encTitle), calloutTitle, target.title, "Database");
|
|
7323
|
+
rewrites++;
|
|
7324
|
+
return `${prefix}![[${target.basePath}|${alias}]]`;
|
|
7325
|
+
}
|
|
7326
|
+
);
|
|
7327
|
+
return { content: result, rewrites };
|
|
7328
|
+
}
|
|
7329
|
+
|
|
6180
7330
|
// src/sync/orchestrator.ts
|
|
6181
7331
|
var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
|
|
7332
|
+
var LINKED_DBS_META_KEY = "linked_dbs";
|
|
7333
|
+
var INCREMENTAL_SAFETY_WINDOW_MS = 15 * 6e4;
|
|
6182
7334
|
function strategyToChoice(strategy) {
|
|
6183
7335
|
switch (strategy) {
|
|
6184
7336
|
case "local-first":
|
|
@@ -6479,7 +7631,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
6479
7631
|
return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
|
|
6480
7632
|
}) : remoteChanges;
|
|
6481
7633
|
if (filtered.length === 0 && (this.config.notion.databases?.length ?? 0) === 0) {
|
|
6482
|
-
const dbDiscovery = await this.pullDiscoveredDatabases(
|
|
7634
|
+
const dbDiscovery = await this.pullDiscoveredDatabases(
|
|
7635
|
+
writtenPaths,
|
|
7636
|
+
failed,
|
|
7637
|
+
conflicts,
|
|
7638
|
+
options?.force === true
|
|
7639
|
+
);
|
|
6483
7640
|
return finalize(dbDiscovery.created, dbDiscovery.updated, 0);
|
|
6484
7641
|
}
|
|
6485
7642
|
if (filtered.length === 0) {
|
|
@@ -6606,7 +7763,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
6606
7763
|
}
|
|
6607
7764
|
}
|
|
6608
7765
|
{
|
|
6609
|
-
const dbDiscovery = await this.pullDiscoveredDatabases(
|
|
7766
|
+
const dbDiscovery = await this.pullDiscoveredDatabases(
|
|
7767
|
+
writtenPaths,
|
|
7768
|
+
failed,
|
|
7769
|
+
conflicts,
|
|
7770
|
+
options?.force === true
|
|
7771
|
+
);
|
|
6610
7772
|
counts.created += dbDiscovery.created;
|
|
6611
7773
|
counts.updated += dbDiscovery.updated;
|
|
6612
7774
|
}
|
|
@@ -6698,7 +7860,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
6698
7860
|
let remoteContent = "";
|
|
6699
7861
|
if (record.notionPageId) {
|
|
6700
7862
|
try {
|
|
6701
|
-
remoteContent = await this.fetchPageMarkdown(record.notionPageId);
|
|
7863
|
+
remoteContent = (await this.fetchPageMarkdown(record.notionPageId)).content;
|
|
6702
7864
|
} catch {
|
|
6703
7865
|
}
|
|
6704
7866
|
}
|
|
@@ -6745,7 +7907,20 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
6745
7907
|
let dbTitle;
|
|
6746
7908
|
try {
|
|
6747
7909
|
const info = await this.notionClient.getDatabaseSyncability(dbId);
|
|
7910
|
+
if (info.linkedOriginalDbId) {
|
|
7911
|
+
getLogger().info(
|
|
7912
|
+
`[Im-Nobsidian] DB ${dbId}: linked view \uCEE8\uD14C\uC774\uB108(\uC6D0\uBCF8 ${info.linkedOriginalDbId}) \u2192 \uC6D0\uBCF8\uC73C\uB85C \uD574\uC18C(F25)`
|
|
7913
|
+
);
|
|
7914
|
+
return { kind: "linked", originalDbId: info.linkedOriginalDbId };
|
|
7915
|
+
}
|
|
6748
7916
|
if (!info.queryable) {
|
|
7917
|
+
const linked = await this.notionClient.resolveLinkedDatabase(dbId);
|
|
7918
|
+
if (linked) {
|
|
7919
|
+
getLogger().info(
|
|
7920
|
+
`[Im-Nobsidian] DB ${dbId}: linked view("${linked.viewName}") \u2192 \uC6D0\uBCF8 ${linked.originalDbId} \uB85C \uD574\uC18C`
|
|
7921
|
+
);
|
|
7922
|
+
return { kind: "linked", originalDbId: linked.originalDbId };
|
|
7923
|
+
}
|
|
6749
7924
|
return { kind: "inaccessible" };
|
|
6750
7925
|
}
|
|
6751
7926
|
dbTitle = info.title;
|
|
@@ -6760,8 +7935,9 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
6760
7935
|
const obsPath = parentEntry.obsidianPath;
|
|
6761
7936
|
if (obsPath.endsWith(".md")) {
|
|
6762
7937
|
const parts = obsPath.split("/");
|
|
6763
|
-
parts.pop();
|
|
6764
|
-
|
|
7938
|
+
const fileBase = (parts.pop() ?? "").replace(/\.md$/, "");
|
|
7939
|
+
const dirName = parts[parts.length - 1] ?? "";
|
|
7940
|
+
parentFolder = fileBase === dirName ? parts.join("/") : [...parts, fileBase].filter(Boolean).join("/");
|
|
6765
7941
|
} else {
|
|
6766
7942
|
parentFolder = obsPath;
|
|
6767
7943
|
}
|
|
@@ -6795,7 +7971,20 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
6795
7971
|
return /* @__PURE__ */ new Set();
|
|
6796
7972
|
}
|
|
6797
7973
|
}
|
|
6798
|
-
|
|
7974
|
+
/** linked view 컨테이너 → 원본 DB 매핑을 상태 메타에서 로드한다(nohyph → nohyph). */
|
|
7975
|
+
loadLinkedDbMap() {
|
|
7976
|
+
const raw = this.stateDb.getMeta(LINKED_DBS_META_KEY);
|
|
7977
|
+
if (!raw) return /* @__PURE__ */ new Map();
|
|
7978
|
+
try {
|
|
7979
|
+
const obj = JSON.parse(raw);
|
|
7980
|
+
return new Map(
|
|
7981
|
+
Object.entries(obj).map(([k, v]) => [k.replace(/-/g, ""), v.replace(/-/g, "")])
|
|
7982
|
+
);
|
|
7983
|
+
} catch {
|
|
7984
|
+
return /* @__PURE__ */ new Map();
|
|
7985
|
+
}
|
|
7986
|
+
}
|
|
7987
|
+
async pullDiscoveredDatabases(writtenPaths, failed, conflicts, forceRediscovery = false) {
|
|
6799
7988
|
if (this.isDatabaseMode) return { created: 0, updated: 0 };
|
|
6800
7989
|
let created = 0;
|
|
6801
7990
|
let updated = 0;
|
|
@@ -6817,6 +8006,23 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
6817
8006
|
...dbConfigs.map((c) => c.databaseId.replace(/-/g, ""))
|
|
6818
8007
|
]);
|
|
6819
8008
|
let changed = false;
|
|
8009
|
+
const folderOwner = new Map(
|
|
8010
|
+
(this.config.notion.databases ?? []).map((d) => [d.localFolder, d.databaseId])
|
|
8011
|
+
);
|
|
8012
|
+
const repairedCount = repairDbFolderCollisions(dbConfigs, folderOwner);
|
|
8013
|
+
for (const c of dbConfigs) folderOwner.set(c.localFolder, c.databaseId);
|
|
8014
|
+
if (repairedCount > 0) {
|
|
8015
|
+
changed = true;
|
|
8016
|
+
getLogger().info(`[Im-Nobsidian] \uB3D9\uBA85 DB \uD3F4\uB354 \uCDA9\uB3CC ${repairedCount}\uAC74 \uBD84\uB9AC(F24)`);
|
|
8017
|
+
}
|
|
8018
|
+
const claimFolder = (config) => {
|
|
8019
|
+
config.localFolder = resolveDbFolderPath(
|
|
8020
|
+
config.localFolder,
|
|
8021
|
+
config.databaseId,
|
|
8022
|
+
(f) => folderOwner.get(f) ?? null
|
|
8023
|
+
);
|
|
8024
|
+
folderOwner.set(config.localFolder, config.databaseId);
|
|
8025
|
+
};
|
|
6820
8026
|
const inaccessibleIds = this.loadInaccessibleDbIds();
|
|
6821
8027
|
let inaccessibleChanged = false;
|
|
6822
8028
|
const degrade = (rawId) => {
|
|
@@ -6829,52 +8035,119 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
6829
8035
|
`[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
8036
|
);
|
|
6831
8037
|
};
|
|
6832
|
-
if (
|
|
6833
|
-
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
8038
|
+
if (forceRediscovery && inaccessibleIds.size > 0) {
|
|
8039
|
+
getLogger().info(
|
|
8040
|
+
`[Im-Nobsidian] --force: \uC811\uADFC \uBD88\uAC00 DB ${inaccessibleIds.size}\uAC1C denylist \uC7AC\uAC80\uC99D`
|
|
8041
|
+
);
|
|
8042
|
+
inaccessibleIds.clear();
|
|
8043
|
+
inaccessibleChanged = true;
|
|
8044
|
+
}
|
|
8045
|
+
const linkedMap = this.loadLinkedDbMap();
|
|
8046
|
+
let linkedChanged = false;
|
|
8047
|
+
const register = async (dbId, parentPageId) => {
|
|
8048
|
+
const nohyph = dbId.replace(/-/g, "");
|
|
8049
|
+
if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) return;
|
|
8050
|
+
const registerOriginal = async (origNohyph) => {
|
|
8051
|
+
if (knownIds.has(origNohyph) || inaccessibleIds.has(origNohyph)) return;
|
|
8052
|
+
const origId = normalizeNotionId(origNohyph);
|
|
8053
|
+
const outcome2 = await this.buildDiscoveredDbConfig(origId, parentPageId);
|
|
8054
|
+
knownIds.add(origNohyph);
|
|
8055
|
+
if (outcome2.kind === "ok") {
|
|
8056
|
+
claimFolder(outcome2.config);
|
|
8057
|
+
dbConfigs.push(outcome2.config);
|
|
6841
8058
|
changed = true;
|
|
6842
|
-
} else if (
|
|
6843
|
-
degrade(
|
|
8059
|
+
} else if (outcome2.kind !== "error") {
|
|
8060
|
+
degrade(origId);
|
|
6844
8061
|
}
|
|
8062
|
+
};
|
|
8063
|
+
const knownOriginal = linkedMap.get(nohyph);
|
|
8064
|
+
if (knownOriginal) {
|
|
8065
|
+
await registerOriginal(knownOriginal);
|
|
8066
|
+
return;
|
|
6845
8067
|
}
|
|
6846
|
-
}
|
|
6847
|
-
for (const [nohyph, parentPageId] of this._inlineDbRefs) {
|
|
6848
|
-
if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) continue;
|
|
6849
|
-
const dbId = normalizeNotionId(nohyph);
|
|
6850
8068
|
const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
|
|
6851
8069
|
knownIds.add(nohyph);
|
|
6852
8070
|
if (outcome.kind === "ok") {
|
|
8071
|
+
claimFolder(outcome.config);
|
|
6853
8072
|
dbConfigs.push(outcome.config);
|
|
6854
8073
|
changed = true;
|
|
8074
|
+
} else if (outcome.kind === "linked") {
|
|
8075
|
+
const origNohyph = outcome.originalDbId.replace(/-/g, "");
|
|
8076
|
+
linkedMap.set(nohyph, origNohyph);
|
|
8077
|
+
linkedChanged = true;
|
|
8078
|
+
await registerOriginal(origNohyph);
|
|
6855
8079
|
} else if (outcome.kind === "inaccessible") {
|
|
6856
8080
|
degrade(dbId);
|
|
6857
8081
|
}
|
|
8082
|
+
};
|
|
8083
|
+
if (dbConfigs.length === 0 || forceRediscovery) {
|
|
8084
|
+
const discovered = await this.discoverChildDatabases();
|
|
8085
|
+
for (const { dbId, parentPageId } of discovered) {
|
|
8086
|
+
await register(dbId, parentPageId);
|
|
8087
|
+
}
|
|
8088
|
+
}
|
|
8089
|
+
for (const [nohyph, parentPageId] of this._inlineDbRefs) {
|
|
8090
|
+
await register(normalizeNotionId(nohyph), parentPageId);
|
|
6858
8091
|
}
|
|
6859
8092
|
const stillSyncable = [];
|
|
6860
|
-
|
|
6861
|
-
|
|
6862
|
-
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
6867
|
-
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
|
|
6871
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
8093
|
+
let queue = [...dbConfigs];
|
|
8094
|
+
const MAX_DISCOVERY_ROUNDS = 4;
|
|
8095
|
+
const resolveDbFolder = (dbId) => {
|
|
8096
|
+
const key = dbId.replace(/-/g, "");
|
|
8097
|
+
const hit = dbConfigs.find((c) => c.databaseId.replace(/-/g, "") === key) ?? (this.config.notion.databases ?? []).find((c) => c.databaseId.replace(/-/g, "") === key);
|
|
8098
|
+
return hit?.localFolder ?? null;
|
|
8099
|
+
};
|
|
8100
|
+
for (let round = 1; queue.length > 0; round++) {
|
|
8101
|
+
const roundRowPaths = [];
|
|
8102
|
+
for (const dbConfig of queue) {
|
|
8103
|
+
if (inaccessibleIds.has(dbConfig.databaseId.replace(/-/g, ""))) continue;
|
|
8104
|
+
try {
|
|
8105
|
+
const dbResult = await this.databaseSyncer.pullDatabase(dbConfig, { resolveDbFolder });
|
|
8106
|
+
if (dbResult.linkedOriginalDbId) {
|
|
8107
|
+
linkedMap.set(
|
|
8108
|
+
dbConfig.databaseId.replace(/-/g, ""),
|
|
8109
|
+
dbResult.linkedOriginalDbId.replace(/-/g, "")
|
|
8110
|
+
);
|
|
8111
|
+
linkedChanged = true;
|
|
8112
|
+
changed = true;
|
|
8113
|
+
getLogger().info(
|
|
8114
|
+
`[Im-Nobsidian] DB ${dbConfig.databaseId}: linked view \uCEE8\uD14C\uC774\uB108 \uAC10\uC9C0 \u2192 \uD589\uC740 \uC6D0\uBCF8 ${dbResult.linkedOriginalDbId} \uD3F4\uB354\uAC00 \uB2E8\uC77C \uC18C\uC720(F25)`
|
|
8115
|
+
);
|
|
8116
|
+
continue;
|
|
8117
|
+
}
|
|
8118
|
+
created += dbResult.created;
|
|
8119
|
+
updated += dbResult.updated;
|
|
8120
|
+
conflicts.push(...dbResult.conflicts);
|
|
8121
|
+
failed.push(...dbResult.failed);
|
|
8122
|
+
writtenPaths.push(...dbResult.writtenPaths);
|
|
8123
|
+
roundRowPaths.push(...dbResult.writtenPaths);
|
|
6875
8124
|
stillSyncable.push(dbConfig);
|
|
8125
|
+
} catch (error) {
|
|
8126
|
+
if (isNotionObjectNotFound(error)) {
|
|
8127
|
+
degrade(dbConfig.databaseId);
|
|
8128
|
+
} else {
|
|
8129
|
+
getLogger().warn(`[Im-Nobsidian] DB ${dbConfig.databaseId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
|
|
8130
|
+
stillSyncable.push(dbConfig);
|
|
8131
|
+
}
|
|
6876
8132
|
}
|
|
6877
8133
|
}
|
|
8134
|
+
const before = dbConfigs.length;
|
|
8135
|
+
for (const ref of await this.collectRowInlineRefs(roundRowPaths)) {
|
|
8136
|
+
await register(ref.dbId, ref.parentPageId);
|
|
8137
|
+
}
|
|
8138
|
+
queue = dbConfigs.slice(before);
|
|
8139
|
+
if (queue.length > 0 && round >= MAX_DISCOVERY_ROUNDS) {
|
|
8140
|
+
getLogger().warn(
|
|
8141
|
+
`[Im-Nobsidian] \uC911\uCCA9 DB \uBC1C\uACAC \uB77C\uC6B4\uB4DC \uD55C\uB3C4(${MAX_DISCOVERY_ROUNDS}) \uB3C4\uB2EC \u2014 ${queue.length}\uAC1C\uB294 \uB2E4\uC74C pull \uC5D0\uC11C \uB3D9\uAE30\uD654`
|
|
8142
|
+
);
|
|
8143
|
+
stillSyncable.push(...queue);
|
|
8144
|
+
break;
|
|
8145
|
+
}
|
|
8146
|
+
if (queue.length > 0) {
|
|
8147
|
+
getLogger().info(
|
|
8148
|
+
`[Im-Nobsidian] DB row \uBCF8\uBB38\uC5D0\uC11C \uC911\uCCA9 DB ${queue.length}\uAC1C \uCD94\uAC00 \uBC1C\uACAC(\uB77C\uC6B4\uB4DC ${round + 1})`
|
|
8149
|
+
);
|
|
8150
|
+
}
|
|
6878
8151
|
}
|
|
6879
8152
|
if (changed || inaccessibleChanged || stillSyncable.length !== dbConfigs.length) {
|
|
6880
8153
|
this.stateDb.setMeta("discovered_dbs", JSON.stringify(stillSyncable));
|
|
@@ -6882,11 +8155,68 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
6882
8155
|
if (inaccessibleChanged) {
|
|
6883
8156
|
this.stateDb.setMeta(INACCESSIBLE_DBS_META_KEY, JSON.stringify([...inaccessibleIds]));
|
|
6884
8157
|
}
|
|
8158
|
+
if (linkedChanged) {
|
|
8159
|
+
this.stateDb.setMeta(LINKED_DBS_META_KEY, JSON.stringify(Object.fromEntries(linkedMap)));
|
|
8160
|
+
}
|
|
8161
|
+
await this.rewriteDbPlaceholderEmbeds(writtenPaths, stillSyncable, linkedMap);
|
|
6885
8162
|
} catch (error) {
|
|
6886
8163
|
getLogger().warn("[Im-Nobsidian] child_database \uC790\uB3D9 \uBC1C\uACAC \uC2E4\uD328:", error);
|
|
6887
8164
|
}
|
|
6888
8165
|
return { created, updated };
|
|
6889
8166
|
}
|
|
8167
|
+
/**
|
|
8168
|
+
* pull 산출 md 의 인라인 DB placeholder(`**제목** *(Notion DB)*` + 보존 마커)를
|
|
8169
|
+
* `![[<실제 .base 경로>|제목]]` 임베드로 재작성한다(F22) — folder note 본문에서
|
|
8170
|
+
* DB 가 텍스트 한 줄이 아니라 실제 Bases 뷰로 보이게 하는 마지막 조각. linked view
|
|
8171
|
+
* 컨테이너는 linkedMap 을 거쳐 원본 DB 의 .base 로 향한다. .base 경로는 DatabaseSyncer
|
|
8172
|
+
* 가 실제 기록한 경로(baseFileInfo)가 SSOT — 폴더명과 .base 파일명은 새니타이즈 규칙이
|
|
8173
|
+
* 달라(공백→하이픈 vs 공백 보존) localFolder 로 추측하면 깨진 임베드가 된다(E2E 실측
|
|
8174
|
+
* 91/158건). 최종적으로 .base 실존까지 확인해, 없으면 placeholder 를 마커째 보존한다.
|
|
8175
|
+
*/
|
|
8176
|
+
async rewriteDbPlaceholderEmbeds(paths, dbConfigs, linkedMap) {
|
|
8177
|
+
const targets = /* @__PURE__ */ new Map();
|
|
8178
|
+
const addTarget = (databaseId, localFolder) => {
|
|
8179
|
+
const nohyph = databaseId.replace(/-/g, "");
|
|
8180
|
+
if (targets.has(nohyph)) return;
|
|
8181
|
+
const info = this.databaseSyncer.baseFileInfo.get(nohyph);
|
|
8182
|
+
if (info) {
|
|
8183
|
+
targets.set(nohyph, { basePath: info.basePath, title: info.title });
|
|
8184
|
+
return;
|
|
8185
|
+
}
|
|
8186
|
+
const safeName = localFolder.split("/").pop() ?? localFolder;
|
|
8187
|
+
targets.set(nohyph, { basePath: `${localFolder}/${safeName}.base`, title: safeName });
|
|
8188
|
+
};
|
|
8189
|
+
for (const cfg of dbConfigs) addTarget(cfg.databaseId, cfg.localFolder);
|
|
8190
|
+
for (const d of this.config.notion.databases ?? []) addTarget(d.databaseId, d.localFolder);
|
|
8191
|
+
for (const [key, t] of [...targets]) {
|
|
8192
|
+
if (!await this.vaultFs.exists(t.basePath)) targets.delete(key);
|
|
8193
|
+
}
|
|
8194
|
+
if (targets.size === 0) return;
|
|
8195
|
+
const resolve = (nohyph) => targets.get(nohyph) ?? targets.get(linkedMap.get(nohyph) ?? "") ?? null;
|
|
8196
|
+
let total = 0;
|
|
8197
|
+
for (const filePath of paths) {
|
|
8198
|
+
if (!filePath.endsWith(".md")) continue;
|
|
8199
|
+
try {
|
|
8200
|
+
const content = await this.vaultFs.readFile(filePath);
|
|
8201
|
+
const { content: next, rewrites } = rewriteDbPlaceholders(content, resolve);
|
|
8202
|
+
if (rewrites === 0) continue;
|
|
8203
|
+
await this.vaultFs.writeFile(filePath, next);
|
|
8204
|
+
total += rewrites;
|
|
8205
|
+
const record = this.stateDb.getByPath(filePath);
|
|
8206
|
+
if (record) {
|
|
8207
|
+
const stat2 = await this.vaultFs.getFileStat(filePath);
|
|
8208
|
+
this.stateDb.transaction(() => {
|
|
8209
|
+
this.stateDb.updateHash(record.id, computeHash(next), Buffer.from(next, "utf-8"));
|
|
8210
|
+
if (stat2) this.stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
|
|
8211
|
+
});
|
|
8212
|
+
}
|
|
8213
|
+
} catch {
|
|
8214
|
+
}
|
|
8215
|
+
}
|
|
8216
|
+
if (total > 0) {
|
|
8217
|
+
getLogger().info(`[Im-Nobsidian] \uC778\uB77C\uC778 DB \uC784\uBCA0\uB4DC \uC7AC\uC791\uC131: ${total}\uAC74`);
|
|
8218
|
+
}
|
|
8219
|
+
}
|
|
6890
8220
|
async resolveNotionLinks(paths) {
|
|
6891
8221
|
let totalResolved = 0;
|
|
6892
8222
|
const allRecords = this.stateDb.getAll();
|
|
@@ -7035,7 +8365,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7035
8365
|
localMtime: null,
|
|
7036
8366
|
localFileSize: null
|
|
7037
8367
|
});
|
|
7038
|
-
await this.imageHandler.uploadAndAppendImages(page.id, conversionResult.images);
|
|
8368
|
+
await this.imageHandler.uploadAndAppendImages(page.id, conversionResult.images, path);
|
|
7039
8369
|
const hash = computeHash(content);
|
|
7040
8370
|
const fileStat = await this.vaultFs.getFileStat(path);
|
|
7041
8371
|
this.stateDb.transaction(() => {
|
|
@@ -7081,7 +8411,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7081
8411
|
parentMode: this.config.notion.parentMode
|
|
7082
8412
|
});
|
|
7083
8413
|
await this.pushUpdatePage(record.notionPageId, conversionResult.content, record.baseSnapshot);
|
|
7084
|
-
await this.imageHandler.uploadAndAppendImages(
|
|
8414
|
+
await this.imageHandler.uploadAndAppendImages(
|
|
8415
|
+
record.notionPageId,
|
|
8416
|
+
conversionResult.images,
|
|
8417
|
+
path
|
|
8418
|
+
);
|
|
7085
8419
|
let propsToUpdate;
|
|
7086
8420
|
if (this.isDatabaseMode && conversionResult.properties && Object.keys(conversionResult.properties).length > 0) {
|
|
7087
8421
|
await this.ensureDbSchema();
|
|
@@ -7305,7 +8639,9 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7305
8639
|
async detectRemoteChangesIncremental(since) {
|
|
7306
8640
|
const changes = [];
|
|
7307
8641
|
this._childParentIds.clear();
|
|
7308
|
-
const
|
|
8642
|
+
const sinceMs = Date.parse(since);
|
|
8643
|
+
const safeSince = Number.isFinite(sinceMs) ? new Date(sinceMs - INCREMENTAL_SAFETY_WINDOW_MS).toISOString() : since;
|
|
8644
|
+
const recentPages = await this.notionClient.searchRecentPages(safeSince);
|
|
7309
8645
|
for (const page of recentPages) {
|
|
7310
8646
|
const record = this.stateDb.getByNotionId(page.id);
|
|
7311
8647
|
if (!record) {
|
|
@@ -7346,7 +8682,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7346
8682
|
const safeName = sanitizeFileName(title);
|
|
7347
8683
|
const parentPath = await this.resolveParentPath(page);
|
|
7348
8684
|
const hasChildPages = await this.pageHasChildContainers(pageId);
|
|
7349
|
-
const markdown = await this.fetchPageMarkdown(pageId);
|
|
8685
|
+
const { content: markdown, compact: exportCompact } = await this.fetchPageMarkdown(pageId);
|
|
7350
8686
|
const hasContent = markdown.trim().length > 0;
|
|
7351
8687
|
let filePath;
|
|
7352
8688
|
let fileType;
|
|
@@ -7374,10 +8710,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7374
8710
|
} else {
|
|
7375
8711
|
properties = this.notionClient.extractProperties(page);
|
|
7376
8712
|
}
|
|
7377
|
-
|
|
8713
|
+
if (this.isDatabaseMode || extractTitle(filePath) !== title) {
|
|
8714
|
+
properties.title = title;
|
|
8715
|
+
}
|
|
7378
8716
|
let processedMarkdown = markdown;
|
|
7379
8717
|
if (this.config.conversion.imageDownload === "immediate") {
|
|
7380
|
-
const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
|
|
8718
|
+
const imageResult = await this.imageHandler.downloadAllImages(markdown, title, pageId);
|
|
7381
8719
|
processedMarkdown = imageResult.content;
|
|
7382
8720
|
this._pullImageCount += imageResult.downloads.length;
|
|
7383
8721
|
}
|
|
@@ -7392,7 +8730,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7392
8730
|
filePath,
|
|
7393
8731
|
parentMode: this.config.notion.parentMode
|
|
7394
8732
|
},
|
|
7395
|
-
{ properties }
|
|
8733
|
+
{ properties, notionExportCompact: exportCompact }
|
|
7396
8734
|
);
|
|
7397
8735
|
const resolvedParentId = await this.extractParentId(page);
|
|
7398
8736
|
await this.vaultFs.writeFile(filePath, finalContent);
|
|
@@ -7449,7 +8787,8 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7449
8787
|
const record = this.stateDb.getByNotionId(change.pageId);
|
|
7450
8788
|
if (!record) return {};
|
|
7451
8789
|
const page = await this.notionClient.getPage(change.pageId);
|
|
7452
|
-
|
|
8790
|
+
const fetched = await this.fetchPageMarkdown(change.pageId);
|
|
8791
|
+
let markdown = fetched.content;
|
|
7453
8792
|
const title = this.notionClient.extractTitle(page);
|
|
7454
8793
|
let properties;
|
|
7455
8794
|
if (this.isDatabaseMode) {
|
|
@@ -7460,9 +8799,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7460
8799
|
} else {
|
|
7461
8800
|
properties = this.notionClient.extractProperties(page);
|
|
7462
8801
|
}
|
|
7463
|
-
|
|
8802
|
+
if (this.isDatabaseMode || extractTitle(record.obsidianPath) !== title) {
|
|
8803
|
+
properties.title = title;
|
|
8804
|
+
}
|
|
7464
8805
|
if (this.config.conversion.imageDownload === "immediate") {
|
|
7465
|
-
const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
|
|
8806
|
+
const imageResult = await this.imageHandler.downloadAllImages(markdown, title, change.pageId);
|
|
7466
8807
|
markdown = imageResult.content;
|
|
7467
8808
|
this._pullImageCount += imageResult.downloads.length;
|
|
7468
8809
|
}
|
|
@@ -7478,7 +8819,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7478
8819
|
filePath: record.obsidianPath,
|
|
7479
8820
|
parentMode: this.config.notion.parentMode
|
|
7480
8821
|
},
|
|
7481
|
-
{
|
|
8822
|
+
{
|
|
8823
|
+
properties,
|
|
8824
|
+
preserveMarkers: savedMarkers.length > 0 ? savedMarkers : void 0,
|
|
8825
|
+
notionExportCompact: fetched.compact
|
|
8826
|
+
}
|
|
7482
8827
|
);
|
|
7483
8828
|
let localContent;
|
|
7484
8829
|
try {
|
|
@@ -7739,24 +9084,51 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7739
9084
|
try {
|
|
7740
9085
|
const result = await this.notionClient.getPageMarkdown(pageId);
|
|
7741
9086
|
this.collectInlineDbRefs(pageId, result.markdown);
|
|
7742
|
-
return
|
|
9087
|
+
return {
|
|
9088
|
+
content: this.resolveNotionIdWikilinks(notionEnhancedToObsidian(result.markdown)),
|
|
9089
|
+
// 압축형 판정은 반드시 원시 export 기준 — enhanced 변환이 <empty-block/> 을
|
|
9090
|
+
// 빈 줄로 바꾼 뒤에는 BlockSpacer 가 저작형과 구분할 수 없다(D1).
|
|
9091
|
+
compact: isCompactExport(result.markdown)
|
|
9092
|
+
};
|
|
7743
9093
|
} catch {
|
|
7744
9094
|
}
|
|
7745
9095
|
}
|
|
7746
|
-
|
|
9096
|
+
const fallback = await this.blockConverter.notionBlocksToMarkdown(pageId);
|
|
9097
|
+
this.collectInlineDbRefs(pageId, fallback);
|
|
9098
|
+
return { content: fallback, compact: false };
|
|
7747
9099
|
}
|
|
7748
|
-
// Markdown API는 인라인 데이터베이스를
|
|
7749
|
-
//
|
|
7750
|
-
//
|
|
7751
|
-
//
|
|
7752
|
-
//
|
|
9100
|
+
// Markdown API는 인라인 데이터베이스를 <database url="..." ...>Title</database> 로 렌더한다
|
|
9101
|
+
// (컬럼/콜아웃/synced_block 내부 포함). url 의 32자리 hex 가 databaseId 이므로 블록 트리
|
|
9102
|
+
// 재귀 없이 이 신호만으로 깊이 중첩된 child_database 를 발견한다. url 호스트는
|
|
9103
|
+
// www.notion.so / app.notion.com/p 두 형태가 실측돼 고정하지 않는다 — 규칙은
|
|
9104
|
+
// extractInlineDbIds(blocks-API 폴백 마커 겸용) 참조.
|
|
7753
9105
|
collectInlineDbRefs(parentPageId, rawMarkdown) {
|
|
7754
|
-
const
|
|
7755
|
-
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
9106
|
+
for (const dbId of extractInlineDbIds(rawMarkdown)) {
|
|
9107
|
+
this._inlineDbRefs.set(dbId, parentPageId);
|
|
9108
|
+
}
|
|
9109
|
+
}
|
|
9110
|
+
/**
|
|
9111
|
+
* 이번 라운드에 기록된 DB row md 를 읽어 본문의 인라인 DB 참조를 수집한다.
|
|
9112
|
+
* row 페이지는 DatabaseSyncer 자체 파이프라인으로 변환돼 fetchPageMarkdown 의
|
|
9113
|
+
* _inlineDbRefs 수집을 타지 않는다 — 디스크에 남은 보존 마커가 유일한 발견 신호다.
|
|
9114
|
+
* 부모 Notion 페이지 id 는 state DB 역조회(getByPath)로 얻는다(row upsert 직후라 항상 존재).
|
|
9115
|
+
*/
|
|
9116
|
+
async collectRowInlineRefs(rowPaths) {
|
|
9117
|
+
const refs = [];
|
|
9118
|
+
for (const path of rowPaths) {
|
|
9119
|
+
if (!path.endsWith(".md")) continue;
|
|
9120
|
+
try {
|
|
9121
|
+
const ids = extractInlineDbIds(await this.vaultFs.readFile(path));
|
|
9122
|
+
if (ids.length === 0) continue;
|
|
9123
|
+
const record = this.stateDb.getByPath(path);
|
|
9124
|
+
if (!record?.notionPageId) continue;
|
|
9125
|
+
for (const id of ids) {
|
|
9126
|
+
refs.push({ dbId: normalizeNotionId(id), parentPageId: record.notionPageId });
|
|
9127
|
+
}
|
|
9128
|
+
} catch {
|
|
9129
|
+
}
|
|
7759
9130
|
}
|
|
9131
|
+
return refs;
|
|
7760
9132
|
}
|
|
7761
9133
|
// pull 시 url 기반 page mention 은 `[[notion:<id>]]` 로 1차 변환된다(notionEnhancedToObsidian).
|
|
7762
9134
|
// 이를 state DB 역조회로 원래 `[[제목]]` 위키링크로 복원해 push↔pull 라운드트립을 수렴시킨다.
|
|
@@ -8039,7 +9411,7 @@ var NodeVaultFS = class {
|
|
|
8039
9411
|
if (this.isExcluded(relativePath)) continue;
|
|
8040
9412
|
if (entry.isDirectory()) {
|
|
8041
9413
|
await this.walkDirNonMd(fullPath, result);
|
|
8042
|
-
} else if (entry.isFile() && !entry.name.endsWith(".md")
|
|
9414
|
+
} else if (entry.isFile() && !entry.name.endsWith(".md")) {
|
|
8043
9415
|
const fileStat = await stat(fullPath);
|
|
8044
9416
|
result.push({
|
|
8045
9417
|
path: relativePath,
|