@im-nobsidian/core 0.3.0 → 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 +94 -2
- package/dist/index.js +861 -163
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
|
@@ -2673,6 +2789,7 @@ var InlineAnnotationPreserver = class {
|
|
|
2673
2789
|
|
|
2674
2790
|
// src/converter/pre-processors/comment-stripper.ts
|
|
2675
2791
|
var COMMENT_REGEX = /%%([\s\S]*?)%%/g;
|
|
2792
|
+
var HTML_COMMENT_REGEX = /<!--([\s\S]*?)-->/g;
|
|
2676
2793
|
var CommentStripper = class {
|
|
2677
2794
|
name = "CommentStripper";
|
|
2678
2795
|
// FrontmatterExtractor(10) 뒤 — YAML 값 속 `%%` 를 본문 주석으로 오인하지 않는다.
|
|
@@ -2682,21 +2799,31 @@ var CommentStripper = class {
|
|
|
2682
2799
|
return { content: input.content, metadata: input.metadata };
|
|
2683
2800
|
}
|
|
2684
2801
|
const markers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
|
|
2685
|
-
const content = mapOutsideCodeFences(
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
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 "";
|
|
2691
2816
|
}
|
|
2817
|
+
);
|
|
2818
|
+
return afterObsidian.replace(HTML_COMMENT_REGEX, (_match, body, offset) => {
|
|
2692
2819
|
markers.push({
|
|
2693
2820
|
type: "comment",
|
|
2694
|
-
params: { text: body, __anchor: computeAnchor(
|
|
2821
|
+
params: { text: body, style: "html", __anchor: computeAnchor(afterObsidian, offset) },
|
|
2695
2822
|
startIndex: offset
|
|
2696
2823
|
});
|
|
2697
2824
|
return "";
|
|
2698
|
-
})
|
|
2699
|
-
);
|
|
2825
|
+
});
|
|
2826
|
+
});
|
|
2700
2827
|
return {
|
|
2701
2828
|
content,
|
|
2702
2829
|
metadata: { ...input.metadata, preserveMarkers: markers }
|
|
@@ -2947,7 +3074,8 @@ var PreserveMarkerInjector = class {
|
|
|
2947
3074
|
renderMarker(marker) {
|
|
2948
3075
|
const params = marker.params ?? {};
|
|
2949
3076
|
if (marker.type === "comment") {
|
|
2950
|
-
|
|
3077
|
+
if (!params.text) return null;
|
|
3078
|
+
return params.style === "html" ? `<!--${params.text}-->` : `%%${params.text}%%`;
|
|
2951
3079
|
}
|
|
2952
3080
|
if (params.__raw !== void 0) {
|
|
2953
3081
|
return spacedMarker(`${marker.type}:${params.__raw}`);
|
|
@@ -4387,6 +4515,13 @@ var ImageHandler = class {
|
|
|
4387
4515
|
(s) => /^https?:\/\//.test(s.url) && this.isNotionImageUrl(s.url)
|
|
4388
4516
|
);
|
|
4389
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
|
+
}
|
|
4390
4525
|
if (notionMatches.length === 0 && internalMatches.length === 0) {
|
|
4391
4526
|
return { content: markdown, downloads: [] };
|
|
4392
4527
|
}
|
|
@@ -4672,7 +4807,9 @@ var ImageHandler = class {
|
|
|
4672
4807
|
const MAX_FILE_SIZE = this.maxFileSizeBytes;
|
|
4673
4808
|
if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
|
|
4674
4809
|
const sizeMB = Math.round(parseInt(contentLength, 10) / 1024 / 1024);
|
|
4675
|
-
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
|
+
);
|
|
4676
4813
|
return { originalUrl: url, localPath: "", hash: "", size: 0 };
|
|
4677
4814
|
}
|
|
4678
4815
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
@@ -4694,7 +4831,26 @@ var ImageHandler = class {
|
|
|
4694
4831
|
return this.isNotionFileUrl(url);
|
|
4695
4832
|
}
|
|
4696
4833
|
isNotionFileUrl(url) {
|
|
4697
|
-
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
|
+
}
|
|
4698
4854
|
}
|
|
4699
4855
|
guessExtension(url) {
|
|
4700
4856
|
const pathname = new URL(url).pathname;
|
|
@@ -4721,6 +4877,28 @@ var ImageHandler = class {
|
|
|
4721
4877
|
return "";
|
|
4722
4878
|
}
|
|
4723
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
|
|
4724
4902
|
var EXTENSION_TO_BLOCK_TYPE = {
|
|
4725
4903
|
".png": "image",
|
|
4726
4904
|
".jpg": "image",
|
|
@@ -4829,7 +5007,7 @@ var FileHandler = class {
|
|
|
4829
5007
|
stateDb;
|
|
4830
5008
|
sema;
|
|
4831
5009
|
async pushFilesForFolder(folderPageId, folderPath) {
|
|
4832
|
-
const allFiles = await this.
|
|
5010
|
+
const allFiles = await this.listUploadableFiles();
|
|
4833
5011
|
const folderFiles = allFiles.filter((f) => getFolderPath(f.path) === folderPath);
|
|
4834
5012
|
if (folderFiles.length === 0) return [];
|
|
4835
5013
|
const results = [];
|
|
@@ -4853,8 +5031,18 @@ var FileHandler = class {
|
|
|
4853
5031
|
}
|
|
4854
5032
|
return results;
|
|
4855
5033
|
}
|
|
4856
|
-
|
|
5034
|
+
/**
|
|
5035
|
+
* 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 제외한다.
|
|
5036
|
+
* node 측 VaultFS 는 과거 `.base` 를 워커에서 빼는 식으로 우회했지만, Obsidian
|
|
5037
|
+
* 어댑터는 모든 파일을 반환하므로 플러그인 push 가 사이드카를 Notion 첨부로
|
|
5038
|
+
* 오염시켰다. 소비자인 여기서 일괄 차단해 구현체 간 동작을 통일한다.
|
|
5039
|
+
*/
|
|
5040
|
+
async listUploadableFiles() {
|
|
4857
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();
|
|
4858
5046
|
if (allFiles.length === 0) return [];
|
|
4859
5047
|
const filesByFolder = /* @__PURE__ */ new Map();
|
|
4860
5048
|
for (const file of allFiles) {
|
|
@@ -5083,6 +5271,44 @@ function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
|
|
|
5083
5271
|
return `${localFolder}/${safeName} (${raw}).md`;
|
|
5084
5272
|
}
|
|
5085
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
|
+
|
|
5086
5312
|
// src/utils/wikilink-title.ts
|
|
5087
5313
|
function wikilinkTitleFromPath(obsidianPath) {
|
|
5088
5314
|
return obsidianPath.replace(/\.md$/i, "").split("/").pop() ?? "";
|
|
@@ -5422,23 +5648,6 @@ function sortKeys(value) {
|
|
|
5422
5648
|
return value;
|
|
5423
5649
|
}
|
|
5424
5650
|
|
|
5425
|
-
// src/sync/stale-db-artifacts.ts
|
|
5426
|
-
var ARTIFACT_SUFFIXES = [".base", ".notion.json"];
|
|
5427
|
-
function selectStaleDbArtifacts(files, localFolder, keepPaths) {
|
|
5428
|
-
const prefix = `${localFolder}/`;
|
|
5429
|
-
const stale = [];
|
|
5430
|
-
for (const file of files) {
|
|
5431
|
-
const path = file.path;
|
|
5432
|
-
if (!path.startsWith(prefix)) continue;
|
|
5433
|
-
const rel = path.slice(prefix.length);
|
|
5434
|
-
if (rel.includes("/")) continue;
|
|
5435
|
-
if (!ARTIFACT_SUFFIXES.some((suffix) => rel.endsWith(suffix))) continue;
|
|
5436
|
-
if (keepPaths.has(path)) continue;
|
|
5437
|
-
stale.push(path);
|
|
5438
|
-
}
|
|
5439
|
-
return stale;
|
|
5440
|
-
}
|
|
5441
|
-
|
|
5442
5651
|
// src/converter/enhanced-md-converter.ts
|
|
5443
5652
|
var NOTION_CALLOUT_RE = /^::: callout\n([\s\S]*?)\n:::/gm;
|
|
5444
5653
|
var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
|
|
@@ -5468,12 +5677,13 @@ function notionEnhancedToObsidian(enhanced) {
|
|
|
5468
5677
|
result = convertNotionTables(result);
|
|
5469
5678
|
result = convertSpans(result);
|
|
5470
5679
|
result = convertDatabaseBlocks(result);
|
|
5471
|
-
result =
|
|
5680
|
+
result = convertBlockColorAttrs(result);
|
|
5472
5681
|
result = removeEmptyBlocks(result);
|
|
5473
5682
|
result = unescapePipes(result);
|
|
5474
5683
|
result = unescapeNotionChars(result);
|
|
5475
5684
|
result = unescapeWikilinkBrackets(result);
|
|
5476
5685
|
result = ensureCalloutContinuity(result);
|
|
5686
|
+
result = separateAdjacentCallouts(result);
|
|
5477
5687
|
return result;
|
|
5478
5688
|
}
|
|
5479
5689
|
function unescapeWikilinkBrackets(content) {
|
|
@@ -5487,9 +5697,12 @@ function obsidianToNotionEnhanced(obsidian) {
|
|
|
5487
5697
|
result = restoreMediaTags(result);
|
|
5488
5698
|
result = restoreUnknownBlocks(result);
|
|
5489
5699
|
result = restoreColorSpans(result);
|
|
5700
|
+
result = restoreBlockColorMarkers(result);
|
|
5490
5701
|
result = restoreUnderlineSpans(result);
|
|
5491
5702
|
result = convertMentionPageIdToUrl(result);
|
|
5492
5703
|
result = restoreWikilinkPreserveLinks(result);
|
|
5704
|
+
result = restoreSyncedBlocks(result);
|
|
5705
|
+
result = reassembleColumns(result);
|
|
5493
5706
|
return result;
|
|
5494
5707
|
}
|
|
5495
5708
|
var MENTION_PAGE_ID_RE = /<mention-page id="([^"]+)">[\s\S]*?<\/mention-page>/g;
|
|
@@ -5582,29 +5795,36 @@ ${fence}
|
|
|
5582
5795
|
}
|
|
5583
5796
|
);
|
|
5584
5797
|
}
|
|
5585
|
-
var NO_CONTAINER_BODY = "(?:(?!<details
|
|
5586
|
-
var SUMMARY_TITLE = "((?:(?!<\\/summary>|<details
|
|
5798
|
+
var NO_CONTAINER_BODY = "(?:(?!<details|<callout|<columns)[\\s\\S])*?";
|
|
5799
|
+
var SUMMARY_TITLE = "((?:(?!<\\/summary>|<details|<summary>)[\\s\\S])*?)";
|
|
5587
5800
|
var INNERMOST_DETAILS_RE = new RegExp(
|
|
5588
|
-
`([\\t ]*)<details
|
|
5801
|
+
`([\\t ]*)<details([^>]*)>[ \\t]*\\n?(?:\\s*<summary>${SUMMARY_TITLE}<\\/summary>)?(${NO_CONTAINER_BODY})<\\/details>`,
|
|
5589
5802
|
"g"
|
|
5590
5803
|
);
|
|
5591
5804
|
var INNERMOST_CALLOUT_RE = new RegExp(
|
|
5592
|
-
`([\\t ]*)<callout[^>]
|
|
5805
|
+
`([\\t ]*)<callout([^>]*)>\\n?(${NO_CONTAINER_BODY})<\\/callout>`,
|
|
5593
5806
|
"g"
|
|
5594
5807
|
);
|
|
5595
5808
|
var INNERMOST_COLUMNS_RE = new RegExp(
|
|
5596
|
-
`([\\t ]*)<columns
|
|
5809
|
+
`([\\t ]*)<columns[^>]*>\\n?(${NO_CONTAINER_BODY})<\\/columns>`,
|
|
5597
5810
|
"g"
|
|
5598
5811
|
);
|
|
5599
|
-
var COLUMN_WRAP_RE = /[\t ]*<column
|
|
5812
|
+
var COLUMN_WRAP_RE = /[\t ]*<column(?!s)[^>]*>\n?([\s\S]*?)[\t ]*<\/column>\n?/g;
|
|
5600
5813
|
function reindentLines(text, indent) {
|
|
5601
5814
|
if (!indent) return text;
|
|
5602
5815
|
return text.split("\n").map((line) => line === "" ? line : indent + line).join("\n");
|
|
5603
5816
|
}
|
|
5604
|
-
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) {
|
|
5605
5823
|
const body = dedentContainerBody(rawBody);
|
|
5824
|
+
const head = ["> [!toggle]-", title.trim(), color ? toggleColorMarker(color) : ""].filter((part) => part !== "").join(" ");
|
|
5825
|
+
if (body.trim() === "") return head;
|
|
5606
5826
|
const calloutBody = body.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n");
|
|
5607
|
-
return
|
|
5827
|
+
return `${head}
|
|
5608
5828
|
${calloutBody}`;
|
|
5609
5829
|
}
|
|
5610
5830
|
function flattenColumns(body) {
|
|
@@ -5618,20 +5838,22 @@ function flattenColumns(body) {
|
|
|
5618
5838
|
if (col.trim() !== "") cols.push(col);
|
|
5619
5839
|
}
|
|
5620
5840
|
if (!matched) return dedentContainerBody(body);
|
|
5621
|
-
|
|
5841
|
+
if (cols.length === 0) return "";
|
|
5842
|
+
return [COLUMN_LIST_START, ...cols.map((c) => `${COLUMN_SEP}
|
|
5843
|
+
${c}`), COLUMN_LIST_END].join("\n");
|
|
5622
5844
|
}
|
|
5623
5845
|
function convertContainers(content) {
|
|
5624
5846
|
let result = content;
|
|
5625
5847
|
let safety = 0;
|
|
5626
|
-
while (safety++ < 2e3 && (result.includes("<details
|
|
5848
|
+
while (safety++ < 2e3 && (result.includes("<details") || result.includes("<callout") || result.includes("<columns"))) {
|
|
5627
5849
|
const before = result;
|
|
5628
5850
|
result = result.replace(
|
|
5629
5851
|
INNERMOST_DETAILS_RE,
|
|
5630
|
-
(_m, indent, title, body) => reindentLines(toggleToCallout(title, body), indent)
|
|
5852
|
+
(_m, indent, attrs, title, body) => reindentLines(toggleToCallout(title ?? "", body, parseDesignAttrs(attrs).color), indent)
|
|
5631
5853
|
);
|
|
5632
5854
|
result = result.replace(
|
|
5633
5855
|
INNERMOST_CALLOUT_RE,
|
|
5634
|
-
(_m, indent, body) => reindentLines(calloutBodyToObsidian(body), indent)
|
|
5856
|
+
(_m, indent, attrs, body) => reindentLines(calloutBodyToObsidian(body, parseDesignAttrs(attrs)), indent)
|
|
5635
5857
|
);
|
|
5636
5858
|
result = result.replace(
|
|
5637
5859
|
INNERMOST_COLUMNS_RE,
|
|
@@ -5639,16 +5861,38 @@ function convertContainers(content) {
|
|
|
5639
5861
|
);
|
|
5640
5862
|
if (result === before) break;
|
|
5641
5863
|
}
|
|
5642
|
-
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
|
+
);
|
|
5643
5883
|
}
|
|
5644
|
-
function calloutBodyToObsidian(body) {
|
|
5884
|
+
function calloutBodyToObsidian(body, style) {
|
|
5645
5885
|
const lines = dedentContainerBody(body).split("\n").filter((l, i, arr) => !(i === 0 && l === "") && !(i === arr.length - 1 && l === ""));
|
|
5646
5886
|
const firstLine = lines[0] ?? "";
|
|
5647
|
-
const emojiMatch = /^([\p{Emoji}️]+)\s*(.*)/u.exec(firstLine);
|
|
5648
|
-
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";
|
|
5649
5891
|
const title = emojiMatch ? emojiMatch[2] : firstLine;
|
|
5650
5892
|
const rest = lines.slice(1).join("\n").trim();
|
|
5651
|
-
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}`;
|
|
5652
5896
|
const calloutBody = rest ? "\n" + rest.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n") : "";
|
|
5653
5897
|
return calloutTitle + calloutBody;
|
|
5654
5898
|
}
|
|
@@ -5661,7 +5905,7 @@ function convertPageMentions(content) {
|
|
|
5661
5905
|
return `[[${cleaned}]]`;
|
|
5662
5906
|
});
|
|
5663
5907
|
result = result.replace(
|
|
5664
|
-
/<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,
|
|
5665
5909
|
(_match, id) => `[[notion:${id}]]`
|
|
5666
5910
|
);
|
|
5667
5911
|
return result;
|
|
@@ -5691,19 +5935,52 @@ function preserveUnknownBlocks(content) {
|
|
|
5691
5935
|
return result;
|
|
5692
5936
|
}
|
|
5693
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();
|
|
5694
5939
|
let result = content.replace(
|
|
5695
|
-
/<synced_block_reference[^>]
|
|
5696
|
-
(match) => {
|
|
5697
|
-
const inner = match
|
|
5698
|
-
|
|
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}`;
|
|
5699
5948
|
}
|
|
5700
5949
|
);
|
|
5701
5950
|
result = result.replace(
|
|
5702
|
-
/<synced_block[^>]
|
|
5703
|
-
(
|
|
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
|
+
}
|
|
5704
5960
|
);
|
|
5705
5961
|
return result;
|
|
5706
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
|
+
}
|
|
5707
5984
|
function convertMediaTags(content) {
|
|
5708
5985
|
let result = content;
|
|
5709
5986
|
result = result.replace(NOTION_AUDIO_RE, (_match, src, caption) => {
|
|
@@ -5746,17 +6023,21 @@ function convertSpans(content) {
|
|
|
5746
6023
|
}
|
|
5747
6024
|
return result;
|
|
5748
6025
|
}
|
|
6026
|
+
var TOGGLE_COLOR_MARKER_RE = new RegExp(`\\s*%%${MARKER_BRAND_RE}:toggle-color:([a-z_]+)%%`);
|
|
5749
6027
|
function convertTogglesToHtml(content) {
|
|
5750
|
-
const calloutToggleRe = /^> \[!toggle\]
|
|
6028
|
+
const calloutToggleRe = /^> \[!toggle\]-[ \t]*(.*)\n?((?:>.*\n?)*)/gm;
|
|
5751
6029
|
let result = content;
|
|
5752
6030
|
let prev = "";
|
|
5753
6031
|
let safety = 0;
|
|
5754
6032
|
while (result !== prev && safety++ < 100) {
|
|
5755
6033
|
prev = result;
|
|
5756
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]}"` : "";
|
|
5757
6038
|
const bodyText = body.split("\n").map((line) => line.replace(/^>\s?/, "")).join("\n").trim();
|
|
5758
|
-
return `<details>
|
|
5759
|
-
<summary>${
|
|
6039
|
+
return `<details${attrs}>
|
|
6040
|
+
<summary>${cleanTitle.trim()}</summary>
|
|
5760
6041
|
|
|
5761
6042
|
${bodyText}
|
|
5762
6043
|
|
|
@@ -5783,6 +6064,7 @@ ${body}
|
|
|
5783
6064
|
result = result.slice(0, startIdx) + html + result.slice(endIdx + TOGGLE_END.length);
|
|
5784
6065
|
}
|
|
5785
6066
|
result = result.replace(startRe, "").replace(endRe, "");
|
|
6067
|
+
result = result.replace(/^(<\/details>)(?!\n|$)/gm, "$1\n");
|
|
5786
6068
|
return result;
|
|
5787
6069
|
}
|
|
5788
6070
|
function findMatchingEnd(content, startFrom) {
|
|
@@ -5803,29 +6085,53 @@ function findMatchingEnd(content, startFrom) {
|
|
|
5803
6085
|
}
|
|
5804
6086
|
return -1;
|
|
5805
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
|
+
}
|
|
5806
6101
|
function convertObsidianCallouts(content) {
|
|
5807
6102
|
const lines = content.split("\n");
|
|
5808
6103
|
const result = [];
|
|
5809
6104
|
let i = 0;
|
|
5810
6105
|
while (i < lines.length) {
|
|
5811
|
-
const headerMatch = /^> \[!(\w+)\]([-+])
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
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
|
+
}
|
|
5817
6118
|
const bodyLines = [];
|
|
5818
6119
|
i++;
|
|
5819
|
-
while (i < lines.length && lines[i].startsWith("> ")) {
|
|
5820
|
-
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));
|
|
5821
6122
|
i++;
|
|
5822
6123
|
}
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
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
|
+
}
|
|
5827
6133
|
}
|
|
5828
|
-
result.push("
|
|
6134
|
+
result.push("</callout>");
|
|
5829
6135
|
} else {
|
|
5830
6136
|
result.push(lines[i]);
|
|
5831
6137
|
i++;
|
|
@@ -5869,12 +6175,38 @@ function convertPageLinks(content) {
|
|
|
5869
6175
|
}
|
|
5870
6176
|
function convertDatabaseBlocks(content) {
|
|
5871
6177
|
return content.replace(
|
|
5872
|
-
/<database[^>]
|
|
5873
|
-
(_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
|
+
}
|
|
5874
6188
|
);
|
|
5875
6189
|
}
|
|
5876
|
-
|
|
5877
|
-
|
|
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");
|
|
5878
6210
|
}
|
|
5879
6211
|
function removeEmptyBlocks(content) {
|
|
5880
6212
|
return content.replace(NOTION_EMPTY_BLOCK_RE, "\n");
|
|
@@ -5939,26 +6271,23 @@ var OBSIDIAN_MEDIA_PDF_RE = /\[📄\s*([^\]]*)\]\(([^)]+)\)/g;
|
|
|
5939
6271
|
var OBSIDIAN_MEDIA_FILE_RE = /\[📎\s*([^\]]*)\]\(([^)]+)\)/g;
|
|
5940
6272
|
function restoreMediaTags(content) {
|
|
5941
6273
|
let result = content;
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
result = result.replace(OBSIDIAN_MEDIA_PDF_RE, (
|
|
5949
|
-
|
|
5950
|
-
});
|
|
5951
|
-
result = result.replace(OBSIDIAN_MEDIA_FILE_RE, (_match, caption, src) => {
|
|
5952
|
-
return `<file src="${src}">${caption}</file>`;
|
|
5953
|
-
});
|
|
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"));
|
|
5954
6282
|
return result;
|
|
5955
6283
|
}
|
|
5956
6284
|
var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)\n((?:> .*\n?)*)/gm;
|
|
5957
6285
|
function restoreTabBlocks(content) {
|
|
5958
|
-
|
|
6286
|
+
const result = content.replace(OBSIDIAN_TAB_RE, (_match, title, body) => {
|
|
5959
6287
|
const unquoted = body.split("\n").map((line) => line.replace(/^> /, "")).join("\n").trim();
|
|
5960
6288
|
return `<tab title="${title}">${unquoted}</tab>`;
|
|
5961
6289
|
});
|
|
6290
|
+
return result.replace(/(<\/tab>)(?!\n|$)/g, "$1\n");
|
|
5962
6291
|
}
|
|
5963
6292
|
var OBSIDIAN_UNKNOWN_RE = new RegExp(
|
|
5964
6293
|
`(?:\\[[^\\]]*\\]\\([^)]*\\))?%%${MARKER_BRAND_RE}:unknown:id=([^&]+)&type=([^%]+)%%`,
|
|
@@ -5991,6 +6320,42 @@ function restoreUnderlineSpans(content) {
|
|
|
5991
6320
|
return `<span underline="true">${text}</span>`;
|
|
5992
6321
|
});
|
|
5993
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
|
+
}
|
|
5994
6359
|
function unescapePipes(content) {
|
|
5995
6360
|
const lines = content.split("\n");
|
|
5996
6361
|
const result = [];
|
|
@@ -6054,6 +6419,14 @@ var DatabaseSyncer = class {
|
|
|
6054
6419
|
propertyMapper = new PropertyMapper();
|
|
6055
6420
|
baseFileGenerator = new BaseFileGenerator();
|
|
6056
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();
|
|
6057
6430
|
async pullAll() {
|
|
6058
6431
|
const databases = this.config.notion.databases;
|
|
6059
6432
|
if (!databases || databases.length === 0) {
|
|
@@ -6108,16 +6481,33 @@ var DatabaseSyncer = class {
|
|
|
6108
6481
|
}
|
|
6109
6482
|
return { created, updated, conflicts: [], failed, writtenPaths: [] };
|
|
6110
6483
|
}
|
|
6111
|
-
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
|
+
}
|
|
6112
6506
|
const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
|
|
6113
6507
|
this.propertyMapper.loadSchema(schema);
|
|
6114
6508
|
await this.vaultFs.ensureFolder(dbConfig.localFolder);
|
|
6115
6509
|
const viewsConfig = await this.pullDatabaseViews(dbConfig);
|
|
6116
6510
|
await this.generateBaseFile(dbConfig, viewsConfig);
|
|
6117
|
-
const pages = await this.notionClient.queryAllDatabasePages(
|
|
6118
|
-
dbConfig.databaseId,
|
|
6119
|
-
dbConfig.pullFilter
|
|
6120
|
-
);
|
|
6121
6511
|
let created = 0;
|
|
6122
6512
|
let updated = 0;
|
|
6123
6513
|
const conflicts = [];
|
|
@@ -6127,7 +6517,8 @@ var DatabaseSyncer = class {
|
|
|
6127
6517
|
try {
|
|
6128
6518
|
const record = this.stateDb.getByNotionId(page.id);
|
|
6129
6519
|
if (record) {
|
|
6130
|
-
|
|
6520
|
+
const misplaced = !isDirectDbRowPath(dbConfig.localFolder, record.obsidianPath);
|
|
6521
|
+
if (page.last_edited_time === record.notionLastEdited && !misplaced) continue;
|
|
6131
6522
|
const outcome = await this.pullDatabasePage(page, dbConfig);
|
|
6132
6523
|
if (outcome.action === "written") {
|
|
6133
6524
|
updated++;
|
|
@@ -6185,7 +6576,7 @@ var DatabaseSyncer = class {
|
|
|
6185
6576
|
return null;
|
|
6186
6577
|
}
|
|
6187
6578
|
}
|
|
6188
|
-
async generateBaseFile(dbConfig, viewsConfig) {
|
|
6579
|
+
async generateBaseFile(dbConfig, viewsConfig, rowsFolder) {
|
|
6189
6580
|
try {
|
|
6190
6581
|
const schemaFull = await this.notionClient.getDatabaseSchemaFull(dbConfig.databaseId);
|
|
6191
6582
|
const dbName = viewsConfig?.databaseName || await this.notionClient.getDatabaseTitle(dbConfig.databaseId) || dbConfig.localFolder.split("/").pop() || "Database";
|
|
@@ -6200,11 +6591,12 @@ var DatabaseSyncer = class {
|
|
|
6200
6591
|
databaseName: dbName,
|
|
6201
6592
|
schema: schemaFull,
|
|
6202
6593
|
viewsConfig: resolvedViews,
|
|
6203
|
-
folderPath: dbConfig.localFolder
|
|
6594
|
+
folderPath: rowsFolder ?? dbConfig.localFolder
|
|
6204
6595
|
});
|
|
6205
6596
|
const safeName = sanitizeFileName(dbName);
|
|
6206
6597
|
const basePath = `${dbConfig.localFolder}/${safeName}.base`;
|
|
6207
6598
|
await this.vaultFs.writeFile(basePath, baseContent);
|
|
6599
|
+
this.baseFileInfo.set(dbConfig.databaseId.replace(/-/g, ""), { basePath, title: dbName });
|
|
6208
6600
|
getLogger().debug(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131: ${basePath}`);
|
|
6209
6601
|
await this.generateSidecar(dbConfig, dbName, safeName, schemaFull, resolvedViews);
|
|
6210
6602
|
await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName);
|
|
@@ -6247,7 +6639,10 @@ var DatabaseSyncer = class {
|
|
|
6247
6639
|
viewsConfig: resolvedViews
|
|
6248
6640
|
});
|
|
6249
6641
|
const sidecarPath = `${dbConfig.localFolder}/${safeName}.notion.json`;
|
|
6250
|
-
|
|
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);
|
|
6251
6646
|
if (sidecar.degraded.length > 0) {
|
|
6252
6647
|
const unrep = sidecar.degraded.filter((d) => d.kind === "view-unrepresentable").length;
|
|
6253
6648
|
const dropped = sidecar.degraded.length - unrep;
|
|
@@ -6259,6 +6654,42 @@ var DatabaseSyncer = class {
|
|
|
6259
6654
|
getLogger().warn(`[DB Sync] \uC0AC\uC774\uB4DC\uCE74 \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
|
|
6260
6655
|
}
|
|
6261
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
|
+
}
|
|
6262
6693
|
async pullDatabasePage(page, dbConfig) {
|
|
6263
6694
|
const title = this.notionClient.extractTitle(page);
|
|
6264
6695
|
const safeName = sanitizeFileName(title);
|
|
@@ -6266,23 +6697,16 @@ var DatabaseSyncer = class {
|
|
|
6266
6697
|
page.properties
|
|
6267
6698
|
);
|
|
6268
6699
|
properties.title = title;
|
|
6700
|
+
await this.localizeFileProperties(
|
|
6701
|
+
page.properties,
|
|
6702
|
+
properties,
|
|
6703
|
+
safeName
|
|
6704
|
+
);
|
|
6269
6705
|
const cover = this.notionClient.extractCover(page);
|
|
6270
6706
|
const icon = this.notionClient.extractIcon(page);
|
|
6271
6707
|
if (cover) {
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
``,
|
|
6275
|
-
`${safeName}-cover`
|
|
6276
|
-
);
|
|
6277
|
-
const resolved = coverResult.content.match(/!\[cover\]\((.+?)\)/)?.[1];
|
|
6278
|
-
if (resolved && !/^https?:\/\//i.test(resolved)) {
|
|
6279
|
-
properties.cover = `[[${resolved}]]`;
|
|
6280
|
-
} else {
|
|
6281
|
-
properties.cover = cover.url;
|
|
6282
|
-
}
|
|
6283
|
-
} catch {
|
|
6284
|
-
properties.cover = cover.url;
|
|
6285
|
-
}
|
|
6708
|
+
const local = await this.imageHandler.localizeNotionFileUrl(cover.url, `${safeName}-cover`);
|
|
6709
|
+
properties.cover = local ? `[[${local}]]` : cover.url;
|
|
6286
6710
|
}
|
|
6287
6711
|
if (icon) {
|
|
6288
6712
|
properties.icon = icon.value;
|
|
@@ -6312,9 +6736,10 @@ var DatabaseSyncer = class {
|
|
|
6312
6736
|
}
|
|
6313
6737
|
}
|
|
6314
6738
|
const existingRecord = this.stateDb.getByNotionId(page.id);
|
|
6739
|
+
const recordPath = existingRecord?.obsidianPath;
|
|
6315
6740
|
let filePath;
|
|
6316
|
-
if (
|
|
6317
|
-
filePath =
|
|
6741
|
+
if (recordPath && isDirectDbRowPath(dbConfig.localFolder, recordPath)) {
|
|
6742
|
+
filePath = recordPath;
|
|
6318
6743
|
} else {
|
|
6319
6744
|
filePath = resolveDbRowPath(
|
|
6320
6745
|
dbConfig.localFolder,
|
|
@@ -6336,7 +6761,7 @@ var DatabaseSyncer = class {
|
|
|
6336
6761
|
if (existingRecord) {
|
|
6337
6762
|
let localContent = "";
|
|
6338
6763
|
try {
|
|
6339
|
-
localContent = await this.vaultFs.readFile(filePath);
|
|
6764
|
+
localContent = await this.vaultFs.readFile(recordPath ?? filePath);
|
|
6340
6765
|
} catch {
|
|
6341
6766
|
localContent = "";
|
|
6342
6767
|
}
|
|
@@ -6353,17 +6778,26 @@ var DatabaseSyncer = class {
|
|
|
6353
6778
|
strategy: this.config.sync.conflictStrategy
|
|
6354
6779
|
});
|
|
6355
6780
|
if (resolution.action === "skip") {
|
|
6356
|
-
return { action: "skipped", path: filePath };
|
|
6781
|
+
return { action: "skipped", path: recordPath ?? filePath };
|
|
6357
6782
|
}
|
|
6358
6783
|
if (resolution.action === "conflict") {
|
|
6359
6784
|
this.stateDb.updateStatus(existingRecord.id, "conflict");
|
|
6360
|
-
return {
|
|
6785
|
+
return {
|
|
6786
|
+
action: "conflict",
|
|
6787
|
+
path: recordPath ?? filePath,
|
|
6788
|
+
conflict: resolution.conflict
|
|
6789
|
+
};
|
|
6361
6790
|
}
|
|
6362
6791
|
}
|
|
6363
6792
|
await this.vaultFs.ensureFolder(dbConfig.localFolder);
|
|
6364
6793
|
await this.vaultFs.writeFile(filePath, finalContent);
|
|
6794
|
+
const rehomed = existingRecord && recordPath && recordPath !== filePath;
|
|
6365
6795
|
const hash = computeHash(finalContent);
|
|
6366
6796
|
this.stateDb.transaction(() => {
|
|
6797
|
+
if (rehomed) {
|
|
6798
|
+
this.stateDb.updatePath(existingRecord.id, filePath);
|
|
6799
|
+
this.stateDb.deleteWikilink(recordPath);
|
|
6800
|
+
}
|
|
6367
6801
|
this.stateDb.upsert({
|
|
6368
6802
|
obsidianPath: filePath,
|
|
6369
6803
|
notionPageId: page.id,
|
|
@@ -6383,6 +6817,12 @@ var DatabaseSyncer = class {
|
|
|
6383
6817
|
aliases: extractAliases(properties)
|
|
6384
6818
|
});
|
|
6385
6819
|
});
|
|
6820
|
+
if (rehomed) {
|
|
6821
|
+
try {
|
|
6822
|
+
await this.vaultFs.deleteFile(recordPath);
|
|
6823
|
+
} catch {
|
|
6824
|
+
}
|
|
6825
|
+
}
|
|
6386
6826
|
return { action: "written", path: filePath };
|
|
6387
6827
|
}
|
|
6388
6828
|
async pushDatabase(dbConfig) {
|
|
@@ -6820,8 +7260,76 @@ function resolveFrontmatterRelations(content, idToTitle) {
|
|
|
6820
7260
|
return { content: stringifyFrontmatter(parsed.content, resolved), count };
|
|
6821
7261
|
}
|
|
6822
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
|
+
|
|
6823
7330
|
// src/sync/orchestrator.ts
|
|
6824
7331
|
var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
|
|
7332
|
+
var LINKED_DBS_META_KEY = "linked_dbs";
|
|
6825
7333
|
var INCREMENTAL_SAFETY_WINDOW_MS = 15 * 6e4;
|
|
6826
7334
|
function strategyToChoice(strategy) {
|
|
6827
7335
|
switch (strategy) {
|
|
@@ -7399,7 +7907,20 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7399
7907
|
let dbTitle;
|
|
7400
7908
|
try {
|
|
7401
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
|
+
}
|
|
7402
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
|
+
}
|
|
7403
7924
|
return { kind: "inaccessible" };
|
|
7404
7925
|
}
|
|
7405
7926
|
dbTitle = info.title;
|
|
@@ -7414,8 +7935,9 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7414
7935
|
const obsPath = parentEntry.obsidianPath;
|
|
7415
7936
|
if (obsPath.endsWith(".md")) {
|
|
7416
7937
|
const parts = obsPath.split("/");
|
|
7417
|
-
parts.pop();
|
|
7418
|
-
|
|
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("/");
|
|
7419
7941
|
} else {
|
|
7420
7942
|
parentFolder = obsPath;
|
|
7421
7943
|
}
|
|
@@ -7449,6 +7971,19 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7449
7971
|
return /* @__PURE__ */ new Set();
|
|
7450
7972
|
}
|
|
7451
7973
|
}
|
|
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
|
+
}
|
|
7452
7987
|
async pullDiscoveredDatabases(writtenPaths, failed, conflicts, forceRediscovery = false) {
|
|
7453
7988
|
if (this.isDatabaseMode) return { created: 0, updated: 0 };
|
|
7454
7989
|
let created = 0;
|
|
@@ -7471,6 +8006,23 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7471
8006
|
...dbConfigs.map((c) => c.databaseId.replace(/-/g, ""))
|
|
7472
8007
|
]);
|
|
7473
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
|
+
};
|
|
7474
8026
|
const inaccessibleIds = this.loadInaccessibleDbIds();
|
|
7475
8027
|
let inaccessibleChanged = false;
|
|
7476
8028
|
const degrade = (rawId) => {
|
|
@@ -7483,52 +8035,119 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7483
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)`
|
|
7484
8036
|
);
|
|
7485
8037
|
};
|
|
7486
|
-
if (
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
|
|
7490
|
-
|
|
7491
|
-
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
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);
|
|
7495
8058
|
changed = true;
|
|
7496
|
-
} else if (
|
|
7497
|
-
degrade(
|
|
8059
|
+
} else if (outcome2.kind !== "error") {
|
|
8060
|
+
degrade(origId);
|
|
7498
8061
|
}
|
|
8062
|
+
};
|
|
8063
|
+
const knownOriginal = linkedMap.get(nohyph);
|
|
8064
|
+
if (knownOriginal) {
|
|
8065
|
+
await registerOriginal(knownOriginal);
|
|
8066
|
+
return;
|
|
7499
8067
|
}
|
|
7500
|
-
}
|
|
7501
|
-
for (const [nohyph, parentPageId] of this._inlineDbRefs) {
|
|
7502
|
-
if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) continue;
|
|
7503
|
-
const dbId = normalizeNotionId(nohyph);
|
|
7504
8068
|
const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
|
|
7505
8069
|
knownIds.add(nohyph);
|
|
7506
8070
|
if (outcome.kind === "ok") {
|
|
8071
|
+
claimFolder(outcome.config);
|
|
7507
8072
|
dbConfigs.push(outcome.config);
|
|
7508
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);
|
|
7509
8079
|
} else if (outcome.kind === "inaccessible") {
|
|
7510
8080
|
degrade(dbId);
|
|
7511
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);
|
|
7512
8091
|
}
|
|
7513
8092
|
const stillSyncable = [];
|
|
7514
|
-
|
|
7515
|
-
|
|
7516
|
-
|
|
7517
|
-
|
|
7518
|
-
|
|
7519
|
-
|
|
7520
|
-
|
|
7521
|
-
|
|
7522
|
-
|
|
7523
|
-
|
|
7524
|
-
|
|
7525
|
-
|
|
7526
|
-
|
|
7527
|
-
|
|
7528
|
-
|
|
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);
|
|
7529
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
|
+
}
|
|
7530
8132
|
}
|
|
7531
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
|
+
}
|
|
7532
8151
|
}
|
|
7533
8152
|
if (changed || inaccessibleChanged || stillSyncable.length !== dbConfigs.length) {
|
|
7534
8153
|
this.stateDb.setMeta("discovered_dbs", JSON.stringify(stillSyncable));
|
|
@@ -7536,11 +8155,68 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7536
8155
|
if (inaccessibleChanged) {
|
|
7537
8156
|
this.stateDb.setMeta(INACCESSIBLE_DBS_META_KEY, JSON.stringify([...inaccessibleIds]));
|
|
7538
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);
|
|
7539
8162
|
} catch (error) {
|
|
7540
8163
|
getLogger().warn("[Im-Nobsidian] child_database \uC790\uB3D9 \uBC1C\uACAC \uC2E4\uD328:", error);
|
|
7541
8164
|
}
|
|
7542
8165
|
return { created, updated };
|
|
7543
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
|
+
}
|
|
7544
8220
|
async resolveNotionLinks(paths) {
|
|
7545
8221
|
let totalResolved = 0;
|
|
7546
8222
|
const allRecords = this.stateDb.getAll();
|
|
@@ -8417,20 +9093,42 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8417
9093
|
} catch {
|
|
8418
9094
|
}
|
|
8419
9095
|
}
|
|
8420
|
-
|
|
9096
|
+
const fallback = await this.blockConverter.notionBlocksToMarkdown(pageId);
|
|
9097
|
+
this.collectInlineDbRefs(pageId, fallback);
|
|
9098
|
+
return { content: fallback, compact: false };
|
|
8421
9099
|
}
|
|
8422
|
-
// Markdown API는 인라인 데이터베이스를
|
|
8423
|
-
//
|
|
8424
|
-
//
|
|
8425
|
-
//
|
|
8426
|
-
//
|
|
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 폴백 마커 겸용) 참조.
|
|
8427
9105
|
collectInlineDbRefs(parentPageId, rawMarkdown) {
|
|
8428
|
-
const
|
|
8429
|
-
|
|
8430
|
-
|
|
8431
|
-
|
|
8432
|
-
|
|
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
|
+
}
|
|
8433
9130
|
}
|
|
9131
|
+
return refs;
|
|
8434
9132
|
}
|
|
8435
9133
|
// pull 시 url 기반 page mention 은 `[[notion:<id>]]` 로 1차 변환된다(notionEnhancedToObsidian).
|
|
8436
9134
|
// 이를 state DB 역조회로 원래 `[[제목]]` 위키링크로 복원해 push↔pull 라운드트립을 수렴시킨다.
|
|
@@ -8713,7 +9411,7 @@ var NodeVaultFS = class {
|
|
|
8713
9411
|
if (this.isExcluded(relativePath)) continue;
|
|
8714
9412
|
if (entry.isDirectory()) {
|
|
8715
9413
|
await this.walkDirNonMd(fullPath, result);
|
|
8716
|
-
} else if (entry.isFile() && !entry.name.endsWith(".md")
|
|
9414
|
+
} else if (entry.isFile() && !entry.name.endsWith(".md")) {
|
|
8717
9415
|
const fileStat = await stat(fullPath);
|
|
8718
9416
|
result.push({
|
|
8719
9417
|
path: relativePath,
|