@im-nobsidian/core 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +183 -11
- package/dist/index.js +1718 -466
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { LogLevel, Client } from '@notionhq/client';
|
|
3
|
-
import { readFile, mkdir, writeFile, unlink, rename, stat, readdir } from 'fs/promises';
|
|
3
|
+
import { readFile, mkdir, writeFile, unlink, rmdir, rename, stat, readdir } from 'fs/promises';
|
|
4
4
|
import { join, dirname, sep, relative } from 'path';
|
|
5
5
|
import { mkdirSync, unlinkSync, utimesSync, openSync, writeSync, closeSync, readFileSync, statSync, existsSync, readlinkSync } from 'fs';
|
|
6
6
|
import Database from 'better-sqlite3';
|
|
@@ -195,6 +195,7 @@ var DEFAULTS = {
|
|
|
195
195
|
autoSyncInterval: 300,
|
|
196
196
|
fullReconcileInterval: 3600,
|
|
197
197
|
deleteSync: false,
|
|
198
|
+
renameOnTitleChange: false,
|
|
198
199
|
syncFiles: true,
|
|
199
200
|
createDatabases: true,
|
|
200
201
|
notifications: "silent"
|
|
@@ -250,6 +251,8 @@ var ConfigSchema = z.object({
|
|
|
250
251
|
// 반영한다. 0 이면 pull 마다 전체 대조(ADR-027). 최대 7일.
|
|
251
252
|
fullReconcileInterval: z.number().int().min(0).max(604800).default(D.sync.fullReconcileInterval),
|
|
252
253
|
deleteSync: z.boolean().default(D.sync.deleteSync),
|
|
254
|
+
// Notion 에서 바꾼 제목으로 노트 이름을 바꾼다 — 끄면 이름은 두고 새 제목을 frontmatter `title` 에 적는다.
|
|
255
|
+
renameOnTitleChange: z.boolean().default(D.sync.renameOnTitleChange),
|
|
253
256
|
syncFiles: z.boolean().default(D.sync.syncFiles),
|
|
254
257
|
// 사이드카가 있는 새 DB 폴더를 push 가 Notion DB 로 만든다(ADR-036). 끄면 폴더 · 페이지로 올린다.
|
|
255
258
|
createDatabases: z.boolean().default(D.sync.createDatabases),
|
|
@@ -1473,6 +1476,13 @@ function normalizeAnnotations(annotations) {
|
|
|
1473
1476
|
};
|
|
1474
1477
|
}
|
|
1475
1478
|
var DEFAULT_STYLE = JSON.stringify(normalizeAnnotations(void 0));
|
|
1479
|
+
function titleRunsOf(properties) {
|
|
1480
|
+
for (const prop of Object.values(properties)) {
|
|
1481
|
+
const value = prop;
|
|
1482
|
+
if (value?.type === "title") return Array.isArray(value.title) ? value.title : null;
|
|
1483
|
+
}
|
|
1484
|
+
return null;
|
|
1485
|
+
}
|
|
1476
1486
|
function stringifyFrontmatter(content, data) {
|
|
1477
1487
|
const astral = maskAstralCharacters(data);
|
|
1478
1488
|
const out = matter.stringify(
|
|
@@ -1657,6 +1667,7 @@ function notionFileIdOf(url) {
|
|
|
1657
1667
|
}
|
|
1658
1668
|
function fileUrlOfBlock(block) {
|
|
1659
1669
|
const media = block.type ? block[block.type] : void 0;
|
|
1670
|
+
if (block.type === "embed") return media?.url ?? null;
|
|
1660
1671
|
return media?.file?.url ?? media?.external?.url ?? null;
|
|
1661
1672
|
}
|
|
1662
1673
|
|
|
@@ -1876,6 +1887,13 @@ var PropertyMapper = class _PropertyMapper {
|
|
|
1876
1887
|
relationKeys(keys) {
|
|
1877
1888
|
return keys.filter((key) => this.schema.get(key)?.type === "relation");
|
|
1878
1889
|
}
|
|
1890
|
+
/** 이 속성들 가운데 Notion 이 값을 정하는 것(수식 · 롤업 · ID · 생성일 …) — 볼트에서 고쳐도 보낼 수 없다. */
|
|
1891
|
+
readOnlyKeys(keys) {
|
|
1892
|
+
return keys.filter((key) => {
|
|
1893
|
+
const type = this.schema.get(key)?.type;
|
|
1894
|
+
return type !== void 0 && READ_ONLY_TYPES.has(type);
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1879
1897
|
/**
|
|
1880
1898
|
* 값을 보낼 수 있는 속성만, pull 이 적는 모양으로 — 원격 값과 로컬 값을 견줄 때 쓴다.
|
|
1881
1899
|
*
|
|
@@ -2304,13 +2322,6 @@ function dateText(value, zone) {
|
|
|
2304
2322
|
if (!DATE_REGEX.test(text)) return null;
|
|
2305
2323
|
return isWallTime(text) ? toZonedIso(text, zone) : text;
|
|
2306
2324
|
}
|
|
2307
|
-
function titleRunsOf(remote) {
|
|
2308
|
-
for (const prop of Object.values(remote)) {
|
|
2309
|
-
const value = prop;
|
|
2310
|
-
if (value?.type === "title") return Array.isArray(value.title) ? value.title : null;
|
|
2311
|
-
}
|
|
2312
|
-
return null;
|
|
2313
|
-
}
|
|
2314
2325
|
function richTextRunsOf(remote, key) {
|
|
2315
2326
|
const value = remote?.[key];
|
|
2316
2327
|
return value?.type === "rich_text" && Array.isArray(value.rich_text) ? value.rich_text : null;
|
|
@@ -2759,8 +2770,7 @@ function isNotionAccessDenied(error) {
|
|
|
2759
2770
|
return e.code === "restricted_resource" || e.status === 403;
|
|
2760
2771
|
}
|
|
2761
2772
|
function isTrashedOrArchived(page) {
|
|
2762
|
-
|
|
2763
|
-
return inTrash || page.archived === true;
|
|
2773
|
+
return page.in_trash === true || page.archived === true;
|
|
2764
2774
|
}
|
|
2765
2775
|
function isNotionBadRequest(error) {
|
|
2766
2776
|
return notionStatusOf(error) === 400;
|
|
@@ -2793,6 +2803,7 @@ var DiscoveryTooLargeError = class extends Error {
|
|
|
2793
2803
|
partial;
|
|
2794
2804
|
};
|
|
2795
2805
|
var NOT_IDEMPOTENT = { idempotent: false };
|
|
2806
|
+
var CREATES_BLOCKS = { idempotent: false, createsBlocks: true };
|
|
2796
2807
|
function parentDatabaseIdOf(page) {
|
|
2797
2808
|
const parent = page.parent;
|
|
2798
2809
|
if (parent?.type !== "data_source_id" && parent?.type !== "database_id") return null;
|
|
@@ -2838,8 +2849,8 @@ var NotionClient = class _NotionClient {
|
|
|
2838
2849
|
// 전역 쿨다운 밖에서, 로그 없이 흐른다.
|
|
2839
2850
|
retry: false,
|
|
2840
2851
|
// SDK 5.27+ 는 브라우저 환경(Obsidian 렌더러)에서 토큰을 받으면 던진다 — 남이 여는 페이지에 토큰이 실리는
|
|
2841
|
-
// 것을 막는 검사다. 플러그인의 토큰은 사용자 기기의 볼트 설정에 있다.
|
|
2842
|
-
|
|
2852
|
+
// 것을 막는 검사다. 플러그인의 토큰은 사용자 기기의 볼트 설정에 있다.
|
|
2853
|
+
dangerouslyAllowBrowser: true,
|
|
2843
2854
|
...options.fetch ? { fetch: options.fetch } : {}
|
|
2844
2855
|
},
|
|
2845
2856
|
options.notifications ?? DEFAULT_CONFIG.sync.notifications
|
|
@@ -2950,7 +2961,7 @@ var NotionClient = class _NotionClient {
|
|
|
2950
2961
|
properties,
|
|
2951
2962
|
children: params.children
|
|
2952
2963
|
}),
|
|
2953
|
-
|
|
2964
|
+
CREATES_BLOCKS
|
|
2954
2965
|
);
|
|
2955
2966
|
}
|
|
2956
2967
|
/**
|
|
@@ -2975,7 +2986,7 @@ var NotionClient = class _NotionClient {
|
|
|
2975
2986
|
}
|
|
2976
2987
|
})
|
|
2977
2988
|
),
|
|
2978
|
-
|
|
2989
|
+
CREATES_BLOCKS
|
|
2979
2990
|
);
|
|
2980
2991
|
const dataSourceId = db.data_sources?.[0]?.id ?? await this.primaryDataSourceId(db.id);
|
|
2981
2992
|
if (!dataSourceId) throw new Error(`\uB9CC\uB4E0 DB(${db.id})\uC758 data source \uB97C \uC54C \uC218 \uC5C6\uC74C`);
|
|
@@ -3020,7 +3031,7 @@ var NotionClient = class _NotionClient {
|
|
|
3020
3031
|
properties,
|
|
3021
3032
|
markdown: params.markdown
|
|
3022
3033
|
}),
|
|
3023
|
-
|
|
3034
|
+
CREATES_BLOCKS
|
|
3024
3035
|
);
|
|
3025
3036
|
}
|
|
3026
3037
|
/**
|
|
@@ -3060,10 +3071,10 @@ var NotionClient = class _NotionClient {
|
|
|
3060
3071
|
/** 워크스페이스 사용자 이모지의 id — 이름이 꼭 같은 것. 없으면 null. */
|
|
3061
3072
|
async findCustomEmojiId(name) {
|
|
3062
3073
|
const response = await this.withRateLimit(() => this.client.customEmojis.list({ name }));
|
|
3063
|
-
const
|
|
3074
|
+
const found2 = response.results.find(
|
|
3064
3075
|
(emoji) => emoji.name === name
|
|
3065
3076
|
);
|
|
3066
|
-
return
|
|
3077
|
+
return found2?.id ?? null;
|
|
3067
3078
|
}
|
|
3068
3079
|
// ─── Markdown API ───
|
|
3069
3080
|
/**
|
|
@@ -3435,7 +3446,7 @@ var NotionClient = class _NotionClient {
|
|
|
3435
3446
|
});
|
|
3436
3447
|
}
|
|
3437
3448
|
async archivePage(pageId) {
|
|
3438
|
-
await this.withRateLimit(() => this.client.pages.update({ page_id: pageId,
|
|
3449
|
+
await this.withRateLimit(() => this.client.pages.update({ page_id: pageId, in_trash: true }));
|
|
3439
3450
|
}
|
|
3440
3451
|
/**
|
|
3441
3452
|
* 파일 블록의 지금 받을 수 있는 주소. 블록이 없거나 볼 수 없으면 null 이다.
|
|
@@ -3579,7 +3590,7 @@ var NotionClient = class _NotionClient {
|
|
|
3579
3590
|
children: batch,
|
|
3580
3591
|
...after ? { position: { type: "after_block", after_block: { id: after } } } : {}
|
|
3581
3592
|
}),
|
|
3582
|
-
|
|
3593
|
+
CREATES_BLOCKS
|
|
3583
3594
|
);
|
|
3584
3595
|
const blocks = response.results.slice(0, batch.length);
|
|
3585
3596
|
created.push(...blocks);
|
|
@@ -4083,6 +4094,12 @@ var NotionClient = class _NotionClient {
|
|
|
4083
4094
|
* 슬롯을 붙잡아 남을 막는 대신, 다 같이 보는 이 게이트로 쉬게 한다.
|
|
4084
4095
|
*/
|
|
4085
4096
|
cooldownUntil = 0;
|
|
4097
|
+
/**
|
|
4098
|
+
* Free 워크스페이스 블록 한도에 막힌 거절과 그 시각. 막힌 동안 블록을 만드는 요청은 보내지 않고 이 거절을 그대로
|
|
4099
|
+
* 돌려준다 — 플랜을 올리기 전에는 풀리지 않으므로 노트마다 같은 거절을 받으러 가지 않는다. 그 사이 플랜을
|
|
4100
|
+
* 올렸을 수 있어 {@link BLOCK_LIMIT_RECHECK_MS} 가 지나면 다시 보내 본다.
|
|
4101
|
+
*/
|
|
4102
|
+
blockLimit = null;
|
|
4086
4103
|
/**
|
|
4087
4104
|
* 요청 1건을 rate limit 슬롯 안에서 실행하고, 재시도 가능한 실패는 백오프 후 다시 시도한다.
|
|
4088
4105
|
*
|
|
@@ -4103,6 +4120,10 @@ var NotionClient = class _NotionClient {
|
|
|
4103
4120
|
*/
|
|
4104
4121
|
async withRateLimit(fn, options) {
|
|
4105
4122
|
const idempotent = options?.idempotent ?? true;
|
|
4123
|
+
if (options?.createsBlocks && this.blockLimit) {
|
|
4124
|
+
if (Date.now() - this.blockLimit.at < BLOCK_LIMIT_RECHECK_MS) throw this.blockLimit.error;
|
|
4125
|
+
this.blockLimit = null;
|
|
4126
|
+
}
|
|
4106
4127
|
this.requestsSent++;
|
|
4107
4128
|
let backoffMs = 0;
|
|
4108
4129
|
for (let attempt = 0; ; attempt++) {
|
|
@@ -4112,6 +4133,12 @@ var NotionClient = class _NotionClient {
|
|
|
4112
4133
|
this.lastRequestTime = Date.now();
|
|
4113
4134
|
return await fn();
|
|
4114
4135
|
} catch (error) {
|
|
4136
|
+
if (isWorkspaceBlockLimit(error)) {
|
|
4137
|
+
const limited = new NotionBlockLimitError(error);
|
|
4138
|
+
this.blockLimit = { error: limited, at: Date.now() };
|
|
4139
|
+
getLogger().warn(`[Im-Nobsidian] ${limited.message}`);
|
|
4140
|
+
throw limited;
|
|
4141
|
+
}
|
|
4115
4142
|
if (!isRetryable(error, idempotent) || attempt >= this.maxRetries) throw error;
|
|
4116
4143
|
const retryAfterMs = extractRetryAfter(error);
|
|
4117
4144
|
backoffMs = retryAfterMs !== null ? retryAfterMs * (1 + Math.random() * 0.25) : this.retryBaseDelayMs * Math.pow(this.retryBackoffFactor, attempt) * (0.5 + Math.random() * 0.5);
|
|
@@ -4176,10 +4203,31 @@ function isAmbiguousFailure(error) {
|
|
|
4176
4203
|
return status === 500 || status === 502 || status === 503 || status === 504;
|
|
4177
4204
|
}
|
|
4178
4205
|
function isRequestBlocked(error) {
|
|
4179
|
-
|
|
4206
|
+
return rateLimitReasonOf(error) === "public_api_request_blocked";
|
|
4207
|
+
}
|
|
4208
|
+
function rateLimitReasonOf(error) {
|
|
4209
|
+
if (notionStatusOf(error) !== 429) return null;
|
|
4180
4210
|
const data = error.additional_data;
|
|
4181
|
-
return data?.rate_limit_reason === "
|
|
4211
|
+
return typeof data?.rate_limit_reason === "string" ? data.rate_limit_reason : null;
|
|
4182
4212
|
}
|
|
4213
|
+
function isWorkspaceBlockLimit(error) {
|
|
4214
|
+
if (notionStatusOf(error) !== 403) return false;
|
|
4215
|
+
const data = error.additional_data;
|
|
4216
|
+
return data?.block_limit !== void 0 && data.block_limit !== null;
|
|
4217
|
+
}
|
|
4218
|
+
var BLOCK_LIMIT_RECHECK_MS = 5 * 6e4;
|
|
4219
|
+
var NotionBlockLimitError = class extends Error {
|
|
4220
|
+
status = 403;
|
|
4221
|
+
code = "restricted_resource";
|
|
4222
|
+
constructor(cause) {
|
|
4223
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
4224
|
+
super(
|
|
4225
|
+
`Notion \uC6CC\uD06C\uC2A4\uD398\uC774\uC2A4\uAC00 \uBB34\uB8CC \uBE14\uB85D \uD55C\uB3C4\uB97C \uB2E4 \uC368\uC11C \uC0C8 \uB0B4\uC6A9\uC744 \uB9CC\uB4E4 \uC218 \uC5C6\uC74C \u2014 Notion \uC5D0\uC11C \uD50C\uB79C\uC744 \uC62C\uB9AC\uBA74 \uD480\uB9B0\uB2E4 (Notion: ${reason})`,
|
|
4226
|
+
{ cause }
|
|
4227
|
+
);
|
|
4228
|
+
this.name = "NotionBlockLimitError";
|
|
4229
|
+
}
|
|
4230
|
+
};
|
|
4183
4231
|
function isRetryable(error, idempotent) {
|
|
4184
4232
|
if (isRequestBlocked(error)) return false;
|
|
4185
4233
|
return isExplicitRejection(error) || idempotent && isAmbiguousFailure(error);
|
|
@@ -4190,10 +4238,13 @@ function describeRetryCause(error) {
|
|
|
4190
4238
|
const parts = [];
|
|
4191
4239
|
if (typeof e.status === "number") parts.push(`status ${e.status}`);
|
|
4192
4240
|
if (typeof e.code === "string") parts.push(e.code);
|
|
4241
|
+
const reason = rateLimitReasonOf(error);
|
|
4242
|
+
if (reason) parts.push(reason);
|
|
4193
4243
|
if (typeof e.message === "string" && e.message) parts.push(e.message.slice(0, 120));
|
|
4194
4244
|
return parts.length > 0 ? parts.join(" \xB7 ") : "\uC54C \uC218 \uC5C6\uB294 \uC624\uB958";
|
|
4195
4245
|
}
|
|
4196
4246
|
var MAX_RETRY_AFTER_MS = 6e4;
|
|
4247
|
+
var MAX_SHARED_RETRY_AFTER_MS = 5 * 6e4;
|
|
4197
4248
|
function readHeader(headers, name) {
|
|
4198
4249
|
if (typeof headers !== "object" || headers === null) return null;
|
|
4199
4250
|
const get = headers.get;
|
|
@@ -4220,7 +4271,8 @@ function extractRetryAfter(error) {
|
|
|
4220
4271
|
ms = Number.isNaN(at) ? Number.NaN : at - Date.now();
|
|
4221
4272
|
}
|
|
4222
4273
|
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
4223
|
-
|
|
4274
|
+
const cap = rateLimitReasonOf(error) === "public_api_space_request_rate_limit" ? MAX_SHARED_RETRY_AFTER_MS : MAX_RETRY_AFTER_MS;
|
|
4275
|
+
return Math.min(ms, cap);
|
|
4224
4276
|
}
|
|
4225
4277
|
function sleep(ms) {
|
|
4226
4278
|
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
@@ -5058,6 +5110,7 @@ function deferredSyncedRefMarker(index) {
|
|
|
5058
5110
|
}
|
|
5059
5111
|
var TOGGLE_START = compactMarker("toggle:start");
|
|
5060
5112
|
var TOGGLE_END = compactMarker("toggle:end");
|
|
5113
|
+
var LARGE_FILE_MARKER = compactMarker("large-file");
|
|
5061
5114
|
var TOGGLE_HEADING_START = compactMarker("toggle-heading");
|
|
5062
5115
|
var TOGGLE_HEADING_END = compactMarker("toggle-heading:end");
|
|
5063
5116
|
function calloutIndentMarker(depth2) {
|
|
@@ -5140,7 +5193,6 @@ var MARKER_TOKEN_RE = new RegExp(
|
|
|
5140
5193
|
|
|
5141
5194
|
// src/converter/pipeline.ts
|
|
5142
5195
|
var INLINE_DB_PROBE = new RegExp(`%% ${MARKER_BRAND_RE}:inline-db`);
|
|
5143
|
-
var TOGGLE_HEADING_PROBE = new RegExp(`%% ${MARKER_BRAND_RE}:toggle-heading`);
|
|
5144
5196
|
var ConversionPipeline = class {
|
|
5145
5197
|
preProcessors = [];
|
|
5146
5198
|
postProcessors = [];
|
|
@@ -5152,11 +5204,14 @@ var ConversionPipeline = class {
|
|
|
5152
5204
|
this.postProcessors.push(processor);
|
|
5153
5205
|
this.postProcessors.sort((a, b) => a.order - b.order);
|
|
5154
5206
|
}
|
|
5207
|
+
/**
|
|
5208
|
+
* 토글 헤딩은 Markdown API 경로가 변환한다(`toggle-heading.ts`) — 블록 경로로 보내면 콜아웃이 옛 이모지 꼴로
|
|
5209
|
+
* 바뀐다(`CalloutTransformer`). 예전에 여기 있던 토글 헤딩 탐지는 띄어 쓴 마커를 찾아 한 번도 맞지 않았다.
|
|
5210
|
+
*/
|
|
5155
5211
|
selectPath(content) {
|
|
5156
5212
|
const hasInlineDb = INLINE_DB_PROBE.test(content);
|
|
5157
5213
|
const hasColumnLayout2 = />\s*\[!col\]/.test(content);
|
|
5158
|
-
|
|
5159
|
-
if (hasInlineDb || hasColumnLayout2 || hasToggleHeading) {
|
|
5214
|
+
if (hasInlineDb || hasColumnLayout2) {
|
|
5160
5215
|
return "block-api";
|
|
5161
5216
|
}
|
|
5162
5217
|
return "markdown-api";
|
|
@@ -5785,6 +5840,27 @@ function obsidianMathCloseAt(line, open) {
|
|
|
5785
5840
|
return close;
|
|
5786
5841
|
}
|
|
5787
5842
|
|
|
5843
|
+
// src/converter/media-labels.ts
|
|
5844
|
+
var MEDIA_LABELS = {
|
|
5845
|
+
audio: "\u{1F50A}",
|
|
5846
|
+
video: "\u{1F3AC}",
|
|
5847
|
+
pdf: "\u{1F4C4}",
|
|
5848
|
+
file: "\u{1F4CE}",
|
|
5849
|
+
html: "\u{1F310}"
|
|
5850
|
+
};
|
|
5851
|
+
var LEGACY_MEDIA_ICONS = ["\u{1F39E}"];
|
|
5852
|
+
var MEDIA_PLACEHOLDER_CAPTIONS = new Set(Object.keys(MEDIA_LABELS));
|
|
5853
|
+
function mediaLabel(kind, caption) {
|
|
5854
|
+
return `${MEDIA_LABELS[kind]} ${caption || kind}`;
|
|
5855
|
+
}
|
|
5856
|
+
function mediaLinkRe(kind) {
|
|
5857
|
+
return new RegExp(`\\[${MEDIA_LABELS[kind]}\\s*([^\\]]*)\\]\\(([^)]+)\\)`, "g");
|
|
5858
|
+
}
|
|
5859
|
+
var MEDIA_LABEL_PREFIX_RE = new RegExp(
|
|
5860
|
+
`^(?:${[...Object.values(MEDIA_LABELS), ...LEGACY_MEDIA_ICONS].join("|")})\\s*`,
|
|
5861
|
+
"u"
|
|
5862
|
+
);
|
|
5863
|
+
|
|
5788
5864
|
// src/converter/container-head.ts
|
|
5789
5865
|
var CALLOUT_HEAD_LINE_RE = /^([ \t]{0,3})> \[!(\w+)\]([-+])?[ \t]*(.*)$/;
|
|
5790
5866
|
var TOGGLE_HEAD_SOURCE = String.raw`^([ \t]{0,3})> \[!toggle\]-`;
|
|
@@ -5859,7 +5935,7 @@ var EmbedResolver = class {
|
|
|
5859
5935
|
(match, alt, url, offset) => {
|
|
5860
5936
|
if (code.some(([start, end]) => offset >= start && offset < end)) return match;
|
|
5861
5937
|
if (isVideoUrl(url)) {
|
|
5862
|
-
return `[
|
|
5938
|
+
return `[${mediaLabel("video", alt)}](${url})`;
|
|
5863
5939
|
}
|
|
5864
5940
|
if (isExternalUrl(url)) {
|
|
5865
5941
|
images.push({ url, isExternal: true });
|
|
@@ -7423,6 +7499,112 @@ var BlankLineCollapser = class {
|
|
|
7423
7499
|
}
|
|
7424
7500
|
};
|
|
7425
7501
|
|
|
7502
|
+
// src/converter/heading-markers.ts
|
|
7503
|
+
var MARKER = `%%${MARKER_BRAND_RE}:(?:block-color:[a-z_]+|toggle-heading)%%`;
|
|
7504
|
+
var MARKERS = `(?:${MARKER}[ \\t]*)+`;
|
|
7505
|
+
var HEADING_WITH_MARKERS_RE = new RegExp(`^([\\t ]*)(#{1,6}[ \\t].*?)[ \\t]+(${MARKERS})$`, "gm");
|
|
7506
|
+
var MARKER_LINE_AFTER_HEADING_RE = new RegExp(
|
|
7507
|
+
`^([\\t ]*#{1,6}[ \\t].*?)[ \\t]*\\n[\\t ]*(${MARKERS})$`,
|
|
7508
|
+
"gm"
|
|
7509
|
+
);
|
|
7510
|
+
var HEADING_LINE_RE = /^[\t ]*#{1,6}[ \t]/;
|
|
7511
|
+
var MARKER_LINE_RE = new RegExp(`^[\\t ]*${MARKERS}$`);
|
|
7512
|
+
function placeHeadingMarkersBelow(content) {
|
|
7513
|
+
return mapOutsideCodeFences(
|
|
7514
|
+
content,
|
|
7515
|
+
(segment) => segment.replace(
|
|
7516
|
+
HEADING_WITH_MARKERS_RE,
|
|
7517
|
+
(_m, indent, heading, markers) => `${indent}${heading}
|
|
7518
|
+
${indent}${markers.trim()}`
|
|
7519
|
+
)
|
|
7520
|
+
);
|
|
7521
|
+
}
|
|
7522
|
+
function liftHeadingMarkers(content) {
|
|
7523
|
+
return mapOutsideCodeFences(
|
|
7524
|
+
content,
|
|
7525
|
+
(segment) => segment.replace(
|
|
7526
|
+
MARKER_LINE_AFTER_HEADING_RE,
|
|
7527
|
+
(_m, heading, markers) => `${heading} ${markers.trim()}`
|
|
7528
|
+
)
|
|
7529
|
+
);
|
|
7530
|
+
}
|
|
7531
|
+
function isHeadingLine(line) {
|
|
7532
|
+
return HEADING_LINE_RE.test(line);
|
|
7533
|
+
}
|
|
7534
|
+
function isHeadingMarkerLine(line) {
|
|
7535
|
+
return MARKER_LINE_RE.test(line);
|
|
7536
|
+
}
|
|
7537
|
+
function sectionLinesWithHeadingMarkers(all, start, end) {
|
|
7538
|
+
const lines = all.slice(start, end + 1);
|
|
7539
|
+
const last = lines.length - 1;
|
|
7540
|
+
const next = all[end + 1];
|
|
7541
|
+
if (last >= 0 && next !== void 0 && isHeadingLine(lines[last]) && isHeadingMarkerLine(next)) {
|
|
7542
|
+
lines[last] = `${lines[last]} ${next.trim()}`;
|
|
7543
|
+
}
|
|
7544
|
+
return lines;
|
|
7545
|
+
}
|
|
7546
|
+
|
|
7547
|
+
// src/converter/pre-processors/heading-marker-lifter.ts
|
|
7548
|
+
var HeadingMarkerLifter = class {
|
|
7549
|
+
name = "HeadingMarkerLifter";
|
|
7550
|
+
order = 0;
|
|
7551
|
+
process(input) {
|
|
7552
|
+
if (input.context.direction !== "push") {
|
|
7553
|
+
return { content: input.content, metadata: input.metadata };
|
|
7554
|
+
}
|
|
7555
|
+
return { content: liftHeadingMarkers(input.content), metadata: input.metadata };
|
|
7556
|
+
}
|
|
7557
|
+
};
|
|
7558
|
+
|
|
7559
|
+
// src/converter/degrade-link.ts
|
|
7560
|
+
function degradeLink(label, url, marker) {
|
|
7561
|
+
return `[${label}](${url.replace(/\(/g, "%28").replace(/\)/g, "%29")})${marker}`;
|
|
7562
|
+
}
|
|
7563
|
+
var DEGRADE_LINK_SOURCE = "(?:\\[(?:\\\\.|[^\\\\\\]])*\\]\\([^)]*\\))?";
|
|
7564
|
+
function embedVaultLink(src, caption = "") {
|
|
7565
|
+
const label = caption.replace(/<[^>]*>/g, "").replace(/[[\]\\]/g, "").trim();
|
|
7566
|
+
return degradeLink(label === "" ? "\u{1F517} Embed" : `\u{1F517} ${label}`, src, embedMarker(src, caption));
|
|
7567
|
+
}
|
|
7568
|
+
|
|
7569
|
+
// src/converter/large-file-link.ts
|
|
7570
|
+
var LARGE_FILE_LINK_RE = new RegExp(` ?${DEGRADE_LINK_SOURCE}${LARGE_FILE_MARKER}`, "g");
|
|
7571
|
+
function notionFileLocation(pageId, blockId) {
|
|
7572
|
+
const page = `https://www.notion.so/${compactNotionId(pageId)}`;
|
|
7573
|
+
return blockId ? `${page}#${compactNotionId(blockId)}` : page;
|
|
7574
|
+
}
|
|
7575
|
+
function fileSizeLabel(bytes) {
|
|
7576
|
+
const mb = bytes / 1024 / 1024;
|
|
7577
|
+
return mb >= 1 ? `${Math.round(mb)}MB` : `${Math.max(1, Math.round(bytes / 1024))}KB`;
|
|
7578
|
+
}
|
|
7579
|
+
function largeFileLink(location, bytes) {
|
|
7580
|
+
return ` ${degradeLink(`\u2197 Notion \uC5D0\uC11C \uC5F4\uAE30 \xB7 ${fileSizeLabel(bytes)}`, location, LARGE_FILE_MARKER)}`;
|
|
7581
|
+
}
|
|
7582
|
+
function withLargeFileLink(content, fileLink, link) {
|
|
7583
|
+
const at = content.indexOf(fileLink);
|
|
7584
|
+
if (at < 0) return content;
|
|
7585
|
+
const end = at + fileLink.length;
|
|
7586
|
+
const sticky = new RegExp(LARGE_FILE_LINK_RE.source, "y");
|
|
7587
|
+
sticky.lastIndex = end;
|
|
7588
|
+
const existing = sticky.exec(content);
|
|
7589
|
+
const rest = existing ? end + existing[0].length : end;
|
|
7590
|
+
return `${content.slice(0, end)}${link}${content.slice(rest)}`;
|
|
7591
|
+
}
|
|
7592
|
+
function stripLargeFileLinks(content) {
|
|
7593
|
+
return content.includes(LARGE_FILE_MARKER) ? content.replace(LARGE_FILE_LINK_RE, "") : content;
|
|
7594
|
+
}
|
|
7595
|
+
|
|
7596
|
+
// src/converter/pre-processors/large-file-link-stripper.ts
|
|
7597
|
+
var LargeFileLinkStripper = class {
|
|
7598
|
+
name = "LargeFileLinkStripper";
|
|
7599
|
+
order = 0;
|
|
7600
|
+
process(input) {
|
|
7601
|
+
if (input.context.direction !== "push") {
|
|
7602
|
+
return { content: input.content, metadata: input.metadata };
|
|
7603
|
+
}
|
|
7604
|
+
return { content: stripLargeFileLinks(input.content), metadata: input.metadata };
|
|
7605
|
+
}
|
|
7606
|
+
};
|
|
7607
|
+
|
|
7426
7608
|
// src/utils/wikilink-title.ts
|
|
7427
7609
|
function wikilinkTitleFromPath(obsidianPath) {
|
|
7428
7610
|
return noteNameFromPath(obsidianPath);
|
|
@@ -7433,7 +7615,7 @@ function formatWikilink(target, label) {
|
|
|
7433
7615
|
|
|
7434
7616
|
// src/converter/link-restore.ts
|
|
7435
7617
|
var NOTION_LINK_REGEX = new RegExp(
|
|
7436
|
-
`\\[${MARKER_LABEL_CAPTURE}\\]\\((?:https:\\/\\/(?:www\\.)?notion\\.so\\/|notion:\\/\\/)([a-f0-9-]+)\\)(?!%%${MARKER_BRAND_RE}:(?:unknown|embed)
|
|
7618
|
+
`\\[${MARKER_LABEL_CAPTURE}\\]\\((?:https:\\/\\/(?:www\\.)?notion\\.so\\/|notion:\\/\\/)([a-f0-9-]+)\\)(?!%%${MARKER_BRAND_RE}:(?:unknown|embed):|${LARGE_FILE_MARKER})`,
|
|
7437
7619
|
"g"
|
|
7438
7620
|
);
|
|
7439
7621
|
var WIKILINK_PRESERVE_REGEX = new RegExp(
|
|
@@ -7698,7 +7880,7 @@ var PreserveMarkerInjector = class {
|
|
|
7698
7880
|
const rendered = this.renderMarker(m);
|
|
7699
7881
|
if (rendered === null) return false;
|
|
7700
7882
|
if (content.includes(rendered)) return false;
|
|
7701
|
-
return !this.
|
|
7883
|
+
return !this.restoredElsewhere(m);
|
|
7702
7884
|
});
|
|
7703
7885
|
if (missing.length === 0) {
|
|
7704
7886
|
return { content, metadata: input.metadata };
|
|
@@ -7733,15 +7915,11 @@ var PreserveMarkerInjector = class {
|
|
|
7733
7915
|
const body = entries.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
|
|
7734
7916
|
return spacedMarker(`${marker.type}:${body}`);
|
|
7735
7917
|
}
|
|
7736
|
-
/**
|
|
7737
|
-
|
|
7738
|
-
const params = marker.params ?? {};
|
|
7918
|
+
/** 다른 복원기가 맡는 마커 — 본문에 다시 끼우면 중복 · 오염이다. */
|
|
7919
|
+
restoredElsewhere(marker) {
|
|
7739
7920
|
switch (marker.type) {
|
|
7740
|
-
case "wikilink":
|
|
7741
|
-
|
|
7742
|
-
if (!text) return false;
|
|
7743
|
-
return content.includes(`[[${text}]]`) || content.includes(`[[${text}|`);
|
|
7744
|
-
}
|
|
7921
|
+
case "wikilink":
|
|
7922
|
+
return true;
|
|
7745
7923
|
case "local-image":
|
|
7746
7924
|
case "local-file":
|
|
7747
7925
|
return true;
|
|
@@ -8601,7 +8779,10 @@ var CommentRestorer = class {
|
|
|
8601
8779
|
if (input.context.direction !== "pull" || local === void 0) {
|
|
8602
8780
|
return { content: input.content, metadata: input.metadata };
|
|
8603
8781
|
}
|
|
8604
|
-
return {
|
|
8782
|
+
return {
|
|
8783
|
+
content: restoreComments(input.content, liftHeadingMarkers(local)),
|
|
8784
|
+
metadata: input.metadata
|
|
8785
|
+
};
|
|
8605
8786
|
}
|
|
8606
8787
|
};
|
|
8607
8788
|
|
|
@@ -8950,9 +9131,10 @@ function pieces(local, pulled) {
|
|
|
8950
9131
|
let j = 0;
|
|
8951
9132
|
const zip = (to) => {
|
|
8952
9133
|
const theirs = pulled.slice(j, to);
|
|
8953
|
-
|
|
8954
|
-
|
|
8955
|
-
|
|
9134
|
+
const paired = Math.min(pending.length, theirs.length);
|
|
9135
|
+
for (let k = 0; k < paired; k++) out.push([[pending[k]], [theirs[k]]]);
|
|
9136
|
+
if (pending.length > paired) out.push([pending.slice(paired), []]);
|
|
9137
|
+
for (let k = paired; k < theirs.length; k++) out.push([[], [theirs[k]]]);
|
|
8956
9138
|
pending = [];
|
|
8957
9139
|
j = to;
|
|
8958
9140
|
};
|
|
@@ -9169,6 +9351,18 @@ function readable(note) {
|
|
|
9169
9351
|
}
|
|
9170
9352
|
}
|
|
9171
9353
|
|
|
9354
|
+
// src/converter/post-processors/heading-marker-placer.ts
|
|
9355
|
+
var HeadingMarkerPlacer = class {
|
|
9356
|
+
name = "HeadingMarkerPlacer";
|
|
9357
|
+
order = 140;
|
|
9358
|
+
process(input) {
|
|
9359
|
+
if (input.context.direction !== "pull") {
|
|
9360
|
+
return { content: input.content, metadata: input.metadata };
|
|
9361
|
+
}
|
|
9362
|
+
return { content: placeHeadingMarkersBelow(input.content), metadata: input.metadata };
|
|
9363
|
+
}
|
|
9364
|
+
};
|
|
9365
|
+
|
|
9172
9366
|
// src/converter/literal-escapes.ts
|
|
9173
9367
|
var ASCII_PUNCT_RE = /[!-/:-@[-`{-~]/;
|
|
9174
9368
|
var MATH_FENCE_LINE_RE = /^(?:[ \t]*>)*[ \t]*\$\$[ \t]*$/;
|
|
@@ -9391,17 +9585,17 @@ var CHILD_PAGE_TAG_RE = /<page url="([^"]*)">([\s\S]*?)<\/page>/g;
|
|
|
9391
9585
|
var CHILD_DATABASE_TAG_RE = /<database\b([^>]*)>([\s\S]*?)<\/database>/g;
|
|
9392
9586
|
var ID32_RE = /[a-f0-9]{32}/;
|
|
9393
9587
|
function extractChildTags(markdown) {
|
|
9394
|
-
const
|
|
9588
|
+
const found2 = [];
|
|
9395
9589
|
for (const m of markdown.matchAll(CHILD_PAGE_TAG_RE)) {
|
|
9396
9590
|
const id = ID32_RE.exec(m[1] ?? "")?.[0];
|
|
9397
9591
|
if (id) {
|
|
9398
|
-
|
|
9592
|
+
found2.push({ at: m.index ?? 0, child: { kind: "page", id, title: m[2] ?? "", tag: m[0] } });
|
|
9399
9593
|
}
|
|
9400
9594
|
}
|
|
9401
9595
|
for (const m of markdown.matchAll(CHILD_DATABASE_TAG_RE)) {
|
|
9402
9596
|
const id = databaseTagId(m[1] ?? "");
|
|
9403
9597
|
if (id) {
|
|
9404
|
-
|
|
9598
|
+
found2.push({
|
|
9405
9599
|
at: m.index ?? 0,
|
|
9406
9600
|
child: { kind: "database", id, title: m[2] ?? "", tag: m[0] }
|
|
9407
9601
|
});
|
|
@@ -9409,7 +9603,7 @@ function extractChildTags(markdown) {
|
|
|
9409
9603
|
}
|
|
9410
9604
|
const seen = /* @__PURE__ */ new Set();
|
|
9411
9605
|
const children = [];
|
|
9412
|
-
for (const { child } of
|
|
9606
|
+
for (const { child } of found2.sort((a, b) => a.at - b.at)) {
|
|
9413
9607
|
if (seen.has(child.id)) continue;
|
|
9414
9608
|
seen.add(child.id);
|
|
9415
9609
|
children.push(child);
|
|
@@ -9749,6 +9943,8 @@ function findBodyEnd(lines, from, level) {
|
|
|
9749
9943
|
// src/utils/sanitize.ts
|
|
9750
9944
|
var INVALID_CHARS = /[<>:"/\\|?*\x00-\x1f]/g;
|
|
9751
9945
|
var WIKILINK_UNSAFE_CHARS = /[[\]]/g;
|
|
9946
|
+
var LINK_SUBPATH_CHARS = /[#^]/g;
|
|
9947
|
+
var TRAILING_DOTS_AND_SPACES = /[.\s]+$/;
|
|
9752
9948
|
var TRAILING_DOTS = /\.+$/;
|
|
9753
9949
|
var RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
9754
9950
|
"CON",
|
|
@@ -9774,15 +9970,38 @@ var RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
|
9774
9970
|
"LPT8",
|
|
9775
9971
|
"LPT9"
|
|
9776
9972
|
]);
|
|
9777
|
-
|
|
9778
|
-
|
|
9779
|
-
|
|
9780
|
-
|
|
9781
|
-
|
|
9782
|
-
|
|
9783
|
-
|
|
9973
|
+
var FILE_NAME_MAX_BYTES = 200;
|
|
9974
|
+
var FILE_SYSTEM_NAME_MAX_BYTES = 255;
|
|
9975
|
+
var utf8 = new TextEncoder();
|
|
9976
|
+
function utf8Bytes(text) {
|
|
9977
|
+
return utf8.encode(text).length;
|
|
9978
|
+
}
|
|
9979
|
+
function truncateBytes(name, maxBytes) {
|
|
9980
|
+
if (utf8.encode(name).length <= maxBytes) return name;
|
|
9981
|
+
let out = "";
|
|
9982
|
+
let bytes = 0;
|
|
9983
|
+
for (const { segment } of new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(
|
|
9984
|
+
name
|
|
9985
|
+
)) {
|
|
9986
|
+
const size = utf8.encode(segment).length;
|
|
9987
|
+
if (bytes + size > maxBytes) break;
|
|
9988
|
+
out += segment;
|
|
9989
|
+
bytes += size;
|
|
9784
9990
|
}
|
|
9785
|
-
return
|
|
9991
|
+
return out;
|
|
9992
|
+
}
|
|
9993
|
+
function guarded(name) {
|
|
9994
|
+
const safe = RESERVED_NAMES.has(name.toUpperCase()) ? `_${name}` : name;
|
|
9995
|
+
return safe.length === 0 ? "_untitled" : safe;
|
|
9996
|
+
}
|
|
9997
|
+
function sanitizeFileName(name) {
|
|
9998
|
+
const cleaned = name.replace(INVALID_CHARS, "_").replace(WIKILINK_UNSAFE_CHARS, "_").replace(LINK_SUBPATH_CHARS, "_").trim();
|
|
9999
|
+
return guarded(truncateBytes(cleaned, FILE_NAME_MAX_BYTES).replace(TRAILING_DOTS_AND_SPACES, ""));
|
|
10000
|
+
}
|
|
10001
|
+
function legacySanitizeFileName(name) {
|
|
10002
|
+
return guarded(
|
|
10003
|
+
name.replace(INVALID_CHARS, "_").replace(WIKILINK_UNSAFE_CHARS, "_").replace(TRAILING_DOTS, "").trim().slice(0, 200)
|
|
10004
|
+
);
|
|
9786
10005
|
}
|
|
9787
10006
|
|
|
9788
10007
|
// src/converter/nfm-attrs.ts
|
|
@@ -10142,16 +10361,16 @@ var TableAttrs = class {
|
|
|
10142
10361
|
cols = [];
|
|
10143
10362
|
/** 셀 하나의 마커를 걷는다 — 행 · 셀 속성은 돌려주고 표 · 열 속성은 모아 둔다. */
|
|
10144
10363
|
take(cell, col) {
|
|
10145
|
-
const
|
|
10364
|
+
const found2 = {};
|
|
10146
10365
|
const text = cell.replace(TABLE_MARKER_RE, (_m, part, payload) => {
|
|
10147
10366
|
const attrs = parseAttrsPayload(payload);
|
|
10148
10367
|
if (part === "table") this.table ??= attrs;
|
|
10149
10368
|
else if (part === "table-col") this.cols[col] ??= attrs;
|
|
10150
|
-
else if (part === "table-row")
|
|
10151
|
-
else
|
|
10369
|
+
else if (part === "table-row") found2.row ??= attrs;
|
|
10370
|
+
else found2.cell ??= attrs;
|
|
10152
10371
|
return "";
|
|
10153
10372
|
});
|
|
10154
|
-
return { text: text.trim(), ...
|
|
10373
|
+
return { text: text.trim(), ...found2 };
|
|
10155
10374
|
}
|
|
10156
10375
|
};
|
|
10157
10376
|
function toNfmTable(prefix, bodies) {
|
|
@@ -10286,16 +10505,6 @@ function breaksToEmptyBlocks(content) {
|
|
|
10286
10505
|
});
|
|
10287
10506
|
}
|
|
10288
10507
|
|
|
10289
|
-
// src/converter/degrade-link.ts
|
|
10290
|
-
function degradeLink(label, url, marker) {
|
|
10291
|
-
return `[${label}](${url.replace(/\(/g, "%28").replace(/\)/g, "%29")})${marker}`;
|
|
10292
|
-
}
|
|
10293
|
-
var DEGRADE_LINK_SOURCE = "(?:\\[(?:\\\\.|[^\\\\\\]])*\\]\\([^)]*\\))?";
|
|
10294
|
-
function embedVaultLink(src, caption = "") {
|
|
10295
|
-
const label = caption.replace(/<[^>]*>/g, "").replace(/[[\]\\]/g, "").trim();
|
|
10296
|
-
return degradeLink(label === "" ? "\u{1F517} Embed" : `\u{1F517} ${label}`, src, embedMarker(src, caption));
|
|
10297
|
-
}
|
|
10298
|
-
|
|
10299
10508
|
// src/converter/placeholder-indent.ts
|
|
10300
10509
|
var PLACEHOLDER_LINE_RE = new RegExp(`^([ \\t]*)${MEDIA_PLACEHOLDER_HEAD}`);
|
|
10301
10510
|
var LEADING_SPACE_RE3 = /^[ \t]*/;
|
|
@@ -11237,6 +11446,7 @@ var NOTION_AUDIO_RE = /<audio src="([^"]*)">([\s\S]*?)<\/audio>/g;
|
|
|
11237
11446
|
var NOTION_VIDEO_RE = /<video src="([^"]*)">([\s\S]*?)<\/video>/g;
|
|
11238
11447
|
var NOTION_PDF_RE = /<pdf src="([^"]*)">([\s\S]*?)<\/pdf>/g;
|
|
11239
11448
|
var NOTION_FILE_RE = /<file src="([^"]*)">([\s\S]*?)<\/file>/g;
|
|
11449
|
+
var NOTION_HOSTED_EMBED_RE = /<embed\s+src="([^"]*)"[^>]*>([^\n]*?)\s*<\/embed>/g;
|
|
11240
11450
|
var NOTION_TAB_RE = /<tab title="([^"]*)">([\s\S]*?)<\/tab>/g;
|
|
11241
11451
|
var NOTION_LABELED_PAGE_LINK_RE = new RegExp(
|
|
11242
11452
|
`\\[${MARKER_LABEL_CAPTURE}\\]\\(https?:\\/\\/(?:[a-z]+\\.)?notion\\.(?:so|com)\\/(?:p\\/)?([a-f0-9]{32}|[a-f0-9-]{36})\\)`,
|
|
@@ -11291,7 +11501,7 @@ function unescapeBrackets(content) {
|
|
|
11291
11501
|
);
|
|
11292
11502
|
}
|
|
11293
11503
|
function obsidianToNotionEnhanced(obsidian) {
|
|
11294
|
-
let result = obsidian;
|
|
11504
|
+
let result = liftHeadingMarkers(stripLargeFileLinks(obsidian));
|
|
11295
11505
|
result = quotedCodeToChildren(result);
|
|
11296
11506
|
result = breaksToEmptyBlocks(result);
|
|
11297
11507
|
result = pipeTablesToNfmTables(result);
|
|
@@ -11553,20 +11763,20 @@ ${indented}
|
|
|
11553
11763
|
function convertMediaTags(content) {
|
|
11554
11764
|
let result = content;
|
|
11555
11765
|
result = result.replace(NOTION_AUDIO_RE, (_match, src, caption) => {
|
|
11556
|
-
|
|
11557
|
-
return cap ? `[\u{1F50A} ${cap}](${src})` : `[\u{1F50A} audio](${src})`;
|
|
11766
|
+
return `[${mediaLabel("audio", caption.trim())}](${src})`;
|
|
11558
11767
|
});
|
|
11559
11768
|
result = result.replace(NOTION_VIDEO_RE, (_match, src, caption) => {
|
|
11560
|
-
|
|
11561
|
-
return cap ? `[\u{1F3AC} ${cap}](${src})` : `[\u{1F3AC} video](${src})`;
|
|
11769
|
+
return `[${mediaLabel("video", caption.trim())}](${src})`;
|
|
11562
11770
|
});
|
|
11563
11771
|
result = result.replace(NOTION_PDF_RE, (_match, src, caption) => {
|
|
11564
|
-
|
|
11565
|
-
return cap ? `[\u{1F4C4} ${cap}](${src})` : `[\u{1F4C4} pdf](${src})`;
|
|
11772
|
+
return `[${mediaLabel("pdf", caption.trim())}](${src})`;
|
|
11566
11773
|
});
|
|
11567
11774
|
result = result.replace(NOTION_FILE_RE, (_match, src, caption) => {
|
|
11568
|
-
|
|
11569
|
-
|
|
11775
|
+
return `[${mediaLabel("file", caption.trim())}](${src})`;
|
|
11776
|
+
});
|
|
11777
|
+
result = result.replace(NOTION_HOSTED_EMBED_RE, (match, src, caption) => {
|
|
11778
|
+
if (!isNotionHostedFileUrl(src)) return match;
|
|
11779
|
+
return `[${mediaLabel("html", caption.trim())}](${src})`;
|
|
11570
11780
|
});
|
|
11571
11781
|
return result;
|
|
11572
11782
|
}
|
|
@@ -11803,10 +12013,11 @@ $$`;
|
|
|
11803
12013
|
function unescapeNotionChars(content) {
|
|
11804
12014
|
return content.replace(/\\~/g, "~").replace(/\\\^/g, "^");
|
|
11805
12015
|
}
|
|
11806
|
-
var OBSIDIAN_MEDIA_AUDIO_RE =
|
|
11807
|
-
var OBSIDIAN_MEDIA_VIDEO_RE =
|
|
11808
|
-
var OBSIDIAN_MEDIA_PDF_RE =
|
|
11809
|
-
var OBSIDIAN_MEDIA_FILE_RE =
|
|
12016
|
+
var OBSIDIAN_MEDIA_AUDIO_RE = mediaLinkRe("audio");
|
|
12017
|
+
var OBSIDIAN_MEDIA_VIDEO_RE = mediaLinkRe("video");
|
|
12018
|
+
var OBSIDIAN_MEDIA_PDF_RE = mediaLinkRe("pdf");
|
|
12019
|
+
var OBSIDIAN_MEDIA_FILE_RE = mediaLinkRe("file");
|
|
12020
|
+
var OBSIDIAN_MEDIA_HTML_RE = mediaLinkRe("html");
|
|
11810
12021
|
function restoreMediaTags(content) {
|
|
11811
12022
|
let result = content;
|
|
11812
12023
|
const restore = (tag) => (_match, caption, src) => {
|
|
@@ -11817,6 +12028,10 @@ function restoreMediaTags(content) {
|
|
|
11817
12028
|
result = result.replace(OBSIDIAN_MEDIA_VIDEO_RE, restore("video"));
|
|
11818
12029
|
result = result.replace(OBSIDIAN_MEDIA_PDF_RE, restore("pdf"));
|
|
11819
12030
|
result = result.replace(OBSIDIAN_MEDIA_FILE_RE, restore("file"));
|
|
12031
|
+
result = result.replace(OBSIDIAN_MEDIA_HTML_RE, (_match, caption, src) => {
|
|
12032
|
+
const cap = caption.trim() === "html" ? "" : caption;
|
|
12033
|
+
return `<embed src="${src}">${cap}</embed>`;
|
|
12034
|
+
});
|
|
11820
12035
|
return result;
|
|
11821
12036
|
}
|
|
11822
12037
|
var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)((?:\n> .*)*)/gm;
|
|
@@ -12087,6 +12302,8 @@ function sortKeys(value) {
|
|
|
12087
12302
|
// src/converter/pipeline-factory.ts
|
|
12088
12303
|
function createDefaultPipeline(options) {
|
|
12089
12304
|
const pipeline = new ConversionPipeline();
|
|
12305
|
+
pipeline.registerPreProcessor(new HeadingMarkerLifter());
|
|
12306
|
+
pipeline.registerPreProcessor(new LargeFileLinkStripper());
|
|
12090
12307
|
pipeline.registerPreProcessor(new InlineAnnotationPreserver());
|
|
12091
12308
|
pipeline.registerPreProcessor(new UnsupportedBlockStripper());
|
|
12092
12309
|
pipeline.registerPreProcessor(new CommentStripper());
|
|
@@ -12117,6 +12334,7 @@ function createDefaultPipeline(options) {
|
|
|
12117
12334
|
pipeline.registerPostProcessor(
|
|
12118
12335
|
new LocalFormRestorer((note, context, children) => sentForm(pipeline, note, context, children))
|
|
12119
12336
|
);
|
|
12337
|
+
pipeline.registerPostProcessor(new HeadingMarkerPlacer());
|
|
12120
12338
|
return pipeline;
|
|
12121
12339
|
}
|
|
12122
12340
|
|
|
@@ -12224,6 +12442,11 @@ function isNotionUploadable(path) {
|
|
|
12224
12442
|
const ext = /\.[^.]+$/.exec(name)?.[0].toLowerCase();
|
|
12225
12443
|
return ext !== void 0 && NOTION_UPLOADABLE_EXTENSIONS.has(ext);
|
|
12226
12444
|
}
|
|
12445
|
+
var HTML_BLOCK_EXTENSIONS = /* @__PURE__ */ new Set([".html", ".htm"]);
|
|
12446
|
+
function isHtmlBlockFile(path) {
|
|
12447
|
+
const ext = /\.[^./]+$/.exec(path)?.[0].toLowerCase();
|
|
12448
|
+
return ext !== void 0 && HTML_BLOCK_EXTENSIONS.has(ext);
|
|
12449
|
+
}
|
|
12227
12450
|
function getBlockType(filename) {
|
|
12228
12451
|
const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
|
|
12229
12452
|
if (!ext) return "file";
|
|
@@ -12351,10 +12574,15 @@ function toWikilinkAlias(label) {
|
|
|
12351
12574
|
return label.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, "$1").replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, t, d) => d ?? t).replace(/[\r\n]+/g, " ").replace(/\\([!-/:-@[-`{-~])/g, "$1").replace(/\]\]/g, ")").replace(/\|/g, "-").trim();
|
|
12352
12575
|
}
|
|
12353
12576
|
var INTERNAL_FILE_URL_RE = /^file:\/\/%7B.*%7D%7D$/;
|
|
12354
|
-
var
|
|
12355
|
-
|
|
12356
|
-
|
|
12357
|
-
|
|
12577
|
+
var MEDIA_TAG_NAMES = "file|video|audio|pdf|embed";
|
|
12578
|
+
function mediaTagRe(src) {
|
|
12579
|
+
return new RegExp(
|
|
12580
|
+
`<(?:${MEDIA_TAG_NAMES})\\s+src="(${src})">([\\s\\S]*?)<\\/(?:${MEDIA_TAG_NAMES})>`,
|
|
12581
|
+
"g"
|
|
12582
|
+
);
|
|
12583
|
+
}
|
|
12584
|
+
var MEDIA_TAG_RE = mediaTagRe('[^"]+');
|
|
12585
|
+
var MEDIA_TAG_NAME_RE = new RegExp(`^<(${MEDIA_TAG_NAMES})\\s`);
|
|
12358
12586
|
var PLACEHOLDER_IMAGE_STEMS = /* @__PURE__ */ new Set(["image", "untitled"]);
|
|
12359
12587
|
var EMBED_SIZE_RE = /^\d+(?:x\d+)?$/;
|
|
12360
12588
|
function splitEmbedTarget(target) {
|
|
@@ -12378,8 +12606,8 @@ function embedTargetFromCaption(target, caption, uploadedName) {
|
|
|
12378
12606
|
return description ? sized(`${path}|${alias}`) : `${path}|${alias}`;
|
|
12379
12607
|
}
|
|
12380
12608
|
function captionOfLabel(label) {
|
|
12381
|
-
const caption = label.replace(
|
|
12382
|
-
return
|
|
12609
|
+
const caption = label.replace(MEDIA_LABEL_PREFIX_RE, "").replace(/\\(.)/g, "$1").trim();
|
|
12610
|
+
return MEDIA_LABEL_PREFIX_RE.test(label) && MEDIA_PLACEHOLDER_CAPTIONS.has(caption) ? "" : caption;
|
|
12383
12611
|
}
|
|
12384
12612
|
function fileNameOf2(caption, url) {
|
|
12385
12613
|
return savedNameOf(caption, uploadedFileNameFromUrl(url));
|
|
@@ -12406,7 +12634,7 @@ function isNotionMediaUrl(url) {
|
|
|
12406
12634
|
function notionMediaSpans(markdown) {
|
|
12407
12635
|
const spans = [];
|
|
12408
12636
|
for (const link of scanMarkdownLinks(markdown)) {
|
|
12409
|
-
if (!link.isEmbed && !
|
|
12637
|
+
if (!link.isEmbed && !MEDIA_LABEL_PREFIX_RE.test(link.label)) continue;
|
|
12410
12638
|
if (isNotionMediaUrl(link.url)) spans.push(link);
|
|
12411
12639
|
}
|
|
12412
12640
|
for (const tag of markdown.matchAll(MEDIA_TAG_RE)) {
|
|
@@ -12478,6 +12706,7 @@ function mediaBlock2(kind, fileUploadId, vaultTarget, target) {
|
|
|
12478
12706
|
const description = captionOfEmbed(target);
|
|
12479
12707
|
const caption = description ? [{ type: "text", text: { content: description } }] : [];
|
|
12480
12708
|
const file = { type: "file_upload", file_upload: { id: fileUploadId }, caption };
|
|
12709
|
+
if (kind !== "image" && isHtmlBlockFile(vaultTarget)) return { type: "embed", embed: file };
|
|
12481
12710
|
const type = kind === "image" ? "image" : getBlockType(baseName(vaultTarget));
|
|
12482
12711
|
return type === "file" ? { type, file: { ...file, name: baseName(vaultTarget) } } : { type, [type]: file };
|
|
12483
12712
|
}
|
|
@@ -12845,12 +13074,12 @@ var ImageHandler = class {
|
|
|
12845
13074
|
let result = "";
|
|
12846
13075
|
let cursor = 0;
|
|
12847
13076
|
for (const placeholder2 of placeholders) {
|
|
12848
|
-
const
|
|
12849
|
-
const line =
|
|
13077
|
+
const found2 = onPage.get(stripAlias2(placeholder2.target))?.shift();
|
|
13078
|
+
const line = found2 && await this.keptMediaLine(placeholder2, found2, noteFolder);
|
|
12850
13079
|
if (!line) continue;
|
|
12851
13080
|
result += markdown.slice(cursor, placeholder2.start) + line;
|
|
12852
13081
|
cursor = placeholder2.end;
|
|
12853
|
-
kept.push({ ...
|
|
13082
|
+
kept.push({ ...found2.media, target: placeholder2.target });
|
|
12854
13083
|
}
|
|
12855
13084
|
if (kept.length === 0) return unchanged;
|
|
12856
13085
|
return { markdown: result + markdown.slice(cursor), kept };
|
|
@@ -12859,21 +13088,21 @@ var ImageHandler = class {
|
|
|
12859
13088
|
* 자리표시자 자리에 실을 Notion 미디어 한 줄. 종류 · 캡션 · URL 을 그대로 실을 수 없거나 볼트의 파일이
|
|
12860
13089
|
* 올린 것과 다르면 null — 그 자리표시자는 다시 올린다.
|
|
12861
13090
|
*/
|
|
12862
|
-
async keptMediaLine(placeholder2,
|
|
13091
|
+
async keptMediaLine(placeholder2, found2, noteFolder) {
|
|
12863
13092
|
const isImage = placeholder2.kind === "image";
|
|
12864
|
-
if (isImage !==
|
|
12865
|
-
const tag = MEDIA_TAG_NAME_RE.exec(
|
|
13093
|
+
if (isImage !== found2.span.full.startsWith("![")) return null;
|
|
13094
|
+
const tag = MEDIA_TAG_NAME_RE.exec(found2.span.full)?.[1];
|
|
12866
13095
|
if (!isImage && !tag) return null;
|
|
12867
|
-
const caption = this.keptCaptionOf(placeholder2.target,
|
|
12868
|
-
const url =
|
|
13096
|
+
const caption = this.keptCaptionOf(placeholder2.target, found2.span);
|
|
13097
|
+
const url = found2.span.url.split("?")[0];
|
|
12869
13098
|
if (!PLAIN_CAPTION_RE.test(caption) || !PLAIN_URL_RE.test(url)) return null;
|
|
12870
13099
|
try {
|
|
12871
13100
|
const { buffer, resolvedPath } = await this.readImageFromVault(
|
|
12872
13101
|
stripAlias2(placeholder2.target),
|
|
12873
13102
|
noteFolder
|
|
12874
13103
|
);
|
|
12875
|
-
if (resolvedPath !==
|
|
12876
|
-
if (createHash("sha256").update(buffer).digest("hex") !==
|
|
13104
|
+
if (resolvedPath !== found2.media.localPath) return null;
|
|
13105
|
+
if (createHash("sha256").update(buffer).digest("hex") !== found2.media.fileHash) return null;
|
|
12877
13106
|
} catch {
|
|
12878
13107
|
return null;
|
|
12879
13108
|
}
|
|
@@ -13122,17 +13351,15 @@ var ImageHandler = class {
|
|
|
13122
13351
|
}
|
|
13123
13352
|
async downloadAllFiles(markdown, pageTitle, pageId) {
|
|
13124
13353
|
const fileLinks = scanMarkdownLinks(markdown).filter(
|
|
13125
|
-
(s) => !s.isEmbed &&
|
|
13354
|
+
(s) => !s.isEmbed && MEDIA_LABEL_PREFIX_RE.test(s.label)
|
|
13126
13355
|
);
|
|
13127
13356
|
const notionHttpMatches = fileLinks.filter(
|
|
13128
13357
|
(s) => /^https?:\/\//.test(s.url) && this.isNotionFileUrl(s.url)
|
|
13129
13358
|
);
|
|
13130
13359
|
const mdInternalMatches = fileLinks.filter((s) => INTERNAL_FILE_URL_RE.test(s.url));
|
|
13131
|
-
const
|
|
13132
|
-
const xmlHttpMatches = [...markdown.matchAll(xmlHttpRegex)];
|
|
13360
|
+
const xmlHttpMatches = [...markdown.matchAll(mediaTagRe('https?:\\/\\/[^"]+'))];
|
|
13133
13361
|
const notionXmlHttpMatches = xmlHttpMatches.filter((m) => m[1] && this.isNotionFileUrl(m[1]));
|
|
13134
|
-
const
|
|
13135
|
-
const xmlInternalMatches = [...markdown.matchAll(xmlInternalRegex)];
|
|
13362
|
+
const xmlInternalMatches = [...markdown.matchAll(mediaTagRe("file:\\/\\/%7B.*?%7D%7D"))];
|
|
13136
13363
|
const totalMatches = notionHttpMatches.length + mdInternalMatches.length + notionXmlHttpMatches.length + xmlInternalMatches.length;
|
|
13137
13364
|
if (totalMatches === 0) {
|
|
13138
13365
|
return { content: markdown, downloads: [] };
|
|
@@ -13144,9 +13371,12 @@ var ImageHandler = class {
|
|
|
13144
13371
|
for (const span of notionHttpMatches) {
|
|
13145
13372
|
await sema.acquire();
|
|
13146
13373
|
try {
|
|
13147
|
-
const caption = fileNameOf2(span.label.replace(
|
|
13374
|
+
const caption = fileNameOf2(span.label.replace(MEDIA_LABEL_PREFIX_RE, ""), span.url);
|
|
13148
13375
|
const download = await this.downloadFile(span.url, pageTitle, caption);
|
|
13149
|
-
if (!download.localPath)
|
|
13376
|
+
if (!download.localPath) {
|
|
13377
|
+
result = keepLargeFile(result, span.full, download, pageId);
|
|
13378
|
+
continue;
|
|
13379
|
+
}
|
|
13150
13380
|
downloads.push(download);
|
|
13151
13381
|
const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
|
|
13152
13382
|
result = result.replace(span.full, () => obsidianLink);
|
|
@@ -13175,9 +13405,12 @@ var ImageHandler = class {
|
|
|
13175
13405
|
getLogger().warn(`[Im-Nobsidian] \uD30C\uC77C Block URL \uC870\uD68C \uC2E4\uD328: ${fileName2}`);
|
|
13176
13406
|
continue;
|
|
13177
13407
|
}
|
|
13178
|
-
const caption = savedNameOf(span.label.replace(
|
|
13408
|
+
const caption = savedNameOf(span.label.replace(MEDIA_LABEL_PREFIX_RE, ""), fileName2);
|
|
13179
13409
|
const download = await this.downloadFile(realUrl, pageTitle, caption);
|
|
13180
|
-
if (!download.localPath)
|
|
13410
|
+
if (!download.localPath) {
|
|
13411
|
+
result = keepLargeFile(result, span.full, download, pageId, blockId);
|
|
13412
|
+
continue;
|
|
13413
|
+
}
|
|
13181
13414
|
downloads.push(download);
|
|
13182
13415
|
const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
|
|
13183
13416
|
result = result.replace(span.full, () => obsidianLink);
|
|
@@ -13200,7 +13433,10 @@ var ImageHandler = class {
|
|
|
13200
13433
|
const url = match[1];
|
|
13201
13434
|
const caption = fileNameOf2(match[2] ?? "", url);
|
|
13202
13435
|
const download = await this.downloadFile(url, pageTitle, caption);
|
|
13203
|
-
if (!download.localPath)
|
|
13436
|
+
if (!download.localPath) {
|
|
13437
|
+
result = keepLargeFile(result, match[0], download, pageId);
|
|
13438
|
+
continue;
|
|
13439
|
+
}
|
|
13204
13440
|
downloads.push(download);
|
|
13205
13441
|
const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
|
|
13206
13442
|
result = result.replace(match[0], () => obsidianLink);
|
|
@@ -13232,7 +13468,10 @@ var ImageHandler = class {
|
|
|
13232
13468
|
}
|
|
13233
13469
|
const caption = savedNameOf(match[2] ?? "", fileName2);
|
|
13234
13470
|
const download = await this.downloadFile(realUrl, pageTitle, caption);
|
|
13235
|
-
if (!download.localPath)
|
|
13471
|
+
if (!download.localPath) {
|
|
13472
|
+
result = keepLargeFile(result, match[0], download, pageId, blockId);
|
|
13473
|
+
continue;
|
|
13474
|
+
}
|
|
13236
13475
|
downloads.push(download);
|
|
13237
13476
|
const obsidianLink = fileEmbedOf(download.localPath, download.name ?? caption);
|
|
13238
13477
|
result = result.replace(match[0], () => obsidianLink);
|
|
@@ -13283,18 +13522,21 @@ var ImageHandler = class {
|
|
|
13283
13522
|
const contentLength = response.headers.get("content-length");
|
|
13284
13523
|
const MAX_FILE_SIZE = this.maxFileSizeBytes;
|
|
13285
13524
|
if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
|
|
13286
|
-
const
|
|
13525
|
+
const bytes = parseInt(contentLength, 10);
|
|
13287
13526
|
getLogger().info(
|
|
13288
|
-
`[Im-Nobsidian] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uAC74\uB108\uB700 \u2014 \uD06C\uAE30 \uC0C1\uD55C \uCD08\uACFC (${
|
|
13527
|
+
`[Im-Nobsidian] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uAC74\uB108\uB700 \u2014 \uD06C\uAE30 \uC0C1\uD55C \uCD08\uACFC (${fileSizeLabel(bytes)} > ${fileSizeLabel(MAX_FILE_SIZE)}), Notion \uC5D0\uC11C \uC5F4\uAE30 \uB9C1\uD06C\uB97C \uB460: ${caption}`
|
|
13289
13528
|
);
|
|
13290
|
-
return { originalUrl: url, localPath: "", hash: "", size: 0 };
|
|
13529
|
+
return { originalUrl: url, localPath: "", hash: "", size: 0, skippedBytes: bytes };
|
|
13291
13530
|
}
|
|
13292
13531
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
13293
13532
|
const fileHash = createHash("sha256").update(buffer).digest("hex");
|
|
13294
13533
|
const hash = nameHashOf(fileHash);
|
|
13295
13534
|
const extension = this.guessFileExtension(url, response.headers.get("content-type"));
|
|
13296
13535
|
const name = withoutOwnHash(caption, hash);
|
|
13297
|
-
const safeName =
|
|
13536
|
+
const safeName = truncateBytes(
|
|
13537
|
+
name.replace(/[^a-zA-Z0-9가-힣\s._-]/g, "").replace(/\s+/g, "-").slice(0, 80),
|
|
13538
|
+
FILE_SYSTEM_NAME_MAX_BYTES - utf8Bytes(`-${hash}${extension}`)
|
|
13539
|
+
);
|
|
13298
13540
|
const fileName2 = `${safeName}-${hash}${extension}`;
|
|
13299
13541
|
const localPath = `${this.attachmentFolder}/${fileName2}`;
|
|
13300
13542
|
await this.vaultFs.ensureFolder(this.attachmentFolder);
|
|
@@ -13356,6 +13598,11 @@ var ImageHandler = class {
|
|
|
13356
13598
|
return "";
|
|
13357
13599
|
}
|
|
13358
13600
|
};
|
|
13601
|
+
function keepLargeFile(content, fileLink, download, pageId, blockId) {
|
|
13602
|
+
if (download.skippedBytes === void 0 || !pageId) return content;
|
|
13603
|
+
const link = largeFileLink(notionFileLocation(pageId, blockId), download.skippedBytes);
|
|
13604
|
+
return withLargeFileLink(content, fileLink, link);
|
|
13605
|
+
}
|
|
13359
13606
|
|
|
13360
13607
|
// src/view/notion-filter-translator.ts
|
|
13361
13608
|
function translateNotionFilter(filter, schema, resolveName) {
|
|
@@ -13685,6 +13932,10 @@ function filterGroup(node) {
|
|
|
13685
13932
|
return ["not", node.not];
|
|
13686
13933
|
}
|
|
13687
13934
|
var BASE_FILE_HEADER = "# Auto-generated by Im-Nobsidian";
|
|
13935
|
+
function inFolderFilter(folderPath) {
|
|
13936
|
+
return `file.inFolder("${folderPath}")`;
|
|
13937
|
+
}
|
|
13938
|
+
var IN_FOLDER_FILTER_RE = /file\.inFolder\("([^"\n]*)"\)/g;
|
|
13688
13939
|
var BaseFileGenerator = class {
|
|
13689
13940
|
generate(options) {
|
|
13690
13941
|
const lines = [];
|
|
@@ -13700,7 +13951,7 @@ var BaseFileGenerator = class {
|
|
|
13700
13951
|
writeFilters(lines, folderPath) {
|
|
13701
13952
|
lines.push("filters:");
|
|
13702
13953
|
lines.push(" and:");
|
|
13703
|
-
lines.push(` -
|
|
13954
|
+
lines.push(` - ${inFolderFilter(folderPath)}`);
|
|
13704
13955
|
lines.push(` - file.ext == "md"`);
|
|
13705
13956
|
lines.push("");
|
|
13706
13957
|
}
|
|
@@ -14538,9 +14789,9 @@ function noteTitle(frontmatter, path) {
|
|
|
14538
14789
|
return explicitTitle(frontmatter) ?? wikilinkTitleFromPath(path);
|
|
14539
14790
|
}
|
|
14540
14791
|
function isFileNameOf(title, fileName2) {
|
|
14541
|
-
|
|
14542
|
-
|
|
14543
|
-
|
|
14792
|
+
return [sanitizeFileName(title), legacySanitizeFileName(title)].some(
|
|
14793
|
+
(safe) => fileName2 === safe || fileName2.startsWith(safe) && /^ \([0-9a-f]{8,32}\)$/i.test(fileName2.slice(safe.length))
|
|
14794
|
+
);
|
|
14544
14795
|
}
|
|
14545
14796
|
function followsFileName(title, path) {
|
|
14546
14797
|
return isFileNameOf(title, wikilinkTitleFromPath(path));
|
|
@@ -14568,8 +14819,11 @@ function titleMayChange(base, current, from, to) {
|
|
|
14568
14819
|
function extractTitle(filePath) {
|
|
14569
14820
|
return noteNameFromPath(filePath);
|
|
14570
14821
|
}
|
|
14571
|
-
function titleProperty(title) {
|
|
14572
|
-
|
|
14822
|
+
function titleProperty(title, remote) {
|
|
14823
|
+
const runs = remote ? titleRunsOf(remote) : null;
|
|
14824
|
+
return {
|
|
14825
|
+
title: { title: (runs && patchRichText(runs, title)) ?? [{ text: { content: title } }] }
|
|
14826
|
+
};
|
|
14573
14827
|
}
|
|
14574
14828
|
function extractAliases(properties) {
|
|
14575
14829
|
const raw = properties.aliases ?? properties.alias;
|
|
@@ -14579,6 +14833,13 @@ function extractAliases(properties) {
|
|
|
14579
14833
|
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
14580
14834
|
return [];
|
|
14581
14835
|
}
|
|
14836
|
+
function retitledInNotion(base, local, path, remoteTitle) {
|
|
14837
|
+
if (followsFileName(remoteTitle, path)) return false;
|
|
14838
|
+
const previous = base ? explicitTitle(base) : null;
|
|
14839
|
+
if (previous !== null && !followsFileName(previous, path)) return false;
|
|
14840
|
+
if (local && explicitTitle(local) !== previous) return false;
|
|
14841
|
+
return true;
|
|
14842
|
+
}
|
|
14582
14843
|
|
|
14583
14844
|
// src/utils/db-row-path.ts
|
|
14584
14845
|
function selectDbRowFiles(files, localFolder) {
|
|
@@ -14589,10 +14850,14 @@ function selectDbRowFiles(files, localFolder) {
|
|
|
14589
14850
|
return rel.length > 0 && !rel.includes("/");
|
|
14590
14851
|
});
|
|
14591
14852
|
}
|
|
14853
|
+
var PAGE_ID_SUFFIX_LENGTHS = [8, 16, 32];
|
|
14592
14854
|
function pagePathCandidates(dir, safeName, pageId) {
|
|
14593
14855
|
const base = dir ? `${dir}/${safeName}` : safeName;
|
|
14594
14856
|
const raw = pageId.replace(/-/g, "");
|
|
14595
|
-
return [
|
|
14857
|
+
return [
|
|
14858
|
+
`${base}.md`,
|
|
14859
|
+
...PAGE_ID_SUFFIX_LENGTHS.map((len) => `${base} (${raw.slice(0, len)}).md`)
|
|
14860
|
+
];
|
|
14596
14861
|
}
|
|
14597
14862
|
function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
|
|
14598
14863
|
const ownedBySelf = (candidate) => {
|
|
@@ -14603,9 +14868,9 @@ function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
|
|
|
14603
14868
|
const candidates = pagePathCandidates(localFolder, safeName, pageId);
|
|
14604
14869
|
return candidates.find(ownedBySelf) ?? candidates[candidates.length - 1];
|
|
14605
14870
|
}
|
|
14606
|
-
function inClaimOrder(items, chosen,
|
|
14871
|
+
function inClaimOrder(items, chosen, idOf2) {
|
|
14607
14872
|
const slots = items.flatMap((item, index) => chosen(item) ? [index] : []);
|
|
14608
|
-
const key = (item) => compactNotionId(
|
|
14873
|
+
const key = (item) => compactNotionId(idOf2(item));
|
|
14609
14874
|
const members = slots.map((index) => items[index]).sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
|
|
14610
14875
|
const ordered = [...items];
|
|
14611
14876
|
slots.forEach((slot, k) => ordered[slot] = members[k]);
|
|
@@ -14960,6 +15225,8 @@ var DbBaseFiles = class {
|
|
|
14960
15225
|
}
|
|
14961
15226
|
stateDb;
|
|
14962
15227
|
entries = null;
|
|
15228
|
+
/** 마지막으로 읽거나 쓴 메타 값 — 그 사이 다른 쪽(폴더를 옮긴 NoteRelocator)이 고쳐 적었으면 다시 읽는다. */
|
|
15229
|
+
loadedRaw = null;
|
|
14963
15230
|
get(databaseId) {
|
|
14964
15231
|
return this.load().get(compactNotionId(databaseId));
|
|
14965
15232
|
}
|
|
@@ -14967,15 +15234,23 @@ var DbBaseFiles = class {
|
|
|
14967
15234
|
const entries = this.load();
|
|
14968
15235
|
const key = compactNotionId(databaseId);
|
|
14969
15236
|
const known = entries.get(key);
|
|
14970
|
-
if (known?.basePath === info.basePath && known.title === info.title)
|
|
14971
|
-
|
|
14972
|
-
|
|
15237
|
+
if (known?.basePath === info.basePath && known.title === info.title && known.hash === info.hash) {
|
|
15238
|
+
return;
|
|
15239
|
+
}
|
|
15240
|
+
entries.set(key, {
|
|
15241
|
+
basePath: info.basePath,
|
|
15242
|
+
title: info.title,
|
|
15243
|
+
...info.hash ? { hash: info.hash } : {}
|
|
15244
|
+
});
|
|
15245
|
+
this.loadedRaw = JSON.stringify(Object.fromEntries(entries));
|
|
15246
|
+
this.stateDb.setMeta(DB_BASE_FILES_META_KEY, this.loadedRaw);
|
|
14973
15247
|
}
|
|
14974
|
-
/** 상태 메타에서
|
|
15248
|
+
/** 상태 메타에서 읽는다 — 값이 그대로면 읽어 둔 것을 쓴다. 없거나 깨졌으면 비어 있다 — 이번 실행이 기록하는 것부터 다시 쌓는다. */
|
|
14975
15249
|
load() {
|
|
14976
|
-
if (this.entries) return this.entries;
|
|
14977
|
-
this.entries = /* @__PURE__ */ new Map();
|
|
14978
15250
|
const raw = this.stateDb.getMeta(DB_BASE_FILES_META_KEY);
|
|
15251
|
+
if (this.entries && (raw === null || raw === this.loadedRaw)) return this.entries;
|
|
15252
|
+
this.entries = /* @__PURE__ */ new Map();
|
|
15253
|
+
this.loadedRaw = raw;
|
|
14979
15254
|
if (!raw) return this.entries;
|
|
14980
15255
|
try {
|
|
14981
15256
|
const parsed = JSON.parse(raw);
|
|
@@ -14983,7 +15258,11 @@ var DbBaseFiles = class {
|
|
|
14983
15258
|
for (const [key, value] of Object.entries(parsed)) {
|
|
14984
15259
|
const entry = value;
|
|
14985
15260
|
if (typeof entry?.basePath === "string" && typeof entry.title === "string") {
|
|
14986
|
-
this.entries.set(compactNotionId(key), {
|
|
15261
|
+
this.entries.set(compactNotionId(key), {
|
|
15262
|
+
basePath: entry.basePath,
|
|
15263
|
+
title: entry.title,
|
|
15264
|
+
...typeof entry.hash === "string" ? { hash: entry.hash } : {}
|
|
15265
|
+
});
|
|
14987
15266
|
}
|
|
14988
15267
|
}
|
|
14989
15268
|
} catch {
|
|
@@ -14992,83 +15271,301 @@ var DbBaseFiles = class {
|
|
|
14992
15271
|
}
|
|
14993
15272
|
};
|
|
14994
15273
|
|
|
14995
|
-
// src/
|
|
14996
|
-
var
|
|
14997
|
-
var
|
|
14998
|
-
var
|
|
14999
|
-
|
|
15000
|
-
|
|
15001
|
-
);
|
|
15002
|
-
|
|
15003
|
-
|
|
15004
|
-
|
|
15005
|
-
}
|
|
15006
|
-
|
|
15007
|
-
|
|
15008
|
-
}
|
|
15009
|
-
function unknownTagKeys(nfm) {
|
|
15010
|
-
const keys = /* @__PURE__ */ new Set();
|
|
15011
|
-
mapOutsideCode(nfm, (segment) => {
|
|
15012
|
-
for (const m of segment.matchAll(UNKNOWN_BLOCK_TAG_RE2)) keys.add(blockKey(m[1], m[2]));
|
|
15013
|
-
for (const m of segment.matchAll(UNKNOWN_MENTION_TAG_RE)) keys.add(mentionKey(m[1]));
|
|
15014
|
-
return segment;
|
|
15015
|
-
});
|
|
15016
|
-
return keys;
|
|
15017
|
-
}
|
|
15018
|
-
function unknownTagFallbacks(vaultMarkdown) {
|
|
15019
|
-
const fallbacks = /* @__PURE__ */ new Map();
|
|
15020
|
-
mapOutsideCode(vaultMarkdown, (segment) => {
|
|
15021
|
-
for (const m of segment.matchAll(VAULT_UNKNOWN_PAIR_RE)) {
|
|
15022
|
-
const [, label, url, encodedId, type] = m;
|
|
15023
|
-
const decoded = decodeURIComponent(encodedId);
|
|
15024
|
-
const key = decoded.startsWith("http") ? blockKey("url", decoded) : blockKey("id", encodedId);
|
|
15025
|
-
fallbacks.set(key, fallbackOf(label, url, decoded, type));
|
|
15026
|
-
}
|
|
15027
|
-
return segment;
|
|
15028
|
-
});
|
|
15029
|
-
return fallbacks;
|
|
15030
|
-
}
|
|
15031
|
-
function fallbackOf(label, url, placeholder2, type) {
|
|
15032
|
-
if (label === void 0 || url === void 0) return "";
|
|
15033
|
-
const bookmark = type === "bookmark" ? bookmarkOfVaultLink(label, url, placeholder2) : null;
|
|
15034
|
-
if (bookmark === null) return `[${label}](${url})`;
|
|
15035
|
-
const caption = bookmark.caption.replace(CAPTION_ESCAPED_CHAR_RE, "\\$&");
|
|
15036
|
-
return `<bookmark url="${escapeAttr(bookmark.url)}">${caption}</bookmark>`;
|
|
15274
|
+
// src/sync/discovered-databases.ts
|
|
15275
|
+
var ROOT_DATABASES_FOLDER = "databases";
|
|
15276
|
+
var DISCOVERED_DBS_META_KEY = "discovered_dbs";
|
|
15277
|
+
var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
|
|
15278
|
+
var LINKED_DBS_META_KEY = "linked_dbs";
|
|
15279
|
+
function parseDiscoveredDbs(raw) {
|
|
15280
|
+
if (!raw) return [];
|
|
15281
|
+
try {
|
|
15282
|
+
const parsed = JSON.parse(raw);
|
|
15283
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
15284
|
+
} catch {
|
|
15285
|
+
return [];
|
|
15286
|
+
}
|
|
15037
15287
|
}
|
|
15038
|
-
function
|
|
15039
|
-
|
|
15288
|
+
function loadInaccessibleDbIds(stateDb) {
|
|
15289
|
+
const raw = stateDb.getMeta(INACCESSIBLE_DBS_META_KEY);
|
|
15290
|
+
if (!raw) return /* @__PURE__ */ new Set();
|
|
15291
|
+
try {
|
|
15292
|
+
const arr = JSON.parse(raw);
|
|
15293
|
+
return new Set(arr.map((id) => id.replace(/-/g, "")));
|
|
15294
|
+
} catch {
|
|
15295
|
+
return /* @__PURE__ */ new Set();
|
|
15296
|
+
}
|
|
15040
15297
|
}
|
|
15041
|
-
function
|
|
15042
|
-
|
|
15043
|
-
|
|
15044
|
-
|
|
15045
|
-
|
|
15046
|
-
return
|
|
15047
|
-
}
|
|
15048
|
-
|
|
15049
|
-
|
|
15050
|
-
(segment) => segment.replace(
|
|
15051
|
-
UNKNOWN_BLOCK_TAG_RE2,
|
|
15052
|
-
(tag, attr, value) => swap(tag, blockKey(attr, value))
|
|
15053
|
-
).replace(UNKNOWN_MENTION_TAG_RE, (tag, url) => swap(tag, mentionKey(url)))
|
|
15054
|
-
);
|
|
15055
|
-
return { nfm: out, replaced };
|
|
15298
|
+
function loadLinkedDbMap(stateDb) {
|
|
15299
|
+
const raw = stateDb.getMeta(LINKED_DBS_META_KEY);
|
|
15300
|
+
if (!raw) return /* @__PURE__ */ new Map();
|
|
15301
|
+
try {
|
|
15302
|
+
const obj = JSON.parse(raw);
|
|
15303
|
+
return new Map(Object.entries(obj).map(([k, v]) => [k.replace(/-/g, ""), v.replace(/-/g, "")]));
|
|
15304
|
+
} catch {
|
|
15305
|
+
return /* @__PURE__ */ new Map();
|
|
15306
|
+
}
|
|
15056
15307
|
}
|
|
15057
15308
|
|
|
15058
|
-
// src/sync/
|
|
15059
|
-
|
|
15309
|
+
// src/sync/db-folder-moves.ts
|
|
15310
|
+
function rewriteDatabaseFolders(stateDb, moves) {
|
|
15311
|
+
const folders = moves.filter((move) => !isMarkdownPath(move.from));
|
|
15312
|
+
if (folders.length === 0) return;
|
|
15313
|
+
const databases = parseDiscoveredDbs(stateDb.getMeta(DISCOVERED_DBS_META_KEY));
|
|
15314
|
+
let changed = false;
|
|
15315
|
+
for (const db of databases) {
|
|
15316
|
+
const folder = db.localFolder.replace(/\/+$/, "");
|
|
15317
|
+
const to = finalPath(folder, folders);
|
|
15318
|
+
if (to === folder) continue;
|
|
15319
|
+
db.localFolder = to;
|
|
15320
|
+
changed = true;
|
|
15321
|
+
}
|
|
15322
|
+
if (changed) stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(databases));
|
|
15323
|
+
const raw = stateDb.getMeta(DB_BASE_FILES_META_KEY);
|
|
15324
|
+
if (!raw) return;
|
|
15325
|
+
let entries;
|
|
15060
15326
|
try {
|
|
15061
|
-
|
|
15327
|
+
entries = JSON.parse(raw);
|
|
15328
|
+
} catch {
|
|
15329
|
+
return;
|
|
15330
|
+
}
|
|
15331
|
+
if (!entries || typeof entries !== "object") return;
|
|
15332
|
+
let moved = false;
|
|
15333
|
+
for (const entry of Object.values(entries)) {
|
|
15334
|
+
if (typeof entry?.basePath !== "string") continue;
|
|
15335
|
+
const to = finalPath(entry.basePath, folders);
|
|
15336
|
+
if (to === entry.basePath) continue;
|
|
15337
|
+
entry.basePath = to;
|
|
15338
|
+
moved = true;
|
|
15339
|
+
}
|
|
15340
|
+
if (moved) stateDb.setMeta(DB_BASE_FILES_META_KEY, JSON.stringify(entries));
|
|
15341
|
+
}
|
|
15342
|
+
async function retargetBaseFiles(vaultFs, moves) {
|
|
15343
|
+
const folders = moves.filter((move) => !isMarkdownPath(move.from));
|
|
15344
|
+
if (folders.length === 0) return;
|
|
15345
|
+
try {
|
|
15346
|
+
for (const file of await vaultFs.listNonMarkdownFiles()) {
|
|
15347
|
+
if (!file.path.endsWith(BASE_SUFFIX)) continue;
|
|
15348
|
+
const content = await vaultFs.readFile(file.path);
|
|
15349
|
+
if (!isGeneratedDbArtifact(file.path, content)) continue;
|
|
15350
|
+
const next = content.replace(IN_FOLDER_FILTER_RE, (whole, folder) => {
|
|
15351
|
+
const to = finalPath(folder, folders);
|
|
15352
|
+
return to === folder ? whole : inFolderFilter(to);
|
|
15353
|
+
});
|
|
15354
|
+
if (next !== content) await vaultFs.writeFile(file.path, next);
|
|
15355
|
+
}
|
|
15062
15356
|
} catch (error) {
|
|
15063
|
-
|
|
15064
|
-
|
|
15065
|
-
|
|
15066
|
-
|
|
15067
|
-
|
|
15068
|
-
|
|
15357
|
+
getLogger().warn(
|
|
15358
|
+
`[Im-Nobsidian] \uC62E\uAE34 \uD3F4\uB354\uB97C \uAC00\uB9AC\uD0A4\uB358 .base \uBCF4\uAE30\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568: ${error instanceof Error ? error.message : String(error)}`
|
|
15359
|
+
);
|
|
15360
|
+
}
|
|
15361
|
+
}
|
|
15362
|
+
function finalPath(path, moves) {
|
|
15363
|
+
let current = path;
|
|
15364
|
+
for (const move of moves) {
|
|
15365
|
+
if (current === move.from || current.startsWith(`${move.from}/`)) {
|
|
15366
|
+
current = move.to + current.slice(move.from.length);
|
|
15069
15367
|
}
|
|
15070
|
-
|
|
15071
|
-
|
|
15368
|
+
}
|
|
15369
|
+
return current;
|
|
15370
|
+
}
|
|
15371
|
+
|
|
15372
|
+
// src/sync/note-relocation.ts
|
|
15373
|
+
var NoteRelocator = class {
|
|
15374
|
+
constructor(stateDb, vaultFs, configuredDatabases = []) {
|
|
15375
|
+
this.stateDb = stateDb;
|
|
15376
|
+
this.vaultFs = vaultFs;
|
|
15377
|
+
this.configuredDatabases = configuredDatabases;
|
|
15378
|
+
}
|
|
15379
|
+
stateDb;
|
|
15380
|
+
vaultFs;
|
|
15381
|
+
configuredDatabases;
|
|
15382
|
+
/**
|
|
15383
|
+
* 함께 옮길 것 — 폴더로 추적하는 페이지는 그 폴더, 폴더 노트(`X/X.md`)는 든 폴더, 노트는 그 파일과 옆의 자식
|
|
15384
|
+
* 폴더(`X/`, 있으면).
|
|
15385
|
+
*/
|
|
15386
|
+
unitsOf(record) {
|
|
15387
|
+
if (isFolderRecord(record)) return [record.obsidianPath];
|
|
15388
|
+
if (record.fileType !== "file") return [parentFolderOf(record.obsidianPath)];
|
|
15389
|
+
const children = stripMarkdownExtension(record.obsidianPath);
|
|
15390
|
+
const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children));
|
|
15391
|
+
return hasChildren ? [record.obsidianPath, children] : [record.obsidianPath];
|
|
15392
|
+
}
|
|
15393
|
+
/** 옮기지 못할 까닭 — 없으면 null. */
|
|
15394
|
+
async blocked(move) {
|
|
15395
|
+
if (within(move.to, move.from)) return "\uC81C \uC548\uC73C\uB85C \uC62E\uAE38 \uC218 \uC5C6\uB2E4";
|
|
15396
|
+
if (!await this.vaultFs.exists(move.from)) return "\uBCFC\uD2B8\uC5D0 \uB178\uD2B8\uAC00 \uC5C6\uB2E4";
|
|
15397
|
+
if (await this.taken(move.to)) return `\uC62E\uAE38 \uC790\uB9AC(${move.to})\uC5D0 \uAC19\uC740 \uC774\uB984\uC774 \uC788\uB2E4`;
|
|
15398
|
+
const isFolder = !isMarkdownPath(move.from);
|
|
15399
|
+
if (this.vaultFs.excludesPath?.(move.to, isFolder ? "folder" : "file")) {
|
|
15400
|
+
return `\uC62E\uAE38 \uC790\uB9AC(${move.to})\uAC00 \uB3D9\uAE30\uD654\uC5D0\uC11C \uBE60\uC9C4\uB2E4(paths \xB7 .im-nobsidian-ignore)`;
|
|
15401
|
+
}
|
|
15402
|
+
if (isFolder && this.holdsConfiguredDatabase(move.from)) {
|
|
15403
|
+
return "\uD3F4\uB354 \uC548\uC5D0 \uC124\uC815 \uD30C\uC77C\uB85C \uC815\uD55C DB \uD3F4\uB354(notion.databases)\uAC00 \uC788\uB2E4 \u2014 \uC124\uC815\uC758 localFolder \uB97C \uACE0\uCE5C \uB4A4 Obsidian \uC5D0\uC11C \uC62E\uAE30\uBA74 \uB2E4\uC74C push \uAC00 Notion \uC744 \uB9DE\uCD98\uB2E4";
|
|
15404
|
+
}
|
|
15405
|
+
if (isFolder && (this.vaultFs.holdsOtherConnection?.(move.from) ?? false)) {
|
|
15406
|
+
return "\uD3F4\uB354 \uC548\uC5D0 \uB2E4\uB978 \uC5F0\uACB0 \uD3F4\uB354\uAC00 \uC788\uB2E4 \u2014 Obsidian \uC5D0\uC11C \uC62E\uAE30\uBA74 \uB2E4\uC74C push \uAC00 Notion \uC744 \uB9DE\uCD98\uB2E4";
|
|
15407
|
+
}
|
|
15408
|
+
return null;
|
|
15409
|
+
}
|
|
15410
|
+
/** 이 경로에 파일 · 폴더가 있거나 추적하는 것이 있다. */
|
|
15411
|
+
async taken(path) {
|
|
15412
|
+
return await this.vaultFs.exists(path) || this.stateDb.getAll().some((r) => within(r.obsidianPath, path));
|
|
15413
|
+
}
|
|
15414
|
+
/**
|
|
15415
|
+
* 차례로 옮기고 상태 DB 를 새 경로로 적는다. 하나라도 실패하면 옮긴 것을 되돌리고 던진다 — 상태 DB 는 옛 경로 그대로다.
|
|
15416
|
+
* @param alsoInTransaction 경로를 옮겨 적는 트랜잭션 안에서 함께 할 것.
|
|
15417
|
+
*/
|
|
15418
|
+
async relocate(moves, alsoInTransaction) {
|
|
15419
|
+
const done = [];
|
|
15420
|
+
try {
|
|
15421
|
+
for (const move of moves) {
|
|
15422
|
+
await this.vaultFs.moveFile(move.from, move.to);
|
|
15423
|
+
done.push(move);
|
|
15424
|
+
}
|
|
15425
|
+
} catch (error) {
|
|
15426
|
+
for (const move of done.reverse())
|
|
15427
|
+
await this.vaultFs.moveFile(move.to, move.from).catch(() => {
|
|
15428
|
+
});
|
|
15429
|
+
throw error;
|
|
15430
|
+
}
|
|
15431
|
+
this.stateDb.transaction(() => {
|
|
15432
|
+
for (const move of moves) this.rewritePaths(move);
|
|
15433
|
+
rewriteDatabaseFolders(this.stateDb, moves);
|
|
15434
|
+
alsoInTransaction?.();
|
|
15435
|
+
});
|
|
15436
|
+
await retargetBaseFiles(this.vaultFs, moves);
|
|
15437
|
+
}
|
|
15438
|
+
holdsConfiguredDatabase(folder) {
|
|
15439
|
+
return this.configuredDatabases.some(
|
|
15440
|
+
(db) => within(db.localFolder.replace(/\/+$/, ""), folder)
|
|
15441
|
+
);
|
|
15442
|
+
}
|
|
15443
|
+
/** 옮긴 것 아래의 레코드 · 위키링크 · 보존 마커 · 첨부 기록을 새 경로로 옮겨 적는다. */
|
|
15444
|
+
rewritePaths(move) {
|
|
15445
|
+
for (const record of this.stateDb.getAll()) {
|
|
15446
|
+
if (!within(record.obsidianPath, move.from)) continue;
|
|
15447
|
+
const from = record.obsidianPath;
|
|
15448
|
+
const to = move.to + from.slice(move.from.length);
|
|
15449
|
+
this.stateDb.updatePath(record.id, to);
|
|
15450
|
+
const entry = record.notionPageId ? this.stateDb.resolvePageId(record.notionPageId) : null;
|
|
15451
|
+
if (entry && entry.obsidianPath === from) {
|
|
15452
|
+
this.stateDb.deleteWikilink(from);
|
|
15453
|
+
this.stateDb.upsertWikilink({
|
|
15454
|
+
obsidianPath: to,
|
|
15455
|
+
notionPageId: entry.notionPageId,
|
|
15456
|
+
title: wikilinkTitleFromPath(to),
|
|
15457
|
+
aliases: entry.aliases
|
|
15458
|
+
});
|
|
15459
|
+
}
|
|
15460
|
+
const markers = this.stateDb.getPreserveMarkers(from);
|
|
15461
|
+
if (markers.length > 0) {
|
|
15462
|
+
this.stateDb.storePreserveMarkers(to, markers);
|
|
15463
|
+
this.stateDb.storePreserveMarkers(from, []);
|
|
15464
|
+
}
|
|
15465
|
+
if (record.notionPageId) {
|
|
15466
|
+
for (const file of this.stateDb.getFilesByPageId(record.notionPageId)) {
|
|
15467
|
+
if (!within(file.localPath, move.from)) continue;
|
|
15468
|
+
this.stateDb.registerFile({
|
|
15469
|
+
localPath: move.to + file.localPath.slice(move.from.length),
|
|
15470
|
+
notionPageId: file.notionPageId,
|
|
15471
|
+
fileUploadId: file.fileUploadId,
|
|
15472
|
+
fileType: file.fileType,
|
|
15473
|
+
fileHash: file.fileHash,
|
|
15474
|
+
fileSize: file.fileSize
|
|
15475
|
+
});
|
|
15476
|
+
this.stateDb.deleteFileRegistry(file.localPath);
|
|
15477
|
+
}
|
|
15478
|
+
}
|
|
15479
|
+
}
|
|
15480
|
+
}
|
|
15481
|
+
};
|
|
15482
|
+
function within(path, unit) {
|
|
15483
|
+
return path === unit || path.startsWith(`${unit}/`);
|
|
15484
|
+
}
|
|
15485
|
+
function baseName2(path) {
|
|
15486
|
+
return path.slice(path.lastIndexOf("/") + 1);
|
|
15487
|
+
}
|
|
15488
|
+
function joinPath(folder, name) {
|
|
15489
|
+
return folder === "" ? name : `${folder}/${name}`;
|
|
15490
|
+
}
|
|
15491
|
+
|
|
15492
|
+
// src/converter/unknown-tag-fallback.ts
|
|
15493
|
+
var UNKNOWN_BLOCK_TAG_RE2 = /<unknown (url|id)="([^"]*)"[^>]*\/>/g;
|
|
15494
|
+
var UNKNOWN_MENTION_TAG_RE = /<unknown_mention\s+url="([^"]*)"[^>]*?\/>/g;
|
|
15495
|
+
var VAULT_UNKNOWN_PAIR_RE = new RegExp(
|
|
15496
|
+
`(?:\\[((?:\\\\.|[^\\\\\\]])*)\\]\\(([^)]*)\\))?%%${MARKER_BRAND_RE}:unknown:id=([^&]+)&type=([^%]+)%%`,
|
|
15497
|
+
"g"
|
|
15498
|
+
);
|
|
15499
|
+
var CAPTION_ESCAPED_CHAR_RE = /[\\*_<>`~[\]|${}]/g;
|
|
15500
|
+
function blockKey(attr, value) {
|
|
15501
|
+
return (attr === "url" ? bookmarkBlockId(value) : void 0) ?? `${attr}:${value}`;
|
|
15502
|
+
}
|
|
15503
|
+
function mentionKey(url) {
|
|
15504
|
+
return `mention:${url}`;
|
|
15505
|
+
}
|
|
15506
|
+
function unknownTagKeys(nfm) {
|
|
15507
|
+
const keys = /* @__PURE__ */ new Set();
|
|
15508
|
+
mapOutsideCode(nfm, (segment) => {
|
|
15509
|
+
for (const m of segment.matchAll(UNKNOWN_BLOCK_TAG_RE2)) keys.add(blockKey(m[1], m[2]));
|
|
15510
|
+
for (const m of segment.matchAll(UNKNOWN_MENTION_TAG_RE)) keys.add(mentionKey(m[1]));
|
|
15511
|
+
return segment;
|
|
15512
|
+
});
|
|
15513
|
+
return keys;
|
|
15514
|
+
}
|
|
15515
|
+
function unknownTagFallbacks(vaultMarkdown) {
|
|
15516
|
+
const fallbacks = /* @__PURE__ */ new Map();
|
|
15517
|
+
mapOutsideCode(vaultMarkdown, (segment) => {
|
|
15518
|
+
for (const m of segment.matchAll(VAULT_UNKNOWN_PAIR_RE)) {
|
|
15519
|
+
const [, label, url, encodedId, type] = m;
|
|
15520
|
+
const decoded = decodeURIComponent(encodedId);
|
|
15521
|
+
const key = decoded.startsWith("http") ? blockKey("url", decoded) : blockKey("id", encodedId);
|
|
15522
|
+
fallbacks.set(key, fallbackOf(label, url, decoded, type));
|
|
15523
|
+
}
|
|
15524
|
+
return segment;
|
|
15525
|
+
});
|
|
15526
|
+
return fallbacks;
|
|
15527
|
+
}
|
|
15528
|
+
function fallbackOf(label, url, placeholder2, type) {
|
|
15529
|
+
if (label === void 0 || url === void 0) return "";
|
|
15530
|
+
const bookmark = type === "bookmark" ? bookmarkOfVaultLink(label, url, placeholder2) : null;
|
|
15531
|
+
if (bookmark === null) return `[${label}](${url})`;
|
|
15532
|
+
const caption = bookmark.caption.replace(CAPTION_ESCAPED_CHAR_RE, "\\$&");
|
|
15533
|
+
return `<bookmark url="${escapeAttr(bookmark.url)}">${caption}</bookmark>`;
|
|
15534
|
+
}
|
|
15535
|
+
function escapeAttr(value) {
|
|
15536
|
+
return value.replace(/&/g, "&").replace(/"/g, """);
|
|
15537
|
+
}
|
|
15538
|
+
function replaceMissingUnknownTags(nfm, present, fallbacks) {
|
|
15539
|
+
let replaced = 0;
|
|
15540
|
+
const swap = (tag, key) => {
|
|
15541
|
+
if (present.has(key)) return tag;
|
|
15542
|
+
replaced += 1;
|
|
15543
|
+
return fallbacks.get(key) ?? "";
|
|
15544
|
+
};
|
|
15545
|
+
const out = mapOutsideCode(
|
|
15546
|
+
nfm,
|
|
15547
|
+
(segment) => segment.replace(
|
|
15548
|
+
UNKNOWN_BLOCK_TAG_RE2,
|
|
15549
|
+
(tag, attr, value) => swap(tag, blockKey(attr, value))
|
|
15550
|
+
).replace(UNKNOWN_MENTION_TAG_RE, (tag, url) => swap(tag, mentionKey(url)))
|
|
15551
|
+
);
|
|
15552
|
+
return { nfm: out, replaced };
|
|
15553
|
+
}
|
|
15554
|
+
|
|
15555
|
+
// src/sync/page-body.ts
|
|
15556
|
+
async function replacePageBody(client, pageId, markdown, options = {}) {
|
|
15557
|
+
try {
|
|
15558
|
+
return await client.replacePageMarkdown(pageId, markdown);
|
|
15559
|
+
} catch (error) {
|
|
15560
|
+
if (!isNotionValidationError(error)) throw error;
|
|
15561
|
+
const limit = pageRecordLimitOf(error);
|
|
15562
|
+
if (limit !== null) {
|
|
15563
|
+
throw new Error(
|
|
15564
|
+
`Notion \uD398\uC774\uC9C0\uAC00 \uB108\uBB34 \uCEE4\uC11C \uBCF8\uBB38\uC744 \uBC14\uAFB8\uC9C0 \uBABB\uD568(\uB808\uCF54\uB4DC ${Number.isNaN(limit) ? "\uD55C\uB3C4" : `${limit}\uAC1C`} \uB118\uC74C) \u2014 Notion \uC5D0\uC11C \uD398\uC774\uC9C0\uB97C \uB098\uB204\uC138\uC694`
|
|
15565
|
+
);
|
|
15566
|
+
}
|
|
15567
|
+
const remote = (await client.getPageMarkdown(pageId)).markdown;
|
|
15568
|
+
const sendable = options.unknownTagFallbacks ? replaceMissingUnknownTags(markdown, unknownTagKeys(remote), options.unknownTagFallbacks) : { nfm: markdown, replaced: 0 };
|
|
15072
15569
|
if (sendable.replaced > 0) {
|
|
15073
15570
|
getLogger().warn(
|
|
15074
15571
|
`[Im-Nobsidian] Notion \uD398\uC774\uC9C0\uC5D0 \uC5C6\uB294 \uBE14\uB85D\uC744 \uAC00\uB9AC\uD0A4\uB294 \uC790\uB9AC ${sendable.replaced}\uAC1C\uB97C \uBCFC\uD2B8\uC758 \uB9C1\uD06C \xB7 \uBD81\uB9C8\uD06C\uB85C \uBCF4\uB0C4 (${pageId})`
|
|
@@ -15223,16 +15720,16 @@ var PageDecorations = class {
|
|
|
15223
15720
|
*
|
|
15224
15721
|
* @param baseName 내려받은 파일 이름의 앞부분 — 보통 노트 이름
|
|
15225
15722
|
*/
|
|
15226
|
-
async pulled(page,
|
|
15723
|
+
async pulled(page, baseName4, download) {
|
|
15227
15724
|
const out = {};
|
|
15228
15725
|
const cover = this.notionClient.extractCover(page);
|
|
15229
15726
|
if (cover) {
|
|
15230
|
-
const local = download ? await this.imageHandler.localizeNotionFileUrl(cover.url, `${
|
|
15727
|
+
const local = download ? await this.imageHandler.localizeNotionFileUrl(cover.url, `${baseName4}-cover`) : null;
|
|
15231
15728
|
out.cover = local ? `[[${local}]]` : cover.url;
|
|
15232
15729
|
}
|
|
15233
15730
|
const icon = this.notionClient.extractIcon(page);
|
|
15234
15731
|
if (icon) {
|
|
15235
|
-
const local = download && icon.type === "file" ? await this.imageHandler.localizeNotionFileUrl(icon.value, `${
|
|
15732
|
+
const local = download && icon.type === "file" ? await this.imageHandler.localizeNotionFileUrl(icon.value, `${baseName4}-icon`) : null;
|
|
15236
15733
|
out.icon = local ? `[[${local}]]` : icon.value;
|
|
15237
15734
|
}
|
|
15238
15735
|
return out;
|
|
@@ -15399,6 +15896,7 @@ var DatabaseSyncer = class {
|
|
|
15399
15896
|
this.observation = observation;
|
|
15400
15897
|
this.noteRowBody = noteRowBody;
|
|
15401
15898
|
this.baseFileInfo = new DbBaseFiles(stateDb);
|
|
15899
|
+
this.relocator = new NoteRelocator(stateDb, vaultFs, config.notion.databases ?? []);
|
|
15402
15900
|
this.propertyMapper = PropertyMapper.fromConfig(config);
|
|
15403
15901
|
this.propertyMapper.setWikilinkResolver({
|
|
15404
15902
|
resolve: (title) => stateDb.resolveWikilink(title)?.notionPageId ?? null,
|
|
@@ -15426,9 +15924,17 @@ var DatabaseSyncer = class {
|
|
|
15426
15924
|
*/
|
|
15427
15925
|
appearances = /* @__PURE__ */ new WeakMap();
|
|
15428
15926
|
baseFileGenerator = new BaseFileGenerator();
|
|
15429
|
-
/** 사용자가
|
|
15927
|
+
/** 사용자가 만들거나 고친 `.base` 라 덮어쓰지 않았다고 이미 알린 경로 — 한 pull 이 같은 DB 를 여러 번 다시 쓴다. */
|
|
15430
15928
|
keptUserBases = /* @__PURE__ */ new Set();
|
|
15431
15929
|
sidecarGenerator = new SidecarGenerator();
|
|
15930
|
+
/** Notion 에서 바꾼 제목으로 이름을 바꾼 행과 실제로 옮긴 차례 — pull 마감이 가져가 다른 노트의 링크를 고친다. */
|
|
15931
|
+
relocator;
|
|
15932
|
+
renamedRows = {
|
|
15933
|
+
renamed: [],
|
|
15934
|
+
applied: []
|
|
15935
|
+
};
|
|
15936
|
+
/** 볼트에서 사라져 되살린 행의 경로 — pull 마감이 가져가 화면에 알린다(D8). */
|
|
15937
|
+
restoredRows = [];
|
|
15432
15938
|
/**
|
|
15433
15939
|
* 실제로 기록한 .base 경로/DB 제목 — placeholder 임베드 재작성(F22)의 SSOT. 이번 pull 이 조회하지
|
|
15434
15940
|
* 않은 DB 의 것도 남아 있다({@link DbBaseFiles}).
|
|
@@ -15546,8 +16052,10 @@ var DatabaseSyncer = class {
|
|
|
15546
16052
|
created++;
|
|
15547
16053
|
if (outcome.action === "written") writtenPaths.push(outcome.path);
|
|
15548
16054
|
} else if (outcome.action === "written") {
|
|
15549
|
-
if (action === "restore")
|
|
15550
|
-
|
|
16055
|
+
if (action === "restore") {
|
|
16056
|
+
restored++;
|
|
16057
|
+
this.restoredRows.push(outcome.path);
|
|
16058
|
+
} else updated++;
|
|
15551
16059
|
writtenPaths.push(outcome.path);
|
|
15552
16060
|
} else if (outcome.action === "conflict") {
|
|
15553
16061
|
conflicts.push(outcome.conflict);
|
|
@@ -15896,17 +16404,23 @@ var DatabaseSyncer = class {
|
|
|
15896
16404
|
});
|
|
15897
16405
|
const safeName = sanitizeFileName(dbName);
|
|
15898
16406
|
const basePath = `${dbConfig.localFolder}/${safeName}${BASE_SUFFIX}`;
|
|
15899
|
-
|
|
16407
|
+
const previous = this.baseFileInfo.get(dbConfig.databaseId);
|
|
16408
|
+
const existing = await this.vaultFs.readFile(basePath).catch(() => null);
|
|
16409
|
+
let hash = computeHash(baseContent);
|
|
16410
|
+
if (existing !== null && !isGeneratedDbArtifact(basePath, existing)) {
|
|
15900
16411
|
if (!this.keptUserBases.has(basePath)) {
|
|
15901
16412
|
this.keptUserBases.add(basePath);
|
|
15902
16413
|
getLogger().info(
|
|
15903
16414
|
`[DB Sync] \uC9C1\uC811 \uB9CC\uB4E0 .base \uB77C Notion \uBCF4\uAE30\uB85C \uB36E\uC5B4\uC4F0\uC9C0 \uC54A\uC74C: ${basePath} \u2014 Notion \uBCF4\uAE30\uB97C \uBC1B\uC73C\uB824\uBA74 \uC774 \uD30C\uC77C\uC758 \uC774\uB984\uC744 \uBC14\uAFB8\uC138\uC694`
|
|
15904
16415
|
);
|
|
15905
16416
|
}
|
|
15906
|
-
} else {
|
|
16417
|
+
} else if (existing !== null && existing !== baseContent && editedSince(previous, basePath, existing)) {
|
|
16418
|
+
hash = previous.hash;
|
|
16419
|
+
this.warnEditedBase(basePath);
|
|
16420
|
+
} else if (existing !== baseContent) {
|
|
15907
16421
|
await this.vaultFs.writeFile(basePath, baseContent);
|
|
15908
16422
|
}
|
|
15909
|
-
this.baseFileInfo.set(dbConfig.databaseId, { basePath, title: dbName });
|
|
16423
|
+
this.baseFileInfo.set(dbConfig.databaseId, { basePath, title: dbName, hash });
|
|
15910
16424
|
getLogger().debug(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131: ${basePath}`);
|
|
15911
16425
|
await this.generateSidecar(
|
|
15912
16426
|
dbConfig,
|
|
@@ -15917,7 +16431,7 @@ var DatabaseSyncer = class {
|
|
|
15917
16431
|
meta.isInline,
|
|
15918
16432
|
await this.appearanceOf(meta, safeName)
|
|
15919
16433
|
);
|
|
15920
|
-
await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName);
|
|
16434
|
+
await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName, previous);
|
|
15921
16435
|
} catch (error) {
|
|
15922
16436
|
getLogger().warn(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
|
|
15923
16437
|
}
|
|
@@ -15927,12 +16441,12 @@ var DatabaseSyncer = class {
|
|
|
15927
16441
|
* 같은 꼴로 적고 올린 파일은 볼트로 내려받는다 — 서명 주소를 적으면 pull 마다 사이드카가 바뀐다.
|
|
15928
16442
|
*/
|
|
15929
16443
|
appearanceOf(meta, safeName) {
|
|
15930
|
-
let
|
|
15931
|
-
if (!
|
|
15932
|
-
|
|
15933
|
-
this.appearances.set(meta,
|
|
16444
|
+
let found2 = this.appearances.get(meta);
|
|
16445
|
+
if (!found2) {
|
|
16446
|
+
found2 = this.readAppearance(meta, safeName);
|
|
16447
|
+
this.appearances.set(meta, found2);
|
|
15934
16448
|
}
|
|
15935
|
-
return
|
|
16449
|
+
return found2;
|
|
15936
16450
|
}
|
|
15937
16451
|
async readAppearance(meta, safeName) {
|
|
15938
16452
|
const a = meta.appearance;
|
|
@@ -15949,17 +16463,20 @@ var DatabaseSyncer = class {
|
|
|
15949
16463
|
...a.showTitle ? {} : { showTitle: false }
|
|
15950
16464
|
};
|
|
15951
16465
|
}
|
|
15952
|
-
/**
|
|
15953
|
-
|
|
15954
|
-
|
|
15955
|
-
|
|
16466
|
+
/** 볼트에서 고친 `.base` 를 덮어쓰거나 지우지 않았다고 한 번 알린다. */
|
|
16467
|
+
warnEditedBase(path) {
|
|
16468
|
+
if (this.keptUserBases.has(path)) return;
|
|
16469
|
+
this.keptUserBases.add(path);
|
|
16470
|
+
getLogger().warn(
|
|
16471
|
+
`[DB Sync] \uBCFC\uD2B8\uC5D0\uC11C \uACE0\uCE5C .base \uB77C Notion \uBCF4\uAE30\uB85C \uB36E\uC5B4\uC4F0\uC9C0 \uC54A\uC74C: ${path} \u2014 Notion \uC758 \uBCF4\uAE30\uB97C \uB2E4\uC2DC \uBC1B\uC73C\uB824\uBA74 \uC774 \uD30C\uC77C\uC744 \uC9C0\uC6B0\uC138\uC694`
|
|
16472
|
+
);
|
|
15956
16473
|
}
|
|
15957
16474
|
/**
|
|
15958
16475
|
* DB 제목이 바뀌면 `.base`/`.notion.json` 파일명(safeName)이 바뀌어 옛 이름의 산출물이
|
|
15959
16476
|
* 같은 폴더에 고아로 잔존한다(drift·부활 원인). DB 폴더 **직속**의 산출물 중 현재 이름이
|
|
15960
16477
|
* 아닌 것을 삭제해 멱등을 보장한다. 하위 폴더(중첩 DB)의 산출물은 보호한다.
|
|
15961
16478
|
*/
|
|
15962
|
-
async cleanupStaleDbArtifacts(localFolder, safeName) {
|
|
16479
|
+
async cleanupStaleDbArtifacts(localFolder, safeName, previous) {
|
|
15963
16480
|
try {
|
|
15964
16481
|
const keep = /* @__PURE__ */ new Set([
|
|
15965
16482
|
`${localFolder}/${safeName}${BASE_SUFFIX}`,
|
|
@@ -15968,7 +16485,12 @@ var DatabaseSyncer = class {
|
|
|
15968
16485
|
const files = await this.vaultFs.listNonMarkdownFiles();
|
|
15969
16486
|
const stale = selectStaleDbArtifacts(files, localFolder, keep);
|
|
15970
16487
|
for (const path of stale) {
|
|
15971
|
-
|
|
16488
|
+
const content = await this.vaultFs.readFile(path).catch(() => null);
|
|
16489
|
+
if (content === null || !isGeneratedDbArtifact(path, content)) continue;
|
|
16490
|
+
if (editedSince(previous, path, content)) {
|
|
16491
|
+
this.warnEditedBase(path);
|
|
16492
|
+
continue;
|
|
16493
|
+
}
|
|
15972
16494
|
await this.vaultFs.deleteFile(path);
|
|
15973
16495
|
getLogger().info(`[DB Sync] \uACE0\uC544 DB \uC0B0\uCD9C\uBB3C \uC0AD\uC81C(rename): ${path}`);
|
|
15974
16496
|
}
|
|
@@ -16146,12 +16668,30 @@ var DatabaseSyncer = class {
|
|
|
16146
16668
|
}
|
|
16147
16669
|
return (path) => this.stateDb.getByPath(path) ?? (untracked.has(path) ? { notionPageId: null } : null);
|
|
16148
16670
|
}
|
|
16671
|
+
/** 되살린 행의 경로를 가져가고 비운다. */
|
|
16672
|
+
takeRestoredRows() {
|
|
16673
|
+
const rows = this.restoredRows;
|
|
16674
|
+
this.restoredRows = [];
|
|
16675
|
+
return rows;
|
|
16676
|
+
}
|
|
16677
|
+
/** 이름을 바꾼 행을 가져가고 비운다. */
|
|
16678
|
+
takeRenamedRows() {
|
|
16679
|
+
const rows = this.renamedRows;
|
|
16680
|
+
this.renamedRows = { renamed: [], applied: [] };
|
|
16681
|
+
return rows;
|
|
16682
|
+
}
|
|
16149
16683
|
async pullDatabasePage(page, dbConfig) {
|
|
16150
16684
|
const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
|
|
16151
16685
|
const existingRecord = this.stateDb.getByNotionId(page.id);
|
|
16152
16686
|
const recordPath = existingRecord?.obsidianPath;
|
|
16153
16687
|
let filePath;
|
|
16154
|
-
|
|
16688
|
+
const retitled = !!existingRecord && !!recordPath && this.config.sync.renameOnTitleChange && isDirectDbRowPath(dbConfig.localFolder, recordPath) && retitledInNotion(
|
|
16689
|
+
snapshotFrontmatter(existingRecord.baseSnapshot),
|
|
16690
|
+
null,
|
|
16691
|
+
recordPath,
|
|
16692
|
+
this.notionClient.extractTitle(page)
|
|
16693
|
+
) && !this.stateDb.getIncompleteOpByState(existingRecord.id, "move");
|
|
16694
|
+
if (recordPath && isDirectDbRowPath(dbConfig.localFolder, recordPath) && !retitled) {
|
|
16155
16695
|
filePath = recordPath;
|
|
16156
16696
|
} else {
|
|
16157
16697
|
filePath = resolveDbRowPath(
|
|
@@ -16161,6 +16701,22 @@ var DatabaseSyncer = class {
|
|
|
16161
16701
|
await this.rowPathOwners(dbConfig.localFolder, safeName, page.id)
|
|
16162
16702
|
);
|
|
16163
16703
|
}
|
|
16704
|
+
let childFolderMove = null;
|
|
16705
|
+
if (retitled) {
|
|
16706
|
+
const from = stripMarkdownExtension(recordPath);
|
|
16707
|
+
if (this.stateDb.getAll().some((r) => within(r.obsidianPath, from))) {
|
|
16708
|
+
childFolderMove = { from, to: stripMarkdownExtension(filePath) };
|
|
16709
|
+
const blocked = await this.relocator.blocked(childFolderMove);
|
|
16710
|
+
if (blocked) {
|
|
16711
|
+
getLogger().warn(
|
|
16712
|
+
`[Im-Nobsidian] Notion \uC5D0\uC11C \uBC14\uAFBC \uC81C\uBAA9\uC73C\uB85C \uD589 \uC774\uB984\uC744 \uBC14\uAFB8\uC9C0 \uC54A\uC74C (${recordPath}): \uD558\uC704 \uD398\uC774\uC9C0 \uD3F4\uB354 \u2014 ${blocked}`
|
|
16713
|
+
);
|
|
16714
|
+
filePath = recordPath;
|
|
16715
|
+
childFolderMove = null;
|
|
16716
|
+
}
|
|
16717
|
+
}
|
|
16718
|
+
}
|
|
16719
|
+
const renaming = retitled && filePath !== recordPath;
|
|
16164
16720
|
const {
|
|
16165
16721
|
content: finalContent,
|
|
16166
16722
|
title,
|
|
@@ -16240,6 +16796,11 @@ var DatabaseSyncer = class {
|
|
|
16240
16796
|
if (rehomed) {
|
|
16241
16797
|
this.stateDb.updatePath(existingRecord.id, filePath);
|
|
16242
16798
|
this.stateDb.deleteWikilink(recordPath);
|
|
16799
|
+
const markers = this.stateDb.getPreserveMarkers(recordPath);
|
|
16800
|
+
if (markers.length > 0) {
|
|
16801
|
+
this.stateDb.storePreserveMarkers(filePath, markers);
|
|
16802
|
+
this.stateDb.storePreserveMarkers(recordPath, []);
|
|
16803
|
+
}
|
|
16243
16804
|
}
|
|
16244
16805
|
this.stateDb.upsert({
|
|
16245
16806
|
obsidianPath: filePath,
|
|
@@ -16267,6 +16828,17 @@ var DatabaseSyncer = class {
|
|
|
16267
16828
|
await this.vaultFs.deleteFile(recordPath);
|
|
16268
16829
|
} catch {
|
|
16269
16830
|
}
|
|
16831
|
+
if (renaming) {
|
|
16832
|
+
if (childFolderMove) await this.relocator.relocate([childFolderMove]);
|
|
16833
|
+
this.renamedRows.renamed.push({ from: recordPath, to: filePath, renamed: true });
|
|
16834
|
+
this.renamedRows.applied.push(
|
|
16835
|
+
{ from: recordPath, to: filePath },
|
|
16836
|
+
...childFolderMove ? [childFolderMove] : []
|
|
16837
|
+
);
|
|
16838
|
+
getLogger().info(
|
|
16839
|
+
`[Im-Nobsidian] Notion \uC5D0\uC11C \uBC14\uAFBC \uC81C\uBAA9\uC73C\uB85C \uD589 \uC774\uB984\uC744 \uBC14\uAFC8: ${recordPath} \u2192 ${filePath}`
|
|
16840
|
+
);
|
|
16841
|
+
}
|
|
16270
16842
|
}
|
|
16271
16843
|
return { action: "written", path: filePath };
|
|
16272
16844
|
}
|
|
@@ -16274,6 +16846,9 @@ var DatabaseSyncer = class {
|
|
|
16274
16846
|
function rowProperties(page) {
|
|
16275
16847
|
return page.properties ?? {};
|
|
16276
16848
|
}
|
|
16849
|
+
function editedSince(previous, path, content) {
|
|
16850
|
+
return previous?.basePath === path && previous.hash !== void 0 && computeHash(content) !== previous.hash;
|
|
16851
|
+
}
|
|
16277
16852
|
|
|
16278
16853
|
// src/audit/completeness.ts
|
|
16279
16854
|
function groupLocalRowsByDatabase(local) {
|
|
@@ -16370,6 +16945,40 @@ async function verifyPageCompleteness(remote, local, rootPageId) {
|
|
|
16370
16945
|
};
|
|
16371
16946
|
}
|
|
16372
16947
|
|
|
16948
|
+
// src/audit/nested-folder-pages.ts
|
|
16949
|
+
var ID_SUFFIX = new RegExp(
|
|
16950
|
+
` \\((?:${PAGE_ID_SUFFIX_LENGTHS.map((len) => `[0-9a-f]{${len}}`).join("|")})\\)$`
|
|
16951
|
+
);
|
|
16952
|
+
function folderOf3(record) {
|
|
16953
|
+
if (record.fileType === "db-row") return null;
|
|
16954
|
+
if (isFolderRecord(record)) return record.obsidianPath;
|
|
16955
|
+
const slash = record.obsidianPath.lastIndexOf("/");
|
|
16956
|
+
if (slash < 0) return null;
|
|
16957
|
+
const folder = record.obsidianPath.slice(0, slash);
|
|
16958
|
+
const stem = stripMarkdownExtension(record.obsidianPath.slice(slash + 1)).replace(ID_SUFFIX, "");
|
|
16959
|
+
return stem === folder.slice(folder.lastIndexOf("/") + 1) ? folder : null;
|
|
16960
|
+
}
|
|
16961
|
+
function findNestedFolderPages(records) {
|
|
16962
|
+
const byPage = /* @__PURE__ */ new Map();
|
|
16963
|
+
for (const record of records) {
|
|
16964
|
+
if (record.notionPageId !== null) byPage.set(compactNotionId(record.notionPageId), record);
|
|
16965
|
+
}
|
|
16966
|
+
const found2 = [];
|
|
16967
|
+
for (const inner of records) {
|
|
16968
|
+
if (inner.notionPageId === null || inner.notionParentId === null) continue;
|
|
16969
|
+
const folder = folderOf3(inner);
|
|
16970
|
+
if (folder === null) continue;
|
|
16971
|
+
const outer = byPage.get(compactNotionId(inner.notionParentId));
|
|
16972
|
+
if (!outer || outer === inner || folderOf3(outer) !== folder) continue;
|
|
16973
|
+
found2.push({
|
|
16974
|
+
folder,
|
|
16975
|
+
outer: { path: outer.obsidianPath, notionPageId: outer.notionPageId },
|
|
16976
|
+
inner: { path: inner.obsidianPath, notionPageId: inner.notionPageId }
|
|
16977
|
+
});
|
|
16978
|
+
}
|
|
16979
|
+
return found2.sort((a, b) => a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0);
|
|
16980
|
+
}
|
|
16981
|
+
|
|
16373
16982
|
// src/conflict/merger.ts
|
|
16374
16983
|
function threeWayMerge(base, local, remote) {
|
|
16375
16984
|
const baseLines = base.split("\n");
|
|
@@ -16784,6 +17393,9 @@ var ScopedVaultFS = class {
|
|
|
16784
17393
|
async ensureFolder(path) {
|
|
16785
17394
|
return await this.base.ensureFolder(this.toBase(path, true));
|
|
16786
17395
|
}
|
|
17396
|
+
async removeEmptyFolder(path) {
|
|
17397
|
+
return await this.base.removeEmptyFolder?.(this.toBase(path, true)) ?? false;
|
|
17398
|
+
}
|
|
16787
17399
|
/** 맡은 노트만 읽는다 — 볼트 전체의 본문을 읽지 않는다. */
|
|
16788
17400
|
async listMarkdownFiles() {
|
|
16789
17401
|
const stats = await this.listMarkdownFileStats();
|
|
@@ -16861,7 +17473,7 @@ function nextFullReconcileAt(lastFullPullAt, intervalSec) {
|
|
|
16861
17473
|
const last = lastFullPullAt === null ? NaN : Date.parse(lastFullPullAt);
|
|
16862
17474
|
return Number.isFinite(last) ? new Date(last + intervalSec * 1e3).toISOString() : null;
|
|
16863
17475
|
}
|
|
16864
|
-
function remoteScanInfo(scan, lastFullPullAt, deleteSync) {
|
|
17476
|
+
function remoteScanInfo(scan, lastFullPullAt, deleteSync, deletionsChecked = false) {
|
|
16865
17477
|
return scan.kind === "full" ? {
|
|
16866
17478
|
kind: "full",
|
|
16867
17479
|
reason: scan.reason,
|
|
@@ -16872,7 +17484,7 @@ function remoteScanInfo(scan, lastFullPullAt, deleteSync) {
|
|
|
16872
17484
|
kind: "incremental",
|
|
16873
17485
|
lastFullAt: scan.lastFullAt,
|
|
16874
17486
|
nextFullAt: scan.nextFullAt,
|
|
16875
|
-
deletionsDeferred: deleteSync
|
|
17487
|
+
deletionsDeferred: deleteSync && !deletionsChecked
|
|
16876
17488
|
};
|
|
16877
17489
|
}
|
|
16878
17490
|
var ALL_DATABASES = { kind: "all" };
|
|
@@ -16894,6 +17506,14 @@ function parsePendingDatabases(raw) {
|
|
|
16894
17506
|
function serializePendingDatabases(ids) {
|
|
16895
17507
|
return JSON.stringify([...ids].sort());
|
|
16896
17508
|
}
|
|
17509
|
+
function addPendingDatabases(stateDb, databaseIds) {
|
|
17510
|
+
const pending = parsePendingDatabases(stateDb.getMeta(PENDING_DATABASES_META_KEY));
|
|
17511
|
+
const before = pending.size;
|
|
17512
|
+
for (const id of databaseIds) pending.add(compactNotionId(id));
|
|
17513
|
+
if (pending.size !== before) {
|
|
17514
|
+
stateDb.setMeta(PENDING_DATABASES_META_KEY, serializePendingDatabases(pending));
|
|
17515
|
+
}
|
|
17516
|
+
}
|
|
16897
17517
|
var DatabasePullLedger = class {
|
|
16898
17518
|
constructor(selection, pending) {
|
|
16899
17519
|
this.selection = selection;
|
|
@@ -17255,8 +17875,7 @@ var InterruptedSyncRecovery = class {
|
|
|
17255
17875
|
if (isNotionObjectNotFound(error)) continue;
|
|
17256
17876
|
throw error;
|
|
17257
17877
|
}
|
|
17258
|
-
|
|
17259
|
-
if (inTrash || page.archived) continue;
|
|
17878
|
+
if (isTrashedOrArchived(page)) continue;
|
|
17260
17879
|
return page;
|
|
17261
17880
|
}
|
|
17262
17881
|
return null;
|
|
@@ -17275,8 +17894,7 @@ var InterruptedSyncRecovery = class {
|
|
|
17275
17894
|
});
|
|
17276
17895
|
for (const row of rows) {
|
|
17277
17896
|
if (this.stateDb.getByNotionId(row.id)) continue;
|
|
17278
|
-
|
|
17279
|
-
if (inTrash || row.archived) continue;
|
|
17897
|
+
if (isTrashedOrArchived(row)) continue;
|
|
17280
17898
|
return row;
|
|
17281
17899
|
}
|
|
17282
17900
|
return null;
|
|
@@ -17391,41 +18009,6 @@ function dropEmbeddedBaseMarkers(content, basePaths) {
|
|
|
17391
18009
|
return kept.length === lines.length ? content : kept.join("\n");
|
|
17392
18010
|
}
|
|
17393
18011
|
|
|
17394
|
-
// src/sync/discovered-databases.ts
|
|
17395
|
-
var ROOT_DATABASES_FOLDER = "databases";
|
|
17396
|
-
var DISCOVERED_DBS_META_KEY = "discovered_dbs";
|
|
17397
|
-
var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
|
|
17398
|
-
var LINKED_DBS_META_KEY = "linked_dbs";
|
|
17399
|
-
function parseDiscoveredDbs(raw) {
|
|
17400
|
-
if (!raw) return [];
|
|
17401
|
-
try {
|
|
17402
|
-
const parsed = JSON.parse(raw);
|
|
17403
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
17404
|
-
} catch {
|
|
17405
|
-
return [];
|
|
17406
|
-
}
|
|
17407
|
-
}
|
|
17408
|
-
function loadInaccessibleDbIds(stateDb) {
|
|
17409
|
-
const raw = stateDb.getMeta(INACCESSIBLE_DBS_META_KEY);
|
|
17410
|
-
if (!raw) return /* @__PURE__ */ new Set();
|
|
17411
|
-
try {
|
|
17412
|
-
const arr = JSON.parse(raw);
|
|
17413
|
-
return new Set(arr.map((id) => id.replace(/-/g, "")));
|
|
17414
|
-
} catch {
|
|
17415
|
-
return /* @__PURE__ */ new Set();
|
|
17416
|
-
}
|
|
17417
|
-
}
|
|
17418
|
-
function loadLinkedDbMap(stateDb) {
|
|
17419
|
-
const raw = stateDb.getMeta(LINKED_DBS_META_KEY);
|
|
17420
|
-
if (!raw) return /* @__PURE__ */ new Map();
|
|
17421
|
-
try {
|
|
17422
|
-
const obj = JSON.parse(raw);
|
|
17423
|
-
return new Map(Object.entries(obj).map(([k, v]) => [k.replace(/-/g, ""), v.replace(/-/g, "")]));
|
|
17424
|
-
} catch {
|
|
17425
|
-
return /* @__PURE__ */ new Map();
|
|
17426
|
-
}
|
|
17427
|
-
}
|
|
17428
|
-
|
|
17429
18012
|
// src/sync/database-discovery.ts
|
|
17430
18013
|
var DISCOVERY_RETRY_META_KEY = "discovery_retry";
|
|
17431
18014
|
var DISCOVERY_SCANNED_META_KEY = "discovery_scanned_at";
|
|
@@ -17613,8 +18196,8 @@ var DatabaseDiscovery = class {
|
|
|
17613
18196
|
...this.stateDb.getAll().filter((r) => r.notionPageId).map((r) => r.notionPageId)
|
|
17614
18197
|
].filter((id) => !this._bodyReadPages.has(normalizeNotionId(id))) : retry.parents;
|
|
17615
18198
|
if (scanIds.length > 0) {
|
|
17616
|
-
const { found, unread } = await this.discoverChildDatabases(scanIds);
|
|
17617
|
-
for (const { dbId, parentPageId } of
|
|
18199
|
+
const { found: found2, unread } = await this.discoverChildDatabases(scanIds);
|
|
18200
|
+
for (const { dbId, parentPageId } of found2) {
|
|
17618
18201
|
await register(dbId, parentPageId);
|
|
17619
18202
|
}
|
|
17620
18203
|
for (const { pageId, error } of unread) unreadParent(pageId, error);
|
|
@@ -17741,7 +18324,7 @@ var DatabaseDiscovery = class {
|
|
|
17741
18324
|
return { created, updated, deleted, restored };
|
|
17742
18325
|
}
|
|
17743
18326
|
async discoverChildDatabases(pageIds) {
|
|
17744
|
-
const
|
|
18327
|
+
const found2 = [];
|
|
17745
18328
|
const unread = [];
|
|
17746
18329
|
const seen = /* @__PURE__ */ new Set();
|
|
17747
18330
|
for (const parentId of pageIds) {
|
|
@@ -17750,7 +18333,7 @@ var DatabaseDiscovery = class {
|
|
|
17750
18333
|
try {
|
|
17751
18334
|
const dbIds = await this.notionClient.getChildDatabaseIds(parentId);
|
|
17752
18335
|
for (const dbId of dbIds) {
|
|
17753
|
-
|
|
18336
|
+
found2.push({ dbId, parentPageId: parentId });
|
|
17754
18337
|
}
|
|
17755
18338
|
} catch (error) {
|
|
17756
18339
|
if (isNotionObjectNotFound(error) || isNotionAccessDenied(error)) continue;
|
|
@@ -17760,7 +18343,7 @@ var DatabaseDiscovery = class {
|
|
|
17760
18343
|
});
|
|
17761
18344
|
}
|
|
17762
18345
|
}
|
|
17763
|
-
return { found, unread };
|
|
18346
|
+
return { found: found2, unread };
|
|
17764
18347
|
}
|
|
17765
18348
|
/**
|
|
17766
18349
|
* 자동 발견된 child_database 1개를 DatabaseSyncer 설정으로 변환한다.
|
|
@@ -17810,7 +18393,7 @@ var DatabaseDiscovery = class {
|
|
|
17810
18393
|
}
|
|
17811
18394
|
let safeName;
|
|
17812
18395
|
if (dbTitle) {
|
|
17813
|
-
safeName = dbTitle
|
|
18396
|
+
safeName = databaseFolderName(dbTitle);
|
|
17814
18397
|
} else {
|
|
17815
18398
|
const parentName = parentFolder.split("/").pop() || "";
|
|
17816
18399
|
safeName = parentName ? `${parentName}-DB` : `db-${dbId.replace(/-/g, "").slice(0, 8)}`;
|
|
@@ -17903,6 +18486,12 @@ var DatabaseDiscovery = class {
|
|
|
17903
18486
|
return refs;
|
|
17904
18487
|
}
|
|
17905
18488
|
};
|
|
18489
|
+
function databaseFolderName(title) {
|
|
18490
|
+
return truncateBytes(
|
|
18491
|
+
title.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-"),
|
|
18492
|
+
FILE_SYSTEM_NAME_MAX_BYTES
|
|
18493
|
+
);
|
|
18494
|
+
}
|
|
17906
18495
|
|
|
17907
18496
|
// src/sync/folder-placement.ts
|
|
17908
18497
|
var FolderPlacement = class {
|
|
@@ -18134,13 +18723,13 @@ var FolderPlacement = class {
|
|
|
18134
18723
|
if (parent?.kind !== "page") throw new Error(this.folderNotReady(parentPath, "\uB9CC\uB4E4\uC9C0"));
|
|
18135
18724
|
parentId = parent.pageId;
|
|
18136
18725
|
}
|
|
18137
|
-
const
|
|
18138
|
-
const folderPage =
|
|
18726
|
+
const found2 = await this.recovery.findChildPageByTitle(parentId, folderName);
|
|
18727
|
+
const folderPage = found2 ?? await this.notionClient.createPage({
|
|
18139
18728
|
parentId,
|
|
18140
18729
|
parentType: "page",
|
|
18141
18730
|
title: folderName
|
|
18142
18731
|
});
|
|
18143
|
-
const bodyFingerprint =
|
|
18732
|
+
const bodyFingerprint = found2 ? await this.drift.remoteBodyFingerprintOf(found2.id) : remoteBodyFingerprint("");
|
|
18144
18733
|
this.stateDb.upsert({
|
|
18145
18734
|
obsidianPath: folderPath,
|
|
18146
18735
|
notionPageId: folderPage.id,
|
|
@@ -18414,7 +19003,7 @@ var LocalPlanner = class {
|
|
|
18414
19003
|
* 쓴다. Notion 에 반영할 것은 이동 WAL 이 «마지막으로 반영한 경로» 로 적고, 반영을 마치면
|
|
18415
19004
|
* 지운다(pushMove). 반영하기 전에 그 자리로 되돌아오면 반영할 것이 없어 바로 지운다.
|
|
18416
19005
|
*/
|
|
18417
|
-
adoptLocalMoves(plan) {
|
|
19006
|
+
async adoptLocalMoves(plan) {
|
|
18418
19007
|
const { adoptions } = plan.scan;
|
|
18419
19008
|
const moved = adoptions.length + plan.folderMoves.length + plan.databaseFolderMoves.length;
|
|
18420
19009
|
if (moved === 0 && plan.settledMoveOps.length === 0 && isEmptyRenameHints(plan.hints)) return;
|
|
@@ -18447,9 +19036,7 @@ var LocalPlanner = class {
|
|
|
18447
19036
|
const record = this.stateDb.getByPath(move.from);
|
|
18448
19037
|
if (record) this.moveRecord(record, move.to);
|
|
18449
19038
|
}
|
|
18450
|
-
|
|
18451
|
-
this.remapDiscoveredDbFolders(plan.databaseFolderMoves);
|
|
18452
|
-
}
|
|
19039
|
+
rewriteDatabaseFolders(this.stateDb, [...plan.folderMoves, ...plan.databaseFolderMoves]);
|
|
18453
19040
|
if (!isEmptyRenameHints(plan.hints)) {
|
|
18454
19041
|
this.writeRenameHints(pruneRenameHints(this.renameHints(), plan.hints));
|
|
18455
19042
|
}
|
|
@@ -18459,6 +19046,7 @@ var LocalPlanner = class {
|
|
|
18459
19046
|
`[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8 ${adoptions.length}\uAC74 \xB7 \uD3F4\uB354 ${plan.folderMoves.length + plan.databaseFolderMoves.length}\uAC74\uC744 \uC0C1\uD0DC\uC5D0 \uC62E\uACA8 \uC801\uC74C`
|
|
18460
19047
|
);
|
|
18461
19048
|
}
|
|
19049
|
+
await retargetBaseFiles(this.vaultFs, [...plan.folderMoves, ...plan.databaseFolderMoves]);
|
|
18462
19050
|
for (const move of plan.databaseFolderMoves) {
|
|
18463
19051
|
getLogger().info(
|
|
18464
19052
|
`[Im-Nobsidian] DB \uD3F4\uB354\uB97C \uC62E\uAE40: ${move.from} \u2192 ${move.to} \u2014 \uBCFC\uD2B8 \uCABD \uC790\uB9AC\uB9CC \uBC14\uB010\uB2E4. Notion \uC758 DB \uB294 \uC62E\uAE30\uAC70\uB098 \uC774\uB984\uC744 \uBC14\uAFB8\uC9C0 \uC54A\uB294\uB2E4`
|
|
@@ -18590,19 +19178,6 @@ var LocalPlanner = class {
|
|
|
18590
19178
|
this.stateDb.storePreserveMarkers(from, []);
|
|
18591
19179
|
}
|
|
18592
19180
|
}
|
|
18593
|
-
/** 옮겨진 자동 발견 DB 폴더를 설정에 옮겨 적는다 — pull 이 행을 새 폴더에 쓴다. */
|
|
18594
|
-
remapDiscoveredDbFolders(moves) {
|
|
18595
|
-
const target = new Map(moves.map((move) => [move.from, move.to]));
|
|
18596
|
-
const configs = parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY));
|
|
18597
|
-
let changed = false;
|
|
18598
|
-
for (const config of configs) {
|
|
18599
|
-
const to = target.get(config.localFolder.replace(/\/+$/, ""));
|
|
18600
|
-
if (to === void 0) continue;
|
|
18601
|
-
config.localFolder = to;
|
|
18602
|
-
changed = true;
|
|
18603
|
-
}
|
|
18604
|
-
if (changed) this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(configs));
|
|
18605
|
-
}
|
|
18606
19181
|
};
|
|
18607
19182
|
|
|
18608
19183
|
// src/sync/remote-moves.ts
|
|
@@ -18620,11 +19195,13 @@ var RemoteMoveFollower = class {
|
|
|
18620
19195
|
this.stateDb = stateDb;
|
|
18621
19196
|
this.vaultFs = vaultFs;
|
|
18622
19197
|
this.folderLookup = folderLookup;
|
|
19198
|
+
this.relocator = new NoteRelocator(stateDb, vaultFs, config.notion.databases ?? []);
|
|
18623
19199
|
}
|
|
18624
19200
|
config;
|
|
18625
19201
|
stateDb;
|
|
18626
19202
|
vaultFs;
|
|
18627
19203
|
folderLookup;
|
|
19204
|
+
relocator;
|
|
18628
19205
|
/**
|
|
18629
19206
|
* @param moved 페이지 id → Notion 의 새 부모 페이지 id.
|
|
18630
19207
|
* @param options.dryRun 옮길 자리만 정하고 쓰지 않는다.
|
|
@@ -18664,33 +19241,18 @@ var RemoteMoveFollower = class {
|
|
|
18664
19241
|
}
|
|
18665
19242
|
const folder = this.parentFolder(parentId);
|
|
18666
19243
|
if (folder === null) return skip("Notion \uC758 \uC0C8 \uBD80\uBAA8\uAC00 \uBCFC\uD2B8\uC5D0 \uC5C6\uB2E4");
|
|
18667
|
-
const units = this.unitsOf(record);
|
|
19244
|
+
const units = this.relocator.unitsOf(record);
|
|
18668
19245
|
const moves = units.map((from) => ({ from, to: joinPath(folder, baseName2(from)) }));
|
|
18669
19246
|
if (moves.every((move) => move.from === move.to)) {
|
|
18670
19247
|
if (!dryRun) this.stateDb.setNotionParentId(record.id, parentId);
|
|
18671
19248
|
return { moves: [] };
|
|
18672
19249
|
}
|
|
18673
19250
|
for (const move of moves) {
|
|
18674
|
-
const blocked = await this.blocked(move);
|
|
19251
|
+
const blocked = await this.relocator.blocked(move);
|
|
18675
19252
|
if (blocked) return skip(blocked);
|
|
18676
19253
|
}
|
|
18677
19254
|
if (dryRun) return { moves };
|
|
18678
|
-
|
|
18679
|
-
try {
|
|
18680
|
-
for (const move of moves) {
|
|
18681
|
-
await this.vaultFs.moveFile(move.from, move.to);
|
|
18682
|
-
done.push(move);
|
|
18683
|
-
}
|
|
18684
|
-
} catch (error) {
|
|
18685
|
-
for (const move of done.reverse())
|
|
18686
|
-
await this.vaultFs.moveFile(move.to, move.from).catch(() => {
|
|
18687
|
-
});
|
|
18688
|
-
throw error;
|
|
18689
|
-
}
|
|
18690
|
-
this.stateDb.transaction(() => {
|
|
18691
|
-
for (const move of moves) this.rewritePaths(move);
|
|
18692
|
-
this.stateDb.setNotionParentId(record.id, parentId);
|
|
18693
|
-
});
|
|
19255
|
+
await this.relocator.relocate(moves, () => this.stateDb.setNotionParentId(record.id, parentId));
|
|
18694
19256
|
for (const move of moves) {
|
|
18695
19257
|
getLogger().info(
|
|
18696
19258
|
`[Im-Nobsidian] Notion \uC5D0\uC11C \uC62E\uAE34 \uD398\uC774\uC9C0\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE40: ${move.from} \u2192 ${move.to}`
|
|
@@ -18704,98 +19266,356 @@ var RemoteMoveFollower = class {
|
|
|
18704
19266
|
const parent = this.stateDb.getByNotionId(parentId);
|
|
18705
19267
|
return parent ? childFolderOf(parent, this.folderLookup()) : null;
|
|
18706
19268
|
}
|
|
18707
|
-
|
|
18708
|
-
|
|
18709
|
-
|
|
18710
|
-
|
|
18711
|
-
|
|
18712
|
-
|
|
18713
|
-
|
|
18714
|
-
|
|
18715
|
-
|
|
18716
|
-
|
|
19269
|
+
};
|
|
19270
|
+
|
|
19271
|
+
// src/sync/remote-renames.ts
|
|
19272
|
+
var RemoteRenameFollower = class {
|
|
19273
|
+
constructor(stateDb, vaultFs, notionClient, pageOf, configuredDatabases = []) {
|
|
19274
|
+
this.stateDb = stateDb;
|
|
19275
|
+
this.vaultFs = vaultFs;
|
|
19276
|
+
this.notionClient = notionClient;
|
|
19277
|
+
this.pageOf = pageOf;
|
|
19278
|
+
this.relocator = new NoteRelocator(stateDb, vaultFs, configuredDatabases);
|
|
19279
|
+
}
|
|
19280
|
+
stateDb;
|
|
19281
|
+
vaultFs;
|
|
19282
|
+
notionClient;
|
|
19283
|
+
pageOf;
|
|
19284
|
+
relocator;
|
|
19285
|
+
async follow(changes, options = {}) {
|
|
19286
|
+
const renamed = [];
|
|
19287
|
+
const skipped = [];
|
|
19288
|
+
const applied = [];
|
|
19289
|
+
for (const change of changes) {
|
|
19290
|
+
if (change.type !== "modified") continue;
|
|
19291
|
+
const record = this.stateDb.getByNotionId(change.pageId);
|
|
19292
|
+
if (!record || !RENAMABLE.has(record.fileType)) continue;
|
|
19293
|
+
if (this.vaultFs.excludesPath?.(record.obsidianPath)) continue;
|
|
19294
|
+
try {
|
|
19295
|
+
const outcome = await this.followOne(record, options.dryRun === true);
|
|
19296
|
+
if (outcome === null) continue;
|
|
19297
|
+
if ("reason" in outcome) {
|
|
19298
|
+
skipped.push(outcome);
|
|
19299
|
+
getLogger().warn(
|
|
19300
|
+
`[Im-Nobsidian] Notion \uC5D0\uC11C \uBC14\uAFBC \uC81C\uBAA9\uC73C\uB85C \uB178\uD2B8 \uC774\uB984\uC744 \uBC14\uAFB8\uC9C0 \uC54A\uC74C (${outcome.path}): ${outcome.reason}`
|
|
19301
|
+
);
|
|
19302
|
+
continue;
|
|
19303
|
+
}
|
|
19304
|
+
renamed.push(outcome.renamed);
|
|
19305
|
+
applied.push(...outcome.moves);
|
|
19306
|
+
} catch (error) {
|
|
19307
|
+
skipped.push({
|
|
19308
|
+
path: record.obsidianPath,
|
|
19309
|
+
reason: `\uC774\uB984\uC744 \uBC14\uAFB8\uC9C0 \uBABB\uD568: ${error instanceof Error ? error.message : String(error)}`
|
|
19310
|
+
});
|
|
19311
|
+
}
|
|
19312
|
+
}
|
|
19313
|
+
return { renamed, skipped, applied };
|
|
19314
|
+
}
|
|
19315
|
+
/** 바꿀 것이 없으면 null. */
|
|
19316
|
+
async followOne(record, dryRun) {
|
|
19317
|
+
const page = await this.pageOf(record.notionPageId);
|
|
19318
|
+
if (isTrashedOrArchived(page)) return null;
|
|
19319
|
+
const title = this.notionClient.extractTitle(page);
|
|
19320
|
+
if (!retitledInNotion(...await this.frontmatters(record), record.obsidianPath, title)) {
|
|
19321
|
+
return null;
|
|
19322
|
+
}
|
|
19323
|
+
const skip = (reason) => ({ path: record.obsidianPath, reason });
|
|
19324
|
+
if (this.stateDb.getIncompleteOpByState(record.id, "move")) {
|
|
19325
|
+
return skip(
|
|
19326
|
+
"\uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE34 \uAC83\uC744 \uC544\uC9C1 \uC62C\uB9AC\uC9C0 \uC54A\uC558\uB2E4 \u2014 \uB2E4\uC74C push \uAC00 Notion \uC744 \uBCFC\uD2B8\uC758 \uC790\uB9AC\uB85C \uC62E\uAE34\uB2E4"
|
|
19327
|
+
);
|
|
19328
|
+
}
|
|
19329
|
+
const moves = await this.freeMoves(record, sanitizeFileName(title));
|
|
19330
|
+
if (moves === null) return skip("\uC0C8 \uC774\uB984\uC758 \uC790\uB9AC\uAC00 \uBAA8\uB450 \uCC28 \uC788\uB2E4");
|
|
19331
|
+
for (const move of moves) {
|
|
19332
|
+
const blocked = await this.relocator.blocked(move);
|
|
19333
|
+
if (blocked) return skip(blocked);
|
|
19334
|
+
}
|
|
19335
|
+
const renamed = { from: record.obsidianPath, to: finalPath2(record.obsidianPath, moves) };
|
|
19336
|
+
if (dryRun) return { renamed: { ...renamed, renamed: true }, moves: [] };
|
|
19337
|
+
await this.relocator.relocate(moves, () => {
|
|
19338
|
+
const entry = this.stateDb.resolvePageId(record.notionPageId);
|
|
19339
|
+
if (entry) this.stateDb.upsertWikilink({ ...entry, obsidianPath: renamed.to, title });
|
|
19340
|
+
});
|
|
19341
|
+
getLogger().info(
|
|
19342
|
+
`[Im-Nobsidian] Notion \uC5D0\uC11C \uBC14\uAFBC \uC81C\uBAA9\uC73C\uB85C \uB178\uD2B8 \uC774\uB984\uC744 \uBC14\uAFC8: ${renamed.from} \u2192 ${renamed.to}`
|
|
19343
|
+
);
|
|
19344
|
+
return { renamed: { ...renamed, renamed: true }, moves };
|
|
19345
|
+
}
|
|
19346
|
+
/** 지난 동기화 때와 지금 노트의 frontmatter — 폴더는 둘 다 없다. */
|
|
19347
|
+
async frontmatters(record) {
|
|
19348
|
+
if (isFolderRecord(record)) return [null, null];
|
|
19349
|
+
const base = snapshotFrontmatter(record.baseSnapshot);
|
|
19350
|
+
try {
|
|
19351
|
+
return [base, parseFrontmatter(await this.vaultFs.readFile(record.obsidianPath)).data];
|
|
19352
|
+
} catch {
|
|
19353
|
+
return [base, null];
|
|
19354
|
+
}
|
|
19355
|
+
}
|
|
19356
|
+
/**
|
|
19357
|
+
* 새 이름으로 옮길 차례 — 새 페이지를 받을 때처럼 자연 이름부터, 차 있으면 id 접미사를 붙인 이름. 모두 차 있으면 null.
|
|
19358
|
+
* 폴더 노트는 노트 이름을 먼저 바꾸고 폴더를 바꾼다 — 거꾸로 하면 두 번째 이동의 출발점이 아직 없다.
|
|
19359
|
+
*/
|
|
19360
|
+
async freeMoves(record, safeName) {
|
|
19361
|
+
for (const candidate of pagePathCandidates("", safeName, record.notionPageId)) {
|
|
19362
|
+
const name = stripMarkdownExtension(candidate);
|
|
19363
|
+
const moves = this.movesTo(record, name);
|
|
19364
|
+
let free = true;
|
|
19365
|
+
for (const move of moves) {
|
|
19366
|
+
if (move.from !== move.to && await this.relocator.taken(move.to)) free = false;
|
|
19367
|
+
}
|
|
19368
|
+
if (free) return moves.filter((move) => move.from !== move.to);
|
|
19369
|
+
}
|
|
19370
|
+
return null;
|
|
19371
|
+
}
|
|
19372
|
+
movesTo(record, name) {
|
|
19373
|
+
const path = record.obsidianPath;
|
|
19374
|
+
if (isFolderRecord(record)) {
|
|
19375
|
+
return [{ from: path, to: joinPath(parentFolderOf(path), name) }];
|
|
19376
|
+
}
|
|
19377
|
+
if (record.fileType === "folder-note") {
|
|
19378
|
+
const folder2 = parentFolderOf(path);
|
|
19379
|
+
return [
|
|
19380
|
+
{ from: path, to: joinPath(folder2, `${name}.md`) },
|
|
19381
|
+
{ from: folder2, to: joinPath(parentFolderOf(folder2), name) }
|
|
19382
|
+
];
|
|
19383
|
+
}
|
|
19384
|
+
const [note, children] = this.relocator.unitsOf(record);
|
|
19385
|
+
const folder = parentFolderOf(path);
|
|
19386
|
+
const moves = [{ from: note, to: joinPath(folder, `${name}.md`) }];
|
|
19387
|
+
if (children) moves.push({ from: children, to: joinPath(folder, name) });
|
|
19388
|
+
return moves;
|
|
19389
|
+
}
|
|
19390
|
+
};
|
|
19391
|
+
var RENAMABLE = /* @__PURE__ */ new Set([
|
|
19392
|
+
"file",
|
|
19393
|
+
"folder-note",
|
|
19394
|
+
"folder-only"
|
|
19395
|
+
]);
|
|
19396
|
+
function finalPath2(path, moves) {
|
|
19397
|
+
let current = path;
|
|
19398
|
+
for (const move of moves) {
|
|
19399
|
+
if (current === move.from || current.startsWith(`${move.from}/`)) {
|
|
19400
|
+
current = move.to + current.slice(move.from.length);
|
|
19401
|
+
}
|
|
19402
|
+
}
|
|
19403
|
+
return current;
|
|
19404
|
+
}
|
|
19405
|
+
|
|
19406
|
+
// src/utils/link-rewrite.ts
|
|
19407
|
+
var WIKILINK = /(!?)\[\[([^[\]\n]+?)\]\]/g;
|
|
19408
|
+
var MARKDOWN_LINK = /(!?\[[^\]\n]*\]\()(<[^>\n]+>|[^)\s<>]+)((?:\s+"[^"\n]*")?\))/g;
|
|
19409
|
+
var URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
19410
|
+
function rewriteLinks(content, source, context) {
|
|
19411
|
+
if (!content.includes("[[") && !content.includes("](")) return content;
|
|
19412
|
+
return mapOutsideCodeFences(
|
|
19413
|
+
content,
|
|
19414
|
+
(fenceFree) => mapOutsideInlineCode(
|
|
19415
|
+
fenceFree,
|
|
19416
|
+
(text) => text.replace(WIKILINK, (whole, bang, inner) => {
|
|
19417
|
+
const [target, rest] = splitWikiTarget(inner);
|
|
19418
|
+
const next = rewriteTarget(target, source, context, "wiki");
|
|
19419
|
+
return next === null ? whole : `${bang}[[${next}${rest}]]`;
|
|
19420
|
+
}).replace(MARKDOWN_LINK, (whole, head, raw, tail) => {
|
|
19421
|
+
const bracketed = raw.startsWith("<");
|
|
19422
|
+
const url = bracketed ? raw.slice(1, -1) : raw;
|
|
19423
|
+
if (URL_SCHEME.test(url) || url.startsWith("#")) return whole;
|
|
19424
|
+
const hash = url.indexOf("#");
|
|
19425
|
+
const target = hash === -1 ? url : url.slice(0, hash);
|
|
19426
|
+
let decoded;
|
|
19427
|
+
try {
|
|
19428
|
+
decoded = decodeURI(target);
|
|
19429
|
+
} catch {
|
|
19430
|
+
return whole;
|
|
19431
|
+
}
|
|
19432
|
+
const next = rewriteTarget(decoded, source, context, "markdown");
|
|
19433
|
+
if (next === null) return whole;
|
|
19434
|
+
const encoded = bracketed ? next : encodeMarkdownTarget(next, target);
|
|
19435
|
+
const fragment = hash === -1 ? "" : url.slice(hash);
|
|
19436
|
+
return `${head}${bracketed ? `<${encoded}${fragment}>` : `${encoded}${fragment}`}${tail}`;
|
|
19437
|
+
})
|
|
19438
|
+
)
|
|
19439
|
+
);
|
|
19440
|
+
}
|
|
19441
|
+
function splitWikiTarget(inner) {
|
|
19442
|
+
const cut = inner.search(/\\?\||#/);
|
|
19443
|
+
return cut === -1 ? [inner, ""] : [inner.slice(0, cut), inner.slice(cut)];
|
|
19444
|
+
}
|
|
19445
|
+
function rewriteTarget(target, source, context, kind) {
|
|
19446
|
+
const linkpath = target.trim();
|
|
19447
|
+
if (linkpath === "") return null;
|
|
19448
|
+
const style = /^\.\.?\//.test(linkpath) ? "relative" : linkpath.includes("/") ? "path" : "name";
|
|
19449
|
+
const resolved = style === "path" && kind === "markdown" ? resolveRelative(linkpath, source.from, context.before) ?? resolveLinkpath(linkpath, source.from, context.before) : resolveLinkpath(linkpath, source.from, context.before);
|
|
19450
|
+
if (resolved === null) return null;
|
|
19451
|
+
const moved = context.renames.get(resolved.path);
|
|
19452
|
+
const sourceMoved = source.from !== source.to;
|
|
19453
|
+
if (moved === void 0 && !(sourceMoved && resolved.relative)) return null;
|
|
19454
|
+
const destination = moved ?? resolved.path;
|
|
19455
|
+
const keepExtension = resolved.explicitExtension || !destination.endsWith(".md");
|
|
19456
|
+
const shown = keepExtension ? destination : destination.slice(0, -".md".length);
|
|
19457
|
+
let next;
|
|
19458
|
+
if (resolved.relative) {
|
|
19459
|
+
next = relativePath(folderOf4(source.to), shown);
|
|
19460
|
+
if (!linkpath.startsWith("./") && next.startsWith("./")) next = next.slice(2);
|
|
19461
|
+
} else if (style === "name") {
|
|
19462
|
+
const name = baseName3(shown);
|
|
19463
|
+
next = uniqueName(baseName3(destination), context.after) ? name : shown;
|
|
19464
|
+
} else {
|
|
19465
|
+
next = shown;
|
|
19466
|
+
}
|
|
19467
|
+
return next === linkpath ? null : next;
|
|
19468
|
+
}
|
|
19469
|
+
function resolveLinkpath(linkpath, sourcePath, files) {
|
|
19470
|
+
if (/^\.\.?\//.test(linkpath)) return resolveRelative(linkpath, sourcePath, files);
|
|
19471
|
+
const clean = linkpath.replace(/^\/+/, "");
|
|
19472
|
+
const candidates = withExtension(clean);
|
|
19473
|
+
for (const candidate of candidates) {
|
|
19474
|
+
if (files.has(candidate)) return found(candidate, linkpath);
|
|
18717
19475
|
}
|
|
18718
|
-
|
|
18719
|
-
|
|
18720
|
-
|
|
18721
|
-
if (
|
|
18722
|
-
if (
|
|
18723
|
-
|
|
19476
|
+
for (const candidate of candidates) {
|
|
19477
|
+
const suffix = `/${candidate}`;
|
|
19478
|
+
const matches = byName(files, baseName3(candidate)).filter((file) => file.endsWith(suffix));
|
|
19479
|
+
if (matches.length === 1) return found(matches[0], linkpath);
|
|
19480
|
+
if (matches.length > 1) {
|
|
19481
|
+
const local = `${folderPrefix(folderOf4(sourcePath))}${candidate}`;
|
|
19482
|
+
return matches.includes(local) ? found(local, linkpath) : null;
|
|
18724
19483
|
}
|
|
18725
|
-
|
|
18726
|
-
|
|
18727
|
-
|
|
18728
|
-
|
|
18729
|
-
|
|
18730
|
-
|
|
19484
|
+
}
|
|
19485
|
+
return null;
|
|
19486
|
+
}
|
|
19487
|
+
function resolveRelative(linkpath, sourcePath, files) {
|
|
19488
|
+
const joined = normalizeSegments(`${folderPrefix(folderOf4(sourcePath))}${linkpath}`);
|
|
19489
|
+
if (joined === null) return null;
|
|
19490
|
+
for (const candidate of withExtension(joined)) {
|
|
19491
|
+
if (files.has(candidate)) return { ...found(candidate, linkpath), relative: true };
|
|
19492
|
+
}
|
|
19493
|
+
return null;
|
|
19494
|
+
}
|
|
19495
|
+
function found(path, linkpath) {
|
|
19496
|
+
return { path, explicitExtension: hasExtension(linkpath), relative: false };
|
|
19497
|
+
}
|
|
19498
|
+
function withExtension(linkpath) {
|
|
19499
|
+
return hasExtension(linkpath) ? [linkpath, `${linkpath}.md`] : [`${linkpath}.md`, linkpath];
|
|
19500
|
+
}
|
|
19501
|
+
function hasExtension(linkpath) {
|
|
19502
|
+
return /\.[^./]+$/.test(baseName3(linkpath));
|
|
19503
|
+
}
|
|
19504
|
+
function uniqueName(name, files) {
|
|
19505
|
+
return byName(files, name).length <= 1;
|
|
19506
|
+
}
|
|
19507
|
+
var nameIndexes = /* @__PURE__ */ new WeakMap();
|
|
19508
|
+
function byName(files, name) {
|
|
19509
|
+
let index = nameIndexes.get(files);
|
|
19510
|
+
if (!index) {
|
|
19511
|
+
index = /* @__PURE__ */ new Map();
|
|
19512
|
+
for (const file of files) {
|
|
19513
|
+
const key = baseName3(file);
|
|
19514
|
+
const list = index.get(key);
|
|
19515
|
+
if (list) list.push(file);
|
|
19516
|
+
else index.set(key, [file]);
|
|
18731
19517
|
}
|
|
18732
|
-
|
|
19518
|
+
nameIndexes.set(files, index);
|
|
19519
|
+
}
|
|
19520
|
+
return index.get(name) ?? [];
|
|
19521
|
+
}
|
|
19522
|
+
function normalizeSegments(path) {
|
|
19523
|
+
const out = [];
|
|
19524
|
+
for (const segment of path.split("/")) {
|
|
19525
|
+
if (segment === "" || segment === ".") continue;
|
|
19526
|
+
if (segment === "..") {
|
|
19527
|
+
if (out.length === 0) return null;
|
|
19528
|
+
out.pop();
|
|
19529
|
+
} else out.push(segment);
|
|
19530
|
+
}
|
|
19531
|
+
return out.join("/");
|
|
19532
|
+
}
|
|
19533
|
+
function relativePath(from, to) {
|
|
19534
|
+
const fromParts = from === "" ? [] : from.split("/");
|
|
19535
|
+
const toParts = to.split("/");
|
|
19536
|
+
let common = 0;
|
|
19537
|
+
while (common < fromParts.length && common < toParts.length - 1 && fromParts[common] === toParts[common]) {
|
|
19538
|
+
common++;
|
|
19539
|
+
}
|
|
19540
|
+
const up = fromParts.length - common;
|
|
19541
|
+
const rest = toParts.slice(common).join("/");
|
|
19542
|
+
return up === 0 ? `./${rest}` : `${"../".repeat(up)}${rest}`;
|
|
19543
|
+
}
|
|
19544
|
+
function encodeMarkdownTarget(next, original) {
|
|
19545
|
+
return original.includes("%") || next.includes(" ") ? next.replace(/ /g, "%20") : next;
|
|
19546
|
+
}
|
|
19547
|
+
function folderOf4(path) {
|
|
19548
|
+
const slash = path.lastIndexOf("/");
|
|
19549
|
+
return slash === -1 ? "" : path.slice(0, slash);
|
|
19550
|
+
}
|
|
19551
|
+
function folderPrefix(folder) {
|
|
19552
|
+
return folder === "" ? "" : `${folder}/`;
|
|
19553
|
+
}
|
|
19554
|
+
function baseName3(path) {
|
|
19555
|
+
return path.slice(path.lastIndexOf("/") + 1);
|
|
19556
|
+
}
|
|
19557
|
+
|
|
19558
|
+
// src/sync/link-updater.ts
|
|
19559
|
+
var LinkUpdater = class {
|
|
19560
|
+
constructor(stateDb, vaultFs) {
|
|
19561
|
+
this.stateDb = stateDb;
|
|
19562
|
+
this.vaultFs = vaultFs;
|
|
18733
19563
|
}
|
|
18734
|
-
|
|
18735
|
-
|
|
18736
|
-
|
|
18737
|
-
|
|
18738
|
-
|
|
19564
|
+
stateDb;
|
|
19565
|
+
vaultFs;
|
|
19566
|
+
/**
|
|
19567
|
+
* @param moves 옮긴 차례 그대로의 이동 — 노트 하나거나 폴더 하나.
|
|
19568
|
+
* @returns 링크를 고친 노트.
|
|
19569
|
+
*/
|
|
19570
|
+
async apply(moves) {
|
|
19571
|
+
if (moves.length === 0) return [];
|
|
19572
|
+
const notes = await this.vaultFs.listMarkdownFiles();
|
|
19573
|
+
const others = await this.vaultFs.listNonMarkdownFiles();
|
|
19574
|
+
const after = new Set([...notes, ...others].map((file) => file.path));
|
|
19575
|
+
const renames = /* @__PURE__ */ new Map();
|
|
19576
|
+
for (const path of after) {
|
|
19577
|
+
const before2 = undo(path, moves);
|
|
19578
|
+
if (before2 !== path) renames.set(before2, path);
|
|
19579
|
+
}
|
|
19580
|
+
if (renames.size === 0) return [];
|
|
19581
|
+
const before = new Set([...after].map((path) => undo(path, moves)));
|
|
19582
|
+
const context = { before, after, renames };
|
|
19583
|
+
const relinked = [];
|
|
19584
|
+
for (const note of notes) {
|
|
19585
|
+
const source = { from: undo(note.path, moves), to: note.path };
|
|
19586
|
+
const next = rewriteLinks(note.content, source, context);
|
|
19587
|
+
if (next === note.content) continue;
|
|
18739
19588
|
try {
|
|
18740
|
-
|
|
18741
|
-
|
|
19589
|
+
await this.vaultFs.writeFile(note.path, next);
|
|
19590
|
+
} catch (error) {
|
|
19591
|
+
getLogger().warn(
|
|
19592
|
+
`[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568 (${note.path}): ${error instanceof Error ? error.message : String(error)}`
|
|
18742
19593
|
);
|
|
18743
|
-
|
|
18744
|
-
}
|
|
19594
|
+
continue;
|
|
19595
|
+
}
|
|
19596
|
+
const record = this.stateDb.getByPath(note.path);
|
|
19597
|
+
if (record && record.contentHash === computeHash(note.content)) {
|
|
19598
|
+
this.stateDb.updateHash(record.id, computeHash(next), Buffer.from(next, "utf-8"));
|
|
19599
|
+
const stat2 = await this.vaultFs.getFileStat(note.path);
|
|
19600
|
+
if (stat2) this.stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
|
|
18745
19601
|
}
|
|
19602
|
+
relinked.push(note.path);
|
|
18746
19603
|
}
|
|
18747
|
-
if (
|
|
18748
|
-
|
|
19604
|
+
if (relinked.length > 0) {
|
|
19605
|
+
getLogger().info(`[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE68: \uB178\uD2B8 ${relinked.length}`);
|
|
18749
19606
|
}
|
|
18750
|
-
return
|
|
19607
|
+
return relinked;
|
|
18751
19608
|
}
|
|
18752
|
-
|
|
18753
|
-
|
|
18754
|
-
|
|
18755
|
-
|
|
18756
|
-
|
|
18757
|
-
|
|
18758
|
-
|
|
18759
|
-
const entry = record.notionPageId ? this.stateDb.resolvePageId(record.notionPageId) : null;
|
|
18760
|
-
if (entry && entry.obsidianPath === from) {
|
|
18761
|
-
this.stateDb.deleteWikilink(from);
|
|
18762
|
-
this.stateDb.upsertWikilink({
|
|
18763
|
-
obsidianPath: to,
|
|
18764
|
-
notionPageId: entry.notionPageId,
|
|
18765
|
-
title: wikilinkTitleFromPath(to),
|
|
18766
|
-
aliases: entry.aliases
|
|
18767
|
-
});
|
|
18768
|
-
}
|
|
18769
|
-
const markers = this.stateDb.getPreserveMarkers(from);
|
|
18770
|
-
if (markers.length > 0) {
|
|
18771
|
-
this.stateDb.storePreserveMarkers(to, markers);
|
|
18772
|
-
this.stateDb.storePreserveMarkers(from, []);
|
|
18773
|
-
}
|
|
18774
|
-
if (record.notionPageId) {
|
|
18775
|
-
for (const file of this.stateDb.getFilesByPageId(record.notionPageId)) {
|
|
18776
|
-
if (!within(file.localPath, move.from)) continue;
|
|
18777
|
-
this.stateDb.registerFile({
|
|
18778
|
-
localPath: move.to + file.localPath.slice(move.from.length),
|
|
18779
|
-
notionPageId: file.notionPageId,
|
|
18780
|
-
fileUploadId: file.fileUploadId,
|
|
18781
|
-
fileType: file.fileType,
|
|
18782
|
-
fileHash: file.fileHash,
|
|
18783
|
-
fileSize: file.fileSize
|
|
18784
|
-
});
|
|
18785
|
-
this.stateDb.deleteFileRegistry(file.localPath);
|
|
18786
|
-
}
|
|
18787
|
-
}
|
|
19609
|
+
};
|
|
19610
|
+
function undo(path, moves) {
|
|
19611
|
+
let current = path;
|
|
19612
|
+
for (let i = moves.length - 1; i >= 0; i--) {
|
|
19613
|
+
const move = moves[i];
|
|
19614
|
+
if (current === move.to || current.startsWith(`${move.to}/`)) {
|
|
19615
|
+
current = move.from + current.slice(move.to.length);
|
|
18788
19616
|
}
|
|
18789
19617
|
}
|
|
18790
|
-
|
|
18791
|
-
function within(path, unit) {
|
|
18792
|
-
return path === unit || path.startsWith(`${unit}/`);
|
|
18793
|
-
}
|
|
18794
|
-
function baseName2(path) {
|
|
18795
|
-
return path.slice(path.lastIndexOf("/") + 1);
|
|
18796
|
-
}
|
|
18797
|
-
function joinPath(folder, name) {
|
|
18798
|
-
return folder === "" ? name : `${folder}/${name}`;
|
|
19618
|
+
return current;
|
|
18799
19619
|
}
|
|
18800
19620
|
|
|
18801
19621
|
// src/sync/change-inspector.ts
|
|
@@ -19518,8 +20338,8 @@ var DatabaseCreator = class {
|
|
|
19518
20338
|
* @param includeEmpty 새 노트가 없는 DB 폴더(행이 없는 DB)도 — 범위를 좁힌 push 는 범위 밖을 만들지 않는다.
|
|
19519
20339
|
*/
|
|
19520
20340
|
async newDatabaseFolders(changes, lookup, includeEmpty) {
|
|
19521
|
-
const
|
|
19522
|
-
if (!this.enabled) return
|
|
20341
|
+
const found2 = /* @__PURE__ */ new Map();
|
|
20342
|
+
if (!this.enabled) return found2;
|
|
19523
20343
|
const candidates = /* @__PURE__ */ new Set();
|
|
19524
20344
|
for (const change of changes) {
|
|
19525
20345
|
if (change.type !== "created" && change.type !== "moved") continue;
|
|
@@ -19527,22 +20347,22 @@ var DatabaseCreator = class {
|
|
|
19527
20347
|
if (!folderContainer(folder, lookup)) candidates.add(folder);
|
|
19528
20348
|
}
|
|
19529
20349
|
}
|
|
19530
|
-
if (candidates.size === 0 && !includeEmpty) return
|
|
20350
|
+
if (candidates.size === 0 && !includeEmpty) return found2;
|
|
19531
20351
|
const known = this.knownDatabaseIds();
|
|
19532
20352
|
const created = loadCreatedDatabases(this.stateDb);
|
|
19533
20353
|
for (const file of await this.vaultFs.listNonMarkdownFiles()) {
|
|
19534
20354
|
if (!isSidecarPath(file.path)) continue;
|
|
19535
20355
|
const folder = parentFolderOf(file.path);
|
|
19536
|
-
if (
|
|
20356
|
+
if (found2.has(folder)) continue;
|
|
19537
20357
|
if (!candidates.has(folder) && !(includeEmpty && !folderContainer(folder, lookup))) continue;
|
|
19538
20358
|
const schema = await this.readSidecar(file.path);
|
|
19539
20359
|
if (!schema) continue;
|
|
19540
20360
|
const entry = created.get(schema.databaseId);
|
|
19541
20361
|
if (entry && entry.databaseId !== null) continue;
|
|
19542
20362
|
if (!entry && known.has(schema.databaseId)) continue;
|
|
19543
|
-
|
|
20363
|
+
found2.set(folder, { folder, schema });
|
|
19544
20364
|
}
|
|
19545
|
-
return
|
|
20365
|
+
return found2;
|
|
19546
20366
|
}
|
|
19547
20367
|
/**
|
|
19548
20368
|
* 폴더의 DB 를 부모 페이지 아래에 만들고 자동 발견 목록에 적는다. 만드는 요청은 다시 보내지 않는다 —
|
|
@@ -19993,9 +20813,7 @@ var DatabaseCreator = class {
|
|
|
19993
20813
|
discovered.push({ databaseId, localFolder: folder, titleProperty: titleProperty2 });
|
|
19994
20814
|
this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(discovered));
|
|
19995
20815
|
}
|
|
19996
|
-
|
|
19997
|
-
pending.add(compactNotionId(databaseId));
|
|
19998
|
-
this.stateDb.setMeta(PENDING_DATABASES_META_KEY, serializePendingDatabases(pending));
|
|
20816
|
+
addPendingDatabases(this.stateDb, [databaseId]);
|
|
19999
20817
|
}
|
|
20000
20818
|
async readSidecar(path) {
|
|
20001
20819
|
try {
|
|
@@ -20300,6 +21118,7 @@ var PagePusher = class {
|
|
|
20300
21118
|
* (ADR-017). relation 을 보내면 가리킨 행의 반대쪽 relation 이, 행을 만들면 그 행의 수식 · ID 가 바뀐다.
|
|
20301
21119
|
*/
|
|
20302
21120
|
markRemoteUnverified(pageIds) {
|
|
21121
|
+
const databases = [];
|
|
20303
21122
|
for (const pageId of new Set(pageIds)) {
|
|
20304
21123
|
const target = this.stateDb.getByNotionId(pageId);
|
|
20305
21124
|
if (!target?.notionLastEdited) continue;
|
|
@@ -20308,6 +21127,76 @@ var PagePusher = class {
|
|
|
20308
21127
|
lastEditedBy: null,
|
|
20309
21128
|
seenAt: null
|
|
20310
21129
|
});
|
|
21130
|
+
if (target.fileType === "db-row" && target.notionParentId) {
|
|
21131
|
+
databases.push(target.notionParentId);
|
|
21132
|
+
}
|
|
21133
|
+
}
|
|
21134
|
+
addPendingDatabases(this.stateDb, databases);
|
|
21135
|
+
}
|
|
21136
|
+
/** 이번 실행에서 볼트의 값을 올리지 않은 읽기 전용 속성 — 노트 경로별. */
|
|
21137
|
+
readOnlySkips = /* @__PURE__ */ new Map();
|
|
21138
|
+
/** 올리지 않은 읽기 전용 속성을 꺼낸다(목록은 비운다). */
|
|
21139
|
+
takeReadOnlySkips() {
|
|
21140
|
+
const skips = [...this.readOnlySkips].sort(([a], [b]) => a.localeCompare(b)).map(([path, properties]) => ({ path, properties }));
|
|
21141
|
+
this.readOnlySkips.clear();
|
|
21142
|
+
return skips;
|
|
21143
|
+
}
|
|
21144
|
+
/**
|
|
21145
|
+
* dry-run 이 알릴 읽기 전용 속성 — 실제 push 처럼 볼트에서 바꾼 행 속성(새 행은 모든 속성) 가운데 Notion 이 값을
|
|
21146
|
+
* 정하는 것. 원격은 읽지 않고 지난 동기화 사본과 견준다 — 원격도 바뀌었으면 실제 push 와 다를 수 있다. 사본이
|
|
21147
|
+
* 없거나 frontmatter · 스키마를 읽지 못하면 그 노트는 건너뛴다(실제 push 가 이유와 함께 알린다).
|
|
21148
|
+
*/
|
|
21149
|
+
async previewReadOnlySkips(changes) {
|
|
21150
|
+
const skips = [];
|
|
21151
|
+
for (const change of changes) {
|
|
21152
|
+
const row = this.previewRow(change);
|
|
21153
|
+
if (!row) continue;
|
|
21154
|
+
try {
|
|
21155
|
+
const current = parseFrontmatter(await this.vaultFs.readFile(change.path));
|
|
21156
|
+
const { changed, cleared } = diffRowProperties(row.base, current.data);
|
|
21157
|
+
const mapper = await this.rowSchemas.mapperFor(row.databaseId);
|
|
21158
|
+
const readOnly = mapper.readOnlyKeys(
|
|
21159
|
+
withoutDecorations([...Object.keys(changed), ...cleared])
|
|
21160
|
+
);
|
|
21161
|
+
if (readOnly.length > 0) skips.push({ path: change.path, properties: readOnly });
|
|
21162
|
+
} catch {
|
|
21163
|
+
continue;
|
|
21164
|
+
}
|
|
21165
|
+
}
|
|
21166
|
+
return skips.sort((a, b) => a.path.localeCompare(b.path));
|
|
21167
|
+
}
|
|
21168
|
+
/** dry-run 이 견줄 행 — 새 행은 기준 없이, 고친 행은 지난 동기화 사본과. 행이 아니거나 사본이 없으면 null. */
|
|
21169
|
+
previewRow(change) {
|
|
21170
|
+
if (change.type === "created") {
|
|
21171
|
+
const databaseId2 = this.placement.newRowDatabaseOf(change.path);
|
|
21172
|
+
return databaseId2 ? { databaseId: databaseId2, base: null } : null;
|
|
21173
|
+
}
|
|
21174
|
+
if (change.type !== "modified") return null;
|
|
21175
|
+
const record = this.stateDb.getByPath(change.path);
|
|
21176
|
+
const databaseId = record ? rowDatabaseOf(this.config, record) : null;
|
|
21177
|
+
const base = record && databaseId ? this.baseRowState(record) : null;
|
|
21178
|
+
return databaseId && base ? { databaseId, base: base.properties } : null;
|
|
21179
|
+
}
|
|
21180
|
+
/**
|
|
21181
|
+
* 보내지 않은 속성을 알린다. 읽기 전용 속성은 경고로 따로 모은다 — 다음 pull 이 볼트의 값을 Notion 값으로 되돌리니
|
|
21182
|
+
* 고친 사람이 알아야 한다. 나머지(DB 에 없는 속성 · 변환 불가 …)는 예전처럼 정보 로그다.
|
|
21183
|
+
*/
|
|
21184
|
+
reportUnsent(path, mapper, skipped, what) {
|
|
21185
|
+
const unsent = withoutDecorations(skipped);
|
|
21186
|
+
const readOnly = mapper.readOnlyKeys(unsent);
|
|
21187
|
+
if (readOnly.length > 0) {
|
|
21188
|
+
this.readOnlySkips.set(path, readOnly);
|
|
21189
|
+
getLogger().warn(
|
|
21190
|
+
`[Im-Nobsidian] \uC77D\uAE30 \uC804\uC6A9 \uC18D\uC131 ${readOnly.length}\uAC1C\uB294 Notion \uC774 \uAC12\uC744 \uC815\uD574 \uC62C\uB9AC\uC9C0 \uC54A\uC74C \u2014 \uB2E4\uC74C pull \uC774 Notion \uAC12\uC73C\uB85C \uB418\uB3CC\uB9B0\uB2E4: ${path} \u2014 ${readOnly.join(", ")}`
|
|
21191
|
+
);
|
|
21192
|
+
} else {
|
|
21193
|
+
this.readOnlySkips.delete(path);
|
|
21194
|
+
}
|
|
21195
|
+
const others = unsent.filter((key) => !readOnly.includes(key));
|
|
21196
|
+
if (others.length > 0) {
|
|
21197
|
+
getLogger().info(
|
|
21198
|
+
`[Im-Nobsidian] ${what} ${others.length}\uAC1C\uB294 \uBCF4\uB0B4\uC9C0 \uC54A\uC74C(DB \uC5D0 \uC5C6\uB294 \uC18D\uC131 \xB7 \uBE44\uC6B8 \uC218 \uC5C6\uB294 \uC18D\uC131 \xB7 \uBCC0\uD658 \uBD88\uAC00): ${path} \u2014 ${others.join(", ")}`
|
|
21199
|
+
);
|
|
20311
21200
|
}
|
|
20312
21201
|
}
|
|
20313
21202
|
/** relation 을 보내지 못한 행을 꺼낸다(목록은 비운다). */
|
|
@@ -20332,7 +21221,7 @@ var PagePusher = class {
|
|
|
20332
21221
|
const selectedPath = this.pipeline.selectPath(content);
|
|
20333
21222
|
if (selectedPath === "block-api") {
|
|
20334
21223
|
getLogger().warn(
|
|
20335
|
-
`[Im-Nobsidian] "${path}" contains block-api features (inline-db/column
|
|
21224
|
+
`[Im-Nobsidian] "${path}" contains block-api features (inline-db/column) \u2014 converted with reduced fidelity in v0.1.0`
|
|
20336
21225
|
);
|
|
20337
21226
|
}
|
|
20338
21227
|
const conversionResult = this.pipeline.convertToNotion(content, {
|
|
@@ -20476,7 +21365,7 @@ var PagePusher = class {
|
|
|
20476
21365
|
const updatePath = this.pipeline.selectPath(content);
|
|
20477
21366
|
if (updatePath === "block-api") {
|
|
20478
21367
|
getLogger().warn(
|
|
20479
|
-
`[Im-Nobsidian] "${path}" contains block-api features (inline-db/column
|
|
21368
|
+
`[Im-Nobsidian] "${path}" contains block-api features (inline-db/column) \u2014 converted with reduced fidelity in v0.1.0`
|
|
20480
21369
|
);
|
|
20481
21370
|
}
|
|
20482
21371
|
const conversionResult = this.pipeline.convertToNotion(content, {
|
|
@@ -20507,7 +21396,7 @@ var PagePusher = class {
|
|
|
20507
21396
|
bodyFingerprint = await this.drift.remoteBodyFingerprintOf(record.notionPageId);
|
|
20508
21397
|
}
|
|
20509
21398
|
const newTitle = this.changedPageTitle(record, content, path);
|
|
20510
|
-
const propsToUpdate = newTitle === null ? void 0 : titleProperty(newTitle);
|
|
21399
|
+
const propsToUpdate = newTitle === null ? void 0 : titleProperty(newTitle, pagePropertiesOf(remote));
|
|
20511
21400
|
const decorated = await this.decorations.push(
|
|
20512
21401
|
record.notionPageId,
|
|
20513
21402
|
path,
|
|
@@ -20690,16 +21579,11 @@ var PagePusher = class {
|
|
|
20690
21579
|
current: current.data,
|
|
20691
21580
|
uploads: await this.uploadsFor(mapper, changes.changed, path)
|
|
20692
21581
|
});
|
|
20693
|
-
|
|
20694
|
-
if (unsentProperties.length > 0) {
|
|
20695
|
-
getLogger().info(
|
|
20696
|
-
`[Im-Nobsidian] \uC0C8 \uD589 \uC18D\uC131 ${unsentProperties.length}\uAC1C\uB294 \uBCF4\uB0B4\uC9C0 \uC54A\uC74C(DB \uC5D0 \uC5C6\uB294 \uC18D\uC131 \xB7 \uC77D\uAE30 \uC804\uC6A9 \xB7 \uBCC0\uD658 \uBD88\uAC00): ${path} \u2014 ${unsentProperties.join(", ")}`
|
|
20697
|
-
);
|
|
20698
|
-
}
|
|
21582
|
+
this.reportUnsent(path, mapper, skipped, "\uC0C8 \uD589 \uC18D\uC131");
|
|
20699
21583
|
const selectedPath = this.pipeline.selectPath(content);
|
|
20700
21584
|
if (selectedPath === "block-api") {
|
|
20701
21585
|
getLogger().warn(
|
|
20702
|
-
`[Im-Nobsidian] "${path}" contains block-api features (inline-db/column
|
|
21586
|
+
`[Im-Nobsidian] "${path}" contains block-api features (inline-db/column) \u2014 converted with reduced fidelity in v0.1.0`
|
|
20703
21587
|
);
|
|
20704
21588
|
}
|
|
20705
21589
|
const conversionResult = this.pipeline.convertToNotion(content, {
|
|
@@ -20898,12 +21782,7 @@ var PagePusher = class {
|
|
|
20898
21782
|
...against === null || options.overwriteRemote ? {} : { uploads: await this.uploadsFor(mapper, changes.changed, path) }
|
|
20899
21783
|
}
|
|
20900
21784
|
);
|
|
20901
|
-
|
|
20902
|
-
if (unsentProperties.length > 0) {
|
|
20903
|
-
getLogger().info(
|
|
20904
|
-
`[Im-Nobsidian] \uD589 \uC18D\uC131 ${unsentProperties.length}\uAC1C\uB294 \uBCF4\uB0B4\uC9C0 \uC54A\uC74C(DB \uC5D0 \uC5C6\uB294 \uC18D\uC131 \xB7 \uC77D\uAE30 \uC804\uC6A9 \xB7 \uBE44\uC6B8 \uC218 \uC5C6\uB294 \uC18D\uC131 \xB7 \uBCC0\uD658 \uBD88\uAC00): ${path} \u2014 ${unsentProperties.join(", ")}`
|
|
20905
|
-
);
|
|
20906
|
-
}
|
|
21785
|
+
this.reportUnsent(path, mapper, skipped, "\uD589 \uC18D\uC131");
|
|
20907
21786
|
const bodyChanged = against?.body == null || against.body !== current.body;
|
|
20908
21787
|
const settles = drift === "none";
|
|
20909
21788
|
if (bodyChanged && !this.drift.overwritesRemote(record, options.overwriteRemote)) {
|
|
@@ -20915,7 +21794,7 @@ var PagePusher = class {
|
|
|
20915
21794
|
const selectedPath = this.pipeline.selectPath(content);
|
|
20916
21795
|
if (selectedPath === "block-api") {
|
|
20917
21796
|
getLogger().warn(
|
|
20918
|
-
`[Im-Nobsidian] "${path}" contains block-api features (inline-db/column
|
|
21797
|
+
`[Im-Nobsidian] "${path}" contains block-api features (inline-db/column) \u2014 converted with reduced fidelity in v0.1.0`
|
|
20919
21798
|
);
|
|
20920
21799
|
}
|
|
20921
21800
|
conversionResult = this.pipeline.convertToNotion(content, {
|
|
@@ -20993,6 +21872,8 @@ var PagePusher = class {
|
|
|
20993
21872
|
aliases: extractAliases(current.data)
|
|
20994
21873
|
});
|
|
20995
21874
|
});
|
|
21875
|
+
if (mapper.readOnlyKeys(withoutDecorations(skipped)).length > 0)
|
|
21876
|
+
this.markRemoteUnverified([pageId]);
|
|
20996
21877
|
}
|
|
20997
21878
|
/**
|
|
20998
21879
|
* 지난 동기화 시점의 행. 사본이 없거나 읽지 못하면 null — 호출측은 비교할 기준이 없다고
|
|
@@ -21037,7 +21918,10 @@ var PagePusher = class {
|
|
|
21037
21918
|
const title = change.title ? change.title(this.notionClient.extractTitle(remote)) : null;
|
|
21038
21919
|
if (newParent !== null) await this.notionClient.movePage(pageId, newParent);
|
|
21039
21920
|
if (title !== null) {
|
|
21040
|
-
written = await this.notionClient.updatePageProperties(
|
|
21921
|
+
written = await this.notionClient.updatePageProperties(
|
|
21922
|
+
pageId,
|
|
21923
|
+
titleProperty(title, pagePropertiesOf(remote))
|
|
21924
|
+
);
|
|
21041
21925
|
} else if (newParent !== null) {
|
|
21042
21926
|
written = await this.notionClient.getPage(pageId);
|
|
21043
21927
|
}
|
|
@@ -21383,6 +22267,8 @@ var PagePuller = class {
|
|
|
21383
22267
|
// 제목이 같은 새 페이지끼리 경로를 고르는 차례({@link orderCreates}) — 페이지 → 앞 차례가 경로를 고르면
|
|
21384
22268
|
// 풀리는 약속과 내 차례를 끝내는 함수.
|
|
21385
22269
|
claimTurns = /* @__PURE__ */ new Map();
|
|
22270
|
+
// Notion 에서 사라져 볼트에서 지운 페이지의 하위 폴더 — pull 이 마칠 때 비었으면 지운다({@link removeVacatedFolders}).
|
|
22271
|
+
vacatedFolders = /* @__PURE__ */ new Set();
|
|
21386
22272
|
decorationsCache;
|
|
21387
22273
|
get decorations() {
|
|
21388
22274
|
return this.decorationsCache ??= new PageDecorations(this.notionClient, this.imageHandler);
|
|
@@ -21397,6 +22283,35 @@ var PagePuller = class {
|
|
|
21397
22283
|
this.claimTurns.clear();
|
|
21398
22284
|
this._pullImageCount = 0;
|
|
21399
22285
|
this._pullFileCount = 0;
|
|
22286
|
+
this.vacatedFolders.clear();
|
|
22287
|
+
}
|
|
22288
|
+
/**
|
|
22289
|
+
* 이번 pull 이 지운 페이지의 폴더가 비었으면 지운다 — 안쪽부터, 그래야 바깥이 빈다. 예전에는 노트만 지우고
|
|
22290
|
+
* 폴더를 남겨 볼트에 빈 폴더가 쌓였다. 사용자 파일 · 숨김 파일이 하나라도 남은 폴더는 둔다
|
|
22291
|
+
* ({@link VaultFS.removeEmptyFolder}).
|
|
22292
|
+
*/
|
|
22293
|
+
async removeVacatedFolders() {
|
|
22294
|
+
const folders = [...this.vacatedFolders].sort(
|
|
22295
|
+
(a, b) => b.split("/").length - a.split("/").length
|
|
22296
|
+
);
|
|
22297
|
+
this.vacatedFolders.clear();
|
|
22298
|
+
for (const folder of folders) {
|
|
22299
|
+
try {
|
|
22300
|
+
await this.vaultFs.removeEmptyFolder?.(folder);
|
|
22301
|
+
} catch (error) {
|
|
22302
|
+
getLogger().warn(`[Im-Nobsidian] \uBE48 \uD3F4\uB354 ${folder} \uB97C \uC9C0\uC6B0\uC9C0 \uBABB\uD568:`, error);
|
|
22303
|
+
}
|
|
22304
|
+
}
|
|
22305
|
+
}
|
|
22306
|
+
/**
|
|
22307
|
+
* 원격에서 사라진 페이지의 하위 폴더를 적어 둔다 — 그 아래 페이지도 함께 사라졌으면 pull 끝에 빈다. 볼트 맨 위와
|
|
22308
|
+
* DB 폴더는 적지 않는다 — DB 는 페이지가 아니다.
|
|
22309
|
+
*/
|
|
22310
|
+
noteVacatedFolder(record) {
|
|
22311
|
+
const lookup = this.placement.folderLookup();
|
|
22312
|
+
if (isFolderRecord(record) && lookup.databaseAt(record.obsidianPath)) return;
|
|
22313
|
+
const folder = childFolderOf(record, lookup);
|
|
22314
|
+
if (folder !== "" && !lookup.databaseAt(folder)) this.vacatedFolders.add(folder);
|
|
21400
22315
|
}
|
|
21401
22316
|
/** 이번 pull 이 내려받은 이미지 수. */
|
|
21402
22317
|
get imageCount() {
|
|
@@ -21443,6 +22358,9 @@ var PagePuller = class {
|
|
|
21443
22358
|
}
|
|
21444
22359
|
return ordered;
|
|
21445
22360
|
}
|
|
22361
|
+
/**
|
|
22362
|
+
* @returns 쓴 노트(폴더) 경로. 동기화에서 뺀 자리에 놓일 페이지면 `excluded` — 받지 않았다.
|
|
22363
|
+
*/
|
|
21446
22364
|
async pullCreate(pageId) {
|
|
21447
22365
|
try {
|
|
21448
22366
|
return await this.createPulledPage(pageId);
|
|
@@ -21450,15 +22368,44 @@ var PagePuller = class {
|
|
|
21450
22368
|
this.claimTurns.get(normalizeNotionId(pageId))?.release();
|
|
21451
22369
|
}
|
|
21452
22370
|
}
|
|
22371
|
+
/**
|
|
22372
|
+
* 새 원격 페이지가 동기화에서 뺀 자리(paths · `.im-nobsidian-ignore`)에 놓일 것이면 그 자리 — 아니면 null.
|
|
22373
|
+
* dry-run 이 받지 않을 것을 미리 알리는 데 쓴다. 볼트도 상태 DB 도 바꾸지 않는다.
|
|
22374
|
+
*/
|
|
22375
|
+
async plannedExclusion(pageId) {
|
|
22376
|
+
if (!this.vaultFs.excludesPath) return null;
|
|
22377
|
+
const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
|
|
22378
|
+
return this.excludedCreatePath(
|
|
22379
|
+
await this.resolveParentPath(page),
|
|
22380
|
+
sanitizeFileName(this.notionClient.extractTitle(page))
|
|
22381
|
+
);
|
|
22382
|
+
}
|
|
22383
|
+
/**
|
|
22384
|
+
* 새 페이지가 놓일 자리가 뺀 자리인가 — 부모 폴더 · 노트 이름 · 폴더 노트가 될 때의 폴더를 본다. 받기 전에는 폴더 노트가
|
|
22385
|
+
* 될지 모른다(본문을 받아 봐야 안다) — 어느 쪽이든 빠지면 뺀다. 자연 이름으로 보고, 같은 이름이 있을 때 붙는 id 접미사는
|
|
22386
|
+
* 보지 않는다.
|
|
22387
|
+
*/
|
|
22388
|
+
excludedCreatePath(parentPath, safeName) {
|
|
22389
|
+
const excludes = this.vaultFs.excludesPath?.bind(this.vaultFs);
|
|
22390
|
+
if (!excludes) return null;
|
|
22391
|
+
const folder = parentPath ? `${parentPath}/${safeName}` : safeName;
|
|
22392
|
+
const note = `${folder}.md`;
|
|
22393
|
+
const blocked = parentPath !== "" && excludes(parentPath, "folder") || excludes(note) || excludes(folder, "folder");
|
|
22394
|
+
return blocked ? note : null;
|
|
22395
|
+
}
|
|
21453
22396
|
async createPulledPage(pageId) {
|
|
21454
22397
|
const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
|
|
21455
22398
|
const title = this.notionClient.extractTitle(page);
|
|
21456
22399
|
const safeName = sanitizeFileName(title);
|
|
21457
22400
|
const parentPath = await this.resolveParentPath(page);
|
|
22401
|
+
const excluded = this.excludedCreatePath(parentPath, safeName);
|
|
22402
|
+
if (excluded !== null) return { path: excluded, excluded: true };
|
|
21458
22403
|
const fetched = await this.fetchPageMarkdown(pageId);
|
|
21459
22404
|
const hasChildPages = this.hasChildContainers(pageId, fetched.children);
|
|
21460
22405
|
if (hasChildPages && !fetched.hasBody) {
|
|
21461
|
-
return
|
|
22406
|
+
return {
|
|
22407
|
+
path: await this.createFolderOnly(pageId, page, fetched.fingerprint, parentPath, safeName)
|
|
22408
|
+
};
|
|
21462
22409
|
}
|
|
21463
22410
|
let filePath;
|
|
21464
22411
|
let fileType;
|
|
@@ -21507,7 +22454,7 @@ var PagePuller = class {
|
|
|
21507
22454
|
aliases
|
|
21508
22455
|
});
|
|
21509
22456
|
});
|
|
21510
|
-
return filePath;
|
|
22457
|
+
return { path: filePath };
|
|
21511
22458
|
}
|
|
21512
22459
|
/**
|
|
21513
22460
|
* 자식 말고 본문이 없는 페이지는 폴더로만 받는다 — 노트를 쓰지 않고 폴더 경로를 추적한다(폴더 레코드).
|
|
@@ -21544,7 +22491,7 @@ var PagePuller = class {
|
|
|
21544
22491
|
if (!folderNote) return { action: "unchanged", path: record.obsidianPath };
|
|
21545
22492
|
record = folderNote;
|
|
21546
22493
|
}
|
|
21547
|
-
const remoteGone = page
|
|
22494
|
+
const remoteGone = isTrashedOrArchived(page);
|
|
21548
22495
|
if (remoteGone && !await this.vaultFs.exists(record.obsidianPath)) {
|
|
21549
22496
|
return { action: "unchanged", path: record.obsidianPath };
|
|
21550
22497
|
}
|
|
@@ -21640,6 +22587,7 @@ var PagePuller = class {
|
|
|
21640
22587
|
this.stateDb.delete(record.id);
|
|
21641
22588
|
this.stateDb.deleteWikilink(record.obsidianPath);
|
|
21642
22589
|
});
|
|
22590
|
+
if (this.config.sync.deleteSync) this.noteVacatedFolder(record);
|
|
21643
22591
|
return { action: "untracked", path: record.obsidianPath };
|
|
21644
22592
|
}
|
|
21645
22593
|
const outcome = await applyRemoteDeletion(this.stateDb, this.vaultFs, record, {
|
|
@@ -21647,6 +22595,7 @@ var PagePuller = class {
|
|
|
21647
22595
|
strategy: this.config.sync.conflictStrategy,
|
|
21648
22596
|
remoteChange: change
|
|
21649
22597
|
});
|
|
22598
|
+
if (outcome.action === "deleted" && this.config.sync.deleteSync) this.noteVacatedFolder(record);
|
|
21650
22599
|
return { ...outcome, path: record.obsidianPath };
|
|
21651
22600
|
}
|
|
21652
22601
|
/**
|
|
@@ -21802,8 +22751,7 @@ var PagePuller = class {
|
|
|
21802
22751
|
*/
|
|
21803
22752
|
async pullFolderRecord(record, page) {
|
|
21804
22753
|
const folder = record.obsidianPath;
|
|
21805
|
-
|
|
21806
|
-
if (remoteGone) return null;
|
|
22754
|
+
if (isTrashedOrArchived(page)) return null;
|
|
21807
22755
|
const { markdown } = await this.notionClient.getPageMarkdown(page.id);
|
|
21808
22756
|
if (!hasBodyBesidesChildren(markdown)) {
|
|
21809
22757
|
this.observation.record(record.id, page, remoteBodyFingerprint(markdown));
|
|
@@ -22135,6 +23083,13 @@ var PullPlanner = class {
|
|
|
22135
23083
|
const restoredModified = planned.filter(
|
|
22136
23084
|
(c) => c.type === "modified" && restoreIds.has(c.pageId)
|
|
22137
23085
|
).length;
|
|
23086
|
+
const restoredPaths = [
|
|
23087
|
+
...[
|
|
23088
|
+
...restoreChanges,
|
|
23089
|
+
...planned.filter((c) => c.type === "modified" && restoreIds.has(c.pageId))
|
|
23090
|
+
].map((change) => this.stateDb.getByNotionId(change.pageId)?.obsidianPath).filter((path) => path !== void 0),
|
|
23091
|
+
...rows.filter((row) => row.operation === "update" && row.restore).map((row) => row.path)
|
|
23092
|
+
];
|
|
22138
23093
|
const rowCount = (operation, restore = false) => rows.filter((row) => row.operation === operation && (row.restore ?? false) === restore).length;
|
|
22139
23094
|
return {
|
|
22140
23095
|
created: count("created") + rowCount("create"),
|
|
@@ -22143,6 +23098,7 @@ var PullPlanner = class {
|
|
|
22143
23098
|
// 복원은 updated 에 섞지 않는다 — dry-run 이 "수정 N건" 이라고만 말하면
|
|
22144
23099
|
// 사용자가 사라진 파일이 되살아난다는 사실을 미리 알 수 없다.
|
|
22145
23100
|
restored: restoreChanges.length + restoredModified + rowCount("update", true),
|
|
23101
|
+
...restoredPaths.length > 0 ? { restoredPaths } : {},
|
|
22146
23102
|
conflicts: [],
|
|
22147
23103
|
writtenPaths: [],
|
|
22148
23104
|
failed,
|
|
@@ -22217,6 +23173,42 @@ var PullPlanner = class {
|
|
|
22217
23173
|
}
|
|
22218
23174
|
};
|
|
22219
23175
|
|
|
23176
|
+
// src/sync/deletion-sweep.ts
|
|
23177
|
+
var DELETION_SWEEP_CURSOR_META_KEY = "deletion_sweep_cursor";
|
|
23178
|
+
var DELETION_SWEEP_BUDGET = 15;
|
|
23179
|
+
function sweepable(records, skip) {
|
|
23180
|
+
return records.filter((r) => r.notionPageId !== null && !skip.has(idOf(r))).sort((a, b) => idOf(a) < idOf(b) ? -1 : idOf(a) > idOf(b) ? 1 : 0);
|
|
23181
|
+
}
|
|
23182
|
+
function sweepBatch(records, cursor, skip, budget = DELETION_SWEEP_BUDGET) {
|
|
23183
|
+
const ordered = sweepable(records, skip);
|
|
23184
|
+
if (ordered.length === 0) return { batch: [], next: null, complete: true };
|
|
23185
|
+
if (ordered.length <= budget) {
|
|
23186
|
+
return { batch: ordered, next: idOf(ordered[ordered.length - 1]), complete: true };
|
|
23187
|
+
}
|
|
23188
|
+
const start = Math.max(0, cursor === null ? 0 : ordered.findIndex((r) => idOf(r) > cursor));
|
|
23189
|
+
const batch = Array.from({ length: budget }, (_, i) => ordered[(start + i) % ordered.length]);
|
|
23190
|
+
return { batch, next: idOf(batch[batch.length - 1]), complete: false };
|
|
23191
|
+
}
|
|
23192
|
+
function trackedDescendants(records, goneIds) {
|
|
23193
|
+
const gone = new Set(goneIds);
|
|
23194
|
+
const out = [];
|
|
23195
|
+
for (let grew = true; grew; ) {
|
|
23196
|
+
grew = false;
|
|
23197
|
+
for (const record of records) {
|
|
23198
|
+
if (record.notionPageId === null || record.notionParentId === null) continue;
|
|
23199
|
+
const id = idOf(record);
|
|
23200
|
+
if (gone.has(id) || !gone.has(compactNotionId(record.notionParentId))) continue;
|
|
23201
|
+
gone.add(id);
|
|
23202
|
+
out.push(record);
|
|
23203
|
+
grew = true;
|
|
23204
|
+
}
|
|
23205
|
+
}
|
|
23206
|
+
return out;
|
|
23207
|
+
}
|
|
23208
|
+
function idOf(record) {
|
|
23209
|
+
return compactNotionId(record.notionPageId);
|
|
23210
|
+
}
|
|
23211
|
+
|
|
22220
23212
|
// src/sync/remote-detector.ts
|
|
22221
23213
|
var RemoteDetector = class _RemoteDetector {
|
|
22222
23214
|
constructor(config, stateDb, notionClient, observation) {
|
|
@@ -22252,6 +23244,12 @@ var RemoteDetector = class _RemoteDetector {
|
|
|
22252
23244
|
// Notion 에서 부모가 바뀐 추적 페이지(정규화 id → 새 부모 페이지 id) — pull 이 볼트에서도 옮긴다(ADR-039).
|
|
22253
23245
|
// 옮겨도 수정 시각이 바뀌지 않아(실측) 변경으로는 보이지 않는다. 감지마다 다시 채운다.
|
|
22254
23246
|
_moved = /* @__PURE__ */ new Map();
|
|
23247
|
+
// 증분 감지가 search 로 살아 있다고 본 페이지(압축 id) — 삭제 확인이 다시 묻지 않는다.
|
|
23248
|
+
_seenAlive = /* @__PURE__ */ new Set();
|
|
23249
|
+
// 이번 감지가 삭제를 물은 뒤의 커서 — pull 이 마칠 때 적는다({@link saveDeletionSweep}). undefined 면 묻지 않았다.
|
|
23250
|
+
_sweepCursor;
|
|
23251
|
+
// 이번 감지가 추적 중인 것을 모두 물었나 — 그러면 증분이어도 미룬 삭제가 없다.
|
|
23252
|
+
_sweptAll = false;
|
|
22255
23253
|
/** 이번 감지에서 자식 페이지를 가진 것으로 본 페이지인가 — 폴더노트 판정의 1차 근거. */
|
|
22256
23254
|
hasChildPages(pageId) {
|
|
22257
23255
|
return this._childParentIds.has(normalizeNotionId(pageId));
|
|
@@ -22293,7 +23291,7 @@ var RemoteDetector = class _RemoteDetector {
|
|
|
22293
23291
|
throwIfAborted(signal);
|
|
22294
23292
|
try {
|
|
22295
23293
|
const page = await this.notionClient.getPage(childId);
|
|
22296
|
-
if (page
|
|
23294
|
+
if (isTrashedOrArchived(page)) continue;
|
|
22297
23295
|
const actualParent = await extractParentId(this.notionClient, page);
|
|
22298
23296
|
if (!actualParent || !notionIdsEqual(actualParent, parentId)) continue;
|
|
22299
23297
|
this._childParentIds.add(normalizeNotionId(parentId));
|
|
@@ -22356,10 +23354,83 @@ var RemoteDetector = class _RemoteDetector {
|
|
|
22356
23354
|
* @param options.databases 바뀐 DB 도 찾는다(pull · dry-run). 상태 확인은 DB 를 조회하지 않아 찾지 않는다.
|
|
22357
23355
|
*/
|
|
22358
23356
|
async detectRemote(scan, options) {
|
|
23357
|
+
this._seenAlive.clear();
|
|
23358
|
+
this._sweepCursor = void 0;
|
|
23359
|
+
this._sweptAll = false;
|
|
22359
23360
|
const detection = scan.kind === "full" ? { changes: await this.detectRemoteChanges(options.signal), databaseIds: null } : await this.detectRemoteChangesIncremental(scan.since, options);
|
|
23361
|
+
if (options.databases) {
|
|
23362
|
+
detection.changes.push(
|
|
23363
|
+
...await this.sweepDeletions(scan, detection.changes, options.signal)
|
|
23364
|
+
);
|
|
23365
|
+
}
|
|
22360
23366
|
this.beginDetection(detection.changes);
|
|
22361
23367
|
return detection;
|
|
22362
23368
|
}
|
|
23369
|
+
/**
|
|
23370
|
+
* 이번 감지가 보지 못하는 삭제를 차례로 몇 개씩 원격에 묻는다({@link sweepBatch}). Notion 은 지운 것을
|
|
23371
|
+
* 목록으로 주지 않는다 — search · DB 조회 모두 휴지통을 돌려주지 않고 거르지도 못한다(ADR-044).
|
|
23372
|
+
*
|
|
23373
|
+
* - 증분 → 추적 중인 페이지 · 행 모두가 차례에 든다. search 가 살아 있다고 보인 것은 뺀다.
|
|
23374
|
+
* - 전체 대조 → 조회 필터를 단 DB 의 행만. 나머지는 순회 · DB 조회가 가른다. 필터를 단 DB 는 조회에 없는
|
|
23375
|
+
* 행이 필터 밖인지 지워진 것인지 몰라 가르지 않는다(DatabaseSyncer) — 여기서 하나씩 묻는다.
|
|
23376
|
+
*
|
|
23377
|
+
* 지워진 페이지 아래의 추적 페이지는 차례와 상관없이 함께 묻는다 — 부모를 휴지통에 넣으면 그 아래도 모두
|
|
23378
|
+
* 휴지통이다(실측 2026-10-28). 삭제는 deleteSync 일 때만, 첫 pull 이 아닐 때만 가른다 — 전체 대조와 같다.
|
|
23379
|
+
*/
|
|
23380
|
+
async sweepDeletions(scan, found2, signal) {
|
|
23381
|
+
if (!this.config.sync.deleteSync || !this.stateDb.getMeta(LAST_PULL_META_KEY)) return [];
|
|
23382
|
+
const records = this.stateDb.getAll();
|
|
23383
|
+
const candidates = scan.kind === "incremental" ? records : records.filter((r) => this.inFilteredDatabase(r));
|
|
23384
|
+
const skip = /* @__PURE__ */ new Set([...this._seenAlive, ...found2.map((c) => compactNotionId(c.pageId))]);
|
|
23385
|
+
const { batch, next, complete: complete2 } = sweepBatch(
|
|
23386
|
+
candidates,
|
|
23387
|
+
this.stateDb.getMeta(DELETION_SWEEP_CURSOR_META_KEY) || null,
|
|
23388
|
+
skip
|
|
23389
|
+
);
|
|
23390
|
+
this._sweepCursor = next;
|
|
23391
|
+
this._sweptAll = scan.kind === "incremental" && complete2;
|
|
23392
|
+
const deletions = [];
|
|
23393
|
+
const gone = /* @__PURE__ */ new Set();
|
|
23394
|
+
const ask = async (record) => {
|
|
23395
|
+
throwIfAborted(signal);
|
|
23396
|
+
const id = compactNotionId(record.notionPageId);
|
|
23397
|
+
try {
|
|
23398
|
+
if ((await remotePresence(this.notionClient, record.notionPageId)).kind !== "gone") return;
|
|
23399
|
+
} catch (error) {
|
|
23400
|
+
getLogger().warn(
|
|
23401
|
+
`[Im-Nobsidian] ${record.obsidianPath} \u2014 \uC6D0\uACA9\uC744 \uD655\uC778\uD558\uC9C0 \uBABB\uD574 \uC774\uBC88\uC5D0\uB294 \uC0AD\uC81C\uB97C \uAC00\uB974\uC9C0 \uC54A\uC74C:`,
|
|
23402
|
+
error
|
|
23403
|
+
);
|
|
23404
|
+
return;
|
|
23405
|
+
}
|
|
23406
|
+
gone.add(id);
|
|
23407
|
+
deletions.push(remoteDeletionChange(record));
|
|
23408
|
+
};
|
|
23409
|
+
for (const record of batch) await ask(record);
|
|
23410
|
+
const asked = new Set(batch.map((r) => compactNotionId(r.notionPageId)));
|
|
23411
|
+
for (const record of trackedDescendants(records, gone)) {
|
|
23412
|
+
const id = compactNotionId(record.notionPageId);
|
|
23413
|
+
if (!asked.has(id) && !skip.has(id)) await ask(record);
|
|
23414
|
+
}
|
|
23415
|
+
return deletions;
|
|
23416
|
+
}
|
|
23417
|
+
/** 조회 필터(`pullFilter`)를 단 설정 DB 의 행인가. */
|
|
23418
|
+
inFilteredDatabase(record) {
|
|
23419
|
+
if (record.fileType !== "db-row" || record.notionParentId === null) return false;
|
|
23420
|
+
return (this.config.notion.databases ?? []).some(
|
|
23421
|
+
(db) => db.pullFilter !== void 0 && notionIdsEqual(db.databaseId, record.notionParentId)
|
|
23422
|
+
);
|
|
23423
|
+
}
|
|
23424
|
+
/** 이번 감지가 추적 중인 페이지 · 행을 모두 원격에 물었나 — 결과의 `deletionsDeferred` 를 가른다. */
|
|
23425
|
+
deletionsChecked() {
|
|
23426
|
+
return this._sweptAll;
|
|
23427
|
+
}
|
|
23428
|
+
/** 삭제를 물은 자리를 적는다 — 다음 pull 이 이어서 묻는다. 경로를 좁힌 pull · dry-run 은 부르지 않는다. */
|
|
23429
|
+
saveDeletionSweep() {
|
|
23430
|
+
if (this._sweepCursor !== void 0) {
|
|
23431
|
+
this.stateDb.setMeta(DELETION_SWEEP_CURSOR_META_KEY, this._sweepCursor ?? "");
|
|
23432
|
+
}
|
|
23433
|
+
}
|
|
22363
23434
|
/**
|
|
22364
23435
|
* 전체 대조 — 루트 아래 페이지를 모두 훑고 추적 중인데 목록에 없는 페이지를 원격에 물어 삭제를
|
|
22365
23436
|
* 가른다. 취소하면 {@link OperationAbortedError} 를 던진다 — 다 훑지 못한 목록으로 삭제를 가르지
|
|
@@ -22500,9 +23571,8 @@ var RemoteDetector = class _RemoteDetector {
|
|
|
22500
23571
|
}
|
|
22501
23572
|
/**
|
|
22502
23573
|
* 증분 원격 변경 감지 — `since` 이후 수정된 페이지만 search 로 받아 created/modified 만
|
|
22503
|
-
* 만든다.
|
|
22504
|
-
*
|
|
22505
|
-
* 전체 대조는 주기마다 돈다(ADR-027 — 예전에는 deleteSync 가 켜져 있으면 매번 돌았다. I10).
|
|
23574
|
+
* 만든다. search API 는 in_trash/archived 페이지를 반환하지 않아 삭제는 여기서 보이지 않는다 —
|
|
23575
|
+
* 차례로 묻는 삭제 확인({@link sweepDeletions})과 주기마다 도는 전체 대조(ADR-027)가 가른다.
|
|
22506
23576
|
*
|
|
22507
23577
|
* 바뀐 행 · 새 행 · 스키마를 고친 DB 도 모은다 — pull 은 그 DB 만 조회한다. 새 행은 페이지로 받지
|
|
22508
23578
|
* 않는다: DB 조회가 행 속성과 함께 받는다. 그래서 부모를 묻지 않는다(행마다 요청 1회 이상 아낀다).
|
|
@@ -22520,6 +23590,7 @@ var RemoteDetector = class _RemoteDetector {
|
|
|
22520
23590
|
const recentPages = await this.notionClient.searchRecentPages(searchSince, options.signal);
|
|
22521
23591
|
const untracked = [];
|
|
22522
23592
|
for (const page of recentPages) {
|
|
23593
|
+
this._seenAlive.add(compactNotionId(page.id));
|
|
22523
23594
|
const record = this.stateDb.getByNotionId(page.id);
|
|
22524
23595
|
if (!record) {
|
|
22525
23596
|
if (page.parentDatabaseId) {
|
|
@@ -22673,6 +23744,9 @@ function isRerenderable(record) {
|
|
|
22673
23744
|
}
|
|
22674
23745
|
|
|
22675
23746
|
// src/sync/missing-local-files.ts
|
|
23747
|
+
function restoredWhenMissing(record) {
|
|
23748
|
+
return record.notionPageId !== null && !isFolderRecord(record) && (record.fileType === "file" || record.fileType === "folder-note" || record.fileType === "db-row");
|
|
23749
|
+
}
|
|
22676
23750
|
async function detectMissingLocalFiles(config, stateDb, vaultFs, paths) {
|
|
22677
23751
|
if (config.sync.deleteSync) return [];
|
|
22678
23752
|
const records = stateDb.getAll();
|
|
@@ -22951,11 +24025,11 @@ ${text}
|
|
|
22951
24025
|
skipped.push({ what: "\uBCF4\uAE30", reason: ".base \uB97C \uC77D\uC9C0 \uBABB\uD588\uB2E4" });
|
|
22952
24026
|
return [];
|
|
22953
24027
|
}
|
|
22954
|
-
const
|
|
22955
|
-
for (const p of properties)
|
|
24028
|
+
const idOf2 = /* @__PURE__ */ new Map();
|
|
24029
|
+
for (const p of properties) idOf2.set(p.type === "title" ? "file.name" : p.name, p.id);
|
|
22956
24030
|
const columnId = (raw) => {
|
|
22957
24031
|
if (typeof raw !== "string") return void 0;
|
|
22958
|
-
return
|
|
24032
|
+
return idOf2.get(raw) ?? idOf2.get(raw.replace(/^note\./, ""));
|
|
22959
24033
|
};
|
|
22960
24034
|
const views = [];
|
|
22961
24035
|
const list = Array.isArray(parsed.views) ? parsed.views : [];
|
|
@@ -23114,6 +24188,14 @@ var SyncOrchestrator = class {
|
|
|
23114
24188
|
vaultFs,
|
|
23115
24189
|
() => this.placement.folderLookup()
|
|
23116
24190
|
);
|
|
24191
|
+
this.remoteRenames = new RemoteRenameFollower(
|
|
24192
|
+
stateDb,
|
|
24193
|
+
vaultFs,
|
|
24194
|
+
notionClient,
|
|
24195
|
+
async (pageId) => this.detector.discoveredPage(pageId) ?? await notionClient.getPage(pageId),
|
|
24196
|
+
config.notion.databases ?? []
|
|
24197
|
+
);
|
|
24198
|
+
this.linkUpdater = new LinkUpdater(stateDb, vaultFs);
|
|
23117
24199
|
this.puller = new PagePuller(
|
|
23118
24200
|
config,
|
|
23119
24201
|
stateDb,
|
|
@@ -23182,6 +24264,8 @@ var SyncOrchestrator = class {
|
|
|
23182
24264
|
// 이번 실행의 로컬 변경 — 옮긴 노트 · 폴더를 짝짓고 상태 DB 에 옮겨 적는다(S-11).
|
|
23183
24265
|
planner;
|
|
23184
24266
|
remoteMoves;
|
|
24267
|
+
remoteRenames;
|
|
24268
|
+
linkUpdater;
|
|
23185
24269
|
// 원격에서 바뀐 것 — 전체 대조 · 증분 감지(ADR-027).
|
|
23186
24270
|
detector;
|
|
23187
24271
|
// 지난번에 본 뒤로 원격이 바뀌었나(N-05) — 덮어쓰기 · 지우기 전에, 변경을 보이기 전에 본다.
|
|
@@ -23240,10 +24324,11 @@ var SyncOrchestrator = class {
|
|
|
23240
24324
|
this.rowSchemas.clear();
|
|
23241
24325
|
this.placement.resetUnpreparedFolders();
|
|
23242
24326
|
this.databaseCreator.reset();
|
|
24327
|
+
this.pusher.takeReadOnlySkips();
|
|
23243
24328
|
if (!options?.dryRun) await this.recovery.recoverInterruptedPushOps();
|
|
23244
24329
|
const plan = await this.planner.planLocalChanges(await this.vaultFs.listMarkdownFileStats());
|
|
23245
24330
|
const view = this.planner.localView(plan);
|
|
23246
|
-
if (!options?.dryRun) this.planner.adoptLocalMoves(plan);
|
|
24331
|
+
if (!options?.dryRun) await this.planner.adoptLocalMoves(plan);
|
|
23247
24332
|
const changes = plan.scan.changes;
|
|
23248
24333
|
const conflictPaths = new Set(this.stateDb.getByStatus("conflict").map((r) => r.obsidianPath));
|
|
23249
24334
|
const eligible = options?.force ? changes : changes.filter((c) => !conflictPaths.has(c.path));
|
|
@@ -23293,6 +24378,8 @@ var SyncOrchestrator = class {
|
|
|
23293
24378
|
view.lookup,
|
|
23294
24379
|
!options?.paths
|
|
23295
24380
|
);
|
|
24381
|
+
const unsentDeletions2 = applicable.filter((change) => this.unsentDeletion(change)).map((change) => change.path);
|
|
24382
|
+
const readOnlySkipped = await this.pusher.previewReadOnlySkips(applicable);
|
|
23296
24383
|
return {
|
|
23297
24384
|
createdDatabases: newDatabases.size,
|
|
23298
24385
|
created: count("create"),
|
|
@@ -23300,13 +24387,16 @@ var SyncOrchestrator = class {
|
|
|
23300
24387
|
deleted: count("delete"),
|
|
23301
24388
|
moved: count("move"),
|
|
23302
24389
|
failed: refusedFailures,
|
|
23303
|
-
duration: Date.now() - startTime
|
|
24390
|
+
duration: Date.now() - startTime,
|
|
24391
|
+
...unsentDeletions2.length > 0 ? { unsentDeletions: unsentDeletions2 } : {},
|
|
24392
|
+
...readOnlySkipped.length > 0 ? { readOnlySkipped } : {}
|
|
23304
24393
|
};
|
|
23305
24394
|
}
|
|
23306
24395
|
this.stateDb.setMeta(PUSH_IN_PROGRESS_META_KEY, "true");
|
|
23307
24396
|
await this.observation.resolveBotUserId();
|
|
23308
24397
|
const counts = { created: 0, updated: 0, deleted: 0, moved: 0 };
|
|
23309
24398
|
const failed = [...refusedFailures];
|
|
24399
|
+
const unsentDeletions = [];
|
|
23310
24400
|
let completed = 0;
|
|
23311
24401
|
const total = movableFolders.length + applicable.length;
|
|
23312
24402
|
counts.moved += await this.pusher.pushFolderMoves(
|
|
@@ -23334,6 +24424,7 @@ var SyncOrchestrator = class {
|
|
|
23334
24424
|
case "deleted": {
|
|
23335
24425
|
const propagated = await this.pusher.pushDelete(change.path);
|
|
23336
24426
|
if (propagated) counts.deleted++;
|
|
24427
|
+
else if (this.unsentDeletion(change)) unsentDeletions.push(change.path);
|
|
23337
24428
|
break;
|
|
23338
24429
|
}
|
|
23339
24430
|
}
|
|
@@ -23489,9 +24580,20 @@ var SyncOrchestrator = class {
|
|
|
23489
24580
|
deleted: counts.deleted,
|
|
23490
24581
|
moved: counts.moved,
|
|
23491
24582
|
failed,
|
|
23492
|
-
duration: Date.now() - startTime
|
|
24583
|
+
duration: Date.now() - startTime,
|
|
24584
|
+
readOnlySkipped: this.pusher.takeReadOnlySkips(),
|
|
24585
|
+
...unsentDeletions.length > 0 ? { unsentDeletions } : {}
|
|
23493
24586
|
};
|
|
23494
24587
|
}
|
|
24588
|
+
/**
|
|
24589
|
+
* 볼트에서 지운 이 노트를 Notion 에서 지우지 않아 다음 pull 이 되살리나 — deleteSync 가 꺼져 있고 pull 이
|
|
24590
|
+
* 되살리는 레코드일 때. 예전에는 대기로 적기만 하고 알리지 않아, 지운 파일이 말없이 돌아왔다(D8).
|
|
24591
|
+
*/
|
|
24592
|
+
unsentDeletion(change) {
|
|
24593
|
+
if (change.type !== "deleted" || this.config.sync.deleteSync) return false;
|
|
24594
|
+
const record = this.stateDb.getByPath(change.path);
|
|
24595
|
+
return !!record && restoredWhenMissing(record);
|
|
24596
|
+
}
|
|
23495
24597
|
/**
|
|
23496
24598
|
* 만든 DB 에 relation · rollup · formula 를 붙이고, 행 스키마를 다시 읽게 한 뒤 보기를 만든다. 실패는
|
|
23497
24599
|
* 다음 push 가 이어 붙인다 — 행은 그대로 올린다.
|
|
@@ -23526,6 +24628,16 @@ var SyncOrchestrator = class {
|
|
|
23526
24628
|
async pull(options) {
|
|
23527
24629
|
return this.gate.run("pull", () => this.executePull(options));
|
|
23528
24630
|
}
|
|
24631
|
+
/** 새 원격 페이지 가운데 동기화에서 뺀 자리에 놓일 것 — 페이지 id → 그 자리. dry-run 이 받지 않을 것으로 알린다. */
|
|
24632
|
+
async plannedExclusions(changes) {
|
|
24633
|
+
const exclusions = /* @__PURE__ */ new Map();
|
|
24634
|
+
for (const change of changes) {
|
|
24635
|
+
if (change.type !== "created") continue;
|
|
24636
|
+
const path = await this.puller.plannedExclusion(change.pageId);
|
|
24637
|
+
if (path !== null) exclusions.set(change.pageId, path);
|
|
24638
|
+
}
|
|
24639
|
+
return exclusions;
|
|
24640
|
+
}
|
|
23529
24641
|
/** 감지가 본, Notion 에서 옮긴 페이지를 볼트에서 옮긴다 — 경로를 좁힌 pull 은 그 범위의 것만. */
|
|
23530
24642
|
async followRemoteMoves(options) {
|
|
23531
24643
|
const moved = new Map(
|
|
@@ -23537,11 +24649,24 @@ var SyncOrchestrator = class {
|
|
|
23537
24649
|
if (moved.size === 0) return { moved: [], skipped: [] };
|
|
23538
24650
|
return await this.remoteMoves.follow(moved, { dryRun: options?.dryRun });
|
|
23539
24651
|
}
|
|
24652
|
+
/**
|
|
24653
|
+
* Notion 에서 제목을 바꾼 페이지의 노트 이름을 바꾼다 — `sync.renameOnTitleChange` 를 켰을 때만, 경로를 좁힌 pull 은
|
|
24654
|
+
* 그 범위의 것만. 설정이 뺀 노트는 두지 않는다.
|
|
24655
|
+
*/
|
|
24656
|
+
async followRemoteRenames(changes, options) {
|
|
24657
|
+
if (!this.config.sync.renameOnTitleChange) return { renamed: [], skipped: [], applied: [] };
|
|
24658
|
+
const scoped = changes.filter((change) => {
|
|
24659
|
+
const record = this.stateDb.getByNotionId(change.pageId);
|
|
24660
|
+
return !!record && (!options?.paths || inAnyPathScope(record.obsidianPath, options.paths));
|
|
24661
|
+
});
|
|
24662
|
+
return await this.remoteRenames.follow(scoped, { dryRun: options?.dryRun });
|
|
24663
|
+
}
|
|
23540
24664
|
async executePull(options) {
|
|
23541
24665
|
const startTime = Date.now();
|
|
23542
24666
|
this.assertConnectionScope();
|
|
23543
24667
|
this.puller.beginPull();
|
|
23544
24668
|
this.rowSchemas.clear();
|
|
24669
|
+
this.databaseSyncer.takeRestoredRows();
|
|
23545
24670
|
const emptyResult = {
|
|
23546
24671
|
created: 0,
|
|
23547
24672
|
updated: 0,
|
|
@@ -23568,12 +24693,14 @@ var SyncOrchestrator = class {
|
|
|
23568
24693
|
`[Im-Nobsidian] \uBCFC\uD2B8\uB97C \uC77D\uC9C0 \uBABB\uD574 \uC62E\uAE34 \uB178\uD2B8\uB97C \uD655\uC778\uD558\uC9C0 \uBABB\uD568 \u2014 \uC774\uBC88 pull \uC740 \uC0AC\uB77C\uC9C4 \uB178\uD2B8\uB97C \uB418\uC0B4\uB9AC\uC9C0 \uC54A\uB294\uB2E4: ${error instanceof Error ? error.message : String(error)}`
|
|
23569
24694
|
);
|
|
23570
24695
|
}
|
|
23571
|
-
if (localPlan && !options?.dryRun) this.planner.adoptLocalMoves(localPlan);
|
|
24696
|
+
if (localPlan && !options?.dryRun) await this.planner.adoptLocalMoves(localPlan);
|
|
23572
24697
|
const adopting = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
|
|
23573
24698
|
const counts = { created: 0, updated: 0, deleted: 0, restored: 0 };
|
|
23574
24699
|
this.discovery.clearInlineRefs();
|
|
23575
24700
|
const conflicts = [];
|
|
23576
24701
|
const writtenPaths = [];
|
|
24702
|
+
const restoredPaths = [];
|
|
24703
|
+
const excluded = [];
|
|
23577
24704
|
const failed = [];
|
|
23578
24705
|
let detected = [];
|
|
23579
24706
|
const applied = /* @__PURE__ */ new Set();
|
|
@@ -23597,6 +24724,12 @@ var SyncOrchestrator = class {
|
|
|
23597
24724
|
const remoteChanges = detection.changes;
|
|
23598
24725
|
detected = remoteChanges;
|
|
23599
24726
|
const remoteMoves = await this.followRemoteMoves(options);
|
|
24727
|
+
const remoteRenames = await this.followRemoteRenames(remoteChanges, options);
|
|
24728
|
+
const relocated = {
|
|
24729
|
+
moved: [...remoteMoves.moved, ...remoteRenames.renamed],
|
|
24730
|
+
skipped: [...remoteMoves.skipped, ...remoteRenames.skipped],
|
|
24731
|
+
applied: [...remoteMoves.moved, ...remoteRenames.applied]
|
|
24732
|
+
};
|
|
23600
24733
|
const pendingDatabases = parsePendingDatabases(
|
|
23601
24734
|
this.stateDb.getMeta(PENDING_DATABASES_META_KEY)
|
|
23602
24735
|
);
|
|
@@ -23610,6 +24743,10 @@ var SyncOrchestrator = class {
|
|
|
23610
24743
|
pendingDatabases
|
|
23611
24744
|
);
|
|
23612
24745
|
const finalize = async (created, updated, deleted, restored = 0) => {
|
|
24746
|
+
await this.puller.removeVacatedFolders();
|
|
24747
|
+
const renamedRows = this.databaseSyncer.takeRenamedRows();
|
|
24748
|
+
restoredPaths.push(...this.databaseSyncer.takeRestoredRows());
|
|
24749
|
+
const relinked = await this.linkUpdater.apply([...relocated.applied, ...renamedRows.applied]);
|
|
23613
24750
|
const linkCount = writtenPaths.length > 0 ? await this.resolveNotionLinks(writtenPaths) : 0;
|
|
23614
24751
|
if (!options?.paths) {
|
|
23615
24752
|
const startedAt = new Date(startTime).toISOString();
|
|
@@ -23624,6 +24761,7 @@ var SyncOrchestrator = class {
|
|
|
23624
24761
|
this.stateDb.setMeta(LAST_FULL_PULL_META_KEY, startedAt);
|
|
23625
24762
|
}
|
|
23626
24763
|
this.detector.savePendingDatabases(ledger);
|
|
24764
|
+
this.detector.saveDeletionSweep();
|
|
23627
24765
|
}
|
|
23628
24766
|
this.stateDb.setMeta(LAST_SYNC_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
|
|
23629
24767
|
this.stateDb.setMeta(PULL_IN_PROGRESS_META_KEY, "");
|
|
@@ -23632,6 +24770,7 @@ var SyncOrchestrator = class {
|
|
|
23632
24770
|
updated,
|
|
23633
24771
|
deleted,
|
|
23634
24772
|
restored,
|
|
24773
|
+
...restoredPaths.length > 0 ? { restoredPaths } : {},
|
|
23635
24774
|
conflicts,
|
|
23636
24775
|
writtenPaths,
|
|
23637
24776
|
failed,
|
|
@@ -23643,12 +24782,15 @@ var SyncOrchestrator = class {
|
|
|
23643
24782
|
...remoteScanInfo(
|
|
23644
24783
|
scan,
|
|
23645
24784
|
this.stateDb.getMeta(LAST_FULL_PULL_META_KEY),
|
|
23646
|
-
this.config.sync.deleteSync
|
|
24785
|
+
this.config.sync.deleteSync,
|
|
24786
|
+
this.detector.deletionsChecked()
|
|
23647
24787
|
),
|
|
23648
24788
|
skippedDatabases: ledger.skipped
|
|
23649
24789
|
},
|
|
23650
|
-
moved:
|
|
23651
|
-
moveSkipped:
|
|
24790
|
+
moved: [...relocated.moved, ...renamedRows.renamed],
|
|
24791
|
+
moveSkipped: relocated.skipped,
|
|
24792
|
+
relinked,
|
|
24793
|
+
excluded
|
|
23652
24794
|
};
|
|
23653
24795
|
};
|
|
23654
24796
|
const excludes = (path) => this.vaultFs.excludesPath?.(path) ?? false;
|
|
@@ -23679,8 +24821,9 @@ var SyncOrchestrator = class {
|
|
|
23679
24821
|
}) : [];
|
|
23680
24822
|
const workItems = [...filtered, ...restoreChanges, ...rerenderChanges];
|
|
23681
24823
|
if (options?.dryRun) {
|
|
24824
|
+
const exclusions = await this.plannedExclusions(filtered);
|
|
23682
24825
|
const plan = await this.pullPlanner.planPull(
|
|
23683
|
-
filtered,
|
|
24826
|
+
filtered.filter((change) => !exclusions.has(change.pageId)),
|
|
23684
24827
|
restoreChanges,
|
|
23685
24828
|
restoreIds,
|
|
23686
24829
|
localPlan,
|
|
@@ -23689,7 +24832,16 @@ var SyncOrchestrator = class {
|
|
|
23689
24832
|
scan,
|
|
23690
24833
|
ledger
|
|
23691
24834
|
);
|
|
23692
|
-
return {
|
|
24835
|
+
return {
|
|
24836
|
+
...plan,
|
|
24837
|
+
remoteScan: plan.remoteScan && {
|
|
24838
|
+
...plan.remoteScan,
|
|
24839
|
+
deletionsDeferred: plan.remoteScan.deletionsDeferred && !this.detector.deletionsChecked()
|
|
24840
|
+
},
|
|
24841
|
+
moved: relocated.moved,
|
|
24842
|
+
moveSkipped: relocated.skipped,
|
|
24843
|
+
excluded: [...exclusions.values()]
|
|
24844
|
+
};
|
|
23693
24845
|
}
|
|
23694
24846
|
let pullTotal = workItems.length;
|
|
23695
24847
|
let pullCompleted = 0;
|
|
@@ -23764,10 +24916,14 @@ var SyncOrchestrator = class {
|
|
|
23764
24916
|
const applyChange = async (change) => {
|
|
23765
24917
|
switch (change.type) {
|
|
23766
24918
|
case "created": {
|
|
23767
|
-
const
|
|
23768
|
-
|
|
24919
|
+
const created = await this.puller.pullCreate(change.pageId);
|
|
24920
|
+
if (created.excluded) {
|
|
24921
|
+
excluded.push(created.path);
|
|
24922
|
+
return { path: created.path, unchanged: true };
|
|
24923
|
+
}
|
|
24924
|
+
writtenPaths.push(created.path);
|
|
23769
24925
|
counts.created++;
|
|
23770
|
-
return { path, unchanged: false };
|
|
24926
|
+
return { path: created.path, unchanged: false };
|
|
23771
24927
|
}
|
|
23772
24928
|
case "modified": {
|
|
23773
24929
|
const outcome = await this.puller.pullUpdate(change);
|
|
@@ -23775,8 +24931,10 @@ var SyncOrchestrator = class {
|
|
|
23775
24931
|
conflicts.push(outcome.conflict);
|
|
23776
24932
|
} else if (outcome.action === "written") {
|
|
23777
24933
|
writtenPaths.push(outcome.path);
|
|
23778
|
-
if (restoreIds.has(change.pageId))
|
|
23779
|
-
|
|
24934
|
+
if (restoreIds.has(change.pageId)) {
|
|
24935
|
+
counts.restored++;
|
|
24936
|
+
restoredPaths.push(outcome.path);
|
|
24937
|
+
} else counts.updated++;
|
|
23780
24938
|
}
|
|
23781
24939
|
return {
|
|
23782
24940
|
path: outcome.path,
|
|
@@ -23823,14 +24981,14 @@ var SyncOrchestrator = class {
|
|
|
23823
24981
|
if (options?.paths) return;
|
|
23824
24982
|
for (; ; ) {
|
|
23825
24983
|
if (options?.signal?.aborted) return;
|
|
23826
|
-
const
|
|
23827
|
-
if (
|
|
24984
|
+
const found2 = await this.detector.takeUndiscoveredChildren(options?.signal);
|
|
24985
|
+
if (found2.length === 0) return;
|
|
23828
24986
|
getLogger().info(
|
|
23829
|
-
`[Im-Nobsidian] \uAC10\uC9C0\uAC00 \uBE60\uB728\uB9B0 \uC790\uC2DD \uD398\uC774\uC9C0 ${
|
|
24987
|
+
`[Im-Nobsidian] \uAC10\uC9C0\uAC00 \uBE60\uB728\uB9B0 \uC790\uC2DD \uD398\uC774\uC9C0 ${found2.length}\uAC74\uC744 \uBD80\uBAA8 \uBCF8\uBB38\uC5D0\uC11C \uCC3E\uC544 \uBC1B\uC2B5\uB2C8\uB2E4`
|
|
23830
24988
|
);
|
|
23831
|
-
detected = [...detected, ...
|
|
23832
|
-
pullTotal +=
|
|
23833
|
-
await runPool(this.puller.orderCreates(
|
|
24989
|
+
detected = [...detected, ...found2];
|
|
24990
|
+
pullTotal += found2.length;
|
|
24991
|
+
await runPool(this.puller.orderCreates(found2), handle, {
|
|
23834
24992
|
concurrency: this.config.advanced.concurrency,
|
|
23835
24993
|
signal: options?.signal
|
|
23836
24994
|
});
|
|
@@ -23911,7 +25069,11 @@ var SyncOrchestrator = class {
|
|
|
23911
25069
|
await this.observation.resolveBotUserId();
|
|
23912
25070
|
const scan = this.detector.remoteScan({ force: false, deferDue: true });
|
|
23913
25071
|
const detection = await this.detector.detectRemote(scan, { signal, databases: false });
|
|
23914
|
-
const
|
|
25072
|
+
const detected = await this.drift.withoutUnchangedRemotes(detection.changes, signal);
|
|
25073
|
+
const moves = await this.followRemoteMoves({ dryRun: true });
|
|
25074
|
+
const renames = await this.followRemoteRenames(detected, { dryRun: true });
|
|
25075
|
+
const exclusions = await this.plannedExclusions(detected);
|
|
25076
|
+
const remoteChanges = detected.filter((change) => !exclusions.has(change.pageId));
|
|
23915
25077
|
const conflictRecords = this.stateDb.getByStatus("conflict");
|
|
23916
25078
|
throwIfAborted(signal);
|
|
23917
25079
|
const conflicts = await this.conflictWorkflow.buildConflictsFromRecords(
|
|
@@ -23929,7 +25091,10 @@ var SyncOrchestrator = class {
|
|
|
23929
25091
|
pendingOperations: conflictRecords.length,
|
|
23930
25092
|
lastSyncAt,
|
|
23931
25093
|
lastFullScanAt,
|
|
23932
|
-
remoteScan: remoteScanInfo(scan, lastFullScanAt, this.config.sync.deleteSync)
|
|
25094
|
+
remoteScan: remoteScanInfo(scan, lastFullScanAt, this.config.sync.deleteSync),
|
|
25095
|
+
remoteMoves: [...moves.moved, ...renames.renamed],
|
|
25096
|
+
remoteMoveSkipped: [...moves.skipped, ...renames.skipped],
|
|
25097
|
+
remoteExcluded: [...exclusions.values()]
|
|
23933
25098
|
};
|
|
23934
25099
|
}
|
|
23935
25100
|
/**
|
|
@@ -23997,7 +25162,12 @@ var SyncOrchestrator = class {
|
|
|
23997
25162
|
this.stateDb,
|
|
23998
25163
|
this.config.notion.rootPageId
|
|
23999
25164
|
);
|
|
24000
|
-
return {
|
|
25165
|
+
return {
|
|
25166
|
+
databases,
|
|
25167
|
+
pages,
|
|
25168
|
+
nestedFolderPages: findNestedFolderPages(this.stateDb.getAll()),
|
|
25169
|
+
complete: databases.complete && (pages?.complete ?? true)
|
|
25170
|
+
};
|
|
24001
25171
|
}
|
|
24002
25172
|
async fetch() {
|
|
24003
25173
|
return this.gate.run("fetch", () => this.executeFetch());
|
|
@@ -24115,6 +25285,10 @@ var PathFilter = class {
|
|
|
24115
25285
|
this.include = compileAll(include);
|
|
24116
25286
|
this.exclude = compileAll(exclude);
|
|
24117
25287
|
}
|
|
25288
|
+
/** 아무것도 빼지 않는다 — 포함 패턴이 없거나 전부(`**\/*`)이고 제외 패턴이 없다. */
|
|
25289
|
+
isEmpty() {
|
|
25290
|
+
return this.exclude.length === 0 && this.include.every((pattern) => pattern.kind === "all");
|
|
25291
|
+
}
|
|
24118
25292
|
/** 동기화하지 않는 노트 · 첨부 — 포함 패턴 어느 것에도 맞지 않거나 제외 패턴에 맞는다. */
|
|
24119
25293
|
excludesFile(path) {
|
|
24120
25294
|
if (this.include.length > 0 && !this.include.some((p) => matchesWithin(path, p))) return true;
|
|
@@ -24128,8 +25302,11 @@ var PathFilter = class {
|
|
|
24128
25302
|
return this.exclude.some((p) => matchesWithin(path, p));
|
|
24129
25303
|
}
|
|
24130
25304
|
};
|
|
24131
|
-
function
|
|
24132
|
-
return
|
|
25305
|
+
function parseIgnorePatterns(content) {
|
|
25306
|
+
return content.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#"));
|
|
25307
|
+
}
|
|
25308
|
+
function toVaultPath(relativePath2, separator) {
|
|
25309
|
+
return separator === "/" ? relativePath2 : relativePath2.split(separator).join("/");
|
|
24133
25310
|
}
|
|
24134
25311
|
function compileAll(patterns) {
|
|
24135
25312
|
return patterns.flatMap((raw) => {
|
|
@@ -24207,8 +25384,7 @@ var NodeVaultFS = class {
|
|
|
24207
25384
|
loadImNobsidianIgnore() {
|
|
24208
25385
|
try {
|
|
24209
25386
|
const ignorePath = join(this.rootPath, IGNORE_FILE);
|
|
24210
|
-
|
|
24211
|
-
return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
25387
|
+
return parseIgnorePatterns(readFileSync(ignorePath, "utf-8"));
|
|
24212
25388
|
} catch (error) {
|
|
24213
25389
|
if (error.code === "ENOENT") return [];
|
|
24214
25390
|
throw error;
|
|
@@ -24236,6 +25412,19 @@ var NodeVaultFS = class {
|
|
|
24236
25412
|
const fullPath = join(this.rootPath, path);
|
|
24237
25413
|
await unlink(fullPath);
|
|
24238
25414
|
}
|
|
25415
|
+
/** `rmdir` 는 비지 않은 폴더를 지우지 않는다(ENOTEMPTY) — 숨김 파일도 그대로 둔다. */
|
|
25416
|
+
async removeEmptyFolder(path) {
|
|
25417
|
+
try {
|
|
25418
|
+
await rmdir(join(this.rootPath, path));
|
|
25419
|
+
return true;
|
|
25420
|
+
} catch (error) {
|
|
25421
|
+
const code = error.code;
|
|
25422
|
+
if (code === "ENOTEMPTY" || code === "EEXIST" || code === "ENOENT" || code === "ENOTDIR") {
|
|
25423
|
+
return false;
|
|
25424
|
+
}
|
|
25425
|
+
throw error;
|
|
25426
|
+
}
|
|
25427
|
+
}
|
|
24239
25428
|
async moveFile(from, to) {
|
|
24240
25429
|
const fromPath = join(this.rootPath, from);
|
|
24241
25430
|
const toPath = join(this.rootPath, to);
|
|
@@ -24286,14 +25475,14 @@ var NodeVaultFS = class {
|
|
|
24286
25475
|
for (const entry of entries) {
|
|
24287
25476
|
const fullPath = join(dir, entry.name);
|
|
24288
25477
|
if (entry.name.startsWith(".")) continue;
|
|
24289
|
-
const
|
|
25478
|
+
const relativePath2 = this.vaultPath(fullPath);
|
|
24290
25479
|
if (entry.isDirectory()) {
|
|
24291
|
-
if (this.filter.excludesFolder(
|
|
25480
|
+
if (this.filter.excludesFolder(relativePath2)) continue;
|
|
24292
25481
|
await this.walkDirNonMd(fullPath, result);
|
|
24293
|
-
} else if (entry.isFile() && !isMarkdownPath(entry.name) && !this.filter.excludesFile(
|
|
25482
|
+
} else if (entry.isFile() && !isMarkdownPath(entry.name) && !this.filter.excludesFile(relativePath2)) {
|
|
24294
25483
|
const fileStat = await stat(fullPath);
|
|
24295
25484
|
result.push({
|
|
24296
|
-
path:
|
|
25485
|
+
path: relativePath2,
|
|
24297
25486
|
size: fileStat.size,
|
|
24298
25487
|
mtime: fileStat.mtime.toISOString()
|
|
24299
25488
|
});
|
|
@@ -24305,14 +25494,14 @@ var NodeVaultFS = class {
|
|
|
24305
25494
|
for (const entry of entries) {
|
|
24306
25495
|
const fullPath = join(dir, entry.name);
|
|
24307
25496
|
if (entry.name.startsWith(".")) continue;
|
|
24308
|
-
const
|
|
25497
|
+
const relativePath2 = this.vaultPath(fullPath);
|
|
24309
25498
|
if (entry.isDirectory()) {
|
|
24310
|
-
if (this.filter.excludesFolder(
|
|
25499
|
+
if (this.filter.excludesFolder(relativePath2)) continue;
|
|
24311
25500
|
await this.walkDirStats(fullPath, result);
|
|
24312
|
-
} else if (entry.isFile() && isMarkdownPath(entry.name) && !this.filter.excludesFile(
|
|
25501
|
+
} else if (entry.isFile() && isMarkdownPath(entry.name) && !this.filter.excludesFile(relativePath2)) {
|
|
24313
25502
|
const fileStat = await stat(fullPath);
|
|
24314
25503
|
result.push({
|
|
24315
|
-
path:
|
|
25504
|
+
path: relativePath2,
|
|
24316
25505
|
mtime: fileStat.mtime.toISOString(),
|
|
24317
25506
|
size: fileStat.size
|
|
24318
25507
|
});
|
|
@@ -24324,15 +25513,15 @@ var NodeVaultFS = class {
|
|
|
24324
25513
|
for (const entry of entries) {
|
|
24325
25514
|
const fullPath = join(dir, entry.name);
|
|
24326
25515
|
if (entry.name.startsWith(".")) continue;
|
|
24327
|
-
const
|
|
25516
|
+
const relativePath2 = this.vaultPath(fullPath);
|
|
24328
25517
|
if (entry.isDirectory()) {
|
|
24329
|
-
if (this.filter.excludesFolder(
|
|
25518
|
+
if (this.filter.excludesFolder(relativePath2)) continue;
|
|
24330
25519
|
await this.walkDir(fullPath, result);
|
|
24331
|
-
} else if (entry.isFile() && isMarkdownPath(entry.name) && !this.filter.excludesFile(
|
|
25520
|
+
} else if (entry.isFile() && isMarkdownPath(entry.name) && !this.filter.excludesFile(relativePath2)) {
|
|
24332
25521
|
const content = await readFile(fullPath, "utf-8");
|
|
24333
25522
|
const fileStat = await stat(fullPath);
|
|
24334
25523
|
result.push({
|
|
24335
|
-
path:
|
|
25524
|
+
path: relativePath2,
|
|
24336
25525
|
content,
|
|
24337
25526
|
mtime: fileStat.mtime.toISOString()
|
|
24338
25527
|
});
|
|
@@ -25138,18 +26327,81 @@ function summarizeFidelity(perFile) {
|
|
|
25138
26327
|
}
|
|
25139
26328
|
return summary;
|
|
25140
26329
|
}
|
|
26330
|
+
|
|
26331
|
+
// src/sync/filtered-vault-fs.ts
|
|
26332
|
+
var FilteredVaultFS = class {
|
|
26333
|
+
constructor(inner, filter) {
|
|
26334
|
+
this.inner = inner;
|
|
26335
|
+
this.filter = filter;
|
|
26336
|
+
if (inner.ownsPath) this.ownsPath = (path) => inner.ownsPath(path);
|
|
26337
|
+
if (inner.holdsOtherConnection) {
|
|
26338
|
+
this.holdsOtherConnection = (folder) => inner.holdsOtherConnection(folder);
|
|
26339
|
+
}
|
|
26340
|
+
}
|
|
26341
|
+
inner;
|
|
26342
|
+
filter;
|
|
26343
|
+
ownsPath;
|
|
26344
|
+
holdsOtherConnection;
|
|
26345
|
+
excludesPath(path, kind = "file") {
|
|
26346
|
+
const excluded = kind === "folder" ? this.filter.excludesFolder(path) : this.filter.excludesFile(path);
|
|
26347
|
+
return excluded || (this.inner.excludesPath?.(path, kind) ?? false);
|
|
26348
|
+
}
|
|
26349
|
+
async listMarkdownFiles() {
|
|
26350
|
+
return (await this.inner.listMarkdownFiles()).filter((f) => !this.filter.excludesFile(f.path));
|
|
26351
|
+
}
|
|
26352
|
+
async listMarkdownFileStats() {
|
|
26353
|
+
return (await this.inner.listMarkdownFileStats()).filter(
|
|
26354
|
+
(f) => !this.filter.excludesFile(f.path)
|
|
26355
|
+
);
|
|
26356
|
+
}
|
|
26357
|
+
async listNonMarkdownFiles() {
|
|
26358
|
+
return (await this.inner.listNonMarkdownFiles()).filter(
|
|
26359
|
+
(f) => !this.filter.excludesFile(f.path)
|
|
26360
|
+
);
|
|
26361
|
+
}
|
|
26362
|
+
readFile(path) {
|
|
26363
|
+
return this.inner.readFile(path);
|
|
26364
|
+
}
|
|
26365
|
+
readBinary(path) {
|
|
26366
|
+
return this.inner.readBinary(path);
|
|
26367
|
+
}
|
|
26368
|
+
writeFile(path, content) {
|
|
26369
|
+
return this.inner.writeFile(path, content);
|
|
26370
|
+
}
|
|
26371
|
+
writeBinary(path, data) {
|
|
26372
|
+
return this.inner.writeBinary(path, data);
|
|
26373
|
+
}
|
|
26374
|
+
deleteFile(path) {
|
|
26375
|
+
return this.inner.deleteFile(path);
|
|
26376
|
+
}
|
|
26377
|
+
moveFile(from, to) {
|
|
26378
|
+
return this.inner.moveFile(from, to);
|
|
26379
|
+
}
|
|
26380
|
+
exists(path) {
|
|
26381
|
+
return this.inner.exists(path);
|
|
26382
|
+
}
|
|
26383
|
+
ensureFolder(path) {
|
|
26384
|
+
return this.inner.ensureFolder(path);
|
|
26385
|
+
}
|
|
26386
|
+
async removeEmptyFolder(path) {
|
|
26387
|
+
return await this.inner.removeEmptyFolder?.(path) ?? false;
|
|
26388
|
+
}
|
|
26389
|
+
getFileStat(path) {
|
|
26390
|
+
return this.inner.getFileStat(path);
|
|
26391
|
+
}
|
|
26392
|
+
};
|
|
25141
26393
|
async function findConnectionFolders(vaultRoot) {
|
|
25142
|
-
const
|
|
26394
|
+
const found2 = [];
|
|
25143
26395
|
const walk = async (dir, rel) => {
|
|
25144
26396
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
25145
|
-
if (entries.some((e) => e.isDirectory() && e.name === INTERNAL_DIR))
|
|
26397
|
+
if (entries.some((e) => e.isDirectory() && e.name === INTERNAL_DIR)) found2.push(rel);
|
|
25146
26398
|
for (const entry of entries) {
|
|
25147
26399
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
25148
26400
|
await walk(join(dir, entry.name), rel === "" ? entry.name : `${rel}/${entry.name}`);
|
|
25149
26401
|
}
|
|
25150
26402
|
};
|
|
25151
26403
|
await walk(vaultRoot, "");
|
|
25152
|
-
return
|
|
26404
|
+
return found2;
|
|
25153
26405
|
}
|
|
25154
26406
|
|
|
25155
26407
|
// src/converter/design-markers.ts
|
|
@@ -25360,6 +26612,6 @@ function formatRatio(ratio) {
|
|
|
25360
26612
|
return String(Math.round(ratio * 100) / 100);
|
|
25361
26613
|
}
|
|
25362
26614
|
|
|
25363
|
-
export { BaseFileGenerator, BlankLineCollapser, CONFIG_FILE, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConnectionBoundaryError, ConnectionScopeError, ConversionPipeline, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DEFAULT_TITLE_PROPERTY, DEFAULT_WATCH_DEBOUNCE_MS, DIFF_SIGN, 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_COLOR_NAMES, NOTION_TOKEN_PREFIX, NOTION_TO_BASES_VIEW, NodeVaultFS, NotionBlockBuilder, NotionClient, OBSIDIAN_CONFIG_DIR, PathFilter, PendingWalError, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, RENDER_LINE_RULES, SAVED_STATE_TABLES_QUERY, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_MIGRATIONS, STATE_DB_PATH, SavedStateDbError, ScopedVaultFS, SidecarGenerator, StateDB, StateDbLockedError, StateDbUnavailableError, StateLock, SyncBusyError, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WIKILINK_LOOKUPS, WatchSyncService, WikilinkResolver, applicableChoices, assertNoPendingWal, basesViewMappingOf, basesViewTypeOf, choiceForStrategy, classifyBodyFidelity, columnLabel, compactNotionId, computeBufferHash, computeHash, configDirFrom, connectionFolderOf, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, findConnectionFolders, formatUnifiedDiff, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, hasColumnLayout, hasDesignMarker, inAnyPathScope, isInternalPath, isMarkdownPath, isRemoteDeletion, lineDiff, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, normalizeVaultPath, noteNameFromPath, notionIdFromInput, notionIdsEqual, operationLabel, parseDesignColor, pendingWalBytes, readColumnLayout, readDesignLine, resolvePropertyName, sanitizeFileName, setLogger, sortEntries, stateDbLockPath, stateDbWalPath, stripMarkdownExtension, summarizeFidelity, systemTimeZone, threeWayMerge, toVaultPath, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness, wikilinkLookupsIn };
|
|
26615
|
+
export { BaseFileGenerator, BlankLineCollapser, CONFIG_FILE, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConnectionBoundaryError, ConnectionScopeError, ConversionPipeline, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DEFAULT_TITLE_PROPERTY, DEFAULT_WATCH_DEBOUNCE_MS, DIFF_SIGN, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FilteredVaultFS, FrontmatterExtractor, FrontmatterGenerator, GITIGNORE_ENTRY, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, ImageHandler, InlineAnnotationPreserver, InlineDBParser, LocalImageRestorer, MARKER_BRAND, MathNormalizer, MentionToWikilink, NOTION_COLOR_NAMES, NOTION_TOKEN_PREFIX, NOTION_TO_BASES_VIEW, NodeVaultFS, NotionBlockBuilder, NotionClient, OBSIDIAN_CONFIG_DIR, PathFilter, PendingWalError, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, RENDER_LINE_RULES, SAVED_STATE_TABLES_QUERY, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_MIGRATIONS, STATE_DB_PATH, SavedStateDbError, ScopedVaultFS, SidecarGenerator, StateDB, StateDbLockedError, StateDbUnavailableError, StateLock, SyncBusyError, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WIKILINK_LOOKUPS, WatchSyncService, WikilinkResolver, applicableChoices, assertNoPendingWal, basesViewMappingOf, basesViewTypeOf, choiceForStrategy, classifyBodyFidelity, columnLabel, compactNotionId, computeBufferHash, computeHash, configDirFrom, connectionFolderOf, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, findConnectionFolders, findNestedFolderPages, formatUnifiedDiff, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, hasColumnLayout, hasDesignMarker, inAnyPathScope, isHeadingLine, isHeadingMarkerLine, isInternalPath, isMarkdownPath, isRemoteDeletion, lineDiff, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, normalizeVaultPath, noteNameFromPath, notionIdFromInput, notionIdsEqual, operationLabel, parseDesignColor, parseIgnorePatterns, pendingWalBytes, readColumnLayout, readDesignLine, resolvePropertyName, sanitizeFileName, sectionLinesWithHeadingMarkers, setLogger, sortEntries, stateDbLockPath, stateDbWalPath, stripMarkdownExtension, summarizeFidelity, systemTimeZone, threeWayMerge, toVaultPath, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness, wikilinkLookupsIn };
|
|
25364
26616
|
//# sourceMappingURL=index.js.map
|
|
25365
26617
|
//# sourceMappingURL=index.js.map
|