@im-nobsidian/core 0.14.1 → 0.15.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 +247 -24
- package/dist/index.js +2279 -492
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -172,6 +172,15 @@ function withNotifications(args, mode) {
|
|
|
172
172
|
return { ...args, body: { ...args.body, notifications: { mode } } };
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
// src/types/sync.ts
|
|
176
|
+
var CONFLICT_STRATEGIES = [
|
|
177
|
+
"local-first",
|
|
178
|
+
"remote-first",
|
|
179
|
+
"manual",
|
|
180
|
+
"duplicate",
|
|
181
|
+
"merge"
|
|
182
|
+
];
|
|
183
|
+
|
|
175
184
|
// src/types/config.ts
|
|
176
185
|
var NOTION_TOKEN_PREFIX = "ntn_";
|
|
177
186
|
var DEFAULT_TITLE_PROPERTY = "Name";
|
|
@@ -246,7 +255,7 @@ var ConfigSchema = z.object({
|
|
|
246
255
|
}),
|
|
247
256
|
sync: z.object({
|
|
248
257
|
direction: z.enum(["push", "pull", "both"]).default(D.sync.direction),
|
|
249
|
-
conflictStrategy: z.enum(
|
|
258
|
+
conflictStrategy: z.enum(CONFLICT_STRATEGIES).default(D.sync.conflictStrategy),
|
|
250
259
|
autoSync: z.boolean().default(D.sync.autoSync),
|
|
251
260
|
autoSyncInterval: z.number().min(30).max(3600).default(D.sync.autoSyncInterval),
|
|
252
261
|
// 전체 대조 주기(초) — 그 사이의 pull 은 바뀐 페이지 · 행 · DB 만 받는다. 원격 삭제는 전체 대조가
|
|
@@ -3274,7 +3283,8 @@ var NotionClient2 = class _NotionClient {
|
|
|
3274
3283
|
cover: db.cover ?? null,
|
|
3275
3284
|
description: Array.isArray(db.description) ? db.description : [],
|
|
3276
3285
|
showTitle: db.show_data_source_title !== false
|
|
3277
|
-
}
|
|
3286
|
+
},
|
|
3287
|
+
...db.in_trash === true || db.archived === true ? { inTrash: true } : {}
|
|
3278
3288
|
};
|
|
3279
3289
|
return { meta, owners };
|
|
3280
3290
|
}
|
|
@@ -4943,8 +4953,72 @@ function pairLocalMoves(missing, untracked, hints) {
|
|
|
4943
4953
|
paired.add(note.path);
|
|
4944
4954
|
pairs.push({ from: note.path, to: file.path });
|
|
4945
4955
|
}
|
|
4956
|
+
const pairedTo = new Set(pairs.map((pair) => pair.to));
|
|
4957
|
+
pairs.push(
|
|
4958
|
+
...pairSimilarContent(
|
|
4959
|
+
missing.filter((note) => !paired.has(note.path)),
|
|
4960
|
+
unmatched.filter((file) => !pairedTo.has(file.path))
|
|
4961
|
+
)
|
|
4962
|
+
);
|
|
4946
4963
|
return pairs;
|
|
4947
4964
|
}
|
|
4965
|
+
var SIMILAR_CONTENT_THRESHOLD = 0.6;
|
|
4966
|
+
var MIN_SIMILAR_WORDS = 8;
|
|
4967
|
+
function wordPairs(text) {
|
|
4968
|
+
let body = text;
|
|
4969
|
+
try {
|
|
4970
|
+
body = splitFrontmatter(text).content;
|
|
4971
|
+
} catch {
|
|
4972
|
+
}
|
|
4973
|
+
const words = body.split(/\s+/).filter((word) => word.length > 0);
|
|
4974
|
+
if (words.length < MIN_SIMILAR_WORDS) return null;
|
|
4975
|
+
const counts = /* @__PURE__ */ new Map();
|
|
4976
|
+
for (let i = 1; i < words.length; i++) {
|
|
4977
|
+
const key = `${words[i - 1]} ${words[i]}`;
|
|
4978
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
4979
|
+
}
|
|
4980
|
+
return counts;
|
|
4981
|
+
}
|
|
4982
|
+
function diceOf(a, b) {
|
|
4983
|
+
let shared = 0;
|
|
4984
|
+
let sizeA = 0;
|
|
4985
|
+
let sizeB = 0;
|
|
4986
|
+
for (const count of a.values()) sizeA += count;
|
|
4987
|
+
for (const count of b.values()) sizeB += count;
|
|
4988
|
+
for (const [key, count] of a) shared += Math.min(count, b.get(key) ?? 0);
|
|
4989
|
+
return 2 * shared / (sizeA + sizeB);
|
|
4990
|
+
}
|
|
4991
|
+
function pairSimilarContent(missing, untracked) {
|
|
4992
|
+
const files = untracked.map((file) => ({ file, pairs: file.text === void 0 ? null : wordPairs(file.text) })).filter((entry) => !!entry.pairs);
|
|
4993
|
+
if (files.length === 0) return [];
|
|
4994
|
+
const candidates = [];
|
|
4995
|
+
for (const note of missing) {
|
|
4996
|
+
const notePairs = note.text === void 0 ? null : wordPairs(note.text);
|
|
4997
|
+
if (!notePairs) continue;
|
|
4998
|
+
for (const { file, pairs } of files) {
|
|
4999
|
+
const near = parentFolderOf(note.path) === parentFolderOf(file.path) || fileNameOf(note.path) === fileNameOf(file.path);
|
|
5000
|
+
if (!near) continue;
|
|
5001
|
+
const score = diceOf(notePairs, pairs);
|
|
5002
|
+
if (score >= SIMILAR_CONTENT_THRESHOLD)
|
|
5003
|
+
candidates.push({ from: note.path, to: file.path, score });
|
|
5004
|
+
}
|
|
5005
|
+
}
|
|
5006
|
+
candidates.sort(
|
|
5007
|
+
(a, b) => b.score - a.score || byPath({ path: a.from }, { path: b.from }) || byPath({ path: a.to }, { path: b.to })
|
|
5008
|
+
);
|
|
5009
|
+
const taken = /* @__PURE__ */ new Set();
|
|
5010
|
+
const result = [];
|
|
5011
|
+
for (const candidate of candidates) {
|
|
5012
|
+
if (taken.has(candidate.from) || taken.has(candidate.to)) continue;
|
|
5013
|
+
const tied = candidates.some(
|
|
5014
|
+
(other) => other !== candidate && other.score === candidate.score && (other.from === candidate.from || other.to === candidate.to) && !taken.has(other.from) && !taken.has(other.to)
|
|
5015
|
+
);
|
|
5016
|
+
taken.add(candidate.from);
|
|
5017
|
+
taken.add(candidate.to);
|
|
5018
|
+
if (!tied) result.push({ from: candidate.from, to: candidate.to });
|
|
5019
|
+
}
|
|
5020
|
+
return result;
|
|
5021
|
+
}
|
|
4948
5022
|
function hintedFolderTarget(hints, folder) {
|
|
4949
5023
|
let best = null;
|
|
4950
5024
|
let ambiguous = false;
|
|
@@ -5042,10 +5116,16 @@ var ChangeDetector = class {
|
|
|
5042
5116
|
for (const file of stats) {
|
|
5043
5117
|
const record = this.stateDb.getByPath(file.path);
|
|
5044
5118
|
const unchanged = hasRemotePage(record) && !this.movePending(record, moveOrigins) && record.localMtime === file.mtime && record.localFileSize === file.size;
|
|
5119
|
+
if (unchanged) {
|
|
5120
|
+
observed.push({ path: file.path, record, hash: null });
|
|
5121
|
+
continue;
|
|
5122
|
+
}
|
|
5123
|
+
const text = await readFile3(file.path);
|
|
5045
5124
|
observed.push({
|
|
5046
5125
|
path: file.path,
|
|
5047
5126
|
record,
|
|
5048
|
-
hash:
|
|
5127
|
+
hash: computeHash(text),
|
|
5128
|
+
...record === null ? { text } : {}
|
|
5049
5129
|
});
|
|
5050
5130
|
}
|
|
5051
5131
|
return this.assemble(observed, moveOrigins, options);
|
|
@@ -5055,7 +5135,12 @@ var ChangeDetector = class {
|
|
|
5055
5135
|
const observed = currentFiles.map((file) => {
|
|
5056
5136
|
const record = this.stateDb.getByPath(file.path);
|
|
5057
5137
|
const unchanged = hasRemotePage(record) && !this.movePending(record, moveOrigins) && Boolean(record.localMtime) && record.localMtime === file.mtime;
|
|
5058
|
-
return {
|
|
5138
|
+
return {
|
|
5139
|
+
path: file.path,
|
|
5140
|
+
record,
|
|
5141
|
+
hash: unchanged ? null : computeHash(file.content),
|
|
5142
|
+
...record === null ? { text: file.content } : {}
|
|
5143
|
+
};
|
|
5059
5144
|
});
|
|
5060
5145
|
return this.assemble(observed, moveOrigins, options);
|
|
5061
5146
|
}
|
|
@@ -5073,7 +5158,13 @@ var ChangeDetector = class {
|
|
|
5073
5158
|
const { record, hash } = file;
|
|
5074
5159
|
if (!hasRemotePage(record)) {
|
|
5075
5160
|
if (record === null) {
|
|
5076
|
-
if (!isConflictCopyPath(file.path))
|
|
5161
|
+
if (!isConflictCopyPath(file.path)) {
|
|
5162
|
+
untracked.push({
|
|
5163
|
+
path: file.path,
|
|
5164
|
+
hash,
|
|
5165
|
+
...file.text !== void 0 ? { text: file.text } : {}
|
|
5166
|
+
});
|
|
5167
|
+
}
|
|
5077
5168
|
} else
|
|
5078
5169
|
changes.push({
|
|
5079
5170
|
path: file.path,
|
|
@@ -5110,7 +5201,8 @@ var ChangeDetector = class {
|
|
|
5110
5201
|
missing.map((record) => ({
|
|
5111
5202
|
path: record.obsidianPath,
|
|
5112
5203
|
hash: record.contentHash,
|
|
5113
|
-
origin: moveOrigins.get(record.id)
|
|
5204
|
+
origin: moveOrigins.get(record.id),
|
|
5205
|
+
...record.baseSnapshot ? { text: record.baseSnapshot.toString("utf-8") } : {}
|
|
5114
5206
|
})),
|
|
5115
5207
|
untracked,
|
|
5116
5208
|
options?.hints ?? EMPTY_RENAME_HINTS
|
|
@@ -5602,6 +5694,69 @@ function decodeMarkerTarget(encoded) {
|
|
|
5602
5694
|
var MARKER_URL_CAPTURE = "((?:[^()\\s]|\\([^()]*\\))+)";
|
|
5603
5695
|
var MARKER_LABEL_CAPTURE = "([^[\\]]+)";
|
|
5604
5696
|
|
|
5697
|
+
// src/converter/anchor-links.ts
|
|
5698
|
+
function splitAnchor(target) {
|
|
5699
|
+
const at = target.indexOf("#");
|
|
5700
|
+
if (at < 0) return null;
|
|
5701
|
+
const anchor = target.slice(at + 1).trim();
|
|
5702
|
+
return anchor === "" ? null : { note: target.slice(0, at).trim(), anchor };
|
|
5703
|
+
}
|
|
5704
|
+
function anchorDisplay(note, anchor) {
|
|
5705
|
+
return [...note === "" ? [] : [note], ...anchor.split("#").map((s) => s.trim())].join(" > ");
|
|
5706
|
+
}
|
|
5707
|
+
function anchorTargetOfDisplay(text, notes, anchor) {
|
|
5708
|
+
for (const note of notes) {
|
|
5709
|
+
const prefix = note === "" ? "" : `${note} > `;
|
|
5710
|
+
if (!text.startsWith(prefix)) continue;
|
|
5711
|
+
const segments = text.slice(prefix.length).split(" > ");
|
|
5712
|
+
const last = segments[segments.length - 1];
|
|
5713
|
+
const same = anchor.startsWith("^") ? last === anchor : headingKey(last) === anchor;
|
|
5714
|
+
if (same && segments.every((s) => s.trim() !== "")) return `${note}#${segments.join("#")}`;
|
|
5715
|
+
}
|
|
5716
|
+
return null;
|
|
5717
|
+
}
|
|
5718
|
+
var BLOCK_ID_SUFFIX_RE = /(?:^|\s)\^([A-Za-z0-9-]+)\s*$/;
|
|
5719
|
+
function headingKey(text) {
|
|
5720
|
+
return text.replace(/[#|^:%[\]\\]/g, " ").replace(/\s+/g, " ").trim();
|
|
5721
|
+
}
|
|
5722
|
+
function anchorBlockId(blocks, anchor) {
|
|
5723
|
+
if (anchor.startsWith("^")) {
|
|
5724
|
+
const id = anchor.slice(1);
|
|
5725
|
+
return blocks.find((b) => BLOCK_ID_SUFFIX_RE.exec(b.text)?.[1] === id)?.id ?? null;
|
|
5726
|
+
}
|
|
5727
|
+
const segments = anchor.split("#").map(headingKey);
|
|
5728
|
+
let next = 0;
|
|
5729
|
+
let found2 = null;
|
|
5730
|
+
for (const block of blocks) {
|
|
5731
|
+
if (!block.heading || headingKey(block.text) !== segments[next]) continue;
|
|
5732
|
+
found2 = block.id;
|
|
5733
|
+
if (++next === segments.length) return found2;
|
|
5734
|
+
}
|
|
5735
|
+
return null;
|
|
5736
|
+
}
|
|
5737
|
+
function anchorOfBlock(block) {
|
|
5738
|
+
if (block.heading) return headingKey(block.text) || null;
|
|
5739
|
+
const id = BLOCK_ID_SUFFIX_RE.exec(block.text)?.[1];
|
|
5740
|
+
return id === void 0 ? null : `^${id}`;
|
|
5741
|
+
}
|
|
5742
|
+
function anchorBlockOf(block) {
|
|
5743
|
+
const body = block[block.type];
|
|
5744
|
+
return {
|
|
5745
|
+
id: block.id,
|
|
5746
|
+
heading: /^heading_[1-3]$/.test(block.type),
|
|
5747
|
+
text: (body?.rich_text ?? []).map((t) => t.plain_text ?? "").join("")
|
|
5748
|
+
};
|
|
5749
|
+
}
|
|
5750
|
+
var ANCHORED_WIKILINK_RE = /(?<!!)\[\[([^[\]|]*#[^[\]|]*?)\\?(?:\|[^[\]]+)?\]\]/g;
|
|
5751
|
+
function anchoredWikilinkTargets(markdown) {
|
|
5752
|
+
return [...new Set([...markdown.matchAll(ANCHORED_WIKILINK_RE)].map((m) => m[1].trim()))].filter(
|
|
5753
|
+
(target) => splitAnchor(target) !== null
|
|
5754
|
+
);
|
|
5755
|
+
}
|
|
5756
|
+
function notionBlockUrl(pageId, blockId) {
|
|
5757
|
+
return `https://www.notion.so/${compactNotionId(pageId)}#${compactNotionId(blockId)}`;
|
|
5758
|
+
}
|
|
5759
|
+
|
|
5605
5760
|
// src/converter/pre-processors/wikilink.ts
|
|
5606
5761
|
var WIKILINK_REGEX2 = /(?<!!)\[\[([^[\]|]+)(?:\|([^[\]]+))?\]\]/g;
|
|
5607
5762
|
function withoutTableAliasEscape(target) {
|
|
@@ -5626,6 +5781,8 @@ var WikilinkResolver = class {
|
|
|
5626
5781
|
(_match, written, display, offset) => {
|
|
5627
5782
|
const target = display === void 0 ? written : withoutTableAliasEscape(written);
|
|
5628
5783
|
const label = display ?? target;
|
|
5784
|
+
const anchored = this.anchorLink(target, display, input.context.anchors);
|
|
5785
|
+
if (anchored) return anchored;
|
|
5629
5786
|
if (this.resolve) {
|
|
5630
5787
|
const entry = this.resolve(target);
|
|
5631
5788
|
if (entry) {
|
|
@@ -5651,6 +5808,19 @@ var WikilinkResolver = class {
|
|
|
5651
5808
|
metadata: { ...input.metadata, preserveMarkers }
|
|
5652
5809
|
};
|
|
5653
5810
|
}
|
|
5811
|
+
/**
|
|
5812
|
+
* `[[노트#제목]]` · `[[노트#^블록]]` · `[[#제목]]` → `[글자](https://www.notion.so/<페이지>#<블록>)`. 글자는 별칭,
|
|
5813
|
+
* 없으면 Obsidian 이 보이는 「노트 > 제목」 — pull 이 그 글자면 별칭을 붙이지 않는다. 대상 페이지 · 블록을 못 찾으면 null.
|
|
5814
|
+
*/
|
|
5815
|
+
anchorLink(target, display, anchors) {
|
|
5816
|
+
const split = anchors ? splitAnchor(target) : null;
|
|
5817
|
+
if (!anchors || !split) return null;
|
|
5818
|
+
const pageId = split.note === "" ? anchors.selfPageId : this.resolve?.(split.note)?.notionPageId ?? null;
|
|
5819
|
+
if (!pageId) return null;
|
|
5820
|
+
const blockId = anchors.blockOf(pageId, split.anchor);
|
|
5821
|
+
if (!blockId) return null;
|
|
5822
|
+
return `[${display ?? anchorDisplay(split.note, split.anchor)}](${notionBlockUrl(pageId, blockId)})`;
|
|
5823
|
+
}
|
|
5654
5824
|
};
|
|
5655
5825
|
|
|
5656
5826
|
// src/converter/callout-types.ts
|
|
@@ -12818,6 +12988,21 @@ function collectLocalMarkerBasenames(markdown) {
|
|
|
12818
12988
|
function baseName(path) {
|
|
12819
12989
|
return path.split("/").pop() ?? path;
|
|
12820
12990
|
}
|
|
12991
|
+
function sha256Of(buffer) {
|
|
12992
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
12993
|
+
}
|
|
12994
|
+
function localEmbedTargets(note) {
|
|
12995
|
+
const targets = [...note.matchAll(/!\[\[([^\]\n]+)\]\]/g)].map((match) => match[1].trim());
|
|
12996
|
+
for (const link of scanMarkdownLinks(note)) {
|
|
12997
|
+
if (!link.isEmbed || /^[a-z][a-z0-9+.-]*:/i.test(link.url)) continue;
|
|
12998
|
+
try {
|
|
12999
|
+
targets.push(decodeURI(link.url));
|
|
13000
|
+
} catch {
|
|
13001
|
+
targets.push(link.url);
|
|
13002
|
+
}
|
|
13003
|
+
}
|
|
13004
|
+
return targets;
|
|
13005
|
+
}
|
|
12821
13006
|
function folderOf(notePath) {
|
|
12822
13007
|
return notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
|
|
12823
13008
|
}
|
|
@@ -12953,8 +13138,9 @@ var ImageHandler = class {
|
|
|
12953
13138
|
*
|
|
12954
13139
|
* @param pageId 본문을 받은 페이지 — 그 페이지에 올린 것만 되찾는다
|
|
12955
13140
|
* @param notePath 노트 경로 — 옛 캡션의 파일을 push 와 같은 사다리로 찾는다
|
|
13141
|
+
* @param adoptFrom 상태 기록에 없던 같은 자리의 노트 글(P7 다시 잇기) — {@link adoptLocalEmbed}
|
|
12956
13142
|
*/
|
|
12957
|
-
async restoreUploadedMedia(markdown, pageId, notePath) {
|
|
13143
|
+
async restoreUploadedMedia(markdown, pageId, notePath, adoptFrom) {
|
|
12958
13144
|
const spans = notionMediaSpans(markdown);
|
|
12959
13145
|
if (spans.length === 0) return markdown;
|
|
12960
13146
|
const known = pageId ? recallUploadedMedia(this.stateDb, pageId) : /* @__PURE__ */ new Map();
|
|
@@ -12962,11 +13148,51 @@ var ImageHandler = class {
|
|
|
12962
13148
|
let result = markdown;
|
|
12963
13149
|
for (const span of spans) {
|
|
12964
13150
|
const uploadedName = this.uploadedNameOf(span.url);
|
|
12965
|
-
const target = await this.knownEmbedTarget(known, span, uploadedName) ?? await this.resolveOriginalTarget(span.label, uploadedName, noteFolder);
|
|
13151
|
+
const target = await this.knownEmbedTarget(known, span, uploadedName) ?? await this.resolveOriginalTarget(span.label, uploadedName, noteFolder) ?? (adoptFrom !== void 0 && pageId ? await this.adoptLocalEmbed(span, uploadedName, adoptFrom, noteFolder, pageId) : null);
|
|
12966
13152
|
if (target) result = result.replace(span.full, () => `![[${target}]]`);
|
|
12967
13153
|
}
|
|
12968
13154
|
return result;
|
|
12969
13155
|
}
|
|
13156
|
+
/**
|
|
13157
|
+
* 상태 DB 를 잃고 다시 잇는 노트(P7, ADR-049)의 미디어 — 올린 미디어 기록도 함께 잃어, push 가 올린 그림을 사본으로 받아
|
|
13158
|
+
* 노트가 달라졌다(실측: `![[img/blue.png]]` → `![[attachments/blue-<해시>.png]]` 로 충돌). 받은 미디어의 이름과 바이트가
|
|
13159
|
+
* 노트가 가리키는 볼트 파일과 같으면 그 임베드로 되살리고 받은 것으로 적는다 — 다음 push 가 그 블록을 둔다. 바이트를
|
|
13160
|
+
* 견주므로 Notion 에서 같은 이름의 다른 파일로 바꾼 것은 되살리지 않는다. 받지 못하거나 다르면 null — 사본을 받는다.
|
|
13161
|
+
*/
|
|
13162
|
+
async adoptLocalEmbed(span, uploadedName, local, noteFolder, pageId) {
|
|
13163
|
+
if (!uploadedName || INTERNAL_FILE_URL_RE.test(span.url)) return null;
|
|
13164
|
+
const targets = localEmbedTargets(local).filter(
|
|
13165
|
+
(target) => baseName(stripAlias2(target)) === uploadedName
|
|
13166
|
+
);
|
|
13167
|
+
if (targets.length === 0) return null;
|
|
13168
|
+
let remoteHash;
|
|
13169
|
+
try {
|
|
13170
|
+
const response = await this.fetchForDownload(span.url);
|
|
13171
|
+
if (!response.ok) return null;
|
|
13172
|
+
remoteHash = sha256Of(Buffer.from(await response.arrayBuffer()));
|
|
13173
|
+
} catch {
|
|
13174
|
+
return null;
|
|
13175
|
+
}
|
|
13176
|
+
for (const target of targets) {
|
|
13177
|
+
for (const candidate of this.vaultCandidates(stripAlias2(target), noteFolder)) {
|
|
13178
|
+
let localHash;
|
|
13179
|
+
try {
|
|
13180
|
+
localHash = sha256Of(await this.vaultFs.readBinary(candidate));
|
|
13181
|
+
} catch {
|
|
13182
|
+
continue;
|
|
13183
|
+
}
|
|
13184
|
+
if (localHash !== remoteHash) continue;
|
|
13185
|
+
const fileId = notionFileIdOf(span.url);
|
|
13186
|
+
if (fileId && this.stateDb) {
|
|
13187
|
+
rememberReceivedMedia(this.stateDb, pageId, [
|
|
13188
|
+
{ fileId, target, localPath: candidate, fileHash: remoteHash }
|
|
13189
|
+
]);
|
|
13190
|
+
}
|
|
13191
|
+
return target;
|
|
13192
|
+
}
|
|
13193
|
+
}
|
|
13194
|
+
return null;
|
|
13195
|
+
}
|
|
12970
13196
|
/** 적어 둔 업로드면 되살릴 임베드 대상. 볼트에서 그 파일이 사라졌으면 null — 사본을 받는다. */
|
|
12971
13197
|
async knownEmbedTarget(known, span, uploadedName) {
|
|
12972
13198
|
const fileId = notionFileIdOf(span.url);
|
|
@@ -14785,6 +15011,136 @@ async function readLocalNote(vaultFs, path) {
|
|
|
14785
15011
|
}
|
|
14786
15012
|
}
|
|
14787
15013
|
|
|
15014
|
+
// src/conflict/merger.ts
|
|
15015
|
+
function threeWayMerge(base, local, remote) {
|
|
15016
|
+
const baseLines = base.split("\n");
|
|
15017
|
+
const localLines = local.split("\n");
|
|
15018
|
+
const remoteLines = remote.split("\n");
|
|
15019
|
+
const hunks = [
|
|
15020
|
+
...computeHunks(baseLines, localLines, 0),
|
|
15021
|
+
...computeHunks(baseLines, remoteLines, 2)
|
|
15022
|
+
];
|
|
15023
|
+
hunks.sort((a, b) => a.baseStart - b.baseStart || a.side - b.side);
|
|
15024
|
+
const merged = [];
|
|
15025
|
+
const conflicts = [];
|
|
15026
|
+
let cursor = 0;
|
|
15027
|
+
let i = 0;
|
|
15028
|
+
while (i < hunks.length) {
|
|
15029
|
+
const first = hunks[i];
|
|
15030
|
+
if (first.baseStart > cursor) {
|
|
15031
|
+
merged.push(...baseLines.slice(cursor, first.baseStart));
|
|
15032
|
+
}
|
|
15033
|
+
const regionStart = first.baseStart;
|
|
15034
|
+
let regionEnd = first.baseEnd;
|
|
15035
|
+
const group2 = [first];
|
|
15036
|
+
i++;
|
|
15037
|
+
while (i < hunks.length) {
|
|
15038
|
+
const next = hunks[i];
|
|
15039
|
+
const overlaps = next.baseStart < regionEnd || regionEnd === regionStart && next.baseStart === regionStart;
|
|
15040
|
+
if (!overlaps) break;
|
|
15041
|
+
regionEnd = Math.max(regionEnd, next.baseEnd);
|
|
15042
|
+
group2.push(next);
|
|
15043
|
+
i++;
|
|
15044
|
+
}
|
|
15045
|
+
const baseRegion = baseLines.slice(regionStart, regionEnd);
|
|
15046
|
+
const localRegion = applyHunks(baseLines, group2, 0, regionStart, regionEnd);
|
|
15047
|
+
const remoteRegion = applyHunks(baseLines, group2, 2, regionStart, regionEnd);
|
|
15048
|
+
if (arraysEqual(localRegion, baseRegion)) {
|
|
15049
|
+
merged.push(...remoteRegion);
|
|
15050
|
+
} else if (arraysEqual(remoteRegion, baseRegion)) {
|
|
15051
|
+
merged.push(...localRegion);
|
|
15052
|
+
} else if (arraysEqual(localRegion, remoteRegion)) {
|
|
15053
|
+
merged.push(...localRegion);
|
|
15054
|
+
} else {
|
|
15055
|
+
conflicts.push({ localLines: localRegion, remoteLines: remoteRegion });
|
|
15056
|
+
merged.push("<<<<<<< LOCAL");
|
|
15057
|
+
merged.push(...localRegion);
|
|
15058
|
+
merged.push("=======");
|
|
15059
|
+
merged.push(...remoteRegion);
|
|
15060
|
+
merged.push(">>>>>>> REMOTE");
|
|
15061
|
+
}
|
|
15062
|
+
cursor = regionEnd;
|
|
15063
|
+
}
|
|
15064
|
+
if (cursor < baseLines.length) {
|
|
15065
|
+
merged.push(...baseLines.slice(cursor));
|
|
15066
|
+
}
|
|
15067
|
+
return {
|
|
15068
|
+
success: conflicts.length === 0,
|
|
15069
|
+
merged: merged.join("\n"),
|
|
15070
|
+
conflicts
|
|
15071
|
+
};
|
|
15072
|
+
}
|
|
15073
|
+
function computeHunks(base, modified, side) {
|
|
15074
|
+
const matches = lcsMatches(base, modified);
|
|
15075
|
+
const hunks = [];
|
|
15076
|
+
let prevBase = -1;
|
|
15077
|
+
let prevMod = -1;
|
|
15078
|
+
const emitGap = (curBase, curMod) => {
|
|
15079
|
+
const baseLen = curBase - (prevBase + 1);
|
|
15080
|
+
const modLen = curMod - (prevMod + 1);
|
|
15081
|
+
if (baseLen > 0 || modLen > 0) {
|
|
15082
|
+
hunks.push({
|
|
15083
|
+
baseStart: prevBase + 1,
|
|
15084
|
+
baseEnd: curBase,
|
|
15085
|
+
content: modified.slice(prevMod + 1, curMod),
|
|
15086
|
+
side
|
|
15087
|
+
});
|
|
15088
|
+
}
|
|
15089
|
+
};
|
|
15090
|
+
for (const [b, m] of matches) {
|
|
15091
|
+
emitGap(b, m);
|
|
15092
|
+
prevBase = b;
|
|
15093
|
+
prevMod = m;
|
|
15094
|
+
}
|
|
15095
|
+
emitGap(base.length, modified.length);
|
|
15096
|
+
return hunks;
|
|
15097
|
+
}
|
|
15098
|
+
function applyHunks(baseLines, group2, side, regionStart, regionEnd) {
|
|
15099
|
+
const out = [];
|
|
15100
|
+
let b = regionStart;
|
|
15101
|
+
for (const hunk of group2) {
|
|
15102
|
+
if (hunk.side !== side) continue;
|
|
15103
|
+
if (hunk.baseStart > b) out.push(...baseLines.slice(b, hunk.baseStart));
|
|
15104
|
+
out.push(...hunk.content);
|
|
15105
|
+
b = hunk.baseEnd;
|
|
15106
|
+
}
|
|
15107
|
+
if (b < regionEnd) out.push(...baseLines.slice(b, regionEnd));
|
|
15108
|
+
return out;
|
|
15109
|
+
}
|
|
15110
|
+
function lcsMatches(a, b) {
|
|
15111
|
+
const n = a.length;
|
|
15112
|
+
const m = b.length;
|
|
15113
|
+
const dp = new Array(n + 1);
|
|
15114
|
+
for (let i2 = 0; i2 <= n; i2++) dp[i2] = new Uint32Array(m + 1);
|
|
15115
|
+
for (let i2 = n - 1; i2 >= 0; i2--) {
|
|
15116
|
+
const row = dp[i2];
|
|
15117
|
+
const next = dp[i2 + 1];
|
|
15118
|
+
const ai = a[i2];
|
|
15119
|
+
for (let j2 = m - 1; j2 >= 0; j2--) {
|
|
15120
|
+
row[j2] = ai === b[j2] ? next[j2 + 1] + 1 : Math.max(next[j2], row[j2 + 1]);
|
|
15121
|
+
}
|
|
15122
|
+
}
|
|
15123
|
+
const matches = [];
|
|
15124
|
+
let i = 0;
|
|
15125
|
+
let j = 0;
|
|
15126
|
+
while (i < n && j < m) {
|
|
15127
|
+
if (a[i] === b[j]) {
|
|
15128
|
+
matches.push([i, j]);
|
|
15129
|
+
i++;
|
|
15130
|
+
j++;
|
|
15131
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
15132
|
+
i++;
|
|
15133
|
+
} else {
|
|
15134
|
+
j++;
|
|
15135
|
+
}
|
|
15136
|
+
}
|
|
15137
|
+
return matches;
|
|
15138
|
+
}
|
|
15139
|
+
function arraysEqual(a, b) {
|
|
15140
|
+
if (a.length !== b.length) return false;
|
|
15141
|
+
return a.every((val, idx) => val === b[idx]);
|
|
15142
|
+
}
|
|
15143
|
+
|
|
14788
15144
|
// src/sync/conflict-detector.ts
|
|
14789
15145
|
function sameNoteContent(a, b) {
|
|
14790
15146
|
return a === b || a.replace(/\n+$/, "") === b.replace(/\n+$/, "");
|
|
@@ -14812,6 +15168,10 @@ function resolvePullConflict(input) {
|
|
|
14812
15168
|
if (strategy === "local-first") {
|
|
14813
15169
|
return { action: "skip", localHash };
|
|
14814
15170
|
}
|
|
15171
|
+
if (strategy === "merge" && baseContent !== null) {
|
|
15172
|
+
const result = threeWayMerge(baseContent, localContent, remoteContent);
|
|
15173
|
+
if (result.success) return { action: "merge", merged: result.merged, localHash };
|
|
15174
|
+
}
|
|
14815
15175
|
const conflict = {
|
|
14816
15176
|
syncRecord: record,
|
|
14817
15177
|
localChange: {
|
|
@@ -14828,49 +15188,211 @@ function resolvePullConflict(input) {
|
|
|
14828
15188
|
return { action: "conflict", conflict, localHash };
|
|
14829
15189
|
}
|
|
14830
15190
|
|
|
14831
|
-
// src/
|
|
14832
|
-
|
|
14833
|
-
|
|
14834
|
-
|
|
14835
|
-
|
|
14836
|
-
|
|
14837
|
-
|
|
14838
|
-
|
|
14839
|
-
|
|
14840
|
-
|
|
14841
|
-
}
|
|
14842
|
-
|
|
14843
|
-
|
|
14844
|
-
|
|
14845
|
-
|
|
14846
|
-
|
|
14847
|
-
|
|
14848
|
-
|
|
14849
|
-
return { action: "kept" };
|
|
14850
|
-
}
|
|
14851
|
-
if (decision.action === "conflict") {
|
|
14852
|
-
stateDb.updateStatus(record.id, "conflict");
|
|
14853
|
-
return {
|
|
14854
|
-
action: "conflict",
|
|
14855
|
-
conflict: remoteDeletionConflict(record, decision.local, options.remoteChange)
|
|
14856
|
-
};
|
|
15191
|
+
// src/converter/notion-id-links.ts
|
|
15192
|
+
var NOTION_ID_WIKILINK_RE = /\[\[notion:([a-f0-9]{32}|[a-f0-9-]{36})(?:(\\?\|)([^[\]]+))?\]\]/g;
|
|
15193
|
+
var compactId = (id) => id.replace(/-/g, "");
|
|
15194
|
+
var PENDING_LINK_RE = new RegExp(
|
|
15195
|
+
`${NOTION_ID_WIKILINK_RE.source}|\\b([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}|[a-f0-9]{32})\\b`,
|
|
15196
|
+
"g"
|
|
15197
|
+
);
|
|
15198
|
+
function sameAsPendingLinks(local, remote) {
|
|
15199
|
+
if (!PENDING_LINK_RE.test(remote)) return false;
|
|
15200
|
+
PENDING_LINK_RE.lastIndex = 0;
|
|
15201
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15202
|
+
let pattern = "";
|
|
15203
|
+
let last = 0;
|
|
15204
|
+
for (const match of remote.matchAll(PENDING_LINK_RE)) {
|
|
15205
|
+
pattern += escape(remote.slice(last, match.index));
|
|
15206
|
+
const alias = match[3];
|
|
15207
|
+
pattern += match[4] ? `"?\\[\\[[^\\]\\n]+\\]\\]"?` : alias ? `(?:\\[\\[(?:[^\\]\\n|]*/)?${escape(alias)}(?:\\\\?\\|[^\\]\\n]*)?\\]\\]|\\[\\[[^\\]\\n|]+\\\\?\\|${escape(alias)}\\]\\]|\\[${escape(alias)}\\]\\([^)\\n]+\\))` : `(?:\\[\\[[^\\]\\n]+\\]\\]|\\[[^\\]\\n]+\\]\\([^)\\n]+\\))`;
|
|
15208
|
+
last = match.index + match[0].length;
|
|
14857
15209
|
}
|
|
14858
|
-
|
|
14859
|
-
return {
|
|
14860
|
-
}
|
|
14861
|
-
async function decideRemoteDeletion(vaultFs, record, options) {
|
|
14862
|
-
const local = options.deleteFile ? await unsyncedLocalEdit(vaultFs, record) : null;
|
|
14863
|
-
if (local === null || options.strategy === "remote-first") return { action: "deleted" };
|
|
14864
|
-
return options.strategy === "local-first" ? { action: "kept" } : { action: "conflict", local };
|
|
15210
|
+
pattern += escape(remote.slice(last).replace(/\n+$/, ""));
|
|
15211
|
+
return new RegExp(`^${pattern}\\n*$`).test(local.replace(/\n+$/, ""));
|
|
14865
15212
|
}
|
|
14866
|
-
|
|
14867
|
-
|
|
14868
|
-
|
|
14869
|
-
|
|
14870
|
-
|
|
14871
|
-
|
|
14872
|
-
|
|
14873
|
-
|
|
15213
|
+
function resolveNotionIdWikilinks(markdown, lookup) {
|
|
15214
|
+
let resolved = 0;
|
|
15215
|
+
const out = markdown.replace(
|
|
15216
|
+
NOTION_ID_WIKILINK_RE,
|
|
15217
|
+
(match, id, separator, rawAlias) => {
|
|
15218
|
+
const path = lookup(id);
|
|
15219
|
+
if (!path) return match;
|
|
15220
|
+
resolved++;
|
|
15221
|
+
const base = wikilinkTitleFromPath(path);
|
|
15222
|
+
const alias = rawAlias === compactId(id) ? void 0 : rawAlias;
|
|
15223
|
+
const link = formatWikilink(base, alias);
|
|
15224
|
+
return separator === "\\|" ? link.replace("|", separator) : link;
|
|
15225
|
+
}
|
|
15226
|
+
);
|
|
15227
|
+
return { markdown: out, resolved };
|
|
15228
|
+
}
|
|
15229
|
+
function degradeUnresolvedNotionIdWikilinks(markdown, titles = /* @__PURE__ */ new Map()) {
|
|
15230
|
+
let degraded = 0;
|
|
15231
|
+
const out = markdown.replace(
|
|
15232
|
+
NOTION_ID_WIKILINK_RE,
|
|
15233
|
+
(_match, id, _separator, alias) => {
|
|
15234
|
+
degraded++;
|
|
15235
|
+
const compact = compactId(id);
|
|
15236
|
+
return `[${alias ?? (titles.get(compact) || compact)}](https://www.notion.so/${compact})`;
|
|
15237
|
+
}
|
|
15238
|
+
);
|
|
15239
|
+
return { markdown: out, degraded };
|
|
15240
|
+
}
|
|
15241
|
+
function unlabeledNotionIds(markdown) {
|
|
15242
|
+
const ids = /* @__PURE__ */ new Set();
|
|
15243
|
+
for (const m of markdown.matchAll(NOTION_ID_WIKILINK_RE)) {
|
|
15244
|
+
if (m[3] === void 0) ids.add(compactId(m[1]));
|
|
15245
|
+
}
|
|
15246
|
+
return [...ids];
|
|
15247
|
+
}
|
|
15248
|
+
function linkLabelOfTitle(title) {
|
|
15249
|
+
return title.replace(/[\r\n]+/g, " ").trim().replace(/\[/g, "(").replace(/\]/g, ")").replace(/[\\*_`~]/g, "\\$&");
|
|
15250
|
+
}
|
|
15251
|
+
var NOTION_RELATIVE_PAGE_LINK_RE = /\[([^\]]+)\]\(\/(?:p\/)?([a-f0-9]{32})(?:\?[^)#]*)?(?:#([^)]*))?\)/g;
|
|
15252
|
+
function resolveNotionRelativePageLinks(markdown, lookup) {
|
|
15253
|
+
let resolved = 0;
|
|
15254
|
+
const out = markdown.replace(NOTION_RELATIVE_PAGE_LINK_RE, (match, label, id) => {
|
|
15255
|
+
const path = lookup(id);
|
|
15256
|
+
if (!path) return match;
|
|
15257
|
+
resolved++;
|
|
15258
|
+
return formatWikilink(wikilinkTitleFromPath(path), label);
|
|
15259
|
+
});
|
|
15260
|
+
return { markdown: out, resolved };
|
|
15261
|
+
}
|
|
15262
|
+
function degradeUnresolvedNotionRelativePageLinks(markdown) {
|
|
15263
|
+
let degraded = 0;
|
|
15264
|
+
const out = markdown.replace(
|
|
15265
|
+
NOTION_RELATIVE_PAGE_LINK_RE,
|
|
15266
|
+
(_match, label, id, anchor) => {
|
|
15267
|
+
degraded++;
|
|
15268
|
+
return `[${label}](https://www.notion.so/${id}${anchor === void 0 ? "" : `#${anchor}`})`;
|
|
15269
|
+
}
|
|
15270
|
+
);
|
|
15271
|
+
return { markdown: out, degraded };
|
|
15272
|
+
}
|
|
15273
|
+
var NOTION_BLOCK_LINK_RE = new RegExp(
|
|
15274
|
+
`\\[${MARKER_LABEL_CAPTURE}\\]\\(https?:\\/\\/(?:www\\.|app\\.)?notion\\.(?:so|com)\\/(?:p\\/)?([a-f0-9]{32})#([a-f0-9]{32}|[a-f0-9-]{36})\\)`,
|
|
15275
|
+
"g"
|
|
15276
|
+
);
|
|
15277
|
+
function noteForms(path, self) {
|
|
15278
|
+
const parts = path.replace(/\.md$/i, "").split("/");
|
|
15279
|
+
return [...parts.map((_, i) => parts.slice(i).join("/")), ...self ? [""] : []];
|
|
15280
|
+
}
|
|
15281
|
+
function notionBlockLinkIds(markdown, lookup) {
|
|
15282
|
+
const ids = /* @__PURE__ */ new Set();
|
|
15283
|
+
for (const m of markdown.matchAll(NOTION_BLOCK_LINK_RE)) {
|
|
15284
|
+
if (lookup(m[2])) ids.add(compactId(m[3]));
|
|
15285
|
+
}
|
|
15286
|
+
return [...ids];
|
|
15287
|
+
}
|
|
15288
|
+
function resolveNotionBlockLinks(markdown, lookup, anchorOf, selfPageId = null) {
|
|
15289
|
+
let resolved = 0;
|
|
15290
|
+
const self = selfPageId === null ? null : compactId(selfPageId);
|
|
15291
|
+
const out = markdown.replace(
|
|
15292
|
+
NOTION_BLOCK_LINK_RE,
|
|
15293
|
+
(match, label, pageId, blockId, offset) => {
|
|
15294
|
+
const path = lookup(pageId);
|
|
15295
|
+
const anchor = anchorOf(compactId(blockId));
|
|
15296
|
+
if (!path || !anchor) return match;
|
|
15297
|
+
resolved++;
|
|
15298
|
+
const note = compactId(pageId) === self ? "" : wikilinkTitleFromPath(path);
|
|
15299
|
+
const text = label.replace(/\\([>^])/g, "$1");
|
|
15300
|
+
const written = anchorTargetOfDisplay(text, noteForms(path, note === ""), anchor);
|
|
15301
|
+
const link = written ? formatWikilink(written) : formatWikilink(`${note}#${anchor}`, text);
|
|
15302
|
+
const line = markdown.slice(markdown.lastIndexOf("\n", offset) + 1, offset);
|
|
15303
|
+
return line.trimStart().startsWith("|") ? link.replace("|", "\\|") : link;
|
|
15304
|
+
}
|
|
15305
|
+
);
|
|
15306
|
+
return { markdown: out, resolved };
|
|
15307
|
+
}
|
|
15308
|
+
|
|
15309
|
+
// src/sync/update-journal.ts
|
|
15310
|
+
var UpdateJournal = class {
|
|
15311
|
+
constructor(stateDb) {
|
|
15312
|
+
this.stateDb = stateDb;
|
|
15313
|
+
}
|
|
15314
|
+
stateDb;
|
|
15315
|
+
/**
|
|
15316
|
+
* 보내기 전에 적는다. 같은 노트의 끊긴 기록은 마친 것으로 돌린다 — 이번에 보내는 글이 그것을 대신한다.
|
|
15317
|
+
* @param sentHash 보내는 노트 글의 해시(`computeHash(content)`).
|
|
15318
|
+
*/
|
|
15319
|
+
begin(recordId, sentHash) {
|
|
15320
|
+
this.finish(recordId);
|
|
15321
|
+
this.stateDb.recordPendingOperation({
|
|
15322
|
+
syncStateId: recordId,
|
|
15323
|
+
operation: "update",
|
|
15324
|
+
direction: "push",
|
|
15325
|
+
payload: JSON.stringify({ hash: sentHash })
|
|
15326
|
+
});
|
|
15327
|
+
}
|
|
15328
|
+
/** 상태 DB 에 적었다 — 끊긴 기록을 마친다. */
|
|
15329
|
+
finish(recordId) {
|
|
15330
|
+
for (const op of this.stateDb.getIncompletePendingOperations()) {
|
|
15331
|
+
if (op.syncStateId === recordId && op.operation === "update") {
|
|
15332
|
+
this.stateDb.markPendingCompleted(op.id);
|
|
15333
|
+
}
|
|
15334
|
+
}
|
|
15335
|
+
}
|
|
15336
|
+
/**
|
|
15337
|
+
* 원격이 이 노트를 쓰다 끊긴 그대로인가.
|
|
15338
|
+
* @param localHash 지금 노트 글의 해시 — 그때 보내던 글과 같아야 한다. 그 뒤 고쳤으면 끊긴 쓰기로 보지 않는다.
|
|
15339
|
+
* @param botUserId 이 토큰의 봇 id. 모르면 가를 수 없어 아니다.
|
|
15340
|
+
*/
|
|
15341
|
+
wroteRemote(record, remote, localHash, botUserId) {
|
|
15342
|
+
if (botUserId === null || remote.last_edited_by?.id !== botUserId) return false;
|
|
15343
|
+
const op = this.stateDb.getIncompleteOpByState(record.id, "update");
|
|
15344
|
+
if (!op?.payload) return false;
|
|
15345
|
+
try {
|
|
15346
|
+
return JSON.parse(op.payload).hash === localHash;
|
|
15347
|
+
} catch {
|
|
15348
|
+
return false;
|
|
15349
|
+
}
|
|
15350
|
+
}
|
|
15351
|
+
};
|
|
15352
|
+
|
|
15353
|
+
// src/sync/remote-deletion.ts
|
|
15354
|
+
async function remotePresence(client, pageId) {
|
|
15355
|
+
let page;
|
|
15356
|
+
try {
|
|
15357
|
+
page = await client.getPage(pageId);
|
|
15358
|
+
} catch (error) {
|
|
15359
|
+
if (isNotionObjectNotFound(error)) return { kind: "gone" };
|
|
15360
|
+
throw error;
|
|
15361
|
+
}
|
|
15362
|
+
return isTrashedOrArchived(page) ? { kind: "gone" } : { kind: "alive", page };
|
|
15363
|
+
}
|
|
15364
|
+
async function applyRemoteDeletion(stateDb, vaultFs, record, options) {
|
|
15365
|
+
const decision = await decideRemoteDeletion(vaultFs, record, options);
|
|
15366
|
+
if (decision.action === "kept") {
|
|
15367
|
+
untrack(stateDb, record);
|
|
15368
|
+
getLogger().info(
|
|
15369
|
+
`[Im-Nobsidian] Notion \uC5D0\uC11C \uC9C0\uC6B4 ${record.obsidianPath} \uC5D0 \uC62C\uB9AC\uC9C0 \uC54A\uC740 \uB85C\uCEEC \uD3B8\uC9D1\uC774 \uC788\uC5B4 \uD30C\uC77C\uC744 \uB460 \u2014 \uB2E4\uC74C push \uAC00 \uC0C8 \uD398\uC774\uC9C0\uB85C \uB9CC\uB4E0\uB2E4(local-first)`
|
|
15370
|
+
);
|
|
15371
|
+
return { action: "kept" };
|
|
15372
|
+
}
|
|
15373
|
+
if (decision.action === "conflict") {
|
|
15374
|
+
stateDb.updateStatus(record.id, "conflict");
|
|
15375
|
+
return {
|
|
15376
|
+
action: "conflict",
|
|
15377
|
+
conflict: remoteDeletionConflict(record, decision.local, options.remoteChange)
|
|
15378
|
+
};
|
|
15379
|
+
}
|
|
15380
|
+
await removeTrackedNote(stateDb, vaultFs, record, options.deleteFile);
|
|
15381
|
+
return { action: "deleted" };
|
|
15382
|
+
}
|
|
15383
|
+
async function decideRemoteDeletion(vaultFs, record, options) {
|
|
15384
|
+
const local = options.deleteFile ? await unsyncedLocalEdit(vaultFs, record) : null;
|
|
15385
|
+
if (local === null || options.strategy === "remote-first") return { action: "deleted" };
|
|
15386
|
+
return options.strategy === "local-first" ? { action: "kept" } : { action: "conflict", local };
|
|
15387
|
+
}
|
|
15388
|
+
async function removeTrackedNote(stateDb, vaultFs, record, deleteFile) {
|
|
15389
|
+
if (deleteFile) {
|
|
15390
|
+
try {
|
|
15391
|
+
await vaultFs.deleteFile(record.obsidianPath);
|
|
15392
|
+
} catch {
|
|
15393
|
+
}
|
|
15394
|
+
}
|
|
15395
|
+
untrack(stateDb, record);
|
|
14874
15396
|
}
|
|
14875
15397
|
function untrack(stateDb, record) {
|
|
14876
15398
|
stateDb.transaction(() => {
|
|
@@ -15090,6 +15612,12 @@ function isDirectDbRowPath(localFolder, path) {
|
|
|
15090
15612
|
const rel = path.slice(prefix.length);
|
|
15091
15613
|
return rel.length > 0 && !rel.includes("/");
|
|
15092
15614
|
}
|
|
15615
|
+
function databaseFolderName(title) {
|
|
15616
|
+
return truncateBytes(
|
|
15617
|
+
title.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-"),
|
|
15618
|
+
FILE_SYSTEM_NAME_MAX_BYTES
|
|
15619
|
+
);
|
|
15620
|
+
}
|
|
15093
15621
|
|
|
15094
15622
|
// src/sync/discovered-databases.ts
|
|
15095
15623
|
var ROOT_DATABASES_FOLDER = "databases";
|
|
@@ -15706,6 +16234,25 @@ var DbBaseFiles = class {
|
|
|
15706
16234
|
this.loadedRaw = JSON.stringify(Object.fromEntries(entries));
|
|
15707
16235
|
this.stateDb.setMeta(DB_BASE_FILES_META_KEY, this.loadedRaw);
|
|
15708
16236
|
}
|
|
16237
|
+
/** 기록을 지운다 — Notion 에서 지운 링크드 뷰. */
|
|
16238
|
+
delete(databaseId) {
|
|
16239
|
+
const entries = this.load();
|
|
16240
|
+
if (!entries.delete(compactNotionId(databaseId))) return;
|
|
16241
|
+
this.loadedRaw = JSON.stringify(Object.fromEntries(entries));
|
|
16242
|
+
this.stateDb.setMeta(DB_BASE_FILES_META_KEY, this.loadedRaw);
|
|
16243
|
+
}
|
|
16244
|
+
/** 이 DB 를 원본으로 둔 링크드 뷰의 `.base` 경로. */
|
|
16245
|
+
linkedBasesOf(databaseId) {
|
|
16246
|
+
const owner = compactNotionId(databaseId);
|
|
16247
|
+
return [...this.load().values()].filter((file) => file.linkedTo === owner).map((file) => file.basePath);
|
|
16248
|
+
}
|
|
16249
|
+
/** 이 경로에 쓴 링크드 뷰 컨테이너(줄인 id) — 없으면 null. */
|
|
16250
|
+
linkedContainerAt(basePath) {
|
|
16251
|
+
for (const [id, file] of this.load()) {
|
|
16252
|
+
if (file.linkedTo && file.basePath === basePath) return id;
|
|
16253
|
+
}
|
|
16254
|
+
return null;
|
|
16255
|
+
}
|
|
15709
16256
|
/** 상태 메타에서 읽는다 — 값이 그대로면 읽어 둔 것을 쓴다. 없거나 깨졌으면 비어 있다 — 이번 실행이 기록하는 것부터 다시 쌓는다. */
|
|
15710
16257
|
load() {
|
|
15711
16258
|
const raw = this.stateDb.getMeta(DB_BASE_FILES_META_KEY);
|
|
@@ -15726,7 +16273,9 @@ var DbBaseFiles = class {
|
|
|
15726
16273
|
title: entry.title,
|
|
15727
16274
|
...typeof entry.hash === "string" ? { hash: entry.hash } : {},
|
|
15728
16275
|
...Array.isArray(entry.views) ? { views: entry.views } : {},
|
|
15729
|
-
...typeof entry.rest === "string" ? { rest: entry.rest } : {}
|
|
16276
|
+
...typeof entry.rest === "string" ? { rest: entry.rest } : {},
|
|
16277
|
+
...typeof entry.docRest === "string" ? { docRest: entry.docRest } : {},
|
|
16278
|
+
...typeof entry.linkedTo === "string" ? { linkedTo: entry.linkedTo } : {}
|
|
15730
16279
|
})
|
|
15731
16280
|
);
|
|
15732
16281
|
}
|
|
@@ -15742,7 +16291,9 @@ function normalize(info) {
|
|
|
15742
16291
|
title: info.title,
|
|
15743
16292
|
...info.hash ? { hash: info.hash } : {},
|
|
15744
16293
|
...info.views && info.views.length > 0 ? { views: info.views } : {},
|
|
15745
|
-
...info.views && info.views.length > 0 && info.rest ? { rest: info.rest } : {}
|
|
16294
|
+
...info.views && info.views.length > 0 && info.rest ? { rest: info.rest } : {},
|
|
16295
|
+
...info.views && info.views.length > 0 && info.docRest ? { docRest: info.docRest } : {},
|
|
16296
|
+
...info.linkedTo ? { linkedTo: info.linkedTo } : {}
|
|
15746
16297
|
};
|
|
15747
16298
|
}
|
|
15748
16299
|
|
|
@@ -15808,8 +16359,22 @@ function matchViews(records, views) {
|
|
|
15808
16359
|
});
|
|
15809
16360
|
return out;
|
|
15810
16361
|
}
|
|
16362
|
+
var without = (from, keys) => Object.fromEntries(Object.entries(from).filter(([key]) => !keys.includes(key)));
|
|
16363
|
+
function viewRestOf(view) {
|
|
16364
|
+
return computeHash(canonical(without(view, PUSHABLE_KEYS)));
|
|
16365
|
+
}
|
|
16366
|
+
function docRestOf(parsed) {
|
|
16367
|
+
return computeHash(canonical(without(parsed.doc, ["views"])));
|
|
16368
|
+
}
|
|
16369
|
+
function restUnchanged(parsed, matched, base) {
|
|
16370
|
+
const records = base.views ?? [];
|
|
16371
|
+
if (base.docRest === void 0 || records.some((record) => record.rest === void 0)) {
|
|
16372
|
+
return matched.filter(Boolean).length === records.length && restSignature(parsed, matched) === base.rest;
|
|
16373
|
+
}
|
|
16374
|
+
if (docRestOf(parsed) !== base.docRest) return false;
|
|
16375
|
+
return parsed.views.every((view, i) => !matched[i] || viewRestOf(view) === matched[i].rest);
|
|
16376
|
+
}
|
|
15811
16377
|
function restSignature(parsed, matched) {
|
|
15812
|
-
const without = (from, keys) => Object.fromEntries(Object.entries(from).filter(([key]) => !keys.includes(key)));
|
|
15813
16378
|
const views = {};
|
|
15814
16379
|
parsed.views.forEach((view, i) => {
|
|
15815
16380
|
const key = matched[i] ? `id:${matched[i].viewId}` : `new:${String(view["name"])}`;
|
|
@@ -15841,10 +16406,11 @@ function recordBaseViews(content, written, views) {
|
|
|
15841
16406
|
type: String(parsed.views[i]["type"]),
|
|
15842
16407
|
...pushableOf(parsed.views[i]),
|
|
15843
16408
|
...source.lossyFilter ? { lossyFilter: true } : {},
|
|
15844
|
-
notion: viewFingerprint(view)
|
|
16409
|
+
notion: viewFingerprint(view),
|
|
16410
|
+
rest: viewRestOf(parsed.views[i])
|
|
15845
16411
|
});
|
|
15846
16412
|
}
|
|
15847
|
-
return { views: records, rest: restSignature(parsed, records) };
|
|
16413
|
+
return { views: records, rest: restSignature(parsed, records), docRest: docRestOf(parsed) };
|
|
15848
16414
|
}
|
|
15849
16415
|
function canonical(value) {
|
|
15850
16416
|
return JSON.stringify(sortKeys3(value)) ?? "null";
|
|
@@ -16091,6 +16657,7 @@ var SidecarSchemaEdits = class {
|
|
|
16091
16657
|
hash,
|
|
16092
16658
|
properties,
|
|
16093
16659
|
payload: diff.payload,
|
|
16660
|
+
...diff.staged ? { staged: diff.staged, optionRenames: diff.optionRenames } : {},
|
|
16094
16661
|
edit: { path: base.path, ...diff.edit },
|
|
16095
16662
|
seen: this.ledger.get(databaseId) ? null : seenSchemaOf(meta)
|
|
16096
16663
|
});
|
|
@@ -16104,6 +16671,12 @@ var SidecarSchemaEdits = class {
|
|
|
16104
16671
|
if (edit.seen) this.ledger.set(edit.databaseId, edit.seen);
|
|
16105
16672
|
let after;
|
|
16106
16673
|
try {
|
|
16674
|
+
if (edit.staged) {
|
|
16675
|
+
await this.notionClient.updateDataSourceProperties(edit.dataSourceId, edit.staged);
|
|
16676
|
+
for (const rename2 of edit.optionRenames ?? []) {
|
|
16677
|
+
await this.moveOptionValues(edit.databaseId, rename2);
|
|
16678
|
+
}
|
|
16679
|
+
}
|
|
16107
16680
|
after = await this.notionClient.updateDataSourceProperties(edit.dataSourceId, edit.payload);
|
|
16108
16681
|
} catch (error) {
|
|
16109
16682
|
failed.push({
|
|
@@ -16125,6 +16698,30 @@ var SidecarSchemaEdits = class {
|
|
|
16125
16698
|
}
|
|
16126
16699
|
return { applied, failed };
|
|
16127
16700
|
}
|
|
16701
|
+
/**
|
|
16702
|
+
* 옛 옵션 값을 가진 Notion 행을 새 이름으로 옮긴다. 중간에 멈추면 두 이름이 다 남는다 — 사이드카 기록을 고치지 않아
|
|
16703
|
+
* 다음 push 가 같은 이름 바꾸기를 다시 해 남은 행을 마저 옮긴다.
|
|
16704
|
+
*/
|
|
16705
|
+
async moveOptionValues(databaseId, rename2) {
|
|
16706
|
+
const { propertyId, type, from, to } = rename2;
|
|
16707
|
+
const filter = type === "select" ? { property: propertyId, select: { equals: from } } : { property: propertyId, multi_select: { contains: from } };
|
|
16708
|
+
const rows = await this.notionClient.queryAllDatabasePages(databaseId, filter);
|
|
16709
|
+
for (const row of rows) {
|
|
16710
|
+
let value;
|
|
16711
|
+
if (type === "select") {
|
|
16712
|
+
value = { name: to };
|
|
16713
|
+
} else {
|
|
16714
|
+
const current = Object.values(row.properties).find(
|
|
16715
|
+
(prop) => prop?.id === propertyId
|
|
16716
|
+
);
|
|
16717
|
+
const names = (current?.multi_select ?? []).map(
|
|
16718
|
+
(option) => option.name === from ? to : option.name ?? ""
|
|
16719
|
+
);
|
|
16720
|
+
value = [...new Set(names.filter((name) => name.length > 0))].map((name) => ({ name }));
|
|
16721
|
+
}
|
|
16722
|
+
await this.notionClient.updatePageProperties(row.id, { [propertyId]: { [type]: value } });
|
|
16723
|
+
}
|
|
16724
|
+
}
|
|
16128
16725
|
};
|
|
16129
16726
|
function sidecarPropertiesEdited(content, base) {
|
|
16130
16727
|
let parsed;
|
|
@@ -16159,9 +16756,12 @@ function diffSidecarProperties(base, edited) {
|
|
|
16159
16756
|
removed: [],
|
|
16160
16757
|
renamed: [],
|
|
16161
16758
|
retyped: [],
|
|
16162
|
-
redefined: []
|
|
16759
|
+
redefined: [],
|
|
16760
|
+
renamedOptions: []
|
|
16163
16761
|
};
|
|
16164
16762
|
const droppedOptions = [];
|
|
16763
|
+
const optionRenames = [];
|
|
16764
|
+
const staged = {};
|
|
16165
16765
|
const names = /* @__PURE__ */ new Set();
|
|
16166
16766
|
const seenIds = /* @__PURE__ */ new Set();
|
|
16167
16767
|
for (const prop of edited) {
|
|
@@ -16196,10 +16796,35 @@ function diffSidecarProperties(base, edited) {
|
|
|
16196
16796
|
`\u300C${prop.name}\u300D(${prop.type}) \uC758 \uC124\uC815\uC740 \uC0AC\uC774\uB4DC\uCE74\uC5D0\uC11C \uBC14\uAFC0 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. Notion \uC5D0\uC11C \uBC14\uAFB8\uC138\uC694`
|
|
16197
16797
|
);
|
|
16198
16798
|
}
|
|
16199
|
-
change[prop.type] = definitionOf(prop, old);
|
|
16200
16799
|
edit.redefined.push(prop.name);
|
|
16201
|
-
const
|
|
16800
|
+
const isSelect = prop.type === "select" || prop.type === "multi_select";
|
|
16801
|
+
const before = isSelect ? optionNamesOf(old) : [];
|
|
16802
|
+
const after = isSelect ? sentOptionNamesOf(prop, old) : [];
|
|
16803
|
+
const renames = renamedOptions(before, after);
|
|
16804
|
+
change[prop.type] = definitionOf(prop, old, renames);
|
|
16805
|
+
const dropped = before.filter(
|
|
16806
|
+
(name) => !after.includes(name) && !renames.some(([from]) => from === name)
|
|
16807
|
+
);
|
|
16202
16808
|
if (dropped.length > 0) droppedOptions.push([prop.name, dropped]);
|
|
16809
|
+
if (renames.length > 0) {
|
|
16810
|
+
const type = prop.type;
|
|
16811
|
+
const final = change[type]?.options ?? [];
|
|
16812
|
+
const known = old.definition?.["options"] ?? [];
|
|
16813
|
+
staged[old.id] = {
|
|
16814
|
+
...change,
|
|
16815
|
+
[type]: {
|
|
16816
|
+
...change[type],
|
|
16817
|
+
options: [
|
|
16818
|
+
...final,
|
|
16819
|
+
...renames.map(([from]) => known.find((o) => o.name === from) ?? { name: from })
|
|
16820
|
+
]
|
|
16821
|
+
}
|
|
16822
|
+
};
|
|
16823
|
+
for (const [from, to] of renames) {
|
|
16824
|
+
optionRenames.push({ propertyId: old.id, type, from, to });
|
|
16825
|
+
edit.renamedOptions.push([prop.name, from, to]);
|
|
16826
|
+
}
|
|
16827
|
+
}
|
|
16203
16828
|
}
|
|
16204
16829
|
if (Object.keys(change).length > 0) payload[old.id] = change;
|
|
16205
16830
|
}
|
|
@@ -16213,19 +16838,50 @@ function diffSidecarProperties(base, edited) {
|
|
|
16213
16838
|
if (kept >= 2 && edit.removed.length === kept && edit.added.length === 0) {
|
|
16214
16839
|
return fail("\uC81C\uBAA9 \uB9D0\uACE0 \uBAA8\uB4E0 \uC18D\uC131\uC744 \uC9C0\uC6B0\uB294 \uAC83\uC740 \uBC1B\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4 \u2014 \uC815\uB9D0\uC774\uBA74 Notion \uC5D0\uC11C \uC9C0\uC6B0\uC138\uC694");
|
|
16215
16840
|
}
|
|
16841
|
+
const { renamedOptions: renamedOptionList, ...rest } = edit;
|
|
16216
16842
|
return {
|
|
16217
16843
|
ok: true,
|
|
16218
16844
|
payload,
|
|
16219
|
-
|
|
16845
|
+
// 먼저 보낼 것 — 옵션 이름을 바꾼 속성만 새 이름 · 옛 이름을 함께, 나머지는 마지막 것과 같다.
|
|
16846
|
+
...optionRenames.length > 0 ? { staged: { ...payload, ...staged }, optionRenames } : {},
|
|
16847
|
+
edit: {
|
|
16848
|
+
...rest,
|
|
16849
|
+
...droppedOptions.length > 0 ? { droppedOptions } : {},
|
|
16850
|
+
...renamedOptionList.length > 0 ? { renamedOptions: renamedOptionList } : {}
|
|
16851
|
+
}
|
|
16220
16852
|
};
|
|
16221
16853
|
}
|
|
16222
|
-
function
|
|
16854
|
+
function optionNamesOf(prop) {
|
|
16855
|
+
if (prop.options) return [...prop.options];
|
|
16856
|
+
const options = prop.definition?.["options"];
|
|
16857
|
+
return Array.isArray(options) ? options.map((o) => o.name).filter((n) => typeof n === "string") : [];
|
|
16858
|
+
}
|
|
16859
|
+
function sentOptionNamesOf(prop, old) {
|
|
16860
|
+
if (sameJson(prop.definition, old.definition)) return [...prop.options ?? []];
|
|
16861
|
+
return optionNamesOf({ ...prop, options: void 0 });
|
|
16862
|
+
}
|
|
16863
|
+
function renamedOptions(before, after) {
|
|
16864
|
+
const renames = [];
|
|
16865
|
+
for (let i = 0; i < Math.min(before.length, after.length); i++) {
|
|
16866
|
+
const from = before[i];
|
|
16867
|
+
const to = after[i];
|
|
16868
|
+
if (from !== to && !after.includes(from) && !before.includes(to)) renames.push([from, to]);
|
|
16869
|
+
}
|
|
16870
|
+
return renames;
|
|
16871
|
+
}
|
|
16872
|
+
function definitionOf(prop, old, renames = []) {
|
|
16223
16873
|
if (!DEFINABLE_TYPES.has(prop.type)) return {};
|
|
16224
16874
|
const definition = prop.definition ?? {};
|
|
16225
16875
|
if (old && (prop.type === "select" || prop.type === "multi_select") && sameJson(prop.definition, old.definition) && !sameJson(prop.options, old.options)) {
|
|
16226
16876
|
const known = old.definition?.["options"] ?? [];
|
|
16877
|
+
const renamedFrom = new Map(renames.map(([from, to]) => [to, from]));
|
|
16227
16878
|
return {
|
|
16228
|
-
options: (prop.options ?? []).map((name) =>
|
|
16879
|
+
options: (prop.options ?? []).map((name) => {
|
|
16880
|
+
const from = renamedFrom.get(name);
|
|
16881
|
+
if (from === void 0) return known.find((o) => o.name === name) ?? { name };
|
|
16882
|
+
const color = known.find((o) => o.name === from)?.color;
|
|
16883
|
+
return color === void 0 ? { name } : { name, color };
|
|
16884
|
+
})
|
|
16229
16885
|
};
|
|
16230
16886
|
}
|
|
16231
16887
|
return definition;
|
|
@@ -17534,12 +18190,60 @@ function rewriteCreatedDatabaseFolders(stateDb, folders) {
|
|
|
17534
18190
|
async function retargetBaseFiles(vaultFs, moves, stateDb) {
|
|
17535
18191
|
const folders = moves.filter((move) => !isMarkdownPath(move.from));
|
|
17536
18192
|
if (folders.length === 0) return;
|
|
18193
|
+
const found2 = [];
|
|
17537
18194
|
await rewriteBaseFilters(
|
|
17538
18195
|
vaultFs,
|
|
17539
18196
|
stateDb,
|
|
17540
18197
|
(folder) => finalPath(folder, folders),
|
|
17541
|
-
"\uC62E\uAE34 \uD3F4\uB354\uB97C \uAC00\uB9AC\uD0A4\uB358 .base \uBCF4\uAE30\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568"
|
|
18198
|
+
"\uC62E\uAE34 \uD3F4\uB354\uB97C \uAC00\uB9AC\uD0A4\uB358 .base \uBCF4\uAE30\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568",
|
|
18199
|
+
(fix) => found2.push(fix)
|
|
17542
18200
|
);
|
|
18201
|
+
if (!stateDb) return;
|
|
18202
|
+
const pending = pendingUserBaseFilterFixes(stateDb).map((fix) => ({
|
|
18203
|
+
path: finalPath(fix.path, folders),
|
|
18204
|
+
from: fix.from,
|
|
18205
|
+
to: finalPath(fix.to, folders)
|
|
18206
|
+
}));
|
|
18207
|
+
for (const fix of found2) {
|
|
18208
|
+
if (!pending.some((p) => p.path === fix.path && p.from === fix.from)) pending.push(fix);
|
|
18209
|
+
}
|
|
18210
|
+
const asks = pending.filter((fix) => fix.from !== fix.to);
|
|
18211
|
+
stateDb.setMeta(USER_BASE_FILTER_FIXES_META_KEY, asks.length > 0 ? JSON.stringify(asks) : "");
|
|
18212
|
+
}
|
|
18213
|
+
var USER_BASE_FILTER_FIXES_META_KEY = "user_base_filter_fixes";
|
|
18214
|
+
function pendingUserBaseFilterFixes(stateDb) {
|
|
18215
|
+
try {
|
|
18216
|
+
const parsed = JSON.parse(stateDb.getMeta(USER_BASE_FILTER_FIXES_META_KEY) || "[]");
|
|
18217
|
+
if (!Array.isArray(parsed)) return [];
|
|
18218
|
+
return parsed.filter(
|
|
18219
|
+
(fix) => typeof fix?.path === "string" && typeof fix.from === "string" && typeof fix.to === "string"
|
|
18220
|
+
);
|
|
18221
|
+
} catch {
|
|
18222
|
+
return [];
|
|
18223
|
+
}
|
|
18224
|
+
}
|
|
18225
|
+
async function resolveUserBaseFilterFixes(vaultFs, stateDb, accept) {
|
|
18226
|
+
const fixes = pendingUserBaseFilterFixes(stateDb);
|
|
18227
|
+
stateDb.setMeta(USER_BASE_FILTER_FIXES_META_KEY, "");
|
|
18228
|
+
if (!accept) return [];
|
|
18229
|
+
const fixed = [];
|
|
18230
|
+
for (const path of [...new Set(fixes.map((fix) => fix.path))]) {
|
|
18231
|
+
const content = await vaultFs.readFile(path).catch(() => null);
|
|
18232
|
+
if (content === null) continue;
|
|
18233
|
+
const next = content.replace(
|
|
18234
|
+
FOLDER_FILTER_RE,
|
|
18235
|
+
(whole, legacy, current) => {
|
|
18236
|
+
const folder = legacy ?? current;
|
|
18237
|
+
const fix = fixes.find((f) => f.path === path && f.from === folder);
|
|
18238
|
+
if (!fix) return whole;
|
|
18239
|
+
return legacy !== void 0 ? `file.inFolder("${fix.to}")` : folderFilter(fix.to);
|
|
18240
|
+
}
|
|
18241
|
+
);
|
|
18242
|
+
if (next === content) continue;
|
|
18243
|
+
await vaultFs.writeFile(path, next);
|
|
18244
|
+
fixed.push(path);
|
|
18245
|
+
}
|
|
18246
|
+
return fixed;
|
|
17543
18247
|
}
|
|
17544
18248
|
var BASE_FOLDER_FILTER_UPGRADED_META_KEY = "base_folder_filter_v2";
|
|
17545
18249
|
async function upgradeBaseFolderFilters(vaultFs, stateDb) {
|
|
@@ -17552,7 +18256,7 @@ async function upgradeBaseFolderFilters(vaultFs, stateDb) {
|
|
|
17552
18256
|
);
|
|
17553
18257
|
if (done) stateDb.setMeta(BASE_FOLDER_FILTER_UPGRADED_META_KEY, "1");
|
|
17554
18258
|
}
|
|
17555
|
-
async function rewriteBaseFilters(vaultFs, stateDb, retarget, failure) {
|
|
18259
|
+
async function rewriteBaseFilters(vaultFs, stateDb, retarget, failure, onUserBase) {
|
|
17556
18260
|
const rehashed = /* @__PURE__ */ new Map();
|
|
17557
18261
|
const viewed = stateDb ? recordedViewBases(stateDb) : /* @__PURE__ */ new Set();
|
|
17558
18262
|
let done = true;
|
|
@@ -17560,7 +18264,16 @@ async function rewriteBaseFilters(vaultFs, stateDb, retarget, failure) {
|
|
|
17560
18264
|
for (const file of await vaultFs.listNonMarkdownFiles()) {
|
|
17561
18265
|
if (!file.path.endsWith(BASE_SUFFIX)) continue;
|
|
17562
18266
|
const content = await vaultFs.readFile(file.path);
|
|
17563
|
-
if (!isGeneratedDbArtifact(file.path, content) && !viewed.has(file.path))
|
|
18267
|
+
if (!isGeneratedDbArtifact(file.path, content) && !viewed.has(file.path)) {
|
|
18268
|
+
if (onUserBase) {
|
|
18269
|
+
for (const match of content.matchAll(FOLDER_FILTER_RE)) {
|
|
18270
|
+
const from = match[1] ?? match[2];
|
|
18271
|
+
const to = retarget(from);
|
|
18272
|
+
if (to !== from) onUserBase({ path: file.path, from, to });
|
|
18273
|
+
}
|
|
18274
|
+
}
|
|
18275
|
+
continue;
|
|
18276
|
+
}
|
|
17564
18277
|
const next = content.replace(
|
|
17565
18278
|
FOLDER_FILTER_RE,
|
|
17566
18279
|
(whole, legacy, current) => {
|
|
@@ -17949,7 +18662,7 @@ function compareRemote(record, page, botUserId) {
|
|
|
17949
18662
|
return "changed";
|
|
17950
18663
|
}
|
|
17951
18664
|
if (isSettled(record.notionLastEdited, record.notionSeenAt)) return "unchanged";
|
|
17952
|
-
if (botUserId !== null && editor === botUserId && record.notionLastEditedBy === botUserId) {
|
|
18665
|
+
if (botUserId !== null && editor === botUserId && record.notionLastEditedBy === botUserId && (record.notionBodyFingerprint === null || record.baseSnapshot === null)) {
|
|
17953
18666
|
return "unchanged";
|
|
17954
18667
|
}
|
|
17955
18668
|
return "unverified";
|
|
@@ -18152,6 +18865,7 @@ var DatabaseSyncer = class {
|
|
|
18152
18865
|
this.observation = observation;
|
|
18153
18866
|
this.noteRowBody = noteRowBody;
|
|
18154
18867
|
this.baseFileInfo = new DbBaseFiles(stateDb);
|
|
18868
|
+
this.journal = new UpdateJournal(stateDb);
|
|
18155
18869
|
this.sidecarBases = new SidecarBases(stateDb);
|
|
18156
18870
|
this.schemaLedger = new SchemaLedger(stateDb);
|
|
18157
18871
|
this.relocator = new NoteRelocator(stateDb, vaultFs, config.notion.databases ?? []);
|
|
@@ -18193,6 +18907,19 @@ var DatabaseSyncer = class {
|
|
|
18193
18907
|
};
|
|
18194
18908
|
/** 볼트에서 사라져 되살린 행의 경로 — pull 마감이 가져가 화면에 알린다(D8). */
|
|
18195
18909
|
restoredRows = [];
|
|
18910
|
+
/** 쓰다 끊긴 행을 알아본다 — {@link UpdateJournal}. */
|
|
18911
|
+
journal;
|
|
18912
|
+
/** merge 전략이 받은 행에 로컬 편집을 합친 행 — {@link takeMergedRows}. */
|
|
18913
|
+
mergedRows = [];
|
|
18914
|
+
/** 상태 기록에 없던 같은 자리의 행 노트를 다시 이은 것(P7) — {@link takeRelinkedRows}. */
|
|
18915
|
+
relinkedRows = [];
|
|
18916
|
+
/**
|
|
18917
|
+
* 이번 pull 을 시작할 때 Notion 과 이은 노트가 하나도 없었다 — 그때는 자연 이름에 있는 행 노트의 글이 달라도 다시
|
|
18918
|
+
* 잇는다({@link relinkExistingRow}). 오케스트레이터가 pull 을 시작할 때 정한다({@link beginPull}).
|
|
18919
|
+
*/
|
|
18920
|
+
unlinkedVault = false;
|
|
18921
|
+
/** 이번 pull 에서 다시 이으려고 고른 행 노트 경로 — 같은 이름의 두 행이 한 노트를 고르지 않게. */
|
|
18922
|
+
relinkClaims = /* @__PURE__ */ new Set();
|
|
18196
18923
|
/** Notion 에서 바뀐 속성을 행 노트에 옮긴 것 — pull 마감이 가져가 화면에 알린다(ADR-045). */
|
|
18197
18924
|
schemaFollowed = [];
|
|
18198
18925
|
/** pull 이 마지막으로 쓴 사이드카 — 볼트에서 고친 것은 덮어쓰지 않는다(ADR-045). */
|
|
@@ -18201,6 +18928,10 @@ var DatabaseSyncer = class {
|
|
|
18201
18928
|
heldSidecars = /* @__PURE__ */ new Set();
|
|
18202
18929
|
/** 이번 pull 이 볼트에서 고친 것이라 덮어쓰지 않은 `.base` 경로(ADR-046). */
|
|
18203
18930
|
heldBases = /* @__PURE__ */ new Set();
|
|
18931
|
+
/** 이번 pull 이 치운 링크드 뷰의 `.base`({@link takeRetiredBases}). */
|
|
18932
|
+
retiredBases = /* @__PURE__ */ new Set();
|
|
18933
|
+
/** 이번 실행에서 받은 DB(줄인 id)의 기간 날짜 짝 키 — 그 DB 를 원본으로 둔 링크드 뷰의 `.base` 도 같게 쓴다. */
|
|
18934
|
+
rangeEnds = /* @__PURE__ */ new Map();
|
|
18204
18935
|
/**
|
|
18205
18936
|
* 실제로 기록한 .base 경로/DB 제목 — placeholder 임베드 재작성(F22)의 SSOT. 이번 pull 이 조회하지
|
|
18206
18937
|
* 않은 DB 의 것도 남아 있다({@link DbBaseFiles}).
|
|
@@ -18270,10 +19001,6 @@ var DatabaseSyncer = class {
|
|
|
18270
19001
|
}
|
|
18271
19002
|
}
|
|
18272
19003
|
if (target.kind === "linked") {
|
|
18273
|
-
if (target.wholeDbInScope) {
|
|
18274
|
-
const viewsConfig = await this.pullDatabaseViews(dbConfig);
|
|
18275
|
-
await this.generateBaseFile(dbConfig, viewsConfig, target.meta, target.ownerFolder);
|
|
18276
|
-
}
|
|
18277
19004
|
return { ...emptyDatabaseSyncResult(), linkedOriginalDbId: target.ownerDbId };
|
|
18278
19005
|
}
|
|
18279
19006
|
const { pages, wholeDbInScope, meta } = target;
|
|
@@ -18284,7 +19011,9 @@ var DatabaseSyncer = class {
|
|
|
18284
19011
|
const refreshedKeys = (page) => followed.changes.get(sourceKeyOf(page, dbConfig.databaseId))?.refreshed;
|
|
18285
19012
|
if (wholeDbInScope) {
|
|
18286
19013
|
const viewsConfig = await this.pullDatabaseViews(dbConfig);
|
|
18287
|
-
|
|
19014
|
+
const rangeEnds = this.rangeEndsOf(pages);
|
|
19015
|
+
this.rangeEnds.set(compactNotionId(dbConfig.databaseId), rangeEnds);
|
|
19016
|
+
await this.generateBaseFile(dbConfig, viewsConfig, meta, void 0, rangeEnds);
|
|
18288
19017
|
}
|
|
18289
19018
|
let created = 0;
|
|
18290
19019
|
let updated = followed.rekeyed.size;
|
|
@@ -18332,7 +19061,11 @@ var DatabaseSyncer = class {
|
|
|
18332
19061
|
this.config.advanced.itemTimeoutMs,
|
|
18333
19062
|
`pull ${dbConfig.localFolder}/${page.id}`
|
|
18334
19063
|
);
|
|
18335
|
-
if (!record) {
|
|
19064
|
+
if (!record && outcome.relinked) {
|
|
19065
|
+
this.relinkedRows.push(outcome.path);
|
|
19066
|
+
if (outcome.action === "written") writtenPaths.push(outcome.path);
|
|
19067
|
+
else if (outcome.action === "conflict") conflicts.push(outcome.conflict);
|
|
19068
|
+
} else if (!record) {
|
|
18336
19069
|
created++;
|
|
18337
19070
|
if (outcome.action === "written") writtenPaths.push(outcome.path);
|
|
18338
19071
|
} else if (outcome.action === "written") {
|
|
@@ -18341,12 +19074,19 @@ var DatabaseSyncer = class {
|
|
|
18341
19074
|
this.restoredRows.push(outcome.path);
|
|
18342
19075
|
} else if (!followed.rekeyed.has(record.id)) updated++;
|
|
18343
19076
|
writtenPaths.push(outcome.path);
|
|
19077
|
+
} else if (outcome.action === "merged") {
|
|
19078
|
+
updated++;
|
|
19079
|
+
this.mergedRows.push(outcome.path);
|
|
18344
19080
|
} else if (outcome.action === "conflict") {
|
|
18345
19081
|
conflicts.push(outcome.conflict);
|
|
18346
19082
|
}
|
|
18347
19083
|
opts?.progress?.done({
|
|
18348
19084
|
path: outcome.path ?? record?.obsidianPath ?? `${dbConfig.localFolder}/${page.id}`,
|
|
18349
|
-
operation: record ? "update" : "create"
|
|
19085
|
+
operation: record ? "update" : "create",
|
|
19086
|
+
// 받아 보니 그대로 · local-first 가 지킨 행은 받은 것이 아니다 — 페이지와 같다. 같은 분 봇 쓰기 뒤에는 행을
|
|
19087
|
+
// 다시 읽어 견주므로(ADR-017 보강) 바뀌지 않은 행이 «updated» 로 찍혔다(0.15 회귀). 다시 이은 행은 쓴 것이
|
|
19088
|
+
// 없으면 만든 것이 아니다(P7).
|
|
19089
|
+
...outcome.action === "unchanged" || outcome.action === "skipped" || !record && outcome.relinked && outcome.action !== "written" ? { unchanged: true } : {}
|
|
18350
19090
|
});
|
|
18351
19091
|
} catch (error) {
|
|
18352
19092
|
failed.push(rowFailure(page, error));
|
|
@@ -18406,6 +19146,72 @@ var DatabaseSyncer = class {
|
|
|
18406
19146
|
...planned.map((row) => ({ ...row, path: moved(row.path) }))
|
|
18407
19147
|
];
|
|
18408
19148
|
}
|
|
19149
|
+
/**
|
|
19150
|
+
* 받은 행이 지난 사본 그대로인가 — 렌더한 글이 달라도 받을 것이 없으면 그 사이 로컬 편집을 충돌로 올리지 않는다
|
|
19151
|
+
* (N-05). 본문은 지문으로, 본문 밖은 지난 사본의 속성과 견준다. 이 도구가 이 행을 쓰다 끊긴 원격도 받을 것이
|
|
19152
|
+
* 없다 — push 가 마저 올린다({@link UpdateJournal}).
|
|
19153
|
+
*/
|
|
19154
|
+
rowRemoteUnchanged(record, page, bodyFingerprint, localContent) {
|
|
19155
|
+
const base = snapshotFrontmatter(record.baseSnapshot);
|
|
19156
|
+
return base !== null && bodyFingerprint === record.notionBodyFingerprint && this.rowOutsideBodyUnchanged(this.propertyMapper, page, base, record.obsidianPath) || this.journal.wroteRemote(
|
|
19157
|
+
record,
|
|
19158
|
+
page,
|
|
19159
|
+
computeHash(localContent),
|
|
19160
|
+
this.observation().botUserId
|
|
19161
|
+
);
|
|
19162
|
+
}
|
|
19163
|
+
/**
|
|
19164
|
+
* dry-run 이 알릴, 로컬도 고친 행을 실제 pull 이 어떻게 처리할지 — 받은 행과 견주는 판정은 실제 pull 과 같다.
|
|
19165
|
+
* 첨부를 내려받지 않고 렌더하며 상태 DB 를 바꾸지 않는다. 로컬을 고치지 않았거나 읽지 못하면 null(받는다).
|
|
19166
|
+
*/
|
|
19167
|
+
async previewRowResolution(record, page) {
|
|
19168
|
+
let localContent;
|
|
19169
|
+
try {
|
|
19170
|
+
localContent = await this.vaultFs.readFile(record.obsidianPath);
|
|
19171
|
+
} catch {
|
|
19172
|
+
return null;
|
|
19173
|
+
}
|
|
19174
|
+
if (computeHash(localContent) === record.contentHash) return null;
|
|
19175
|
+
const rendered = await this.renderRow(page, record.obsidianPath, { downloadMedia: false });
|
|
19176
|
+
const resolution = resolvePullConflict({
|
|
19177
|
+
record,
|
|
19178
|
+
localContent,
|
|
19179
|
+
remoteContent: rendered.content,
|
|
19180
|
+
remoteUnchanged: this.rowRemoteUnchanged(
|
|
19181
|
+
record,
|
|
19182
|
+
page,
|
|
19183
|
+
rendered.bodyFingerprint,
|
|
19184
|
+
localContent
|
|
19185
|
+
),
|
|
19186
|
+
remoteChange: {
|
|
19187
|
+
pageId: page.id,
|
|
19188
|
+
type: "modified",
|
|
19189
|
+
lastEdited: page.last_edited_time,
|
|
19190
|
+
previousEdited: record.notionLastEdited
|
|
19191
|
+
},
|
|
19192
|
+
strategy: this.config.sync.conflictStrategy
|
|
19193
|
+
});
|
|
19194
|
+
if (resolution.action === "conflict")
|
|
19195
|
+
return { action: "conflict", conflict: resolution.conflict };
|
|
19196
|
+
return resolution.action === "write" ? null : { action: resolution.action };
|
|
19197
|
+
}
|
|
19198
|
+
/**
|
|
19199
|
+
* 수정 시각 · 편집자로 가르지 못한 행이 지난 사본 그대로인가 — 본문 지문과 본문 밖 속성으로({@link rowRemoteUnchanged}).
|
|
19200
|
+
* dry-run 이 실제 pull 처럼 받을 것이 없는 행을 세지 않게. 견줄 사본 · 지문이 없거나 본문을 읽지 못하면 아니다.
|
|
19201
|
+
*/
|
|
19202
|
+
async unverifiedRowUnchanged(record, page) {
|
|
19203
|
+
if (this.remoteVerdict(record, page) !== "unverified") return false;
|
|
19204
|
+
const base = snapshotFrontmatter(record.baseSnapshot);
|
|
19205
|
+
if (base === null || record.notionBodyFingerprint === null) return false;
|
|
19206
|
+
try {
|
|
19207
|
+
const fingerprint = remoteBodyFingerprint(
|
|
19208
|
+
(await this.notionClient.getPageMarkdown(page.id)).markdown
|
|
19209
|
+
);
|
|
19210
|
+
return fingerprint === record.notionBodyFingerprint && this.rowOutsideBodyUnchanged(this.propertyMapper, page, base, record.obsidianPath);
|
|
19211
|
+
} catch {
|
|
19212
|
+
return false;
|
|
19213
|
+
}
|
|
19214
|
+
}
|
|
18409
19215
|
async planRows(dbConfig, target, paths, movingInVault = /* @__PURE__ */ new Set()) {
|
|
18410
19216
|
const opts = { paths };
|
|
18411
19217
|
const planned = [];
|
|
@@ -18451,11 +19257,18 @@ var DatabaseSyncer = class {
|
|
|
18451
19257
|
moves: [{ from: record.obsidianPath, to: path }, ...children ? [children] : []]
|
|
18452
19258
|
});
|
|
18453
19259
|
} else {
|
|
19260
|
+
if (action === "pull" && await this.unverifiedRowUnchanged(record, page)) continue;
|
|
19261
|
+
const retitle = await this.retitledRowTarget(dbConfig.localFolder, record, page);
|
|
19262
|
+
const renamed = retitle && "to" in retitle ? retitle : null;
|
|
19263
|
+
const resolution = action === "restore" ? null : await this.previewRowResolution(record, page);
|
|
18454
19264
|
planned.push({
|
|
18455
19265
|
pageId: page.id,
|
|
18456
|
-
path: record.obsidianPath,
|
|
19266
|
+
path: renamed?.to ?? record.obsidianPath,
|
|
18457
19267
|
operation: "update",
|
|
18458
|
-
restore: action === "restore"
|
|
19268
|
+
restore: action === "restore",
|
|
19269
|
+
...resolution ? { resolution } : {},
|
|
19270
|
+
// 실제 pull 이 알리는 것과 같게 행만 — 하위 페이지 폴더는 함께 옮기지만 따로 알리지 않는다.
|
|
19271
|
+
...renamed ? { moves: [{ from: record.obsidianPath, to: renamed.to, renamed: true }] } : {}
|
|
18459
19272
|
});
|
|
18460
19273
|
}
|
|
18461
19274
|
}
|
|
@@ -18547,6 +19360,9 @@ var DatabaseSyncer = class {
|
|
|
18547
19360
|
const changed = misplaced || this.remoteVerdict(record, page) !== "unchanged";
|
|
18548
19361
|
if (!changed && await this.staleKeysToRewrite(record, page, refreshed)) return "reform";
|
|
18549
19362
|
if (this.config.sync.deleteSync || await this.vaultFs.exists(record.obsidianPath)) {
|
|
19363
|
+
if (changed && !misplaced && this.config.sync.deleteSync && !await this.vaultFs.exists(record.obsidianPath) && await this.unverifiedRowUnchanged(record, page)) {
|
|
19364
|
+
return "skip";
|
|
19365
|
+
}
|
|
18550
19366
|
return changed ? "pull" : "skip";
|
|
18551
19367
|
}
|
|
18552
19368
|
return "restore";
|
|
@@ -18795,10 +19611,10 @@ var DatabaseSyncer = class {
|
|
|
18795
19611
|
}
|
|
18796
19612
|
return ends;
|
|
18797
19613
|
}
|
|
18798
|
-
async generateBaseFile(dbConfig, viewsConfig, meta, rowsFolder, rangeEnds) {
|
|
19614
|
+
async generateBaseFile(dbConfig, viewsConfig, meta, rowsFolder, rangeEnds, linked) {
|
|
18799
19615
|
try {
|
|
18800
19616
|
const schemaFull = await this.notionClient.getDatabaseSchemaFull(dbConfig.databaseId, meta);
|
|
18801
|
-
const dbName = viewsConfig?.databaseName || await this.notionClient.getDatabaseTitle(dbConfig.databaseId, meta) || dbConfig.localFolder.split("/").pop() || "Database";
|
|
19617
|
+
const dbName = linked?.name || viewsConfig?.databaseName || await this.notionClient.getDatabaseTitle(dbConfig.databaseId, meta) || dbConfig.localFolder.split("/").pop() || "Database";
|
|
18802
19618
|
const resolvedViews = viewsConfig ?? {
|
|
18803
19619
|
databaseId: dbConfig.databaseId,
|
|
18804
19620
|
databaseName: dbName,
|
|
@@ -18823,11 +19639,12 @@ var DatabaseSyncer = class {
|
|
|
18823
19639
|
title: dbName,
|
|
18824
19640
|
hash: computeHash(baseContent),
|
|
18825
19641
|
// Notion 보기를 받지 못해 기본 표 하나를 쓴 파일은 올릴 보기가 없다.
|
|
18826
|
-
...viewsConfig ? recordBaseViews(baseContent, generated.views, viewsConfig.views) : null
|
|
19642
|
+
...viewsConfig ? recordBaseViews(baseContent, generated.views, viewsConfig.views) : null,
|
|
19643
|
+
...linked ? { linkedTo: compactNotionId(linked.ownerId) } : null
|
|
18827
19644
|
};
|
|
18828
19645
|
const ours = existing === null || isGeneratedDbArtifact(basePath, existing) || previous?.basePath === basePath && previous.views !== void 0;
|
|
18829
19646
|
if (!ours) {
|
|
18830
|
-
record = { basePath, title: dbName, hash: record.hash };
|
|
19647
|
+
record = { basePath, title: dbName, hash: record.hash, linkedTo: record.linkedTo };
|
|
18831
19648
|
if (!this.keptUserBases.has(basePath)) {
|
|
18832
19649
|
this.keptUserBases.add(basePath);
|
|
18833
19650
|
getLogger().info(
|
|
@@ -18842,6 +19659,15 @@ var DatabaseSyncer = class {
|
|
|
18842
19659
|
}
|
|
18843
19660
|
this.baseFileInfo.set(dbConfig.databaseId, record);
|
|
18844
19661
|
getLogger().debug(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131: ${basePath}`);
|
|
19662
|
+
if (linked) {
|
|
19663
|
+
if (previous && previous.basePath !== basePath) {
|
|
19664
|
+
await this.removeLinkedBase(previous);
|
|
19665
|
+
if (!await this.vaultFs.exists(previous.basePath) && await this.vaultFs.exists(basePath)) {
|
|
19666
|
+
this.renamedRows.applied.push({ from: previous.basePath, to: basePath });
|
|
19667
|
+
}
|
|
19668
|
+
}
|
|
19669
|
+
return;
|
|
19670
|
+
}
|
|
18845
19671
|
await this.generateSidecar(
|
|
18846
19672
|
dbConfig,
|
|
18847
19673
|
dbName,
|
|
@@ -18851,7 +19677,15 @@ var DatabaseSyncer = class {
|
|
|
18851
19677
|
meta.isInline,
|
|
18852
19678
|
await this.appearanceOf(meta, safeName)
|
|
18853
19679
|
);
|
|
18854
|
-
await this.cleanupStaleDbArtifacts(
|
|
19680
|
+
await this.cleanupStaleDbArtifacts(
|
|
19681
|
+
dbConfig.databaseId,
|
|
19682
|
+
dbConfig.localFolder,
|
|
19683
|
+
safeName,
|
|
19684
|
+
previous
|
|
19685
|
+
);
|
|
19686
|
+
if (previous && previous.basePath !== basePath && !await this.vaultFs.exists(previous.basePath) && await this.vaultFs.exists(basePath)) {
|
|
19687
|
+
this.renamedRows.applied.push({ from: previous.basePath, to: basePath });
|
|
19688
|
+
}
|
|
18855
19689
|
} catch (error) {
|
|
18856
19690
|
getLogger().warn(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
|
|
18857
19691
|
}
|
|
@@ -18883,13 +19717,87 @@ var DatabaseSyncer = class {
|
|
|
18883
19717
|
...a.showTitle ? {} : { showTitle: false }
|
|
18884
19718
|
};
|
|
18885
19719
|
}
|
|
19720
|
+
/**
|
|
19721
|
+
* 링크드 뷰 컨테이너의 `.base` 를 그 컨테이너의 보기(이름 · 필터 · 정렬)로 원본 DB 폴더에 쓴다. 행은 원본 DB 가
|
|
19722
|
+
* 갖는다(F25) — 폴더 필터도 원본 폴더를 가리킨다. 예전에는 링크드 뷰를 원본의 `.base` 로 몰아, 링크드 뷰가 고른
|
|
19723
|
+
* 보기가 볼트에 오지 않았다.
|
|
19724
|
+
*
|
|
19725
|
+
* 이름은 Notion 이 그 블록 위에 보이는 것처럼 「원본 제목 - 첫 보기 이름」 이다. 컨테이너 제목은 비어 온다.
|
|
19726
|
+
*
|
|
19727
|
+
* @returns `gone` — Notion 에서 컨테이너를 지웠다. 쓴 `.base` 를 치웠으니 부른 쪽이 연결을 잊는다.
|
|
19728
|
+
* `failed` — 읽지 못했다. 쓴 `.base` 는 그대로 두고 다음 pull 이 다시 본다.
|
|
19729
|
+
*/
|
|
19730
|
+
async pullLinkedView(containerId, owner) {
|
|
19731
|
+
let meta;
|
|
19732
|
+
try {
|
|
19733
|
+
meta = await this.notionClient.getDatabaseMeta(containerId);
|
|
19734
|
+
} catch (error) {
|
|
19735
|
+
if (isNotionObjectNotFound(error)) {
|
|
19736
|
+
await this.retireLinkedView(containerId);
|
|
19737
|
+
return "gone";
|
|
19738
|
+
}
|
|
19739
|
+
getLogger().warn(
|
|
19740
|
+
`[DB Sync] \uB9C1\uD06C\uB4DC \uBDF0 ${containerId} \uB97C \uC77D\uC9C0 \uBABB\uD568 (\uB2E4\uC74C pull \uC774 \uB2E4\uC2DC \uBD04):`,
|
|
19741
|
+
error
|
|
19742
|
+
);
|
|
19743
|
+
return "failed";
|
|
19744
|
+
}
|
|
19745
|
+
if (meta.inTrash) {
|
|
19746
|
+
await this.retireLinkedView(containerId);
|
|
19747
|
+
return "gone";
|
|
19748
|
+
}
|
|
19749
|
+
const container = { ...owner, databaseId: containerId };
|
|
19750
|
+
const views = await this.pullDatabaseViews(container);
|
|
19751
|
+
if (!views || views.views.length === 0) return "failed";
|
|
19752
|
+
await this.generateBaseFile(
|
|
19753
|
+
container,
|
|
19754
|
+
views,
|
|
19755
|
+
meta,
|
|
19756
|
+
owner.localFolder,
|
|
19757
|
+
this.rangeEnds.get(compactNotionId(owner.databaseId)),
|
|
19758
|
+
{ ownerId: owner.databaseId, name: this.linkedBaseName(containerId, owner, views) }
|
|
19759
|
+
);
|
|
19760
|
+
return "written";
|
|
19761
|
+
}
|
|
19762
|
+
/** 링크드 뷰 `.base` 의 이름 — 같은 폴더의 다른 `.base` 가 쓰는 이름이면 컨테이너 id 앞 네 자리를 붙인다. */
|
|
19763
|
+
linkedBaseName(containerId, owner, views) {
|
|
19764
|
+
const ownerTitle = this.baseFileInfo.get(owner.databaseId)?.title || owner.localFolder.split("/").pop() || "Database";
|
|
19765
|
+
const name = `${ownerTitle} - ${views.views[0]?.name || "View"}`;
|
|
19766
|
+
const self = compactNotionId(containerId);
|
|
19767
|
+
const taken = new Set(
|
|
19768
|
+
this.baseFileInfo.all().filter(([id]) => id !== self).map(([, file]) => file.basePath)
|
|
19769
|
+
);
|
|
19770
|
+
return taken.has(`${owner.localFolder}/${sanitizeFileName(name)}${BASE_SUFFIX}`) ? `${name} (${self.slice(0, 4)})` : name;
|
|
19771
|
+
}
|
|
19772
|
+
/** Notion 에서 지운 링크드 뷰 — 쓴 `.base` 를 치우고 기록을 잊는다. */
|
|
19773
|
+
async retireLinkedView(containerId) {
|
|
19774
|
+
const record = this.baseFileInfo.get(containerId);
|
|
19775
|
+
if (!record) return;
|
|
19776
|
+
if (await this.removeLinkedBase(record)) this.retiredBases.add(record.basePath);
|
|
19777
|
+
this.baseFileInfo.delete(containerId);
|
|
19778
|
+
getLogger().info(`[DB Sync] Notion \uC5D0\uC11C \uC9C0\uC6B4 \uB9C1\uD06C\uB4DC \uBDF0\uC758 .base \uB97C \uCE58\uC6C0: ${record.basePath}`);
|
|
19779
|
+
}
|
|
19780
|
+
/**
|
|
19781
|
+
* pull 이 쓴 그대로인 링크드 뷰 `.base` 를 지운다 — 볼트에서 고쳤으면 두고 알린다.
|
|
19782
|
+
* @returns 지웠나.
|
|
19783
|
+
*/
|
|
19784
|
+
async removeLinkedBase(record) {
|
|
19785
|
+
const content = await this.vaultFs.readFile(record.basePath).catch(() => null);
|
|
19786
|
+
if (content === null || !isGeneratedDbArtifact(record.basePath, content)) return false;
|
|
19787
|
+
if (editedSince(record, record.basePath, content)) {
|
|
19788
|
+
this.warnEditedBase(record.basePath);
|
|
19789
|
+
return false;
|
|
19790
|
+
}
|
|
19791
|
+
await this.vaultFs.deleteFile(record.basePath);
|
|
19792
|
+
return true;
|
|
19793
|
+
}
|
|
18886
19794
|
/** 볼트에서 고친 `.base` 를 덮어쓰거나 지우지 않았다고 한 번 알린다. */
|
|
18887
19795
|
warnEditedBase(path) {
|
|
18888
19796
|
this.heldBases.add(path);
|
|
18889
19797
|
if (this.keptUserBases.has(path)) return;
|
|
18890
19798
|
this.keptUserBases.add(path);
|
|
18891
19799
|
getLogger().warn(
|
|
18892
|
-
`[DB Sync] \uBCFC\uD2B8\uC5D0\uC11C \uACE0\uCE5C .base \uB77C Notion \uBCF4\uAE30\uB85C \uB36E\uC5B4\uC4F0\uC9C0 \uC54A\uC74C: ${path} \u2014 push \uD558\uBA74 \uACE0\uCE5C \uBCF4\uAE30 \uC774\uB984 \xB7 \uC5F4 \xB7 \uC815\uB82C \xB7 \uD544\uD130\uB97C Notion \uC5D0 \uC62C\uB9BD\uB2C8\uB2E4. Notion \uC758 \uBCF4\uAE30\uB97C \uB2E4\uC2DC \uBC1B\uC73C\uB824\uBA74 \uC774 \uD30C\uC77C\uC744 \uC9C0\uC6B0\uC138\uC694`
|
|
19800
|
+
`[DB Sync] \uBCFC\uD2B8\uC5D0\uC11C \uACE0\uCE5C .base \uB77C Notion \uBCF4\uAE30\uB85C \uB36E\uC5B4\uC4F0\uC9C0 \uC54A\uC74C: ${path} \u2014 push \uD558\uBA74 \uACE0\uCE5C \uBCF4\uAE30 \uC774\uB984 \xB7 \uC5F4 \xB7 \uC815\uB82C \xB7 \uD544\uD130\uC640 \uB354\uD558\uACE0 \uC9C0\uC6B4 \uBCF4\uAE30\uB97C Notion \uC5D0 \uC62C\uB9BD\uB2C8\uB2E4. Notion \uC758 \uBCF4\uAE30\uB97C \uB2E4\uC2DC \uBC1B\uC73C\uB824\uBA74 \uC774 \uD30C\uC77C\uC744 \uC9C0\uC6B0\uC138\uC694`
|
|
18893
19801
|
);
|
|
18894
19802
|
}
|
|
18895
19803
|
/**
|
|
@@ -18897,11 +19805,13 @@ var DatabaseSyncer = class {
|
|
|
18897
19805
|
* 같은 폴더에 고아로 잔존한다(drift·부활 원인). DB 폴더 **직속**의 산출물 중 현재 이름이
|
|
18898
19806
|
* 아닌 것을 삭제해 멱등을 보장한다. 하위 폴더(중첩 DB)의 산출물은 보호한다.
|
|
18899
19807
|
*/
|
|
18900
|
-
async cleanupStaleDbArtifacts(localFolder, safeName, previous) {
|
|
19808
|
+
async cleanupStaleDbArtifacts(databaseId, localFolder, safeName, previous) {
|
|
18901
19809
|
try {
|
|
18902
19810
|
const keep = /* @__PURE__ */ new Set([
|
|
18903
19811
|
`${localFolder}/${safeName}${BASE_SUFFIX}`,
|
|
18904
|
-
`${localFolder}/${safeName}${SIDECAR_SUFFIX}
|
|
19812
|
+
`${localFolder}/${safeName}${SIDECAR_SUFFIX}`,
|
|
19813
|
+
// 이 DB 를 원본으로 둔 링크드 뷰의 `.base` — 같은 폴더에 놓인다.
|
|
19814
|
+
...this.baseFileInfo.linkedBasesOf(databaseId)
|
|
18905
19815
|
]);
|
|
18906
19816
|
const files = await this.vaultFs.listNonMarkdownFiles();
|
|
18907
19817
|
const stale = selectStaleDbArtifacts(files, localFolder, keep);
|
|
@@ -19035,7 +19945,12 @@ var DatabaseSyncer = class {
|
|
|
19035
19945
|
const bodyFingerprint = remoteBodyFingerprint(mdResult.markdown);
|
|
19036
19946
|
const exportCompact = isCompactExport(mdResult.markdown);
|
|
19037
19947
|
let markdown = await notionBodyToObsidian(this.notionClient, mdResult.markdown, page.id);
|
|
19038
|
-
markdown = await this.imageHandler.restoreUploadedMedia(
|
|
19948
|
+
markdown = await this.imageHandler.restoreUploadedMedia(
|
|
19949
|
+
markdown,
|
|
19950
|
+
page.id,
|
|
19951
|
+
filePath,
|
|
19952
|
+
options?.adoptFrom
|
|
19953
|
+
);
|
|
19039
19954
|
if (downloadMedia && this.config.conversion.imageDownload === "immediate" && markdown) {
|
|
19040
19955
|
try {
|
|
19041
19956
|
const imageResult = await this.imageHandler.downloadAllImages(markdown, title, page.id);
|
|
@@ -19117,6 +20032,105 @@ var DatabaseSyncer = class {
|
|
|
19117
20032
|
this.restoredRows = [];
|
|
19118
20033
|
return rows;
|
|
19119
20034
|
}
|
|
20035
|
+
/** pull 을 시작한다 — 이은 노트가 하나도 없는 볼트인지 적고 다시 잇기 장부를 비운다. */
|
|
20036
|
+
beginPull(unlinkedVault) {
|
|
20037
|
+
this.unlinkedVault = unlinkedVault;
|
|
20038
|
+
this.relinkClaims.clear();
|
|
20039
|
+
}
|
|
20040
|
+
/** 이번 받기에서 다시 이은 행의 경로를 가져가고 비운다. */
|
|
20041
|
+
takeRelinkedRows() {
|
|
20042
|
+
const rows = this.relinkedRows;
|
|
20043
|
+
this.relinkedRows = [];
|
|
20044
|
+
return rows;
|
|
20045
|
+
}
|
|
20046
|
+
/**
|
|
20047
|
+
* 상태 기록에 없는 행 노트가 이 행이 놓일 자리에 이미 있다 — 이 행 id 를 단 이름이 먼저, 그 다음 자연 이름. 페이지의
|
|
20048
|
+
* `claimExistingNote` 와 같은 규칙이다.
|
|
20049
|
+
*/
|
|
20050
|
+
async claimExistingRow(localFolder, safeName, pageId) {
|
|
20051
|
+
const [natural, ...suffixed] = pagePathCandidates(localFolder, safeName, pageId);
|
|
20052
|
+
const order = [
|
|
20053
|
+
...suffixed.map((path) => [path, "own"]),
|
|
20054
|
+
[natural, "name"]
|
|
20055
|
+
];
|
|
20056
|
+
for (const [candidate, existing] of order) {
|
|
20057
|
+
if (this.relinkClaims.has(candidate) || this.stateDb.getByPath(candidate)) continue;
|
|
20058
|
+
if (!await this.vaultFs.exists(candidate)) continue;
|
|
20059
|
+
this.relinkClaims.add(candidate);
|
|
20060
|
+
return { path: candidate, existing };
|
|
20061
|
+
}
|
|
20062
|
+
return null;
|
|
20063
|
+
}
|
|
20064
|
+
/**
|
|
20065
|
+
* 상태 기록에 없던 행 노트를 이 행과 다시 잇는다(P7) — 페이지의 `relinkExistingNote` 와 같은 규칙. 글이 같으면 잇고, 다르면
|
|
20066
|
+
* 이 행 id 를 단 이름은 충돌 전략을 따르고 · 자연 이름은 이은 노트가 없는 볼트에서만 충돌로 잇는다. 잇지 않으면 null.
|
|
20067
|
+
* remote-first 는 `"write"` — 호출측이 받은 행으로 덮는다.
|
|
20068
|
+
*/
|
|
20069
|
+
async relinkExistingRow(filePath, existing, local, finalContent, page, dbConfig, rendered, bodyFingerprint) {
|
|
20070
|
+
const same = sameNoteContent(local, finalContent) || sameAsPendingLinks(local, finalContent);
|
|
20071
|
+
if (!same && existing === "name" && !this.unlinkedVault) return null;
|
|
20072
|
+
const strategy = existing === "own" ? this.config.sync.conflictStrategy : "manual";
|
|
20073
|
+
if (!same && strategy === "remote-first") return "write";
|
|
20074
|
+
const conflicted = !same && strategy !== "local-first";
|
|
20075
|
+
const stat2 = same ? await this.vaultFs.getFileStat(filePath) : null;
|
|
20076
|
+
this.stateDb.transaction(() => {
|
|
20077
|
+
this.stateDb.upsert({
|
|
20078
|
+
obsidianPath: filePath,
|
|
20079
|
+
notionPageId: page.id,
|
|
20080
|
+
notionParentId: dbConfig.databaseId,
|
|
20081
|
+
// 같으면 로컬 글로 적어 바뀐 것이 없게 · local-first 는 받은 글로 적어 다음 push 가 로컬을 올리게.
|
|
20082
|
+
contentHash: computeHash(same ? local : finalContent),
|
|
20083
|
+
...observedRecordFields(page, this.observation().seenAt, bodyFingerprint),
|
|
20084
|
+
localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
20085
|
+
syncDirection: "both",
|
|
20086
|
+
fileType: "db-row",
|
|
20087
|
+
status: conflicted ? "conflict" : "synced",
|
|
20088
|
+
// 충돌로 이으면 공통 조상을 모른다 — merge 도 합치지 않는다.
|
|
20089
|
+
baseSnapshot: conflicted ? null : Buffer.from(finalContent, "utf-8"),
|
|
20090
|
+
localMtime: stat2?.mtime ?? null,
|
|
20091
|
+
localFileSize: stat2?.size ?? null
|
|
20092
|
+
});
|
|
20093
|
+
this.stateDb.upsertWikilink({
|
|
20094
|
+
obsidianPath: filePath,
|
|
20095
|
+
notionPageId: page.id,
|
|
20096
|
+
title: rendered.title,
|
|
20097
|
+
aliases: extractAliases(rendered.properties)
|
|
20098
|
+
});
|
|
20099
|
+
});
|
|
20100
|
+
if (!conflicted) return { action: "unchanged", path: filePath, relinked: true };
|
|
20101
|
+
const record = this.stateDb.getByPath(filePath);
|
|
20102
|
+
return {
|
|
20103
|
+
action: "conflict",
|
|
20104
|
+
path: filePath,
|
|
20105
|
+
relinked: true,
|
|
20106
|
+
conflict: {
|
|
20107
|
+
syncRecord: record,
|
|
20108
|
+
localChange: {
|
|
20109
|
+
path: filePath,
|
|
20110
|
+
type: "modified",
|
|
20111
|
+
currentHash: computeHash(local),
|
|
20112
|
+
previousHash: record.contentHash
|
|
20113
|
+
},
|
|
20114
|
+
remoteChange: {
|
|
20115
|
+
pageId: page.id,
|
|
20116
|
+
type: "modified",
|
|
20117
|
+
lastEdited: page.last_edited_time,
|
|
20118
|
+
previousEdited: null,
|
|
20119
|
+
path: filePath,
|
|
20120
|
+
title: rendered.title
|
|
20121
|
+
},
|
|
20122
|
+
baseContent: null,
|
|
20123
|
+
localContent: local,
|
|
20124
|
+
remoteContent: finalContent
|
|
20125
|
+
}
|
|
20126
|
+
};
|
|
20127
|
+
}
|
|
20128
|
+
/** 이번 받기에서 로컬 편집을 합친 행의 경로를 가져가고 비운다. */
|
|
20129
|
+
takeMergedRows() {
|
|
20130
|
+
const rows = this.mergedRows;
|
|
20131
|
+
this.mergedRows = [];
|
|
20132
|
+
return rows;
|
|
20133
|
+
}
|
|
19120
20134
|
/**
|
|
19121
20135
|
* Notion 에서 다른 페이지 아래로 옮긴 DB 의 폴더를 볼트에서도 옮긴다 — 옮겼으면 그 이동. 옮기지 못하면 까닭을 남기고
|
|
19122
20136
|
* 옛 자리에서 받는다.
|
|
@@ -19130,7 +20144,7 @@ var DatabaseSyncer = class {
|
|
|
19130
20144
|
this.renamedRows.renamed.push(move);
|
|
19131
20145
|
this.renamedRows.applied.push(move);
|
|
19132
20146
|
getLogger().info(
|
|
19133
|
-
`[Im-Nobsidian] Notion \uC5D0\uC11C \uC62E\uAE34 DB \uC758 \uD3F4\uB354\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE40: ${move.from} \u2192 ${move.to}`
|
|
20147
|
+
`[Im-Nobsidian] Notion \uC5D0\uC11C ${move.renamed ? "\uC774\uB984\uC744 \uBC14\uAFBC" : "\uC62E\uAE34"} DB \uC758 \uD3F4\uB354\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE40: ${move.from} \u2192 ${move.to}`
|
|
19134
20148
|
);
|
|
19135
20149
|
return move;
|
|
19136
20150
|
}
|
|
@@ -19139,7 +20153,7 @@ var DatabaseSyncer = class {
|
|
|
19139
20153
|
if (!outcome) return null;
|
|
19140
20154
|
if ("reason" in outcome) {
|
|
19141
20155
|
getLogger().warn(
|
|
19142
|
-
`[Im-Nobsidian] Notion \uC5D0\uC11C \uC62E\
|
|
20156
|
+
`[Im-Nobsidian] Notion \uC5D0\uC11C \uC62E\uAE30\uAC70\uB098 \uC774\uB984\uC744 \uBC14\uAFBC DB \uC758 \uD3F4\uB354\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE30\uC9C0 \uC54A\uC74C (${outcome.path}): ${outcome.reason}`
|
|
19143
20157
|
);
|
|
19144
20158
|
return null;
|
|
19145
20159
|
}
|
|
@@ -19151,18 +20165,17 @@ var DatabaseSyncer = class {
|
|
|
19151
20165
|
return movingInVault.has(record.id) || this.stateDb.getIncompleteOpByState(record.id, "move") !== null;
|
|
19152
20166
|
}
|
|
19153
20167
|
/**
|
|
19154
|
-
* Notion 에서 다른 페이지 아래로
|
|
20168
|
+
* Notion 에서 다른 페이지 아래로 옮기거나 이름을 바꾼 발견 DB 의 폴더를 옮길 곳(ADR-047 · {@link retitledFolderName})
|
|
20169
|
+
* — 제자리면 null.
|
|
19155
20170
|
*
|
|
19156
20171
|
* Notion 에서 DB 를 옮겨도 DB · 행의 수정 시각이 그대로라 받은 DB 의 부모로만 안다 — 전체 대조는 모든 DB 를 받으니
|
|
19157
20172
|
* 거기서 보인다. 새 자리는 처음 발견할 때와 같다 — 부모 페이지의 하위 폴더 이름 그대로, 루트면 `databases` 선반.
|
|
19158
20173
|
* 맨 위 폴더도 루트 아래로 본다(볼트에서 옮긴 DB 를 push 가 루트 아래에 둔다 — {@link DatabaseMover}).
|
|
19159
20174
|
* 다음은 옮기지 않는다.
|
|
19160
|
-
* - 부모가 블록(토글 · 열) 안 · 볼트가 모르는 페이지 — 볼트에 그 자리가 없다
|
|
20175
|
+
* - 부모가 블록(토글 · 열) 안 · 볼트가 모르는 페이지 — 볼트에 그 자리가 없다(이름만 따른다)
|
|
19161
20176
|
* - 볼트에서 옮긴 DB 폴더 · 부모 노트를 아직 올리지 않았다 — 볼트가 먼저다(push 가 Notion 을 옮긴다)
|
|
19162
20177
|
*/
|
|
19163
20178
|
placementMoveOf(dbConfig, meta, movingInVault = /* @__PURE__ */ new Set()) {
|
|
19164
|
-
const parentId = meta.parentPageId;
|
|
19165
|
-
if (!parentId) return null;
|
|
19166
20179
|
const id = dbConfig.databaseId;
|
|
19167
20180
|
if ((this.config.notion.databases ?? []).some((db) => notionIdsEqual(db.databaseId, id))) {
|
|
19168
20181
|
return null;
|
|
@@ -19171,23 +20184,52 @@ var DatabaseSyncer = class {
|
|
|
19171
20184
|
return null;
|
|
19172
20185
|
}
|
|
19173
20186
|
const folder = dbConfig.localFolder;
|
|
19174
|
-
|
|
19175
|
-
|
|
19176
|
-
|
|
19177
|
-
|
|
19178
|
-
|
|
19179
|
-
if (
|
|
19180
|
-
|
|
19181
|
-
|
|
19182
|
-
|
|
19183
|
-
|
|
19184
|
-
|
|
20187
|
+
const current = parentFolderOf(folder);
|
|
20188
|
+
let place = current;
|
|
20189
|
+
const parentId = meta.parentPageId;
|
|
20190
|
+
if (parentId) {
|
|
20191
|
+
let places = null;
|
|
20192
|
+
if (notionIdsEqual(parentId, this.config.notion.rootPageId)) {
|
|
20193
|
+
places = [ROOT_DATABASES_FOLDER, ""];
|
|
20194
|
+
} else {
|
|
20195
|
+
const parent = this.stateDb.getByNotionId(parentId);
|
|
20196
|
+
if (parent && this.movedInVault(parent, movingInVault)) {
|
|
20197
|
+
return {
|
|
20198
|
+
path: folder,
|
|
20199
|
+
reason: "\uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE34 \uBD80\uBAA8 \uB178\uD2B8\uB97C \uC544\uC9C1 \uC62C\uB9AC\uC9C0 \uC54A\uC558\uB2E4 \u2014 \uB2E4\uC74C push \uAC00 Notion \uC744 \uB9DE\uCD98\uB2E4"
|
|
20200
|
+
};
|
|
20201
|
+
}
|
|
20202
|
+
if (parent) {
|
|
20203
|
+
places = [childFolderOf(parent, { databaseAt: (path) => this.databaseAt(path) })];
|
|
20204
|
+
if (isFolderRecord(parent)) places.push(parent.obsidianPath);
|
|
20205
|
+
}
|
|
19185
20206
|
}
|
|
19186
|
-
|
|
19187
|
-
if (isFolderRecord(parent)) places.push(parent.obsidianPath);
|
|
20207
|
+
if (places && !places.includes(current)) place = places[0];
|
|
19188
20208
|
}
|
|
19189
|
-
|
|
19190
|
-
|
|
20209
|
+
const renamed = this.retitledFolderName(folder, dbConfig.databaseId, meta.title);
|
|
20210
|
+
const to = renamed === null ? joinPath(place, baseName2(folder)) : resolveDbFolderPath(joinPath(place, renamed), id, (path) => this.databaseAt(path));
|
|
20211
|
+
if (to === folder) return null;
|
|
20212
|
+
return { from: folder, to, ...renamed !== null ? { renamed: true } : {} };
|
|
20213
|
+
}
|
|
20214
|
+
/**
|
|
20215
|
+
* Notion 에서 바꾼 DB 제목을 따른 폴더 이름(`sync.renameOnTitleChange`) — 바꿀 것이 없으면 null.
|
|
20216
|
+
*
|
|
20217
|
+
* 지난번에 받은 제목({@link DbBaseFiles})과 달라졌고, 폴더 이름이 그 옛 제목을 따르고 있을 때만 — 폴더를 만든 규칙
|
|
20218
|
+
* (`databaseFolderName`) 이나 `.base` 이름 규칙(`sanitizeFileName`, 볼트에서 바꿔 올린 이름) 그대로일 때다. 사용자가
|
|
20219
|
+
* 따로 정한 이름이면 두고, 새 이름도 지금 이름과 같은 규칙으로 짓는다. 같은 이름 DB 와 가르려 붙인 ` (id 조각)` 은
|
|
20220
|
+
* 떼고 견준다(F24) — 새 자리에서 다시 가른다.
|
|
20221
|
+
*/
|
|
20222
|
+
retitledFolderName(folder, databaseId, title) {
|
|
20223
|
+
if (!this.config.sync.renameOnTitleChange || !title) return null;
|
|
20224
|
+
const previous = this.baseFileInfo.get(databaseId)?.title;
|
|
20225
|
+
if (!previous || previous === title) return null;
|
|
20226
|
+
const name = baseName2(folder).replace(/ \([0-9a-f]{8,32}\)$/, "");
|
|
20227
|
+
for (const style of [databaseFolderName, sanitizeFileName]) {
|
|
20228
|
+
if (style(previous) !== name) continue;
|
|
20229
|
+
const next = style(title);
|
|
20230
|
+
return next && next !== name ? next : null;
|
|
20231
|
+
}
|
|
20232
|
+
return null;
|
|
19191
20233
|
}
|
|
19192
20234
|
/** 이 폴더가 받는 DB — 설정한 DB 와 발견한 DB 에서. */
|
|
19193
20235
|
databaseAt(folder) {
|
|
@@ -19198,6 +20240,12 @@ var DatabaseSyncer = class {
|
|
|
19198
20240
|
return all.find((db) => db.localFolder === folder)?.databaseId ?? null;
|
|
19199
20241
|
}
|
|
19200
20242
|
/** 이름을 바꾼 행을 가져가고 비운다. */
|
|
20243
|
+
/** 이번 pull 이 Notion 에서 지운 링크드 뷰라 치운 `.base` — 그것을 넣은 노트의 임베드를 뺀다({@link LinkUpdater.dropEmbeds}). */
|
|
20244
|
+
takeRetiredBases() {
|
|
20245
|
+
const paths = [...this.retiredBases];
|
|
20246
|
+
this.retiredBases.clear();
|
|
20247
|
+
return paths;
|
|
20248
|
+
}
|
|
19201
20249
|
takeRenamedRows() {
|
|
19202
20250
|
const rows = this.renamedRows;
|
|
19203
20251
|
this.renamedRows = { renamed: [], applied: [] };
|
|
@@ -19212,43 +20260,60 @@ var DatabaseSyncer = class {
|
|
|
19212
20260
|
const hasChildren = this.stateDb.getAll().some((r) => r.id !== record.id && within(r.obsidianPath, from));
|
|
19213
20261
|
return hasChildren ? { from, to: stripMarkdownExtension(rowPath) } : null;
|
|
19214
20262
|
}
|
|
20263
|
+
/**
|
|
20264
|
+
* Notion 에서 바꾼 제목으로 행 이름을 바꿀 자리(`sync.renameOnTitleChange`) — 바꿀 것이 없으면 null.
|
|
20265
|
+
* 받기 · pull dry-run · status 가 같은 판정을 쓴다. 행의 하위 페이지 폴더(`행/`)도 새 이름으로 옮기며, 옮길 수
|
|
20266
|
+
* 없으면 이름을 바꾸지 않는다(예전처럼 제목만 받는다) — 그 까닭을 돌려준다.
|
|
20267
|
+
*/
|
|
20268
|
+
async retitledRowTarget(localFolder, record, page) {
|
|
20269
|
+
const recordPath = record.obsidianPath;
|
|
20270
|
+
const title = this.notionClient.extractTitle(page);
|
|
20271
|
+
if (!this.config.sync.renameOnTitleChange || !isDirectDbRowPath(localFolder, recordPath) || !retitledInNotion(snapshotFrontmatter(record.baseSnapshot), null, recordPath, title) || this.stateDb.getIncompleteOpByState(record.id, "move")) {
|
|
20272
|
+
return null;
|
|
20273
|
+
}
|
|
20274
|
+
const safeName = sanitizeFileName(title);
|
|
20275
|
+
const to = resolveDbRowPath(
|
|
20276
|
+
localFolder,
|
|
20277
|
+
safeName,
|
|
20278
|
+
page.id,
|
|
20279
|
+
await this.rowPathOwners(localFolder, safeName, page.id)
|
|
20280
|
+
);
|
|
20281
|
+
if (to === recordPath) return null;
|
|
20282
|
+
const from = stripMarkdownExtension(recordPath);
|
|
20283
|
+
if (!this.stateDb.getAll().some((r) => within(r.obsidianPath, from))) {
|
|
20284
|
+
return { to, children: null };
|
|
20285
|
+
}
|
|
20286
|
+
const children = { from, to: stripMarkdownExtension(to) };
|
|
20287
|
+
const blocked = await this.relocator.blocked(children);
|
|
20288
|
+
return blocked ? { reason: `\uD558\uC704 \uD398\uC774\uC9C0 \uD3F4\uB354 \u2014 ${blocked}` } : { to, children };
|
|
20289
|
+
}
|
|
19215
20290
|
async pullDatabasePage(page, dbConfig) {
|
|
19216
20291
|
const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
|
|
19217
20292
|
const existingRecord = this.stateDb.getByNotionId(page.id);
|
|
19218
20293
|
const recordPath = existingRecord?.obsidianPath;
|
|
20294
|
+
const retitle = existingRecord && recordPath ? await this.retitledRowTarget(dbConfig.localFolder, existingRecord, page) : null;
|
|
19219
20295
|
let filePath;
|
|
19220
|
-
|
|
19221
|
-
|
|
19222
|
-
|
|
19223
|
-
|
|
19224
|
-
|
|
19225
|
-
|
|
19226
|
-
if (recordPath && isDirectDbRowPath(dbConfig.localFolder, recordPath) && !retitled) {
|
|
20296
|
+
let childFolderMove = null;
|
|
20297
|
+
let adopt = null;
|
|
20298
|
+
if (retitle && "to" in retitle) {
|
|
20299
|
+
filePath = retitle.to;
|
|
20300
|
+
childFolderMove = retitle.children;
|
|
20301
|
+
} else if (recordPath && isDirectDbRowPath(dbConfig.localFolder, recordPath)) {
|
|
19227
20302
|
filePath = recordPath;
|
|
19228
20303
|
} else {
|
|
19229
|
-
filePath = resolveDbRowPath(
|
|
20304
|
+
filePath = (!existingRecord ? (adopt = await this.claimExistingRow(dbConfig.localFolder, safeName, page.id))?.path : void 0) ?? resolveDbRowPath(
|
|
19230
20305
|
dbConfig.localFolder,
|
|
19231
20306
|
safeName,
|
|
19232
20307
|
page.id,
|
|
19233
20308
|
await this.rowPathOwners(dbConfig.localFolder, safeName, page.id)
|
|
19234
20309
|
);
|
|
19235
20310
|
}
|
|
19236
|
-
|
|
19237
|
-
|
|
19238
|
-
|
|
19239
|
-
|
|
19240
|
-
childFolderMove = { from, to: stripMarkdownExtension(filePath) };
|
|
19241
|
-
const blocked = await this.relocator.blocked(childFolderMove);
|
|
19242
|
-
if (blocked) {
|
|
19243
|
-
getLogger().warn(
|
|
19244
|
-
`[Im-Nobsidian] Notion \uC5D0\uC11C \uBC14\uAFBC \uC81C\uBAA9\uC73C\uB85C \uD589 \uC774\uB984\uC744 \uBC14\uAFB8\uC9C0 \uC54A\uC74C (${recordPath}): \uD558\uC704 \uD398\uC774\uC9C0 \uD3F4\uB354 \u2014 ${blocked}`
|
|
19245
|
-
);
|
|
19246
|
-
filePath = recordPath;
|
|
19247
|
-
childFolderMove = null;
|
|
19248
|
-
}
|
|
19249
|
-
}
|
|
20311
|
+
if (retitle && "reason" in retitle) {
|
|
20312
|
+
getLogger().warn(
|
|
20313
|
+
`[Im-Nobsidian] Notion \uC5D0\uC11C \uBC14\uAFBC \uC81C\uBAA9\uC73C\uB85C \uD589 \uC774\uB984\uC744 \uBC14\uAFB8\uC9C0 \uC54A\uC74C (${recordPath}): ${retitle.reason}`
|
|
20314
|
+
);
|
|
19250
20315
|
}
|
|
19251
|
-
const renaming =
|
|
20316
|
+
const renaming = retitle !== null && "to" in retitle;
|
|
19252
20317
|
const becomesRow = !!existingRecord && !!recordPath && wasPage(existingRecord);
|
|
19253
20318
|
const arrives = becomesRow || !!existingRecord && !!recordPath && arrivesFromOtherDatabase(existingRecord, dbConfig);
|
|
19254
20319
|
if (arrives && recordPath && this.stateDb.getIncompleteOpByState(existingRecord.id, "move")) {
|
|
@@ -19271,10 +20336,39 @@ var DatabaseSyncer = class {
|
|
|
19271
20336
|
emptiedFolder = parentFolderOf(recordPath);
|
|
19272
20337
|
}
|
|
19273
20338
|
}
|
|
19274
|
-
const
|
|
20339
|
+
const local = adopt ? await readLocalNote(this.vaultFs, filePath) : void 0;
|
|
20340
|
+
let rendered = await this.renderRow(page, filePath, {
|
|
20341
|
+
localPath: recordPath ?? filePath,
|
|
20342
|
+
...local !== void 0 ? { adoptFrom: local } : {}
|
|
20343
|
+
});
|
|
20344
|
+
let relinked = false;
|
|
20345
|
+
if (adopt) {
|
|
20346
|
+
const outcome = local === void 0 ? null : await this.relinkExistingRow(
|
|
20347
|
+
filePath,
|
|
20348
|
+
adopt.existing,
|
|
20349
|
+
local,
|
|
20350
|
+
rendered.content,
|
|
20351
|
+
page,
|
|
20352
|
+
dbConfig,
|
|
20353
|
+
rendered,
|
|
20354
|
+
rendered.bodyFingerprint
|
|
20355
|
+
);
|
|
20356
|
+
if (outcome === "write") relinked = true;
|
|
20357
|
+
else if (outcome) return outcome;
|
|
20358
|
+
else {
|
|
20359
|
+
filePath = resolveDbRowPath(
|
|
20360
|
+
dbConfig.localFolder,
|
|
20361
|
+
safeName,
|
|
20362
|
+
page.id,
|
|
20363
|
+
await this.rowPathOwners(dbConfig.localFolder, safeName, page.id)
|
|
20364
|
+
);
|
|
20365
|
+
rendered = await this.renderRow(page, filePath, { localPath: filePath });
|
|
20366
|
+
}
|
|
20367
|
+
}
|
|
19275
20368
|
const { title, properties, bodyFingerprint } = rendered;
|
|
19276
20369
|
const finalContent = becomesRow ? keepPageValues(rendered.content, await readLocalNote(this.vaultFs, recordPath)) : rendered.content;
|
|
19277
20370
|
const seenAt = this.observation().seenAt;
|
|
20371
|
+
let merged = null;
|
|
19278
20372
|
if (existingRecord) {
|
|
19279
20373
|
let localContent = "";
|
|
19280
20374
|
let localExists = true;
|
|
@@ -19285,8 +20379,7 @@ var DatabaseSyncer = class {
|
|
|
19285
20379
|
localContent = "";
|
|
19286
20380
|
localExists = await this.vaultFs.exists(readPath);
|
|
19287
20381
|
}
|
|
19288
|
-
const
|
|
19289
|
-
const remoteUnchanged = localExists && base !== null && bodyFingerprint === existingRecord.notionBodyFingerprint && this.rowOutsideBodyUnchanged(this.propertyMapper, page, base, existingRecord.obsidianPath);
|
|
20382
|
+
const remoteUnchanged = localExists && this.rowRemoteUnchanged(existingRecord, page, bodyFingerprint, localContent);
|
|
19290
20383
|
const resolution = resolvePullConflict({
|
|
19291
20384
|
record: existingRecord,
|
|
19292
20385
|
localContent,
|
|
@@ -19337,9 +20430,10 @@ var DatabaseSyncer = class {
|
|
|
19337
20430
|
});
|
|
19338
20431
|
return { action: "unchanged", path: filePath };
|
|
19339
20432
|
}
|
|
20433
|
+
if (resolution.action === "merge") merged = resolution.merged;
|
|
19340
20434
|
}
|
|
19341
20435
|
await this.vaultFs.ensureFolder(dbConfig.localFolder);
|
|
19342
|
-
await this.vaultFs.writeFile(filePath, finalContent);
|
|
20436
|
+
await this.vaultFs.writeFile(filePath, merged ?? finalContent);
|
|
19343
20437
|
const rehomed = existingRecord && recordPath && recordPath !== filePath;
|
|
19344
20438
|
const outOfStep = becomesRow && (rendered.propertiesBlock || !this.rowOutsideBodyUnchanged(
|
|
19345
20439
|
this.propertyMapper,
|
|
@@ -19370,9 +20464,9 @@ var DatabaseSyncer = class {
|
|
|
19370
20464
|
fileType: "db-row",
|
|
19371
20465
|
status: "synced",
|
|
19372
20466
|
baseSnapshot: Buffer.from(finalContent, "utf-8"),
|
|
19373
|
-
// 비운
|
|
19374
|
-
localMtime: outOfStep ? null : stat2?.mtime ?? null,
|
|
19375
|
-
localFileSize: outOfStep ? null : stat2?.size ?? null,
|
|
20467
|
+
// 비운 해시 · 합친 글을 변경 확인이 보게 — stat 이 맞으면 내용을 읽지 않는다(B5).
|
|
20468
|
+
localMtime: outOfStep || merged !== null ? null : stat2?.mtime ?? null,
|
|
20469
|
+
localFileSize: outOfStep || merged !== null ? null : stat2?.size ?? null,
|
|
19376
20470
|
// 빈 해시와 함께 — push 가 지난 사본 대신 원격의 지금 값과 견줘 다른 속성을 모두 보낸다(overwritesRemote).
|
|
19377
20471
|
...outOfStep ? { notionBodyFingerprint: null } : {}
|
|
19378
20472
|
});
|
|
@@ -19410,7 +20504,7 @@ var DatabaseSyncer = class {
|
|
|
19410
20504
|
);
|
|
19411
20505
|
}
|
|
19412
20506
|
}
|
|
19413
|
-
return { action: "written", path: filePath };
|
|
20507
|
+
return merged !== null ? { action: "merged", path: filePath } : { action: "written", path: filePath, ...relinked ? { relinked: true } : {} };
|
|
19414
20508
|
}
|
|
19415
20509
|
};
|
|
19416
20510
|
function arrivesFromOtherDatabase(record, dbConfig) {
|
|
@@ -19549,160 +20643,30 @@ function folderOf3(record) {
|
|
|
19549
20643
|
if (record.fileType === "db-row") return null;
|
|
19550
20644
|
if (isFolderRecord(record)) return record.obsidianPath;
|
|
19551
20645
|
const slash = record.obsidianPath.lastIndexOf("/");
|
|
19552
|
-
if (slash < 0) return null;
|
|
19553
|
-
const folder = record.obsidianPath.slice(0, slash);
|
|
19554
|
-
const stem = stripMarkdownExtension(record.obsidianPath.slice(slash + 1)).replace(ID_SUFFIX, "");
|
|
19555
|
-
return stem === folder.slice(folder.lastIndexOf("/") + 1) ? folder : null;
|
|
19556
|
-
}
|
|
19557
|
-
function findNestedFolderPages(records) {
|
|
19558
|
-
const byPage = /* @__PURE__ */ new Map();
|
|
19559
|
-
for (const record of records) {
|
|
19560
|
-
if (record.notionPageId !== null) byPage.set(compactNotionId(record.notionPageId), record);
|
|
19561
|
-
}
|
|
19562
|
-
const found2 = [];
|
|
19563
|
-
for (const inner of records) {
|
|
19564
|
-
if (inner.notionPageId === null || inner.notionParentId === null) continue;
|
|
19565
|
-
const folder = folderOf3(inner);
|
|
19566
|
-
if (folder === null) continue;
|
|
19567
|
-
const outer = byPage.get(compactNotionId(inner.notionParentId));
|
|
19568
|
-
if (!outer || outer === inner || folderOf3(outer) !== folder) continue;
|
|
19569
|
-
found2.push({
|
|
19570
|
-
folder,
|
|
19571
|
-
outer: { path: outer.obsidianPath, notionPageId: outer.notionPageId },
|
|
19572
|
-
inner: { path: inner.obsidianPath, notionPageId: inner.notionPageId }
|
|
19573
|
-
});
|
|
19574
|
-
}
|
|
19575
|
-
return found2.sort((a, b) => a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0);
|
|
19576
|
-
}
|
|
19577
|
-
|
|
19578
|
-
// src/conflict/merger.ts
|
|
19579
|
-
function threeWayMerge(base, local, remote) {
|
|
19580
|
-
const baseLines = base.split("\n");
|
|
19581
|
-
const localLines = local.split("\n");
|
|
19582
|
-
const remoteLines = remote.split("\n");
|
|
19583
|
-
const hunks = [
|
|
19584
|
-
...computeHunks(baseLines, localLines, 0),
|
|
19585
|
-
...computeHunks(baseLines, remoteLines, 2)
|
|
19586
|
-
];
|
|
19587
|
-
hunks.sort((a, b) => a.baseStart - b.baseStart || a.side - b.side);
|
|
19588
|
-
const merged = [];
|
|
19589
|
-
const conflicts = [];
|
|
19590
|
-
let cursor = 0;
|
|
19591
|
-
let i = 0;
|
|
19592
|
-
while (i < hunks.length) {
|
|
19593
|
-
const first = hunks[i];
|
|
19594
|
-
if (first.baseStart > cursor) {
|
|
19595
|
-
merged.push(...baseLines.slice(cursor, first.baseStart));
|
|
19596
|
-
}
|
|
19597
|
-
const regionStart = first.baseStart;
|
|
19598
|
-
let regionEnd = first.baseEnd;
|
|
19599
|
-
const group2 = [first];
|
|
19600
|
-
i++;
|
|
19601
|
-
while (i < hunks.length) {
|
|
19602
|
-
const next = hunks[i];
|
|
19603
|
-
const overlaps = next.baseStart < regionEnd || regionEnd === regionStart && next.baseStart === regionStart;
|
|
19604
|
-
if (!overlaps) break;
|
|
19605
|
-
regionEnd = Math.max(regionEnd, next.baseEnd);
|
|
19606
|
-
group2.push(next);
|
|
19607
|
-
i++;
|
|
19608
|
-
}
|
|
19609
|
-
const baseRegion = baseLines.slice(regionStart, regionEnd);
|
|
19610
|
-
const localRegion = applyHunks(baseLines, group2, 0, regionStart, regionEnd);
|
|
19611
|
-
const remoteRegion = applyHunks(baseLines, group2, 2, regionStart, regionEnd);
|
|
19612
|
-
if (arraysEqual(localRegion, baseRegion)) {
|
|
19613
|
-
merged.push(...remoteRegion);
|
|
19614
|
-
} else if (arraysEqual(remoteRegion, baseRegion)) {
|
|
19615
|
-
merged.push(...localRegion);
|
|
19616
|
-
} else if (arraysEqual(localRegion, remoteRegion)) {
|
|
19617
|
-
merged.push(...localRegion);
|
|
19618
|
-
} else {
|
|
19619
|
-
conflicts.push({ localLines: localRegion, remoteLines: remoteRegion });
|
|
19620
|
-
merged.push("<<<<<<< LOCAL");
|
|
19621
|
-
merged.push(...localRegion);
|
|
19622
|
-
merged.push("=======");
|
|
19623
|
-
merged.push(...remoteRegion);
|
|
19624
|
-
merged.push(">>>>>>> REMOTE");
|
|
19625
|
-
}
|
|
19626
|
-
cursor = regionEnd;
|
|
19627
|
-
}
|
|
19628
|
-
if (cursor < baseLines.length) {
|
|
19629
|
-
merged.push(...baseLines.slice(cursor));
|
|
19630
|
-
}
|
|
19631
|
-
return {
|
|
19632
|
-
success: conflicts.length === 0,
|
|
19633
|
-
merged: merged.join("\n"),
|
|
19634
|
-
conflicts
|
|
19635
|
-
};
|
|
19636
|
-
}
|
|
19637
|
-
function computeHunks(base, modified, side) {
|
|
19638
|
-
const matches = lcsMatches(base, modified);
|
|
19639
|
-
const hunks = [];
|
|
19640
|
-
let prevBase = -1;
|
|
19641
|
-
let prevMod = -1;
|
|
19642
|
-
const emitGap = (curBase, curMod) => {
|
|
19643
|
-
const baseLen = curBase - (prevBase + 1);
|
|
19644
|
-
const modLen = curMod - (prevMod + 1);
|
|
19645
|
-
if (baseLen > 0 || modLen > 0) {
|
|
19646
|
-
hunks.push({
|
|
19647
|
-
baseStart: prevBase + 1,
|
|
19648
|
-
baseEnd: curBase,
|
|
19649
|
-
content: modified.slice(prevMod + 1, curMod),
|
|
19650
|
-
side
|
|
19651
|
-
});
|
|
19652
|
-
}
|
|
19653
|
-
};
|
|
19654
|
-
for (const [b, m] of matches) {
|
|
19655
|
-
emitGap(b, m);
|
|
19656
|
-
prevBase = b;
|
|
19657
|
-
prevMod = m;
|
|
19658
|
-
}
|
|
19659
|
-
emitGap(base.length, modified.length);
|
|
19660
|
-
return hunks;
|
|
19661
|
-
}
|
|
19662
|
-
function applyHunks(baseLines, group2, side, regionStart, regionEnd) {
|
|
19663
|
-
const out = [];
|
|
19664
|
-
let b = regionStart;
|
|
19665
|
-
for (const hunk of group2) {
|
|
19666
|
-
if (hunk.side !== side) continue;
|
|
19667
|
-
if (hunk.baseStart > b) out.push(...baseLines.slice(b, hunk.baseStart));
|
|
19668
|
-
out.push(...hunk.content);
|
|
19669
|
-
b = hunk.baseEnd;
|
|
19670
|
-
}
|
|
19671
|
-
if (b < regionEnd) out.push(...baseLines.slice(b, regionEnd));
|
|
19672
|
-
return out;
|
|
20646
|
+
if (slash < 0) return null;
|
|
20647
|
+
const folder = record.obsidianPath.slice(0, slash);
|
|
20648
|
+
const stem = stripMarkdownExtension(record.obsidianPath.slice(slash + 1)).replace(ID_SUFFIX, "");
|
|
20649
|
+
return stem === folder.slice(folder.lastIndexOf("/") + 1) ? folder : null;
|
|
19673
20650
|
}
|
|
19674
|
-
function
|
|
19675
|
-
const
|
|
19676
|
-
const
|
|
19677
|
-
|
|
19678
|
-
for (let i2 = 0; i2 <= n; i2++) dp[i2] = new Uint32Array(m + 1);
|
|
19679
|
-
for (let i2 = n - 1; i2 >= 0; i2--) {
|
|
19680
|
-
const row = dp[i2];
|
|
19681
|
-
const next = dp[i2 + 1];
|
|
19682
|
-
const ai = a[i2];
|
|
19683
|
-
for (let j2 = m - 1; j2 >= 0; j2--) {
|
|
19684
|
-
row[j2] = ai === b[j2] ? next[j2 + 1] + 1 : Math.max(next[j2], row[j2 + 1]);
|
|
19685
|
-
}
|
|
20651
|
+
function findNestedFolderPages(records) {
|
|
20652
|
+
const byPage = /* @__PURE__ */ new Map();
|
|
20653
|
+
for (const record of records) {
|
|
20654
|
+
if (record.notionPageId !== null) byPage.set(compactNotionId(record.notionPageId), record);
|
|
19686
20655
|
}
|
|
19687
|
-
const
|
|
19688
|
-
|
|
19689
|
-
|
|
19690
|
-
|
|
19691
|
-
if (
|
|
19692
|
-
|
|
19693
|
-
|
|
19694
|
-
|
|
19695
|
-
|
|
19696
|
-
|
|
19697
|
-
|
|
19698
|
-
|
|
19699
|
-
}
|
|
20656
|
+
const found2 = [];
|
|
20657
|
+
for (const inner of records) {
|
|
20658
|
+
if (inner.notionPageId === null || inner.notionParentId === null) continue;
|
|
20659
|
+
const folder = folderOf3(inner);
|
|
20660
|
+
if (folder === null) continue;
|
|
20661
|
+
const outer = byPage.get(compactNotionId(inner.notionParentId));
|
|
20662
|
+
if (!outer || outer === inner || folderOf3(outer) !== folder) continue;
|
|
20663
|
+
found2.push({
|
|
20664
|
+
folder,
|
|
20665
|
+
outer: { path: outer.obsidianPath, notionPageId: outer.notionPageId },
|
|
20666
|
+
inner: { path: inner.obsidianPath, notionPageId: inner.notionPageId }
|
|
20667
|
+
});
|
|
19700
20668
|
}
|
|
19701
|
-
return
|
|
19702
|
-
}
|
|
19703
|
-
function arraysEqual(a, b) {
|
|
19704
|
-
if (a.length !== b.length) return false;
|
|
19705
|
-
return a.every((val, idx) => val === b[idx]);
|
|
20669
|
+
return found2.sort((a, b) => a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0);
|
|
19706
20670
|
}
|
|
19707
20671
|
|
|
19708
20672
|
// src/conflict/resolver.ts
|
|
@@ -19723,6 +20687,7 @@ function choiceForStrategy(conflict, strategy) {
|
|
|
19723
20687
|
case "duplicate":
|
|
19724
20688
|
return isRemoteDeletion(conflict) ? "local" : "duplicate";
|
|
19725
20689
|
case "manual":
|
|
20690
|
+
case "merge":
|
|
19726
20691
|
if (isRemoteDeletion(conflict)) {
|
|
19727
20692
|
throw new Error(
|
|
19728
20693
|
`Notion \uC5D0\uC11C \uC9C0\uC6B4 \uB178\uD2B8\uB294 \uC790\uB3D9 \uBCD1\uD569\uD560 \uC218 \uC5C6\uC74C \u2014 \uB85C\uCEEC \uC720\uC9C0\uB098 \uC6D0\uACA9 \uC720\uC9C0\uB97C \uACE0\uB974\uC138\uC694: ${conflict.syncRecord.obsidianPath}`
|
|
@@ -20691,6 +21656,12 @@ function depthOf(filter) {
|
|
|
20691
21656
|
}
|
|
20692
21657
|
|
|
20693
21658
|
// src/sync/base-view-push.ts
|
|
21659
|
+
var BASES_TO_NOTION_VIEW = {
|
|
21660
|
+
table: "table",
|
|
21661
|
+
cards: "gallery",
|
|
21662
|
+
list: "list"
|
|
21663
|
+
};
|
|
21664
|
+
var CREATABLE_KEYS = ["type", "name", "order", "sort", "filters"];
|
|
20694
21665
|
var PROPERTY_REQUEST_KEYS = [
|
|
20695
21666
|
"property_id",
|
|
20696
21667
|
"visible",
|
|
@@ -20702,6 +21673,9 @@ var PROPERTY_REQUEST_KEYS = [
|
|
|
20702
21673
|
"time_format"
|
|
20703
21674
|
];
|
|
20704
21675
|
var REQUIRED_CONFIGURATION_KEYS = ["group_by", "date_property_id"];
|
|
21676
|
+
function plannedViewEdits(file) {
|
|
21677
|
+
return [...file.created, ...file.views, ...file.deleted].map((planned) => planned.edit);
|
|
21678
|
+
}
|
|
20705
21679
|
var BaseViewEdits = class {
|
|
20706
21680
|
constructor(stateDb, vaultFs, notionClient) {
|
|
20707
21681
|
this.vaultFs = vaultFs;
|
|
@@ -20741,7 +21715,9 @@ var BaseViewEdits = class {
|
|
|
20741
21715
|
const changed = changedFields(record, edited);
|
|
20742
21716
|
return changed.length > 0 ? [{ record, edited, changed }] : [];
|
|
20743
21717
|
});
|
|
20744
|
-
|
|
21718
|
+
const added = parsed.views.flatMap((view, i) => matched[i] ? [] : [{ view, index: i }]);
|
|
21719
|
+
const removed = base.views.filter((record) => !matched.includes(record));
|
|
21720
|
+
if (edits.length === 0 && added.length === 0 && removed.length === 0) continue;
|
|
20745
21721
|
const meta = await this.notionClient.getDatabaseMeta(databaseId);
|
|
20746
21722
|
if (meta.dataSources.length > 1) {
|
|
20747
21723
|
refuse2(base.basePath, "\uB370\uC774\uD130 \uC18C\uC2A4\uAC00 \uC5EC\uB7FF\uC778 DB \uC758 \uBCF4\uAE30\uB294 Notion \uC5D0\uC11C \uBC14\uAFB8\uC138\uC694");
|
|
@@ -20750,15 +21726,66 @@ var BaseViewEdits = class {
|
|
|
20750
21726
|
const schema = await this.notionClient.getDatabaseSchemaFull(databaseId, meta);
|
|
20751
21727
|
const views = [];
|
|
20752
21728
|
let allPlanned = true;
|
|
20753
|
-
|
|
20754
|
-
const label = `\uBCF4\uAE30 '${record.name}'`;
|
|
20755
|
-
let live;
|
|
21729
|
+
const liveOf = async (viewId) => {
|
|
20756
21730
|
try {
|
|
20757
|
-
|
|
21731
|
+
return await this.notionClient.getView(viewId);
|
|
20758
21732
|
} catch (error) {
|
|
20759
21733
|
if (!isNotionObjectNotFound(error)) throw error;
|
|
20760
|
-
|
|
21734
|
+
return null;
|
|
21735
|
+
}
|
|
21736
|
+
};
|
|
21737
|
+
const created = [];
|
|
21738
|
+
for (const { view, index } of added) {
|
|
21739
|
+
const planned2 = planCreate(view, index, base.basePath, schema);
|
|
21740
|
+
if ("error" in planned2) {
|
|
21741
|
+
refuse2(base.basePath, `\uB354\uD55C \uBCF4\uAE30 '${String(view["name"] ?? "")}': ${planned2.error}`);
|
|
21742
|
+
allPlanned = false;
|
|
21743
|
+
continue;
|
|
21744
|
+
}
|
|
21745
|
+
if (!meta.dataSources[0]) {
|
|
21746
|
+
refuse2(
|
|
21747
|
+
base.basePath,
|
|
21748
|
+
`\uB354\uD55C \uBCF4\uAE30 '${planned2.edit.view}': DB \uC758 \uB370\uC774\uD130 \uC18C\uC2A4\uB97C \uC77D\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4`
|
|
21749
|
+
);
|
|
21750
|
+
allPlanned = false;
|
|
21751
|
+
continue;
|
|
21752
|
+
}
|
|
21753
|
+
created.push(planned2);
|
|
21754
|
+
if (Object.keys(view).some((key) => !CREATABLE_KEYS.includes(key))) allPlanned = false;
|
|
21755
|
+
}
|
|
21756
|
+
const deleted = [];
|
|
21757
|
+
const gone = [];
|
|
21758
|
+
for (const record of removed) {
|
|
21759
|
+
const label = `\uC9C0\uC6B4 \uBCF4\uAE30 '${record.name}'`;
|
|
21760
|
+
if (parsed.views.length === 0) {
|
|
21761
|
+
refuse2(
|
|
21762
|
+
base.basePath,
|
|
21763
|
+
`${label}: \uBCF4\uAE30\uB97C \uBAA8\uB450 \uC9C0\uC6B4 .base \uB85C\uB294 Notion \uBCF4\uAE30\uB97C \uC9C0\uC6B0\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4`
|
|
21764
|
+
);
|
|
21765
|
+
allPlanned = false;
|
|
21766
|
+
continue;
|
|
21767
|
+
}
|
|
21768
|
+
const live = await liveOf(record.viewId);
|
|
21769
|
+
if (!live) {
|
|
21770
|
+
gone.push(record.viewId);
|
|
21771
|
+
continue;
|
|
21772
|
+
}
|
|
21773
|
+
if (viewFingerprint(live) !== record.notion) {
|
|
21774
|
+
refuse2(
|
|
21775
|
+
base.basePath,
|
|
21776
|
+
`${label}: \uADF8 \uC0AC\uC774 Notion \uC5D0\uC11C \uC774 \uBCF4\uAE30\uB97C \uACE0\uCCD0 \uC9C0\uC6B0\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. \uC9C0\uC6B0\uB824\uBA74 Notion \uC5D0\uC11C \uC9C0\uC6B0\uC138\uC694`
|
|
21777
|
+
);
|
|
21778
|
+
allPlanned = false;
|
|
21779
|
+
continue;
|
|
20761
21780
|
}
|
|
21781
|
+
deleted.push({
|
|
21782
|
+
viewId: record.viewId,
|
|
21783
|
+
edit: { path: base.basePath, view: record.name, action: "deleted", changed: [] }
|
|
21784
|
+
});
|
|
21785
|
+
}
|
|
21786
|
+
for (const { record, edited, changed } of edits) {
|
|
21787
|
+
const label = `\uBCF4\uAE30 '${record.name}'`;
|
|
21788
|
+
const live = await liveOf(record.viewId);
|
|
20762
21789
|
if (!live) {
|
|
20763
21790
|
refuse2(base.basePath, `${label}: Notion \uC5D0\uC11C \uC9C0\uC6B4 \uBCF4\uAE30\uC785\uB2C8\uB2E4`);
|
|
20764
21791
|
allPlanned = false;
|
|
@@ -20792,18 +21819,25 @@ var BaseViewEdits = class {
|
|
|
20792
21819
|
type: record.type,
|
|
20793
21820
|
...edited,
|
|
20794
21821
|
...record.lossyFilter && !changed.includes("filter") ? { lossyFilter: true } : {},
|
|
20795
|
-
notion: record.notion
|
|
21822
|
+
notion: record.notion,
|
|
21823
|
+
...record.rest !== void 0 ? { rest: record.rest } : {}
|
|
20796
21824
|
}
|
|
20797
21825
|
});
|
|
20798
21826
|
}
|
|
20799
|
-
if (views.length === 0)
|
|
21827
|
+
if (views.length === 0 && created.length === 0 && deleted.length === 0 && gone.length === 0) {
|
|
21828
|
+
continue;
|
|
21829
|
+
}
|
|
20800
21830
|
planned.push({
|
|
20801
21831
|
databaseId,
|
|
20802
21832
|
path: base.basePath,
|
|
20803
21833
|
base,
|
|
20804
21834
|
hash,
|
|
20805
|
-
complete: allPlanned &&
|
|
20806
|
-
views
|
|
21835
|
+
complete: allPlanned && restUnchanged(parsed, matched, base),
|
|
21836
|
+
views,
|
|
21837
|
+
created,
|
|
21838
|
+
deleted,
|
|
21839
|
+
gone,
|
|
21840
|
+
matched
|
|
20807
21841
|
});
|
|
20808
21842
|
}
|
|
20809
21843
|
return { planned, refused };
|
|
@@ -20813,29 +21847,73 @@ var BaseViewEdits = class {
|
|
|
20813
21847
|
const failed = [];
|
|
20814
21848
|
for (const file of planned) {
|
|
20815
21849
|
const records = new Map(file.base.views.map((record) => [record.viewId, record]));
|
|
21850
|
+
const made = /* @__PURE__ */ new Map();
|
|
20816
21851
|
let allApplied = true;
|
|
21852
|
+
const fail = (what, error) => {
|
|
21853
|
+
allApplied = false;
|
|
21854
|
+
failed.push({
|
|
21855
|
+
path: file.path,
|
|
21856
|
+
operation: "update",
|
|
21857
|
+
error: `${what}: ${error instanceof Error ? error.message : String(error)}`
|
|
21858
|
+
});
|
|
21859
|
+
};
|
|
21860
|
+
const fingerprintOf = async (viewId, fallback) => {
|
|
21861
|
+
const after = await this.notionClient.getView(viewId).catch(() => null);
|
|
21862
|
+
return after ? viewFingerprint(after) : fallback;
|
|
21863
|
+
};
|
|
21864
|
+
const meta = file.created.length > 0 ? await this.dataSourceOf(file.databaseId) : null;
|
|
21865
|
+
for (const view of file.created) {
|
|
21866
|
+
let viewId;
|
|
21867
|
+
try {
|
|
21868
|
+
if (!meta) throw new Error("DB \uC758 \uB370\uC774\uD130 \uC18C\uC2A4\uB97C \uC77D\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4");
|
|
21869
|
+
viewId = await this.notionClient.createView({
|
|
21870
|
+
databaseId: file.databaseId,
|
|
21871
|
+
dataSourceId: meta,
|
|
21872
|
+
request: view.request
|
|
21873
|
+
});
|
|
21874
|
+
} catch (error) {
|
|
21875
|
+
fail(`\uBCF4\uAE30 '${view.edit.view}' \uB97C Notion \uC5D0 \uB9CC\uB4E4\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4`, error);
|
|
21876
|
+
continue;
|
|
21877
|
+
}
|
|
21878
|
+
applied.push(view.edit);
|
|
21879
|
+
made.set(view.index, { ...view.record, viewId, notion: await fingerprintOf(viewId, "") });
|
|
21880
|
+
}
|
|
20817
21881
|
for (const view of file.views) {
|
|
20818
21882
|
try {
|
|
20819
21883
|
await this.notionClient.updateView(view.viewId, view.request);
|
|
20820
21884
|
} catch (error) {
|
|
20821
|
-
|
|
20822
|
-
failed.push({
|
|
20823
|
-
path: file.path,
|
|
20824
|
-
operation: "update",
|
|
20825
|
-
error: `\uBCF4\uAE30 '${view.edit.view}' \uB97C Notion \uC5D0 \uC62C\uB9AC\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: ${error instanceof Error ? error.message : String(error)}`
|
|
20826
|
-
});
|
|
21885
|
+
fail(`\uBCF4\uAE30 '${view.edit.view}' \uB97C Notion \uC5D0 \uC62C\uB9AC\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4`, error);
|
|
20827
21886
|
continue;
|
|
20828
21887
|
}
|
|
20829
21888
|
applied.push(view.edit);
|
|
20830
|
-
const after = await this.notionClient.getView(view.viewId).catch(() => null);
|
|
20831
21889
|
records.set(view.viewId, {
|
|
20832
21890
|
...view.record,
|
|
20833
|
-
notion:
|
|
21891
|
+
notion: await fingerprintOf(view.viewId, view.record.notion)
|
|
20834
21892
|
});
|
|
20835
21893
|
}
|
|
21894
|
+
for (const viewId of file.gone) records.delete(viewId);
|
|
21895
|
+
for (const view of file.deleted) {
|
|
21896
|
+
try {
|
|
21897
|
+
await this.notionClient.deleteView(view.viewId);
|
|
21898
|
+
} catch (error) {
|
|
21899
|
+
if (!isNotionObjectNotFound(error)) {
|
|
21900
|
+
fail(`\uBCF4\uAE30 '${view.edit.view}' \uB97C Notion \uC5D0\uC11C \uC9C0\uC6B0\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4`, error);
|
|
21901
|
+
continue;
|
|
21902
|
+
}
|
|
21903
|
+
}
|
|
21904
|
+
applied.push(view.edit);
|
|
21905
|
+
records.delete(view.viewId);
|
|
21906
|
+
}
|
|
21907
|
+
const inFile = file.matched.flatMap((record, i) => {
|
|
21908
|
+
const kept = record ? records.get(record.viewId) : made.get(i);
|
|
21909
|
+
return kept ? [kept] : [];
|
|
21910
|
+
});
|
|
21911
|
+
const left = [...records.values()].filter(
|
|
21912
|
+
(record) => !inFile.some((r) => r.viewId === record.viewId)
|
|
21913
|
+
);
|
|
20836
21914
|
this.bases.set(file.databaseId, {
|
|
20837
21915
|
...file.base,
|
|
20838
|
-
views:
|
|
21916
|
+
views: [...inFile, ...left],
|
|
20839
21917
|
// 고친 것을 모두 올렸으면 이 파일은 이제 pull 이 쓴 것과 같은 뜻이다 — 다음 pull 이 Notion 의 모습으로 다시 쓴다.
|
|
20840
21918
|
// 남은 것이 있으면 계속 붙잡아 둔다.
|
|
20841
21919
|
...file.complete && allApplied ? { hash: file.hash } : {}
|
|
@@ -20843,15 +21921,55 @@ var BaseViewEdits = class {
|
|
|
20843
21921
|
}
|
|
20844
21922
|
return { applied, failed };
|
|
20845
21923
|
}
|
|
21924
|
+
/** 보기를 만들 data source — 계획 때 하나뿐임을 보았다. */
|
|
21925
|
+
async dataSourceOf(databaseId) {
|
|
21926
|
+
const meta = await this.notionClient.getDatabaseMeta(databaseId).catch(() => null);
|
|
21927
|
+
return meta?.dataSources[0]?.id ?? null;
|
|
21928
|
+
}
|
|
20846
21929
|
};
|
|
20847
|
-
function
|
|
21930
|
+
function planCreate(view, index, path, schema) {
|
|
21931
|
+
const basesType = String(view["type"] ?? "");
|
|
21932
|
+
const type = Object.hasOwn(BASES_TO_NOTION_VIEW, basesType) ? BASES_TO_NOTION_VIEW[basesType] : void 0;
|
|
21933
|
+
if (!type) return { error: `Notion \uC5D0 \uAC19\uC740 \uBCF4\uAE30 \uC885\uB958\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4(${basesType || "\uD0C0\uC785 \uC5C6\uC74C"})` };
|
|
21934
|
+
const edited = pushableOf(view);
|
|
21935
|
+
if (!edited.name.trim()) return { error: "\uBCF4\uAE30 \uC774\uB984\uC774 \uBE44\uC5C8\uC2B5\uB2C8\uB2E4" };
|
|
21936
|
+
const live = { id: "", name: edited.name, type, configuration: { type } };
|
|
21937
|
+
const changed = ["columns", "sort", "filter"].filter(
|
|
21938
|
+
(field) => field === "columns" ? edited.order !== void 0 : field === "sort" ? edited.sort !== void 0 : edited.filters !== void 0
|
|
21939
|
+
);
|
|
21940
|
+
const built = buildRequest(
|
|
21941
|
+
{ name: edited.name, notion: "", viewId: "", type: basesType },
|
|
21942
|
+
edited,
|
|
21943
|
+
changed,
|
|
21944
|
+
live,
|
|
21945
|
+
schema,
|
|
21946
|
+
{ hideRest: true }
|
|
21947
|
+
);
|
|
21948
|
+
if (!built.ok) return { error: built.error };
|
|
21949
|
+
const { configuration, filter, sorts } = built.request;
|
|
21950
|
+
return {
|
|
21951
|
+
index,
|
|
21952
|
+
request: {
|
|
21953
|
+
name: edited.name,
|
|
21954
|
+
type,
|
|
21955
|
+
configuration: configuration ?? { type },
|
|
21956
|
+
...filter ? { filter } : {},
|
|
21957
|
+
...sorts ? { sorts } : {}
|
|
21958
|
+
},
|
|
21959
|
+
edit: { path, view: edited.name, action: "created", changed: [] },
|
|
21960
|
+
record: { type: basesType, ...edited, rest: viewRestOf(view) }
|
|
21961
|
+
};
|
|
21962
|
+
}
|
|
21963
|
+
function buildRequest(record, edited, changed, live, schema, options = {}) {
|
|
20848
21964
|
const request = {};
|
|
20849
21965
|
try {
|
|
20850
21966
|
if (changed.includes("name")) {
|
|
20851
21967
|
if (!edited.name.trim()) throw new Error("\uBCF4\uAE30 \uC774\uB984\uC774 \uBE44\uC5C8\uC2B5\uB2C8\uB2E4");
|
|
20852
21968
|
request.name = edited.name;
|
|
20853
21969
|
}
|
|
20854
|
-
if (changed.includes("columns"))
|
|
21970
|
+
if (changed.includes("columns")) {
|
|
21971
|
+
request.configuration = columnsOf(edited, live, schema, options.hideRest === true);
|
|
21972
|
+
}
|
|
20855
21973
|
if (changed.includes("sort")) {
|
|
20856
21974
|
request.sorts = edited.sort ? edited.sort.map((s) => ({
|
|
20857
21975
|
property: propertyIdOf(s.property, schema, "\uC815\uB82C"),
|
|
@@ -20877,7 +21995,7 @@ function buildRequest(record, edited, changed, live, schema) {
|
|
|
20877
21995
|
}
|
|
20878
21996
|
return { ok: true, request };
|
|
20879
21997
|
}
|
|
20880
|
-
function columnsOf(edited, live, schema) {
|
|
21998
|
+
function columnsOf(edited, live, schema, hideRest = false) {
|
|
20881
21999
|
if (!edited.order) throw new Error("\uC5F4 \uBAA9\uB85D(order)\uC744 \uC9C0\uC6B4 \uBCF4\uAE30\uB294 \uC62C\uB9AC\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
|
|
20882
22000
|
const configuration = live.configuration ?? {};
|
|
20883
22001
|
const liveEntries = Array.isArray(configuration["properties"]) ? configuration["properties"] : [];
|
|
@@ -20906,7 +22024,7 @@ function columnsOf(edited, live, schema) {
|
|
|
20906
22024
|
const properties = [
|
|
20907
22025
|
...titleId ? [entryOf(titleId)] : [],
|
|
20908
22026
|
...shown.map((id) => ({ ...entryOf(id), visible: true })),
|
|
20909
|
-
...liveEntries.map((entry) => decodePropertyId(String(entry["property_id"]))).filter((id) => id !== titleId && !shown.includes(id)).map((id) => ({ ...entryOf(id), visible: false }))
|
|
22027
|
+
...(hideRest ? Object.values(schema).map((prop) => decodePropertyId(prop.id)) : liveEntries.map((entry) => decodePropertyId(String(entry["property_id"])))).filter((id, i, all) => id !== titleId && !shown.includes(id) && all.indexOf(id) === i).map((id) => ({ ...entryOf(id), visible: false }))
|
|
20910
22028
|
];
|
|
20911
22029
|
return {
|
|
20912
22030
|
type: configuration["type"] ?? live.type,
|
|
@@ -21023,7 +22141,8 @@ var OPERATION_LABELS = {
|
|
|
21023
22141
|
fetch: "\uC6D0\uACA9 \uD655\uC778",
|
|
21024
22142
|
resolve: "\uCDA9\uB3CC \uD574\uACB0",
|
|
21025
22143
|
discard: "\uBCC0\uACBD \uB418\uB3CC\uB9AC\uAE30",
|
|
21026
|
-
"prepare-database": "DB \uB85C \uC62C\uB9B4 \uC900\uBE44"
|
|
22144
|
+
"prepare-database": "DB \uB85C \uC62C\uB9B4 \uC900\uBE44",
|
|
22145
|
+
"base-filters": ".base \uD3F4\uB354 \uD544\uD130 \uACE0\uCE58\uAE30"
|
|
21027
22146
|
};
|
|
21028
22147
|
function operationLabel(operation) {
|
|
21029
22148
|
return OPERATION_LABELS[operation];
|
|
@@ -21208,6 +22327,7 @@ var InterruptedSyncRecovery = class {
|
|
|
21208
22327
|
if (ops.length === 0) return;
|
|
21209
22328
|
for (const op of ops) {
|
|
21210
22329
|
if (op.direction === "push" && op.operation === "move") continue;
|
|
22330
|
+
if (op.direction === "push" && op.operation === "update") continue;
|
|
21211
22331
|
if (op.direction !== "push" || op.operation !== "create") {
|
|
21212
22332
|
this.stateDb.markPendingFailed(op.id, "unsupported resume op");
|
|
21213
22333
|
continue;
|
|
@@ -21329,6 +22449,101 @@ var InterruptedSyncRecovery = class {
|
|
|
21329
22449
|
}
|
|
21330
22450
|
};
|
|
21331
22451
|
|
|
22452
|
+
// src/sync/lost-state-guard.ts
|
|
22453
|
+
var SHOWN_CLASHES = 5;
|
|
22454
|
+
var UnlinkedVaultError = class extends Error {
|
|
22455
|
+
clashes;
|
|
22456
|
+
constructor(clashes) {
|
|
22457
|
+
const shown = clashes.slice(0, SHOWN_CLASHES).join(", ");
|
|
22458
|
+
const more = clashes.length > SHOWN_CLASHES ? ` \uC678 ${clashes.length - SHOWN_CLASHES}\uAC1C` : "";
|
|
22459
|
+
super(
|
|
22460
|
+
`\uC0C1\uD0DC \uAE30\uB85D\uC5D0 Notion \uACFC \uC774\uC740 \uB178\uD2B8\uAC00 \uC5C6\uB294\uB370 Notion \uC5D0 \uAC19\uC740 \uC774\uB984\uC758 \uD398\uC774\uC9C0\uAC00 \uC774\uBBF8 \uC788\uC5B4 \uC62C\uB9AC\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4(${shown}${more}) \u2014 \uC774\uB300\uB85C \uC62C\uB9AC\uBA74 \uAC19\uC740 \uD398\uC774\uC9C0\uB97C \uB610 \uB9CC\uB4ED\uB2C8\uB2E4. \uBA3C\uC800 pull(\uB610\uB294 sync)\uB85C \uBC1B\uC73C\uC138\uC694: \uAC19\uC740 \uC790\uB9AC\uC5D0 \uAC19\uC740 \uAE00\uC774 \uC788\uB294 \uB178\uD2B8\uB294 \uADF8 \uD398\uC774\uC9C0\uC640 \uB2E4\uC2DC \uC787\uACE0, \uAE00\uC774 \uB2E4\uB974\uBA74 \uCDA9\uB3CC\uB85C \uB0A8\uAE41\uB2C8\uB2E4`
|
|
22461
|
+
);
|
|
22462
|
+
this.name = "UnlinkedVaultError";
|
|
22463
|
+
this.clashes = clashes;
|
|
22464
|
+
}
|
|
22465
|
+
};
|
|
22466
|
+
async function assertVaultLinked(config, stateDb, notionClient, createdPaths) {
|
|
22467
|
+
const unverified = /* @__PURE__ */ new Map();
|
|
22468
|
+
if (config.sync.direction === "push" || createdPaths.length === 0) return unverified;
|
|
22469
|
+
if (stateDb.getAll().length > 0 || stateDb.getIncompletePendingOperations().length > 0) {
|
|
22470
|
+
return unverified;
|
|
22471
|
+
}
|
|
22472
|
+
const clashes = await new RemoteTree(notionClient).clashes(config, createdPaths, unverified);
|
|
22473
|
+
if (clashes.length > 0) throw new UnlinkedVaultError(clashes);
|
|
22474
|
+
return unverified;
|
|
22475
|
+
}
|
|
22476
|
+
var RemoteTree = class {
|
|
22477
|
+
constructor(notionClient) {
|
|
22478
|
+
this.notionClient = notionClient;
|
|
22479
|
+
}
|
|
22480
|
+
notionClient;
|
|
22481
|
+
listed = /* @__PURE__ */ new Map();
|
|
22482
|
+
async clashes(config, createdPaths, unverified) {
|
|
22483
|
+
const databases = config.notion.databases ?? [];
|
|
22484
|
+
const clashes = [];
|
|
22485
|
+
for (const path of [...createdPaths].sort()) {
|
|
22486
|
+
const database = databases.find((db) => path.startsWith(`${db.localFolder}/`));
|
|
22487
|
+
const found2 = await (database ? this.find({ id: database.databaseId, kind: "database" }, [
|
|
22488
|
+
path.slice(database.localFolder.length + 1)
|
|
22489
|
+
]) : isDatabaseMode(config) ? this.find({ id: config.notion.databaseId, kind: "database" }, [
|
|
22490
|
+
path.split("/").pop()
|
|
22491
|
+
]) : this.find({ id: config.notion.rootPageId, kind: "page" }, path.split("/"))).catch((error) => {
|
|
22492
|
+
unverified.set(
|
|
22493
|
+
path,
|
|
22494
|
+
"\uC0C1\uD0DC \uAE30\uB85D\uC774 \uBE48 \uBCFC\uD2B8\uB77C Notion \uC5D0 \uAC19\uC740 \uD398\uC774\uC9C0\uAC00 \uC788\uB294\uC9C0 \uBA3C\uC800 \uBCF4\uB294\uB370 \uC77D\uC9C0 \uBABB\uD574 \uC62C\uB9AC\uC9C0 \uC54A\uC74C: " + (error instanceof Error ? error.message : String(error))
|
|
22495
|
+
);
|
|
22496
|
+
return false;
|
|
22497
|
+
});
|
|
22498
|
+
if (found2) clashes.push(path);
|
|
22499
|
+
}
|
|
22500
|
+
return clashes;
|
|
22501
|
+
}
|
|
22502
|
+
/** `segments`(폴더 … 노트 파일)의 노트 페이지가 `node` 아래에 이미 있나. */
|
|
22503
|
+
async find(node, segments) {
|
|
22504
|
+
let current = node;
|
|
22505
|
+
for (const folder of segments.slice(0, -1)) {
|
|
22506
|
+
const next = (await this.children(current)).get(sanitizeFileName(folder));
|
|
22507
|
+
if (!next) return false;
|
|
22508
|
+
current = next;
|
|
22509
|
+
}
|
|
22510
|
+
const name = sanitizeFileName(stripMarkdownExtension(segments[segments.length - 1]));
|
|
22511
|
+
const parentFolder = segments.length > 1 ? sanitizeFileName(segments[segments.length - 2]) : null;
|
|
22512
|
+
if (parentFolder === name && current.kind === "page") return true;
|
|
22513
|
+
return (await this.children(current)).has(name);
|
|
22514
|
+
}
|
|
22515
|
+
children(node) {
|
|
22516
|
+
let listed = this.listed.get(node.id);
|
|
22517
|
+
if (!listed) {
|
|
22518
|
+
listed = node.kind === "database" ? this.rows(node.id) : this.subpages(node.id);
|
|
22519
|
+
this.listed.set(node.id, listed);
|
|
22520
|
+
}
|
|
22521
|
+
return listed;
|
|
22522
|
+
}
|
|
22523
|
+
async rows(databaseId) {
|
|
22524
|
+
const rows = /* @__PURE__ */ new Map();
|
|
22525
|
+
for (const page of await this.notionClient.queryAllDatabasePages(databaseId)) {
|
|
22526
|
+
rows.set(sanitizeFileName(this.notionClient.extractTitle(page)), {
|
|
22527
|
+
id: page.id,
|
|
22528
|
+
kind: "page"
|
|
22529
|
+
});
|
|
22530
|
+
}
|
|
22531
|
+
return rows;
|
|
22532
|
+
}
|
|
22533
|
+
async subpages(pageId) {
|
|
22534
|
+
const found2 = /* @__PURE__ */ new Map();
|
|
22535
|
+
for (const block of await this.notionClient.fetchAllChildren(pageId)) {
|
|
22536
|
+
const child = block;
|
|
22537
|
+
if (child.type === "child_page" && child.child_page?.title !== void 0) {
|
|
22538
|
+
found2.set(sanitizeFileName(child.child_page.title), { id: child.id, kind: "page" });
|
|
22539
|
+
} else if (child.type === "child_database" && child.child_database?.title !== void 0) {
|
|
22540
|
+
found2.set(sanitizeFileName(child.child_database.title), { id: child.id, kind: "database" });
|
|
22541
|
+
}
|
|
22542
|
+
}
|
|
22543
|
+
return found2;
|
|
22544
|
+
}
|
|
22545
|
+
};
|
|
22546
|
+
|
|
21332
22547
|
// src/sync/db-placeholder-rewriter.ts
|
|
21333
22548
|
var ID32 = "[a-f0-9]{32}";
|
|
21334
22549
|
var IDUUID = "[a-f0-9-]{32,36}";
|
|
@@ -21515,6 +22730,7 @@ var DatabaseDiscovery = class {
|
|
|
21515
22730
|
}
|
|
21516
22731
|
const linkedMap = loadLinkedDbMap(this.stateDb);
|
|
21517
22732
|
let linkedChanged = false;
|
|
22733
|
+
const linkedNow = /* @__PURE__ */ new Set();
|
|
21518
22734
|
const retry = parseDiscoveryRetry(this.stateDb.getMeta(DISCOVERY_RETRY_META_KEY));
|
|
21519
22735
|
const nextRetry = { parents: [], dbs: [] };
|
|
21520
22736
|
const pathOf = (pageId) => notionIdsEqual(pageId, this.config.notion.rootPageId) ? "" : this.stateDb.getByNotionId(pageId)?.obsidianPath ?? pageId;
|
|
@@ -21568,6 +22784,7 @@ var DatabaseDiscovery = class {
|
|
|
21568
22784
|
} else if (outcome.kind === "linked") {
|
|
21569
22785
|
const origNohyph = outcome.originalDbId.replace(/-/g, "");
|
|
21570
22786
|
linkedMap.set(nohyph, origNohyph);
|
|
22787
|
+
linkedNow.add(nohyph);
|
|
21571
22788
|
linkedChanged = true;
|
|
21572
22789
|
await registerOriginal(origNohyph);
|
|
21573
22790
|
} else if (outcome.kind === "inaccessible") {
|
|
@@ -21602,6 +22819,7 @@ var DatabaseDiscovery = class {
|
|
|
21602
22819
|
await register(normalizeNotionId(nohyph), parentPageId);
|
|
21603
22820
|
}
|
|
21604
22821
|
const stillSyncable = [];
|
|
22822
|
+
const pulledWhole = /* @__PURE__ */ new Set();
|
|
21605
22823
|
let queue = [...dbConfigs];
|
|
21606
22824
|
const MAX_DISCOVERY_ROUNDS = 4;
|
|
21607
22825
|
const resolveDbFolder = (dbId) => {
|
|
@@ -21641,10 +22859,8 @@ var DatabaseDiscovery = class {
|
|
|
21641
22859
|
}
|
|
21642
22860
|
});
|
|
21643
22861
|
if (dbResult.linkedOriginalDbId) {
|
|
21644
|
-
linkedMap.set(
|
|
21645
|
-
|
|
21646
|
-
dbResult.linkedOriginalDbId.replace(/-/g, "")
|
|
21647
|
-
);
|
|
22862
|
+
linkedMap.set(nohyph, dbResult.linkedOriginalDbId.replace(/-/g, ""));
|
|
22863
|
+
linkedNow.add(nohyph);
|
|
21648
22864
|
linkedChanged = true;
|
|
21649
22865
|
changed = true;
|
|
21650
22866
|
ledger?.settle(dbConfig.databaseId);
|
|
@@ -21653,6 +22869,7 @@ var DatabaseDiscovery = class {
|
|
|
21653
22869
|
);
|
|
21654
22870
|
continue;
|
|
21655
22871
|
}
|
|
22872
|
+
if (!paths) pulledWhole.add(nohyph);
|
|
21656
22873
|
created += dbResult.created;
|
|
21657
22874
|
updated += dbResult.updated;
|
|
21658
22875
|
deleted += dbResult.deleted;
|
|
@@ -21699,6 +22916,11 @@ var DatabaseDiscovery = class {
|
|
|
21699
22916
|
);
|
|
21700
22917
|
}
|
|
21701
22918
|
}
|
|
22919
|
+
if (!signal?.aborted) {
|
|
22920
|
+
linkedChanged = await this.pullLinkedViews(linkedMap, stillSyncable, {
|
|
22921
|
+
pulled: (container, original) => forceRediscovery || linkedNow.has(container) || pulledWhole.has(original)
|
|
22922
|
+
}) || linkedChanged;
|
|
22923
|
+
}
|
|
21702
22924
|
ledger?.keepOnly([...configuredIds, ...stillSyncable.map((c) => c.databaseId)]);
|
|
21703
22925
|
if (changed || inaccessibleChanged || stillSyncable.length !== dbConfigs.length) {
|
|
21704
22926
|
this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(stillSyncable));
|
|
@@ -21723,6 +22945,29 @@ var DatabaseDiscovery = class {
|
|
|
21723
22945
|
}
|
|
21724
22946
|
return { created, updated, deleted, restored };
|
|
21725
22947
|
}
|
|
22948
|
+
/**
|
|
22949
|
+
* 링크드 뷰 컨테이너마다 그 보기로 `.base` 를 쓴다({@link DatabaseSyncer.pullLinkedView}). 원본 DB 를 받은 pull ·
|
|
22950
|
+
* 이번에 해소한 컨테이너 · 아직 `.base` 가 없는 컨테이너만 — 보기만 고친 것은 DB 수정 시각을 바꾸지 않아 원본처럼
|
|
22951
|
+
* 다음 전체 대조가 받는다. Notion 에서 지운 컨테이너는 연결에서 뺀다.
|
|
22952
|
+
*
|
|
22953
|
+
* @returns 연결(`linkedMap`)을 바꿨나.
|
|
22954
|
+
*/
|
|
22955
|
+
async pullLinkedViews(linkedMap, syncing, due) {
|
|
22956
|
+
let changed = false;
|
|
22957
|
+
const owners = [...syncing, ...this.config.notion.databases ?? []];
|
|
22958
|
+
for (const [container, original] of [...linkedMap]) {
|
|
22959
|
+
const owner = owners.find((c) => c.databaseId.replace(/-/g, "") === original);
|
|
22960
|
+
if (!owner) continue;
|
|
22961
|
+
if (!due.pulled(container, original) && this.databaseSyncer.baseFileInfo.get(container)) {
|
|
22962
|
+
continue;
|
|
22963
|
+
}
|
|
22964
|
+
if (await this.databaseSyncer.pullLinkedView(container, owner) === "gone") {
|
|
22965
|
+
linkedMap.delete(container);
|
|
22966
|
+
changed = true;
|
|
22967
|
+
}
|
|
22968
|
+
}
|
|
22969
|
+
return changed;
|
|
22970
|
+
}
|
|
21726
22971
|
async discoverChildDatabases(pageIds) {
|
|
21727
22972
|
const found2 = [];
|
|
21728
22973
|
const unread = [];
|
|
@@ -21836,6 +23081,12 @@ var DatabaseDiscovery = class {
|
|
|
21836
23081
|
for (const [key, t] of [...targets]) {
|
|
21837
23082
|
if (!await this.vaultFs.exists(t.basePath)) targets.delete(key);
|
|
21838
23083
|
}
|
|
23084
|
+
for (const container of linkedMap.keys()) {
|
|
23085
|
+
const info = this.databaseSyncer.baseFileInfo.get(container);
|
|
23086
|
+
if (info?.linkedTo && await this.vaultFs.exists(info.basePath)) {
|
|
23087
|
+
targets.set(container, { basePath: info.basePath, title: info.title });
|
|
23088
|
+
}
|
|
23089
|
+
}
|
|
21839
23090
|
if (targets.size === 0) return;
|
|
21840
23091
|
const resolve2 = (nohyph) => targets.get(nohyph) ?? targets.get(linkedMap.get(nohyph) ?? "") ?? null;
|
|
21841
23092
|
let total = 0;
|
|
@@ -21886,12 +23137,6 @@ var DatabaseDiscovery = class {
|
|
|
21886
23137
|
return refs;
|
|
21887
23138
|
}
|
|
21888
23139
|
};
|
|
21889
|
-
function databaseFolderName(title) {
|
|
21890
|
-
return truncateBytes(
|
|
21891
|
-
title.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-"),
|
|
21892
|
-
FILE_SYSTEM_NAME_MAX_BYTES
|
|
21893
|
-
);
|
|
21894
|
-
}
|
|
21895
23140
|
|
|
21896
23141
|
// src/sync/folder-placement.ts
|
|
21897
23142
|
var FolderPlacement = class {
|
|
@@ -22980,12 +24225,14 @@ function baseName3(path) {
|
|
|
22980
24225
|
|
|
22981
24226
|
// src/sync/link-updater.ts
|
|
22982
24227
|
var LinkUpdater = class {
|
|
22983
|
-
constructor(stateDb, vaultFs) {
|
|
24228
|
+
constructor(stateDb, vaultFs, outside) {
|
|
22984
24229
|
this.stateDb = stateDb;
|
|
22985
24230
|
this.vaultFs = vaultFs;
|
|
24231
|
+
this.outside = outside;
|
|
22986
24232
|
}
|
|
22987
24233
|
stateDb;
|
|
22988
24234
|
vaultFs;
|
|
24235
|
+
outside;
|
|
22989
24236
|
/**
|
|
22990
24237
|
* @param moves 옮긴 차례 그대로의 이동 — 노트 하나거나 폴더 하나.
|
|
22991
24238
|
* @returns 링크를 고친 노트.
|
|
@@ -23003,33 +24250,130 @@ var LinkUpdater = class {
|
|
|
23003
24250
|
if (renames.size === 0) return [];
|
|
23004
24251
|
const before = new Set([...after].map((path) => undo(path, moves)));
|
|
23005
24252
|
const context = { before, after, renames };
|
|
24253
|
+
const noteRenames = new Map([...renames].filter(([from]) => isMarkdownPath(from)));
|
|
23006
24254
|
const relinked = [];
|
|
23007
24255
|
for (const note of notes) {
|
|
23008
24256
|
const source = { from: undo(note.path, moves), to: note.path };
|
|
23009
24257
|
const next = rewriteLinks(note.content, source, context);
|
|
23010
24258
|
if (next === note.content) continue;
|
|
24259
|
+
const notesOnly = noteRenames.size === renames.size || rewriteLinks(note.content, source, { ...context, renames: noteRenames }) === next;
|
|
24260
|
+
if (await this.rewrite(note, next, notesOnly)) relinked.push(note.path);
|
|
24261
|
+
}
|
|
24262
|
+
if (this.outside) relinked.push(...await this.applyOutside(this.outside, moves, after));
|
|
24263
|
+
if (relinked.length > 0) {
|
|
24264
|
+
getLogger().info(`[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE68: \uB178\uD2B8 ${relinked.length}`);
|
|
24265
|
+
}
|
|
24266
|
+
return relinked;
|
|
24267
|
+
}
|
|
24268
|
+
/**
|
|
24269
|
+
* 이 연결이 보지 않는 노트의 링크를 고친다 — 경로는 볼트 맨 위 기준으로 옮겨 견준다. Obsidian 은 링크를 볼트 전체에서
|
|
24270
|
+
* 찾으므로 이름만 적은 링크도 볼트 전체의 파일로 가른다.
|
|
24271
|
+
*
|
|
24272
|
+
* @param own 이 연결이 보는 파일(연결 폴더 기준) — 위에서 고쳤다.
|
|
24273
|
+
* @returns 고친 노트(볼트 맨 위 기준).
|
|
24274
|
+
*/
|
|
24275
|
+
async applyOutside(outside, moves, own) {
|
|
24276
|
+
const prefix = outside.folder === "" ? "" : `${outside.folder}/`;
|
|
24277
|
+
const vaultMoves = moves.map((move) => ({
|
|
24278
|
+
...move,
|
|
24279
|
+
from: prefix + move.from,
|
|
24280
|
+
to: prefix + move.to
|
|
24281
|
+
}));
|
|
24282
|
+
const mine = new Set([...own].map((path) => prefix + path));
|
|
24283
|
+
const notes = await outside.fs.listMarkdownFiles();
|
|
24284
|
+
const others = await outside.fs.listNonMarkdownFiles();
|
|
24285
|
+
const after = new Set([...notes, ...others].map((file) => file.path));
|
|
24286
|
+
const renames = /* @__PURE__ */ new Map();
|
|
24287
|
+
for (const path of after) {
|
|
24288
|
+
const before = undo(path, vaultMoves);
|
|
24289
|
+
if (before !== path) renames.set(before, path);
|
|
24290
|
+
}
|
|
24291
|
+
if (renames.size === 0) return [];
|
|
24292
|
+
const context = {
|
|
24293
|
+
before: new Set([...after].map((path) => undo(path, vaultMoves))),
|
|
24294
|
+
after,
|
|
24295
|
+
renames
|
|
24296
|
+
};
|
|
24297
|
+
const relinked = [];
|
|
24298
|
+
for (const note of notes) {
|
|
24299
|
+
if (mine.has(note.path)) continue;
|
|
24300
|
+
const next = rewriteLinks(note.content, { from: note.path, to: note.path }, context);
|
|
24301
|
+
if (next === note.content) continue;
|
|
23011
24302
|
try {
|
|
23012
|
-
await
|
|
24303
|
+
await outside.fs.writeFile(note.path, next);
|
|
24304
|
+
relinked.push(note.path);
|
|
23013
24305
|
} catch (error) {
|
|
23014
24306
|
getLogger().warn(
|
|
23015
|
-
`[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568 (${note.path}): ${error instanceof Error ? error.message : String(error)}`
|
|
24307
|
+
`[Im-Nobsidian] \uB2E4\uB978 \uC5F0\uACB0\uC758 \uB178\uD2B8\uC5D0\uC11C \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568 (${note.path}): ${error instanceof Error ? error.message : String(error)}`
|
|
23016
24308
|
);
|
|
23017
|
-
continue;
|
|
23018
24309
|
}
|
|
23019
|
-
const record = this.stateDb.getByPath(note.path);
|
|
23020
|
-
if (record && record.contentHash === computeHash(note.content)) {
|
|
23021
|
-
this.stateDb.updateHash(record.id, computeHash(next), Buffer.from(next, "utf-8"));
|
|
23022
|
-
const stat2 = await this.vaultFs.getFileStat(note.path);
|
|
23023
|
-
if (stat2) this.stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
|
|
23024
|
-
}
|
|
23025
|
-
relinked.push(note.path);
|
|
23026
|
-
}
|
|
23027
|
-
if (relinked.length > 0) {
|
|
23028
|
-
getLogger().info(`[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE68: \uB178\uD2B8 ${relinked.length}`);
|
|
23029
24310
|
}
|
|
23030
24311
|
return relinked;
|
|
23031
24312
|
}
|
|
24313
|
+
/**
|
|
24314
|
+
* 치운 `.base` 를 넣은 줄을 뺀다 — Notion 에서 지운 링크드 뷰라 pull 이 그 `.base` 를 치웠다. Notion 의 본문에는 그
|
|
24315
|
+
* 블록이 이미 없다. API 로 블록을 지우면 페이지의 수정 시각이 그대로라(2026-10-30 실측) 그 페이지를 다시 받지 않아
|
|
24316
|
+
* 깨진 임베드가 남았다. 동기화된 노트는 고친 글을 사본으로 적는다 — Notion 의 본문과 같아졌다.
|
|
24317
|
+
*
|
|
24318
|
+
* @returns 임베드를 뺀 노트.
|
|
24319
|
+
*/
|
|
24320
|
+
async dropEmbeds(basePaths) {
|
|
24321
|
+
if (basePaths.length === 0) return [];
|
|
24322
|
+
const gone = new Set(basePaths);
|
|
24323
|
+
const dropped = [];
|
|
24324
|
+
for (const note of await this.vaultFs.listMarkdownFiles()) {
|
|
24325
|
+
const next = dropEmbedLines(note.content, gone);
|
|
24326
|
+
if (next !== note.content && await this.rewrite(note, next, true)) dropped.push(note.path);
|
|
24327
|
+
}
|
|
24328
|
+
if (dropped.length > 0) {
|
|
24329
|
+
getLogger().info(
|
|
24330
|
+
`[Im-Nobsidian] Notion \uC5D0\uC11C \uC9C0\uC6B4 \uB9C1\uD06C\uB4DC \uBDF0\uC758 \uC784\uBCA0\uB4DC\uB97C \uBE8C: \uB178\uD2B8 ${dropped.length}`
|
|
24331
|
+
);
|
|
24332
|
+
}
|
|
24333
|
+
return dropped;
|
|
24334
|
+
}
|
|
24335
|
+
/**
|
|
24336
|
+
* 고친 글을 쓴다. `matchesNotion` 이고 동기화된 노트(지난 사본 그대로)면 고친 글을 사본으로 적어 다음 push 가 올리지
|
|
24337
|
+
* 않게 한다 — Notion 의 본문은 이미 그 모양이다.
|
|
24338
|
+
*/
|
|
24339
|
+
async rewrite(note, next, matchesNotion) {
|
|
24340
|
+
try {
|
|
24341
|
+
await this.vaultFs.writeFile(note.path, next);
|
|
24342
|
+
} catch (error) {
|
|
24343
|
+
getLogger().warn(
|
|
24344
|
+
`[Im-Nobsidian] \uB2E4\uB978 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE58\uC9C0 \uBABB\uD568 (${note.path}): ${error instanceof Error ? error.message : String(error)}`
|
|
24345
|
+
);
|
|
24346
|
+
return false;
|
|
24347
|
+
}
|
|
24348
|
+
const record = this.stateDb.getByPath(note.path);
|
|
24349
|
+
if (record && record.contentHash === computeHash(note.content) && matchesNotion) {
|
|
24350
|
+
this.stateDb.updateHash(record.id, computeHash(next), Buffer.from(next, "utf-8"));
|
|
24351
|
+
const stat2 = await this.vaultFs.getFileStat(note.path);
|
|
24352
|
+
if (stat2) this.stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
|
|
24353
|
+
}
|
|
24354
|
+
return true;
|
|
24355
|
+
}
|
|
23032
24356
|
};
|
|
24357
|
+
var EMBED_LINE_RE = /^!\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$/;
|
|
24358
|
+
function dropEmbedLines(content, gone) {
|
|
24359
|
+
const kept = [];
|
|
24360
|
+
let skipBlank = false;
|
|
24361
|
+
for (const line of content.split("\n")) {
|
|
24362
|
+
const target = EMBED_LINE_RE.exec(line.trim())?.[1];
|
|
24363
|
+
if (target !== void 0 && gone.has(target)) {
|
|
24364
|
+
if (kept.length > 0 && kept[kept.length - 1].trim() === "") kept.pop();
|
|
24365
|
+
else skipBlank = kept.length === 0;
|
|
24366
|
+
continue;
|
|
24367
|
+
}
|
|
24368
|
+
if (skipBlank && line.trim() === "") {
|
|
24369
|
+
skipBlank = false;
|
|
24370
|
+
continue;
|
|
24371
|
+
}
|
|
24372
|
+
skipBlank = false;
|
|
24373
|
+
kept.push(line);
|
|
24374
|
+
}
|
|
24375
|
+
return kept.join("\n");
|
|
24376
|
+
}
|
|
23033
24377
|
function undo(path, moves) {
|
|
23034
24378
|
let current = path;
|
|
23035
24379
|
for (let i = moves.length - 1; i >= 0; i--) {
|
|
@@ -23041,6 +24385,19 @@ function undo(path, moves) {
|
|
|
23041
24385
|
return current;
|
|
23042
24386
|
}
|
|
23043
24387
|
|
|
24388
|
+
// src/sync/block-anchors.ts
|
|
24389
|
+
async function readBlockAnchors(client, blockIds) {
|
|
24390
|
+
const anchors = /* @__PURE__ */ new Map();
|
|
24391
|
+
for (const id of new Set(blockIds.map(compactNotionId))) {
|
|
24392
|
+
try {
|
|
24393
|
+
const anchor = anchorOfBlock(anchorBlockOf(await client.getBlock(id)));
|
|
24394
|
+
if (anchor) anchors.set(id, anchor);
|
|
24395
|
+
} catch {
|
|
24396
|
+
}
|
|
24397
|
+
}
|
|
24398
|
+
return anchors;
|
|
24399
|
+
}
|
|
24400
|
+
|
|
23044
24401
|
// src/sync/change-inspector.ts
|
|
23045
24402
|
var ChangeInspector = class {
|
|
23046
24403
|
constructor(stateDb, vaultFs, notionClient, planner, puller) {
|
|
@@ -23549,6 +24906,7 @@ var RemoteDriftChecker = class {
|
|
|
23549
24906
|
this.databaseSyncer = databaseSyncer;
|
|
23550
24907
|
this.rowSchemas = rowSchemas;
|
|
23551
24908
|
this.observation = observation;
|
|
24909
|
+
this.journal = new UpdateJournal(stateDb);
|
|
23552
24910
|
}
|
|
23553
24911
|
config;
|
|
23554
24912
|
stateDb;
|
|
@@ -23556,6 +24914,20 @@ var RemoteDriftChecker = class {
|
|
|
23556
24914
|
databaseSyncer;
|
|
23557
24915
|
rowSchemas;
|
|
23558
24916
|
observation;
|
|
24917
|
+
journal;
|
|
24918
|
+
/**
|
|
24919
|
+
* 원격이 이 노트를 쓰다 끊긴 그대로인가({@link UpdateJournal}) — push 는 마저 올리고, pull 은 받을 것이 없다고
|
|
24920
|
+
* 본다.
|
|
24921
|
+
* @param localContent 지금 노트 글.
|
|
24922
|
+
*/
|
|
24923
|
+
interruptedOwnWrite(record, remote, localContent) {
|
|
24924
|
+
return this.journal.wroteRemote(
|
|
24925
|
+
record,
|
|
24926
|
+
remote,
|
|
24927
|
+
computeHash(localContent),
|
|
24928
|
+
this.observation.context.botUserId
|
|
24929
|
+
);
|
|
24930
|
+
}
|
|
23559
24931
|
/**
|
|
23560
24932
|
* 원격이 바뀌었는지 확인하지 않고 로컬로 덮어써도 되는가 — 원격을 로컬로 맞추라는 요청이다.
|
|
23561
24933
|
*
|
|
@@ -23702,6 +25074,7 @@ var PagePusher = class {
|
|
|
23702
25074
|
this.drift = drift;
|
|
23703
25075
|
this.placement = placement;
|
|
23704
25076
|
this.recovery = recovery;
|
|
25077
|
+
this.journal = new UpdateJournal(stateDb);
|
|
23705
25078
|
}
|
|
23706
25079
|
config;
|
|
23707
25080
|
stateDb;
|
|
@@ -23720,6 +25093,8 @@ var PagePusher = class {
|
|
|
23720
25093
|
}
|
|
23721
25094
|
/** 이번 실행에서 relation 을 보내지 못한 행 — 대상 노트가 아직 Notion 에 없었다. */
|
|
23722
25095
|
unsentRelations = /* @__PURE__ */ new Set();
|
|
25096
|
+
/** 본문을 쓰다 끊긴 노트를 다음 실행이 알게 — {@link UpdateJournal}. */
|
|
25097
|
+
journal;
|
|
23723
25098
|
/** 이번 실행에서 페이지 ↔ 행 · 다른 DB 의 행이 된 노트({@link takePlacementChanges}). */
|
|
23724
25099
|
placementChanges = [];
|
|
23725
25100
|
/**
|
|
@@ -23770,10 +25145,63 @@ var PagePusher = class {
|
|
|
23770
25145
|
);
|
|
23771
25146
|
if (readOnly.length > 0) skips.push({ path: change.path, properties: readOnly });
|
|
23772
25147
|
} catch {
|
|
23773
|
-
continue;
|
|
25148
|
+
continue;
|
|
25149
|
+
}
|
|
25150
|
+
}
|
|
25151
|
+
return skips.sort((a, b) => a.path.localeCompare(b.path));
|
|
25152
|
+
}
|
|
25153
|
+
/**
|
|
25154
|
+
* dry-run 이 알릴, 실제 push 가 원격을 보고 거절할 고침 · 지움 — {@link pushUpdate} · {@link pushDelete} 와 같은
|
|
25155
|
+
* 판정이다(Notion 에서 지운 페이지 · pull 하지 않은 Notion 편집). 원격을 읽기만 하고 상태 DB 는 바꾸지 않는다 —
|
|
25156
|
+
* 실제 push 는 지운 페이지를 충돌로 남기지만 dry-run 은 이유만 보인다. 원격을 읽지 못했으면 그 실패를 보인다.
|
|
25157
|
+
*
|
|
25158
|
+
* 옮김은 보지 않는다 — 옮기는 노트는 옮길 자리 판정을 먼저 받는다(S-11).
|
|
25159
|
+
*/
|
|
25160
|
+
async previewRemoteRefusals(changes) {
|
|
25161
|
+
const refused = [];
|
|
25162
|
+
for (const change of changes) {
|
|
25163
|
+
const operation = change.type === "modified" ? "update" : change.type === "deleted" ? "delete" : null;
|
|
25164
|
+
if (operation === null) continue;
|
|
25165
|
+
if (operation === "delete" && !this.config.sync.deleteSync) continue;
|
|
25166
|
+
const record = this.stateDb.getByPath(change.path);
|
|
25167
|
+
if (!record?.notionPageId) continue;
|
|
25168
|
+
try {
|
|
25169
|
+
if (operation === "update") await this.previewUpdate(change.path, record);
|
|
25170
|
+
else await this.previewDelete(change.path, record);
|
|
25171
|
+
} catch (error) {
|
|
25172
|
+
refused.push({
|
|
25173
|
+
path: change.path,
|
|
25174
|
+
operation,
|
|
25175
|
+
error: error instanceof Error ? error.message : String(error)
|
|
25176
|
+
});
|
|
25177
|
+
}
|
|
25178
|
+
}
|
|
25179
|
+
return refused;
|
|
25180
|
+
}
|
|
25181
|
+
/** 실제 고침이 원격을 보고 거절하면 그 오류를 던진다({@link pushUpdate} · {@link pushRowUpdate}). */
|
|
25182
|
+
async previewUpdate(path, record) {
|
|
25183
|
+
const presence = await remotePresence(this.notionClient, record.notionPageId);
|
|
25184
|
+
if (presence.kind !== "alive") throw new Error(trashedPageRefusal(path));
|
|
25185
|
+
if (this.drift.overwritesRemote(record, false)) return;
|
|
25186
|
+
const content = await this.vaultFs.readFile(path);
|
|
25187
|
+
if (this.drift.interruptedOwnWrite(record, presence.page, content)) return;
|
|
25188
|
+
if (rowDatabaseOf(this.config, record)) {
|
|
25189
|
+
const base = this.baseRowState(record);
|
|
25190
|
+
let body;
|
|
25191
|
+
try {
|
|
25192
|
+
body = parseFrontmatter(content).body;
|
|
25193
|
+
} catch {
|
|
25194
|
+
return;
|
|
23774
25195
|
}
|
|
25196
|
+
if (base?.body != null && base.body === body) return;
|
|
23775
25197
|
}
|
|
23776
|
-
|
|
25198
|
+
refuseUnpulledBody(await this.drift.remoteDrift(record, presence.page), path);
|
|
25199
|
+
}
|
|
25200
|
+
/** 실제 지움이 원격을 보고 거절하면 그 오류를 던진다({@link pushDelete}). 이미 사라진 원격은 추적만 놓는다. */
|
|
25201
|
+
async previewDelete(path, record) {
|
|
25202
|
+
const presence = await remotePresence(this.notionClient, record.notionPageId);
|
|
25203
|
+
if (presence.kind !== "alive" || this.drift.overwritesRemote(record, false)) return;
|
|
25204
|
+
refuseUnpulledDeletion(await this.drift.remoteDrift(record, presence.page), path);
|
|
23777
25205
|
}
|
|
23778
25206
|
/** dry-run 이 견줄 행 — 새 행은 기준 없이, 고친 행은 지난 동기화 사본과. 행이 아니거나 사본이 없으면 null. */
|
|
23779
25207
|
previewRow(change) {
|
|
@@ -23838,7 +25266,8 @@ var PagePusher = class {
|
|
|
23838
25266
|
direction: "push",
|
|
23839
25267
|
path: selectedPath,
|
|
23840
25268
|
filePath: path,
|
|
23841
|
-
parentMode: this.config.notion.parentMode
|
|
25269
|
+
parentMode: this.config.notion.parentMode,
|
|
25270
|
+
anchors: await this.anchorLookup(content, null)
|
|
23842
25271
|
});
|
|
23843
25272
|
const title = noteTitle(conversionResult.properties, path);
|
|
23844
25273
|
const placeholder2 = this.stateDb.upsert({
|
|
@@ -23948,13 +25377,40 @@ var PagePusher = class {
|
|
|
23948
25377
|
}
|
|
23949
25378
|
return uploads;
|
|
23950
25379
|
}
|
|
25380
|
+
/**
|
|
25381
|
+
* 노트의 제목 · 블록 링크(`[[노트#제목]]`)가 가리키는 페이지의 맨 위 블록을 읽어 둔다 — 변환은 기다리지 않는다
|
|
25382
|
+
* (anchor-links). 그런 링크가 없으면 읽지 않는다. 읽지 못한 페이지의 링크는 예전처럼 간다.
|
|
25383
|
+
*
|
|
25384
|
+
* @param selfPageId 이 노트의 페이지 — `[[#제목]]` 의 대상. 새로 만드는 노트는 아직 없다(그 링크는 다음 push 에 걸린다).
|
|
25385
|
+
*/
|
|
25386
|
+
async anchorLookup(content, selfPageId) {
|
|
25387
|
+
const pages = /* @__PURE__ */ new Set();
|
|
25388
|
+
for (const target of anchoredWikilinkTargets(content)) {
|
|
25389
|
+
const { note } = splitAnchor(target);
|
|
25390
|
+
const pageId = note === "" ? selfPageId : this.stateDb.resolveWikilink(note)?.notionPageId ?? null;
|
|
25391
|
+
if (pageId) pages.add(compactNotionId(pageId));
|
|
25392
|
+
}
|
|
25393
|
+
if (pages.size === 0) return void 0;
|
|
25394
|
+
const blocks = /* @__PURE__ */ new Map();
|
|
25395
|
+
for (const pageId of pages) {
|
|
25396
|
+
try {
|
|
25397
|
+
blocks.set(pageId, (await this.notionClient.fetchAllChildren(pageId)).map(anchorBlockOf));
|
|
25398
|
+
} catch (error) {
|
|
25399
|
+
getLogger().debug(
|
|
25400
|
+
`[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)}`
|
|
25401
|
+
);
|
|
25402
|
+
}
|
|
25403
|
+
}
|
|
25404
|
+
return {
|
|
25405
|
+
selfPageId,
|
|
25406
|
+
blockOf: (pageId, anchor) => anchorBlockId(blocks.get(compactNotionId(pageId)) ?? [], anchor)
|
|
25407
|
+
};
|
|
25408
|
+
}
|
|
23951
25409
|
async livePage(record) {
|
|
23952
25410
|
const presence = await remotePresence(this.notionClient, record.notionPageId);
|
|
23953
25411
|
if (presence.kind === "alive") return presence.page;
|
|
23954
25412
|
this.stateDb.updateStatus(record.id, "conflict");
|
|
23955
|
-
throw new Error(
|
|
23956
|
-
`Notion \uC5D0\uC11C \uC9C0\uC6B4 \uD398\uC774\uC9C0\uB77C \uC62C\uB9AC\uC9C0 \uC54A\uC74C \u2014 \uCDA9\uB3CC\uB85C \uB0A8\uACBC\uB2E4. \uCDA9\uB3CC \uD574\uACB0\uC5D0\uC11C \uB85C\uCEEC \uC720\uC9C0(\uC0C8 \uD398\uC774\uC9C0\uB85C \uB2E4\uC2DC \uB9CC\uB4E4\uAE30)\uB098 \uC6D0\uACA9 \uC720\uC9C0(\uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B0\uAE30)\uB97C \uACE0\uB974\uC138\uC694: ${record.obsidianPath}`
|
|
23957
|
-
);
|
|
25413
|
+
throw new Error(trashedPageRefusal(record.obsidianPath));
|
|
23958
25414
|
}
|
|
23959
25415
|
/**
|
|
23960
25416
|
* @param options.overwriteRemote 원격을 로컬 내용으로 맞춘다(충돌 해소 결과 전파 · 입양한 행).
|
|
@@ -23982,15 +25438,15 @@ var PagePusher = class {
|
|
|
23982
25438
|
direction: "push",
|
|
23983
25439
|
path: updatePath,
|
|
23984
25440
|
filePath: path,
|
|
23985
|
-
parentMode: this.config.notion.parentMode
|
|
25441
|
+
parentMode: this.config.notion.parentMode,
|
|
25442
|
+
anchors: await this.anchorLookup(content, record.notionPageId)
|
|
23986
25443
|
});
|
|
23987
25444
|
const remote = await this.livePage(record);
|
|
23988
|
-
const drift = this.drift.overwritesRemote(
|
|
23989
|
-
record,
|
|
23990
|
-
options?.overwriteRemote === true
|
|
23991
|
-
) ? "none" : await this.drift.remoteDrift(record, remote);
|
|
25445
|
+
const drift = this.drift.overwritesRemote(record, options?.overwriteRemote === true) || this.drift.interruptedOwnWrite(record, remote, content) ? "none" : await this.drift.remoteDrift(record, remote);
|
|
23992
25446
|
refuseUnpulledBody(drift, path);
|
|
23993
25447
|
const settles = drift === "none";
|
|
25448
|
+
const hash = computeHash(content);
|
|
25449
|
+
this.journal.begin(record.id, hash);
|
|
23994
25450
|
const pushed = await this.pushUpdatePage(record.notionPageId, conversionResult.content, path);
|
|
23995
25451
|
let bodyFingerprint = pushed.fingerprint;
|
|
23996
25452
|
let touched = await this.fillDeferredBlocks(
|
|
@@ -24025,11 +25481,11 @@ var PagePusher = class {
|
|
|
24025
25481
|
} else if (settles) {
|
|
24026
25482
|
written = await this.notionClient.getPage(record.notionPageId);
|
|
24027
25483
|
}
|
|
24028
|
-
const hash = computeHash(content);
|
|
24029
25484
|
const fileStat = await this.vaultFs.getFileStat(path);
|
|
24030
25485
|
const title = extractTitle(path);
|
|
24031
25486
|
const aliases = extractAliases(conversionResult.properties);
|
|
24032
25487
|
this.stateDb.transaction(() => {
|
|
25488
|
+
this.journal.finish(record.id);
|
|
24033
25489
|
this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
|
|
24034
25490
|
this.stateDb.updateStatus(record.id, "synced");
|
|
24035
25491
|
if (written !== null && settles) {
|
|
@@ -24244,20 +25700,28 @@ var PagePusher = class {
|
|
|
24244
25700
|
* ({@link RemoteDriftChecker.overwritesRemote}).
|
|
24245
25701
|
*
|
|
24246
25702
|
* 다시 보내면 Notion 을 덮으므로, 받지 않은 Notion 편집이 있으면 옮기지 않는다.
|
|
25703
|
+
*
|
|
25704
|
+
* 옮기기 전에 보낼 노트를 갱신 WAL 에 적는다(P7). 옮긴 뒤 응답을 잃으면 같은 실행의 재시도 · 다음 push 는 이미 그
|
|
25705
|
+
* 자리에 있고 마지막 편집자가 이 봇인 페이지를 본다 — 우리가 옮기다 끊긴 것이므로 옮기기를 건너뛰고 마저 보낸다.
|
|
25706
|
+
* 예전에는 옮기며 바뀐 수정 시각을 Notion 편집으로 보고 «pull 을 먼저 하라» 고 거절했다.
|
|
24247
25707
|
*/
|
|
24248
25708
|
async changePlacement(record, move) {
|
|
24249
25709
|
const { path, databaseId } = move;
|
|
24250
25710
|
const pageId = record.notionPageId;
|
|
25711
|
+
let content;
|
|
24251
25712
|
let current;
|
|
24252
25713
|
try {
|
|
24253
|
-
|
|
25714
|
+
content = await this.vaultFs.readFile(path);
|
|
25715
|
+
current = parseFrontmatter(content).data;
|
|
24254
25716
|
} catch (error) {
|
|
24255
25717
|
throw new Error(
|
|
24256
25718
|
`frontmatter \uB97C \uC77D\uC9C0 \uBABB\uD574 \uC62E\uAE30\uC9C0 \uC54A\uC74C (${path}): ${error instanceof Error ? error.message : String(error)}`
|
|
24257
25719
|
);
|
|
24258
25720
|
}
|
|
24259
25721
|
const remote = await this.livePage(record);
|
|
24260
|
-
|
|
25722
|
+
const pageParentId = databaseId ? null : this.placement.moveParentOf(path);
|
|
25723
|
+
const resumed = placedAt(remote, databaseId ?? pageParentId, databaseId !== null) && this.drift.interruptedOwnWrite(record, remote, content);
|
|
25724
|
+
if (!resumed && !this.drift.overwritesRemote(record, false) && await this.drift.remoteDrift(record, remote) !== "none") {
|
|
24261
25725
|
throw new Error(
|
|
24262
25726
|
`Notion \uC5D0\uC11C \uBC14\uB010 \uB4A4 \uBC1B\uC9C0 \uC54A\uC740 \uD3B8\uC9D1\uC774 \uC788\uC5B4 \uC62E\uAE30\uC9C0 \uC54A\uC74C \u2014 \uC62E\uAE30\uBA74 \uB178\uD2B8\uB97C \uD1B5\uC9F8\uB85C \uB2E4\uC2DC \uBCF4\uB0B4 \uADF8 \uD3B8\uC9D1\uC744 \uB36E\uB294\uB2E4. pull \uC744 \uBA3C\uC800 \uD558\uC138\uC694: ${path}`
|
|
24263
25727
|
);
|
|
@@ -24276,11 +25740,17 @@ var PagePusher = class {
|
|
|
24276
25740
|
);
|
|
24277
25741
|
addedColumns = columnsAddedByMove(pagePropertiesOf(remote), schema);
|
|
24278
25742
|
}
|
|
24279
|
-
|
|
25743
|
+
if (!resumed) {
|
|
25744
|
+
this.journal.begin(record.id, computeHash(content));
|
|
25745
|
+
await this.notionClient.movePageToDataSource(pageId, source.id);
|
|
25746
|
+
}
|
|
24280
25747
|
parentId = databaseId;
|
|
24281
25748
|
} else {
|
|
24282
|
-
parentId =
|
|
24283
|
-
|
|
25749
|
+
parentId = pageParentId;
|
|
25750
|
+
if (!resumed) {
|
|
25751
|
+
this.journal.begin(record.id, computeHash(content));
|
|
25752
|
+
await this.notionClient.movePage(pageId, parentId);
|
|
25753
|
+
}
|
|
24284
25754
|
}
|
|
24285
25755
|
this.stateDb.transaction(() => {
|
|
24286
25756
|
this.stateDb.upsert({
|
|
@@ -24443,7 +25913,8 @@ var PagePusher = class {
|
|
|
24443
25913
|
direction: "push",
|
|
24444
25914
|
path: selectedPath,
|
|
24445
25915
|
filePath: path,
|
|
24446
|
-
parentMode: "database"
|
|
25916
|
+
parentMode: "database",
|
|
25917
|
+
anchors: await this.anchorLookup(content, null)
|
|
24447
25918
|
});
|
|
24448
25919
|
const placeholder2 = this.stateDb.upsert({
|
|
24449
25920
|
obsidianPath: path,
|
|
@@ -24620,7 +26091,7 @@ var PagePusher = class {
|
|
|
24620
26091
|
}
|
|
24621
26092
|
const remote = await this.livePage(record);
|
|
24622
26093
|
const mapper = await this.rowSchemas.mapperFor(databaseId, dataSourceIdOf(remote));
|
|
24623
|
-
const drift = options.overwriteRemote ? "none" : await this.drift.remoteDrift(record, remote);
|
|
26094
|
+
const drift = options.overwriteRemote || this.drift.interruptedOwnWrite(record, remote, content) ? "none" : await this.drift.remoteDrift(record, remote);
|
|
24624
26095
|
const against = options.overwriteRemote ? this.remoteRowState(mapper, remote) : this.baseRowState(record);
|
|
24625
26096
|
const title = noteTitle(current.data, path);
|
|
24626
26097
|
const changes = diffRowProperties(
|
|
@@ -24658,8 +26129,10 @@ var PagePusher = class {
|
|
|
24658
26129
|
direction: "push",
|
|
24659
26130
|
path: selectedPath,
|
|
24660
26131
|
filePath: path,
|
|
24661
|
-
parentMode: "database"
|
|
26132
|
+
parentMode: "database",
|
|
26133
|
+
anchors: await this.anchorLookup(content, pageId)
|
|
24662
26134
|
});
|
|
26135
|
+
this.journal.begin(record.id, computeHash(content));
|
|
24663
26136
|
const pushed = await this.pushUpdatePage(pageId, conversionResult.content, path);
|
|
24664
26137
|
bodyFingerprint = pushed.fingerprint;
|
|
24665
26138
|
let touched = await this.fillDeferredBlocks(
|
|
@@ -24709,6 +26182,7 @@ var PagePusher = class {
|
|
|
24709
26182
|
const hash = computeHash(snapshot);
|
|
24710
26183
|
const fileStat = unsent.length > 0 ? null : await this.vaultFs.getFileStat(path);
|
|
24711
26184
|
this.stateDb.transaction(() => {
|
|
26185
|
+
this.journal.finish(record.id);
|
|
24712
26186
|
this.stateDb.updateHash(record.id, hash, Buffer.from(snapshot, "utf-8"));
|
|
24713
26187
|
this.stateDb.updateStatus(record.id, "synced");
|
|
24714
26188
|
if (written !== null && settles) {
|
|
@@ -24885,11 +26359,13 @@ var PagePusher = class {
|
|
|
24885
26359
|
}
|
|
24886
26360
|
}
|
|
24887
26361
|
/**
|
|
24888
|
-
* `.base` 가 나타내는 Notion DB id 들 — 옆 사이드카(`<이름>.notion.json`)의
|
|
24889
|
-
* 원본으로 둔 링크드 뷰 컨테이너(pull
|
|
24890
|
-
* 없거나 읽지 못하면 빈 목록 — 호출측이 제목으로 맞춘다.
|
|
26362
|
+
* `.base` 가 나타내는 Notion DB id 들 — 링크드 뷰의 `.base` 면 그 컨테이너. 아니면 옆 사이드카(`<이름>.notion.json`)의
|
|
26363
|
+
* DB 와, 그 DB 를 원본으로 둔 링크드 뷰 컨테이너(예전 pull 은 링크드 뷰를 원본의 `.base` 로 몰아 넣었다 — 그 노트를
|
|
26364
|
+
* 올릴 때도 링크드 뷰 블록을 지킨다). 사이드카가 없거나 읽지 못하면 빈 목록 — 호출측이 제목으로 맞춘다.
|
|
24891
26365
|
*/
|
|
24892
26366
|
async databaseIdsOfBase(basePath) {
|
|
26367
|
+
const container = new DbBaseFiles(this.stateDb).linkedContainerAt(basePath);
|
|
26368
|
+
if (container) return [container];
|
|
24893
26369
|
const sidecarPath = basePath.slice(0, -BASE_SUFFIX.length) + SIDECAR_SUFFIX;
|
|
24894
26370
|
let databaseId;
|
|
24895
26371
|
try {
|
|
@@ -24900,7 +26376,7 @@ var PagePusher = class {
|
|
|
24900
26376
|
if (typeof databaseId !== "string") return [];
|
|
24901
26377
|
const own = compactNotionId(databaseId);
|
|
24902
26378
|
const made = loadCreatedDatabases(this.stateDb).get(own)?.databaseId;
|
|
24903
|
-
const linked = [...loadLinkedDbMap(this.stateDb)].filter(([, original]) => original === own).map(([
|
|
26379
|
+
const linked = [...loadLinkedDbMap(this.stateDb)].filter(([, original]) => original === own).map(([container2]) => container2);
|
|
24904
26380
|
return [own, ...made ? [compactNotionId(made)] : [], ...linked];
|
|
24905
26381
|
}
|
|
24906
26382
|
/**
|
|
@@ -24994,69 +26470,13 @@ function blockModeBody(markdownContent) {
|
|
|
24994
26470
|
function withoutDecorations(skipped) {
|
|
24995
26471
|
return skipped.filter((key) => key !== "icon" && key !== "cover");
|
|
24996
26472
|
}
|
|
24997
|
-
|
|
24998
|
-
|
|
24999
|
-
var NOTION_ID_WIKILINK_RE = /\[\[notion:([a-f0-9]{32}|[a-f0-9-]{36})(?:(\\?\|)([^[\]]+))?\]\]/g;
|
|
25000
|
-
var compactId = (id) => id.replace(/-/g, "");
|
|
25001
|
-
function resolveNotionIdWikilinks(markdown, lookup) {
|
|
25002
|
-
let resolved = 0;
|
|
25003
|
-
const out = markdown.replace(
|
|
25004
|
-
NOTION_ID_WIKILINK_RE,
|
|
25005
|
-
(match, id, separator, rawAlias) => {
|
|
25006
|
-
const path = lookup(id);
|
|
25007
|
-
if (!path) return match;
|
|
25008
|
-
resolved++;
|
|
25009
|
-
const base = wikilinkTitleFromPath(path);
|
|
25010
|
-
const alias = rawAlias === compactId(id) ? void 0 : rawAlias;
|
|
25011
|
-
const link = formatWikilink(base, alias);
|
|
25012
|
-
return separator === "\\|" ? link.replace("|", separator) : link;
|
|
25013
|
-
}
|
|
25014
|
-
);
|
|
25015
|
-
return { markdown: out, resolved };
|
|
25016
|
-
}
|
|
25017
|
-
function degradeUnresolvedNotionIdWikilinks(markdown, titles = /* @__PURE__ */ new Map()) {
|
|
25018
|
-
let degraded = 0;
|
|
25019
|
-
const out = markdown.replace(
|
|
25020
|
-
NOTION_ID_WIKILINK_RE,
|
|
25021
|
-
(_match, id, _separator, alias) => {
|
|
25022
|
-
degraded++;
|
|
25023
|
-
const compact = compactId(id);
|
|
25024
|
-
return `[${alias ?? (titles.get(compact) || compact)}](https://www.notion.so/${compact})`;
|
|
25025
|
-
}
|
|
25026
|
-
);
|
|
25027
|
-
return { markdown: out, degraded };
|
|
25028
|
-
}
|
|
25029
|
-
function unlabeledNotionIds(markdown) {
|
|
25030
|
-
const ids = /* @__PURE__ */ new Set();
|
|
25031
|
-
for (const m of markdown.matchAll(NOTION_ID_WIKILINK_RE)) {
|
|
25032
|
-
if (m[3] === void 0) ids.add(compactId(m[1]));
|
|
25033
|
-
}
|
|
25034
|
-
return [...ids];
|
|
25035
|
-
}
|
|
25036
|
-
function linkLabelOfTitle(title) {
|
|
25037
|
-
return title.replace(/[\r\n]+/g, " ").trim().replace(/\[/g, "(").replace(/\]/g, ")").replace(/[\\*_`~]/g, "\\$&");
|
|
25038
|
-
}
|
|
25039
|
-
var NOTION_RELATIVE_PAGE_LINK_RE = /\[([^\]]+)\]\(\/(?:p\/)?([a-f0-9]{32})(?:\?[^)#]*)?(?:#([^)]*))?\)/g;
|
|
25040
|
-
function resolveNotionRelativePageLinks(markdown, lookup) {
|
|
25041
|
-
let resolved = 0;
|
|
25042
|
-
const out = markdown.replace(NOTION_RELATIVE_PAGE_LINK_RE, (match, label, id) => {
|
|
25043
|
-
const path = lookup(id);
|
|
25044
|
-
if (!path) return match;
|
|
25045
|
-
resolved++;
|
|
25046
|
-
return formatWikilink(wikilinkTitleFromPath(path), label);
|
|
25047
|
-
});
|
|
25048
|
-
return { markdown: out, resolved };
|
|
26473
|
+
function trashedPageRefusal(path) {
|
|
26474
|
+
return `Notion \uC5D0\uC11C \uC9C0\uC6B4 \uD398\uC774\uC9C0\uB77C \uC62C\uB9AC\uC9C0 \uC54A\uC74C \u2014 \uCDA9\uB3CC\uB85C \uB0A8\uAE34\uB2E4. \uCDA9\uB3CC \uD574\uACB0\uC5D0\uC11C \uB85C\uCEEC \uC720\uC9C0(\uC0C8 \uD398\uC774\uC9C0\uB85C \uB2E4\uC2DC \uB9CC\uB4E4\uAE30)\uB098 \uC6D0\uACA9 \uC720\uC9C0(\uBCFC\uD2B8\uC5D0\uC11C \uC9C0\uC6B0\uAE30)\uB97C \uACE0\uB974\uC138\uC694: ${path}`;
|
|
25049
26475
|
}
|
|
25050
|
-
function
|
|
25051
|
-
|
|
25052
|
-
const
|
|
25053
|
-
|
|
25054
|
-
(_match, label, id, anchor) => {
|
|
25055
|
-
degraded++;
|
|
25056
|
-
return `[${label}](https://www.notion.so/${id}${anchor === void 0 ? "" : `#${anchor}`})`;
|
|
25057
|
-
}
|
|
25058
|
-
);
|
|
25059
|
-
return { markdown: out, degraded };
|
|
26476
|
+
function placedAt(page, targetId, asRow) {
|
|
26477
|
+
const parent = page.parent;
|
|
26478
|
+
const at = asRow ? parent.type === "data_source_id" || parent.type === "database_id" ? parent.database_id : void 0 : parent.type === "page_id" ? parent.page_id : void 0;
|
|
26479
|
+
return at !== void 0 && notionIdsEqual(at, targetId);
|
|
25060
26480
|
}
|
|
25061
26481
|
|
|
25062
26482
|
// src/sync/notion-parent.ts
|
|
@@ -25129,6 +26549,15 @@ var PagePuller = class {
|
|
|
25129
26549
|
claimTurns = /* @__PURE__ */ new Map();
|
|
25130
26550
|
// Notion 에서 사라져 볼트에서 지운 페이지의 하위 폴더 — pull 이 마칠 때 비었으면 지운다({@link removeVacatedFolders}).
|
|
25131
26551
|
vacatedFolders = /* @__PURE__ */ new Set();
|
|
26552
|
+
/**
|
|
26553
|
+
* 이번 pull 을 시작할 때 Notion 과 이은 노트가 하나도 없었다 — 상태 DB 를 잃었거나 처음 잇는 볼트다. 그때는 자연 이름에
|
|
26554
|
+
* 있는 노트의 글이 달라도 다시 잇는다({@link relinkExistingNote}). 시작할 때 정한다 — 받는 동안 기록이 생긴다.
|
|
26555
|
+
*/
|
|
26556
|
+
unlinkedVault = false;
|
|
26557
|
+
/** 이번 pull 을 시작할 때 Notion 과 이은 노트가 하나도 없었나 — DB 행 받기도 같은 판정을 쓴다. */
|
|
26558
|
+
get unlinkedVaultAtStart() {
|
|
26559
|
+
return this.unlinkedVault;
|
|
26560
|
+
}
|
|
25132
26561
|
decorationsCache;
|
|
25133
26562
|
get decorations() {
|
|
25134
26563
|
return this.decorationsCache ??= new PageDecorations(this.notionClient, this.imageHandler);
|
|
@@ -25144,6 +26573,7 @@ var PagePuller = class {
|
|
|
25144
26573
|
this._pullImageCount = 0;
|
|
25145
26574
|
this._pullFileCount = 0;
|
|
25146
26575
|
this.vacatedFolders.clear();
|
|
26576
|
+
this.unlinkedVault = !this.stateDb.getAll().some((record) => record.notionPageId !== null);
|
|
25147
26577
|
}
|
|
25148
26578
|
/**
|
|
25149
26579
|
* 이번 pull 이 지운 페이지의 폴더가 비었으면 지운다 — 안쪽부터, 그래야 바깥이 빈다. 예전에는 노트만 지우고
|
|
@@ -25263,58 +26693,147 @@ var PagePuller = class {
|
|
|
25263
26693
|
const fetched = await this.fetchPageMarkdown(pageId);
|
|
25264
26694
|
const hasChildPages = this.hasChildContainers(pageId, fetched.children);
|
|
25265
26695
|
if (hasChildPages && !fetched.hasBody) {
|
|
25266
|
-
return
|
|
25267
|
-
|
|
25268
|
-
|
|
25269
|
-
}
|
|
25270
|
-
|
|
25271
|
-
|
|
25272
|
-
|
|
25273
|
-
|
|
25274
|
-
filePath = await this.resolveUniqueFilePath(folderPath, safeName, pageId);
|
|
25275
|
-
fileType = "folder-note";
|
|
25276
|
-
await this.vaultFs.ensureFolder(folderPath);
|
|
25277
|
-
} else {
|
|
25278
|
-
filePath = await this.resolveUniqueFilePath(parentPath, safeName, pageId);
|
|
25279
|
-
fileType = "file";
|
|
25280
|
-
}
|
|
25281
|
-
const { content: finalContent, properties } = await this.renderPageNote({
|
|
26696
|
+
return this.createFolderOnly(pageId, page, fetched.fingerprint, parentPath, safeName);
|
|
26697
|
+
}
|
|
26698
|
+
const fileType = hasChildPages ? "folder-note" : "file";
|
|
26699
|
+
const noteDir = hasChildPages ? parentPath ? `${parentPath}/${safeName}` : safeName : parentPath;
|
|
26700
|
+
if (hasChildPages) await this.vaultFs.ensureFolder(noteDir);
|
|
26701
|
+
const claim = await this.resolveUniqueFilePath(noteDir, safeName, pageId, { adopt: true });
|
|
26702
|
+
let filePath = claim.path;
|
|
26703
|
+
const render2 = async (path, local) => await this.renderPageNote({
|
|
25282
26704
|
pageId,
|
|
25283
26705
|
page,
|
|
25284
26706
|
fetched,
|
|
25285
|
-
filePath,
|
|
26707
|
+
filePath: path,
|
|
25286
26708
|
title,
|
|
25287
|
-
titleFollowsName: extractTitle(
|
|
25288
|
-
downloadMedia: true
|
|
26709
|
+
titleFollowsName: extractTitle(path) === title,
|
|
26710
|
+
downloadMedia: true,
|
|
26711
|
+
...local !== void 0 ? { local: { content: local, markers: [] }, adoptLocalMedia: true } : {}
|
|
25289
26712
|
});
|
|
25290
26713
|
const resolvedParentId = await extractParentId(this.notionClient, page);
|
|
25291
|
-
|
|
25292
|
-
|
|
25293
|
-
|
|
26714
|
+
const note = {
|
|
26715
|
+
pageId,
|
|
26716
|
+
page,
|
|
26717
|
+
fingerprint: fetched.fingerprint,
|
|
26718
|
+
parentId: resolvedParentId,
|
|
26719
|
+
fileType,
|
|
26720
|
+
title
|
|
26721
|
+
};
|
|
26722
|
+
let rendered;
|
|
26723
|
+
if (claim.existing) {
|
|
26724
|
+
const local = await readLocalNote(this.vaultFs, filePath);
|
|
26725
|
+
rendered = await render2(filePath, local);
|
|
26726
|
+
const relinked = local === void 0 ? null : this.relinkExistingNote(filePath, claim.existing, local, rendered, note);
|
|
26727
|
+
if (relinked) return relinked;
|
|
26728
|
+
this.claimedPaths.delete(filePath);
|
|
26729
|
+
filePath = (await this.resolveUniqueFilePath(noteDir, safeName, pageId)).path;
|
|
26730
|
+
rendered = await render2(filePath);
|
|
26731
|
+
} else {
|
|
26732
|
+
rendered = await render2(filePath);
|
|
26733
|
+
}
|
|
26734
|
+
await this.vaultFs.writeFile(filePath, rendered.content);
|
|
26735
|
+
await this.recordPulledNote(filePath, note, {
|
|
26736
|
+
content: rendered.content,
|
|
26737
|
+
properties: rendered.properties,
|
|
26738
|
+
status: "synced"
|
|
26739
|
+
});
|
|
26740
|
+
return { path: filePath };
|
|
26741
|
+
}
|
|
26742
|
+
/**
|
|
26743
|
+
* 상태 기록에 없는 노트가 이 페이지가 놓일 자리에 이미 있다 — 상태 DB 를 잃었거나 처음 잇는 볼트다(P7).
|
|
26744
|
+
* 예전에는 남의 노트로 보고 id 를 붙인 이름으로 또 받아 모든 노트가 두 벌이 됐다(push 는 모든 페이지를 또 만들었다).
|
|
26745
|
+
*
|
|
26746
|
+
* - 글이 같으면(끝 개행만 달라도) 그 노트를 이 페이지와 다시 잇는다 — 쓰지 않는다.
|
|
26747
|
+
* - 다르면 이 페이지 id 를 단 이름(지난번 이 페이지를 받은 이름)이면 충돌 전략을 따른다: remote-first 는 원격으로 덮고 ·
|
|
26748
|
+
* local-first 는 로컬을 두어 다음 push 가 올리고 · 그 밖은 충돌로 남긴다(지난 사본이 없어 merge 도 합칠 수 없다).
|
|
26749
|
+
* - 자연 이름에 있는 다른 글은 이은 노트가 하나도 없는 볼트에서만 잇고 전략과 상관없이 충돌로 남긴다 — 다른 노트일 수 있어
|
|
26750
|
+
* 덮거나 올리면 잃는다. 이은 노트가 있는 볼트에서는 잇지 않는다(null) — 그 볼트의 새 노트일 수 있다.
|
|
26751
|
+
*/
|
|
26752
|
+
relinkExistingNote(filePath, existing, local, rendered, note) {
|
|
26753
|
+
const same = sameNoteContent(local, rendered.content) || sameAsPendingLinks(local, rendered.content);
|
|
26754
|
+
if (!same && existing === "name" && !this.unlinkedVault) return null;
|
|
26755
|
+
return (async () => {
|
|
26756
|
+
const strategy = existing === "own" ? this.config.sync.conflictStrategy : "manual";
|
|
26757
|
+
if (same || strategy === "local-first") {
|
|
26758
|
+
await this.recordPulledNote(filePath, note, {
|
|
26759
|
+
content: rendered.content,
|
|
26760
|
+
properties: rendered.properties,
|
|
26761
|
+
status: "synced",
|
|
26762
|
+
// 같으면 로컬 글로 적어 바뀐 것이 없게 · local-first 는 받은 글로 적어 다음 push 가 로컬을 올리게.
|
|
26763
|
+
hashOf: same ? local : rendered.content,
|
|
26764
|
+
...same ? {} : { differsOnDisk: true }
|
|
26765
|
+
});
|
|
26766
|
+
return { path: filePath, relinked: true };
|
|
26767
|
+
}
|
|
26768
|
+
if (strategy === "remote-first") {
|
|
26769
|
+
await this.vaultFs.writeFile(filePath, rendered.content);
|
|
26770
|
+
await this.recordPulledNote(filePath, note, {
|
|
26771
|
+
content: rendered.content,
|
|
26772
|
+
properties: rendered.properties,
|
|
26773
|
+
status: "synced"
|
|
26774
|
+
});
|
|
26775
|
+
return { path: filePath, relinked: true, written: true };
|
|
26776
|
+
}
|
|
26777
|
+
await this.recordPulledNote(filePath, note, {
|
|
26778
|
+
content: rendered.content,
|
|
26779
|
+
properties: rendered.properties,
|
|
26780
|
+
status: "conflict",
|
|
26781
|
+
snapshot: null,
|
|
26782
|
+
differsOnDisk: true
|
|
26783
|
+
});
|
|
26784
|
+
const record = this.stateDb.getByPath(filePath);
|
|
26785
|
+
const conflict = {
|
|
26786
|
+
syncRecord: record,
|
|
26787
|
+
localChange: {
|
|
26788
|
+
path: filePath,
|
|
26789
|
+
type: "modified",
|
|
26790
|
+
currentHash: computeHash(local),
|
|
26791
|
+
previousHash: record.contentHash
|
|
26792
|
+
},
|
|
26793
|
+
remoteChange: {
|
|
26794
|
+
pageId: note.pageId,
|
|
26795
|
+
type: "modified",
|
|
26796
|
+
lastEdited: note.page.last_edited_time,
|
|
26797
|
+
previousEdited: null,
|
|
26798
|
+
path: filePath,
|
|
26799
|
+
title: note.title
|
|
26800
|
+
},
|
|
26801
|
+
baseContent: null,
|
|
26802
|
+
localContent: local,
|
|
26803
|
+
remoteContent: rendered.content
|
|
26804
|
+
};
|
|
26805
|
+
return { path: filePath, relinked: true, conflict };
|
|
26806
|
+
})();
|
|
26807
|
+
}
|
|
26808
|
+
/**
|
|
26809
|
+
* 받은 노트를 상태 기록 · 위키링크 표에 적는다. 해시는 `hashOf`(기본은 받은 글), 지난 사본은 `snapshot`(기본은 받은 글 —
|
|
26810
|
+
* 충돌로 이은 노트는 공통 조상을 모르므로 null). `differsOnDisk` 는 노트가 적는 해시와 다르다는 뜻이다 — stat 을 적지 않아
|
|
26811
|
+
* 변경 확인이 내용을 읽게 한다. 적으면 stat 이 맞아 바뀐 노트를 읽지 않고 지나쳤다(B5).
|
|
26812
|
+
*/
|
|
26813
|
+
async recordPulledNote(filePath, note, input) {
|
|
26814
|
+
const pullStat = input.differsOnDisk ? null : await this.vaultFs.getFileStat(filePath);
|
|
25294
26815
|
this.stateDb.transaction(() => {
|
|
25295
26816
|
this.stateDb.upsert({
|
|
25296
26817
|
obsidianPath: filePath,
|
|
25297
|
-
notionPageId: pageId,
|
|
25298
|
-
notionParentId:
|
|
25299
|
-
contentHash:
|
|
25300
|
-
...this.observation.fieldsOf(page,
|
|
26818
|
+
notionPageId: note.pageId,
|
|
26819
|
+
notionParentId: note.parentId,
|
|
26820
|
+
contentHash: computeHash(input.hashOf ?? input.content),
|
|
26821
|
+
...this.observation.fieldsOf(note.page, note.fingerprint),
|
|
25301
26822
|
localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
25302
26823
|
syncDirection: "both",
|
|
25303
|
-
fileType,
|
|
25304
|
-
status:
|
|
25305
|
-
baseSnapshot: Buffer.from(
|
|
26824
|
+
fileType: note.fileType,
|
|
26825
|
+
status: input.status,
|
|
26826
|
+
baseSnapshot: input.snapshot === null ? null : Buffer.from(input.content, "utf-8"),
|
|
25306
26827
|
localMtime: pullStat?.mtime ?? null,
|
|
25307
26828
|
localFileSize: pullStat?.size ?? null
|
|
25308
26829
|
});
|
|
25309
|
-
const aliases = extractAliases(properties);
|
|
25310
26830
|
this.stateDb.upsertWikilink({
|
|
25311
26831
|
obsidianPath: filePath,
|
|
25312
|
-
notionPageId: pageId,
|
|
25313
|
-
title,
|
|
25314
|
-
aliases
|
|
26832
|
+
notionPageId: note.pageId,
|
|
26833
|
+
title: note.title,
|
|
26834
|
+
aliases: extractAliases(input.properties)
|
|
25315
26835
|
});
|
|
25316
26836
|
});
|
|
25317
|
-
return { path: filePath };
|
|
25318
26837
|
}
|
|
25319
26838
|
/**
|
|
25320
26839
|
* 자식 말고 본문이 없는 페이지는 폴더로만 받는다 — 노트를 쓰지 않고 폴더 경로를 추적한다(폴더 레코드).
|
|
@@ -25325,9 +26844,10 @@ var PagePuller = class {
|
|
|
25325
26844
|
*/
|
|
25326
26845
|
async createFolderOnly(pageId, page, fingerprint, parentPath, safeName) {
|
|
25327
26846
|
const folderPath = stripMarkdownExtension(
|
|
25328
|
-
await this.resolveUniqueFilePath(parentPath, safeName, pageId)
|
|
26847
|
+
(await this.resolveUniqueFilePath(parentPath, safeName, pageId)).path
|
|
25329
26848
|
);
|
|
25330
26849
|
const resolvedParentId = await extractParentId(this.notionClient, page);
|
|
26850
|
+
const existed = await this.vaultFs.exists(folderPath);
|
|
25331
26851
|
await this.vaultFs.ensureFolder(folderPath);
|
|
25332
26852
|
this.stateDb.upsert({
|
|
25333
26853
|
obsidianPath: folderPath,
|
|
@@ -25340,7 +26860,39 @@ var PagePuller = class {
|
|
|
25340
26860
|
fileType: "folder-only",
|
|
25341
26861
|
status: "synced"
|
|
25342
26862
|
});
|
|
25343
|
-
return folderPath;
|
|
26863
|
+
return existed ? { path: folderPath, folderExisted: true } : { path: folderPath };
|
|
26864
|
+
}
|
|
26865
|
+
/**
|
|
26866
|
+
* dry-run 이 알릴, 로컬도 고친 노트를 실제 pull({@link pullUpdate})이 어떻게 처리할지 — 받은 원격과 견주는 판정은
|
|
26867
|
+
* 실제 pull 과 같다. 첨부를 내려받지 않고 렌더하며 상태 DB 를 바꾸지 않는다. 로컬을 고치지 않았거나 · 읽지 못했거나
|
|
26868
|
+
* · 폴더로 추적하는 페이지면 null(받는다).
|
|
26869
|
+
*/
|
|
26870
|
+
async previewUpdate(change) {
|
|
26871
|
+
const record = this.stateDb.getByNotionId(change.pageId);
|
|
26872
|
+
if (!record || isFolderRecord(record)) return null;
|
|
26873
|
+
let localContent;
|
|
26874
|
+
try {
|
|
26875
|
+
localContent = await this.vaultFs.readFile(record.obsidianPath);
|
|
26876
|
+
} catch {
|
|
26877
|
+
return null;
|
|
26878
|
+
}
|
|
26879
|
+
if (computeHash(localContent) === record.contentHash) return null;
|
|
26880
|
+
const page = await this.notionClient.getPage(change.pageId);
|
|
26881
|
+
const rendered = await this.renderRemotePage(record, change.pageId, page, {
|
|
26882
|
+
downloadMedia: false
|
|
26883
|
+
});
|
|
26884
|
+
const resolution = resolvePullConflict({
|
|
26885
|
+
record,
|
|
26886
|
+
localContent,
|
|
26887
|
+
remoteContent: rendered.content,
|
|
26888
|
+
remoteUnchanged: await this.drift.unchangedSinceSync(record, page, rendered.bodyFingerprint) || this.drift.interruptedOwnWrite(record, page, localContent),
|
|
26889
|
+
remoteChange: change,
|
|
26890
|
+
strategy: this.config.sync.conflictStrategy
|
|
26891
|
+
});
|
|
26892
|
+
if (resolution.action === "conflict") {
|
|
26893
|
+
return { action: "conflict", conflict: resolution.conflict };
|
|
26894
|
+
}
|
|
26895
|
+
return resolution.action === "write" ? null : { action: resolution.action };
|
|
25344
26896
|
}
|
|
25345
26897
|
async pullUpdate(change) {
|
|
25346
26898
|
let record = this.stateDb.getByNotionId(change.pageId);
|
|
@@ -25365,7 +26917,7 @@ var PagePuller = class {
|
|
|
25365
26917
|
localContent = "";
|
|
25366
26918
|
localExists = await this.vaultFs.exists(record.obsidianPath);
|
|
25367
26919
|
}
|
|
25368
|
-
const remoteUnchanged = localExists && await this.drift.unchangedSinceSync(record, page, rendered.bodyFingerprint);
|
|
26920
|
+
const remoteUnchanged = localExists && (await this.drift.unchangedSinceSync(record, page, rendered.bodyFingerprint) || this.drift.interruptedOwnWrite(record, page, localContent));
|
|
25369
26921
|
const resolution = resolvePullConflict({
|
|
25370
26922
|
record,
|
|
25371
26923
|
localContent,
|
|
@@ -25386,6 +26938,32 @@ var PagePuller = class {
|
|
|
25386
26938
|
this.stateDb.updateStatus(record.id, "conflict");
|
|
25387
26939
|
return { action: "conflict", path: record.obsidianPath, conflict: resolution.conflict };
|
|
25388
26940
|
}
|
|
26941
|
+
if (resolution.action === "merge") {
|
|
26942
|
+
await this.vaultFs.writeFile(record.obsidianPath, resolution.merged);
|
|
26943
|
+
this.stateDb.transaction(() => {
|
|
26944
|
+
this.stateDb.upsert({
|
|
26945
|
+
obsidianPath: record.obsidianPath,
|
|
26946
|
+
notionPageId: change.pageId,
|
|
26947
|
+
notionParentId: record.notionParentId,
|
|
26948
|
+
contentHash: computeHash(remoteContent),
|
|
26949
|
+
...this.observation.fieldsOf(page, rendered.bodyFingerprint),
|
|
26950
|
+
localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
26951
|
+
syncDirection: record.syncDirection,
|
|
26952
|
+
fileType: record.fileType,
|
|
26953
|
+
status: "synced",
|
|
26954
|
+
baseSnapshot: Buffer.from(remoteContent, "utf-8"),
|
|
26955
|
+
localMtime: null,
|
|
26956
|
+
localFileSize: null
|
|
26957
|
+
});
|
|
26958
|
+
this.stateDb.upsertWikilink({
|
|
26959
|
+
obsidianPath: record.obsidianPath,
|
|
26960
|
+
notionPageId: change.pageId,
|
|
26961
|
+
title: rendered.title,
|
|
26962
|
+
aliases: extractAliases(rendered.properties)
|
|
26963
|
+
});
|
|
26964
|
+
});
|
|
26965
|
+
return { action: "merged", path: record.obsidianPath };
|
|
26966
|
+
}
|
|
25389
26967
|
if (localExists && sameNoteContent(remoteContent, localContent)) {
|
|
25390
26968
|
const stat2 = await this.vaultFs.getFileStat(record.obsidianPath);
|
|
25391
26969
|
this.stateDb.upsert({
|
|
@@ -25509,7 +27087,7 @@ var PagePuller = class {
|
|
|
25509
27087
|
properties = this.propertyMapper.fromNotionProperties(raw);
|
|
25510
27088
|
notionKeys = [...this.propertyMapper.ownedKeys(raw), "title", ...DECORATION_KEYS];
|
|
25511
27089
|
} else {
|
|
25512
|
-
properties = this.notionClient.extractProperties(page);
|
|
27090
|
+
properties = { ...this.notionClient.extractProperties(page) };
|
|
25513
27091
|
}
|
|
25514
27092
|
if (isDatabaseMode(this.config) || !input.titleFollowsName) {
|
|
25515
27093
|
properties.title = title;
|
|
@@ -25521,7 +27099,8 @@ var PagePuller = class {
|
|
|
25521
27099
|
let markdown = await this.imageHandler.restoreUploadedMedia(
|
|
25522
27100
|
input.fetched.content,
|
|
25523
27101
|
pageId,
|
|
25524
|
-
filePath
|
|
27102
|
+
filePath,
|
|
27103
|
+
input.adoptLocalMedia ? input.local?.content : void 0
|
|
25525
27104
|
);
|
|
25526
27105
|
if (input.downloadMedia && this.config.conversion.imageDownload === "immediate") {
|
|
25527
27106
|
const imageResult = await this.imageHandler.downloadAllImages(markdown, title, pageId);
|
|
@@ -25566,8 +27145,11 @@ var PagePuller = class {
|
|
|
25566
27145
|
this.discovery.collectInlineDbRefs(pageId, result.markdown);
|
|
25567
27146
|
this.detector.noteBodyChildren(pageId, result.markdown);
|
|
25568
27147
|
return {
|
|
25569
|
-
content: this.
|
|
25570
|
-
|
|
27148
|
+
content: await this.resolveBlockLinks(
|
|
27149
|
+
this.resolveNotionIdWikilinks(
|
|
27150
|
+
await notionBodyToObsidian(this.notionClient, result.markdown, pageId)
|
|
27151
|
+
),
|
|
27152
|
+
pageId
|
|
25571
27153
|
),
|
|
25572
27154
|
// 압축형 판정은 반드시 원시 export 기준 — enhanced 변환이 <empty-block/> 을
|
|
25573
27155
|
// 빈 줄로 바꾼 뒤에는 BlockSpacer 가 저작형과 구분할 수 없다(D1).
|
|
@@ -25586,7 +27168,10 @@ var PagePuller = class {
|
|
|
25586
27168
|
this.discovery.collectInlineDbRefs(pageId, fallback.markdown);
|
|
25587
27169
|
this.detector.noteBodyChildren(pageId, fallback.markdown);
|
|
25588
27170
|
return {
|
|
25589
|
-
content: this.
|
|
27171
|
+
content: await this.resolveBlockLinks(
|
|
27172
|
+
this.resolveNotionIdWikilinks(fallback.content),
|
|
27173
|
+
pageId
|
|
27174
|
+
),
|
|
25590
27175
|
compact: isCompactExport(fallback.markdown),
|
|
25591
27176
|
children: extractChildTags(fallback.markdown),
|
|
25592
27177
|
fingerprint: null,
|
|
@@ -25725,15 +27310,38 @@ var PagePuller = class {
|
|
|
25725
27310
|
* 파일 1개·레코드 1건). 그래서 마지막 확인과 등록을 **await 없는 동기 구간**에 묶는다 —
|
|
25726
27311
|
* 그 사이에는 다른 작업이 끼어들 수 없으므로 두 페이지가 같은 경로를 얻는 일이 없다.
|
|
25727
27312
|
*/
|
|
25728
|
-
async resolveUniqueFilePath(dir, safeName, pageId) {
|
|
27313
|
+
async resolveUniqueFilePath(dir, safeName, pageId, options) {
|
|
25729
27314
|
const turn = this.claimTurns.get(normalizeNotionId(pageId));
|
|
25730
27315
|
await turn?.previous;
|
|
25731
27316
|
try {
|
|
25732
|
-
|
|
27317
|
+
if (options?.adopt) {
|
|
27318
|
+
const existing = await this.claimExistingNote(dir, safeName, pageId);
|
|
27319
|
+
if (existing) return existing;
|
|
27320
|
+
}
|
|
27321
|
+
return { path: await this.claimFilePath(dir, safeName, pageId) };
|
|
25733
27322
|
} finally {
|
|
25734
27323
|
turn?.release();
|
|
25735
27324
|
}
|
|
25736
27325
|
}
|
|
27326
|
+
/**
|
|
27327
|
+
* 상태 기록에 없는 노트가 이미 있는 자리를 고른다 — 이 페이지 id 를 단 이름(지난번 이 페이지를 받은 이름)이 먼저, 그 다음 자연
|
|
27328
|
+
* 이름. 다시 이을지는 받은 글과 견줘 호출측이 정한다({@link relinkExistingNote}) — 잇지 않으면 장부에서 지운다.
|
|
27329
|
+
*/
|
|
27330
|
+
async claimExistingNote(dir, safeName, pageId) {
|
|
27331
|
+
const [natural, ...suffixed] = pagePathCandidates(dir, safeName, pageId);
|
|
27332
|
+
const order = [
|
|
27333
|
+
...suffixed.map((path) => [path, "own"]),
|
|
27334
|
+
[natural, "name"]
|
|
27335
|
+
];
|
|
27336
|
+
for (const [candidate, existing] of order) {
|
|
27337
|
+
if (this.claimedPaths.has(candidate) || this.stateDb.getByPath(candidate)) continue;
|
|
27338
|
+
if (!await this.vaultFs.exists(candidate)) continue;
|
|
27339
|
+
if (this.claimedPaths.has(candidate)) continue;
|
|
27340
|
+
this.claimedPaths.add(candidate);
|
|
27341
|
+
return { path: candidate, existing };
|
|
27342
|
+
}
|
|
27343
|
+
return null;
|
|
27344
|
+
}
|
|
25737
27345
|
async claimFilePath(dir, safeName, pageId) {
|
|
25738
27346
|
const candidates = pagePathCandidates(dir, safeName, pageId);
|
|
25739
27347
|
for (const candidate of candidates) {
|
|
@@ -25764,6 +27372,17 @@ var PagePuller = class {
|
|
|
25764
27372
|
(id) => this.stateDb.getByNotionId(normalizeNotionId(id))?.obsidianPath ?? null
|
|
25765
27373
|
).markdown;
|
|
25766
27374
|
}
|
|
27375
|
+
/**
|
|
27376
|
+
* 볼트에 있는 페이지로 가는 블록 링크를 `[[노트#제목]]` 으로 — 그 블록을 읽어 앵커를 안다(anchor-links). 아직 받지
|
|
27377
|
+
* 않은 페이지로 가는 것은 pull 끝의 링크 패스가 푼다.
|
|
27378
|
+
*/
|
|
27379
|
+
async resolveBlockLinks(markdown, pageId) {
|
|
27380
|
+
const pathOf = (id) => this.stateDb.getByNotionId(normalizeNotionId(id))?.obsidianPath ?? null;
|
|
27381
|
+
const blockIds = notionBlockLinkIds(markdown, pathOf);
|
|
27382
|
+
if (blockIds.length === 0) return markdown;
|
|
27383
|
+
const anchors = await readBlockAnchors(this.notionClient, blockIds);
|
|
27384
|
+
return resolveNotionBlockLinks(markdown, pathOf, (id) => anchors.get(id) ?? null, pageId).markdown;
|
|
27385
|
+
}
|
|
25767
27386
|
async ensureDbSchema() {
|
|
25768
27387
|
if (this.dbSchemaLoaded || !isDatabaseMode(this.config)) return;
|
|
25769
27388
|
const schema = await this.notionClient.getDatabaseSchema(this.config.notion.databaseId);
|
|
@@ -25813,7 +27432,7 @@ function resolveFrontmatterRelations(content, idToTitle) {
|
|
|
25813
27432
|
}
|
|
25814
27433
|
|
|
25815
27434
|
// src/sync/notion-link-pass.ts
|
|
25816
|
-
async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle) {
|
|
27435
|
+
async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle, blockAnchors) {
|
|
25817
27436
|
let totalResolved = 0;
|
|
25818
27437
|
let totalDegraded = 0;
|
|
25819
27438
|
const titles = /* @__PURE__ */ new Map();
|
|
@@ -25848,6 +27467,21 @@ async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle) {
|
|
|
25848
27467
|
totalResolved += urlPass.resolved;
|
|
25849
27468
|
changed = true;
|
|
25850
27469
|
}
|
|
27470
|
+
const blockIds = blockAnchors ? notionBlockLinkIds(content, notionIdToPath) : [];
|
|
27471
|
+
if (blockIds.length > 0) {
|
|
27472
|
+
const anchors = await blockAnchors(blockIds);
|
|
27473
|
+
const blockPass = resolveNotionBlockLinks(
|
|
27474
|
+
content,
|
|
27475
|
+
notionIdToPath,
|
|
27476
|
+
(id) => anchors.get(id) ?? null,
|
|
27477
|
+
stateDb.getByPath(filePath)?.notionPageId ?? null
|
|
27478
|
+
);
|
|
27479
|
+
content = blockPass.markdown;
|
|
27480
|
+
if (blockPass.resolved > 0) {
|
|
27481
|
+
totalResolved += blockPass.resolved;
|
|
27482
|
+
changed = true;
|
|
27483
|
+
}
|
|
27484
|
+
}
|
|
25851
27485
|
const fm = resolveFrontmatterRelations(content, idToTitle);
|
|
25852
27486
|
if (fm.count > 0) {
|
|
25853
27487
|
content = fm.content;
|
|
@@ -25933,19 +27567,38 @@ var PullPlanner = class {
|
|
|
25933
27567
|
async planPull(filtered, restoreChanges, restoreIds, localPlan, options, startTime, scan, ledger) {
|
|
25934
27568
|
const unchangedDropped = await this.drift.withoutUnchangedRemotes(filtered, options.signal);
|
|
25935
27569
|
const planned = [];
|
|
27570
|
+
const failed = [];
|
|
27571
|
+
const conflicts = [];
|
|
27572
|
+
const mergedPaths = [];
|
|
25936
27573
|
for (const change of unchangedDropped) {
|
|
25937
27574
|
if (change.type === "deleted" && !await this.plannedRemoteDeletion(change)) continue;
|
|
25938
27575
|
if (change.type === "modified" && !await this.plannedFolderPage(change)) continue;
|
|
27576
|
+
if (change.type === "modified" && !restoreIds.has(change.pageId)) {
|
|
27577
|
+
const resolution = await this.previewUpdate(change, failed);
|
|
27578
|
+
if (resolution === void 0) continue;
|
|
27579
|
+
if (resolution?.action === "conflict") {
|
|
27580
|
+
conflicts.push(resolution.conflict);
|
|
27581
|
+
continue;
|
|
27582
|
+
}
|
|
27583
|
+
if (resolution?.action === "skip") continue;
|
|
27584
|
+
if (resolution?.action === "merge") {
|
|
27585
|
+
mergedPaths.push(this.stateDb.getByNotionId(change.pageId).obsidianPath);
|
|
27586
|
+
}
|
|
27587
|
+
}
|
|
25939
27588
|
planned.push(change);
|
|
25940
27589
|
}
|
|
25941
|
-
const failed = [];
|
|
25942
27590
|
const queued = new Set(
|
|
25943
27591
|
[...filtered, ...restoreChanges].map((change) => compactNotionId(change.pageId))
|
|
25944
27592
|
);
|
|
25945
27593
|
const movingInVault = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
|
|
25946
|
-
const rows =
|
|
25947
|
-
|
|
25948
|
-
|
|
27594
|
+
const rows = [];
|
|
27595
|
+
for (const row of await this.planDatabaseRows(options.paths, failed, ledger, movingInVault)) {
|
|
27596
|
+
if (queued.has(compactNotionId(row.pageId))) continue;
|
|
27597
|
+
if (row.resolution?.action === "conflict") conflicts.push(row.resolution.conflict);
|
|
27598
|
+
else if (row.resolution?.action === "merge") mergedPaths.push(row.path);
|
|
27599
|
+
if (row.resolution?.action === "conflict" || row.resolution?.action === "skip") continue;
|
|
27600
|
+
rows.push(row);
|
|
27601
|
+
}
|
|
25949
27602
|
const items = [];
|
|
25950
27603
|
const plannedPath = this.planner.plannedPaths(localPlan);
|
|
25951
27604
|
for (const change of [...planned, ...restoreChanges]) {
|
|
@@ -25979,7 +27632,8 @@ var PullPlanner = class {
|
|
|
25979
27632
|
restored: restoreChanges.length + restoredModified + rowCount("update", true),
|
|
25980
27633
|
...restoredPaths.length > 0 ? { restoredPaths } : {},
|
|
25981
27634
|
...movedRows.length > 0 ? { moved: movedRows.flatMap((row) => row.moves) } : {},
|
|
25982
|
-
conflicts
|
|
27635
|
+
conflicts,
|
|
27636
|
+
...mergedPaths.length > 0 ? { mergedPaths } : {},
|
|
25983
27637
|
writtenPaths: [],
|
|
25984
27638
|
failed,
|
|
25985
27639
|
duration: Date.now() - startTime,
|
|
@@ -26009,6 +27663,22 @@ var PullPlanner = class {
|
|
|
26009
27663
|
return true;
|
|
26010
27664
|
}
|
|
26011
27665
|
}
|
|
27666
|
+
/**
|
|
27667
|
+
* 로컬도 고친 노트를 실제 pull 이 어떻게 처리할지({@link PagePuller.previewUpdate}). 원격을 읽지 못했으면 실제
|
|
27668
|
+
* pull 도 그 노트에서 실패한다 — 실패로 싣고 undefined(세지 않는다).
|
|
27669
|
+
*/
|
|
27670
|
+
async previewUpdate(change, failed) {
|
|
27671
|
+
try {
|
|
27672
|
+
return await this.puller.previewUpdate(change);
|
|
27673
|
+
} catch (error) {
|
|
27674
|
+
failed.push({
|
|
27675
|
+
path: this.stateDb.getByNotionId(change.pageId)?.obsidianPath ?? change.pageId,
|
|
27676
|
+
operation: "update",
|
|
27677
|
+
error: error instanceof Error ? error.message : String(error)
|
|
27678
|
+
});
|
|
27679
|
+
return void 0;
|
|
27680
|
+
}
|
|
27681
|
+
}
|
|
26012
27682
|
/** 원격에서 지운 페이지를 실제 pull 이 볼트에서 지우는가 — `PagePuller.pullDelete` 와 같게 가른다. */
|
|
26013
27683
|
async plannedRemoteDeletion(change) {
|
|
26014
27684
|
const record = this.stateDb.getByNotionId(change.pageId);
|
|
@@ -26733,7 +28403,7 @@ async function detectMissingLocalFiles(config, stateDb, vaultFs, paths) {
|
|
|
26733
28403
|
}
|
|
26734
28404
|
var LAST_SYNC_META_KEY = "last_sync_at";
|
|
26735
28405
|
var SyncOrchestrator = class {
|
|
26736
|
-
constructor(config, stateDb, notionClient, vaultFs, customFetch) {
|
|
28406
|
+
constructor(config, stateDb, notionClient, vaultFs, customFetch, outside) {
|
|
26737
28407
|
this.config = config;
|
|
26738
28408
|
this.stateDb = stateDb;
|
|
26739
28409
|
this.notionClient = notionClient;
|
|
@@ -26866,7 +28536,7 @@ var SyncOrchestrator = class {
|
|
|
26866
28536
|
async (pageId) => this.detector.discoveredPage(pageId) ?? await notionClient.getPage(pageId),
|
|
26867
28537
|
config.notion.databases ?? []
|
|
26868
28538
|
);
|
|
26869
|
-
this.linkUpdater = new LinkUpdater(stateDb, vaultFs);
|
|
28539
|
+
this.linkUpdater = new LinkUpdater(stateDb, vaultFs, outside);
|
|
26870
28540
|
this.puller = new PagePuller(
|
|
26871
28541
|
config,
|
|
26872
28542
|
stateDb,
|
|
@@ -26982,7 +28652,15 @@ var SyncOrchestrator = class {
|
|
|
26982
28652
|
const foreign = this.stateDb.getAll().map((record) => record.obsidianPath).filter((path) => !vaultFs.ownsPath(path));
|
|
26983
28653
|
if (foreign.length > 0) throw new ConnectionScopeError(foreign);
|
|
26984
28654
|
}
|
|
26985
|
-
|
|
28655
|
+
/**
|
|
28656
|
+
* @param mergedByPull sync dry-run 만 — 앞선 pull dry-run 이 합칠 노트. 실제 sync 는 합친 뒤 원격을 본 것으로
|
|
28657
|
+
* 적어 push 가 올린다 — dry-run 은 받지 않아 원격이 바뀐 그대로라, 원격을 보고 거절하지 않는다.
|
|
28658
|
+
*/
|
|
28659
|
+
/**
|
|
28660
|
+
* @param relinkedByPull 같은 sync 의 dry-run pull 이 다시 이을 것이라고 미리 봤다 — 실제 sync 는 받으며 먼저 잇고 올리므로
|
|
28661
|
+
* 상태 기록이 비었어도 막지 않는다({@link assertVaultLinked}).
|
|
28662
|
+
*/
|
|
28663
|
+
async executePush(options, mergedByPull = /* @__PURE__ */ new Set(), relinkedByPull = false) {
|
|
26986
28664
|
const startTime = Date.now();
|
|
26987
28665
|
this.assertConnectionScope();
|
|
26988
28666
|
if (this.config.sync.direction === "pull") {
|
|
@@ -27013,13 +28691,25 @@ var SyncOrchestrator = class {
|
|
|
27013
28691
|
const conflictPaths = new Set(this.stateDb.getByStatus("conflict").map((r) => r.obsidianPath));
|
|
27014
28692
|
const eligible = options?.force ? changes : changes.filter((c) => !conflictPaths.has(c.path));
|
|
27015
28693
|
const excludeSet = options?.excludePaths ? new Set(options.excludePaths) : null;
|
|
27016
|
-
const
|
|
28694
|
+
const scoped = (options?.paths ? eligible.filter((c) => inAnyPathScope(c.path, options.paths)) : eligible).filter((c) => !excludeSet || !excludeSet.has(c.path));
|
|
28695
|
+
const unverified = relinkedByPull ? /* @__PURE__ */ new Map() : await assertVaultLinked(
|
|
28696
|
+
this.config,
|
|
28697
|
+
this.stateDb,
|
|
28698
|
+
this.notionClient,
|
|
28699
|
+
scoped.filter((c) => c.type === "created").map((c) => c.path)
|
|
28700
|
+
);
|
|
28701
|
+
const filtered = scoped.filter((c) => !unverified.has(c.path));
|
|
28702
|
+
const unverifiedFailures = [...unverified].map(([path, error]) => ({
|
|
28703
|
+
path,
|
|
28704
|
+
operation: "create",
|
|
28705
|
+
error
|
|
28706
|
+
}));
|
|
27017
28707
|
const folderMoves = this.planner.pendingFolderMoves(plan).filter((move) => inAnyPathScope(move.to, options?.paths));
|
|
27018
28708
|
const inScope = (path) => inAnyPathScope(path, options?.paths) && !excludeSet?.has(path);
|
|
27019
28709
|
const sidecarPlan = await this.sidecarEdits.plan(inScope);
|
|
27020
28710
|
const plannedEdits = sidecarPlan.planned.map((edit) => edit.edit);
|
|
27021
28711
|
const viewPlan = await this.viewEdits.plan(inScope);
|
|
27022
|
-
const plannedViews = viewPlan.planned.flatMap(
|
|
28712
|
+
const plannedViews = viewPlan.planned.flatMap(plannedViewEdits);
|
|
27023
28713
|
const preparedDatabases = loadPreparedDatabases(this.stateDb).size > 0;
|
|
27024
28714
|
const recordedLookup = this.placement.folderLookup();
|
|
27025
28715
|
const databaseMoves = mergeDatabaseMoves(
|
|
@@ -27045,7 +28735,13 @@ var SyncOrchestrator = class {
|
|
|
27045
28735
|
updated: 0,
|
|
27046
28736
|
deleted: 0,
|
|
27047
28737
|
moved: 0,
|
|
27048
|
-
failed: [
|
|
28738
|
+
failed: [
|
|
28739
|
+
...unverifiedFailures,
|
|
28740
|
+
...sidecarPlan.refused,
|
|
28741
|
+
...edited2.failed,
|
|
28742
|
+
...viewPlan.refused,
|
|
28743
|
+
...viewed2.failed
|
|
28744
|
+
],
|
|
27049
28745
|
duration: Date.now() - startTime,
|
|
27050
28746
|
...edited2.applied.length > 0 ? { schemaEdits: edited2.applied } : {},
|
|
27051
28747
|
...viewed2.applied.length > 0 ? { viewEdits: viewed2.applied } : {}
|
|
@@ -27078,7 +28774,12 @@ var SyncOrchestrator = class {
|
|
|
27078
28774
|
const applicable = placeable2.filter((c) => !renameRefused.has(c.path));
|
|
27079
28775
|
const plannedRenames = renames.planned.map(({ folder, from, to }) => ({ folder, from, to }));
|
|
27080
28776
|
if (options?.dryRun) {
|
|
27081
|
-
|
|
28777
|
+
await this.observation.resolveBotUserId();
|
|
28778
|
+
const remoteRefusals = await this.pusher.previewRemoteRefusals(
|
|
28779
|
+
applicable.filter((change) => !mergedByPull.has(change.path))
|
|
28780
|
+
);
|
|
28781
|
+
const remoteRefused = new Set(remoteRefusals.map((failure) => failure.path));
|
|
28782
|
+
const planned = (this.config.sync.deleteSync ? applicable : applicable.filter((c) => c.type !== "deleted")).filter((change) => !remoteRefused.has(change.path));
|
|
27082
28783
|
const items = [
|
|
27083
28784
|
...movableFolders.map(({ to }) => ({ path: to, operation: "move" })),
|
|
27084
28785
|
...planned.map((change) => ({ path: change.path, operation: pushOperationOf(change) }))
|
|
@@ -27109,7 +28810,12 @@ var SyncOrchestrator = class {
|
|
|
27109
28810
|
updated: count("update") - convertedUpdates,
|
|
27110
28811
|
deleted: count("delete"),
|
|
27111
28812
|
moved: count("move") + preparedConversions.length + movableDatabases.length,
|
|
27112
|
-
failed: [
|
|
28813
|
+
failed: [
|
|
28814
|
+
...unverifiedFailures,
|
|
28815
|
+
...refusedFailures,
|
|
28816
|
+
...refusedDatabaseMoves,
|
|
28817
|
+
...remoteRefusals
|
|
28818
|
+
],
|
|
27113
28819
|
duration: Date.now() - startTime,
|
|
27114
28820
|
...unsentDeletions2.length > 0 ? { unsentDeletions: unsentDeletions2 } : {},
|
|
27115
28821
|
...readOnlySkipped.length > 0 ? { readOnlySkipped } : {},
|
|
@@ -27123,7 +28829,7 @@ var SyncOrchestrator = class {
|
|
|
27123
28829
|
this.stateDb.setMeta(PUSH_IN_PROGRESS_META_KEY, "true");
|
|
27124
28830
|
await this.observation.resolveBotUserId();
|
|
27125
28831
|
const counts = { created: 0, updated: 0, deleted: 0, moved: 0 };
|
|
27126
|
-
const failed = [...refusedFailures];
|
|
28832
|
+
const failed = [...unverifiedFailures, ...refusedFailures];
|
|
27127
28833
|
const edited = await this.sidecarEdits.apply(sidecarPlan.planned);
|
|
27128
28834
|
failed.push(...edited.failed);
|
|
27129
28835
|
const viewed = await this.viewEdits.apply(viewPlan.planned);
|
|
@@ -27418,8 +29124,10 @@ var SyncOrchestrator = class {
|
|
|
27418
29124
|
const startTime = Date.now();
|
|
27419
29125
|
this.assertConnectionScope();
|
|
27420
29126
|
this.puller.beginPull();
|
|
29127
|
+
this.databaseSyncer.beginPull(this.puller.unlinkedVaultAtStart);
|
|
27421
29128
|
this.rowSchemas.clear();
|
|
27422
29129
|
this.databaseSyncer.takeRestoredRows();
|
|
29130
|
+
this.databaseSyncer.takeMergedRows();
|
|
27423
29131
|
this.databaseSyncer.takeSchemaFollowed();
|
|
27424
29132
|
this.databaseSyncer.takeHeldSidecars();
|
|
27425
29133
|
this.databaseSyncer.takeHeldBases();
|
|
@@ -27459,6 +29167,8 @@ var SyncOrchestrator = class {
|
|
|
27459
29167
|
const conflicts = [];
|
|
27460
29168
|
const writtenPaths = [];
|
|
27461
29169
|
const restoredPaths = [];
|
|
29170
|
+
const mergedPaths = [];
|
|
29171
|
+
const relinkedPaths = [];
|
|
27462
29172
|
const excluded = [];
|
|
27463
29173
|
const failed = [];
|
|
27464
29174
|
let detected = [];
|
|
@@ -27505,10 +29215,15 @@ var SyncOrchestrator = class {
|
|
|
27505
29215
|
await this.puller.removeVacatedFolders();
|
|
27506
29216
|
const renamedRows = this.databaseSyncer.takeRenamedRows();
|
|
27507
29217
|
restoredPaths.push(...this.databaseSyncer.takeRestoredRows());
|
|
29218
|
+
mergedPaths.push(...this.databaseSyncer.takeMergedRows());
|
|
29219
|
+
relinkedPaths.push(...this.databaseSyncer.takeRelinkedRows());
|
|
27508
29220
|
const schemaFollowed = this.databaseSyncer.takeSchemaFollowed();
|
|
27509
29221
|
const heldSidecars = this.databaseSyncer.takeHeldSidecars();
|
|
27510
29222
|
const heldBases = this.databaseSyncer.takeHeldBases();
|
|
27511
|
-
const relinked =
|
|
29223
|
+
const relinked = [
|
|
29224
|
+
...await this.linkUpdater.apply([...relocated.applied, ...renamedRows.applied]),
|
|
29225
|
+
...await this.linkUpdater.dropEmbeds(this.databaseSyncer.takeRetiredBases())
|
|
29226
|
+
];
|
|
27512
29227
|
const linkCount = writtenPaths.length > 0 ? await this.resolveNotionLinks(writtenPaths) : 0;
|
|
27513
29228
|
if (!options?.paths) {
|
|
27514
29229
|
const startedAt = new Date(startTime).toISOString();
|
|
@@ -27533,6 +29248,8 @@ var SyncOrchestrator = class {
|
|
|
27533
29248
|
deleted,
|
|
27534
29249
|
restored,
|
|
27535
29250
|
...restoredPaths.length > 0 ? { restoredPaths } : {},
|
|
29251
|
+
...mergedPaths.length > 0 ? { mergedPaths } : {},
|
|
29252
|
+
...relinkedPaths.length > 0 ? { relinkedPaths } : {},
|
|
27536
29253
|
...schemaFollowed.length > 0 ? { schemaFollowed } : {},
|
|
27537
29254
|
...heldSidecars.length > 0 ? { heldSidecars } : {},
|
|
27538
29255
|
...heldBases.length > 0 ? { heldBases } : {},
|
|
@@ -27605,7 +29322,8 @@ var SyncOrchestrator = class {
|
|
|
27605
29322
|
},
|
|
27606
29323
|
moved: [...relocated.moved, ...plan.moved ?? []],
|
|
27607
29324
|
moveSkipped: relocated.skipped,
|
|
27608
|
-
excluded: [...exclusions.values()]
|
|
29325
|
+
excluded: [...exclusions.values()],
|
|
29326
|
+
...this.puller.unlinkedVaultAtStart && plan.created > 0 ? { relinkPending: true } : {}
|
|
27609
29327
|
};
|
|
27610
29328
|
}
|
|
27611
29329
|
let pullTotal = workItems.length;
|
|
@@ -27687,6 +29405,14 @@ var SyncOrchestrator = class {
|
|
|
27687
29405
|
excluded.push(created.path);
|
|
27688
29406
|
return { path: created.path, unchanged: true };
|
|
27689
29407
|
}
|
|
29408
|
+
if (created.folderExisted) return { path: created.path, unchanged: true };
|
|
29409
|
+
if (created.relinked) {
|
|
29410
|
+
relinkedPaths.push(created.path);
|
|
29411
|
+
if (created.conflict) conflicts.push(created.conflict);
|
|
29412
|
+
if (!created.written) return { path: created.path, unchanged: true };
|
|
29413
|
+
writtenPaths.push(created.path);
|
|
29414
|
+
return { path: created.path, unchanged: false };
|
|
29415
|
+
}
|
|
27690
29416
|
writtenPaths.push(created.path);
|
|
27691
29417
|
counts.created++;
|
|
27692
29418
|
return { path: created.path, unchanged: false };
|
|
@@ -27695,6 +29421,9 @@ var SyncOrchestrator = class {
|
|
|
27695
29421
|
const outcome = await this.puller.pullUpdate(change);
|
|
27696
29422
|
if (outcome.action === "conflict") {
|
|
27697
29423
|
conflicts.push(outcome.conflict);
|
|
29424
|
+
} else if (outcome.action === "merged") {
|
|
29425
|
+
counts.updated++;
|
|
29426
|
+
mergedPaths.push(outcome.path);
|
|
27698
29427
|
} else if (outcome.action === "written") {
|
|
27699
29428
|
writtenPaths.push(outcome.path);
|
|
27700
29429
|
if (restoreIds.has(change.pageId)) {
|
|
@@ -27805,12 +29534,17 @@ var SyncOrchestrator = class {
|
|
|
27805
29534
|
const onProgress = options?.onProgress;
|
|
27806
29535
|
const tagged = (direction) => onProgress && ((current, total, item) => onProgress(current, total, { ...item, direction }));
|
|
27807
29536
|
const pullResult = await this.executePull({ ...options, onProgress: tagged("pull") });
|
|
27808
|
-
const
|
|
27809
|
-
|
|
27810
|
-
|
|
27811
|
-
|
|
27812
|
-
|
|
27813
|
-
|
|
29537
|
+
const conflicted = options?.dryRun ? pullResult.conflicts.map((conflict) => conflict.syncRecord.obsidianPath) : [];
|
|
29538
|
+
const pushResult = await this.executePush(
|
|
29539
|
+
{
|
|
29540
|
+
...options,
|
|
29541
|
+
onProgress: tagged("push"),
|
|
29542
|
+
paths: options?.paths,
|
|
29543
|
+
excludePaths: [...pullResult.writtenPaths, ...conflicted]
|
|
29544
|
+
},
|
|
29545
|
+
new Set(options?.dryRun ? pullResult.mergedPaths ?? [] : []),
|
|
29546
|
+
pullResult.relinkPending === true
|
|
29547
|
+
);
|
|
27814
29548
|
return {
|
|
27815
29549
|
pull: pullResult,
|
|
27816
29550
|
push: pushResult,
|
|
@@ -27838,6 +29572,7 @@ var SyncOrchestrator = class {
|
|
|
27838
29572
|
const detected = await this.drift.withoutUnchangedRemotes(detection.changes, signal);
|
|
27839
29573
|
const moves = await this.followRemoteMoves({ dryRun: true }, plan);
|
|
27840
29574
|
const renames = await this.followRemoteRenames(detected, { dryRun: true });
|
|
29575
|
+
const rowRenames = await this.previewRowRenames(detected, signal);
|
|
27841
29576
|
const exclusions = await this.plannedExclusions(detected);
|
|
27842
29577
|
const remoteChanges = detected.filter((change) => !exclusions.has(change.pageId));
|
|
27843
29578
|
const conflictRecords = this.stateDb.getByStatus("conflict");
|
|
@@ -27858,11 +29593,45 @@ var SyncOrchestrator = class {
|
|
|
27858
29593
|
lastSyncAt,
|
|
27859
29594
|
lastFullScanAt,
|
|
27860
29595
|
remoteScan: remoteScanInfo(scan, lastFullScanAt, this.config.sync.deleteSync),
|
|
27861
|
-
remoteMoves: [...moves.moved, ...renames.renamed],
|
|
27862
|
-
remoteMoveSkipped: [...moves.skipped, ...renames.skipped],
|
|
29596
|
+
remoteMoves: [...moves.moved, ...renames.renamed, ...rowRenames.renamed],
|
|
29597
|
+
remoteMoveSkipped: [...moves.skipped, ...renames.skipped, ...rowRenames.skipped],
|
|
27863
29598
|
remoteExcluded: [...exclusions.values()]
|
|
27864
29599
|
};
|
|
27865
29600
|
}
|
|
29601
|
+
/**
|
|
29602
|
+
* 다음 pull 이 Notion 에서 바꾼 제목으로 이름을 바꿀 행 — 바꾸지 않고 본다. 행 이름은 DB 받기가 바꾸므로
|
|
29603
|
+
* 페이지 이름 바꾸기({@link followRemoteRenames})에 없다. 판정은 받기와 같다(`retitledRowTarget`).
|
|
29604
|
+
*/
|
|
29605
|
+
async previewRowRenames(changes, signal) {
|
|
29606
|
+
const renamed = [];
|
|
29607
|
+
const skipped = [];
|
|
29608
|
+
if (!this.config.sync.renameOnTitleChange) return { renamed, skipped };
|
|
29609
|
+
const folders = /* @__PURE__ */ new Map();
|
|
29610
|
+
for (const db of [
|
|
29611
|
+
...this.config.notion.databases ?? [],
|
|
29612
|
+
...parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY))
|
|
29613
|
+
]) {
|
|
29614
|
+
if (db.databaseId) folders.set(compactNotionId(db.databaseId), db.localFolder);
|
|
29615
|
+
}
|
|
29616
|
+
for (const change of changes) {
|
|
29617
|
+
throwIfAborted(signal);
|
|
29618
|
+
if (change.type !== "modified") continue;
|
|
29619
|
+
const record = this.stateDb.getByNotionId(change.pageId);
|
|
29620
|
+
if (record?.fileType !== "db-row" || !record.notionParentId) continue;
|
|
29621
|
+
if (this.vaultFs.excludesPath?.(record.obsidianPath)) continue;
|
|
29622
|
+
const folder = folders.get(compactNotionId(record.notionParentId));
|
|
29623
|
+
if (folder === void 0) continue;
|
|
29624
|
+
try {
|
|
29625
|
+
const page = this.detector.discoveredPage(change.pageId) ?? await this.notionClient.getPage(change.pageId);
|
|
29626
|
+
const target = await this.databaseSyncer.retitledRowTarget(folder, record, page);
|
|
29627
|
+
if (target === null) continue;
|
|
29628
|
+
if ("reason" in target) skipped.push({ path: record.obsidianPath, reason: target.reason });
|
|
29629
|
+
else renamed.push({ from: record.obsidianPath, to: target.to, renamed: true });
|
|
29630
|
+
} catch {
|
|
29631
|
+
}
|
|
29632
|
+
}
|
|
29633
|
+
return { renamed, skipped };
|
|
29634
|
+
}
|
|
27866
29635
|
/**
|
|
27867
29636
|
* 로컬만 본다 — 원격 기준을 정하지 않아 도는 작업과 겹쳐도 된다. 변경은 push 가 쓰는 판정 그대로다
|
|
27868
29637
|
* (옮긴 노트 · 폴더 포함). 상태 DB 는 바꾸지 않는다.
|
|
@@ -27955,7 +29724,8 @@ var SyncOrchestrator = class {
|
|
|
27955
29724
|
this.stateDb,
|
|
27956
29725
|
this.vaultFs,
|
|
27957
29726
|
paths,
|
|
27958
|
-
async (pageId) => this.notionClient.extractTitle(await this.notionClient.getPage(pageId))
|
|
29727
|
+
async (pageId) => this.notionClient.extractTitle(await this.notionClient.getPage(pageId)),
|
|
29728
|
+
(blockIds) => readBlockAnchors(this.notionClient, blockIds)
|
|
27959
29729
|
);
|
|
27960
29730
|
}
|
|
27961
29731
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -28010,6 +29780,19 @@ var SyncOrchestrator = class {
|
|
|
28010
29780
|
async discardLocalChange(path) {
|
|
28011
29781
|
return this.gate.run("discard", () => this.inspector.discardLocalChange(path));
|
|
28012
29782
|
}
|
|
29783
|
+
/**
|
|
29784
|
+
* 동기화가 옮긴 폴더를 가리키는 사용자 `.base` 의 폴더 필터 — 말없이 고치지 않고 고칠지 묻는다. 없으면 빈 목록.
|
|
29785
|
+
*/
|
|
29786
|
+
userBaseFilterFixes() {
|
|
29787
|
+
return pendingUserBaseFilterFixes(this.stateDb);
|
|
29788
|
+
}
|
|
29789
|
+
/** {@link userBaseFilterFixes} 를 물은 답대로 — 고치면 고친 `.base` 경로. 어느 쪽이든 다시 묻지 않는다. */
|
|
29790
|
+
async resolveUserBaseFilterFixes(accept) {
|
|
29791
|
+
return this.gate.run(
|
|
29792
|
+
"base-filters",
|
|
29793
|
+
() => resolveUserBaseFilterFixes(this.vaultFs, this.stateDb, accept)
|
|
29794
|
+
);
|
|
29795
|
+
}
|
|
28013
29796
|
/**
|
|
28014
29797
|
* 볼트에서 만든 노트 폴더를 Notion DB 로 올릴 준비 — frontmatter · `.base` 로 사이드카를 써 둔다(#75).
|
|
28015
29798
|
* Notion 에는 쓰지 않는다. 다음 push 가 DB 를 만들고 노트를 행으로 올린다(ADR-036 · ADR-037).
|
|
@@ -28141,10 +29924,14 @@ function matchesWithin(path, pattern) {
|
|
|
28141
29924
|
|
|
28142
29925
|
// src/sync/node-vault-fs.ts
|
|
28143
29926
|
var NodeVaultFS = class {
|
|
28144
|
-
|
|
29927
|
+
/**
|
|
29928
|
+
* @param ignoreFile 폴더의 `.im-nobsidian-ignore` 를 따른다 — 볼트 전체를 보는 쪽(다른 연결의 노트에서 링크를 고칠 때)은
|
|
29929
|
+
* 그 폴더를 맡은 연결의 제외 목록을 따르지 않는다.
|
|
29930
|
+
*/
|
|
29931
|
+
constructor(rootPath, pathConfig, ignoreFile = true) {
|
|
28145
29932
|
this.rootPath = rootPath;
|
|
28146
29933
|
const configExclude = pathConfig?.exclude ?? [];
|
|
28147
|
-
const ignorePatterns = this.loadImNobsidianIgnore();
|
|
29934
|
+
const ignorePatterns = ignoreFile ? this.loadImNobsidianIgnore() : [];
|
|
28148
29935
|
this.filter = new PathFilter(pathConfig?.include ?? [], [...configExclude, ...ignorePatterns]);
|
|
28149
29936
|
}
|
|
28150
29937
|
rootPath;
|
|
@@ -29383,6 +31170,6 @@ function formatRatio(ratio) {
|
|
|
29383
31170
|
return String(Math.round(ratio * 100) / 100);
|
|
29384
31171
|
}
|
|
29385
31172
|
|
|
29386
|
-
export { BaseFileGenerator, BlankLineCollapser, CONFIG_FILE, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConnectionBoundaryError, ConnectionScopeError, ConversionPipeline, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DEFAULT_TITLE_PROPERTY, DEFAULT_WATCH_DEBOUNCE_MS, DIFF_SIGN, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FilteredVaultFS, FrontmatterExtractor, FrontmatterGenerator, GITIGNORE_ENTRY, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, ImageHandler, InlineAnnotationPreserver, InlineDBParser, LocalImageRestorer, MARKER_BRAND, MathNormalizer, MentionToWikilink, NOTION_COLOR_NAMES, NOTION_TOKEN_PREFIX, NOTION_TO_BASES_VIEW, NodeVaultFS, NotionBlockBuilder, NotionClient2 as NotionClient, OBSIDIAN_CONFIG_DIR, PathFilter, PendingWalError, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, RENDER_LINE_RULES, SAVED_STATE_TABLES_QUERY, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_MIGRATIONS, STATE_DB_PATH, SavedStateDbError, ScopedVaultFS, SidecarGenerator, StateDB, StateDbLockedError, StateDbUnavailableError, StateLock, SyncBusyError, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WIKILINK_LOOKUPS, WatchSyncService, WikilinkResolver, applicableChoices, assertNoPendingWal, basesViewMappingOf, basesViewTypeOf, choiceForStrategy, classifyBodyFidelity, columnLabel, compactNotionId, computeBufferHash, computeHash, configDirFrom, connectionFolderOf, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, findConnectionFolders, findNestedFolderPages, formatUnifiedDiff, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, hasColumnLayout, hasDesignMarker, inAnyPathScope, isHeadingLine, isHeadingMarkerLine, isInternalPath, isMarkdownPath, isRemoteDeletion, lineDiff, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, normalizeVaultPath, noteNameFromPath, notionIdFromInput, notionIdsEqual, operationLabel, parseDesignColor, parseIgnorePatterns, pendingWalBytes, readColumnLayout, readDesignLine, resolvePropertyName, sanitizeFileName, sectionLinesWithHeadingMarkers, setLogger, shortNotionId, sortEntries, stateDbLockPath, stateDbWalPath, stripMarkdownExtension, summarizeFidelity, systemTimeZone, threeWayMerge, toVaultPath, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness, wikilinkLookupsIn };
|
|
31173
|
+
export { BaseFileGenerator, BlankLineCollapser, CONFIG_FILE, CONFLICT_STRATEGIES, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConnectionBoundaryError, ConnectionScopeError, ConversionPipeline, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DEFAULT_TITLE_PROPERTY, DEFAULT_WATCH_DEBOUNCE_MS, DIFF_SIGN, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FilteredVaultFS, FrontmatterExtractor, FrontmatterGenerator, GITIGNORE_ENTRY, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, ImageHandler, InlineAnnotationPreserver, InlineDBParser, LocalImageRestorer, MARKER_BRAND, MathNormalizer, MentionToWikilink, NOTION_COLOR_NAMES, NOTION_TOKEN_PREFIX, NOTION_TO_BASES_VIEW, NodeVaultFS, NotionBlockBuilder, NotionClient2 as NotionClient, OBSIDIAN_CONFIG_DIR, PathFilter, PendingWalError, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, RENDER_LINE_RULES, SAVED_STATE_TABLES_QUERY, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_MIGRATIONS, STATE_DB_PATH, SavedStateDbError, ScopedVaultFS, SidecarGenerator, StateDB, StateDbLockedError, StateDbUnavailableError, StateLock, SyncBusyError, SyncOrchestrator, TreeMapper, UnlinkedVaultError, UnsupportedBlockStripper, ViewDataProvider, WIKILINK_LOOKUPS, WatchSyncService, WikilinkResolver, applicableChoices, assertNoPendingWal, basesViewMappingOf, basesViewTypeOf, choiceForStrategy, classifyBodyFidelity, columnLabel, compactNotionId, computeBufferHash, computeHash, configDirFrom, connectionFolderOf, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, findConnectionFolders, findNestedFolderPages, formatUnifiedDiff, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, hasColumnLayout, hasDesignMarker, inAnyPathScope, isHeadingLine, isHeadingMarkerLine, isInternalPath, isMarkdownPath, isRemoteDeletion, lineDiff, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, normalizeVaultPath, noteNameFromPath, notionIdFromInput, notionIdsEqual, operationLabel, parseDesignColor, parseIgnorePatterns, pendingWalBytes, readColumnLayout, readDesignLine, resolvePropertyName, sanitizeFileName, sectionLinesWithHeadingMarkers, setLogger, shortNotionId, sortEntries, stateDbLockPath, stateDbWalPath, stripMarkdownExtension, summarizeFidelity, systemTimeZone, threeWayMerge, toVaultPath, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness, wikilinkLookupsIn };
|
|
29387
31174
|
//# sourceMappingURL=index.js.map
|
|
29388
31175
|
//# sourceMappingURL=index.js.map
|