@im-nobsidian/core 0.19.1 → 0.20.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 +21 -5
- package/dist/index.js +489 -263
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -13397,6 +13397,29 @@ var ImageHandler = class {
|
|
|
13397
13397
|
get fetchFn() {
|
|
13398
13398
|
return this.customFetch ?? globalThis.fetch;
|
|
13399
13399
|
}
|
|
13400
|
+
/** {@link preview} 안에서 도는 중인 렌더 수 — 0 보다 크면 받은 미디어를 볼트에 쓰지 않고 상태 DB 에 적지 않는다. */
|
|
13401
|
+
previewDepth = 0;
|
|
13402
|
+
/**
|
|
13403
|
+
* 받기를 미리 본다(dry-run) — 미디어를 실제 pull 처럼 받아 이름(내용 해시)과 임베드를 정하되 볼트에 쓰지 않고 상태 DB 에
|
|
13404
|
+
* 적지 않는다. 받지 않고 렌더하면 미디어가 원격 주소로 남아, 받은 글을 볼트의 노트와 견줄 수 없다.
|
|
13405
|
+
*/
|
|
13406
|
+
async preview(run) {
|
|
13407
|
+
this.previewDepth++;
|
|
13408
|
+
try {
|
|
13409
|
+
return await run();
|
|
13410
|
+
} finally {
|
|
13411
|
+
this.previewDepth--;
|
|
13412
|
+
}
|
|
13413
|
+
}
|
|
13414
|
+
get previewing() {
|
|
13415
|
+
return this.previewDepth > 0;
|
|
13416
|
+
}
|
|
13417
|
+
/** 받은 파일을 볼트에 쓴다 — 미리 보기에서는 쓰지 않는다({@link preview}). */
|
|
13418
|
+
async writeDownload(localPath, buffer) {
|
|
13419
|
+
if (this.previewing) return;
|
|
13420
|
+
await this.vaultFs.ensureFolder(this.attachmentFolder);
|
|
13421
|
+
await this.vaultFs.writeBinary(localPath, buffer);
|
|
13422
|
+
}
|
|
13400
13423
|
/** 상한 로직은 첨부 경로와 공유한다 — 한쪽만 빠뜨리지 않도록(utils/download-fetch). */
|
|
13401
13424
|
fetchForDownload(url) {
|
|
13402
13425
|
return fetchForDownload(this.fetchFn, url, this.downloadTimeoutMs);
|
|
@@ -13460,8 +13483,7 @@ var ImageHandler = class {
|
|
|
13460
13483
|
return null;
|
|
13461
13484
|
}
|
|
13462
13485
|
async persistImage(fetched) {
|
|
13463
|
-
await this.
|
|
13464
|
-
await this.vaultFs.writeBinary(fetched.localPath, fetched.buffer);
|
|
13486
|
+
await this.writeDownload(fetched.localPath, fetched.buffer);
|
|
13465
13487
|
return {
|
|
13466
13488
|
originalUrl: fetched.originalUrl,
|
|
13467
13489
|
localPath: fetched.localPath,
|
|
@@ -13530,7 +13552,7 @@ var ImageHandler = class {
|
|
|
13530
13552
|
}
|
|
13531
13553
|
if (localHash !== remoteHash) continue;
|
|
13532
13554
|
const fileId = notionFileIdOf(span.url);
|
|
13533
|
-
if (fileId && this.stateDb) {
|
|
13555
|
+
if (fileId && this.stateDb && !this.previewing) {
|
|
13534
13556
|
rememberReceivedMedia(this.stateDb, pageId, [
|
|
13535
13557
|
{ fileId, target, localPath: candidate, fileHash: remoteHash }
|
|
13536
13558
|
]);
|
|
@@ -14001,7 +14023,7 @@ var ImageHandler = class {
|
|
|
14001
14023
|
*/
|
|
14002
14024
|
rememberReceived(pageId, received) {
|
|
14003
14025
|
const media = received.filter((item) => item !== null);
|
|
14004
|
-
if (!pageId || !this.stateDb || media.length === 0) return;
|
|
14026
|
+
if (!pageId || !this.stateDb || media.length === 0 || this.previewing) return;
|
|
14005
14027
|
try {
|
|
14006
14028
|
rememberReceivedMedia(this.stateDb, pageId, media);
|
|
14007
14029
|
} catch (error) {
|
|
@@ -14250,8 +14272,7 @@ var ImageHandler = class {
|
|
|
14250
14272
|
name
|
|
14251
14273
|
};
|
|
14252
14274
|
}
|
|
14253
|
-
await this.
|
|
14254
|
-
await this.vaultFs.writeBinary(localPath, buffer);
|
|
14275
|
+
await this.writeDownload(localPath, buffer);
|
|
14255
14276
|
return { originalUrl: url, localPath, hash, size: buffer.length, fileHash, name };
|
|
14256
14277
|
} catch (error) {
|
|
14257
14278
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
@@ -15760,8 +15781,59 @@ async function findUntrackedNote(stateDb, vaultFs, dir, safeName, pageId, taken)
|
|
|
15760
15781
|
}
|
|
15761
15782
|
return null;
|
|
15762
15783
|
}
|
|
15763
|
-
function
|
|
15764
|
-
|
|
15784
|
+
function relinkOutcome(existing, same, unlinkedVault, strategy) {
|
|
15785
|
+
if (same) return "relink";
|
|
15786
|
+
if (existing === "name" && !unlinkedVault) return null;
|
|
15787
|
+
const chosen = existing === "own" ? strategy : "manual";
|
|
15788
|
+
if (chosen === "local-first") return "relink";
|
|
15789
|
+
if (chosen === "remote-first") return "overwrite";
|
|
15790
|
+
return "conflict";
|
|
15791
|
+
}
|
|
15792
|
+
function relinkConflict(input) {
|
|
15793
|
+
return {
|
|
15794
|
+
syncRecord: input.record,
|
|
15795
|
+
localChange: {
|
|
15796
|
+
path: input.path,
|
|
15797
|
+
type: "modified",
|
|
15798
|
+
currentHash: computeHash(input.local),
|
|
15799
|
+
previousHash: input.record.contentHash
|
|
15800
|
+
},
|
|
15801
|
+
remoteChange: {
|
|
15802
|
+
pageId: input.pageId,
|
|
15803
|
+
type: "modified",
|
|
15804
|
+
lastEdited: input.lastEdited,
|
|
15805
|
+
previousEdited: null,
|
|
15806
|
+
path: input.path,
|
|
15807
|
+
title: input.title
|
|
15808
|
+
},
|
|
15809
|
+
baseContent: null,
|
|
15810
|
+
localContent: input.local,
|
|
15811
|
+
remoteContent: input.remote
|
|
15812
|
+
};
|
|
15813
|
+
}
|
|
15814
|
+
function plannedRelinkRecord(input) {
|
|
15815
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
15816
|
+
return {
|
|
15817
|
+
id: `planned:${input.pageId}`,
|
|
15818
|
+
obsidianPath: input.path,
|
|
15819
|
+
notionPageId: input.pageId,
|
|
15820
|
+
notionParentId: input.parentId,
|
|
15821
|
+
contentHash: computeHash(input.remote),
|
|
15822
|
+
notionLastEdited: input.lastEdited,
|
|
15823
|
+
notionLastEditedBy: null,
|
|
15824
|
+
notionSeenAt: null,
|
|
15825
|
+
notionBodyFingerprint: null,
|
|
15826
|
+
localLastModified: now,
|
|
15827
|
+
syncDirection: "both",
|
|
15828
|
+
fileType: input.fileType,
|
|
15829
|
+
status: "conflict",
|
|
15830
|
+
baseSnapshot: null,
|
|
15831
|
+
localMtime: null,
|
|
15832
|
+
localFileSize: null,
|
|
15833
|
+
version: 0,
|
|
15834
|
+
createdAt: now,
|
|
15835
|
+
updatedAt: now
|
|
15836
|
+
};
|
|
15765
15837
|
}
|
|
15766
15838
|
|
|
15767
15839
|
// src/conflict/merger.ts
|
|
@@ -15949,20 +16021,27 @@ var PENDING_LINK_RE = new RegExp(
|
|
|
15949
16021
|
"g"
|
|
15950
16022
|
);
|
|
15951
16023
|
function sameAsPendingLinks(local, remote) {
|
|
15952
|
-
|
|
15953
|
-
|
|
16024
|
+
const pending = new RegExp(`${NOTION_BLOCK_LINK_RE.source}|${PENDING_LINK_RE.source}`, "g");
|
|
16025
|
+
if (!pending.test(remote)) return false;
|
|
16026
|
+
pending.lastIndex = 0;
|
|
15954
16027
|
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15955
16028
|
let pattern = "";
|
|
15956
16029
|
let last = 0;
|
|
15957
|
-
for (const match of remote.matchAll(
|
|
16030
|
+
for (const match of remote.matchAll(pending)) {
|
|
15958
16031
|
pattern += escape(remote.slice(last, match.index));
|
|
15959
|
-
const
|
|
15960
|
-
|
|
16032
|
+
const blockLabel = match[1];
|
|
16033
|
+
const alias = match[6];
|
|
16034
|
+
pattern += blockLabel ? pendingBlockLinkPattern(blockLabel, escape) : match[7] ? `"?\\[\\[[^\\]\\n]+\\]\\]"?` : alias ? `(?:\\[\\[(?:[^\\]\\n|]*/)?${escape(alias)}(?:\\\\?\\|[^\\]\\n]*)?\\]\\]|\\[\\[[^\\]\\n|]+\\\\?\\|${escape(alias)}\\]\\]|\\[${escape(alias)}\\]\\([^)\\n]+\\))` : `(?:\\[\\[[^\\]\\n]+\\]\\]|\\[[^\\]\\n]+\\]\\([^)\\n]+\\))`;
|
|
15961
16035
|
last = match.index + match[0].length;
|
|
15962
16036
|
}
|
|
15963
16037
|
pattern += escape(remote.slice(last).replace(/\n+$/, ""));
|
|
15964
16038
|
return new RegExp(`^${pattern}\\n*$`).test(local.replace(/\n+$/, ""));
|
|
15965
16039
|
}
|
|
16040
|
+
function pendingBlockLinkPattern(label, escape) {
|
|
16041
|
+
const text = label.replace(/\\([>^])/g, "$1");
|
|
16042
|
+
const written = escape(text.split(" > ").join("#"));
|
|
16043
|
+
return `(?:\\[\\[[^\\]\\n|]*#[^\\]\\n|]*\\\\?\\|${escape(text)}\\]\\]|\\[\\[(?:[^\\]\\n|#]*/)?${written}\\]\\]|\\[\\[#${written}\\]\\]|\\[(?:${escape(text)}|${escape(label)})\\]\\([^)\\n]+\\))`;
|
|
16044
|
+
}
|
|
15966
16045
|
function resolveNotionIdWikilinks(markdown, lookup) {
|
|
15967
16046
|
let resolved = 0;
|
|
15968
16047
|
const out = markdown.replace(
|
|
@@ -19989,13 +20068,15 @@ var DatabaseSyncer = class {
|
|
|
19989
20068
|
page.id,
|
|
19990
20069
|
(key) => relinkTaken.has(key) || claimed.has(key)
|
|
19991
20070
|
);
|
|
19992
|
-
|
|
20071
|
+
const relink = untracked && await this.plannedRowRelink(page, dbConfig, untracked);
|
|
20072
|
+
if (untracked && relink) {
|
|
19993
20073
|
relinkTaken.add(pathKey(untracked.path));
|
|
19994
20074
|
planned.push({
|
|
19995
20075
|
pageId: page.id,
|
|
19996
20076
|
path: untracked.path,
|
|
19997
20077
|
operation: "create",
|
|
19998
|
-
relink: true
|
|
20078
|
+
relink: true,
|
|
20079
|
+
...relink.conflict ? { relinkConflict: relink.conflict } : {}
|
|
19999
20080
|
});
|
|
20000
20081
|
continue;
|
|
20001
20082
|
}
|
|
@@ -20826,16 +20907,20 @@ var DatabaseSyncer = class {
|
|
|
20826
20907
|
return found2;
|
|
20827
20908
|
}
|
|
20828
20909
|
/**
|
|
20829
|
-
* 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) —
|
|
20830
|
-
*
|
|
20831
|
-
* remote-first 는 `"write"` — 호출측이 받은 행으로 덮는다.
|
|
20910
|
+
* 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 받은 글과 견줘 {@link relinkOutcome} 대로. 잇지 않으면 null.
|
|
20911
|
+
* 원격으로 덮을 것은 `"write"` — 호출측이 받은 행으로 덮는다.
|
|
20832
20912
|
*/
|
|
20833
20913
|
async relinkExistingRow(filePath, existing, local, finalContent, page, dbConfig, rendered, bodyFingerprint) {
|
|
20834
20914
|
const same = sameNoteContent(local, finalContent) || sameAsPendingLinks(local, finalContent);
|
|
20835
|
-
|
|
20836
|
-
|
|
20837
|
-
|
|
20838
|
-
|
|
20915
|
+
const outcome = relinkOutcome(
|
|
20916
|
+
existing,
|
|
20917
|
+
same,
|
|
20918
|
+
this.unlinkedVault,
|
|
20919
|
+
this.config.sync.conflictStrategy
|
|
20920
|
+
);
|
|
20921
|
+
if (outcome === null) return null;
|
|
20922
|
+
if (outcome === "overwrite") return "write";
|
|
20923
|
+
const conflicted = outcome === "conflict";
|
|
20839
20924
|
const stat2 = same ? await this.vaultFs.getFileStat(filePath) : null;
|
|
20840
20925
|
this.stateDb.transaction(() => {
|
|
20841
20926
|
this.stateDb.upsert({
|
|
@@ -20862,31 +20947,57 @@ var DatabaseSyncer = class {
|
|
|
20862
20947
|
});
|
|
20863
20948
|
});
|
|
20864
20949
|
if (!conflicted) return { action: "unchanged", path: filePath, relinked: true };
|
|
20865
|
-
const record = this.stateDb.getByPath(filePath);
|
|
20866
20950
|
return {
|
|
20867
20951
|
action: "conflict",
|
|
20868
20952
|
path: filePath,
|
|
20869
20953
|
relinked: true,
|
|
20870
|
-
conflict: {
|
|
20871
|
-
|
|
20872
|
-
|
|
20873
|
-
|
|
20874
|
-
|
|
20875
|
-
|
|
20876
|
-
|
|
20877
|
-
|
|
20878
|
-
|
|
20954
|
+
conflict: relinkConflict({
|
|
20955
|
+
record: this.stateDb.getByPath(filePath),
|
|
20956
|
+
path: filePath,
|
|
20957
|
+
local,
|
|
20958
|
+
remote: finalContent,
|
|
20959
|
+
pageId: page.id,
|
|
20960
|
+
lastEdited: page.last_edited_time,
|
|
20961
|
+
title: rendered.title
|
|
20962
|
+
})
|
|
20963
|
+
};
|
|
20964
|
+
}
|
|
20965
|
+
/**
|
|
20966
|
+
* dry-run — 실제 pull 이 상태 기록에 없던 이 행 노트를 다시 잇나(P7). 받은 행을 실제 pull 처럼 렌더하되 볼트 · 상태 DB 는
|
|
20967
|
+
* 바꾸지 않고({@link ImageHandler.preview}) 견준다. 잇지 않으면 null(새 이름으로 받는다), 충돌로 남길 것이면 그 충돌.
|
|
20968
|
+
*/
|
|
20969
|
+
async plannedRowRelink(page, dbConfig, untracked) {
|
|
20970
|
+
const local = await readLocalNote(this.vaultFs, untracked.path);
|
|
20971
|
+
if (local === void 0) return null;
|
|
20972
|
+
const rendered = await this.imageHandler.preview(
|
|
20973
|
+
() => this.renderRow(page, untracked.path, { localPath: untracked.path, adoptFrom: local })
|
|
20974
|
+
);
|
|
20975
|
+
const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
|
|
20976
|
+
const outcome = relinkOutcome(
|
|
20977
|
+
untracked.existing,
|
|
20978
|
+
same,
|
|
20979
|
+
this.unlinkedVault,
|
|
20980
|
+
this.config.sync.conflictStrategy
|
|
20981
|
+
);
|
|
20982
|
+
if (outcome === null) return null;
|
|
20983
|
+
if (outcome !== "conflict") return {};
|
|
20984
|
+
return {
|
|
20985
|
+
conflict: relinkConflict({
|
|
20986
|
+
record: plannedRelinkRecord({
|
|
20987
|
+
path: untracked.path,
|
|
20879
20988
|
pageId: page.id,
|
|
20880
|
-
|
|
20989
|
+
parentId: dbConfig.databaseId,
|
|
20990
|
+
remote: rendered.content,
|
|
20881
20991
|
lastEdited: page.last_edited_time,
|
|
20882
|
-
|
|
20883
|
-
|
|
20884
|
-
|
|
20885
|
-
|
|
20886
|
-
|
|
20887
|
-
|
|
20888
|
-
|
|
20889
|
-
|
|
20992
|
+
fileType: "db-row"
|
|
20993
|
+
}),
|
|
20994
|
+
path: untracked.path,
|
|
20995
|
+
local,
|
|
20996
|
+
remote: rendered.content,
|
|
20997
|
+
pageId: page.id,
|
|
20998
|
+
lastEdited: page.last_edited_time,
|
|
20999
|
+
title: rendered.title
|
|
21000
|
+
})
|
|
20890
21001
|
};
|
|
20891
21002
|
}
|
|
20892
21003
|
/** 이번 받기에서 로컬 편집을 합친 행의 경로를 가져가고 비운다. */
|
|
@@ -23446,144 +23557,25 @@ var DatabaseDiscovery = class {
|
|
|
23446
23557
|
let deleted = 0;
|
|
23447
23558
|
let restored = 0;
|
|
23448
23559
|
try {
|
|
23449
|
-
const
|
|
23450
|
-
|
|
23451
|
-
|
|
23452
|
-
|
|
23453
|
-
|
|
23454
|
-
|
|
23455
|
-
|
|
23456
|
-
|
|
23457
|
-
|
|
23458
|
-
|
|
23459
|
-
|
|
23460
|
-
|
|
23461
|
-
|
|
23462
|
-
|
|
23463
|
-
|
|
23464
|
-
|
|
23465
|
-
|
|
23466
|
-
|
|
23467
|
-
}
|
|
23468
|
-
const folderKeys = new Map([...folderOwner].map(([folder, id]) => [pathKey(folder), id]));
|
|
23469
|
-
const claimFolder = (config) => {
|
|
23470
|
-
config.localFolder = resolveDbFolderPath(
|
|
23471
|
-
config.localFolder,
|
|
23472
|
-
config.databaseId,
|
|
23473
|
-
(f) => folderOwner.get(f) ?? folderKeys.get(pathKey(f)) ?? null
|
|
23474
|
-
);
|
|
23475
|
-
folderOwner.set(config.localFolder, config.databaseId);
|
|
23476
|
-
folderKeys.set(pathKey(config.localFolder), config.databaseId);
|
|
23477
|
-
};
|
|
23478
|
-
const inaccessibleIds = loadInaccessibleDbIds(this.stateDb);
|
|
23479
|
-
let inaccessibleChanged = false;
|
|
23480
|
-
const degrade = (rawId) => {
|
|
23481
|
-
const nohyph = rawId.replace(/-/g, "");
|
|
23482
|
-
if (!inaccessibleIds.has(nohyph)) {
|
|
23483
|
-
inaccessibleIds.add(nohyph);
|
|
23484
|
-
inaccessibleChanged = true;
|
|
23485
|
-
}
|
|
23486
|
-
getLogger().warn(
|
|
23487
|
-
`[Im-Nobsidian] DB ${rawId}: \uC811\uADFC \uAC00\uB2A5\uD55C data source \uC5C6\uC74C(\uB9C1\uD06C\uB4DC/\uBBF8\uACF5\uC720/\uC0AD\uC81C \uCD94\uC815) \u2014 \uB3D9\uAE30\uD654 \uB300\uC0C1\uC5D0\uC11C \uC81C\uC678(\uC81C\uBAA9\uC740 \uBD80\uBAA8 \uD398\uC774\uC9C0\uC5D0 \uBCF4\uC874)`
|
|
23488
|
-
);
|
|
23489
|
-
};
|
|
23490
|
-
if (forceRediscovery && inaccessibleIds.size > 0) {
|
|
23491
|
-
getLogger().info(
|
|
23492
|
-
`[Im-Nobsidian] --force: \uC811\uADFC \uBD88\uAC00 DB ${inaccessibleIds.size}\uAC1C denylist \uC7AC\uAC80\uC99D`
|
|
23493
|
-
);
|
|
23494
|
-
inaccessibleIds.clear();
|
|
23495
|
-
inaccessibleChanged = true;
|
|
23496
|
-
}
|
|
23497
|
-
const linkedMap = loadLinkedDbMap(this.stateDb);
|
|
23498
|
-
let linkedChanged = false;
|
|
23499
|
-
const linkedNow = /* @__PURE__ */ new Set();
|
|
23500
|
-
const retry = parseDiscoveryRetry(this.stateDb.getMeta(DISCOVERY_RETRY_META_KEY));
|
|
23501
|
-
const nextRetry = { parents: [], dbs: [] };
|
|
23502
|
-
const pathOf = (pageId) => notionIdsEqual(pageId, this.config.notion.rootPageId) ? "" : this.stateDb.getByNotionId(pageId)?.obsidianPath ?? pageId;
|
|
23503
|
-
const unreadParent = (pageId, error) => {
|
|
23504
|
-
nextRetry.parents.push(pageId);
|
|
23505
|
-
failed.push({
|
|
23506
|
-
path: pathOf(pageId),
|
|
23507
|
-
operation: "update",
|
|
23508
|
-
error: `\uD558\uC704 DB \uB97C \uD655\uC778\uD558\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uD655\uC778\uD55C\uB2E4: ${error}`
|
|
23509
|
-
});
|
|
23510
|
-
};
|
|
23511
|
-
const retryLater = (dbId, parentPageId, error) => {
|
|
23512
|
-
nextRetry.dbs.push({ dbId, parentPageId });
|
|
23513
|
-
failed.push({
|
|
23514
|
-
path: pathOf(parentPageId),
|
|
23515
|
-
operation: "create",
|
|
23516
|
-
error: `DB ${shortNotionId(dbId)} \uB97C \uC77D\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uBCF8\uB2E4: ${error}`
|
|
23517
|
-
});
|
|
23518
|
-
};
|
|
23519
|
-
const register = async (dbId, parentPageId) => {
|
|
23520
|
-
const nohyph = dbId.replace(/-/g, "");
|
|
23521
|
-
if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) return;
|
|
23522
|
-
const registerOriginal = async (origNohyph) => {
|
|
23523
|
-
if (knownIds.has(origNohyph) || inaccessibleIds.has(origNohyph)) return;
|
|
23524
|
-
const origId = normalizeNotionId(origNohyph);
|
|
23525
|
-
const outcome2 = await this.buildDiscoveredDbConfig(origId, parentPageId);
|
|
23526
|
-
knownIds.add(origNohyph);
|
|
23527
|
-
if (outcome2.kind === "ok") {
|
|
23528
|
-
claimFolder(outcome2.config);
|
|
23529
|
-
dbConfigs.push(outcome2.config);
|
|
23530
|
-
registeredNow.add(origNohyph);
|
|
23531
|
-
changed = true;
|
|
23532
|
-
} else if (outcome2.kind === "error") {
|
|
23533
|
-
retryLater(origId, parentPageId, outcome2.error);
|
|
23534
|
-
} else {
|
|
23535
|
-
degrade(origId);
|
|
23536
|
-
}
|
|
23537
|
-
};
|
|
23538
|
-
const knownOriginal = linkedMap.get(nohyph);
|
|
23539
|
-
if (knownOriginal) {
|
|
23540
|
-
await registerOriginal(knownOriginal);
|
|
23541
|
-
return;
|
|
23542
|
-
}
|
|
23543
|
-
const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
|
|
23544
|
-
knownIds.add(nohyph);
|
|
23545
|
-
if (outcome.kind === "ok") {
|
|
23546
|
-
claimFolder(outcome.config);
|
|
23547
|
-
dbConfigs.push(outcome.config);
|
|
23548
|
-
registeredNow.add(nohyph);
|
|
23549
|
-
changed = true;
|
|
23550
|
-
} else if (outcome.kind === "linked") {
|
|
23551
|
-
const origNohyph = outcome.originalDbId.replace(/-/g, "");
|
|
23552
|
-
linkedMap.set(nohyph, origNohyph);
|
|
23553
|
-
linkedNow.add(nohyph);
|
|
23554
|
-
linkedChanged = true;
|
|
23555
|
-
await registerOriginal(origNohyph);
|
|
23556
|
-
} else if (outcome.kind === "inaccessible") {
|
|
23557
|
-
degrade(dbId);
|
|
23558
|
-
} else if (outcome.kind === "error") {
|
|
23559
|
-
retryLater(dbId, parentPageId, outcome.error);
|
|
23560
|
-
}
|
|
23561
|
-
};
|
|
23562
|
-
const scanAll = forceRediscovery || discoveryScanPending(this.stateDb);
|
|
23563
|
-
if (signal?.aborted) {
|
|
23564
|
-
nextRetry.parents.push(...retry.parents);
|
|
23565
|
-
nextRetry.dbs.push(...retry.dbs);
|
|
23566
|
-
} else {
|
|
23567
|
-
const root = this.config.notion.rootPageId;
|
|
23568
|
-
const scanIds = scanAll ? [
|
|
23569
|
-
root,
|
|
23570
|
-
...this.stateDb.getAll().filter((r) => r.notionPageId).map((r) => r.notionPageId)
|
|
23571
|
-
].filter((id) => !this._bodyReadPages.has(normalizeNotionId(id))) : options.rescanRoot && !retry.parents.some((id) => notionIdsEqual(id, root)) ? [root, ...retry.parents] : retry.parents;
|
|
23572
|
-
if (scanIds.length > 0) {
|
|
23573
|
-
const { found: found2, unread } = await this.discoverChildDatabases(scanIds);
|
|
23574
|
-
for (const { dbId, parentPageId } of found2) {
|
|
23575
|
-
await register(dbId, parentPageId);
|
|
23576
|
-
}
|
|
23577
|
-
for (const { pageId, error } of unread) unreadParent(pageId, error);
|
|
23578
|
-
}
|
|
23579
|
-
for (const { dbId, parentPageId } of retry.dbs) {
|
|
23580
|
-
await register(dbId, parentPageId);
|
|
23581
|
-
}
|
|
23582
|
-
if (scanAll) this.stateDb.setMeta(DISCOVERY_SCANNED_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
|
|
23583
|
-
}
|
|
23584
|
-
for (const [nohyph, parentPageId] of this._inlineDbRefs) {
|
|
23585
|
-
await register(normalizeNotionId(nohyph), parentPageId);
|
|
23586
|
-
}
|
|
23560
|
+
const registry = await this.openRegistry(failed, {
|
|
23561
|
+
forceRediscovery,
|
|
23562
|
+
...options.rescanRoot !== void 0 ? { rescanRoot: options.rescanRoot } : {},
|
|
23563
|
+
...signal ? { signal } : {},
|
|
23564
|
+
markScanned: true
|
|
23565
|
+
});
|
|
23566
|
+
const {
|
|
23567
|
+
dbConfigs,
|
|
23568
|
+
configuredIds,
|
|
23569
|
+
registeredNow,
|
|
23570
|
+
inaccessibleIds,
|
|
23571
|
+
linkedMap,
|
|
23572
|
+
linkedNow,
|
|
23573
|
+
retry,
|
|
23574
|
+
nextRetry,
|
|
23575
|
+
dirty,
|
|
23576
|
+
register,
|
|
23577
|
+
degrade
|
|
23578
|
+
} = registry;
|
|
23587
23579
|
const stillSyncable = [];
|
|
23588
23580
|
const pulledWhole = /* @__PURE__ */ new Set();
|
|
23589
23581
|
let queue = [...dbConfigs];
|
|
@@ -23621,14 +23613,14 @@ var DatabaseDiscovery = class {
|
|
|
23621
23613
|
config.localFolder = move.to + config.localFolder.slice(move.from.length);
|
|
23622
23614
|
}
|
|
23623
23615
|
}
|
|
23624
|
-
|
|
23616
|
+
dirty.list = true;
|
|
23625
23617
|
}
|
|
23626
23618
|
});
|
|
23627
23619
|
if (dbResult.linkedOriginalDbId) {
|
|
23628
23620
|
linkedMap.set(nohyph, dbResult.linkedOriginalDbId.replace(/-/g, ""));
|
|
23629
23621
|
linkedNow.add(nohyph);
|
|
23630
|
-
|
|
23631
|
-
|
|
23622
|
+
dirty.linked = true;
|
|
23623
|
+
dirty.list = true;
|
|
23632
23624
|
ledger?.settle(dbConfig.databaseId);
|
|
23633
23625
|
getLogger().info(
|
|
23634
23626
|
`[Im-Nobsidian] DB ${dbConfig.databaseId}: linked view \uCEE8\uD14C\uC774\uB108 \uAC10\uC9C0 \u2192 \uD589\uC740 \uC6D0\uBCF8 ${dbResult.linkedOriginalDbId} \uD3F4\uB354\uAC00 \uB2E8\uC77C \uC18C\uC720(F25)`
|
|
@@ -23683,18 +23675,18 @@ var DatabaseDiscovery = class {
|
|
|
23683
23675
|
}
|
|
23684
23676
|
}
|
|
23685
23677
|
if (!signal?.aborted) {
|
|
23686
|
-
|
|
23678
|
+
dirty.linked = await this.pullLinkedViews(linkedMap, stillSyncable, {
|
|
23687
23679
|
pulled: (container, original) => forceRediscovery || linkedNow.has(container) || pulledWhole.has(original)
|
|
23688
|
-
}) ||
|
|
23680
|
+
}) || dirty.linked;
|
|
23689
23681
|
}
|
|
23690
23682
|
ledger?.keepOnly([...configuredIds, ...stillSyncable.map((c) => c.databaseId)]);
|
|
23691
|
-
if (
|
|
23683
|
+
if (dirty.list || dirty.inaccessible || stillSyncable.length !== dbConfigs.length) {
|
|
23692
23684
|
this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(stillSyncable));
|
|
23693
23685
|
}
|
|
23694
|
-
if (
|
|
23686
|
+
if (dirty.inaccessible) {
|
|
23695
23687
|
this.stateDb.setMeta(INACCESSIBLE_DBS_META_KEY, JSON.stringify([...inaccessibleIds]));
|
|
23696
23688
|
}
|
|
23697
|
-
if (
|
|
23689
|
+
if (dirty.linked) {
|
|
23698
23690
|
this.stateDb.setMeta(LINKED_DBS_META_KEY, JSON.stringify(Object.fromEntries(linkedMap)));
|
|
23699
23691
|
}
|
|
23700
23692
|
if (retry.parents.length + retry.dbs.length + nextRetry.parents.length + nextRetry.dbs.length > 0) {
|
|
@@ -23711,6 +23703,196 @@ var DatabaseDiscovery = class {
|
|
|
23711
23703
|
}
|
|
23712
23704
|
return { created, updated, deleted, restored };
|
|
23713
23705
|
}
|
|
23706
|
+
/**
|
|
23707
|
+
* 이번 pull 이 찾을 DB 를 등록부에 올린다 — 블록을 훑고({@link discoverChildDatabases}) 받은 본문의 `<database>` 를
|
|
23708
|
+
* 더한다. 상태 DB 에는 쓰지 않는다 — 등록부를 적는 것은 실제 pull 의 마감이고 dry-run 은 버린다. 다 훑은 표시만
|
|
23709
|
+
* `markScanned` 일 때 적는다.
|
|
23710
|
+
*
|
|
23711
|
+
* @param failed 읽지 못한 페이지 · DB — 이유와 함께 싣는다.
|
|
23712
|
+
* @param options.notePathOf 페이지 → 볼트 경로. 새 DB 폴더를 부모 노트 옆에 둔다 — dry-run 은 다시 이을 노트의 자리를 준다.
|
|
23713
|
+
*/
|
|
23714
|
+
async openRegistry(failed, options) {
|
|
23715
|
+
const { forceRediscovery, signal } = options;
|
|
23716
|
+
const { notePathOf } = options;
|
|
23717
|
+
const dbConfigs = parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY));
|
|
23718
|
+
const configuredIds = new Set(
|
|
23719
|
+
(this.config.notion.databases ?? []).map((d) => d.databaseId.replace(/-/g, ""))
|
|
23720
|
+
);
|
|
23721
|
+
const knownIds = /* @__PURE__ */ new Set([
|
|
23722
|
+
...configuredIds,
|
|
23723
|
+
...dbConfigs.map((c) => c.databaseId.replace(/-/g, ""))
|
|
23724
|
+
]);
|
|
23725
|
+
const dirty = { list: false, inaccessible: false, linked: false };
|
|
23726
|
+
const registeredNow = /* @__PURE__ */ new Set();
|
|
23727
|
+
const folderOwner = new Map(
|
|
23728
|
+
(this.config.notion.databases ?? []).map((d) => [d.localFolder, d.databaseId])
|
|
23729
|
+
);
|
|
23730
|
+
const repairedCount = repairDbFolderCollisions(dbConfigs, folderOwner);
|
|
23731
|
+
for (const c of dbConfigs) folderOwner.set(c.localFolder, c.databaseId);
|
|
23732
|
+
if (repairedCount > 0) {
|
|
23733
|
+
dirty.list = true;
|
|
23734
|
+
getLogger().info(`[Im-Nobsidian] \uB3D9\uBA85 DB \uD3F4\uB354 \uCDA9\uB3CC ${repairedCount}\uAC74 \uBD84\uB9AC(F24)`);
|
|
23735
|
+
}
|
|
23736
|
+
const folderKeys = new Map([...folderOwner].map(([folder, id]) => [pathKey(folder), id]));
|
|
23737
|
+
const claimFolder = (config) => {
|
|
23738
|
+
config.localFolder = resolveDbFolderPath(
|
|
23739
|
+
config.localFolder,
|
|
23740
|
+
config.databaseId,
|
|
23741
|
+
(f) => folderOwner.get(f) ?? folderKeys.get(pathKey(f)) ?? null
|
|
23742
|
+
);
|
|
23743
|
+
folderOwner.set(config.localFolder, config.databaseId);
|
|
23744
|
+
folderKeys.set(pathKey(config.localFolder), config.databaseId);
|
|
23745
|
+
};
|
|
23746
|
+
const inaccessibleIds = loadInaccessibleDbIds(this.stateDb);
|
|
23747
|
+
const degrade = (rawId) => {
|
|
23748
|
+
const nohyph = rawId.replace(/-/g, "");
|
|
23749
|
+
if (!inaccessibleIds.has(nohyph)) {
|
|
23750
|
+
inaccessibleIds.add(nohyph);
|
|
23751
|
+
dirty.inaccessible = true;
|
|
23752
|
+
}
|
|
23753
|
+
getLogger().warn(
|
|
23754
|
+
`[Im-Nobsidian] DB ${rawId}: \uC811\uADFC \uAC00\uB2A5\uD55C data source \uC5C6\uC74C(\uB9C1\uD06C\uB4DC/\uBBF8\uACF5\uC720/\uC0AD\uC81C \uCD94\uC815) \u2014 \uB3D9\uAE30\uD654 \uB300\uC0C1\uC5D0\uC11C \uC81C\uC678(\uC81C\uBAA9\uC740 \uBD80\uBAA8 \uD398\uC774\uC9C0\uC5D0 \uBCF4\uC874)`
|
|
23755
|
+
);
|
|
23756
|
+
};
|
|
23757
|
+
if (forceRediscovery && inaccessibleIds.size > 0) {
|
|
23758
|
+
getLogger().info(
|
|
23759
|
+
`[Im-Nobsidian] --force: \uC811\uADFC \uBD88\uAC00 DB ${inaccessibleIds.size}\uAC1C denylist \uC7AC\uAC80\uC99D`
|
|
23760
|
+
);
|
|
23761
|
+
inaccessibleIds.clear();
|
|
23762
|
+
dirty.inaccessible = true;
|
|
23763
|
+
}
|
|
23764
|
+
const linkedMap = loadLinkedDbMap(this.stateDb);
|
|
23765
|
+
const linkedNow = /* @__PURE__ */ new Set();
|
|
23766
|
+
const retry = parseDiscoveryRetry(this.stateDb.getMeta(DISCOVERY_RETRY_META_KEY));
|
|
23767
|
+
const nextRetry = { parents: [], dbs: [] };
|
|
23768
|
+
const pathOf = (pageId) => notionIdsEqual(pageId, this.config.notion.rootPageId) ? "" : this.stateDb.getByNotionId(pageId)?.obsidianPath ?? pageId;
|
|
23769
|
+
const unreadParent = (pageId, error) => {
|
|
23770
|
+
nextRetry.parents.push(pageId);
|
|
23771
|
+
failed.push({
|
|
23772
|
+
path: pathOf(pageId),
|
|
23773
|
+
operation: "update",
|
|
23774
|
+
error: `\uD558\uC704 DB \uB97C \uD655\uC778\uD558\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uD655\uC778\uD55C\uB2E4: ${error}`
|
|
23775
|
+
});
|
|
23776
|
+
};
|
|
23777
|
+
const retryLater = (dbId, parentPageId, error) => {
|
|
23778
|
+
nextRetry.dbs.push({ dbId, parentPageId });
|
|
23779
|
+
failed.push({
|
|
23780
|
+
path: pathOf(parentPageId),
|
|
23781
|
+
operation: "create",
|
|
23782
|
+
error: `DB ${shortNotionId(dbId)} \uB97C \uC77D\uC9C0 \uBABB\uD568 \u2014 \uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uBCF8\uB2E4: ${error}`
|
|
23783
|
+
});
|
|
23784
|
+
};
|
|
23785
|
+
const register = async (dbId, parentPageId) => {
|
|
23786
|
+
const nohyph = dbId.replace(/-/g, "");
|
|
23787
|
+
if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) return;
|
|
23788
|
+
const registerOriginal = async (origNohyph) => {
|
|
23789
|
+
if (knownIds.has(origNohyph) || inaccessibleIds.has(origNohyph)) return;
|
|
23790
|
+
const origId = normalizeNotionId(origNohyph);
|
|
23791
|
+
const outcome2 = await this.buildDiscoveredDbConfig(origId, parentPageId, notePathOf);
|
|
23792
|
+
knownIds.add(origNohyph);
|
|
23793
|
+
if (outcome2.kind === "ok") {
|
|
23794
|
+
claimFolder(outcome2.config);
|
|
23795
|
+
dbConfigs.push(outcome2.config);
|
|
23796
|
+
registeredNow.add(origNohyph);
|
|
23797
|
+
dirty.list = true;
|
|
23798
|
+
} else if (outcome2.kind === "error") {
|
|
23799
|
+
retryLater(origId, parentPageId, outcome2.error);
|
|
23800
|
+
} else {
|
|
23801
|
+
degrade(origId);
|
|
23802
|
+
}
|
|
23803
|
+
};
|
|
23804
|
+
const knownOriginal = linkedMap.get(nohyph);
|
|
23805
|
+
if (knownOriginal) {
|
|
23806
|
+
await registerOriginal(knownOriginal);
|
|
23807
|
+
return;
|
|
23808
|
+
}
|
|
23809
|
+
const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId, notePathOf);
|
|
23810
|
+
knownIds.add(nohyph);
|
|
23811
|
+
if (outcome.kind === "ok") {
|
|
23812
|
+
claimFolder(outcome.config);
|
|
23813
|
+
dbConfigs.push(outcome.config);
|
|
23814
|
+
registeredNow.add(nohyph);
|
|
23815
|
+
dirty.list = true;
|
|
23816
|
+
} else if (outcome.kind === "linked") {
|
|
23817
|
+
const origNohyph = outcome.originalDbId.replace(/-/g, "");
|
|
23818
|
+
linkedMap.set(nohyph, origNohyph);
|
|
23819
|
+
linkedNow.add(nohyph);
|
|
23820
|
+
dirty.linked = true;
|
|
23821
|
+
await registerOriginal(origNohyph);
|
|
23822
|
+
} else if (outcome.kind === "inaccessible") {
|
|
23823
|
+
degrade(dbId);
|
|
23824
|
+
} else if (outcome.kind === "error") {
|
|
23825
|
+
retryLater(dbId, parentPageId, outcome.error);
|
|
23826
|
+
}
|
|
23827
|
+
};
|
|
23828
|
+
const scanAll = forceRediscovery || discoveryScanPending(this.stateDb);
|
|
23829
|
+
if (signal?.aborted) {
|
|
23830
|
+
nextRetry.parents.push(...retry.parents);
|
|
23831
|
+
nextRetry.dbs.push(...retry.dbs);
|
|
23832
|
+
} else {
|
|
23833
|
+
const root = this.config.notion.rootPageId;
|
|
23834
|
+
const scanIds = scanAll ? [
|
|
23835
|
+
root,
|
|
23836
|
+
...this.stateDb.getAll().filter((r) => r.notionPageId).map((r) => r.notionPageId)
|
|
23837
|
+
].filter((id) => !this._bodyReadPages.has(normalizeNotionId(id))) : options.rescanRoot && !retry.parents.some((id) => notionIdsEqual(id, root)) ? [root, ...retry.parents] : retry.parents;
|
|
23838
|
+
if (scanIds.length > 0) {
|
|
23839
|
+
const { found: found2, unread } = await this.discoverChildDatabases(scanIds);
|
|
23840
|
+
for (const { dbId, parentPageId } of found2) {
|
|
23841
|
+
await register(dbId, parentPageId);
|
|
23842
|
+
}
|
|
23843
|
+
for (const { pageId, error } of unread) unreadParent(pageId, error);
|
|
23844
|
+
}
|
|
23845
|
+
for (const { dbId, parentPageId } of retry.dbs) {
|
|
23846
|
+
await register(dbId, parentPageId);
|
|
23847
|
+
}
|
|
23848
|
+
if (scanAll && options.markScanned) {
|
|
23849
|
+
this.stateDb.setMeta(DISCOVERY_SCANNED_META_KEY, (/* @__PURE__ */ new Date()).toISOString());
|
|
23850
|
+
}
|
|
23851
|
+
}
|
|
23852
|
+
for (const [nohyph, parentPageId] of this._inlineDbRefs) {
|
|
23853
|
+
await register(normalizeNotionId(nohyph), parentPageId);
|
|
23854
|
+
}
|
|
23855
|
+
return {
|
|
23856
|
+
dbConfigs,
|
|
23857
|
+
configuredIds,
|
|
23858
|
+
registeredNow,
|
|
23859
|
+
inaccessibleIds,
|
|
23860
|
+
linkedMap,
|
|
23861
|
+
linkedNow,
|
|
23862
|
+
retry,
|
|
23863
|
+
nextRetry,
|
|
23864
|
+
dirty,
|
|
23865
|
+
register,
|
|
23866
|
+
degrade
|
|
23867
|
+
};
|
|
23868
|
+
}
|
|
23869
|
+
/**
|
|
23870
|
+
* dry-run — 이번 pull 이 새로 찾을 DB(실제 pull 의 {@link openRegistry} 와 같은 규칙). 볼트 · 상태 DB 를 바꾸지 않는다. 받은
|
|
23871
|
+
* 본문은 dry-run 이 이미 읽은 것(다시 이을 노트 · 로컬도 고친 노트)만 본다 — {@link bodyRead}.
|
|
23872
|
+
*
|
|
23873
|
+
* @returns 새로 찾을 DB 와 그 폴더를 가를 전체 목록(발견해 둔 DB 포함).
|
|
23874
|
+
*/
|
|
23875
|
+
async plannedNewDatabases(failed, options) {
|
|
23876
|
+
if (isDatabaseMode(this.config)) return { found: [], all: [] };
|
|
23877
|
+
const registry = await this.openRegistry(failed, {
|
|
23878
|
+
forceRediscovery: false,
|
|
23879
|
+
rescanRoot: options.rescanRoot,
|
|
23880
|
+
markScanned: false,
|
|
23881
|
+
notePathOf: options.notePathOf
|
|
23882
|
+
});
|
|
23883
|
+
return {
|
|
23884
|
+
found: registry.dbConfigs.filter(
|
|
23885
|
+
(config) => registry.registeredNow.has(config.databaseId.replace(/-/g, ""))
|
|
23886
|
+
),
|
|
23887
|
+
all: registry.dbConfigs.filter(
|
|
23888
|
+
(config) => !registry.inaccessibleIds.has(config.databaseId.replace(/-/g, ""))
|
|
23889
|
+
)
|
|
23890
|
+
};
|
|
23891
|
+
}
|
|
23892
|
+
/** 이번 pull 에서 본문을 받은 페이지인가 — 그 본문의 `<database>` 로 새 DB 를 찾았다. */
|
|
23893
|
+
bodyRead(pageId) {
|
|
23894
|
+
return this._bodyReadPages.has(normalizeNotionId(pageId));
|
|
23895
|
+
}
|
|
23714
23896
|
/**
|
|
23715
23897
|
* 링크드 뷰 컨테이너마다 그 보기로 `.base` 를 쓴다({@link DatabaseSyncer.pullLinkedView}). 원본 DB 를 받은 pull ·
|
|
23716
23898
|
* 이번에 해소한 컨테이너 · 아직 `.base` 가 없는 컨테이너만 — 보기만 고친 것은 DB 수정 시각을 바꾸지 않아 원본처럼
|
|
@@ -23763,7 +23945,7 @@ var DatabaseDiscovery = class {
|
|
|
23763
23945
|
* (예: 부모 "AI Engineer (1).md" + DB "Minirecord Project" → "AI Engineer (1)/Minirecord-Project")
|
|
23764
23946
|
* - 제목 취득 실패 시 부모 폴더명 기반 안전 이름으로 폴백
|
|
23765
23947
|
*/
|
|
23766
|
-
async buildDiscoveredDbConfig(dbId, parentPageId) {
|
|
23948
|
+
async buildDiscoveredDbConfig(dbId, parentPageId, notePathOf = (pageId) => this.stateDb.getByNotionId(pageId)?.obsidianPath ?? null) {
|
|
23767
23949
|
let dbTitle;
|
|
23768
23950
|
try {
|
|
23769
23951
|
const info = await this.notionClient.getDatabaseSyncability(dbId);
|
|
@@ -23789,10 +23971,9 @@ var DatabaseDiscovery = class {
|
|
|
23789
23971
|
getLogger().warn(`[Im-Nobsidian] \uC790\uB3D9 \uBC1C\uACAC DB ${dbId} \uC124\uC815 \uC0DD\uC131 \uC2E4\uD328:`, error);
|
|
23790
23972
|
return { kind: "error", error: error instanceof Error ? error.message : String(error) };
|
|
23791
23973
|
}
|
|
23792
|
-
const
|
|
23974
|
+
const obsPath = parentPageId ? notePathOf(parentPageId) : null;
|
|
23793
23975
|
let parentFolder = "";
|
|
23794
|
-
if (
|
|
23795
|
-
const obsPath = parentEntry.obsidianPath;
|
|
23976
|
+
if (obsPath) {
|
|
23796
23977
|
if (isMarkdownPath(obsPath)) {
|
|
23797
23978
|
const parts = obsPath.split("/");
|
|
23798
23979
|
const fileBase = stripMarkdownExtension(parts.pop() ?? "");
|
|
@@ -27454,34 +27635,80 @@ var PagePuller = class {
|
|
|
27454
27635
|
);
|
|
27455
27636
|
}
|
|
27456
27637
|
/**
|
|
27457
|
-
* dry-run — 실제 pull 이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7).
|
|
27458
|
-
* 이미 있는 폴더(`folder` —
|
|
27459
|
-
*
|
|
27460
|
-
* 더한다. 볼트도 상태 DB 도
|
|
27638
|
+
* dry-run — 실제 pull({@link createPulledPage})이 이 새 페이지를 새로 받지 않고 상태 기록에 없던 노트 · 폴더와 다시 잇는가(P7).
|
|
27639
|
+
* 다시 이을 노트(`note` — 충돌로 남길 것이면 `conflict`) · 이미 있는 폴더(`folder` — 폴더로만 받는 페이지), 아니면 null.
|
|
27640
|
+
* 그 자리에 무엇이 있을 때만 본문을 받아 실제 pull 처럼 렌더해 견준다 — 미디어는 받되 볼트에 쓰지 않는다
|
|
27641
|
+
* ({@link ImageHandler.preview}). `taken` 은 이번 미리 보기가 이미 이은 자리({@link pathKey}) — 고르면 더한다. 볼트도 상태 DB 도
|
|
27642
|
+
* 바꾸지 않는다.
|
|
27461
27643
|
*/
|
|
27462
27644
|
async plannedRelink(pageId, taken) {
|
|
27463
27645
|
const page = this.detector.discoveredPage(pageId) ?? await this.notionClient.getPage(pageId);
|
|
27464
|
-
const
|
|
27646
|
+
const title = this.notionClient.extractTitle(page);
|
|
27647
|
+
const safeName = sanitizeFileName(title);
|
|
27465
27648
|
const parentPath = await this.resolveParentPath(page);
|
|
27649
|
+
if (this.excludedCreatePath(parentPath, safeName) !== null) return null;
|
|
27466
27650
|
const folder = parentPath ? `${parentPath}/${safeName}` : safeName;
|
|
27467
|
-
|
|
27468
|
-
|
|
27469
|
-
|
|
27470
|
-
|
|
27471
|
-
dir,
|
|
27472
|
-
safeName,
|
|
27473
|
-
pageId,
|
|
27474
|
-
(key) => taken.has(key)
|
|
27475
|
-
);
|
|
27476
|
-
if (found2 && relinksWithoutContent(found2, this.unlinkedVault)) {
|
|
27477
|
-
taken.add(pathKey(found2.path));
|
|
27478
|
-
return { path: found2.path, kind: "note" };
|
|
27479
|
-
}
|
|
27651
|
+
const untrackedIn = (dir) => findUntrackedNote(this.stateDb, this.vaultFs, dir, safeName, pageId, (key) => taken.has(key));
|
|
27652
|
+
const folderExists = async () => !taken.has(pathKey(folder)) && this.stateDb.getByPath(folder) === null && await this.vaultFs.exists(folder);
|
|
27653
|
+
if (!await untrackedIn(parentPath) && !await untrackedIn(folder) && !await folderExists()) {
|
|
27654
|
+
return null;
|
|
27480
27655
|
}
|
|
27481
|
-
const
|
|
27482
|
-
|
|
27483
|
-
|
|
27484
|
-
|
|
27656
|
+
const fetched = await this.fetchPageMarkdown(pageId);
|
|
27657
|
+
const hasChildPages = this.hasChildContainers(pageId, fetched.children);
|
|
27658
|
+
const folderOnly = hasChildPages && !fetched.hasBody || fetched.folderFiles !== null && (hasChildPages || fetched.folderFiles.named);
|
|
27659
|
+
if (folderOnly) {
|
|
27660
|
+
if (!await folderExists()) return null;
|
|
27661
|
+
taken.add(pathKey(folder));
|
|
27662
|
+
return { path: folder, kind: "folder" };
|
|
27663
|
+
}
|
|
27664
|
+
const found2 = await untrackedIn(hasChildPages ? folder : parentPath);
|
|
27665
|
+
if (!found2) return null;
|
|
27666
|
+
const local = await readLocalNote(this.vaultFs, found2.path);
|
|
27667
|
+
if (local === void 0) return null;
|
|
27668
|
+
const rendered = await this.imageHandler.preview(
|
|
27669
|
+
() => this.renderPageNote({
|
|
27670
|
+
pageId,
|
|
27671
|
+
page,
|
|
27672
|
+
fetched,
|
|
27673
|
+
filePath: found2.path,
|
|
27674
|
+
title,
|
|
27675
|
+
titleFollowsName: extractTitle(found2.path) === title,
|
|
27676
|
+
downloadMedia: true,
|
|
27677
|
+
local: { content: local, markers: [] },
|
|
27678
|
+
adoptLocalMedia: true
|
|
27679
|
+
})
|
|
27680
|
+
);
|
|
27681
|
+
const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
|
|
27682
|
+
const outcome = relinkOutcome(
|
|
27683
|
+
found2.existing,
|
|
27684
|
+
same,
|
|
27685
|
+
this.unlinkedVault,
|
|
27686
|
+
this.config.sync.conflictStrategy
|
|
27687
|
+
);
|
|
27688
|
+
if (outcome === null) return null;
|
|
27689
|
+
taken.add(pathKey(found2.path));
|
|
27690
|
+
if (outcome !== "conflict") return { path: found2.path, kind: "note" };
|
|
27691
|
+
const parentId = await extractParentId(this.notionClient, page);
|
|
27692
|
+
return {
|
|
27693
|
+
path: found2.path,
|
|
27694
|
+
kind: "note",
|
|
27695
|
+
conflict: relinkConflict({
|
|
27696
|
+
record: plannedRelinkRecord({
|
|
27697
|
+
path: found2.path,
|
|
27698
|
+
pageId,
|
|
27699
|
+
parentId,
|
|
27700
|
+
remote: rendered.content,
|
|
27701
|
+
lastEdited: page.last_edited_time,
|
|
27702
|
+
fileType: hasChildPages ? "folder-note" : "file"
|
|
27703
|
+
}),
|
|
27704
|
+
path: found2.path,
|
|
27705
|
+
local,
|
|
27706
|
+
remote: rendered.content,
|
|
27707
|
+
pageId,
|
|
27708
|
+
lastEdited: page.last_edited_time,
|
|
27709
|
+
title
|
|
27710
|
+
})
|
|
27711
|
+
};
|
|
27485
27712
|
}
|
|
27486
27713
|
/**
|
|
27487
27714
|
* 새 페이지가 놓일 자리가 뺀 자리인가 — 부모 폴더 · 노트 이름 · 폴더 노트가 될 때의 폴더를 본다. 받기 전에는 폴더 노트가
|
|
@@ -27564,19 +27791,19 @@ var PagePuller = class {
|
|
|
27564
27791
|
/**
|
|
27565
27792
|
* 상태 기록에 없는 노트가 이 페이지가 놓일 자리에 이미 있다 — 상태 DB 를 잃었거나 처음 잇는 볼트다(P7).
|
|
27566
27793
|
* 예전에는 남의 노트로 보고 id 를 붙인 이름으로 또 받아 모든 노트가 두 벌이 됐다(push 는 모든 페이지를 또 만들었다).
|
|
27567
|
-
*
|
|
27568
|
-
* - 글이 같으면(끝 개행만 달라도) 그 노트를 이 페이지와 다시 잇는다 — 쓰지 않는다.
|
|
27569
|
-
* - 다르면 이 페이지 id 를 단 이름(지난번 이 페이지를 받은 이름)이면 충돌 전략을 따른다: remote-first 는 원격으로 덮고 ·
|
|
27570
|
-
* local-first 는 로컬을 두어 다음 push 가 올리고 · 그 밖은 충돌로 남긴다(지난 사본이 없어 merge 도 합칠 수 없다).
|
|
27571
|
-
* - 자연 이름에 있는 다른 글은 이은 노트가 하나도 없는 볼트에서만 잇고 전략과 상관없이 충돌로 남긴다 — 다른 노트일 수 있어
|
|
27572
|
-
* 덮거나 올리면 잃는다. 이은 노트가 있는 볼트에서는 잇지 않는다(null) — 그 볼트의 새 노트일 수 있다.
|
|
27794
|
+
* 받은 글과 견줘 {@link relinkOutcome} 대로 잇는다 — 잇지 않으면 null(id 를 단 새 이름으로 받는다).
|
|
27573
27795
|
*/
|
|
27574
27796
|
relinkExistingNote(filePath, existing, local, rendered, note) {
|
|
27575
27797
|
const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
|
|
27576
|
-
|
|
27798
|
+
const outcome = relinkOutcome(
|
|
27799
|
+
existing,
|
|
27800
|
+
same,
|
|
27801
|
+
this.unlinkedVault,
|
|
27802
|
+
this.config.sync.conflictStrategy
|
|
27803
|
+
);
|
|
27804
|
+
if (outcome === null) return null;
|
|
27577
27805
|
return (async () => {
|
|
27578
|
-
|
|
27579
|
-
if (same || strategy === "local-first") {
|
|
27806
|
+
if (outcome === "relink") {
|
|
27580
27807
|
await this.recordPulledNote(filePath, note, {
|
|
27581
27808
|
content: rendered.content,
|
|
27582
27809
|
properties: rendered.properties,
|
|
@@ -27587,7 +27814,7 @@ var PagePuller = class {
|
|
|
27587
27814
|
});
|
|
27588
27815
|
return { path: filePath, relinked: true };
|
|
27589
27816
|
}
|
|
27590
|
-
if (
|
|
27817
|
+
if (outcome === "overwrite") {
|
|
27591
27818
|
await this.vaultFs.writeFile(filePath, rendered.content);
|
|
27592
27819
|
await this.recordPulledNote(filePath, note, {
|
|
27593
27820
|
content: rendered.content,
|
|
@@ -27603,27 +27830,15 @@ var PagePuller = class {
|
|
|
27603
27830
|
snapshot: null,
|
|
27604
27831
|
differsOnDisk: true
|
|
27605
27832
|
});
|
|
27606
|
-
const
|
|
27607
|
-
|
|
27608
|
-
|
|
27609
|
-
|
|
27610
|
-
|
|
27611
|
-
|
|
27612
|
-
|
|
27613
|
-
|
|
27614
|
-
|
|
27615
|
-
remoteChange: {
|
|
27616
|
-
pageId: note.pageId,
|
|
27617
|
-
type: "modified",
|
|
27618
|
-
lastEdited: note.page.last_edited_time,
|
|
27619
|
-
previousEdited: null,
|
|
27620
|
-
path: filePath,
|
|
27621
|
-
title: note.title
|
|
27622
|
-
},
|
|
27623
|
-
baseContent: null,
|
|
27624
|
-
localContent: local,
|
|
27625
|
-
remoteContent: rendered.content
|
|
27626
|
-
};
|
|
27833
|
+
const conflict = relinkConflict({
|
|
27834
|
+
record: this.stateDb.getByPath(filePath),
|
|
27835
|
+
path: filePath,
|
|
27836
|
+
local,
|
|
27837
|
+
remote: rendered.content,
|
|
27838
|
+
pageId: note.pageId,
|
|
27839
|
+
lastEdited: note.page.last_edited_time,
|
|
27840
|
+
title: note.title
|
|
27841
|
+
});
|
|
27627
27842
|
return { path: filePath, relinked: true, conflict };
|
|
27628
27843
|
})();
|
|
27629
27844
|
}
|
|
@@ -28413,7 +28628,7 @@ function resolvesNow(stateDb, record, marker, pushed) {
|
|
|
28413
28628
|
|
|
28414
28629
|
// src/sync/pull-planner.ts
|
|
28415
28630
|
var PullPlanner = class {
|
|
28416
|
-
constructor(config, stateDb, vaultFs, databaseSyncer, drift, planner, puller) {
|
|
28631
|
+
constructor(config, stateDb, vaultFs, databaseSyncer, drift, planner, puller, discovery, detector) {
|
|
28417
28632
|
this.config = config;
|
|
28418
28633
|
this.stateDb = stateDb;
|
|
28419
28634
|
this.vaultFs = vaultFs;
|
|
@@ -28421,6 +28636,8 @@ var PullPlanner = class {
|
|
|
28421
28636
|
this.drift = drift;
|
|
28422
28637
|
this.planner = planner;
|
|
28423
28638
|
this.puller = puller;
|
|
28639
|
+
this.discovery = discovery;
|
|
28640
|
+
this.detector = detector;
|
|
28424
28641
|
}
|
|
28425
28642
|
config;
|
|
28426
28643
|
stateDb;
|
|
@@ -28429,11 +28646,13 @@ var PullPlanner = class {
|
|
|
28429
28646
|
drift;
|
|
28430
28647
|
planner;
|
|
28431
28648
|
puller;
|
|
28649
|
+
discovery;
|
|
28650
|
+
detector;
|
|
28432
28651
|
/**
|
|
28433
28652
|
* pull 할 것을 세기만 한다(dry-run) — 볼트 · 상태 DB · Notion 을 바꾸지 않는다. 페이지와 DB 행을
|
|
28434
|
-
* 같이 센다. DB 는 설정한 DB 와 이미 발견해 둔 DB 가운데 실제 pull 이 조회할 것(`ledger`)
|
|
28435
|
-
*
|
|
28436
|
-
*
|
|
28653
|
+
* 같이 센다. DB 는 설정한 DB 와 이미 발견해 둔 DB 가운데 실제 pull 이 조회할 것(`ledger`)과, 이번 pull 이 새로 찾을
|
|
28654
|
+
* DB(루트 · 미리 보며 읽은 본문 안)를 센다. 세지 못한 DB 는 이유와 함께 `failed` 에 싣는다(세지 못한 것을 없다고 하지
|
|
28655
|
+
* 않는다).
|
|
28437
28656
|
*/
|
|
28438
28657
|
async planPull(filtered, restoreChanges, restoreIds, localPlan, options, startTime, scan, ledger) {
|
|
28439
28658
|
const unchangedDropped = await this.drift.withoutUnchangedRemotes(filtered, options.signal);
|
|
@@ -28459,20 +28678,32 @@ var PullPlanner = class {
|
|
|
28459
28678
|
planned.push(change);
|
|
28460
28679
|
}
|
|
28461
28680
|
const relinks = await this.plannedRelinks(planned);
|
|
28681
|
+
for (const relink of relinks.byPage.values()) {
|
|
28682
|
+
if (relink.conflict) conflicts.push(relink.conflict);
|
|
28683
|
+
}
|
|
28462
28684
|
const queued = new Set(
|
|
28463
28685
|
[...filtered, ...restoreChanges].map((change) => compactNotionId(change.pageId))
|
|
28464
28686
|
);
|
|
28465
28687
|
const movingInVault = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
|
|
28688
|
+
const relinkPaths = new Map(
|
|
28689
|
+
[...relinks.byPage].map(([pageId, relink]) => [compactNotionId(pageId), relink.path])
|
|
28690
|
+
);
|
|
28691
|
+
const newDatabases = await this.discovery.plannedNewDatabases(failed, {
|
|
28692
|
+
rescanRoot: this.detector.rootChanged(),
|
|
28693
|
+
notePathOf: (pageId) => relinkPaths.get(compactNotionId(pageId)) ?? this.stateDb.getByNotionId(pageId)?.obsidianPath ?? null
|
|
28694
|
+
});
|
|
28466
28695
|
const rows = [];
|
|
28467
28696
|
for (const row of await this.planDatabaseRows(
|
|
28468
28697
|
options.paths,
|
|
28469
28698
|
failed,
|
|
28470
28699
|
ledger,
|
|
28471
28700
|
movingInVault,
|
|
28472
|
-
relinks.taken
|
|
28701
|
+
relinks.taken,
|
|
28702
|
+
newDatabases
|
|
28473
28703
|
)) {
|
|
28474
28704
|
if (queued.has(compactNotionId(row.pageId))) continue;
|
|
28475
28705
|
if (row.resolution?.action === "conflict") conflicts.push(row.resolution.conflict);
|
|
28706
|
+
if (row.relinkConflict) conflicts.push(row.relinkConflict);
|
|
28476
28707
|
else if (row.resolution?.action === "merge") mergedPaths.push(row.path);
|
|
28477
28708
|
if (row.resolution?.action === "conflict" || row.resolution?.action === "skip") continue;
|
|
28478
28709
|
rows.push(row);
|
|
@@ -28514,7 +28745,7 @@ var PullPlanner = class {
|
|
|
28514
28745
|
const rowCount = (operation, restore = false) => rows.filter(
|
|
28515
28746
|
(row) => row.operation === operation && (row.restore ?? false) === restore && !row.relink
|
|
28516
28747
|
).length;
|
|
28517
|
-
const undiscoveredDatabases = !isDatabaseMode(this.config) &&
|
|
28748
|
+
const undiscoveredDatabases = !isDatabaseMode(this.config) && planned.some((c) => c.type === "created" && !this.discovery.bodyRead(c.pageId));
|
|
28518
28749
|
const relinkedPaths = [
|
|
28519
28750
|
...[...relinks.byPage.values()].filter((relink) => relink.kind === "note").map((relink) => relink.path),
|
|
28520
28751
|
...rows.filter((row) => row.relink).map((row) => row.path)
|
|
@@ -28617,11 +28848,12 @@ var PullPlanner = class {
|
|
|
28617
28848
|
* (`DatabaseDiscovery.pullDiscoveredDatabases`)를 같은 규칙으로 고른다. 세지 못한 DB 는 이유와
|
|
28618
28849
|
* 함께 `failed` 에 싣는다 — 세지 못한 것을 없다고 하지 않는다.
|
|
28619
28850
|
*/
|
|
28620
|
-
async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken) {
|
|
28851
|
+
async planDatabaseRows(paths, failed, ledger, movingInVault, relinkTaken, databases) {
|
|
28621
28852
|
const configured = this.config.notion.databases ?? [];
|
|
28622
28853
|
const rows = [];
|
|
28854
|
+
const found2 = new Set(databases.found);
|
|
28623
28855
|
const plan = async (dbConfig, resolveDbFolder2) => {
|
|
28624
|
-
if (!ledger.selects(dbConfig.databaseId)) {
|
|
28856
|
+
if (!found2.has(dbConfig) && !ledger.selects(dbConfig.databaseId)) {
|
|
28625
28857
|
ledger.skip();
|
|
28626
28858
|
return;
|
|
28627
28859
|
}
|
|
@@ -28646,17 +28878,9 @@ var PullPlanner = class {
|
|
|
28646
28878
|
};
|
|
28647
28879
|
for (const dbConfig of configured) await plan(dbConfig);
|
|
28648
28880
|
if (isDatabaseMode(this.config)) return rows;
|
|
28649
|
-
const discovered =
|
|
28650
|
-
repairDbFolderCollisions(
|
|
28651
|
-
discovered,
|
|
28652
|
-
new Map(configured.map((d) => [d.localFolder, d.databaseId]))
|
|
28653
|
-
);
|
|
28654
|
-
const inaccessible = loadInaccessibleDbIds(this.stateDb);
|
|
28881
|
+
const discovered = databases.all;
|
|
28655
28882
|
const resolveDbFolder = (dbId) => [...discovered, ...configured].find((c) => notionIdsEqual(c.databaseId, dbId))?.localFolder ?? null;
|
|
28656
|
-
for (const dbConfig of discovered)
|
|
28657
|
-
if (inaccessible.has(dbConfig.databaseId.replace(/-/g, ""))) continue;
|
|
28658
|
-
await plan(dbConfig, resolveDbFolder);
|
|
28659
|
-
}
|
|
28883
|
+
for (const dbConfig of discovered) await plan(dbConfig, resolveDbFolder);
|
|
28660
28884
|
return rows;
|
|
28661
28885
|
}
|
|
28662
28886
|
};
|
|
@@ -29541,7 +29765,9 @@ var SyncOrchestrator = class {
|
|
|
29541
29765
|
this.databaseSyncer,
|
|
29542
29766
|
this.drift,
|
|
29543
29767
|
this.planner,
|
|
29544
|
-
this.puller
|
|
29768
|
+
this.puller,
|
|
29769
|
+
this.discovery,
|
|
29770
|
+
this.detector
|
|
29545
29771
|
);
|
|
29546
29772
|
this.pusher = new PagePusher(
|
|
29547
29773
|
config,
|