@im-nobsidian/core 0.20.0 → 0.22.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 +96 -15
- package/dist/index.js +658 -100
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3753,7 +3753,7 @@ var NotionClient2 = class _NotionClient {
|
|
|
3753
3753
|
return blocks;
|
|
3754
3754
|
}
|
|
3755
3755
|
/**
|
|
3756
|
-
* 자식 블록을 덧붙인다. `options.after` 를 주면 그 블록 **바로
|
|
3756
|
+
* 자식 블록을 덧붙인다. `options.after` 를 주면 그 블록 **바로 뒤**에, `options.atStart` 면 맨 앞에 삽입한다
|
|
3757
3757
|
* (기본은 맨 끝). 자리표시자를 실제 미디어 블록으로 제자리 교체할 때 필요하다.
|
|
3758
3758
|
*
|
|
3759
3759
|
* 실측(2025-09-03): position 삽입 응답의 `results` 는 방금 넣은 블록뿐 아니라
|
|
@@ -3771,19 +3771,24 @@ var NotionClient2 = class _NotionClient {
|
|
|
3771
3771
|
const batchSize = this.batchSize;
|
|
3772
3772
|
const created = [];
|
|
3773
3773
|
let after = options?.after;
|
|
3774
|
+
let atStart = after === void 0 && options?.atStart === true;
|
|
3774
3775
|
for (let i = 0; i < children.length; i += batchSize) {
|
|
3775
3776
|
const batch = children.slice(i, i + batchSize);
|
|
3777
|
+
const position = after ? { position: { type: "after_block", after_block: { id: after } } } : atStart ? { position: { type: "start" } } : {};
|
|
3776
3778
|
const response = await this.withRateLimit(
|
|
3777
3779
|
() => this.client.blocks.children.append({
|
|
3778
3780
|
block_id: blockId,
|
|
3779
3781
|
children: batch,
|
|
3780
|
-
...
|
|
3782
|
+
...position
|
|
3781
3783
|
}),
|
|
3782
3784
|
CREATES_BLOCKS
|
|
3783
3785
|
);
|
|
3784
3786
|
const blocks = response.results.slice(0, batch.length);
|
|
3785
3787
|
created.push(...blocks);
|
|
3786
|
-
if (after && blocks.length > 0)
|
|
3788
|
+
if ((after || atStart) && blocks.length > 0) {
|
|
3789
|
+
after = blocks[blocks.length - 1].id;
|
|
3790
|
+
atStart = false;
|
|
3791
|
+
}
|
|
3787
3792
|
}
|
|
3788
3793
|
return created;
|
|
3789
3794
|
}
|
|
@@ -3792,9 +3797,11 @@ var NotionClient2 = class _NotionClient {
|
|
|
3792
3797
|
* 자식은 부모를 만든 뒤 그 아래에 마저 붙인다({@link shapeAppendRequests}). 더 깊은 블록의 id 는
|
|
3793
3798
|
* 응답에 없어 부모의 자식 목록을 읽어 찾는다.
|
|
3794
3799
|
*
|
|
3800
|
+
* `options.atStart` 면 맨 위 블록을 맨 앞에 넣는다 — 본문 뒤에 둘 블록(폴더 첨부)을 남기고 본문만 바꿀 때.
|
|
3801
|
+
*
|
|
3795
3802
|
* 중간에 실패하면 그때까지 만든 블록은 남는다 — 호출측이 실패로 알린다.
|
|
3796
3803
|
*/
|
|
3797
|
-
async appendBlockTree(parentId, blocks) {
|
|
3804
|
+
async appendBlockTree(parentId, blocks, options) {
|
|
3798
3805
|
const listed = /* @__PURE__ */ new Map();
|
|
3799
3806
|
const childIds = async (id) => {
|
|
3800
3807
|
let ids = listed.get(id);
|
|
@@ -3805,15 +3812,19 @@ var NotionClient2 = class _NotionClient {
|
|
|
3805
3812
|
return ids;
|
|
3806
3813
|
};
|
|
3807
3814
|
const queue = [
|
|
3808
|
-
{ parentId, blocks }
|
|
3815
|
+
{ parentId, blocks, top: true }
|
|
3809
3816
|
];
|
|
3817
|
+
let topAfter;
|
|
3810
3818
|
while (queue.length > 0) {
|
|
3811
3819
|
const next = queue.shift();
|
|
3812
3820
|
for (const request of shapeAppendRequests(next.blocks)) {
|
|
3821
|
+
const position = next.top && options?.atStart ? topAfter ? { after: topAfter } : { atStart: true } : void 0;
|
|
3813
3822
|
const created = await this.appendChildren(
|
|
3814
3823
|
next.parentId,
|
|
3815
|
-
request.map((shaped) => shaped.body)
|
|
3824
|
+
request.map((shaped) => shaped.body),
|
|
3825
|
+
position
|
|
3816
3826
|
);
|
|
3827
|
+
if (position) topAfter = created[created.length - 1] ?? topAfter;
|
|
3817
3828
|
for (const [k, shaped] of request.entries()) {
|
|
3818
3829
|
for (const pending of shaped.pending) {
|
|
3819
3830
|
let id = created[k];
|
|
@@ -15174,6 +15185,21 @@ function selectStaleDbArtifacts(files, localFolder, keepPaths) {
|
|
|
15174
15185
|
return stale;
|
|
15175
15186
|
}
|
|
15176
15187
|
|
|
15188
|
+
// src/sync/parent-mode.ts
|
|
15189
|
+
function isDatabaseMode(config) {
|
|
15190
|
+
return config.notion.parentMode === "database" && !!config.notion.databaseId;
|
|
15191
|
+
}
|
|
15192
|
+
function rowDatabaseOf(config, record) {
|
|
15193
|
+
if (record.fileType === "db-row") {
|
|
15194
|
+
if (!record.notionParentId) {
|
|
15195
|
+
throw new Error(`DB \uD589\uC778\uB370 \uC18C\uC18D DB \uB97C \uC54C \uC218 \uC5C6\uC74C: ${record.obsidianPath}`);
|
|
15196
|
+
}
|
|
15197
|
+
return record.notionParentId;
|
|
15198
|
+
}
|
|
15199
|
+
if (isDatabaseMode(config)) return config.notion.databaseId;
|
|
15200
|
+
return null;
|
|
15201
|
+
}
|
|
15202
|
+
|
|
15177
15203
|
// src/sync/folder-files.ts
|
|
15178
15204
|
function unescapeCaption(caption) {
|
|
15179
15205
|
return caption.replace(/\\([\\`*_{}[\]()#+\-.!|~<>$&])/g, "$1").trim();
|
|
@@ -15184,12 +15210,89 @@ function folderFilesOf(markdown, namedOnly) {
|
|
|
15184
15210
|
for (const span of notionMediaSpans(rest)) {
|
|
15185
15211
|
if (!/^https?:\/\//.test(span.url)) return null;
|
|
15186
15212
|
const name = uploadedFileNameFromUrl(span.url);
|
|
15187
|
-
if (!name
|
|
15213
|
+
if (!name) return null;
|
|
15214
|
+
if (namedOnly && unescapeCaption(span.label) !== name && !isMarkedCaption(span.label, name)) {
|
|
15215
|
+
return null;
|
|
15216
|
+
}
|
|
15188
15217
|
rest = rest.replace(span.full, "");
|
|
15189
15218
|
files.push({ url: span.url, name });
|
|
15190
15219
|
}
|
|
15191
15220
|
return files.length > 0 && rest.trim() === "" ? files : null;
|
|
15192
15221
|
}
|
|
15222
|
+
var FOLDER_FILE_MARK = "\u{1F4C1}";
|
|
15223
|
+
function folderFileCaption(name) {
|
|
15224
|
+
return `${FOLDER_FILE_MARK} ${name}`;
|
|
15225
|
+
}
|
|
15226
|
+
function isMarkedCaption(label, name) {
|
|
15227
|
+
return unescapeCaption(label) === folderFileCaption(name);
|
|
15228
|
+
}
|
|
15229
|
+
function uploadedFileOfBlock(block) {
|
|
15230
|
+
const media = block[block.type];
|
|
15231
|
+
const url = media?.type === "file" ? media.file?.url : void 0;
|
|
15232
|
+
if (!url) return null;
|
|
15233
|
+
return { url, caption: (media?.caption ?? []).map((part) => part.plain_text ?? "").join("") };
|
|
15234
|
+
}
|
|
15235
|
+
function isFolderFileBlock(block, knownIds) {
|
|
15236
|
+
const file = uploadedFileOfBlock(block);
|
|
15237
|
+
if (!file) return false;
|
|
15238
|
+
const name = uploadedFileNameFromUrl(file.url);
|
|
15239
|
+
const fileId = notionFileIdOf(file.url);
|
|
15240
|
+
return fileId !== null && knownIds.has(fileId) || name !== null && file.caption === folderFileCaption(name);
|
|
15241
|
+
}
|
|
15242
|
+
var FENCE_RE = /^\s*(`{3,}|~{3,})/;
|
|
15243
|
+
function folderFilesInNote(markdown, options = {}) {
|
|
15244
|
+
const all = markdown.split("\n");
|
|
15245
|
+
const drop = /* @__PURE__ */ new Set();
|
|
15246
|
+
const files = [];
|
|
15247
|
+
const lines = [];
|
|
15248
|
+
let fence = null;
|
|
15249
|
+
all.forEach((line, i) => {
|
|
15250
|
+
const opener = FENCE_RE.exec(line)?.[1];
|
|
15251
|
+
if (opener !== void 0 && (fence === null || opener.startsWith(fence))) {
|
|
15252
|
+
fence = fence === null ? opener : null;
|
|
15253
|
+
return;
|
|
15254
|
+
}
|
|
15255
|
+
if (fence !== null) return;
|
|
15256
|
+
const full = line.trim();
|
|
15257
|
+
const [span, ...more] = notionMediaSpans(full);
|
|
15258
|
+
if (!span || more.length > 0 || span.full !== full || !/^https?:\/\//.test(span.url)) return;
|
|
15259
|
+
const name = uploadedFileNameFromUrl(span.url);
|
|
15260
|
+
if (!name) return;
|
|
15261
|
+
const fileId = notionFileIdOf(span.url);
|
|
15262
|
+
const marked = isMarkedCaption(span.label, name) || fileId !== null && options.knownIds?.has(fileId) === true;
|
|
15263
|
+
const file = { url: span.url, name };
|
|
15264
|
+
if (!marked || options.exclude?.(file)) return;
|
|
15265
|
+
drop.add(i);
|
|
15266
|
+
files.push(file);
|
|
15267
|
+
lines.push(full);
|
|
15268
|
+
});
|
|
15269
|
+
if (files.length === 0) return null;
|
|
15270
|
+
return {
|
|
15271
|
+
markdown: all.filter((_, i) => !drop.has(i)).join("\n"),
|
|
15272
|
+
files,
|
|
15273
|
+
lines
|
|
15274
|
+
};
|
|
15275
|
+
}
|
|
15276
|
+
function noteFolderFilesOfPage(stateDb, pageId, markdown, embedded) {
|
|
15277
|
+
const ids = new Set(folderFileIdsOf(stateDb, pageId).values());
|
|
15278
|
+
const media = recallUploadedMedia(stateDb, pageId);
|
|
15279
|
+
const found2 = folderFilesInNote(markdown, {
|
|
15280
|
+
knownIds: ids,
|
|
15281
|
+
exclude: (file) => {
|
|
15282
|
+
const fileId = notionFileIdOf(file.url);
|
|
15283
|
+
return embedded?.has(file.name) === true || fileId !== null && media.has(fileId);
|
|
15284
|
+
}
|
|
15285
|
+
});
|
|
15286
|
+
const rest = found2?.markdown ?? markdown;
|
|
15287
|
+
return {
|
|
15288
|
+
found: found2,
|
|
15289
|
+
holdsAll: ids.size === 0 || !notionMediaSpans(rest).some((span) => ids.has(notionFileIdOf(span.url) ?? ""))
|
|
15290
|
+
};
|
|
15291
|
+
}
|
|
15292
|
+
function rootFilesPageOf(config) {
|
|
15293
|
+
if (isDatabaseMode(config) || !config.notion.rootPageId) return null;
|
|
15294
|
+
return normalizeNotionId(config.notion.rootPageId);
|
|
15295
|
+
}
|
|
15193
15296
|
var WIKI_EMBED_RE = /!\[\[([^\]|#^\n]+)/g;
|
|
15194
15297
|
var MD_EMBED_RE = /!\[[^\]\n]*\]\(<?([^)>\s]+)/g;
|
|
15195
15298
|
var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
@@ -15217,8 +15320,8 @@ function embeddedNamesOf(notes) {
|
|
|
15217
15320
|
function keyOf3(pageId) {
|
|
15218
15321
|
return `folder_files:${compactNotionId(pageId)}`;
|
|
15219
15322
|
}
|
|
15220
|
-
function
|
|
15221
|
-
const raw = stateDb.getMeta(
|
|
15323
|
+
function readPathMap(stateDb, key) {
|
|
15324
|
+
const raw = stateDb.getMeta(key);
|
|
15222
15325
|
if (!raw) return /* @__PURE__ */ new Map();
|
|
15223
15326
|
try {
|
|
15224
15327
|
return new Map(Object.entries(JSON.parse(raw)));
|
|
@@ -15226,19 +15329,45 @@ function folderFileIdsOf(stateDb, pageId) {
|
|
|
15226
15329
|
return /* @__PURE__ */ new Map();
|
|
15227
15330
|
}
|
|
15228
15331
|
}
|
|
15332
|
+
function writePathMap(stateDb, key, map) {
|
|
15333
|
+
stateDb.setMeta(key, JSON.stringify(Object.fromEntries(map)));
|
|
15334
|
+
}
|
|
15335
|
+
function folderFileIdsOf(stateDb, pageId) {
|
|
15336
|
+
return readPathMap(stateDb, keyOf3(pageId));
|
|
15337
|
+
}
|
|
15229
15338
|
function forgetFolderFileId(stateDb, pageId, localPath) {
|
|
15339
|
+
rememberFolderFileStamp(stateDb, pageId, localPath, null);
|
|
15230
15340
|
const ids = folderFileIdsOf(stateDb, pageId);
|
|
15231
15341
|
if (!ids.delete(localPath)) return;
|
|
15232
|
-
stateDb
|
|
15342
|
+
writePathMap(stateDb, keyOf3(pageId), ids);
|
|
15233
15343
|
}
|
|
15234
15344
|
function rememberFolderFileId(stateDb, pageId, localPath, fileId) {
|
|
15235
15345
|
const ids = folderFileIdsOf(stateDb, pageId);
|
|
15236
15346
|
if (ids.get(localPath) === fileId) return;
|
|
15237
15347
|
ids.set(localPath, fileId);
|
|
15238
|
-
stateDb
|
|
15348
|
+
writePathMap(stateDb, keyOf3(pageId), ids);
|
|
15349
|
+
}
|
|
15350
|
+
function fileStampOf(file) {
|
|
15351
|
+
return `${file.size}:${file.mtime}`;
|
|
15352
|
+
}
|
|
15353
|
+
function stampKeyOf(pageId) {
|
|
15354
|
+
return `folder_file_stamps:${compactNotionId(pageId)}`;
|
|
15355
|
+
}
|
|
15356
|
+
function folderFileStampsOf(stateDb, pageId) {
|
|
15357
|
+
return readPathMap(stateDb, stampKeyOf(pageId));
|
|
15358
|
+
}
|
|
15359
|
+
function rememberFolderFileStamp(stateDb, pageId, localPath, stamp) {
|
|
15360
|
+
const stamps = folderFileStampsOf(stateDb, pageId);
|
|
15361
|
+
if ((stamps.get(localPath) ?? null) === stamp) return;
|
|
15362
|
+
if (stamp === null) stamps.delete(localPath);
|
|
15363
|
+
else stamps.set(localPath, stamp);
|
|
15364
|
+
writePathMap(stateDb, stampKeyOf(pageId), stamps);
|
|
15239
15365
|
}
|
|
15240
15366
|
|
|
15241
15367
|
// src/sync/file-handler.ts
|
|
15368
|
+
function flattenBlockTree2(blocks) {
|
|
15369
|
+
return blocks.flatMap((block) => [block, ...flattenBlockTree2(block.children ?? [])]);
|
|
15370
|
+
}
|
|
15242
15371
|
function isHiddenPath(path) {
|
|
15243
15372
|
return path.split("/").some((part) => part.startsWith("."));
|
|
15244
15373
|
}
|
|
@@ -15246,6 +15375,15 @@ function getFolderPath(filePath) {
|
|
|
15246
15375
|
const parts = filePath.split("/");
|
|
15247
15376
|
return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
|
|
15248
15377
|
}
|
|
15378
|
+
function filesFolderOfNote(record) {
|
|
15379
|
+
if (record.fileType === "folder-note" && isFolderNotePath(record.obsidianPath)) {
|
|
15380
|
+
return getFolderPath(record.obsidianPath);
|
|
15381
|
+
}
|
|
15382
|
+
return isNamedNote(record) ? stripMarkdownExtension(record.obsidianPath) : null;
|
|
15383
|
+
}
|
|
15384
|
+
function isNamedNote(record) {
|
|
15385
|
+
return record.fileType === "file" || record.fileType === "db-row";
|
|
15386
|
+
}
|
|
15249
15387
|
var LOGGED_NAMES = 5;
|
|
15250
15388
|
var FileHandler = class {
|
|
15251
15389
|
constructor(vaultFs, notionClient, stateDb, concurrency = DEFAULT_CONFIG.advanced.fileConcurrency, options) {
|
|
@@ -15257,6 +15395,7 @@ var FileHandler = class {
|
|
|
15257
15395
|
this.downloadTimeoutMs = options?.downloadTimeoutMs ?? DEFAULT_CONFIG.advanced.mediaDownloadTimeoutMs;
|
|
15258
15396
|
this.itemTimeoutMs = options?.itemTimeoutMs ?? DEFAULT_CONFIG.advanced.itemTimeoutMs;
|
|
15259
15397
|
this.deleteSync = options?.deleteSync ?? false;
|
|
15398
|
+
this.rootPageId = options?.rootPageId ?? null;
|
|
15260
15399
|
this.attachmentsFolder = (options?.attachmentsFolder ?? DEFAULT_CONFIG.paths.attachments).replace(/^\/+|\/+$/g, "");
|
|
15261
15400
|
}
|
|
15262
15401
|
vaultFs;
|
|
@@ -15268,6 +15407,7 @@ var FileHandler = class {
|
|
|
15268
15407
|
itemTimeoutMs;
|
|
15269
15408
|
attachmentsFolder;
|
|
15270
15409
|
deleteSync;
|
|
15410
|
+
rootPageId;
|
|
15271
15411
|
get fetchFn() {
|
|
15272
15412
|
return this.customFetch ?? globalThis.fetch;
|
|
15273
15413
|
}
|
|
@@ -15325,21 +15465,58 @@ var FileHandler = class {
|
|
|
15325
15465
|
this.sema.release();
|
|
15326
15466
|
}
|
|
15327
15467
|
}
|
|
15328
|
-
/**
|
|
15468
|
+
/**
|
|
15469
|
+
* 올리거나 받은 뒤 바뀌지 않았나 — 그때의 표(크기 · 수정 시각, {@link fileStampOf})와 같으면 읽지 않는다. 크기가 다르면 바뀌었다.
|
|
15470
|
+
* 크기만 같으면 내용으로 견주고, 같으면 지금 표를 적어 다음에는 읽지 않는다. 예전에는 크기가 같으면 바뀌지 않았다고 봐, 같은
|
|
15471
|
+
* 크기로 고친 파일을 올리지 않았다.
|
|
15472
|
+
*/
|
|
15329
15473
|
async isUnchanged(existing, file) {
|
|
15330
|
-
|
|
15474
|
+
const stamp = fileStampOf(file);
|
|
15475
|
+
if (folderFileStampsOf(this.stateDb, existing.notionPageId).get(file.path) === stamp) {
|
|
15476
|
+
return true;
|
|
15477
|
+
}
|
|
15478
|
+
if (existing.fileSize !== file.size) return false;
|
|
15331
15479
|
const buffer = await this.vaultFs.readBinary(file.path);
|
|
15332
|
-
|
|
15480
|
+
const same = existing.fileHash === createHash("sha256").update(buffer).digest("hex");
|
|
15481
|
+
if (same) rememberFolderFileStamp(this.stateDb, existing.notionPageId, file.path, stamp);
|
|
15482
|
+
return same;
|
|
15333
15483
|
}
|
|
15334
15484
|
/** pull 이 받은 것을 두는 폴더 안인가. */
|
|
15335
15485
|
inAttachmentsFolder(path) {
|
|
15336
15486
|
return this.attachmentsFolder !== "" && path.startsWith(`${this.attachmentsFolder}/`);
|
|
15337
15487
|
}
|
|
15338
|
-
/**
|
|
15488
|
+
/**
|
|
15489
|
+
* 폴더 첨부를 둘 Notion 페이지 — 그 폴더의 Notion 자리({@link folderContainer} 와 같은 차례). 폴더 노트(`X/X.md`)나 노트
|
|
15490
|
+
* 이름의 폴더(`X.md` 옆 `X/`)면 그 노트의 페이지 끝(ADR-056), 아니면 push 가 만든 폴더 페이지. 맨 위는 루트 페이지 끝.
|
|
15491
|
+
* 행 이름의 폴더(`DB/행/`)는 그 행의 페이지 끝. DB 폴더는 둘 곳이 없다 — Notion 의 DB 는 본문이 없다.
|
|
15492
|
+
*/
|
|
15339
15493
|
folderPageOf(folder) {
|
|
15340
|
-
|
|
15341
|
-
|
|
15342
|
-
|
|
15494
|
+
return this.notePageOf(folder) ?? this.folderRecordPageOf(folder);
|
|
15495
|
+
}
|
|
15496
|
+
/**
|
|
15497
|
+
* 이 폴더를 품은 노트의 페이지 — 폴더 노트 · 노트(행 포함) 이름의 폴더 · 볼트 맨 위는 루트 페이지. 본문이 이 도구의 것이
|
|
15498
|
+
* 아니거나(루트) 노트의 것이라, 적어 둔 폴더 첨부만 제 것으로 본다. 없으면 null.
|
|
15499
|
+
*/
|
|
15500
|
+
notePageOf(folder) {
|
|
15501
|
+
if (folder === "") return this.rootPageId;
|
|
15502
|
+
const note = this.stateDb.getByPath(folderNoteOf(folder));
|
|
15503
|
+
if (note?.notionPageId && note.fileType === "folder-note") return note.notionPageId;
|
|
15504
|
+
const named = this.stateDb.getByPath(`${folder}.md`);
|
|
15505
|
+
return named?.notionPageId && isNamedNote(named) ? named.notionPageId : null;
|
|
15506
|
+
}
|
|
15507
|
+
folderRecordPageOf(folder) {
|
|
15508
|
+
const record = this.stateDb.getByPath(folder);
|
|
15509
|
+
return record?.notionPageId && isFolderRecord(record) ? record.notionPageId : null;
|
|
15510
|
+
}
|
|
15511
|
+
/**
|
|
15512
|
+
* 이 기록이 그 폴더의 폴더 첨부인가. 노트의 페이지에는 그 노트가 임베드해 올린 파일도 같은 페이지로 적혀 있다 — 폴더
|
|
15513
|
+
* 첨부로 적어 둔 것({@link folderFileIdsOf})만이다.
|
|
15514
|
+
*/
|
|
15515
|
+
isFolderFileEntry(entry, folder) {
|
|
15516
|
+
const pageId = this.folderPageOf(folder);
|
|
15517
|
+
if (!pageId || entry.notionPageId !== pageId) return false;
|
|
15518
|
+
if (pageId !== this.notePageOf(folder)) return true;
|
|
15519
|
+
return folderFileIdsOf(this.stateDb, pageId).has(entry.localPath);
|
|
15343
15520
|
}
|
|
15344
15521
|
/**
|
|
15345
15522
|
* 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 아예 빼고, Notion 이 받지 않는
|
|
@@ -15367,7 +15544,7 @@ var FileHandler = class {
|
|
|
15367
15544
|
* @param failed 올리지 못한 첨부를 담을 곳 — push 결과의 `failed` 를 넘기면 CLI · 플러그인이 처리하지 못한 것으로
|
|
15368
15545
|
* 보인다. 예전엔 경고 로그로만 남아 push 가 「실패 0」 으로 끝났다.
|
|
15369
15546
|
*/
|
|
15370
|
-
async pushAllFiles(failed, folders) {
|
|
15547
|
+
async pushAllFiles(failed, folders, noteWrites) {
|
|
15371
15548
|
const { uploadable: allFiles, unsupported: unsupported2 } = await this.listUploadableFiles();
|
|
15372
15549
|
if (unsupported2.length > 0) {
|
|
15373
15550
|
const names = unsupported2.slice(0, LOGGED_NAMES).map((f) => f.path);
|
|
@@ -15376,13 +15553,13 @@ var FileHandler = class {
|
|
|
15376
15553
|
`[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` : ""}`
|
|
15377
15554
|
);
|
|
15378
15555
|
}
|
|
15379
|
-
if (this.deleteSync) await this.removeDeletedFolderFiles();
|
|
15556
|
+
if (this.deleteSync) await this.removeDeletedFolderFiles(noteWrites);
|
|
15380
15557
|
const pending = [];
|
|
15381
15558
|
for (const file of allFiles) {
|
|
15382
15559
|
if (this.inAttachmentsFolder(file.path) || isHiddenPath(file.path)) continue;
|
|
15383
15560
|
const existing = this.stateDb.getFileRegistry(file.path);
|
|
15384
15561
|
if (existing) {
|
|
15385
|
-
if (
|
|
15562
|
+
if (!this.isFolderFileEntry(existing, getFolderPath(file.path))) continue;
|
|
15386
15563
|
if (await this.isUnchanged(existing, file)) continue;
|
|
15387
15564
|
}
|
|
15388
15565
|
pending.push({ file, existing });
|
|
@@ -15404,10 +15581,23 @@ var FileHandler = class {
|
|
|
15404
15581
|
for (const [folder, items] of byFolder) {
|
|
15405
15582
|
const pageId = await this.provideFolderPage(folder, folders, items, stranded);
|
|
15406
15583
|
if (!pageId) continue;
|
|
15407
|
-
|
|
15408
|
-
|
|
15409
|
-
|
|
15410
|
-
|
|
15584
|
+
await this.writingNotePage(
|
|
15585
|
+
pageId === this.notePageOf(folder),
|
|
15586
|
+
pageId,
|
|
15587
|
+
noteWrites,
|
|
15588
|
+
async () => {
|
|
15589
|
+
for (const { file, existing } of items) {
|
|
15590
|
+
let previous = existing;
|
|
15591
|
+
if (!previous) {
|
|
15592
|
+
const moved = await this.movedHere(file, pageId);
|
|
15593
|
+
if (moved?.same) continue;
|
|
15594
|
+
previous = moved?.entry ?? null;
|
|
15595
|
+
}
|
|
15596
|
+
const result = await this.pushSingleFile(file, pageId, previous, failed);
|
|
15597
|
+
if (result) results.push(result);
|
|
15598
|
+
}
|
|
15599
|
+
}
|
|
15600
|
+
);
|
|
15411
15601
|
}
|
|
15412
15602
|
this.reportStranded(stranded);
|
|
15413
15603
|
return results;
|
|
@@ -15416,25 +15606,42 @@ var FileHandler = class {
|
|
|
15416
15606
|
* 볼트에서 지운 폴더 첨부를 Notion 에서도 지운다(deleteSync) — 폴더 페이지에 적힌 파일 가운데 볼트에 없는 것의 블록과 기록.
|
|
15417
15607
|
* 포함 · 제외 패턴으로 빠진 파일은 볼트에 있어 지우지 않는다.
|
|
15418
15608
|
*/
|
|
15419
|
-
async removeDeletedFolderFiles() {
|
|
15609
|
+
async removeDeletedFolderFiles(noteWrites) {
|
|
15420
15610
|
const removed = [];
|
|
15611
|
+
const pages = [];
|
|
15421
15612
|
for (const record of this.stateDb.getAll()) {
|
|
15422
|
-
if (!record.notionPageId
|
|
15423
|
-
|
|
15424
|
-
|
|
15425
|
-
|
|
15426
|
-
|
|
15427
|
-
|
|
15428
|
-
|
|
15429
|
-
|
|
15430
|
-
|
|
15431
|
-
|
|
15432
|
-
|
|
15433
|
-
|
|
15434
|
-
|
|
15435
|
-
|
|
15436
|
-
|
|
15613
|
+
if (!record.notionPageId) continue;
|
|
15614
|
+
const folder = isFolderRecord(record) ? record.obsidianPath : filesFolderOfNote(record);
|
|
15615
|
+
if (folder !== null) {
|
|
15616
|
+
pages.push({ pageId: record.notionPageId, folder, onNote: !isFolderRecord(record) });
|
|
15617
|
+
}
|
|
15618
|
+
}
|
|
15619
|
+
if (this.rootPageId) pages.push({ pageId: this.rootPageId, folder: "", onNote: true });
|
|
15620
|
+
for (const { pageId, folder, onNote } of pages) {
|
|
15621
|
+
const ids = folderFileIdsOf(this.stateDb, pageId);
|
|
15622
|
+
if (onNote && ids.size === 0) continue;
|
|
15623
|
+
const gone = [];
|
|
15624
|
+
for (const entry of this.stateDb.getFilesByPageId(pageId)) {
|
|
15625
|
+
if (getFolderPath(entry.localPath) !== folder) continue;
|
|
15626
|
+
if (onNote && !ids.has(entry.localPath)) continue;
|
|
15627
|
+
if (!await this.vaultFs.exists(entry.localPath)) gone.push(entry);
|
|
15437
15628
|
}
|
|
15629
|
+
if (gone.length === 0) continue;
|
|
15630
|
+
await this.writingNotePage(onNote, pageId, noteWrites, async () => {
|
|
15631
|
+
for (const entry of gone) {
|
|
15632
|
+
try {
|
|
15633
|
+
const name = entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1);
|
|
15634
|
+
await this.removeOlderBlocks(pageId, entry.localPath, name, void 0);
|
|
15635
|
+
this.stateDb.deleteFileRegistry(entry.localPath);
|
|
15636
|
+
forgetFolderFileId(this.stateDb, pageId, entry.localPath);
|
|
15637
|
+
removed.push(entry.localPath);
|
|
15638
|
+
} catch (error) {
|
|
15639
|
+
getLogger().warn(
|
|
15640
|
+
`[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)}`
|
|
15641
|
+
);
|
|
15642
|
+
}
|
|
15643
|
+
}
|
|
15644
|
+
});
|
|
15438
15645
|
}
|
|
15439
15646
|
if (removed.length > 0) {
|
|
15440
15647
|
getLogger().info(
|
|
@@ -15442,10 +15649,20 @@ var FileHandler = class {
|
|
|
15442
15649
|
);
|
|
15443
15650
|
}
|
|
15444
15651
|
}
|
|
15652
|
+
/** 노트의 페이지에 쓰는 동안을 알린다({@link NotePageWrites}) — 폴더 페이지는 노트가 없어 알릴 것이 없다. */
|
|
15653
|
+
async writingNotePage(isNotePage, pageId, noteWrites, write) {
|
|
15654
|
+
if (!isNotePage || !noteWrites) return write();
|
|
15655
|
+
await noteWrites.begin(pageId);
|
|
15656
|
+
try {
|
|
15657
|
+
await write();
|
|
15658
|
+
} finally {
|
|
15659
|
+
await noteWrites.end(pageId);
|
|
15660
|
+
}
|
|
15661
|
+
}
|
|
15445
15662
|
/** 폴더 첨부를 둘 페이지 — 노트 없는 폴더면 마련한다. 둘 곳이 없으면 그 파일들을 `stranded` 에 담고 null. */
|
|
15446
15663
|
async provideFolderPage(folder, folders, items, stranded) {
|
|
15447
15664
|
let pageId = this.folderPageOf(folder);
|
|
15448
|
-
let reason = folder === "" ? "\uBCFC\uD2B8 \uB9E8 \uC704
|
|
15665
|
+
let reason = folder === "" ? "\uBCFC\uD2B8 \uB9E8 \uC704 \u2014 DB \uBAA8\uB4DC\uC758 \uB8E8\uD2B8\uB294 DB \uB77C \uBCF8\uBB38\uC774 \uC5C6\uB2E4" : "DB \uD3F4\uB354 \xB7 \uC544\uC9C1 \uC62C\uB9AC\uC9C0 \uC54A\uC740 \uD3F4\uB354 \uB178\uD2B8";
|
|
15449
15666
|
if (!pageId && folder !== "" && folders && !this.stateDb.getByPath(folder)) {
|
|
15450
15667
|
try {
|
|
15451
15668
|
await folders.ensureFolderPage(folder);
|
|
@@ -15594,6 +15811,8 @@ var FileHandler = class {
|
|
|
15594
15811
|
fileHash: hash,
|
|
15595
15812
|
fileSize: buffer.length
|
|
15596
15813
|
});
|
|
15814
|
+
const stat2 = await this.vaultFs.getFileStat(localPath);
|
|
15815
|
+
if (stat2) rememberFolderFileStamp(this.stateDb, pageId, localPath, fileStampOf(stat2));
|
|
15597
15816
|
return wrote;
|
|
15598
15817
|
}
|
|
15599
15818
|
async downloadFileBlock(url, filename, targetFolder) {
|
|
@@ -15635,7 +15854,9 @@ var FileHandler = class {
|
|
|
15635
15854
|
const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
|
|
15636
15855
|
const block = this.createFileBlock(blockType, fileUploadId, filename);
|
|
15637
15856
|
const [created] = await this.notionClient.appendChildBlocks(parentPageId, [block]);
|
|
15638
|
-
if (previous)
|
|
15857
|
+
if (previous) {
|
|
15858
|
+
await this.removeOlderBlocks(parentPageId, file.path, filename, created?.id);
|
|
15859
|
+
}
|
|
15639
15860
|
const url = created ? fileUrlOfBlock(created) : null;
|
|
15640
15861
|
const fileId = url ? notionFileIdOf(url) : null;
|
|
15641
15862
|
if (fileId) rememberFolderFileId(this.stateDb, parentPageId, file.path, fileId);
|
|
@@ -15647,28 +15868,79 @@ var FileHandler = class {
|
|
|
15647
15868
|
fileHash: hash,
|
|
15648
15869
|
fileSize: file.size
|
|
15649
15870
|
});
|
|
15871
|
+
rememberFolderFileStamp(this.stateDb, parentPageId, file.path, fileStampOf(file));
|
|
15650
15872
|
getLogger().info(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC: ${file.path} \u2192 ${blockType} \uBE14\uB85D`);
|
|
15651
15873
|
return { localPath: file.path, fileUploadId, blockType };
|
|
15652
15874
|
}
|
|
15653
|
-
/**
|
|
15654
|
-
|
|
15655
|
-
|
|
15656
|
-
|
|
15657
|
-
|
|
15658
|
-
|
|
15659
|
-
|
|
15660
|
-
|
|
15875
|
+
/**
|
|
15876
|
+
* 처음 보는 파일이 이 페이지에 적어 둔 같은 이름의 폴더 첨부를 옮긴 것인가 — 그 기록의 경로가 볼트에서 사라졌으면 옮겼다(폴더 ·
|
|
15877
|
+
* 폴더 노트 · 노트의 이름 바꾸기). 기록 · 파일 id · 표를 새 경로로 옮긴다. `same` 이면 내용이 같아 올리지 않는다. 다르면 옮긴
|
|
15878
|
+
* 기록을 옛 것으로 올려 옛 블록을 지운다. 예전에는 새 파일로 올려 노트 페이지에 같은 파일의 블록이 둘 남았다.
|
|
15879
|
+
*/
|
|
15880
|
+
async movedHere(file, pageId) {
|
|
15881
|
+
const name = file.path.slice(file.path.lastIndexOf("/") + 1);
|
|
15882
|
+
const record = this.stateDb.getByNotionId(pageId);
|
|
15883
|
+
const ids = folderFileIdsOf(this.stateDb, pageId);
|
|
15884
|
+
for (const entry of this.stateDb.getFilesByPageId(pageId)) {
|
|
15885
|
+
if (entry.localPath === file.path) continue;
|
|
15886
|
+
if (entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1) !== name) continue;
|
|
15887
|
+
if (record && !isFolderRecord(record) && !ids.has(entry.localPath)) continue;
|
|
15888
|
+
if (await this.vaultFs.exists(entry.localPath)) continue;
|
|
15889
|
+
const buffer = await this.vaultFs.readBinary(file.path);
|
|
15890
|
+
const same = entry.fileSize === file.size && entry.fileHash === createHash("sha256").update(buffer).digest("hex");
|
|
15891
|
+
const fileId = ids.get(entry.localPath);
|
|
15892
|
+
this.stateDb.deleteFileRegistry(entry.localPath);
|
|
15893
|
+
forgetFolderFileId(this.stateDb, pageId, entry.localPath);
|
|
15894
|
+
this.stateDb.registerFile({
|
|
15895
|
+
localPath: file.path,
|
|
15896
|
+
notionPageId: pageId,
|
|
15897
|
+
fileUploadId: entry.fileUploadId,
|
|
15898
|
+
fileType: entry.fileType,
|
|
15899
|
+
fileHash: entry.fileHash,
|
|
15900
|
+
fileSize: entry.fileSize
|
|
15901
|
+
});
|
|
15902
|
+
if (fileId) rememberFolderFileId(this.stateDb, pageId, file.path, fileId);
|
|
15903
|
+
if (same) rememberFolderFileStamp(this.stateDb, pageId, file.path, fileStampOf(file));
|
|
15904
|
+
return { entry: { ...entry, localPath: file.path }, same };
|
|
15661
15905
|
}
|
|
15906
|
+
return null;
|
|
15662
15907
|
}
|
|
15663
15908
|
/**
|
|
15664
|
-
* 폴더 첨부의
|
|
15665
|
-
*
|
|
15909
|
+
* 이 페이지에서 그 폴더 첨부의 블록 중 방금 붙인 것(`keep`) 말고를 지운다. 폴더 페이지는 같은 이름의 파일 블록 모두(id 를
|
|
15910
|
+
* 적기 전에 올린 블록도). 노트의 페이지는 노트가 임베드한 같은 이름의 파일이 있을 수 있어, 적어 둔 Notion 파일 id 의 블록과
|
|
15911
|
+
* 표시를 단 캡션({@link folderFileCaption})의 블록만 — 다른 볼트가 같은 파일을 먼저 고쳐 올린 블록은 id 를 모르지만 표시가 있다.
|
|
15912
|
+
* 예전에는 id 로만 지워, 두 볼트가 따로 고쳐 올리면 블록이 둘 남았다.
|
|
15913
|
+
*/
|
|
15914
|
+
async removeOlderBlocks(pageId, localPath, name, keep) {
|
|
15915
|
+
const record = this.stateDb.getByNotionId(pageId);
|
|
15916
|
+
const onNote = record !== null && !isFolderRecord(record) || this.rootPageId !== null && notionIdsEqual(pageId, this.rootPageId);
|
|
15917
|
+
const fileId = folderFileIdsOf(this.stateDb, pageId).get(localPath);
|
|
15918
|
+
const isOlder = (block) => {
|
|
15919
|
+
if (block.id === keep) return false;
|
|
15920
|
+
const media = uploadedFileOfBlock(block);
|
|
15921
|
+
if (!media || uploadedFileNameFromUrl(media.url) !== name) return false;
|
|
15922
|
+
if (keep === void 0 && fileId !== void 0) {
|
|
15923
|
+
return notionFileIdOf(media.url) === fileId;
|
|
15924
|
+
}
|
|
15925
|
+
if (!onNote) return true;
|
|
15926
|
+
return fileId !== void 0 && notionFileIdOf(media.url) === fileId || media.caption === folderFileCaption(name);
|
|
15927
|
+
};
|
|
15928
|
+
let older = (await this.notionClient.fetchAllChildren(pageId)).filter(isOlder);
|
|
15929
|
+
if (older.length === 0 && onNote) {
|
|
15930
|
+
older = flattenBlockTree2(await this.notionClient.getBlockTree(pageId)).filter(isOlder);
|
|
15931
|
+
}
|
|
15932
|
+
for (const block of older) await this.notionClient.deleteBlock(block.id);
|
|
15933
|
+
}
|
|
15934
|
+
/**
|
|
15935
|
+
* 폴더 첨부의 파일 블록 — 캡션에 표시를 단 파일 이름을 적는다({@link folderFileCaption}). 받는 쪽이 그 표시로 폴더 첨부를
|
|
15936
|
+
* 알아본다 — 자식 페이지가 없는 폴더 페이지는 PDF 하나만 붙인 노트와 모양이 같고, 노트 페이지의 폴더 첨부는 그 노트의
|
|
15937
|
+
* 임베드와 같은 블록이다.
|
|
15666
15938
|
*/
|
|
15667
15939
|
createFileBlock(blockType, fileUploadId, name) {
|
|
15668
15940
|
const fileRef = {
|
|
15669
15941
|
type: "file_upload",
|
|
15670
15942
|
file_upload: { id: fileUploadId },
|
|
15671
|
-
caption: [{ type: "text", text: { content: name } }]
|
|
15943
|
+
caption: [{ type: "text", text: { content: folderFileCaption(name) } }]
|
|
15672
15944
|
};
|
|
15673
15945
|
switch (blockType) {
|
|
15674
15946
|
case "image":
|
|
@@ -16693,21 +16965,6 @@ function loadLinkedDbMap(stateDb) {
|
|
|
16693
16965
|
}
|
|
16694
16966
|
}
|
|
16695
16967
|
|
|
16696
|
-
// src/sync/parent-mode.ts
|
|
16697
|
-
function isDatabaseMode(config) {
|
|
16698
|
-
return config.notion.parentMode === "database" && !!config.notion.databaseId;
|
|
16699
|
-
}
|
|
16700
|
-
function rowDatabaseOf(config, record) {
|
|
16701
|
-
if (record.fileType === "db-row") {
|
|
16702
|
-
if (!record.notionParentId) {
|
|
16703
|
-
throw new Error(`DB \uD589\uC778\uB370 \uC18C\uC18D DB \uB97C \uC54C \uC218 \uC5C6\uC74C: ${record.obsidianPath}`);
|
|
16704
|
-
}
|
|
16705
|
-
return record.notionParentId;
|
|
16706
|
-
}
|
|
16707
|
-
if (isDatabaseMode(config)) return config.notion.databaseId;
|
|
16708
|
-
return null;
|
|
16709
|
-
}
|
|
16710
|
-
|
|
16711
16968
|
// src/sync/remote-scan.ts
|
|
16712
16969
|
var LAST_FULL_PULL_META_KEY = "last_full_pull_at";
|
|
16713
16970
|
var LAST_PULL_META_KEY = "last_pull_at";
|
|
@@ -19167,7 +19424,9 @@ var NoteRelocator = class {
|
|
|
19167
19424
|
if (isFolderRecord(record)) return [record.obsidianPath];
|
|
19168
19425
|
if (record.fileType === "folder-note") return [parentFolderOf(record.obsidianPath)];
|
|
19169
19426
|
const children = stripMarkdownExtension(record.obsidianPath);
|
|
19170
|
-
const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children))
|
|
19427
|
+
const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children)) || record.notionPageId !== null && [...folderFileIdsOf(this.stateDb, record.notionPageId).keys()].some(
|
|
19428
|
+
(path) => within(path, children)
|
|
19429
|
+
);
|
|
19171
19430
|
return hasChildren ? [record.obsidianPath, children] : [record.obsidianPath];
|
|
19172
19431
|
}
|
|
19173
19432
|
/** 옮기지 못할 까닭 — 없으면 null. */
|
|
@@ -19400,7 +19659,7 @@ async function notionBodyToObsidian(client, markdown, pageId) {
|
|
|
19400
19659
|
const bookmarks = bookmarkIds.length > 0 ? await client.getBookmarks(bookmarkIds) : void 0;
|
|
19401
19660
|
return notionEnhancedToObsidian(markdown, { userNames, codeTexts, bookmarks });
|
|
19402
19661
|
}
|
|
19403
|
-
async function pageBlocksToObsidian(client, pageId) {
|
|
19662
|
+
async function pageBlocksToObsidian(client, pageId, bodyOf5 = (markdown) => markdown) {
|
|
19404
19663
|
const tree = await client.getBlockTree(pageId);
|
|
19405
19664
|
const markdown = blocksToNfm(tree, { pageId });
|
|
19406
19665
|
const content = await notionBodyToObsidian(
|
|
@@ -19409,7 +19668,7 @@ async function pageBlocksToObsidian(client, pageId) {
|
|
|
19409
19668
|
getCodeBlockTexts: () => Promise.resolve(blocksOfType(tree, "code").map(plainTextOf)),
|
|
19410
19669
|
getBookmarks: () => Promise.resolve(bookmarksOf(tree))
|
|
19411
19670
|
},
|
|
19412
|
-
markdown,
|
|
19671
|
+
bodyOf5(markdown),
|
|
19413
19672
|
pageId
|
|
19414
19673
|
);
|
|
19415
19674
|
return { markdown, content };
|
|
@@ -19676,6 +19935,7 @@ function emptyDatabaseSyncResult() {
|
|
|
19676
19935
|
}
|
|
19677
19936
|
var DatabaseSyncer = class {
|
|
19678
19937
|
constructor(config, stateDb, notionClient, vaultFs, pipeline, imageHandler, observation = () => NO_OBSERVATION, noteRowBody = () => {
|
|
19938
|
+
}, receiveRowFiles = async () => {
|
|
19679
19939
|
}) {
|
|
19680
19940
|
this.config = config;
|
|
19681
19941
|
this.stateDb = stateDb;
|
|
@@ -19685,6 +19945,7 @@ var DatabaseSyncer = class {
|
|
|
19685
19945
|
this.imageHandler = imageHandler;
|
|
19686
19946
|
this.observation = observation;
|
|
19687
19947
|
this.noteRowBody = noteRowBody;
|
|
19948
|
+
this.receiveRowFiles = receiveRowFiles;
|
|
19688
19949
|
this.baseFileInfo = new DbBaseFiles(stateDb);
|
|
19689
19950
|
this.journal = new UpdateJournal(stateDb);
|
|
19690
19951
|
this.sidecarBases = new SidecarBases(stateDb);
|
|
@@ -19709,6 +19970,7 @@ var DatabaseSyncer = class {
|
|
|
19709
19970
|
imageHandler;
|
|
19710
19971
|
observation;
|
|
19711
19972
|
noteRowBody;
|
|
19973
|
+
receiveRowFiles;
|
|
19712
19974
|
propertyMapper;
|
|
19713
19975
|
decorationsCache;
|
|
19714
19976
|
/**
|
|
@@ -20791,7 +21053,18 @@ var DatabaseSyncer = class {
|
|
|
20791
21053
|
this.noteRowBody(page.id, mdResult.markdown);
|
|
20792
21054
|
const bodyFingerprint = remoteBodyFingerprint(mdResult.markdown);
|
|
20793
21055
|
const exportCompact = isCompactExport(mdResult.markdown);
|
|
20794
|
-
|
|
21056
|
+
const localContent = await readLocalNote(this.vaultFs, options?.localPath ?? filePath);
|
|
21057
|
+
const noteFiles = noteFolderFilesOfPage(
|
|
21058
|
+
this.stateDb,
|
|
21059
|
+
page.id,
|
|
21060
|
+
mdResult.markdown,
|
|
21061
|
+
embeddedNamesOf(localContent === void 0 ? [] : [localContent])
|
|
21062
|
+
);
|
|
21063
|
+
let markdown = await notionBodyToObsidian(
|
|
21064
|
+
this.notionClient,
|
|
21065
|
+
noteFiles.found?.markdown ?? mdResult.markdown,
|
|
21066
|
+
page.id
|
|
21067
|
+
);
|
|
20795
21068
|
markdown = await this.imageHandler.restoreUploadedMedia(
|
|
20796
21069
|
markdown,
|
|
20797
21070
|
page.id,
|
|
@@ -20825,7 +21098,7 @@ var DatabaseSyncer = class {
|
|
|
20825
21098
|
{
|
|
20826
21099
|
properties,
|
|
20827
21100
|
notionExportCompact: exportCompact,
|
|
20828
|
-
localContent
|
|
21101
|
+
localContent,
|
|
20829
21102
|
// 이 렌더러가 적는 키 — 속성과 제목 · 커버 · 아이콘. 나머지는 로컬 키다(F-08).
|
|
20830
21103
|
notionKeys: [...this.propertyMapper.ownedKeys(rawProperties), "title", ...DECORATION_KEYS]
|
|
20831
21104
|
}
|
|
@@ -20835,7 +21108,11 @@ var DatabaseSyncer = class {
|
|
|
20835
21108
|
title,
|
|
20836
21109
|
properties,
|
|
20837
21110
|
bodyFingerprint,
|
|
20838
|
-
propertiesBlock: hasPropertiesBlock(mdResult.markdown)
|
|
21111
|
+
propertiesBlock: hasPropertiesBlock(mdResult.markdown),
|
|
21112
|
+
folderFiles: {
|
|
21113
|
+
files: noteFiles.found?.files ?? [],
|
|
21114
|
+
complete: !mdResult.truncated && noteFiles.holdsAll
|
|
21115
|
+
}
|
|
20839
21116
|
};
|
|
20840
21117
|
}
|
|
20841
21118
|
/**
|
|
@@ -21132,8 +21409,19 @@ var DatabaseSyncer = class {
|
|
|
21132
21409
|
*/
|
|
21133
21410
|
adoptedChildrenOf(record, rowPath) {
|
|
21134
21411
|
const from = record.fileType === "folder-note" ? parentFolderOf(record.obsidianPath) : stripMarkdownExtension(record.obsidianPath);
|
|
21135
|
-
|
|
21136
|
-
|
|
21412
|
+
return this.holdsChildren(record, from) ? { from, to: stripMarkdownExtension(rowPath) } : null;
|
|
21413
|
+
}
|
|
21414
|
+
/**
|
|
21415
|
+
* 이 노트 · 행 옆의 폴더(`from`)가 그것의 것인가 — 하위 페이지가 있거나, 그 페이지에 올린 폴더 첨부(ADR-056)가 든 폴더.
|
|
21416
|
+
* 예전에는 하위 페이지만 보아 행의 이름을 따라 옮길 때 폴더 첨부만 든 폴더가 옛 이름에 남았다.
|
|
21417
|
+
*/
|
|
21418
|
+
holdsChildren(record, from) {
|
|
21419
|
+
if (this.stateDb.getAll().some((r) => r.id !== record.id && within(r.obsidianPath, from))) {
|
|
21420
|
+
return true;
|
|
21421
|
+
}
|
|
21422
|
+
return record.notionPageId !== null && [...folderFileIdsOf(this.stateDb, record.notionPageId).keys()].some(
|
|
21423
|
+
(path) => within(path, from)
|
|
21424
|
+
);
|
|
21137
21425
|
}
|
|
21138
21426
|
/**
|
|
21139
21427
|
* Notion 에서 바꾼 제목으로 행 이름을 바꿀 자리(`sync.renameOnTitleChange`) — 바꿀 것이 없으면 null.
|
|
@@ -21155,14 +21443,32 @@ var DatabaseSyncer = class {
|
|
|
21155
21443
|
);
|
|
21156
21444
|
if (to === recordPath) return null;
|
|
21157
21445
|
const from = stripMarkdownExtension(recordPath);
|
|
21158
|
-
if (!this.
|
|
21446
|
+
if (!this.holdsChildren(record, from)) {
|
|
21159
21447
|
return { to, children: null };
|
|
21160
21448
|
}
|
|
21161
21449
|
const children = { from, to: stripMarkdownExtension(to) };
|
|
21162
21450
|
const blocked = await this.relocator.blocked(children);
|
|
21163
21451
|
return blocked ? { reason: `\uD558\uC704 \uD398\uC774\uC9C0 \uD3F4\uB354 \u2014 ${blocked}` } : { to, children };
|
|
21164
21452
|
}
|
|
21453
|
+
/**
|
|
21454
|
+
* 행 하나를 받고 그 행 페이지의 폴더 첨부를 행 이름의 폴더에 받는다(ADR-056) — 노트가 충돌로 남거나 로컬을 지켜도 파일은
|
|
21455
|
+
* 제 규칙대로 받는다(페이지와 같다). 행을 옮겼으면 옮긴 자리로.
|
|
21456
|
+
*/
|
|
21165
21457
|
async pullDatabasePage(page, dbConfig) {
|
|
21458
|
+
const rendered = {};
|
|
21459
|
+
const outcome = await this.pullDatabaseRow(page, dbConfig, rendered);
|
|
21460
|
+
const path = outcome.path ?? this.stateDb.getByNotionId(page.id)?.obsidianPath;
|
|
21461
|
+
if (rendered.folderFiles && path) {
|
|
21462
|
+
await this.receiveRowFiles(
|
|
21463
|
+
stripMarkdownExtension(path),
|
|
21464
|
+
page.id,
|
|
21465
|
+
rendered.folderFiles.files,
|
|
21466
|
+
rendered.folderFiles.complete
|
|
21467
|
+
);
|
|
21468
|
+
}
|
|
21469
|
+
return outcome;
|
|
21470
|
+
}
|
|
21471
|
+
async pullDatabaseRow(page, dbConfig, seen) {
|
|
21166
21472
|
const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
|
|
21167
21473
|
const existingRecord = this.stateDb.getByNotionId(page.id);
|
|
21168
21474
|
const recordPath = existingRecord?.obsidianPath;
|
|
@@ -21216,6 +21522,7 @@ var DatabaseSyncer = class {
|
|
|
21216
21522
|
localPath: recordPath ?? filePath,
|
|
21217
21523
|
...local !== void 0 ? { adoptFrom: local } : {}
|
|
21218
21524
|
});
|
|
21525
|
+
seen.folderFiles = rendered.folderFiles;
|
|
21219
21526
|
let relinked = false;
|
|
21220
21527
|
if (adopt) {
|
|
21221
21528
|
const outcome = local === void 0 ? null : await this.relinkExistingRow(
|
|
@@ -21238,6 +21545,7 @@ var DatabaseSyncer = class {
|
|
|
21238
21545
|
await this.rowPathOwners(dbConfig.localFolder, safeName, page.id)
|
|
21239
21546
|
);
|
|
21240
21547
|
rendered = await this.renderRow(page, filePath, { localPath: filePath });
|
|
21548
|
+
seen.folderFiles = rendered.folderFiles;
|
|
21241
21549
|
}
|
|
21242
21550
|
}
|
|
21243
21551
|
const { title, properties, bodyFingerprint } = rendered;
|
|
@@ -23709,7 +24017,8 @@ var DatabaseDiscovery = class {
|
|
|
23709
24017
|
* `markScanned` 일 때 적는다.
|
|
23710
24018
|
*
|
|
23711
24019
|
* @param failed 읽지 못한 페이지 · DB — 이유와 함께 싣는다.
|
|
23712
|
-
* @param options.notePathOf 페이지 → 볼트 경로. 새 DB 폴더를 부모 노트 옆에 둔다 — dry-run 은 다시 이을
|
|
24020
|
+
* @param options.notePathOf 페이지 → 볼트 경로. 새 DB 폴더를 부모 노트 옆에 둔다 — dry-run 은 다시 이을 노트 · 새로 받을 페이지의
|
|
24021
|
+
* 자리를 준다.
|
|
23713
24022
|
*/
|
|
23714
24023
|
async openRegistry(failed, options) {
|
|
23715
24024
|
const { forceRediscovery, signal } = options;
|
|
@@ -23889,6 +24198,41 @@ var DatabaseDiscovery = class {
|
|
|
23889
24198
|
)
|
|
23890
24199
|
};
|
|
23891
24200
|
}
|
|
24201
|
+
/**
|
|
24202
|
+
* dry-run — 새로 받을 페이지의 본문을 읽어 그 안의 DB 를 찾는다({@link plannedNewDatabases} 가 그 행을 센다). 실제 pull 은
|
|
24203
|
+
* 받으며 찾는다 — 같은 길로 읽는다: Markdown API(잘린 본문은 채운 것 · 권한 없는 자리표시는 그대로), 실패하거나 블록 방식이면
|
|
24204
|
+
* 블록 트리. 예전에는 100개까지만 읽고 넘는 페이지 안의 DB 를 «미리 세지 않았습니다» 로만 알렸다 — 미리 보기는 사용자가
|
|
24205
|
+
* 부르고 멈출 수 있어(`signal`) 모두 읽는다. 둘 다 읽지 못한 페이지만 {@link bodyRead} 가 아니어서 알림으로 남는다.
|
|
24206
|
+
*/
|
|
24207
|
+
async readNewPageBodies(pageIds, signal) {
|
|
24208
|
+
if (isDatabaseMode(this.config)) return;
|
|
24209
|
+
const unread = pageIds.filter((pageId) => !this.bodyRead(pageId));
|
|
24210
|
+
if (unread.length > 0) {
|
|
24211
|
+
getLogger().info(
|
|
24212
|
+
`[Im-Nobsidian] \uBBF8\uB9AC \uBCF4\uAE30: \uC0C8 \uD398\uC774\uC9C0 ${unread.length}\uAC1C\uC758 \uBCF8\uBB38\uC5D0\uC11C DB \uB97C \uCC3E\uC74C`
|
|
24213
|
+
);
|
|
24214
|
+
}
|
|
24215
|
+
for (const pageId of unread) {
|
|
24216
|
+
if (signal?.aborted) return;
|
|
24217
|
+
try {
|
|
24218
|
+
this.collectInlineDbRefs(pageId, await this.previewBodyOf(pageId));
|
|
24219
|
+
} catch (error) {
|
|
24220
|
+
getLogger().debug(
|
|
24221
|
+
`[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)}`
|
|
24222
|
+
);
|
|
24223
|
+
}
|
|
24224
|
+
}
|
|
24225
|
+
}
|
|
24226
|
+
/** 미리 보기가 읽는 새 페이지 본문 — 실제 pull 의 `PagePuller.fetchPageMarkdown` 과 같은 차례. */
|
|
24227
|
+
async previewBodyOf(pageId) {
|
|
24228
|
+
if (this.config.conversion.preferMarkdownApi !== false) {
|
|
24229
|
+
try {
|
|
24230
|
+
return (await this.notionClient.getPageMarkdown(pageId)).markdown;
|
|
24231
|
+
} catch {
|
|
24232
|
+
}
|
|
24233
|
+
}
|
|
24234
|
+
return blocksToNfm(await this.notionClient.getBlockTree(pageId), { pageId });
|
|
24235
|
+
}
|
|
23892
24236
|
/** 이번 pull 에서 본문을 받은 페이지인가 — 그 본문의 `<database>` 로 새 DB 를 찾았다. */
|
|
23893
24237
|
bodyRead(pageId) {
|
|
23894
24238
|
return this._bodyReadPages.has(normalizeNotionId(pageId));
|
|
@@ -23971,7 +24315,7 @@ var DatabaseDiscovery = class {
|
|
|
23971
24315
|
getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uC124\uC815 \uC0DD\uC131 \uC2E4\uD328:`, error);
|
|
23972
24316
|
return { kind: "error", error: error instanceof Error ? error.message : String(error) };
|
|
23973
24317
|
}
|
|
23974
|
-
const obsPath = parentPageId ? notePathOf(parentPageId) : null;
|
|
24318
|
+
const obsPath = parentPageId ? await notePathOf(parentPageId) : null;
|
|
23975
24319
|
let parentFolder = "";
|
|
23976
24320
|
if (obsPath) {
|
|
23977
24321
|
if (isMarkdownPath(obsPath)) {
|
|
@@ -24725,7 +25069,14 @@ var LocalPlanner = class {
|
|
|
24725
25069
|
live.add(target);
|
|
24726
25070
|
}
|
|
24727
25071
|
}
|
|
24728
|
-
const
|
|
25072
|
+
const filePairs = await this.movedFolderFilePairs(
|
|
25073
|
+
[...folderRecords].filter((folder) => !live.has(folder))
|
|
25074
|
+
);
|
|
25075
|
+
for (const { to } of filePairs) for (const folder of foldersOf([to])) live.add(folder);
|
|
25076
|
+
const pairs = [
|
|
25077
|
+
...scan.adoptions.map((a) => ({ from: a.record.obsidianPath, to: a.to })),
|
|
25078
|
+
...filePairs
|
|
25079
|
+
];
|
|
24729
25080
|
const folderMoves = [];
|
|
24730
25081
|
const databaseFolderMoves = [];
|
|
24731
25082
|
for (const move of deriveFolderMoves(tracked, live, pairs, hints)) {
|
|
@@ -24734,6 +25085,29 @@ var LocalPlanner = class {
|
|
|
24734
25085
|
}
|
|
24735
25086
|
return { folderMoves, databaseFolderMoves };
|
|
24736
25087
|
}
|
|
25088
|
+
/**
|
|
25089
|
+
* 사라진 폴더 페이지에 올린 폴더 첨부의 짝 — 노트 없이 파일만 든 폴더는 노트의 짝이 없어 이름을 바꿔도 옮긴 줄 몰랐다. 그
|
|
25090
|
+
* 폴더 페이지에 올린 파일과 이름 · 크기가 같은 파일이 볼트에 하나뿐이면 그리로 옮겼다. 예전에는 새 폴더 페이지를 만들어 다시
|
|
25091
|
+
* 올렸고, 삭제 동기화가 꺼져 있으면 옛 폴더 페이지가 남아 다른 볼트가 두 폴더를 받았다.
|
|
25092
|
+
*/
|
|
25093
|
+
async movedFolderFilePairs(missing) {
|
|
25094
|
+
const entries = missing.flatMap((folder) => {
|
|
25095
|
+
const pageId = this.stateDb.getByPath(folder)?.notionPageId;
|
|
25096
|
+
if (!pageId) return [];
|
|
25097
|
+
return this.stateDb.getFilesByPageId(pageId).filter((entry) => entry.localPath.startsWith(`${folder}/`));
|
|
25098
|
+
});
|
|
25099
|
+
if (entries.length === 0) return [];
|
|
25100
|
+
const files = await this.vaultFs.listNonMarkdownFiles();
|
|
25101
|
+
const nameOf = (path) => path.slice(path.lastIndexOf("/") + 1);
|
|
25102
|
+
const pairs = [];
|
|
25103
|
+
for (const entry of entries) {
|
|
25104
|
+
const found2 = files.filter(
|
|
25105
|
+
(file) => nameOf(file.path) === nameOf(entry.localPath) && file.size === entry.fileSize
|
|
25106
|
+
);
|
|
25107
|
+
if (found2.length === 1) pairs.push({ from: entry.localPath, to: found2[0].path });
|
|
25108
|
+
}
|
|
25109
|
+
return pairs;
|
|
25110
|
+
}
|
|
24737
25111
|
/** 레코드를 새 경로로 옮겨 적는다 — 이동 WAL · 위키링크 · 보존 마커도 함께. */
|
|
24738
25112
|
moveRecord(record, to) {
|
|
24739
25113
|
const from = record.obsidianPath;
|
|
@@ -27348,18 +27722,22 @@ var PagePusher = class {
|
|
|
27348
27722
|
* 실패로 알린다. 예전처럼 조용히 건너뛰면 동기화됨으로 기록돼 편집이 영영 가지 않는다.
|
|
27349
27723
|
*
|
|
27350
27724
|
* 바뀌지 않은 미디어는 다시 올리지 않고 Notion 의 그 블록을 둔다({@link ImageHandler.keepUploadedMedia}).
|
|
27351
|
-
* 블록 방식은
|
|
27725
|
+
* 블록 방식은 노트의 미디어를 둘 수 없다 — 예전처럼 모두 다시 올린다. 페이지 끝의 폴더 첨부(ADR-056)는 노트 밖의 것이라
|
|
27726
|
+
* 블록째 두고 새 본문을 그 앞(맨 앞)에 넣는다.
|
|
27352
27727
|
*
|
|
27353
27728
|
* @returns 보낸 본문 · 그 지문 · 그대로 둔 미디어({@link PushedBody})
|
|
27354
27729
|
*/
|
|
27355
27730
|
async pushUpdatePage(pageId, markdownContent, notePath) {
|
|
27356
27731
|
if (this.config.conversion.preferMarkdownApi !== false) {
|
|
27357
27732
|
const deferred = deferSyncedReferences(obsidianToNotionEnhanced(markdownContent));
|
|
27358
|
-
const { markdown, kept } = await this.imageHandler.keepUploadedMedia(
|
|
27733
|
+
const { markdown: body, kept } = await this.imageHandler.keepUploadedMedia(
|
|
27359
27734
|
pageId,
|
|
27360
27735
|
deferred.nfm,
|
|
27361
27736
|
notePath
|
|
27362
27737
|
);
|
|
27738
|
+
const folderFiles2 = await this.folderFileLines(pageId);
|
|
27739
|
+
const markdown = folderFiles2 ? `${body}
|
|
27740
|
+
${folderFiles2}` : body;
|
|
27363
27741
|
const written = await replacePageBody(this.notionClient, pageId, markdown, {
|
|
27364
27742
|
databaseIdsOfBase: (basePath) => this.databaseIdsOfBase(basePath),
|
|
27365
27743
|
unknownTagFallbacks: unknownTagFallbacks(markdownContent)
|
|
@@ -27379,13 +27757,19 @@ var PagePusher = class {
|
|
|
27379
27757
|
"\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"
|
|
27380
27758
|
);
|
|
27381
27759
|
}
|
|
27760
|
+
const folderFiles = this.folderFileBlocks(existingBlocks, pageId, notePath);
|
|
27382
27761
|
const { nfm, blocks } = blockModeBody(markdownContent);
|
|
27383
27762
|
if (blocks.length > 0) {
|
|
27384
|
-
await this.notionClient.appendBlockTree(
|
|
27763
|
+
await this.notionClient.appendBlockTree(
|
|
27764
|
+
pageId,
|
|
27765
|
+
blocks,
|
|
27766
|
+
folderFiles.size > 0 ? { atStart: true } : void 0
|
|
27767
|
+
);
|
|
27385
27768
|
}
|
|
27386
27769
|
const deleteSema = new Sema(this.config.advanced.concurrency);
|
|
27387
27770
|
await Promise.all(
|
|
27388
27771
|
existingBlocks.map(async (block) => {
|
|
27772
|
+
if (folderFiles.has(block.id)) return;
|
|
27389
27773
|
await deleteSema.acquire();
|
|
27390
27774
|
try {
|
|
27391
27775
|
await this.notionClient.deleteBlock(block.id);
|
|
@@ -27396,6 +27780,41 @@ var PagePusher = class {
|
|
|
27396
27780
|
);
|
|
27397
27781
|
return { fingerprint: null, kept: [], sent: nfm, syncedRefs: [], syncedRefText: "" };
|
|
27398
27782
|
}
|
|
27783
|
+
/**
|
|
27784
|
+
* 노트 페이지의 폴더 첨부 줄(ADR-056) — 본문을 통째로 바꿀 때 받은 줄 그대로 끝에 되실어야 Notion 이 그 블록을 둔다
|
|
27785
|
+
* ({@link ImageHandler.keepUploadedMedia} 와 같은 실측). 이 페이지에 적어 둔 폴더 첨부가 없으면 읽지 않는다. 읽지 못하면
|
|
27786
|
+
* 던진다 — 모르고 바꾸면 폴더 첨부가 지워진다.
|
|
27787
|
+
*/
|
|
27788
|
+
async folderFileLines(pageId) {
|
|
27789
|
+
const ids = new Set(folderFileIdsOf(this.stateDb, pageId).values());
|
|
27790
|
+
if (ids.size === 0) return "";
|
|
27791
|
+
const remote = (await this.notionClient.getPageMarkdown(pageId)).markdown;
|
|
27792
|
+
return (folderFilesInNote(remote, { knownIds: ids })?.lines ?? []).join("\n");
|
|
27793
|
+
}
|
|
27794
|
+
/**
|
|
27795
|
+
* 블록 방식으로 바꿀 본문에서 남길 폴더 첨부 블록(ADR-056) — 맨 위 블록 가운데 {@link isFolderFileBlock}. 예전에는 함께 지우고
|
|
27796
|
+
* 기록을 놓아 노트를 고칠 때마다 폴더 첨부를 다시 올렸다.
|
|
27797
|
+
*
|
|
27798
|
+
* 맨 위에 없는 폴더 첨부(Notion 에서 토글 안으로 옮긴 것)는 그 토글과 함께 지워진다 — 기록을 놓아 이어지는 첨부 push 가
|
|
27799
|
+
* 페이지 끝에 다시 올리게 한다. 두면 볼트의 기록만 남아 다음 pull 이 Notion 에서 지운 파일로 본다.
|
|
27800
|
+
*/
|
|
27801
|
+
folderFileBlocks(blocks, pageId, notePath) {
|
|
27802
|
+
const recorded = folderFileIdsOf(this.stateDb, pageId);
|
|
27803
|
+
const ids = new Set(recorded.values());
|
|
27804
|
+
const kept = blocks.filter((block) => isFolderFileBlock(block, ids));
|
|
27805
|
+
const keptIds = new Set(kept.map((block) => notionFileIdOf(fileUrlOfBlock(block) ?? "")));
|
|
27806
|
+
const lost = [...recorded].filter(([, fileId]) => !keptIds.has(fileId));
|
|
27807
|
+
for (const [localPath] of lost) {
|
|
27808
|
+
this.stateDb.deleteFileRegistry(localPath);
|
|
27809
|
+
forgetFolderFileId(this.stateDb, pageId, localPath);
|
|
27810
|
+
}
|
|
27811
|
+
if (lost.length > 0) {
|
|
27812
|
+
getLogger().info(
|
|
27813
|
+
`[Im-Nobsidian] \uBE14\uB85D \uBC29\uC2DD\uC73C\uB85C \uBCF8\uBB38\uC744 \uBC14\uAFB8\uBA70 \uB9E8 \uC704\uC5D0 \uC5C6\uB358 \uD3F4\uB354 \uCCA8\uBD80 ${lost.length}\uAC1C\uB97C \uB2E4\uC2DC \uC62C\uB9BC (${notePath})`
|
|
27814
|
+
);
|
|
27815
|
+
}
|
|
27816
|
+
return new Set(kept.map((block) => block.id));
|
|
27817
|
+
}
|
|
27399
27818
|
/**
|
|
27400
27819
|
* 자리표시로 보낸 코드를 채우고(S-22) 동기화 블록 참조를 붙인다({@link deferSyncedReferences}) — 본문을
|
|
27401
27820
|
* 바꾼 뒤(블록 방식이면 옛 블록을 지운 뒤) 부른다.
|
|
@@ -27634,6 +28053,17 @@ var PagePuller = class {
|
|
|
27634
28053
|
sanitizeFileName(this.notionClient.extractTitle(page))
|
|
27635
28054
|
);
|
|
27636
28055
|
}
|
|
28056
|
+
/**
|
|
28057
|
+
* dry-run — 새 페이지를 실제 pull 이 받을 자리(부모의 자리 + 제목). 그 안에서 찾은 DB 의 폴더를 실제처럼 그 옆에 둔다 — 예전에는
|
|
28058
|
+
* 자리를 몰라 `databases/` 아래로 보였다. 같은 이름이 있으면 실제 pull 은 접미사를 붙이지만 여기서는 붙이지 않는다(선점하지
|
|
28059
|
+
* 않는다). 볼트도 상태 DB 도 바꾸지 않는다.
|
|
28060
|
+
*/
|
|
28061
|
+
async plannedNotePath(pageId) {
|
|
28062
|
+
const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
|
|
28063
|
+
const parent = await this.resolveParentPath(page);
|
|
28064
|
+
const name = `${sanitizeFileName(this.notionClient.extractTitle(page))}.md`;
|
|
28065
|
+
return parent ? `${parent}/${name}` : name;
|
|
28066
|
+
}
|
|
27637
28067
|
/**
|
|
27638
28068
|
* dry-run — 실제 pull({@link createPulledPage})이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7).
|
|
27639
28069
|
* 다시 이을 노트(`note` — 충돌로 남길 것이면 `conflict`) · 이미 있는 폴더(`folder` — 폴더로만 받는 페이지), 아니면 null.
|
|
@@ -27773,7 +28203,10 @@ var PagePuller = class {
|
|
|
27773
28203
|
const local = await readLocalNote(this.vaultFs, filePath);
|
|
27774
28204
|
rendered = await render2(filePath, local);
|
|
27775
28205
|
const relinked = local === void 0 ? null : this.relinkExistingNote(filePath, claim.existing, local, rendered, note);
|
|
27776
|
-
if (relinked)
|
|
28206
|
+
if (relinked) {
|
|
28207
|
+
await this.receiveNoteFiles(filePath, fileType, pageId, fetched);
|
|
28208
|
+
return relinked;
|
|
28209
|
+
}
|
|
27777
28210
|
this.claimedPaths.delete(pathKey(filePath));
|
|
27778
28211
|
filePath = (await this.resolveUniqueFilePath(noteDir, safeName, pageId)).path;
|
|
27779
28212
|
rendered = await render2(filePath);
|
|
@@ -27786,8 +28219,19 @@ var PagePuller = class {
|
|
|
27786
28219
|
properties: rendered.properties,
|
|
27787
28220
|
status: "synced"
|
|
27788
28221
|
});
|
|
28222
|
+
await this.receiveNoteFiles(filePath, fileType, pageId, fetched);
|
|
27789
28223
|
return { path: filePath };
|
|
27790
28224
|
}
|
|
28225
|
+
/** 새로 받은 노트 페이지의 폴더 첨부를 그 노트의 폴더에 받는다(ADR-056) — 폴더 노트는 그 폴더, 노트는 이름의 폴더. */
|
|
28226
|
+
async receiveNoteFiles(filePath, fileType, pageId, fetched) {
|
|
28227
|
+
if (fetched.noteFiles.length === 0) return;
|
|
28228
|
+
await this.receiveFolderFiles(
|
|
28229
|
+
childFolderOf({ fileType, obsidianPath: filePath }, this.placement.folderLookup()),
|
|
28230
|
+
pageId,
|
|
28231
|
+
fetched.noteFiles,
|
|
28232
|
+
fetched.noteFilesComplete
|
|
28233
|
+
);
|
|
28234
|
+
}
|
|
27791
28235
|
/**
|
|
27792
28236
|
* 상태 기록에 없는 노트가 이 페이지가 놓일 자리에 이미 있다 — 상태 DB 를 잃었거나 처음 잇는 볼트다(P7).
|
|
27793
28237
|
* 예전에는 남의 노트로 보고 id 를 붙인 이름으로 또 받아 모든 노트가 두 벌이 됐다(push 는 모든 페이지를 또 만들었다).
|
|
@@ -27946,6 +28390,14 @@ var PagePuller = class {
|
|
|
27946
28390
|
}
|
|
27947
28391
|
const rendered = await this.renderRemotePage(record, change.pageId, page);
|
|
27948
28392
|
const remoteContent = rendered.content;
|
|
28393
|
+
if (rendered.folderFiles && !remoteGone) {
|
|
28394
|
+
await this.receiveFolderFiles(
|
|
28395
|
+
childFolderOf(record, this.placement.folderLookup()),
|
|
28396
|
+
change.pageId,
|
|
28397
|
+
rendered.folderFiles.files,
|
|
28398
|
+
rendered.folderFiles.complete
|
|
28399
|
+
);
|
|
28400
|
+
}
|
|
27949
28401
|
let localContent;
|
|
27950
28402
|
let localExists = true;
|
|
27951
28403
|
try {
|
|
@@ -28089,7 +28541,10 @@ var PagePuller = class {
|
|
|
28089
28541
|
if (record.fileType === "db-row") {
|
|
28090
28542
|
return this.databaseSyncer.renderRow(page, record.obsidianPath, { downloadMedia });
|
|
28091
28543
|
}
|
|
28092
|
-
const
|
|
28544
|
+
const local = await readLocalNote(this.vaultFs, record.obsidianPath);
|
|
28545
|
+
const fetched = await this.fetchPageMarkdown(pageId, {
|
|
28546
|
+
embedded: embeddedNamesOf(local === void 0 ? [] : [local])
|
|
28547
|
+
});
|
|
28093
28548
|
const title = this.notionClient.extractTitle(page);
|
|
28094
28549
|
const { content, properties } = await this.renderPageNote({
|
|
28095
28550
|
pageId,
|
|
@@ -28100,11 +28555,17 @@ var PagePuller = class {
|
|
|
28100
28555
|
titleFollowsName: this.titleFollowsName(record, title),
|
|
28101
28556
|
downloadMedia,
|
|
28102
28557
|
local: {
|
|
28103
|
-
content:
|
|
28558
|
+
content: local,
|
|
28104
28559
|
markers: this.stateDb.getPreserveMarkers(record.obsidianPath)
|
|
28105
28560
|
}
|
|
28106
28561
|
});
|
|
28107
|
-
return {
|
|
28562
|
+
return {
|
|
28563
|
+
content,
|
|
28564
|
+
title,
|
|
28565
|
+
properties,
|
|
28566
|
+
bodyFingerprint: fetched.fingerprint,
|
|
28567
|
+
folderFiles: { files: fetched.noteFiles, complete: fetched.noteFilesComplete }
|
|
28568
|
+
};
|
|
28108
28569
|
}
|
|
28109
28570
|
/**
|
|
28110
28571
|
* 받은 페이지 본문을 볼트에 쓸 노트로 — 새로 받기(`createPulledPage`)와 다시 받기({@link renderRemotePage})가
|
|
@@ -28172,19 +28633,27 @@ var PagePuller = class {
|
|
|
28172
28633
|
/**
|
|
28173
28634
|
* 페이지 본문을 볼트 쪽 markdown 으로 받는다.
|
|
28174
28635
|
*
|
|
28636
|
+
* @param options.embedded 로컬 노트가 임베드한 파일 이름 — 폴더 첨부({@link folderFilesInNote})를 노트에서 떼어
|
|
28637
|
+
* `noteFiles` 로 돌려줄 때, 이 이름 · 이 볼트가 노트 첨부로 올린 파일은 노트의 것으로 둔다(ADR-056).
|
|
28175
28638
|
* @returns `fingerprint` 는 받은 원격 본문의 지문({@link remoteBodyFingerprint}). 블록 API 로
|
|
28176
28639
|
* 받았으면 null — 지문은 Markdown API 의 본문으로만 견준다.
|
|
28177
28640
|
*/
|
|
28178
|
-
async fetchPageMarkdown(pageId) {
|
|
28641
|
+
async fetchPageMarkdown(pageId, options = {}) {
|
|
28642
|
+
const noteFilesOf = (markdown) => noteFolderFilesOfPage(this.stateDb, pageId, markdown, options.embedded);
|
|
28179
28643
|
if (this.config.conversion.preferMarkdownApi !== false) {
|
|
28180
28644
|
try {
|
|
28181
28645
|
const result = await this.notionClient.getPageMarkdown(pageId);
|
|
28182
28646
|
this.discovery.collectInlineDbRefs(pageId, result.markdown);
|
|
28183
28647
|
this.detector.noteBodyChildren(pageId, result.markdown);
|
|
28648
|
+
const { found: found2, holdsAll } = noteFilesOf(result.markdown);
|
|
28184
28649
|
return {
|
|
28185
28650
|
content: await this.resolveBlockLinks(
|
|
28186
28651
|
this.resolveNotionIdWikilinks(
|
|
28187
|
-
await notionBodyToObsidian(
|
|
28652
|
+
await notionBodyToObsidian(
|
|
28653
|
+
this.notionClient,
|
|
28654
|
+
found2?.markdown ?? result.markdown,
|
|
28655
|
+
pageId
|
|
28656
|
+
)
|
|
28188
28657
|
),
|
|
28189
28658
|
pageId
|
|
28190
28659
|
),
|
|
@@ -28194,7 +28663,9 @@ var PagePuller = class {
|
|
|
28194
28663
|
children: extractChildTags(result.markdown),
|
|
28195
28664
|
fingerprint: remoteBodyFingerprint(result.markdown),
|
|
28196
28665
|
hasBody: hasBodyBesidesChildren(result.markdown),
|
|
28197
|
-
folderFiles: folderFilesInBody(result.markdown)
|
|
28666
|
+
folderFiles: folderFilesInBody(result.markdown),
|
|
28667
|
+
noteFiles: found2?.files ?? [],
|
|
28668
|
+
noteFilesComplete: !result.truncated && holdsAll
|
|
28198
28669
|
};
|
|
28199
28670
|
} catch (error) {
|
|
28200
28671
|
getLogger().warn(
|
|
@@ -28202,7 +28673,14 @@ var PagePuller = class {
|
|
|
28202
28673
|
);
|
|
28203
28674
|
}
|
|
28204
28675
|
}
|
|
28205
|
-
|
|
28676
|
+
let noteFiles = [];
|
|
28677
|
+
let noteFilesComplete = true;
|
|
28678
|
+
const fallback = await pageBlocksToObsidian(this.notionClient, pageId, (markdown) => {
|
|
28679
|
+
const { found: found2, holdsAll } = noteFilesOf(markdown);
|
|
28680
|
+
noteFiles = found2?.files ?? [];
|
|
28681
|
+
noteFilesComplete = holdsAll;
|
|
28682
|
+
return found2?.markdown ?? markdown;
|
|
28683
|
+
});
|
|
28206
28684
|
this.discovery.collectInlineDbRefs(pageId, fallback.markdown);
|
|
28207
28685
|
this.detector.noteBodyChildren(pageId, fallback.markdown);
|
|
28208
28686
|
return {
|
|
@@ -28214,10 +28692,44 @@ var PagePuller = class {
|
|
|
28214
28692
|
children: extractChildTags(fallback.markdown),
|
|
28215
28693
|
fingerprint: null,
|
|
28216
28694
|
hasBody: hasBodyBesidesChildren(fallback.markdown),
|
|
28217
|
-
folderFiles: folderFilesInBody(fallback.markdown)
|
|
28695
|
+
folderFiles: folderFilesInBody(fallback.markdown),
|
|
28696
|
+
noteFiles,
|
|
28697
|
+
noteFilesComplete
|
|
28218
28698
|
};
|
|
28219
28699
|
}
|
|
28220
|
-
/**
|
|
28700
|
+
/**
|
|
28701
|
+
* 볼트 맨 위 파일 — 루트 페이지 끝의 폴더 첨부(「📁 이름」 · 적어 둔 것)를 볼트 맨 위에 받는다({@link rootFilesPageOf}).
|
|
28702
|
+
* 루트의 다른 블록(사용자가 붙인 파일 포함)은 받지 않는다. Markdown API 로 읽지 못하면 맨 위 블록에서 찾되 빠진 파일을
|
|
28703
|
+
* 지운 것으로 보지 않는다 — 토글 안으로 옮긴 것은 보이지 않는다. 읽지 못하면 경고만 남긴다(다음 pull 이 다시 읽는다).
|
|
28704
|
+
*/
|
|
28705
|
+
async receiveRootFiles() {
|
|
28706
|
+
const rootId = rootFilesPageOf(this.config);
|
|
28707
|
+
if (!rootId) return;
|
|
28708
|
+
try {
|
|
28709
|
+
let files;
|
|
28710
|
+
let complete2;
|
|
28711
|
+
try {
|
|
28712
|
+
const result = await this.notionClient.getPageMarkdown(rootId);
|
|
28713
|
+
const { found: found2, holdsAll } = noteFolderFilesOfPage(this.stateDb, rootId, result.markdown);
|
|
28714
|
+
files = found2?.files ?? [];
|
|
28715
|
+
complete2 = !result.truncated && holdsAll;
|
|
28716
|
+
} catch {
|
|
28717
|
+
const ids = new Set(folderFileIdsOf(this.stateDb, rootId).values());
|
|
28718
|
+
files = (await this.notionClient.fetchAllChildren(rootId)).flatMap((block) => {
|
|
28719
|
+
const url = isFolderFileBlock(block, ids) ? fileUrlOfBlock(block) : null;
|
|
28720
|
+
const name = url ? uploadedFileNameFromUrl(url) : null;
|
|
28721
|
+
return url && name ? [{ url, name }] : [];
|
|
28722
|
+
});
|
|
28723
|
+
complete2 = false;
|
|
28724
|
+
}
|
|
28725
|
+
await this.receiveFolderFiles("", rootId, files, complete2);
|
|
28726
|
+
} catch (error) {
|
|
28727
|
+
getLogger().warn(
|
|
28728
|
+
`[Im-Nobsidian] \uBCFC\uD2B8 \uB9E8 \uC704 \uD30C\uC77C\uC744 \uB8E8\uD2B8 \uD398\uC774\uC9C0\uC5D0\uC11C \uBC1B\uC9C0 \uBABB\uD568: ${error instanceof Error ? error.message : String(error)}`
|
|
28729
|
+
);
|
|
28730
|
+
}
|
|
28731
|
+
}
|
|
28732
|
+
/** 폴더 첨부를 받아 받은 파일 수에 더한다 — 행의 폴더 첨부도 여기로 받는다(`DatabaseSyncer`). */
|
|
28221
28733
|
async receiveFolderFiles(folder, pageId, files, complete2 = false) {
|
|
28222
28734
|
this._pullFileCount += await this.fileHandler.receiveFolderFiles(
|
|
28223
28735
|
folder,
|
|
@@ -28688,9 +29200,12 @@ var PullPlanner = class {
|
|
|
28688
29200
|
const relinkPaths = new Map(
|
|
28689
29201
|
[...relinks.byPage].map(([pageId, relink]) => [compactNotionId(pageId), relink.path])
|
|
28690
29202
|
);
|
|
29203
|
+
const created = planned.filter((change) => change.type === "created").map((change) => change.pageId);
|
|
29204
|
+
await this.discovery.readNewPageBodies(created, options.signal);
|
|
29205
|
+
const createdIds = new Set(created.map(compactNotionId));
|
|
28691
29206
|
const newDatabases = await this.discovery.plannedNewDatabases(failed, {
|
|
28692
29207
|
rescanRoot: this.detector.rootChanged(),
|
|
28693
|
-
notePathOf: (pageId) => relinkPaths.get(compactNotionId(pageId)) ?? this.stateDb.getByNotionId(pageId)?.obsidianPath ?? null
|
|
29208
|
+
notePathOf: async (pageId) => relinkPaths.get(compactNotionId(pageId)) ?? this.stateDb.getByNotionId(pageId)?.obsidianPath ?? (createdIds.has(compactNotionId(pageId)) ? await this.puller.plannedNotePath(pageId) : null)
|
|
28694
29209
|
});
|
|
28695
29210
|
const rows = [];
|
|
28696
29211
|
for (const row of await this.planDatabaseRows(
|
|
@@ -29645,7 +30160,8 @@ var SyncOrchestrator = class {
|
|
|
29645
30160
|
downloadTimeoutMs: config.advanced.mediaDownloadTimeoutMs,
|
|
29646
30161
|
itemTimeoutMs: config.advanced.itemTimeoutMs,
|
|
29647
30162
|
attachmentsFolder: config.paths.attachments,
|
|
29648
|
-
deleteSync: config.sync.deleteSync
|
|
30163
|
+
deleteSync: config.sync.deleteSync,
|
|
30164
|
+
rootPageId: rootFilesPageOf(config)
|
|
29649
30165
|
}
|
|
29650
30166
|
);
|
|
29651
30167
|
const propertyMapper = PropertyMapper.fromConfig(config);
|
|
@@ -29657,7 +30173,8 @@ var SyncOrchestrator = class {
|
|
|
29657
30173
|
pipeline,
|
|
29658
30174
|
imageHandler,
|
|
29659
30175
|
() => this.observation.context,
|
|
29660
|
-
(pageId, rawMarkdown) => this.detector.noteBodyChildren(pageId, rawMarkdown)
|
|
30176
|
+
(pageId, rawMarkdown) => this.detector.noteBodyChildren(pageId, rawMarkdown),
|
|
30177
|
+
(folder, pageId, files, complete2) => this.puller.receiveFolderFiles(folder, pageId, files, complete2)
|
|
29661
30178
|
);
|
|
29662
30179
|
this.discovery = new DatabaseDiscovery(
|
|
29663
30180
|
config,
|
|
@@ -29880,7 +30397,11 @@ var SyncOrchestrator = class {
|
|
|
29880
30397
|
async pushVaultFiles(failed) {
|
|
29881
30398
|
if (this.config.sync.syncFiles === false) return 0;
|
|
29882
30399
|
try {
|
|
29883
|
-
const fileResults = await this.fileHandler.pushAllFiles(
|
|
30400
|
+
const fileResults = await this.fileHandler.pushAllFiles(
|
|
30401
|
+
failed,
|
|
30402
|
+
this.placement,
|
|
30403
|
+
this.notePageWrites()
|
|
30404
|
+
);
|
|
29884
30405
|
if (fileResults.length > 0) {
|
|
29885
30406
|
getLogger().info(`[Im-Nobsidian] ${fileResults.length}\uAC1C \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC`);
|
|
29886
30407
|
}
|
|
@@ -29890,6 +30411,42 @@ var SyncOrchestrator = class {
|
|
|
29890
30411
|
return 0;
|
|
29891
30412
|
}
|
|
29892
30413
|
}
|
|
30414
|
+
/**
|
|
30415
|
+
* 폴더 첨부를 노트의 페이지에 붙이거나 지울 때({@link NotePageWrites}) — 쓰기 전에 원격과 맞던 노트만 쓴 뒤의 원격을 본
|
|
30416
|
+
* 것으로 적는다. 받지 않은 Notion 편집이 걸린 노트를 적으면 다음 pull 이 그 편집을 받지 않는다. 읽지 못하면 적지 않는다 —
|
|
30417
|
+
* 다음 push 가 그 노트를 올리지 않고 pull 을 먼저 하라고 알릴 뿐이다.
|
|
30418
|
+
*/
|
|
30419
|
+
notePageWrites() {
|
|
30420
|
+
const inSync = /* @__PURE__ */ new Map();
|
|
30421
|
+
return {
|
|
30422
|
+
begin: async (pageId) => {
|
|
30423
|
+
const record = this.stateDb.getByNotionId(pageId);
|
|
30424
|
+
if (!record || isFolderRecord(record)) return;
|
|
30425
|
+
try {
|
|
30426
|
+
const page = await this.notionClient.getPage(pageId);
|
|
30427
|
+
if (await this.drift.remoteDrift(record, page) === "none")
|
|
30428
|
+
inSync.set(pageId, record.id);
|
|
30429
|
+
} catch (error) {
|
|
30430
|
+
getLogger().warn(
|
|
30431
|
+
`[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)}`
|
|
30432
|
+
);
|
|
30433
|
+
}
|
|
30434
|
+
},
|
|
30435
|
+
end: async (pageId) => {
|
|
30436
|
+
const recordId = inSync.get(pageId);
|
|
30437
|
+
if (recordId === void 0) return;
|
|
30438
|
+
inSync.delete(pageId);
|
|
30439
|
+
try {
|
|
30440
|
+
const page = await this.notionClient.getPage(pageId);
|
|
30441
|
+
this.observation.record(recordId, page, await this.drift.remoteBodyFingerprintOf(pageId));
|
|
30442
|
+
} catch (error) {
|
|
30443
|
+
getLogger().warn(
|
|
30444
|
+
`[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)}`
|
|
30445
|
+
);
|
|
30446
|
+
}
|
|
30447
|
+
}
|
|
30448
|
+
};
|
|
30449
|
+
}
|
|
29893
30450
|
/**
|
|
29894
30451
|
* @param mergedByPull sync dry-run 만 — 앞선 pull dry-run 이 합칠 노트. 실제 sync 는 합친 뒤 원격을 본 것으로
|
|
29895
30452
|
* 적어 push 가 올린다 — dry-run 은 받지 않아 원격이 바뀐 그대로라, 원격을 보고 거절하지 않는다.
|
|
@@ -30457,6 +31014,7 @@ var SyncOrchestrator = class {
|
|
|
30457
31014
|
pendingDatabases
|
|
30458
31015
|
);
|
|
30459
31016
|
const finalize = async (created, updated, deleted, restored = 0) => {
|
|
31017
|
+
if (!options?.dryRun && this.detector.rootChanged()) await this.puller.receiveRootFiles();
|
|
30460
31018
|
await this.puller.removeVacatedFolders();
|
|
30461
31019
|
const renamedRows = this.databaseSyncer.takeRenamedRows();
|
|
30462
31020
|
restoredPaths.push(...this.databaseSyncer.takeRestoredRows());
|
|
@@ -32468,7 +33026,7 @@ function hasColumnLayout(text) {
|
|
|
32468
33026
|
var START_RE = new RegExp(`^[\\t ]*${COLUMN_LIST_START}[\\t ]*$`);
|
|
32469
33027
|
var END_RE = new RegExp(`^[\\t ]*${COLUMN_LIST_END}[\\t ]*$`);
|
|
32470
33028
|
var SEP_RE = new RegExp(`^[\\t ]*${COLUMN_SEP_SOURCE}[\\t ]*$`);
|
|
32471
|
-
var
|
|
33029
|
+
var FENCE_RE2 = /^[\t ]*(`{3,}|~{3,})/;
|
|
32472
33030
|
function readColumnLayout(lines) {
|
|
32473
33031
|
const places = new Array(lines.length);
|
|
32474
33032
|
let group2 = -1;
|
|
@@ -32492,7 +33050,7 @@ function readColumnLayout(lines) {
|
|
|
32492
33050
|
open = null;
|
|
32493
33051
|
};
|
|
32494
33052
|
lines.forEach((line, i) => {
|
|
32495
|
-
const fenceMatch =
|
|
33053
|
+
const fenceMatch = FENCE_RE2.exec(line);
|
|
32496
33054
|
if (fence) {
|
|
32497
33055
|
if (fenceMatch && fenceMatch[1].startsWith(fence)) fence = null;
|
|
32498
33056
|
return;
|