@im-nobsidian/core 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +621 -42
- package/dist/index.js +2608 -798
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -70,7 +70,11 @@ var ConfigSchema = z.object({
|
|
|
70
70
|
// 비마크다운 파일(첨부) 업로드/다운로드 동시성
|
|
71
71
|
fileConcurrency: z.number().min(1).max(10).default(2),
|
|
72
72
|
// 다운로드 파일 크기 상한 (bytes, 기본 100MB)
|
|
73
|
-
maxFileSizeBytes: z.number().min(1024).default(100 * 1024 * 1024)
|
|
73
|
+
maxFileSizeBytes: z.number().min(1024).default(100 * 1024 * 1024),
|
|
74
|
+
// 미디어·첨부 다운로드 1회 시도의 시간 상한 (ms, 기본 300초). 0 = 상한 없음(권장하지 않음)
|
|
75
|
+
mediaDownloadTimeoutMs: z.number().min(0).max(18e5).default(3e5),
|
|
76
|
+
// 페이지 1건 처리의 시간 상한 (ms, 기본 30분). 한 건이 동기화 전체를 멈춰 세우지 못하게 한다. 0 = 상한 없음
|
|
77
|
+
itemTimeoutMs: z.number().min(0).max(72e5).default(18e5)
|
|
74
78
|
})
|
|
75
79
|
});
|
|
76
80
|
var DEFAULT_CONFIG = {
|
|
@@ -114,7 +118,9 @@ var DEFAULT_CONFIG = {
|
|
|
114
118
|
mediaMaxRetries: 3,
|
|
115
119
|
mediaRetryBaseMs: 1e3,
|
|
116
120
|
fileConcurrency: 2,
|
|
117
|
-
maxFileSizeBytes: 100 * 1024 * 1024
|
|
121
|
+
maxFileSizeBytes: 100 * 1024 * 1024,
|
|
122
|
+
mediaDownloadTimeoutMs: 3e5,
|
|
123
|
+
itemTimeoutMs: 18e5
|
|
118
124
|
}
|
|
119
125
|
};
|
|
120
126
|
|
|
@@ -761,13 +767,13 @@ var PropertyMapper = class {
|
|
|
761
767
|
const items = Array.isArray(value) ? value : [value];
|
|
762
768
|
const ids = [];
|
|
763
769
|
for (const item of items) {
|
|
764
|
-
const
|
|
765
|
-
const match =
|
|
770
|
+
const str2 = String(item);
|
|
771
|
+
const match = str2.match(WIKILINK_REGEX);
|
|
766
772
|
if (match?.[1] && this.wikilinkResolver) {
|
|
767
773
|
const pageId = this.wikilinkResolver.resolve(match[1]);
|
|
768
774
|
if (pageId) ids.push({ id: pageId });
|
|
769
|
-
} else if (
|
|
770
|
-
ids.push({ id:
|
|
775
|
+
} else if (str2.match(/^[0-9a-f-]{32,36}$/)) {
|
|
776
|
+
ids.push({ id: str2 });
|
|
771
777
|
}
|
|
772
778
|
}
|
|
773
779
|
return ids.length > 0 ? { relation: ids } : null;
|
|
@@ -956,10 +962,12 @@ function isNotionObjectNotFound(error) {
|
|
|
956
962
|
return e.code === "object_not_found" || e.status === 404;
|
|
957
963
|
}
|
|
958
964
|
var DiscoveryTooLargeError = class extends Error {
|
|
959
|
-
constructor(elapsedMs) {
|
|
965
|
+
constructor(elapsedMs, partial = []) {
|
|
960
966
|
super(`subtree discovery exceeded time budget (${elapsedMs}ms)`);
|
|
967
|
+
this.partial = partial;
|
|
961
968
|
this.name = "DiscoveryTooLargeError";
|
|
962
969
|
}
|
|
970
|
+
partial;
|
|
963
971
|
};
|
|
964
972
|
var NotionClient = class _NotionClient {
|
|
965
973
|
client;
|
|
@@ -987,7 +995,7 @@ var NotionClient = class _NotionClient {
|
|
|
987
995
|
this.batchSize = options.batchSize ?? 100;
|
|
988
996
|
}
|
|
989
997
|
/** config.advanced 의 운영 튜닝값으로 클라이언트를 생성한다 (매직넘버 단일 진실원). */
|
|
990
|
-
static fromConfig(config,
|
|
998
|
+
static fromConfig(config, fetch) {
|
|
991
999
|
const a = config.advanced;
|
|
992
1000
|
return new _NotionClient({
|
|
993
1001
|
token: config.notion.token,
|
|
@@ -999,7 +1007,7 @@ var NotionClient = class _NotionClient {
|
|
|
999
1007
|
retryBackoffFactor: a.retryBackoffFactor,
|
|
1000
1008
|
pageSize: a.pageSize,
|
|
1001
1009
|
batchSize: a.batchSize,
|
|
1002
|
-
...
|
|
1010
|
+
...fetch ? { fetch } : {}
|
|
1003
1011
|
});
|
|
1004
1012
|
}
|
|
1005
1013
|
getInternalClient() {
|
|
@@ -1193,36 +1201,25 @@ var NotionClient = class _NotionClient {
|
|
|
1193
1201
|
}
|
|
1194
1202
|
return schema;
|
|
1195
1203
|
}
|
|
1204
|
+
/**
|
|
1205
|
+
* DB 속성 스키마 전체(선택지·status 그룹 포함).
|
|
1206
|
+
*
|
|
1207
|
+
* 옵션 `id` 까지 그대로 실어 보낸다 — 산출물에는 안 나가지만, status 뷰 필터가
|
|
1208
|
+
* 옵션명이 아니라 **그룹명**(`To-do` 등)으로 오기 때문에 `groups[].optionIds` 를
|
|
1209
|
+
* 옵션명으로 되돌리려면 이 id 가 있어야 한다. 예전엔 여기서 id 를 떨궈,
|
|
1210
|
+
* 그룹으로 필터링된 뷰를 Bases 로 옮길 방법이 아예 없었다.
|
|
1211
|
+
*/
|
|
1196
1212
|
async getDatabaseSchemaFull(databaseId) {
|
|
1197
1213
|
const db = await this.fetchDatabaseModern(databaseId);
|
|
1198
1214
|
const properties = db.properties;
|
|
1199
1215
|
if (!properties) return {};
|
|
1200
1216
|
const schema = {};
|
|
1201
1217
|
for (const [name, prop] of Object.entries(properties)) {
|
|
1202
|
-
|
|
1218
|
+
schema[name] = {
|
|
1203
1219
|
id: prop.id,
|
|
1204
|
-
type: prop.type
|
|
1220
|
+
type: prop.type,
|
|
1221
|
+
...extractPropertyChoices(prop)
|
|
1205
1222
|
};
|
|
1206
|
-
if (prop.type === "select" || prop.type === "multi_select") {
|
|
1207
|
-
const typeData = prop[prop.type];
|
|
1208
|
-
if (typeData?.options) {
|
|
1209
|
-
entry.options = typeData.options.map((o) => ({ name: o.name, color: o.color }));
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
if (prop.type === "status") {
|
|
1213
|
-
const statusData = prop.status;
|
|
1214
|
-
if (statusData?.options) {
|
|
1215
|
-
entry.options = statusData.options.map((o) => ({ name: o.name, color: o.color }));
|
|
1216
|
-
}
|
|
1217
|
-
if (statusData?.groups) {
|
|
1218
|
-
entry.groups = statusData.groups.map((g) => ({
|
|
1219
|
-
name: g.name,
|
|
1220
|
-
color: g.color,
|
|
1221
|
-
optionIds: g.option_ids
|
|
1222
|
-
}));
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
schema[name] = entry;
|
|
1226
1223
|
}
|
|
1227
1224
|
return schema;
|
|
1228
1225
|
}
|
|
@@ -1375,17 +1372,32 @@ var NotionClient = class _NotionClient {
|
|
|
1375
1372
|
}
|
|
1376
1373
|
return allBlocks;
|
|
1377
1374
|
}
|
|
1378
|
-
|
|
1375
|
+
/**
|
|
1376
|
+
* 자식 블록을 덧붙인다. `options.after` 를 주면 그 블록 **바로 뒤**에 삽입한다
|
|
1377
|
+
* (기본은 맨 끝). 자리표시자를 실제 미디어 블록으로 제자리 교체할 때 필요하다.
|
|
1378
|
+
*
|
|
1379
|
+
* 실측(2025-09-03): position 삽입 응답의 `results` 는 방금 넣은 블록뿐 아니라
|
|
1380
|
+
* 삽입 지점 이후의 형제까지 함께 돌려준다. 앞에서부터 batch 길이만큼이 새 블록이므로
|
|
1381
|
+
* 배치를 이어 붙일 때는 그 마지막 id 를 다음 기준점으로 삼는다.
|
|
1382
|
+
*/
|
|
1383
|
+
async appendChildren(blockId, children, options) {
|
|
1379
1384
|
const batchSize = this.batchSize;
|
|
1385
|
+
const created = [];
|
|
1386
|
+
let after = options?.after;
|
|
1380
1387
|
for (let i = 0; i < children.length; i += batchSize) {
|
|
1381
1388
|
const batch = children.slice(i, i + batchSize);
|
|
1382
|
-
await this.withRateLimit(
|
|
1389
|
+
const response = await this.withRateLimit(
|
|
1383
1390
|
() => this.client.blocks.children.append({
|
|
1384
1391
|
block_id: blockId,
|
|
1385
|
-
children: batch
|
|
1392
|
+
children: batch,
|
|
1393
|
+
...after ? { position: { type: "after_block", after_block: { id: after } } } : {}
|
|
1386
1394
|
})
|
|
1387
1395
|
);
|
|
1396
|
+
const ids = response.results.slice(0, batch.length).map((b) => b.id);
|
|
1397
|
+
created.push(...ids);
|
|
1398
|
+
if (after && ids.length > 0) after = ids[ids.length - 1];
|
|
1388
1399
|
}
|
|
1400
|
+
return created;
|
|
1389
1401
|
}
|
|
1390
1402
|
async deleteBlock(blockId) {
|
|
1391
1403
|
await this.withRateLimit(() => this.client.blocks.delete({ block_id: blockId }));
|
|
@@ -1509,6 +1521,8 @@ var NotionClient = class _NotionClient {
|
|
|
1509
1521
|
*
|
|
1510
1522
|
* @param opts.deadlineMs `Date.now()` 기준 마감 시각. 각 페이지 처리 전 초과를 검사해
|
|
1511
1523
|
* 초과 시 {@link DiscoveryTooLargeError}를 던진다(대규모 서브트리 → search 폴백 유도).
|
|
1524
|
+
* **그때까지 찾은 페이지는 에러의 `partial` 에 실어 보낸다** — 버리면 호출측이 두
|
|
1525
|
+
* 경로를 경합시키게 되고, 어느 쪽이 이기는지를 벽시계가 정하게 된다(R12-A).
|
|
1512
1526
|
* 미지정 시 무제한(기존 동작 유지 — 테스트 mock 경로는 영향 없음).
|
|
1513
1527
|
*/
|
|
1514
1528
|
async getChildPagesRecursive(parentId, opts) {
|
|
@@ -1519,7 +1533,7 @@ var NotionClient = class _NotionClient {
|
|
|
1519
1533
|
const nextLevel = [];
|
|
1520
1534
|
for (const id of currentLevel) {
|
|
1521
1535
|
if (opts?.deadlineMs !== void 0 && Date.now() > opts.deadlineMs) {
|
|
1522
|
-
throw new DiscoveryTooLargeError(Date.now() - start);
|
|
1536
|
+
throw new DiscoveryTooLargeError(Date.now() - start, all);
|
|
1523
1537
|
}
|
|
1524
1538
|
let children;
|
|
1525
1539
|
try {
|
|
@@ -1583,7 +1597,10 @@ var NotionClient = class _NotionClient {
|
|
|
1583
1597
|
let bp;
|
|
1584
1598
|
try {
|
|
1585
1599
|
bp = (await this.getBlock(bid)).parent;
|
|
1586
|
-
} catch {
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
getLogger().warn(
|
|
1602
|
+
`[Im-Nobsidian] \uBD80\uBAA8 \uBE14\uB85D \uD574\uC18C \uC2E4\uD328 (${bid}) \u2014 \uC774 \uBE14\uB85D\uC5D0 \uC911\uCCA9\uB41C \uD398\uC774\uC9C0\uB294 search \uB514\uC2A4\uCEE4\uBC84\uB9AC\uC5D0\uC11C \uC81C\uC678\uB428: ${error instanceof Error ? error.message : String(error)}`
|
|
1603
|
+
);
|
|
1587
1604
|
blockOwner.set(bid, null);
|
|
1588
1605
|
return null;
|
|
1589
1606
|
}
|
|
@@ -1741,34 +1758,78 @@ var NotionClient = class _NotionClient {
|
|
|
1741
1758
|
}
|
|
1742
1759
|
// ─── Internal ───
|
|
1743
1760
|
lastRequestTime = 0;
|
|
1761
|
+
/**
|
|
1762
|
+
* 429 이후 **클라이언트 전체**가 쉬어야 하는 시각(epoch ms).
|
|
1763
|
+
*
|
|
1764
|
+
* rate limit 은 요청 하나의 문제가 아니라 워크스페이스 전체에 걸린 신호다. 그러니
|
|
1765
|
+
* 429 를 맞은 요청만 기다리게 두면 나머지 슬롯이 곧바로 다시 두드려 429 를 재생산한다.
|
|
1766
|
+
* 슬롯을 붙잡아 남을 막는 대신, 다 같이 보는 이 게이트로 쉬게 한다.
|
|
1767
|
+
*/
|
|
1768
|
+
cooldownUntil = 0;
|
|
1769
|
+
/**
|
|
1770
|
+
* 요청 1건을 rate limit 슬롯 안에서 실행하고, 재시도 가능한 실패는 백오프 후 다시 시도한다.
|
|
1771
|
+
*
|
|
1772
|
+
* 백오프 대기는 **슬롯을 놓은 뒤** 한다. 예전 구현은 `sema.acquire()` 안에서 재시도
|
|
1773
|
+
* 전체(최대 60초 x 5회)를 돌려, 불운한 요청 하나가 동시성 한 칸을 최대 5분 점유했다.
|
|
1774
|
+
* 동시성 기본값이 3이므로 그런 요청 3건이면 클라이언트 전체가 멈춘다. 게다가 재시도는
|
|
1775
|
+
* 로그를 한 줄도 남기지 않아, 겉보기에는 죽은 프로세스와 구분되지 않았다 — 실제로
|
|
1776
|
+
* 268페이지 pull 이 진행 로그가 멈춘 채 수 분씩 정지하는 것으로 나타났다(R9).
|
|
1777
|
+
* 그래서 (1) 대기 중에는 슬롯을 반납하고, (2) 429 는 {@link cooldownUntil} 전역
|
|
1778
|
+
* 게이트로 함께 쉬고, (3) 모든 재시도를 경고로 남긴다.
|
|
1779
|
+
*/
|
|
1744
1780
|
async withRateLimit(fn) {
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1781
|
+
let backoffMs = 0;
|
|
1782
|
+
for (let attempt = 0; ; attempt++) {
|
|
1783
|
+
await this.sema.acquire();
|
|
1784
|
+
try {
|
|
1785
|
+
await this.waitForTurn();
|
|
1786
|
+
this.lastRequestTime = Date.now();
|
|
1787
|
+
return await fn();
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
if (!isRetryable(error) || attempt >= this.maxRetries) throw error;
|
|
1790
|
+
const retryAfterMs = extractRetryAfter(error);
|
|
1791
|
+
backoffMs = retryAfterMs !== null ? retryAfterMs * (1 + Math.random() * 0.25) : this.retryBaseDelayMs * Math.pow(this.retryBackoffFactor, attempt) * (0.5 + Math.random() * 0.5);
|
|
1792
|
+
if (isRateLimited(error)) {
|
|
1793
|
+
this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + backoffMs);
|
|
1794
|
+
}
|
|
1795
|
+
getLogger().warn(
|
|
1796
|
+
`Notion API \uC7AC\uC2DC\uB3C4 ${attempt + 1}/${this.maxRetries} \u2014 ${Math.round(backoffMs)}ms \uB300\uAE30 (${describeRetryCause(error)})`
|
|
1797
|
+
);
|
|
1798
|
+
} finally {
|
|
1799
|
+
this.sema.release();
|
|
1751
1800
|
}
|
|
1752
|
-
|
|
1753
|
-
return await this.executeWithRetry(fn);
|
|
1754
|
-
} finally {
|
|
1755
|
-
this.sema.release();
|
|
1801
|
+
await sleep(backoffMs);
|
|
1756
1802
|
}
|
|
1757
1803
|
}
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
const jitter = baseDelay * (0.5 + Math.random() * 0.5);
|
|
1765
|
-
await sleep(jitter);
|
|
1766
|
-
return this.executeWithRetry(fn, attempt + 1);
|
|
1767
|
-
}
|
|
1768
|
-
throw error;
|
|
1769
|
-
}
|
|
1804
|
+
/** 슬롯을 쥔 뒤의 발사 대기 — 전역 쿨다운(429)과 최소 요청 간격을 함께 지킨다. */
|
|
1805
|
+
async waitForTurn() {
|
|
1806
|
+
const cooldownMs = this.cooldownUntil - Date.now();
|
|
1807
|
+
if (cooldownMs > 0) await sleep(cooldownMs);
|
|
1808
|
+
const elapsed = Date.now() - this.lastRequestTime;
|
|
1809
|
+
if (elapsed < this.minRequestInterval) await sleep(this.minRequestInterval - elapsed);
|
|
1770
1810
|
}
|
|
1771
1811
|
};
|
|
1812
|
+
function extractPropertyChoices(prop) {
|
|
1813
|
+
const type = prop.type;
|
|
1814
|
+
if (type !== "select" && type !== "multi_select" && type !== "status") return {};
|
|
1815
|
+
const typeData = prop[type];
|
|
1816
|
+
if (!typeData) return {};
|
|
1817
|
+
const result = {};
|
|
1818
|
+
if (typeData.options) {
|
|
1819
|
+
result.options = typeData.options.map((o) => ({ id: o.id, name: o.name, color: o.color }));
|
|
1820
|
+
}
|
|
1821
|
+
if (typeData.groups) {
|
|
1822
|
+
result.groups = typeData.groups.map((g) => ({
|
|
1823
|
+
name: g.name,
|
|
1824
|
+
color: g.color,
|
|
1825
|
+
optionIds: g.option_ids
|
|
1826
|
+
}));
|
|
1827
|
+
}
|
|
1828
|
+
return result;
|
|
1829
|
+
}
|
|
1830
|
+
function isRateLimited(error) {
|
|
1831
|
+
return typeof error === "object" && error !== null && error.status === 429;
|
|
1832
|
+
}
|
|
1772
1833
|
function isRetryable(error) {
|
|
1773
1834
|
if (typeof error !== "object" || error === null) return false;
|
|
1774
1835
|
const code = error.code;
|
|
@@ -1779,14 +1840,44 @@ function isRetryable(error) {
|
|
|
1779
1840
|
const status = error.status;
|
|
1780
1841
|
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
1781
1842
|
}
|
|
1782
|
-
function
|
|
1783
|
-
if (typeof error
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1843
|
+
function describeRetryCause(error) {
|
|
1844
|
+
if (typeof error !== "object" || error === null) return String(error);
|
|
1845
|
+
const e = error;
|
|
1846
|
+
const parts = [];
|
|
1847
|
+
if (typeof e.status === "number") parts.push(`status ${e.status}`);
|
|
1848
|
+
if (typeof e.code === "string") parts.push(e.code);
|
|
1849
|
+
if (typeof e.message === "string" && e.message) parts.push(e.message.slice(0, 120));
|
|
1850
|
+
return parts.length > 0 ? parts.join(" \xB7 ") : "\uC54C \uC218 \uC5C6\uB294 \uC624\uB958";
|
|
1851
|
+
}
|
|
1852
|
+
var MAX_RETRY_AFTER_MS = 6e4;
|
|
1853
|
+
function readHeader(headers, name) {
|
|
1854
|
+
if (typeof headers !== "object" || headers === null) return null;
|
|
1855
|
+
const get = headers.get;
|
|
1856
|
+
if (typeof get === "function") {
|
|
1857
|
+
const value = get.call(headers, name);
|
|
1858
|
+
return typeof value === "string" && value ? value : null;
|
|
1859
|
+
}
|
|
1860
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
1861
|
+
if (key.toLowerCase() !== name) continue;
|
|
1862
|
+
return typeof value === "string" && value ? value : null;
|
|
1787
1863
|
}
|
|
1788
1864
|
return null;
|
|
1789
1865
|
}
|
|
1866
|
+
function extractRetryAfter(error) {
|
|
1867
|
+
if (typeof error !== "object" || error === null || !("headers" in error)) return null;
|
|
1868
|
+
const raw = readHeader(error.headers, "retry-after");
|
|
1869
|
+
if (!raw) return null;
|
|
1870
|
+
const seconds = Number(raw);
|
|
1871
|
+
let ms;
|
|
1872
|
+
if (Number.isFinite(seconds)) {
|
|
1873
|
+
ms = seconds * 1e3;
|
|
1874
|
+
} else {
|
|
1875
|
+
const at = Date.parse(raw);
|
|
1876
|
+
ms = Number.isNaN(at) ? Number.NaN : at - Date.now();
|
|
1877
|
+
}
|
|
1878
|
+
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
1879
|
+
return Math.min(ms, MAX_RETRY_AFTER_MS);
|
|
1880
|
+
}
|
|
1790
1881
|
function sleep(ms) {
|
|
1791
1882
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1792
1883
|
}
|
|
@@ -2320,10 +2411,33 @@ function compactMarker(body) {
|
|
|
2320
2411
|
}
|
|
2321
2412
|
var TOGGLE_START = compactMarker("toggle:start");
|
|
2322
2413
|
var TOGGLE_END = compactMarker("toggle:end");
|
|
2414
|
+
var TOGGLE_HEADING_START = compactMarker("toggle-heading");
|
|
2415
|
+
var TOGGLE_HEADING_END = compactMarker("toggle-heading:end");
|
|
2416
|
+
function calloutIndentMarker(depth) {
|
|
2417
|
+
return compactMarker(`callout-indent:${depth}`);
|
|
2418
|
+
}
|
|
2323
2419
|
var COLUMN_LIST_START = compactMarker("column-list:start");
|
|
2324
2420
|
var COLUMN_LIST_END = compactMarker("column-list:end");
|
|
2325
2421
|
var COLUMN_SEP = compactMarker("column");
|
|
2422
|
+
function columnMarker(ratio) {
|
|
2423
|
+
return ratio ? compactMarker(`column:ratio=${ratio}`) : COLUMN_SEP;
|
|
2424
|
+
}
|
|
2425
|
+
function columnSepSource(ratio = "capture") {
|
|
2426
|
+
const value = ratio === "capture" ? "([\\d.]+)" : "(?:[\\d.]+)";
|
|
2427
|
+
return `%%${MARKER_BRAND}:column(?::ratio=${value})?%%`;
|
|
2428
|
+
}
|
|
2429
|
+
var COLUMN_SEP_SOURCE = columnSepSource("capture");
|
|
2326
2430
|
var TOC_MARKER = compactMarker("toc");
|
|
2431
|
+
function tocMarker(color) {
|
|
2432
|
+
return !color || color === "default" ? TOC_MARKER : compactMarker(`toc:color=${color}`);
|
|
2433
|
+
}
|
|
2434
|
+
function embedMarker(src) {
|
|
2435
|
+
return compactMarker(`embed:src=${encodeURIComponent(src)}`);
|
|
2436
|
+
}
|
|
2437
|
+
function unknownMentionMarker(url, alt) {
|
|
2438
|
+
const tail = alt ? `&alt=${encodeURIComponent(alt)}` : "";
|
|
2439
|
+
return compactMarker(`unknown-mention:url=${encodeURIComponent(url)}${tail}`);
|
|
2440
|
+
}
|
|
2327
2441
|
function syncedStartMarker(kind, url) {
|
|
2328
2442
|
return compactMarker(`synced:start:kind=${kind}&url=${encodeURIComponent(url)}`);
|
|
2329
2443
|
}
|
|
@@ -2342,7 +2456,13 @@ function blockColorMarker(color) {
|
|
|
2342
2456
|
return compactMarker(`block-color:${color}`);
|
|
2343
2457
|
}
|
|
2344
2458
|
var PROPERTIES_TAG = `# ${MARKER_BRAND}:properties`;
|
|
2459
|
+
var MARKER_PAYLOAD_CHAR = "(?:[^\\n%]|%(?!%))";
|
|
2460
|
+
var MEDIA_PLACEHOLDER_HEAD = `>\\s*\u{1F4CE}\\s*${MARKER_PAYLOAD_CHAR}*(?:\\n>)?\\s*%%\\s*${MARKER_BRAND}:local-(?:image|file):`;
|
|
2345
2461
|
var MARKER_BRAND_RE = MARKER_BRAND;
|
|
2462
|
+
var MARKER_TOKEN_RE = new RegExp(
|
|
2463
|
+
`%%\\s*(?:${MARKER_BRAND_RE}:${MARKER_PAYLOAD_CHAR}*|/[A-Za-z][\\w-]*)\\s*%%`,
|
|
2464
|
+
"g"
|
|
2465
|
+
);
|
|
2346
2466
|
|
|
2347
2467
|
// src/converter/pipeline.ts
|
|
2348
2468
|
var INLINE_DB_PROBE = new RegExp(`%% ${MARKER_BRAND_RE}:inline-db`);
|
|
@@ -2444,10 +2564,12 @@ function mapOutsideCodeFences(content, fn) {
|
|
|
2444
2564
|
const lines = content.split("\n");
|
|
2445
2565
|
const out = [];
|
|
2446
2566
|
let buffer = [];
|
|
2567
|
+
let bufferStart = 0;
|
|
2568
|
+
let pos = 0;
|
|
2447
2569
|
let fence = null;
|
|
2448
2570
|
const flush = () => {
|
|
2449
2571
|
if (buffer.length > 0) {
|
|
2450
|
-
out.push(fn(buffer.join("\n")));
|
|
2572
|
+
out.push(fn(buffer.join("\n"), bufferStart));
|
|
2451
2573
|
buffer = [];
|
|
2452
2574
|
}
|
|
2453
2575
|
};
|
|
@@ -2457,23 +2579,78 @@ function mapOutsideCodeFences(content, fn) {
|
|
|
2457
2579
|
flush();
|
|
2458
2580
|
fence = fenceMatch[1][0].repeat(3);
|
|
2459
2581
|
out.push(line);
|
|
2460
|
-
|
|
2461
|
-
}
|
|
2462
|
-
if (fence !== null) {
|
|
2582
|
+
} else if (fence !== null) {
|
|
2463
2583
|
out.push(line);
|
|
2464
2584
|
if (fenceMatch && fenceMatch[1].startsWith(fence)) {
|
|
2465
2585
|
fence = null;
|
|
2466
2586
|
}
|
|
2467
|
-
|
|
2587
|
+
} else {
|
|
2588
|
+
if (buffer.length === 0) bufferStart = pos;
|
|
2589
|
+
buffer.push(line);
|
|
2468
2590
|
}
|
|
2469
|
-
|
|
2591
|
+
pos += line.length + 1;
|
|
2470
2592
|
}
|
|
2471
2593
|
flush();
|
|
2472
2594
|
return out.join("\n");
|
|
2473
2595
|
}
|
|
2596
|
+
function mapOutsideInlineCode(content, fn) {
|
|
2597
|
+
let out = "";
|
|
2598
|
+
let plainStart = 0;
|
|
2599
|
+
let i = 0;
|
|
2600
|
+
while (i < content.length) {
|
|
2601
|
+
if (content[i] !== "`") {
|
|
2602
|
+
i += 1;
|
|
2603
|
+
continue;
|
|
2604
|
+
}
|
|
2605
|
+
let run = 1;
|
|
2606
|
+
while (content[i + run] === "`") run += 1;
|
|
2607
|
+
const close = content.indexOf("`".repeat(run), i + run);
|
|
2608
|
+
if (close === -1) {
|
|
2609
|
+
i += run;
|
|
2610
|
+
continue;
|
|
2611
|
+
}
|
|
2612
|
+
out += fn(content.slice(plainStart, i), plainStart);
|
|
2613
|
+
out += content.slice(i, close + run);
|
|
2614
|
+
i = close + run;
|
|
2615
|
+
plainStart = i;
|
|
2616
|
+
}
|
|
2617
|
+
return out + fn(content.slice(plainStart), plainStart);
|
|
2618
|
+
}
|
|
2619
|
+
function mapOutsideCode(content, fn) {
|
|
2620
|
+
return mapOutsideCodeFences(
|
|
2621
|
+
content,
|
|
2622
|
+
(segment, base) => mapOutsideInlineCode(segment, (text, inner) => fn(text, base + inner))
|
|
2623
|
+
);
|
|
2624
|
+
}
|
|
2625
|
+
function mapOutsideMarkers(content, fn) {
|
|
2626
|
+
const re = new RegExp(MARKER_TOKEN_RE.source, "g");
|
|
2627
|
+
let out = "";
|
|
2628
|
+
let last = 0;
|
|
2629
|
+
let match;
|
|
2630
|
+
while ((match = re.exec(content)) !== null) {
|
|
2631
|
+
out += fn(content.slice(last, match.index), last);
|
|
2632
|
+
out += match[0];
|
|
2633
|
+
last = match.index + match[0].length;
|
|
2634
|
+
}
|
|
2635
|
+
return out + fn(content.slice(last), last);
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
// src/converter/marker-url.ts
|
|
2639
|
+
function encodeMarkerTarget(target) {
|
|
2640
|
+
return encodeURIComponent(target).replace(/\(/g, "%28").replace(/\)/g, "%29");
|
|
2641
|
+
}
|
|
2642
|
+
function decodeMarkerTarget(encoded) {
|
|
2643
|
+
try {
|
|
2644
|
+
return decodeURIComponent(encoded);
|
|
2645
|
+
} catch {
|
|
2646
|
+
return encoded;
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
var MARKER_URL_CAPTURE = "((?:[^()\\s]|\\([^()]*\\))+)";
|
|
2650
|
+
var MARKER_LABEL_CAPTURE = "([^[\\]]+)";
|
|
2474
2651
|
|
|
2475
2652
|
// src/converter/pre-processors/wikilink.ts
|
|
2476
|
-
var WIKILINK_REGEX2 = /(?<!!)\[\[([
|
|
2653
|
+
var WIKILINK_REGEX2 = /(?<!!)\[\[([^[\]|]+)(?:\|([^[\]]+))?\]\]/g;
|
|
2477
2654
|
var WikilinkResolver = class {
|
|
2478
2655
|
constructor(resolve) {
|
|
2479
2656
|
this.resolve = resolve;
|
|
@@ -2486,26 +2663,31 @@ var WikilinkResolver = class {
|
|
|
2486
2663
|
return { content: input.content, metadata: input.metadata };
|
|
2487
2664
|
}
|
|
2488
2665
|
const preserveMarkers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
|
|
2489
|
-
const resolved =
|
|
2490
|
-
|
|
2491
|
-
(
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
const
|
|
2495
|
-
if (
|
|
2496
|
-
|
|
2666
|
+
const resolved = mapOutsideCode(
|
|
2667
|
+
input.content,
|
|
2668
|
+
(segment, base) => segment.replace(
|
|
2669
|
+
WIKILINK_REGEX2,
|
|
2670
|
+
(_match, target, display, offset) => {
|
|
2671
|
+
const label = display ?? target;
|
|
2672
|
+
if (this.resolve) {
|
|
2673
|
+
const entry = this.resolve(target);
|
|
2674
|
+
if (entry) {
|
|
2675
|
+
if (display !== void 0 && display !== target) {
|
|
2676
|
+
return `[${label}](https://www.notion.so/${entry.notionPageId.replace(/-/g, "")})`;
|
|
2677
|
+
}
|
|
2678
|
+
return `<mention-page id="${entry.notionPageId}">${label}</mention-page>`;
|
|
2679
|
+
}
|
|
2497
2680
|
}
|
|
2681
|
+
const params = display ? { text: target, display } : { text: target };
|
|
2682
|
+
params.__anchor = computeAnchor(input.content, base + offset);
|
|
2683
|
+
preserveMarkers.push({
|
|
2684
|
+
type: "wikilink",
|
|
2685
|
+
params,
|
|
2686
|
+
startIndex: base + offset
|
|
2687
|
+
});
|
|
2688
|
+
return `[${label}](${WIKILINK_PROTOCOL}${encodeMarkerTarget(target)})`;
|
|
2498
2689
|
}
|
|
2499
|
-
|
|
2500
|
-
params.__anchor = computeAnchor(input.content, offset);
|
|
2501
|
-
preserveMarkers.push({
|
|
2502
|
-
type: "wikilink",
|
|
2503
|
-
params,
|
|
2504
|
-
startIndex: offset
|
|
2505
|
-
});
|
|
2506
|
-
const encodedTarget = encodeURIComponent(target);
|
|
2507
|
-
return `[${label}](${WIKILINK_PROTOCOL}${encodedTarget})`;
|
|
2508
|
-
}
|
|
2690
|
+
)
|
|
2509
2691
|
);
|
|
2510
2692
|
return {
|
|
2511
2693
|
content: resolved,
|
|
@@ -2587,7 +2769,8 @@ $$`;
|
|
|
2587
2769
|
};
|
|
2588
2770
|
|
|
2589
2771
|
// src/converter/pre-processors/embed.ts
|
|
2590
|
-
var OBSIDIAN_EMBED_REGEX =
|
|
2772
|
+
var OBSIDIAN_EMBED_REGEX = /((?:^[ \t]*(?:>[ \t]*)+)?[ \t]*)!\[\[([^[\]]+)\]\]([ \t]*)/gm;
|
|
2773
|
+
var QUOTE_LEAD = /^[ \t]*>/;
|
|
2591
2774
|
var MARKDOWN_IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
2592
2775
|
var VIDEO_HOSTS = ["youtube.com", "youtu.be", "vimeo.com"];
|
|
2593
2776
|
var EmbedResolver = class {
|
|
@@ -2596,24 +2779,23 @@ var EmbedResolver = class {
|
|
|
2596
2779
|
process(input) {
|
|
2597
2780
|
const images = input.metadata.images ? [...input.metadata.images] : [];
|
|
2598
2781
|
const isPush = input.context.direction === "push";
|
|
2599
|
-
let content = input.content.replace(
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2782
|
+
let content = input.content.replace(
|
|
2783
|
+
OBSIDIAN_EMBED_REGEX,
|
|
2784
|
+
(match, lead, target, trail, offset, whole) => {
|
|
2785
|
+
const span = { offset, length: match.length, whole, lead};
|
|
2786
|
+
if (isImageFile(target)) {
|
|
2787
|
+
images.push({ url: target, localPath: stripAlias(target), isExternal: false });
|
|
2788
|
+
if (isPush && !isExternalUrl(target)) {
|
|
2789
|
+
return isolate(placeholder("local-image", target), span);
|
|
2790
|
+
}
|
|
2791
|
+
return `${lead}})${trail}`;
|
|
2606
2792
|
}
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
return `> \u{1F4CE} ${fileName}
|
|
2612
|
-
> ${spacedMarker(`local-file:${target}`)}`;
|
|
2793
|
+
if (isPush && !isExternalUrl(target) && isAttachmentFile(target)) {
|
|
2794
|
+
return isolate(placeholder("local-file", target), span);
|
|
2795
|
+
}
|
|
2796
|
+
return match;
|
|
2613
2797
|
}
|
|
2614
|
-
|
|
2615
|
-
return `[${target}](${EMBED_PROTOCOL}${encoded})`;
|
|
2616
|
-
});
|
|
2798
|
+
);
|
|
2617
2799
|
content = content.replace(MARKDOWN_IMAGE_REGEX, (_match, alt, url) => {
|
|
2618
2800
|
if (isVideoUrl(url)) {
|
|
2619
2801
|
return `${spacedMarker(`embed:type=video&url=${encodeURIComponent(url)}`)}
|
|
@@ -2630,8 +2812,26 @@ var EmbedResolver = class {
|
|
|
2630
2812
|
};
|
|
2631
2813
|
}
|
|
2632
2814
|
};
|
|
2815
|
+
function stripAlias(target) {
|
|
2816
|
+
return target.split("|")[0];
|
|
2817
|
+
}
|
|
2818
|
+
function placeholder(kind, target) {
|
|
2819
|
+
const fileName = stripAlias(target).split("/").pop() ?? target;
|
|
2820
|
+
return `> \u{1F4CE} ${fileName} ${spacedMarker(`${kind}:${encodeMarkerTarget(target)}`)}`;
|
|
2821
|
+
}
|
|
2822
|
+
function isolate(text, span) {
|
|
2823
|
+
const { offset, length, whole } = span;
|
|
2824
|
+
const beforeOnLine = whole.slice(whole.lastIndexOf("\n", offset - 1) + 1, offset);
|
|
2825
|
+
const afterIdx = offset + length;
|
|
2826
|
+
const nl = whole.indexOf("\n", afterIdx);
|
|
2827
|
+
const afterOnLine = whole.slice(afterIdx, nl === -1 ? whole.length : nl);
|
|
2828
|
+
const tail = afterOnLine.trim() === "" ? "" : "\n\n";
|
|
2829
|
+
if (QUOTE_LEAD.test(span.lead)) return `${offset === 0 ? "" : "\n"}${text}${tail}`;
|
|
2830
|
+
const head = beforeOnLine.trim() === "" ? span.lead : "\n\n";
|
|
2831
|
+
return `${head}${text}${tail}`;
|
|
2832
|
+
}
|
|
2633
2833
|
function isImageFile(path) {
|
|
2634
|
-
return /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(path);
|
|
2834
|
+
return /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(stripAlias(path));
|
|
2635
2835
|
}
|
|
2636
2836
|
function isAttachmentFile(target) {
|
|
2637
2837
|
const path = target.split("|")[0];
|
|
@@ -2800,20 +3000,20 @@ var CommentStripper = class {
|
|
|
2800
3000
|
}
|
|
2801
3001
|
const markers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
|
|
2802
3002
|
const content = mapOutsideCodeFences(input.content, (segment) => {
|
|
2803
|
-
const afterObsidian =
|
|
2804
|
-
|
|
2805
|
-
(match, body, offset) => {
|
|
3003
|
+
const afterObsidian = mapOutsideMarkers(
|
|
3004
|
+
segment,
|
|
3005
|
+
(plain, base) => plain.replace(COMMENT_REGEX, (match, body, offset) => {
|
|
2806
3006
|
const trimmed = body.trimStart();
|
|
2807
3007
|
if (trimmed.startsWith(`${MARKER_BRAND}:`) || trimmed.startsWith("/")) {
|
|
2808
3008
|
return match;
|
|
2809
3009
|
}
|
|
2810
3010
|
markers.push({
|
|
2811
3011
|
type: "comment",
|
|
2812
|
-
params: { text: body, __anchor: computeAnchor(segment, offset) },
|
|
2813
|
-
startIndex: offset
|
|
3012
|
+
params: { text: body, __anchor: computeAnchor(segment, base + offset) },
|
|
3013
|
+
startIndex: base + offset
|
|
2814
3014
|
});
|
|
2815
3015
|
return "";
|
|
2816
|
-
}
|
|
3016
|
+
})
|
|
2817
3017
|
);
|
|
2818
3018
|
return afterObsidian.replace(HTML_COMMENT_REGEX, (_match, body, offset) => {
|
|
2819
3019
|
markers.push({
|
|
@@ -2917,19 +3117,34 @@ var TableAlignmentGuard = class {
|
|
|
2917
3117
|
}
|
|
2918
3118
|
};
|
|
2919
3119
|
|
|
3120
|
+
// src/utils/wikilink-title.ts
|
|
3121
|
+
function wikilinkTitleFromPath(obsidianPath) {
|
|
3122
|
+
return obsidianPath.replace(/\.md$/i, "").split("/").pop() ?? "";
|
|
3123
|
+
}
|
|
3124
|
+
function formatWikilink(target, label) {
|
|
3125
|
+
return label === void 0 || label === target ? `[[${target}]]` : `[[${target}|${label}]]`;
|
|
3126
|
+
}
|
|
3127
|
+
|
|
2920
3128
|
// src/converter/link-restore.ts
|
|
2921
|
-
var NOTION_LINK_REGEX =
|
|
2922
|
-
|
|
2923
|
-
|
|
3129
|
+
var NOTION_LINK_REGEX = new RegExp(
|
|
3130
|
+
`\\[${MARKER_LABEL_CAPTURE}\\]\\((?:https:\\/\\/(?:www\\.)?notion\\.so\\/|notion:\\/\\/)([a-f0-9-]+)\\)`,
|
|
3131
|
+
"g"
|
|
3132
|
+
);
|
|
3133
|
+
var WIKILINK_PRESERVE_REGEX = new RegExp(
|
|
3134
|
+
`\\[${MARKER_LABEL_CAPTURE}\\]\\(${WIKILINK_PROTOCOL}${MARKER_URL_CAPTURE}\\)`,
|
|
3135
|
+
"g"
|
|
3136
|
+
);
|
|
3137
|
+
var EMBED_PRESERVE_REGEX = new RegExp(
|
|
3138
|
+
`\\[${MARKER_LABEL_CAPTURE}\\]\\(${EMBED_PROTOCOL}${MARKER_URL_CAPTURE}\\)`,
|
|
3139
|
+
"g"
|
|
3140
|
+
);
|
|
2924
3141
|
function restoreLinkMarkers(text) {
|
|
2925
3142
|
let out = text.replace(NOTION_LINK_REGEX, (_match, display) => `[[${display}]]`);
|
|
2926
3143
|
out = out.replace(WIKILINK_PRESERVE_REGEX, (_match, display, encodedTarget) => {
|
|
2927
|
-
|
|
2928
|
-
return target === display ? `[[${target}]]` : `[[${target}|${display}]]`;
|
|
3144
|
+
return formatWikilink(decodeMarkerTarget(encodedTarget), display);
|
|
2929
3145
|
});
|
|
2930
3146
|
out = out.replace(EMBED_PRESERVE_REGEX, (_match, _display, encodedTarget) => {
|
|
2931
|
-
|
|
2932
|
-
return `![[${target}]]`;
|
|
3147
|
+
return `![[${decodeMarkerTarget(encodedTarget)}]]`;
|
|
2933
3148
|
});
|
|
2934
3149
|
return out;
|
|
2935
3150
|
}
|
|
@@ -3096,7 +3311,8 @@ var PreserveMarkerInjector = class {
|
|
|
3096
3311
|
}
|
|
3097
3312
|
case "local-image":
|
|
3098
3313
|
case "local-file": {
|
|
3099
|
-
const
|
|
3314
|
+
const raw = params.__raw ?? params.path;
|
|
3315
|
+
const target = raw !== void 0 ? decodeMarkerTarget(raw) : void 0;
|
|
3100
3316
|
return target ? content.includes(`![[${target}]]`) : false;
|
|
3101
3317
|
}
|
|
3102
3318
|
case "table-align":
|
|
@@ -3177,8 +3393,8 @@ var CalloutRestorer = class {
|
|
|
3177
3393
|
};
|
|
3178
3394
|
}
|
|
3179
3395
|
};
|
|
3180
|
-
function escapeRegex(
|
|
3181
|
-
return
|
|
3396
|
+
function escapeRegex(str2) {
|
|
3397
|
+
return str2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3182
3398
|
}
|
|
3183
3399
|
|
|
3184
3400
|
// src/converter/post-processors/color-annotator.ts
|
|
@@ -3265,7 +3481,7 @@ function normalizeDate(dateStr) {
|
|
|
3265
3481
|
|
|
3266
3482
|
// src/converter/post-processors/local-image-restorer.ts
|
|
3267
3483
|
var LOCAL_IMAGE_MARKER_REGEX = new RegExp(
|
|
3268
|
-
|
|
3484
|
+
`${MEDIA_PLACEHOLDER_HEAD}(${MARKER_PAYLOAD_CHAR}+?)\\s*%%`,
|
|
3269
3485
|
"g"
|
|
3270
3486
|
);
|
|
3271
3487
|
var LocalImageRestorer = class {
|
|
@@ -3276,7 +3492,7 @@ var LocalImageRestorer = class {
|
|
|
3276
3492
|
return { content: input.content, metadata: input.metadata };
|
|
3277
3493
|
}
|
|
3278
3494
|
const content = input.content.replace(LOCAL_IMAGE_MARKER_REGEX, (_match, target) => {
|
|
3279
|
-
return `![[${target}]]`;
|
|
3495
|
+
return `![[${decodeMarkerTarget(target)}]]`;
|
|
3280
3496
|
});
|
|
3281
3497
|
return { content, metadata: input.metadata };
|
|
3282
3498
|
}
|
|
@@ -3377,6 +3593,229 @@ function stripQuotes(s) {
|
|
|
3377
3593
|
return s;
|
|
3378
3594
|
}
|
|
3379
3595
|
|
|
3596
|
+
// src/converter/container-indent.ts
|
|
3597
|
+
var CONTAINER_PREFIX_SOURCE = "[\\t ]*(?:>[\\t ]*)*";
|
|
3598
|
+
var CONTAINER_PREFIX_RE = new RegExp(`^${CONTAINER_PREFIX_SOURCE}`);
|
|
3599
|
+
function nfmOpenTagSource(name, attrs = "ignore") {
|
|
3600
|
+
return `<${name}${attrs === "capture" ? "(\\s[^>]*|)" : "(?:\\s[^>]*)?"}>`;
|
|
3601
|
+
}
|
|
3602
|
+
function splitContainerPrefix(line) {
|
|
3603
|
+
const prefix = CONTAINER_PREFIX_RE.exec(line)?.[0] ?? "";
|
|
3604
|
+
return { prefix, body: line.slice(prefix.length) };
|
|
3605
|
+
}
|
|
3606
|
+
function dedentContainerBody(text) {
|
|
3607
|
+
const lines = text.split("\n");
|
|
3608
|
+
while (lines.length && lines[0].trim() === "") lines.shift();
|
|
3609
|
+
while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
|
|
3610
|
+
const blocks = scanContainerBlocks(lines);
|
|
3611
|
+
const kinds = kindsOf(lines, blocks);
|
|
3612
|
+
const widened = widenAmbiguousFences(lines, blocks);
|
|
3613
|
+
let min = Infinity;
|
|
3614
|
+
lines.forEach((l, i) => {
|
|
3615
|
+
if (kinds[i] !== "prose" || l.trim() === "") return;
|
|
3616
|
+
min = Math.min(min, /^[\t ]*/.exec(l)[0].length);
|
|
3617
|
+
});
|
|
3618
|
+
if (!Number.isFinite(min)) min = 0;
|
|
3619
|
+
return lines.map((l, i) => {
|
|
3620
|
+
if (l.trim() === "") return "";
|
|
3621
|
+
if (kinds[i] === "code") return l;
|
|
3622
|
+
if (kinds[i] === "fence") {
|
|
3623
|
+
const flat = l.replace(/^[\t ]+/, "");
|
|
3624
|
+
const bar = widened.get(i);
|
|
3625
|
+
return bar === void 0 ? flat : flat.replace(/`{3,}|~{3,}/, bar);
|
|
3626
|
+
}
|
|
3627
|
+
return l.slice(min);
|
|
3628
|
+
}).join("\n");
|
|
3629
|
+
}
|
|
3630
|
+
function indentContainerBody(text, indent = " ") {
|
|
3631
|
+
const lines = text.split("\n");
|
|
3632
|
+
const kinds = classifyContainerLines(lines);
|
|
3633
|
+
return lines.map((line, i) => line === "" || kinds[i] === "code" ? line : indent + line).join("\n");
|
|
3634
|
+
}
|
|
3635
|
+
function stripContainerIndent(text, indent) {
|
|
3636
|
+
if (indent === "") return text;
|
|
3637
|
+
return text.split("\n").map((line) => line.startsWith(indent) ? line.slice(indent.length) : line).join("\n");
|
|
3638
|
+
}
|
|
3639
|
+
var TABLE_OPEN_LINE_RE = new RegExp(`^[\\t ]*${nfmOpenTagSource("table")}`);
|
|
3640
|
+
var FENCE_LINE_RE = /^([\t ]*)(`{3,}|~{3,})(.*)$/;
|
|
3641
|
+
function matchFence(line) {
|
|
3642
|
+
const m = FENCE_LINE_RE.exec(line);
|
|
3643
|
+
return m ? { indent: m[1], char: m[2][0], len: m[2].length, info: m[3] } : null;
|
|
3644
|
+
}
|
|
3645
|
+
function findFenceClose(lines, start, open) {
|
|
3646
|
+
let loose = null;
|
|
3647
|
+
for (let j = start + 1; j < lines.length; j++) {
|
|
3648
|
+
const f = matchFence(lines[j]);
|
|
3649
|
+
if (!f || f.char !== open.char || f.len < open.len || f.info.trim() !== "") continue;
|
|
3650
|
+
if (f.indent === open.indent) return j;
|
|
3651
|
+
loose ??= j;
|
|
3652
|
+
}
|
|
3653
|
+
return loose;
|
|
3654
|
+
}
|
|
3655
|
+
function scanContainerBlocks(lines) {
|
|
3656
|
+
const blocks = [];
|
|
3657
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3658
|
+
const fence = matchFence(lines[i]);
|
|
3659
|
+
if (fence) {
|
|
3660
|
+
const close2 = findFenceClose(lines, i, fence);
|
|
3661
|
+
blocks.push({ type: "code", open: i, close: close2, fence });
|
|
3662
|
+
i = close2 ?? lines.length;
|
|
3663
|
+
continue;
|
|
3664
|
+
}
|
|
3665
|
+
if (!TABLE_OPEN_LINE_RE.test(lines[i])) continue;
|
|
3666
|
+
if (/<\/table>/.test(lines[i])) {
|
|
3667
|
+
blocks.push({ type: "table", open: i, close: i });
|
|
3668
|
+
continue;
|
|
3669
|
+
}
|
|
3670
|
+
let close = null;
|
|
3671
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
3672
|
+
if (!/<\/table>/.test(lines[j])) continue;
|
|
3673
|
+
close = j;
|
|
3674
|
+
break;
|
|
3675
|
+
}
|
|
3676
|
+
blocks.push({ type: "table", open: i, close });
|
|
3677
|
+
i = close ?? lines.length;
|
|
3678
|
+
}
|
|
3679
|
+
return blocks;
|
|
3680
|
+
}
|
|
3681
|
+
function kindsOf(lines, blocks) {
|
|
3682
|
+
const kinds = lines.map(() => "prose");
|
|
3683
|
+
for (const b of blocks) {
|
|
3684
|
+
kinds[b.open] = "fence";
|
|
3685
|
+
const end = b.close ?? lines.length;
|
|
3686
|
+
for (let j = b.open + 1; j < end; j++) kinds[j] = "code";
|
|
3687
|
+
if (b.close !== null && b.close !== b.open) kinds[b.close] = "fence";
|
|
3688
|
+
}
|
|
3689
|
+
return kinds;
|
|
3690
|
+
}
|
|
3691
|
+
function widenAmbiguousFences(lines, blocks) {
|
|
3692
|
+
const widened = /* @__PURE__ */ new Map();
|
|
3693
|
+
for (const b of blocks) {
|
|
3694
|
+
if (b.type !== "code" || !b.fence) continue;
|
|
3695
|
+
const end = b.close ?? lines.length;
|
|
3696
|
+
let longest = 0;
|
|
3697
|
+
for (let j = b.open + 1; j < end; j++) {
|
|
3698
|
+
const f = matchFence(lines[j]);
|
|
3699
|
+
if (f?.char === b.fence.char) longest = Math.max(longest, f.len);
|
|
3700
|
+
}
|
|
3701
|
+
if (longest < b.fence.len) continue;
|
|
3702
|
+
const bar = b.fence.char.repeat(longest + 1);
|
|
3703
|
+
widened.set(b.open, bar);
|
|
3704
|
+
if (b.close !== null) widened.set(b.close, bar);
|
|
3705
|
+
}
|
|
3706
|
+
return widened;
|
|
3707
|
+
}
|
|
3708
|
+
function codeInteriorRanges(text) {
|
|
3709
|
+
const lines = text.split("\n");
|
|
3710
|
+
const kinds = classifyContainerLines(lines.map((l) => splitContainerPrefix(l).body));
|
|
3711
|
+
const ranges = [];
|
|
3712
|
+
let offset = 0;
|
|
3713
|
+
let start = null;
|
|
3714
|
+
lines.forEach((line, i) => {
|
|
3715
|
+
if (kinds[i] === "code" && start === null) start = offset;
|
|
3716
|
+
else if (kinds[i] !== "code" && start !== null) {
|
|
3717
|
+
ranges.push([start, offset]);
|
|
3718
|
+
start = null;
|
|
3719
|
+
}
|
|
3720
|
+
offset += line.length + 1;
|
|
3721
|
+
});
|
|
3722
|
+
if (start !== null) ranges.push([start, offset]);
|
|
3723
|
+
return ranges;
|
|
3724
|
+
}
|
|
3725
|
+
function isInsideRanges(ranges, offset) {
|
|
3726
|
+
return ranges.some(([start, end]) => offset >= start && offset < end);
|
|
3727
|
+
}
|
|
3728
|
+
function classifyContainerLines(lines) {
|
|
3729
|
+
return kindsOf(lines, scanContainerBlocks(lines));
|
|
3730
|
+
}
|
|
3731
|
+
function containerBlockEnds(lines) {
|
|
3732
|
+
const ends = lines.map((_, i) => i);
|
|
3733
|
+
for (const b of scanContainerBlocks(lines)) {
|
|
3734
|
+
const end = b.close ?? lines.length - 1;
|
|
3735
|
+
for (let j = b.open; j <= end; j++) ends[j] = end;
|
|
3736
|
+
}
|
|
3737
|
+
return ends;
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
// src/converter/callout-indent.ts
|
|
3741
|
+
var CLAMPED_INDENT = " ";
|
|
3742
|
+
var CODE_INDENT_WIDTH = 4;
|
|
3743
|
+
var INDENTED_QUOTE_RE = /^([\t ]+)>/;
|
|
3744
|
+
var CALLOUT_HEAD_RE = /^> \[!\w+\][-+]?/;
|
|
3745
|
+
var CALLOUT_INDENT_MARKER_RE = new RegExp(`\\s*%%${MARKER_BRAND_RE}:callout-indent:(\\d+)%%`);
|
|
3746
|
+
function visualWidth(ws) {
|
|
3747
|
+
let width = 0;
|
|
3748
|
+
for (const ch of ws)
|
|
3749
|
+
width = ch === " " ? width + CODE_INDENT_WIDTH - width % CODE_INDENT_WIDTH : width + 1;
|
|
3750
|
+
return width;
|
|
3751
|
+
}
|
|
3752
|
+
function clampCalloutIndent(content) {
|
|
3753
|
+
const lines = content.split("\n");
|
|
3754
|
+
const kinds = classifyContainerLines(lines);
|
|
3755
|
+
const out = [];
|
|
3756
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3757
|
+
const indent = kinds[i] === "prose" ? INDENTED_QUOTE_RE.exec(lines[i])?.[1] ?? null : null;
|
|
3758
|
+
if (indent === null || visualWidth(indent) < CODE_INDENT_WIDTH) {
|
|
3759
|
+
out.push(lines[i]);
|
|
3760
|
+
continue;
|
|
3761
|
+
}
|
|
3762
|
+
let end = i;
|
|
3763
|
+
while (end + 1 < lines.length && kinds[end + 1] === "prose" && lines[end + 1].startsWith(`${indent}>`)) {
|
|
3764
|
+
end++;
|
|
3765
|
+
}
|
|
3766
|
+
const depth = Math.floor(visualWidth(indent) / CODE_INDENT_WIDTH);
|
|
3767
|
+
const head = lines[i].slice(indent.length);
|
|
3768
|
+
const marked = depth > 1 && CALLOUT_HEAD_RE.test(head) ? `${head} ${calloutIndentMarker(depth)}` : head;
|
|
3769
|
+
out.push(CLAMPED_INDENT + marked);
|
|
3770
|
+
for (let j = i + 1; j <= end; j++) out.push(CLAMPED_INDENT + lines[j].slice(indent.length));
|
|
3771
|
+
i = end;
|
|
3772
|
+
}
|
|
3773
|
+
return out.join("\n");
|
|
3774
|
+
}
|
|
3775
|
+
var BODY_INDENT_RE = /^\t+(?![ \t]*(?:[-*+]|\d+[.)])\s)/;
|
|
3776
|
+
function clampBodyIndent(line) {
|
|
3777
|
+
return line.replace(BODY_INDENT_RE, CLAMPED_INDENT);
|
|
3778
|
+
}
|
|
3779
|
+
var QUOTE_RUN_RE = /^(?:>[\t ]?)*/;
|
|
3780
|
+
function inheritedQuoteRun(lines, i) {
|
|
3781
|
+
const runOf = (step) => {
|
|
3782
|
+
for (let j = i + step; j >= 0 && j < lines.length; j += step) {
|
|
3783
|
+
if (lines[j].trim() === "") continue;
|
|
3784
|
+
return (QUOTE_RUN_RE.exec(lines[j])?.[0] ?? "").trimEnd();
|
|
3785
|
+
}
|
|
3786
|
+
return null;
|
|
3787
|
+
};
|
|
3788
|
+
const runs = [runOf(-1), runOf(1)].filter((r) => r !== null);
|
|
3789
|
+
return runs.length === 0 ? "" : runs.reduce((a, b) => a.length <= b.length ? a : b);
|
|
3790
|
+
}
|
|
3791
|
+
function quoteCalloutBody(body) {
|
|
3792
|
+
const lines = body.split("\n");
|
|
3793
|
+
const kinds = classifyContainerLines(lines);
|
|
3794
|
+
return lines.map((line, i) => {
|
|
3795
|
+
if (!line.trim()) {
|
|
3796
|
+
const run = inheritedQuoteRun(lines, i);
|
|
3797
|
+
return run ? `> ${run}` : ">";
|
|
3798
|
+
}
|
|
3799
|
+
return `> ${kinds[i] === "prose" ? clampBodyIndent(line) : line}`;
|
|
3800
|
+
}).join("\n");
|
|
3801
|
+
}
|
|
3802
|
+
var RESTORE_BODY_INDENT_RE = new RegExp(`^${CLAMPED_INDENT}`);
|
|
3803
|
+
function restoreBodyIndent(body) {
|
|
3804
|
+
const lines = body.split("\n");
|
|
3805
|
+
const kinds = classifyContainerLines(lines);
|
|
3806
|
+
return lines.map((line, i) => kinds[i] === "prose" ? line.replace(RESTORE_BODY_INDENT_RE, " ") : line).join("\n");
|
|
3807
|
+
}
|
|
3808
|
+
function readCalloutIndentDepth(indent, title) {
|
|
3809
|
+
const match = CALLOUT_INDENT_MARKER_RE.exec(title);
|
|
3810
|
+
if (match) {
|
|
3811
|
+
return { depth: Number(match[1]), title: title.replace(CALLOUT_INDENT_MARKER_RE, "") };
|
|
3812
|
+
}
|
|
3813
|
+
return { depth: indent === "" ? 0 : 1, title };
|
|
3814
|
+
}
|
|
3815
|
+
function applyCalloutIndent(block, depth) {
|
|
3816
|
+
return depth > 0 ? indentContainerBody(block, " ".repeat(depth)) : block;
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3380
3819
|
// src/converter/post-processors/block-spacer.ts
|
|
3381
3820
|
var BlockSpacer = class {
|
|
3382
3821
|
name = "BlockSpacer";
|
|
@@ -3452,7 +3891,7 @@ function respace(content, sourceCompact) {
|
|
|
3452
3891
|
fence = "math";
|
|
3453
3892
|
continue;
|
|
3454
3893
|
}
|
|
3455
|
-
const line = LIST_ITEM_RE.test(raw) || INDENTED_CONTINUATION_RE.test(raw) ? raw.replace(/^\t+/, (t) => " ".repeat(t.length)) : raw;
|
|
3894
|
+
const line = QUOTE_RE.test(raw) ? raw.replace(/^\t+/, CLAMPED_INDENT) : LIST_ITEM_RE.test(raw) || INDENTED_CONTINUATION_RE.test(raw) ? raw.replace(/^\t+/, (t) => " ".repeat(t.length)) : raw;
|
|
3456
3895
|
if (BRAND_MARKER_LINE_RE.test(line) && blocks.length > 0) {
|
|
3457
3896
|
appendToLast(line);
|
|
3458
3897
|
continue;
|
|
@@ -3463,6 +3902,9 @@ function respace(content, sourceCompact) {
|
|
|
3463
3902
|
continue;
|
|
3464
3903
|
}
|
|
3465
3904
|
if (last()?.kind === "list" && INDENTED_CONTINUATION_RE.test(line)) {
|
|
3905
|
+
if (QUOTE_RE.test(line) && (atBoundary || CALLOUT_START_RE.test(line))) {
|
|
3906
|
+
appendToLast("");
|
|
3907
|
+
}
|
|
3466
3908
|
appendToLast(line);
|
|
3467
3909
|
continue;
|
|
3468
3910
|
}
|
|
@@ -3600,6 +4042,7 @@ function formatMention(mention, plainText) {
|
|
|
3600
4042
|
}
|
|
3601
4043
|
|
|
3602
4044
|
// src/converter/block-converter.ts
|
|
4045
|
+
var TOC_COLOR_MARKER_RE = new RegExp(`^%%${MARKER_BRAND}:toc:color=([a-z_]+)%%$`);
|
|
3603
4046
|
var TRULY_UNSUPPORTED_BLOCK_TYPES = ["unsupported", "template"];
|
|
3604
4047
|
var DIVIDER_PLACEHOLDER = "\u200B%%IM-NOBSIDIAN_DIVIDER%%\u200B";
|
|
3605
4048
|
var VIDEO_URL_PATTERNS = [
|
|
@@ -3621,8 +4064,8 @@ var EMBED_URL_PATTERNS = [
|
|
|
3621
4064
|
/^https?:\/\/(www\.)?spotify\.com\//,
|
|
3622
4065
|
/^https?:\/\/(www\.)?soundcloud\.com\//
|
|
3623
4066
|
];
|
|
3624
|
-
function escapeRegex2(
|
|
3625
|
-
return
|
|
4067
|
+
function escapeRegex2(str2) {
|
|
4068
|
+
return str2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3626
4069
|
}
|
|
3627
4070
|
var BlockConverter = class {
|
|
3628
4071
|
n2m = null;
|
|
@@ -3972,9 +4415,9 @@ ${COLUMN_LIST_END}`;
|
|
|
3972
4415
|
const content = result.slice(match.index + match[0].length + 1, endMatch.index).trimEnd();
|
|
3973
4416
|
const id = this.toggleCounter++;
|
|
3974
4417
|
this.toggleContents.set(id, content);
|
|
3975
|
-
const
|
|
3976
|
-
result = result.slice(0, match.index) +
|
|
3977
|
-
startRe.lastIndex = match.index +
|
|
4418
|
+
const placeholder2 = `%%IM-NOBSIDIAN_TOGGLE_${id}%%`;
|
|
4419
|
+
result = result.slice(0, match.index) + placeholder2 + result.slice(endMatch.index + endMatch[0].length);
|
|
4420
|
+
startRe.lastIndex = match.index + placeholder2.length;
|
|
3978
4421
|
}
|
|
3979
4422
|
return result;
|
|
3980
4423
|
}
|
|
@@ -3988,12 +4431,12 @@ ${COLUMN_LIST_END}`;
|
|
|
3988
4431
|
const endMatch = endRe.exec(result);
|
|
3989
4432
|
if (!endMatch) break;
|
|
3990
4433
|
const inner = result.slice(match.index + match[0].length + 1, endMatch.index);
|
|
3991
|
-
const columns = inner.split(new RegExp(`^${
|
|
4434
|
+
const columns = inner.split(new RegExp(`^${columnSepSource("ignore")}$`, "m")).map((c) => c.trim()).filter(Boolean);
|
|
3992
4435
|
const id = this.columnCounter++;
|
|
3993
4436
|
this.columnContents.set(id, columns);
|
|
3994
|
-
const
|
|
3995
|
-
result = result.slice(0, match.index) +
|
|
3996
|
-
startRe.lastIndex = match.index +
|
|
4437
|
+
const placeholder2 = `%%IM-NOBSIDIAN_COLLIST_${id}%%`;
|
|
4438
|
+
result = result.slice(0, match.index) + placeholder2 + result.slice(endMatch.index + endMatch[0].length);
|
|
4439
|
+
startRe.lastIndex = match.index + placeholder2.length;
|
|
3997
4440
|
}
|
|
3998
4441
|
return result;
|
|
3999
4442
|
}
|
|
@@ -4087,8 +4530,9 @@ ${COLUMN_LIST_END}`;
|
|
|
4087
4530
|
const texts = para?.rich_text ?? [];
|
|
4088
4531
|
if (texts.length !== 1) return null;
|
|
4089
4532
|
const text = (texts[0]?.text?.content ?? "").trim();
|
|
4090
|
-
|
|
4091
|
-
|
|
4533
|
+
const toc = TOC_COLOR_MARKER_RE.exec(text);
|
|
4534
|
+
if (text === TOC_MARKER || toc) {
|
|
4535
|
+
return NotionBlockBuilder.tableOfContents(toc?.[1]);
|
|
4092
4536
|
}
|
|
4093
4537
|
if (text === BREADCRUMB_MARKER) {
|
|
4094
4538
|
return NotionBlockBuilder.breadcrumb();
|
|
@@ -4341,14 +4785,43 @@ function collectAllDescendants(blocks) {
|
|
|
4341
4785
|
}
|
|
4342
4786
|
return result;
|
|
4343
4787
|
}
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
"
|
|
4348
|
-
"
|
|
4349
|
-
"
|
|
4350
|
-
"
|
|
4351
|
-
"
|
|
4788
|
+
|
|
4789
|
+
// src/utils/mime.ts
|
|
4790
|
+
var EXTENSION_TO_BLOCK_TYPE = {
|
|
4791
|
+
".png": "image",
|
|
4792
|
+
".jpg": "image",
|
|
4793
|
+
".jpeg": "image",
|
|
4794
|
+
".gif": "image",
|
|
4795
|
+
".svg": "image",
|
|
4796
|
+
".webp": "image",
|
|
4797
|
+
".ico": "image",
|
|
4798
|
+
".bmp": "image",
|
|
4799
|
+
".tiff": "image",
|
|
4800
|
+
".tif": "image",
|
|
4801
|
+
".avif": "image",
|
|
4802
|
+
".apng": "image",
|
|
4803
|
+
".heic": "image",
|
|
4804
|
+
".pdf": "pdf",
|
|
4805
|
+
".mp4": "video",
|
|
4806
|
+
".mov": "video",
|
|
4807
|
+
".webm": "video",
|
|
4808
|
+
".avi": "video",
|
|
4809
|
+
".mkv": "video",
|
|
4810
|
+
".flv": "video",
|
|
4811
|
+
".wmv": "video",
|
|
4812
|
+
".m4v": "video",
|
|
4813
|
+
".mpeg": "video",
|
|
4814
|
+
".ogv": "video",
|
|
4815
|
+
".3gp": "video",
|
|
4816
|
+
".mp3": "audio",
|
|
4817
|
+
".wav": "audio",
|
|
4818
|
+
".ogg": "audio",
|
|
4819
|
+
".m4a": "audio",
|
|
4820
|
+
".flac": "audio",
|
|
4821
|
+
".aac": "audio",
|
|
4822
|
+
".wma": "audio",
|
|
4823
|
+
".opus": "audio",
|
|
4824
|
+
".weba": "audio"
|
|
4352
4825
|
};
|
|
4353
4826
|
var EXTENSION_TO_MIME = {
|
|
4354
4827
|
".png": "image/png",
|
|
@@ -4359,18 +4832,81 @@ var EXTENSION_TO_MIME = {
|
|
|
4359
4832
|
".svg": "image/svg+xml",
|
|
4360
4833
|
".bmp": "image/bmp",
|
|
4361
4834
|
".ico": "image/x-icon",
|
|
4362
|
-
".tiff": "image/tiff"
|
|
4835
|
+
".tiff": "image/tiff",
|
|
4836
|
+
".tif": "image/tiff",
|
|
4837
|
+
".avif": "image/avif",
|
|
4838
|
+
".heic": "image/heic",
|
|
4839
|
+
".pdf": "application/pdf",
|
|
4840
|
+
".mp4": "video/mp4",
|
|
4841
|
+
".mov": "video/quicktime",
|
|
4842
|
+
".webm": "video/webm",
|
|
4843
|
+
".avi": "video/x-msvideo",
|
|
4844
|
+
".mkv": "video/x-matroska",
|
|
4845
|
+
".mp3": "audio/mpeg",
|
|
4846
|
+
".wav": "audio/wav",
|
|
4847
|
+
".ogg": "audio/ogg",
|
|
4848
|
+
".m4a": "audio/mp4",
|
|
4849
|
+
".flac": "audio/flac",
|
|
4850
|
+
".aac": "audio/aac",
|
|
4851
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
4852
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
4853
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
4854
|
+
".zip": "application/zip",
|
|
4855
|
+
".gz": "application/gzip",
|
|
4856
|
+
".tar": "application/x-tar",
|
|
4857
|
+
".rar": "application/vnd.rar",
|
|
4858
|
+
".7z": "application/x-7z-compressed",
|
|
4859
|
+
".py": "text/x-python",
|
|
4860
|
+
".js": "text/javascript",
|
|
4861
|
+
".ts": "text/typescript",
|
|
4862
|
+
".json": "application/json",
|
|
4863
|
+
".csv": "text/csv",
|
|
4864
|
+
".txt": "text/plain",
|
|
4865
|
+
".html": "text/html",
|
|
4866
|
+
".xml": "application/xml",
|
|
4867
|
+
".hwp": "application/x-hwp",
|
|
4868
|
+
".xls": "application/vnd.ms-excel",
|
|
4869
|
+
".doc": "application/msword",
|
|
4870
|
+
".ppt": "application/vnd.ms-powerpoint"
|
|
4363
4871
|
};
|
|
4364
|
-
function
|
|
4365
|
-
const
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4872
|
+
function getBlockType(filename) {
|
|
4873
|
+
const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
|
|
4874
|
+
if (!ext) return "file";
|
|
4875
|
+
return EXTENSION_TO_BLOCK_TYPE[ext] ?? "file";
|
|
4876
|
+
}
|
|
4877
|
+
function getMimeType(filename) {
|
|
4878
|
+
const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
|
|
4879
|
+
if (!ext) return "application/octet-stream";
|
|
4880
|
+
return EXTENSION_TO_MIME[ext] ?? "application/octet-stream";
|
|
4881
|
+
}
|
|
4882
|
+
|
|
4883
|
+
// src/utils/download-fetch.ts
|
|
4884
|
+
var DEFAULT_DOWNLOAD_TIMEOUT_MS = 3e5;
|
|
4885
|
+
function fetchForDownload(fetchFn, url, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS) {
|
|
4886
|
+
if (timeoutMs <= 0) return fetchFn(url);
|
|
4887
|
+
return fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
4888
|
+
}
|
|
4889
|
+
|
|
4890
|
+
// src/sync/image-handler.ts
|
|
4891
|
+
var IMAGE_EXTENSIONS = {
|
|
4892
|
+
"image/png": ".png",
|
|
4893
|
+
"image/jpeg": ".jpg",
|
|
4894
|
+
"image/gif": ".gif",
|
|
4895
|
+
"image/webp": ".webp",
|
|
4896
|
+
"image/svg+xml": ".svg",
|
|
4897
|
+
"image/bmp": ".bmp",
|
|
4898
|
+
"image/tiff": ".tiff"
|
|
4899
|
+
};
|
|
4900
|
+
function scanMarkdownLinks(markdown) {
|
|
4901
|
+
const spans = [];
|
|
4902
|
+
let i = 0;
|
|
4903
|
+
while (i < markdown.length) {
|
|
4904
|
+
const open = markdown.indexOf("[", i);
|
|
4905
|
+
if (open === -1) break;
|
|
4906
|
+
if (markdown[open - 1] === "\\") {
|
|
4907
|
+
i = open + 1;
|
|
4908
|
+
continue;
|
|
4909
|
+
}
|
|
4374
4910
|
const isEmbed = markdown[open - 1] === "!" && markdown[open - 2] !== "\\";
|
|
4375
4911
|
let depth = 1;
|
|
4376
4912
|
let j = open + 1;
|
|
@@ -4430,18 +4966,44 @@ function toWikilinkAlias(label) {
|
|
|
4430
4966
|
}
|
|
4431
4967
|
var INTERNAL_FILE_URL_RE = /^file:\/\/%7B.*%7D%7D$/;
|
|
4432
4968
|
var FILE_LABEL_PREFIX_RE = /^(?:📎|🎬|🎞|📄|🔊)\s*/u;
|
|
4433
|
-
var LOCAL_MARKER_RE = new RegExp(
|
|
4969
|
+
var LOCAL_MARKER_RE = new RegExp(
|
|
4970
|
+
`${MARKER_BRAND_RE}:local-(?:image|file):(${MARKER_PAYLOAD_CHAR}+?)\\s*%%`,
|
|
4971
|
+
"g"
|
|
4972
|
+
);
|
|
4973
|
+
var PLACEHOLDER_MARKER_RE = new RegExp(
|
|
4974
|
+
`${MARKER_BRAND_RE}:local-(image|file):(${MARKER_PAYLOAD_CHAR}+?)\\s*%%`,
|
|
4975
|
+
"u"
|
|
4976
|
+
);
|
|
4977
|
+
function stripAlias2(target) {
|
|
4978
|
+
return target.split("|")[0];
|
|
4979
|
+
}
|
|
4980
|
+
function uploadedFileNameFromUrl(url) {
|
|
4981
|
+
try {
|
|
4982
|
+
return decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
|
|
4983
|
+
} catch {
|
|
4984
|
+
return "";
|
|
4985
|
+
}
|
|
4986
|
+
}
|
|
4987
|
+
var NON_DESCENDABLE_TYPES = /* @__PURE__ */ new Set(["child_page", "child_database", "synced_block", "table"]);
|
|
4434
4988
|
function collectLocalMarkerBasenames(markdown) {
|
|
4435
4989
|
const names = /* @__PURE__ */ new Set();
|
|
4436
4990
|
const unescaped = markdown.replace(/\\/g, "");
|
|
4437
4991
|
for (const m of unescaped.matchAll(LOCAL_MARKER_RE)) {
|
|
4438
|
-
names.add(baseName(m[1]));
|
|
4992
|
+
names.add(baseName(decodeMarkerTarget(m[1])));
|
|
4439
4993
|
}
|
|
4440
4994
|
return names;
|
|
4441
4995
|
}
|
|
4442
4996
|
function baseName(path) {
|
|
4443
4997
|
return path.split("/").pop() ?? path;
|
|
4444
4998
|
}
|
|
4999
|
+
function folderOf(notePath) {
|
|
5000
|
+
return notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
|
|
5001
|
+
}
|
|
5002
|
+
function plainTextOf(block) {
|
|
5003
|
+
const body = block[block.type];
|
|
5004
|
+
if (!body?.rich_text) return "";
|
|
5005
|
+
return body.rich_text.map((r) => r.plain_text ?? "").join("");
|
|
5006
|
+
}
|
|
4445
5007
|
var ImageHandler = class {
|
|
4446
5008
|
constructor(vaultFs, attachmentFolder = "attachments", notionClient, customFetch, options, stateDb) {
|
|
4447
5009
|
this.vaultFs = vaultFs;
|
|
@@ -4453,6 +5015,7 @@ var ImageHandler = class {
|
|
|
4453
5015
|
this.maxRetries = options?.maxRetries ?? 3;
|
|
4454
5016
|
this.retryBaseMs = options?.retryBaseMs ?? 1e3;
|
|
4455
5017
|
this.maxFileSizeBytes = options?.maxFileSizeBytes ?? 100 * 1024 * 1024;
|
|
5018
|
+
this.downloadTimeoutMs = options?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
|
|
4456
5019
|
}
|
|
4457
5020
|
vaultFs;
|
|
4458
5021
|
attachmentFolder;
|
|
@@ -4463,9 +5026,14 @@ var ImageHandler = class {
|
|
|
4463
5026
|
maxRetries;
|
|
4464
5027
|
retryBaseMs;
|
|
4465
5028
|
maxFileSizeBytes;
|
|
5029
|
+
downloadTimeoutMs;
|
|
4466
5030
|
get fetchFn() {
|
|
4467
5031
|
return this.customFetch ?? globalThis.fetch;
|
|
4468
5032
|
}
|
|
5033
|
+
/** 상한 로직은 첨부 경로와 공유한다 — 한쪽만 빠뜨리지 않도록(utils/download-fetch). */
|
|
5034
|
+
fetchForDownload(url) {
|
|
5035
|
+
return fetchForDownload(this.fetchFn, url, this.downloadTimeoutMs);
|
|
5036
|
+
}
|
|
4469
5037
|
async downloadImage(url, pageTitle) {
|
|
4470
5038
|
return this.persistImage(await this.fetchImage(url, pageTitle));
|
|
4471
5039
|
}
|
|
@@ -4480,7 +5048,7 @@ var ImageHandler = class {
|
|
|
4480
5048
|
`[Im-Nobsidian] \uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC7AC\uC2DC\uB3C4 (${attempt + 1}/${maxRetries}): ${pageTitle}`
|
|
4481
5049
|
);
|
|
4482
5050
|
}
|
|
4483
|
-
const response = await this.
|
|
5051
|
+
const response = await this.fetchForDownload(url);
|
|
4484
5052
|
if (!response.ok) {
|
|
4485
5053
|
throw new Error(`\uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
|
|
4486
5054
|
}
|
|
@@ -4499,6 +5067,31 @@ var ImageHandler = class {
|
|
|
4499
5067
|
}
|
|
4500
5068
|
throw lastError;
|
|
4501
5069
|
}
|
|
5070
|
+
/**
|
|
5071
|
+
* 캡션이 push 가 적어 둔 원본 임베드 대상인지 판정한다(R1). 맞으면 그 문자열을
|
|
5072
|
+
* 그대로 돌려주고, 호출자는 사본을 내려받는 대신 `![[원본]]` 으로 되살린다.
|
|
5073
|
+
*
|
|
5074
|
+
* 사용자가 Notion 에서 손으로 쓴 캡션을 경로로 오인하면 깨진 위키링크가 되므로,
|
|
5075
|
+
* ① 파일명이 실제 업로드된 파일명과 같고 ② 볼트에 그 파일이 실재할 때만 인정한다.
|
|
5076
|
+
* 둘 중 하나라도 어긋나면 null 을 돌려 기존 다운로드 경로로 떨어진다.
|
|
5077
|
+
*
|
|
5078
|
+
* ②의 실재 확인은 push 와 **같은 후보 사다리**(노트 폴더 → 루트 → attachments/)를
|
|
5079
|
+
* 쓴다 — 위키링크는 파일명만 적는 게 관례라 볼트 루트만 보면 노트 옆 파일을 놓친다.
|
|
5080
|
+
*/
|
|
5081
|
+
async resolveOriginalTarget(label, uploadedName, noteFolder) {
|
|
5082
|
+
if (!label || !uploadedName) return null;
|
|
5083
|
+
const target = label.replace(FILE_LABEL_PREFIX_RE, "").replace(/\\(.)/g, "$1").trim();
|
|
5084
|
+
if (!target || /^https?:\/\//.test(target) || target.includes("\n")) return null;
|
|
5085
|
+
const vaultPath = stripAlias2(target);
|
|
5086
|
+
if (baseName(vaultPath) !== uploadedName) return null;
|
|
5087
|
+
for (const candidate of this.vaultCandidates(vaultPath, noteFolder)) {
|
|
5088
|
+
try {
|
|
5089
|
+
if (await this.vaultFs.exists(candidate)) return target;
|
|
5090
|
+
} catch {
|
|
5091
|
+
}
|
|
5092
|
+
}
|
|
5093
|
+
return null;
|
|
5094
|
+
}
|
|
4502
5095
|
async persistImage(fetched) {
|
|
4503
5096
|
await this.vaultFs.ensureFolder(this.attachmentFolder);
|
|
4504
5097
|
await this.vaultFs.writeBinary(fetched.localPath, fetched.buffer);
|
|
@@ -4509,7 +5102,8 @@ var ImageHandler = class {
|
|
|
4509
5102
|
size: fetched.buffer.length
|
|
4510
5103
|
};
|
|
4511
5104
|
}
|
|
4512
|
-
async downloadAllImages(markdown, pageTitle, pageId) {
|
|
5105
|
+
async downloadAllImages(markdown, pageTitle, pageId, notePath) {
|
|
5106
|
+
const noteFolder = folderOf(notePath);
|
|
4513
5107
|
const embeds = scanMarkdownLinks(markdown).filter((s) => s.isEmbed);
|
|
4514
5108
|
const notionMatches = embeds.filter(
|
|
4515
5109
|
(s) => /^https?:\/\//.test(s.url) && this.isNotionImageUrl(s.url)
|
|
@@ -4561,6 +5155,15 @@ var ImageHandler = class {
|
|
|
4561
5155
|
result = result.replace(span.full, "");
|
|
4562
5156
|
continue;
|
|
4563
5157
|
}
|
|
5158
|
+
const original = await this.resolveOriginalTarget(
|
|
5159
|
+
span.label,
|
|
5160
|
+
uploadedFileNameFromUrl(span.url),
|
|
5161
|
+
noteFolder
|
|
5162
|
+
);
|
|
5163
|
+
if (original) {
|
|
5164
|
+
result = result.replace(span.full, `![[${original}]]`);
|
|
5165
|
+
continue;
|
|
5166
|
+
}
|
|
4564
5167
|
const download = await this.persistImage(fetched);
|
|
4565
5168
|
downloads.push(download);
|
|
4566
5169
|
const alias = toWikilinkAlias(span.label);
|
|
@@ -4582,6 +5185,15 @@ var ImageHandler = class {
|
|
|
4582
5185
|
result = result.replace(span.full, "");
|
|
4583
5186
|
continue;
|
|
4584
5187
|
}
|
|
5188
|
+
const original = await this.resolveOriginalTarget(
|
|
5189
|
+
span.label,
|
|
5190
|
+
parsed.fileName || uploadedFileNameFromUrl(realUrl),
|
|
5191
|
+
noteFolder
|
|
5192
|
+
);
|
|
5193
|
+
if (original) {
|
|
5194
|
+
result = result.replace(span.full, `![[${original}]]`);
|
|
5195
|
+
continue;
|
|
5196
|
+
}
|
|
4585
5197
|
const download = await this.persistImage(fetched);
|
|
4586
5198
|
downloads.push(download);
|
|
4587
5199
|
const alias = toWikilinkAlias(span.label);
|
|
@@ -4601,7 +5213,7 @@ var ImageHandler = class {
|
|
|
4601
5213
|
}
|
|
4602
5214
|
const { buffer, resolvedPath } = await this.readImageFromVault(localPath, noteFolder);
|
|
4603
5215
|
const filename = localPath.split("/").pop() ?? "image.png";
|
|
4604
|
-
const contentType =
|
|
5216
|
+
const contentType = getMimeType(filename);
|
|
4605
5217
|
const blob = new Blob([buffer], { type: contentType });
|
|
4606
5218
|
const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
|
|
4607
5219
|
const fileHash = createHash("sha256").update(buffer).digest("hex");
|
|
@@ -4611,7 +5223,7 @@ var ImageHandler = class {
|
|
|
4611
5223
|
if (!this.notionClient) return [];
|
|
4612
5224
|
const localImages = images.filter((img) => !img.isExternal && img.localPath);
|
|
4613
5225
|
if (localImages.length === 0) return [];
|
|
4614
|
-
const noteFolder =
|
|
5226
|
+
const noteFolder = folderOf(notePath);
|
|
4615
5227
|
const results = [];
|
|
4616
5228
|
const imageBlocks = [];
|
|
4617
5229
|
for (const img of localImages) {
|
|
@@ -4635,36 +5247,150 @@ var ImageHandler = class {
|
|
|
4635
5247
|
}
|
|
4636
5248
|
if (imageBlocks.length > 0) {
|
|
4637
5249
|
await this.notionClient.appendChildren(pageId, imageBlocks);
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
5250
|
+
this.registerUploads(pageId, results);
|
|
5251
|
+
}
|
|
5252
|
+
return results;
|
|
5253
|
+
}
|
|
5254
|
+
/**
|
|
5255
|
+
* push 가 심어 둔 자리표시자 quote 를 **제자리에서** 실제 image/file 블록으로 바꾼다(R1).
|
|
5256
|
+
*
|
|
5257
|
+
* 그 전에는 임베드 하나가 Notion 에서 두 조각으로 갈라졌다 — 본문 자리에는
|
|
5258
|
+
* `> 📎 foo.png` + `%% im-nobsidian:local-image:… %%` 리터럴 마커가, 실제 이미지는
|
|
5259
|
+
* 페이지 맨 끝에 따로. 실측: 볼트 175개 파일에서 마커 978건이 Notion 에 그대로
|
|
5260
|
+
* 텍스트로 새고 있었고 그중 964건(98.6%)이 이 미디어 쌍이었다. 사용자가 Notion 에서
|
|
5261
|
+
* 보는 화면이 깨질 뿐 아니라 이미지가 본문 흐름에서 이탈한다.
|
|
5262
|
+
*
|
|
5263
|
+
* 캡션에는 원본 임베드 대상을 그대로 적는다. pull 이 경로(와 `|별칭`)를 되찾는
|
|
5264
|
+
* 진실원이 되고, 사용자에게도 출처가 보인다.
|
|
5265
|
+
*
|
|
5266
|
+
* 볼트에서 파일을 못 찾으면 자리표시자를 그대로 둔다 — 마커가 남아야 pull 이
|
|
5267
|
+
* 임베드를 복원할 수 있으므로, 반쯤 지우는 것보다 안전하다.
|
|
5268
|
+
*/
|
|
5269
|
+
async materializeLocalMedia(pageId, pushedMarkdown, notePath) {
|
|
5270
|
+
const empty = { uploaded: [], handledTargets: /* @__PURE__ */ new Set() };
|
|
5271
|
+
if (!this.notionClient) return empty;
|
|
5272
|
+
LOCAL_MARKER_RE.lastIndex = 0;
|
|
5273
|
+
if (!LOCAL_MARKER_RE.test(pushedMarkdown)) return empty;
|
|
5274
|
+
let hits;
|
|
5275
|
+
try {
|
|
5276
|
+
hits = await this.collectPlaceholders(pageId);
|
|
5277
|
+
} catch (error) {
|
|
5278
|
+
getLogger().warn(`\uBBF8\uB514\uC5B4 \uC790\uB9AC\uD45C\uC2DC\uC790 \uC870\uD68C \uC2E4\uD328 (${pageId}):`, error);
|
|
5279
|
+
return empty;
|
|
5280
|
+
}
|
|
5281
|
+
if (hits.length === 0) return empty;
|
|
5282
|
+
const noteFolder = folderOf(notePath);
|
|
5283
|
+
const uploaded = [];
|
|
5284
|
+
const handledTargets = /* @__PURE__ */ new Set();
|
|
5285
|
+
for (const hit of hits) {
|
|
5286
|
+
const vaultTarget = stripAlias2(hit.target);
|
|
5287
|
+
try {
|
|
5288
|
+
const result = await this.uploadLocalImage(vaultTarget, noteFolder);
|
|
5289
|
+
const fileName = baseName(vaultTarget);
|
|
5290
|
+
const caption = [{ type: "text", text: { content: hit.target } }];
|
|
5291
|
+
const block = hit.kind === "image" ? {
|
|
5292
|
+
type: "image",
|
|
5293
|
+
image: { type: "file_upload", file_upload: { id: result.fileUploadId }, caption }
|
|
5294
|
+
} : {
|
|
5295
|
+
type: "file",
|
|
5296
|
+
file: {
|
|
5297
|
+
type: "file_upload",
|
|
5298
|
+
file_upload: { id: result.fileUploadId },
|
|
5299
|
+
name: fileName,
|
|
5300
|
+
caption
|
|
4651
5301
|
}
|
|
5302
|
+
};
|
|
5303
|
+
await this.notionClient.appendChildren(hit.parentId, [block], { after: hit.blockId });
|
|
5304
|
+
await this.notionClient.deleteBlock(hit.blockId);
|
|
5305
|
+
uploaded.push(result);
|
|
5306
|
+
handledTargets.add(hit.target);
|
|
5307
|
+
handledTargets.add(vaultTarget);
|
|
5308
|
+
handledTargets.add(result.localPath);
|
|
5309
|
+
} catch (error) {
|
|
5310
|
+
if (error?.code === "ENOENT") {
|
|
5311
|
+
getLogger().warn(`\uBBF8\uB514\uC5B4 \uC81C\uC790\uB9AC \uC0BD\uC785 \uAC74\uB108\uB700 \u2014 \uBCFC\uD2B8\uC5D0\uC11C \uD30C\uC77C\uC744 \uCC3E\uC9C0 \uBABB\uD568: ${vaultTarget}`);
|
|
5312
|
+
} else {
|
|
5313
|
+
getLogger().warn(`\uBBF8\uB514\uC5B4 \uC81C\uC790\uB9AC \uC0BD\uC785 \uC2E4\uD328 (${vaultTarget}):`, error);
|
|
4652
5314
|
}
|
|
4653
5315
|
}
|
|
4654
5316
|
}
|
|
4655
|
-
|
|
5317
|
+
this.registerUploads(pageId, uploaded);
|
|
5318
|
+
return { uploaded, handledTargets };
|
|
4656
5319
|
}
|
|
4657
5320
|
/**
|
|
4658
|
-
*
|
|
4659
|
-
*
|
|
4660
|
-
*
|
|
5321
|
+
* 페이지 블록 트리를 훑어 자리표시자를 부모와 함께 모은다. 콜아웃·토글·목록 안에
|
|
5322
|
+
* 들어간 임베드도 제자리 교체하려면 부모 id 가 필요하다(append 의 after_block 은
|
|
5323
|
+
* 같은 부모의 자식만 기준으로 삼는다).
|
|
4661
5324
|
*/
|
|
4662
|
-
async
|
|
5325
|
+
async collectPlaceholders(rootId, depth = 0) {
|
|
5326
|
+
if (depth > 4) return [];
|
|
5327
|
+
const client = this.notionClient;
|
|
5328
|
+
const children = await client.fetchAllChildren(rootId);
|
|
5329
|
+
const hits = [];
|
|
5330
|
+
for (const block of children) {
|
|
5331
|
+
const marker = PLACEHOLDER_MARKER_RE.exec(plainTextOf(block));
|
|
5332
|
+
if (marker) {
|
|
5333
|
+
hits.push({
|
|
5334
|
+
parentId: rootId,
|
|
5335
|
+
blockId: block.id,
|
|
5336
|
+
kind: marker[1] === "image" ? "image" : "file",
|
|
5337
|
+
// 마커에 실린 경로는 퍼센트 인코딩돼 있다 — 볼트 조회·캡션 모두 원문이어야 한다.
|
|
5338
|
+
target: decodeMarkerTarget(marker[2])
|
|
5339
|
+
});
|
|
5340
|
+
continue;
|
|
5341
|
+
}
|
|
5342
|
+
if (block.has_children && !NON_DESCENDABLE_TYPES.has(block.type)) {
|
|
5343
|
+
hits.push(...await this.collectPlaceholders(block.id, depth + 1));
|
|
5344
|
+
}
|
|
5345
|
+
}
|
|
5346
|
+
return hits;
|
|
5347
|
+
}
|
|
5348
|
+
/**
|
|
5349
|
+
* 업로드분을 file_registry 에 남긴다. 노트 임베드는 여기서 페이지에 붙으므로,
|
|
5350
|
+
* 이후 FileHandler.pushAllFiles 가 같은 첨부를 폴더 페이지에 standalone 으로
|
|
5351
|
+
* 재업로드하는 중복을 공유 레지스트리로 차단한다.
|
|
5352
|
+
*/
|
|
5353
|
+
registerUploads(pageId, results) {
|
|
5354
|
+
if (!this.stateDb || results.length === 0) return;
|
|
5355
|
+
for (const r of results) {
|
|
5356
|
+
try {
|
|
5357
|
+
this.stateDb.registerFile({
|
|
5358
|
+
localPath: r.localPath,
|
|
5359
|
+
notionPageId: pageId,
|
|
5360
|
+
fileUploadId: r.fileUploadId,
|
|
5361
|
+
fileType: "image",
|
|
5362
|
+
fileHash: r.fileHash,
|
|
5363
|
+
fileSize: r.fileSize
|
|
5364
|
+
});
|
|
5365
|
+
} catch (error) {
|
|
5366
|
+
getLogger().warn(`\uBBF8\uB514\uC5B4 \uB808\uC9C0\uC2A4\uD2B8\uB9AC \uB4F1\uB85D \uC2E4\uD328 (${r.localPath}):`, error);
|
|
5367
|
+
}
|
|
5368
|
+
}
|
|
5369
|
+
}
|
|
5370
|
+
/**
|
|
5371
|
+
* 임베드 경로가 가리킬 수 있는 볼트 실제 파일 후보를 우선순위대로 만든다(F23).
|
|
5372
|
+
* Obsidian 위키링크 임베드는 노트 옆 파일을 파일명만으로 참조하는 게 관례라
|
|
5373
|
+
* 노트 폴더 → 볼트 루트 → attachments/ 순.
|
|
5374
|
+
*
|
|
5375
|
+
* push(업로드)와 pull(원본 복원)이 **같은 사다리**를 봐야 왕복이 닫힌다. 예전에는
|
|
5376
|
+
* pull 쪽이 볼트 루트만 확인해서, `__e2e_probe__/노트.md` 안의 `![[t-img.png]]` 는
|
|
5377
|
+
* 올라갈 때는 노트 폴더에서 잘 찾아 올리고도 내려올 때는 원본을 못 찾아 사본을
|
|
5378
|
+
* 새로 받았다(실측) — 왕복할 때마다 첨부가 한 벌씩 늘어난다.
|
|
5379
|
+
*/
|
|
5380
|
+
vaultCandidates(localPath, noteFolder) {
|
|
4663
5381
|
const candidates = [];
|
|
4664
5382
|
if (noteFolder && !localPath.includes("/")) {
|
|
4665
5383
|
candidates.push(`${noteFolder}/${localPath}`);
|
|
4666
5384
|
}
|
|
4667
5385
|
candidates.push(localPath, `${this.attachmentFolder}/${localPath}`);
|
|
5386
|
+
return candidates;
|
|
5387
|
+
}
|
|
5388
|
+
/**
|
|
5389
|
+
* 임베드 경로를 볼트 실제 파일로 해석해 읽는다(F23).
|
|
5390
|
+
* 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
|
|
5391
|
+
*/
|
|
5392
|
+
async readImageFromVault(localPath, noteFolder) {
|
|
5393
|
+
const candidates = this.vaultCandidates(localPath, noteFolder);
|
|
4668
5394
|
let lastError;
|
|
4669
5395
|
for (const candidate of candidates) {
|
|
4670
5396
|
try {
|
|
@@ -4675,11 +5401,8 @@ var ImageHandler = class {
|
|
|
4675
5401
|
}
|
|
4676
5402
|
throw lastError;
|
|
4677
5403
|
}
|
|
4678
|
-
|
|
4679
|
-
const
|
|
4680
|
-
return (ext && EXTENSION_TO_MIME[ext]) ?? "application/octet-stream";
|
|
4681
|
-
}
|
|
4682
|
-
async downloadAllFiles(markdown, pageTitle) {
|
|
5404
|
+
async downloadAllFiles(markdown, pageTitle, notePath) {
|
|
5405
|
+
const noteFolder = folderOf(notePath);
|
|
4683
5406
|
const fileLinks = scanMarkdownLinks(markdown).filter(
|
|
4684
5407
|
(s) => !s.isEmbed && FILE_LABEL_PREFIX_RE.test(s.label)
|
|
4685
5408
|
);
|
|
@@ -4702,6 +5425,15 @@ var ImageHandler = class {
|
|
|
4702
5425
|
for (const span of notionHttpMatches) {
|
|
4703
5426
|
await sema.acquire();
|
|
4704
5427
|
try {
|
|
5428
|
+
const original = await this.resolveOriginalTarget(
|
|
5429
|
+
span.label,
|
|
5430
|
+
uploadedFileNameFromUrl(span.url),
|
|
5431
|
+
noteFolder
|
|
5432
|
+
);
|
|
5433
|
+
if (original) {
|
|
5434
|
+
result = result.replace(span.full, `![[${original}]]`);
|
|
5435
|
+
continue;
|
|
5436
|
+
}
|
|
4705
5437
|
const caption = toWikilinkAlias(span.label.replace(FILE_LABEL_PREFIX_RE, "")) || "file";
|
|
4706
5438
|
const download = await this.downloadFile(span.url, pageTitle, caption);
|
|
4707
5439
|
if (!download.localPath) continue;
|
|
@@ -4721,6 +5453,11 @@ var ImageHandler = class {
|
|
|
4721
5453
|
if (!parsed || !this.notionClient) continue;
|
|
4722
5454
|
const blockId = parsed.blockId;
|
|
4723
5455
|
const fileName = parsed.fileName;
|
|
5456
|
+
const original = await this.resolveOriginalTarget(span.label, fileName, noteFolder);
|
|
5457
|
+
if (original) {
|
|
5458
|
+
result = result.replace(span.full, `![[${original}]]`);
|
|
5459
|
+
continue;
|
|
5460
|
+
}
|
|
4724
5461
|
const realUrl = await this.notionClient.getFileBlockUrl(blockId);
|
|
4725
5462
|
if (!realUrl) continue;
|
|
4726
5463
|
const caption = fileName || toWikilinkAlias(span.label.replace(FILE_LABEL_PREFIX_RE, "")) || "file";
|
|
@@ -4739,6 +5476,15 @@ var ImageHandler = class {
|
|
|
4739
5476
|
await sema.acquire();
|
|
4740
5477
|
try {
|
|
4741
5478
|
const url = match[1];
|
|
5479
|
+
const original = await this.resolveOriginalTarget(
|
|
5480
|
+
match[2] ?? "",
|
|
5481
|
+
uploadedFileNameFromUrl(url),
|
|
5482
|
+
noteFolder
|
|
5483
|
+
);
|
|
5484
|
+
if (original) {
|
|
5485
|
+
result = result.replace(match[0], `![[${original}]]`);
|
|
5486
|
+
continue;
|
|
5487
|
+
}
|
|
4742
5488
|
const caption = toWikilinkAlias(match[2] ?? "") || "file";
|
|
4743
5489
|
const download = await this.downloadFile(url, pageTitle, caption);
|
|
4744
5490
|
if (!download.localPath) continue;
|
|
@@ -4759,6 +5505,11 @@ var ImageHandler = class {
|
|
|
4759
5505
|
if (!parsed || !this.notionClient) continue;
|
|
4760
5506
|
const blockId = parsed.blockId;
|
|
4761
5507
|
const fileName = parsed.fileName;
|
|
5508
|
+
const original = await this.resolveOriginalTarget(match[2] ?? "", fileName, noteFolder);
|
|
5509
|
+
if (original) {
|
|
5510
|
+
result = result.replace(match[0], `![[${original}]]`);
|
|
5511
|
+
continue;
|
|
5512
|
+
}
|
|
4762
5513
|
const realUrl = await this.notionClient.getFileBlockUrl(blockId);
|
|
4763
5514
|
if (!realUrl) continue;
|
|
4764
5515
|
const caption = fileName || toWikilinkAlias(match[2] ?? "") || "file";
|
|
@@ -4799,7 +5550,7 @@ var ImageHandler = class {
|
|
|
4799
5550
|
`[Im-Nobsidian] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC7AC\uC2DC\uB3C4 (${attempt + 1}/${maxRetries}): ${caption}`
|
|
4800
5551
|
);
|
|
4801
5552
|
}
|
|
4802
|
-
const response = await this.
|
|
5553
|
+
const response = await this.fetchForDownload(url);
|
|
4803
5554
|
if (!response.ok) {
|
|
4804
5555
|
throw new Error(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
|
|
4805
5556
|
}
|
|
@@ -4898,139 +5649,105 @@ function selectStaleDbArtifacts(files, localFolder, keepPaths) {
|
|
|
4898
5649
|
return stale;
|
|
4899
5650
|
}
|
|
4900
5651
|
|
|
4901
|
-
// src/
|
|
4902
|
-
var
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
".tif": "image",
|
|
4913
|
-
".avif": "image",
|
|
4914
|
-
".apng": "image",
|
|
4915
|
-
".heic": "image",
|
|
4916
|
-
".pdf": "pdf",
|
|
4917
|
-
".mp4": "video",
|
|
4918
|
-
".mov": "video",
|
|
4919
|
-
".webm": "video",
|
|
4920
|
-
".avi": "video",
|
|
4921
|
-
".mkv": "video",
|
|
4922
|
-
".flv": "video",
|
|
4923
|
-
".wmv": "video",
|
|
4924
|
-
".m4v": "video",
|
|
4925
|
-
".mpeg": "video",
|
|
4926
|
-
".ogv": "video",
|
|
4927
|
-
".3gp": "video",
|
|
4928
|
-
".mp3": "audio",
|
|
4929
|
-
".wav": "audio",
|
|
4930
|
-
".ogg": "audio",
|
|
4931
|
-
".m4a": "audio",
|
|
4932
|
-
".flac": "audio",
|
|
4933
|
-
".aac": "audio",
|
|
4934
|
-
".wma": "audio",
|
|
4935
|
-
".opus": "audio",
|
|
4936
|
-
".weba": "audio"
|
|
4937
|
-
};
|
|
4938
|
-
var EXTENSION_TO_MIME2 = {
|
|
4939
|
-
".png": "image/png",
|
|
4940
|
-
".jpg": "image/jpeg",
|
|
4941
|
-
".jpeg": "image/jpeg",
|
|
4942
|
-
".gif": "image/gif",
|
|
4943
|
-
".webp": "image/webp",
|
|
4944
|
-
".svg": "image/svg+xml",
|
|
4945
|
-
".bmp": "image/bmp",
|
|
4946
|
-
".ico": "image/x-icon",
|
|
4947
|
-
".tiff": "image/tiff",
|
|
4948
|
-
".tif": "image/tiff",
|
|
4949
|
-
".avif": "image/avif",
|
|
4950
|
-
".heic": "image/heic",
|
|
4951
|
-
".pdf": "application/pdf",
|
|
4952
|
-
".mp4": "video/mp4",
|
|
4953
|
-
".mov": "video/quicktime",
|
|
4954
|
-
".webm": "video/webm",
|
|
4955
|
-
".avi": "video/x-msvideo",
|
|
4956
|
-
".mkv": "video/x-matroska",
|
|
4957
|
-
".mp3": "audio/mpeg",
|
|
4958
|
-
".wav": "audio/wav",
|
|
4959
|
-
".ogg": "audio/ogg",
|
|
4960
|
-
".m4a": "audio/mp4",
|
|
4961
|
-
".flac": "audio/flac",
|
|
4962
|
-
".aac": "audio/aac",
|
|
4963
|
-
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
4964
|
-
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
4965
|
-
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
4966
|
-
".zip": "application/zip",
|
|
4967
|
-
".gz": "application/gzip",
|
|
4968
|
-
".tar": "application/x-tar",
|
|
4969
|
-
".rar": "application/vnd.rar",
|
|
4970
|
-
".7z": "application/x-7z-compressed",
|
|
4971
|
-
".py": "text/x-python",
|
|
4972
|
-
".js": "text/javascript",
|
|
4973
|
-
".ts": "text/typescript",
|
|
4974
|
-
".json": "application/json",
|
|
4975
|
-
".csv": "text/csv",
|
|
4976
|
-
".txt": "text/plain",
|
|
4977
|
-
".html": "text/html",
|
|
4978
|
-
".xml": "application/xml",
|
|
4979
|
-
".hwp": "application/x-hwp",
|
|
4980
|
-
".xls": "application/vnd.ms-excel",
|
|
4981
|
-
".doc": "application/msword",
|
|
4982
|
-
".ppt": "application/vnd.ms-powerpoint"
|
|
5652
|
+
// src/utils/deadline.ts
|
|
5653
|
+
var DEFAULT_ITEM_TIMEOUT_MS = 18e5;
|
|
5654
|
+
var DeadlineExceededError = class extends Error {
|
|
5655
|
+
constructor(label, timeoutMs) {
|
|
5656
|
+
super(`\uC2DC\uAC04 \uC0C1\uD55C \uCD08\uACFC(${Math.round(timeoutMs / 1e3)}\uCD08): ${label}`);
|
|
5657
|
+
this.label = label;
|
|
5658
|
+
this.timeoutMs = timeoutMs;
|
|
5659
|
+
this.name = "DeadlineExceededError";
|
|
5660
|
+
}
|
|
5661
|
+
label;
|
|
5662
|
+
timeoutMs;
|
|
4983
5663
|
};
|
|
4984
|
-
function
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
5664
|
+
async function withDeadline(task, timeoutMs, label) {
|
|
5665
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return task();
|
|
5666
|
+
let timer;
|
|
5667
|
+
try {
|
|
5668
|
+
return await Promise.race([
|
|
5669
|
+
task(),
|
|
5670
|
+
new Promise((_resolve, reject) => {
|
|
5671
|
+
timer = setTimeout(() => reject(new DeadlineExceededError(label, timeoutMs)), timeoutMs);
|
|
5672
|
+
})
|
|
5673
|
+
]);
|
|
5674
|
+
} finally {
|
|
5675
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
5676
|
+
}
|
|
4993
5677
|
}
|
|
5678
|
+
|
|
5679
|
+
// src/sync/file-handler.ts
|
|
4994
5680
|
function getFolderPath(filePath) {
|
|
4995
5681
|
const parts = filePath.split("/");
|
|
4996
5682
|
return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
|
|
4997
5683
|
}
|
|
4998
5684
|
var FileHandler = class {
|
|
4999
|
-
constructor(vaultFs, notionClient, stateDb, concurrency = 2) {
|
|
5685
|
+
constructor(vaultFs, notionClient, stateDb, concurrency = 2, options) {
|
|
5000
5686
|
this.vaultFs = vaultFs;
|
|
5001
5687
|
this.notionClient = notionClient;
|
|
5002
5688
|
this.stateDb = stateDb;
|
|
5003
5689
|
this.sema = new Sema(concurrency);
|
|
5690
|
+
this.customFetch = options?.fetch;
|
|
5691
|
+
this.downloadTimeoutMs = options?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
|
|
5692
|
+
this.itemTimeoutMs = options?.itemTimeoutMs ?? DEFAULT_ITEM_TIMEOUT_MS;
|
|
5004
5693
|
}
|
|
5005
5694
|
vaultFs;
|
|
5006
5695
|
notionClient;
|
|
5007
5696
|
stateDb;
|
|
5008
5697
|
sema;
|
|
5698
|
+
customFetch;
|
|
5699
|
+
downloadTimeoutMs;
|
|
5700
|
+
itemTimeoutMs;
|
|
5701
|
+
get fetchFn() {
|
|
5702
|
+
return this.customFetch ?? globalThis.fetch;
|
|
5703
|
+
}
|
|
5009
5704
|
async pushFilesForFolder(folderPageId, folderPath) {
|
|
5010
5705
|
const allFiles = await this.listUploadableFiles();
|
|
5011
5706
|
const folderFiles = allFiles.filter((f) => getFolderPath(f.path) === folderPath);
|
|
5012
5707
|
if (folderFiles.length === 0) return [];
|
|
5013
5708
|
const results = [];
|
|
5014
5709
|
for (const file of folderFiles) {
|
|
5015
|
-
const
|
|
5016
|
-
if (
|
|
5017
|
-
if (existing.fileSize === file.size) continue;
|
|
5018
|
-
const buffer = await this.vaultFs.readBinary(file.path);
|
|
5019
|
-
const currentHash = createHash("sha256").update(buffer).digest("hex");
|
|
5020
|
-
if (existing.fileHash === currentHash) continue;
|
|
5021
|
-
}
|
|
5022
|
-
await this.sema.acquire();
|
|
5023
|
-
try {
|
|
5024
|
-
const result = await this.uploadFile(file, folderPageId);
|
|
5025
|
-
if (result) results.push(result);
|
|
5026
|
-
} catch (error) {
|
|
5027
|
-
getLogger().warn(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${file.path}):`, error);
|
|
5028
|
-
} finally {
|
|
5029
|
-
this.sema.release();
|
|
5030
|
-
}
|
|
5710
|
+
const result = await this.pushSingleFile(file, folderPageId);
|
|
5711
|
+
if (result) results.push(result);
|
|
5031
5712
|
}
|
|
5032
5713
|
return results;
|
|
5033
5714
|
}
|
|
5715
|
+
/**
|
|
5716
|
+
* 첨부 1건을 폴더 페이지에 올린다 — 두 push 경로(`pushFilesForFolder` · `pushAllFiles`)의
|
|
5717
|
+
* 공통 단위. 변경 없음 판정·동시성 슬롯·시간 상한·실패 격리를 모두 여기 한 곳에 둔다.
|
|
5718
|
+
*
|
|
5719
|
+
* 한 곳에 모은 이유는 R9a 에서 실제로 데인 적이 있어서다. 같은 다운로드 로직이
|
|
5720
|
+
* image/file 핸들러에 두 벌 존재한 탓에 한쪽에만 상한이 걸린 채로 남았고, 상한 없는
|
|
5721
|
+
* 쪽이 세마포어를 쥔 채 영원히 매달렸다. 여기도 같은 코드가 두 루프에 나뉘어 있었다.
|
|
5722
|
+
*
|
|
5723
|
+
* 상한(R9f): 업로드는 호출 1건마다 상한이 있어도(SDK 30초) 멀티파트는 파트 수만큼
|
|
5724
|
+
* 호출이 늘어나는 **합성 경로**라 전체로는 여전히 무한대다. 상한을 넘긴 첨부는 경고만
|
|
5725
|
+
* 남기고 나머지 첨부는 계속 올린다 — 첨부 하나가 push 전체를 멎게 하지 않는다.
|
|
5726
|
+
*
|
|
5727
|
+
* @returns 업로드 결과. 변경 없음(스킵)이거나 실패했으면 null.
|
|
5728
|
+
*/
|
|
5729
|
+
async pushSingleFile(file, folderPageId) {
|
|
5730
|
+
const existing = this.stateDb.getFileRegistry(file.path);
|
|
5731
|
+
if (existing) {
|
|
5732
|
+
if (existing.fileSize === file.size) return null;
|
|
5733
|
+
const buffer = await this.vaultFs.readBinary(file.path);
|
|
5734
|
+
const currentHash = createHash("sha256").update(buffer).digest("hex");
|
|
5735
|
+
if (existing.fileHash === currentHash) return null;
|
|
5736
|
+
}
|
|
5737
|
+
await this.sema.acquire();
|
|
5738
|
+
try {
|
|
5739
|
+
return await withDeadline(
|
|
5740
|
+
() => this.uploadFile(file, folderPageId),
|
|
5741
|
+
this.itemTimeoutMs,
|
|
5742
|
+
`\uCCA8\uBD80 \uC5C5\uB85C\uB4DC ${file.path}`
|
|
5743
|
+
);
|
|
5744
|
+
} catch (error) {
|
|
5745
|
+
getLogger().warn(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${file.path}):`, error);
|
|
5746
|
+
return null;
|
|
5747
|
+
} finally {
|
|
5748
|
+
this.sema.release();
|
|
5749
|
+
}
|
|
5750
|
+
}
|
|
5034
5751
|
/**
|
|
5035
5752
|
* 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 제외한다.
|
|
5036
5753
|
* node 측 VaultFS 는 과거 `.base` 를 워커에서 빼는 식으로 우회했지만, Obsidian
|
|
@@ -5064,28 +5781,14 @@ var FileHandler = class {
|
|
|
5064
5781
|
continue;
|
|
5065
5782
|
}
|
|
5066
5783
|
for (const file of files) {
|
|
5067
|
-
const
|
|
5068
|
-
if (
|
|
5069
|
-
if (existing.fileSize === file.size) continue;
|
|
5070
|
-
const buffer = await this.vaultFs.readBinary(file.path);
|
|
5071
|
-
const currentHash = createHash("sha256").update(buffer).digest("hex");
|
|
5072
|
-
if (existing.fileHash === currentHash) continue;
|
|
5073
|
-
}
|
|
5074
|
-
await this.sema.acquire();
|
|
5075
|
-
try {
|
|
5076
|
-
const result = await this.uploadFile(file, folderPageId);
|
|
5077
|
-
if (result) results.push(result);
|
|
5078
|
-
} catch (error) {
|
|
5079
|
-
getLogger().warn(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC2E4\uD328 (${file.path}):`, error);
|
|
5080
|
-
} finally {
|
|
5081
|
-
this.sema.release();
|
|
5082
|
-
}
|
|
5784
|
+
const result = await this.pushSingleFile(file, folderPageId);
|
|
5785
|
+
if (result) results.push(result);
|
|
5083
5786
|
}
|
|
5084
5787
|
}
|
|
5085
5788
|
return results;
|
|
5086
5789
|
}
|
|
5087
5790
|
async downloadFileBlock(url, filename, targetFolder) {
|
|
5088
|
-
const response = await
|
|
5791
|
+
const response = await fetchForDownload(this.fetchFn, url, this.downloadTimeoutMs);
|
|
5089
5792
|
if (!response.ok) {
|
|
5090
5793
|
throw new Error(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
|
|
5091
5794
|
}
|
|
@@ -5174,6 +5877,9 @@ var FileHandler = class {
|
|
|
5174
5877
|
function resolvePullConflict(input) {
|
|
5175
5878
|
const { record, localContent, remoteContent, remoteChange, strategy } = input;
|
|
5176
5879
|
const localHash = computeHash(localContent);
|
|
5880
|
+
if (input.localExists === false) {
|
|
5881
|
+
return { action: "write", localHash };
|
|
5882
|
+
}
|
|
5177
5883
|
const localModified = localHash !== record.contentHash;
|
|
5178
5884
|
if (!localModified) {
|
|
5179
5885
|
return { action: "write", localHash };
|
|
@@ -5255,20 +5961,19 @@ function selectDbRowFiles(files, localFolder) {
|
|
|
5255
5961
|
return rel.length > 0 && !rel.includes("/");
|
|
5256
5962
|
});
|
|
5257
5963
|
}
|
|
5964
|
+
function pagePathCandidates(dir, safeName, pageId) {
|
|
5965
|
+
const base = dir ? `${dir}/${safeName}` : safeName;
|
|
5966
|
+
const raw = pageId.replace(/-/g, "");
|
|
5967
|
+
return [`${base}.md`, ...[8, 16, 32].map((len) => `${base} (${raw.slice(0, len)}).md`)];
|
|
5968
|
+
}
|
|
5258
5969
|
function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
|
|
5259
5970
|
const ownedBySelf = (candidate) => {
|
|
5260
5971
|
const owner = lookupByPath(candidate);
|
|
5261
5972
|
if (!owner) return true;
|
|
5262
5973
|
return owner.notionPageId != null && notionIdsEqual(owner.notionPageId, pageId);
|
|
5263
5974
|
};
|
|
5264
|
-
const
|
|
5265
|
-
|
|
5266
|
-
const raw = pageId.replace(/-/g, "");
|
|
5267
|
-
for (const len of [8, 16, 32]) {
|
|
5268
|
-
const candidate = `${localFolder}/${safeName} (${raw.slice(0, len)}).md`;
|
|
5269
|
-
if (ownedBySelf(candidate)) return candidate;
|
|
5270
|
-
}
|
|
5271
|
-
return `${localFolder}/${safeName} (${raw}).md`;
|
|
5975
|
+
const candidates = pagePathCandidates(localFolder, safeName, pageId);
|
|
5976
|
+
return candidates.find(ownedBySelf) ?? candidates[candidates.length - 1];
|
|
5272
5977
|
}
|
|
5273
5978
|
|
|
5274
5979
|
// src/utils/db-folder-path.ts
|
|
@@ -5285,50 +5990,356 @@ function resolveDbFolderPath(folder, dbId, ownerOf) {
|
|
|
5285
5990
|
}
|
|
5286
5991
|
return `${folder} (${raw})`;
|
|
5287
5992
|
}
|
|
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++;
|
|
5993
|
+
function repairDbFolderCollisions(configs, takenFolders) {
|
|
5994
|
+
const owner = new Map(takenFolders ?? []);
|
|
5995
|
+
let repaired = 0;
|
|
5996
|
+
for (const config of configs) {
|
|
5997
|
+
const resolved = resolveDbFolderPath(
|
|
5998
|
+
config.localFolder,
|
|
5999
|
+
config.databaseId,
|
|
6000
|
+
(f) => owner.get(f) ?? null
|
|
6001
|
+
);
|
|
6002
|
+
if (resolved !== config.localFolder) {
|
|
6003
|
+
config.localFolder = resolved;
|
|
6004
|
+
repaired++;
|
|
6005
|
+
}
|
|
6006
|
+
owner.set(resolved, config.databaseId);
|
|
6007
|
+
}
|
|
6008
|
+
return repaired;
|
|
6009
|
+
}
|
|
6010
|
+
function isDirectDbRowPath(localFolder, path) {
|
|
6011
|
+
const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
|
|
6012
|
+
if (!path.startsWith(prefix)) return false;
|
|
6013
|
+
const rel = path.slice(prefix.length);
|
|
6014
|
+
return rel.length > 0 && !rel.includes("/");
|
|
6015
|
+
}
|
|
6016
|
+
|
|
6017
|
+
// src/view/notion-filter-translator.ts
|
|
6018
|
+
function translateNotionFilter(filter, schema, resolveName) {
|
|
6019
|
+
const ctx = { resolveName, schema, untranslated: /* @__PURE__ */ new Set() };
|
|
6020
|
+
const node = translateNode(filter, ctx);
|
|
6021
|
+
return {
|
|
6022
|
+
node,
|
|
6023
|
+
untranslated: [...ctx.untranslated].sort((a, b) => a.localeCompare(b))
|
|
6024
|
+
};
|
|
6025
|
+
}
|
|
6026
|
+
function translateNode(node, ctx) {
|
|
6027
|
+
if (!isRecord(node)) return null;
|
|
6028
|
+
if (Array.isArray(node["and"])) {
|
|
6029
|
+
const parts = node["and"].map((c) => translateNode(c, ctx)).filter(isNode);
|
|
6030
|
+
if (parts.length === 0) return null;
|
|
6031
|
+
return parts.length === 1 ? parts[0] : { and: parts };
|
|
6032
|
+
}
|
|
6033
|
+
if (Array.isArray(node["or"])) {
|
|
6034
|
+
const children = node["or"];
|
|
6035
|
+
const parts = [];
|
|
6036
|
+
for (const child of children) {
|
|
6037
|
+
const part = translateNode(child, ctx);
|
|
6038
|
+
if (part === null) {
|
|
6039
|
+
ctx.untranslated.add("or \uADF8\uB8F9 \uC548\uC5D0 \uC62E\uAE38 \uC218 \uC5C6\uB294 \uC870\uAC74\uC774 \uC788\uC5B4 \uADF8\uB8F9 \uC804\uCCB4\uB97C \uC0DD\uB7B5\uD568");
|
|
6040
|
+
return null;
|
|
6041
|
+
}
|
|
6042
|
+
parts.push(part);
|
|
6043
|
+
}
|
|
6044
|
+
if (parts.length === 0) return null;
|
|
6045
|
+
return parts.length === 1 ? parts[0] : { or: parts };
|
|
6046
|
+
}
|
|
6047
|
+
if (Array.isArray(node["not"])) {
|
|
6048
|
+
const parts = [];
|
|
6049
|
+
for (const child of node["not"]) {
|
|
6050
|
+
const part = translateNode(child, ctx);
|
|
6051
|
+
if (part === null) {
|
|
6052
|
+
ctx.untranslated.add("not \uADF8\uB8F9 \uC548\uC5D0 \uC62E\uAE38 \uC218 \uC5C6\uB294 \uC870\uAC74\uC774 \uC788\uC5B4 \uADF8\uB8F9 \uC804\uCCB4\uB97C \uC0DD\uB7B5\uD568");
|
|
6053
|
+
return null;
|
|
6054
|
+
}
|
|
6055
|
+
parts.push(part);
|
|
6056
|
+
}
|
|
6057
|
+
return parts.length === 0 ? null : { not: parts };
|
|
6058
|
+
}
|
|
6059
|
+
return translateLeaf(node, ctx);
|
|
6060
|
+
}
|
|
6061
|
+
function translateLeaf(leaf, ctx) {
|
|
6062
|
+
const timestamp = leaf["timestamp"];
|
|
6063
|
+
if (typeof timestamp === "string") {
|
|
6064
|
+
const ref2 = timestamp === "created_time" ? "file.ctime" : "file.mtime";
|
|
6065
|
+
const cond = leaf[timestamp];
|
|
6066
|
+
if (!isRecord(cond)) return unsupported(ctx, `timestamp '${timestamp}' \uD544\uD130`);
|
|
6067
|
+
return translateDate(ref2, cond, ctx, `timestamp '${timestamp}'`);
|
|
6068
|
+
}
|
|
6069
|
+
const propRaw = leaf["property"];
|
|
6070
|
+
if (typeof propRaw !== "string") return null;
|
|
6071
|
+
const propName = ctx.resolveName(propRaw);
|
|
6072
|
+
if (!propName) {
|
|
6073
|
+
return unsupported(ctx, `\uC18D\uC131 '${propRaw}' \uC744 \uC2A4\uD0A4\uB9C8\uC5D0\uC11C \uCC3E\uC9C0 \uBABB\uD574 \uC870\uAC74\uC744 \uC0DD\uB7B5\uD568`);
|
|
6074
|
+
}
|
|
6075
|
+
const ref = noteRef(propName);
|
|
6076
|
+
const label = `\uC18D\uC131 '${propName}'`;
|
|
6077
|
+
for (const [typeKey, condRaw] of Object.entries(leaf)) {
|
|
6078
|
+
if (typeKey === "property") continue;
|
|
6079
|
+
if (!isRecord(condRaw)) continue;
|
|
6080
|
+
switch (typeKey) {
|
|
6081
|
+
case "title":
|
|
6082
|
+
case "rich_text":
|
|
6083
|
+
case "url":
|
|
6084
|
+
case "email":
|
|
6085
|
+
case "phone_number":
|
|
6086
|
+
return translateText(ref, condRaw, ctx, label);
|
|
6087
|
+
case "number":
|
|
6088
|
+
return translateNumber(ref, condRaw, ctx, label);
|
|
6089
|
+
case "checkbox":
|
|
6090
|
+
return translateCheckbox(ref, condRaw, ctx, label);
|
|
6091
|
+
case "select":
|
|
6092
|
+
return translateSelect(ref, condRaw, ctx, label);
|
|
6093
|
+
case "status":
|
|
6094
|
+
return translateStatus(ref, condRaw, ctx, label, propName);
|
|
6095
|
+
case "multi_select":
|
|
6096
|
+
case "relation":
|
|
6097
|
+
case "people":
|
|
6098
|
+
return translateList(ref, condRaw, ctx, label);
|
|
6099
|
+
case "files":
|
|
6100
|
+
return translateEmptiness(ref, condRaw, ctx, label);
|
|
6101
|
+
case "date":
|
|
6102
|
+
case "created_time":
|
|
6103
|
+
case "last_edited_time":
|
|
6104
|
+
return translateDate(ref, condRaw, ctx, label);
|
|
6105
|
+
case "formula":
|
|
6106
|
+
case "rollup":
|
|
6107
|
+
return unsupported(ctx, `${label}: ${typeKey} \uD544\uD130\uB294 Bases \uB85C \uC62E\uAE38 \uC218 \uC5C6\uC74C`);
|
|
6108
|
+
default:
|
|
6109
|
+
return unsupported(ctx, `${label}: '${typeKey}' \uD544\uD130 \uC720\uD615 \uBBF8\uC9C0\uC6D0`);
|
|
6110
|
+
}
|
|
6111
|
+
}
|
|
6112
|
+
return null;
|
|
6113
|
+
}
|
|
6114
|
+
function translateText(ref, cond, ctx, label) {
|
|
6115
|
+
const empt = translateEmptinessOnly(ref, cond);
|
|
6116
|
+
if (empt) return empt;
|
|
6117
|
+
const [op, value] = firstEntry(cond);
|
|
6118
|
+
if (typeof value !== "string") return unsupported(ctx, `${label}: '${op}' \uAC12\uC774 \uBB38\uC790\uC5F4\uC774 \uC544\uB2D8`);
|
|
6119
|
+
const lit = str(value);
|
|
6120
|
+
switch (op) {
|
|
6121
|
+
case "equals":
|
|
6122
|
+
return `${ref} == ${lit}`;
|
|
6123
|
+
case "does_not_equal":
|
|
6124
|
+
return `${ref} != ${lit}`;
|
|
6125
|
+
case "contains":
|
|
6126
|
+
return `${ref}.contains(${lit})`;
|
|
6127
|
+
case "does_not_contain":
|
|
6128
|
+
return `!${ref}.contains(${lit})`;
|
|
6129
|
+
case "starts_with":
|
|
6130
|
+
return `${ref}.startsWith(${lit})`;
|
|
6131
|
+
case "ends_with":
|
|
6132
|
+
return `${ref}.endsWith(${lit})`;
|
|
6133
|
+
default:
|
|
6134
|
+
return unsupported(ctx, `${label}: \uD14D\uC2A4\uD2B8 \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
|
|
6135
|
+
}
|
|
6136
|
+
}
|
|
6137
|
+
function translateNumber(ref, cond, ctx, label) {
|
|
6138
|
+
const empt = translateEmptinessOnly(ref, cond);
|
|
6139
|
+
if (empt) return empt;
|
|
6140
|
+
const [op, value] = firstEntry(cond);
|
|
6141
|
+
if (typeof value !== "number") return unsupported(ctx, `${label}: '${op}' \uAC12\uC774 \uC22B\uC790\uAC00 \uC544\uB2D8`);
|
|
6142
|
+
switch (op) {
|
|
6143
|
+
case "equals":
|
|
6144
|
+
return `${ref} == ${value}`;
|
|
6145
|
+
case "does_not_equal":
|
|
6146
|
+
return `${ref} != ${value}`;
|
|
6147
|
+
case "greater_than":
|
|
6148
|
+
return `${ref} > ${value}`;
|
|
6149
|
+
case "less_than":
|
|
6150
|
+
return `${ref} < ${value}`;
|
|
6151
|
+
case "greater_than_or_equal_to":
|
|
6152
|
+
return `${ref} >= ${value}`;
|
|
6153
|
+
case "less_than_or_equal_to":
|
|
6154
|
+
return `${ref} <= ${value}`;
|
|
6155
|
+
default:
|
|
6156
|
+
return unsupported(ctx, `${label}: \uC22B\uC790 \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
|
|
6157
|
+
}
|
|
6158
|
+
}
|
|
6159
|
+
function translateCheckbox(ref, cond, ctx, label) {
|
|
6160
|
+
const [op, value] = firstEntry(cond);
|
|
6161
|
+
if (typeof value !== "boolean") return unsupported(ctx, `${label}: '${op}' \uAC12\uC774 \uBD88\uB9AC\uC5B8\uC774 \uC544\uB2D8`);
|
|
6162
|
+
if (op === "equals") return `${ref} == ${value}`;
|
|
6163
|
+
if (op === "does_not_equal") return `${ref} != ${value}`;
|
|
6164
|
+
return unsupported(ctx, `${label}: \uCCB4\uD06C\uBC15\uC2A4 \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
|
|
6165
|
+
}
|
|
6166
|
+
function translateSelect(ref, cond, ctx, label) {
|
|
6167
|
+
const empt = translateEmptinessOnly(ref, cond);
|
|
6168
|
+
if (empt) return empt;
|
|
6169
|
+
const [op, value] = firstEntry(cond);
|
|
6170
|
+
const names = toStringList(value);
|
|
6171
|
+
if (names === null) return unsupported(ctx, `${label}: '${op}' \uAC12 \uD615\uD0DC\uB97C \uD574\uC11D\uD558\uC9C0 \uBABB\uD568`);
|
|
6172
|
+
return equalityOverNames(ref, op, names, ctx, label);
|
|
6173
|
+
}
|
|
6174
|
+
function translateStatus(ref, cond, ctx, label, propName) {
|
|
6175
|
+
const empt = translateEmptinessOnly(ref, cond);
|
|
6176
|
+
if (empt) return empt;
|
|
6177
|
+
const [op, value] = firstEntry(cond);
|
|
6178
|
+
const raw = toStringList(value);
|
|
6179
|
+
if (raw === null) return unsupported(ctx, `${label}: '${op}' \uAC12 \uD615\uD0DC\uB97C \uD574\uC11D\uD558\uC9C0 \uBABB\uD568`);
|
|
6180
|
+
const prop = ctx.schema[propName];
|
|
6181
|
+
const optionNames = new Set((prop?.options ?? []).map((o) => o.name));
|
|
6182
|
+
const expanded = [];
|
|
6183
|
+
for (const name of raw) {
|
|
6184
|
+
if (optionNames.has(name)) {
|
|
6185
|
+
expanded.push(name);
|
|
6186
|
+
continue;
|
|
5300
6187
|
}
|
|
5301
|
-
|
|
6188
|
+
const group = prop?.groups?.find((g) => g.name === name);
|
|
6189
|
+
const members = group && prop ? expandGroup(group, prop) : null;
|
|
6190
|
+
if (members === null) {
|
|
6191
|
+
return unsupported(ctx, `${label}: \uC0C1\uD0DC '${name}' \uC744 \uC635\uC158\uC73C\uB85C \uC804\uAC1C\uD558\uC9C0 \uBABB\uD568`);
|
|
6192
|
+
}
|
|
6193
|
+
expanded.push(...members);
|
|
5302
6194
|
}
|
|
5303
|
-
|
|
6195
|
+
if (expanded.length === 0) {
|
|
6196
|
+
return unsupported(ctx, `${label}: \uC0C1\uD0DC \uADF8\uB8F9\uC5D0 \uC18D\uD55C \uC635\uC158\uC774 \uC5C6\uC5B4 \uC870\uAC74\uC744 \uC0DD\uB7B5\uD568`);
|
|
6197
|
+
}
|
|
6198
|
+
return equalityOverNames(ref, op, dedupe(expanded), ctx, label);
|
|
5304
6199
|
}
|
|
5305
|
-
function
|
|
5306
|
-
const
|
|
5307
|
-
if (!
|
|
5308
|
-
const
|
|
5309
|
-
|
|
6200
|
+
function expandGroup(group, prop) {
|
|
6201
|
+
const ids = group.optionIds;
|
|
6202
|
+
if (!ids || ids.length === 0) return [];
|
|
6203
|
+
const byId = new Map((prop.options ?? []).map((o) => [o.id, o.name]));
|
|
6204
|
+
const names = [];
|
|
6205
|
+
for (const id of ids) {
|
|
6206
|
+
const name = id === void 0 ? void 0 : byId.get(id);
|
|
6207
|
+
if (name === void 0) return null;
|
|
6208
|
+
names.push(name);
|
|
6209
|
+
}
|
|
6210
|
+
return names;
|
|
6211
|
+
}
|
|
6212
|
+
function equalityOverNames(ref, op, names, ctx, label) {
|
|
6213
|
+
if (names.length === 0) return unsupported(ctx, `${label}: '${op}' \uBE44\uAD50 \uB300\uC0C1\uC774 \uBE44\uC5B4 \uC788\uC74C`);
|
|
6214
|
+
if (op === "equals") {
|
|
6215
|
+
const parts = names.map((n) => `${ref} == ${str(n)}`);
|
|
6216
|
+
return parts.length === 1 ? parts[0] : { or: parts };
|
|
6217
|
+
}
|
|
6218
|
+
if (op === "does_not_equal") {
|
|
6219
|
+
const parts = names.map((n) => `${ref} != ${str(n)}`);
|
|
6220
|
+
return parts.length === 1 ? parts[0] : { and: parts };
|
|
6221
|
+
}
|
|
6222
|
+
return unsupported(ctx, `${label}: \uC120\uD0DD \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
|
|
6223
|
+
}
|
|
6224
|
+
function translateList(ref, cond, ctx, label) {
|
|
6225
|
+
const empt = translateEmptinessOnly(ref, cond);
|
|
6226
|
+
if (empt) return empt;
|
|
6227
|
+
const [op, value] = firstEntry(cond);
|
|
6228
|
+
const names = toStringList(value);
|
|
6229
|
+
if (names === null) return unsupported(ctx, `${label}: '${op}' \uAC12 \uD615\uD0DC\uB97C \uD574\uC11D\uD558\uC9C0 \uBABB\uD568`);
|
|
6230
|
+
const args = names.map(str).join(", ");
|
|
6231
|
+
if (op === "contains") return `${ref}.containsAny(${args})`;
|
|
6232
|
+
if (op === "does_not_contain") return `!${ref}.containsAny(${args})`;
|
|
6233
|
+
return unsupported(ctx, `${label}: \uBAA9\uB85D \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
|
|
6234
|
+
}
|
|
6235
|
+
function translateEmptiness(ref, cond, ctx, label) {
|
|
6236
|
+
const empt = translateEmptinessOnly(ref, cond);
|
|
6237
|
+
if (empt) return empt;
|
|
6238
|
+
const [op] = firstEntry(cond);
|
|
6239
|
+
return unsupported(ctx, `${label}: \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
|
|
6240
|
+
}
|
|
6241
|
+
function translateDate(ref, cond, ctx, label) {
|
|
6242
|
+
const empt = translateEmptinessOnly(ref, cond);
|
|
6243
|
+
if (empt) return empt;
|
|
6244
|
+
const [op, value] = firstEntry(cond);
|
|
6245
|
+
if (typeof value !== "string") {
|
|
6246
|
+
return unsupported(ctx, `${label}: \uC0C1\uB300 \uB0A0\uC9DC \uC870\uAC74 '${op}' \uC740 \uC62E\uAE30\uC9C0 \uC54A\uC74C`);
|
|
6247
|
+
}
|
|
6248
|
+
const lit = str(value);
|
|
6249
|
+
switch (op) {
|
|
6250
|
+
case "equals":
|
|
6251
|
+
return `${ref} == ${lit}`;
|
|
6252
|
+
case "before":
|
|
6253
|
+
return `${ref} < ${lit}`;
|
|
6254
|
+
case "after":
|
|
6255
|
+
return `${ref} > ${lit}`;
|
|
6256
|
+
case "on_or_before":
|
|
6257
|
+
return `${ref} <= ${lit}`;
|
|
6258
|
+
case "on_or_after":
|
|
6259
|
+
return `${ref} >= ${lit}`;
|
|
6260
|
+
default:
|
|
6261
|
+
return unsupported(ctx, `${label}: \uB0A0\uC9DC \uC5F0\uC0B0\uC790 '${op}' \uBBF8\uC9C0\uC6D0`);
|
|
6262
|
+
}
|
|
6263
|
+
}
|
|
6264
|
+
function translateEmptinessOnly(ref, cond) {
|
|
6265
|
+
if (cond["is_empty"] === true) return `${ref}.isEmpty()`;
|
|
6266
|
+
if (cond["is_not_empty"] === true) return `!${ref}.isEmpty()`;
|
|
6267
|
+
return null;
|
|
6268
|
+
}
|
|
6269
|
+
function unsupported(ctx, reason) {
|
|
6270
|
+
ctx.untranslated.add(reason);
|
|
6271
|
+
return null;
|
|
6272
|
+
}
|
|
6273
|
+
function noteRef(propertyName) {
|
|
6274
|
+
return `note[${str(propertyName)}]`;
|
|
6275
|
+
}
|
|
6276
|
+
function str(value) {
|
|
6277
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
6278
|
+
}
|
|
6279
|
+
function toStringList(value) {
|
|
6280
|
+
if (typeof value === "string") return [value];
|
|
6281
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string")) return value;
|
|
6282
|
+
return null;
|
|
6283
|
+
}
|
|
6284
|
+
function firstEntry(cond) {
|
|
6285
|
+
const entries = Object.entries(cond);
|
|
6286
|
+
return entries[0] ?? ["", void 0];
|
|
6287
|
+
}
|
|
6288
|
+
function dedupe(values) {
|
|
6289
|
+
return [...new Set(values)];
|
|
6290
|
+
}
|
|
6291
|
+
function isRecord(value) {
|
|
6292
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6293
|
+
}
|
|
6294
|
+
function isNode(value) {
|
|
6295
|
+
return value !== null;
|
|
5310
6296
|
}
|
|
5311
6297
|
|
|
5312
|
-
// src/
|
|
5313
|
-
function
|
|
5314
|
-
|
|
6298
|
+
// src/view/property-resolver.ts
|
|
6299
|
+
function resolvePropertyName(propertyId, schema) {
|
|
6300
|
+
const target = decodePropertyId(propertyId);
|
|
6301
|
+
for (const [name, prop] of Object.entries(schema)) {
|
|
6302
|
+
if (decodePropertyId(prop.id) === target) return name;
|
|
6303
|
+
}
|
|
6304
|
+
return schema[propertyId] ? propertyId : void 0;
|
|
6305
|
+
}
|
|
6306
|
+
function decodePropertyId(id) {
|
|
6307
|
+
try {
|
|
6308
|
+
return decodeURIComponent(id);
|
|
6309
|
+
} catch {
|
|
6310
|
+
return id;
|
|
6311
|
+
}
|
|
5315
6312
|
}
|
|
5316
6313
|
|
|
5317
6314
|
// src/view/base-file-generator.ts
|
|
5318
6315
|
var NOTION_TO_BASES_VIEW = {
|
|
5319
|
-
table: "table",
|
|
5320
|
-
gallery: "cards",
|
|
5321
|
-
list: "list",
|
|
5322
|
-
board: "cards",
|
|
5323
|
-
calendar:
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
6316
|
+
table: { type: "table" },
|
|
6317
|
+
gallery: { type: "cards" },
|
|
6318
|
+
list: { type: "list" },
|
|
6319
|
+
board: { type: "cards" },
|
|
6320
|
+
calendar: {
|
|
6321
|
+
type: "table",
|
|
6322
|
+
degradeReason: "\uB2EC\uB825 \uACA9\uC790\uB294 Bases \uBBF8\uC9C0\uC6D0 \u2014 \uB0A0\uC9DC \uC624\uB984\uCC28\uC21C \uD45C\uB85C \uACA9\uD558"
|
|
6323
|
+
},
|
|
6324
|
+
timeline: {
|
|
6325
|
+
type: "table",
|
|
6326
|
+
degradeReason: "\uD0C0\uC784\uB77C\uC778 \uCD95\uC740 Bases \uBBF8\uC9C0\uC6D0 \u2014 \uB0A0\uC9DC \uC624\uB984\uCC28\uC21C \uD45C\uB85C \uACA9\uD558"
|
|
6327
|
+
},
|
|
6328
|
+
form: { type: null },
|
|
6329
|
+
chart: { type: null },
|
|
6330
|
+
map: { type: null },
|
|
6331
|
+
dashboard: { type: null }
|
|
5329
6332
|
};
|
|
6333
|
+
function basesViewMappingOf(notionType) {
|
|
6334
|
+
return NOTION_TO_BASES_VIEW[notionType] ?? { type: null };
|
|
6335
|
+
}
|
|
5330
6336
|
function basesViewTypeOf(notionType) {
|
|
5331
|
-
return
|
|
6337
|
+
return basesViewMappingOf(notionType).type;
|
|
6338
|
+
}
|
|
6339
|
+
function filterGroup(node) {
|
|
6340
|
+
if ("and" in node) return ["and", node.and];
|
|
6341
|
+
if ("or" in node) return ["or", node.or];
|
|
6342
|
+
return ["not", node.not];
|
|
5332
6343
|
}
|
|
5333
6344
|
var BaseFileGenerator = class {
|
|
5334
6345
|
generate(options) {
|
|
@@ -5406,8 +6417,49 @@ var BaseFileGenerator = class {
|
|
|
5406
6417
|
if (view.image) {
|
|
5407
6418
|
lines.push(` image: ${this.propRef(view.image)}`);
|
|
5408
6419
|
}
|
|
6420
|
+
if (view.filters) {
|
|
6421
|
+
this.writeFilterValue(lines, view.filters, " ");
|
|
6422
|
+
}
|
|
6423
|
+
}
|
|
6424
|
+
}
|
|
6425
|
+
/** `filters:` 키와 그 값을 쓴다. 단일 식이면 한 줄, 그룹이면 블록. */
|
|
6426
|
+
writeFilterValue(lines, node, indent) {
|
|
6427
|
+
if (typeof node === "string") {
|
|
6428
|
+
lines.push(`${indent}filters: ${this.yamlExpr(node)}`);
|
|
6429
|
+
return;
|
|
6430
|
+
}
|
|
6431
|
+
lines.push(`${indent}filters:`);
|
|
6432
|
+
const [op, children] = filterGroup(node);
|
|
6433
|
+
lines.push(`${indent} ${op}:`);
|
|
6434
|
+
for (const child of children) {
|
|
6435
|
+
this.writeFilterItem(lines, child, `${indent} `);
|
|
6436
|
+
}
|
|
6437
|
+
}
|
|
6438
|
+
/** 필터 노드를 시퀀스 항목(`- …`)으로 쓴다. 그룹이면 재귀적으로 중첩한다. */
|
|
6439
|
+
writeFilterItem(lines, node, indent) {
|
|
6440
|
+
if (typeof node === "string") {
|
|
6441
|
+
lines.push(`${indent}- ${this.yamlExpr(node)}`);
|
|
6442
|
+
return;
|
|
6443
|
+
}
|
|
6444
|
+
const [op, children] = filterGroup(node);
|
|
6445
|
+
lines.push(`${indent}- ${op}:`);
|
|
6446
|
+
for (const child of children) {
|
|
6447
|
+
this.writeFilterItem(lines, child, `${indent} `);
|
|
5409
6448
|
}
|
|
5410
6449
|
}
|
|
6450
|
+
/**
|
|
6451
|
+
* 필터 식을 YAML 스칼라로 쓴다.
|
|
6452
|
+
*
|
|
6453
|
+
* 식은 대개 `note["상태"] == "진행 중"` 처럼 큰따옴표를 품는다. 평문 스칼라는 큰따옴표를
|
|
6454
|
+
* 안에 담을 수 있으므로(선두만 아니면 된다) 기본은 평문으로 두어 기존 `file.ext == "md"`
|
|
6455
|
+
* 라인과 모양을 맞춘다. 다만 `: `·` #`·선두 지시문자는 평문에서 의미가 달라지므로
|
|
6456
|
+
* 그때만 작은따옴표로 감싼다(속성명에 콜론이나 해시가 들어갈 수 있다).
|
|
6457
|
+
*/
|
|
6458
|
+
yamlExpr(expr) {
|
|
6459
|
+
const unsafe = expr !== expr.trim() || expr.includes(": ") || expr.includes(" #") || expr.endsWith(":") || /^[-?:,[\]{}#&*!|>'"%@`]/.test(expr);
|
|
6460
|
+
if (!unsafe) return expr;
|
|
6461
|
+
return `'${expr.replace(/'/g, "''")}'`;
|
|
6462
|
+
}
|
|
5411
6463
|
/**
|
|
5412
6464
|
* 뷰 이름을 Bases 요구사항대로 유일화한다(in-place). 동명 충돌 시 ' 2',' 3'… 를 붙이되
|
|
5413
6465
|
* 이미 존재하는 이름과 다시 충돌하지 않을 때까지 증가시킨다.
|
|
@@ -5426,7 +6478,7 @@ var BaseFileGenerator = class {
|
|
|
5426
6478
|
}
|
|
5427
6479
|
}
|
|
5428
6480
|
convertView(view, schema) {
|
|
5429
|
-
const basesType =
|
|
6481
|
+
const basesType = basesViewMappingOf(view.type).type;
|
|
5430
6482
|
if (!basesType) return null;
|
|
5431
6483
|
const result = {
|
|
5432
6484
|
type: basesType,
|
|
@@ -5469,6 +6521,12 @@ var BaseFileGenerator = class {
|
|
|
5469
6521
|
};
|
|
5470
6522
|
}
|
|
5471
6523
|
}
|
|
6524
|
+
if ((view.type === "calendar" || view.type === "timeline") && !result.sort?.length) {
|
|
6525
|
+
const dateName = view.datePropertyName ?? (view.datePropertyId ? this.resolvePropertyName(view.datePropertyId, schema) : void 0);
|
|
6526
|
+
if (dateName) {
|
|
6527
|
+
result.sort = [{ property: this.toNoteRef(dateName), direction: "ASC" }];
|
|
6528
|
+
}
|
|
6529
|
+
}
|
|
5472
6530
|
if (view.type === "gallery" && view.cover) {
|
|
5473
6531
|
if (view.cover.type === "property" && view.cover.propertyId) {
|
|
5474
6532
|
const propName = this.resolvePropertyName(view.cover.propertyId, schema);
|
|
@@ -5479,6 +6537,14 @@ var BaseFileGenerator = class {
|
|
|
5479
6537
|
result.image = "formula.coverImage";
|
|
5480
6538
|
}
|
|
5481
6539
|
}
|
|
6540
|
+
if (view.filter !== void 0 && view.filter !== null) {
|
|
6541
|
+
const { node } = translateNotionFilter(
|
|
6542
|
+
view.filter,
|
|
6543
|
+
schema,
|
|
6544
|
+
(id) => this.resolvePropertyName(id, schema)
|
|
6545
|
+
);
|
|
6546
|
+
if (node) result.filters = node;
|
|
6547
|
+
}
|
|
5482
6548
|
return result;
|
|
5483
6549
|
}
|
|
5484
6550
|
toNoteRef(propertyName) {
|
|
@@ -5494,18 +6560,7 @@ var BaseFileGenerator = class {
|
|
|
5494
6560
|
return `"${ref.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
5495
6561
|
}
|
|
5496
6562
|
resolvePropertyName(propertyId, schema) {
|
|
5497
|
-
|
|
5498
|
-
for (const [name, prop] of Object.entries(schema)) {
|
|
5499
|
-
if (this.decodeId(prop.id) === target) return name;
|
|
5500
|
-
}
|
|
5501
|
-
return void 0;
|
|
5502
|
-
}
|
|
5503
|
-
decodeId(id) {
|
|
5504
|
-
try {
|
|
5505
|
-
return decodeURIComponent(id);
|
|
5506
|
-
} catch {
|
|
5507
|
-
return id;
|
|
5508
|
-
}
|
|
6563
|
+
return resolvePropertyName(propertyId, schema);
|
|
5509
6564
|
}
|
|
5510
6565
|
yamlKey(key) {
|
|
5511
6566
|
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) return key;
|
|
@@ -5519,7 +6574,6 @@ var BaseFileGenerator = class {
|
|
|
5519
6574
|
|
|
5520
6575
|
// src/view/sidecar-generator.ts
|
|
5521
6576
|
var DROPPED_SETTING_REASONS = {
|
|
5522
|
-
filter: "Notion \uD544\uD130\uC2DD\uC740 .base \uB85C \uD45C\uD604\uB418\uC9C0 \uC54A\uC74C(\uD3F4\uB354 \uD544\uD130\uB9CC \uC801\uC6A9)",
|
|
5523
6577
|
coverSize: "\uAC24\uB7EC\uB9AC \uCEE4\uBC84 \uD06C\uAE30\uB294 Bases \uBBF8\uC9C0\uC6D0",
|
|
5524
6578
|
coverAspect: "\uAC24\uB7EC\uB9AC \uCEE4\uBC84 \uBE44\uC728\uC740 Bases \uBBF8\uC9C0\uC6D0",
|
|
5525
6579
|
viewRange: "\uCE98\uB9B0\uB354 \uD45C\uC2DC \uBC94\uC704(week/month)\uB294 Bases \uBBF8\uC9C0\uC6D0",
|
|
@@ -5534,7 +6588,7 @@ var SidecarGenerator = class {
|
|
|
5534
6588
|
build(options) {
|
|
5535
6589
|
const degraded = [];
|
|
5536
6590
|
const properties = this.buildProperties(options.schema);
|
|
5537
|
-
const views = options.viewsConfig.views.map((v) => this.buildView(v, degraded));
|
|
6591
|
+
const views = options.viewsConfig.views.map((v) => this.buildView(v, options.schema, degraded));
|
|
5538
6592
|
degraded.sort(
|
|
5539
6593
|
(a, b) => a.view.localeCompare(b.view) || a.kind.localeCompare(b.kind) || a.detail.localeCompare(b.detail)
|
|
5540
6594
|
);
|
|
@@ -5566,8 +6620,9 @@ var SidecarGenerator = class {
|
|
|
5566
6620
|
};
|
|
5567
6621
|
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
5568
6622
|
}
|
|
5569
|
-
buildView(view, degraded) {
|
|
5570
|
-
const
|
|
6623
|
+
buildView(view, schema, degraded) {
|
|
6624
|
+
const mapping = basesViewMappingOf(view.type);
|
|
6625
|
+
const basesType = mapping.type;
|
|
5571
6626
|
const viewName = view.name || view.type;
|
|
5572
6627
|
if (basesType === null) {
|
|
5573
6628
|
degraded.push({
|
|
@@ -5575,8 +6630,14 @@ var SidecarGenerator = class {
|
|
|
5575
6630
|
kind: "view-unrepresentable",
|
|
5576
6631
|
detail: `'${view.type}' \uBDF0\uB294 Obsidian Bases \uB300\uC751\uC774 \uC5C6\uC5B4 .base \uC5D0\uC11C \uB204\uB77D \u2014 \uC0AC\uC774\uB4DC\uCE74\uB85C\uB9CC \uBCF4\uC874`
|
|
5577
6632
|
});
|
|
6633
|
+
} else if (mapping.degradeReason) {
|
|
6634
|
+
degraded.push({
|
|
6635
|
+
view: viewName,
|
|
6636
|
+
kind: "view-degraded",
|
|
6637
|
+
detail: `'${view.type}' \uBDF0: ${mapping.degradeReason}`
|
|
6638
|
+
});
|
|
5578
6639
|
}
|
|
5579
|
-
const config = this.extractConfig(view, basesType, viewName, degraded);
|
|
6640
|
+
const config = this.extractConfig(view, schema, basesType, viewName, degraded);
|
|
5580
6641
|
return {
|
|
5581
6642
|
id: view.id,
|
|
5582
6643
|
name: viewName,
|
|
@@ -5590,7 +6651,7 @@ var SidecarGenerator = class {
|
|
|
5590
6651
|
* config 에 보존하면서 동시에 degrade 리포트에 1줄씩 기록한다(표현되는 뷰에 한해 —
|
|
5591
6652
|
* 미표현 뷰는 이미 view-unrepresentable 로 통째 보고됨).
|
|
5592
6653
|
*/
|
|
5593
|
-
extractConfig(view, basesType, viewName, degraded) {
|
|
6654
|
+
extractConfig(view, schema, basesType, viewName, degraded) {
|
|
5594
6655
|
const config = {};
|
|
5595
6656
|
const noteDrop = (key) => {
|
|
5596
6657
|
if (basesType !== null && DROPPED_SETTING_REASONS[key]) {
|
|
@@ -5603,7 +6664,7 @@ var SidecarGenerator = class {
|
|
|
5603
6664
|
};
|
|
5604
6665
|
if (view.filter !== void 0 && view.filter !== null) {
|
|
5605
6666
|
config["filter"] = view.filter;
|
|
5606
|
-
|
|
6667
|
+
this.noteFilterDegrade(view.filter, schema, basesType, viewName, degraded);
|
|
5607
6668
|
}
|
|
5608
6669
|
if (view.sorts && view.sorts.length > 0) config["sorts"] = view.sorts;
|
|
5609
6670
|
if (view.groupBy) {
|
|
@@ -5632,6 +6693,36 @@ var SidecarGenerator = class {
|
|
|
5632
6693
|
}
|
|
5633
6694
|
return config;
|
|
5634
6695
|
}
|
|
6696
|
+
/**
|
|
6697
|
+
* 필터 손실을 **실제로 못 옮긴 것만** 보고한다.
|
|
6698
|
+
*
|
|
6699
|
+
* 예전엔 필터가 있기만 하면 "Notion 필터식은 .base 로 표현되지 않음"을 무조건 찍었다.
|
|
6700
|
+
* 이제 `.base` 가 뷰별 `filters:` 를 내보내므로 그 문구는 대부분의 경우 거짓이다.
|
|
6701
|
+
* 거짓 degrade 는 진짜 degrade 를 묻어 버리므로, `.base` 와 **같은 번역기**를 돌려
|
|
6702
|
+
* 남은 것만 적는다(두 산출물이 어긋날 여지를 없앤다).
|
|
6703
|
+
*/
|
|
6704
|
+
noteFilterDegrade(filter, schema, basesType, viewName, degraded) {
|
|
6705
|
+
if (basesType === null) return;
|
|
6706
|
+
const { node, untranslated } = translateNotionFilter(
|
|
6707
|
+
filter,
|
|
6708
|
+
schema,
|
|
6709
|
+
(id) => resolvePropertyName(id, schema)
|
|
6710
|
+
);
|
|
6711
|
+
for (const reason of untranslated) {
|
|
6712
|
+
degraded.push({
|
|
6713
|
+
view: viewName,
|
|
6714
|
+
kind: "setting-dropped",
|
|
6715
|
+
detail: `\uD544\uD130 \uC77C\uBD80 \uB204\uB77D \u2014 ${reason}`
|
|
6716
|
+
});
|
|
6717
|
+
}
|
|
6718
|
+
if (node === null && untranslated.length === 0) {
|
|
6719
|
+
degraded.push({
|
|
6720
|
+
view: viewName,
|
|
6721
|
+
kind: "setting-dropped",
|
|
6722
|
+
detail: "\uD544\uD130 \uB204\uB77D \u2014 Notion \uD544\uD130\uC2DD\uC744 \uD574\uC11D\uD558\uC9C0 \uBABB\uD574 .base \uC5D0 \uC62E\uAE30\uC9C0 \uBABB\uD568"
|
|
6723
|
+
});
|
|
6724
|
+
}
|
|
6725
|
+
}
|
|
5635
6726
|
};
|
|
5636
6727
|
function stableStringify(value) {
|
|
5637
6728
|
return JSON.stringify(sortKeys(value), null, 2);
|
|
@@ -5648,6 +6739,94 @@ function sortKeys(value) {
|
|
|
5648
6739
|
return value;
|
|
5649
6740
|
}
|
|
5650
6741
|
|
|
6742
|
+
// src/converter/toggle-heading.ts
|
|
6743
|
+
var NFM_TOGGLE_HEADING_RE = /^([\t ]*)(#{1,6})[ \t]+(.*?)[ \t]*\{toggle="true"\}[ \t]*$/;
|
|
6744
|
+
var MARKED_TOGGLE_HEADING_RE = new RegExp(
|
|
6745
|
+
`^([\\t ]*)(#{1,6})[ \\t]+(.*?)[ \\t]*${TOGGLE_HEADING_START}[ \\t]*$`
|
|
6746
|
+
);
|
|
6747
|
+
var ANY_HEADING_RE = /^(#{1,6})[ \t]/;
|
|
6748
|
+
function headingLine(hashes, title, suffix) {
|
|
6749
|
+
return [hashes, title.trim(), suffix].filter((part) => part !== "").join(" ");
|
|
6750
|
+
}
|
|
6751
|
+
function convertToggleHeadings(content) {
|
|
6752
|
+
return convertLevel(content.split("\n")).join("\n");
|
|
6753
|
+
}
|
|
6754
|
+
function convertLevel(lines) {
|
|
6755
|
+
const out = [];
|
|
6756
|
+
const ends = containerBlockEnds(lines);
|
|
6757
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6758
|
+
const match = NFM_TOGGLE_HEADING_RE.exec(lines[i]);
|
|
6759
|
+
if (!match) {
|
|
6760
|
+
out.push(lines[i]);
|
|
6761
|
+
continue;
|
|
6762
|
+
}
|
|
6763
|
+
const [, indent, hashes, title] = match;
|
|
6764
|
+
const childIndent = `${indent} `;
|
|
6765
|
+
let lastChild = i;
|
|
6766
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
6767
|
+
const line = lines[j];
|
|
6768
|
+
if (line.trim() === "") continue;
|
|
6769
|
+
if (!line.startsWith(childIndent)) break;
|
|
6770
|
+
lastChild = ends[j];
|
|
6771
|
+
j = lastChild;
|
|
6772
|
+
}
|
|
6773
|
+
if (lastChild === i) {
|
|
6774
|
+
out.push(indent + headingLine(hashes, title, TOGGLE_HEADING_START));
|
|
6775
|
+
continue;
|
|
6776
|
+
}
|
|
6777
|
+
const body = dedentContainerBody(lines.slice(i + 1, lastChild + 1).join("\n"));
|
|
6778
|
+
const inner = convertLevel(body.split("\n")).join("\n");
|
|
6779
|
+
out.push(indent + headingLine(hashes, title, TOGGLE_HEADING_START));
|
|
6780
|
+
out.push(...(indent === "" ? inner : indentContainerBody(inner, indent)).split("\n"));
|
|
6781
|
+
out.push(indent + TOGGLE_HEADING_END);
|
|
6782
|
+
i = lastChild;
|
|
6783
|
+
}
|
|
6784
|
+
return out;
|
|
6785
|
+
}
|
|
6786
|
+
function restoreToggleHeadings(content) {
|
|
6787
|
+
return restoreLevel(content.split("\n")).join("\n");
|
|
6788
|
+
}
|
|
6789
|
+
function restoreLevel(lines) {
|
|
6790
|
+
const out = [];
|
|
6791
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6792
|
+
const line = lines[i];
|
|
6793
|
+
const match = MARKED_TOGGLE_HEADING_RE.exec(line);
|
|
6794
|
+
if (!match) {
|
|
6795
|
+
if (line.trim() !== TOGGLE_HEADING_END) out.push(line);
|
|
6796
|
+
continue;
|
|
6797
|
+
}
|
|
6798
|
+
const [, indent, hashes, title] = match;
|
|
6799
|
+
const bound = findBodyEnd(lines, i + 1, hashes.length);
|
|
6800
|
+
const raw = stripContainerIndent(lines.slice(i + 1, bound.bodyEnd).join("\n"), indent);
|
|
6801
|
+
const inner = restoreLevel(raw.split("\n"));
|
|
6802
|
+
const body = inner.join("\n").replace(/^\n+/, "").replace(/\n+$/, "");
|
|
6803
|
+
out.push(indent + headingLine(hashes, title, '{toggle="true"}'));
|
|
6804
|
+
if (body.trim() !== "") out.push(...indentContainerBody(body, `${indent} `).split("\n"));
|
|
6805
|
+
i = bound.consumed;
|
|
6806
|
+
}
|
|
6807
|
+
return out;
|
|
6808
|
+
}
|
|
6809
|
+
function findBodyEnd(lines, from, level) {
|
|
6810
|
+
let depth = 0;
|
|
6811
|
+
for (let j = from; j < lines.length; j++) {
|
|
6812
|
+
const line = lines[j];
|
|
6813
|
+
if (MARKED_TOGGLE_HEADING_RE.test(line)) {
|
|
6814
|
+
depth++;
|
|
6815
|
+
continue;
|
|
6816
|
+
}
|
|
6817
|
+
if (line.trim() === TOGGLE_HEADING_END) {
|
|
6818
|
+
if (depth > 0) {
|
|
6819
|
+
depth--;
|
|
6820
|
+
continue;
|
|
6821
|
+
}
|
|
6822
|
+
return { bodyEnd: j, consumed: j };
|
|
6823
|
+
}
|
|
6824
|
+
const heading = ANY_HEADING_RE.exec(line);
|
|
6825
|
+
if (heading && heading[1].length <= level) return { bodyEnd: j, consumed: j - 1 };
|
|
6826
|
+
}
|
|
6827
|
+
return { bodyEnd: lines.length, consumed: lines.length - 1 };
|
|
6828
|
+
}
|
|
6829
|
+
|
|
5651
6830
|
// src/converter/enhanced-md-converter.ts
|
|
5652
6831
|
var NOTION_CALLOUT_RE = /^::: callout\n([\s\S]*?)\n:::/gm;
|
|
5653
6832
|
var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
|
|
@@ -5660,10 +6839,14 @@ var NOTION_VIDEO_RE = /[\t ]*<video src="([^"]*)">([\s\S]*?)<\/video>/g;
|
|
|
5660
6839
|
var NOTION_PDF_RE = /[\t ]*<pdf src="([^"]*)">([\s\S]*?)<\/pdf>/g;
|
|
5661
6840
|
var NOTION_FILE_RE = /[\t ]*<file src="([^"]*)">([\s\S]*?)<\/file>/g;
|
|
5662
6841
|
var NOTION_TAB_RE = /<tab title="([^"]*)">([\s\S]*?)<\/tab>/g;
|
|
6842
|
+
var NOTION_LABELED_PAGE_LINK_RE = new RegExp(
|
|
6843
|
+
`\\[${MARKER_LABEL_CAPTURE}\\]\\(https?:\\/\\/(?:[a-z]+\\.)?notion\\.(?:so|com)\\/(?:p\\/)?([a-f0-9]{32}|[a-f0-9-]{36})\\)`,
|
|
6844
|
+
"g"
|
|
6845
|
+
);
|
|
5663
6846
|
function notionEnhancedToObsidian(enhanced) {
|
|
5664
6847
|
let result = enhanced;
|
|
6848
|
+
result = convertToggleHeadings(result);
|
|
5665
6849
|
result = convertSyncedBlockRef(result);
|
|
5666
|
-
result = normalizeCodeBlockToggles(result);
|
|
5667
6850
|
result = convertContainers(result);
|
|
5668
6851
|
result = convertFencedCallouts(result);
|
|
5669
6852
|
result = convertPageMentions(result);
|
|
@@ -5673,6 +6856,7 @@ function notionEnhancedToObsidian(enhanced) {
|
|
|
5673
6856
|
result = convertMediaTags(result);
|
|
5674
6857
|
result = convertTabBlocks(result);
|
|
5675
6858
|
result = preserveUnknownBlocks(result);
|
|
6859
|
+
result = preserveNfmOnlyBlocks(result);
|
|
5676
6860
|
result = convertNotionMath(result);
|
|
5677
6861
|
result = convertNotionTables(result);
|
|
5678
6862
|
result = convertSpans(result);
|
|
@@ -5681,13 +6865,24 @@ function notionEnhancedToObsidian(enhanced) {
|
|
|
5681
6865
|
result = removeEmptyBlocks(result);
|
|
5682
6866
|
result = unescapePipes(result);
|
|
5683
6867
|
result = unescapeNotionChars(result);
|
|
5684
|
-
result =
|
|
6868
|
+
result = unescapeBrackets(result);
|
|
5685
6869
|
result = ensureCalloutContinuity(result);
|
|
5686
6870
|
result = separateAdjacentCallouts(result);
|
|
6871
|
+
result = clampCalloutIndent(result);
|
|
5687
6872
|
return result;
|
|
5688
6873
|
}
|
|
5689
|
-
function
|
|
5690
|
-
return
|
|
6874
|
+
function unescapeBrackets(content) {
|
|
6875
|
+
return mapOutsideCodeFences(
|
|
6876
|
+
content,
|
|
6877
|
+
(segment) => (
|
|
6878
|
+
// 첫 갈래가 escape 된 백슬래시 쌍을 통째로 소비해, 뒤따르는 대괄호가 escape 로
|
|
6879
|
+
// 오인되지 않게 한다. 갈래 순서가 곧 우선순위다.
|
|
6880
|
+
segment.replace(
|
|
6881
|
+
/\\\\|\\\[[^\n[\]]*\\\]\(|\\([[\]])/g,
|
|
6882
|
+
(match, bracket) => bracket === void 0 ? match : bracket
|
|
6883
|
+
)
|
|
6884
|
+
)
|
|
6885
|
+
);
|
|
5691
6886
|
}
|
|
5692
6887
|
function obsidianToNotionEnhanced(obsidian) {
|
|
5693
6888
|
let result = obsidian;
|
|
@@ -5696,6 +6891,7 @@ function obsidianToNotionEnhanced(obsidian) {
|
|
|
5696
6891
|
result = convertObsidianCallouts(result);
|
|
5697
6892
|
result = restoreMediaTags(result);
|
|
5698
6893
|
result = restoreUnknownBlocks(result);
|
|
6894
|
+
result = restoreNfmOnlyBlocks(result);
|
|
5699
6895
|
result = restoreColorSpans(result);
|
|
5700
6896
|
result = restoreBlockColorMarkers(result);
|
|
5701
6897
|
result = restoreUnderlineSpans(result);
|
|
@@ -5703,11 +6899,12 @@ function obsidianToNotionEnhanced(obsidian) {
|
|
|
5703
6899
|
result = restoreWikilinkPreserveLinks(result);
|
|
5704
6900
|
result = restoreSyncedBlocks(result);
|
|
5705
6901
|
result = reassembleColumns(result);
|
|
6902
|
+
result = restoreToggleHeadings(result);
|
|
5706
6903
|
return result;
|
|
5707
6904
|
}
|
|
5708
6905
|
var MENTION_PAGE_ID_RE = /<mention-page id="([^"]+)">[\s\S]*?<\/mention-page>/g;
|
|
5709
6906
|
var WIKILINK_PRESERVE_LINK_RE = new RegExp(
|
|
5710
|
-
`\\[
|
|
6907
|
+
`\\[${MARKER_LABEL_CAPTURE}\\]\\(${WIKILINK_PROTOCOL}${MARKER_URL_CAPTURE}\\)`,
|
|
5711
6908
|
"g"
|
|
5712
6909
|
);
|
|
5713
6910
|
function convertMentionPageIdToUrl(content) {
|
|
@@ -5718,82 +6915,8 @@ function convertMentionPageIdToUrl(content) {
|
|
|
5718
6915
|
}
|
|
5719
6916
|
function restoreWikilinkPreserveLinks(content) {
|
|
5720
6917
|
return content.replace(WIKILINK_PRESERVE_LINK_RE, (_match, label, enc) => {
|
|
5721
|
-
|
|
5722
|
-
try {
|
|
5723
|
-
target = decodeURIComponent(enc);
|
|
5724
|
-
} catch {
|
|
5725
|
-
}
|
|
5726
|
-
return target === label ? `[[${target}]]` : `[[${target}|${label}]]`;
|
|
5727
|
-
});
|
|
5728
|
-
}
|
|
5729
|
-
function fenceFor(body) {
|
|
5730
|
-
let longest = 0;
|
|
5731
|
-
const re = /`+/g;
|
|
5732
|
-
let m;
|
|
5733
|
-
while ((m = re.exec(body)) !== null) longest = Math.max(longest, m[0].length);
|
|
5734
|
-
return "`".repeat(Math.max(3, longest + 1));
|
|
5735
|
-
}
|
|
5736
|
-
function dedentContainerBody(text) {
|
|
5737
|
-
const lines = text.split("\n");
|
|
5738
|
-
while (lines.length && lines[0].trim() === "") lines.shift();
|
|
5739
|
-
while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
|
|
5740
|
-
const kinds = [];
|
|
5741
|
-
let inCode = false;
|
|
5742
|
-
let fenceChar = "";
|
|
5743
|
-
let fenceLen = 0;
|
|
5744
|
-
let inTable = false;
|
|
5745
|
-
for (const line of lines) {
|
|
5746
|
-
const fence = /^[\t ]*(`{3,}|~{3,})(.*)$/.exec(line);
|
|
5747
|
-
if (inCode) {
|
|
5748
|
-
if (fence && fence[1][0] === fenceChar && fence[1].length >= fenceLen && fence[2].trim() === "") {
|
|
5749
|
-
inCode = false;
|
|
5750
|
-
kinds.push("fence");
|
|
5751
|
-
} else {
|
|
5752
|
-
kinds.push("code");
|
|
5753
|
-
}
|
|
5754
|
-
} else if (inTable) {
|
|
5755
|
-
const closes = /<\/table>/.test(line);
|
|
5756
|
-
kinds.push(closes ? "fence" : "code");
|
|
5757
|
-
if (closes) inTable = false;
|
|
5758
|
-
} else if (fence) {
|
|
5759
|
-
inCode = true;
|
|
5760
|
-
fenceChar = fence[1][0];
|
|
5761
|
-
fenceLen = fence[1].length;
|
|
5762
|
-
kinds.push("fence");
|
|
5763
|
-
} else if (/^[\t ]*<table[^>]*>/.test(line)) {
|
|
5764
|
-
if (!/<\/table>/.test(line)) inTable = true;
|
|
5765
|
-
kinds.push("fence");
|
|
5766
|
-
} else {
|
|
5767
|
-
kinds.push("prose");
|
|
5768
|
-
}
|
|
5769
|
-
}
|
|
5770
|
-
let min = Infinity;
|
|
5771
|
-
lines.forEach((l, i) => {
|
|
5772
|
-
if (kinds[i] !== "prose" || l.trim() === "") return;
|
|
5773
|
-
min = Math.min(min, /^[\t ]*/.exec(l)[0].length);
|
|
6918
|
+
return formatWikilink(decodeMarkerTarget(enc), label);
|
|
5774
6919
|
});
|
|
5775
|
-
if (!Number.isFinite(min)) min = 0;
|
|
5776
|
-
return lines.map((l, i) => {
|
|
5777
|
-
if (l.trim() === "") return "";
|
|
5778
|
-
if (kinds[i] === "code") return l;
|
|
5779
|
-
if (kinds[i] === "fence") return l.replace(/^[\t ]+/, "");
|
|
5780
|
-
return l.slice(min);
|
|
5781
|
-
}).join("\n");
|
|
5782
|
-
}
|
|
5783
|
-
function normalizeCodeBlockToggles(content) {
|
|
5784
|
-
return content.replace(
|
|
5785
|
-
/^[\t ]*- (.+)\n[\t ]*(`{3,})(\w*)\n([\s\S]*?)\n[\t ]*\2[\t ]*$/gm,
|
|
5786
|
-
(_match, title, _open, lang, rawBody) => {
|
|
5787
|
-
const body = dedentContainerBody(rawBody);
|
|
5788
|
-
const fence = fenceFor(body);
|
|
5789
|
-
return `<details>
|
|
5790
|
-
<summary>${title.trim()}</summary>
|
|
5791
|
-
${fence}${lang}
|
|
5792
|
-
${body}
|
|
5793
|
-
${fence}
|
|
5794
|
-
</details>`;
|
|
5795
|
-
}
|
|
5796
|
-
);
|
|
5797
6920
|
}
|
|
5798
6921
|
var NO_CONTAINER_BODY = "(?:(?!<details|<callout|<columns)[\\s\\S])*?";
|
|
5799
6922
|
var SUMMARY_TITLE = "((?:(?!<\\/summary>|<details|<summary>)[\\s\\S])*?)";
|
|
@@ -5802,14 +6925,17 @@ var INNERMOST_DETAILS_RE = new RegExp(
|
|
|
5802
6925
|
"g"
|
|
5803
6926
|
);
|
|
5804
6927
|
var INNERMOST_CALLOUT_RE = new RegExp(
|
|
5805
|
-
`([\\t ]*)
|
|
6928
|
+
`([\\t ]*)${nfmOpenTagSource("callout", "capture")}\\n?(${NO_CONTAINER_BODY})<\\/callout>`,
|
|
5806
6929
|
"g"
|
|
5807
6930
|
);
|
|
5808
6931
|
var INNERMOST_COLUMNS_RE = new RegExp(
|
|
5809
|
-
`([\\t ]*)
|
|
6932
|
+
`([\\t ]*)${nfmOpenTagSource("columns")}\\n?(${NO_CONTAINER_BODY})<\\/columns>`,
|
|
6933
|
+
"g"
|
|
6934
|
+
);
|
|
6935
|
+
var COLUMN_WRAP_RE = new RegExp(
|
|
6936
|
+
`[\\t ]*${nfmOpenTagSource("column", "capture")}\\n?([\\s\\S]*?)[\\t ]*<\\/column>\\n?`,
|
|
5810
6937
|
"g"
|
|
5811
6938
|
);
|
|
5812
|
-
var COLUMN_WRAP_RE = /[\t ]*<column(?!s)[^>]*>\n?([\s\S]*?)[\t ]*<\/column>\n?/g;
|
|
5813
6939
|
function reindentLines(text, indent) {
|
|
5814
6940
|
if (!indent) return text;
|
|
5815
6941
|
return text.split("\n").map((line) => line === "" ? line : indent + line).join("\n");
|
|
@@ -5819,13 +6945,15 @@ function parseDesignAttrs(attrs) {
|
|
|
5819
6945
|
const color = /\bcolor="([^"]*)"/.exec(attrs)?.[1];
|
|
5820
6946
|
return { icon: icon || void 0, color: color || void 0 };
|
|
5821
6947
|
}
|
|
6948
|
+
function parseColumnRatio(attrs) {
|
|
6949
|
+
return /\bratio="(\d+(?:\.\d+)?)"/.exec(attrs)?.[1];
|
|
6950
|
+
}
|
|
5822
6951
|
function toggleToCallout(title, rawBody, color) {
|
|
5823
6952
|
const body = dedentContainerBody(rawBody);
|
|
5824
6953
|
const head = ["> [!toggle]-", title.trim(), color ? toggleColorMarker(color) : ""].filter((part) => part !== "").join(" ");
|
|
5825
6954
|
if (body.trim() === "") return head;
|
|
5826
|
-
const calloutBody = body.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n");
|
|
5827
6955
|
return `${head}
|
|
5828
|
-
${
|
|
6956
|
+
${quoteCalloutBody(body)}`;
|
|
5829
6957
|
}
|
|
5830
6958
|
function flattenColumns(body) {
|
|
5831
6959
|
const cols = [];
|
|
@@ -5834,13 +6962,16 @@ function flattenColumns(body) {
|
|
|
5834
6962
|
let matched = false;
|
|
5835
6963
|
while ((m = re.exec(body)) !== null) {
|
|
5836
6964
|
matched = true;
|
|
5837
|
-
|
|
5838
|
-
if (col.trim() !== "") cols.push(col);
|
|
6965
|
+
cols.push({ ratio: parseColumnRatio(m[1] ?? ""), body: dedentContainerBody(m[2]) });
|
|
5839
6966
|
}
|
|
5840
6967
|
if (!matched) return dedentContainerBody(body);
|
|
5841
|
-
if (cols.
|
|
5842
|
-
return [
|
|
5843
|
-
|
|
6968
|
+
if (cols.every((c) => c.body.trim() === "")) return "";
|
|
6969
|
+
return [
|
|
6970
|
+
COLUMN_LIST_START,
|
|
6971
|
+
...cols.map((c) => `${columnMarker(c.ratio)}
|
|
6972
|
+
${c.body}`),
|
|
6973
|
+
COLUMN_LIST_END
|
|
6974
|
+
].join("\n");
|
|
5844
6975
|
}
|
|
5845
6976
|
function convertContainers(content) {
|
|
5846
6977
|
let result = content;
|
|
@@ -5861,39 +6992,30 @@ function convertContainers(content) {
|
|
|
5861
6992
|
);
|
|
5862
6993
|
if (result === before) break;
|
|
5863
6994
|
}
|
|
5864
|
-
return
|
|
6995
|
+
return result;
|
|
5865
6996
|
}
|
|
5866
|
-
var
|
|
5867
|
-
|
|
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"
|
|
6997
|
+
var COLUMN_MARKER_LINE_RE = new RegExp(
|
|
6998
|
+
`^${CONTAINER_PREFIX_SOURCE}(?:${COLUMN_SEP_SOURCE}|%%${MARKER_BRAND_RE}:column-list:(?:start|end)%%)[\\t ]*$`
|
|
5877
6999
|
);
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
(line) => line.replace(QUOTED_COLUMN_INLINE_RE, "").trimEnd()
|
|
5882
|
-
);
|
|
7000
|
+
var QUOTED_LINE_RE = /^[\t ]*>/;
|
|
7001
|
+
function isStructuralFirstLine(line) {
|
|
7002
|
+
return COLUMN_MARKER_LINE_RE.test(line) || QUOTED_LINE_RE.test(line);
|
|
5883
7003
|
}
|
|
5884
7004
|
function calloutBodyToObsidian(body, style) {
|
|
5885
7005
|
const lines = dedentContainerBody(body).split("\n").filter((l, i, arr) => !(i === 0 && l === "") && !(i === arr.length - 1 && l === ""));
|
|
5886
|
-
const
|
|
7006
|
+
const titleIndex = isStructuralFirstLine(lines[0] ?? "") ? -1 : 0;
|
|
7007
|
+
const firstLine = titleIndex === 0 ? lines[0] ?? "" : "";
|
|
5887
7008
|
const emojiMatch = style?.icon || style?.color ? null : /^([\p{Emoji}️]+)\s*(.*)/u.exec(firstLine);
|
|
5888
7009
|
const rawIcon = style?.icon ?? (emojiMatch ? emojiMatch[1] : void 0);
|
|
5889
7010
|
const icon = rawIcon !== void 0 && /^https?:\/\//i.test(rawIcon) ? void 0 : rawIcon;
|
|
5890
7011
|
const type = icon ? emojiToCalloutType(icon) : "note";
|
|
5891
7012
|
const title = emojiMatch ? emojiMatch[2] : firstLine;
|
|
5892
|
-
const rest = lines.slice(1).join("\n").
|
|
7013
|
+
const rest = lines.slice(titleIndex + 1).join("\n").replace(/^(?:[\t ]*\n)+/, "").replace(/\n[\t ]*$/, "");
|
|
5893
7014
|
const needsMarker = Boolean(style?.color) || icon !== void 0 && calloutTypeToEmoji(type) !== icon;
|
|
5894
7015
|
const styleTail = needsMarker ? ` ${calloutStyleMarker({ icon, color: style?.color })}` : "";
|
|
5895
7016
|
const calloutTitle = title ? `> [!${type}] ${title}${styleTail}` : `> [!${type}]${styleTail}`;
|
|
5896
|
-
const calloutBody = rest
|
|
7017
|
+
const calloutBody = rest.trim() ? `
|
|
7018
|
+
${quoteCalloutBody(rest)}` : "";
|
|
5897
7019
|
return calloutTitle + calloutBody;
|
|
5898
7020
|
}
|
|
5899
7021
|
function convertFencedCallouts(content) {
|
|
@@ -5908,6 +7030,10 @@ function convertPageMentions(content) {
|
|
|
5908
7030
|
/<mention-page\s+url="https?:\/\/(?:[a-z]+\.)?notion\.(?:so|com)\/(?:p\/)?([a-f0-9]{32})"[^>]*?(?:\/>|>[\s\S]*?<\/mention-page>)/g,
|
|
5909
7031
|
(_match, id) => `[[notion:${id}]]`
|
|
5910
7032
|
);
|
|
7033
|
+
result = result.replace(NOTION_LABELED_PAGE_LINK_RE, (_match, label, id) => {
|
|
7034
|
+
const normalized = id.replace(/-/g, "");
|
|
7035
|
+
return `[[notion:${normalized}|${label}]]`;
|
|
7036
|
+
});
|
|
5911
7037
|
return result;
|
|
5912
7038
|
}
|
|
5913
7039
|
function convertUserMentions(content) {
|
|
@@ -5918,6 +7044,10 @@ function convertDateMentions(content) {
|
|
|
5918
7044
|
return end ? `${start} \u2192 ${end}` : start;
|
|
5919
7045
|
});
|
|
5920
7046
|
}
|
|
7047
|
+
function degradeLink(label, url, marker) {
|
|
7048
|
+
return `[${label}](${url.replace(/\(/g, "%28").replace(/\)/g, "%29")})${marker}`;
|
|
7049
|
+
}
|
|
7050
|
+
var DEGRADE_LINK_SOURCE = "(?:\\[[^\\]]*\\]\\([^)]*\\))?";
|
|
5921
7051
|
function preserveUnknownBlocks(content) {
|
|
5922
7052
|
let result = content.replace(NOTION_UNKNOWN_RE, (_match, id, attrs) => {
|
|
5923
7053
|
const typeMatch = /type="([^"]*)"/.exec(attrs);
|
|
@@ -5930,34 +7060,48 @@ function preserveUnknownBlocks(content) {
|
|
|
5930
7060
|
const blockType = altMatch?.[1] ?? "bookmark";
|
|
5931
7061
|
const marker = compactMarker(`unknown:id=${encodeURIComponent(url)}&type=${blockType}`);
|
|
5932
7062
|
const label = blockType === "embed" ? "\u{1F517} Embed" : blockType === "bookmark" ? "\u{1F516} Bookmark" : `\u{1F517} ${blockType}`;
|
|
5933
|
-
return
|
|
7063
|
+
return degradeLink(label, url, marker);
|
|
5934
7064
|
});
|
|
5935
7065
|
return result;
|
|
5936
7066
|
}
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5941
|
-
|
|
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}`;
|
|
5948
|
-
}
|
|
5949
|
-
);
|
|
7067
|
+
var NOTION_TOC_RE = /<table_of_contents(?:\s+color="([^"]*)")?\s*\/>/g;
|
|
7068
|
+
var NOTION_EMBED_RE = /<embed\s+src="([^"]*)"[^>]*>\s*<\/embed>/g;
|
|
7069
|
+
var NOTION_UNKNOWN_MENTION_RE = /<unknown_mention\s+url="([^"]*)"([^>]*?)\/>/g;
|
|
7070
|
+
function preserveNfmOnlyBlocks(content) {
|
|
7071
|
+
let result = content.replace(NOTION_TOC_RE, (_match, color) => tocMarker(color));
|
|
5950
7072
|
result = result.replace(
|
|
5951
|
-
|
|
5952
|
-
(
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
7073
|
+
NOTION_EMBED_RE,
|
|
7074
|
+
(_match, src) => degradeLink("\u{1F517} Embed", src, embedMarker(src))
|
|
7075
|
+
);
|
|
7076
|
+
return result.replace(
|
|
7077
|
+
NOTION_UNKNOWN_MENTION_RE,
|
|
7078
|
+
(_match, url, attrs) => unknownMentionMarker(url, /alt="([^"]*)"/.exec(attrs)?.[1])
|
|
7079
|
+
);
|
|
7080
|
+
}
|
|
7081
|
+
function convertSyncedBlockRef(content) {
|
|
7082
|
+
const rebuild = (match, indent, attrs, kind, openRe, closeRe) => {
|
|
7083
|
+
const inner = dedentContainerBody(
|
|
7084
|
+
match.slice(indent.length).replace(openRe, "").replace(closeRe, "")
|
|
7085
|
+
);
|
|
7086
|
+
const url = /url="([^"]*)"/.exec(attrs)?.[1];
|
|
7087
|
+
if (!url) return reindentLines(inner, indent);
|
|
7088
|
+
return reindentLines(`${syncedStartMarker(kind, url)}
|
|
5957
7089
|
${inner}
|
|
5958
|
-
${SYNCED_END}
|
|
5959
|
-
|
|
7090
|
+
${SYNCED_END}`, indent);
|
|
7091
|
+
};
|
|
7092
|
+
const replaceSynced = (text, name, kind) => text.replace(
|
|
7093
|
+
new RegExp(`([\\t ]*)${nfmOpenTagSource(name, "capture")}[\\s\\S]*?<\\/${name}>`, "g"),
|
|
7094
|
+
(match, indent, attrs) => rebuild(
|
|
7095
|
+
match,
|
|
7096
|
+
indent,
|
|
7097
|
+
attrs,
|
|
7098
|
+
kind,
|
|
7099
|
+
new RegExp(`${nfmOpenTagSource(name)}\\n?`),
|
|
7100
|
+
new RegExp(`</${name}>`)
|
|
7101
|
+
)
|
|
5960
7102
|
);
|
|
7103
|
+
let result = replaceSynced(content, "synced_block_reference", "ref");
|
|
7104
|
+
result = replaceSynced(result, "synced_block", "orig");
|
|
5961
7105
|
return result;
|
|
5962
7106
|
}
|
|
5963
7107
|
var SYNCED_MARKER_PAIR_RE = new RegExp(
|
|
@@ -5975,7 +7119,7 @@ function restoreSyncedBlocks(content) {
|
|
|
5975
7119
|
}
|
|
5976
7120
|
if (!url) return body.trim();
|
|
5977
7121
|
const tag = kind === "ref" ? "synced_block_reference" : "synced_block";
|
|
5978
|
-
const indented = body.trim()
|
|
7122
|
+
const indented = indentContainerBody(body.trim());
|
|
5979
7123
|
return `<${tag} url="${url}">
|
|
5980
7124
|
${indented}
|
|
5981
7125
|
</${tag}>`;
|
|
@@ -6025,24 +7169,31 @@ function convertSpans(content) {
|
|
|
6025
7169
|
}
|
|
6026
7170
|
var TOGGLE_COLOR_MARKER_RE = new RegExp(`\\s*%%${MARKER_BRAND_RE}:toggle-color:([a-z_]+)%%`);
|
|
6027
7171
|
function convertTogglesToHtml(content) {
|
|
6028
|
-
const calloutToggleRe =
|
|
7172
|
+
const calloutToggleRe = /^([ \t]{0,3})> \[!toggle\]-[ \t]*(.*)((?:\n\1>.*)*)/gm;
|
|
6029
7173
|
let result = content;
|
|
6030
7174
|
let prev = "";
|
|
6031
7175
|
let safety = 0;
|
|
6032
7176
|
while (result !== prev && safety++ < 100) {
|
|
6033
7177
|
prev = result;
|
|
6034
|
-
result = result.replace(
|
|
6035
|
-
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
7178
|
+
result = result.replace(
|
|
7179
|
+
calloutToggleRe,
|
|
7180
|
+
(_match, indent, rawTitle, body) => {
|
|
7181
|
+
const { depth, title } = readCalloutIndentDepth(indent, rawTitle);
|
|
7182
|
+
const colorMatch = TOGGLE_COLOR_MARKER_RE.exec(title);
|
|
7183
|
+
const cleanTitle = colorMatch ? title.replace(TOGGLE_COLOR_MARKER_RE, "") : title;
|
|
7184
|
+
const attrs = colorMatch ? ` color="${colorMatch[1]}"` : "";
|
|
7185
|
+
const bodyText = restoreBodyIndent(
|
|
7186
|
+
body.split("\n").map((line) => line.replace(/^[ \t]*>\s?/, "")).join("\n")
|
|
7187
|
+
).trim();
|
|
7188
|
+
const html = `<details${attrs}>
|
|
6040
7189
|
<summary>${cleanTitle.trim()}</summary>
|
|
6041
7190
|
|
|
6042
7191
|
${bodyText}
|
|
6043
7192
|
|
|
6044
7193
|
</details>`;
|
|
6045
|
-
|
|
7194
|
+
return applyCalloutIndent(html, depth);
|
|
7195
|
+
}
|
|
7196
|
+
);
|
|
6046
7197
|
}
|
|
6047
7198
|
const startRe = new RegExp(escapeRegex3(TOGGLE_START), "g");
|
|
6048
7199
|
const endRe = new RegExp(escapeRegex3(TOGGLE_END), "g");
|
|
@@ -6064,7 +7215,7 @@ ${body}
|
|
|
6064
7215
|
result = result.slice(0, startIdx) + html + result.slice(endIdx + TOGGLE_END.length);
|
|
6065
7216
|
}
|
|
6066
7217
|
result = result.replace(startRe, "").replace(endRe, "");
|
|
6067
|
-
result = result.replace(/^(
|
|
7218
|
+
result = result.replace(/^([\t ]*<\/details>)(?!\n|$)/gm, "$1\n");
|
|
6068
7219
|
return result;
|
|
6069
7220
|
}
|
|
6070
7221
|
function findMatchingEnd(content, startFrom) {
|
|
@@ -6103,10 +7254,12 @@ function convertObsidianCallouts(content) {
|
|
|
6103
7254
|
const result = [];
|
|
6104
7255
|
let i = 0;
|
|
6105
7256
|
while (i < lines.length) {
|
|
6106
|
-
const headerMatch =
|
|
6107
|
-
const type = headerMatch?.[
|
|
7257
|
+
const headerMatch = /^([ \t]{0,3})> \[!(\w+)\]([-+])?[ \t]*(.*)$/.exec(lines[i]);
|
|
7258
|
+
const type = headerMatch?.[2]?.toLowerCase();
|
|
6108
7259
|
if (headerMatch && type !== "toggle" && type !== "tab") {
|
|
6109
|
-
|
|
7260
|
+
const indentRead = readCalloutIndentDepth(headerMatch[1], headerMatch[4]);
|
|
7261
|
+
const depth = indentRead.depth;
|
|
7262
|
+
let title = indentRead.title;
|
|
6110
7263
|
let style = {};
|
|
6111
7264
|
const styleMatch = CALLOUT_STYLE_MARKER_RE.exec(title);
|
|
6112
7265
|
if (styleMatch) {
|
|
@@ -6117,21 +7270,23 @@ function convertObsidianCallouts(content) {
|
|
|
6117
7270
|
}
|
|
6118
7271
|
const bodyLines = [];
|
|
6119
7272
|
i++;
|
|
6120
|
-
|
|
6121
|
-
|
|
7273
|
+
const quote = `${headerMatch[1]}>`;
|
|
7274
|
+
while (i < lines.length && (lines[i] === quote || lines[i].startsWith(`${quote} `))) {
|
|
7275
|
+
bodyLines.push(lines[i] === quote ? "" : lines[i].slice(quote.length + 1));
|
|
6122
7276
|
i++;
|
|
6123
7277
|
}
|
|
6124
|
-
const innerConverted = convertObsidianCallouts(
|
|
7278
|
+
const innerConverted = convertObsidianCallouts(
|
|
7279
|
+
convertTogglesToHtml(restoreBodyIndent(bodyLines.join("\n")))
|
|
7280
|
+
);
|
|
6125
7281
|
const attrs = `${style.icon ? ` icon="${style.icon}"` : ""}${style.color ? ` color="${style.color}"` : ""}`;
|
|
6126
|
-
|
|
7282
|
+
const block = [`<callout${attrs}>`];
|
|
6127
7283
|
const cleanTitle = title.trim();
|
|
6128
|
-
if (cleanTitle)
|
|
7284
|
+
if (cleanTitle) block.push(` ${cleanTitle}`);
|
|
6129
7285
|
if (innerConverted.trim() !== "") {
|
|
6130
|
-
|
|
6131
|
-
result.push(line === "" ? "" : ` ${line}`);
|
|
6132
|
-
}
|
|
7286
|
+
block.push(...indentContainerBody(innerConverted).split("\n"));
|
|
6133
7287
|
}
|
|
6134
|
-
|
|
7288
|
+
block.push("</callout>");
|
|
7289
|
+
result.push(...applyCalloutIndent(block.join("\n"), depth).split("\n"));
|
|
6135
7290
|
} else {
|
|
6136
7291
|
result.push(lines[i]);
|
|
6137
7292
|
i++;
|
|
@@ -6162,11 +7317,11 @@ function emojiToCalloutType(emoji) {
|
|
|
6162
7317
|
function calloutTypeToEmoji(type) {
|
|
6163
7318
|
return TYPE_EMOJI_MAP[type.toLowerCase()];
|
|
6164
7319
|
}
|
|
6165
|
-
function escapeRegex3(
|
|
6166
|
-
return
|
|
7320
|
+
function escapeRegex3(str2) {
|
|
7321
|
+
return str2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6167
7322
|
}
|
|
6168
7323
|
var NOTION_PAGE_LINK_RE = /<page url="[^"]*">([\s\S]*?)<\/page>/g;
|
|
6169
|
-
var NOTION_EMPTY_BLOCK_RE = /^(?:>[\t ]*)*[\t ]
|
|
7324
|
+
var NOTION_EMPTY_BLOCK_RE = /^([\t ]*(?:>[\t ]*)*)<empty-block\/>[\t ]*\n?/gm;
|
|
6170
7325
|
function convertPageLinks(content) {
|
|
6171
7326
|
return content.replace(NOTION_PAGE_LINK_RE, (_match, text) => {
|
|
6172
7327
|
const cleaned = text.replace(/\*\*/g, "").trim();
|
|
@@ -6178,10 +7333,10 @@ function convertDatabaseBlocks(content) {
|
|
|
6178
7333
|
/<database\b([^>]*)>([\s\S]*?)<\/database>/g,
|
|
6179
7334
|
(_match, attrs, title) => {
|
|
6180
7335
|
const clean = title.trim();
|
|
6181
|
-
const
|
|
7336
|
+
const placeholder2 = `**${clean}** *(Notion DB)*`;
|
|
6182
7337
|
const idMatch = /\burl="[^"]*?([a-f0-9]{32})[^"]*"/.exec(attrs);
|
|
6183
|
-
if (!idMatch?.[1]) return
|
|
6184
|
-
return `${
|
|
7338
|
+
if (!idMatch?.[1]) return placeholder2;
|
|
7339
|
+
return `${placeholder2}${compactMarker(
|
|
6185
7340
|
`child-database:id=${idMatch[1]}&title=${encodeURIComponent(clean)}`
|
|
6186
7341
|
)}`;
|
|
6187
7342
|
}
|
|
@@ -6191,14 +7346,14 @@ var TRAILING_BLOCK_COLOR_RE = /[ \t]*\{color="([a-z_]+)"\}(?=[ \t]*$)/gm;
|
|
|
6191
7346
|
function convertBlockColorAttrs(content) {
|
|
6192
7347
|
return content.replace(TRAILING_BLOCK_COLOR_RE, (_m, color) => ` ${blockColorMarker(color)}`).replace(/\s*\{color="[^"]*"\}/g, "");
|
|
6193
7348
|
}
|
|
6194
|
-
var
|
|
7349
|
+
var CALLOUT_HEAD_RE2 = /^([\t ]*)((?:> )*)> \[!\w+\][-+]?/;
|
|
6195
7350
|
function separateAdjacentCallouts(content) {
|
|
6196
7351
|
const lines = content.split("\n");
|
|
6197
7352
|
const out = [];
|
|
6198
7353
|
for (const line of lines) {
|
|
6199
|
-
const head =
|
|
7354
|
+
const head = CALLOUT_HEAD_RE2.exec(line);
|
|
6200
7355
|
if (head && out.length > 0) {
|
|
6201
|
-
const parentPrefix = head[1]
|
|
7356
|
+
const parentPrefix = `${head[1]}${head[2]}`;
|
|
6202
7357
|
const prev = out[out.length - 1];
|
|
6203
7358
|
if (prev.startsWith(`${parentPrefix}>`)) {
|
|
6204
7359
|
out.push(parentPrefix.trimEnd());
|
|
@@ -6208,8 +7363,13 @@ function separateAdjacentCallouts(content) {
|
|
|
6208
7363
|
}
|
|
6209
7364
|
return out.join("\n");
|
|
6210
7365
|
}
|
|
7366
|
+
var QUOTE_ONLY_PREFIX_RE = /^[\t ]*(?:>[\t ]*)+$/;
|
|
6211
7367
|
function removeEmptyBlocks(content) {
|
|
6212
|
-
return content.replace(
|
|
7368
|
+
return content.replace(
|
|
7369
|
+
NOTION_EMPTY_BLOCK_RE,
|
|
7370
|
+
(_match, prefix) => QUOTE_ONLY_PREFIX_RE.test(prefix) ? `${prefix.trimEnd()}
|
|
7371
|
+
` : "\n"
|
|
7372
|
+
);
|
|
6213
7373
|
}
|
|
6214
7374
|
var NOTION_INLINE_MATH_RE = /\$`([^`]+)`\$/g;
|
|
6215
7375
|
var NOTION_BLOCK_MATH_RE = /\$\$\n```\n([\s\S]*?)\n```\n\$\$/g;
|
|
@@ -6229,14 +7389,22 @@ $$`;
|
|
|
6229
7389
|
function unescapeNotionChars(content) {
|
|
6230
7390
|
return content.replace(/\\~/g, "~").replace(/\\\^/g, "^");
|
|
6231
7391
|
}
|
|
6232
|
-
var NOTION_TABLE_RE =
|
|
6233
|
-
|
|
7392
|
+
var NOTION_TABLE_RE = new RegExp(
|
|
7393
|
+
`^(${CONTAINER_PREFIX_SOURCE})${nfmOpenTagSource("table")}([\\s\\S]*?)</table>`,
|
|
7394
|
+
"gm"
|
|
7395
|
+
);
|
|
7396
|
+
var TABLE_ROW_RE2 = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
|
|
6234
7397
|
var TABLE_CELL_RE = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/g;
|
|
6235
7398
|
function isAlignmentRow(cells) {
|
|
6236
7399
|
return cells.every((c) => /^:?-{2,}:?$/.test(c.trim()));
|
|
6237
7400
|
}
|
|
7401
|
+
function toTableCell(raw) {
|
|
7402
|
+
return raw.trim().replace(/\r?\n/g, "<br>").replace(/(?<!\\)\|/g, "\\|");
|
|
7403
|
+
}
|
|
6238
7404
|
function convertNotionTables(content) {
|
|
6239
|
-
|
|
7405
|
+
const code = codeInteriorRanges(content);
|
|
7406
|
+
return content.replace(NOTION_TABLE_RE, (_match, prefix, tableBody, offset) => {
|
|
7407
|
+
if (isInsideRanges(code, offset)) return _match;
|
|
6240
7408
|
const rows = [];
|
|
6241
7409
|
let rowMatch;
|
|
6242
7410
|
const rowRe = new RegExp(TABLE_ROW_RE2.source, TABLE_ROW_RE2.flags);
|
|
@@ -6245,7 +7413,7 @@ function convertNotionTables(content) {
|
|
|
6245
7413
|
let cellMatch;
|
|
6246
7414
|
const cellRe = new RegExp(TABLE_CELL_RE.source, TABLE_CELL_RE.flags);
|
|
6247
7415
|
while ((cellMatch = cellRe.exec(rowMatch[1])) !== null) {
|
|
6248
|
-
cells.push(cellMatch[1]
|
|
7416
|
+
cells.push(toTableCell(cellMatch[1]));
|
|
6249
7417
|
}
|
|
6250
7418
|
if (!isAlignmentRow(cells)) {
|
|
6251
7419
|
rows.push(cells);
|
|
@@ -6262,7 +7430,7 @@ function convertNotionTables(content) {
|
|
|
6262
7430
|
lines.push(`| ${padded.map(() => "---").join(" | ")} |`);
|
|
6263
7431
|
}
|
|
6264
7432
|
}
|
|
6265
|
-
return lines.join("\n");
|
|
7433
|
+
return lines.map((line) => prefix + line).join("\n");
|
|
6266
7434
|
});
|
|
6267
7435
|
}
|
|
6268
7436
|
var OBSIDIAN_MEDIA_AUDIO_RE = /\[🔊\s*([^\]]*)\]\(([^)]+)\)/g;
|
|
@@ -6281,16 +7449,15 @@ function restoreMediaTags(content) {
|
|
|
6281
7449
|
result = result.replace(OBSIDIAN_MEDIA_FILE_RE, restore("file"));
|
|
6282
7450
|
return result;
|
|
6283
7451
|
}
|
|
6284
|
-
var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)
|
|
7452
|
+
var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)((?:\n> .*)*)/gm;
|
|
6285
7453
|
function restoreTabBlocks(content) {
|
|
6286
|
-
|
|
7454
|
+
return content.replace(OBSIDIAN_TAB_RE, (_match, title, body) => {
|
|
6287
7455
|
const unquoted = body.split("\n").map((line) => line.replace(/^> /, "")).join("\n").trim();
|
|
6288
7456
|
return `<tab title="${title}">${unquoted}</tab>`;
|
|
6289
7457
|
});
|
|
6290
|
-
return result.replace(/(<\/tab>)(?!\n|$)/g, "$1\n");
|
|
6291
7458
|
}
|
|
6292
7459
|
var OBSIDIAN_UNKNOWN_RE = new RegExp(
|
|
6293
|
-
|
|
7460
|
+
`${DEGRADE_LINK_SOURCE}%%${MARKER_BRAND_RE}:unknown:id=([^&]+)&type=([^%]+)%%`,
|
|
6294
7461
|
"g"
|
|
6295
7462
|
);
|
|
6296
7463
|
function restoreUnknownBlocks(content) {
|
|
@@ -6302,6 +7469,30 @@ function restoreUnknownBlocks(content) {
|
|
|
6302
7469
|
return `<unknown id="${id}" type="${type}"/>`;
|
|
6303
7470
|
});
|
|
6304
7471
|
}
|
|
7472
|
+
var OBSIDIAN_TOC_RE = new RegExp(`%%${MARKER_BRAND_RE}:toc(?::color=([^%]+))?%%`, "g");
|
|
7473
|
+
var OBSIDIAN_EMBED_RE = new RegExp(
|
|
7474
|
+
`${DEGRADE_LINK_SOURCE}%%${MARKER_BRAND_RE}:embed:src=(${MARKER_PAYLOAD_CHAR}+)%%`,
|
|
7475
|
+
"g"
|
|
7476
|
+
);
|
|
7477
|
+
var OBSIDIAN_UNKNOWN_MENTION_RE = new RegExp(
|
|
7478
|
+
`%%${MARKER_BRAND_RE}:unknown-mention:url=(${MARKER_PAYLOAD_CHAR}+)%%`,
|
|
7479
|
+
"g"
|
|
7480
|
+
);
|
|
7481
|
+
function restoreNfmOnlyBlocks(content) {
|
|
7482
|
+
let result = content.replace(
|
|
7483
|
+
OBSIDIAN_TOC_RE,
|
|
7484
|
+
(_match, color) => color ? `<table_of_contents color="${color}"/>` : `<table_of_contents/>`
|
|
7485
|
+
);
|
|
7486
|
+
result = result.replace(
|
|
7487
|
+
OBSIDIAN_EMBED_RE,
|
|
7488
|
+
(_match, src) => `<embed src="${decodeURIComponent(src)}"></embed>`
|
|
7489
|
+
);
|
|
7490
|
+
return result.replace(OBSIDIAN_UNKNOWN_MENTION_RE, (_match, payload) => {
|
|
7491
|
+
const [url, alt] = payload.split("&alt=");
|
|
7492
|
+
const altAttr = alt ? ` alt="${decodeURIComponent(alt)}"` : "";
|
|
7493
|
+
return `<unknown_mention url="${decodeURIComponent(url ?? "")}"${altAttr}/>`;
|
|
7494
|
+
});
|
|
7495
|
+
}
|
|
6305
7496
|
var OBSIDIAN_COLOR_RE = new RegExp(
|
|
6306
7497
|
`%%${MARKER_BRAND_RE}:color:([^%]+)%%([\\s\\S]*?)%%\\/color%%`,
|
|
6307
7498
|
"g"
|
|
@@ -6327,29 +7518,51 @@ var OBSIDIAN_BLOCK_COLOR_RE = new RegExp(
|
|
|
6327
7518
|
function restoreBlockColorMarkers(content) {
|
|
6328
7519
|
return content.replace(OBSIDIAN_BLOCK_COLOR_RE, (_m, color) => ` {color="${color}"}`);
|
|
6329
7520
|
}
|
|
6330
|
-
var
|
|
6331
|
-
|
|
7521
|
+
var INNERMOST_COLUMN_REGION_RE = new RegExp(
|
|
7522
|
+
`^([ \\t]*)${escapeRegex3(COLUMN_LIST_START)}[ \\t]*\\n((?:(?!^[ \\t]*${escapeRegex3(COLUMN_LIST_START)})[\\s\\S])*?)\\n?^[ \\t]*${escapeRegex3(COLUMN_LIST_END)}[ \\t]*$\\n?`,
|
|
6332
7523
|
"gm"
|
|
6333
7524
|
);
|
|
6334
|
-
function
|
|
6335
|
-
|
|
7525
|
+
function splitColumnSegments(inner) {
|
|
7526
|
+
const sepRe = new RegExp(`^[ \\t]*${COLUMN_SEP_SOURCE}[ \\t]*$`, "gm");
|
|
7527
|
+
const segments = [];
|
|
7528
|
+
let cursor = 0;
|
|
7529
|
+
let pending = { ratio: void 0, body: "" };
|
|
7530
|
+
let m;
|
|
7531
|
+
while ((m = sepRe.exec(inner)) !== null) {
|
|
7532
|
+
pending.body = inner.slice(cursor, m.index);
|
|
7533
|
+
segments.push(pending);
|
|
7534
|
+
pending = { ratio: m[1], body: "" };
|
|
7535
|
+
cursor = m.index + m[0].length;
|
|
7536
|
+
}
|
|
7537
|
+
pending.body = inner.slice(cursor);
|
|
7538
|
+
segments.push(pending);
|
|
7539
|
+
return segments.map((s) => ({ ...s, body: s.body.replace(/^\n/, "").replace(/\n$/, "") }));
|
|
6336
7540
|
}
|
|
6337
7541
|
function reassembleColumns(content) {
|
|
6338
|
-
|
|
6339
|
-
let
|
|
6340
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
7542
|
+
let result = content;
|
|
7543
|
+
let safety = 0;
|
|
7544
|
+
while (result.includes(COLUMN_LIST_START) && safety++ < 100) {
|
|
7545
|
+
const before = result;
|
|
7546
|
+
result = result.replace(INNERMOST_COLUMN_REGION_RE, (_m, indent, inner) => {
|
|
7547
|
+
const segments = splitColumnSegments(indent ? dedentContainerBody(inner) : inner);
|
|
7548
|
+
const cols = segments[0]?.body.trim() === "" ? segments.slice(1) : segments;
|
|
7549
|
+
if (cols.every((c) => c.body.trim() === "")) return "";
|
|
7550
|
+
const parts = cols.map(
|
|
7551
|
+
(c) => `<column${c.ratio ? ` ratio="${c.ratio}"` : ""}>
|
|
7552
|
+
${indentContainerBody(c.body)}
|
|
7553
|
+
</column>`
|
|
7554
|
+
).join("\n");
|
|
7555
|
+
const block = `<columns>
|
|
7556
|
+
${indentContainerBody(parts)}
|
|
7557
|
+
</columns>`;
|
|
7558
|
+
return `${indent ? indentContainerBody(block, indent) : block}
|
|
6348
7559
|
`;
|
|
6349
|
-
|
|
7560
|
+
});
|
|
7561
|
+
if (result === before) break;
|
|
7562
|
+
}
|
|
6350
7563
|
result = result.replace(
|
|
6351
7564
|
new RegExp(
|
|
6352
|
-
`^[>\\t ]
|
|
7565
|
+
`^[>\\t ]*(?:%%${MARKER_BRAND_RE}:column-list:(?:start|end)%%|${COLUMN_SEP_SOURCE})[ \\t]*\\n?`,
|
|
6353
7566
|
"gm"
|
|
6354
7567
|
),
|
|
6355
7568
|
""
|
|
@@ -6361,17 +7574,14 @@ function unescapePipes(content) {
|
|
|
6361
7574
|
const result = [];
|
|
6362
7575
|
let inTable = false;
|
|
6363
7576
|
for (const line of lines) {
|
|
6364
|
-
|
|
7577
|
+
const trimmed = splitContainerPrefix(line).body.trim();
|
|
7578
|
+
if (/^\|.*\|$/.test(trimmed) || /^\|[\s-|]+\|$/.test(trimmed)) {
|
|
6365
7579
|
inTable = true;
|
|
6366
7580
|
result.push(line);
|
|
6367
|
-
|
|
6368
|
-
if (inTable && line.trim() === "") {
|
|
6369
|
-
inTable = false;
|
|
6370
|
-
} else if (!/^\|/.test(line.trim())) {
|
|
6371
|
-
inTable = false;
|
|
6372
|
-
}
|
|
6373
|
-
result.push(inTable ? line : line.replace(/\\\|/g, "|"));
|
|
7581
|
+
continue;
|
|
6374
7582
|
}
|
|
7583
|
+
if (!trimmed.startsWith("|")) inTable = false;
|
|
7584
|
+
result.push(inTable ? line : line.replace(/\\\|/g, "|"));
|
|
6375
7585
|
}
|
|
6376
7586
|
return result.join("\n");
|
|
6377
7587
|
}
|
|
@@ -6383,7 +7593,8 @@ function ensureCalloutContinuity(content) {
|
|
|
6383
7593
|
if (line.trim() === "" && i > 0 && i < lines.length - 1) {
|
|
6384
7594
|
const prev = result[result.length - 1] ?? "";
|
|
6385
7595
|
const next = lines[i + 1] ?? "";
|
|
6386
|
-
|
|
7596
|
+
const joinable = (s) => /^>/.test(s) && !MEDIA_PLACEHOLDER_LINE_RE.test(s);
|
|
7597
|
+
if (joinable(prev) && joinable(next) && !/^> \[!/.test(next)) {
|
|
6387
7598
|
result.push(">");
|
|
6388
7599
|
continue;
|
|
6389
7600
|
}
|
|
@@ -6392,6 +7603,7 @@ function ensureCalloutContinuity(content) {
|
|
|
6392
7603
|
}
|
|
6393
7604
|
return result.join("\n");
|
|
6394
7605
|
}
|
|
7606
|
+
var MEDIA_PLACEHOLDER_LINE_RE = new RegExp(`^${MEDIA_PLACEHOLDER_HEAD}`);
|
|
6395
7607
|
var DatabaseSyncer = class {
|
|
6396
7608
|
constructor(config, stateDb, notionClient, vaultFs, pipeline, imageHandler) {
|
|
6397
7609
|
this.config = config;
|
|
@@ -6430,10 +7642,11 @@ var DatabaseSyncer = class {
|
|
|
6430
7642
|
async pullAll() {
|
|
6431
7643
|
const databases = this.config.notion.databases;
|
|
6432
7644
|
if (!databases || databases.length === 0) {
|
|
6433
|
-
return { created: 0, updated: 0, conflicts: [], failed: [], writtenPaths: [] };
|
|
7645
|
+
return { created: 0, updated: 0, restored: 0, conflicts: [], failed: [], writtenPaths: [] };
|
|
6434
7646
|
}
|
|
6435
7647
|
let created = 0;
|
|
6436
7648
|
let updated = 0;
|
|
7649
|
+
let restored = 0;
|
|
6437
7650
|
const conflicts = [];
|
|
6438
7651
|
const failed = [];
|
|
6439
7652
|
const writtenPaths = [];
|
|
@@ -6442,6 +7655,7 @@ var DatabaseSyncer = class {
|
|
|
6442
7655
|
const result = await this.pullDatabase(dbConfig);
|
|
6443
7656
|
created += result.created;
|
|
6444
7657
|
updated += result.updated;
|
|
7658
|
+
restored += result.restored;
|
|
6445
7659
|
conflicts.push(...result.conflicts);
|
|
6446
7660
|
failed.push(...result.failed);
|
|
6447
7661
|
writtenPaths.push(...result.writtenPaths);
|
|
@@ -6454,12 +7668,12 @@ var DatabaseSyncer = class {
|
|
|
6454
7668
|
});
|
|
6455
7669
|
}
|
|
6456
7670
|
}
|
|
6457
|
-
return { created, updated, conflicts, failed, writtenPaths };
|
|
7671
|
+
return { created, updated, restored, conflicts, failed, writtenPaths };
|
|
6458
7672
|
}
|
|
6459
7673
|
async pushAll() {
|
|
6460
7674
|
const databases = this.config.notion.databases;
|
|
6461
7675
|
if (!databases || databases.length === 0) {
|
|
6462
|
-
return { created: 0, updated: 0, conflicts: [], failed: [], writtenPaths: [] };
|
|
7676
|
+
return { created: 0, updated: 0, restored: 0, conflicts: [], failed: [], writtenPaths: [] };
|
|
6463
7677
|
}
|
|
6464
7678
|
let created = 0;
|
|
6465
7679
|
let updated = 0;
|
|
@@ -6479,7 +7693,7 @@ var DatabaseSyncer = class {
|
|
|
6479
7693
|
});
|
|
6480
7694
|
}
|
|
6481
7695
|
}
|
|
6482
|
-
return { created, updated, conflicts: [], failed, writtenPaths: [] };
|
|
7696
|
+
return { created, updated, restored: 0, conflicts: [], failed, writtenPaths: [] };
|
|
6483
7697
|
}
|
|
6484
7698
|
async pullDatabase(dbConfig, opts) {
|
|
6485
7699
|
const pages = await this.notionClient.queryAllDatabasePages(
|
|
@@ -6496,6 +7710,7 @@ var DatabaseSyncer = class {
|
|
|
6496
7710
|
return {
|
|
6497
7711
|
created: 0,
|
|
6498
7712
|
updated: 0,
|
|
7713
|
+
restored: 0,
|
|
6499
7714
|
conflicts: [],
|
|
6500
7715
|
failed: [],
|
|
6501
7716
|
writtenPaths: [],
|
|
@@ -6510,28 +7725,35 @@ var DatabaseSyncer = class {
|
|
|
6510
7725
|
await this.generateBaseFile(dbConfig, viewsConfig);
|
|
6511
7726
|
let created = 0;
|
|
6512
7727
|
let updated = 0;
|
|
7728
|
+
let restored = 0;
|
|
6513
7729
|
const conflicts = [];
|
|
6514
7730
|
const failed = [];
|
|
6515
7731
|
const writtenPaths = [];
|
|
6516
7732
|
for (const page of pages) {
|
|
6517
7733
|
try {
|
|
6518
7734
|
const record = this.stateDb.getByNotionId(page.id);
|
|
7735
|
+
let restoring = false;
|
|
6519
7736
|
if (record) {
|
|
6520
7737
|
const misplaced = !isDirectDbRowPath(dbConfig.localFolder, record.obsidianPath);
|
|
6521
|
-
if (page.last_edited_time === record.notionLastEdited && !misplaced)
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
updated++;
|
|
6525
|
-
writtenPaths.push(outcome.path);
|
|
6526
|
-
} else if (outcome.action === "conflict") {
|
|
6527
|
-
conflicts.push(outcome.conflict);
|
|
7738
|
+
if (page.last_edited_time === record.notionLastEdited && !misplaced) {
|
|
7739
|
+
if (await this.vaultFs.exists(record.obsidianPath)) continue;
|
|
7740
|
+
restoring = true;
|
|
6528
7741
|
}
|
|
6529
|
-
}
|
|
6530
|
-
|
|
7742
|
+
}
|
|
7743
|
+
const outcome = await withDeadline(
|
|
7744
|
+
() => this.pullDatabasePage(page, dbConfig),
|
|
7745
|
+
this.config.advanced.itemTimeoutMs,
|
|
7746
|
+
`pull ${dbConfig.localFolder}/${page.id}`
|
|
7747
|
+
);
|
|
7748
|
+
if (!record) {
|
|
6531
7749
|
created++;
|
|
6532
|
-
if (outcome.action === "written")
|
|
6533
|
-
|
|
6534
|
-
|
|
7750
|
+
if (outcome.action === "written") writtenPaths.push(outcome.path);
|
|
7751
|
+
} else if (outcome.action === "written") {
|
|
7752
|
+
if (restoring) restored++;
|
|
7753
|
+
else updated++;
|
|
7754
|
+
writtenPaths.push(outcome.path);
|
|
7755
|
+
} else if (outcome.action === "conflict") {
|
|
7756
|
+
conflicts.push(outcome.conflict);
|
|
6535
7757
|
}
|
|
6536
7758
|
} catch (error) {
|
|
6537
7759
|
failed.push({
|
|
@@ -6542,9 +7764,9 @@ var DatabaseSyncer = class {
|
|
|
6542
7764
|
}
|
|
6543
7765
|
}
|
|
6544
7766
|
getLogger().debug(
|
|
6545
|
-
`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8, ${conflicts.length} \uCDA9\uB3CC`
|
|
7767
|
+
`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8, ${restored} \uBCF5\uC6D0, ${conflicts.length} \uCDA9\uB3CC`
|
|
6546
7768
|
);
|
|
6547
|
-
return { created, updated, conflicts, failed, writtenPaths };
|
|
7769
|
+
return { created, updated, restored, conflicts, failed, writtenPaths };
|
|
6548
7770
|
}
|
|
6549
7771
|
async pullDatabaseViews(dbConfig, force = false) {
|
|
6550
7772
|
try {
|
|
@@ -6760,14 +7982,18 @@ var DatabaseSyncer = class {
|
|
|
6760
7982
|
);
|
|
6761
7983
|
if (existingRecord) {
|
|
6762
7984
|
let localContent = "";
|
|
7985
|
+
let localExists = true;
|
|
7986
|
+
const readPath = recordPath ?? filePath;
|
|
6763
7987
|
try {
|
|
6764
|
-
localContent = await this.vaultFs.readFile(
|
|
7988
|
+
localContent = await this.vaultFs.readFile(readPath);
|
|
6765
7989
|
} catch {
|
|
6766
7990
|
localContent = "";
|
|
7991
|
+
localExists = await this.vaultFs.exists(readPath);
|
|
6767
7992
|
}
|
|
6768
7993
|
const resolution = resolvePullConflict({
|
|
6769
7994
|
record: existingRecord,
|
|
6770
7995
|
localContent,
|
|
7996
|
+
localExists,
|
|
6771
7997
|
remoteContent: finalContent,
|
|
6772
7998
|
remoteChange: {
|
|
6773
7999
|
pageId: page.id,
|
|
@@ -6827,101 +8053,24 @@ var DatabaseSyncer = class {
|
|
|
6827
8053
|
}
|
|
6828
8054
|
async pushDatabase(dbConfig) {
|
|
6829
8055
|
const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
|
|
6830
|
-
this.propertyMapper.loadSchema(schema);
|
|
6831
|
-
const allFiles = await this.vaultFs.listMarkdownFiles();
|
|
6832
|
-
const dbFiles = selectDbRowFiles(allFiles, dbConfig.localFolder);
|
|
6833
|
-
const livePaths = new Set(dbFiles.map((f) => f.path));
|
|
6834
|
-
const orphanByHash = this.buildOrphanHashIndex(dbConfig.databaseId, livePaths);
|
|
6835
|
-
let created = 0;
|
|
6836
|
-
let updated = 0;
|
|
6837
|
-
const failed = [];
|
|
6838
|
-
for (const file of dbFiles) {
|
|
6839
|
-
try {
|
|
6840
|
-
const
|
|
6841
|
-
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
|
|
6847
|
-
|
|
6848
|
-
delete frontmatter.title;
|
|
6849
|
-
const notionProps = this.propertyMapper.toNotionProperties(frontmatter, title);
|
|
6850
|
-
const enhanced = obsidianToNotionEnhanced(body.trim());
|
|
6851
|
-
if (record?.notionPageId) {
|
|
6852
|
-
try {
|
|
6853
|
-
await this.notionClient.updatePageProperties(record.notionPageId, notionProps);
|
|
6854
|
-
await this.notionClient.replacePageMarkdown(record.notionPageId, enhanced);
|
|
6855
|
-
} catch (error) {
|
|
6856
|
-
this.stateDb.updateStatus(record.id, "error");
|
|
6857
|
-
failed.push({
|
|
6858
|
-
path: file.path,
|
|
6859
|
-
operation: "update",
|
|
6860
|
-
error: error instanceof Error ? error.message : String(error)
|
|
6861
|
-
});
|
|
6862
|
-
continue;
|
|
6863
|
-
}
|
|
6864
|
-
const updatedPage = await this.notionClient.getPage(record.notionPageId);
|
|
6865
|
-
this.stateDb.transaction(() => {
|
|
6866
|
-
this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
|
|
6867
|
-
this.stateDb.updateStatus(record.id, "synced");
|
|
6868
|
-
this.stateDb.setNotionLastEdited(record.id, updatedPage.last_edited_time);
|
|
6869
|
-
this.stateDb.upsertWikilink({
|
|
6870
|
-
obsidianPath: file.path,
|
|
6871
|
-
notionPageId: record.notionPageId,
|
|
6872
|
-
title,
|
|
6873
|
-
aliases: extractAliases(frontmatter)
|
|
6874
|
-
});
|
|
6875
|
-
});
|
|
6876
|
-
updated++;
|
|
6877
|
-
} else {
|
|
6878
|
-
const renamed = orphanByHash.get(hash);
|
|
6879
|
-
if (renamed?.notionPageId) {
|
|
6880
|
-
const movedPageId = renamed.notionPageId;
|
|
6881
|
-
orphanByHash.delete(hash);
|
|
6882
|
-
this.stateDb.transaction(() => {
|
|
6883
|
-
this.stateDb.updatePath(renamed.id, file.path);
|
|
6884
|
-
this.stateDb.updateHash(renamed.id, hash, Buffer.from(content, "utf-8"));
|
|
6885
|
-
this.stateDb.updateStatus(renamed.id, "synced");
|
|
6886
|
-
this.stateDb.upsertWikilink({
|
|
6887
|
-
obsidianPath: file.path,
|
|
6888
|
-
notionPageId: movedPageId,
|
|
6889
|
-
title,
|
|
6890
|
-
aliases: extractAliases(frontmatter)
|
|
6891
|
-
});
|
|
6892
|
-
});
|
|
6893
|
-
updated++;
|
|
6894
|
-
continue;
|
|
6895
|
-
}
|
|
6896
|
-
const page = await this.notionClient.createPageWithMarkdown({
|
|
6897
|
-
parentId: dbConfig.databaseId,
|
|
6898
|
-
parentType: "database",
|
|
6899
|
-
title,
|
|
6900
|
-
markdown: enhanced,
|
|
6901
|
-
properties: notionProps
|
|
6902
|
-
});
|
|
6903
|
-
this.stateDb.transaction(() => {
|
|
6904
|
-
this.stateDb.upsert({
|
|
6905
|
-
obsidianPath: file.path,
|
|
6906
|
-
notionPageId: page.id,
|
|
6907
|
-
notionParentId: dbConfig.databaseId,
|
|
6908
|
-
contentHash: hash,
|
|
6909
|
-
notionLastEdited: page.last_edited_time,
|
|
6910
|
-
localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6911
|
-
syncDirection: "both",
|
|
6912
|
-
fileType: "db-row",
|
|
6913
|
-
status: "synced",
|
|
6914
|
-
baseSnapshot: Buffer.from(content, "utf-8")
|
|
6915
|
-
});
|
|
6916
|
-
this.stateDb.upsertWikilink({
|
|
6917
|
-
obsidianPath: file.path,
|
|
6918
|
-
notionPageId: page.id,
|
|
6919
|
-
title,
|
|
6920
|
-
aliases: extractAliases(frontmatter)
|
|
6921
|
-
});
|
|
6922
|
-
});
|
|
6923
|
-
created++;
|
|
6924
|
-
}
|
|
8056
|
+
this.propertyMapper.loadSchema(schema);
|
|
8057
|
+
const allFiles = await this.vaultFs.listMarkdownFiles();
|
|
8058
|
+
const dbFiles = selectDbRowFiles(allFiles, dbConfig.localFolder);
|
|
8059
|
+
const livePaths = new Set(dbFiles.map((f) => f.path));
|
|
8060
|
+
const orphanByHash = this.buildOrphanHashIndex(dbConfig.databaseId, livePaths);
|
|
8061
|
+
let created = 0;
|
|
8062
|
+
let updated = 0;
|
|
8063
|
+
const failed = [];
|
|
8064
|
+
for (const file of dbFiles) {
|
|
8065
|
+
try {
|
|
8066
|
+
const outcome = await withDeadline(
|
|
8067
|
+
() => this.pushDatabaseRow(file.path, dbConfig, orphanByHash),
|
|
8068
|
+
this.config.advanced.itemTimeoutMs,
|
|
8069
|
+
`push ${file.path}`
|
|
8070
|
+
);
|
|
8071
|
+
if (outcome.action === "created") created++;
|
|
8072
|
+
else if (outcome.action === "updated") updated++;
|
|
8073
|
+
else if (outcome.action === "failed") failed.push(outcome.failure);
|
|
6925
8074
|
} catch (error) {
|
|
6926
8075
|
failed.push({
|
|
6927
8076
|
path: file.path,
|
|
@@ -6931,7 +8080,105 @@ var DatabaseSyncer = class {
|
|
|
6931
8080
|
}
|
|
6932
8081
|
}
|
|
6933
8082
|
getLogger().debug(`[DB Sync] ${dbConfig.localFolder}: ${created} \uC0DD\uC131, ${updated} \uC5C5\uB370\uC774\uD2B8`);
|
|
6934
|
-
return { created, updated, conflicts: [], failed, writtenPaths: [] };
|
|
8083
|
+
return { created, updated, restored: 0, conflicts: [], failed, writtenPaths: [] };
|
|
8084
|
+
}
|
|
8085
|
+
/**
|
|
8086
|
+
* db-row 파일 1건을 Notion 으로 밀어 올린다 — 생성·갱신·rename 재매핑·스킵을 모두 담당.
|
|
8087
|
+
*
|
|
8088
|
+
* 호출부의 루프에서 떼어낸 이유는 상한(`withDeadline`)을 걸 단위가 필요해서다(R9e).
|
|
8089
|
+
* 그래서 결과는 카운터를 직접 건드리지 않고 {@link PushRowOutcome} 으로 돌려준다 —
|
|
8090
|
+
* 상한에 걸린 행이 카운터를 절반만 올린 채 끊기는 일이 구조적으로 불가능해진다.
|
|
8091
|
+
*
|
|
8092
|
+
* 본문 push 실패만 `failed` 를 **반환**하고 나머지 예외는 그대로 던진다. 둘의 차이는
|
|
8093
|
+
* 상태 처리에 있다: 전자는 레코드를 `error` 로 내려 해시 전진을 막아야 하므로 여기서
|
|
8094
|
+
* 처리하고, 후자는 호출부의 catch 가 일괄 기록한다(기존 동작 그대로).
|
|
8095
|
+
*/
|
|
8096
|
+
async pushDatabaseRow(filePath, dbConfig, orphanByHash) {
|
|
8097
|
+
const content = await this.vaultFs.readFile(filePath);
|
|
8098
|
+
const hash = computeHash(content);
|
|
8099
|
+
const record = this.stateDb.getByPath(filePath);
|
|
8100
|
+
if (record?.contentHash === hash) return { action: "skipped" };
|
|
8101
|
+
const parsed = matter(content);
|
|
8102
|
+
const frontmatter = parsed.data;
|
|
8103
|
+
const body = parsed.content;
|
|
8104
|
+
const title = frontmatter.title ?? extractTitleFromPath(filePath);
|
|
8105
|
+
delete frontmatter.title;
|
|
8106
|
+
const notionProps = this.propertyMapper.toNotionProperties(frontmatter, title);
|
|
8107
|
+
const enhanced = obsidianToNotionEnhanced(body.trim());
|
|
8108
|
+
if (record?.notionPageId) {
|
|
8109
|
+
try {
|
|
8110
|
+
await this.notionClient.updatePageProperties(record.notionPageId, notionProps);
|
|
8111
|
+
await this.notionClient.replacePageMarkdown(record.notionPageId, enhanced);
|
|
8112
|
+
} catch (error) {
|
|
8113
|
+
this.stateDb.updateStatus(record.id, "error");
|
|
8114
|
+
return {
|
|
8115
|
+
action: "failed",
|
|
8116
|
+
failure: {
|
|
8117
|
+
path: filePath,
|
|
8118
|
+
operation: "update",
|
|
8119
|
+
error: error instanceof Error ? error.message : String(error)
|
|
8120
|
+
}
|
|
8121
|
+
};
|
|
8122
|
+
}
|
|
8123
|
+
const updatedPage = await this.notionClient.getPage(record.notionPageId);
|
|
8124
|
+
this.stateDb.transaction(() => {
|
|
8125
|
+
this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
|
|
8126
|
+
this.stateDb.updateStatus(record.id, "synced");
|
|
8127
|
+
this.stateDb.setNotionLastEdited(record.id, updatedPage.last_edited_time);
|
|
8128
|
+
this.stateDb.upsertWikilink({
|
|
8129
|
+
obsidianPath: filePath,
|
|
8130
|
+
notionPageId: record.notionPageId,
|
|
8131
|
+
title,
|
|
8132
|
+
aliases: extractAliases(frontmatter)
|
|
8133
|
+
});
|
|
8134
|
+
});
|
|
8135
|
+
return { action: "updated" };
|
|
8136
|
+
}
|
|
8137
|
+
const renamed = orphanByHash.get(hash);
|
|
8138
|
+
if (renamed?.notionPageId) {
|
|
8139
|
+
const movedPageId = renamed.notionPageId;
|
|
8140
|
+
orphanByHash.delete(hash);
|
|
8141
|
+
this.stateDb.transaction(() => {
|
|
8142
|
+
this.stateDb.updatePath(renamed.id, filePath);
|
|
8143
|
+
this.stateDb.updateHash(renamed.id, hash, Buffer.from(content, "utf-8"));
|
|
8144
|
+
this.stateDb.updateStatus(renamed.id, "synced");
|
|
8145
|
+
this.stateDb.upsertWikilink({
|
|
8146
|
+
obsidianPath: filePath,
|
|
8147
|
+
notionPageId: movedPageId,
|
|
8148
|
+
title,
|
|
8149
|
+
aliases: extractAliases(frontmatter)
|
|
8150
|
+
});
|
|
8151
|
+
});
|
|
8152
|
+
return { action: "updated" };
|
|
8153
|
+
}
|
|
8154
|
+
const page = await this.notionClient.createPageWithMarkdown({
|
|
8155
|
+
parentId: dbConfig.databaseId,
|
|
8156
|
+
parentType: "database",
|
|
8157
|
+
title,
|
|
8158
|
+
markdown: enhanced,
|
|
8159
|
+
properties: notionProps
|
|
8160
|
+
});
|
|
8161
|
+
this.stateDb.transaction(() => {
|
|
8162
|
+
this.stateDb.upsert({
|
|
8163
|
+
obsidianPath: filePath,
|
|
8164
|
+
notionPageId: page.id,
|
|
8165
|
+
notionParentId: dbConfig.databaseId,
|
|
8166
|
+
contentHash: hash,
|
|
8167
|
+
notionLastEdited: page.last_edited_time,
|
|
8168
|
+
localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8169
|
+
syncDirection: "both",
|
|
8170
|
+
fileType: "db-row",
|
|
8171
|
+
status: "synced",
|
|
8172
|
+
baseSnapshot: Buffer.from(content, "utf-8")
|
|
8173
|
+
});
|
|
8174
|
+
this.stateDb.upsertWikilink({
|
|
8175
|
+
obsidianPath: filePath,
|
|
8176
|
+
notionPageId: page.id,
|
|
8177
|
+
title,
|
|
8178
|
+
aliases: extractAliases(frontmatter)
|
|
8179
|
+
});
|
|
8180
|
+
});
|
|
8181
|
+
return { action: "created" };
|
|
6935
8182
|
}
|
|
6936
8183
|
/**
|
|
6937
8184
|
* rename 감지를 위한 고아 레코드 인덱스(내용 해시 → 레코드)를 만든다.
|
|
@@ -6962,6 +8209,101 @@ function extractTitleFromPath(filePath) {
|
|
|
6962
8209
|
return filename.replace(/\.md$/, "");
|
|
6963
8210
|
}
|
|
6964
8211
|
|
|
8212
|
+
// src/audit/completeness.ts
|
|
8213
|
+
function groupLocalRowsByDatabase(local) {
|
|
8214
|
+
const byDb = /* @__PURE__ */ new Map();
|
|
8215
|
+
for (const record of local.getAll()) {
|
|
8216
|
+
if (record.fileType !== "db-row") continue;
|
|
8217
|
+
if (!record.notionParentId || !record.notionPageId) continue;
|
|
8218
|
+
const dbKey = compactNotionId(record.notionParentId);
|
|
8219
|
+
let rows = byDb.get(dbKey);
|
|
8220
|
+
if (!rows) {
|
|
8221
|
+
rows = /* @__PURE__ */ new Set();
|
|
8222
|
+
byDb.set(dbKey, rows);
|
|
8223
|
+
}
|
|
8224
|
+
rows.add(compactNotionId(record.notionPageId));
|
|
8225
|
+
}
|
|
8226
|
+
return byDb;
|
|
8227
|
+
}
|
|
8228
|
+
async function verifyDatabaseCompleteness(remote, local, options) {
|
|
8229
|
+
const localRowsByDb = groupLocalRowsByDatabase(local);
|
|
8230
|
+
const targets = [];
|
|
8231
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8232
|
+
for (const id of options?.databaseIds ?? []) {
|
|
8233
|
+
const key = compactNotionId(id);
|
|
8234
|
+
if (seen.has(key)) continue;
|
|
8235
|
+
seen.add(key);
|
|
8236
|
+
targets.push({ databaseId: id, key });
|
|
8237
|
+
}
|
|
8238
|
+
for (const key of localRowsByDb.keys()) {
|
|
8239
|
+
if (seen.has(key)) continue;
|
|
8240
|
+
seen.add(key);
|
|
8241
|
+
targets.push({ databaseId: key, key });
|
|
8242
|
+
}
|
|
8243
|
+
const databases = [];
|
|
8244
|
+
const failures = [];
|
|
8245
|
+
let remoteTotal = 0;
|
|
8246
|
+
let localTotal = 0;
|
|
8247
|
+
for (const target of targets) {
|
|
8248
|
+
const localIds = localRowsByDb.get(target.key) ?? /* @__PURE__ */ new Set();
|
|
8249
|
+
let remoteIds;
|
|
8250
|
+
try {
|
|
8251
|
+
const pages = await remote.queryAllDatabasePages(target.databaseId);
|
|
8252
|
+
remoteIds = new Set(pages.map((p) => compactNotionId(p.id)));
|
|
8253
|
+
} catch (error) {
|
|
8254
|
+
failures.push({
|
|
8255
|
+
databaseId: target.databaseId,
|
|
8256
|
+
error: error instanceof Error ? error.message : String(error)
|
|
8257
|
+
});
|
|
8258
|
+
continue;
|
|
8259
|
+
}
|
|
8260
|
+
const missingIds = [...remoteIds].filter((id) => !localIds.has(id));
|
|
8261
|
+
const extraIds = [...localIds].filter((id) => !remoteIds.has(id));
|
|
8262
|
+
remoteTotal += remoteIds.size;
|
|
8263
|
+
localTotal += localIds.size;
|
|
8264
|
+
databases.push({
|
|
8265
|
+
databaseId: target.databaseId,
|
|
8266
|
+
remoteRows: remoteIds.size,
|
|
8267
|
+
localRows: localIds.size,
|
|
8268
|
+
missingIds,
|
|
8269
|
+
extraIds
|
|
8270
|
+
});
|
|
8271
|
+
}
|
|
8272
|
+
const complete = failures.length === 0 && databases.every((d) => d.missingIds.length === 0 && d.extraIds.length === 0);
|
|
8273
|
+
return { databases, failures, remoteTotal, localTotal, complete };
|
|
8274
|
+
}
|
|
8275
|
+
async function verifyPageCompleteness(remote, local, rootPageId) {
|
|
8276
|
+
const localIds = /* @__PURE__ */ new Set();
|
|
8277
|
+
for (const record of local.getAll()) {
|
|
8278
|
+
if (record.fileType === "db-row") continue;
|
|
8279
|
+
if (!record.notionPageId) continue;
|
|
8280
|
+
localIds.add(compactNotionId(record.notionPageId));
|
|
8281
|
+
}
|
|
8282
|
+
let remoteIds;
|
|
8283
|
+
try {
|
|
8284
|
+
const pages = await remote.getPagesUnderRootViaSearch(rootPageId);
|
|
8285
|
+
remoteIds = new Set(pages.map((p) => compactNotionId(p.id)));
|
|
8286
|
+
} catch (error) {
|
|
8287
|
+
return {
|
|
8288
|
+
remotePages: 0,
|
|
8289
|
+
vaultPages: localIds.size,
|
|
8290
|
+
missingIds: [],
|
|
8291
|
+
localOnlyIds: [],
|
|
8292
|
+
error: error instanceof Error ? error.message : String(error),
|
|
8293
|
+
complete: false
|
|
8294
|
+
};
|
|
8295
|
+
}
|
|
8296
|
+
const missingIds = [...remoteIds].filter((id) => !localIds.has(id));
|
|
8297
|
+
const localOnlyIds = [...localIds].filter((id) => !remoteIds.has(id));
|
|
8298
|
+
return {
|
|
8299
|
+
remotePages: remoteIds.size,
|
|
8300
|
+
vaultPages: localIds.size,
|
|
8301
|
+
missingIds,
|
|
8302
|
+
localOnlyIds,
|
|
8303
|
+
complete: missingIds.length === 0
|
|
8304
|
+
};
|
|
8305
|
+
}
|
|
8306
|
+
|
|
6965
8307
|
// src/conflict/merger.ts
|
|
6966
8308
|
function threeWayMerge(base, local, remote) {
|
|
6967
8309
|
const baseLines = base.split("\n");
|
|
@@ -7206,6 +8548,18 @@ var ConflictResolver = class {
|
|
|
7206
8548
|
}
|
|
7207
8549
|
};
|
|
7208
8550
|
|
|
8551
|
+
// src/utils/path-scope.ts
|
|
8552
|
+
function matchesPathScope(path, scope) {
|
|
8553
|
+
const prefix = scope.endsWith("/") ? scope.slice(0, -1) : scope;
|
|
8554
|
+
if (prefix === "") return true;
|
|
8555
|
+
if (path === prefix) return true;
|
|
8556
|
+
return path.startsWith(`${prefix}/`);
|
|
8557
|
+
}
|
|
8558
|
+
function inAnyPathScope(path, scopes) {
|
|
8559
|
+
if (!scopes || scopes.length === 0) return true;
|
|
8560
|
+
return scopes.some((s) => matchesPathScope(path, s));
|
|
8561
|
+
}
|
|
8562
|
+
|
|
7209
8563
|
// src/utils/pool.ts
|
|
7210
8564
|
async function runPool(items, worker, options) {
|
|
7211
8565
|
const total = items.length;
|
|
@@ -7260,6 +8614,59 @@ function resolveFrontmatterRelations(content, idToTitle) {
|
|
|
7260
8614
|
return { content: stringifyFrontmatter(parsed.content, resolved), count };
|
|
7261
8615
|
}
|
|
7262
8616
|
|
|
8617
|
+
// src/converter/notion-id-links.ts
|
|
8618
|
+
var NOTION_ID_WIKILINK_RE = /\[\[notion:([a-f0-9]{32}|[a-f0-9-]{36})(?:\|([^[\]]+))?\]\]/g;
|
|
8619
|
+
var compactId = (id) => id.replace(/-/g, "");
|
|
8620
|
+
function resolveNotionIdWikilinks(markdown, lookup) {
|
|
8621
|
+
let resolved = 0;
|
|
8622
|
+
const out = markdown.replace(
|
|
8623
|
+
NOTION_ID_WIKILINK_RE,
|
|
8624
|
+
(match, id, rawAlias) => {
|
|
8625
|
+
const path = lookup(id);
|
|
8626
|
+
if (!path) return match;
|
|
8627
|
+
resolved++;
|
|
8628
|
+
const base = wikilinkTitleFromPath(path);
|
|
8629
|
+
const alias = rawAlias === compactId(id) ? void 0 : rawAlias;
|
|
8630
|
+
return formatWikilink(base, alias);
|
|
8631
|
+
}
|
|
8632
|
+
);
|
|
8633
|
+
return { markdown: out, resolved };
|
|
8634
|
+
}
|
|
8635
|
+
function degradeUnresolvedNotionIdWikilinks(markdown) {
|
|
8636
|
+
let degraded = 0;
|
|
8637
|
+
const out = markdown.replace(
|
|
8638
|
+
NOTION_ID_WIKILINK_RE,
|
|
8639
|
+
(_match, id, alias) => {
|
|
8640
|
+
degraded++;
|
|
8641
|
+
const compact = compactId(id);
|
|
8642
|
+
return `[${alias ?? compact}](https://www.notion.so/${compact})`;
|
|
8643
|
+
}
|
|
8644
|
+
);
|
|
8645
|
+
return { markdown: out, degraded };
|
|
8646
|
+
}
|
|
8647
|
+
var NOTION_RELATIVE_PAGE_LINK_RE = /\[([^\]]+)\]\(\/(?:p\/)?([a-f0-9]{32})(?:\?[^)#]*)?(?:#([^)]*))?\)/g;
|
|
8648
|
+
function resolveNotionRelativePageLinks(markdown, lookup) {
|
|
8649
|
+
let resolved = 0;
|
|
8650
|
+
const out = markdown.replace(NOTION_RELATIVE_PAGE_LINK_RE, (match, label, id) => {
|
|
8651
|
+
const path = lookup(id);
|
|
8652
|
+
if (!path) return match;
|
|
8653
|
+
resolved++;
|
|
8654
|
+
return formatWikilink(wikilinkTitleFromPath(path), label);
|
|
8655
|
+
});
|
|
8656
|
+
return { markdown: out, resolved };
|
|
8657
|
+
}
|
|
8658
|
+
function degradeUnresolvedNotionRelativePageLinks(markdown) {
|
|
8659
|
+
let degraded = 0;
|
|
8660
|
+
const out = markdown.replace(
|
|
8661
|
+
NOTION_RELATIVE_PAGE_LINK_RE,
|
|
8662
|
+
(_match, label, id, anchor) => {
|
|
8663
|
+
degraded++;
|
|
8664
|
+
return `[${label}](https://www.notion.so/${id}${anchor === void 0 ? "" : `#${anchor}`})`;
|
|
8665
|
+
}
|
|
8666
|
+
);
|
|
8667
|
+
return { markdown: out, degraded };
|
|
8668
|
+
}
|
|
8669
|
+
|
|
7263
8670
|
// src/utils/inline-db-refs.ts
|
|
7264
8671
|
function extractInlineDbIds(markdown) {
|
|
7265
8672
|
const ids = /* @__PURE__ */ new Set();
|
|
@@ -7363,7 +8770,8 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7363
8770
|
concurrency: config.advanced.mediaConcurrency,
|
|
7364
8771
|
maxRetries: config.advanced.mediaMaxRetries,
|
|
7365
8772
|
retryBaseMs: config.advanced.mediaRetryBaseMs,
|
|
7366
|
-
maxFileSizeBytes: config.advanced.maxFileSizeBytes
|
|
8773
|
+
maxFileSizeBytes: config.advanced.maxFileSizeBytes,
|
|
8774
|
+
downloadTimeoutMs: config.advanced.mediaDownloadTimeoutMs
|
|
7367
8775
|
},
|
|
7368
8776
|
stateDb
|
|
7369
8777
|
);
|
|
@@ -7371,7 +8779,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7371
8779
|
vaultFs,
|
|
7372
8780
|
notionClient,
|
|
7373
8781
|
stateDb,
|
|
7374
|
-
config.advanced.fileConcurrency
|
|
8782
|
+
config.advanced.fileConcurrency,
|
|
8783
|
+
{
|
|
8784
|
+
fetch: customFetch,
|
|
8785
|
+
downloadTimeoutMs: config.advanced.mediaDownloadTimeoutMs,
|
|
8786
|
+
itemTimeoutMs: config.advanced.itemTimeoutMs
|
|
8787
|
+
}
|
|
7375
8788
|
);
|
|
7376
8789
|
this.propertyMapper = new PropertyMapper();
|
|
7377
8790
|
this.databaseSyncer = new DatabaseSyncer(
|
|
@@ -7419,6 +8832,10 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7419
8832
|
// column 등 컨테이너에 중첩된 자식 페이지를 놓쳐 폴더노트를 file 로 오분류하던 결함
|
|
7420
8833
|
// (본문이 최상위로 밀려 ' (1).md' 로 분리)을 차단한다. detectRemoteChanges* 진입 시 재구성.
|
|
7421
8834
|
_childParentIds = /* @__PURE__ */ new Set();
|
|
8835
|
+
// 이번 pull 실행에서 이미 배정된 파일 경로. 워커 풀이 동시에 도는 동안 동명 페이지가
|
|
8836
|
+
// 같은 경로를 골라 서로를 덮어쓰는 것을 막는다({@link resolveUniqueFilePath}).
|
|
8837
|
+
// 실행마다 비운다 — 지난 실행에서 삭제된 경로를 영구히 막지 않기 위해.
|
|
8838
|
+
claimedPaths = /* @__PURE__ */ new Set();
|
|
7422
8839
|
// 서브트리 직접 순회(getChildPagesRecursive) 시간 예산. 초과하면 search 기반 디스커버리로
|
|
7423
8840
|
// 폴백한다. 분기점 근거: 워크스페이스 search 열거는 latency-bound 로 대략 이 수준(수천 페이지
|
|
7424
8841
|
// 워크스페이스에서 ~100s)이므로, 순회가 이 시간을 넘기면 search 가 더 저렴해진다. 작은 볼트는
|
|
@@ -7444,7 +8861,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7444
8861
|
);
|
|
7445
8862
|
const isDbPath = (path) => dbFolders.some((prefix) => path.startsWith(prefix));
|
|
7446
8863
|
const excludeSet = options?.excludePaths ? new Set(options.excludePaths) : null;
|
|
7447
|
-
const filtered = (options?.paths ? eligible.filter((c) =>
|
|
8864
|
+
const filtered = (options?.paths ? eligible.filter((c) => inAnyPathScope(c.path, options.paths)) : eligible).filter((c) => !isDbPath(c.path) && (!excludeSet || !excludeSet.has(c.path)));
|
|
7448
8865
|
const hasDbConfigs = (this.config.notion.databases?.length ?? 0) > 0;
|
|
7449
8866
|
if (filtered.length === 0 && !hasDbConfigs) {
|
|
7450
8867
|
return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
|
|
@@ -7516,7 +8933,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7516
8933
|
if (options?.signal?.aborted) return;
|
|
7517
8934
|
options?.onProgress?.(++completed, total, { path: change.path, operation: opOf(change) });
|
|
7518
8935
|
try {
|
|
7519
|
-
await
|
|
8936
|
+
await withDeadline(
|
|
8937
|
+
() => applyPushChange(change),
|
|
8938
|
+
this.config.advanced.itemTimeoutMs,
|
|
8939
|
+
`push ${change.path}`
|
|
8940
|
+
);
|
|
7520
8941
|
} catch {
|
|
7521
8942
|
retryQueue.push(change);
|
|
7522
8943
|
}
|
|
@@ -7533,7 +8954,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7533
8954
|
retryQueue,
|
|
7534
8955
|
async (change) => {
|
|
7535
8956
|
try {
|
|
7536
|
-
await
|
|
8957
|
+
await withDeadline(
|
|
8958
|
+
() => applyPushChange(change),
|
|
8959
|
+
this.config.advanced.itemTimeoutMs,
|
|
8960
|
+
`push ${change.path}`
|
|
8961
|
+
);
|
|
7537
8962
|
getLogger().info(`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC131\uACF5: ${change.path}`);
|
|
7538
8963
|
} catch (error) {
|
|
7539
8964
|
failed.push({
|
|
@@ -7581,10 +9006,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7581
9006
|
}
|
|
7582
9007
|
async pull(options) {
|
|
7583
9008
|
const startTime = Date.now();
|
|
9009
|
+
this.claimedPaths.clear();
|
|
7584
9010
|
const emptyResult = {
|
|
7585
9011
|
created: 0,
|
|
7586
9012
|
updated: 0,
|
|
7587
9013
|
deleted: 0,
|
|
9014
|
+
restored: 0,
|
|
7588
9015
|
conflicts: [],
|
|
7589
9016
|
writtenPaths: [],
|
|
7590
9017
|
failed: [],
|
|
@@ -7597,14 +9024,14 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7597
9024
|
return emptyResult;
|
|
7598
9025
|
}
|
|
7599
9026
|
this.cleanupInterruptedSync();
|
|
7600
|
-
const counts = { created: 0, updated: 0, deleted: 0 };
|
|
9027
|
+
const counts = { created: 0, updated: 0, deleted: 0, restored: 0 };
|
|
7601
9028
|
this._pullImageCount = 0;
|
|
7602
9029
|
this._pullFileCount = 0;
|
|
7603
9030
|
this._inlineDbRefs.clear();
|
|
7604
9031
|
const conflicts = [];
|
|
7605
9032
|
const writtenPaths = [];
|
|
7606
9033
|
const failed = [];
|
|
7607
|
-
const finalize = async (created, updated, deleted) => {
|
|
9034
|
+
const finalize = async (created, updated, deleted, restored = 0) => {
|
|
7608
9035
|
const linkCount = writtenPaths.length > 0 ? await this.resolveNotionLinks(writtenPaths) : 0;
|
|
7609
9036
|
this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
7610
9037
|
this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
@@ -7613,6 +9040,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7613
9040
|
created,
|
|
7614
9041
|
updated,
|
|
7615
9042
|
deleted,
|
|
9043
|
+
restored,
|
|
7616
9044
|
conflicts,
|
|
7617
9045
|
writtenPaths,
|
|
7618
9046
|
failed,
|
|
@@ -7628,27 +9056,54 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7628
9056
|
const remoteChanges = canUseIncremental ? await this.detectRemoteChangesIncremental(lastPull) : await this.detectRemoteChanges();
|
|
7629
9057
|
const filtered = options?.paths ? remoteChanges.filter((c) => {
|
|
7630
9058
|
const record = this.stateDb.getByNotionId(c.pageId);
|
|
7631
|
-
return record &&
|
|
9059
|
+
return !!record && inAnyPathScope(record.obsidianPath, options.paths);
|
|
7632
9060
|
}) : remoteChanges;
|
|
7633
|
-
|
|
9061
|
+
const queuedIds = new Set(filtered.map((c) => c.pageId));
|
|
9062
|
+
const restoreChanges = (await this.detectMissingLocalFiles(options?.paths)).filter((r) => r.notionPageId !== null && !queuedIds.has(r.notionPageId)).map((r) => ({
|
|
9063
|
+
pageId: r.notionPageId,
|
|
9064
|
+
type: "modified",
|
|
9065
|
+
lastEdited: r.notionLastEdited ?? r.updatedAt,
|
|
9066
|
+
previousEdited: r.notionLastEdited
|
|
9067
|
+
}));
|
|
9068
|
+
const restoreIds = new Set(restoreChanges.map((c) => c.pageId));
|
|
9069
|
+
const workItems = restoreChanges.length > 0 ? [...filtered, ...restoreChanges] : filtered;
|
|
9070
|
+
if (workItems.length === 0) {
|
|
9071
|
+
let dbCreated = 0;
|
|
9072
|
+
let dbUpdated = 0;
|
|
9073
|
+
let dbRestored = 0;
|
|
9074
|
+
if ((this.config.notion.databases?.length ?? 0) > 0) {
|
|
9075
|
+
try {
|
|
9076
|
+
const dbResult = await this.databaseSyncer.pullAll();
|
|
9077
|
+
dbCreated += dbResult.created;
|
|
9078
|
+
dbUpdated += dbResult.updated;
|
|
9079
|
+
dbRestored += dbResult.restored;
|
|
9080
|
+
conflicts.push(...dbResult.conflicts);
|
|
9081
|
+
failed.push(...dbResult.failed);
|
|
9082
|
+
writtenPaths.push(...dbResult.writtenPaths);
|
|
9083
|
+
} catch (error) {
|
|
9084
|
+
getLogger().warn("[Im-Nobsidian] DB Pull \uC911 \uC624\uB958:", error);
|
|
9085
|
+
}
|
|
9086
|
+
}
|
|
7634
9087
|
const dbDiscovery = await this.pullDiscoveredDatabases(
|
|
7635
9088
|
writtenPaths,
|
|
7636
9089
|
failed,
|
|
7637
9090
|
conflicts,
|
|
7638
9091
|
options?.force === true
|
|
7639
9092
|
);
|
|
7640
|
-
return finalize(
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
9093
|
+
return finalize(
|
|
9094
|
+
dbCreated + dbDiscovery.created,
|
|
9095
|
+
dbUpdated + dbDiscovery.updated,
|
|
9096
|
+
0,
|
|
9097
|
+
dbRestored + dbDiscovery.restored
|
|
9098
|
+
);
|
|
7644
9099
|
}
|
|
7645
9100
|
if (options?.dryRun) {
|
|
7646
9101
|
const dryCreated = filtered.filter((c) => c.type === "created").length;
|
|
7647
9102
|
const dryUpdated = filtered.filter((c) => c.type === "modified").length;
|
|
7648
9103
|
const dryDeleted = filtered.filter((c) => c.type === "deleted").length;
|
|
7649
9104
|
let dryProgress = 0;
|
|
7650
|
-
const dryTotal =
|
|
7651
|
-
for (const change of
|
|
9105
|
+
const dryTotal = workItems.length;
|
|
9106
|
+
for (const change of workItems) {
|
|
7652
9107
|
const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
|
|
7653
9108
|
const record = this.stateDb.getByNotionId(change.pageId);
|
|
7654
9109
|
const displayPath = record?.obsidianPath ?? change.pageId;
|
|
@@ -7658,6 +9113,9 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7658
9113
|
created: dryCreated,
|
|
7659
9114
|
updated: dryUpdated,
|
|
7660
9115
|
deleted: dryDeleted,
|
|
9116
|
+
// 복원은 updated 에 섞지 않는다 — dry-run 이 "수정 N건" 이라고만 말하면
|
|
9117
|
+
// 사용자가 사라진 파일이 되살아난다는 사실을 미리 알 수 없다.
|
|
9118
|
+
restored: restoreChanges.length,
|
|
7661
9119
|
conflicts: [],
|
|
7662
9120
|
writtenPaths: [],
|
|
7663
9121
|
failed: [],
|
|
@@ -7668,7 +9126,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7668
9126
|
};
|
|
7669
9127
|
}
|
|
7670
9128
|
this.stateDb.setMeta("pull_in_progress", "true");
|
|
7671
|
-
const pullTotal =
|
|
9129
|
+
const pullTotal = workItems.length;
|
|
7672
9130
|
let pullCompleted = 0;
|
|
7673
9131
|
const toFailure = (change, error) => {
|
|
7674
9132
|
const record = this.stateDb.getByNotionId(change.pageId);
|
|
@@ -7692,7 +9150,8 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7692
9150
|
conflicts.push(result.conflict);
|
|
7693
9151
|
} else if (result.path && !result.unchanged) {
|
|
7694
9152
|
writtenPaths.push(result.path);
|
|
7695
|
-
counts.
|
|
9153
|
+
if (restoreIds.has(change.pageId)) counts.restored++;
|
|
9154
|
+
else counts.updated++;
|
|
7696
9155
|
}
|
|
7697
9156
|
return result.path;
|
|
7698
9157
|
}
|
|
@@ -7711,11 +9170,15 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7711
9170
|
};
|
|
7712
9171
|
const retryQueue = [];
|
|
7713
9172
|
await runPool(
|
|
7714
|
-
|
|
9173
|
+
workItems,
|
|
7715
9174
|
async (change) => {
|
|
7716
9175
|
if (options?.signal?.aborted) return;
|
|
7717
9176
|
try {
|
|
7718
|
-
const resultPath = await
|
|
9177
|
+
const resultPath = await withDeadline(
|
|
9178
|
+
() => applyChange(change),
|
|
9179
|
+
this.config.advanced.itemTimeoutMs,
|
|
9180
|
+
`pull ${this.stateDb.getByNotionId(change.pageId)?.obsidianPath ?? change.pageId}`
|
|
9181
|
+
);
|
|
7719
9182
|
const record = this.stateDb.getByNotionId(change.pageId);
|
|
7720
9183
|
const displayPath = resultPath ?? record?.obsidianPath ?? change.pageId;
|
|
7721
9184
|
const op = change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update";
|
|
@@ -7736,7 +9199,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7736
9199
|
retryQueue,
|
|
7737
9200
|
async (change) => {
|
|
7738
9201
|
try {
|
|
7739
|
-
await
|
|
9202
|
+
await withDeadline(
|
|
9203
|
+
() => applyChange(change),
|
|
9204
|
+
this.config.advanced.itemTimeoutMs,
|
|
9205
|
+
`pull ${this.stateDb.getByNotionId(change.pageId)?.obsidianPath ?? change.pageId}`
|
|
9206
|
+
);
|
|
7740
9207
|
const record = this.stateDb.getByNotionId(change.pageId);
|
|
7741
9208
|
getLogger().info(
|
|
7742
9209
|
`[Im-Nobsidian] \uC7AC\uC2DC\uB3C4 \uC131\uACF5: ${record?.obsidianPath ?? change.pageId}`
|
|
@@ -7755,6 +9222,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7755
9222
|
const dbResult = await this.databaseSyncer.pullAll();
|
|
7756
9223
|
counts.created += dbResult.created;
|
|
7757
9224
|
counts.updated += dbResult.updated;
|
|
9225
|
+
counts.restored += dbResult.restored;
|
|
7758
9226
|
conflicts.push(...dbResult.conflicts);
|
|
7759
9227
|
failed.push(...dbResult.failed);
|
|
7760
9228
|
writtenPaths.push(...dbResult.writtenPaths);
|
|
@@ -7771,8 +9239,9 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7771
9239
|
);
|
|
7772
9240
|
counts.created += dbDiscovery.created;
|
|
7773
9241
|
counts.updated += dbDiscovery.updated;
|
|
9242
|
+
counts.restored += dbDiscovery.restored;
|
|
7774
9243
|
}
|
|
7775
|
-
return finalize(counts.created, counts.updated, counts.deleted);
|
|
9244
|
+
return finalize(counts.created, counts.updated, counts.deleted, counts.restored);
|
|
7776
9245
|
}
|
|
7777
9246
|
async sync(options) {
|
|
7778
9247
|
const startTime = Date.now();
|
|
@@ -7826,6 +9295,45 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7826
9295
|
lastSyncAt
|
|
7827
9296
|
};
|
|
7828
9297
|
}
|
|
9298
|
+
/**
|
|
9299
|
+
* 볼트 완결성 검증 — 원격에 있는 것이 볼트에 빠짐없이 있는지 대조한다(R11-B · R12-C).
|
|
9300
|
+
*
|
|
9301
|
+
* 기존 게이트(해시 일치·repull 바이트 동일·churn 0)는 전부 **멱등성**을 본다. 매번
|
|
9302
|
+
* 같은 것을 놓치는 체계적 미발견은 그 게이트를 전부 통과한다 — 실제로 296개 DB 행이
|
|
9303
|
+
* 그렇게 침묵 유실됐다. 여기서만 "빠짐없다"를 본다.
|
|
9304
|
+
*
|
|
9305
|
+
* 대조 대상 database id 는 이 클래스가 조립한다: 설정에 적힌 것 + 디스커버리 캐시가
|
|
9306
|
+
* 아는 것 + DB 모드의 루트 DB. `discovered_dbs` 메타 키를 아는 곳을 이 파일 하나로
|
|
9307
|
+
* 유지해, 호출부(CLI·E2E)가 캐시 표현을 각자 다시 해석하지 않게 한다.
|
|
9308
|
+
*
|
|
9309
|
+
* 페이지(R12-C)는 pull 이 쓴 경로가 아니라 **search 열거**로 대조한다. 페이지는 열거
|
|
9310
|
+
* 경로가 둘이고 둘이 같은 집합을 내지 않는 게 애초의 결함이므로(R12-A), 독립된 두 번째
|
|
9311
|
+
* 열거와 맞대 봐야 의미가 있다.
|
|
9312
|
+
*/
|
|
9313
|
+
async verifyCompleteness() {
|
|
9314
|
+
const ids = [];
|
|
9315
|
+
if (this.isDatabaseMode) ids.push(this.config.notion.databaseId);
|
|
9316
|
+
for (const db of this.config.notion.databases ?? []) ids.push(db.databaseId);
|
|
9317
|
+
const cachedRaw = this.stateDb.getMeta("discovered_dbs");
|
|
9318
|
+
if (cachedRaw) {
|
|
9319
|
+
try {
|
|
9320
|
+
const cached = JSON.parse(cachedRaw);
|
|
9321
|
+
for (const entry of cached) {
|
|
9322
|
+
if (entry.databaseId) ids.push(entry.databaseId);
|
|
9323
|
+
}
|
|
9324
|
+
} catch {
|
|
9325
|
+
}
|
|
9326
|
+
}
|
|
9327
|
+
const databases = await verifyDatabaseCompleteness(this.notionClient, this.stateDb, {
|
|
9328
|
+
databaseIds: ids
|
|
9329
|
+
});
|
|
9330
|
+
const pages = this.isDatabaseMode ? null : await verifyPageCompleteness(
|
|
9331
|
+
this.notionClient,
|
|
9332
|
+
this.stateDb,
|
|
9333
|
+
this.config.notion.rootPageId
|
|
9334
|
+
);
|
|
9335
|
+
return { databases, pages, complete: databases.complete && (pages?.complete ?? true) };
|
|
9336
|
+
}
|
|
7829
9337
|
async fetch() {
|
|
7830
9338
|
const startTime = Date.now();
|
|
7831
9339
|
const changes = await this.detectRemoteChanges();
|
|
@@ -7837,7 +9345,12 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7837
9345
|
duration: Date.now() - startTime
|
|
7838
9346
|
};
|
|
7839
9347
|
}
|
|
7840
|
-
|
|
9348
|
+
/**
|
|
9349
|
+
* @param options.fullRender 원격 본문을 pull 과 동일한 파이프라인으로 렌더할지.
|
|
9350
|
+
* 기본(false)은 화면 미리보기용 경량 렌더 — 첨부를 내려받지 않으므로 `status` 처럼
|
|
9351
|
+
* 읽기만 하는 경로가 쓴다. 볼트에 덮어쓸 본문이 필요한 해소 경로는 반드시 켠다.
|
|
9352
|
+
*/
|
|
9353
|
+
async buildConflictsFromRecords(records, localChanges, remoteChanges, options) {
|
|
7841
9354
|
const conflicts = [];
|
|
7842
9355
|
for (const record of records) {
|
|
7843
9356
|
const localChange = localChanges.find((c) => c.path === record.obsidianPath) ?? {
|
|
@@ -7846,24 +9359,34 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7846
9359
|
currentHash: record.contentHash,
|
|
7847
9360
|
previousHash: record.contentHash
|
|
7848
9361
|
};
|
|
7849
|
-
const remoteChange = remoteChanges.find((c) => c.pageId === record.notionPageId) ?? {
|
|
7850
|
-
pageId: record.notionPageId ?? "",
|
|
7851
|
-
type: "modified",
|
|
7852
|
-
lastEdited: record.notionLastEdited ?? "",
|
|
7853
|
-
previousEdited: null
|
|
7854
|
-
};
|
|
7855
9362
|
let localContent = "";
|
|
7856
9363
|
try {
|
|
7857
9364
|
localContent = await this.vaultFs.readFile(record.obsidianPath);
|
|
7858
9365
|
} catch {
|
|
7859
9366
|
}
|
|
7860
9367
|
let remoteContent = "";
|
|
9368
|
+
let remoteLastEdited = null;
|
|
7861
9369
|
if (record.notionPageId) {
|
|
7862
9370
|
try {
|
|
7863
|
-
|
|
9371
|
+
if (options?.fullRender) {
|
|
9372
|
+
const page = await this.notionClient.getPage(record.notionPageId);
|
|
9373
|
+
remoteLastEdited = page.last_edited_time ?? null;
|
|
9374
|
+
remoteContent = (await this.renderRemotePage(record, record.notionPageId, page)).content;
|
|
9375
|
+
} else {
|
|
9376
|
+
remoteContent = (await this.fetchPageMarkdown(record.notionPageId)).content;
|
|
9377
|
+
}
|
|
7864
9378
|
} catch {
|
|
7865
9379
|
}
|
|
7866
9380
|
}
|
|
9381
|
+
const remoteChange = remoteChanges.find((c) => c.pageId === record.notionPageId) ?? {
|
|
9382
|
+
pageId: record.notionPageId ?? "",
|
|
9383
|
+
type: "modified",
|
|
9384
|
+
// 현재 원격 시각을 실제로 읽어왔다면 그 값을 쓴다. 해소 후 재조정(propagateResolution)
|
|
9385
|
+
// 이 이 값을 그대로 기준점으로 삼는데, 낡은 저장값을 실으면 다음 pull 이 같은 변경을
|
|
9386
|
+
// 다시 충돌로 보고 무한 재충돌한다.
|
|
9387
|
+
lastEdited: remoteLastEdited ?? record.notionLastEdited ?? "",
|
|
9388
|
+
previousEdited: null
|
|
9389
|
+
};
|
|
7867
9390
|
conflicts.push({
|
|
7868
9391
|
syncRecord: record,
|
|
7869
9392
|
localChange,
|
|
@@ -7985,9 +9508,10 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
7985
9508
|
}
|
|
7986
9509
|
}
|
|
7987
9510
|
async pullDiscoveredDatabases(writtenPaths, failed, conflicts, forceRediscovery = false) {
|
|
7988
|
-
if (this.isDatabaseMode) return { created: 0, updated: 0 };
|
|
9511
|
+
if (this.isDatabaseMode) return { created: 0, updated: 0, restored: 0 };
|
|
7989
9512
|
let created = 0;
|
|
7990
9513
|
let updated = 0;
|
|
9514
|
+
let restored = 0;
|
|
7991
9515
|
try {
|
|
7992
9516
|
const cachedRaw = this.stateDb.getMeta("discovered_dbs");
|
|
7993
9517
|
let dbConfigs = [];
|
|
@@ -8117,6 +9641,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8117
9641
|
}
|
|
8118
9642
|
created += dbResult.created;
|
|
8119
9643
|
updated += dbResult.updated;
|
|
9644
|
+
restored += dbResult.restored;
|
|
8120
9645
|
conflicts.push(...dbResult.conflicts);
|
|
8121
9646
|
failed.push(...dbResult.failed);
|
|
8122
9647
|
writtenPaths.push(...dbResult.writtenPaths);
|
|
@@ -8162,7 +9687,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8162
9687
|
} catch (error) {
|
|
8163
9688
|
getLogger().warn("[Im-Nobsidian] child_database \uC790\uB3D9 \uBC1C\uACAC \uC2E4\uD328:", error);
|
|
8164
9689
|
}
|
|
8165
|
-
return { created, updated };
|
|
9690
|
+
return { created, updated, restored };
|
|
8166
9691
|
}
|
|
8167
9692
|
/**
|
|
8168
9693
|
* pull 산출 md 의 인라인 DB placeholder(`**제목** *(Notion DB)*` + 보존 마커)를
|
|
@@ -8219,51 +9744,55 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8219
9744
|
}
|
|
8220
9745
|
async resolveNotionLinks(paths) {
|
|
8221
9746
|
let totalResolved = 0;
|
|
9747
|
+
let totalDegraded = 0;
|
|
8222
9748
|
const allRecords = this.stateDb.getAll();
|
|
8223
9749
|
const idToTitle = /* @__PURE__ */ new Map();
|
|
9750
|
+
const idToPath = /* @__PURE__ */ new Map();
|
|
8224
9751
|
for (const r of allRecords) {
|
|
8225
9752
|
if (r.notionPageId && r.obsidianPath) {
|
|
8226
9753
|
const title = wikilinkTitleFromPath(r.obsidianPath);
|
|
8227
9754
|
const cleanId = r.notionPageId.replace(/-/g, "");
|
|
8228
9755
|
idToTitle.set(cleanId, title);
|
|
8229
9756
|
idToTitle.set(r.notionPageId, title);
|
|
9757
|
+
idToPath.set(cleanId, r.obsidianPath);
|
|
8230
9758
|
}
|
|
8231
9759
|
}
|
|
8232
9760
|
if (idToTitle.size === 0) return 0;
|
|
9761
|
+
const notionIdToPath = (id) => idToPath.get(id.replace(/-/g, "")) ?? null;
|
|
8233
9762
|
for (const filePath of paths) {
|
|
8234
9763
|
if (!filePath.endsWith(".md")) continue;
|
|
8235
9764
|
try {
|
|
8236
9765
|
let content = await this.vaultFs.readFile(filePath);
|
|
8237
9766
|
let changed = false;
|
|
8238
|
-
const
|
|
8239
|
-
|
|
8240
|
-
|
|
8241
|
-
|
|
8242
|
-
|
|
8243
|
-
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
|
|
8250
|
-
(_match, text, id) => {
|
|
8251
|
-
const title = idToTitle.get(id);
|
|
8252
|
-
if (title) {
|
|
8253
|
-
totalResolved++;
|
|
8254
|
-
changed = true;
|
|
8255
|
-
return `[[${title}|${text}]]`;
|
|
8256
|
-
}
|
|
8257
|
-
return _match;
|
|
8258
|
-
}
|
|
8259
|
-
);
|
|
8260
|
-
content = resolved2;
|
|
9767
|
+
const idPass = resolveNotionIdWikilinks(content, notionIdToPath);
|
|
9768
|
+
content = idPass.markdown;
|
|
9769
|
+
if (idPass.resolved > 0) {
|
|
9770
|
+
totalResolved += idPass.resolved;
|
|
9771
|
+
changed = true;
|
|
9772
|
+
}
|
|
9773
|
+
const urlPass = resolveNotionRelativePageLinks(content, notionIdToPath);
|
|
9774
|
+
content = urlPass.markdown;
|
|
9775
|
+
if (urlPass.resolved > 0) {
|
|
9776
|
+
totalResolved += urlPass.resolved;
|
|
9777
|
+
changed = true;
|
|
9778
|
+
}
|
|
8261
9779
|
const fm = resolveFrontmatterRelations(content, idToTitle);
|
|
8262
9780
|
if (fm.count > 0) {
|
|
8263
9781
|
content = fm.content;
|
|
8264
9782
|
totalResolved += fm.count;
|
|
8265
9783
|
changed = true;
|
|
8266
9784
|
}
|
|
9785
|
+
for (const degrade of [
|
|
9786
|
+
degradeUnresolvedNotionIdWikilinks,
|
|
9787
|
+
degradeUnresolvedNotionRelativePageLinks
|
|
9788
|
+
]) {
|
|
9789
|
+
const degradation = degrade(content);
|
|
9790
|
+
if (degradation.degraded > 0) {
|
|
9791
|
+
content = degradation.markdown;
|
|
9792
|
+
totalDegraded += degradation.degraded;
|
|
9793
|
+
changed = true;
|
|
9794
|
+
}
|
|
9795
|
+
}
|
|
8267
9796
|
if (changed) {
|
|
8268
9797
|
await this.vaultFs.writeFile(filePath, content);
|
|
8269
9798
|
const record = this.stateDb.getByPath(filePath);
|
|
@@ -8282,6 +9811,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8282
9811
|
} catch {
|
|
8283
9812
|
}
|
|
8284
9813
|
}
|
|
9814
|
+
if (totalDegraded > 0) {
|
|
9815
|
+
getLogger().info(
|
|
9816
|
+
`[Im-Nobsidian] \uBCFC\uD2B8 \uBC16 Notion \uD398\uC774\uC9C0 \uB9C1\uD06C ${totalDegraded}\uAC74\uC744 URL \uB9C1\uD06C\uB85C \uC720\uC9C0`
|
|
9817
|
+
);
|
|
9818
|
+
}
|
|
8285
9819
|
return totalResolved;
|
|
8286
9820
|
}
|
|
8287
9821
|
async ensureDbSchema() {
|
|
@@ -8323,7 +9857,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8323
9857
|
title
|
|
8324
9858
|
);
|
|
8325
9859
|
}
|
|
8326
|
-
const
|
|
9860
|
+
const placeholder2 = this.stateDb.upsert({
|
|
8327
9861
|
obsidianPath: path,
|
|
8328
9862
|
notionPageId: null,
|
|
8329
9863
|
notionParentId: parentId,
|
|
@@ -8337,9 +9871,9 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8337
9871
|
localMtime: null,
|
|
8338
9872
|
localFileSize: null
|
|
8339
9873
|
});
|
|
8340
|
-
const existingOp = this.stateDb.getIncompleteOpByState(
|
|
9874
|
+
const existingOp = this.stateDb.getIncompleteOpByState(placeholder2.id, "create");
|
|
8341
9875
|
const walOpId = existingOp?.id ?? this.stateDb.recordPendingOperation({
|
|
8342
|
-
syncStateId:
|
|
9876
|
+
syncStateId: placeholder2.id,
|
|
8343
9877
|
operation: "create",
|
|
8344
9878
|
direction: "push",
|
|
8345
9879
|
payload: JSON.stringify({ path, parentId, title })
|
|
@@ -8365,7 +9899,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8365
9899
|
localMtime: null,
|
|
8366
9900
|
localFileSize: null
|
|
8367
9901
|
});
|
|
8368
|
-
await this.
|
|
9902
|
+
await this.syncEmbeddedMedia(page.id, conversionResult, path);
|
|
8369
9903
|
const hash = computeHash(content);
|
|
8370
9904
|
const fileStat = await this.vaultFs.getFileStat(path);
|
|
8371
9905
|
this.stateDb.transaction(() => {
|
|
@@ -8394,6 +9928,27 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8394
9928
|
});
|
|
8395
9929
|
this.stateDb.markPendingCompleted(walOpId);
|
|
8396
9930
|
}
|
|
9931
|
+
/**
|
|
9932
|
+
* 노트에 박힌 로컬 미디어를 Notion 에 올린다(R1).
|
|
9933
|
+
*
|
|
9934
|
+
* 먼저 본문 자리표시자를 실제 image/file 블록으로 **제자리** 교체한다. 그러고도 남은
|
|
9935
|
+
* 이미지만 페이지 끝에 덧붙이는 예전 경로로 흘린다 — 자리표시자가 없는 경우는 본문
|
|
9936
|
+
* push 가 통째로 스킵됐을 때(pageHasLiveChildren) 정도이고, 그때도 이미지 자체는
|
|
9937
|
+
* 올라가야 하므로 폴백을 남긴다.
|
|
9938
|
+
*/
|
|
9939
|
+
async syncEmbeddedMedia(pageId, conversionResult, path) {
|
|
9940
|
+
const { handledTargets } = await this.imageHandler.materializeLocalMedia(
|
|
9941
|
+
pageId,
|
|
9942
|
+
conversionResult.content,
|
|
9943
|
+
path
|
|
9944
|
+
);
|
|
9945
|
+
const leftovers = conversionResult.images.filter(
|
|
9946
|
+
(img) => !img.localPath || !handledTargets.has(img.localPath)
|
|
9947
|
+
);
|
|
9948
|
+
if (leftovers.length > 0) {
|
|
9949
|
+
await this.imageHandler.uploadAndAppendImages(pageId, leftovers, path);
|
|
9950
|
+
}
|
|
9951
|
+
}
|
|
8397
9952
|
async pushUpdate(path) {
|
|
8398
9953
|
const content = await this.vaultFs.readFile(path);
|
|
8399
9954
|
const record = this.stateDb.getByPath(path);
|
|
@@ -8411,11 +9966,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8411
9966
|
parentMode: this.config.notion.parentMode
|
|
8412
9967
|
});
|
|
8413
9968
|
await this.pushUpdatePage(record.notionPageId, conversionResult.content, record.baseSnapshot);
|
|
8414
|
-
await this.
|
|
8415
|
-
record.notionPageId,
|
|
8416
|
-
conversionResult.images,
|
|
8417
|
-
path
|
|
8418
|
-
);
|
|
9969
|
+
await this.syncEmbeddedMedia(record.notionPageId, conversionResult, path);
|
|
8419
9970
|
let propsToUpdate;
|
|
8420
9971
|
if (this.isDatabaseMode && conversionResult.properties && Object.keys(conversionResult.properties).length > 0) {
|
|
8421
9972
|
await this.ensureDbSchema();
|
|
@@ -8465,6 +10016,67 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8465
10016
|
// 유실·충돌 루프가 발생한다. 해소 → 전파(propagate)를 오케스트레이터에서 봉합해 무손실
|
|
8466
10017
|
// 보장. 변환 파이프라인이 필요한 push 는 기존 엔터프라이즈 경로(pushUpdate)를 재사용한다.
|
|
8467
10018
|
// ──────────────────────────────────────────────────────────────────────────
|
|
10019
|
+
/**
|
|
10020
|
+
* 해소 대상 충돌 목록 — 원격 본문을 pull 과 **같은 파이프라인**으로 렌더해 담는다.
|
|
10021
|
+
*
|
|
10022
|
+
* 해소는 실제로 볼트 파일을 덮어쓰므로 `status()` 의 경량 미리보기 렌더를 쓰면 안 된다
|
|
10023
|
+
* (프론트매터·첨부가 빠진 반쪽 본문이 덮인다). 반대로 전체 pull 을 먼저 돌려 목록을
|
|
10024
|
+
* 얻는 것도 안 된다 — 해소하겠다고 볼트를 먼저 원격으로 덮어쓰는 셈이라 순서가 거꾸로다.
|
|
10025
|
+
* 그래서 충돌 레코드만 좁혀 그 페이지들만 읽어 온다.
|
|
10026
|
+
*/
|
|
10027
|
+
async listConflicts() {
|
|
10028
|
+
const records = this.stateDb.getByStatus("conflict");
|
|
10029
|
+
if (records.length === 0) return [];
|
|
10030
|
+
const files = await this.vaultFs.listMarkdownFiles();
|
|
10031
|
+
const localChanges = this.changeDetector.detectLocalChanges(files);
|
|
10032
|
+
return this.buildConflictsFromRecords(records, localChanges, [], { fullRender: true });
|
|
10033
|
+
}
|
|
10034
|
+
/**
|
|
10035
|
+
* 추적 파일의 **현재 원격 본문**을 pull 과 같은 변환으로 렌더한다 — 표시 전용.
|
|
10036
|
+
*
|
|
10037
|
+
* 첨부는 내려받지 않는다: 비교를 보려다 볼트에 파일이 생기면 안 된다. 그 대가로 아직
|
|
10038
|
+
* 내려받지 않은 미디어는 원격 URL 로 남아 비교 화면에만 차이로 보인다.
|
|
10039
|
+
*
|
|
10040
|
+
* @returns 추적되지 않았거나 원격 페이지가 없으면 null.
|
|
10041
|
+
*/
|
|
10042
|
+
async renderRemoteSnapshot(path) {
|
|
10043
|
+
const record = this.stateDb.getByPath(path);
|
|
10044
|
+
if (!record?.notionPageId) return null;
|
|
10045
|
+
const page = await this.notionClient.getPage(record.notionPageId);
|
|
10046
|
+
const rendered = await this.renderRemotePage(record, record.notionPageId, page, {
|
|
10047
|
+
downloadMedia: false
|
|
10048
|
+
});
|
|
10049
|
+
return rendered.content;
|
|
10050
|
+
}
|
|
10051
|
+
/**
|
|
10052
|
+
* 해소할 게 남지 않은 충돌 레코드를 `synced` 로 되돌리고, 되돌린 경로를 반환한다.
|
|
10053
|
+
*
|
|
10054
|
+
* 충돌로 표시된 파일은 push 대상에서 통째로 빠진다(양쪽 덮어쓰기 방지). 그래서 사용자가
|
|
10055
|
+
* 손으로 양쪽을 맞춰 둬 이미 같은 내용이 됐는데도 레코드만 남으면, 그 파일의 이후 편집이
|
|
10056
|
+
* **영원히 Notion 에 올라가지 않는다** — 아무 경고 없이 정체된다. 내용이 이미 동일한
|
|
10057
|
+
* 건만 골라 상태를 되돌린다(진짜 충돌은 손대지 않는다).
|
|
10058
|
+
*/
|
|
10059
|
+
clearStaleConflicts(conflicts) {
|
|
10060
|
+
const cleared = [];
|
|
10061
|
+
for (const conflict of conflicts) {
|
|
10062
|
+
if (conflict.localContent === "" && conflict.remoteContent === "") continue;
|
|
10063
|
+
if (conflict.localContent !== conflict.remoteContent) continue;
|
|
10064
|
+
const record = conflict.syncRecord;
|
|
10065
|
+
this.stateDb.transaction(() => {
|
|
10066
|
+
this.stateDb.updateHash(
|
|
10067
|
+
record.id,
|
|
10068
|
+
computeHash(conflict.localContent),
|
|
10069
|
+
Buffer.from(conflict.localContent, "utf-8")
|
|
10070
|
+
);
|
|
10071
|
+
this.stateDb.updateStatus(record.id, "synced");
|
|
10072
|
+
if (conflict.remoteChange.lastEdited) {
|
|
10073
|
+
this.stateDb.setNotionLastEdited(record.id, conflict.remoteChange.lastEdited);
|
|
10074
|
+
}
|
|
10075
|
+
});
|
|
10076
|
+
cleared.push(record.obsidianPath);
|
|
10077
|
+
}
|
|
10078
|
+
return cleared;
|
|
10079
|
+
}
|
|
8468
10080
|
/** 단일 충돌을 사용자가 고른 선택지(local/remote/merge/duplicate)로 해소 + Notion 전파. */
|
|
8469
10081
|
async resolveConflict(conflict, choice) {
|
|
8470
10082
|
const result = await this.conflictResolver.resolve(conflict, choice);
|
|
@@ -8557,18 +10169,10 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8557
10169
|
);
|
|
8558
10170
|
let remotePages;
|
|
8559
10171
|
if (this.isDatabaseMode) {
|
|
8560
|
-
const allPages =
|
|
8561
|
-
|
|
8562
|
-
|
|
8563
|
-
|
|
8564
|
-
startCursor: cursor
|
|
8565
|
-
});
|
|
8566
|
-
allPages.push(
|
|
8567
|
-
...result.results.map((p) => ({ id: p.id, last_edited_time: p.last_edited_time }))
|
|
8568
|
-
);
|
|
8569
|
-
cursor = result.nextCursor ?? void 0;
|
|
8570
|
-
} while (cursor);
|
|
8571
|
-
remotePages = allPages;
|
|
10172
|
+
const allPages = await this.notionClient.queryAllDatabasePages(
|
|
10173
|
+
this.config.notion.databaseId
|
|
10174
|
+
);
|
|
10175
|
+
remotePages = allPages.map((p) => ({ id: p.id, last_edited_time: p.last_edited_time }));
|
|
8572
10176
|
} else {
|
|
8573
10177
|
let underRoot;
|
|
8574
10178
|
try {
|
|
@@ -8578,11 +10182,19 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8578
10182
|
} catch (error) {
|
|
8579
10183
|
if (!(error instanceof DiscoveryTooLargeError)) throw error;
|
|
8580
10184
|
getLogger().info(
|
|
8581
|
-
`[Im-Nobsidian] \uC11C\uBE0C\uD2B8\uB9AC\uAC00 \uD07C(${error.message}) \u2192 \uC6CC\uD06C\uC2A4\uD398\uC774\uC2A4 search \uAE30\uBC18 \uB514\uC2A4\uCEE4\uBC84\uB9AC\
|
|
10185
|
+
`[Im-Nobsidian] \uC11C\uBE0C\uD2B8\uB9AC\uAC00 \uD07C(${error.message}) \u2192 \uC21C\uD68C \uBD80\uBD84 \uACB0\uACFC ${error.partial.length}\uAC74\uC5D0 \uC6CC\uD06C\uC2A4\uD398\uC774\uC2A4 search \uAE30\uBC18 \uB514\uC2A4\uCEE4\uBC84\uB9AC\uB97C \uD569\uCE69\uB2C8\uB2E4`
|
|
8582
10186
|
);
|
|
8583
|
-
|
|
10187
|
+
const viaSearch = await this.notionClient.getPagesUnderRootViaSearch(
|
|
8584
10188
|
this.config.notion.rootPageId
|
|
8585
10189
|
);
|
|
10190
|
+
const byId = /* @__PURE__ */ new Map();
|
|
10191
|
+
for (const page of [...error.partial, ...viaSearch]) {
|
|
10192
|
+
byId.set(normalizeNotionId(page.id), page);
|
|
10193
|
+
}
|
|
10194
|
+
underRoot = [...byId.values()];
|
|
10195
|
+
getLogger().info(
|
|
10196
|
+
`[Im-Nobsidian] \uB514\uC2A4\uCEE4\uBC84\uB9AC \uD569\uC9D1\uD569: \uC21C\uD68C ${error.partial.length} \u222A search ${viaSearch.length} \u2192 ${underRoot.length}\uAC74`
|
|
10197
|
+
);
|
|
8586
10198
|
}
|
|
8587
10199
|
this._childParentIds.add(normalizeNotionId(this.config.notion.rootPageId));
|
|
8588
10200
|
for (const page of underRoot) {
|
|
@@ -8676,6 +10288,82 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8676
10288
|
if (notionIdsEqual(parentId, this.config.notion.rootPageId)) return true;
|
|
8677
10289
|
return !!this.stateDb.getByNotionId(parentId);
|
|
8678
10290
|
}
|
|
10291
|
+
/**
|
|
10292
|
+
* 추적 중(sync_state)인데 디스크에서 사라진 파일을 찾는다.
|
|
10293
|
+
*
|
|
10294
|
+
* 원격 변경 감지(detectRemoteChanges*)는 "Notion 에서 바뀐 것"만 본다. 로컬에서 파일이
|
|
10295
|
+
* 지워진 경우는 어느 경로로도 잡히지 않아, 재pull 해도 `--force` 로도 되살아나지 않고
|
|
10296
|
+
* 영구히 발산했다. push 는 deleteSync=false 면 원격을 지우지 않으므로 사용자에겐 복구
|
|
10297
|
+
* 수단이 볼트 전체 초기화밖에 남지 않는다 — 이 스캔이 그 마지막 구멍을 막는다.
|
|
10298
|
+
*
|
|
10299
|
+
* 레코드마다 stat 을 던지면 1000건 규모에서 수 초가 든다. 볼트 워크 1회로 실재 목록을
|
|
10300
|
+
* 만든 뒤 차집합을 취한다(md). md 가 아닌 추적 레코드는 수가 적어 개별 확인으로 남긴다.
|
|
10301
|
+
*
|
|
10302
|
+
* 로컬 이름변경·이동은 "사라짐" 과 구별해야 한다 — 되살리면 원본과 새 이름의 사본이
|
|
10303
|
+
* 둘 다 남는다. push 의 detectMoves 와 같은 기준(내용 해시 일치)을 쓰되, 볼트 전체를
|
|
10304
|
+
* 해시하지 않도록 크기가 같은 미추적 파일만 후보로 좁혀 확인한다.
|
|
10305
|
+
*/
|
|
10306
|
+
async detectMissingLocalFiles(paths) {
|
|
10307
|
+
if (this.config.sync.deleteSync) return [];
|
|
10308
|
+
const records = this.stateDb.getAll();
|
|
10309
|
+
if (records.length === 0) return [];
|
|
10310
|
+
const scoped = records.filter(
|
|
10311
|
+
(r) => (
|
|
10312
|
+
// folder-only 는 실체가 폴더라 파일 부재가 정상. db-row 를 제외하는 이유는
|
|
10313
|
+
// 여기서 되살리면 행 전용 frontmatter(속성 매핑) 없이 본문만 쓰는 잘못된 경로로
|
|
10314
|
+
// 새기 때문이다 — 복원은 반드시 database-syncer 의 행 경로가 해야 한다.
|
|
10315
|
+
// 다만 그쪽이 실제로 복원하는지는 오래 참이 아니었다: 원격 무변경이면 로컬 존재를
|
|
10316
|
+
// 묻지도 않고 건너뛰어, 지운 행이 어느 경로로도 돌아오지 않았다. R13 에서 그
|
|
10317
|
+
// 존재 확인을 넣어 이 제외가 비로소 근거를 갖는다(tests/sync/db-row-restore-deleted).
|
|
10318
|
+
(r.fileType === "file" || r.fileType === "folder-note") && r.notionPageId !== null && inAnyPathScope(r.obsidianPath, paths)
|
|
10319
|
+
)
|
|
10320
|
+
);
|
|
10321
|
+
if (scoped.length === 0) return [];
|
|
10322
|
+
const stats = await this.vaultFs.listMarkdownFileStats();
|
|
10323
|
+
const live = new Set(stats.map((f) => f.path));
|
|
10324
|
+
const candidates = [];
|
|
10325
|
+
for (const record of scoped) {
|
|
10326
|
+
if (record.obsidianPath.endsWith(".md")) {
|
|
10327
|
+
if (!live.has(record.obsidianPath)) candidates.push(record);
|
|
10328
|
+
} else if (!await this.vaultFs.exists(record.obsidianPath)) {
|
|
10329
|
+
candidates.push(record);
|
|
10330
|
+
}
|
|
10331
|
+
}
|
|
10332
|
+
if (candidates.length === 0) return [];
|
|
10333
|
+
const tracked = new Set(records.map((r) => r.obsidianPath));
|
|
10334
|
+
const untrackedBySize = /* @__PURE__ */ new Map();
|
|
10335
|
+
for (const f of stats) {
|
|
10336
|
+
if (tracked.has(f.path)) continue;
|
|
10337
|
+
const bucket = untrackedBySize.get(f.size);
|
|
10338
|
+
if (bucket) bucket.push(f.path);
|
|
10339
|
+
else untrackedBySize.set(f.size, [f.path]);
|
|
10340
|
+
}
|
|
10341
|
+
const hashCache = /* @__PURE__ */ new Map();
|
|
10342
|
+
const hashOf = async (path) => {
|
|
10343
|
+
const cached = hashCache.get(path);
|
|
10344
|
+
if (cached !== void 0) return cached;
|
|
10345
|
+
let hash = null;
|
|
10346
|
+
try {
|
|
10347
|
+
hash = computeHash(await this.vaultFs.readFile(path));
|
|
10348
|
+
} catch {
|
|
10349
|
+
}
|
|
10350
|
+
hashCache.set(path, hash);
|
|
10351
|
+
return hash;
|
|
10352
|
+
};
|
|
10353
|
+
const missing = [];
|
|
10354
|
+
for (const record of candidates) {
|
|
10355
|
+
const sameSize = record.localFileSize !== null ? untrackedBySize.get(record.localFileSize) ?? [] : [...untrackedBySize.values()].flat();
|
|
10356
|
+
let renamed = false;
|
|
10357
|
+
for (const path of sameSize) {
|
|
10358
|
+
if (await hashOf(path) === record.contentHash) {
|
|
10359
|
+
renamed = true;
|
|
10360
|
+
break;
|
|
10361
|
+
}
|
|
10362
|
+
}
|
|
10363
|
+
if (!renamed) missing.push(record);
|
|
10364
|
+
}
|
|
10365
|
+
return missing;
|
|
10366
|
+
}
|
|
8679
10367
|
async pullCreate(pageId) {
|
|
8680
10368
|
const page = await this.notionClient.getPage(pageId);
|
|
8681
10369
|
const title = this.notionClient.extractTitle(page);
|
|
@@ -8688,17 +10376,16 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8688
10376
|
let fileType;
|
|
8689
10377
|
if (hasChildPages && hasContent) {
|
|
8690
10378
|
const folderPath = parentPath ? `${parentPath}/${safeName}` : safeName;
|
|
8691
|
-
filePath = await this.resolveUniqueFilePath(
|
|
10379
|
+
filePath = await this.resolveUniqueFilePath(folderPath, safeName, pageId);
|
|
8692
10380
|
fileType = "folder-note";
|
|
8693
10381
|
await this.vaultFs.ensureFolder(folderPath);
|
|
8694
10382
|
} else if (hasChildPages && !hasContent) {
|
|
8695
10383
|
const folderPath = parentPath ? `${parentPath}/${safeName}` : safeName;
|
|
8696
|
-
filePath = await this.resolveUniqueFilePath(
|
|
10384
|
+
filePath = await this.resolveUniqueFilePath(folderPath, safeName, pageId);
|
|
8697
10385
|
fileType = "folder-only";
|
|
8698
10386
|
await this.vaultFs.ensureFolder(folderPath);
|
|
8699
10387
|
} else {
|
|
8700
|
-
|
|
8701
|
-
filePath = await this.resolveUniqueFilePath(basePath);
|
|
10388
|
+
filePath = await this.resolveUniqueFilePath(parentPath, safeName, pageId);
|
|
8702
10389
|
fileType = "file";
|
|
8703
10390
|
}
|
|
8704
10391
|
let properties;
|
|
@@ -8715,11 +10402,16 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8715
10402
|
}
|
|
8716
10403
|
let processedMarkdown = markdown;
|
|
8717
10404
|
if (this.config.conversion.imageDownload === "immediate") {
|
|
8718
|
-
const imageResult = await this.imageHandler.downloadAllImages(
|
|
10405
|
+
const imageResult = await this.imageHandler.downloadAllImages(
|
|
10406
|
+
markdown,
|
|
10407
|
+
title,
|
|
10408
|
+
pageId,
|
|
10409
|
+
filePath
|
|
10410
|
+
);
|
|
8719
10411
|
processedMarkdown = imageResult.content;
|
|
8720
10412
|
this._pullImageCount += imageResult.downloads.length;
|
|
8721
10413
|
}
|
|
8722
|
-
const fileResult = await this.imageHandler.downloadAllFiles(processedMarkdown, title);
|
|
10414
|
+
const fileResult = await this.imageHandler.downloadAllFiles(processedMarkdown, title, filePath);
|
|
8723
10415
|
processedMarkdown = fileResult.content;
|
|
8724
10416
|
this._pullFileCount += fileResult.downloads.length;
|
|
8725
10417
|
const finalContent = this.pipeline.convertToMarkdown(
|
|
@@ -8783,11 +10475,20 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8783
10475
|
return false;
|
|
8784
10476
|
}
|
|
8785
10477
|
}
|
|
8786
|
-
|
|
8787
|
-
|
|
8788
|
-
|
|
8789
|
-
|
|
8790
|
-
|
|
10478
|
+
/**
|
|
10479
|
+
* 원격 페이지를 **pull 과 동일한 파이프라인**으로 볼트에 쓸 수 있는 본문까지 렌더한다.
|
|
10480
|
+
* (본문 변환 → 이미지/첨부 내려받기 → 속성 주입 → preserve marker → 압축형 간격 판정)
|
|
10481
|
+
*
|
|
10482
|
+
* pull 경로와 충돌 해소 경로가 이 한 곳을 공유해야 한다. 예전엔 충돌 목록만
|
|
10483
|
+
* {@link fetchPageMarkdown} 원문을 그대로 담아, `nobsi resolve` 에서 "원격 유지"를 고르면
|
|
10484
|
+
* 프론트매터도 첨부도 없는 반쪽 본문이 볼트에 덮여 썼다 — 해소가 곧 손실이었다.
|
|
10485
|
+
*
|
|
10486
|
+
* @returns 렌더된 본문과 함께, 호출자가 위키링크 레지스트리 등에 쓰는 제목·속성.
|
|
10487
|
+
* 여기서 이미 읽은 값을 되돌려 줘야 호출자가 같은 페이지를 다시 파싱하지 않는다.
|
|
10488
|
+
*/
|
|
10489
|
+
async renderRemotePage(record, pageId, page, options) {
|
|
10490
|
+
const downloadMedia = options?.downloadMedia !== false;
|
|
10491
|
+
const fetched = await this.fetchPageMarkdown(pageId);
|
|
8791
10492
|
let markdown = fetched.content;
|
|
8792
10493
|
const title = this.notionClient.extractTitle(page);
|
|
8793
10494
|
let properties;
|
|
@@ -8802,16 +10503,27 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8802
10503
|
if (this.isDatabaseMode || extractTitle(record.obsidianPath) !== title) {
|
|
8803
10504
|
properties.title = title;
|
|
8804
10505
|
}
|
|
8805
|
-
if (this.config.conversion.imageDownload === "immediate") {
|
|
8806
|
-
const imageResult = await this.imageHandler.downloadAllImages(
|
|
10506
|
+
if (downloadMedia && this.config.conversion.imageDownload === "immediate") {
|
|
10507
|
+
const imageResult = await this.imageHandler.downloadAllImages(
|
|
10508
|
+
markdown,
|
|
10509
|
+
title,
|
|
10510
|
+
pageId,
|
|
10511
|
+
record.obsidianPath
|
|
10512
|
+
);
|
|
8807
10513
|
markdown = imageResult.content;
|
|
8808
10514
|
this._pullImageCount += imageResult.downloads.length;
|
|
8809
10515
|
}
|
|
8810
|
-
|
|
8811
|
-
|
|
8812
|
-
|
|
10516
|
+
if (downloadMedia) {
|
|
10517
|
+
const fileResult = await this.imageHandler.downloadAllFiles(
|
|
10518
|
+
markdown,
|
|
10519
|
+
title,
|
|
10520
|
+
record.obsidianPath
|
|
10521
|
+
);
|
|
10522
|
+
markdown = fileResult.content;
|
|
10523
|
+
this._pullFileCount += fileResult.downloads.length;
|
|
10524
|
+
}
|
|
8813
10525
|
const savedMarkers = this.stateDb.getPreserveMarkers(record.obsidianPath);
|
|
8814
|
-
const
|
|
10526
|
+
const content = this.pipeline.convertToMarkdown(
|
|
8815
10527
|
markdown,
|
|
8816
10528
|
{
|
|
8817
10529
|
direction: "pull",
|
|
@@ -8825,15 +10537,30 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8825
10537
|
notionExportCompact: fetched.compact
|
|
8826
10538
|
}
|
|
8827
10539
|
);
|
|
10540
|
+
return { content, title, properties };
|
|
10541
|
+
}
|
|
10542
|
+
async pullUpdate(change) {
|
|
10543
|
+
const record = this.stateDb.getByNotionId(change.pageId);
|
|
10544
|
+
if (!record) return {};
|
|
10545
|
+
const page = await this.notionClient.getPage(change.pageId);
|
|
10546
|
+
const remoteGone = page.in_trash === true || page.archived === true;
|
|
10547
|
+
if (remoteGone && !await this.vaultFs.exists(record.obsidianPath)) {
|
|
10548
|
+
return { unchanged: true };
|
|
10549
|
+
}
|
|
10550
|
+
const rendered = await this.renderRemotePage(record, change.pageId, page);
|
|
10551
|
+
const remoteContent = rendered.content;
|
|
8828
10552
|
let localContent;
|
|
10553
|
+
let localExists = true;
|
|
8829
10554
|
try {
|
|
8830
10555
|
localContent = await this.vaultFs.readFile(record.obsidianPath);
|
|
8831
10556
|
} catch {
|
|
8832
10557
|
localContent = "";
|
|
10558
|
+
localExists = await this.vaultFs.exists(record.obsidianPath);
|
|
8833
10559
|
}
|
|
8834
10560
|
const resolution = resolvePullConflict({
|
|
8835
10561
|
record,
|
|
8836
10562
|
localContent,
|
|
10563
|
+
localExists,
|
|
8837
10564
|
remoteContent,
|
|
8838
10565
|
remoteChange: change,
|
|
8839
10566
|
strategy: this.config.sync.conflictStrategy
|
|
@@ -8845,7 +10572,7 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8845
10572
|
this.stateDb.updateStatus(record.id, "conflict");
|
|
8846
10573
|
return { conflict: resolution.conflict };
|
|
8847
10574
|
}
|
|
8848
|
-
if (remoteContent === localContent) {
|
|
10575
|
+
if (localExists && remoteContent === localContent) {
|
|
8849
10576
|
const stat2 = await this.vaultFs.getFileStat(record.obsidianPath);
|
|
8850
10577
|
this.stateDb.upsert({
|
|
8851
10578
|
obsidianPath: record.obsidianPath,
|
|
@@ -8881,11 +10608,11 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
8881
10608
|
localMtime: updateStat?.mtime ?? null,
|
|
8882
10609
|
localFileSize: updateStat?.size ?? null
|
|
8883
10610
|
});
|
|
8884
|
-
const aliases = extractAliases2(properties);
|
|
10611
|
+
const aliases = extractAliases2(rendered.properties);
|
|
8885
10612
|
this.stateDb.upsertWikilink({
|
|
8886
10613
|
obsidianPath: record.obsidianPath,
|
|
8887
10614
|
notionPageId: change.pageId,
|
|
8888
|
-
title,
|
|
10615
|
+
title: rendered.title,
|
|
8889
10616
|
aliases
|
|
8890
10617
|
});
|
|
8891
10618
|
});
|
|
@@ -9134,13 +10861,10 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
9134
10861
|
// 이를 state DB 역조회로 원래 `[[제목]]` 위키링크로 복원해 push↔pull 라운드트립을 수렴시킨다.
|
|
9135
10862
|
// 볼트 밖/미추적 페이지면 `[[notion:<id>]]` 를 그대로 두어 정보 손실을 막는다.
|
|
9136
10863
|
resolveNotionIdWikilinks(markdown) {
|
|
9137
|
-
return
|
|
9138
|
-
|
|
9139
|
-
|
|
9140
|
-
|
|
9141
|
-
const title = base.replace(/\.md$/, "");
|
|
9142
|
-
return `[[${title}]]`;
|
|
9143
|
-
});
|
|
10864
|
+
return resolveNotionIdWikilinks(
|
|
10865
|
+
markdown,
|
|
10866
|
+
(id) => this.stateDb.getByNotionId(normalizeNotionId(id))?.obsidianPath ?? null
|
|
10867
|
+
).markdown;
|
|
9144
10868
|
}
|
|
9145
10869
|
async extractParentId(page) {
|
|
9146
10870
|
const parent = page.parent;
|
|
@@ -9272,16 +10996,45 @@ var SyncOrchestrator = class _SyncOrchestrator {
|
|
|
9272
10996
|
}
|
|
9273
10997
|
return null;
|
|
9274
10998
|
}
|
|
9275
|
-
|
|
9276
|
-
|
|
9277
|
-
|
|
9278
|
-
|
|
9279
|
-
|
|
9280
|
-
|
|
9281
|
-
|
|
9282
|
-
|
|
10999
|
+
/**
|
|
11000
|
+
* 신규 pull 페이지가 쓸 파일 경로를 충돌 없이 결정하고 **그 자리에서 선점**한다.
|
|
11001
|
+
*
|
|
11002
|
+
* DB 행과 **같은 규칙**({@link pagePathCandidates})을 쓴다. 예전에는 `(1)`, `(2)` …
|
|
11003
|
+
* 순번을 99 까지 훑고 고갈되면 **원본 경로를 그대로 돌려줬는데**, 그러면 남의 노트를
|
|
11004
|
+
* 조용히 덮어써 내용이 사라진다. 순번은 그때의 볼트 상태로 정해져 실행마다 페이지끼리
|
|
11005
|
+
* 접미사가 뒤바뀔 수도 있었다(pull 마다 파일이 갈아엎히는 churn).
|
|
11006
|
+
*
|
|
11007
|
+
* 점유 판정은 세 가지를 모두 본다:
|
|
11008
|
+
* · 이번 실행의 선점 장부 — 아래 참조.
|
|
11009
|
+
* · 추적 레코드 — 다른 페이지가 소유한 경로면 피하고, 자기 소유면 그대로 재사용한다.
|
|
11010
|
+
* · 볼트 파일 — 추적되지 않는 사용자 노트가 놓여 있으면 피한다.
|
|
11011
|
+
*
|
|
11012
|
+
* 선점 장부가 필요한 이유는 pull 이 워커 풀로 **동시 실행**되기 때문이다. 조회와 기록
|
|
11013
|
+
* 사이에 await 가 끼면 동명 페이지 여럿이 나란히 "비어 있음"을 보고 같은 경로를 고른다.
|
|
11014
|
+
* 그러면 마지막에 쓴 페이지만 남고 나머지 본문이 사라진다(실측 재현: 동명 3페이지 →
|
|
11015
|
+
* 파일 1개·레코드 1건). 그래서 마지막 확인과 등록을 **await 없는 동기 구간**에 묶는다 —
|
|
11016
|
+
* 그 사이에는 다른 작업이 끼어들 수 없으므로 두 페이지가 같은 경로를 얻는 일이 없다.
|
|
11017
|
+
*/
|
|
11018
|
+
async resolveUniqueFilePath(dir, safeName, pageId) {
|
|
11019
|
+
const candidates = pagePathCandidates(dir, safeName, pageId);
|
|
11020
|
+
for (const candidate of candidates) {
|
|
11021
|
+
if (this.claimedPaths.has(candidate)) continue;
|
|
11022
|
+
const owner = this.stateDb.getByPath(candidate);
|
|
11023
|
+
if (owner) {
|
|
11024
|
+
if (owner.notionPageId != null && notionIdsEqual(owner.notionPageId, pageId)) {
|
|
11025
|
+
this.claimedPaths.add(candidate);
|
|
11026
|
+
return candidate;
|
|
11027
|
+
}
|
|
11028
|
+
continue;
|
|
11029
|
+
}
|
|
11030
|
+
if (await this.vaultFs.exists(candidate)) continue;
|
|
11031
|
+
if (this.claimedPaths.has(candidate)) continue;
|
|
11032
|
+
this.claimedPaths.add(candidate);
|
|
11033
|
+
return candidate;
|
|
9283
11034
|
}
|
|
9284
|
-
|
|
11035
|
+
const fallback = candidates[candidates.length - 1];
|
|
11036
|
+
this.claimedPaths.add(fallback);
|
|
11037
|
+
return fallback;
|
|
9285
11038
|
}
|
|
9286
11039
|
};
|
|
9287
11040
|
function extractTitle(filePath) {
|
|
@@ -10205,6 +11958,63 @@ function summarizeFidelity(perFile) {
|
|
|
10205
11958
|
return summary;
|
|
10206
11959
|
}
|
|
10207
11960
|
|
|
10208
|
-
|
|
11961
|
+
// src/audit/render.ts
|
|
11962
|
+
var RENDER_LINE_RULES = [
|
|
11963
|
+
{
|
|
11964
|
+
code: "\u2460",
|
|
11965
|
+
label: "\uCF5C\uC544\uC6C3 4\uCE78+ \uB4E4\uC5EC\uC4F0\uAE30",
|
|
11966
|
+
// Obsidian 은 절대 들여쓰기 4칸부터 들여쓰기 코드블록으로 오파싱한다.
|
|
11967
|
+
// 콜아웃이 통째로 회색 코드 상자가 되어 서식이 전부 죽는다.
|
|
11968
|
+
re: /^(?: {4,}|\t)+>\s*\[!/
|
|
11969
|
+
},
|
|
11970
|
+
{
|
|
11971
|
+
code: "\u2461",
|
|
11972
|
+
label: "\uD1A0\uAE00\uD5E4\uB529 \uC18D\uC131 \uB204\uC218",
|
|
11973
|
+
// NFM 전용 속성이 본문에 그대로 노출된 상태 — 토글도 접히지 않는다.
|
|
11974
|
+
re: /\{toggle="true"\}/
|
|
11975
|
+
},
|
|
11976
|
+
{
|
|
11977
|
+
code: "\u2462",
|
|
11978
|
+
label: "NFM \uCEE8\uD14C\uC774\uB108 \uD0DC\uADF8 \uB204\uC218",
|
|
11979
|
+
re: /<\/?(?:details|summary|callout|columns|column)\b/
|
|
11980
|
+
},
|
|
11981
|
+
{
|
|
11982
|
+
code: "\u2463",
|
|
11983
|
+
label: "NFM \uC804\uC6A9 \uD0DC\uADF8 \uB204\uC218",
|
|
11984
|
+
// 이름이 붙어 `<unknown>` 폴백에 걸리지 않던 태그들.
|
|
11985
|
+
re: /<(?:table_of_contents|embed|unknown_mention|empty-block)\b/
|
|
11986
|
+
},
|
|
11987
|
+
{
|
|
11988
|
+
code: "\u2464",
|
|
11989
|
+
label: "\uCF54\uB4DC\uD39C\uC2A4 4\uCE78 \uB4E4\uC5EC\uC4F0\uAE30(\uCF5C\uC544\uC6C3 \uBC16)",
|
|
11990
|
+
re: /^ {4,}```/
|
|
11991
|
+
}
|
|
11992
|
+
];
|
|
11993
|
+
var SEPARATOR_BODY = "[ \\t:|-]*-{3,}";
|
|
11994
|
+
var SEPARATOR_RE = new RegExp(`^[\\t ]*>?[\\t ]*\\|${SEPARATOR_BODY}[ \\t:|-]*\\|`);
|
|
11995
|
+
var FENCE_RE2 = /^[\t ]*(?:>[\t ]*)*```/;
|
|
11996
|
+
function lintRenderedMarkdown(markdown) {
|
|
11997
|
+
const lines = markdown.split("\n");
|
|
11998
|
+
const findings = [];
|
|
11999
|
+
for (let i = 0; i < lines.length; i++) {
|
|
12000
|
+
const line = lines[i];
|
|
12001
|
+
for (const rule of RENDER_LINE_RULES) {
|
|
12002
|
+
if (rule.re.test(line)) findings.push({ code: rule.code, label: rule.label, line: i + 1 });
|
|
12003
|
+
}
|
|
12004
|
+
if (SEPARATOR_RE.test(line)) {
|
|
12005
|
+
const prefix = /^([\t ]*>?[\t ]*)/.exec(line)[1];
|
|
12006
|
+
const prev = lines[i - 1] ?? "";
|
|
12007
|
+
if (!prev.startsWith(prefix) || !prev.slice(prefix.length).trimStart().startsWith("|")) {
|
|
12008
|
+
findings.push({ code: "\u2466", label: "\uD45C \uAD6C\uBD84\uD589 \uACE0\uC544(\uD45C \uC0AC\uB9DD)", line: i + 1 });
|
|
12009
|
+
}
|
|
12010
|
+
}
|
|
12011
|
+
}
|
|
12012
|
+
if (lines.filter((l) => FENCE_RE2.test(l)).length % 2 !== 0) {
|
|
12013
|
+
findings.push({ code: "\u2467", label: "\uCF54\uB4DC\uD39C\uC2A4 \uD640\uC218(\uBB38\uC11C \uC794\uC5EC\uBD84 \uC0BC\uD0B4)", line: 0 });
|
|
12014
|
+
}
|
|
12015
|
+
return findings;
|
|
12016
|
+
}
|
|
12017
|
+
|
|
12018
|
+
export { BaseFileGenerator, BlockConverter, CONFIG_FILE, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConversionPipeline, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FrontmatterExtractor, FrontmatterGenerator, GITIGNORE_ENTRY, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, ImageHandler, InlineAnnotationPreserver, InlineDBParser, LocalImageRestorer, MARKER_BRAND, MathNormalizer, MentionToWikilink, NOTION_TO_BASES_VIEW, NodeVaultFS, NotionBlockBuilder, NotionClient, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, RENDER_LINE_RULES, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_PATH, SidecarGenerator, StateDB, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WatchSyncService, WikilinkResolver, basesViewMappingOf, basesViewTypeOf, classifyBodyFidelity, compactNotionId, computeBufferHash, computeHash, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, inAnyPathScope, isInternalPath, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, notionIdsEqual, resolvePropertyName, sanitizeFileName, setLogger, sortEntries, summarizeFidelity, threeWayMerge, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness };
|
|
10209
12019
|
//# sourceMappingURL=index.js.map
|
|
10210
12020
|
//# sourceMappingURL=index.js.map
|