@im-nobsidian/core 0.14.0 → 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 +2284 -495
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import
|
|
2
|
+
import * as notionSdk from '@notionhq/client';
|
|
3
|
+
import { LogLevel } from '@notionhq/client';
|
|
3
4
|
import { readFile, mkdir, writeFile, unlink, rmdir, rename, stat, readdir } from 'fs/promises';
|
|
4
5
|
import { join, dirname, sep, relative } from 'path';
|
|
5
6
|
import { mkdirSync, unlinkSync, utimesSync, openSync, writeSync, closeSync, readFileSync, statSync, existsSync, readlinkSync } from 'fs';
|
|
@@ -152,7 +153,8 @@ var NOTIFYING_WRITE_PATHS = [
|
|
|
152
153
|
/^pages\/[^/]+$/,
|
|
153
154
|
/^blocks\/[^/]+\/children$/
|
|
154
155
|
];
|
|
155
|
-
var
|
|
156
|
+
var NotionClient = notionSdk.Client ?? notionSdk.default.Client;
|
|
157
|
+
var SdkClient = class extends NotionClient {
|
|
156
158
|
constructor(options, notifications) {
|
|
157
159
|
super(options);
|
|
158
160
|
this.notifications = notifications;
|
|
@@ -170,6 +172,15 @@ function withNotifications(args, mode) {
|
|
|
170
172
|
return { ...args, body: { ...args.body, notifications: { mode } } };
|
|
171
173
|
}
|
|
172
174
|
|
|
175
|
+
// src/types/sync.ts
|
|
176
|
+
var CONFLICT_STRATEGIES = [
|
|
177
|
+
"local-first",
|
|
178
|
+
"remote-first",
|
|
179
|
+
"manual",
|
|
180
|
+
"duplicate",
|
|
181
|
+
"merge"
|
|
182
|
+
];
|
|
183
|
+
|
|
173
184
|
// src/types/config.ts
|
|
174
185
|
var NOTION_TOKEN_PREFIX = "ntn_";
|
|
175
186
|
var DEFAULT_TITLE_PROPERTY = "Name";
|
|
@@ -244,7 +255,7 @@ var ConfigSchema = z.object({
|
|
|
244
255
|
}),
|
|
245
256
|
sync: z.object({
|
|
246
257
|
direction: z.enum(["push", "pull", "both"]).default(D.sync.direction),
|
|
247
|
-
conflictStrategy: z.enum(
|
|
258
|
+
conflictStrategy: z.enum(CONFLICT_STRATEGIES).default(D.sync.conflictStrategy),
|
|
248
259
|
autoSync: z.boolean().default(D.sync.autoSync),
|
|
249
260
|
autoSyncInterval: z.number().min(30).max(3600).default(D.sync.autoSyncInterval),
|
|
250
261
|
// 전체 대조 주기(초) — 그 사이의 pull 은 바뀐 페이지 · 행 · DB 만 받는다. 원격 삭제는 전체 대조가
|
|
@@ -2825,7 +2836,7 @@ function dataSourcesToQuery(meta) {
|
|
|
2825
2836
|
return meta.dataSources.map((ds) => ({ id: ds.id, name: ds.name }));
|
|
2826
2837
|
}
|
|
2827
2838
|
var CHECKED_DATABASE_REUSE_MS = 10 * 60 * 1e3;
|
|
2828
|
-
var
|
|
2839
|
+
var NotionClient2 = class _NotionClient {
|
|
2829
2840
|
client;
|
|
2830
2841
|
sema;
|
|
2831
2842
|
propertyMapper;
|
|
@@ -3272,7 +3283,8 @@ var NotionClient = class _NotionClient {
|
|
|
3272
3283
|
cover: db.cover ?? null,
|
|
3273
3284
|
description: Array.isArray(db.description) ? db.description : [],
|
|
3274
3285
|
showTitle: db.show_data_source_title !== false
|
|
3275
|
-
}
|
|
3286
|
+
},
|
|
3287
|
+
...db.in_trash === true || db.archived === true ? { inTrash: true } : {}
|
|
3276
3288
|
};
|
|
3277
3289
|
return { meta, owners };
|
|
3278
3290
|
}
|
|
@@ -4941,8 +4953,72 @@ function pairLocalMoves(missing, untracked, hints) {
|
|
|
4941
4953
|
paired.add(note.path);
|
|
4942
4954
|
pairs.push({ from: note.path, to: file.path });
|
|
4943
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
|
+
);
|
|
4944
4963
|
return pairs;
|
|
4945
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
|
+
}
|
|
4946
5022
|
function hintedFolderTarget(hints, folder) {
|
|
4947
5023
|
let best = null;
|
|
4948
5024
|
let ambiguous = false;
|
|
@@ -5040,10 +5116,16 @@ var ChangeDetector = class {
|
|
|
5040
5116
|
for (const file of stats) {
|
|
5041
5117
|
const record = this.stateDb.getByPath(file.path);
|
|
5042
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);
|
|
5043
5124
|
observed.push({
|
|
5044
5125
|
path: file.path,
|
|
5045
5126
|
record,
|
|
5046
|
-
hash:
|
|
5127
|
+
hash: computeHash(text),
|
|
5128
|
+
...record === null ? { text } : {}
|
|
5047
5129
|
});
|
|
5048
5130
|
}
|
|
5049
5131
|
return this.assemble(observed, moveOrigins, options);
|
|
@@ -5053,7 +5135,12 @@ var ChangeDetector = class {
|
|
|
5053
5135
|
const observed = currentFiles.map((file) => {
|
|
5054
5136
|
const record = this.stateDb.getByPath(file.path);
|
|
5055
5137
|
const unchanged = hasRemotePage(record) && !this.movePending(record, moveOrigins) && Boolean(record.localMtime) && record.localMtime === file.mtime;
|
|
5056
|
-
return {
|
|
5138
|
+
return {
|
|
5139
|
+
path: file.path,
|
|
5140
|
+
record,
|
|
5141
|
+
hash: unchanged ? null : computeHash(file.content),
|
|
5142
|
+
...record === null ? { text: file.content } : {}
|
|
5143
|
+
};
|
|
5057
5144
|
});
|
|
5058
5145
|
return this.assemble(observed, moveOrigins, options);
|
|
5059
5146
|
}
|
|
@@ -5071,7 +5158,13 @@ var ChangeDetector = class {
|
|
|
5071
5158
|
const { record, hash } = file;
|
|
5072
5159
|
if (!hasRemotePage(record)) {
|
|
5073
5160
|
if (record === null) {
|
|
5074
|
-
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
|
+
}
|
|
5075
5168
|
} else
|
|
5076
5169
|
changes.push({
|
|
5077
5170
|
path: file.path,
|
|
@@ -5108,7 +5201,8 @@ var ChangeDetector = class {
|
|
|
5108
5201
|
missing.map((record) => ({
|
|
5109
5202
|
path: record.obsidianPath,
|
|
5110
5203
|
hash: record.contentHash,
|
|
5111
|
-
origin: moveOrigins.get(record.id)
|
|
5204
|
+
origin: moveOrigins.get(record.id),
|
|
5205
|
+
...record.baseSnapshot ? { text: record.baseSnapshot.toString("utf-8") } : {}
|
|
5112
5206
|
})),
|
|
5113
5207
|
untracked,
|
|
5114
5208
|
options?.hints ?? EMPTY_RENAME_HINTS
|
|
@@ -5600,6 +5694,69 @@ function decodeMarkerTarget(encoded) {
|
|
|
5600
5694
|
var MARKER_URL_CAPTURE = "((?:[^()\\s]|\\([^()]*\\))+)";
|
|
5601
5695
|
var MARKER_LABEL_CAPTURE = "([^[\\]]+)";
|
|
5602
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
|
+
|
|
5603
5760
|
// src/converter/pre-processors/wikilink.ts
|
|
5604
5761
|
var WIKILINK_REGEX2 = /(?<!!)\[\[([^[\]|]+)(?:\|([^[\]]+))?\]\]/g;
|
|
5605
5762
|
function withoutTableAliasEscape(target) {
|
|
@@ -5624,6 +5781,8 @@ var WikilinkResolver = class {
|
|
|
5624
5781
|
(_match, written, display, offset) => {
|
|
5625
5782
|
const target = display === void 0 ? written : withoutTableAliasEscape(written);
|
|
5626
5783
|
const label = display ?? target;
|
|
5784
|
+
const anchored = this.anchorLink(target, display, input.context.anchors);
|
|
5785
|
+
if (anchored) return anchored;
|
|
5627
5786
|
if (this.resolve) {
|
|
5628
5787
|
const entry = this.resolve(target);
|
|
5629
5788
|
if (entry) {
|
|
@@ -5649,6 +5808,19 @@ var WikilinkResolver = class {
|
|
|
5649
5808
|
metadata: { ...input.metadata, preserveMarkers }
|
|
5650
5809
|
};
|
|
5651
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
|
+
}
|
|
5652
5824
|
};
|
|
5653
5825
|
|
|
5654
5826
|
// src/converter/callout-types.ts
|
|
@@ -12816,6 +12988,21 @@ function collectLocalMarkerBasenames(markdown) {
|
|
|
12816
12988
|
function baseName(path) {
|
|
12817
12989
|
return path.split("/").pop() ?? path;
|
|
12818
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
|
+
}
|
|
12819
13006
|
function folderOf(notePath) {
|
|
12820
13007
|
return notePath?.includes("/") ? notePath.slice(0, notePath.lastIndexOf("/")) : void 0;
|
|
12821
13008
|
}
|
|
@@ -12951,8 +13138,9 @@ var ImageHandler = class {
|
|
|
12951
13138
|
*
|
|
12952
13139
|
* @param pageId 본문을 받은 페이지 — 그 페이지에 올린 것만 되찾는다
|
|
12953
13140
|
* @param notePath 노트 경로 — 옛 캡션의 파일을 push 와 같은 사다리로 찾는다
|
|
13141
|
+
* @param adoptFrom 상태 기록에 없던 같은 자리의 노트 글(P7 다시 잇기) — {@link adoptLocalEmbed}
|
|
12954
13142
|
*/
|
|
12955
|
-
async restoreUploadedMedia(markdown, pageId, notePath) {
|
|
13143
|
+
async restoreUploadedMedia(markdown, pageId, notePath, adoptFrom) {
|
|
12956
13144
|
const spans = notionMediaSpans(markdown);
|
|
12957
13145
|
if (spans.length === 0) return markdown;
|
|
12958
13146
|
const known = pageId ? recallUploadedMedia(this.stateDb, pageId) : /* @__PURE__ */ new Map();
|
|
@@ -12960,11 +13148,51 @@ var ImageHandler = class {
|
|
|
12960
13148
|
let result = markdown;
|
|
12961
13149
|
for (const span of spans) {
|
|
12962
13150
|
const uploadedName = this.uploadedNameOf(span.url);
|
|
12963
|
-
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);
|
|
12964
13152
|
if (target) result = result.replace(span.full, () => `![[${target}]]`);
|
|
12965
13153
|
}
|
|
12966
13154
|
return result;
|
|
12967
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
|
+
}
|
|
12968
13196
|
/** 적어 둔 업로드면 되살릴 임베드 대상. 볼트에서 그 파일이 사라졌으면 null — 사본을 받는다. */
|
|
12969
13197
|
async knownEmbedTarget(known, span, uploadedName) {
|
|
12970
13198
|
const fileId = notionFileIdOf(span.url);
|
|
@@ -14783,6 +15011,136 @@ async function readLocalNote(vaultFs, path) {
|
|
|
14783
15011
|
}
|
|
14784
15012
|
}
|
|
14785
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
|
+
|
|
14786
15144
|
// src/sync/conflict-detector.ts
|
|
14787
15145
|
function sameNoteContent(a, b) {
|
|
14788
15146
|
return a === b || a.replace(/\n+$/, "") === b.replace(/\n+$/, "");
|
|
@@ -14810,6 +15168,10 @@ function resolvePullConflict(input) {
|
|
|
14810
15168
|
if (strategy === "local-first") {
|
|
14811
15169
|
return { action: "skip", localHash };
|
|
14812
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
|
+
}
|
|
14813
15175
|
const conflict = {
|
|
14814
15176
|
syncRecord: record,
|
|
14815
15177
|
localChange: {
|
|
@@ -14826,49 +15188,211 @@ function resolvePullConflict(input) {
|
|
|
14826
15188
|
return { action: "conflict", conflict, localHash };
|
|
14827
15189
|
}
|
|
14828
15190
|
|
|
14829
|
-
// src/
|
|
14830
|
-
|
|
14831
|
-
|
|
14832
|
-
|
|
14833
|
-
|
|
14834
|
-
|
|
14835
|
-
|
|
14836
|
-
|
|
14837
|
-
|
|
14838
|
-
|
|
14839
|
-
}
|
|
14840
|
-
|
|
14841
|
-
|
|
14842
|
-
|
|
14843
|
-
|
|
14844
|
-
|
|
14845
|
-
|
|
14846
|
-
|
|
14847
|
-
return { action: "kept" };
|
|
14848
|
-
}
|
|
14849
|
-
if (decision.action === "conflict") {
|
|
14850
|
-
stateDb.updateStatus(record.id, "conflict");
|
|
14851
|
-
return {
|
|
14852
|
-
action: "conflict",
|
|
14853
|
-
conflict: remoteDeletionConflict(record, decision.local, options.remoteChange)
|
|
14854
|
-
};
|
|
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;
|
|
14855
15209
|
}
|
|
14856
|
-
|
|
14857
|
-
return {
|
|
14858
|
-
}
|
|
14859
|
-
async function decideRemoteDeletion(vaultFs, record, options) {
|
|
14860
|
-
const local = options.deleteFile ? await unsyncedLocalEdit(vaultFs, record) : null;
|
|
14861
|
-
if (local === null || options.strategy === "remote-first") return { action: "deleted" };
|
|
14862
|
-
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+$/, ""));
|
|
14863
15212
|
}
|
|
14864
|
-
|
|
14865
|
-
|
|
14866
|
-
|
|
14867
|
-
|
|
14868
|
-
|
|
14869
|
-
|
|
14870
|
-
|
|
14871
|
-
|
|
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);
|
|
14872
15396
|
}
|
|
14873
15397
|
function untrack(stateDb, record) {
|
|
14874
15398
|
stateDb.transaction(() => {
|
|
@@ -15088,6 +15612,12 @@ function isDirectDbRowPath(localFolder, path) {
|
|
|
15088
15612
|
const rel = path.slice(prefix.length);
|
|
15089
15613
|
return rel.length > 0 && !rel.includes("/");
|
|
15090
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
|
+
}
|
|
15091
15621
|
|
|
15092
15622
|
// src/sync/discovered-databases.ts
|
|
15093
15623
|
var ROOT_DATABASES_FOLDER = "databases";
|
|
@@ -15704,6 +16234,25 @@ var DbBaseFiles = class {
|
|
|
15704
16234
|
this.loadedRaw = JSON.stringify(Object.fromEntries(entries));
|
|
15705
16235
|
this.stateDb.setMeta(DB_BASE_FILES_META_KEY, this.loadedRaw);
|
|
15706
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
|
+
}
|
|
15707
16256
|
/** 상태 메타에서 읽는다 — 값이 그대로면 읽어 둔 것을 쓴다. 없거나 깨졌으면 비어 있다 — 이번 실행이 기록하는 것부터 다시 쌓는다. */
|
|
15708
16257
|
load() {
|
|
15709
16258
|
const raw = this.stateDb.getMeta(DB_BASE_FILES_META_KEY);
|
|
@@ -15724,7 +16273,9 @@ var DbBaseFiles = class {
|
|
|
15724
16273
|
title: entry.title,
|
|
15725
16274
|
...typeof entry.hash === "string" ? { hash: entry.hash } : {},
|
|
15726
16275
|
...Array.isArray(entry.views) ? { views: entry.views } : {},
|
|
15727
|
-
...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 } : {}
|
|
15728
16279
|
})
|
|
15729
16280
|
);
|
|
15730
16281
|
}
|
|
@@ -15740,7 +16291,9 @@ function normalize(info) {
|
|
|
15740
16291
|
title: info.title,
|
|
15741
16292
|
...info.hash ? { hash: info.hash } : {},
|
|
15742
16293
|
...info.views && info.views.length > 0 ? { views: info.views } : {},
|
|
15743
|
-
...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 } : {}
|
|
15744
16297
|
};
|
|
15745
16298
|
}
|
|
15746
16299
|
|
|
@@ -15806,8 +16359,22 @@ function matchViews(records, views) {
|
|
|
15806
16359
|
});
|
|
15807
16360
|
return out;
|
|
15808
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
|
+
}
|
|
15809
16377
|
function restSignature(parsed, matched) {
|
|
15810
|
-
const without = (from, keys) => Object.fromEntries(Object.entries(from).filter(([key]) => !keys.includes(key)));
|
|
15811
16378
|
const views = {};
|
|
15812
16379
|
parsed.views.forEach((view, i) => {
|
|
15813
16380
|
const key = matched[i] ? `id:${matched[i].viewId}` : `new:${String(view["name"])}`;
|
|
@@ -15839,10 +16406,11 @@ function recordBaseViews(content, written, views) {
|
|
|
15839
16406
|
type: String(parsed.views[i]["type"]),
|
|
15840
16407
|
...pushableOf(parsed.views[i]),
|
|
15841
16408
|
...source.lossyFilter ? { lossyFilter: true } : {},
|
|
15842
|
-
notion: viewFingerprint(view)
|
|
16409
|
+
notion: viewFingerprint(view),
|
|
16410
|
+
rest: viewRestOf(parsed.views[i])
|
|
15843
16411
|
});
|
|
15844
16412
|
}
|
|
15845
|
-
return { views: records, rest: restSignature(parsed, records) };
|
|
16413
|
+
return { views: records, rest: restSignature(parsed, records), docRest: docRestOf(parsed) };
|
|
15846
16414
|
}
|
|
15847
16415
|
function canonical(value) {
|
|
15848
16416
|
return JSON.stringify(sortKeys3(value)) ?? "null";
|
|
@@ -16089,6 +16657,7 @@ var SidecarSchemaEdits = class {
|
|
|
16089
16657
|
hash,
|
|
16090
16658
|
properties,
|
|
16091
16659
|
payload: diff.payload,
|
|
16660
|
+
...diff.staged ? { staged: diff.staged, optionRenames: diff.optionRenames } : {},
|
|
16092
16661
|
edit: { path: base.path, ...diff.edit },
|
|
16093
16662
|
seen: this.ledger.get(databaseId) ? null : seenSchemaOf(meta)
|
|
16094
16663
|
});
|
|
@@ -16102,6 +16671,12 @@ var SidecarSchemaEdits = class {
|
|
|
16102
16671
|
if (edit.seen) this.ledger.set(edit.databaseId, edit.seen);
|
|
16103
16672
|
let after;
|
|
16104
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
|
+
}
|
|
16105
16680
|
after = await this.notionClient.updateDataSourceProperties(edit.dataSourceId, edit.payload);
|
|
16106
16681
|
} catch (error) {
|
|
16107
16682
|
failed.push({
|
|
@@ -16123,6 +16698,30 @@ var SidecarSchemaEdits = class {
|
|
|
16123
16698
|
}
|
|
16124
16699
|
return { applied, failed };
|
|
16125
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
|
+
}
|
|
16126
16725
|
};
|
|
16127
16726
|
function sidecarPropertiesEdited(content, base) {
|
|
16128
16727
|
let parsed;
|
|
@@ -16157,9 +16756,12 @@ function diffSidecarProperties(base, edited) {
|
|
|
16157
16756
|
removed: [],
|
|
16158
16757
|
renamed: [],
|
|
16159
16758
|
retyped: [],
|
|
16160
|
-
redefined: []
|
|
16759
|
+
redefined: [],
|
|
16760
|
+
renamedOptions: []
|
|
16161
16761
|
};
|
|
16162
16762
|
const droppedOptions = [];
|
|
16763
|
+
const optionRenames = [];
|
|
16764
|
+
const staged = {};
|
|
16163
16765
|
const names = /* @__PURE__ */ new Set();
|
|
16164
16766
|
const seenIds = /* @__PURE__ */ new Set();
|
|
16165
16767
|
for (const prop of edited) {
|
|
@@ -16194,10 +16796,35 @@ function diffSidecarProperties(base, edited) {
|
|
|
16194
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`
|
|
16195
16797
|
);
|
|
16196
16798
|
}
|
|
16197
|
-
change[prop.type] = definitionOf(prop, old);
|
|
16198
16799
|
edit.redefined.push(prop.name);
|
|
16199
|
-
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
|
+
);
|
|
16200
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
|
+
}
|
|
16201
16828
|
}
|
|
16202
16829
|
if (Object.keys(change).length > 0) payload[old.id] = change;
|
|
16203
16830
|
}
|
|
@@ -16211,19 +16838,50 @@ function diffSidecarProperties(base, edited) {
|
|
|
16211
16838
|
if (kept >= 2 && edit.removed.length === kept && edit.added.length === 0) {
|
|
16212
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");
|
|
16213
16840
|
}
|
|
16841
|
+
const { renamedOptions: renamedOptionList, ...rest } = edit;
|
|
16214
16842
|
return {
|
|
16215
16843
|
ok: true,
|
|
16216
16844
|
payload,
|
|
16217
|
-
|
|
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
|
+
}
|
|
16218
16852
|
};
|
|
16219
16853
|
}
|
|
16220
|
-
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 = []) {
|
|
16221
16873
|
if (!DEFINABLE_TYPES.has(prop.type)) return {};
|
|
16222
16874
|
const definition = prop.definition ?? {};
|
|
16223
16875
|
if (old && (prop.type === "select" || prop.type === "multi_select") && sameJson(prop.definition, old.definition) && !sameJson(prop.options, old.options)) {
|
|
16224
16876
|
const known = old.definition?.["options"] ?? [];
|
|
16877
|
+
const renamedFrom = new Map(renames.map(([from, to]) => [to, from]));
|
|
16225
16878
|
return {
|
|
16226
|
-
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
|
+
})
|
|
16227
16885
|
};
|
|
16228
16886
|
}
|
|
16229
16887
|
return definition;
|
|
@@ -17532,12 +18190,60 @@ function rewriteCreatedDatabaseFolders(stateDb, folders) {
|
|
|
17532
18190
|
async function retargetBaseFiles(vaultFs, moves, stateDb) {
|
|
17533
18191
|
const folders = moves.filter((move) => !isMarkdownPath(move.from));
|
|
17534
18192
|
if (folders.length === 0) return;
|
|
18193
|
+
const found2 = [];
|
|
17535
18194
|
await rewriteBaseFilters(
|
|
17536
18195
|
vaultFs,
|
|
17537
18196
|
stateDb,
|
|
17538
18197
|
(folder) => finalPath(folder, folders),
|
|
17539
|
-
"\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)
|
|
17540
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;
|
|
17541
18247
|
}
|
|
17542
18248
|
var BASE_FOLDER_FILTER_UPGRADED_META_KEY = "base_folder_filter_v2";
|
|
17543
18249
|
async function upgradeBaseFolderFilters(vaultFs, stateDb) {
|
|
@@ -17550,7 +18256,7 @@ async function upgradeBaseFolderFilters(vaultFs, stateDb) {
|
|
|
17550
18256
|
);
|
|
17551
18257
|
if (done) stateDb.setMeta(BASE_FOLDER_FILTER_UPGRADED_META_KEY, "1");
|
|
17552
18258
|
}
|
|
17553
|
-
async function rewriteBaseFilters(vaultFs, stateDb, retarget, failure) {
|
|
18259
|
+
async function rewriteBaseFilters(vaultFs, stateDb, retarget, failure, onUserBase) {
|
|
17554
18260
|
const rehashed = /* @__PURE__ */ new Map();
|
|
17555
18261
|
const viewed = stateDb ? recordedViewBases(stateDb) : /* @__PURE__ */ new Set();
|
|
17556
18262
|
let done = true;
|
|
@@ -17558,7 +18264,16 @@ async function rewriteBaseFilters(vaultFs, stateDb, retarget, failure) {
|
|
|
17558
18264
|
for (const file of await vaultFs.listNonMarkdownFiles()) {
|
|
17559
18265
|
if (!file.path.endsWith(BASE_SUFFIX)) continue;
|
|
17560
18266
|
const content = await vaultFs.readFile(file.path);
|
|
17561
|
-
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
|
+
}
|
|
17562
18277
|
const next = content.replace(
|
|
17563
18278
|
FOLDER_FILTER_RE,
|
|
17564
18279
|
(whole, legacy, current) => {
|
|
@@ -17947,7 +18662,7 @@ function compareRemote(record, page, botUserId) {
|
|
|
17947
18662
|
return "changed";
|
|
17948
18663
|
}
|
|
17949
18664
|
if (isSettled(record.notionLastEdited, record.notionSeenAt)) return "unchanged";
|
|
17950
|
-
if (botUserId !== null && editor === botUserId && record.notionLastEditedBy === botUserId) {
|
|
18665
|
+
if (botUserId !== null && editor === botUserId && record.notionLastEditedBy === botUserId && (record.notionBodyFingerprint === null || record.baseSnapshot === null)) {
|
|
17951
18666
|
return "unchanged";
|
|
17952
18667
|
}
|
|
17953
18668
|
return "unverified";
|
|
@@ -18150,6 +18865,7 @@ var DatabaseSyncer = class {
|
|
|
18150
18865
|
this.observation = observation;
|
|
18151
18866
|
this.noteRowBody = noteRowBody;
|
|
18152
18867
|
this.baseFileInfo = new DbBaseFiles(stateDb);
|
|
18868
|
+
this.journal = new UpdateJournal(stateDb);
|
|
18153
18869
|
this.sidecarBases = new SidecarBases(stateDb);
|
|
18154
18870
|
this.schemaLedger = new SchemaLedger(stateDb);
|
|
18155
18871
|
this.relocator = new NoteRelocator(stateDb, vaultFs, config.notion.databases ?? []);
|
|
@@ -18191,6 +18907,19 @@ var DatabaseSyncer = class {
|
|
|
18191
18907
|
};
|
|
18192
18908
|
/** 볼트에서 사라져 되살린 행의 경로 — pull 마감이 가져가 화면에 알린다(D8). */
|
|
18193
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();
|
|
18194
18923
|
/** Notion 에서 바뀐 속성을 행 노트에 옮긴 것 — pull 마감이 가져가 화면에 알린다(ADR-045). */
|
|
18195
18924
|
schemaFollowed = [];
|
|
18196
18925
|
/** pull 이 마지막으로 쓴 사이드카 — 볼트에서 고친 것은 덮어쓰지 않는다(ADR-045). */
|
|
@@ -18199,6 +18928,10 @@ var DatabaseSyncer = class {
|
|
|
18199
18928
|
heldSidecars = /* @__PURE__ */ new Set();
|
|
18200
18929
|
/** 이번 pull 이 볼트에서 고친 것이라 덮어쓰지 않은 `.base` 경로(ADR-046). */
|
|
18201
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();
|
|
18202
18935
|
/**
|
|
18203
18936
|
* 실제로 기록한 .base 경로/DB 제목 — placeholder 임베드 재작성(F22)의 SSOT. 이번 pull 이 조회하지
|
|
18204
18937
|
* 않은 DB 의 것도 남아 있다({@link DbBaseFiles}).
|
|
@@ -18268,10 +19001,6 @@ var DatabaseSyncer = class {
|
|
|
18268
19001
|
}
|
|
18269
19002
|
}
|
|
18270
19003
|
if (target.kind === "linked") {
|
|
18271
|
-
if (target.wholeDbInScope) {
|
|
18272
|
-
const viewsConfig = await this.pullDatabaseViews(dbConfig);
|
|
18273
|
-
await this.generateBaseFile(dbConfig, viewsConfig, target.meta, target.ownerFolder);
|
|
18274
|
-
}
|
|
18275
19004
|
return { ...emptyDatabaseSyncResult(), linkedOriginalDbId: target.ownerDbId };
|
|
18276
19005
|
}
|
|
18277
19006
|
const { pages, wholeDbInScope, meta } = target;
|
|
@@ -18282,7 +19011,9 @@ var DatabaseSyncer = class {
|
|
|
18282
19011
|
const refreshedKeys = (page) => followed.changes.get(sourceKeyOf(page, dbConfig.databaseId))?.refreshed;
|
|
18283
19012
|
if (wholeDbInScope) {
|
|
18284
19013
|
const viewsConfig = await this.pullDatabaseViews(dbConfig);
|
|
18285
|
-
|
|
19014
|
+
const rangeEnds = this.rangeEndsOf(pages);
|
|
19015
|
+
this.rangeEnds.set(compactNotionId(dbConfig.databaseId), rangeEnds);
|
|
19016
|
+
await this.generateBaseFile(dbConfig, viewsConfig, meta, void 0, rangeEnds);
|
|
18286
19017
|
}
|
|
18287
19018
|
let created = 0;
|
|
18288
19019
|
let updated = followed.rekeyed.size;
|
|
@@ -18330,7 +19061,11 @@ var DatabaseSyncer = class {
|
|
|
18330
19061
|
this.config.advanced.itemTimeoutMs,
|
|
18331
19062
|
`pull ${dbConfig.localFolder}/${page.id}`
|
|
18332
19063
|
);
|
|
18333
|
-
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) {
|
|
18334
19069
|
created++;
|
|
18335
19070
|
if (outcome.action === "written") writtenPaths.push(outcome.path);
|
|
18336
19071
|
} else if (outcome.action === "written") {
|
|
@@ -18339,12 +19074,19 @@ var DatabaseSyncer = class {
|
|
|
18339
19074
|
this.restoredRows.push(outcome.path);
|
|
18340
19075
|
} else if (!followed.rekeyed.has(record.id)) updated++;
|
|
18341
19076
|
writtenPaths.push(outcome.path);
|
|
19077
|
+
} else if (outcome.action === "merged") {
|
|
19078
|
+
updated++;
|
|
19079
|
+
this.mergedRows.push(outcome.path);
|
|
18342
19080
|
} else if (outcome.action === "conflict") {
|
|
18343
19081
|
conflicts.push(outcome.conflict);
|
|
18344
19082
|
}
|
|
18345
19083
|
opts?.progress?.done({
|
|
18346
19084
|
path: outcome.path ?? record?.obsidianPath ?? `${dbConfig.localFolder}/${page.id}`,
|
|
18347
|
-
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 } : {}
|
|
18348
19090
|
});
|
|
18349
19091
|
} catch (error) {
|
|
18350
19092
|
failed.push(rowFailure(page, error));
|
|
@@ -18404,6 +19146,72 @@ var DatabaseSyncer = class {
|
|
|
18404
19146
|
...planned.map((row) => ({ ...row, path: moved(row.path) }))
|
|
18405
19147
|
];
|
|
18406
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
|
+
}
|
|
18407
19215
|
async planRows(dbConfig, target, paths, movingInVault = /* @__PURE__ */ new Set()) {
|
|
18408
19216
|
const opts = { paths };
|
|
18409
19217
|
const planned = [];
|
|
@@ -18449,11 +19257,18 @@ var DatabaseSyncer = class {
|
|
|
18449
19257
|
moves: [{ from: record.obsidianPath, to: path }, ...children ? [children] : []]
|
|
18450
19258
|
});
|
|
18451
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);
|
|
18452
19264
|
planned.push({
|
|
18453
19265
|
pageId: page.id,
|
|
18454
|
-
path: record.obsidianPath,
|
|
19266
|
+
path: renamed?.to ?? record.obsidianPath,
|
|
18455
19267
|
operation: "update",
|
|
18456
|
-
restore: action === "restore"
|
|
19268
|
+
restore: action === "restore",
|
|
19269
|
+
...resolution ? { resolution } : {},
|
|
19270
|
+
// 실제 pull 이 알리는 것과 같게 행만 — 하위 페이지 폴더는 함께 옮기지만 따로 알리지 않는다.
|
|
19271
|
+
...renamed ? { moves: [{ from: record.obsidianPath, to: renamed.to, renamed: true }] } : {}
|
|
18457
19272
|
});
|
|
18458
19273
|
}
|
|
18459
19274
|
}
|
|
@@ -18545,6 +19360,9 @@ var DatabaseSyncer = class {
|
|
|
18545
19360
|
const changed = misplaced || this.remoteVerdict(record, page) !== "unchanged";
|
|
18546
19361
|
if (!changed && await this.staleKeysToRewrite(record, page, refreshed)) return "reform";
|
|
18547
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
|
+
}
|
|
18548
19366
|
return changed ? "pull" : "skip";
|
|
18549
19367
|
}
|
|
18550
19368
|
return "restore";
|
|
@@ -18793,10 +19611,10 @@ var DatabaseSyncer = class {
|
|
|
18793
19611
|
}
|
|
18794
19612
|
return ends;
|
|
18795
19613
|
}
|
|
18796
|
-
async generateBaseFile(dbConfig, viewsConfig, meta, rowsFolder, rangeEnds) {
|
|
19614
|
+
async generateBaseFile(dbConfig, viewsConfig, meta, rowsFolder, rangeEnds, linked) {
|
|
18797
19615
|
try {
|
|
18798
19616
|
const schemaFull = await this.notionClient.getDatabaseSchemaFull(dbConfig.databaseId, meta);
|
|
18799
|
-
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";
|
|
18800
19618
|
const resolvedViews = viewsConfig ?? {
|
|
18801
19619
|
databaseId: dbConfig.databaseId,
|
|
18802
19620
|
databaseName: dbName,
|
|
@@ -18821,11 +19639,12 @@ var DatabaseSyncer = class {
|
|
|
18821
19639
|
title: dbName,
|
|
18822
19640
|
hash: computeHash(baseContent),
|
|
18823
19641
|
// Notion 보기를 받지 못해 기본 표 하나를 쓴 파일은 올릴 보기가 없다.
|
|
18824
|
-
...viewsConfig ? recordBaseViews(baseContent, generated.views, viewsConfig.views) : null
|
|
19642
|
+
...viewsConfig ? recordBaseViews(baseContent, generated.views, viewsConfig.views) : null,
|
|
19643
|
+
...linked ? { linkedTo: compactNotionId(linked.ownerId) } : null
|
|
18825
19644
|
};
|
|
18826
19645
|
const ours = existing === null || isGeneratedDbArtifact(basePath, existing) || previous?.basePath === basePath && previous.views !== void 0;
|
|
18827
19646
|
if (!ours) {
|
|
18828
|
-
record = { basePath, title: dbName, hash: record.hash };
|
|
19647
|
+
record = { basePath, title: dbName, hash: record.hash, linkedTo: record.linkedTo };
|
|
18829
19648
|
if (!this.keptUserBases.has(basePath)) {
|
|
18830
19649
|
this.keptUserBases.add(basePath);
|
|
18831
19650
|
getLogger().info(
|
|
@@ -18840,6 +19659,15 @@ var DatabaseSyncer = class {
|
|
|
18840
19659
|
}
|
|
18841
19660
|
this.baseFileInfo.set(dbConfig.databaseId, record);
|
|
18842
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
|
+
}
|
|
18843
19671
|
await this.generateSidecar(
|
|
18844
19672
|
dbConfig,
|
|
18845
19673
|
dbName,
|
|
@@ -18849,7 +19677,15 @@ var DatabaseSyncer = class {
|
|
|
18849
19677
|
meta.isInline,
|
|
18850
19678
|
await this.appearanceOf(meta, safeName)
|
|
18851
19679
|
);
|
|
18852
|
-
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
|
+
}
|
|
18853
19689
|
} catch (error) {
|
|
18854
19690
|
getLogger().warn(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
|
|
18855
19691
|
}
|
|
@@ -18881,13 +19717,87 @@ var DatabaseSyncer = class {
|
|
|
18881
19717
|
...a.showTitle ? {} : { showTitle: false }
|
|
18882
19718
|
};
|
|
18883
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
|
+
}
|
|
18884
19794
|
/** 볼트에서 고친 `.base` 를 덮어쓰거나 지우지 않았다고 한 번 알린다. */
|
|
18885
19795
|
warnEditedBase(path) {
|
|
18886
19796
|
this.heldBases.add(path);
|
|
18887
19797
|
if (this.keptUserBases.has(path)) return;
|
|
18888
19798
|
this.keptUserBases.add(path);
|
|
18889
19799
|
getLogger().warn(
|
|
18890
|
-
`[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`
|
|
18891
19801
|
);
|
|
18892
19802
|
}
|
|
18893
19803
|
/**
|
|
@@ -18895,11 +19805,13 @@ var DatabaseSyncer = class {
|
|
|
18895
19805
|
* 같은 폴더에 고아로 잔존한다(drift·부활 원인). DB 폴더 **직속**의 산출물 중 현재 이름이
|
|
18896
19806
|
* 아닌 것을 삭제해 멱등을 보장한다. 하위 폴더(중첩 DB)의 산출물은 보호한다.
|
|
18897
19807
|
*/
|
|
18898
|
-
async cleanupStaleDbArtifacts(localFolder, safeName, previous) {
|
|
19808
|
+
async cleanupStaleDbArtifacts(databaseId, localFolder, safeName, previous) {
|
|
18899
19809
|
try {
|
|
18900
19810
|
const keep = /* @__PURE__ */ new Set([
|
|
18901
19811
|
`${localFolder}/${safeName}${BASE_SUFFIX}`,
|
|
18902
|
-
`${localFolder}/${safeName}${SIDECAR_SUFFIX}
|
|
19812
|
+
`${localFolder}/${safeName}${SIDECAR_SUFFIX}`,
|
|
19813
|
+
// 이 DB 를 원본으로 둔 링크드 뷰의 `.base` — 같은 폴더에 놓인다.
|
|
19814
|
+
...this.baseFileInfo.linkedBasesOf(databaseId)
|
|
18903
19815
|
]);
|
|
18904
19816
|
const files = await this.vaultFs.listNonMarkdownFiles();
|
|
18905
19817
|
const stale = selectStaleDbArtifacts(files, localFolder, keep);
|
|
@@ -19033,7 +19945,12 @@ var DatabaseSyncer = class {
|
|
|
19033
19945
|
const bodyFingerprint = remoteBodyFingerprint(mdResult.markdown);
|
|
19034
19946
|
const exportCompact = isCompactExport(mdResult.markdown);
|
|
19035
19947
|
let markdown = await notionBodyToObsidian(this.notionClient, mdResult.markdown, page.id);
|
|
19036
|
-
markdown = await this.imageHandler.restoreUploadedMedia(
|
|
19948
|
+
markdown = await this.imageHandler.restoreUploadedMedia(
|
|
19949
|
+
markdown,
|
|
19950
|
+
page.id,
|
|
19951
|
+
filePath,
|
|
19952
|
+
options?.adoptFrom
|
|
19953
|
+
);
|
|
19037
19954
|
if (downloadMedia && this.config.conversion.imageDownload === "immediate" && markdown) {
|
|
19038
19955
|
try {
|
|
19039
19956
|
const imageResult = await this.imageHandler.downloadAllImages(markdown, title, page.id);
|
|
@@ -19115,6 +20032,105 @@ var DatabaseSyncer = class {
|
|
|
19115
20032
|
this.restoredRows = [];
|
|
19116
20033
|
return rows;
|
|
19117
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
|
+
}
|
|
19118
20134
|
/**
|
|
19119
20135
|
* Notion 에서 다른 페이지 아래로 옮긴 DB 의 폴더를 볼트에서도 옮긴다 — 옮겼으면 그 이동. 옮기지 못하면 까닭을 남기고
|
|
19120
20136
|
* 옛 자리에서 받는다.
|
|
@@ -19128,7 +20144,7 @@ var DatabaseSyncer = class {
|
|
|
19128
20144
|
this.renamedRows.renamed.push(move);
|
|
19129
20145
|
this.renamedRows.applied.push(move);
|
|
19130
20146
|
getLogger().info(
|
|
19131
|
-
`[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}`
|
|
19132
20148
|
);
|
|
19133
20149
|
return move;
|
|
19134
20150
|
}
|
|
@@ -19137,7 +20153,7 @@ var DatabaseSyncer = class {
|
|
|
19137
20153
|
if (!outcome) return null;
|
|
19138
20154
|
if ("reason" in outcome) {
|
|
19139
20155
|
getLogger().warn(
|
|
19140
|
-
`[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}`
|
|
19141
20157
|
);
|
|
19142
20158
|
return null;
|
|
19143
20159
|
}
|
|
@@ -19149,18 +20165,17 @@ var DatabaseSyncer = class {
|
|
|
19149
20165
|
return movingInVault.has(record.id) || this.stateDb.getIncompleteOpByState(record.id, "move") !== null;
|
|
19150
20166
|
}
|
|
19151
20167
|
/**
|
|
19152
|
-
* Notion 에서 다른 페이지 아래로
|
|
20168
|
+
* Notion 에서 다른 페이지 아래로 옮기거나 이름을 바꾼 발견 DB 의 폴더를 옮길 곳(ADR-047 · {@link retitledFolderName})
|
|
20169
|
+
* — 제자리면 null.
|
|
19153
20170
|
*
|
|
19154
20171
|
* Notion 에서 DB 를 옮겨도 DB · 행의 수정 시각이 그대로라 받은 DB 의 부모로만 안다 — 전체 대조는 모든 DB 를 받으니
|
|
19155
20172
|
* 거기서 보인다. 새 자리는 처음 발견할 때와 같다 — 부모 페이지의 하위 폴더 이름 그대로, 루트면 `databases` 선반.
|
|
19156
20173
|
* 맨 위 폴더도 루트 아래로 본다(볼트에서 옮긴 DB 를 push 가 루트 아래에 둔다 — {@link DatabaseMover}).
|
|
19157
20174
|
* 다음은 옮기지 않는다.
|
|
19158
|
-
* - 부모가 블록(토글 · 열) 안 · 볼트가 모르는 페이지 — 볼트에 그 자리가 없다
|
|
20175
|
+
* - 부모가 블록(토글 · 열) 안 · 볼트가 모르는 페이지 — 볼트에 그 자리가 없다(이름만 따른다)
|
|
19159
20176
|
* - 볼트에서 옮긴 DB 폴더 · 부모 노트를 아직 올리지 않았다 — 볼트가 먼저다(push 가 Notion 을 옮긴다)
|
|
19160
20177
|
*/
|
|
19161
20178
|
placementMoveOf(dbConfig, meta, movingInVault = /* @__PURE__ */ new Set()) {
|
|
19162
|
-
const parentId = meta.parentPageId;
|
|
19163
|
-
if (!parentId) return null;
|
|
19164
20179
|
const id = dbConfig.databaseId;
|
|
19165
20180
|
if ((this.config.notion.databases ?? []).some((db) => notionIdsEqual(db.databaseId, id))) {
|
|
19166
20181
|
return null;
|
|
@@ -19169,23 +20184,52 @@ var DatabaseSyncer = class {
|
|
|
19169
20184
|
return null;
|
|
19170
20185
|
}
|
|
19171
20186
|
const folder = dbConfig.localFolder;
|
|
19172
|
-
|
|
19173
|
-
|
|
19174
|
-
|
|
19175
|
-
|
|
19176
|
-
|
|
19177
|
-
if (
|
|
19178
|
-
|
|
19179
|
-
|
|
19180
|
-
|
|
19181
|
-
|
|
19182
|
-
|
|
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
|
+
}
|
|
19183
20206
|
}
|
|
19184
|
-
|
|
19185
|
-
if (isFolderRecord(parent)) places.push(parent.obsidianPath);
|
|
20207
|
+
if (places && !places.includes(current)) place = places[0];
|
|
19186
20208
|
}
|
|
19187
|
-
|
|
19188
|
-
|
|
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;
|
|
19189
20233
|
}
|
|
19190
20234
|
/** 이 폴더가 받는 DB — 설정한 DB 와 발견한 DB 에서. */
|
|
19191
20235
|
databaseAt(folder) {
|
|
@@ -19196,6 +20240,12 @@ var DatabaseSyncer = class {
|
|
|
19196
20240
|
return all.find((db) => db.localFolder === folder)?.databaseId ?? null;
|
|
19197
20241
|
}
|
|
19198
20242
|
/** 이름을 바꾼 행을 가져가고 비운다. */
|
|
20243
|
+
/** 이번 pull 이 Notion 에서 지운 링크드 뷰라 치운 `.base` — 그것을 넣은 노트의 임베드를 뺀다({@link LinkUpdater.dropEmbeds}). */
|
|
20244
|
+
takeRetiredBases() {
|
|
20245
|
+
const paths = [...this.retiredBases];
|
|
20246
|
+
this.retiredBases.clear();
|
|
20247
|
+
return paths;
|
|
20248
|
+
}
|
|
19199
20249
|
takeRenamedRows() {
|
|
19200
20250
|
const rows = this.renamedRows;
|
|
19201
20251
|
this.renamedRows = { renamed: [], applied: [] };
|
|
@@ -19210,43 +20260,60 @@ var DatabaseSyncer = class {
|
|
|
19210
20260
|
const hasChildren = this.stateDb.getAll().some((r) => r.id !== record.id && within(r.obsidianPath, from));
|
|
19211
20261
|
return hasChildren ? { from, to: stripMarkdownExtension(rowPath) } : null;
|
|
19212
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
|
+
}
|
|
19213
20290
|
async pullDatabasePage(page, dbConfig) {
|
|
19214
20291
|
const safeName = sanitizeFileName(this.notionClient.extractTitle(page));
|
|
19215
20292
|
const existingRecord = this.stateDb.getByNotionId(page.id);
|
|
19216
20293
|
const recordPath = existingRecord?.obsidianPath;
|
|
20294
|
+
const retitle = existingRecord && recordPath ? await this.retitledRowTarget(dbConfig.localFolder, existingRecord, page) : null;
|
|
19217
20295
|
let filePath;
|
|
19218
|
-
|
|
19219
|
-
|
|
19220
|
-
|
|
19221
|
-
|
|
19222
|
-
|
|
19223
|
-
|
|
19224
|
-
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)) {
|
|
19225
20302
|
filePath = recordPath;
|
|
19226
20303
|
} else {
|
|
19227
|
-
filePath = resolveDbRowPath(
|
|
20304
|
+
filePath = (!existingRecord ? (adopt = await this.claimExistingRow(dbConfig.localFolder, safeName, page.id))?.path : void 0) ?? resolveDbRowPath(
|
|
19228
20305
|
dbConfig.localFolder,
|
|
19229
20306
|
safeName,
|
|
19230
20307
|
page.id,
|
|
19231
20308
|
await this.rowPathOwners(dbConfig.localFolder, safeName, page.id)
|
|
19232
20309
|
);
|
|
19233
20310
|
}
|
|
19234
|
-
|
|
19235
|
-
|
|
19236
|
-
|
|
19237
|
-
|
|
19238
|
-
childFolderMove = { from, to: stripMarkdownExtension(filePath) };
|
|
19239
|
-
const blocked = await this.relocator.blocked(childFolderMove);
|
|
19240
|
-
if (blocked) {
|
|
19241
|
-
getLogger().warn(
|
|
19242
|
-
`[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}`
|
|
19243
|
-
);
|
|
19244
|
-
filePath = recordPath;
|
|
19245
|
-
childFolderMove = null;
|
|
19246
|
-
}
|
|
19247
|
-
}
|
|
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
|
+
);
|
|
19248
20315
|
}
|
|
19249
|
-
const renaming =
|
|
20316
|
+
const renaming = retitle !== null && "to" in retitle;
|
|
19250
20317
|
const becomesRow = !!existingRecord && !!recordPath && wasPage(existingRecord);
|
|
19251
20318
|
const arrives = becomesRow || !!existingRecord && !!recordPath && arrivesFromOtherDatabase(existingRecord, dbConfig);
|
|
19252
20319
|
if (arrives && recordPath && this.stateDb.getIncompleteOpByState(existingRecord.id, "move")) {
|
|
@@ -19269,10 +20336,39 @@ var DatabaseSyncer = class {
|
|
|
19269
20336
|
emptiedFolder = parentFolderOf(recordPath);
|
|
19270
20337
|
}
|
|
19271
20338
|
}
|
|
19272
|
-
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
|
+
}
|
|
19273
20368
|
const { title, properties, bodyFingerprint } = rendered;
|
|
19274
20369
|
const finalContent = becomesRow ? keepPageValues(rendered.content, await readLocalNote(this.vaultFs, recordPath)) : rendered.content;
|
|
19275
20370
|
const seenAt = this.observation().seenAt;
|
|
20371
|
+
let merged = null;
|
|
19276
20372
|
if (existingRecord) {
|
|
19277
20373
|
let localContent = "";
|
|
19278
20374
|
let localExists = true;
|
|
@@ -19283,8 +20379,7 @@ var DatabaseSyncer = class {
|
|
|
19283
20379
|
localContent = "";
|
|
19284
20380
|
localExists = await this.vaultFs.exists(readPath);
|
|
19285
20381
|
}
|
|
19286
|
-
const
|
|
19287
|
-
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);
|
|
19288
20383
|
const resolution = resolvePullConflict({
|
|
19289
20384
|
record: existingRecord,
|
|
19290
20385
|
localContent,
|
|
@@ -19335,9 +20430,10 @@ var DatabaseSyncer = class {
|
|
|
19335
20430
|
});
|
|
19336
20431
|
return { action: "unchanged", path: filePath };
|
|
19337
20432
|
}
|
|
20433
|
+
if (resolution.action === "merge") merged = resolution.merged;
|
|
19338
20434
|
}
|
|
19339
20435
|
await this.vaultFs.ensureFolder(dbConfig.localFolder);
|
|
19340
|
-
await this.vaultFs.writeFile(filePath, finalContent);
|
|
20436
|
+
await this.vaultFs.writeFile(filePath, merged ?? finalContent);
|
|
19341
20437
|
const rehomed = existingRecord && recordPath && recordPath !== filePath;
|
|
19342
20438
|
const outOfStep = becomesRow && (rendered.propertiesBlock || !this.rowOutsideBodyUnchanged(
|
|
19343
20439
|
this.propertyMapper,
|
|
@@ -19368,9 +20464,9 @@ var DatabaseSyncer = class {
|
|
|
19368
20464
|
fileType: "db-row",
|
|
19369
20465
|
status: "synced",
|
|
19370
20466
|
baseSnapshot: Buffer.from(finalContent, "utf-8"),
|
|
19371
|
-
// 비운
|
|
19372
|
-
localMtime: outOfStep ? null : stat2?.mtime ?? null,
|
|
19373
|
-
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,
|
|
19374
20470
|
// 빈 해시와 함께 — push 가 지난 사본 대신 원격의 지금 값과 견줘 다른 속성을 모두 보낸다(overwritesRemote).
|
|
19375
20471
|
...outOfStep ? { notionBodyFingerprint: null } : {}
|
|
19376
20472
|
});
|
|
@@ -19408,7 +20504,7 @@ var DatabaseSyncer = class {
|
|
|
19408
20504
|
);
|
|
19409
20505
|
}
|
|
19410
20506
|
}
|
|
19411
|
-
return { action: "written", path: filePath };
|
|
20507
|
+
return merged !== null ? { action: "merged", path: filePath } : { action: "written", path: filePath, ...relinked ? { relinked: true } : {} };
|
|
19412
20508
|
}
|
|
19413
20509
|
};
|
|
19414
20510
|
function arrivesFromOtherDatabase(record, dbConfig) {
|
|
@@ -19547,160 +20643,30 @@ function folderOf3(record) {
|
|
|
19547
20643
|
if (record.fileType === "db-row") return null;
|
|
19548
20644
|
if (isFolderRecord(record)) return record.obsidianPath;
|
|
19549
20645
|
const slash = record.obsidianPath.lastIndexOf("/");
|
|
19550
|
-
if (slash < 0) return null;
|
|
19551
|
-
const folder = record.obsidianPath.slice(0, slash);
|
|
19552
|
-
const stem = stripMarkdownExtension(record.obsidianPath.slice(slash + 1)).replace(ID_SUFFIX, "");
|
|
19553
|
-
return stem === folder.slice(folder.lastIndexOf("/") + 1) ? folder : null;
|
|
19554
|
-
}
|
|
19555
|
-
function findNestedFolderPages(records) {
|
|
19556
|
-
const byPage = /* @__PURE__ */ new Map();
|
|
19557
|
-
for (const record of records) {
|
|
19558
|
-
if (record.notionPageId !== null) byPage.set(compactNotionId(record.notionPageId), record);
|
|
19559
|
-
}
|
|
19560
|
-
const found2 = [];
|
|
19561
|
-
for (const inner of records) {
|
|
19562
|
-
if (inner.notionPageId === null || inner.notionParentId === null) continue;
|
|
19563
|
-
const folder = folderOf3(inner);
|
|
19564
|
-
if (folder === null) continue;
|
|
19565
|
-
const outer = byPage.get(compactNotionId(inner.notionParentId));
|
|
19566
|
-
if (!outer || outer === inner || folderOf3(outer) !== folder) continue;
|
|
19567
|
-
found2.push({
|
|
19568
|
-
folder,
|
|
19569
|
-
outer: { path: outer.obsidianPath, notionPageId: outer.notionPageId },
|
|
19570
|
-
inner: { path: inner.obsidianPath, notionPageId: inner.notionPageId }
|
|
19571
|
-
});
|
|
19572
|
-
}
|
|
19573
|
-
return found2.sort((a, b) => a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0);
|
|
19574
|
-
}
|
|
19575
|
-
|
|
19576
|
-
// src/conflict/merger.ts
|
|
19577
|
-
function threeWayMerge(base, local, remote) {
|
|
19578
|
-
const baseLines = base.split("\n");
|
|
19579
|
-
const localLines = local.split("\n");
|
|
19580
|
-
const remoteLines = remote.split("\n");
|
|
19581
|
-
const hunks = [
|
|
19582
|
-
...computeHunks(baseLines, localLines, 0),
|
|
19583
|
-
...computeHunks(baseLines, remoteLines, 2)
|
|
19584
|
-
];
|
|
19585
|
-
hunks.sort((a, b) => a.baseStart - b.baseStart || a.side - b.side);
|
|
19586
|
-
const merged = [];
|
|
19587
|
-
const conflicts = [];
|
|
19588
|
-
let cursor = 0;
|
|
19589
|
-
let i = 0;
|
|
19590
|
-
while (i < hunks.length) {
|
|
19591
|
-
const first = hunks[i];
|
|
19592
|
-
if (first.baseStart > cursor) {
|
|
19593
|
-
merged.push(...baseLines.slice(cursor, first.baseStart));
|
|
19594
|
-
}
|
|
19595
|
-
const regionStart = first.baseStart;
|
|
19596
|
-
let regionEnd = first.baseEnd;
|
|
19597
|
-
const group2 = [first];
|
|
19598
|
-
i++;
|
|
19599
|
-
while (i < hunks.length) {
|
|
19600
|
-
const next = hunks[i];
|
|
19601
|
-
const overlaps = next.baseStart < regionEnd || regionEnd === regionStart && next.baseStart === regionStart;
|
|
19602
|
-
if (!overlaps) break;
|
|
19603
|
-
regionEnd = Math.max(regionEnd, next.baseEnd);
|
|
19604
|
-
group2.push(next);
|
|
19605
|
-
i++;
|
|
19606
|
-
}
|
|
19607
|
-
const baseRegion = baseLines.slice(regionStart, regionEnd);
|
|
19608
|
-
const localRegion = applyHunks(baseLines, group2, 0, regionStart, regionEnd);
|
|
19609
|
-
const remoteRegion = applyHunks(baseLines, group2, 2, regionStart, regionEnd);
|
|
19610
|
-
if (arraysEqual(localRegion, baseRegion)) {
|
|
19611
|
-
merged.push(...remoteRegion);
|
|
19612
|
-
} else if (arraysEqual(remoteRegion, baseRegion)) {
|
|
19613
|
-
merged.push(...localRegion);
|
|
19614
|
-
} else if (arraysEqual(localRegion, remoteRegion)) {
|
|
19615
|
-
merged.push(...localRegion);
|
|
19616
|
-
} else {
|
|
19617
|
-
conflicts.push({ localLines: localRegion, remoteLines: remoteRegion });
|
|
19618
|
-
merged.push("<<<<<<< LOCAL");
|
|
19619
|
-
merged.push(...localRegion);
|
|
19620
|
-
merged.push("=======");
|
|
19621
|
-
merged.push(...remoteRegion);
|
|
19622
|
-
merged.push(">>>>>>> REMOTE");
|
|
19623
|
-
}
|
|
19624
|
-
cursor = regionEnd;
|
|
19625
|
-
}
|
|
19626
|
-
if (cursor < baseLines.length) {
|
|
19627
|
-
merged.push(...baseLines.slice(cursor));
|
|
19628
|
-
}
|
|
19629
|
-
return {
|
|
19630
|
-
success: conflicts.length === 0,
|
|
19631
|
-
merged: merged.join("\n"),
|
|
19632
|
-
conflicts
|
|
19633
|
-
};
|
|
19634
|
-
}
|
|
19635
|
-
function computeHunks(base, modified, side) {
|
|
19636
|
-
const matches = lcsMatches(base, modified);
|
|
19637
|
-
const hunks = [];
|
|
19638
|
-
let prevBase = -1;
|
|
19639
|
-
let prevMod = -1;
|
|
19640
|
-
const emitGap = (curBase, curMod) => {
|
|
19641
|
-
const baseLen = curBase - (prevBase + 1);
|
|
19642
|
-
const modLen = curMod - (prevMod + 1);
|
|
19643
|
-
if (baseLen > 0 || modLen > 0) {
|
|
19644
|
-
hunks.push({
|
|
19645
|
-
baseStart: prevBase + 1,
|
|
19646
|
-
baseEnd: curBase,
|
|
19647
|
-
content: modified.slice(prevMod + 1, curMod),
|
|
19648
|
-
side
|
|
19649
|
-
});
|
|
19650
|
-
}
|
|
19651
|
-
};
|
|
19652
|
-
for (const [b, m] of matches) {
|
|
19653
|
-
emitGap(b, m);
|
|
19654
|
-
prevBase = b;
|
|
19655
|
-
prevMod = m;
|
|
19656
|
-
}
|
|
19657
|
-
emitGap(base.length, modified.length);
|
|
19658
|
-
return hunks;
|
|
19659
|
-
}
|
|
19660
|
-
function applyHunks(baseLines, group2, side, regionStart, regionEnd) {
|
|
19661
|
-
const out = [];
|
|
19662
|
-
let b = regionStart;
|
|
19663
|
-
for (const hunk of group2) {
|
|
19664
|
-
if (hunk.side !== side) continue;
|
|
19665
|
-
if (hunk.baseStart > b) out.push(...baseLines.slice(b, hunk.baseStart));
|
|
19666
|
-
out.push(...hunk.content);
|
|
19667
|
-
b = hunk.baseEnd;
|
|
19668
|
-
}
|
|
19669
|
-
if (b < regionEnd) out.push(...baseLines.slice(b, regionEnd));
|
|
19670
|
-
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;
|
|
19671
20650
|
}
|
|
19672
|
-
function
|
|
19673
|
-
const
|
|
19674
|
-
const
|
|
19675
|
-
|
|
19676
|
-
for (let i2 = 0; i2 <= n; i2++) dp[i2] = new Uint32Array(m + 1);
|
|
19677
|
-
for (let i2 = n - 1; i2 >= 0; i2--) {
|
|
19678
|
-
const row = dp[i2];
|
|
19679
|
-
const next = dp[i2 + 1];
|
|
19680
|
-
const ai = a[i2];
|
|
19681
|
-
for (let j2 = m - 1; j2 >= 0; j2--) {
|
|
19682
|
-
row[j2] = ai === b[j2] ? next[j2 + 1] + 1 : Math.max(next[j2], row[j2 + 1]);
|
|
19683
|
-
}
|
|
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);
|
|
19684
20655
|
}
|
|
19685
|
-
const
|
|
19686
|
-
|
|
19687
|
-
|
|
19688
|
-
|
|
19689
|
-
if (
|
|
19690
|
-
|
|
19691
|
-
|
|
19692
|
-
|
|
19693
|
-
|
|
19694
|
-
|
|
19695
|
-
|
|
19696
|
-
|
|
19697
|
-
}
|
|
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
|
+
});
|
|
19698
20668
|
}
|
|
19699
|
-
return
|
|
19700
|
-
}
|
|
19701
|
-
function arraysEqual(a, b) {
|
|
19702
|
-
if (a.length !== b.length) return false;
|
|
19703
|
-
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);
|
|
19704
20670
|
}
|
|
19705
20671
|
|
|
19706
20672
|
// src/conflict/resolver.ts
|
|
@@ -19721,6 +20687,7 @@ function choiceForStrategy(conflict, strategy) {
|
|
|
19721
20687
|
case "duplicate":
|
|
19722
20688
|
return isRemoteDeletion(conflict) ? "local" : "duplicate";
|
|
19723
20689
|
case "manual":
|
|
20690
|
+
case "merge":
|
|
19724
20691
|
if (isRemoteDeletion(conflict)) {
|
|
19725
20692
|
throw new Error(
|
|
19726
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}`
|
|
@@ -20689,6 +21656,12 @@ function depthOf(filter) {
|
|
|
20689
21656
|
}
|
|
20690
21657
|
|
|
20691
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"];
|
|
20692
21665
|
var PROPERTY_REQUEST_KEYS = [
|
|
20693
21666
|
"property_id",
|
|
20694
21667
|
"visible",
|
|
@@ -20700,6 +21673,9 @@ var PROPERTY_REQUEST_KEYS = [
|
|
|
20700
21673
|
"time_format"
|
|
20701
21674
|
];
|
|
20702
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
|
+
}
|
|
20703
21679
|
var BaseViewEdits = class {
|
|
20704
21680
|
constructor(stateDb, vaultFs, notionClient) {
|
|
20705
21681
|
this.vaultFs = vaultFs;
|
|
@@ -20739,7 +21715,9 @@ var BaseViewEdits = class {
|
|
|
20739
21715
|
const changed = changedFields(record, edited);
|
|
20740
21716
|
return changed.length > 0 ? [{ record, edited, changed }] : [];
|
|
20741
21717
|
});
|
|
20742
|
-
|
|
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;
|
|
20743
21721
|
const meta = await this.notionClient.getDatabaseMeta(databaseId);
|
|
20744
21722
|
if (meta.dataSources.length > 1) {
|
|
20745
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");
|
|
@@ -20748,15 +21726,66 @@ var BaseViewEdits = class {
|
|
|
20748
21726
|
const schema = await this.notionClient.getDatabaseSchemaFull(databaseId, meta);
|
|
20749
21727
|
const views = [];
|
|
20750
21728
|
let allPlanned = true;
|
|
20751
|
-
|
|
20752
|
-
const label = `\uBCF4\uAE30 '${record.name}'`;
|
|
20753
|
-
let live;
|
|
21729
|
+
const liveOf = async (viewId) => {
|
|
20754
21730
|
try {
|
|
20755
|
-
|
|
21731
|
+
return await this.notionClient.getView(viewId);
|
|
20756
21732
|
} catch (error) {
|
|
20757
21733
|
if (!isNotionObjectNotFound(error)) throw error;
|
|
20758
|
-
|
|
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;
|
|
20759
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);
|
|
20760
21789
|
if (!live) {
|
|
20761
21790
|
refuse2(base.basePath, `${label}: Notion \uC5D0\uC11C \uC9C0\uC6B4 \uBCF4\uAE30\uC785\uB2C8\uB2E4`);
|
|
20762
21791
|
allPlanned = false;
|
|
@@ -20790,18 +21819,25 @@ var BaseViewEdits = class {
|
|
|
20790
21819
|
type: record.type,
|
|
20791
21820
|
...edited,
|
|
20792
21821
|
...record.lossyFilter && !changed.includes("filter") ? { lossyFilter: true } : {},
|
|
20793
|
-
notion: record.notion
|
|
21822
|
+
notion: record.notion,
|
|
21823
|
+
...record.rest !== void 0 ? { rest: record.rest } : {}
|
|
20794
21824
|
}
|
|
20795
21825
|
});
|
|
20796
21826
|
}
|
|
20797
|
-
if (views.length === 0)
|
|
21827
|
+
if (views.length === 0 && created.length === 0 && deleted.length === 0 && gone.length === 0) {
|
|
21828
|
+
continue;
|
|
21829
|
+
}
|
|
20798
21830
|
planned.push({
|
|
20799
21831
|
databaseId,
|
|
20800
21832
|
path: base.basePath,
|
|
20801
21833
|
base,
|
|
20802
21834
|
hash,
|
|
20803
|
-
complete: allPlanned &&
|
|
20804
|
-
views
|
|
21835
|
+
complete: allPlanned && restUnchanged(parsed, matched, base),
|
|
21836
|
+
views,
|
|
21837
|
+
created,
|
|
21838
|
+
deleted,
|
|
21839
|
+
gone,
|
|
21840
|
+
matched
|
|
20805
21841
|
});
|
|
20806
21842
|
}
|
|
20807
21843
|
return { planned, refused };
|
|
@@ -20811,29 +21847,73 @@ var BaseViewEdits = class {
|
|
|
20811
21847
|
const failed = [];
|
|
20812
21848
|
for (const file of planned) {
|
|
20813
21849
|
const records = new Map(file.base.views.map((record) => [record.viewId, record]));
|
|
21850
|
+
const made = /* @__PURE__ */ new Map();
|
|
20814
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
|
+
}
|
|
20815
21881
|
for (const view of file.views) {
|
|
20816
21882
|
try {
|
|
20817
21883
|
await this.notionClient.updateView(view.viewId, view.request);
|
|
20818
21884
|
} catch (error) {
|
|
20819
|
-
|
|
20820
|
-
failed.push({
|
|
20821
|
-
path: file.path,
|
|
20822
|
-
operation: "update",
|
|
20823
|
-
error: `\uBCF4\uAE30 '${view.edit.view}' \uB97C Notion \uC5D0 \uC62C\uB9AC\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: ${error instanceof Error ? error.message : String(error)}`
|
|
20824
|
-
});
|
|
21885
|
+
fail(`\uBCF4\uAE30 '${view.edit.view}' \uB97C Notion \uC5D0 \uC62C\uB9AC\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4`, error);
|
|
20825
21886
|
continue;
|
|
20826
21887
|
}
|
|
20827
21888
|
applied.push(view.edit);
|
|
20828
|
-
const after = await this.notionClient.getView(view.viewId).catch(() => null);
|
|
20829
21889
|
records.set(view.viewId, {
|
|
20830
21890
|
...view.record,
|
|
20831
|
-
notion:
|
|
21891
|
+
notion: await fingerprintOf(view.viewId, view.record.notion)
|
|
20832
21892
|
});
|
|
20833
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
|
+
);
|
|
20834
21914
|
this.bases.set(file.databaseId, {
|
|
20835
21915
|
...file.base,
|
|
20836
|
-
views:
|
|
21916
|
+
views: [...inFile, ...left],
|
|
20837
21917
|
// 고친 것을 모두 올렸으면 이 파일은 이제 pull 이 쓴 것과 같은 뜻이다 — 다음 pull 이 Notion 의 모습으로 다시 쓴다.
|
|
20838
21918
|
// 남은 것이 있으면 계속 붙잡아 둔다.
|
|
20839
21919
|
...file.complete && allApplied ? { hash: file.hash } : {}
|
|
@@ -20841,15 +21921,55 @@ var BaseViewEdits = class {
|
|
|
20841
21921
|
}
|
|
20842
21922
|
return { applied, failed };
|
|
20843
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
|
+
}
|
|
20844
21929
|
};
|
|
20845
|
-
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 = {}) {
|
|
20846
21964
|
const request = {};
|
|
20847
21965
|
try {
|
|
20848
21966
|
if (changed.includes("name")) {
|
|
20849
21967
|
if (!edited.name.trim()) throw new Error("\uBCF4\uAE30 \uC774\uB984\uC774 \uBE44\uC5C8\uC2B5\uB2C8\uB2E4");
|
|
20850
21968
|
request.name = edited.name;
|
|
20851
21969
|
}
|
|
20852
|
-
if (changed.includes("columns"))
|
|
21970
|
+
if (changed.includes("columns")) {
|
|
21971
|
+
request.configuration = columnsOf(edited, live, schema, options.hideRest === true);
|
|
21972
|
+
}
|
|
20853
21973
|
if (changed.includes("sort")) {
|
|
20854
21974
|
request.sorts = edited.sort ? edited.sort.map((s) => ({
|
|
20855
21975
|
property: propertyIdOf(s.property, schema, "\uC815\uB82C"),
|
|
@@ -20875,7 +21995,7 @@ function buildRequest(record, edited, changed, live, schema) {
|
|
|
20875
21995
|
}
|
|
20876
21996
|
return { ok: true, request };
|
|
20877
21997
|
}
|
|
20878
|
-
function columnsOf(edited, live, schema) {
|
|
21998
|
+
function columnsOf(edited, live, schema, hideRest = false) {
|
|
20879
21999
|
if (!edited.order) throw new Error("\uC5F4 \uBAA9\uB85D(order)\uC744 \uC9C0\uC6B4 \uBCF4\uAE30\uB294 \uC62C\uB9AC\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
|
|
20880
22000
|
const configuration = live.configuration ?? {};
|
|
20881
22001
|
const liveEntries = Array.isArray(configuration["properties"]) ? configuration["properties"] : [];
|
|
@@ -20904,7 +22024,7 @@ function columnsOf(edited, live, schema) {
|
|
|
20904
22024
|
const properties = [
|
|
20905
22025
|
...titleId ? [entryOf(titleId)] : [],
|
|
20906
22026
|
...shown.map((id) => ({ ...entryOf(id), visible: true })),
|
|
20907
|
-
...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 }))
|
|
20908
22028
|
];
|
|
20909
22029
|
return {
|
|
20910
22030
|
type: configuration["type"] ?? live.type,
|
|
@@ -21021,7 +22141,8 @@ var OPERATION_LABELS = {
|
|
|
21021
22141
|
fetch: "\uC6D0\uACA9 \uD655\uC778",
|
|
21022
22142
|
resolve: "\uCDA9\uB3CC \uD574\uACB0",
|
|
21023
22143
|
discard: "\uBCC0\uACBD \uB418\uB3CC\uB9AC\uAE30",
|
|
21024
|
-
"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"
|
|
21025
22146
|
};
|
|
21026
22147
|
function operationLabel(operation) {
|
|
21027
22148
|
return OPERATION_LABELS[operation];
|
|
@@ -21206,6 +22327,7 @@ var InterruptedSyncRecovery = class {
|
|
|
21206
22327
|
if (ops.length === 0) return;
|
|
21207
22328
|
for (const op of ops) {
|
|
21208
22329
|
if (op.direction === "push" && op.operation === "move") continue;
|
|
22330
|
+
if (op.direction === "push" && op.operation === "update") continue;
|
|
21209
22331
|
if (op.direction !== "push" || op.operation !== "create") {
|
|
21210
22332
|
this.stateDb.markPendingFailed(op.id, "unsupported resume op");
|
|
21211
22333
|
continue;
|
|
@@ -21327,6 +22449,101 @@ var InterruptedSyncRecovery = class {
|
|
|
21327
22449
|
}
|
|
21328
22450
|
};
|
|
21329
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
|
+
|
|
21330
22547
|
// src/sync/db-placeholder-rewriter.ts
|
|
21331
22548
|
var ID32 = "[a-f0-9]{32}";
|
|
21332
22549
|
var IDUUID = "[a-f0-9-]{32,36}";
|
|
@@ -21513,6 +22730,7 @@ var DatabaseDiscovery = class {
|
|
|
21513
22730
|
}
|
|
21514
22731
|
const linkedMap = loadLinkedDbMap(this.stateDb);
|
|
21515
22732
|
let linkedChanged = false;
|
|
22733
|
+
const linkedNow = /* @__PURE__ */ new Set();
|
|
21516
22734
|
const retry = parseDiscoveryRetry(this.stateDb.getMeta(DISCOVERY_RETRY_META_KEY));
|
|
21517
22735
|
const nextRetry = { parents: [], dbs: [] };
|
|
21518
22736
|
const pathOf = (pageId) => notionIdsEqual(pageId, this.config.notion.rootPageId) ? "" : this.stateDb.getByNotionId(pageId)?.obsidianPath ?? pageId;
|
|
@@ -21566,6 +22784,7 @@ var DatabaseDiscovery = class {
|
|
|
21566
22784
|
} else if (outcome.kind === "linked") {
|
|
21567
22785
|
const origNohyph = outcome.originalDbId.replace(/-/g, "");
|
|
21568
22786
|
linkedMap.set(nohyph, origNohyph);
|
|
22787
|
+
linkedNow.add(nohyph);
|
|
21569
22788
|
linkedChanged = true;
|
|
21570
22789
|
await registerOriginal(origNohyph);
|
|
21571
22790
|
} else if (outcome.kind === "inaccessible") {
|
|
@@ -21600,6 +22819,7 @@ var DatabaseDiscovery = class {
|
|
|
21600
22819
|
await register(normalizeNotionId(nohyph), parentPageId);
|
|
21601
22820
|
}
|
|
21602
22821
|
const stillSyncable = [];
|
|
22822
|
+
const pulledWhole = /* @__PURE__ */ new Set();
|
|
21603
22823
|
let queue = [...dbConfigs];
|
|
21604
22824
|
const MAX_DISCOVERY_ROUNDS = 4;
|
|
21605
22825
|
const resolveDbFolder = (dbId) => {
|
|
@@ -21639,10 +22859,8 @@ var DatabaseDiscovery = class {
|
|
|
21639
22859
|
}
|
|
21640
22860
|
});
|
|
21641
22861
|
if (dbResult.linkedOriginalDbId) {
|
|
21642
|
-
linkedMap.set(
|
|
21643
|
-
|
|
21644
|
-
dbResult.linkedOriginalDbId.replace(/-/g, "")
|
|
21645
|
-
);
|
|
22862
|
+
linkedMap.set(nohyph, dbResult.linkedOriginalDbId.replace(/-/g, ""));
|
|
22863
|
+
linkedNow.add(nohyph);
|
|
21646
22864
|
linkedChanged = true;
|
|
21647
22865
|
changed = true;
|
|
21648
22866
|
ledger?.settle(dbConfig.databaseId);
|
|
@@ -21651,6 +22869,7 @@ var DatabaseDiscovery = class {
|
|
|
21651
22869
|
);
|
|
21652
22870
|
continue;
|
|
21653
22871
|
}
|
|
22872
|
+
if (!paths) pulledWhole.add(nohyph);
|
|
21654
22873
|
created += dbResult.created;
|
|
21655
22874
|
updated += dbResult.updated;
|
|
21656
22875
|
deleted += dbResult.deleted;
|
|
@@ -21697,6 +22916,11 @@ var DatabaseDiscovery = class {
|
|
|
21697
22916
|
);
|
|
21698
22917
|
}
|
|
21699
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
|
+
}
|
|
21700
22924
|
ledger?.keepOnly([...configuredIds, ...stillSyncable.map((c) => c.databaseId)]);
|
|
21701
22925
|
if (changed || inaccessibleChanged || stillSyncable.length !== dbConfigs.length) {
|
|
21702
22926
|
this.stateDb.setMeta(DISCOVERED_DBS_META_KEY, JSON.stringify(stillSyncable));
|
|
@@ -21721,6 +22945,29 @@ var DatabaseDiscovery = class {
|
|
|
21721
22945
|
}
|
|
21722
22946
|
return { created, updated, deleted, restored };
|
|
21723
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
|
+
}
|
|
21724
22971
|
async discoverChildDatabases(pageIds) {
|
|
21725
22972
|
const found2 = [];
|
|
21726
22973
|
const unread = [];
|
|
@@ -21834,6 +23081,12 @@ var DatabaseDiscovery = class {
|
|
|
21834
23081
|
for (const [key, t] of [...targets]) {
|
|
21835
23082
|
if (!await this.vaultFs.exists(t.basePath)) targets.delete(key);
|
|
21836
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
|
+
}
|
|
21837
23090
|
if (targets.size === 0) return;
|
|
21838
23091
|
const resolve2 = (nohyph) => targets.get(nohyph) ?? targets.get(linkedMap.get(nohyph) ?? "") ?? null;
|
|
21839
23092
|
let total = 0;
|
|
@@ -21884,12 +23137,6 @@ var DatabaseDiscovery = class {
|
|
|
21884
23137
|
return refs;
|
|
21885
23138
|
}
|
|
21886
23139
|
};
|
|
21887
|
-
function databaseFolderName(title) {
|
|
21888
|
-
return truncateBytes(
|
|
21889
|
-
title.replace(/[^a-zA-Z0-9가-힣\s_-]/g, "").replace(/\s+/g, "-"),
|
|
21890
|
-
FILE_SYSTEM_NAME_MAX_BYTES
|
|
21891
|
-
);
|
|
21892
|
-
}
|
|
21893
23140
|
|
|
21894
23141
|
// src/sync/folder-placement.ts
|
|
21895
23142
|
var FolderPlacement = class {
|
|
@@ -22978,12 +24225,14 @@ function baseName3(path) {
|
|
|
22978
24225
|
|
|
22979
24226
|
// src/sync/link-updater.ts
|
|
22980
24227
|
var LinkUpdater = class {
|
|
22981
|
-
constructor(stateDb, vaultFs) {
|
|
24228
|
+
constructor(stateDb, vaultFs, outside) {
|
|
22982
24229
|
this.stateDb = stateDb;
|
|
22983
24230
|
this.vaultFs = vaultFs;
|
|
24231
|
+
this.outside = outside;
|
|
22984
24232
|
}
|
|
22985
24233
|
stateDb;
|
|
22986
24234
|
vaultFs;
|
|
24235
|
+
outside;
|
|
22987
24236
|
/**
|
|
22988
24237
|
* @param moves 옮긴 차례 그대로의 이동 — 노트 하나거나 폴더 하나.
|
|
22989
24238
|
* @returns 링크를 고친 노트.
|
|
@@ -23001,33 +24250,130 @@ var LinkUpdater = class {
|
|
|
23001
24250
|
if (renames.size === 0) return [];
|
|
23002
24251
|
const before = new Set([...after].map((path) => undo(path, moves)));
|
|
23003
24252
|
const context = { before, after, renames };
|
|
24253
|
+
const noteRenames = new Map([...renames].filter(([from]) => isMarkdownPath(from)));
|
|
23004
24254
|
const relinked = [];
|
|
23005
24255
|
for (const note of notes) {
|
|
23006
24256
|
const source = { from: undo(note.path, moves), to: note.path };
|
|
23007
24257
|
const next = rewriteLinks(note.content, source, context);
|
|
23008
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;
|
|
23009
24302
|
try {
|
|
23010
|
-
await
|
|
24303
|
+
await outside.fs.writeFile(note.path, next);
|
|
24304
|
+
relinked.push(note.path);
|
|
23011
24305
|
} catch (error) {
|
|
23012
24306
|
getLogger().warn(
|
|
23013
|
-
`[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)}`
|
|
23014
24308
|
);
|
|
23015
|
-
continue;
|
|
23016
24309
|
}
|
|
23017
|
-
const record = this.stateDb.getByPath(note.path);
|
|
23018
|
-
if (record && record.contentHash === computeHash(note.content)) {
|
|
23019
|
-
this.stateDb.updateHash(record.id, computeHash(next), Buffer.from(next, "utf-8"));
|
|
23020
|
-
const stat2 = await this.vaultFs.getFileStat(note.path);
|
|
23021
|
-
if (stat2) this.stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
|
|
23022
|
-
}
|
|
23023
|
-
relinked.push(note.path);
|
|
23024
|
-
}
|
|
23025
|
-
if (relinked.length > 0) {
|
|
23026
|
-
getLogger().info(`[Im-Nobsidian] \uC62E\uAE34 \uB178\uD2B8\uB97C \uAC00\uB9AC\uD0A4\uB358 \uB9C1\uD06C\uB97C \uACE0\uCE68: \uB178\uD2B8 ${relinked.length}`);
|
|
23027
24310
|
}
|
|
23028
24311
|
return relinked;
|
|
23029
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
|
+
}
|
|
23030
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
|
+
}
|
|
23031
24377
|
function undo(path, moves) {
|
|
23032
24378
|
let current = path;
|
|
23033
24379
|
for (let i = moves.length - 1; i >= 0; i--) {
|
|
@@ -23039,6 +24385,19 @@ function undo(path, moves) {
|
|
|
23039
24385
|
return current;
|
|
23040
24386
|
}
|
|
23041
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
|
+
|
|
23042
24401
|
// src/sync/change-inspector.ts
|
|
23043
24402
|
var ChangeInspector = class {
|
|
23044
24403
|
constructor(stateDb, vaultFs, notionClient, planner, puller) {
|
|
@@ -23547,6 +24906,7 @@ var RemoteDriftChecker = class {
|
|
|
23547
24906
|
this.databaseSyncer = databaseSyncer;
|
|
23548
24907
|
this.rowSchemas = rowSchemas;
|
|
23549
24908
|
this.observation = observation;
|
|
24909
|
+
this.journal = new UpdateJournal(stateDb);
|
|
23550
24910
|
}
|
|
23551
24911
|
config;
|
|
23552
24912
|
stateDb;
|
|
@@ -23554,6 +24914,20 @@ var RemoteDriftChecker = class {
|
|
|
23554
24914
|
databaseSyncer;
|
|
23555
24915
|
rowSchemas;
|
|
23556
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
|
+
}
|
|
23557
24931
|
/**
|
|
23558
24932
|
* 원격이 바뀌었는지 확인하지 않고 로컬로 덮어써도 되는가 — 원격을 로컬로 맞추라는 요청이다.
|
|
23559
24933
|
*
|
|
@@ -23700,6 +25074,7 @@ var PagePusher = class {
|
|
|
23700
25074
|
this.drift = drift;
|
|
23701
25075
|
this.placement = placement;
|
|
23702
25076
|
this.recovery = recovery;
|
|
25077
|
+
this.journal = new UpdateJournal(stateDb);
|
|
23703
25078
|
}
|
|
23704
25079
|
config;
|
|
23705
25080
|
stateDb;
|
|
@@ -23718,6 +25093,8 @@ var PagePusher = class {
|
|
|
23718
25093
|
}
|
|
23719
25094
|
/** 이번 실행에서 relation 을 보내지 못한 행 — 대상 노트가 아직 Notion 에 없었다. */
|
|
23720
25095
|
unsentRelations = /* @__PURE__ */ new Set();
|
|
25096
|
+
/** 본문을 쓰다 끊긴 노트를 다음 실행이 알게 — {@link UpdateJournal}. */
|
|
25097
|
+
journal;
|
|
23721
25098
|
/** 이번 실행에서 페이지 ↔ 행 · 다른 DB 의 행이 된 노트({@link takePlacementChanges}). */
|
|
23722
25099
|
placementChanges = [];
|
|
23723
25100
|
/**
|
|
@@ -23768,10 +25145,63 @@ var PagePusher = class {
|
|
|
23768
25145
|
);
|
|
23769
25146
|
if (readOnly.length > 0) skips.push({ path: change.path, properties: readOnly });
|
|
23770
25147
|
} catch {
|
|
23771
|
-
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;
|
|
23772
25195
|
}
|
|
25196
|
+
if (base?.body != null && base.body === body) return;
|
|
23773
25197
|
}
|
|
23774
|
-
|
|
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);
|
|
23775
25205
|
}
|
|
23776
25206
|
/** dry-run 이 견줄 행 — 새 행은 기준 없이, 고친 행은 지난 동기화 사본과. 행이 아니거나 사본이 없으면 null. */
|
|
23777
25207
|
previewRow(change) {
|
|
@@ -23836,7 +25266,8 @@ var PagePusher = class {
|
|
|
23836
25266
|
direction: "push",
|
|
23837
25267
|
path: selectedPath,
|
|
23838
25268
|
filePath: path,
|
|
23839
|
-
parentMode: this.config.notion.parentMode
|
|
25269
|
+
parentMode: this.config.notion.parentMode,
|
|
25270
|
+
anchors: await this.anchorLookup(content, null)
|
|
23840
25271
|
});
|
|
23841
25272
|
const title = noteTitle(conversionResult.properties, path);
|
|
23842
25273
|
const placeholder2 = this.stateDb.upsert({
|
|
@@ -23946,13 +25377,40 @@ var PagePusher = class {
|
|
|
23946
25377
|
}
|
|
23947
25378
|
return uploads;
|
|
23948
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
|
+
}
|
|
23949
25409
|
async livePage(record) {
|
|
23950
25410
|
const presence = await remotePresence(this.notionClient, record.notionPageId);
|
|
23951
25411
|
if (presence.kind === "alive") return presence.page;
|
|
23952
25412
|
this.stateDb.updateStatus(record.id, "conflict");
|
|
23953
|
-
throw new Error(
|
|
23954
|
-
`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}`
|
|
23955
|
-
);
|
|
25413
|
+
throw new Error(trashedPageRefusal(record.obsidianPath));
|
|
23956
25414
|
}
|
|
23957
25415
|
/**
|
|
23958
25416
|
* @param options.overwriteRemote 원격을 로컬 내용으로 맞춘다(충돌 해소 결과 전파 · 입양한 행).
|
|
@@ -23980,15 +25438,15 @@ var PagePusher = class {
|
|
|
23980
25438
|
direction: "push",
|
|
23981
25439
|
path: updatePath,
|
|
23982
25440
|
filePath: path,
|
|
23983
|
-
parentMode: this.config.notion.parentMode
|
|
25441
|
+
parentMode: this.config.notion.parentMode,
|
|
25442
|
+
anchors: await this.anchorLookup(content, record.notionPageId)
|
|
23984
25443
|
});
|
|
23985
25444
|
const remote = await this.livePage(record);
|
|
23986
|
-
const drift = this.drift.overwritesRemote(
|
|
23987
|
-
record,
|
|
23988
|
-
options?.overwriteRemote === true
|
|
23989
|
-
) ? "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);
|
|
23990
25446
|
refuseUnpulledBody(drift, path);
|
|
23991
25447
|
const settles = drift === "none";
|
|
25448
|
+
const hash = computeHash(content);
|
|
25449
|
+
this.journal.begin(record.id, hash);
|
|
23992
25450
|
const pushed = await this.pushUpdatePage(record.notionPageId, conversionResult.content, path);
|
|
23993
25451
|
let bodyFingerprint = pushed.fingerprint;
|
|
23994
25452
|
let touched = await this.fillDeferredBlocks(
|
|
@@ -24023,11 +25481,11 @@ var PagePusher = class {
|
|
|
24023
25481
|
} else if (settles) {
|
|
24024
25482
|
written = await this.notionClient.getPage(record.notionPageId);
|
|
24025
25483
|
}
|
|
24026
|
-
const hash = computeHash(content);
|
|
24027
25484
|
const fileStat = await this.vaultFs.getFileStat(path);
|
|
24028
25485
|
const title = extractTitle(path);
|
|
24029
25486
|
const aliases = extractAliases(conversionResult.properties);
|
|
24030
25487
|
this.stateDb.transaction(() => {
|
|
25488
|
+
this.journal.finish(record.id);
|
|
24031
25489
|
this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
|
|
24032
25490
|
this.stateDb.updateStatus(record.id, "synced");
|
|
24033
25491
|
if (written !== null && settles) {
|
|
@@ -24242,20 +25700,28 @@ var PagePusher = class {
|
|
|
24242
25700
|
* ({@link RemoteDriftChecker.overwritesRemote}).
|
|
24243
25701
|
*
|
|
24244
25702
|
* 다시 보내면 Notion 을 덮으므로, 받지 않은 Notion 편집이 있으면 옮기지 않는다.
|
|
25703
|
+
*
|
|
25704
|
+
* 옮기기 전에 보낼 노트를 갱신 WAL 에 적는다(P7). 옮긴 뒤 응답을 잃으면 같은 실행의 재시도 · 다음 push 는 이미 그
|
|
25705
|
+
* 자리에 있고 마지막 편집자가 이 봇인 페이지를 본다 — 우리가 옮기다 끊긴 것이므로 옮기기를 건너뛰고 마저 보낸다.
|
|
25706
|
+
* 예전에는 옮기며 바뀐 수정 시각을 Notion 편집으로 보고 «pull 을 먼저 하라» 고 거절했다.
|
|
24245
25707
|
*/
|
|
24246
25708
|
async changePlacement(record, move) {
|
|
24247
25709
|
const { path, databaseId } = move;
|
|
24248
25710
|
const pageId = record.notionPageId;
|
|
25711
|
+
let content;
|
|
24249
25712
|
let current;
|
|
24250
25713
|
try {
|
|
24251
|
-
|
|
25714
|
+
content = await this.vaultFs.readFile(path);
|
|
25715
|
+
current = parseFrontmatter(content).data;
|
|
24252
25716
|
} catch (error) {
|
|
24253
25717
|
throw new Error(
|
|
24254
25718
|
`frontmatter \uB97C \uC77D\uC9C0 \uBABB\uD574 \uC62E\uAE30\uC9C0 \uC54A\uC74C (${path}): ${error instanceof Error ? error.message : String(error)}`
|
|
24255
25719
|
);
|
|
24256
25720
|
}
|
|
24257
25721
|
const remote = await this.livePage(record);
|
|
24258
|
-
|
|
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") {
|
|
24259
25725
|
throw new Error(
|
|
24260
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}`
|
|
24261
25727
|
);
|
|
@@ -24274,11 +25740,17 @@ var PagePusher = class {
|
|
|
24274
25740
|
);
|
|
24275
25741
|
addedColumns = columnsAddedByMove(pagePropertiesOf(remote), schema);
|
|
24276
25742
|
}
|
|
24277
|
-
|
|
25743
|
+
if (!resumed) {
|
|
25744
|
+
this.journal.begin(record.id, computeHash(content));
|
|
25745
|
+
await this.notionClient.movePageToDataSource(pageId, source.id);
|
|
25746
|
+
}
|
|
24278
25747
|
parentId = databaseId;
|
|
24279
25748
|
} else {
|
|
24280
|
-
parentId =
|
|
24281
|
-
|
|
25749
|
+
parentId = pageParentId;
|
|
25750
|
+
if (!resumed) {
|
|
25751
|
+
this.journal.begin(record.id, computeHash(content));
|
|
25752
|
+
await this.notionClient.movePage(pageId, parentId);
|
|
25753
|
+
}
|
|
24282
25754
|
}
|
|
24283
25755
|
this.stateDb.transaction(() => {
|
|
24284
25756
|
this.stateDb.upsert({
|
|
@@ -24441,7 +25913,8 @@ var PagePusher = class {
|
|
|
24441
25913
|
direction: "push",
|
|
24442
25914
|
path: selectedPath,
|
|
24443
25915
|
filePath: path,
|
|
24444
|
-
parentMode: "database"
|
|
25916
|
+
parentMode: "database",
|
|
25917
|
+
anchors: await this.anchorLookup(content, null)
|
|
24445
25918
|
});
|
|
24446
25919
|
const placeholder2 = this.stateDb.upsert({
|
|
24447
25920
|
obsidianPath: path,
|
|
@@ -24618,7 +26091,7 @@ var PagePusher = class {
|
|
|
24618
26091
|
}
|
|
24619
26092
|
const remote = await this.livePage(record);
|
|
24620
26093
|
const mapper = await this.rowSchemas.mapperFor(databaseId, dataSourceIdOf(remote));
|
|
24621
|
-
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);
|
|
24622
26095
|
const against = options.overwriteRemote ? this.remoteRowState(mapper, remote) : this.baseRowState(record);
|
|
24623
26096
|
const title = noteTitle(current.data, path);
|
|
24624
26097
|
const changes = diffRowProperties(
|
|
@@ -24656,8 +26129,10 @@ var PagePusher = class {
|
|
|
24656
26129
|
direction: "push",
|
|
24657
26130
|
path: selectedPath,
|
|
24658
26131
|
filePath: path,
|
|
24659
|
-
parentMode: "database"
|
|
26132
|
+
parentMode: "database",
|
|
26133
|
+
anchors: await this.anchorLookup(content, pageId)
|
|
24660
26134
|
});
|
|
26135
|
+
this.journal.begin(record.id, computeHash(content));
|
|
24661
26136
|
const pushed = await this.pushUpdatePage(pageId, conversionResult.content, path);
|
|
24662
26137
|
bodyFingerprint = pushed.fingerprint;
|
|
24663
26138
|
let touched = await this.fillDeferredBlocks(
|
|
@@ -24707,6 +26182,7 @@ var PagePusher = class {
|
|
|
24707
26182
|
const hash = computeHash(snapshot);
|
|
24708
26183
|
const fileStat = unsent.length > 0 ? null : await this.vaultFs.getFileStat(path);
|
|
24709
26184
|
this.stateDb.transaction(() => {
|
|
26185
|
+
this.journal.finish(record.id);
|
|
24710
26186
|
this.stateDb.updateHash(record.id, hash, Buffer.from(snapshot, "utf-8"));
|
|
24711
26187
|
this.stateDb.updateStatus(record.id, "synced");
|
|
24712
26188
|
if (written !== null && settles) {
|
|
@@ -24883,11 +26359,13 @@ var PagePusher = class {
|
|
|
24883
26359
|
}
|
|
24884
26360
|
}
|
|
24885
26361
|
/**
|
|
24886
|
-
* `.base` 가 나타내는 Notion DB id 들 — 옆 사이드카(`<이름>.notion.json`)의
|
|
24887
|
-
* 원본으로 둔 링크드 뷰 컨테이너(pull
|
|
24888
|
-
* 없거나 읽지 못하면 빈 목록 — 호출측이 제목으로 맞춘다.
|
|
26362
|
+
* `.base` 가 나타내는 Notion DB id 들 — 링크드 뷰의 `.base` 면 그 컨테이너. 아니면 옆 사이드카(`<이름>.notion.json`)의
|
|
26363
|
+
* DB 와, 그 DB 를 원본으로 둔 링크드 뷰 컨테이너(예전 pull 은 링크드 뷰를 원본의 `.base` 로 몰아 넣었다 — 그 노트를
|
|
26364
|
+
* 올릴 때도 링크드 뷰 블록을 지킨다). 사이드카가 없거나 읽지 못하면 빈 목록 — 호출측이 제목으로 맞춘다.
|
|
24889
26365
|
*/
|
|
24890
26366
|
async databaseIdsOfBase(basePath) {
|
|
26367
|
+
const container = new DbBaseFiles(this.stateDb).linkedContainerAt(basePath);
|
|
26368
|
+
if (container) return [container];
|
|
24891
26369
|
const sidecarPath = basePath.slice(0, -BASE_SUFFIX.length) + SIDECAR_SUFFIX;
|
|
24892
26370
|
let databaseId;
|
|
24893
26371
|
try {
|
|
@@ -24898,7 +26376,7 @@ var PagePusher = class {
|
|
|
24898
26376
|
if (typeof databaseId !== "string") return [];
|
|
24899
26377
|
const own = compactNotionId(databaseId);
|
|
24900
26378
|
const made = loadCreatedDatabases(this.stateDb).get(own)?.databaseId;
|
|
24901
|
-
const linked = [...loadLinkedDbMap(this.stateDb)].filter(([, original]) => original === own).map(([
|
|
26379
|
+
const linked = [...loadLinkedDbMap(this.stateDb)].filter(([, original]) => original === own).map(([container2]) => container2);
|
|
24902
26380
|
return [own, ...made ? [compactNotionId(made)] : [], ...linked];
|
|
24903
26381
|
}
|
|
24904
26382
|
/**
|
|
@@ -24992,69 +26470,13 @@ function blockModeBody(markdownContent) {
|
|
|
24992
26470
|
function withoutDecorations(skipped) {
|
|
24993
26471
|
return skipped.filter((key) => key !== "icon" && key !== "cover");
|
|
24994
26472
|
}
|
|
24995
|
-
|
|
24996
|
-
|
|
24997
|
-
var NOTION_ID_WIKILINK_RE = /\[\[notion:([a-f0-9]{32}|[a-f0-9-]{36})(?:(\\?\|)([^[\]]+))?\]\]/g;
|
|
24998
|
-
var compactId = (id) => id.replace(/-/g, "");
|
|
24999
|
-
function resolveNotionIdWikilinks(markdown, lookup) {
|
|
25000
|
-
let resolved = 0;
|
|
25001
|
-
const out = markdown.replace(
|
|
25002
|
-
NOTION_ID_WIKILINK_RE,
|
|
25003
|
-
(match, id, separator, rawAlias) => {
|
|
25004
|
-
const path = lookup(id);
|
|
25005
|
-
if (!path) return match;
|
|
25006
|
-
resolved++;
|
|
25007
|
-
const base = wikilinkTitleFromPath(path);
|
|
25008
|
-
const alias = rawAlias === compactId(id) ? void 0 : rawAlias;
|
|
25009
|
-
const link = formatWikilink(base, alias);
|
|
25010
|
-
return separator === "\\|" ? link.replace("|", separator) : link;
|
|
25011
|
-
}
|
|
25012
|
-
);
|
|
25013
|
-
return { markdown: out, resolved };
|
|
25014
|
-
}
|
|
25015
|
-
function degradeUnresolvedNotionIdWikilinks(markdown, titles = /* @__PURE__ */ new Map()) {
|
|
25016
|
-
let degraded = 0;
|
|
25017
|
-
const out = markdown.replace(
|
|
25018
|
-
NOTION_ID_WIKILINK_RE,
|
|
25019
|
-
(_match, id, _separator, alias) => {
|
|
25020
|
-
degraded++;
|
|
25021
|
-
const compact = compactId(id);
|
|
25022
|
-
return `[${alias ?? (titles.get(compact) || compact)}](https://www.notion.so/${compact})`;
|
|
25023
|
-
}
|
|
25024
|
-
);
|
|
25025
|
-
return { markdown: out, degraded };
|
|
25026
|
-
}
|
|
25027
|
-
function unlabeledNotionIds(markdown) {
|
|
25028
|
-
const ids = /* @__PURE__ */ new Set();
|
|
25029
|
-
for (const m of markdown.matchAll(NOTION_ID_WIKILINK_RE)) {
|
|
25030
|
-
if (m[3] === void 0) ids.add(compactId(m[1]));
|
|
25031
|
-
}
|
|
25032
|
-
return [...ids];
|
|
25033
|
-
}
|
|
25034
|
-
function linkLabelOfTitle(title) {
|
|
25035
|
-
return title.replace(/[\r\n]+/g, " ").trim().replace(/\[/g, "(").replace(/\]/g, ")").replace(/[\\*_`~]/g, "\\$&");
|
|
25036
|
-
}
|
|
25037
|
-
var NOTION_RELATIVE_PAGE_LINK_RE = /\[([^\]]+)\]\(\/(?:p\/)?([a-f0-9]{32})(?:\?[^)#]*)?(?:#([^)]*))?\)/g;
|
|
25038
|
-
function resolveNotionRelativePageLinks(markdown, lookup) {
|
|
25039
|
-
let resolved = 0;
|
|
25040
|
-
const out = markdown.replace(NOTION_RELATIVE_PAGE_LINK_RE, (match, label, id) => {
|
|
25041
|
-
const path = lookup(id);
|
|
25042
|
-
if (!path) return match;
|
|
25043
|
-
resolved++;
|
|
25044
|
-
return formatWikilink(wikilinkTitleFromPath(path), label);
|
|
25045
|
-
});
|
|
25046
|
-
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}`;
|
|
25047
26475
|
}
|
|
25048
|
-
function
|
|
25049
|
-
|
|
25050
|
-
const
|
|
25051
|
-
|
|
25052
|
-
(_match, label, id, anchor) => {
|
|
25053
|
-
degraded++;
|
|
25054
|
-
return `[${label}](https://www.notion.so/${id}${anchor === void 0 ? "" : `#${anchor}`})`;
|
|
25055
|
-
}
|
|
25056
|
-
);
|
|
25057
|
-
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);
|
|
25058
26480
|
}
|
|
25059
26481
|
|
|
25060
26482
|
// src/sync/notion-parent.ts
|
|
@@ -25127,6 +26549,15 @@ var PagePuller = class {
|
|
|
25127
26549
|
claimTurns = /* @__PURE__ */ new Map();
|
|
25128
26550
|
// Notion 에서 사라져 볼트에서 지운 페이지의 하위 폴더 — pull 이 마칠 때 비었으면 지운다({@link removeVacatedFolders}).
|
|
25129
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
|
+
}
|
|
25130
26561
|
decorationsCache;
|
|
25131
26562
|
get decorations() {
|
|
25132
26563
|
return this.decorationsCache ??= new PageDecorations(this.notionClient, this.imageHandler);
|
|
@@ -25142,6 +26573,7 @@ var PagePuller = class {
|
|
|
25142
26573
|
this._pullImageCount = 0;
|
|
25143
26574
|
this._pullFileCount = 0;
|
|
25144
26575
|
this.vacatedFolders.clear();
|
|
26576
|
+
this.unlinkedVault = !this.stateDb.getAll().some((record) => record.notionPageId !== null);
|
|
25145
26577
|
}
|
|
25146
26578
|
/**
|
|
25147
26579
|
* 이번 pull 이 지운 페이지의 폴더가 비었으면 지운다 — 안쪽부터, 그래야 바깥이 빈다. 예전에는 노트만 지우고
|
|
@@ -25261,58 +26693,147 @@ var PagePuller = class {
|
|
|
25261
26693
|
const fetched = await this.fetchPageMarkdown(pageId);
|
|
25262
26694
|
const hasChildPages = this.hasChildContainers(pageId, fetched.children);
|
|
25263
26695
|
if (hasChildPages && !fetched.hasBody) {
|
|
25264
|
-
return
|
|
25265
|
-
|
|
25266
|
-
|
|
25267
|
-
}
|
|
25268
|
-
|
|
25269
|
-
|
|
25270
|
-
|
|
25271
|
-
|
|
25272
|
-
filePath = await this.resolveUniqueFilePath(folderPath, safeName, pageId);
|
|
25273
|
-
fileType = "folder-note";
|
|
25274
|
-
await this.vaultFs.ensureFolder(folderPath);
|
|
25275
|
-
} else {
|
|
25276
|
-
filePath = await this.resolveUniqueFilePath(parentPath, safeName, pageId);
|
|
25277
|
-
fileType = "file";
|
|
25278
|
-
}
|
|
25279
|
-
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({
|
|
25280
26704
|
pageId,
|
|
25281
26705
|
page,
|
|
25282
26706
|
fetched,
|
|
25283
|
-
filePath,
|
|
26707
|
+
filePath: path,
|
|
25284
26708
|
title,
|
|
25285
|
-
titleFollowsName: extractTitle(
|
|
25286
|
-
downloadMedia: true
|
|
26709
|
+
titleFollowsName: extractTitle(path) === title,
|
|
26710
|
+
downloadMedia: true,
|
|
26711
|
+
...local !== void 0 ? { local: { content: local, markers: [] }, adoptLocalMedia: true } : {}
|
|
25287
26712
|
});
|
|
25288
26713
|
const resolvedParentId = await extractParentId(this.notionClient, page);
|
|
25289
|
-
|
|
25290
|
-
|
|
25291
|
-
|
|
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);
|
|
25292
26815
|
this.stateDb.transaction(() => {
|
|
25293
26816
|
this.stateDb.upsert({
|
|
25294
26817
|
obsidianPath: filePath,
|
|
25295
|
-
notionPageId: pageId,
|
|
25296
|
-
notionParentId:
|
|
25297
|
-
contentHash:
|
|
25298
|
-
...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),
|
|
25299
26822
|
localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
25300
26823
|
syncDirection: "both",
|
|
25301
|
-
fileType,
|
|
25302
|
-
status:
|
|
25303
|
-
baseSnapshot: Buffer.from(
|
|
26824
|
+
fileType: note.fileType,
|
|
26825
|
+
status: input.status,
|
|
26826
|
+
baseSnapshot: input.snapshot === null ? null : Buffer.from(input.content, "utf-8"),
|
|
25304
26827
|
localMtime: pullStat?.mtime ?? null,
|
|
25305
26828
|
localFileSize: pullStat?.size ?? null
|
|
25306
26829
|
});
|
|
25307
|
-
const aliases = extractAliases(properties);
|
|
25308
26830
|
this.stateDb.upsertWikilink({
|
|
25309
26831
|
obsidianPath: filePath,
|
|
25310
|
-
notionPageId: pageId,
|
|
25311
|
-
title,
|
|
25312
|
-
aliases
|
|
26832
|
+
notionPageId: note.pageId,
|
|
26833
|
+
title: note.title,
|
|
26834
|
+
aliases: extractAliases(input.properties)
|
|
25313
26835
|
});
|
|
25314
26836
|
});
|
|
25315
|
-
return { path: filePath };
|
|
25316
26837
|
}
|
|
25317
26838
|
/**
|
|
25318
26839
|
* 자식 말고 본문이 없는 페이지는 폴더로만 받는다 — 노트를 쓰지 않고 폴더 경로를 추적한다(폴더 레코드).
|
|
@@ -25323,9 +26844,10 @@ var PagePuller = class {
|
|
|
25323
26844
|
*/
|
|
25324
26845
|
async createFolderOnly(pageId, page, fingerprint, parentPath, safeName) {
|
|
25325
26846
|
const folderPath = stripMarkdownExtension(
|
|
25326
|
-
await this.resolveUniqueFilePath(parentPath, safeName, pageId)
|
|
26847
|
+
(await this.resolveUniqueFilePath(parentPath, safeName, pageId)).path
|
|
25327
26848
|
);
|
|
25328
26849
|
const resolvedParentId = await extractParentId(this.notionClient, page);
|
|
26850
|
+
const existed = await this.vaultFs.exists(folderPath);
|
|
25329
26851
|
await this.vaultFs.ensureFolder(folderPath);
|
|
25330
26852
|
this.stateDb.upsert({
|
|
25331
26853
|
obsidianPath: folderPath,
|
|
@@ -25338,7 +26860,39 @@ var PagePuller = class {
|
|
|
25338
26860
|
fileType: "folder-only",
|
|
25339
26861
|
status: "synced"
|
|
25340
26862
|
});
|
|
25341
|
-
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 };
|
|
25342
26896
|
}
|
|
25343
26897
|
async pullUpdate(change) {
|
|
25344
26898
|
let record = this.stateDb.getByNotionId(change.pageId);
|
|
@@ -25363,7 +26917,7 @@ var PagePuller = class {
|
|
|
25363
26917
|
localContent = "";
|
|
25364
26918
|
localExists = await this.vaultFs.exists(record.obsidianPath);
|
|
25365
26919
|
}
|
|
25366
|
-
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));
|
|
25367
26921
|
const resolution = resolvePullConflict({
|
|
25368
26922
|
record,
|
|
25369
26923
|
localContent,
|
|
@@ -25384,6 +26938,32 @@ var PagePuller = class {
|
|
|
25384
26938
|
this.stateDb.updateStatus(record.id, "conflict");
|
|
25385
26939
|
return { action: "conflict", path: record.obsidianPath, conflict: resolution.conflict };
|
|
25386
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
|
+
}
|
|
25387
26967
|
if (localExists && sameNoteContent(remoteContent, localContent)) {
|
|
25388
26968
|
const stat2 = await this.vaultFs.getFileStat(record.obsidianPath);
|
|
25389
26969
|
this.stateDb.upsert({
|
|
@@ -25507,7 +27087,7 @@ var PagePuller = class {
|
|
|
25507
27087
|
properties = this.propertyMapper.fromNotionProperties(raw);
|
|
25508
27088
|
notionKeys = [...this.propertyMapper.ownedKeys(raw), "title", ...DECORATION_KEYS];
|
|
25509
27089
|
} else {
|
|
25510
|
-
properties = this.notionClient.extractProperties(page);
|
|
27090
|
+
properties = { ...this.notionClient.extractProperties(page) };
|
|
25511
27091
|
}
|
|
25512
27092
|
if (isDatabaseMode(this.config) || !input.titleFollowsName) {
|
|
25513
27093
|
properties.title = title;
|
|
@@ -25519,7 +27099,8 @@ var PagePuller = class {
|
|
|
25519
27099
|
let markdown = await this.imageHandler.restoreUploadedMedia(
|
|
25520
27100
|
input.fetched.content,
|
|
25521
27101
|
pageId,
|
|
25522
|
-
filePath
|
|
27102
|
+
filePath,
|
|
27103
|
+
input.adoptLocalMedia ? input.local?.content : void 0
|
|
25523
27104
|
);
|
|
25524
27105
|
if (input.downloadMedia && this.config.conversion.imageDownload === "immediate") {
|
|
25525
27106
|
const imageResult = await this.imageHandler.downloadAllImages(markdown, title, pageId);
|
|
@@ -25564,8 +27145,11 @@ var PagePuller = class {
|
|
|
25564
27145
|
this.discovery.collectInlineDbRefs(pageId, result.markdown);
|
|
25565
27146
|
this.detector.noteBodyChildren(pageId, result.markdown);
|
|
25566
27147
|
return {
|
|
25567
|
-
content: this.
|
|
25568
|
-
|
|
27148
|
+
content: await this.resolveBlockLinks(
|
|
27149
|
+
this.resolveNotionIdWikilinks(
|
|
27150
|
+
await notionBodyToObsidian(this.notionClient, result.markdown, pageId)
|
|
27151
|
+
),
|
|
27152
|
+
pageId
|
|
25569
27153
|
),
|
|
25570
27154
|
// 압축형 판정은 반드시 원시 export 기준 — enhanced 변환이 <empty-block/> 을
|
|
25571
27155
|
// 빈 줄로 바꾼 뒤에는 BlockSpacer 가 저작형과 구분할 수 없다(D1).
|
|
@@ -25584,7 +27168,10 @@ var PagePuller = class {
|
|
|
25584
27168
|
this.discovery.collectInlineDbRefs(pageId, fallback.markdown);
|
|
25585
27169
|
this.detector.noteBodyChildren(pageId, fallback.markdown);
|
|
25586
27170
|
return {
|
|
25587
|
-
content: this.
|
|
27171
|
+
content: await this.resolveBlockLinks(
|
|
27172
|
+
this.resolveNotionIdWikilinks(fallback.content),
|
|
27173
|
+
pageId
|
|
27174
|
+
),
|
|
25588
27175
|
compact: isCompactExport(fallback.markdown),
|
|
25589
27176
|
children: extractChildTags(fallback.markdown),
|
|
25590
27177
|
fingerprint: null,
|
|
@@ -25723,15 +27310,38 @@ var PagePuller = class {
|
|
|
25723
27310
|
* 파일 1개·레코드 1건). 그래서 마지막 확인과 등록을 **await 없는 동기 구간**에 묶는다 —
|
|
25724
27311
|
* 그 사이에는 다른 작업이 끼어들 수 없으므로 두 페이지가 같은 경로를 얻는 일이 없다.
|
|
25725
27312
|
*/
|
|
25726
|
-
async resolveUniqueFilePath(dir, safeName, pageId) {
|
|
27313
|
+
async resolveUniqueFilePath(dir, safeName, pageId, options) {
|
|
25727
27314
|
const turn = this.claimTurns.get(normalizeNotionId(pageId));
|
|
25728
27315
|
await turn?.previous;
|
|
25729
27316
|
try {
|
|
25730
|
-
|
|
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) };
|
|
25731
27322
|
} finally {
|
|
25732
27323
|
turn?.release();
|
|
25733
27324
|
}
|
|
25734
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
|
+
}
|
|
25735
27345
|
async claimFilePath(dir, safeName, pageId) {
|
|
25736
27346
|
const candidates = pagePathCandidates(dir, safeName, pageId);
|
|
25737
27347
|
for (const candidate of candidates) {
|
|
@@ -25762,6 +27372,17 @@ var PagePuller = class {
|
|
|
25762
27372
|
(id) => this.stateDb.getByNotionId(normalizeNotionId(id))?.obsidianPath ?? null
|
|
25763
27373
|
).markdown;
|
|
25764
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
|
+
}
|
|
25765
27386
|
async ensureDbSchema() {
|
|
25766
27387
|
if (this.dbSchemaLoaded || !isDatabaseMode(this.config)) return;
|
|
25767
27388
|
const schema = await this.notionClient.getDatabaseSchema(this.config.notion.databaseId);
|
|
@@ -25811,7 +27432,7 @@ function resolveFrontmatterRelations(content, idToTitle) {
|
|
|
25811
27432
|
}
|
|
25812
27433
|
|
|
25813
27434
|
// src/sync/notion-link-pass.ts
|
|
25814
|
-
async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle) {
|
|
27435
|
+
async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle, blockAnchors) {
|
|
25815
27436
|
let totalResolved = 0;
|
|
25816
27437
|
let totalDegraded = 0;
|
|
25817
27438
|
const titles = /* @__PURE__ */ new Map();
|
|
@@ -25846,6 +27467,21 @@ async function resolveNotionLinks(stateDb, vaultFs, paths, pageTitle) {
|
|
|
25846
27467
|
totalResolved += urlPass.resolved;
|
|
25847
27468
|
changed = true;
|
|
25848
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
|
+
}
|
|
25849
27485
|
const fm = resolveFrontmatterRelations(content, idToTitle);
|
|
25850
27486
|
if (fm.count > 0) {
|
|
25851
27487
|
content = fm.content;
|
|
@@ -25931,19 +27567,38 @@ var PullPlanner = class {
|
|
|
25931
27567
|
async planPull(filtered, restoreChanges, restoreIds, localPlan, options, startTime, scan, ledger) {
|
|
25932
27568
|
const unchangedDropped = await this.drift.withoutUnchangedRemotes(filtered, options.signal);
|
|
25933
27569
|
const planned = [];
|
|
27570
|
+
const failed = [];
|
|
27571
|
+
const conflicts = [];
|
|
27572
|
+
const mergedPaths = [];
|
|
25934
27573
|
for (const change of unchangedDropped) {
|
|
25935
27574
|
if (change.type === "deleted" && !await this.plannedRemoteDeletion(change)) continue;
|
|
25936
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
|
+
}
|
|
25937
27588
|
planned.push(change);
|
|
25938
27589
|
}
|
|
25939
|
-
const failed = [];
|
|
25940
27590
|
const queued = new Set(
|
|
25941
27591
|
[...filtered, ...restoreChanges].map((change) => compactNotionId(change.pageId))
|
|
25942
27592
|
);
|
|
25943
27593
|
const movingInVault = new Set(localPlan?.scan.adoptions.map((adoption) => adoption.record.id));
|
|
25944
|
-
const rows =
|
|
25945
|
-
|
|
25946
|
-
|
|
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
|
+
}
|
|
25947
27602
|
const items = [];
|
|
25948
27603
|
const plannedPath = this.planner.plannedPaths(localPlan);
|
|
25949
27604
|
for (const change of [...planned, ...restoreChanges]) {
|
|
@@ -25977,7 +27632,8 @@ var PullPlanner = class {
|
|
|
25977
27632
|
restored: restoreChanges.length + restoredModified + rowCount("update", true),
|
|
25978
27633
|
...restoredPaths.length > 0 ? { restoredPaths } : {},
|
|
25979
27634
|
...movedRows.length > 0 ? { moved: movedRows.flatMap((row) => row.moves) } : {},
|
|
25980
|
-
conflicts
|
|
27635
|
+
conflicts,
|
|
27636
|
+
...mergedPaths.length > 0 ? { mergedPaths } : {},
|
|
25981
27637
|
writtenPaths: [],
|
|
25982
27638
|
failed,
|
|
25983
27639
|
duration: Date.now() - startTime,
|
|
@@ -26007,6 +27663,22 @@ var PullPlanner = class {
|
|
|
26007
27663
|
return true;
|
|
26008
27664
|
}
|
|
26009
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
|
+
}
|
|
26010
27682
|
/** 원격에서 지운 페이지를 실제 pull 이 볼트에서 지우는가 — `PagePuller.pullDelete` 와 같게 가른다. */
|
|
26011
27683
|
async plannedRemoteDeletion(change) {
|
|
26012
27684
|
const record = this.stateDb.getByNotionId(change.pageId);
|
|
@@ -26731,7 +28403,7 @@ async function detectMissingLocalFiles(config, stateDb, vaultFs, paths) {
|
|
|
26731
28403
|
}
|
|
26732
28404
|
var LAST_SYNC_META_KEY = "last_sync_at";
|
|
26733
28405
|
var SyncOrchestrator = class {
|
|
26734
|
-
constructor(config, stateDb, notionClient, vaultFs, customFetch) {
|
|
28406
|
+
constructor(config, stateDb, notionClient, vaultFs, customFetch, outside) {
|
|
26735
28407
|
this.config = config;
|
|
26736
28408
|
this.stateDb = stateDb;
|
|
26737
28409
|
this.notionClient = notionClient;
|
|
@@ -26864,7 +28536,7 @@ var SyncOrchestrator = class {
|
|
|
26864
28536
|
async (pageId) => this.detector.discoveredPage(pageId) ?? await notionClient.getPage(pageId),
|
|
26865
28537
|
config.notion.databases ?? []
|
|
26866
28538
|
);
|
|
26867
|
-
this.linkUpdater = new LinkUpdater(stateDb, vaultFs);
|
|
28539
|
+
this.linkUpdater = new LinkUpdater(stateDb, vaultFs, outside);
|
|
26868
28540
|
this.puller = new PagePuller(
|
|
26869
28541
|
config,
|
|
26870
28542
|
stateDb,
|
|
@@ -26980,7 +28652,15 @@ var SyncOrchestrator = class {
|
|
|
26980
28652
|
const foreign = this.stateDb.getAll().map((record) => record.obsidianPath).filter((path) => !vaultFs.ownsPath(path));
|
|
26981
28653
|
if (foreign.length > 0) throw new ConnectionScopeError(foreign);
|
|
26982
28654
|
}
|
|
26983
|
-
|
|
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) {
|
|
26984
28664
|
const startTime = Date.now();
|
|
26985
28665
|
this.assertConnectionScope();
|
|
26986
28666
|
if (this.config.sync.direction === "pull") {
|
|
@@ -27011,13 +28691,25 @@ var SyncOrchestrator = class {
|
|
|
27011
28691
|
const conflictPaths = new Set(this.stateDb.getByStatus("conflict").map((r) => r.obsidianPath));
|
|
27012
28692
|
const eligible = options?.force ? changes : changes.filter((c) => !conflictPaths.has(c.path));
|
|
27013
28693
|
const excludeSet = options?.excludePaths ? new Set(options.excludePaths) : null;
|
|
27014
|
-
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
|
+
}));
|
|
27015
28707
|
const folderMoves = this.planner.pendingFolderMoves(plan).filter((move) => inAnyPathScope(move.to, options?.paths));
|
|
27016
28708
|
const inScope = (path) => inAnyPathScope(path, options?.paths) && !excludeSet?.has(path);
|
|
27017
28709
|
const sidecarPlan = await this.sidecarEdits.plan(inScope);
|
|
27018
28710
|
const plannedEdits = sidecarPlan.planned.map((edit) => edit.edit);
|
|
27019
28711
|
const viewPlan = await this.viewEdits.plan(inScope);
|
|
27020
|
-
const plannedViews = viewPlan.planned.flatMap(
|
|
28712
|
+
const plannedViews = viewPlan.planned.flatMap(plannedViewEdits);
|
|
27021
28713
|
const preparedDatabases = loadPreparedDatabases(this.stateDb).size > 0;
|
|
27022
28714
|
const recordedLookup = this.placement.folderLookup();
|
|
27023
28715
|
const databaseMoves = mergeDatabaseMoves(
|
|
@@ -27043,7 +28735,13 @@ var SyncOrchestrator = class {
|
|
|
27043
28735
|
updated: 0,
|
|
27044
28736
|
deleted: 0,
|
|
27045
28737
|
moved: 0,
|
|
27046
|
-
failed: [
|
|
28738
|
+
failed: [
|
|
28739
|
+
...unverifiedFailures,
|
|
28740
|
+
...sidecarPlan.refused,
|
|
28741
|
+
...edited2.failed,
|
|
28742
|
+
...viewPlan.refused,
|
|
28743
|
+
...viewed2.failed
|
|
28744
|
+
],
|
|
27047
28745
|
duration: Date.now() - startTime,
|
|
27048
28746
|
...edited2.applied.length > 0 ? { schemaEdits: edited2.applied } : {},
|
|
27049
28747
|
...viewed2.applied.length > 0 ? { viewEdits: viewed2.applied } : {}
|
|
@@ -27076,7 +28774,12 @@ var SyncOrchestrator = class {
|
|
|
27076
28774
|
const applicable = placeable2.filter((c) => !renameRefused.has(c.path));
|
|
27077
28775
|
const plannedRenames = renames.planned.map(({ folder, from, to }) => ({ folder, from, to }));
|
|
27078
28776
|
if (options?.dryRun) {
|
|
27079
|
-
|
|
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));
|
|
27080
28783
|
const items = [
|
|
27081
28784
|
...movableFolders.map(({ to }) => ({ path: to, operation: "move" })),
|
|
27082
28785
|
...planned.map((change) => ({ path: change.path, operation: pushOperationOf(change) }))
|
|
@@ -27107,7 +28810,12 @@ var SyncOrchestrator = class {
|
|
|
27107
28810
|
updated: count("update") - convertedUpdates,
|
|
27108
28811
|
deleted: count("delete"),
|
|
27109
28812
|
moved: count("move") + preparedConversions.length + movableDatabases.length,
|
|
27110
|
-
failed: [
|
|
28813
|
+
failed: [
|
|
28814
|
+
...unverifiedFailures,
|
|
28815
|
+
...refusedFailures,
|
|
28816
|
+
...refusedDatabaseMoves,
|
|
28817
|
+
...remoteRefusals
|
|
28818
|
+
],
|
|
27111
28819
|
duration: Date.now() - startTime,
|
|
27112
28820
|
...unsentDeletions2.length > 0 ? { unsentDeletions: unsentDeletions2 } : {},
|
|
27113
28821
|
...readOnlySkipped.length > 0 ? { readOnlySkipped } : {},
|
|
@@ -27121,7 +28829,7 @@ var SyncOrchestrator = class {
|
|
|
27121
28829
|
this.stateDb.setMeta(PUSH_IN_PROGRESS_META_KEY, "true");
|
|
27122
28830
|
await this.observation.resolveBotUserId();
|
|
27123
28831
|
const counts = { created: 0, updated: 0, deleted: 0, moved: 0 };
|
|
27124
|
-
const failed = [...refusedFailures];
|
|
28832
|
+
const failed = [...unverifiedFailures, ...refusedFailures];
|
|
27125
28833
|
const edited = await this.sidecarEdits.apply(sidecarPlan.planned);
|
|
27126
28834
|
failed.push(...edited.failed);
|
|
27127
28835
|
const viewed = await this.viewEdits.apply(viewPlan.planned);
|
|
@@ -27416,8 +29124,10 @@ var SyncOrchestrator = class {
|
|
|
27416
29124
|
const startTime = Date.now();
|
|
27417
29125
|
this.assertConnectionScope();
|
|
27418
29126
|
this.puller.beginPull();
|
|
29127
|
+
this.databaseSyncer.beginPull(this.puller.unlinkedVaultAtStart);
|
|
27419
29128
|
this.rowSchemas.clear();
|
|
27420
29129
|
this.databaseSyncer.takeRestoredRows();
|
|
29130
|
+
this.databaseSyncer.takeMergedRows();
|
|
27421
29131
|
this.databaseSyncer.takeSchemaFollowed();
|
|
27422
29132
|
this.databaseSyncer.takeHeldSidecars();
|
|
27423
29133
|
this.databaseSyncer.takeHeldBases();
|
|
@@ -27457,6 +29167,8 @@ var SyncOrchestrator = class {
|
|
|
27457
29167
|
const conflicts = [];
|
|
27458
29168
|
const writtenPaths = [];
|
|
27459
29169
|
const restoredPaths = [];
|
|
29170
|
+
const mergedPaths = [];
|
|
29171
|
+
const relinkedPaths = [];
|
|
27460
29172
|
const excluded = [];
|
|
27461
29173
|
const failed = [];
|
|
27462
29174
|
let detected = [];
|
|
@@ -27503,10 +29215,15 @@ var SyncOrchestrator = class {
|
|
|
27503
29215
|
await this.puller.removeVacatedFolders();
|
|
27504
29216
|
const renamedRows = this.databaseSyncer.takeRenamedRows();
|
|
27505
29217
|
restoredPaths.push(...this.databaseSyncer.takeRestoredRows());
|
|
29218
|
+
mergedPaths.push(...this.databaseSyncer.takeMergedRows());
|
|
29219
|
+
relinkedPaths.push(...this.databaseSyncer.takeRelinkedRows());
|
|
27506
29220
|
const schemaFollowed = this.databaseSyncer.takeSchemaFollowed();
|
|
27507
29221
|
const heldSidecars = this.databaseSyncer.takeHeldSidecars();
|
|
27508
29222
|
const heldBases = this.databaseSyncer.takeHeldBases();
|
|
27509
|
-
const relinked =
|
|
29223
|
+
const relinked = [
|
|
29224
|
+
...await this.linkUpdater.apply([...relocated.applied, ...renamedRows.applied]),
|
|
29225
|
+
...await this.linkUpdater.dropEmbeds(this.databaseSyncer.takeRetiredBases())
|
|
29226
|
+
];
|
|
27510
29227
|
const linkCount = writtenPaths.length > 0 ? await this.resolveNotionLinks(writtenPaths) : 0;
|
|
27511
29228
|
if (!options?.paths) {
|
|
27512
29229
|
const startedAt = new Date(startTime).toISOString();
|
|
@@ -27531,6 +29248,8 @@ var SyncOrchestrator = class {
|
|
|
27531
29248
|
deleted,
|
|
27532
29249
|
restored,
|
|
27533
29250
|
...restoredPaths.length > 0 ? { restoredPaths } : {},
|
|
29251
|
+
...mergedPaths.length > 0 ? { mergedPaths } : {},
|
|
29252
|
+
...relinkedPaths.length > 0 ? { relinkedPaths } : {},
|
|
27534
29253
|
...schemaFollowed.length > 0 ? { schemaFollowed } : {},
|
|
27535
29254
|
...heldSidecars.length > 0 ? { heldSidecars } : {},
|
|
27536
29255
|
...heldBases.length > 0 ? { heldBases } : {},
|
|
@@ -27603,7 +29322,8 @@ var SyncOrchestrator = class {
|
|
|
27603
29322
|
},
|
|
27604
29323
|
moved: [...relocated.moved, ...plan.moved ?? []],
|
|
27605
29324
|
moveSkipped: relocated.skipped,
|
|
27606
|
-
excluded: [...exclusions.values()]
|
|
29325
|
+
excluded: [...exclusions.values()],
|
|
29326
|
+
...this.puller.unlinkedVaultAtStart && plan.created > 0 ? { relinkPending: true } : {}
|
|
27607
29327
|
};
|
|
27608
29328
|
}
|
|
27609
29329
|
let pullTotal = workItems.length;
|
|
@@ -27685,6 +29405,14 @@ var SyncOrchestrator = class {
|
|
|
27685
29405
|
excluded.push(created.path);
|
|
27686
29406
|
return { path: created.path, unchanged: true };
|
|
27687
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
|
+
}
|
|
27688
29416
|
writtenPaths.push(created.path);
|
|
27689
29417
|
counts.created++;
|
|
27690
29418
|
return { path: created.path, unchanged: false };
|
|
@@ -27693,6 +29421,9 @@ var SyncOrchestrator = class {
|
|
|
27693
29421
|
const outcome = await this.puller.pullUpdate(change);
|
|
27694
29422
|
if (outcome.action === "conflict") {
|
|
27695
29423
|
conflicts.push(outcome.conflict);
|
|
29424
|
+
} else if (outcome.action === "merged") {
|
|
29425
|
+
counts.updated++;
|
|
29426
|
+
mergedPaths.push(outcome.path);
|
|
27696
29427
|
} else if (outcome.action === "written") {
|
|
27697
29428
|
writtenPaths.push(outcome.path);
|
|
27698
29429
|
if (restoreIds.has(change.pageId)) {
|
|
@@ -27803,12 +29534,17 @@ var SyncOrchestrator = class {
|
|
|
27803
29534
|
const onProgress = options?.onProgress;
|
|
27804
29535
|
const tagged = (direction) => onProgress && ((current, total, item) => onProgress(current, total, { ...item, direction }));
|
|
27805
29536
|
const pullResult = await this.executePull({ ...options, onProgress: tagged("pull") });
|
|
27806
|
-
const
|
|
27807
|
-
|
|
27808
|
-
|
|
27809
|
-
|
|
27810
|
-
|
|
27811
|
-
|
|
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
|
+
);
|
|
27812
29548
|
return {
|
|
27813
29549
|
pull: pullResult,
|
|
27814
29550
|
push: pushResult,
|
|
@@ -27836,6 +29572,7 @@ var SyncOrchestrator = class {
|
|
|
27836
29572
|
const detected = await this.drift.withoutUnchangedRemotes(detection.changes, signal);
|
|
27837
29573
|
const moves = await this.followRemoteMoves({ dryRun: true }, plan);
|
|
27838
29574
|
const renames = await this.followRemoteRenames(detected, { dryRun: true });
|
|
29575
|
+
const rowRenames = await this.previewRowRenames(detected, signal);
|
|
27839
29576
|
const exclusions = await this.plannedExclusions(detected);
|
|
27840
29577
|
const remoteChanges = detected.filter((change) => !exclusions.has(change.pageId));
|
|
27841
29578
|
const conflictRecords = this.stateDb.getByStatus("conflict");
|
|
@@ -27856,11 +29593,45 @@ var SyncOrchestrator = class {
|
|
|
27856
29593
|
lastSyncAt,
|
|
27857
29594
|
lastFullScanAt,
|
|
27858
29595
|
remoteScan: remoteScanInfo(scan, lastFullScanAt, this.config.sync.deleteSync),
|
|
27859
|
-
remoteMoves: [...moves.moved, ...renames.renamed],
|
|
27860
|
-
remoteMoveSkipped: [...moves.skipped, ...renames.skipped],
|
|
29596
|
+
remoteMoves: [...moves.moved, ...renames.renamed, ...rowRenames.renamed],
|
|
29597
|
+
remoteMoveSkipped: [...moves.skipped, ...renames.skipped, ...rowRenames.skipped],
|
|
27861
29598
|
remoteExcluded: [...exclusions.values()]
|
|
27862
29599
|
};
|
|
27863
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
|
+
}
|
|
27864
29635
|
/**
|
|
27865
29636
|
* 로컬만 본다 — 원격 기준을 정하지 않아 도는 작업과 겹쳐도 된다. 변경은 push 가 쓰는 판정 그대로다
|
|
27866
29637
|
* (옮긴 노트 · 폴더 포함). 상태 DB 는 바꾸지 않는다.
|
|
@@ -27953,7 +29724,8 @@ var SyncOrchestrator = class {
|
|
|
27953
29724
|
this.stateDb,
|
|
27954
29725
|
this.vaultFs,
|
|
27955
29726
|
paths,
|
|
27956
|
-
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)
|
|
27957
29729
|
);
|
|
27958
29730
|
}
|
|
27959
29731
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -28008,6 +29780,19 @@ var SyncOrchestrator = class {
|
|
|
28008
29780
|
async discardLocalChange(path) {
|
|
28009
29781
|
return this.gate.run("discard", () => this.inspector.discardLocalChange(path));
|
|
28010
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
|
+
}
|
|
28011
29796
|
/**
|
|
28012
29797
|
* 볼트에서 만든 노트 폴더를 Notion DB 로 올릴 준비 — frontmatter · `.base` 로 사이드카를 써 둔다(#75).
|
|
28013
29798
|
* Notion 에는 쓰지 않는다. 다음 push 가 DB 를 만들고 노트를 행으로 올린다(ADR-036 · ADR-037).
|
|
@@ -28139,10 +29924,14 @@ function matchesWithin(path, pattern) {
|
|
|
28139
29924
|
|
|
28140
29925
|
// src/sync/node-vault-fs.ts
|
|
28141
29926
|
var NodeVaultFS = class {
|
|
28142
|
-
|
|
29927
|
+
/**
|
|
29928
|
+
* @param ignoreFile 폴더의 `.im-nobsidian-ignore` 를 따른다 — 볼트 전체를 보는 쪽(다른 연결의 노트에서 링크를 고칠 때)은
|
|
29929
|
+
* 그 폴더를 맡은 연결의 제외 목록을 따르지 않는다.
|
|
29930
|
+
*/
|
|
29931
|
+
constructor(rootPath, pathConfig, ignoreFile = true) {
|
|
28143
29932
|
this.rootPath = rootPath;
|
|
28144
29933
|
const configExclude = pathConfig?.exclude ?? [];
|
|
28145
|
-
const ignorePatterns = this.loadImNobsidianIgnore();
|
|
29934
|
+
const ignorePatterns = ignoreFile ? this.loadImNobsidianIgnore() : [];
|
|
28146
29935
|
this.filter = new PathFilter(pathConfig?.include ?? [], [...configExclude, ...ignorePatterns]);
|
|
28147
29936
|
}
|
|
28148
29937
|
rootPath;
|
|
@@ -29381,6 +31170,6 @@ function formatRatio(ratio) {
|
|
|
29381
31170
|
return String(Math.round(ratio * 100) / 100);
|
|
29382
31171
|
}
|
|
29383
31172
|
|
|
29384
|
-
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, 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 };
|
|
29385
31174
|
//# sourceMappingURL=index.js.map
|
|
29386
31175
|
//# sourceMappingURL=index.js.map
|