@im-nobsidian/core 0.17.1 → 0.19.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 +91 -6
- package/dist/index.js +787 -210
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5994,6 +5994,9 @@ function anchorBlockOf(block) {
|
|
|
5994
5994
|
text: (body?.rich_text ?? []).map((t) => t.plain_text ?? "").join("")
|
|
5995
5995
|
};
|
|
5996
5996
|
}
|
|
5997
|
+
function flattenBlockTree(blocks) {
|
|
5998
|
+
return blocks.flatMap((block) => [block, ...flattenBlockTree(block.children ?? [])]);
|
|
5999
|
+
}
|
|
5997
6000
|
var ANCHORED_WIKILINK_RE = new LookbehindRegExp(
|
|
5998
6001
|
/\[\[([^[\]|]*#[^[\]|]*?)\\?(?:\|[^[\]]+)?\]\]/g,
|
|
5999
6002
|
void 0,
|
|
@@ -6037,7 +6040,7 @@ var WikilinkResolver = class {
|
|
|
6037
6040
|
const target = display === void 0 ? written : withoutTableAliasEscape(written);
|
|
6038
6041
|
const label = display ?? target;
|
|
6039
6042
|
const anchored = this.anchorLink(target, display, input.context.anchors);
|
|
6040
|
-
if (anchored) return anchored;
|
|
6043
|
+
if (anchored?.link) return anchored.link;
|
|
6041
6044
|
if (this.resolve) {
|
|
6042
6045
|
const entry = this.resolve(target);
|
|
6043
6046
|
if (entry) {
|
|
@@ -6048,6 +6051,7 @@ var WikilinkResolver = class {
|
|
|
6048
6051
|
}
|
|
6049
6052
|
}
|
|
6050
6053
|
const params = display ? { text: target, display } : { text: target };
|
|
6054
|
+
if (anchored?.miss) params.anchor = anchored.miss;
|
|
6051
6055
|
params.__anchor = computeAnchor(input.content, base + offset);
|
|
6052
6056
|
preserveMarkers.push({
|
|
6053
6057
|
type: "wikilink",
|
|
@@ -6065,16 +6069,19 @@ var WikilinkResolver = class {
|
|
|
6065
6069
|
}
|
|
6066
6070
|
/**
|
|
6067
6071
|
* `[[노트#제목]]` · `[[노트#^블록]]` · `[[#제목]]` → `[글자](https://www.notion.so/<페이지>#<블록>)`. 글자는 별칭,
|
|
6068
|
-
* 없으면 Obsidian 이 보이는 「노트 > 제목」 — pull 이 그 글자면 별칭을 붙이지 않는다.
|
|
6072
|
+
* 없으면 Obsidian 이 보이는 「노트 > 제목」 — pull 이 그 글자면 별칭을 붙이지 않는다. 앵커 링크가 아니면 null, 못 풀면
|
|
6073
|
+
* 그 까닭({@link AnchorMiss}).
|
|
6069
6074
|
*/
|
|
6070
6075
|
anchorLink(target, display, anchors) {
|
|
6071
6076
|
const split = anchors ? splitAnchor(target) : null;
|
|
6072
6077
|
if (!anchors || !split) return null;
|
|
6073
6078
|
const pageId = split.note === "" ? anchors.selfPageId : this.resolve?.(split.note)?.notionPageId ?? null;
|
|
6074
|
-
if (!pageId) return
|
|
6079
|
+
if (!pageId) return { miss: "page" };
|
|
6075
6080
|
const blockId = anchors.blockOf(pageId, split.anchor);
|
|
6076
|
-
if (!blockId) return
|
|
6077
|
-
return
|
|
6081
|
+
if (!blockId) return { miss: "block" };
|
|
6082
|
+
return {
|
|
6083
|
+
link: `[${display ?? anchorDisplay(split.note, split.anchor)}](${notionBlockUrl(pageId, blockId)})`
|
|
6084
|
+
};
|
|
6078
6085
|
}
|
|
6079
6086
|
};
|
|
6080
6087
|
|
|
@@ -12897,6 +12904,76 @@ function createDefaultPipeline(options) {
|
|
|
12897
12904
|
return pipeline;
|
|
12898
12905
|
}
|
|
12899
12906
|
|
|
12907
|
+
// src/sync/vault-file-index.ts
|
|
12908
|
+
var INDEX_MAX_AGE_MS = 5e3;
|
|
12909
|
+
function keyOf(path) {
|
|
12910
|
+
return path.normalize("NFC").toLowerCase();
|
|
12911
|
+
}
|
|
12912
|
+
function baseNameOf(path) {
|
|
12913
|
+
return path.slice(path.lastIndexOf("/") + 1);
|
|
12914
|
+
}
|
|
12915
|
+
function folderOf(path) {
|
|
12916
|
+
const slash = path.lastIndexOf("/");
|
|
12917
|
+
return slash === -1 ? "" : path.slice(0, slash);
|
|
12918
|
+
}
|
|
12919
|
+
var VaultFileIndex = class {
|
|
12920
|
+
constructor(vaultFs, now = Date.now) {
|
|
12921
|
+
this.vaultFs = vaultFs;
|
|
12922
|
+
this.now = now;
|
|
12923
|
+
}
|
|
12924
|
+
vaultFs;
|
|
12925
|
+
now;
|
|
12926
|
+
byName = null;
|
|
12927
|
+
builtAt = 0;
|
|
12928
|
+
/**
|
|
12929
|
+
* `linkpath`(이름 또는 경로 끝)로 끝나는 볼트 파일 — 가까운 것부터: 노트 폴더 안(얕은 것 먼저) → 짧은 경로 → 가나다. 지금
|
|
12930
|
+
* 있는 것만 돌려준다.
|
|
12931
|
+
*/
|
|
12932
|
+
async find(linkpath, noteFolder) {
|
|
12933
|
+
if (this.byName === null || this.now() - this.builtAt > INDEX_MAX_AGE_MS) await this.rebuild();
|
|
12934
|
+
const key = keyOf(linkpath.replace(/^\/+/, ""));
|
|
12935
|
+
const suffix = `/${key}`;
|
|
12936
|
+
const matches = (this.byName?.get(keyOf(baseNameOf(key))) ?? []).filter((path) => {
|
|
12937
|
+
const k = keyOf(path);
|
|
12938
|
+
return k === key || k.endsWith(suffix);
|
|
12939
|
+
});
|
|
12940
|
+
const existing = [];
|
|
12941
|
+
for (const path of rank(matches, noteFolder)) {
|
|
12942
|
+
if (await this.vaultFs.exists(path)) existing.push(path);
|
|
12943
|
+
}
|
|
12944
|
+
return existing;
|
|
12945
|
+
}
|
|
12946
|
+
async rebuild() {
|
|
12947
|
+
const byName2 = /* @__PURE__ */ new Map();
|
|
12948
|
+
let files = [];
|
|
12949
|
+
try {
|
|
12950
|
+
files = await this.vaultFs.listNonMarkdownFiles() ?? [];
|
|
12951
|
+
} catch {
|
|
12952
|
+
}
|
|
12953
|
+
for (const file of files) {
|
|
12954
|
+
const key = keyOf(baseNameOf(file.path));
|
|
12955
|
+
const list = byName2.get(key) ?? [];
|
|
12956
|
+
list.push(file.path);
|
|
12957
|
+
byName2.set(key, list);
|
|
12958
|
+
}
|
|
12959
|
+
this.byName = byName2;
|
|
12960
|
+
this.builtAt = this.now();
|
|
12961
|
+
}
|
|
12962
|
+
};
|
|
12963
|
+
function rank(paths, noteFolder) {
|
|
12964
|
+
const inside2 = (path) => {
|
|
12965
|
+
if (noteFolder === void 0) return Number.POSITIVE_INFINITY;
|
|
12966
|
+
const folder = folderOf(path);
|
|
12967
|
+
if (noteFolder === "") return folder.split("/").filter(Boolean).length;
|
|
12968
|
+
if (folder !== noteFolder && !folder.startsWith(`${noteFolder}/`))
|
|
12969
|
+
return Number.POSITIVE_INFINITY;
|
|
12970
|
+
return folder.slice(noteFolder.length).split("/").filter(Boolean).length;
|
|
12971
|
+
};
|
|
12972
|
+
return [...paths].sort(
|
|
12973
|
+
(a, b) => inside2(a) - inside2(b) || a.length - b.length || a.localeCompare(b)
|
|
12974
|
+
);
|
|
12975
|
+
}
|
|
12976
|
+
|
|
12900
12977
|
// src/utils/mime.ts
|
|
12901
12978
|
var EXTENSION_TO_BLOCK_TYPE = {
|
|
12902
12979
|
".png": "image",
|
|
@@ -13018,11 +13095,11 @@ function getMimeType(filename) {
|
|
|
13018
13095
|
}
|
|
13019
13096
|
|
|
13020
13097
|
// src/sync/uploaded-media.ts
|
|
13021
|
-
function
|
|
13098
|
+
function keyOf2(pageId) {
|
|
13022
13099
|
return `uploaded_media:${compactNotionId(pageId)}`;
|
|
13023
13100
|
}
|
|
13024
13101
|
function rememberUploadedMedia(stateDb, pageId, media) {
|
|
13025
|
-
const key =
|
|
13102
|
+
const key = keyOf2(pageId);
|
|
13026
13103
|
if (media.length === 0 && stateDb.getMeta(key) === null) return;
|
|
13027
13104
|
stateDb.setMeta(key, JSON.stringify(media.map(rowOf)));
|
|
13028
13105
|
}
|
|
@@ -13030,14 +13107,14 @@ function rememberReceivedMedia(stateDb, pageId, media) {
|
|
|
13030
13107
|
if (media.length === 0) return;
|
|
13031
13108
|
const merged = recallUploadedMedia(stateDb, pageId);
|
|
13032
13109
|
for (const item of media) merged.set(item.fileId, item);
|
|
13033
|
-
stateDb.setMeta(
|
|
13110
|
+
stateDb.setMeta(keyOf2(pageId), JSON.stringify([...merged.values()].map(rowOf)));
|
|
13034
13111
|
}
|
|
13035
13112
|
function rowOf({ fileId, target, localPath, fileHash }) {
|
|
13036
13113
|
return fileHash === void 0 ? { fileId, target, localPath } : { fileId, target, localPath, fileHash };
|
|
13037
13114
|
}
|
|
13038
13115
|
function recallUploadedMedia(stateDb, pageId) {
|
|
13039
13116
|
const known = /* @__PURE__ */ new Map();
|
|
13040
|
-
const value = stateDb?.getMeta(
|
|
13117
|
+
const value = stateDb?.getMeta(keyOf2(pageId));
|
|
13041
13118
|
if (!value) return known;
|
|
13042
13119
|
try {
|
|
13043
13120
|
for (const row of JSON.parse(value)) {
|
|
@@ -13226,8 +13303,11 @@ function nameHashOf(fullHash) {
|
|
|
13226
13303
|
var IMAGE_STEM_MAX = 50;
|
|
13227
13304
|
function withoutOwnHash(name, hash) {
|
|
13228
13305
|
if (hash.length !== NAME_HASH_LENGTH || !/^[0-9a-f]+$/.test(hash)) return name;
|
|
13229
|
-
const
|
|
13230
|
-
|
|
13306
|
+
const tail = new RegExp(`-${hash}(\\.[^./]*)?$`).exec(name);
|
|
13307
|
+
const stem = tail ? name.slice(0, tail.index) : "";
|
|
13308
|
+
if (stem === "") return name;
|
|
13309
|
+
const extension = tail?.[1] ?? "";
|
|
13310
|
+
return extension && !stem.toLowerCase().endsWith(extension.toLowerCase()) ? `${stem}${extension}` : stem;
|
|
13231
13311
|
}
|
|
13232
13312
|
function imageStemOf(uploadedName, pageTitle) {
|
|
13233
13313
|
const stem = safeImageStem(uploadedName.replace(/\.[^.]*$/, ""));
|
|
@@ -13270,7 +13350,7 @@ function localEmbedTargets(note) {
|
|
|
13270
13350
|
}
|
|
13271
13351
|
return targets;
|
|
13272
13352
|
}
|
|
13273
|
-
function
|
|
13353
|
+
function folderOf2(notePath) {
|
|
13274
13354
|
return notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
|
|
13275
13355
|
}
|
|
13276
13356
|
function messageOf2(error) {
|
|
@@ -13371,7 +13451,7 @@ var ImageHandler = class {
|
|
|
13371
13451
|
if (!target || /^https?:\/\//.test(target) || target.includes("\n")) return null;
|
|
13372
13452
|
const vaultPath = stripAlias2(target);
|
|
13373
13453
|
if (baseName(vaultPath) !== uploadedName) return null;
|
|
13374
|
-
for (const candidate of this.vaultCandidates(vaultPath, noteFolder)) {
|
|
13454
|
+
for await (const candidate of this.vaultCandidates(vaultPath, noteFolder)) {
|
|
13375
13455
|
try {
|
|
13376
13456
|
if (await this.vaultFs.exists(candidate)) return target;
|
|
13377
13457
|
} catch {
|
|
@@ -13411,7 +13491,7 @@ var ImageHandler = class {
|
|
|
13411
13491
|
const spans = notionMediaSpans(markdown);
|
|
13412
13492
|
if (spans.length === 0) return markdown;
|
|
13413
13493
|
const known = pageId ? recallUploadedMedia(this.stateDb, pageId) : /* @__PURE__ */ new Map();
|
|
13414
|
-
const noteFolder =
|
|
13494
|
+
const noteFolder = folderOf2(notePath);
|
|
13415
13495
|
let result = markdown;
|
|
13416
13496
|
for (const span of spans) {
|
|
13417
13497
|
const uploadedName = this.uploadedNameOf(span.url);
|
|
@@ -13441,7 +13521,7 @@ var ImageHandler = class {
|
|
|
13441
13521
|
return null;
|
|
13442
13522
|
}
|
|
13443
13523
|
for (const target of targets) {
|
|
13444
|
-
for (const candidate of this.vaultCandidates(stripAlias2(target), noteFolder)) {
|
|
13524
|
+
for await (const candidate of this.vaultCandidates(stripAlias2(target), noteFolder)) {
|
|
13445
13525
|
let localHash;
|
|
13446
13526
|
try {
|
|
13447
13527
|
localHash = sha256Of(await this.vaultFs.readBinary(candidate));
|
|
@@ -13584,7 +13664,7 @@ var ImageHandler = class {
|
|
|
13584
13664
|
* @returns 보일 이름은 받을 때 붙인 내용 해시를 뗀 것이다 — 다시 받으면 같은 이름 · 같은 경로가 된다.
|
|
13585
13665
|
*/
|
|
13586
13666
|
async uploadNoteAttachment(target, notePath) {
|
|
13587
|
-
const uploaded = await this.uploadLocalImage(target,
|
|
13667
|
+
const uploaded = await this.uploadLocalImage(target, folderOf2(notePath));
|
|
13588
13668
|
const base = uploaded.localPath.split("/").pop() ?? uploaded.localPath;
|
|
13589
13669
|
const name = withoutOwnHash(base, nameHashOf(uploaded.fileHash));
|
|
13590
13670
|
const extension = /\.[^./]+$/.exec(base)?.[0] ?? "";
|
|
@@ -13597,7 +13677,7 @@ var ImageHandler = class {
|
|
|
13597
13677
|
if (!this.notionClient) return [];
|
|
13598
13678
|
const localImages = images.filter((img) => !img.isExternal && img.localPath);
|
|
13599
13679
|
if (localImages.length === 0) return [];
|
|
13600
|
-
const noteFolder =
|
|
13680
|
+
const noteFolder = folderOf2(notePath);
|
|
13601
13681
|
const results = [];
|
|
13602
13682
|
const imageBlocks = [];
|
|
13603
13683
|
for (const img of localImages) {
|
|
@@ -13684,7 +13764,7 @@ var ImageHandler = class {
|
|
|
13684
13764
|
}
|
|
13685
13765
|
return segment;
|
|
13686
13766
|
});
|
|
13687
|
-
const noteFolder =
|
|
13767
|
+
const noteFolder = folderOf2(notePath);
|
|
13688
13768
|
const kept = [];
|
|
13689
13769
|
let result = "";
|
|
13690
13770
|
let cursor = 0;
|
|
@@ -13775,7 +13855,7 @@ var ImageHandler = class {
|
|
|
13775
13855
|
if (kept.length > 0) this.rememberUploads(pageId, kept);
|
|
13776
13856
|
return empty;
|
|
13777
13857
|
}
|
|
13778
|
-
const noteFolder =
|
|
13858
|
+
const noteFolder = folderOf2(notePath);
|
|
13779
13859
|
const uploaded = [];
|
|
13780
13860
|
const remembered = [...kept];
|
|
13781
13861
|
const unsupported2 = [];
|
|
@@ -13933,29 +14013,37 @@ var ImageHandler = class {
|
|
|
13933
14013
|
/**
|
|
13934
14014
|
* 임베드 경로가 가리킬 수 있는 볼트 실제 파일 후보를 우선순위대로 만든다(F23).
|
|
13935
14015
|
* Obsidian 위키링크 임베드는 노트 옆 파일을 파일명만으로 참조하는 게 관례라
|
|
13936
|
-
* 노트 폴더 → 볼트 루트 → attachments/ 순.
|
|
14016
|
+
* 노트 폴더 → 볼트 루트 → attachments/ 순. 세 곳에 없으면 볼트 어디서든 이름으로 찾는다({@link VaultFileIndex}).
|
|
13937
14017
|
*
|
|
13938
14018
|
* push(업로드)와 pull(원본 복원)이 **같은 사다리**를 봐야 왕복이 닫힌다. 예전에는
|
|
13939
14019
|
* pull 쪽이 볼트 루트만 확인해서, `__e2e_probe__/노트.md` 안의 `![[t-img.png]]` 는
|
|
13940
14020
|
* 올라갈 때는 노트 폴더에서 잘 찾아 올리고도 내려올 때는 원본을 못 찾아 사본을
|
|
13941
14021
|
* 새로 받았다(실측) — 왕복할 때마다 첨부가 한 벌씩 늘어난다.
|
|
13942
14022
|
*/
|
|
13943
|
-
vaultCandidates(localPath, noteFolder) {
|
|
13944
|
-
const
|
|
14023
|
+
async *vaultCandidates(localPath, noteFolder) {
|
|
14024
|
+
const ladder = [];
|
|
13945
14025
|
if (noteFolder && !localPath.includes("/")) {
|
|
13946
|
-
|
|
14026
|
+
ladder.push(`${noteFolder}/${localPath}`);
|
|
14027
|
+
}
|
|
14028
|
+
ladder.push(localPath, `${this.attachmentFolder}/${localPath}`);
|
|
14029
|
+
yield* ladder;
|
|
14030
|
+
for (const found2 of await this.fileIndex.find(localPath, noteFolder)) {
|
|
14031
|
+
if (!ladder.includes(found2)) yield found2;
|
|
13947
14032
|
}
|
|
13948
|
-
|
|
13949
|
-
|
|
14033
|
+
}
|
|
14034
|
+
indexOfFiles = null;
|
|
14035
|
+
/** 볼트의 첨부 이름표 — 처음 쓸 때 만든다. */
|
|
14036
|
+
get fileIndex() {
|
|
14037
|
+
this.indexOfFiles ??= new VaultFileIndex(this.vaultFs);
|
|
14038
|
+
return this.indexOfFiles;
|
|
13950
14039
|
}
|
|
13951
14040
|
/**
|
|
13952
14041
|
* 임베드 경로를 볼트 실제 파일로 해석해 읽는다(F23).
|
|
13953
14042
|
* 폴백은 조용히 진행하고, 전부 실패했을 때만 마지막 오류를 던진다.
|
|
13954
14043
|
*/
|
|
13955
14044
|
async readImageFromVault(localPath, noteFolder) {
|
|
13956
|
-
const candidates = this.vaultCandidates(localPath, noteFolder);
|
|
13957
14045
|
let lastError;
|
|
13958
|
-
for (const candidate of
|
|
14046
|
+
for await (const candidate of this.vaultCandidates(localPath, noteFolder)) {
|
|
13959
14047
|
try {
|
|
13960
14048
|
return { buffer: await this.vaultFs.readBinary(candidate), resolvedPath: candidate };
|
|
13961
14049
|
} catch (error) {
|
|
@@ -14148,12 +14236,20 @@ var ImageHandler = class {
|
|
|
14148
14236
|
const hash = nameHashOf(fileHash);
|
|
14149
14237
|
const extension = this.guessFileExtension(url, response.headers.get("content-type"));
|
|
14150
14238
|
const name = withoutOwnHash(caption, hash);
|
|
14151
|
-
const
|
|
14152
|
-
|
|
14153
|
-
|
|
14154
|
-
)
|
|
14155
|
-
|
|
14156
|
-
|
|
14239
|
+
const hasExtension2 = extension !== "" && name.toLowerCase().endsWith(extension.toLowerCase());
|
|
14240
|
+
const stem = hasExtension2 ? name.slice(0, -extension.length) : name;
|
|
14241
|
+
const localPath = `${this.attachmentFolder}/${this.fileNameOf(stem, hash, extension)}`;
|
|
14242
|
+
const legacyPath = `${this.attachmentFolder}/${this.fileNameOf(name, hash, extension)}`;
|
|
14243
|
+
if (hasExtension2 && await this.vaultFs.exists(legacyPath)) {
|
|
14244
|
+
return {
|
|
14245
|
+
originalUrl: url,
|
|
14246
|
+
localPath: legacyPath,
|
|
14247
|
+
hash,
|
|
14248
|
+
size: buffer.length,
|
|
14249
|
+
fileHash,
|
|
14250
|
+
name
|
|
14251
|
+
};
|
|
14252
|
+
}
|
|
14157
14253
|
await this.vaultFs.ensureFolder(this.attachmentFolder);
|
|
14158
14254
|
await this.vaultFs.writeBinary(localPath, buffer);
|
|
14159
14255
|
return { originalUrl: url, localPath, hash, size: buffer.length, fileHash, name };
|
|
@@ -14163,6 +14259,14 @@ var ImageHandler = class {
|
|
|
14163
14259
|
}
|
|
14164
14260
|
throw lastError;
|
|
14165
14261
|
}
|
|
14262
|
+
/** 받은 파일의 이름 — `<줄기>-<해시><확장자>`. */
|
|
14263
|
+
fileNameOf(stem, hash, extension) {
|
|
14264
|
+
const safeName = truncateBytes(
|
|
14265
|
+
stem.replace(/[^a-zA-Z0-9가-힣\s._-]/g, "").replace(/\s+/g, "-").slice(0, 80),
|
|
14266
|
+
FILE_SYSTEM_NAME_MAX_BYTES - utf8Bytes(`-${hash}${extension}`)
|
|
14267
|
+
);
|
|
14268
|
+
return `${safeName}-${hash}${extension}`;
|
|
14269
|
+
}
|
|
14166
14270
|
isNotionImageUrl(url) {
|
|
14167
14271
|
return this.isNotionFileUrl(url);
|
|
14168
14272
|
}
|
|
@@ -15049,7 +15153,74 @@ function selectStaleDbArtifacts(files, localFolder, keepPaths) {
|
|
|
15049
15153
|
return stale;
|
|
15050
15154
|
}
|
|
15051
15155
|
|
|
15156
|
+
// src/sync/folder-files.ts
|
|
15157
|
+
function unescapeCaption(caption) {
|
|
15158
|
+
return caption.replace(/\\([\\`*_{}[\]()#+\-.!|~<>$&])/g, "$1").trim();
|
|
15159
|
+
}
|
|
15160
|
+
function folderFilesOf(markdown, namedOnly) {
|
|
15161
|
+
let rest = markdown.replace(CHILD_PAGE_TAG_RE, "").replace(CHILD_DATABASE_TAG_RE, "").replace(/<empty-block\/>/g, "");
|
|
15162
|
+
const files = [];
|
|
15163
|
+
for (const span of notionMediaSpans(rest)) {
|
|
15164
|
+
if (!/^https?:\/\//.test(span.url)) return null;
|
|
15165
|
+
const name = uploadedFileNameFromUrl(span.url);
|
|
15166
|
+
if (!name || namedOnly && unescapeCaption(span.label) !== name) return null;
|
|
15167
|
+
rest = rest.replace(span.full, "");
|
|
15168
|
+
files.push({ url: span.url, name });
|
|
15169
|
+
}
|
|
15170
|
+
return files.length > 0 && rest.trim() === "" ? files : null;
|
|
15171
|
+
}
|
|
15172
|
+
var WIKI_EMBED_RE = /!\[\[([^\]|#^\n]+)/g;
|
|
15173
|
+
var MD_EMBED_RE = /!\[[^\]\n]*\]\(<?([^)>\s]+)/g;
|
|
15174
|
+
var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
15175
|
+
var WIKILINK_TARGET_RE = /\[\[([^\]|#^\n]+)/g;
|
|
15176
|
+
function baseNameOf2(target) {
|
|
15177
|
+
let decoded = target.trim();
|
|
15178
|
+
try {
|
|
15179
|
+
decoded = decodeURI(decoded);
|
|
15180
|
+
} catch {
|
|
15181
|
+
}
|
|
15182
|
+
return decoded.slice(decoded.lastIndexOf("/") + 1).normalize("NFC");
|
|
15183
|
+
}
|
|
15184
|
+
function embeddedNamesOf(notes) {
|
|
15185
|
+
const names = /* @__PURE__ */ new Set();
|
|
15186
|
+
for (const text of notes) {
|
|
15187
|
+
for (const m of text.matchAll(WIKI_EMBED_RE)) names.add(baseNameOf2(m[1]));
|
|
15188
|
+
for (const m of text.matchAll(MD_EMBED_RE)) names.add(baseNameOf2(m[1]));
|
|
15189
|
+
const frontmatter = FRONTMATTER_RE.exec(text)?.[1];
|
|
15190
|
+
if (frontmatter) {
|
|
15191
|
+
for (const m of frontmatter.matchAll(WIKILINK_TARGET_RE)) names.add(baseNameOf2(m[1]));
|
|
15192
|
+
}
|
|
15193
|
+
}
|
|
15194
|
+
return names;
|
|
15195
|
+
}
|
|
15196
|
+
function keyOf3(pageId) {
|
|
15197
|
+
return `folder_files:${compactNotionId(pageId)}`;
|
|
15198
|
+
}
|
|
15199
|
+
function folderFileIdsOf(stateDb, pageId) {
|
|
15200
|
+
const raw = stateDb.getMeta(keyOf3(pageId));
|
|
15201
|
+
if (!raw) return /* @__PURE__ */ new Map();
|
|
15202
|
+
try {
|
|
15203
|
+
return new Map(Object.entries(JSON.parse(raw)));
|
|
15204
|
+
} catch {
|
|
15205
|
+
return /* @__PURE__ */ new Map();
|
|
15206
|
+
}
|
|
15207
|
+
}
|
|
15208
|
+
function forgetFolderFileId(stateDb, pageId, localPath) {
|
|
15209
|
+
const ids = folderFileIdsOf(stateDb, pageId);
|
|
15210
|
+
if (!ids.delete(localPath)) return;
|
|
15211
|
+
stateDb.setMeta(keyOf3(pageId), JSON.stringify(Object.fromEntries(ids)));
|
|
15212
|
+
}
|
|
15213
|
+
function rememberFolderFileId(stateDb, pageId, localPath, fileId) {
|
|
15214
|
+
const ids = folderFileIdsOf(stateDb, pageId);
|
|
15215
|
+
if (ids.get(localPath) === fileId) return;
|
|
15216
|
+
ids.set(localPath, fileId);
|
|
15217
|
+
stateDb.setMeta(keyOf3(pageId), JSON.stringify(Object.fromEntries(ids)));
|
|
15218
|
+
}
|
|
15219
|
+
|
|
15052
15220
|
// src/sync/file-handler.ts
|
|
15221
|
+
function isHiddenPath(path) {
|
|
15222
|
+
return path.split("/").some((part) => part.startsWith("."));
|
|
15223
|
+
}
|
|
15053
15224
|
function getFolderPath(filePath) {
|
|
15054
15225
|
const parts = filePath.split("/");
|
|
15055
15226
|
return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
|
|
@@ -15064,6 +15235,8 @@ var FileHandler = class {
|
|
|
15064
15235
|
this.customFetch = options?.fetch;
|
|
15065
15236
|
this.downloadTimeoutMs = options?.downloadTimeoutMs ?? DEFAULT_CONFIG.advanced.mediaDownloadTimeoutMs;
|
|
15066
15237
|
this.itemTimeoutMs = options?.itemTimeoutMs ?? DEFAULT_CONFIG.advanced.itemTimeoutMs;
|
|
15238
|
+
this.deleteSync = options?.deleteSync ?? false;
|
|
15239
|
+
this.attachmentsFolder = (options?.attachmentsFolder ?? DEFAULT_CONFIG.paths.attachments).replace(/^\/+|\/+$/g, "");
|
|
15067
15240
|
}
|
|
15068
15241
|
vaultFs;
|
|
15069
15242
|
notionClient;
|
|
@@ -15072,6 +15245,8 @@ var FileHandler = class {
|
|
|
15072
15245
|
customFetch;
|
|
15073
15246
|
downloadTimeoutMs;
|
|
15074
15247
|
itemTimeoutMs;
|
|
15248
|
+
attachmentsFolder;
|
|
15249
|
+
deleteSync;
|
|
15075
15250
|
get fetchFn() {
|
|
15076
15251
|
return this.customFetch ?? globalThis.fetch;
|
|
15077
15252
|
}
|
|
@@ -15081,7 +15256,9 @@ var FileHandler = class {
|
|
|
15081
15256
|
if (folderFiles.length === 0) return [];
|
|
15082
15257
|
const results = [];
|
|
15083
15258
|
for (const file of folderFiles) {
|
|
15084
|
-
const
|
|
15259
|
+
const existing = this.stateDb.getFileRegistry(file.path);
|
|
15260
|
+
if (existing && await this.isUnchanged(existing, file)) continue;
|
|
15261
|
+
const result = await this.pushSingleFile(file, folderPageId, existing);
|
|
15085
15262
|
if (result) results.push(result);
|
|
15086
15263
|
}
|
|
15087
15264
|
return results;
|
|
@@ -15102,18 +15279,11 @@ var FileHandler = class {
|
|
|
15102
15279
|
* 내면 같은 말이 두 번 나온다). 없으면 경고 로그로만 남는다.
|
|
15103
15280
|
* @returns 업로드 결과. 변경 없음(스킵)이거나 실패했으면 null.
|
|
15104
15281
|
*/
|
|
15105
|
-
async pushSingleFile(file, folderPageId, failed) {
|
|
15106
|
-
const existing = this.stateDb.getFileRegistry(file.path);
|
|
15107
|
-
if (existing) {
|
|
15108
|
-
if (existing.fileSize === file.size) return null;
|
|
15109
|
-
const buffer = await this.vaultFs.readBinary(file.path);
|
|
15110
|
-
const currentHash = createHash("sha256").update(buffer).digest("hex");
|
|
15111
|
-
if (existing.fileHash === currentHash) return null;
|
|
15112
|
-
}
|
|
15282
|
+
async pushSingleFile(file, folderPageId, existing, failed) {
|
|
15113
15283
|
await this.sema.acquire();
|
|
15114
15284
|
try {
|
|
15115
15285
|
return await withDeadline(
|
|
15116
|
-
() => this.uploadFile(file, folderPageId),
|
|
15286
|
+
() => this.uploadFile(file, folderPageId, existing),
|
|
15117
15287
|
this.itemTimeoutMs,
|
|
15118
15288
|
`\uCCA8\uBD80 \uC5C5\uB85C\uB4DC ${file.path}`
|
|
15119
15289
|
);
|
|
@@ -15134,6 +15304,22 @@ var FileHandler = class {
|
|
|
15134
15304
|
this.sema.release();
|
|
15135
15305
|
}
|
|
15136
15306
|
}
|
|
15307
|
+
/** 올린 뒤 바뀌지 않았나 — 크기가 같으면 읽지 않는다. */
|
|
15308
|
+
async isUnchanged(existing, file) {
|
|
15309
|
+
if (existing.fileSize === file.size) return true;
|
|
15310
|
+
const buffer = await this.vaultFs.readBinary(file.path);
|
|
15311
|
+
return existing.fileHash === createHash("sha256").update(buffer).digest("hex");
|
|
15312
|
+
}
|
|
15313
|
+
/** pull 이 받은 것을 두는 폴더 안인가. */
|
|
15314
|
+
inAttachmentsFolder(path) {
|
|
15315
|
+
return this.attachmentsFolder !== "" && path.startsWith(`${this.attachmentsFolder}/`);
|
|
15316
|
+
}
|
|
15317
|
+
/** 폴더 첨부를 둘 Notion 페이지 — 폴더 노트 없는 폴더의 페이지(폴더 레코드)만. 맨 위는 루트 기록이 있을 때만. */
|
|
15318
|
+
folderPageOf(folder) {
|
|
15319
|
+
const record = this.stateDb.getByPath(folder || "__root__");
|
|
15320
|
+
if (!record?.notionPageId) return null;
|
|
15321
|
+
return folder === "" || isFolderRecord(record) ? record.notionPageId : null;
|
|
15322
|
+
}
|
|
15137
15323
|
/**
|
|
15138
15324
|
* 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 아예 빼고, Notion 이 받지 않는
|
|
15139
15325
|
* 형식은 따로 모은다.
|
|
@@ -15160,7 +15346,7 @@ var FileHandler = class {
|
|
|
15160
15346
|
* @param failed 올리지 못한 첨부를 담을 곳 — push 결과의 `failed` 를 넘기면 CLI · 플러그인이 처리하지 못한 것으로
|
|
15161
15347
|
* 보인다. 예전엔 경고 로그로만 남아 push 가 「실패 0」 으로 끝났다.
|
|
15162
15348
|
*/
|
|
15163
|
-
async pushAllFiles(failed) {
|
|
15349
|
+
async pushAllFiles(failed, folders) {
|
|
15164
15350
|
const { uploadable: allFiles, unsupported: unsupported2 } = await this.listUploadableFiles();
|
|
15165
15351
|
if (unsupported2.length > 0) {
|
|
15166
15352
|
const names = unsupported2.slice(0, LOGGED_NAMES).map((f) => f.path);
|
|
@@ -15169,32 +15355,225 @@ var FileHandler = class {
|
|
|
15169
15355
|
`[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` : ""}`
|
|
15170
15356
|
);
|
|
15171
15357
|
}
|
|
15172
|
-
if (
|
|
15173
|
-
const
|
|
15358
|
+
if (this.deleteSync) await this.removeDeletedFolderFiles();
|
|
15359
|
+
const pending = [];
|
|
15174
15360
|
for (const file of allFiles) {
|
|
15175
|
-
|
|
15176
|
-
const
|
|
15177
|
-
|
|
15178
|
-
|
|
15361
|
+
if (this.inAttachmentsFolder(file.path) || isHiddenPath(file.path)) continue;
|
|
15362
|
+
const existing = this.stateDb.getFileRegistry(file.path);
|
|
15363
|
+
if (existing) {
|
|
15364
|
+
if (existing.notionPageId !== this.folderPageOf(getFolderPath(file.path))) continue;
|
|
15365
|
+
if (await this.isUnchanged(existing, file)) continue;
|
|
15366
|
+
}
|
|
15367
|
+
pending.push({ file, existing });
|
|
15368
|
+
}
|
|
15369
|
+
if (pending.length === 0) return [];
|
|
15370
|
+
const embedded = pending.some((p) => !p.existing) ? await this.embeddedNames() : /* @__PURE__ */ new Set();
|
|
15371
|
+
const byFolder = /* @__PURE__ */ new Map();
|
|
15372
|
+
for (const item of pending) {
|
|
15373
|
+
if (!item.existing && embedded.has(item.file.path.slice(item.file.path.lastIndexOf("/") + 1).normalize("NFC"))) {
|
|
15374
|
+
continue;
|
|
15375
|
+
}
|
|
15376
|
+
const folder = getFolderPath(item.file.path);
|
|
15377
|
+
const list = byFolder.get(folder) ?? [];
|
|
15378
|
+
list.push(item);
|
|
15379
|
+
byFolder.set(folder, list);
|
|
15179
15380
|
}
|
|
15180
15381
|
const results = [];
|
|
15181
|
-
|
|
15182
|
-
|
|
15183
|
-
const
|
|
15184
|
-
if (!
|
|
15185
|
-
|
|
15186
|
-
|
|
15187
|
-
|
|
15382
|
+
const stranded = [];
|
|
15383
|
+
for (const [folder, items] of byFolder) {
|
|
15384
|
+
const pageId = await this.provideFolderPage(folder, folders, items, stranded);
|
|
15385
|
+
if (!pageId) continue;
|
|
15386
|
+
for (const { file, existing } of items) {
|
|
15387
|
+
const result = await this.pushSingleFile(file, pageId, existing, failed);
|
|
15388
|
+
if (result) results.push(result);
|
|
15389
|
+
}
|
|
15390
|
+
}
|
|
15391
|
+
this.reportStranded(stranded);
|
|
15392
|
+
return results;
|
|
15393
|
+
}
|
|
15394
|
+
/**
|
|
15395
|
+
* 볼트에서 지운 폴더 첨부를 Notion 에서도 지운다(deleteSync) — 폴더 페이지에 적힌 파일 가운데 볼트에 없는 것의 블록과 기록.
|
|
15396
|
+
* 포함 · 제외 패턴으로 빠진 파일은 볼트에 있어 지우지 않는다.
|
|
15397
|
+
*/
|
|
15398
|
+
async removeDeletedFolderFiles() {
|
|
15399
|
+
const removed = [];
|
|
15400
|
+
for (const record of this.stateDb.getAll()) {
|
|
15401
|
+
if (!record.notionPageId || !isFolderRecord(record)) continue;
|
|
15402
|
+
for (const entry of this.stateDb.getFilesByPageId(record.notionPageId)) {
|
|
15403
|
+
if (getFolderPath(entry.localPath) !== record.obsidianPath) continue;
|
|
15404
|
+
if (await this.vaultFs.exists(entry.localPath)) continue;
|
|
15405
|
+
try {
|
|
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)}`
|
|
15188
15414
|
);
|
|
15189
15415
|
}
|
|
15416
|
+
}
|
|
15417
|
+
}
|
|
15418
|
+
if (removed.length > 0) {
|
|
15419
|
+
getLogger().info(
|
|
15420
|
+
`[Im-Nobsidian] \uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80 ${removed.length}\uAC1C\uB97C Notion \uC5D0\uC11C \uC9C0\uC6C0`
|
|
15421
|
+
);
|
|
15422
|
+
}
|
|
15423
|
+
}
|
|
15424
|
+
/** 폴더 첨부를 둘 페이지 — 노트 없는 폴더면 마련한다. 둘 곳이 없으면 그 파일들을 `stranded` 에 담고 null. */
|
|
15425
|
+
async provideFolderPage(folder, folders, items, stranded) {
|
|
15426
|
+
let pageId = this.folderPageOf(folder);
|
|
15427
|
+
let reason = folder === "" ? "\uBCFC\uD2B8 \uB9E8 \uC704" : "\uD3F4\uB354 \uB178\uD2B8\uAC00 \uC788\uB294 \uD3F4\uB354 \xB7 DB \uD3F4\uB354";
|
|
15428
|
+
if (!pageId && folder !== "" && folders && !this.stateDb.getByPath(folder)) {
|
|
15429
|
+
try {
|
|
15430
|
+
await folders.ensureFolderPage(folder);
|
|
15431
|
+
pageId = this.folderPageOf(folder);
|
|
15432
|
+
} catch (error) {
|
|
15433
|
+
reason = error instanceof Error ? error.message : String(error);
|
|
15434
|
+
}
|
|
15435
|
+
}
|
|
15436
|
+
if (!pageId) stranded.push(...items.map(({ file }) => ({ path: file.path, reason })));
|
|
15437
|
+
return pageId;
|
|
15438
|
+
}
|
|
15439
|
+
/**
|
|
15440
|
+
* 둘 곳이 없어 올리지 않은 폴더 첨부 — 경고로 알린다. 예전에는 디버그 로그로만 남아 파일이 Notion 에 가지 않은 것을 알 수
|
|
15441
|
+
* 없었다. 노트에 붙이면 그 노트와 함께 올라간다.
|
|
15442
|
+
*/
|
|
15443
|
+
reportStranded(stranded) {
|
|
15444
|
+
if (stranded.length === 0) return;
|
|
15445
|
+
const names = stranded.slice(0, LOGGED_NAMES).map((f) => `${f.path}(${f.reason})`);
|
|
15446
|
+
const rest = stranded.length - names.length;
|
|
15447
|
+
getLogger().warn(
|
|
15448
|
+
`[Im-Nobsidian] \uD3F4\uB354\uC5D0 Notion \uC790\uB9AC\uAC00 \uC5C6\uC5B4 \uC62C\uB9AC\uC9C0 \uC54A\uC740 \uCCA8\uBD80 ${stranded.length}\uAC1C \u2014 \uB178\uD2B8\uC5D0 \uBD99\uC774\uBA74 \uD568\uAED8 \uC62C\uB77C\uAC04\uB2E4: ${names.join(", ")}${rest > 0 ? ` \uC678 ${rest}\uAC1C` : ""}`
|
|
15449
|
+
);
|
|
15450
|
+
}
|
|
15451
|
+
/** 볼트의 노트가 붙인 파일 이름 — {@link embeddedNamesOf}. 읽지 못한 노트는 건너뛴다. */
|
|
15452
|
+
async embeddedNames() {
|
|
15453
|
+
const texts = [];
|
|
15454
|
+
for (const note of await this.vaultFs.listMarkdownFiles()) {
|
|
15455
|
+
try {
|
|
15456
|
+
texts.push(await this.vaultFs.readFile(note.path));
|
|
15457
|
+
} catch {
|
|
15458
|
+
}
|
|
15459
|
+
}
|
|
15460
|
+
return embeddedNamesOf(texts);
|
|
15461
|
+
}
|
|
15462
|
+
/**
|
|
15463
|
+
* 폴더 페이지의 폴더 첨부를 그 폴더에 받는다 — 올린 이름 그대로. Notion 파일 id 가 적어 둔 것과 같으면(올린 볼트 · 앞서
|
|
15464
|
+
* 받은 볼트) 받지 않는다. Notion 에서 바뀐 파일은 볼트 쪽이 그대로일 때만 덮는다 — 볼트에서도 고쳤으면 두고 알린다(다음
|
|
15465
|
+
* push 가 볼트의 것을 올린다).
|
|
15466
|
+
*
|
|
15467
|
+
* @param complete 본문을 끝까지 받았나 — 그럴 때만 Notion 에서 지운 파일을 볼트에서 지운다(deleteSync). 잘린 본문에 없는
|
|
15468
|
+
* 파일은 지운 것이 아니다.
|
|
15469
|
+
* @returns 새로 받거나 바꾼 파일 수
|
|
15470
|
+
*/
|
|
15471
|
+
async receiveFolderFiles(folder, pageId, files, complete2 = false) {
|
|
15472
|
+
const known = new Map(
|
|
15473
|
+
this.stateDb.getFilesByPageId(pageId).map((entry) => [entry.localPath, entry])
|
|
15474
|
+
);
|
|
15475
|
+
const ids = folderFileIdsOf(this.stateDb, pageId);
|
|
15476
|
+
let received = 0;
|
|
15477
|
+
for (const file of files) {
|
|
15478
|
+
const localPath = folder ? `${folder}/${file.name}` : file.name;
|
|
15479
|
+
const fileId = notionFileIdOf(file.url);
|
|
15480
|
+
const entry = known.get(localPath);
|
|
15481
|
+
if (entry && fileId && ids.get(localPath) === fileId) continue;
|
|
15482
|
+
if (entry && fileId && !ids.has(localPath)) {
|
|
15483
|
+
rememberFolderFileId(this.stateDb, pageId, localPath, fileId);
|
|
15190
15484
|
continue;
|
|
15191
15485
|
}
|
|
15192
|
-
|
|
15193
|
-
|
|
15194
|
-
|
|
15486
|
+
if (entry && !fileId) continue;
|
|
15487
|
+
await this.sema.acquire();
|
|
15488
|
+
try {
|
|
15489
|
+
if (await this.receiveOne(localPath, folder, pageId, file, entry ?? null)) received++;
|
|
15490
|
+
if (fileId) rememberFolderFileId(this.stateDb, pageId, localPath, fileId);
|
|
15491
|
+
} catch (error) {
|
|
15492
|
+
getLogger().warn(
|
|
15493
|
+
`[Im-Nobsidian] \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBC1B\uC9C0 \uBABB\uD568 (${localPath}): ${error instanceof Error ? error.message : String(error)}`
|
|
15494
|
+
);
|
|
15495
|
+
} finally {
|
|
15496
|
+
this.sema.release();
|
|
15195
15497
|
}
|
|
15196
15498
|
}
|
|
15197
|
-
|
|
15499
|
+
if (complete2 && this.deleteSync) {
|
|
15500
|
+
const names = new Set(files.map((file) => file.name));
|
|
15501
|
+
for (const entry of known.values()) {
|
|
15502
|
+
const name = entry.localPath.slice(entry.localPath.lastIndexOf("/") + 1);
|
|
15503
|
+
if (getFolderPath(entry.localPath) !== folder || names.has(name)) continue;
|
|
15504
|
+
if (!ids.has(entry.localPath)) continue;
|
|
15505
|
+
await this.removeLocalFolderFile(pageId, entry);
|
|
15506
|
+
}
|
|
15507
|
+
}
|
|
15508
|
+
return received;
|
|
15509
|
+
}
|
|
15510
|
+
/**
|
|
15511
|
+
* Notion 에서 지운 폴더 첨부를 볼트에서 지운다 — 지난 동기화 그대로일 때만. 볼트에서 고쳤으면 두고 기록만 놓는다 — 다음 push
|
|
15512
|
+
* 가 다시 올린다(노트의 원격 삭제와 같은 규칙).
|
|
15513
|
+
*/
|
|
15514
|
+
async removeLocalFolderFile(pageId, entry) {
|
|
15515
|
+
try {
|
|
15516
|
+
if (await this.vaultFs.exists(entry.localPath)) {
|
|
15517
|
+
const local = createHash("sha256").update(await this.vaultFs.readBinary(entry.localPath)).digest("hex");
|
|
15518
|
+
if (local === entry.fileHash) {
|
|
15519
|
+
await this.vaultFs.deleteFile(entry.localPath);
|
|
15520
|
+
getLogger().info(
|
|
15521
|
+
`[Im-Nobsidian] Notion \uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6C0: ${entry.localPath}`
|
|
15522
|
+
);
|
|
15523
|
+
} else {
|
|
15524
|
+
getLogger().warn(
|
|
15525
|
+
`[Im-Nobsidian] Notion \uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uACE0\uCCD0 \uB450\uC5C8\uC74C \u2014 \uB2E4\uC74C push \uAC00 \uB2E4\uC2DC \uC62C\uB9B0\uB2E4: ${entry.localPath}`
|
|
15526
|
+
);
|
|
15527
|
+
}
|
|
15528
|
+
}
|
|
15529
|
+
this.stateDb.deleteFileRegistry(entry.localPath);
|
|
15530
|
+
forgetFolderFileId(this.stateDb, pageId, entry.localPath);
|
|
15531
|
+
} catch (error) {
|
|
15532
|
+
getLogger().warn(
|
|
15533
|
+
`[Im-Nobsidian] Notion \uC5D0\uC11C \uC9C0\uC6B4 \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B0\uC9C0 \uBABB\uD568 (${entry.localPath}): ${error instanceof Error ? error.message : String(error)}`
|
|
15534
|
+
);
|
|
15535
|
+
}
|
|
15536
|
+
}
|
|
15537
|
+
/**
|
|
15538
|
+
* 폴더 첨부 하나를 받는다. 볼트에 같은 경로가 있으면: 같은 내용이면 적기만 하고, 지난 동기화 뒤 볼트에서 그대로인 파일은
|
|
15539
|
+
* 덮고(`entry`), 그 밖(볼트에서 고친 것 · 처음 보는 다른 파일)은 두고 알린다.
|
|
15540
|
+
*
|
|
15541
|
+
* @returns 볼트에 썼는가
|
|
15542
|
+
*/
|
|
15543
|
+
async receiveOne(localPath, folder, pageId, file, entry) {
|
|
15544
|
+
const response = await fetchForDownload(this.fetchFn, file.url, this.downloadTimeoutMs);
|
|
15545
|
+
if (!response.ok) {
|
|
15546
|
+
throw new Error(`\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
|
|
15547
|
+
}
|
|
15548
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
15549
|
+
const hash = createHash("sha256").update(buffer).digest("hex");
|
|
15550
|
+
let wrote = false;
|
|
15551
|
+
if (await this.vaultFs.exists(localPath)) {
|
|
15552
|
+
const localHash = createHash("sha256").update(await this.vaultFs.readBinary(localPath)).digest("hex");
|
|
15553
|
+
if (localHash !== hash) {
|
|
15554
|
+
if (entry?.fileHash !== localHash) {
|
|
15555
|
+
getLogger().warn(
|
|
15556
|
+
`[Im-Nobsidian] \uD3F4\uB354 \uCCA8\uBD80\uB97C \uBC1B\uC9C0 \uC54A\uC74C \u2014 \uBCFC\uD2B8\uC758 ${localPath} \uAC00 Notion \uC758 \uAC83\uACFC \uB2E4\uB974\uB2E4${entry ? "(\uC591\uCABD\uC5D0\uC11C \uACE0\uCE68 \u2014 \uB2E4\uC74C push \uAC00 \uBCFC\uD2B8\uC758 \uAC83\uC744 \uC62C\uB9B0\uB2E4)" : "(\uAC19\uC740 \uC774\uB984\uC758 \uB2E4\uB978 \uD30C\uC77C)"}`
|
|
15557
|
+
);
|
|
15558
|
+
return false;
|
|
15559
|
+
}
|
|
15560
|
+
await this.vaultFs.writeBinary(localPath, buffer);
|
|
15561
|
+
wrote = true;
|
|
15562
|
+
}
|
|
15563
|
+
} else {
|
|
15564
|
+
await this.vaultFs.ensureFolder(folder || ".");
|
|
15565
|
+
await this.vaultFs.writeBinary(localPath, buffer);
|
|
15566
|
+
wrote = true;
|
|
15567
|
+
}
|
|
15568
|
+
this.stateDb.registerFile({
|
|
15569
|
+
localPath,
|
|
15570
|
+
notionPageId: pageId,
|
|
15571
|
+
fileUploadId: "",
|
|
15572
|
+
fileType: getBlockType(file.name),
|
|
15573
|
+
fileHash: hash,
|
|
15574
|
+
fileSize: buffer.length
|
|
15575
|
+
});
|
|
15576
|
+
return wrote;
|
|
15198
15577
|
}
|
|
15199
15578
|
async downloadFileBlock(url, filename, targetFolder) {
|
|
15200
15579
|
const response = await fetchForDownload(this.fetchFn, url, this.downloadTimeoutMs);
|
|
@@ -15225,7 +15604,7 @@ var FileHandler = class {
|
|
|
15225
15604
|
}
|
|
15226
15605
|
return results;
|
|
15227
15606
|
}
|
|
15228
|
-
async uploadFile(file, parentPageId) {
|
|
15607
|
+
async uploadFile(file, parentPageId, previous) {
|
|
15229
15608
|
const buffer = await this.vaultFs.readBinary(file.path);
|
|
15230
15609
|
const filename = file.path.split("/").pop() ?? "file";
|
|
15231
15610
|
const contentType = getMimeType(filename);
|
|
@@ -15233,8 +15612,12 @@ var FileHandler = class {
|
|
|
15233
15612
|
const hash = createHash("sha256").update(buffer).digest("hex");
|
|
15234
15613
|
const blob = new Blob([buffer], { type: contentType });
|
|
15235
15614
|
const fileUploadId = await this.notionClient.uploadFile(blob, filename, contentType);
|
|
15236
|
-
const block = this.createFileBlock(blockType, fileUploadId);
|
|
15237
|
-
await this.notionClient.
|
|
15615
|
+
const block = this.createFileBlock(blockType, fileUploadId, filename);
|
|
15616
|
+
const [created] = await this.notionClient.appendChildBlocks(parentPageId, [block]);
|
|
15617
|
+
if (previous) await this.removeOlderBlocks(parentPageId, filename, created?.id);
|
|
15618
|
+
const url = created ? fileUrlOfBlock(created) : null;
|
|
15619
|
+
const fileId = url ? notionFileIdOf(url) : null;
|
|
15620
|
+
if (fileId) rememberFolderFileId(this.stateDb, parentPageId, file.path, fileId);
|
|
15238
15621
|
this.stateDb.registerFile({
|
|
15239
15622
|
localPath: file.path,
|
|
15240
15623
|
notionPageId: parentPageId,
|
|
@@ -15246,10 +15629,25 @@ var FileHandler = class {
|
|
|
15246
15629
|
getLogger().info(`\uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC: ${file.path} \u2192 ${blockType} \uBE14\uB85D`);
|
|
15247
15630
|
return { localPath: file.path, fileUploadId, blockType };
|
|
15248
15631
|
}
|
|
15249
|
-
|
|
15632
|
+
/** 이 페이지에서 같은 이름의 파일 블록 중 방금 붙인 것 말고를 지운다. */
|
|
15633
|
+
async removeOlderBlocks(pageId, name, keep) {
|
|
15634
|
+
for (const block of await this.notionClient.fetchAllChildren(pageId)) {
|
|
15635
|
+
if (block.id === keep) continue;
|
|
15636
|
+
const media = block[block.type];
|
|
15637
|
+
const url = media?.type === "file" ? media.file?.url : void 0;
|
|
15638
|
+
if (!url || uploadedFileNameFromUrl(url) !== name) continue;
|
|
15639
|
+
await this.notionClient.deleteBlock(block.id);
|
|
15640
|
+
}
|
|
15641
|
+
}
|
|
15642
|
+
/**
|
|
15643
|
+
* 폴더 첨부의 파일 블록 — 캡션에 파일 이름을 적는다. 받는 쪽이 그 표시로 폴더 첨부를 알아본다({@link folderFilesOf}) —
|
|
15644
|
+
* 자식 페이지가 없는 폴더 페이지는 PDF 하나만 붙인 노트와 모양이 같다.
|
|
15645
|
+
*/
|
|
15646
|
+
createFileBlock(blockType, fileUploadId, name) {
|
|
15250
15647
|
const fileRef = {
|
|
15251
15648
|
type: "file_upload",
|
|
15252
|
-
file_upload: { id: fileUploadId }
|
|
15649
|
+
file_upload: { id: fileUploadId },
|
|
15650
|
+
caption: [{ type: "text", text: { content: name } }]
|
|
15253
15651
|
};
|
|
15254
15652
|
switch (blockType) {
|
|
15255
15653
|
case "image":
|
|
@@ -15295,6 +15693,77 @@ async function readLocalNote(vaultFs, path) {
|
|
|
15295
15693
|
}
|
|
15296
15694
|
}
|
|
15297
15695
|
|
|
15696
|
+
// src/utils/db-row-path.ts
|
|
15697
|
+
function selectDbRowFiles(files, localFolder) {
|
|
15698
|
+
const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
|
|
15699
|
+
return files.filter((file) => {
|
|
15700
|
+
if (!file.path.startsWith(prefix)) return false;
|
|
15701
|
+
const rel = file.path.slice(prefix.length);
|
|
15702
|
+
return rel.length > 0 && !rel.includes("/");
|
|
15703
|
+
});
|
|
15704
|
+
}
|
|
15705
|
+
var PAGE_ID_SUFFIX_LENGTHS = [8, 16, 32];
|
|
15706
|
+
var PAGE_ID_SUFFIX = new RegExp(
|
|
15707
|
+
` \\((?:${PAGE_ID_SUFFIX_LENGTHS.map((length) => `[0-9a-f]{${length}}`).join("|")})\\)$`,
|
|
15708
|
+
"i"
|
|
15709
|
+
);
|
|
15710
|
+
function withoutPageIdSuffix(name) {
|
|
15711
|
+
return name.replace(PAGE_ID_SUFFIX, "");
|
|
15712
|
+
}
|
|
15713
|
+
function pagePathCandidates(dir, safeName, pageId) {
|
|
15714
|
+
const base = dir ? `${dir}/${safeName}` : safeName;
|
|
15715
|
+
const raw = pageId.replace(/-/g, "");
|
|
15716
|
+
return [
|
|
15717
|
+
`${base}.md`,
|
|
15718
|
+
...PAGE_ID_SUFFIX_LENGTHS.map((len) => `${base} (${raw.slice(0, len)}).md`)
|
|
15719
|
+
];
|
|
15720
|
+
}
|
|
15721
|
+
function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
|
|
15722
|
+
const ownedBySelf = (candidate) => {
|
|
15723
|
+
const owner = lookupByPath(candidate);
|
|
15724
|
+
if (!owner) return true;
|
|
15725
|
+
return owner.notionPageId != null && notionIdsEqual(owner.notionPageId, pageId);
|
|
15726
|
+
};
|
|
15727
|
+
const candidates = pagePathCandidates(localFolder, safeName, pageId);
|
|
15728
|
+
return candidates.find(ownedBySelf) ?? candidates[candidates.length - 1];
|
|
15729
|
+
}
|
|
15730
|
+
function inClaimOrder(items, chosen, idOf2) {
|
|
15731
|
+
const slots = items.flatMap((item, index) => chosen(item) ? [index] : []);
|
|
15732
|
+
const key = (item) => compactNotionId(idOf2(item));
|
|
15733
|
+
const members = slots.map((index) => items[index]).sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
|
|
15734
|
+
const ordered = [...items];
|
|
15735
|
+
slots.forEach((slot, k) => ordered[slot] = members[k]);
|
|
15736
|
+
return ordered;
|
|
15737
|
+
}
|
|
15738
|
+
|
|
15739
|
+
// src/utils/path-key.ts
|
|
15740
|
+
function pathKey(path) {
|
|
15741
|
+
return path.normalize("NFC").toLowerCase();
|
|
15742
|
+
}
|
|
15743
|
+
function foldedOwnerOf(stateDb, candidate, pageId) {
|
|
15744
|
+
return stateDb.getByPathKey(candidate).some(
|
|
15745
|
+
(record) => record.obsidianPath !== candidate && !(record.notionPageId != null && notionIdsEqual(record.notionPageId, pageId))
|
|
15746
|
+
);
|
|
15747
|
+
}
|
|
15748
|
+
|
|
15749
|
+
// src/sync/untracked-note.ts
|
|
15750
|
+
async function findUntrackedNote(stateDb, vaultFs, dir, safeName, pageId, taken) {
|
|
15751
|
+
const [natural, ...suffixed] = pagePathCandidates(dir, safeName, pageId);
|
|
15752
|
+
const order = [
|
|
15753
|
+
...suffixed.map((path) => ({ path, existing: "own" })),
|
|
15754
|
+
{ path: natural, existing: "name" }
|
|
15755
|
+
];
|
|
15756
|
+
for (const candidate of order) {
|
|
15757
|
+
if (taken(pathKey(candidate.path)) || stateDb.getByPath(candidate.path)) continue;
|
|
15758
|
+
if (foldedOwnerOf(stateDb, candidate.path, pageId)) continue;
|
|
15759
|
+
if (await vaultFs.exists(candidate.path)) return candidate;
|
|
15760
|
+
}
|
|
15761
|
+
return null;
|
|
15762
|
+
}
|
|
15763
|
+
function relinksWithoutContent(note, unlinkedVault) {
|
|
15764
|
+
return note.existing === "own" || unlinkedVault;
|
|
15765
|
+
}
|
|
15766
|
+
|
|
15298
15767
|
// src/conflict/merger.ts
|
|
15299
15768
|
function threeWayMerge(base, local, remote) {
|
|
15300
15769
|
const baseLines = base.split("\n");
|
|
@@ -15760,49 +16229,6 @@ function withUnsentProperties(content, keys, before) {
|
|
|
15760
16229
|
return (lines && replaceFrontmatterLines(content, lines)) ?? content;
|
|
15761
16230
|
}
|
|
15762
16231
|
|
|
15763
|
-
// src/utils/db-row-path.ts
|
|
15764
|
-
function selectDbRowFiles(files, localFolder) {
|
|
15765
|
-
const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
|
|
15766
|
-
return files.filter((file) => {
|
|
15767
|
-
if (!file.path.startsWith(prefix)) return false;
|
|
15768
|
-
const rel = file.path.slice(prefix.length);
|
|
15769
|
-
return rel.length > 0 && !rel.includes("/");
|
|
15770
|
-
});
|
|
15771
|
-
}
|
|
15772
|
-
var PAGE_ID_SUFFIX_LENGTHS = [8, 16, 32];
|
|
15773
|
-
var PAGE_ID_SUFFIX = new RegExp(
|
|
15774
|
-
` \\((?:${PAGE_ID_SUFFIX_LENGTHS.map((length) => `[0-9a-f]{${length}}`).join("|")})\\)$`,
|
|
15775
|
-
"i"
|
|
15776
|
-
);
|
|
15777
|
-
function withoutPageIdSuffix(name) {
|
|
15778
|
-
return name.replace(PAGE_ID_SUFFIX, "");
|
|
15779
|
-
}
|
|
15780
|
-
function pagePathCandidates(dir, safeName, pageId) {
|
|
15781
|
-
const base = dir ? `${dir}/${safeName}` : safeName;
|
|
15782
|
-
const raw = pageId.replace(/-/g, "");
|
|
15783
|
-
return [
|
|
15784
|
-
`${base}.md`,
|
|
15785
|
-
...PAGE_ID_SUFFIX_LENGTHS.map((len) => `${base} (${raw.slice(0, len)}).md`)
|
|
15786
|
-
];
|
|
15787
|
-
}
|
|
15788
|
-
function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
|
|
15789
|
-
const ownedBySelf = (candidate) => {
|
|
15790
|
-
const owner = lookupByPath(candidate);
|
|
15791
|
-
if (!owner) return true;
|
|
15792
|
-
return owner.notionPageId != null && notionIdsEqual(owner.notionPageId, pageId);
|
|
15793
|
-
};
|
|
15794
|
-
const candidates = pagePathCandidates(localFolder, safeName, pageId);
|
|
15795
|
-
return candidates.find(ownedBySelf) ?? candidates[candidates.length - 1];
|
|
15796
|
-
}
|
|
15797
|
-
function inClaimOrder(items, chosen, idOf2) {
|
|
15798
|
-
const slots = items.flatMap((item, index) => chosen(item) ? [index] : []);
|
|
15799
|
-
const key = (item) => compactNotionId(idOf2(item));
|
|
15800
|
-
const members = slots.map((index) => items[index]).sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
|
|
15801
|
-
const ordered = [...items];
|
|
15802
|
-
slots.forEach((slot, k) => ordered[slot] = members[k]);
|
|
15803
|
-
return ordered;
|
|
15804
|
-
}
|
|
15805
|
-
|
|
15806
16232
|
// src/sync/note-title.ts
|
|
15807
16233
|
function explicitTitle(frontmatter) {
|
|
15808
16234
|
const title = frontmatter.title;
|
|
@@ -18770,16 +19196,6 @@ function joinPath(folder, name) {
|
|
|
18770
19196
|
return folder === "" ? name : `${folder}/${name}`;
|
|
18771
19197
|
}
|
|
18772
19198
|
|
|
18773
|
-
// src/utils/path-key.ts
|
|
18774
|
-
function pathKey(path) {
|
|
18775
|
-
return path.normalize("NFC").toLowerCase();
|
|
18776
|
-
}
|
|
18777
|
-
function foldedOwnerOf(stateDb, candidate, pageId) {
|
|
18778
|
-
return stateDb.getByPathKey(candidate).some(
|
|
18779
|
-
(record) => record.obsidianPath !== candidate && !(record.notionPageId != null && notionIdsEqual(record.notionPageId, pageId))
|
|
18780
|
-
);
|
|
18781
|
-
}
|
|
18782
|
-
|
|
18783
19199
|
// src/converter/unknown-tag-fallback.ts
|
|
18784
19200
|
var UNKNOWN_BLOCK_TAG_RE2 = /<unknown (url|id)="([^"]*)"[^>]*\/>/g;
|
|
18785
19201
|
var UNKNOWN_MENTION_TAG_RE = /<unknown_mention\s+url="([^"]*)"[^>]*?\/>/g;
|
|
@@ -19114,7 +19530,7 @@ var PageDecorations = class {
|
|
|
19114
19530
|
}
|
|
19115
19531
|
case "local":
|
|
19116
19532
|
try {
|
|
19117
|
-
const uploaded = await this.imageHandler.uploadLocalImage(source.target,
|
|
19533
|
+
const uploaded = await this.imageHandler.uploadLocalImage(source.target, folderOf3(path));
|
|
19118
19534
|
return { type: "file_upload", file_upload: { id: uploaded.fileUploadId } };
|
|
19119
19535
|
} catch (error) {
|
|
19120
19536
|
getLogger().warn(
|
|
@@ -19159,7 +19575,7 @@ function sameText(a, b) {
|
|
|
19159
19575
|
if (isEmptyFrontmatterValue(before) && isEmptyFrontmatterValue(after)) return true;
|
|
19160
19576
|
return String(before) === String(after);
|
|
19161
19577
|
}
|
|
19162
|
-
function
|
|
19578
|
+
function folderOf3(notePath) {
|
|
19163
19579
|
return notePath.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
|
|
19164
19580
|
}
|
|
19165
19581
|
|
|
@@ -19455,7 +19871,13 @@ var DatabaseSyncer = class {
|
|
|
19455
19871
|
const target = await this.queryRows(dbConfig, opts);
|
|
19456
19872
|
if (target.kind !== "rows") return [];
|
|
19457
19873
|
const folderMove = opts?.followPlacement && !opts.paths ? await this.unblocked(this.placementMoveOf(dbConfig, target.meta, opts.movingInVault)) : null;
|
|
19458
|
-
const planned = await this.planRows(
|
|
19874
|
+
const planned = await this.planRows(
|
|
19875
|
+
dbConfig,
|
|
19876
|
+
target,
|
|
19877
|
+
opts?.paths,
|
|
19878
|
+
opts?.movingInVault,
|
|
19879
|
+
opts?.relinkTaken
|
|
19880
|
+
);
|
|
19459
19881
|
if (!folderMove) return planned;
|
|
19460
19882
|
const moved = (path) => within(path, folderMove.from) ? folderMove.to + path.slice(folderMove.from.length) : path;
|
|
19461
19883
|
return [
|
|
@@ -19534,7 +19956,7 @@ var DatabaseSyncer = class {
|
|
|
19534
19956
|
return false;
|
|
19535
19957
|
}
|
|
19536
19958
|
}
|
|
19537
|
-
async planRows(dbConfig, target, paths, movingInVault = /* @__PURE__ */ new Set()) {
|
|
19959
|
+
async planRows(dbConfig, target, paths, movingInVault = /* @__PURE__ */ new Set(), relinkTaken = /* @__PURE__ */ new Set()) {
|
|
19538
19960
|
const opts = { paths };
|
|
19539
19961
|
const planned = [];
|
|
19540
19962
|
const claimed = /* @__PURE__ */ new Map();
|
|
@@ -19555,6 +19977,24 @@ var DatabaseSyncer = class {
|
|
|
19555
19977
|
if (action === "skip") continue;
|
|
19556
19978
|
if (!record) {
|
|
19557
19979
|
const name = sanitizeFileName(this.notionClient.extractTitle(page));
|
|
19980
|
+
const untracked = await findUntrackedNote(
|
|
19981
|
+
this.stateDb,
|
|
19982
|
+
this.vaultFs,
|
|
19983
|
+
dbConfig.localFolder,
|
|
19984
|
+
name,
|
|
19985
|
+
page.id,
|
|
19986
|
+
(key) => relinkTaken.has(key) || claimed.has(key)
|
|
19987
|
+
);
|
|
19988
|
+
if (untracked && relinksWithoutContent(untracked, this.unlinkedVault)) {
|
|
19989
|
+
relinkTaken.add(pathKey(untracked.path));
|
|
19990
|
+
planned.push({
|
|
19991
|
+
pageId: page.id,
|
|
19992
|
+
path: untracked.path,
|
|
19993
|
+
operation: "create",
|
|
19994
|
+
relink: true
|
|
19995
|
+
});
|
|
19996
|
+
continue;
|
|
19997
|
+
}
|
|
19558
19998
|
const ownerOf = await this.rowPathOwners(dbConfig.localFolder, name, page.id);
|
|
19559
19999
|
const path = resolveDbRowPath(dbConfig.localFolder, name, page.id, (p) => {
|
|
19560
20000
|
const owner = claimed.get(pathKey(p));
|
|
@@ -20370,20 +20810,16 @@ var DatabaseSyncer = class {
|
|
|
20370
20810
|
* `claimExistingNote` 와 같은 규칙이다.
|
|
20371
20811
|
*/
|
|
20372
20812
|
async claimExistingRow(localFolder, safeName, pageId) {
|
|
20373
|
-
const
|
|
20374
|
-
|
|
20375
|
-
|
|
20376
|
-
|
|
20377
|
-
|
|
20378
|
-
|
|
20379
|
-
|
|
20380
|
-
|
|
20381
|
-
|
|
20382
|
-
|
|
20383
|
-
this.relinkClaims.add(key);
|
|
20384
|
-
return { path: candidate, existing };
|
|
20385
|
-
}
|
|
20386
|
-
return null;
|
|
20813
|
+
const found2 = await findUntrackedNote(
|
|
20814
|
+
this.stateDb,
|
|
20815
|
+
this.vaultFs,
|
|
20816
|
+
localFolder,
|
|
20817
|
+
safeName,
|
|
20818
|
+
pageId,
|
|
20819
|
+
(key) => this.relinkClaims.has(key)
|
|
20820
|
+
);
|
|
20821
|
+
if (found2) this.relinkClaims.add(pathKey(found2.path));
|
|
20822
|
+
return found2;
|
|
20387
20823
|
}
|
|
20388
20824
|
/**
|
|
20389
20825
|
* 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 페이지의 `relinkExistingNote` 와 같은 규칙. 글이 같으면 잇고, 다르면
|
|
@@ -20963,7 +21399,7 @@ async function verifyPageCompleteness(remote, local, rootPageId) {
|
|
|
20963
21399
|
var ID_SUFFIX = new RegExp(
|
|
20964
21400
|
` \\((?:${PAGE_ID_SUFFIX_LENGTHS.map((len) => `[0-9a-f]{${len}}`).join("|")})\\)$`
|
|
20965
21401
|
);
|
|
20966
|
-
function
|
|
21402
|
+
function folderOf4(record) {
|
|
20967
21403
|
if (record.fileType === "db-row") return null;
|
|
20968
21404
|
if (isFolderRecord(record)) return record.obsidianPath;
|
|
20969
21405
|
const slash = record.obsidianPath.lastIndexOf("/");
|
|
@@ -20980,10 +21416,10 @@ function findNestedFolderPages(records) {
|
|
|
20980
21416
|
const found2 = [];
|
|
20981
21417
|
for (const inner of records) {
|
|
20982
21418
|
if (inner.notionPageId === null || inner.notionParentId === null) continue;
|
|
20983
|
-
const folder =
|
|
21419
|
+
const folder = folderOf4(inner);
|
|
20984
21420
|
if (folder === null) continue;
|
|
20985
21421
|
const outer = byPage.get(compactNotionId(inner.notionParentId));
|
|
20986
|
-
if (!outer || outer === inner ||
|
|
21422
|
+
if (!outer || outer === inner || folderOf4(outer) !== folder) continue;
|
|
20987
21423
|
found2.push({
|
|
20988
21424
|
folder,
|
|
20989
21425
|
outer: { path: outer.obsidianPath, notionPageId: outer.notionPageId },
|
|
@@ -21358,7 +21794,7 @@ var VaultSchemaRenames = class {
|
|
|
21358
21794
|
const meta = await this.notionClient.getDatabaseMeta(databaseId);
|
|
21359
21795
|
for (const [from, to] of pairs) {
|
|
21360
21796
|
const carrying = rows.filter((row) => Object.hasOwn(row.current, to));
|
|
21361
|
-
const folder =
|
|
21797
|
+
const folder = folderOf5(carrying[0].record.obsidianPath);
|
|
21362
21798
|
const owners = meta.dataSources.filter(
|
|
21363
21799
|
(ds) => ds.properties?.[from] !== void 0
|
|
21364
21800
|
);
|
|
@@ -21524,7 +21960,7 @@ function sameRow(a, b) {
|
|
|
21524
21960
|
const keys = Object.keys(left.data);
|
|
21525
21961
|
return left.content === right.content && keys.length === Object.keys(right.data).length && keys.every((key) => sameFrontmatterValue(left.data[key], right.data[key]));
|
|
21526
21962
|
}
|
|
21527
|
-
function
|
|
21963
|
+
function folderOf5(path) {
|
|
21528
21964
|
const at = path.lastIndexOf("/");
|
|
21529
21965
|
return at === -1 ? "" : path.slice(0, at);
|
|
21530
21966
|
}
|
|
@@ -24450,7 +24886,7 @@ function rewriteTarget(target, source, context, kind) {
|
|
|
24450
24886
|
const shown = keepExtension ? destination : destination.slice(0, -".md".length);
|
|
24451
24887
|
let next;
|
|
24452
24888
|
if (resolved.relative) {
|
|
24453
|
-
next = relativePath(
|
|
24889
|
+
next = relativePath(folderOf6(source.to), shown);
|
|
24454
24890
|
if (!linkpath.startsWith("./") && next.startsWith("./")) next = next.slice(2);
|
|
24455
24891
|
} else if (style === "name") {
|
|
24456
24892
|
const name = baseName3(shown);
|
|
@@ -24472,14 +24908,14 @@ function resolveLinkpath(linkpath, sourcePath, files) {
|
|
|
24472
24908
|
const matches = byName(files, baseName3(candidate)).filter((file) => file.endsWith(suffix));
|
|
24473
24909
|
if (matches.length === 1) return found(matches[0], linkpath);
|
|
24474
24910
|
if (matches.length > 1) {
|
|
24475
|
-
const local = `${folderPrefix(
|
|
24911
|
+
const local = `${folderPrefix(folderOf6(sourcePath))}${candidate}`;
|
|
24476
24912
|
return matches.includes(local) ? found(local, linkpath) : null;
|
|
24477
24913
|
}
|
|
24478
24914
|
}
|
|
24479
24915
|
return null;
|
|
24480
24916
|
}
|
|
24481
24917
|
function resolveRelative(linkpath, sourcePath, files) {
|
|
24482
|
-
const joined = normalizeSegments(`${folderPrefix(
|
|
24918
|
+
const joined = normalizeSegments(`${folderPrefix(folderOf6(sourcePath))}${linkpath}`);
|
|
24483
24919
|
if (joined === null) return null;
|
|
24484
24920
|
for (const candidate of withExtension(joined)) {
|
|
24485
24921
|
if (files.has(candidate)) return { ...found(candidate, linkpath), relative: true };
|
|
@@ -24538,7 +24974,7 @@ function relativePath(from, to) {
|
|
|
24538
24974
|
function encodeMarkdownTarget(next, original) {
|
|
24539
24975
|
return original.includes("%") || next.includes(" ") ? next.replace(/ /g, "%20") : next;
|
|
24540
24976
|
}
|
|
24541
|
-
function
|
|
24977
|
+
function folderOf6(path) {
|
|
24542
24978
|
const slash = path.lastIndexOf("/");
|
|
24543
24979
|
return slash === -1 ? "" : path.slice(0, slash);
|
|
24544
24980
|
}
|
|
@@ -25708,23 +26144,32 @@ var PagePusher = class {
|
|
|
25708
26144
|
return uploads;
|
|
25709
26145
|
}
|
|
25710
26146
|
/**
|
|
25711
|
-
* 노트의 제목 · 블록 링크(`[[노트#제목]]`)가 가리키는 페이지의
|
|
25712
|
-
* (anchor-links).
|
|
26147
|
+
* 노트의 제목 · 블록 링크(`[[노트#제목]]`)가 가리키는 페이지의 블록을 읽어 둔다 — 변환은 기다리지 않는다
|
|
26148
|
+
* (anchor-links). 맨 위 블록에서 모두 찾으면 거기서 멈추고, 못 찾은 앵커가 있으면 콜아웃 · 토글 · 목록 자식까지 읽는다.
|
|
26149
|
+
* 그런 링크가 없으면 읽지 않는다. 읽지 못한 페이지의 링크는 예전처럼 간다.
|
|
25713
26150
|
*
|
|
25714
|
-
* @param selfPageId 이 노트의 페이지 — `[[#제목]]` 의 대상. 새로 만드는 노트는 아직 없다(그 링크는
|
|
26151
|
+
* @param selfPageId 이 노트의 페이지 — `[[#제목]]` 의 대상. 새로 만드는 노트는 아직 없다(그 링크는 push 뒤 패스가 다시 보낸다 — `push-link-pass`).
|
|
25715
26152
|
*/
|
|
25716
26153
|
async anchorLookup(content, selfPageId) {
|
|
25717
|
-
const pages = /* @__PURE__ */ new
|
|
26154
|
+
const pages = /* @__PURE__ */ new Map();
|
|
25718
26155
|
for (const target of anchoredWikilinkTargets(content)) {
|
|
25719
|
-
const { note } = splitAnchor(target);
|
|
26156
|
+
const { note, anchor } = splitAnchor(target);
|
|
25720
26157
|
const pageId = note === "" ? selfPageId : this.stateDb.resolveWikilink(note)?.notionPageId ?? null;
|
|
25721
|
-
if (pageId)
|
|
26158
|
+
if (!pageId) continue;
|
|
26159
|
+
const key = compactNotionId(pageId);
|
|
26160
|
+
pages.set(key, [...pages.get(key) ?? [], anchor]);
|
|
25722
26161
|
}
|
|
25723
26162
|
if (pages.size === 0) return void 0;
|
|
25724
26163
|
const blocks = /* @__PURE__ */ new Map();
|
|
25725
|
-
for (const pageId of pages) {
|
|
26164
|
+
for (const [pageId, anchors] of pages) {
|
|
25726
26165
|
try {
|
|
25727
|
-
|
|
26166
|
+
const top = (await this.notionClient.fetchAllChildren(pageId)).map(anchorBlockOf);
|
|
26167
|
+
blocks.set(pageId, top);
|
|
26168
|
+
if (anchors.every((anchor) => anchorBlockId(top, anchor) !== null)) continue;
|
|
26169
|
+
blocks.set(
|
|
26170
|
+
pageId,
|
|
26171
|
+
flattenBlockTree(await this.notionClient.getBlockTree(pageId)).map(anchorBlockOf)
|
|
26172
|
+
);
|
|
25728
26173
|
} catch (error) {
|
|
25729
26174
|
getLogger().debug(
|
|
25730
26175
|
`[Im-Nobsidian] \uC81C\uBAA9 \uB9C1\uD06C\uC758 \uB300\uC0C1 \uD398\uC774\uC9C0 \uBE14\uB85D\uC744 \uC77D\uC9C0 \uBABB\uD568 (${pageId}): ${error instanceof Error ? error.message : String(error)}`
|
|
@@ -26841,7 +27286,7 @@ async function resolveBlockToPageId(notionClient, blockId) {
|
|
|
26841
27286
|
|
|
26842
27287
|
// src/sync/page-puller.ts
|
|
26843
27288
|
var PagePuller = class {
|
|
26844
|
-
constructor(config, stateDb, notionClient, vaultFs, pipeline, imageHandler, propertyMapper, databaseSyncer, observation, drift, placement, detector, discovery) {
|
|
27289
|
+
constructor(config, stateDb, notionClient, vaultFs, pipeline, imageHandler, propertyMapper, databaseSyncer, observation, drift, placement, detector, discovery, fileHandler) {
|
|
26845
27290
|
this.config = config;
|
|
26846
27291
|
this.stateDb = stateDb;
|
|
26847
27292
|
this.notionClient = notionClient;
|
|
@@ -26855,6 +27300,7 @@ var PagePuller = class {
|
|
|
26855
27300
|
this.placement = placement;
|
|
26856
27301
|
this.detector = detector;
|
|
26857
27302
|
this.discovery = discovery;
|
|
27303
|
+
this.fileHandler = fileHandler;
|
|
26858
27304
|
}
|
|
26859
27305
|
config;
|
|
26860
27306
|
stateDb;
|
|
@@ -26869,6 +27315,7 @@ var PagePuller = class {
|
|
|
26869
27315
|
placement;
|
|
26870
27316
|
detector;
|
|
26871
27317
|
discovery;
|
|
27318
|
+
fileHandler;
|
|
26872
27319
|
dbSchemaLoaded = false;
|
|
26873
27320
|
_pullImageCount = 0;
|
|
26874
27321
|
_pullFileCount = 0;
|
|
@@ -27003,6 +27450,36 @@ var PagePuller = class {
|
|
|
27003
27450
|
sanitizeFileName(this.notionClient.extractTitle(page))
|
|
27004
27451
|
);
|
|
27005
27452
|
}
|
|
27453
|
+
/**
|
|
27454
|
+
* dry-run — 실제 pull 이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7). 다시 이을 노트(`note`) ·
|
|
27455
|
+
* 이미 있는 폴더(`folder` — 이은 노트가 없는 볼트에서 폴더로만 받으면 만들지 않는다), 아니면 null. 받기 전에는 폴더 노트가 될지
|
|
27456
|
+
* 모른다 — 노트 자리 · 폴더 노트 자리를 차례로 본다. `taken` 은 이번 미리 보기가 이미 이은 자리({@link pathKey}) — 고르면
|
|
27457
|
+
* 더한다. 볼트도 상태 DB 도 바꾸지 않는다.
|
|
27458
|
+
*/
|
|
27459
|
+
async plannedRelink(pageId, taken) {
|
|
27460
|
+
const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
|
|
27461
|
+
const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
|
|
27462
|
+
const parentPath = await this.resolveParentPath(page);
|
|
27463
|
+
const folder = parentPath ? `${parentPath}/${safeName}` : safeName;
|
|
27464
|
+
for (const dir of [parentPath, folder]) {
|
|
27465
|
+
const found2 = await findUntrackedNote(
|
|
27466
|
+
this.stateDb,
|
|
27467
|
+
this.vaultFs,
|
|
27468
|
+
dir,
|
|
27469
|
+
safeName,
|
|
27470
|
+
pageId,
|
|
27471
|
+
(key) => taken.has(key)
|
|
27472
|
+
);
|
|
27473
|
+
if (found2 && relinksWithoutContent(found2, this.unlinkedVault)) {
|
|
27474
|
+
taken.add(pathKey(found2.path));
|
|
27475
|
+
return { path: found2.path, kind: "note" };
|
|
27476
|
+
}
|
|
27477
|
+
}
|
|
27478
|
+
const folderExists = this.unlinkedVault && !taken.has(pathKey(folder)) && this.stateDb.getByPath(folder) === null && await this.vaultFs.exists(folder);
|
|
27479
|
+
if (!folderExists) return null;
|
|
27480
|
+
taken.add(pathKey(folder));
|
|
27481
|
+
return { path: folder, kind: "folder" };
|
|
27482
|
+
}
|
|
27006
27483
|
/**
|
|
27007
27484
|
* 새 페이지가 놓일 자리가 뺀 자리인가 — 부모 폴더 · 노트 이름 · 폴더 노트가 될 때의 폴더를 본다. 받기 전에는 폴더 노트가
|
|
27008
27485
|
* 될지 모른다(본문을 받아 봐야 안다) — 어느 쪽이든 빠지면 뺀다. 자연 이름으로 보고, 같은 이름이 있을 때 붙는 id 접미사는
|
|
@@ -27025,8 +27502,17 @@ var PagePuller = class {
|
|
|
27025
27502
|
if (excluded !== null) return { path: excluded, excluded: true };
|
|
27026
27503
|
const fetched = await this.fetchPageMarkdown(pageId);
|
|
27027
27504
|
const hasChildPages = this.hasChildContainers(pageId, fetched.children);
|
|
27028
|
-
|
|
27029
|
-
|
|
27505
|
+
const folderFiles = fetched.folderFiles && (hasChildPages || fetched.folderFiles.named) ? fetched.folderFiles.files : null;
|
|
27506
|
+
if (hasChildPages && !fetched.hasBody || folderFiles) {
|
|
27507
|
+
const created = await this.createFolderOnly(
|
|
27508
|
+
pageId,
|
|
27509
|
+
page,
|
|
27510
|
+
fetched.fingerprint,
|
|
27511
|
+
parentPath,
|
|
27512
|
+
safeName
|
|
27513
|
+
);
|
|
27514
|
+
if (folderFiles) await this.receiveFolderFiles(created.path, pageId, folderFiles);
|
|
27515
|
+
return created;
|
|
27030
27516
|
}
|
|
27031
27517
|
const fileType = hasChildPages ? "folder-note" : "file";
|
|
27032
27518
|
const noteDir = hasChildPages ? parentPath ? `${parentPath}/${safeName}` : safeName : parentPath;
|
|
@@ -27489,7 +27975,8 @@ var PagePuller = class {
|
|
|
27489
27975
|
compact: isCompactExport(result.markdown),
|
|
27490
27976
|
children: extractChildTags(result.markdown),
|
|
27491
27977
|
fingerprint: remoteBodyFingerprint(result.markdown),
|
|
27492
|
-
hasBody: hasBodyBesidesChildren(result.markdown)
|
|
27978
|
+
hasBody: hasBodyBesidesChildren(result.markdown),
|
|
27979
|
+
folderFiles: folderFilesInBody(result.markdown)
|
|
27493
27980
|
};
|
|
27494
27981
|
} catch (error) {
|
|
27495
27982
|
getLogger().warn(
|
|
@@ -27508,9 +27995,34 @@ var PagePuller = class {
|
|
|
27508
27995
|
compact: isCompactExport(fallback.markdown),
|
|
27509
27996
|
children: extractChildTags(fallback.markdown),
|
|
27510
27997
|
fingerprint: null,
|
|
27511
|
-
hasBody: hasBodyBesidesChildren(fallback.markdown)
|
|
27998
|
+
hasBody: hasBodyBesidesChildren(fallback.markdown),
|
|
27999
|
+
folderFiles: folderFilesInBody(fallback.markdown)
|
|
27512
28000
|
};
|
|
27513
28001
|
}
|
|
28002
|
+
/** 폴더 첨부를 받아 받은 파일 수에 더한다. */
|
|
28003
|
+
async receiveFolderFiles(folder, pageId, files, complete2 = false) {
|
|
28004
|
+
this._pullFileCount += await this.fileHandler.receiveFolderFiles(
|
|
28005
|
+
folder,
|
|
28006
|
+
pageId,
|
|
28007
|
+
files,
|
|
28008
|
+
complete2
|
|
28009
|
+
);
|
|
28010
|
+
}
|
|
28011
|
+
/**
|
|
28012
|
+
* 이 폴더 레코드의 Notion 페이지에서 받을 본문이 있나 — dry-run 이 실제 pull({@link pullFolderRecord})과 같게 센다.
|
|
28013
|
+
* 본문이 하위 페이지뿐이면 실제 pull 은 본 것만 적고 고친 것으로 세지 않는다.
|
|
28014
|
+
*/
|
|
28015
|
+
async folderPageHasBody(record) {
|
|
28016
|
+
const page = await this.notionClient.getPage(record.notionPageId);
|
|
28017
|
+
if (isTrashedOrArchived(page)) return false;
|
|
28018
|
+
const { markdown } = await this.notionClient.getPageMarkdown(page.id);
|
|
28019
|
+
return hasBodyBesidesChildren(markdown) && folderFilesOf(markdown, false) === null && this.folderNoteRefusal(record.obsidianPath) === null;
|
|
28020
|
+
}
|
|
28021
|
+
/** 이 폴더에 폴더 노트를 둘 수 없는 까닭 — 둘 수 있으면 null. */
|
|
28022
|
+
folderNoteRefusal(folder) {
|
|
28023
|
+
if (isDatabaseMode(this.config)) return "DB \uBAA8\uB4DC\uB294 \uD3F4\uB354 \uB178\uD2B8\uB97C \uBC1B\uC9C0 \uC54A\uC74C";
|
|
28024
|
+
return this.placement.folderLookup().databaseAt(folder) ? "\uADF8 \uD3F4\uB354\uB294 DB \uB77C \uD3F4\uB354 \uB178\uD2B8\uB97C \uB458 \uC218 \uC5C6\uC74C" : null;
|
|
28025
|
+
}
|
|
27514
28026
|
/**
|
|
27515
28027
|
* push 가 만든 폴더 페이지(폴더 레코드)의 원격 변경을 받는다(S-17). 폴더 레코드에는 볼트 파일이
|
|
27516
28028
|
* 없다 — 예전에는 폴더 경로를 파일로 읽어 «빈 로컬 파일» 과 원격 본문의 충돌로 남겼다.
|
|
@@ -27527,26 +28039,18 @@ var PagePuller = class {
|
|
|
27527
28039
|
* @throws 받을 자리가 없을 때 — 이유와 함께 실패로 남고, 다음 pull 이 다시 본다(기준 시각이
|
|
27528
28040
|
* 이 변경 앞에 묶인다).
|
|
27529
28041
|
*/
|
|
27530
|
-
/**
|
|
27531
|
-
* 이 폴더 레코드의 Notion 페이지에서 받을 본문이 있나 — dry-run 이 실제 pull({@link pullFolderRecord})과 같게 센다.
|
|
27532
|
-
* 본문이 하위 페이지뿐이면 실제 pull 은 본 것만 적고 고친 것으로 세지 않는다.
|
|
27533
|
-
*/
|
|
27534
|
-
async folderPageHasBody(record) {
|
|
27535
|
-
const page = await this.notionClient.getPage(record.notionPageId);
|
|
27536
|
-
if (isTrashedOrArchived(page)) return false;
|
|
27537
|
-
const { markdown } = await this.notionClient.getPageMarkdown(page.id);
|
|
27538
|
-
return hasBodyBesidesChildren(markdown) && this.folderNoteRefusal(record.obsidianPath) === null;
|
|
27539
|
-
}
|
|
27540
|
-
/** 이 폴더에 폴더 노트를 둘 수 없는 까닭 — 둘 수 있으면 null. */
|
|
27541
|
-
folderNoteRefusal(folder) {
|
|
27542
|
-
if (isDatabaseMode(this.config)) return "DB \uBAA8\uB4DC\uB294 \uD3F4\uB354 \uB178\uD2B8\uB97C \uBC1B\uC9C0 \uC54A\uC74C";
|
|
27543
|
-
return this.placement.folderLookup().databaseAt(folder) ? "\uADF8 \uD3F4\uB354\uB294 DB \uB77C \uD3F4\uB354 \uB178\uD2B8\uB97C \uB458 \uC218 \uC5C6\uC74C" : null;
|
|
27544
|
-
}
|
|
27545
28042
|
async pullFolderRecord(record, page) {
|
|
27546
28043
|
const folder = record.obsidianPath;
|
|
27547
28044
|
if (isTrashedOrArchived(page)) return null;
|
|
27548
|
-
const { markdown } = await this.notionClient.getPageMarkdown(page.id);
|
|
28045
|
+
const { markdown, truncated } = await this.notionClient.getPageMarkdown(page.id);
|
|
27549
28046
|
if (!hasBodyBesidesChildren(markdown)) {
|
|
28047
|
+
await this.receiveFolderFiles(folder, page.id, [], !truncated);
|
|
28048
|
+
this.observation.record(record.id, page, remoteBodyFingerprint(markdown));
|
|
28049
|
+
return null;
|
|
28050
|
+
}
|
|
28051
|
+
const files = folderFilesOf(markdown, false);
|
|
28052
|
+
if (files) {
|
|
28053
|
+
await this.receiveFolderFiles(folder, page.id, files, !truncated);
|
|
27550
28054
|
this.observation.record(record.id, page, remoteBodyFingerprint(markdown));
|
|
27551
28055
|
return null;
|
|
27552
28056
|
}
|
|
@@ -27661,21 +28165,22 @@ var PagePuller = class {
|
|
|
27661
28165
|
* 이름. 다시 이을지는 받은 글과 견줘 호출측이 정한다({@link relinkExistingNote}) — 잇지 않으면 장부에서 지운다.
|
|
27662
28166
|
*/
|
|
27663
28167
|
async claimExistingNote(dir, safeName, pageId) {
|
|
27664
|
-
const
|
|
27665
|
-
|
|
27666
|
-
|
|
27667
|
-
|
|
27668
|
-
|
|
27669
|
-
|
|
27670
|
-
|
|
27671
|
-
|
|
27672
|
-
|
|
27673
|
-
|
|
28168
|
+
const taken = (key) => this.claimedPaths.has(key);
|
|
28169
|
+
for (; ; ) {
|
|
28170
|
+
const found2 = await findUntrackedNote(
|
|
28171
|
+
this.stateDb,
|
|
28172
|
+
this.vaultFs,
|
|
28173
|
+
dir,
|
|
28174
|
+
safeName,
|
|
28175
|
+
pageId,
|
|
28176
|
+
taken
|
|
28177
|
+
);
|
|
28178
|
+
if (!found2) return null;
|
|
28179
|
+
const key = pathKey(found2.path);
|
|
27674
28180
|
if (this.claimedPaths.has(key)) continue;
|
|
27675
|
-
this.claimedPaths.set(key,
|
|
27676
|
-
return
|
|
28181
|
+
this.claimedPaths.set(key, found2.path);
|
|
28182
|
+
return found2;
|
|
27677
28183
|
}
|
|
27678
|
-
return null;
|
|
27679
28184
|
}
|
|
27680
28185
|
async claimFilePath(dir, safeName, pageId) {
|
|
27681
28186
|
const candidates = pagePathCandidates(dir, safeName, pageId);
|
|
@@ -27735,6 +28240,10 @@ var PagePuller = class {
|
|
|
27735
28240
|
this.dbSchemaLoaded = true;
|
|
27736
28241
|
}
|
|
27737
28242
|
};
|
|
28243
|
+
function folderFilesInBody(markdown) {
|
|
28244
|
+
const files = folderFilesOf(markdown, false);
|
|
28245
|
+
return files ? { files, named: folderFilesOf(markdown, true) !== null } : null;
|
|
28246
|
+
}
|
|
27738
28247
|
|
|
27739
28248
|
// src/sync/frontmatter-link-resolver.ts
|
|
27740
28249
|
function resolveFrontmatterRelations(content, idToTitle) {
|
|
@@ -27873,17 +28382,31 @@ async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle, blockAncho
|
|
|
27873
28382
|
}
|
|
27874
28383
|
|
|
27875
28384
|
// src/sync/push-link-pass.ts
|
|
27876
|
-
function notesWithResolvableWikilinks(stateDb) {
|
|
28385
|
+
function notesWithResolvableWikilinks(stateDb, pushed = /* @__PURE__ */ new Set()) {
|
|
27877
28386
|
const paths = [];
|
|
27878
28387
|
for (const record of stateDb.getAll()) {
|
|
27879
28388
|
if (!record.notionPageId || record.status !== "synced") continue;
|
|
27880
|
-
const resolvable = stateDb.getPreserveMarkers(record.obsidianPath).some(
|
|
27881
|
-
(marker) => marker.type === "wikilink" && marker.params.text !== void 0 && stateDb.resolveWikilink(marker.params.text) !== null
|
|
27882
|
-
);
|
|
28389
|
+
const resolvable = stateDb.getPreserveMarkers(record.obsidianPath).some((marker) => marker.type === "wikilink" && resolvesNow(stateDb, record, marker, pushed));
|
|
27883
28390
|
if (resolvable) paths.push(record.obsidianPath);
|
|
27884
28391
|
}
|
|
27885
28392
|
return paths.sort();
|
|
27886
28393
|
}
|
|
28394
|
+
function resolvesNow(stateDb, record, marker, pushed) {
|
|
28395
|
+
const text = marker.params.text;
|
|
28396
|
+
if (text === void 0) return false;
|
|
28397
|
+
const split = splitAnchor(text);
|
|
28398
|
+
if (!split) return stateDb.resolveWikilink(text) !== null;
|
|
28399
|
+
const target = split.note === "" ? null : stateDb.resolveWikilink(split.note);
|
|
28400
|
+
if (split.note !== "" && target === null) return false;
|
|
28401
|
+
switch (marker.params.anchor) {
|
|
28402
|
+
case "page":
|
|
28403
|
+
return true;
|
|
28404
|
+
case "block":
|
|
28405
|
+
return target !== null && target.obsidianPath !== record.obsidianPath && pushed.has(target.obsidianPath);
|
|
28406
|
+
default:
|
|
28407
|
+
return true;
|
|
28408
|
+
}
|
|
28409
|
+
}
|
|
27887
28410
|
|
|
27888
28411
|
// src/sync/pull-planner.ts
|
|
27889
28412
|
var PullPlanner = class {
|
|
@@ -27932,12 +28455,19 @@ var PullPlanner = class {
|
|
|
27932
28455
|
}
|
|
27933
28456
|
planned.push(change);
|
|
27934
28457
|
}
|
|
28458
|
+
const relinks = await this.plannedRelinks(planned);
|
|
27935
28459
|
const queued = new Set(
|
|
27936
28460
|
[...filtered, ...restoreChanges].map((change) => compactNotionId(change.pageId))
|
|
27937
28461
|
);
|
|
27938
28462
|
const movingInVault = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
|
|
27939
28463
|
const rows = [];
|
|
27940
|
-
for (const row of await this.planDatabaseRows(
|
|
28464
|
+
for (const row of await this.planDatabaseRows(
|
|
28465
|
+
options.paths,
|
|
28466
|
+
failed,
|
|
28467
|
+
ledger,
|
|
28468
|
+
movingInVault,
|
|
28469
|
+
relinks.taken
|
|
28470
|
+
)) {
|
|
27941
28471
|
if (queued.has(compactNotionId(row.pageId))) continue;
|
|
27942
28472
|
if (row.resolution?.action === "conflict") conflicts.push(row.resolution.conflict);
|
|
27943
28473
|
else if (row.resolution?.action === "merge") mergedPaths.push(row.path);
|
|
@@ -27947,16 +28477,27 @@ var PullPlanner = class {
|
|
|
27947
28477
|
const items = [];
|
|
27948
28478
|
const plannedPath = this.planner.plannedPaths(localPlan);
|
|
27949
28479
|
for (const change of [...planned, ...restoreChanges]) {
|
|
28480
|
+
const relink = relinks.byPage.get(change.pageId);
|
|
28481
|
+
if (relink) {
|
|
28482
|
+
items.push({ path: relink.path, operation: "create", unchanged: true });
|
|
28483
|
+
continue;
|
|
28484
|
+
}
|
|
27950
28485
|
const record = this.stateDb.getByNotionId(change.pageId);
|
|
27951
28486
|
items.push({
|
|
27952
28487
|
path: (record && plannedPath.get(record.id)) ?? record?.obsidianPath ?? change.title ?? change.pageId,
|
|
27953
28488
|
operation: change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update"
|
|
27954
28489
|
});
|
|
27955
28490
|
}
|
|
27956
|
-
for (const row of rows)
|
|
28491
|
+
for (const row of rows) {
|
|
28492
|
+
items.push({
|
|
28493
|
+
path: row.path,
|
|
28494
|
+
operation: row.operation,
|
|
28495
|
+
...row.relink ? { unchanged: true } : {}
|
|
28496
|
+
});
|
|
28497
|
+
}
|
|
27957
28498
|
const movedRows = rows.filter((row) => row.moves !== void 0);
|
|
27958
28499
|
items.forEach((item, index) => options.onProgress?.(index + 1, items.length, item));
|
|
27959
|
-
const count = (type) => planned.filter((c) => c.type === type).length;
|
|
28500
|
+
const count = (type) => planned.filter((c) => c.type === type && !relinks.byPage.has(c.pageId)).length;
|
|
27960
28501
|
const restoredModified = planned.filter(
|
|
27961
28502
|
(c) => c.type === "modified" && restoreIds.has(c.pageId)
|
|
27962
28503
|
).length;
|
|
@@ -27967,7 +28508,13 @@ var PullPlanner = class {
|
|
|
27967
28508
|
].map((change) => this.stateDb.getByNotionId(change.pageId)?.obsidianPath).filter((path) => path !== void 0),
|
|
27968
28509
|
...rows.filter((row) => row.operation === "update" && row.restore).map((row) => row.path)
|
|
27969
28510
|
];
|
|
27970
|
-
const rowCount = (operation, restore = false) => rows.filter(
|
|
28511
|
+
const rowCount = (operation, restore = false) => rows.filter(
|
|
28512
|
+
(row) => row.operation === operation && (row.restore ?? false) === restore && !row.relink
|
|
28513
|
+
).length;
|
|
28514
|
+
const relinkedPaths = [
|
|
28515
|
+
...[...relinks.byPage.values()].filter((relink) => relink.kind === "note").map((relink) => relink.path),
|
|
28516
|
+
...rows.filter((row) => row.relink).map((row) => row.path)
|
|
28517
|
+
];
|
|
27971
28518
|
return {
|
|
27972
28519
|
created: count("created") + rowCount("create"),
|
|
27973
28520
|
updated: count("modified") - restoredModified + rowCount("update"),
|
|
@@ -27979,6 +28526,7 @@ var PullPlanner = class {
|
|
|
27979
28526
|
...movedRows.length > 0 ? { moved: movedRows.flatMap((row) => row.moves) } : {},
|
|
27980
28527
|
conflicts,
|
|
27981
28528
|
...mergedPaths.length > 0 ? { mergedPaths } : {},
|
|
28529
|
+
...relinkedPaths.length > 0 ? { relinkedPaths, relinkPending: true } : {},
|
|
27982
28530
|
writtenPaths: [],
|
|
27983
28531
|
failed,
|
|
27984
28532
|
duration: Date.now() - startTime,
|
|
@@ -27995,6 +28543,27 @@ var PullPlanner = class {
|
|
|
27995
28543
|
}
|
|
27996
28544
|
};
|
|
27997
28545
|
}
|
|
28546
|
+
/**
|
|
28547
|
+
* 새 페이지 가운데 실제 pull 이 상태 기록에 없던 노트 · 폴더와 다시 이을 것({@link PagePuller.plannedRelink}) — 페이지 id →
|
|
28548
|
+
* 자리. `taken` 은 고른 자리라 행도 피한다.
|
|
28549
|
+
*/
|
|
28550
|
+
async plannedRelinks(planned) {
|
|
28551
|
+
const byPage = /* @__PURE__ */ new Map();
|
|
28552
|
+
const taken = /* @__PURE__ */ new Set();
|
|
28553
|
+
for (const change of planned) {
|
|
28554
|
+
if (change.type !== "created") continue;
|
|
28555
|
+
try {
|
|
28556
|
+
const relink = await this.puller.plannedRelink(change.pageId, taken);
|
|
28557
|
+
if (relink) byPage.set(change.pageId, relink);
|
|
28558
|
+
} catch (error) {
|
|
28559
|
+
getLogger().debug(
|
|
28560
|
+
`[Im-Nobsidian] \uB2E4\uC2DC \uC774\uC744 \uC790\uB9AC\uB97C \uBBF8\uB9AC \uBCF4\uC9C0 \uBABB\uD568 (${change.pageId}):`,
|
|
28561
|
+
error
|
|
28562
|
+
);
|
|
28563
|
+
}
|
|
28564
|
+
}
|
|
28565
|
+
return { byPage, taken };
|
|
28566
|
+
}
|
|
27998
28567
|
/**
|
|
27999
28568
|
* 고친 페이지를 실제 pull 이 받는가 — 폴더로만 추적하는 페이지는 본문이 생겼을 때만 폴더 노트로 받는다. 하위 페이지 ·
|
|
28000
28569
|
* DB 를 옮기기만 해도 그 페이지의 수정 시각이 바뀌어 고친 것으로 보였다. 확인하지 못하면 센다(실제 pull 이 받아 본다).
|
|
@@ -28043,7 +28612,7 @@ var PullPlanner = class {
|
|
|
28043
28612
|
* (`DatabaseDiscovery.pullDiscoveredDatabases`)를 같은 규칙으로 고른다. 세지 못한 DB 는 이유와
|
|
28044
28613
|
* 함께 `failed` 에 싣는다 — 세지 못한 것을 없다고 하지 않는다.
|
|
28045
28614
|
*/
|
|
28046
|
-
async planDatabaseRows(paths, failed, ledger, movingInVault) {
|
|
28615
|
+
async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken) {
|
|
28047
28616
|
const configured = this.config.notion.databases ?? [];
|
|
28048
28617
|
const rows = [];
|
|
28049
28618
|
const plan = async (dbConfig, resolveDbFolder2) => {
|
|
@@ -28057,7 +28626,8 @@ var PullPlanner = class {
|
|
|
28057
28626
|
paths,
|
|
28058
28627
|
resolveDbFolder: resolveDbFolder2,
|
|
28059
28628
|
followPlacement: resolveDbFolder2 !== void 0,
|
|
28060
|
-
movingInVault
|
|
28629
|
+
movingInVault,
|
|
28630
|
+
relinkTaken
|
|
28061
28631
|
})
|
|
28062
28632
|
);
|
|
28063
28633
|
} catch (error) {
|
|
@@ -28844,7 +29414,9 @@ var SyncOrchestrator = class {
|
|
|
28844
29414
|
{
|
|
28845
29415
|
fetch: customFetch,
|
|
28846
29416
|
downloadTimeoutMs: config.advanced.mediaDownloadTimeoutMs,
|
|
28847
|
-
itemTimeoutMs: config.advanced.itemTimeoutMs
|
|
29417
|
+
itemTimeoutMs: config.advanced.itemTimeoutMs,
|
|
29418
|
+
attachmentsFolder: config.paths.attachments,
|
|
29419
|
+
deleteSync: config.sync.deleteSync
|
|
28848
29420
|
}
|
|
28849
29421
|
);
|
|
28850
29422
|
const propertyMapper = PropertyMapper.fromConfig(config);
|
|
@@ -28954,7 +29526,8 @@ var SyncOrchestrator = class {
|
|
|
28954
29526
|
this.drift,
|
|
28955
29527
|
this.placement,
|
|
28956
29528
|
this.detector,
|
|
28957
|
-
this.discovery
|
|
29529
|
+
this.discovery,
|
|
29530
|
+
this.fileHandler
|
|
28958
29531
|
);
|
|
28959
29532
|
this.pullPlanner = new PullPlanner(
|
|
28960
29533
|
config,
|
|
@@ -29074,14 +29647,16 @@ var SyncOrchestrator = class {
|
|
|
29074
29647
|
* 있든 없든 push 마다 부른다 — 바뀌지 않은 첨부는 파일 기록이 건너뛴다.
|
|
29075
29648
|
*/
|
|
29076
29649
|
async pushVaultFiles(failed) {
|
|
29077
|
-
if (this.config.sync.syncFiles === false) return;
|
|
29650
|
+
if (this.config.sync.syncFiles === false) return 0;
|
|
29078
29651
|
try {
|
|
29079
|
-
const fileResults = await this.fileHandler.pushAllFiles(failed);
|
|
29652
|
+
const fileResults = await this.fileHandler.pushAllFiles(failed, this.placement);
|
|
29080
29653
|
if (fileResults.length > 0) {
|
|
29081
29654
|
getLogger().info(`[Im-Nobsidian] ${fileResults.length}\uAC1C \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC644\uB8CC`);
|
|
29082
29655
|
}
|
|
29656
|
+
return fileResults.length;
|
|
29083
29657
|
} catch (error) {
|
|
29084
29658
|
getLogger().warn("[Im-Nobsidian] \uD30C\uC77C \uC5C5\uB85C\uB4DC \uC911 \uC624\uB958:", error);
|
|
29659
|
+
return 0;
|
|
29085
29660
|
}
|
|
29086
29661
|
}
|
|
29087
29662
|
/**
|
|
@@ -29089,7 +29664,7 @@ var SyncOrchestrator = class {
|
|
|
29089
29664
|
* 적어 push 가 올린다 — dry-run 은 받지 않아 원격이 바뀐 그대로라, 원격을 보고 거절하지 않는다.
|
|
29090
29665
|
*/
|
|
29091
29666
|
/**
|
|
29092
|
-
* @param relinkedByPull 같은 sync 의 dry-run pull 이 다시 이을
|
|
29667
|
+
* @param relinkedByPull 같은 sync 의 dry-run pull 이 받거나 다시 이을 것이 있다 — 실제 sync 는 받으며 먼저 잇고 올리므로
|
|
29093
29668
|
* 상태 기록이 비었어도 막지 않는다({@link assertVaultLinked}).
|
|
29094
29669
|
*/
|
|
29095
29670
|
async executePush(options, mergedByPull = /* @__PURE__ */ new Set(), relinkedByPull = false) {
|
|
@@ -29170,12 +29745,13 @@ var SyncOrchestrator = class {
|
|
|
29170
29745
|
...viewPlan.refused,
|
|
29171
29746
|
...viewed2.failed
|
|
29172
29747
|
];
|
|
29173
|
-
|
|
29748
|
+
const uploadedFiles2 = !options?.dryRun && !options?.signal?.aborted ? await this.pushVaultFiles(failed2) : 0;
|
|
29174
29749
|
return {
|
|
29175
29750
|
created: 0,
|
|
29176
29751
|
updated: 0,
|
|
29177
29752
|
deleted: 0,
|
|
29178
29753
|
moved: 0,
|
|
29754
|
+
...uploadedFiles2 > 0 ? { uploadedFiles: uploadedFiles2 } : {},
|
|
29179
29755
|
failed: failed2,
|
|
29180
29756
|
duration: Date.now() - startTime,
|
|
29181
29757
|
...edited2.applied.length > 0 ? { schemaEdits: edited2.applied } : {},
|
|
@@ -29446,7 +30022,7 @@ var SyncOrchestrator = class {
|
|
|
29446
30022
|
);
|
|
29447
30023
|
}
|
|
29448
30024
|
};
|
|
29449
|
-
const relinkable = notesWithResolvableWikilinks(this.stateDb).filter(
|
|
30025
|
+
const relinkable = notesWithResolvableWikilinks(this.stateDb, pushedBodies).filter(
|
|
29450
30026
|
(path) => inAnyPathScope(path, options?.paths) && !excludeSet?.has(path)
|
|
29451
30027
|
);
|
|
29452
30028
|
await runPool(relinkable, relink, {
|
|
@@ -29454,7 +30030,7 @@ var SyncOrchestrator = class {
|
|
|
29454
30030
|
signal: options?.signal
|
|
29455
30031
|
});
|
|
29456
30032
|
}
|
|
29457
|
-
await this.pushVaultFiles(failed);
|
|
30033
|
+
const uploadedFiles = await this.pushVaultFiles(failed);
|
|
29458
30034
|
this.stateDb.setMeta("last_push_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
29459
30035
|
this.stateDb.setMeta(LAST_SYNC_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
|
|
29460
30036
|
this.stateDb.setMeta(PUSH_IN_PROGRESS_META_KEY, "");
|
|
@@ -29466,6 +30042,7 @@ var SyncOrchestrator = class {
|
|
|
29466
30042
|
updated: counts.updated,
|
|
29467
30043
|
deleted: counts.deleted,
|
|
29468
30044
|
moved: counts.moved,
|
|
30045
|
+
...uploadedFiles > 0 ? { uploadedFiles } : {},
|
|
29469
30046
|
failed,
|
|
29470
30047
|
duration: Date.now() - startTime,
|
|
29471
30048
|
readOnlySkipped: this.pusher.takeReadOnlySkips(),
|
|
@@ -29759,8 +30336,7 @@ var SyncOrchestrator = class {
|
|
|
29759
30336
|
},
|
|
29760
30337
|
moved: [...relocated.moved, ...plan.moved ?? []],
|
|
29761
30338
|
moveSkipped: relocated.skipped,
|
|
29762
|
-
excluded: [...exclusions.values()]
|
|
29763
|
-
...this.puller.unlinkedVaultAtStart && plan.created > 0 ? { relinkPending: true } : {}
|
|
30339
|
+
excluded: [...exclusions.values()]
|
|
29764
30340
|
};
|
|
29765
30341
|
}
|
|
29766
30342
|
let pullTotal = workItems.length;
|
|
@@ -29972,15 +30548,16 @@ var SyncOrchestrator = class {
|
|
|
29972
30548
|
const tagged = (direction) => onProgress && ((current, total, item) => onProgress(current, total, { ...item, direction }));
|
|
29973
30549
|
const pullResult = await this.executePull({ ...options, onProgress: tagged("pull") });
|
|
29974
30550
|
const conflicted = options?.dryRun ? pullResult.conflicts.map((conflict) => conflict.syncRecord.obsidianPath) : [];
|
|
30551
|
+
const relinked = options?.dryRun ? pullResult.relinkedPaths ?? [] : [];
|
|
29975
30552
|
const pushResult = await this.executePush(
|
|
29976
30553
|
{
|
|
29977
30554
|
...options,
|
|
29978
30555
|
onProgress: tagged("push"),
|
|
29979
30556
|
paths: options?.paths,
|
|
29980
|
-
excludePaths: [...pullResult.writtenPaths, ...conflicted]
|
|
30557
|
+
excludePaths: [...pullResult.writtenPaths, ...conflicted, ...relinked]
|
|
29981
30558
|
},
|
|
29982
30559
|
new Set(options?.dryRun ? pullResult.mergedPaths ?? [] : []),
|
|
29983
|
-
pullResult.
|
|
30560
|
+
options?.dryRun === true && (pullResult.created > 0 || relinked.length > 0)
|
|
29984
30561
|
);
|
|
29985
30562
|
return {
|
|
29986
30563
|
pull: pullResult,
|