@im-nobsidian/core 0.19.1 → 0.21.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 +73 -11
- package/dist/index.js +938 -321
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -13397,6 +13397,29 @@ var ImageHandler = class {
|
|
|
13397
13397
|
get fetchFn() {
|
|
13398
13398
|
return this.customFetch ?? globalThis.fetch;
|
|
13399
13399
|
}
|
|
13400
|
+
/** {@link preview} 안에서 도는 중인 렌더 수 — 0 보다 크면 받은 미디어를 볼트에 쓰지 않고 상태 DB 에 적지 않는다. */
|
|
13401
|
+
previewDepth = 0;
|
|
13402
|
+
/**
|
|
13403
|
+
* 받기를 미리 본다(dry-run) — 미디어를 실제 pull 처럼 받아 이름(내용 해시)과 임베드를 정하되 볼트에 쓰지 않고 상태 DB 에
|
|
13404
|
+
* 적지 않는다. 받지 않고 렌더하면 미디어가 원격 주소로 남아, 받은 글을 볼트의 노트와 견줄 수 없다.
|
|
13405
|
+
*/
|
|
13406
|
+
async preview(run) {
|
|
13407
|
+
this.previewDepth++;
|
|
13408
|
+
try {
|
|
13409
|
+
return await run();
|
|
13410
|
+
} finally {
|
|
13411
|
+
this.previewDepth--;
|
|
13412
|
+
}
|
|
13413
|
+
}
|
|
13414
|
+
get previewing() {
|
|
13415
|
+
return this.previewDepth > 0;
|
|
13416
|
+
}
|
|
13417
|
+
/** 받은 파일을 볼트에 쓴다 — 미리 보기에서는 쓰지 않는다({@link preview}). */
|
|
13418
|
+
async writeDownload(localPath, buffer) {
|
|
13419
|
+
if (this.previewing) return;
|
|
13420
|
+
await this.vaultFs.ensureFolder(this.attachmentFolder);
|
|
13421
|
+
await this.vaultFs.writeBinary(localPath, buffer);
|
|
13422
|
+
}
|
|
13400
13423
|
/** 상한 로직은 첨부 경로와 공유한다 — 한쪽만 빠뜨리지 않도록(utils/download-fetch). */
|
|
13401
13424
|
fetchForDownload(url) {
|
|
13402
13425
|
return fetchForDownload(this.fetchFn, url, this.downloadTimeoutMs);
|
|
@@ -13460,8 +13483,7 @@ var ImageHandler = class {
|
|
|
13460
13483
|
return null;
|
|
13461
13484
|
}
|
|
13462
13485
|
async persistImage(fetched) {
|
|
13463
|
-
await this.
|
|
13464
|
-
await this.vaultFs.writeBinary(fetched.localPath, fetched.buffer);
|
|
13486
|
+
await this.writeDownload(fetched.localPath, fetched.buffer);
|
|
13465
13487
|
return {
|
|
13466
13488
|
originalUrl: fetched.originalUrl,
|
|
13467
13489
|
localPath: fetched.localPath,
|
|
@@ -13530,7 +13552,7 @@ var ImageHandler = class {
|
|
|
13530
13552
|
}
|
|
13531
13553
|
if (localHash !== remoteHash) continue;
|
|
13532
13554
|
const fileId = notionFileIdOf(span.url);
|
|
13533
|
-
if (fileId && this.stateDb) {
|
|
13555
|
+
if (fileId && this.stateDb && !this.previewing) {
|
|
13534
13556
|
rememberReceivedMedia(this.stateDb, pageId, [
|
|
13535
13557
|
{ fileId, target, localPath: candidate, fileHash: remoteHash }
|
|
13536
13558
|
]);
|
|
@@ -14001,7 +14023,7 @@ var ImageHandler = class {
|
|
|
14001
14023
|
*/
|
|
14002
14024
|
rememberReceived(pageId, received) {
|
|
14003
14025
|
const media = received.filter((item) => item !== null);
|
|
14004
|
-
if (!pageId || !this.stateDb || media.length === 0) return;
|
|
14026
|
+
if (!pageId || !this.stateDb || media.length === 0 || this.previewing) return;
|
|
14005
14027
|
try {
|
|
14006
14028
|
rememberReceivedMedia(this.stateDb, pageId, media);
|
|
14007
14029
|
} catch (error) {
|
|
@@ -14250,8 +14272,7 @@ var ImageHandler = class {
|
|
|
14250
14272
|
name
|
|
14251
14273
|
};
|
|
14252
14274
|
}
|
|
14253
|
-
await this.
|
|
14254
|
-
await this.vaultFs.writeBinary(localPath, buffer);
|
|
14275
|
+
await this.writeDownload(localPath, buffer);
|
|
14255
14276
|
return { originalUrl: url, localPath, hash, size: buffer.length, fileHash, name };
|
|
14256
14277
|
} catch (error) {
|
|
14257
14278
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
@@ -15163,12 +15184,56 @@ function folderFilesOf(markdown, namedOnly) {
|
|
|
15163
15184
|
for (const span of notionMediaSpans(rest)) {
|
|
15164
15185
|
if (!/^https?:\/\//.test(span.url)) return null;
|
|
15165
15186
|
const name = uploadedFileNameFromUrl(span.url);
|
|
15166
|
-
if (!name
|
|
15187
|
+
if (!name) return null;
|
|
15188
|
+
if (namedOnly && unescapeCaption(span.label) !== name && !isMarkedCaption(span.label, name)) {
|
|
15189
|
+
return null;
|
|
15190
|
+
}
|
|
15167
15191
|
rest = rest.replace(span.full, "");
|
|
15168
15192
|
files.push({ url: span.url, name });
|
|
15169
15193
|
}
|
|
15170
15194
|
return files.length > 0 && rest.trim() === "" ? files : null;
|
|
15171
15195
|
}
|
|
15196
|
+
var FOLDER_FILE_MARK = "\u{1F4C1}";
|
|
15197
|
+
function folderFileCaption(name) {
|
|
15198
|
+
return `${FOLDER_FILE_MARK} ${name}`;
|
|
15199
|
+
}
|
|
15200
|
+
function isMarkedCaption(label, name) {
|
|
15201
|
+
return unescapeCaption(label) === folderFileCaption(name);
|
|
15202
|
+
}
|
|
15203
|
+
var FENCE_RE = /^(`{3,}|~{3,})/;
|
|
15204
|
+
function folderFilesInNote(markdown, options = {}) {
|
|
15205
|
+
const all = markdown.split("\n");
|
|
15206
|
+
const drop = /* @__PURE__ */ new Set();
|
|
15207
|
+
const files = [];
|
|
15208
|
+
const lines = [];
|
|
15209
|
+
let fence = null;
|
|
15210
|
+
all.forEach((line, i) => {
|
|
15211
|
+
const opener = FENCE_RE.exec(line)?.[1];
|
|
15212
|
+
if (opener !== void 0 && (fence === null || opener.startsWith(fence))) {
|
|
15213
|
+
fence = fence === null ? opener : null;
|
|
15214
|
+
return;
|
|
15215
|
+
}
|
|
15216
|
+
if (fence !== null) return;
|
|
15217
|
+
const full = line.trimEnd();
|
|
15218
|
+
const [span, ...more] = notionMediaSpans(full);
|
|
15219
|
+
if (!span || more.length > 0 || span.full !== full || !/^https?:\/\//.test(span.url)) return;
|
|
15220
|
+
const name = uploadedFileNameFromUrl(span.url);
|
|
15221
|
+
if (!name) return;
|
|
15222
|
+
const fileId = notionFileIdOf(span.url);
|
|
15223
|
+
const marked = isMarkedCaption(span.label, name) || fileId !== null && options.knownIds?.has(fileId) === true;
|
|
15224
|
+
const file = { url: span.url, name };
|
|
15225
|
+
if (!marked || options.exclude?.(file)) return;
|
|
15226
|
+
drop.add(i);
|
|
15227
|
+
files.push(file);
|
|
15228
|
+
lines.push(full);
|
|
15229
|
+
});
|
|
15230
|
+
if (files.length === 0) return null;
|
|
15231
|
+
return {
|
|
15232
|
+
markdown: all.filter((_, i) => !drop.has(i)).join("\n"),
|
|
15233
|
+
files,
|
|
15234
|
+
lines
|
|
15235
|
+
};
|
|
15236
|
+
}
|
|
15172
15237
|
var WIKI_EMBED_RE = /!\[\[([^\]|#^\n]+)/g;
|
|
15173
15238
|
var MD_EMBED_RE = /!\[[^\]\n]*\]\(<?([^)>\s]+)/g;
|
|
15174
15239
|
var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
@@ -15196,8 +15261,8 @@ function embeddedNamesOf(notes) {
|
|
|
15196
15261
|
function keyOf3(pageId) {
|
|
15197
15262
|
return `folder_files:${compactNotionId(pageId)}`;
|
|
15198
15263
|
}
|
|
15199
|
-
function
|
|
15200
|
-
const raw = stateDb.getMeta(
|
|
15264
|
+
function readPathMap(stateDb, key) {
|
|
15265
|
+
const raw = stateDb.getMeta(key);
|
|
15201
15266
|
if (!raw) return /* @__PURE__ */ new Map();
|
|
15202
15267
|
try {
|
|
15203
15268
|
return new Map(Object.entries(JSON.parse(raw)));
|
|
@@ -15205,16 +15270,39 @@ function folderFileIdsOf(stateDb, pageId) {
|
|
|
15205
15270
|
return /* @__PURE__ */ new Map();
|
|
15206
15271
|
}
|
|
15207
15272
|
}
|
|
15273
|
+
function writePathMap(stateDb, key, map) {
|
|
15274
|
+
stateDb.setMeta(key, JSON.stringify(Object.fromEntries(map)));
|
|
15275
|
+
}
|
|
15276
|
+
function folderFileIdsOf(stateDb, pageId) {
|
|
15277
|
+
return readPathMap(stateDb, keyOf3(pageId));
|
|
15278
|
+
}
|
|
15208
15279
|
function forgetFolderFileId(stateDb, pageId, localPath) {
|
|
15280
|
+
rememberFolderFileStamp(stateDb, pageId, localPath, null);
|
|
15209
15281
|
const ids = folderFileIdsOf(stateDb, pageId);
|
|
15210
15282
|
if (!ids.delete(localPath)) return;
|
|
15211
|
-
stateDb
|
|
15283
|
+
writePathMap(stateDb, keyOf3(pageId), ids);
|
|
15212
15284
|
}
|
|
15213
15285
|
function rememberFolderFileId(stateDb, pageId, localPath, fileId) {
|
|
15214
15286
|
const ids = folderFileIdsOf(stateDb, pageId);
|
|
15215
15287
|
if (ids.get(localPath) === fileId) return;
|
|
15216
15288
|
ids.set(localPath, fileId);
|
|
15217
|
-
stateDb
|
|
15289
|
+
writePathMap(stateDb, keyOf3(pageId), ids);
|
|
15290
|
+
}
|
|
15291
|
+
function fileStampOf(file) {
|
|
15292
|
+
return `${file.size}:${file.mtime}`;
|
|
15293
|
+
}
|
|
15294
|
+
function stampKeyOf(pageId) {
|
|
15295
|
+
return `folder_file_stamps:${compactNotionId(pageId)}`;
|
|
15296
|
+
}
|
|
15297
|
+
function folderFileStampsOf(stateDb, pageId) {
|
|
15298
|
+
return readPathMap(stateDb, stampKeyOf(pageId));
|
|
15299
|
+
}
|
|
15300
|
+
function rememberFolderFileStamp(stateDb, pageId, localPath, stamp) {
|
|
15301
|
+
const stamps = folderFileStampsOf(stateDb, pageId);
|
|
15302
|
+
if ((stamps.get(localPath) ?? null) === stamp) return;
|
|
15303
|
+
if (stamp === null) stamps.delete(localPath);
|
|
15304
|
+
else stamps.set(localPath, stamp);
|
|
15305
|
+
writePathMap(stateDb, stampKeyOf(pageId), stamps);
|
|
15218
15306
|
}
|
|
15219
15307
|
|
|
15220
15308
|
// src/sync/file-handler.ts
|
|
@@ -15225,6 +15313,12 @@ function getFolderPath(filePath) {
|
|
|
15225
15313
|
const parts = filePath.split("/");
|
|
15226
15314
|
return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
|
|
15227
15315
|
}
|
|
15316
|
+
function filesFolderOfNote(record) {
|
|
15317
|
+
if (record.fileType === "folder-note" && isFolderNotePath(record.obsidianPath)) {
|
|
15318
|
+
return getFolderPath(record.obsidianPath);
|
|
15319
|
+
}
|
|
15320
|
+
return record.fileType === "file" ? stripMarkdownExtension(record.obsidianPath) : null;
|
|
15321
|
+
}
|
|
15228
15322
|
var LOGGED_NAMES = 5;
|
|
15229
15323
|
var FileHandler = class {
|
|
15230
15324
|
constructor(vaultFs, notionClient, stateDb, concurrency = DEFAULT_CONFIG.advanced.fileConcurrency, options) {
|
|
@@ -15304,21 +15398,55 @@ var FileHandler = class {
|
|
|
15304
15398
|
this.sema.release();
|
|
15305
15399
|
}
|
|
15306
15400
|
}
|
|
15307
|
-
/**
|
|
15401
|
+
/**
|
|
15402
|
+
* 올리거나 받은 뒤 바뀌지 않았나 — 그때의 표(크기 · 수정 시각, {@link fileStampOf})와 같으면 읽지 않는다. 크기가 다르면 바뀌었다.
|
|
15403
|
+
* 크기만 같으면 내용으로 견주고, 같으면 지금 표를 적어 다음에는 읽지 않는다. 예전에는 크기가 같으면 바뀌지 않았다고 봐, 같은
|
|
15404
|
+
* 크기로 고친 파일을 올리지 않았다.
|
|
15405
|
+
*/
|
|
15308
15406
|
async isUnchanged(existing, file) {
|
|
15309
|
-
|
|
15407
|
+
const stamp = fileStampOf(file);
|
|
15408
|
+
if (folderFileStampsOf(this.stateDb, existing.notionPageId).get(file.path) === stamp) {
|
|
15409
|
+
return true;
|
|
15410
|
+
}
|
|
15411
|
+
if (existing.fileSize !== file.size) return false;
|
|
15310
15412
|
const buffer = await this.vaultFs.readBinary(file.path);
|
|
15311
|
-
|
|
15413
|
+
const same = existing.fileHash === createHash("sha256").update(buffer).digest("hex");
|
|
15414
|
+
if (same) rememberFolderFileStamp(this.stateDb, existing.notionPageId, file.path, stamp);
|
|
15415
|
+
return same;
|
|
15312
15416
|
}
|
|
15313
15417
|
/** pull 이 받은 것을 두는 폴더 안인가. */
|
|
15314
15418
|
inAttachmentsFolder(path) {
|
|
15315
15419
|
return this.attachmentsFolder !== "" && path.startsWith(`${this.attachmentsFolder}/`);
|
|
15316
15420
|
}
|
|
15317
|
-
/**
|
|
15421
|
+
/**
|
|
15422
|
+
* 폴더 첨부를 둘 Notion 페이지 — 그 폴더의 Notion 자리({@link folderContainer} 와 같은 차례). 폴더 노트(`X/X.md`)나 노트
|
|
15423
|
+
* 이름의 폴더(`X.md` 옆 `X/`)면 그 노트의 페이지 끝(ADR-056), 아니면 push 가 만든 폴더 페이지. 맨 위는 루트 기록이 있을 때만.
|
|
15424
|
+
* 행 · DB 폴더는 둘 곳이 없다 — 행의 본문은 행의 것이다.
|
|
15425
|
+
*/
|
|
15318
15426
|
folderPageOf(folder) {
|
|
15319
|
-
|
|
15320
|
-
|
|
15321
|
-
|
|
15427
|
+
if (folder === "") return this.stateDb.getByPath("__root__")?.notionPageId ?? null;
|
|
15428
|
+
return this.notePageOf(folder) ?? this.folderRecordPageOf(folder);
|
|
15429
|
+
}
|
|
15430
|
+
/** 이 폴더를 품은 노트의 페이지 — 폴더 노트 · 노트 이름의 폴더. 없거나 행이면 null. */
|
|
15431
|
+
notePageOf(folder) {
|
|
15432
|
+
const note = this.stateDb.getByPath(folderNoteOf(folder));
|
|
15433
|
+
if (note?.notionPageId && note.fileType === "folder-note") return note.notionPageId;
|
|
15434
|
+
const named = this.stateDb.getByPath(`${folder}.md`);
|
|
15435
|
+
return named?.notionPageId && named.fileType === "file" ? named.notionPageId : null;
|
|
15436
|
+
}
|
|
15437
|
+
folderRecordPageOf(folder) {
|
|
15438
|
+
const record = this.stateDb.getByPath(folder);
|
|
15439
|
+
return record?.notionPageId && isFolderRecord(record) ? record.notionPageId : null;
|
|
15440
|
+
}
|
|
15441
|
+
/**
|
|
15442
|
+
* 이 기록이 그 폴더의 폴더 첨부인가. 노트의 페이지에는 그 노트가 임베드해 올린 파일도 같은 페이지로 적혀 있다 — 폴더
|
|
15443
|
+
* 첨부로 적어 둔 것({@link folderFileIdsOf})만이다.
|
|
15444
|
+
*/
|
|
15445
|
+
isFolderFileEntry(entry, folder) {
|
|
15446
|
+
const pageId = this.folderPageOf(folder);
|
|
15447
|
+
if (!pageId || entry.notionPageId !== pageId) return false;
|
|
15448
|
+
if (pageId !== this.notePageOf(folder)) return true;
|
|
15449
|
+
return folderFileIdsOf(this.stateDb, pageId).has(entry.localPath);
|
|
15322
15450
|
}
|
|
15323
15451
|
/**
|
|
15324
15452
|
* 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 아예 빼고, Notion 이 받지 않는
|
|
@@ -15346,7 +15474,7 @@ var FileHandler = class {
|
|
|
15346
15474
|
* @param failed 올리지 못한 첨부를 담을 곳 — push 결과의 `failed` 를 넘기면 CLI · 플러그인이 처리하지 못한 것으로
|
|
15347
15475
|
* 보인다. 예전엔 경고 로그로만 남아 push 가 「실패 0」 으로 끝났다.
|
|
15348
15476
|
*/
|
|
15349
|
-
async pushAllFiles(failed, folders) {
|
|
15477
|
+
async pushAllFiles(failed, folders, noteWrites) {
|
|
15350
15478
|
const { uploadable: allFiles, unsupported: unsupported2 } = await this.listUploadableFiles();
|
|
15351
15479
|
if (unsupported2.length > 0) {
|
|
15352
15480
|
const names = unsupported2.slice(0, LOGGED_NAMES).map((f) => f.path);
|
|
@@ -15355,13 +15483,13 @@ var FileHandler = class {
|
|
|
15355
15483
|
`[Im-Nobsidian] Notion \uC774 \uBC1B\uC9C0 \uC54A\uB294 \uD615\uC2DD\uC774\uB77C \uC62C\uB9AC\uC9C0 \uC54A\uC740 \uCCA8\uBD80 ${unsupported2.length}\uAC1C: ${names.join(", ")}${rest > 0 ? ` \uC678 ${rest}\uAC1C` : ""}`
|
|
15356
15484
|
);
|
|
15357
15485
|
}
|
|
15358
|
-
if (this.deleteSync) await this.removeDeletedFolderFiles();
|
|
15486
|
+
if (this.deleteSync) await this.removeDeletedFolderFiles(noteWrites);
|
|
15359
15487
|
const pending = [];
|
|
15360
15488
|
for (const file of allFiles) {
|
|
15361
15489
|
if (this.inAttachmentsFolder(file.path) || isHiddenPath(file.path)) continue;
|
|
15362
15490
|
const existing = this.stateDb.getFileRegistry(file.path);
|
|
15363
15491
|
if (existing) {
|
|
15364
|
-
if (
|
|
15492
|
+
if (!this.isFolderFileEntry(existing, getFolderPath(file.path))) continue;
|
|
15365
15493
|
if (await this.isUnchanged(existing, file)) continue;
|
|
15366
15494
|
}
|
|
15367
15495
|
pending.push({ file, existing });
|
|
@@ -15383,10 +15511,23 @@ var FileHandler = class {
|
|
|
15383
15511
|
for (const [folder, items] of byFolder) {
|
|
15384
15512
|
const pageId = await this.provideFolderPage(folder, folders, items, stranded);
|
|
15385
15513
|
if (!pageId) continue;
|
|
15386
|
-
|
|
15387
|
-
|
|
15388
|
-
|
|
15389
|
-
|
|
15514
|
+
await this.writingNotePage(
|
|
15515
|
+
pageId === this.notePageOf(folder),
|
|
15516
|
+
pageId,
|
|
15517
|
+
noteWrites,
|
|
15518
|
+
async () => {
|
|
15519
|
+
for (const { file, existing } of items) {
|
|
15520
|
+
let previous = existing;
|
|
15521
|
+
if (!previous) {
|
|
15522
|
+
const moved = await this.movedHere(file, pageId);
|
|
15523
|
+
if (moved?.same) continue;
|
|
15524
|
+
previous = moved?.entry ?? null;
|
|
15525
|
+
}
|
|
15526
|
+
const result = await this.pushSingleFile(file, pageId, previous, failed);
|
|
15527
|
+
if (result) results.push(result);
|
|
15528
|
+
}
|
|
15529
|
+
}
|
|
15530
|
+
);
|
|
15390
15531
|
}
|
|
15391
15532
|
this.reportStranded(stranded);
|
|
15392
15533
|
return results;
|
|
@@ -15395,25 +15536,37 @@ var FileHandler = class {
|
|
|
15395
15536
|
* 볼트에서 지운 폴더 첨부를 Notion 에서도 지운다(deleteSync) — 폴더 페이지에 적힌 파일 가운데 볼트에 없는 것의 블록과 기록.
|
|
15396
15537
|
* 포함 · 제외 패턴으로 빠진 파일은 볼트에 있어 지우지 않는다.
|
|
15397
15538
|
*/
|
|
15398
|
-
async removeDeletedFolderFiles() {
|
|
15539
|
+
async removeDeletedFolderFiles(noteWrites) {
|
|
15399
15540
|
const removed = [];
|
|
15400
15541
|
for (const record of this.stateDb.getAll()) {
|
|
15401
|
-
if (!record.notionPageId
|
|
15542
|
+
if (!record.notionPageId) continue;
|
|
15543
|
+
const folder = isFolderRecord(record) ? record.obsidianPath : filesFolderOfNote(record);
|
|
15544
|
+
if (folder === null) continue;
|
|
15545
|
+
const ids = folderFileIdsOf(this.stateDb, record.notionPageId);
|
|
15546
|
+
if (!isFolderRecord(record) && ids.size === 0) continue;
|
|
15547
|
+
const gone = [];
|
|
15402
15548
|
for (const entry of this.stateDb.getFilesByPageId(record.notionPageId)) {
|
|
15403
|
-
if (getFolderPath(entry.localPath) !==
|
|
15404
|
-
if (
|
|
15405
|
-
|
|
15406
|
-
const name = entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1);
|
|
15407
|
-
await this.removeOlderBlocks(record.notionPageId, name, void 0);
|
|
15408
|
-
this.stateDb.deleteFileRegistry(entry.localPath);
|
|
15409
|
-
forgetFolderFileId(this.stateDb, record.notionPageId, entry.localPath);
|
|
15410
|
-
removed.push(entry.localPath);
|
|
15411
|
-
} catch (error) {
|
|
15412
|
-
getLogger().warn(
|
|
15413
|
-
`[Im-Nobsidian] \uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80\uB97C Notion \uC5D0\uC11C \uC9C0\uC6B0\uC9C0 \uBABB\uD568 (${entry.localPath}): ${error instanceof Error ? error.message : String(error)}`
|
|
15414
|
-
);
|
|
15415
|
-
}
|
|
15549
|
+
if (getFolderPath(entry.localPath) !== folder) continue;
|
|
15550
|
+
if (!isFolderRecord(record) && !ids.has(entry.localPath)) continue;
|
|
15551
|
+
if (!await this.vaultFs.exists(entry.localPath)) gone.push(entry);
|
|
15416
15552
|
}
|
|
15553
|
+
if (gone.length === 0) continue;
|
|
15554
|
+
const pageId = record.notionPageId;
|
|
15555
|
+
await this.writingNotePage(!isFolderRecord(record), pageId, noteWrites, async () => {
|
|
15556
|
+
for (const entry of gone) {
|
|
15557
|
+
try {
|
|
15558
|
+
const name = entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1);
|
|
15559
|
+
await this.removeOlderBlocks(pageId, entry.localPath, name, void 0);
|
|
15560
|
+
this.stateDb.deleteFileRegistry(entry.localPath);
|
|
15561
|
+
forgetFolderFileId(this.stateDb, pageId, entry.localPath);
|
|
15562
|
+
removed.push(entry.localPath);
|
|
15563
|
+
} catch (error) {
|
|
15564
|
+
getLogger().warn(
|
|
15565
|
+
`[Im-Nobsidian] \uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80\uB97C Notion \uC5D0\uC11C \uC9C0\uC6B0\uC9C0 \uBABB\uD568 (${entry.localPath}): ${error instanceof Error ? error.message : String(error)}`
|
|
15566
|
+
);
|
|
15567
|
+
}
|
|
15568
|
+
}
|
|
15569
|
+
});
|
|
15417
15570
|
}
|
|
15418
15571
|
if (removed.length > 0) {
|
|
15419
15572
|
getLogger().info(
|
|
@@ -15421,10 +15574,20 @@ var FileHandler = class {
|
|
|
15421
15574
|
);
|
|
15422
15575
|
}
|
|
15423
15576
|
}
|
|
15577
|
+
/** 노트의 페이지에 쓰는 동안을 알린다({@link NotePageWrites}) — 폴더 페이지는 노트가 없어 알릴 것이 없다. */
|
|
15578
|
+
async writingNotePage(isNotePage, pageId, noteWrites, write) {
|
|
15579
|
+
if (!isNotePage || !noteWrites) return write();
|
|
15580
|
+
await noteWrites.begin(pageId);
|
|
15581
|
+
try {
|
|
15582
|
+
await write();
|
|
15583
|
+
} finally {
|
|
15584
|
+
await noteWrites.end(pageId);
|
|
15585
|
+
}
|
|
15586
|
+
}
|
|
15424
15587
|
/** 폴더 첨부를 둘 페이지 — 노트 없는 폴더면 마련한다. 둘 곳이 없으면 그 파일들을 `stranded` 에 담고 null. */
|
|
15425
15588
|
async provideFolderPage(folder, folders, items, stranded) {
|
|
15426
15589
|
let pageId = this.folderPageOf(folder);
|
|
15427
|
-
let reason = folder === "" ? "\uBCFC\uD2B8 \uB9E8 \uC704" : "\uD3F4\uB354 \
|
|
15590
|
+
let reason = folder === "" ? "\uBCFC\uD2B8 \uB9E8 \uC704" : "DB \uD3F4\uB354 \xB7 \uC544\uC9C1 \uC62C\uB9AC\uC9C0 \uC54A\uC740 \uD3F4\uB354 \uB178\uD2B8";
|
|
15428
15591
|
if (!pageId && folder !== "" && folders && !this.stateDb.getByPath(folder)) {
|
|
15429
15592
|
try {
|
|
15430
15593
|
await folders.ensureFolderPage(folder);
|
|
@@ -15573,6 +15736,8 @@ var FileHandler = class {
|
|
|
15573
15736
|
fileHash: hash,
|
|
15574
15737
|
fileSize: buffer.length
|
|
15575
15738
|
});
|
|
15739
|
+
const stat2 = await this.vaultFs.getFileStat(localPath);
|
|
15740
|
+
if (stat2) rememberFolderFileStamp(this.stateDb, pageId, localPath, fileStampOf(stat2));
|
|
15576
15741
|
return wrote;
|
|
15577
15742
|
}
|
|
15578
15743
|
async downloadFileBlock(url, filename, targetFolder) {
|
|
@@ -15614,7 +15779,9 @@ var FileHandler = class {
|
|
|
15614
15779
|
const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
|
|
15615
15780
|
const block = this.createFileBlock(blockType, fileUploadId, filename);
|
|
15616
15781
|
const [created] = await this.notionClient.appendChildBlocks(parentPageId, [block]);
|
|
15617
|
-
if (previous)
|
|
15782
|
+
if (previous) {
|
|
15783
|
+
await this.removeOlderBlocks(parentPageId, file.path, filename, created?.id);
|
|
15784
|
+
}
|
|
15618
15785
|
const url = created ? fileUrlOfBlock(created) : null;
|
|
15619
15786
|
const fileId = url ? notionFileIdOf(url) : null;
|
|
15620
15787
|
if (fileId) rememberFolderFileId(this.stateDb, parentPageId, file.path, fileId);
|
|
@@ -15626,28 +15793,78 @@ var FileHandler = class {
|
|
|
15626
15793
|
fileHash: hash,
|
|
15627
15794
|
fileSize: file.size
|
|
15628
15795
|
});
|
|
15796
|
+
rememberFolderFileStamp(this.stateDb, parentPageId, file.path, fileStampOf(file));
|
|
15629
15797
|
getLogger().info(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC: ${file.path} \u2192 ${blockType} \uBE14\uB85D`);
|
|
15630
15798
|
return { localPath: file.path, fileUploadId, blockType };
|
|
15631
15799
|
}
|
|
15632
|
-
/**
|
|
15633
|
-
|
|
15800
|
+
/**
|
|
15801
|
+
* 처음 보는 파일이 이 페이지에 적어 둔 같은 이름의 폴더 첨부를 옮긴 것인가 — 그 기록의 경로가 볼트에서 사라졌으면 옮겼다(폴더 ·
|
|
15802
|
+
* 폴더 노트 · 노트의 이름 바꾸기). 기록 · 파일 id · 표를 새 경로로 옮긴다. `same` 이면 내용이 같아 올리지 않는다. 다르면 옮긴
|
|
15803
|
+
* 기록을 옛 것으로 올려 옛 블록을 지운다. 예전에는 새 파일로 올려 노트 페이지에 같은 파일의 블록이 둘 남았다.
|
|
15804
|
+
*/
|
|
15805
|
+
async movedHere(file, pageId) {
|
|
15806
|
+
const name = file.path.slice(file.path.lastIndexOf("/") + 1);
|
|
15807
|
+
const record = this.stateDb.getByNotionId(pageId);
|
|
15808
|
+
const ids = folderFileIdsOf(this.stateDb, pageId);
|
|
15809
|
+
for (const entry of this.stateDb.getFilesByPageId(pageId)) {
|
|
15810
|
+
if (entry.localPath === file.path) continue;
|
|
15811
|
+
if (entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1) !== name) continue;
|
|
15812
|
+
if (record && !isFolderRecord(record) && !ids.has(entry.localPath)) continue;
|
|
15813
|
+
if (await this.vaultFs.exists(entry.localPath)) continue;
|
|
15814
|
+
const buffer = await this.vaultFs.readBinary(file.path);
|
|
15815
|
+
const same = entry.fileSize === file.size && entry.fileHash === createHash("sha256").update(buffer).digest("hex");
|
|
15816
|
+
const fileId = ids.get(entry.localPath);
|
|
15817
|
+
this.stateDb.deleteFileRegistry(entry.localPath);
|
|
15818
|
+
forgetFolderFileId(this.stateDb, pageId, entry.localPath);
|
|
15819
|
+
this.stateDb.registerFile({
|
|
15820
|
+
localPath: file.path,
|
|
15821
|
+
notionPageId: pageId,
|
|
15822
|
+
fileUploadId: entry.fileUploadId,
|
|
15823
|
+
fileType: entry.fileType,
|
|
15824
|
+
fileHash: entry.fileHash,
|
|
15825
|
+
fileSize: entry.fileSize
|
|
15826
|
+
});
|
|
15827
|
+
if (fileId) rememberFolderFileId(this.stateDb, pageId, file.path, fileId);
|
|
15828
|
+
if (same) rememberFolderFileStamp(this.stateDb, pageId, file.path, fileStampOf(file));
|
|
15829
|
+
return { entry: { ...entry, localPath: file.path }, same };
|
|
15830
|
+
}
|
|
15831
|
+
return null;
|
|
15832
|
+
}
|
|
15833
|
+
/**
|
|
15834
|
+
* 이 페이지에서 그 폴더 첨부의 블록 중 방금 붙인 것(`keep`) 말고를 지운다. 폴더 페이지는 같은 이름의 파일 블록 모두(id 를
|
|
15835
|
+
* 적기 전에 올린 블록도). 노트의 페이지는 노트가 임베드한 같은 이름의 파일이 있을 수 있어, 적어 둔 Notion 파일 id 의 블록과
|
|
15836
|
+
* 표시를 단 캡션({@link folderFileCaption})의 블록만 — 다른 볼트가 같은 파일을 먼저 고쳐 올린 블록은 id 를 모르지만 표시가 있다.
|
|
15837
|
+
* 예전에는 id 로만 지워, 두 볼트가 따로 고쳐 올리면 블록이 둘 남았다.
|
|
15838
|
+
*/
|
|
15839
|
+
async removeOlderBlocks(pageId, localPath, name, keep) {
|
|
15840
|
+
const record = this.stateDb.getByNotionId(pageId);
|
|
15841
|
+
const onNote = record !== null && !isFolderRecord(record);
|
|
15842
|
+
const fileId = folderFileIdsOf(this.stateDb, pageId).get(localPath);
|
|
15634
15843
|
for (const block of await this.notionClient.fetchAllChildren(pageId)) {
|
|
15635
15844
|
if (block.id === keep) continue;
|
|
15636
15845
|
const media = block[block.type];
|
|
15637
15846
|
const url = media?.type === "file" ? media.file?.url : void 0;
|
|
15638
15847
|
if (!url || uploadedFileNameFromUrl(url) !== name) continue;
|
|
15848
|
+
if (keep === void 0 && fileId !== void 0) {
|
|
15849
|
+
if (notionFileIdOf(url) !== fileId) continue;
|
|
15850
|
+
} else if (onNote) {
|
|
15851
|
+
const caption = (media?.caption ?? []).map((part) => part.plain_text ?? "").join("");
|
|
15852
|
+
const ours = fileId !== void 0 && notionFileIdOf(url) === fileId || caption === folderFileCaption(name);
|
|
15853
|
+
if (!ours) continue;
|
|
15854
|
+
}
|
|
15639
15855
|
await this.notionClient.deleteBlock(block.id);
|
|
15640
15856
|
}
|
|
15641
15857
|
}
|
|
15642
15858
|
/**
|
|
15643
|
-
* 폴더 첨부의 파일 블록 — 캡션에 파일 이름을
|
|
15644
|
-
* 자식 페이지가 없는 폴더 페이지는 PDF 하나만 붙인 노트와 모양이
|
|
15859
|
+
* 폴더 첨부의 파일 블록 — 캡션에 표시를 단 파일 이름을 적는다({@link folderFileCaption}). 받는 쪽이 그 표시로 폴더 첨부를
|
|
15860
|
+
* 알아본다 — 자식 페이지가 없는 폴더 페이지는 PDF 하나만 붙인 노트와 모양이 같고, 노트 페이지의 폴더 첨부는 그 노트의
|
|
15861
|
+
* 임베드와 같은 블록이다.
|
|
15645
15862
|
*/
|
|
15646
15863
|
createFileBlock(blockType, fileUploadId, name) {
|
|
15647
15864
|
const fileRef = {
|
|
15648
15865
|
type: "file_upload",
|
|
15649
15866
|
file_upload: { id: fileUploadId },
|
|
15650
|
-
caption: [{ type: "text", text: { content: name } }]
|
|
15867
|
+
caption: [{ type: "text", text: { content: folderFileCaption(name) } }]
|
|
15651
15868
|
};
|
|
15652
15869
|
switch (blockType) {
|
|
15653
15870
|
case "image":
|
|
@@ -15760,8 +15977,59 @@ async function findUntrackedNote(stateDb, vaultFs, dir, safeName, pageId, taken)
|
|
|
15760
15977
|
}
|
|
15761
15978
|
return null;
|
|
15762
15979
|
}
|
|
15763
|
-
function
|
|
15764
|
-
|
|
15980
|
+
function relinkOutcome(existing, same, unlinkedVault, strategy) {
|
|
15981
|
+
if (same) return "relink";
|
|
15982
|
+
if (existing === "name" && !unlinkedVault) return null;
|
|
15983
|
+
const chosen = existing === "own" ? strategy : "manual";
|
|
15984
|
+
if (chosen === "local-first") return "relink";
|
|
15985
|
+
if (chosen === "remote-first") return "overwrite";
|
|
15986
|
+
return "conflict";
|
|
15987
|
+
}
|
|
15988
|
+
function relinkConflict(input) {
|
|
15989
|
+
return {
|
|
15990
|
+
syncRecord: input.record,
|
|
15991
|
+
localChange: {
|
|
15992
|
+
path: input.path,
|
|
15993
|
+
type: "modified",
|
|
15994
|
+
currentHash: computeHash(input.local),
|
|
15995
|
+
previousHash: input.record.contentHash
|
|
15996
|
+
},
|
|
15997
|
+
remoteChange: {
|
|
15998
|
+
pageId: input.pageId,
|
|
15999
|
+
type: "modified",
|
|
16000
|
+
lastEdited: input.lastEdited,
|
|
16001
|
+
previousEdited: null,
|
|
16002
|
+
path: input.path,
|
|
16003
|
+
title: input.title
|
|
16004
|
+
},
|
|
16005
|
+
baseContent: null,
|
|
16006
|
+
localContent: input.local,
|
|
16007
|
+
remoteContent: input.remote
|
|
16008
|
+
};
|
|
16009
|
+
}
|
|
16010
|
+
function plannedRelinkRecord(input) {
|
|
16011
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
16012
|
+
return {
|
|
16013
|
+
id: `planned:${input.pageId}`,
|
|
16014
|
+
obsidianPath: input.path,
|
|
16015
|
+
notionPageId: input.pageId,
|
|
16016
|
+
notionParentId: input.parentId,
|
|
16017
|
+
contentHash: computeHash(input.remote),
|
|
16018
|
+
notionLastEdited: input.lastEdited,
|
|
16019
|
+
notionLastEditedBy: null,
|
|
16020
|
+
notionSeenAt: null,
|
|
16021
|
+
notionBodyFingerprint: null,
|
|
16022
|
+
localLastModified: now,
|
|
16023
|
+
syncDirection: "both",
|
|
16024
|
+
fileType: input.fileType,
|
|
16025
|
+
status: "conflict",
|
|
16026
|
+
baseSnapshot: null,
|
|
16027
|
+
localMtime: null,
|
|
16028
|
+
localFileSize: null,
|
|
16029
|
+
version: 0,
|
|
16030
|
+
createdAt: now,
|
|
16031
|
+
updatedAt: now
|
|
16032
|
+
};
|
|
15765
16033
|
}
|
|
15766
16034
|
|
|
15767
16035
|
// src/conflict/merger.ts
|
|
@@ -15949,20 +16217,27 @@ var PENDING_LINK_RE = new RegExp(
|
|
|
15949
16217
|
"g"
|
|
15950
16218
|
);
|
|
15951
16219
|
function sameAsPendingLinks(local, remote) {
|
|
15952
|
-
|
|
15953
|
-
|
|
16220
|
+
const pending = new RegExp(`${NOTION_BLOCK_LINK_RE.source}|${PENDING_LINK_RE.source}`, "g");
|
|
16221
|
+
if (!pending.test(remote)) return false;
|
|
16222
|
+
pending.lastIndex = 0;
|
|
15954
16223
|
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15955
16224
|
let pattern = "";
|
|
15956
16225
|
let last = 0;
|
|
15957
|
-
for (const match of remote.matchAll(
|
|
16226
|
+
for (const match of remote.matchAll(pending)) {
|
|
15958
16227
|
pattern += escape(remote.slice(last, match.index));
|
|
15959
|
-
const
|
|
15960
|
-
|
|
16228
|
+
const blockLabel = match[1];
|
|
16229
|
+
const alias = match[6];
|
|
16230
|
+
pattern += blockLabel ? pendingBlockLinkPattern(blockLabel, escape) : match[7] ? `"?\\[\\[[^\\]\\n]+\\]\\]"?` : alias ? `(?:\\[\\[(?:[^\\]\\n|]*/)?${escape(alias)}(?:\\\\?\\|[^\\]\\n]*)?\\]\\]|\\[\\[[^\\]\\n|]+\\\\?\\|${escape(alias)}\\]\\]|\\[${escape(alias)}\\]\\([^)\\n]+\\))` : `(?:\\[\\[[^\\]\\n]+\\]\\]|\\[[^\\]\\n]+\\]\\([^)\\n]+\\))`;
|
|
15961
16231
|
last = match.index + match[0].length;
|
|
15962
16232
|
}
|
|
15963
16233
|
pattern += escape(remote.slice(last).replace(/\n+$/, ""));
|
|
15964
16234
|
return new RegExp(`^${pattern}\\n*$`).test(local.replace(/\n+$/, ""));
|
|
15965
16235
|
}
|
|
16236
|
+
function pendingBlockLinkPattern(label, escape) {
|
|
16237
|
+
const text = label.replace(/\\([>^])/g, "$1");
|
|
16238
|
+
const written = escape(text.split(" > ").join("#"));
|
|
16239
|
+
return `(?:\\[\\[[^\\]\\n|]*#[^\\]\\n|]*\\\\?\\|${escape(text)}\\]\\]|\\[\\[(?:[^\\]\\n|#]*/)?${written}\\]\\]|\\[\\[#${written}\\]\\]|\\[(?:${escape(text)}|${escape(label)})\\]\\([^)\\n]+\\))`;
|
|
16240
|
+
}
|
|
15966
16241
|
function resolveNotionIdWikilinks(markdown, lookup) {
|
|
15967
16242
|
let resolved = 0;
|
|
15968
16243
|
const out = markdown.replace(
|
|
@@ -19088,7 +19363,9 @@ var NoteRelocator = class {
|
|
|
19088
19363
|
if (isFolderRecord(record)) return [record.obsidianPath];
|
|
19089
19364
|
if (record.fileType === "folder-note") return [parentFolderOf(record.obsidianPath)];
|
|
19090
19365
|
const children = stripMarkdownExtension(record.obsidianPath);
|
|
19091
|
-
const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children))
|
|
19366
|
+
const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children)) || record.notionPageId !== null && [...folderFileIdsOf(this.stateDb, record.notionPageId).keys()].some(
|
|
19367
|
+
(path) => within(path, children)
|
|
19368
|
+
);
|
|
19092
19369
|
return hasChildren ? [record.obsidianPath, children] : [record.obsidianPath];
|
|
19093
19370
|
}
|
|
19094
19371
|
/** 옮기지 못할 까닭 — 없으면 null. */
|
|
@@ -19321,7 +19598,7 @@ async function notionBodyToObsidian(client, markdown, pageId) {
|
|
|
19321
19598
|
const bookmarks = bookmarkIds.length > 0 ? await client.getBookmarks(bookmarkIds) : void 0;
|
|
19322
19599
|
return notionEnhancedToObsidian(markdown, { userNames, codeTexts, bookmarks });
|
|
19323
19600
|
}
|
|
19324
|
-
async function pageBlocksToObsidian(client, pageId) {
|
|
19601
|
+
async function pageBlocksToObsidian(client, pageId, bodyOf5 = (markdown) => markdown) {
|
|
19325
19602
|
const tree = await client.getBlockTree(pageId);
|
|
19326
19603
|
const markdown = blocksToNfm(tree, { pageId });
|
|
19327
19604
|
const content = await notionBodyToObsidian(
|
|
@@ -19330,7 +19607,7 @@ async function pageBlocksToObsidian(client, pageId) {
|
|
|
19330
19607
|
getCodeBlockTexts: () => Promise.resolve(blocksOfType(tree, "code").map(plainTextOf)),
|
|
19331
19608
|
getBookmarks: () => Promise.resolve(bookmarksOf(tree))
|
|
19332
19609
|
},
|
|
19333
|
-
markdown,
|
|
19610
|
+
bodyOf5(markdown),
|
|
19334
19611
|
pageId
|
|
19335
19612
|
);
|
|
19336
19613
|
return { markdown, content };
|
|
@@ -19989,13 +20266,15 @@ var DatabaseSyncer = class {
|
|
|
19989
20266
|
page.id,
|
|
19990
20267
|
(key) => relinkTaken.has(key) || claimed.has(key)
|
|
19991
20268
|
);
|
|
19992
|
-
|
|
20269
|
+
const relink = untracked && await this.plannedRowRelink(page, dbConfig, untracked);
|
|
20270
|
+
if (untracked && relink) {
|
|
19993
20271
|
relinkTaken.add(pathKey(untracked.path));
|
|
19994
20272
|
planned.push({
|
|
19995
20273
|
pageId: page.id,
|
|
19996
20274
|
path: untracked.path,
|
|
19997
20275
|
operation: "create",
|
|
19998
|
-
relink: true
|
|
20276
|
+
relink: true,
|
|
20277
|
+
...relink.conflict ? { relinkConflict: relink.conflict } : {}
|
|
19999
20278
|
});
|
|
20000
20279
|
continue;
|
|
20001
20280
|
}
|
|
@@ -20826,16 +21105,20 @@ var DatabaseSyncer = class {
|
|
|
20826
21105
|
return found2;
|
|
20827
21106
|
}
|
|
20828
21107
|
/**
|
|
20829
|
-
* 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) —
|
|
20830
|
-
*
|
|
20831
|
-
* remote-first 는 `"write"` — 호출측이 받은 행으로 덮는다.
|
|
21108
|
+
* 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 받은 글과 견줘 {@link relinkOutcome} 대로. 잇지 않으면 null.
|
|
21109
|
+
* 원격으로 덮을 것은 `"write"` — 호출측이 받은 행으로 덮는다.
|
|
20832
21110
|
*/
|
|
20833
21111
|
async relinkExistingRow(filePath, existing, local, finalContent, page, dbConfig, rendered, bodyFingerprint) {
|
|
20834
21112
|
const same = sameNoteContent(local, finalContent) || sameAsPendingLinks(local, finalContent);
|
|
20835
|
-
|
|
20836
|
-
|
|
20837
|
-
|
|
20838
|
-
|
|
21113
|
+
const outcome = relinkOutcome(
|
|
21114
|
+
existing,
|
|
21115
|
+
same,
|
|
21116
|
+
this.unlinkedVault,
|
|
21117
|
+
this.config.sync.conflictStrategy
|
|
21118
|
+
);
|
|
21119
|
+
if (outcome === null) return null;
|
|
21120
|
+
if (outcome === "overwrite") return "write";
|
|
21121
|
+
const conflicted = outcome === "conflict";
|
|
20839
21122
|
const stat2 = same ? await this.vaultFs.getFileStat(filePath) : null;
|
|
20840
21123
|
this.stateDb.transaction(() => {
|
|
20841
21124
|
this.stateDb.upsert({
|
|
@@ -20862,31 +21145,57 @@ var DatabaseSyncer = class {
|
|
|
20862
21145
|
});
|
|
20863
21146
|
});
|
|
20864
21147
|
if (!conflicted) return { action: "unchanged", path: filePath, relinked: true };
|
|
20865
|
-
const record = this.stateDb.getByPath(filePath);
|
|
20866
21148
|
return {
|
|
20867
21149
|
action: "conflict",
|
|
20868
21150
|
path: filePath,
|
|
20869
21151
|
relinked: true,
|
|
20870
|
-
conflict: {
|
|
20871
|
-
|
|
20872
|
-
|
|
20873
|
-
|
|
20874
|
-
|
|
20875
|
-
|
|
20876
|
-
|
|
20877
|
-
|
|
20878
|
-
|
|
21152
|
+
conflict: relinkConflict({
|
|
21153
|
+
record: this.stateDb.getByPath(filePath),
|
|
21154
|
+
path: filePath,
|
|
21155
|
+
local,
|
|
21156
|
+
remote: finalContent,
|
|
21157
|
+
pageId: page.id,
|
|
21158
|
+
lastEdited: page.last_edited_time,
|
|
21159
|
+
title: rendered.title
|
|
21160
|
+
})
|
|
21161
|
+
};
|
|
21162
|
+
}
|
|
21163
|
+
/**
|
|
21164
|
+
* dry-run — 실제 pull 이 상태 기록에 없던 이 행 노트를 다시 잇나(P7). 받은 행을 실제 pull 처럼 렌더하되 볼트 · 상태 DB 는
|
|
21165
|
+
* 바꾸지 않고({@link ImageHandler.preview}) 견준다. 잇지 않으면 null(새 이름으로 받는다), 충돌로 남길 것이면 그 충돌.
|
|
21166
|
+
*/
|
|
21167
|
+
async plannedRowRelink(page, dbConfig, untracked) {
|
|
21168
|
+
const local = await readLocalNote(this.vaultFs, untracked.path);
|
|
21169
|
+
if (local === void 0) return null;
|
|
21170
|
+
const rendered = await this.imageHandler.preview(
|
|
21171
|
+
() => this.renderRow(page, untracked.path, { localPath: untracked.path, adoptFrom: local })
|
|
21172
|
+
);
|
|
21173
|
+
const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
|
|
21174
|
+
const outcome = relinkOutcome(
|
|
21175
|
+
untracked.existing,
|
|
21176
|
+
same,
|
|
21177
|
+
this.unlinkedVault,
|
|
21178
|
+
this.config.sync.conflictStrategy
|
|
21179
|
+
);
|
|
21180
|
+
if (outcome === null) return null;
|
|
21181
|
+
if (outcome !== "conflict") return {};
|
|
21182
|
+
return {
|
|
21183
|
+
conflict: relinkConflict({
|
|
21184
|
+
record: plannedRelinkRecord({
|
|
21185
|
+
path: untracked.path,
|
|
20879
21186
|
pageId: page.id,
|
|
20880
|
-
|
|
21187
|
+
parentId: dbConfig.databaseId,
|
|
21188
|
+
remote: rendered.content,
|
|
20881
21189
|
lastEdited: page.last_edited_time,
|
|
20882
|
-
|
|
20883
|
-
|
|
20884
|
-
|
|
20885
|
-
|
|
20886
|
-
|
|
20887
|
-
|
|
20888
|
-
|
|
20889
|
-
|
|
21190
|
+
fileType: "db-row"
|
|
21191
|
+
}),
|
|
21192
|
+
path: untracked.path,
|
|
21193
|
+
local,
|
|
21194
|
+
remote: rendered.content,
|
|
21195
|
+
pageId: page.id,
|
|
21196
|
+
lastEdited: page.last_edited_time,
|
|
21197
|
+
title: rendered.title
|
|
21198
|
+
})
|
|
20890
21199
|
};
|
|
20891
21200
|
}
|
|
20892
21201
|
/** 이번 받기에서 로컬 편집을 합친 행의 경로를 가져가고 비운다. */
|
|
@@ -23381,6 +23690,7 @@ function dropEmbeddedBaseMarkers(content, basePaths) {
|
|
|
23381
23690
|
|
|
23382
23691
|
// src/sync/database-discovery.ts
|
|
23383
23692
|
var DISCOVERY_RETRY_META_KEY = "discovery_retry";
|
|
23693
|
+
var PREVIEW_BODY_READ_BUDGET = 100;
|
|
23384
23694
|
function parseDiscoveryRetry(raw) {
|
|
23385
23695
|
if (!raw) return { parents: [], dbs: [] };
|
|
23386
23696
|
try {
|
|
@@ -23446,144 +23756,25 @@ var DatabaseDiscovery = class {
|
|
|
23446
23756
|
let deleted = 0;
|
|
23447
23757
|
let restored = 0;
|
|
23448
23758
|
try {
|
|
23449
|
-
const
|
|
23450
|
-
|
|
23451
|
-
|
|
23452
|
-
|
|
23453
|
-
|
|
23454
|
-
|
|
23455
|
-
|
|
23456
|
-
|
|
23457
|
-
|
|
23458
|
-
|
|
23459
|
-
|
|
23460
|
-
|
|
23461
|
-
|
|
23462
|
-
|
|
23463
|
-
|
|
23464
|
-
|
|
23465
|
-
|
|
23466
|
-
|
|
23467
|
-
}
|
|
23468
|
-
const folderKeys = new Map([...folderOwner].map(([folder, id]) => [pathKey(folder), id]));
|
|
23469
|
-
const claimFolder = (config) => {
|
|
23470
|
-
config.localFolder = resolveDbFolderPath(
|
|
23471
|
-
config.localFolder,
|
|
23472
|
-
config.databaseId,
|
|
23473
|
-
(f) => folderOwner.get(f) ?? folderKeys.get(pathKey(f)) ?? null
|
|
23474
|
-
);
|
|
23475
|
-
folderOwner.set(config.localFolder, config.databaseId);
|
|
23476
|
-
folderKeys.set(pathKey(config.localFolder), config.databaseId);
|
|
23477
|
-
};
|
|
23478
|
-
const inaccessibleIds = loadInaccessibleDbIds(this.stateDb);
|
|
23479
|
-
let inaccessibleChanged = false;
|
|
23480
|
-
const degrade = (rawId) => {
|
|
23481
|
-
const nohyph = rawId.replace(/-/g, "");
|
|
23482
|
-
if (!inaccessibleIds.has(nohyph)) {
|
|
23483
|
-
inaccessibleIds.add(nohyph);
|
|
23484
|
-
inaccessibleChanged = true;
|
|
23485
|
-
}
|
|
23486
|
-
getLogger().warn(
|
|
23487
|
-
`[Im-Nobsidian] DB ${rawId}: \uC811\uADFC \uAC00\uB2A5\uD55C data source \uC5C6\uC74C(\uB9C1\uD06C\uB4DC/\uBBF8\uACF5\uC720/\uC0AD\uC81C \uCD94\uC815) \u2014 \uB3D9\uAE30\uD654 \uB300\uC0C1\uC5D0\uC11C \uC81C\uC678(\uC81C\uBAA9\uC740 \uBD80\uBAA8 \uD398\uC774\uC9C0\uC5D0 \uBCF4\uC874)`
|
|
23488
|
-
);
|
|
23489
|
-
};
|
|
23490
|
-
if (forceRediscovery && inaccessibleIds.size > 0) {
|
|
23491
|
-
getLogger().info(
|
|
23492
|
-
`[Im-Nobsidian] --force: \uC811\uADFC \uBD88\uAC00 DB ${inaccessibleIds.size}\uAC1C denylist \uC7AC\uAC80\uC99D`
|
|
23493
|
-
);
|
|
23494
|
-
inaccessibleIds.clear();
|
|
23495
|
-
inaccessibleChanged = true;
|
|
23496
|
-
}
|
|
23497
|
-
const linkedMap = loadLinkedDbMap(this.stateDb);
|
|
23498
|
-
let linkedChanged = false;
|
|
23499
|
-
const linkedNow = /* @__PURE__ */ new Set();
|
|
23500
|
-
const retry = parseDiscoveryRetry(this.stateDb.getMeta(DISCOVERY_RETRY_META_KEY));
|
|
23501
|
-
const nextRetry = { parents: [], dbs: [] };
|
|
23502
|
-
const pathOf = (pageId) => notionIdsEqual(pageId, this.config.notion.rootPageId) ? "" : this.stateDb.getByNotionId(pageId)?.obsidianPath ?? pageId;
|
|
23503
|
-
const unreadParent = (pageId, error) => {
|
|
23504
|
-
nextRetry.parents.push(pageId);
|
|
23505
|
-
failed.push({
|
|
23506
|
-
path: pathOf(pageId),
|
|
23507
|
-
operation: "update",
|
|
23508
|
-
error: `\uD558\uC704 DB \uB97C \uD655\uC778\uD558\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uD655\uC778\uD55C\uB2E4: ${error}`
|
|
23509
|
-
});
|
|
23510
|
-
};
|
|
23511
|
-
const retryLater = (dbId, parentPageId, error) => {
|
|
23512
|
-
nextRetry.dbs.push({ dbId, parentPageId });
|
|
23513
|
-
failed.push({
|
|
23514
|
-
path: pathOf(parentPageId),
|
|
23515
|
-
operation: "create",
|
|
23516
|
-
error: `DB ${shortNotionId(dbId)} \uB97C \uC77D\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uBCF8\uB2E4: ${error}`
|
|
23517
|
-
});
|
|
23518
|
-
};
|
|
23519
|
-
const register = async (dbId, parentPageId) => {
|
|
23520
|
-
const nohyph = dbId.replace(/-/g, "");
|
|
23521
|
-
if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) return;
|
|
23522
|
-
const registerOriginal = async (origNohyph) => {
|
|
23523
|
-
if (knownIds.has(origNohyph) || inaccessibleIds.has(origNohyph)) return;
|
|
23524
|
-
const origId = normalizeNotionId(origNohyph);
|
|
23525
|
-
const outcome2 = await this.buildDiscoveredDbConfig(origId, parentPageId);
|
|
23526
|
-
knownIds.add(origNohyph);
|
|
23527
|
-
if (outcome2.kind === "ok") {
|
|
23528
|
-
claimFolder(outcome2.config);
|
|
23529
|
-
dbConfigs.push(outcome2.config);
|
|
23530
|
-
registeredNow.add(origNohyph);
|
|
23531
|
-
changed = true;
|
|
23532
|
-
} else if (outcome2.kind === "error") {
|
|
23533
|
-
retryLater(origId, parentPageId, outcome2.error);
|
|
23534
|
-
} else {
|
|
23535
|
-
degrade(origId);
|
|
23536
|
-
}
|
|
23537
|
-
};
|
|
23538
|
-
const knownOriginal = linkedMap.get(nohyph);
|
|
23539
|
-
if (knownOriginal) {
|
|
23540
|
-
await registerOriginal(knownOriginal);
|
|
23541
|
-
return;
|
|
23542
|
-
}
|
|
23543
|
-
const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
|
|
23544
|
-
knownIds.add(nohyph);
|
|
23545
|
-
if (outcome.kind === "ok") {
|
|
23546
|
-
claimFolder(outcome.config);
|
|
23547
|
-
dbConfigs.push(outcome.config);
|
|
23548
|
-
registeredNow.add(nohyph);
|
|
23549
|
-
changed = true;
|
|
23550
|
-
} else if (outcome.kind === "linked") {
|
|
23551
|
-
const origNohyph = outcome.originalDbId.replace(/-/g, "");
|
|
23552
|
-
linkedMap.set(nohyph, origNohyph);
|
|
23553
|
-
linkedNow.add(nohyph);
|
|
23554
|
-
linkedChanged = true;
|
|
23555
|
-
await registerOriginal(origNohyph);
|
|
23556
|
-
} else if (outcome.kind === "inaccessible") {
|
|
23557
|
-
degrade(dbId);
|
|
23558
|
-
} else if (outcome.kind === "error") {
|
|
23559
|
-
retryLater(dbId, parentPageId, outcome.error);
|
|
23560
|
-
}
|
|
23561
|
-
};
|
|
23562
|
-
const scanAll = forceRediscovery || discoveryScanPending(this.stateDb);
|
|
23563
|
-
if (signal?.aborted) {
|
|
23564
|
-
nextRetry.parents.push(...retry.parents);
|
|
23565
|
-
nextRetry.dbs.push(...retry.dbs);
|
|
23566
|
-
} else {
|
|
23567
|
-
const root = this.config.notion.rootPageId;
|
|
23568
|
-
const scanIds = scanAll ? [
|
|
23569
|
-
root,
|
|
23570
|
-
...this.stateDb.getAll().filter((r) => r.notionPageId).map((r) => r.notionPageId)
|
|
23571
|
-
].filter((id) => !this._bodyReadPages.has(normalizeNotionId(id))) : options.rescanRoot && !retry.parents.some((id) => notionIdsEqual(id, root)) ? [root, ...retry.parents] : retry.parents;
|
|
23572
|
-
if (scanIds.length > 0) {
|
|
23573
|
-
const { found: found2, unread } = await this.discoverChildDatabases(scanIds);
|
|
23574
|
-
for (const { dbId, parentPageId } of found2) {
|
|
23575
|
-
await register(dbId, parentPageId);
|
|
23576
|
-
}
|
|
23577
|
-
for (const { pageId, error } of unread) unreadParent(pageId, error);
|
|
23578
|
-
}
|
|
23579
|
-
for (const { dbId, parentPageId } of retry.dbs) {
|
|
23580
|
-
await register(dbId, parentPageId);
|
|
23581
|
-
}
|
|
23582
|
-
if (scanAll) this.stateDb.setMeta(DISCOVERY_SCANNED_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
|
|
23583
|
-
}
|
|
23584
|
-
for (const [nohyph, parentPageId] of this._inlineDbRefs) {
|
|
23585
|
-
await register(normalizeNotionId(nohyph), parentPageId);
|
|
23586
|
-
}
|
|
23759
|
+
const registry = await this.openRegistry(failed, {
|
|
23760
|
+
forceRediscovery,
|
|
23761
|
+
...options.rescanRoot !== void 0 ? { rescanRoot: options.rescanRoot } : {},
|
|
23762
|
+
...signal ? { signal } : {},
|
|
23763
|
+
markScanned: true
|
|
23764
|
+
});
|
|
23765
|
+
const {
|
|
23766
|
+
dbConfigs,
|
|
23767
|
+
configuredIds,
|
|
23768
|
+
registeredNow,
|
|
23769
|
+
inaccessibleIds,
|
|
23770
|
+
linkedMap,
|
|
23771
|
+
linkedNow,
|
|
23772
|
+
retry,
|
|
23773
|
+
nextRetry,
|
|
23774
|
+
dirty,
|
|
23775
|
+
register,
|
|
23776
|
+
degrade
|
|
23777
|
+
} = registry;
|
|
23587
23778
|
const stillSyncable = [];
|
|
23588
23779
|
const pulledWhole = /* @__PURE__ */ new Set();
|
|
23589
23780
|
let queue = [...dbConfigs];
|
|
@@ -23621,14 +23812,14 @@ var DatabaseDiscovery = class {
|
|
|
23621
23812
|
config.localFolder = move.to + config.localFolder.slice(move.from.length);
|
|
23622
23813
|
}
|
|
23623
23814
|
}
|
|
23624
|
-
|
|
23815
|
+
dirty.list = true;
|
|
23625
23816
|
}
|
|
23626
23817
|
});
|
|
23627
23818
|
if (dbResult.linkedOriginalDbId) {
|
|
23628
23819
|
linkedMap.set(nohyph, dbResult.linkedOriginalDbId.replace(/-/g, ""));
|
|
23629
23820
|
linkedNow.add(nohyph);
|
|
23630
|
-
|
|
23631
|
-
|
|
23821
|
+
dirty.linked = true;
|
|
23822
|
+
dirty.list = true;
|
|
23632
23823
|
ledger?.settle(dbConfig.databaseId);
|
|
23633
23824
|
getLogger().info(
|
|
23634
23825
|
`[Im-Nobsidian] DB ${dbConfig.databaseId}: linked view \uCEE8\uD14C\uC774\uB108 \uAC10\uC9C0 \u2192 \uD589\uC740 \uC6D0\uBCF8 ${dbResult.linkedOriginalDbId} \uD3F4\uB354\uAC00 \uB2E8\uC77C \uC18C\uC720(F25)`
|
|
@@ -23683,18 +23874,18 @@ var DatabaseDiscovery = class {
|
|
|
23683
23874
|
}
|
|
23684
23875
|
}
|
|
23685
23876
|
if (!signal?.aborted) {
|
|
23686
|
-
|
|
23877
|
+
dirty.linked = await this.pullLinkedViews(linkedMap, stillSyncable, {
|
|
23687
23878
|
pulled: (container, original) => forceRediscovery || linkedNow.has(container) || pulledWhole.has(original)
|
|
23688
|
-
}) ||
|
|
23879
|
+
}) || dirty.linked;
|
|
23689
23880
|
}
|
|
23690
23881
|
ledger?.keepOnly([...configuredIds, ...stillSyncable.map((c) => c.databaseId)]);
|
|
23691
|
-
if (
|
|
23882
|
+
if (dirty.list || dirty.inaccessible || stillSyncable.length !== dbConfigs.length) {
|
|
23692
23883
|
this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(stillSyncable));
|
|
23693
23884
|
}
|
|
23694
|
-
if (
|
|
23885
|
+
if (dirty.inaccessible) {
|
|
23695
23886
|
this.stateDb.setMeta(INACCESSIBLE_DBS_META_KEY, JSON.stringify([...inaccessibleIds]));
|
|
23696
23887
|
}
|
|
23697
|
-
if (
|
|
23888
|
+
if (dirty.linked) {
|
|
23698
23889
|
this.stateDb.setMeta(LINKED_DBS_META_KEY, JSON.stringify(Object.fromEntries(linkedMap)));
|
|
23699
23890
|
}
|
|
23700
23891
|
if (retry.parents.length + retry.dbs.length + nextRetry.parents.length + nextRetry.dbs.length > 0) {
|
|
@@ -23711,6 +23902,217 @@ var DatabaseDiscovery = class {
|
|
|
23711
23902
|
}
|
|
23712
23903
|
return { created, updated, deleted, restored };
|
|
23713
23904
|
}
|
|
23905
|
+
/**
|
|
23906
|
+
* 이번 pull 이 찾을 DB 를 등록부에 올린다 — 블록을 훑고({@link discoverChildDatabases}) 받은 본문의 `<database>` 를
|
|
23907
|
+
* 더한다. 상태 DB 에는 쓰지 않는다 — 등록부를 적는 것은 실제 pull 의 마감이고 dry-run 은 버린다. 다 훑은 표시만
|
|
23908
|
+
* `markScanned` 일 때 적는다.
|
|
23909
|
+
*
|
|
23910
|
+
* @param failed 읽지 못한 페이지 · DB — 이유와 함께 싣는다.
|
|
23911
|
+
* @param options.notePathOf 페이지 → 볼트 경로. 새 DB 폴더를 부모 노트 옆에 둔다 — dry-run 은 다시 이을 노트의 자리를 준다.
|
|
23912
|
+
*/
|
|
23913
|
+
async openRegistry(failed, options) {
|
|
23914
|
+
const { forceRediscovery, signal } = options;
|
|
23915
|
+
const { notePathOf } = options;
|
|
23916
|
+
const dbConfigs = parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY));
|
|
23917
|
+
const configuredIds = new Set(
|
|
23918
|
+
(this.config.notion.databases ?? []).map((d) => d.databaseId.replace(/-/g, ""))
|
|
23919
|
+
);
|
|
23920
|
+
const knownIds = /* @__PURE__ */ new Set([
|
|
23921
|
+
...configuredIds,
|
|
23922
|
+
...dbConfigs.map((c) => c.databaseId.replace(/-/g, ""))
|
|
23923
|
+
]);
|
|
23924
|
+
const dirty = { list: false, inaccessible: false, linked: false };
|
|
23925
|
+
const registeredNow = /* @__PURE__ */ new Set();
|
|
23926
|
+
const folderOwner = new Map(
|
|
23927
|
+
(this.config.notion.databases ?? []).map((d) => [d.localFolder, d.databaseId])
|
|
23928
|
+
);
|
|
23929
|
+
const repairedCount = repairDbFolderCollisions(dbConfigs, folderOwner);
|
|
23930
|
+
for (const c of dbConfigs) folderOwner.set(c.localFolder, c.databaseId);
|
|
23931
|
+
if (repairedCount > 0) {
|
|
23932
|
+
dirty.list = true;
|
|
23933
|
+
getLogger().info(`[Im-Nobsidian] \uB3D9\uBA85 DB \uD3F4\uB354 \uCDA9\uB3CC ${repairedCount}\uAC74 \uBD84\uB9AC(F24)`);
|
|
23934
|
+
}
|
|
23935
|
+
const folderKeys = new Map([...folderOwner].map(([folder, id]) => [pathKey(folder), id]));
|
|
23936
|
+
const claimFolder = (config) => {
|
|
23937
|
+
config.localFolder = resolveDbFolderPath(
|
|
23938
|
+
config.localFolder,
|
|
23939
|
+
config.databaseId,
|
|
23940
|
+
(f) => folderOwner.get(f) ?? folderKeys.get(pathKey(f)) ?? null
|
|
23941
|
+
);
|
|
23942
|
+
folderOwner.set(config.localFolder, config.databaseId);
|
|
23943
|
+
folderKeys.set(pathKey(config.localFolder), config.databaseId);
|
|
23944
|
+
};
|
|
23945
|
+
const inaccessibleIds = loadInaccessibleDbIds(this.stateDb);
|
|
23946
|
+
const degrade = (rawId) => {
|
|
23947
|
+
const nohyph = rawId.replace(/-/g, "");
|
|
23948
|
+
if (!inaccessibleIds.has(nohyph)) {
|
|
23949
|
+
inaccessibleIds.add(nohyph);
|
|
23950
|
+
dirty.inaccessible = true;
|
|
23951
|
+
}
|
|
23952
|
+
getLogger().warn(
|
|
23953
|
+
`[Im-Nobsidian] DB ${rawId}: \uC811\uADFC \uAC00\uB2A5\uD55C data source \uC5C6\uC74C(\uB9C1\uD06C\uB4DC/\uBBF8\uACF5\uC720/\uC0AD\uC81C \uCD94\uC815) \u2014 \uB3D9\uAE30\uD654 \uB300\uC0C1\uC5D0\uC11C \uC81C\uC678(\uC81C\uBAA9\uC740 \uBD80\uBAA8 \uD398\uC774\uC9C0\uC5D0 \uBCF4\uC874)`
|
|
23954
|
+
);
|
|
23955
|
+
};
|
|
23956
|
+
if (forceRediscovery && inaccessibleIds.size > 0) {
|
|
23957
|
+
getLogger().info(
|
|
23958
|
+
`[Im-Nobsidian] --force: \uC811\uADFC \uBD88\uAC00 DB ${inaccessibleIds.size}\uAC1C denylist \uC7AC\uAC80\uC99D`
|
|
23959
|
+
);
|
|
23960
|
+
inaccessibleIds.clear();
|
|
23961
|
+
dirty.inaccessible = true;
|
|
23962
|
+
}
|
|
23963
|
+
const linkedMap = loadLinkedDbMap(this.stateDb);
|
|
23964
|
+
const linkedNow = /* @__PURE__ */ new Set();
|
|
23965
|
+
const retry = parseDiscoveryRetry(this.stateDb.getMeta(DISCOVERY_RETRY_META_KEY));
|
|
23966
|
+
const nextRetry = { parents: [], dbs: [] };
|
|
23967
|
+
const pathOf = (pageId) => notionIdsEqual(pageId, this.config.notion.rootPageId) ? "" : this.stateDb.getByNotionId(pageId)?.obsidianPath ?? pageId;
|
|
23968
|
+
const unreadParent = (pageId, error) => {
|
|
23969
|
+
nextRetry.parents.push(pageId);
|
|
23970
|
+
failed.push({
|
|
23971
|
+
path: pathOf(pageId),
|
|
23972
|
+
operation: "update",
|
|
23973
|
+
error: `\uD558\uC704 DB \uB97C \uD655\uC778\uD558\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uD655\uC778\uD55C\uB2E4: ${error}`
|
|
23974
|
+
});
|
|
23975
|
+
};
|
|
23976
|
+
const retryLater = (dbId, parentPageId, error) => {
|
|
23977
|
+
nextRetry.dbs.push({ dbId, parentPageId });
|
|
23978
|
+
failed.push({
|
|
23979
|
+
path: pathOf(parentPageId),
|
|
23980
|
+
operation: "create",
|
|
23981
|
+
error: `DB ${shortNotionId(dbId)} \uB97C \uC77D\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uBCF8\uB2E4: ${error}`
|
|
23982
|
+
});
|
|
23983
|
+
};
|
|
23984
|
+
const register = async (dbId, parentPageId) => {
|
|
23985
|
+
const nohyph = dbId.replace(/-/g, "");
|
|
23986
|
+
if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) return;
|
|
23987
|
+
const registerOriginal = async (origNohyph) => {
|
|
23988
|
+
if (knownIds.has(origNohyph) || inaccessibleIds.has(origNohyph)) return;
|
|
23989
|
+
const origId = normalizeNotionId(origNohyph);
|
|
23990
|
+
const outcome2 = await this.buildDiscoveredDbConfig(origId, parentPageId, notePathOf);
|
|
23991
|
+
knownIds.add(origNohyph);
|
|
23992
|
+
if (outcome2.kind === "ok") {
|
|
23993
|
+
claimFolder(outcome2.config);
|
|
23994
|
+
dbConfigs.push(outcome2.config);
|
|
23995
|
+
registeredNow.add(origNohyph);
|
|
23996
|
+
dirty.list = true;
|
|
23997
|
+
} else if (outcome2.kind === "error") {
|
|
23998
|
+
retryLater(origId, parentPageId, outcome2.error);
|
|
23999
|
+
} else {
|
|
24000
|
+
degrade(origId);
|
|
24001
|
+
}
|
|
24002
|
+
};
|
|
24003
|
+
const knownOriginal = linkedMap.get(nohyph);
|
|
24004
|
+
if (knownOriginal) {
|
|
24005
|
+
await registerOriginal(knownOriginal);
|
|
24006
|
+
return;
|
|
24007
|
+
}
|
|
24008
|
+
const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId, notePathOf);
|
|
24009
|
+
knownIds.add(nohyph);
|
|
24010
|
+
if (outcome.kind === "ok") {
|
|
24011
|
+
claimFolder(outcome.config);
|
|
24012
|
+
dbConfigs.push(outcome.config);
|
|
24013
|
+
registeredNow.add(nohyph);
|
|
24014
|
+
dirty.list = true;
|
|
24015
|
+
} else if (outcome.kind === "linked") {
|
|
24016
|
+
const origNohyph = outcome.originalDbId.replace(/-/g, "");
|
|
24017
|
+
linkedMap.set(nohyph, origNohyph);
|
|
24018
|
+
linkedNow.add(nohyph);
|
|
24019
|
+
dirty.linked = true;
|
|
24020
|
+
await registerOriginal(origNohyph);
|
|
24021
|
+
} else if (outcome.kind === "inaccessible") {
|
|
24022
|
+
degrade(dbId);
|
|
24023
|
+
} else if (outcome.kind === "error") {
|
|
24024
|
+
retryLater(dbId, parentPageId, outcome.error);
|
|
24025
|
+
}
|
|
24026
|
+
};
|
|
24027
|
+
const scanAll = forceRediscovery || discoveryScanPending(this.stateDb);
|
|
24028
|
+
if (signal?.aborted) {
|
|
24029
|
+
nextRetry.parents.push(...retry.parents);
|
|
24030
|
+
nextRetry.dbs.push(...retry.dbs);
|
|
24031
|
+
} else {
|
|
24032
|
+
const root = this.config.notion.rootPageId;
|
|
24033
|
+
const scanIds = scanAll ? [
|
|
24034
|
+
root,
|
|
24035
|
+
...this.stateDb.getAll().filter((r) => r.notionPageId).map((r) => r.notionPageId)
|
|
24036
|
+
].filter((id) => !this._bodyReadPages.has(normalizeNotionId(id))) : options.rescanRoot && !retry.parents.some((id) => notionIdsEqual(id, root)) ? [root, ...retry.parents] : retry.parents;
|
|
24037
|
+
if (scanIds.length > 0) {
|
|
24038
|
+
const { found: found2, unread } = await this.discoverChildDatabases(scanIds);
|
|
24039
|
+
for (const { dbId, parentPageId } of found2) {
|
|
24040
|
+
await register(dbId, parentPageId);
|
|
24041
|
+
}
|
|
24042
|
+
for (const { pageId, error } of unread) unreadParent(pageId, error);
|
|
24043
|
+
}
|
|
24044
|
+
for (const { dbId, parentPageId } of retry.dbs) {
|
|
24045
|
+
await register(dbId, parentPageId);
|
|
24046
|
+
}
|
|
24047
|
+
if (scanAll && options.markScanned) {
|
|
24048
|
+
this.stateDb.setMeta(DISCOVERY_SCANNED_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
|
|
24049
|
+
}
|
|
24050
|
+
}
|
|
24051
|
+
for (const [nohyph, parentPageId] of this._inlineDbRefs) {
|
|
24052
|
+
await register(normalizeNotionId(nohyph), parentPageId);
|
|
24053
|
+
}
|
|
24054
|
+
return {
|
|
24055
|
+
dbConfigs,
|
|
24056
|
+
configuredIds,
|
|
24057
|
+
registeredNow,
|
|
24058
|
+
inaccessibleIds,
|
|
24059
|
+
linkedMap,
|
|
24060
|
+
linkedNow,
|
|
24061
|
+
retry,
|
|
24062
|
+
nextRetry,
|
|
24063
|
+
dirty,
|
|
24064
|
+
register,
|
|
24065
|
+
degrade
|
|
24066
|
+
};
|
|
24067
|
+
}
|
|
24068
|
+
/**
|
|
24069
|
+
* dry-run — 이번 pull 이 새로 찾을 DB(실제 pull 의 {@link openRegistry} 와 같은 규칙). 볼트 · 상태 DB 를 바꾸지 않는다. 받은
|
|
24070
|
+
* 본문은 dry-run 이 이미 읽은 것(다시 이을 노트 · 로컬도 고친 노트)만 본다 — {@link bodyRead}.
|
|
24071
|
+
*
|
|
24072
|
+
* @returns 새로 찾을 DB 와 그 폴더를 가를 전체 목록(발견해 둔 DB 포함).
|
|
24073
|
+
*/
|
|
24074
|
+
async plannedNewDatabases(failed, options) {
|
|
24075
|
+
if (isDatabaseMode(this.config)) return { found: [], all: [] };
|
|
24076
|
+
const registry = await this.openRegistry(failed, {
|
|
24077
|
+
forceRediscovery: false,
|
|
24078
|
+
rescanRoot: options.rescanRoot,
|
|
24079
|
+
markScanned: false,
|
|
24080
|
+
notePathOf: options.notePathOf
|
|
24081
|
+
});
|
|
24082
|
+
return {
|
|
24083
|
+
found: registry.dbConfigs.filter(
|
|
24084
|
+
(config) => registry.registeredNow.has(config.databaseId.replace(/-/g, ""))
|
|
24085
|
+
),
|
|
24086
|
+
all: registry.dbConfigs.filter(
|
|
24087
|
+
(config) => !registry.inaccessibleIds.has(config.databaseId.replace(/-/g, ""))
|
|
24088
|
+
)
|
|
24089
|
+
};
|
|
24090
|
+
}
|
|
24091
|
+
/**
|
|
24092
|
+
* dry-run — 새로 받을 페이지의 본문을 읽어 그 안의 DB 를 찾는다({@link plannedNewDatabases} 가 그 행을 센다). 실제 pull 은
|
|
24093
|
+
* 받으며 찾는다. 예전에는 읽지 않고 «미리 세지 않았습니다» 만 알렸다. {@link PREVIEW_BODY_READ_BUDGET} 개까지만 읽는다 —
|
|
24094
|
+
* 넘거나 읽지 못한(다 받지 못한) 페이지는 {@link bodyRead} 가 아니어서 알림으로 남는다.
|
|
24095
|
+
*/
|
|
24096
|
+
async readNewPageBodies(pageIds, signal) {
|
|
24097
|
+
if (isDatabaseMode(this.config)) return;
|
|
24098
|
+
const unread = pageIds.filter((pageId) => !this.bodyRead(pageId));
|
|
24099
|
+
for (const pageId of unread.slice(0, PREVIEW_BODY_READ_BUDGET)) {
|
|
24100
|
+
if (signal?.aborted) return;
|
|
24101
|
+
try {
|
|
24102
|
+
const result = await this.notionClient.getPageMarkdown(pageId);
|
|
24103
|
+
if (result.truncated || (result.unknown_block_ids ?? []).length > 0) continue;
|
|
24104
|
+
this.collectInlineDbRefs(pageId, result.markdown);
|
|
24105
|
+
} catch (error) {
|
|
24106
|
+
getLogger().debug(
|
|
24107
|
+
`[Im-Nobsidian] \uBBF8\uB9AC \uBCF4\uAE30: \uC0C8 \uD398\uC774\uC9C0 \uBCF8\uBB38\uC744 \uC77D\uC9C0 \uBABB\uD574 \uC548\uC758 DB \uB294 \uBC1B\uC73C\uBA70 \uCC3E\uC74C (${pageId}): ${error instanceof Error ? error.message : String(error)}`
|
|
24108
|
+
);
|
|
24109
|
+
}
|
|
24110
|
+
}
|
|
24111
|
+
}
|
|
24112
|
+
/** 이번 pull 에서 본문을 받은 페이지인가 — 그 본문의 `<database>` 로 새 DB 를 찾았다. */
|
|
24113
|
+
bodyRead(pageId) {
|
|
24114
|
+
return this._bodyReadPages.has(normalizeNotionId(pageId));
|
|
24115
|
+
}
|
|
23714
24116
|
/**
|
|
23715
24117
|
* 링크드 뷰 컨테이너마다 그 보기로 `.base` 를 쓴다({@link DatabaseSyncer.pullLinkedView}). 원본 DB 를 받은 pull ·
|
|
23716
24118
|
* 이번에 해소한 컨테이너 · 아직 `.base` 가 없는 컨테이너만 — 보기만 고친 것은 DB 수정 시각을 바꾸지 않아 원본처럼
|
|
@@ -23763,7 +24165,7 @@ var DatabaseDiscovery = class {
|
|
|
23763
24165
|
* (예: 부모 "AI Engineer (1).md" + DB "Minirecord Project" → "AI Engineer (1)/Minirecord-Project")
|
|
23764
24166
|
* - 제목 취득 실패 시 부모 폴더명 기반 안전 이름으로 폴백
|
|
23765
24167
|
*/
|
|
23766
|
-
async buildDiscoveredDbConfig(dbId, parentPageId) {
|
|
24168
|
+
async buildDiscoveredDbConfig(dbId, parentPageId, notePathOf = (pageId) => this.stateDb.getByNotionId(pageId)?.obsidianPath ?? null) {
|
|
23767
24169
|
let dbTitle;
|
|
23768
24170
|
try {
|
|
23769
24171
|
const info = await this.notionClient.getDatabaseSyncability(dbId);
|
|
@@ -23789,10 +24191,9 @@ var DatabaseDiscovery = class {
|
|
|
23789
24191
|
getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uC124\uC815 \uC0DD\uC131 \uC2E4\uD328:`, error);
|
|
23790
24192
|
return { kind: "error", error: error instanceof Error ? error.message : String(error) };
|
|
23791
24193
|
}
|
|
23792
|
-
const
|
|
24194
|
+
const obsPath = parentPageId ? notePathOf(parentPageId) : null;
|
|
23793
24195
|
let parentFolder = "";
|
|
23794
|
-
if (
|
|
23795
|
-
const obsPath = parentEntry.obsidianPath;
|
|
24196
|
+
if (obsPath) {
|
|
23796
24197
|
if (isMarkdownPath(obsPath)) {
|
|
23797
24198
|
const parts = obsPath.split("/");
|
|
23798
24199
|
const fileBase = stripMarkdownExtension(parts.pop() ?? "");
|
|
@@ -24544,7 +24945,14 @@ var LocalPlanner = class {
|
|
|
24544
24945
|
live.add(target);
|
|
24545
24946
|
}
|
|
24546
24947
|
}
|
|
24547
|
-
const
|
|
24948
|
+
const filePairs = await this.movedFolderFilePairs(
|
|
24949
|
+
[...folderRecords].filter((folder) => !live.has(folder))
|
|
24950
|
+
);
|
|
24951
|
+
for (const { to } of filePairs) for (const folder of foldersOf([to])) live.add(folder);
|
|
24952
|
+
const pairs = [
|
|
24953
|
+
...scan.adoptions.map((a) => ({ from: a.record.obsidianPath, to: a.to })),
|
|
24954
|
+
...filePairs
|
|
24955
|
+
];
|
|
24548
24956
|
const folderMoves = [];
|
|
24549
24957
|
const databaseFolderMoves = [];
|
|
24550
24958
|
for (const move of deriveFolderMoves(tracked, live, pairs, hints)) {
|
|
@@ -24553,6 +24961,29 @@ var LocalPlanner = class {
|
|
|
24553
24961
|
}
|
|
24554
24962
|
return { folderMoves, databaseFolderMoves };
|
|
24555
24963
|
}
|
|
24964
|
+
/**
|
|
24965
|
+
* 사라진 폴더 페이지에 올린 폴더 첨부의 짝 — 노트 없이 파일만 든 폴더는 노트의 짝이 없어 이름을 바꿔도 옮긴 줄 몰랐다. 그
|
|
24966
|
+
* 폴더 페이지에 올린 파일과 이름 · 크기가 같은 파일이 볼트에 하나뿐이면 그리로 옮겼다. 예전에는 새 폴더 페이지를 만들어 다시
|
|
24967
|
+
* 올렸고, 삭제 동기화가 꺼져 있으면 옛 폴더 페이지가 남아 다른 볼트가 두 폴더를 받았다.
|
|
24968
|
+
*/
|
|
24969
|
+
async movedFolderFilePairs(missing) {
|
|
24970
|
+
const entries = missing.flatMap((folder) => {
|
|
24971
|
+
const pageId = this.stateDb.getByPath(folder)?.notionPageId;
|
|
24972
|
+
if (!pageId) return [];
|
|
24973
|
+
return this.stateDb.getFilesByPageId(pageId).filter((entry) => entry.localPath.startsWith(`${folder}/`));
|
|
24974
|
+
});
|
|
24975
|
+
if (entries.length === 0) return [];
|
|
24976
|
+
const files = await this.vaultFs.listNonMarkdownFiles();
|
|
24977
|
+
const nameOf = (path) => path.slice(path.lastIndexOf("/") + 1);
|
|
24978
|
+
const pairs = [];
|
|
24979
|
+
for (const entry of entries) {
|
|
24980
|
+
const found2 = files.filter(
|
|
24981
|
+
(file) => nameOf(file.path) === nameOf(entry.localPath) && file.size === entry.fileSize
|
|
24982
|
+
);
|
|
24983
|
+
if (found2.length === 1) pairs.push({ from: entry.localPath, to: found2[0].path });
|
|
24984
|
+
}
|
|
24985
|
+
return pairs;
|
|
24986
|
+
}
|
|
24556
24987
|
/** 레코드를 새 경로로 옮겨 적는다 — 이동 WAL · 위키링크 · 보존 마커도 함께. */
|
|
24557
24988
|
moveRecord(record, to) {
|
|
24558
24989
|
const from = record.obsidianPath;
|
|
@@ -27174,11 +27605,14 @@ var PagePusher = class {
|
|
|
27174
27605
|
async pushUpdatePage(pageId, markdownContent, notePath) {
|
|
27175
27606
|
if (this.config.conversion.preferMarkdownApi !== false) {
|
|
27176
27607
|
const deferred = deferSyncedReferences(obsidianToNotionEnhanced(markdownContent));
|
|
27177
|
-
const { markdown, kept } = await this.imageHandler.keepUploadedMedia(
|
|
27608
|
+
const { markdown: body, kept } = await this.imageHandler.keepUploadedMedia(
|
|
27178
27609
|
pageId,
|
|
27179
27610
|
deferred.nfm,
|
|
27180
27611
|
notePath
|
|
27181
27612
|
);
|
|
27613
|
+
const folderFiles = await this.folderFileLines(pageId);
|
|
27614
|
+
const markdown = folderFiles ? `${body}
|
|
27615
|
+
${folderFiles}` : body;
|
|
27182
27616
|
const written = await replacePageBody(this.notionClient, pageId, markdown, {
|
|
27183
27617
|
databaseIdsOfBase: (basePath) => this.databaseIdsOfBase(basePath),
|
|
27184
27618
|
unknownTagFallbacks: unknownTagFallbacks(markdownContent)
|
|
@@ -27198,6 +27632,7 @@ var PagePusher = class {
|
|
|
27198
27632
|
"\uC790\uC2DD \uD398\uC774\uC9C0 \xB7 DB \uAC00 \uC788\uB294 \uD398\uC774\uC9C0\uB294 \uBE14\uB85D \uBC29\uC2DD\uC73C\uB85C \uBCF8\uBB38\uC744 \uBCF4\uB0B4\uBA74 \uC790\uC2DD\uAE4C\uC9C0 \uC9C0\uC6CC\uC838 \uBCF4\uB0B4\uC9C0 \uC54A\uC74C \u2014 \uC124\uC815 conversion.preferMarkdownApi \uB97C \uAE30\uBCF8\uAC12(true)\uC73C\uB85C \uB450\uACE0 \uB2E4\uC2DC push \uD558\uC138\uC694"
|
|
27199
27633
|
);
|
|
27200
27634
|
}
|
|
27635
|
+
this.forgetFolderFilesForBlockMode(pageId, notePath);
|
|
27201
27636
|
const { nfm, blocks } = blockModeBody(markdownContent);
|
|
27202
27637
|
if (blocks.length > 0) {
|
|
27203
27638
|
await this.notionClient.appendBlockTree(pageId, blocks);
|
|
@@ -27215,6 +27650,32 @@ var PagePusher = class {
|
|
|
27215
27650
|
);
|
|
27216
27651
|
return { fingerprint: null, kept: [], sent: nfm, syncedRefs: [], syncedRefText: "" };
|
|
27217
27652
|
}
|
|
27653
|
+
/**
|
|
27654
|
+
* 노트 페이지의 폴더 첨부 줄(ADR-056) — 본문을 통째로 바꿀 때 받은 줄 그대로 끝에 되실어야 Notion 이 그 블록을 둔다
|
|
27655
|
+
* ({@link ImageHandler.keepUploadedMedia} 와 같은 실측). 이 페이지에 적어 둔 폴더 첨부가 없으면 읽지 않는다. 읽지 못하면
|
|
27656
|
+
* 던진다 — 모르고 바꾸면 폴더 첨부가 지워진다.
|
|
27657
|
+
*/
|
|
27658
|
+
async folderFileLines(pageId) {
|
|
27659
|
+
const ids = new Set(folderFileIdsOf(this.stateDb, pageId).values());
|
|
27660
|
+
if (ids.size === 0) return "";
|
|
27661
|
+
const remote = (await this.notionClient.getPageMarkdown(pageId)).markdown;
|
|
27662
|
+
return (folderFilesInNote(remote, { knownIds: ids })?.lines ?? []).join("\n");
|
|
27663
|
+
}
|
|
27664
|
+
/**
|
|
27665
|
+
* 블록 방식은 본문 블록을 모두 지우고 새로 붙인다 — 폴더 첨부 블록을 남기면 새 본문이 그 뒤에 붙는다.
|
|
27666
|
+
* 함께 지우고 기록을 놓는다 — 이어지는 첨부 push 가 볼트의 파일을 다시 끝에 올린다.
|
|
27667
|
+
*/
|
|
27668
|
+
forgetFolderFilesForBlockMode(pageId, notePath) {
|
|
27669
|
+
const ids = folderFileIdsOf(this.stateDb, pageId);
|
|
27670
|
+
if (ids.size === 0) return;
|
|
27671
|
+
for (const localPath of ids.keys()) {
|
|
27672
|
+
this.stateDb.deleteFileRegistry(localPath);
|
|
27673
|
+
forgetFolderFileId(this.stateDb, pageId, localPath);
|
|
27674
|
+
}
|
|
27675
|
+
getLogger().info(
|
|
27676
|
+
`[Im-Nobsidian] \uBE14\uB85D \uBC29\uC2DD\uC73C\uB85C \uBCF8\uBB38\uC744 \uBC14\uAFD4 \uD3F4\uB354 \uCCA8\uBD80 ${ids.size}\uAC1C\uB97C \uB2E4\uC2DC \uC62C\uB9BC (${notePath})`
|
|
27677
|
+
);
|
|
27678
|
+
}
|
|
27218
27679
|
/**
|
|
27219
27680
|
* 자리표시로 보낸 코드를 채우고(S-22) 동기화 블록 참조를 붙인다({@link deferSyncedReferences}) — 본문을
|
|
27220
27681
|
* 바꾼 뒤(블록 방식이면 옛 블록을 지운 뒤) 부른다.
|
|
@@ -27454,34 +27915,80 @@ var PagePuller = class {
|
|
|
27454
27915
|
);
|
|
27455
27916
|
}
|
|
27456
27917
|
/**
|
|
27457
|
-
* dry-run — 실제 pull 이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7).
|
|
27458
|
-
* 이미 있는 폴더(`folder` —
|
|
27459
|
-
*
|
|
27460
|
-
* 더한다. 볼트도 상태 DB 도
|
|
27918
|
+
* dry-run — 실제 pull({@link createPulledPage})이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7).
|
|
27919
|
+
* 다시 이을 노트(`note` — 충돌로 남길 것이면 `conflict`) · 이미 있는 폴더(`folder` — 폴더로만 받는 페이지), 아니면 null.
|
|
27920
|
+
* 그 자리에 무엇이 있을 때만 본문을 받아 실제 pull 처럼 렌더해 견준다 — 미디어는 받되 볼트에 쓰지 않는다
|
|
27921
|
+
* ({@link ImageHandler.preview}). `taken` 은 이번 미리 보기가 이미 이은 자리({@link pathKey}) — 고르면 더한다. 볼트도 상태 DB 도
|
|
27922
|
+
* 바꾸지 않는다.
|
|
27461
27923
|
*/
|
|
27462
27924
|
async plannedRelink(pageId, taken) {
|
|
27463
27925
|
const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
|
|
27464
|
-
const
|
|
27926
|
+
const title = this.notionClient.extractTitle(page);
|
|
27927
|
+
const safeName = sanitizeFileName(title);
|
|
27465
27928
|
const parentPath = await this.resolveParentPath(page);
|
|
27929
|
+
if (this.excludedCreatePath(parentPath, safeName) !== null) return null;
|
|
27466
27930
|
const folder = parentPath ? `${parentPath}/${safeName}` : safeName;
|
|
27467
|
-
|
|
27468
|
-
|
|
27469
|
-
|
|
27470
|
-
|
|
27471
|
-
dir,
|
|
27472
|
-
safeName,
|
|
27473
|
-
pageId,
|
|
27474
|
-
(key) => taken.has(key)
|
|
27475
|
-
);
|
|
27476
|
-
if (found2 && relinksWithoutContent(found2, this.unlinkedVault)) {
|
|
27477
|
-
taken.add(pathKey(found2.path));
|
|
27478
|
-
return { path: found2.path, kind: "note" };
|
|
27479
|
-
}
|
|
27931
|
+
const untrackedIn = (dir) => findUntrackedNote(this.stateDb, this.vaultFs, dir, safeName, pageId, (key) => taken.has(key));
|
|
27932
|
+
const folderExists = async () => !taken.has(pathKey(folder)) && this.stateDb.getByPath(folder) === null && await this.vaultFs.exists(folder);
|
|
27933
|
+
if (!await untrackedIn(parentPath) && !await untrackedIn(folder) && !await folderExists()) {
|
|
27934
|
+
return null;
|
|
27480
27935
|
}
|
|
27481
|
-
const
|
|
27482
|
-
|
|
27483
|
-
|
|
27484
|
-
|
|
27936
|
+
const fetched = await this.fetchPageMarkdown(pageId);
|
|
27937
|
+
const hasChildPages = this.hasChildContainers(pageId, fetched.children);
|
|
27938
|
+
const folderOnly = hasChildPages && !fetched.hasBody || fetched.folderFiles !== null && (hasChildPages || fetched.folderFiles.named);
|
|
27939
|
+
if (folderOnly) {
|
|
27940
|
+
if (!await folderExists()) return null;
|
|
27941
|
+
taken.add(pathKey(folder));
|
|
27942
|
+
return { path: folder, kind: "folder" };
|
|
27943
|
+
}
|
|
27944
|
+
const found2 = await untrackedIn(hasChildPages ? folder : parentPath);
|
|
27945
|
+
if (!found2) return null;
|
|
27946
|
+
const local = await readLocalNote(this.vaultFs, found2.path);
|
|
27947
|
+
if (local === void 0) return null;
|
|
27948
|
+
const rendered = await this.imageHandler.preview(
|
|
27949
|
+
() => this.renderPageNote({
|
|
27950
|
+
pageId,
|
|
27951
|
+
page,
|
|
27952
|
+
fetched,
|
|
27953
|
+
filePath: found2.path,
|
|
27954
|
+
title,
|
|
27955
|
+
titleFollowsName: extractTitle(found2.path) === title,
|
|
27956
|
+
downloadMedia: true,
|
|
27957
|
+
local: { content: local, markers: [] },
|
|
27958
|
+
adoptLocalMedia: true
|
|
27959
|
+
})
|
|
27960
|
+
);
|
|
27961
|
+
const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
|
|
27962
|
+
const outcome = relinkOutcome(
|
|
27963
|
+
found2.existing,
|
|
27964
|
+
same,
|
|
27965
|
+
this.unlinkedVault,
|
|
27966
|
+
this.config.sync.conflictStrategy
|
|
27967
|
+
);
|
|
27968
|
+
if (outcome === null) return null;
|
|
27969
|
+
taken.add(pathKey(found2.path));
|
|
27970
|
+
if (outcome !== "conflict") return { path: found2.path, kind: "note" };
|
|
27971
|
+
const parentId = await extractParentId(this.notionClient, page);
|
|
27972
|
+
return {
|
|
27973
|
+
path: found2.path,
|
|
27974
|
+
kind: "note",
|
|
27975
|
+
conflict: relinkConflict({
|
|
27976
|
+
record: plannedRelinkRecord({
|
|
27977
|
+
path: found2.path,
|
|
27978
|
+
pageId,
|
|
27979
|
+
parentId,
|
|
27980
|
+
remote: rendered.content,
|
|
27981
|
+
lastEdited: page.last_edited_time,
|
|
27982
|
+
fileType: hasChildPages ? "folder-note" : "file"
|
|
27983
|
+
}),
|
|
27984
|
+
path: found2.path,
|
|
27985
|
+
local,
|
|
27986
|
+
remote: rendered.content,
|
|
27987
|
+
pageId,
|
|
27988
|
+
lastEdited: page.last_edited_time,
|
|
27989
|
+
title
|
|
27990
|
+
})
|
|
27991
|
+
};
|
|
27485
27992
|
}
|
|
27486
27993
|
/**
|
|
27487
27994
|
* 새 페이지가 놓일 자리가 뺀 자리인가 — 부모 폴더 · 노트 이름 · 폴더 노트가 될 때의 폴더를 본다. 받기 전에는 폴더 노트가
|
|
@@ -27546,7 +28053,10 @@ var PagePuller = class {
|
|
|
27546
28053
|
const local = await readLocalNote(this.vaultFs, filePath);
|
|
27547
28054
|
rendered = await render2(filePath, local);
|
|
27548
28055
|
const relinked = local === void 0 ? null : this.relinkExistingNote(filePath, claim.existing, local, rendered, note);
|
|
27549
|
-
if (relinked)
|
|
28056
|
+
if (relinked) {
|
|
28057
|
+
await this.receiveNoteFiles(filePath, fileType, pageId, fetched);
|
|
28058
|
+
return relinked;
|
|
28059
|
+
}
|
|
27550
28060
|
this.claimedPaths.delete(pathKey(filePath));
|
|
27551
28061
|
filePath = (await this.resolveUniqueFilePath(noteDir, safeName, pageId)).path;
|
|
27552
28062
|
rendered = await render2(filePath);
|
|
@@ -27559,24 +28069,35 @@ var PagePuller = class {
|
|
|
27559
28069
|
properties: rendered.properties,
|
|
27560
28070
|
status: "synced"
|
|
27561
28071
|
});
|
|
28072
|
+
await this.receiveNoteFiles(filePath, fileType, pageId, fetched);
|
|
27562
28073
|
return { path: filePath };
|
|
27563
28074
|
}
|
|
28075
|
+
/** 새로 받은 노트 페이지의 폴더 첨부를 그 노트의 폴더에 받는다(ADR-056) — 폴더 노트는 그 폴더, 노트는 이름의 폴더. */
|
|
28076
|
+
async receiveNoteFiles(filePath, fileType, pageId, fetched) {
|
|
28077
|
+
if (fetched.noteFiles.length === 0) return;
|
|
28078
|
+
await this.receiveFolderFiles(
|
|
28079
|
+
childFolderOf({ fileType, obsidianPath: filePath }, this.placement.folderLookup()),
|
|
28080
|
+
pageId,
|
|
28081
|
+
fetched.noteFiles,
|
|
28082
|
+
fetched.noteFilesComplete
|
|
28083
|
+
);
|
|
28084
|
+
}
|
|
27564
28085
|
/**
|
|
27565
28086
|
* 상태 기록에 없는 노트가 이 페이지가 놓일 자리에 이미 있다 — 상태 DB 를 잃었거나 처음 잇는 볼트다(P7).
|
|
27566
28087
|
* 예전에는 남의 노트로 보고 id 를 붙인 이름으로 또 받아 모든 노트가 두 벌이 됐다(push 는 모든 페이지를 또 만들었다).
|
|
27567
|
-
*
|
|
27568
|
-
* - 글이 같으면(끝 개행만 달라도) 그 노트를 이 페이지와 다시 잇는다 — 쓰지 않는다.
|
|
27569
|
-
* - 다르면 이 페이지 id 를 단 이름(지난번 이 페이지를 받은 이름)이면 충돌 전략을 따른다: remote-first 는 원격으로 덮고 ·
|
|
27570
|
-
* local-first 는 로컬을 두어 다음 push 가 올리고 · 그 밖은 충돌로 남긴다(지난 사본이 없어 merge 도 합칠 수 없다).
|
|
27571
|
-
* - 자연 이름에 있는 다른 글은 이은 노트가 하나도 없는 볼트에서만 잇고 전략과 상관없이 충돌로 남긴다 — 다른 노트일 수 있어
|
|
27572
|
-
* 덮거나 올리면 잃는다. 이은 노트가 있는 볼트에서는 잇지 않는다(null) — 그 볼트의 새 노트일 수 있다.
|
|
28088
|
+
* 받은 글과 견줘 {@link relinkOutcome} 대로 잇는다 — 잇지 않으면 null(id 를 단 새 이름으로 받는다).
|
|
27573
28089
|
*/
|
|
27574
28090
|
relinkExistingNote(filePath, existing, local, rendered, note) {
|
|
27575
28091
|
const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
|
|
27576
|
-
|
|
28092
|
+
const outcome = relinkOutcome(
|
|
28093
|
+
existing,
|
|
28094
|
+
same,
|
|
28095
|
+
this.unlinkedVault,
|
|
28096
|
+
this.config.sync.conflictStrategy
|
|
28097
|
+
);
|
|
28098
|
+
if (outcome === null) return null;
|
|
27577
28099
|
return (async () => {
|
|
27578
|
-
|
|
27579
|
-
if (same || strategy === "local-first") {
|
|
28100
|
+
if (outcome === "relink") {
|
|
27580
28101
|
await this.recordPulledNote(filePath, note, {
|
|
27581
28102
|
content: rendered.content,
|
|
27582
28103
|
properties: rendered.properties,
|
|
@@ -27587,7 +28108,7 @@ var PagePuller = class {
|
|
|
27587
28108
|
});
|
|
27588
28109
|
return { path: filePath, relinked: true };
|
|
27589
28110
|
}
|
|
27590
|
-
if (
|
|
28111
|
+
if (outcome === "overwrite") {
|
|
27591
28112
|
await this.vaultFs.writeFile(filePath, rendered.content);
|
|
27592
28113
|
await this.recordPulledNote(filePath, note, {
|
|
27593
28114
|
content: rendered.content,
|
|
@@ -27603,27 +28124,15 @@ var PagePuller = class {
|
|
|
27603
28124
|
snapshot: null,
|
|
27604
28125
|
differsOnDisk: true
|
|
27605
28126
|
});
|
|
27606
|
-
const
|
|
27607
|
-
|
|
27608
|
-
|
|
27609
|
-
|
|
27610
|
-
|
|
27611
|
-
|
|
27612
|
-
|
|
27613
|
-
|
|
27614
|
-
|
|
27615
|
-
remoteChange: {
|
|
27616
|
-
pageId: note.pageId,
|
|
27617
|
-
type: "modified",
|
|
27618
|
-
lastEdited: note.page.last_edited_time,
|
|
27619
|
-
previousEdited: null,
|
|
27620
|
-
path: filePath,
|
|
27621
|
-
title: note.title
|
|
27622
|
-
},
|
|
27623
|
-
baseContent: null,
|
|
27624
|
-
localContent: local,
|
|
27625
|
-
remoteContent: rendered.content
|
|
27626
|
-
};
|
|
28127
|
+
const conflict = relinkConflict({
|
|
28128
|
+
record: this.stateDb.getByPath(filePath),
|
|
28129
|
+
path: filePath,
|
|
28130
|
+
local,
|
|
28131
|
+
remote: rendered.content,
|
|
28132
|
+
pageId: note.pageId,
|
|
28133
|
+
lastEdited: note.page.last_edited_time,
|
|
28134
|
+
title: note.title
|
|
28135
|
+
});
|
|
27627
28136
|
return { path: filePath, relinked: true, conflict };
|
|
27628
28137
|
})();
|
|
27629
28138
|
}
|
|
@@ -27731,6 +28240,14 @@ var PagePuller = class {
|
|
|
27731
28240
|
}
|
|
27732
28241
|
const rendered = await this.renderRemotePage(record, change.pageId, page);
|
|
27733
28242
|
const remoteContent = rendered.content;
|
|
28243
|
+
if (rendered.folderFiles && !remoteGone) {
|
|
28244
|
+
await this.receiveFolderFiles(
|
|
28245
|
+
childFolderOf(record, this.placement.folderLookup()),
|
|
28246
|
+
change.pageId,
|
|
28247
|
+
rendered.folderFiles.files,
|
|
28248
|
+
rendered.folderFiles.complete
|
|
28249
|
+
);
|
|
28250
|
+
}
|
|
27734
28251
|
let localContent;
|
|
27735
28252
|
let localExists = true;
|
|
27736
28253
|
try {
|
|
@@ -27874,7 +28391,10 @@ var PagePuller = class {
|
|
|
27874
28391
|
if (record.fileType === "db-row") {
|
|
27875
28392
|
return this.databaseSyncer.renderRow(page, record.obsidianPath, { downloadMedia });
|
|
27876
28393
|
}
|
|
27877
|
-
const
|
|
28394
|
+
const local = await readLocalNote(this.vaultFs, record.obsidianPath);
|
|
28395
|
+
const fetched = await this.fetchPageMarkdown(pageId, {
|
|
28396
|
+
embedded: embeddedNamesOf(local === void 0 ? [] : [local])
|
|
28397
|
+
});
|
|
27878
28398
|
const title = this.notionClient.extractTitle(page);
|
|
27879
28399
|
const { content, properties } = await this.renderPageNote({
|
|
27880
28400
|
pageId,
|
|
@@ -27885,11 +28405,17 @@ var PagePuller = class {
|
|
|
27885
28405
|
titleFollowsName: this.titleFollowsName(record, title),
|
|
27886
28406
|
downloadMedia,
|
|
27887
28407
|
local: {
|
|
27888
|
-
content:
|
|
28408
|
+
content: local,
|
|
27889
28409
|
markers: this.stateDb.getPreserveMarkers(record.obsidianPath)
|
|
27890
28410
|
}
|
|
27891
28411
|
});
|
|
27892
|
-
return {
|
|
28412
|
+
return {
|
|
28413
|
+
content,
|
|
28414
|
+
title,
|
|
28415
|
+
properties,
|
|
28416
|
+
bodyFingerprint: fetched.fingerprint,
|
|
28417
|
+
folderFiles: { files: fetched.noteFiles, complete: fetched.noteFilesComplete }
|
|
28418
|
+
};
|
|
27893
28419
|
}
|
|
27894
28420
|
/**
|
|
27895
28421
|
* 받은 페이지 본문을 볼트에 쓸 노트로 — 새로 받기(`createPulledPage`)와 다시 받기({@link renderRemotePage})가
|
|
@@ -27957,19 +28483,27 @@ var PagePuller = class {
|
|
|
27957
28483
|
/**
|
|
27958
28484
|
* 페이지 본문을 볼트 쪽 markdown 으로 받는다.
|
|
27959
28485
|
*
|
|
28486
|
+
* @param options.embedded 로컬 노트가 임베드한 파일 이름 — 폴더 첨부({@link folderFilesInNote})를 노트에서 떼어
|
|
28487
|
+
* `noteFiles` 로 돌려줄 때, 이 이름 · 이 볼트가 노트 첨부로 올린 파일은 노트의 것으로 둔다(ADR-056).
|
|
27960
28488
|
* @returns `fingerprint` 는 받은 원격 본문의 지문({@link remoteBodyFingerprint}). 블록 API 로
|
|
27961
28489
|
* 받았으면 null — 지문은 Markdown API 의 본문으로만 견준다.
|
|
27962
28490
|
*/
|
|
27963
|
-
async fetchPageMarkdown(pageId) {
|
|
28491
|
+
async fetchPageMarkdown(pageId, options = {}) {
|
|
28492
|
+
const noteFilesOf = (markdown) => this.noteFolderFilesOf(pageId, markdown, options);
|
|
27964
28493
|
if (this.config.conversion.preferMarkdownApi !== false) {
|
|
27965
28494
|
try {
|
|
27966
28495
|
const result = await this.notionClient.getPageMarkdown(pageId);
|
|
27967
28496
|
this.discovery.collectInlineDbRefs(pageId, result.markdown);
|
|
27968
28497
|
this.detector.noteBodyChildren(pageId, result.markdown);
|
|
28498
|
+
const found2 = noteFilesOf(result.markdown);
|
|
27969
28499
|
return {
|
|
27970
28500
|
content: await this.resolveBlockLinks(
|
|
27971
28501
|
this.resolveNotionIdWikilinks(
|
|
27972
|
-
await notionBodyToObsidian(
|
|
28502
|
+
await notionBodyToObsidian(
|
|
28503
|
+
this.notionClient,
|
|
28504
|
+
found2?.markdown ?? result.markdown,
|
|
28505
|
+
pageId
|
|
28506
|
+
)
|
|
27973
28507
|
),
|
|
27974
28508
|
pageId
|
|
27975
28509
|
),
|
|
@@ -27979,7 +28513,9 @@ var PagePuller = class {
|
|
|
27979
28513
|
children: extractChildTags(result.markdown),
|
|
27980
28514
|
fingerprint: remoteBodyFingerprint(result.markdown),
|
|
27981
28515
|
hasBody: hasBodyBesidesChildren(result.markdown),
|
|
27982
|
-
folderFiles: folderFilesInBody(result.markdown)
|
|
28516
|
+
folderFiles: folderFilesInBody(result.markdown),
|
|
28517
|
+
noteFiles: found2?.files ?? [],
|
|
28518
|
+
noteFilesComplete: !result.truncated && this.noteFilesHoldAll(pageId, found2?.markdown ?? result.markdown)
|
|
27983
28519
|
};
|
|
27984
28520
|
} catch (error) {
|
|
27985
28521
|
getLogger().warn(
|
|
@@ -27987,7 +28523,14 @@ var PagePuller = class {
|
|
|
27987
28523
|
);
|
|
27988
28524
|
}
|
|
27989
28525
|
}
|
|
27990
|
-
|
|
28526
|
+
let noteFiles = [];
|
|
28527
|
+
let noteFilesComplete = true;
|
|
28528
|
+
const fallback = await pageBlocksToObsidian(this.notionClient, pageId, (markdown) => {
|
|
28529
|
+
const found2 = noteFilesOf(markdown);
|
|
28530
|
+
noteFiles = found2?.files ?? [];
|
|
28531
|
+
noteFilesComplete = this.noteFilesHoldAll(pageId, found2?.markdown ?? markdown);
|
|
28532
|
+
return found2?.markdown ?? markdown;
|
|
28533
|
+
});
|
|
27991
28534
|
this.discovery.collectInlineDbRefs(pageId, fallback.markdown);
|
|
27992
28535
|
this.detector.noteBodyChildren(pageId, fallback.markdown);
|
|
27993
28536
|
return {
|
|
@@ -27999,9 +28542,28 @@ var PagePuller = class {
|
|
|
27999
28542
|
children: extractChildTags(fallback.markdown),
|
|
28000
28543
|
fingerprint: null,
|
|
28001
28544
|
hasBody: hasBodyBesidesChildren(fallback.markdown),
|
|
28002
|
-
folderFiles: folderFilesInBody(fallback.markdown)
|
|
28545
|
+
folderFiles: folderFilesInBody(fallback.markdown),
|
|
28546
|
+
noteFiles,
|
|
28547
|
+
noteFilesComplete
|
|
28003
28548
|
};
|
|
28004
28549
|
}
|
|
28550
|
+
/** 노트 페이지의 폴더 첨부({@link folderFilesInNote}) — 없으면 null. */
|
|
28551
|
+
noteFolderFilesOf(pageId, markdown, options) {
|
|
28552
|
+
const media = recallUploadedMedia(this.stateDb, pageId);
|
|
28553
|
+
return folderFilesInNote(markdown, {
|
|
28554
|
+
knownIds: new Set(folderFileIdsOf(this.stateDb, pageId).values()),
|
|
28555
|
+
exclude: (file) => {
|
|
28556
|
+
const fileId = notionFileIdOf(file.url);
|
|
28557
|
+
return options.embedded?.has(file.name) === true || fileId !== null && media.has(fileId);
|
|
28558
|
+
}
|
|
28559
|
+
});
|
|
28560
|
+
}
|
|
28561
|
+
/** 폴더 첨부를 뗀 본문에 적어 둔 폴더 첨부가 남지 않았나 — {@link fetchPageMarkdown} 의 `noteFilesComplete`. */
|
|
28562
|
+
noteFilesHoldAll(pageId, rest) {
|
|
28563
|
+
const ids = new Set(folderFileIdsOf(this.stateDb, pageId).values());
|
|
28564
|
+
if (ids.size === 0) return true;
|
|
28565
|
+
return !notionMediaSpans(rest).some((span) => ids.has(notionFileIdOf(span.url) ?? ""));
|
|
28566
|
+
}
|
|
28005
28567
|
/** 폴더 첨부를 받아 받은 파일 수에 더한다. */
|
|
28006
28568
|
async receiveFolderFiles(folder, pageId, files, complete2 = false) {
|
|
28007
28569
|
this._pullFileCount += await this.fileHandler.receiveFolderFiles(
|
|
@@ -28413,7 +28975,7 @@ function resolvesNow(stateDb, record, marker, pushed) {
|
|
|
28413
28975
|
|
|
28414
28976
|
// src/sync/pull-planner.ts
|
|
28415
28977
|
var PullPlanner = class {
|
|
28416
|
-
constructor(config, stateDb, vaultFs, databaseSyncer, drift, planner, puller) {
|
|
28978
|
+
constructor(config, stateDb, vaultFs, databaseSyncer, drift, planner, puller, discovery, detector) {
|
|
28417
28979
|
this.config = config;
|
|
28418
28980
|
this.stateDb = stateDb;
|
|
28419
28981
|
this.vaultFs = vaultFs;
|
|
@@ -28421,6 +28983,8 @@ var PullPlanner = class {
|
|
|
28421
28983
|
this.drift = drift;
|
|
28422
28984
|
this.planner = planner;
|
|
28423
28985
|
this.puller = puller;
|
|
28986
|
+
this.discovery = discovery;
|
|
28987
|
+
this.detector = detector;
|
|
28424
28988
|
}
|
|
28425
28989
|
config;
|
|
28426
28990
|
stateDb;
|
|
@@ -28429,11 +28993,13 @@ var PullPlanner = class {
|
|
|
28429
28993
|
drift;
|
|
28430
28994
|
planner;
|
|
28431
28995
|
puller;
|
|
28996
|
+
discovery;
|
|
28997
|
+
detector;
|
|
28432
28998
|
/**
|
|
28433
28999
|
* pull 할 것을 세기만 한다(dry-run) — 볼트 · 상태 DB · Notion 을 바꾸지 않는다. 페이지와 DB 행을
|
|
28434
|
-
* 같이 센다. DB 는 설정한 DB 와 이미 발견해 둔 DB 가운데 실제 pull 이 조회할 것(`ledger`)
|
|
28435
|
-
*
|
|
28436
|
-
*
|
|
29000
|
+
* 같이 센다. DB 는 설정한 DB 와 이미 발견해 둔 DB 가운데 실제 pull 이 조회할 것(`ledger`)과, 이번 pull 이 새로 찾을
|
|
29001
|
+
* DB(루트 · 미리 보며 읽은 본문 안)를 센다. 세지 못한 DB 는 이유와 함께 `failed` 에 싣는다(세지 못한 것을 없다고 하지
|
|
29002
|
+
* 않는다).
|
|
28437
29003
|
*/
|
|
28438
29004
|
async planPull(filtered, restoreChanges, restoreIds, localPlan, options, startTime, scan, ledger) {
|
|
28439
29005
|
const unchangedDropped = await this.drift.withoutUnchangedRemotes(filtered, options.signal);
|
|
@@ -28459,20 +29025,36 @@ var PullPlanner = class {
|
|
|
28459
29025
|
planned.push(change);
|
|
28460
29026
|
}
|
|
28461
29027
|
const relinks = await this.plannedRelinks(planned);
|
|
29028
|
+
for (const relink of relinks.byPage.values()) {
|
|
29029
|
+
if (relink.conflict) conflicts.push(relink.conflict);
|
|
29030
|
+
}
|
|
28462
29031
|
const queued = new Set(
|
|
28463
29032
|
[...filtered, ...restoreChanges].map((change) => compactNotionId(change.pageId))
|
|
28464
29033
|
);
|
|
28465
29034
|
const movingInVault = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
|
|
29035
|
+
const relinkPaths = new Map(
|
|
29036
|
+
[...relinks.byPage].map(([pageId, relink]) => [compactNotionId(pageId), relink.path])
|
|
29037
|
+
);
|
|
29038
|
+
await this.discovery.readNewPageBodies(
|
|
29039
|
+
planned.filter((change) => change.type === "created").map((change) => change.pageId),
|
|
29040
|
+
options.signal
|
|
29041
|
+
);
|
|
29042
|
+
const newDatabases = await this.discovery.plannedNewDatabases(failed, {
|
|
29043
|
+
rescanRoot: this.detector.rootChanged(),
|
|
29044
|
+
notePathOf: (pageId) => relinkPaths.get(compactNotionId(pageId)) ?? this.stateDb.getByNotionId(pageId)?.obsidianPath ?? null
|
|
29045
|
+
});
|
|
28466
29046
|
const rows = [];
|
|
28467
29047
|
for (const row of await this.planDatabaseRows(
|
|
28468
29048
|
options.paths,
|
|
28469
29049
|
failed,
|
|
28470
29050
|
ledger,
|
|
28471
29051
|
movingInVault,
|
|
28472
|
-
relinks.taken
|
|
29052
|
+
relinks.taken,
|
|
29053
|
+
newDatabases
|
|
28473
29054
|
)) {
|
|
28474
29055
|
if (queued.has(compactNotionId(row.pageId))) continue;
|
|
28475
29056
|
if (row.resolution?.action === "conflict") conflicts.push(row.resolution.conflict);
|
|
29057
|
+
if (row.relinkConflict) conflicts.push(row.relinkConflict);
|
|
28476
29058
|
else if (row.resolution?.action === "merge") mergedPaths.push(row.path);
|
|
28477
29059
|
if (row.resolution?.action === "conflict" || row.resolution?.action === "skip") continue;
|
|
28478
29060
|
rows.push(row);
|
|
@@ -28514,7 +29096,7 @@ var PullPlanner = class {
|
|
|
28514
29096
|
const rowCount = (operation, restore = false) => rows.filter(
|
|
28515
29097
|
(row) => row.operation === operation && (row.restore ?? false) === restore && !row.relink
|
|
28516
29098
|
).length;
|
|
28517
|
-
const undiscoveredDatabases = !isDatabaseMode(this.config) &&
|
|
29099
|
+
const undiscoveredDatabases = !isDatabaseMode(this.config) && planned.some((c) => c.type === "created" && !this.discovery.bodyRead(c.pageId));
|
|
28518
29100
|
const relinkedPaths = [
|
|
28519
29101
|
...[...relinks.byPage.values()].filter((relink) => relink.kind === "note").map((relink) => relink.path),
|
|
28520
29102
|
...rows.filter((row) => row.relink).map((row) => row.path)
|
|
@@ -28617,11 +29199,12 @@ var PullPlanner = class {
|
|
|
28617
29199
|
* (`DatabaseDiscovery.pullDiscoveredDatabases`)를 같은 규칙으로 고른다. 세지 못한 DB 는 이유와
|
|
28618
29200
|
* 함께 `failed` 에 싣는다 — 세지 못한 것을 없다고 하지 않는다.
|
|
28619
29201
|
*/
|
|
28620
|
-
async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken) {
|
|
29202
|
+
async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken, databases) {
|
|
28621
29203
|
const configured = this.config.notion.databases ?? [];
|
|
28622
29204
|
const rows = [];
|
|
29205
|
+
const found2 = new Set(databases.found);
|
|
28623
29206
|
const plan = async (dbConfig, resolveDbFolder2) => {
|
|
28624
|
-
if (!ledger.selects(dbConfig.databaseId)) {
|
|
29207
|
+
if (!found2.has(dbConfig) && !ledger.selects(dbConfig.databaseId)) {
|
|
28625
29208
|
ledger.skip();
|
|
28626
29209
|
return;
|
|
28627
29210
|
}
|
|
@@ -28646,17 +29229,9 @@ var PullPlanner = class {
|
|
|
28646
29229
|
};
|
|
28647
29230
|
for (const dbConfig of configured) await plan(dbConfig);
|
|
28648
29231
|
if (isDatabaseMode(this.config)) return rows;
|
|
28649
|
-
const discovered =
|
|
28650
|
-
repairDbFolderCollisions(
|
|
28651
|
-
discovered,
|
|
28652
|
-
new Map(configured.map((d) => [d.localFolder, d.databaseId]))
|
|
28653
|
-
);
|
|
28654
|
-
const inaccessible = loadInaccessibleDbIds(this.stateDb);
|
|
29232
|
+
const discovered = databases.all;
|
|
28655
29233
|
const resolveDbFolder = (dbId) => [...discovered, ...configured].find((c) => notionIdsEqual(c.databaseId, dbId))?.localFolder ?? null;
|
|
28656
|
-
for (const dbConfig of discovered)
|
|
28657
|
-
if (inaccessible.has(dbConfig.databaseId.replace(/-/g, ""))) continue;
|
|
28658
|
-
await plan(dbConfig, resolveDbFolder);
|
|
28659
|
-
}
|
|
29234
|
+
for (const dbConfig of discovered) await plan(dbConfig, resolveDbFolder);
|
|
28660
29235
|
return rows;
|
|
28661
29236
|
}
|
|
28662
29237
|
};
|
|
@@ -29541,7 +30116,9 @@ var SyncOrchestrator = class {
|
|
|
29541
30116
|
this.databaseSyncer,
|
|
29542
30117
|
this.drift,
|
|
29543
30118
|
this.planner,
|
|
29544
|
-
this.puller
|
|
30119
|
+
this.puller,
|
|
30120
|
+
this.discovery,
|
|
30121
|
+
this.detector
|
|
29545
30122
|
);
|
|
29546
30123
|
this.pusher = new PagePusher(
|
|
29547
30124
|
config,
|
|
@@ -29654,7 +30231,11 @@ var SyncOrchestrator = class {
|
|
|
29654
30231
|
async pushVaultFiles(failed) {
|
|
29655
30232
|
if (this.config.sync.syncFiles === false) return 0;
|
|
29656
30233
|
try {
|
|
29657
|
-
const fileResults = await this.fileHandler.pushAllFiles(
|
|
30234
|
+
const fileResults = await this.fileHandler.pushAllFiles(
|
|
30235
|
+
failed,
|
|
30236
|
+
this.placement,
|
|
30237
|
+
this.notePageWrites()
|
|
30238
|
+
);
|
|
29658
30239
|
if (fileResults.length > 0) {
|
|
29659
30240
|
getLogger().info(`[Im-Nobsidian] ${fileResults.length}\uAC1C \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC`);
|
|
29660
30241
|
}
|
|
@@ -29664,6 +30245,42 @@ var SyncOrchestrator = class {
|
|
|
29664
30245
|
return 0;
|
|
29665
30246
|
}
|
|
29666
30247
|
}
|
|
30248
|
+
/**
|
|
30249
|
+
* 폴더 첨부를 노트의 페이지에 붙이거나 지울 때({@link NotePageWrites}) — 쓰기 전에 원격과 맞던 노트만 쓴 뒤의 원격을 본
|
|
30250
|
+
* 것으로 적는다. 받지 않은 Notion 편집이 걸린 노트를 적으면 다음 pull 이 그 편집을 받지 않는다. 읽지 못하면 적지 않는다 —
|
|
30251
|
+
* 다음 push 가 그 노트를 올리지 않고 pull 을 먼저 하라고 알릴 뿐이다.
|
|
30252
|
+
*/
|
|
30253
|
+
notePageWrites() {
|
|
30254
|
+
const inSync = /* @__PURE__ */ new Map();
|
|
30255
|
+
return {
|
|
30256
|
+
begin: async (pageId) => {
|
|
30257
|
+
const record = this.stateDb.getByNotionId(pageId);
|
|
30258
|
+
if (!record || isFolderRecord(record)) return;
|
|
30259
|
+
try {
|
|
30260
|
+
const page = await this.notionClient.getPage(pageId);
|
|
30261
|
+
if (await this.drift.remoteDrift(record, page) === "none")
|
|
30262
|
+
inSync.set(pageId, record.id);
|
|
30263
|
+
} catch (error) {
|
|
30264
|
+
getLogger().warn(
|
|
30265
|
+
`[Im-Nobsidian] \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBD99\uC77C \uB178\uD2B8\uC758 \uC6D0\uACA9\uC744 \uC77D\uC9C0 \uBABB\uD568 (${record.obsidianPath}): ${error instanceof Error ? error.message : String(error)}`
|
|
30266
|
+
);
|
|
30267
|
+
}
|
|
30268
|
+
},
|
|
30269
|
+
end: async (pageId) => {
|
|
30270
|
+
const recordId = inSync.get(pageId);
|
|
30271
|
+
if (recordId === void 0) return;
|
|
30272
|
+
inSync.delete(pageId);
|
|
30273
|
+
try {
|
|
30274
|
+
const page = await this.notionClient.getPage(pageId);
|
|
30275
|
+
this.observation.record(recordId, page, await this.drift.remoteBodyFingerprintOf(pageId));
|
|
30276
|
+
} catch (error) {
|
|
30277
|
+
getLogger().warn(
|
|
30278
|
+
`[Im-Nobsidian] \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBD99\uC778 \uB178\uD2B8\uC758 \uC6D0\uACA9\uC744 \uC801\uC9C0 \uBABB\uD568 (${pageId}): ${error instanceof Error ? error.message : String(error)}`
|
|
30279
|
+
);
|
|
30280
|
+
}
|
|
30281
|
+
}
|
|
30282
|
+
};
|
|
30283
|
+
}
|
|
29667
30284
|
/**
|
|
29668
30285
|
* @param mergedByPull sync dry-run 만 — 앞선 pull dry-run 이 합칠 노트. 실제 sync 는 합친 뒤 원격을 본 것으로
|
|
29669
30286
|
* 적어 push 가 올린다 — dry-run 은 받지 않아 원격이 바뀐 그대로라, 원격을 보고 거절하지 않는다.
|
|
@@ -32242,7 +32859,7 @@ function hasColumnLayout(text) {
|
|
|
32242
32859
|
var START_RE = new RegExp(`^[\\t ]*${COLUMN_LIST_START}[\\t ]*$`);
|
|
32243
32860
|
var END_RE = new RegExp(`^[\\t ]*${COLUMN_LIST_END}[\\t ]*$`);
|
|
32244
32861
|
var SEP_RE = new RegExp(`^[\\t ]*${COLUMN_SEP_SOURCE}[\\t ]*$`);
|
|
32245
|
-
var
|
|
32862
|
+
var FENCE_RE2 = /^[\t ]*(`{3,}|~{3,})/;
|
|
32246
32863
|
function readColumnLayout(lines) {
|
|
32247
32864
|
const places = new Array(lines.length);
|
|
32248
32865
|
let group2 = -1;
|
|
@@ -32266,7 +32883,7 @@ function readColumnLayout(lines) {
|
|
|
32266
32883
|
open = null;
|
|
32267
32884
|
};
|
|
32268
32885
|
lines.forEach((line, i) => {
|
|
32269
|
-
const fenceMatch =
|
|
32886
|
+
const fenceMatch = FENCE_RE2.exec(line);
|
|
32270
32887
|
if (fence) {
|
|
32271
32888
|
if (fenceMatch && fenceMatch[1].startsWith(fence)) fence = null;
|
|
32272
32889
|
return;
|