@im-nobsidian/core 0.1.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/LICENSE +21 -0
- package/dist/index.d.ts +935 -0
- package/dist/index.js +3683 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3683 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { readFile, mkdir, writeFile, unlink, rename, stat, readdir } from 'fs/promises';
|
|
3
|
+
import { join, dirname, relative } from 'path';
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
|
+
import { randomUUID, createHash } from 'crypto';
|
|
6
|
+
import { Client } from '@notionhq/client';
|
|
7
|
+
import { Sema } from 'async-sema';
|
|
8
|
+
import matter from 'gray-matter';
|
|
9
|
+
import { markdownToBlocks } from '@tryfabric/martian';
|
|
10
|
+
import { NotionToMarkdown } from 'notion-to-md';
|
|
11
|
+
import { readFileSync } from 'fs';
|
|
12
|
+
import { watch } from 'chokidar';
|
|
13
|
+
|
|
14
|
+
// src/types/config.ts
|
|
15
|
+
var ConfigSchema = z.object({
|
|
16
|
+
version: z.literal(1),
|
|
17
|
+
notion: z.object({
|
|
18
|
+
token: z.string().startsWith("ntn_"),
|
|
19
|
+
rootPageId: z.string(),
|
|
20
|
+
workspaceId: z.string().optional(),
|
|
21
|
+
parentMode: z.enum(["page", "database"]).default("page"),
|
|
22
|
+
databaseId: z.string().optional()
|
|
23
|
+
}),
|
|
24
|
+
sync: z.object({
|
|
25
|
+
direction: z.enum(["push", "pull", "both"]).default("both"),
|
|
26
|
+
conflictStrategy: z.enum(["local-first", "remote-first", "manual", "duplicate"]).default("manual"),
|
|
27
|
+
autoSync: z.boolean().default(false),
|
|
28
|
+
autoSyncInterval: z.number().min(30).max(3600).default(300),
|
|
29
|
+
deleteSync: z.boolean().default(false)
|
|
30
|
+
}),
|
|
31
|
+
paths: z.object({
|
|
32
|
+
include: z.array(z.string()).default(["**/*"]),
|
|
33
|
+
exclude: z.array(z.string()).default([]),
|
|
34
|
+
attachments: z.string().default("attachments")
|
|
35
|
+
}),
|
|
36
|
+
conversion: z.object({
|
|
37
|
+
preferMarkdownApi: z.boolean().default(true),
|
|
38
|
+
preserveMarkers: z.boolean().default(true),
|
|
39
|
+
frontmatterMapping: z.boolean().default(true),
|
|
40
|
+
imageDownload: z.enum(["immediate", "lazy", "skip"]).default("immediate")
|
|
41
|
+
}),
|
|
42
|
+
advanced: z.object({
|
|
43
|
+
concurrency: z.number().min(1).max(10).default(3),
|
|
44
|
+
maxRetries: z.number().min(0).max(10).default(5),
|
|
45
|
+
timeoutMs: z.number().min(5e3).max(6e4).default(3e4),
|
|
46
|
+
batchSize: z.number().min(1).max(100).default(50)
|
|
47
|
+
})
|
|
48
|
+
});
|
|
49
|
+
var DEFAULT_CONFIG = {
|
|
50
|
+
version: 1,
|
|
51
|
+
notion: {
|
|
52
|
+
token: "",
|
|
53
|
+
rootPageId: "",
|
|
54
|
+
parentMode: "page"
|
|
55
|
+
},
|
|
56
|
+
sync: {
|
|
57
|
+
direction: "both",
|
|
58
|
+
conflictStrategy: "manual",
|
|
59
|
+
autoSync: false,
|
|
60
|
+
autoSyncInterval: 300,
|
|
61
|
+
deleteSync: false
|
|
62
|
+
},
|
|
63
|
+
paths: {
|
|
64
|
+
include: ["**/*"],
|
|
65
|
+
exclude: [],
|
|
66
|
+
attachments: "attachments"
|
|
67
|
+
},
|
|
68
|
+
conversion: {
|
|
69
|
+
preferMarkdownApi: true,
|
|
70
|
+
preserveMarkers: true,
|
|
71
|
+
frontmatterMapping: true,
|
|
72
|
+
imageDownload: "immediate"
|
|
73
|
+
},
|
|
74
|
+
advanced: {
|
|
75
|
+
concurrency: 3,
|
|
76
|
+
maxRetries: 5,
|
|
77
|
+
timeoutMs: 3e4,
|
|
78
|
+
batchSize: 50
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
var CONFIG_FILE = "config.json";
|
|
82
|
+
var IM_NOBSIDIAN_DIR = ".im-nobsidian";
|
|
83
|
+
var ConfigManager = class {
|
|
84
|
+
constructor(vaultRoot) {
|
|
85
|
+
this.vaultRoot = vaultRoot;
|
|
86
|
+
}
|
|
87
|
+
vaultRoot;
|
|
88
|
+
config = null;
|
|
89
|
+
get configDir() {
|
|
90
|
+
return join(this.vaultRoot, IM_NOBSIDIAN_DIR);
|
|
91
|
+
}
|
|
92
|
+
get configPath() {
|
|
93
|
+
return join(this.configDir, CONFIG_FILE);
|
|
94
|
+
}
|
|
95
|
+
get dbPath() {
|
|
96
|
+
return join(this.configDir, "sync.db");
|
|
97
|
+
}
|
|
98
|
+
async load() {
|
|
99
|
+
try {
|
|
100
|
+
const raw = await readFile(this.configPath, "utf-8");
|
|
101
|
+
const parsed = JSON.parse(raw);
|
|
102
|
+
this.config = ConfigSchema.parse(parsed);
|
|
103
|
+
return this.config;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof SyntaxError) {
|
|
106
|
+
throw new Error(`\uC124\uC815 \uD30C\uC77C\uC758 JSON \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: ${this.configPath}`);
|
|
107
|
+
}
|
|
108
|
+
if (error instanceof Error && error.name === "ZodError") {
|
|
109
|
+
throw new Error(`\uC124\uC815 \uD30C\uC77C \uAC80\uC99D \uC2E4\uD328: ${error.message}`);
|
|
110
|
+
}
|
|
111
|
+
throw new Error(
|
|
112
|
+
`\uC124\uC815 \uD30C\uC77C\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${this.configPath}
|
|
113
|
+
'im-nobsidian init'\uC744 \uBA3C\uC800 \uC2E4\uD589\uD558\uC138\uC694.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async save(config) {
|
|
118
|
+
await mkdir(this.configDir, { recursive: true });
|
|
119
|
+
const content = JSON.stringify(config, null, 2);
|
|
120
|
+
await writeFile(this.configPath, content, "utf-8");
|
|
121
|
+
this.config = config;
|
|
122
|
+
}
|
|
123
|
+
async init(params) {
|
|
124
|
+
const config = {
|
|
125
|
+
...DEFAULT_CONFIG,
|
|
126
|
+
notion: {
|
|
127
|
+
...DEFAULT_CONFIG.notion,
|
|
128
|
+
token: params.token,
|
|
129
|
+
rootPageId: params.rootPageId
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
await this.save(config);
|
|
133
|
+
await this.ensureGitignore();
|
|
134
|
+
return config;
|
|
135
|
+
}
|
|
136
|
+
getConfig() {
|
|
137
|
+
if (!this.config) {
|
|
138
|
+
throw new Error("\uC124\uC815\uC774 \uB85C\uB4DC\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. load()\uB97C \uBA3C\uC800 \uD638\uCD9C\uD558\uC138\uC694.");
|
|
139
|
+
}
|
|
140
|
+
return this.config;
|
|
141
|
+
}
|
|
142
|
+
async ensureGitignore() {
|
|
143
|
+
const gitignorePath = join(this.vaultRoot, ".gitignore");
|
|
144
|
+
const entry = ".im-nobsidian/";
|
|
145
|
+
try {
|
|
146
|
+
const content = await readFile(gitignorePath, "utf-8");
|
|
147
|
+
if (!content.includes(entry)) {
|
|
148
|
+
await writeFile(gitignorePath, `${content.trimEnd()}
|
|
149
|
+
${entry}
|
|
150
|
+
`, "utf-8");
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
await writeFile(gitignorePath, `${entry}
|
|
154
|
+
`, "utf-8");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async isInitialized() {
|
|
158
|
+
try {
|
|
159
|
+
const { stat: stat2 } = await import('fs/promises');
|
|
160
|
+
await stat2(this.configPath);
|
|
161
|
+
return true;
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
function generateId() {
|
|
168
|
+
return randomUUID();
|
|
169
|
+
}
|
|
170
|
+
function normalizeNotionId(id) {
|
|
171
|
+
const raw = id.replace(/-/g, "");
|
|
172
|
+
if (raw.length !== 32) return id;
|
|
173
|
+
return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
|
|
174
|
+
}
|
|
175
|
+
function notionIdsEqual(a, b) {
|
|
176
|
+
return a.replace(/-/g, "") === b.replace(/-/g, "");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/state/migrations/001-initial.ts
|
|
180
|
+
var INITIAL_MIGRATION = `
|
|
181
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
182
|
+
id TEXT PRIMARY KEY,
|
|
183
|
+
obsidian_path TEXT NOT NULL,
|
|
184
|
+
notion_page_id TEXT,
|
|
185
|
+
notion_parent_id TEXT,
|
|
186
|
+
content_hash TEXT NOT NULL,
|
|
187
|
+
notion_last_edited TEXT,
|
|
188
|
+
local_last_modified TEXT NOT NULL,
|
|
189
|
+
sync_direction TEXT NOT NULL DEFAULT 'both',
|
|
190
|
+
file_type TEXT NOT NULL DEFAULT 'file',
|
|
191
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
192
|
+
base_snapshot BLOB,
|
|
193
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
194
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
195
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
196
|
+
|
|
197
|
+
CONSTRAINT chk_sync_direction CHECK (sync_direction IN ('push', 'pull', 'both')),
|
|
198
|
+
CONSTRAINT chk_file_type CHECK (file_type IN ('file', 'folder-note', 'folder-only', 'db-row')),
|
|
199
|
+
CONSTRAINT chk_status CHECK (status IN ('synced', 'pending', 'conflict', 'error'))
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_sync_state_path ON sync_state(obsidian_path);
|
|
203
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_sync_state_notion ON sync_state(notion_page_id) WHERE notion_page_id IS NOT NULL;
|
|
204
|
+
CREATE INDEX IF NOT EXISTS idx_sync_state_status ON sync_state(status);
|
|
205
|
+
CREATE INDEX IF NOT EXISTS idx_sync_state_parent ON sync_state(notion_parent_id);
|
|
206
|
+
|
|
207
|
+
CREATE TABLE IF NOT EXISTS wikilink_map (
|
|
208
|
+
obsidian_path TEXT PRIMARY KEY,
|
|
209
|
+
notion_page_id TEXT NOT NULL UNIQUE,
|
|
210
|
+
title TEXT NOT NULL,
|
|
211
|
+
aliases TEXT DEFAULT '[]',
|
|
212
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
CREATE INDEX IF NOT EXISTS idx_wikilink_title ON wikilink_map(title);
|
|
216
|
+
CREATE INDEX IF NOT EXISTS idx_wikilink_notion ON wikilink_map(notion_page_id);
|
|
217
|
+
|
|
218
|
+
CREATE TABLE IF NOT EXISTS pending_operations (
|
|
219
|
+
id TEXT PRIMARY KEY,
|
|
220
|
+
sync_state_id TEXT NOT NULL REFERENCES sync_state(id) ON DELETE CASCADE,
|
|
221
|
+
operation TEXT NOT NULL,
|
|
222
|
+
direction TEXT NOT NULL,
|
|
223
|
+
payload TEXT,
|
|
224
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
225
|
+
error_message TEXT,
|
|
226
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
227
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
228
|
+
completed_at TEXT,
|
|
229
|
+
|
|
230
|
+
CONSTRAINT chk_operation CHECK (operation IN ('create', 'update', 'delete', 'move')),
|
|
231
|
+
CONSTRAINT chk_direction CHECK (direction IN ('push', 'pull')),
|
|
232
|
+
CONSTRAINT chk_op_status CHECK (status IN ('pending', 'processing', 'completed', 'failed'))
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
CREATE INDEX IF NOT EXISTS idx_pending_status ON pending_operations(status, created_at);
|
|
236
|
+
|
|
237
|
+
CREATE TABLE IF NOT EXISTS image_registry (
|
|
238
|
+
id TEXT PRIMARY KEY,
|
|
239
|
+
sync_state_id TEXT NOT NULL REFERENCES sync_state(id) ON DELETE CASCADE,
|
|
240
|
+
notion_file_url TEXT NOT NULL,
|
|
241
|
+
local_path TEXT NOT NULL,
|
|
242
|
+
content_hash TEXT NOT NULL,
|
|
243
|
+
file_size INTEGER NOT NULL,
|
|
244
|
+
mime_type TEXT NOT NULL,
|
|
245
|
+
expires_at TEXT,
|
|
246
|
+
downloaded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_image_hash ON image_registry(content_hash);
|
|
250
|
+
CREATE INDEX IF NOT EXISTS idx_image_sync ON image_registry(sync_state_id);
|
|
251
|
+
|
|
252
|
+
CREATE TABLE IF NOT EXISTS sync_metadata (
|
|
253
|
+
key TEXT PRIMARY KEY,
|
|
254
|
+
value TEXT NOT NULL
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
INSERT OR IGNORE INTO sync_metadata (key, value) VALUES ('schema_version', '1');
|
|
258
|
+
`;
|
|
259
|
+
|
|
260
|
+
// src/state/state-db.ts
|
|
261
|
+
var StateDB = class _StateDB {
|
|
262
|
+
db;
|
|
263
|
+
constructor(db) {
|
|
264
|
+
this.db = db;
|
|
265
|
+
this.db.pragma("journal_mode = WAL");
|
|
266
|
+
this.db.pragma("foreign_keys = ON");
|
|
267
|
+
}
|
|
268
|
+
static open(dbPath) {
|
|
269
|
+
const db = new Database(dbPath);
|
|
270
|
+
const stateDb = new _StateDB(db);
|
|
271
|
+
stateDb.migrate();
|
|
272
|
+
return stateDb;
|
|
273
|
+
}
|
|
274
|
+
migrate() {
|
|
275
|
+
const version = this.getSchemaVersion();
|
|
276
|
+
if (version < 1) {
|
|
277
|
+
this.db.exec(INITIAL_MIGRATION);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
getSchemaVersion() {
|
|
281
|
+
try {
|
|
282
|
+
const row = this.db.prepare("SELECT value FROM sync_metadata WHERE key = 'schema_version'").get();
|
|
283
|
+
return row ? Number(row.value) : 0;
|
|
284
|
+
} catch {
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
close() {
|
|
289
|
+
this.db.close();
|
|
290
|
+
}
|
|
291
|
+
// --- sync_state ---
|
|
292
|
+
getByPath(path) {
|
|
293
|
+
const row = this.db.prepare("SELECT * FROM sync_state WHERE obsidian_path = ?").get(path);
|
|
294
|
+
return row ? this.mapRow(row) : null;
|
|
295
|
+
}
|
|
296
|
+
getByNotionId(pageId) {
|
|
297
|
+
const row = this.db.prepare("SELECT * FROM sync_state WHERE notion_page_id = ?").get(pageId);
|
|
298
|
+
return row ? this.mapRow(row) : null;
|
|
299
|
+
}
|
|
300
|
+
getByStatus(status) {
|
|
301
|
+
const rows = this.db.prepare("SELECT * FROM sync_state WHERE status = ?").all(status);
|
|
302
|
+
return rows.map((r) => this.mapRow(r));
|
|
303
|
+
}
|
|
304
|
+
getAll() {
|
|
305
|
+
const rows = this.db.prepare("SELECT * FROM sync_state").all();
|
|
306
|
+
return rows.map((r) => this.mapRow(r));
|
|
307
|
+
}
|
|
308
|
+
upsert(record) {
|
|
309
|
+
const existing = this.getByPath(record.obsidianPath);
|
|
310
|
+
if (existing) {
|
|
311
|
+
this.db.prepare(
|
|
312
|
+
`UPDATE sync_state SET
|
|
313
|
+
notion_page_id = ?, notion_parent_id = ?, content_hash = ?,
|
|
314
|
+
notion_last_edited = ?, local_last_modified = ?,
|
|
315
|
+
sync_direction = ?, file_type = ?, status = ?,
|
|
316
|
+
base_snapshot = ?, version = version + 1,
|
|
317
|
+
updated_at = datetime('now')
|
|
318
|
+
WHERE id = ?`
|
|
319
|
+
).run(
|
|
320
|
+
record.notionPageId ?? null,
|
|
321
|
+
record.notionParentId ?? null,
|
|
322
|
+
record.contentHash,
|
|
323
|
+
record.notionLastEdited ?? null,
|
|
324
|
+
record.localLastModified,
|
|
325
|
+
record.syncDirection,
|
|
326
|
+
record.fileType,
|
|
327
|
+
record.status,
|
|
328
|
+
record.baseSnapshot ?? null,
|
|
329
|
+
existing.id
|
|
330
|
+
);
|
|
331
|
+
return this.getByPath(record.obsidianPath);
|
|
332
|
+
}
|
|
333
|
+
const id = generateId();
|
|
334
|
+
this.db.prepare(
|
|
335
|
+
`INSERT INTO sync_state
|
|
336
|
+
(id, obsidian_path, notion_page_id, notion_parent_id, content_hash,
|
|
337
|
+
notion_last_edited, local_last_modified, sync_direction, file_type,
|
|
338
|
+
status, base_snapshot, version)
|
|
339
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
|
340
|
+
).run(
|
|
341
|
+
id,
|
|
342
|
+
record.obsidianPath,
|
|
343
|
+
record.notionPageId ?? null,
|
|
344
|
+
record.notionParentId ?? null,
|
|
345
|
+
record.contentHash,
|
|
346
|
+
record.notionLastEdited ?? null,
|
|
347
|
+
record.localLastModified,
|
|
348
|
+
record.syncDirection,
|
|
349
|
+
record.fileType,
|
|
350
|
+
record.status,
|
|
351
|
+
record.baseSnapshot ?? null
|
|
352
|
+
);
|
|
353
|
+
return this.getByPath(record.obsidianPath);
|
|
354
|
+
}
|
|
355
|
+
updateStatus(id, status) {
|
|
356
|
+
this.db.prepare("UPDATE sync_state SET status = ?, updated_at = datetime('now') WHERE id = ?").run(status, id);
|
|
357
|
+
}
|
|
358
|
+
updateHash(id, hash, snapshot) {
|
|
359
|
+
if (snapshot !== void 0) {
|
|
360
|
+
this.db.prepare(
|
|
361
|
+
"UPDATE sync_state SET content_hash = ?, base_snapshot = ?, updated_at = datetime('now') WHERE id = ?"
|
|
362
|
+
).run(hash, snapshot, id);
|
|
363
|
+
} else {
|
|
364
|
+
this.db.prepare(
|
|
365
|
+
"UPDATE sync_state SET content_hash = ?, updated_at = datetime('now') WHERE id = ?"
|
|
366
|
+
).run(hash, id);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
delete(id) {
|
|
370
|
+
this.db.prepare("DELETE FROM sync_state WHERE id = ?").run(id);
|
|
371
|
+
}
|
|
372
|
+
// --- wikilink_map ---
|
|
373
|
+
resolveWikilink(text) {
|
|
374
|
+
const byTitle = this.db.prepare("SELECT * FROM wikilink_map WHERE title = ?").get(text);
|
|
375
|
+
if (byTitle) return this.mapWikilinkRow(byTitle);
|
|
376
|
+
const byAlias = this.db.prepare("SELECT * FROM wikilink_map WHERE aliases LIKE ?").get(`%"${text}"%`);
|
|
377
|
+
if (byAlias) return this.mapWikilinkRow(byAlias);
|
|
378
|
+
const byFilename = this.db.prepare("SELECT * FROM wikilink_map WHERE obsidian_path LIKE ?").get(`%/${text}.md`);
|
|
379
|
+
return byFilename ? this.mapWikilinkRow(byFilename) : null;
|
|
380
|
+
}
|
|
381
|
+
resolvePageId(pageId) {
|
|
382
|
+
const row = this.db.prepare("SELECT * FROM wikilink_map WHERE notion_page_id = ?").get(pageId);
|
|
383
|
+
return row ? this.mapWikilinkRow(row) : null;
|
|
384
|
+
}
|
|
385
|
+
upsertWikilink(entry) {
|
|
386
|
+
this.db.prepare(
|
|
387
|
+
`INSERT OR REPLACE INTO wikilink_map (obsidian_path, notion_page_id, title, aliases, updated_at)
|
|
388
|
+
VALUES (?, ?, ?, ?, datetime('now'))`
|
|
389
|
+
).run(entry.obsidianPath, entry.notionPageId, entry.title, JSON.stringify(entry.aliases));
|
|
390
|
+
}
|
|
391
|
+
// --- sync_metadata ---
|
|
392
|
+
getMeta(key) {
|
|
393
|
+
const row = this.db.prepare("SELECT value FROM sync_metadata WHERE key = ?").get(key);
|
|
394
|
+
return row?.value ?? null;
|
|
395
|
+
}
|
|
396
|
+
setMeta(key, value) {
|
|
397
|
+
this.db.prepare("INSERT OR REPLACE INTO sync_metadata (key, value) VALUES (?, ?)").run(key, value);
|
|
398
|
+
}
|
|
399
|
+
// --- preserve markers ---
|
|
400
|
+
storePreserveMarkers(path, markers) {
|
|
401
|
+
if (markers.length === 0) {
|
|
402
|
+
this.db.prepare("DELETE FROM sync_metadata WHERE key = ?").run(`preserve_markers:${path}`);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
this.setMeta(`preserve_markers:${path}`, JSON.stringify(markers));
|
|
406
|
+
}
|
|
407
|
+
getPreserveMarkers(path) {
|
|
408
|
+
const value = this.getMeta(`preserve_markers:${path}`);
|
|
409
|
+
if (!value) return [];
|
|
410
|
+
try {
|
|
411
|
+
return JSON.parse(value);
|
|
412
|
+
} catch {
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// --- transaction ---
|
|
417
|
+
transaction(fn) {
|
|
418
|
+
return this.db.transaction(fn)();
|
|
419
|
+
}
|
|
420
|
+
// --- private ---
|
|
421
|
+
mapRow(row) {
|
|
422
|
+
return {
|
|
423
|
+
id: row.id,
|
|
424
|
+
obsidianPath: row.obsidian_path,
|
|
425
|
+
notionPageId: row.notion_page_id,
|
|
426
|
+
notionParentId: row.notion_parent_id,
|
|
427
|
+
contentHash: row.content_hash,
|
|
428
|
+
notionLastEdited: row.notion_last_edited,
|
|
429
|
+
localLastModified: row.local_last_modified,
|
|
430
|
+
syncDirection: row.sync_direction,
|
|
431
|
+
fileType: row.file_type,
|
|
432
|
+
status: row.status,
|
|
433
|
+
baseSnapshot: row.base_snapshot,
|
|
434
|
+
version: row.version,
|
|
435
|
+
createdAt: row.created_at,
|
|
436
|
+
updatedAt: row.updated_at
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
mapWikilinkRow(row) {
|
|
440
|
+
return {
|
|
441
|
+
obsidianPath: row.obsidian_path,
|
|
442
|
+
notionPageId: row.notion_page_id,
|
|
443
|
+
title: row.title,
|
|
444
|
+
aliases: JSON.parse(row.aliases || "[]")
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// src/notion/property-mapper.ts
|
|
450
|
+
var DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2})?/;
|
|
451
|
+
var PropertyMapper = class {
|
|
452
|
+
schema = /* @__PURE__ */ new Map();
|
|
453
|
+
loadSchema(properties) {
|
|
454
|
+
this.schema.clear();
|
|
455
|
+
for (const [name, prop] of Object.entries(properties)) {
|
|
456
|
+
this.schema.set(name, { id: prop.id, type: prop.type, name });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
toNotionProperties(frontmatter, title) {
|
|
460
|
+
const result = {
|
|
461
|
+
title: { title: [{ text: { content: title } }] }
|
|
462
|
+
};
|
|
463
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
464
|
+
if (key === "title") continue;
|
|
465
|
+
if (value === null || value === void 0) continue;
|
|
466
|
+
const schemaProp = this.schema.get(key);
|
|
467
|
+
if (schemaProp) {
|
|
468
|
+
const converted = this.convertBySchema(schemaProp.type, value);
|
|
469
|
+
if (converted) result[key] = converted;
|
|
470
|
+
} else {
|
|
471
|
+
const inferred = this.inferAndConvert(value);
|
|
472
|
+
if (inferred) result[key] = inferred;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return result;
|
|
476
|
+
}
|
|
477
|
+
fromNotionProperties(notionProps) {
|
|
478
|
+
const result = {};
|
|
479
|
+
for (const [key, rawProp] of Object.entries(notionProps)) {
|
|
480
|
+
const prop = rawProp;
|
|
481
|
+
if (prop.type === "title") continue;
|
|
482
|
+
const value = this.extractValue(prop);
|
|
483
|
+
if (value !== void 0) {
|
|
484
|
+
result[key] = value;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return result;
|
|
488
|
+
}
|
|
489
|
+
convertBySchema(type, value) {
|
|
490
|
+
switch (type) {
|
|
491
|
+
case "rich_text":
|
|
492
|
+
return { rich_text: [{ text: { content: String(value) } }] };
|
|
493
|
+
case "number":
|
|
494
|
+
return { number: typeof value === "number" ? value : Number(value) };
|
|
495
|
+
case "select":
|
|
496
|
+
return { select: { name: String(value) } };
|
|
497
|
+
case "multi_select": {
|
|
498
|
+
const items = Array.isArray(value) ? value : String(value).split(",").map((s) => s.trim());
|
|
499
|
+
return { multi_select: items.map((name) => ({ name: String(name) })) };
|
|
500
|
+
}
|
|
501
|
+
case "checkbox":
|
|
502
|
+
return { checkbox: Boolean(value) };
|
|
503
|
+
case "date": {
|
|
504
|
+
const dateStr = String(value);
|
|
505
|
+
if (DATE_REGEX.test(dateStr)) {
|
|
506
|
+
return { date: { start: dateStr } };
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
case "url":
|
|
511
|
+
return { url: String(value) };
|
|
512
|
+
case "email":
|
|
513
|
+
return { email: String(value) };
|
|
514
|
+
case "phone_number":
|
|
515
|
+
return { phone_number: String(value) };
|
|
516
|
+
case "status":
|
|
517
|
+
return { status: { name: String(value) } };
|
|
518
|
+
default:
|
|
519
|
+
return { rich_text: [{ text: { content: String(value) } }] };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
inferAndConvert(value) {
|
|
523
|
+
if (typeof value === "boolean") {
|
|
524
|
+
return { checkbox: value };
|
|
525
|
+
}
|
|
526
|
+
if (typeof value === "number") {
|
|
527
|
+
return { number: value };
|
|
528
|
+
}
|
|
529
|
+
if (Array.isArray(value)) {
|
|
530
|
+
return {
|
|
531
|
+
multi_select: value.map((v) => ({ name: String(v) }))
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
if (typeof value === "string") {
|
|
535
|
+
if (DATE_REGEX.test(value)) {
|
|
536
|
+
return { date: { start: value } };
|
|
537
|
+
}
|
|
538
|
+
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
539
|
+
return { url: value };
|
|
540
|
+
}
|
|
541
|
+
return { rich_text: [{ text: { content: value } }] };
|
|
542
|
+
}
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
extractValue(prop) {
|
|
546
|
+
switch (prop.type) {
|
|
547
|
+
case "rich_text": {
|
|
548
|
+
const arr = prop.rich_text;
|
|
549
|
+
return arr?.map((t) => t.plain_text).join("") || null;
|
|
550
|
+
}
|
|
551
|
+
case "number":
|
|
552
|
+
return prop.number;
|
|
553
|
+
case "select": {
|
|
554
|
+
const sel = prop.select;
|
|
555
|
+
return sel?.name ?? null;
|
|
556
|
+
}
|
|
557
|
+
case "multi_select": {
|
|
558
|
+
const items = prop.multi_select;
|
|
559
|
+
return items?.map((s) => s.name) ?? [];
|
|
560
|
+
}
|
|
561
|
+
case "checkbox":
|
|
562
|
+
return prop.checkbox;
|
|
563
|
+
case "date":
|
|
564
|
+
return prop.date ?? null;
|
|
565
|
+
case "url":
|
|
566
|
+
return prop.url;
|
|
567
|
+
case "email":
|
|
568
|
+
return prop.email;
|
|
569
|
+
case "phone_number":
|
|
570
|
+
return prop.phone_number;
|
|
571
|
+
case "status": {
|
|
572
|
+
const status = prop.status;
|
|
573
|
+
return status?.name ?? null;
|
|
574
|
+
}
|
|
575
|
+
case "created_time":
|
|
576
|
+
return prop.created_time;
|
|
577
|
+
case "last_edited_time":
|
|
578
|
+
return prop.last_edited_time;
|
|
579
|
+
case "people": {
|
|
580
|
+
const people = prop.people;
|
|
581
|
+
return people?.map((p) => p.name ?? p.id) ?? [];
|
|
582
|
+
}
|
|
583
|
+
case "files": {
|
|
584
|
+
const files = prop.files;
|
|
585
|
+
return files?.map((f) => ({
|
|
586
|
+
name: f.name,
|
|
587
|
+
url: f.type === "file" ? f.file?.url : f.external?.url
|
|
588
|
+
})) ?? [];
|
|
589
|
+
}
|
|
590
|
+
case "formula": {
|
|
591
|
+
const formula = prop.formula;
|
|
592
|
+
if (!formula) return null;
|
|
593
|
+
return formula[formula.type];
|
|
594
|
+
}
|
|
595
|
+
case "relation": {
|
|
596
|
+
const rel = prop.relation;
|
|
597
|
+
return rel?.map((r) => r.id) ?? [];
|
|
598
|
+
}
|
|
599
|
+
case "rollup": {
|
|
600
|
+
const rollup = prop.rollup;
|
|
601
|
+
if (!rollup) return null;
|
|
602
|
+
if (rollup.type === "array") {
|
|
603
|
+
const arr = rollup.array;
|
|
604
|
+
return arr.map((item) => this.extractValue(item));
|
|
605
|
+
}
|
|
606
|
+
return rollup[rollup.type] ?? null;
|
|
607
|
+
}
|
|
608
|
+
case "unique_id": {
|
|
609
|
+
const uid = prop.unique_id;
|
|
610
|
+
if (!uid) return null;
|
|
611
|
+
return uid.prefix ? `${uid.prefix}-${uid.number}` : String(uid.number);
|
|
612
|
+
}
|
|
613
|
+
case "created_by":
|
|
614
|
+
case "last_edited_by": {
|
|
615
|
+
const user = prop[prop.type];
|
|
616
|
+
return user?.name ?? user?.id ?? null;
|
|
617
|
+
}
|
|
618
|
+
case "verification": {
|
|
619
|
+
const v = prop.verification;
|
|
620
|
+
return v?.state ?? null;
|
|
621
|
+
}
|
|
622
|
+
default:
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// src/notion/client.ts
|
|
629
|
+
var NotionClient = class {
|
|
630
|
+
client;
|
|
631
|
+
sema;
|
|
632
|
+
propertyMapper = new PropertyMapper();
|
|
633
|
+
constructor(options) {
|
|
634
|
+
this.client = new Client({
|
|
635
|
+
auth: options.token,
|
|
636
|
+
timeoutMs: options.timeoutMs ?? 3e4
|
|
637
|
+
});
|
|
638
|
+
this.sema = new Sema(options.concurrency ?? 3);
|
|
639
|
+
}
|
|
640
|
+
getInternalClient() {
|
|
641
|
+
return this.client;
|
|
642
|
+
}
|
|
643
|
+
async getPage(pageId) {
|
|
644
|
+
return this.withRateLimit(
|
|
645
|
+
() => this.client.pages.retrieve({ page_id: pageId })
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
async createPage(params) {
|
|
649
|
+
const parent = params.parentType === "database" ? { database_id: params.parentId } : { page_id: params.parentId };
|
|
650
|
+
const properties = params.parentType === "page" ? { title: { title: [{ text: { content: params.title } }] } } : params.properties ?? {
|
|
651
|
+
title: { title: [{ text: { content: params.title } }] }
|
|
652
|
+
};
|
|
653
|
+
return this.withRateLimit(
|
|
654
|
+
() => this.client.pages.create({
|
|
655
|
+
parent,
|
|
656
|
+
properties,
|
|
657
|
+
children: params.children
|
|
658
|
+
})
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
async updatePageProperties(pageId, properties) {
|
|
662
|
+
return this.withRateLimit(
|
|
663
|
+
() => this.client.pages.update({
|
|
664
|
+
page_id: pageId,
|
|
665
|
+
properties
|
|
666
|
+
})
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
async getDatabaseSchema(databaseId) {
|
|
670
|
+
const db = await this.withRateLimit(
|
|
671
|
+
() => this.client.databases.retrieve({ database_id: databaseId })
|
|
672
|
+
);
|
|
673
|
+
const properties = db.properties;
|
|
674
|
+
const schema = {};
|
|
675
|
+
for (const [name, prop] of Object.entries(properties)) {
|
|
676
|
+
schema[name] = { id: prop.id, type: prop.type };
|
|
677
|
+
}
|
|
678
|
+
return schema;
|
|
679
|
+
}
|
|
680
|
+
async queryDatabase(databaseId, options) {
|
|
681
|
+
const response = await this.withRateLimit(
|
|
682
|
+
() => this.client.databases.query({
|
|
683
|
+
database_id: databaseId,
|
|
684
|
+
start_cursor: options?.startCursor,
|
|
685
|
+
page_size: options?.pageSize ?? 100
|
|
686
|
+
})
|
|
687
|
+
);
|
|
688
|
+
return {
|
|
689
|
+
results: response.results,
|
|
690
|
+
nextCursor: response.next_cursor
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
async archivePage(pageId) {
|
|
694
|
+
await this.withRateLimit(() => this.client.pages.update({ page_id: pageId, archived: true }));
|
|
695
|
+
}
|
|
696
|
+
async listChildren(blockId, options) {
|
|
697
|
+
const response = await this.withRateLimit(
|
|
698
|
+
() => this.client.blocks.children.list({
|
|
699
|
+
block_id: blockId,
|
|
700
|
+
start_cursor: options?.startCursor,
|
|
701
|
+
page_size: options?.pageSize ?? 100
|
|
702
|
+
})
|
|
703
|
+
);
|
|
704
|
+
return {
|
|
705
|
+
results: response.results,
|
|
706
|
+
nextCursor: response.next_cursor
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
async fetchAllChildren(blockId) {
|
|
710
|
+
const blocks = [];
|
|
711
|
+
let cursor;
|
|
712
|
+
do {
|
|
713
|
+
const response = await this.listChildren(blockId, { startCursor: cursor });
|
|
714
|
+
blocks.push(...response.results.filter((b) => "type" in b));
|
|
715
|
+
cursor = response.nextCursor ?? void 0;
|
|
716
|
+
} while (cursor);
|
|
717
|
+
return blocks;
|
|
718
|
+
}
|
|
719
|
+
async appendChildren(blockId, children) {
|
|
720
|
+
const BATCH_SIZE = 100;
|
|
721
|
+
for (let i = 0; i < children.length; i += BATCH_SIZE) {
|
|
722
|
+
const batch = children.slice(i, i + BATCH_SIZE);
|
|
723
|
+
await this.withRateLimit(
|
|
724
|
+
() => this.client.blocks.children.append({
|
|
725
|
+
block_id: blockId,
|
|
726
|
+
children: batch
|
|
727
|
+
})
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
async deleteBlock(blockId) {
|
|
732
|
+
await this.withRateLimit(() => this.client.blocks.delete({ block_id: blockId }));
|
|
733
|
+
}
|
|
734
|
+
async search(params) {
|
|
735
|
+
const response = await this.withRateLimit(
|
|
736
|
+
() => this.client.search({
|
|
737
|
+
query: params.query,
|
|
738
|
+
filter: params.filter,
|
|
739
|
+
start_cursor: params.startCursor,
|
|
740
|
+
page_size: params.pageSize ?? 100
|
|
741
|
+
})
|
|
742
|
+
);
|
|
743
|
+
return {
|
|
744
|
+
results: response.results,
|
|
745
|
+
nextCursor: response.next_cursor
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
async getChildPages(parentId) {
|
|
749
|
+
const blocks = await this.fetchAllChildren(parentId);
|
|
750
|
+
const childPageBlocks = blocks.filter((b) => b.type === "child_page");
|
|
751
|
+
if (childPageBlocks.length === 0) return [];
|
|
752
|
+
const pages = await Promise.all(childPageBlocks.map((b) => this.getPage(b.id)));
|
|
753
|
+
return pages;
|
|
754
|
+
}
|
|
755
|
+
async getChildPagesRecursive(parentId) {
|
|
756
|
+
const all = [];
|
|
757
|
+
let currentLevel = [parentId];
|
|
758
|
+
while (currentLevel.length > 0) {
|
|
759
|
+
const childArrays = await Promise.all(currentLevel.map((id) => this.getChildPages(id)));
|
|
760
|
+
const nextLevel = [];
|
|
761
|
+
for (const children of childArrays) {
|
|
762
|
+
for (const child of children) {
|
|
763
|
+
all.push(child);
|
|
764
|
+
nextLevel.push(child.id);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
currentLevel = nextLevel;
|
|
768
|
+
}
|
|
769
|
+
return all;
|
|
770
|
+
}
|
|
771
|
+
extractTitle(page) {
|
|
772
|
+
for (const prop of Object.values(page.properties)) {
|
|
773
|
+
if (prop.type === "title" && "title" in prop) {
|
|
774
|
+
const titleArr = prop.title;
|
|
775
|
+
if (titleArr[0]?.plain_text) return titleArr[0].plain_text;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return "\uC81C\uBAA9 \uC5C6\uC74C";
|
|
779
|
+
}
|
|
780
|
+
extractProperties(page) {
|
|
781
|
+
return this.propertyMapper.fromNotionProperties(page.properties);
|
|
782
|
+
}
|
|
783
|
+
async withRateLimit(fn) {
|
|
784
|
+
await this.sema.acquire();
|
|
785
|
+
try {
|
|
786
|
+
return await this.executeWithRetry(fn);
|
|
787
|
+
} finally {
|
|
788
|
+
this.sema.release();
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
async executeWithRetry(fn, attempt = 0) {
|
|
792
|
+
try {
|
|
793
|
+
return await fn();
|
|
794
|
+
} catch (error) {
|
|
795
|
+
if (isRateLimited(error) && attempt < 5) {
|
|
796
|
+
const baseDelay = extractRetryAfter(error) ?? 1e3 * Math.pow(2, attempt);
|
|
797
|
+
const jitter = baseDelay * (0.5 + Math.random() * 0.5);
|
|
798
|
+
await sleep(jitter);
|
|
799
|
+
return this.executeWithRetry(fn, attempt + 1);
|
|
800
|
+
}
|
|
801
|
+
throw error;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
function isRateLimited(error) {
|
|
806
|
+
return typeof error === "object" && error !== null && "status" in error && error.status === 429;
|
|
807
|
+
}
|
|
808
|
+
function extractRetryAfter(error) {
|
|
809
|
+
if (typeof error === "object" && error !== null && "headers" in error) {
|
|
810
|
+
const headers = error.headers;
|
|
811
|
+
const retryAfter = headers["retry-after"];
|
|
812
|
+
if (retryAfter) return Number(retryAfter) * 1e3;
|
|
813
|
+
}
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
function sleep(ms) {
|
|
817
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// src/notion/block-builder.ts
|
|
821
|
+
var VALID_COLORS = /* @__PURE__ */ new Set([
|
|
822
|
+
"default",
|
|
823
|
+
"gray",
|
|
824
|
+
"brown",
|
|
825
|
+
"orange",
|
|
826
|
+
"yellow",
|
|
827
|
+
"green",
|
|
828
|
+
"blue",
|
|
829
|
+
"purple",
|
|
830
|
+
"pink",
|
|
831
|
+
"red",
|
|
832
|
+
"gray_background",
|
|
833
|
+
"brown_background",
|
|
834
|
+
"orange_background",
|
|
835
|
+
"yellow_background",
|
|
836
|
+
"green_background",
|
|
837
|
+
"blue_background",
|
|
838
|
+
"purple_background",
|
|
839
|
+
"pink_background",
|
|
840
|
+
"red_background"
|
|
841
|
+
]);
|
|
842
|
+
function safeColor(color) {
|
|
843
|
+
return VALID_COLORS.has(color) ? color : "default";
|
|
844
|
+
}
|
|
845
|
+
var NotionBlockBuilder = class _NotionBlockBuilder {
|
|
846
|
+
// ─── Rich Text ──────────────────────────────────────────
|
|
847
|
+
static richText(content, options) {
|
|
848
|
+
const textObj = { content };
|
|
849
|
+
if (options?.link) {
|
|
850
|
+
textObj.link = { url: options.link };
|
|
851
|
+
}
|
|
852
|
+
return [
|
|
853
|
+
{
|
|
854
|
+
type: "text",
|
|
855
|
+
text: textObj,
|
|
856
|
+
annotations: {
|
|
857
|
+
bold: options?.bold ?? false,
|
|
858
|
+
italic: options?.italic ?? false,
|
|
859
|
+
underline: options?.underline ?? false,
|
|
860
|
+
strikethrough: options?.strikethrough ?? false,
|
|
861
|
+
code: options?.code ?? false,
|
|
862
|
+
color: safeColor(options?.color ?? "default")
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
];
|
|
866
|
+
}
|
|
867
|
+
static richTextArray(segments) {
|
|
868
|
+
return segments.map((seg) => {
|
|
869
|
+
const textObj = {
|
|
870
|
+
content: seg.content
|
|
871
|
+
};
|
|
872
|
+
if (seg.link) {
|
|
873
|
+
textObj.link = { url: seg.link };
|
|
874
|
+
}
|
|
875
|
+
return {
|
|
876
|
+
type: "text",
|
|
877
|
+
text: textObj,
|
|
878
|
+
annotations: {
|
|
879
|
+
bold: seg.bold ?? false,
|
|
880
|
+
italic: seg.italic ?? false,
|
|
881
|
+
underline: seg.underline ?? false,
|
|
882
|
+
strikethrough: seg.strikethrough ?? false,
|
|
883
|
+
code: seg.code ?? false,
|
|
884
|
+
color: safeColor(seg.color ?? "default")
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
static richTextEquation(expression) {
|
|
890
|
+
return [{ type: "equation", equation: { expression } }];
|
|
891
|
+
}
|
|
892
|
+
static richTextMentionPage(pageId) {
|
|
893
|
+
return [{ type: "mention", mention: { type: "page", page: { id: pageId } } }];
|
|
894
|
+
}
|
|
895
|
+
static richTextMentionDate(start, end) {
|
|
896
|
+
const date = { start };
|
|
897
|
+
if (end) date.end = end;
|
|
898
|
+
return [{ type: "mention", mention: { type: "date", date } }];
|
|
899
|
+
}
|
|
900
|
+
// ─── Basic Blocks ───────────────────────────────────────
|
|
901
|
+
static paragraph(richText, color) {
|
|
902
|
+
return {
|
|
903
|
+
object: "block",
|
|
904
|
+
type: "paragraph",
|
|
905
|
+
paragraph: { rich_text: richText, color: safeColor(color ?? "default") }
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
static heading(level, richText, options) {
|
|
909
|
+
const key = `heading_${level}`;
|
|
910
|
+
const data = {
|
|
911
|
+
rich_text: richText,
|
|
912
|
+
color: safeColor(options?.color ?? "default"),
|
|
913
|
+
is_toggleable: options?.isToggleable ?? false
|
|
914
|
+
};
|
|
915
|
+
if (options?.isToggleable && options?.children) {
|
|
916
|
+
data.children = options.children;
|
|
917
|
+
}
|
|
918
|
+
return { object: "block", type: key, [key]: data };
|
|
919
|
+
}
|
|
920
|
+
static callout(richText, icon, options) {
|
|
921
|
+
const data = {
|
|
922
|
+
rich_text: richText,
|
|
923
|
+
icon: { type: "emoji", emoji: icon ?? "\u{1F4CC}" },
|
|
924
|
+
color: safeColor(options?.color ?? "default")
|
|
925
|
+
};
|
|
926
|
+
if (options?.children) {
|
|
927
|
+
data.children = options.children;
|
|
928
|
+
}
|
|
929
|
+
return { object: "block", type: "callout", callout: data };
|
|
930
|
+
}
|
|
931
|
+
static toggle(richText, children, color) {
|
|
932
|
+
const data = {
|
|
933
|
+
rich_text: richText,
|
|
934
|
+
color: safeColor(color ?? "default")
|
|
935
|
+
};
|
|
936
|
+
if (children) {
|
|
937
|
+
data.children = children;
|
|
938
|
+
}
|
|
939
|
+
return { object: "block", type: "toggle", toggle: data };
|
|
940
|
+
}
|
|
941
|
+
static bulletedListItem(richText, children, color) {
|
|
942
|
+
const data = {
|
|
943
|
+
rich_text: richText,
|
|
944
|
+
color: safeColor(color ?? "default")
|
|
945
|
+
};
|
|
946
|
+
if (children) {
|
|
947
|
+
data.children = children;
|
|
948
|
+
}
|
|
949
|
+
return {
|
|
950
|
+
object: "block",
|
|
951
|
+
type: "bulleted_list_item",
|
|
952
|
+
bulleted_list_item: data
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
static numberedListItem(richText, children, color) {
|
|
956
|
+
const data = {
|
|
957
|
+
rich_text: richText,
|
|
958
|
+
color: safeColor(color ?? "default")
|
|
959
|
+
};
|
|
960
|
+
if (children) {
|
|
961
|
+
data.children = children;
|
|
962
|
+
}
|
|
963
|
+
return {
|
|
964
|
+
object: "block",
|
|
965
|
+
type: "numbered_list_item",
|
|
966
|
+
numbered_list_item: data
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
static toDo(richText, checked, color) {
|
|
970
|
+
return {
|
|
971
|
+
object: "block",
|
|
972
|
+
type: "to_do",
|
|
973
|
+
to_do: {
|
|
974
|
+
rich_text: richText,
|
|
975
|
+
checked: checked ?? false,
|
|
976
|
+
color: safeColor(color ?? "default")
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
static quote(richText, children, color) {
|
|
981
|
+
const data = {
|
|
982
|
+
rich_text: richText,
|
|
983
|
+
color: safeColor(color ?? "default")
|
|
984
|
+
};
|
|
985
|
+
if (children) {
|
|
986
|
+
data.children = children;
|
|
987
|
+
}
|
|
988
|
+
return { object: "block", type: "quote", quote: data };
|
|
989
|
+
}
|
|
990
|
+
static divider() {
|
|
991
|
+
return { object: "block", type: "divider", divider: {} };
|
|
992
|
+
}
|
|
993
|
+
static code(code, language, caption) {
|
|
994
|
+
const data = {
|
|
995
|
+
rich_text: _NotionBlockBuilder.richText(code),
|
|
996
|
+
language: language ?? "plain text"
|
|
997
|
+
};
|
|
998
|
+
if (caption) {
|
|
999
|
+
data.caption = _NotionBlockBuilder.richText(caption);
|
|
1000
|
+
}
|
|
1001
|
+
return { object: "block", type: "code", code: data };
|
|
1002
|
+
}
|
|
1003
|
+
static equationBlock(expression) {
|
|
1004
|
+
return {
|
|
1005
|
+
object: "block",
|
|
1006
|
+
type: "equation",
|
|
1007
|
+
equation: { expression }
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
// ─── Media Blocks ───────────────────────────────────────
|
|
1011
|
+
static image(url, caption) {
|
|
1012
|
+
const data = {
|
|
1013
|
+
type: "external",
|
|
1014
|
+
external: { url }
|
|
1015
|
+
};
|
|
1016
|
+
if (caption) {
|
|
1017
|
+
data.caption = _NotionBlockBuilder.richText(caption);
|
|
1018
|
+
}
|
|
1019
|
+
return { object: "block", type: "image", image: data };
|
|
1020
|
+
}
|
|
1021
|
+
static bookmark(url, caption) {
|
|
1022
|
+
const data = { url };
|
|
1023
|
+
if (caption) {
|
|
1024
|
+
data.caption = _NotionBlockBuilder.richText(caption);
|
|
1025
|
+
}
|
|
1026
|
+
return { object: "block", type: "bookmark", bookmark: data };
|
|
1027
|
+
}
|
|
1028
|
+
static video(url, caption) {
|
|
1029
|
+
const data = {
|
|
1030
|
+
type: "external",
|
|
1031
|
+
external: { url }
|
|
1032
|
+
};
|
|
1033
|
+
if (caption) {
|
|
1034
|
+
data.caption = _NotionBlockBuilder.richText(caption);
|
|
1035
|
+
}
|
|
1036
|
+
return { object: "block", type: "video", video: data };
|
|
1037
|
+
}
|
|
1038
|
+
static audio(url, caption) {
|
|
1039
|
+
const data = {
|
|
1040
|
+
type: "external",
|
|
1041
|
+
external: { url }
|
|
1042
|
+
};
|
|
1043
|
+
if (caption) {
|
|
1044
|
+
data.caption = _NotionBlockBuilder.richText(caption);
|
|
1045
|
+
}
|
|
1046
|
+
return { object: "block", type: "audio", audio: data };
|
|
1047
|
+
}
|
|
1048
|
+
static fileBlock(url, name, caption) {
|
|
1049
|
+
const data = {
|
|
1050
|
+
type: "external",
|
|
1051
|
+
external: { url }
|
|
1052
|
+
};
|
|
1053
|
+
if (name) data.name = name;
|
|
1054
|
+
if (caption) {
|
|
1055
|
+
data.caption = _NotionBlockBuilder.richText(caption);
|
|
1056
|
+
}
|
|
1057
|
+
return { object: "block", type: "file", file: data };
|
|
1058
|
+
}
|
|
1059
|
+
static pdf(url, caption) {
|
|
1060
|
+
const data = {
|
|
1061
|
+
type: "external",
|
|
1062
|
+
external: { url }
|
|
1063
|
+
};
|
|
1064
|
+
if (caption) {
|
|
1065
|
+
data.caption = _NotionBlockBuilder.richText(caption);
|
|
1066
|
+
}
|
|
1067
|
+
return { object: "block", type: "pdf", pdf: data };
|
|
1068
|
+
}
|
|
1069
|
+
static embed(url, caption) {
|
|
1070
|
+
const data = { url };
|
|
1071
|
+
if (caption) {
|
|
1072
|
+
data.caption = _NotionBlockBuilder.richText(caption);
|
|
1073
|
+
}
|
|
1074
|
+
return { object: "block", type: "embed", embed: data };
|
|
1075
|
+
}
|
|
1076
|
+
// ─── Advanced Blocks ────────────────────────────────────
|
|
1077
|
+
static tableOfContents(color) {
|
|
1078
|
+
return {
|
|
1079
|
+
object: "block",
|
|
1080
|
+
type: "table_of_contents",
|
|
1081
|
+
table_of_contents: { color: safeColor(color ?? "default") }
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
static breadcrumb() {
|
|
1085
|
+
return { object: "block", type: "breadcrumb", breadcrumb: {} };
|
|
1086
|
+
}
|
|
1087
|
+
static columnList(columns) {
|
|
1088
|
+
const children = columns.map((colBlocks) => ({
|
|
1089
|
+
object: "block",
|
|
1090
|
+
type: "column",
|
|
1091
|
+
column: { children: colBlocks }
|
|
1092
|
+
}));
|
|
1093
|
+
return {
|
|
1094
|
+
object: "block",
|
|
1095
|
+
type: "column_list",
|
|
1096
|
+
column_list: { children }
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
static syncedBlockOriginal(children) {
|
|
1100
|
+
return {
|
|
1101
|
+
object: "block",
|
|
1102
|
+
type: "synced_block",
|
|
1103
|
+
synced_block: { synced_from: null, children }
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
static syncedBlockDuplicate(originalBlockId) {
|
|
1107
|
+
return {
|
|
1108
|
+
object: "block",
|
|
1109
|
+
type: "synced_block",
|
|
1110
|
+
synced_block: { synced_from: { block_id: originalBlockId } }
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
static linkToPage(pageId) {
|
|
1114
|
+
return {
|
|
1115
|
+
object: "block",
|
|
1116
|
+
type: "link_to_page",
|
|
1117
|
+
link_to_page: { type: "page_id", page_id: pageId }
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
static table(rows, hasColumnHeader, hasRowHeader) {
|
|
1121
|
+
if (rows.length === 0) rows = [["", ""]];
|
|
1122
|
+
const width = rows[0].length;
|
|
1123
|
+
const tableRows = rows.map((row) => {
|
|
1124
|
+
const cells = row.map((cell) => _NotionBlockBuilder.richText(cell));
|
|
1125
|
+
while (cells.length < width) {
|
|
1126
|
+
cells.push(_NotionBlockBuilder.richText(""));
|
|
1127
|
+
}
|
|
1128
|
+
return {
|
|
1129
|
+
object: "block",
|
|
1130
|
+
type: "table_row",
|
|
1131
|
+
table_row: { cells }
|
|
1132
|
+
};
|
|
1133
|
+
});
|
|
1134
|
+
return {
|
|
1135
|
+
object: "block",
|
|
1136
|
+
type: "table",
|
|
1137
|
+
table: {
|
|
1138
|
+
table_width: width,
|
|
1139
|
+
has_column_header: hasColumnHeader ?? true,
|
|
1140
|
+
has_row_header: hasRowHeader ?? false,
|
|
1141
|
+
children: tableRows
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
function computeHash(content) {
|
|
1147
|
+
return createHash("sha256").update(content, "utf-8").digest("hex");
|
|
1148
|
+
}
|
|
1149
|
+
function computeBufferHash(buffer) {
|
|
1150
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// src/sync/change-detector.ts
|
|
1154
|
+
var ChangeDetector = class {
|
|
1155
|
+
constructor(stateDb) {
|
|
1156
|
+
this.stateDb = stateDb;
|
|
1157
|
+
}
|
|
1158
|
+
stateDb;
|
|
1159
|
+
detectLocalChanges(currentFiles) {
|
|
1160
|
+
const changes = [];
|
|
1161
|
+
const existingPaths = /* @__PURE__ */ new Set();
|
|
1162
|
+
for (const file of currentFiles) {
|
|
1163
|
+
existingPaths.add(file.path);
|
|
1164
|
+
const record = this.stateDb.getByPath(file.path);
|
|
1165
|
+
if (!record) {
|
|
1166
|
+
changes.push({
|
|
1167
|
+
path: file.path,
|
|
1168
|
+
type: "created",
|
|
1169
|
+
currentHash: computeHash(file.content),
|
|
1170
|
+
previousHash: null
|
|
1171
|
+
});
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
if (record.localLastModified && file.mtime === record.localLastModified) {
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
const currentHash = computeHash(file.content);
|
|
1178
|
+
if (record.contentHash !== currentHash) {
|
|
1179
|
+
changes.push({
|
|
1180
|
+
path: file.path,
|
|
1181
|
+
type: "modified",
|
|
1182
|
+
currentHash,
|
|
1183
|
+
previousHash: record.contentHash
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
const syncedRecords = this.stateDb.getByStatus("synced");
|
|
1188
|
+
for (const record of syncedRecords) {
|
|
1189
|
+
if (!existingPaths.has(record.obsidianPath)) {
|
|
1190
|
+
changes.push({
|
|
1191
|
+
path: record.obsidianPath,
|
|
1192
|
+
type: "deleted",
|
|
1193
|
+
currentHash: "",
|
|
1194
|
+
previousHash: record.contentHash
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
return this.detectMoves(changes);
|
|
1199
|
+
}
|
|
1200
|
+
detectMoves(changes) {
|
|
1201
|
+
const result = [];
|
|
1202
|
+
const created = changes.filter((c) => c.type === "created");
|
|
1203
|
+
const deleted = changes.filter((c) => c.type === "deleted");
|
|
1204
|
+
const others = changes.filter((c) => c.type !== "created" && c.type !== "deleted");
|
|
1205
|
+
const matchedCreated = /* @__PURE__ */ new Set();
|
|
1206
|
+
const matchedDeleted = /* @__PURE__ */ new Set();
|
|
1207
|
+
for (const del of deleted) {
|
|
1208
|
+
const match = created.find(
|
|
1209
|
+
(c) => c.currentHash === del.previousHash && !matchedCreated.has(c.path)
|
|
1210
|
+
);
|
|
1211
|
+
if (match) {
|
|
1212
|
+
matchedCreated.add(match.path);
|
|
1213
|
+
matchedDeleted.add(del.path);
|
|
1214
|
+
result.push({
|
|
1215
|
+
path: match.path,
|
|
1216
|
+
type: "moved",
|
|
1217
|
+
currentHash: match.currentHash,
|
|
1218
|
+
previousHash: del.previousHash,
|
|
1219
|
+
movedFrom: del.path
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
for (const c of created) {
|
|
1224
|
+
if (!matchedCreated.has(c.path)) result.push(c);
|
|
1225
|
+
}
|
|
1226
|
+
for (const d of deleted) {
|
|
1227
|
+
if (!matchedDeleted.has(d.path)) result.push(d);
|
|
1228
|
+
}
|
|
1229
|
+
result.push(...others);
|
|
1230
|
+
return result;
|
|
1231
|
+
}
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
// src/utils/logger.ts
|
|
1235
|
+
var consoleLogger = {
|
|
1236
|
+
warn: (msg, ...args) => console.warn(msg, ...args),
|
|
1237
|
+
error: (msg, ...args) => console.error(msg, ...args),
|
|
1238
|
+
info: (msg, ...args) => console.info(msg, ...args),
|
|
1239
|
+
debug: () => {
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
var currentLogger = consoleLogger;
|
|
1243
|
+
function setLogger(logger) {
|
|
1244
|
+
currentLogger = logger;
|
|
1245
|
+
}
|
|
1246
|
+
function getLogger() {
|
|
1247
|
+
return currentLogger;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// src/converter/pipeline.ts
|
|
1251
|
+
var ConversionPipeline = class {
|
|
1252
|
+
preProcessors = [];
|
|
1253
|
+
postProcessors = [];
|
|
1254
|
+
registerPreProcessor(processor) {
|
|
1255
|
+
this.preProcessors.push(processor);
|
|
1256
|
+
this.preProcessors.sort((a, b) => a.order - b.order);
|
|
1257
|
+
}
|
|
1258
|
+
registerPostProcessor(processor) {
|
|
1259
|
+
this.postProcessors.push(processor);
|
|
1260
|
+
this.postProcessors.sort((a, b) => a.order - b.order);
|
|
1261
|
+
}
|
|
1262
|
+
selectPath(content) {
|
|
1263
|
+
const hasInlineDb = /%% im-nobsidian:inline-db/.test(content);
|
|
1264
|
+
const hasColumnLayout = />\s*\[!col\]/.test(content);
|
|
1265
|
+
const hasToggleHeading = /%% im-nobsidian:toggle-heading/.test(content);
|
|
1266
|
+
if (hasInlineDb || hasColumnLayout || hasToggleHeading) {
|
|
1267
|
+
return "block-api";
|
|
1268
|
+
}
|
|
1269
|
+
return "markdown-api";
|
|
1270
|
+
}
|
|
1271
|
+
convertToNotion(markdown, context) {
|
|
1272
|
+
let input = {
|
|
1273
|
+
content: markdown,
|
|
1274
|
+
metadata: {},
|
|
1275
|
+
context
|
|
1276
|
+
};
|
|
1277
|
+
for (const processor of this.preProcessors) {
|
|
1278
|
+
try {
|
|
1279
|
+
const output = processor.process(input);
|
|
1280
|
+
input = {
|
|
1281
|
+
content: output.content,
|
|
1282
|
+
metadata: output.metadata,
|
|
1283
|
+
context
|
|
1284
|
+
};
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
getLogger().warn(`[${processor.name}] Pre-processor failed:`, error);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return {
|
|
1290
|
+
content: input.content,
|
|
1291
|
+
properties: input.metadata.properties ?? {},
|
|
1292
|
+
images: input.metadata.images ?? [],
|
|
1293
|
+
preserveMarkers: input.metadata.preserveMarkers ?? []
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
convertToMarkdown(content, context, metadata) {
|
|
1297
|
+
let input = {
|
|
1298
|
+
content,
|
|
1299
|
+
metadata: metadata ?? {},
|
|
1300
|
+
context
|
|
1301
|
+
};
|
|
1302
|
+
for (const processor of this.postProcessors) {
|
|
1303
|
+
try {
|
|
1304
|
+
const output = processor.process(input);
|
|
1305
|
+
input = {
|
|
1306
|
+
content: output.content,
|
|
1307
|
+
metadata: output.metadata,
|
|
1308
|
+
context
|
|
1309
|
+
};
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
getLogger().warn(`[${processor.name}] Post-processor failed:`, error);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
return input.content;
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
var FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
1318
|
+
var WIKILINK_IN_YAML = /\[\[([^\]]+)\]\]/g;
|
|
1319
|
+
var FrontmatterExtractor = class {
|
|
1320
|
+
name = "FrontmatterExtractor";
|
|
1321
|
+
order = 10;
|
|
1322
|
+
process(input) {
|
|
1323
|
+
const sanitized = input.content.replace(FRONTMATTER_REGEX, (match, yaml) => {
|
|
1324
|
+
const fixed = yaml.replace(WIKILINK_IN_YAML, "$1");
|
|
1325
|
+
return match.replace(yaml, fixed);
|
|
1326
|
+
});
|
|
1327
|
+
const { data, content } = matter(sanitized);
|
|
1328
|
+
return {
|
|
1329
|
+
content: content.trim(),
|
|
1330
|
+
metadata: {
|
|
1331
|
+
...input.metadata,
|
|
1332
|
+
properties: data
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
|
|
1338
|
+
// src/converter/pre-processors/wikilink.ts
|
|
1339
|
+
var WIKILINK_REGEX = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
|
1340
|
+
var WikilinkResolver = class {
|
|
1341
|
+
name = "WikilinkResolver";
|
|
1342
|
+
order = 20;
|
|
1343
|
+
process(input) {
|
|
1344
|
+
const resolved = input.content.replace(
|
|
1345
|
+
WIKILINK_REGEX,
|
|
1346
|
+
(_match, target, display) => {
|
|
1347
|
+
const label = display ?? target;
|
|
1348
|
+
return `**${label}**`;
|
|
1349
|
+
}
|
|
1350
|
+
);
|
|
1351
|
+
return {
|
|
1352
|
+
content: resolved,
|
|
1353
|
+
metadata: input.metadata
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
// src/converter/pre-processors/callout.ts
|
|
1359
|
+
var CALLOUT_REGEX = /^> \[!(\w+)\]([+-])?\s*(.*)?$/gm;
|
|
1360
|
+
var CALLOUT_EMOJI_MAP = {
|
|
1361
|
+
note: "\u{1F4DD}",
|
|
1362
|
+
abstract: "\u{1F4CB}",
|
|
1363
|
+
summary: "\u{1F4CB}",
|
|
1364
|
+
info: "\u2139\uFE0F",
|
|
1365
|
+
tip: "\u{1F4A1}",
|
|
1366
|
+
hint: "\u{1F4A1}",
|
|
1367
|
+
success: "\u2705",
|
|
1368
|
+
check: "\u2705",
|
|
1369
|
+
question: "\u2753",
|
|
1370
|
+
faq: "\u2753",
|
|
1371
|
+
warning: "\u26A0\uFE0F",
|
|
1372
|
+
caution: "\u26A0\uFE0F",
|
|
1373
|
+
failure: "\u274C",
|
|
1374
|
+
fail: "\u274C",
|
|
1375
|
+
danger: "\u{1F525}",
|
|
1376
|
+
error: "\u{1F525}",
|
|
1377
|
+
bug: "\u{1F41B}",
|
|
1378
|
+
example: "\u{1F4CC}",
|
|
1379
|
+
quote: "\u{1F4AC}",
|
|
1380
|
+
cite: "\u{1F4AC}"
|
|
1381
|
+
};
|
|
1382
|
+
var CalloutTransformer = class {
|
|
1383
|
+
name = "CalloutTransformer";
|
|
1384
|
+
order = 30;
|
|
1385
|
+
process(input) {
|
|
1386
|
+
const content = input.content.replace(
|
|
1387
|
+
CALLOUT_REGEX,
|
|
1388
|
+
(_match, type, foldable, title) => {
|
|
1389
|
+
const emoji = CALLOUT_EMOJI_MAP[type.toLowerCase()] ?? "\u{1F4DD}";
|
|
1390
|
+
const titleText = title?.trim() || type;
|
|
1391
|
+
const foldMeta = foldable ? `
|
|
1392
|
+
%% im-nobsidian:callout:type=${type}&foldable=${foldable === "+" ? "open" : "closed"} %%` : "";
|
|
1393
|
+
return `> ${emoji} **${titleText}**${foldMeta}`;
|
|
1394
|
+
}
|
|
1395
|
+
);
|
|
1396
|
+
return {
|
|
1397
|
+
content,
|
|
1398
|
+
metadata: input.metadata
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1403
|
+
// src/converter/pre-processors/math.ts
|
|
1404
|
+
var BLOCK_MATH_REGEX = /\$\$([\s\S]+?)\$\$/g;
|
|
1405
|
+
var INLINE_MATH_REGEX = /(?<![\\$])\$([^\n$]+?)\$(?!\$)/g;
|
|
1406
|
+
var MathNormalizer = class {
|
|
1407
|
+
name = "MathNormalizer";
|
|
1408
|
+
order = 50;
|
|
1409
|
+
process(input) {
|
|
1410
|
+
let content = input.content;
|
|
1411
|
+
content = content.replace(BLOCK_MATH_REGEX, (_match, equation) => {
|
|
1412
|
+
return `$$
|
|
1413
|
+
${equation.trim()}
|
|
1414
|
+
$$`;
|
|
1415
|
+
});
|
|
1416
|
+
content = content.replace(INLINE_MATH_REGEX, (_match, equation) => {
|
|
1417
|
+
return `$${equation.trim()}$`;
|
|
1418
|
+
});
|
|
1419
|
+
return {
|
|
1420
|
+
content,
|
|
1421
|
+
metadata: input.metadata
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
|
|
1426
|
+
// src/converter/pre-processors/embed.ts
|
|
1427
|
+
var OBSIDIAN_EMBED_REGEX = /!\[\[([^\]]+)\]\]/g;
|
|
1428
|
+
var MARKDOWN_IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
1429
|
+
var VIDEO_HOSTS = ["youtube.com", "youtu.be", "vimeo.com"];
|
|
1430
|
+
var EmbedResolver = class {
|
|
1431
|
+
name = "EmbedResolver";
|
|
1432
|
+
order = 60;
|
|
1433
|
+
process(input) {
|
|
1434
|
+
const images = input.metadata.images ? [...input.metadata.images] : [];
|
|
1435
|
+
const isPush = input.context.direction === "push";
|
|
1436
|
+
let content = input.content.replace(OBSIDIAN_EMBED_REGEX, (_match, target) => {
|
|
1437
|
+
if (isImageFile(target)) {
|
|
1438
|
+
images.push({ url: target, localPath: target, isExternal: false });
|
|
1439
|
+
if (isPush && !isExternalUrl(target)) {
|
|
1440
|
+
const fileName = target.split("/").pop() ?? target;
|
|
1441
|
+
return `> \u{1F4CE} ${fileName} (\uB85C\uCEEC \uC774\uBBF8\uC9C0 \u2014 Notion API \uC81C\uD55C\uC73C\uB85C \uC5C5\uB85C\uB4DC \uBD88\uAC00)
|
|
1442
|
+
> %% im-nobsidian:local-image:${target} %%`;
|
|
1443
|
+
}
|
|
1444
|
+
return `})`;
|
|
1445
|
+
}
|
|
1446
|
+
return `[${target}](${encodeURI(target)})`;
|
|
1447
|
+
});
|
|
1448
|
+
content = content.replace(MARKDOWN_IMAGE_REGEX, (_match, alt, url) => {
|
|
1449
|
+
if (isVideoUrl(url)) {
|
|
1450
|
+
return `%% im-nobsidian:embed:type=video&url=${encodeURIComponent(url)} %%
|
|
1451
|
+
[${alt || "Video"}](${url})`;
|
|
1452
|
+
}
|
|
1453
|
+
if (isExternalUrl(url)) {
|
|
1454
|
+
images.push({ url, isExternal: true });
|
|
1455
|
+
}
|
|
1456
|
+
return ``;
|
|
1457
|
+
});
|
|
1458
|
+
return {
|
|
1459
|
+
content,
|
|
1460
|
+
metadata: { ...input.metadata, images }
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1464
|
+
function isImageFile(path) {
|
|
1465
|
+
return /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(path);
|
|
1466
|
+
}
|
|
1467
|
+
function isVideoUrl(url) {
|
|
1468
|
+
return VIDEO_HOSTS.some((host) => url.includes(host));
|
|
1469
|
+
}
|
|
1470
|
+
function isExternalUrl(url) {
|
|
1471
|
+
return url.startsWith("http://") || url.startsWith("https://");
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// src/converter/pre-processors/preserve-marker.ts
|
|
1475
|
+
var MARKER_REGEX = /%% im-nobsidian:(\w[\w-]*):([^\s]+?) %%/g;
|
|
1476
|
+
var PreserveMarkerCollector = class {
|
|
1477
|
+
name = "PreserveMarkerCollector";
|
|
1478
|
+
order = 70;
|
|
1479
|
+
process(input) {
|
|
1480
|
+
const markers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
|
|
1481
|
+
const matches = [...input.content.matchAll(MARKER_REGEX)];
|
|
1482
|
+
for (const match of matches) {
|
|
1483
|
+
markers.push({
|
|
1484
|
+
type: match[1],
|
|
1485
|
+
params: parseMarkerParams(match[2]),
|
|
1486
|
+
startIndex: match.index
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
return {
|
|
1490
|
+
content: input.content,
|
|
1491
|
+
metadata: { ...input.metadata, preserveMarkers: markers }
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
function parseMarkerParams(paramString) {
|
|
1496
|
+
const params = {};
|
|
1497
|
+
for (const pair of paramString.split("&")) {
|
|
1498
|
+
const eqIdx = pair.indexOf("=");
|
|
1499
|
+
if (eqIdx > 0) {
|
|
1500
|
+
params[pair.slice(0, eqIdx)] = decodeURIComponent(pair.slice(eqIdx + 1));
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
return params;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// src/converter/pre-processors/unsupported-block-stripper.ts
|
|
1507
|
+
var UNSUPPORTED_CALLOUT_REGEX = /> \[!im-nobsidian-unsupported\][^\n]*\n(?:>[^\n]*\n)*/g;
|
|
1508
|
+
var UnsupportedBlockStripper = class {
|
|
1509
|
+
name = "UnsupportedBlockStripper";
|
|
1510
|
+
order = 5;
|
|
1511
|
+
process(input) {
|
|
1512
|
+
if (input.context.direction !== "push") {
|
|
1513
|
+
return { content: input.content, metadata: input.metadata };
|
|
1514
|
+
}
|
|
1515
|
+
const content = input.content.replace(UNSUPPORTED_CALLOUT_REGEX, "");
|
|
1516
|
+
return {
|
|
1517
|
+
content: content.replace(/\n{3,}/g, "\n\n"),
|
|
1518
|
+
metadata: input.metadata
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
// src/converter/pre-processors/properties-table.ts
|
|
1524
|
+
var PropertiesTableInjector = class {
|
|
1525
|
+
name = "PropertiesTableInjector";
|
|
1526
|
+
order = 15;
|
|
1527
|
+
process(input) {
|
|
1528
|
+
if (input.context.direction !== "push") {
|
|
1529
|
+
return { content: input.content, metadata: input.metadata };
|
|
1530
|
+
}
|
|
1531
|
+
if (input.context.parentMode === "database") {
|
|
1532
|
+
return { content: input.content, metadata: input.metadata };
|
|
1533
|
+
}
|
|
1534
|
+
const properties = input.metadata.properties;
|
|
1535
|
+
if (!properties || Object.keys(properties).length === 0) {
|
|
1536
|
+
return { content: input.content, metadata: input.metadata };
|
|
1537
|
+
}
|
|
1538
|
+
const filteredEntries = Object.entries(properties).filter(([key]) => key !== "title");
|
|
1539
|
+
if (filteredEntries.length === 0) {
|
|
1540
|
+
return { content: input.content, metadata: input.metadata };
|
|
1541
|
+
}
|
|
1542
|
+
const rows = filteredEntries.map(([key, value]) => {
|
|
1543
|
+
const display = formatValue(value);
|
|
1544
|
+
return `| ${key} | ${display} |`;
|
|
1545
|
+
});
|
|
1546
|
+
const table = ["| Property | Value |", "| --- | --- |", ...rows].join("\n");
|
|
1547
|
+
const content = table + "\n\n---\n\n" + input.content;
|
|
1548
|
+
return { content, metadata: input.metadata };
|
|
1549
|
+
}
|
|
1550
|
+
};
|
|
1551
|
+
function formatValue(value) {
|
|
1552
|
+
if (value === null || value === void 0) return "";
|
|
1553
|
+
if (value instanceof Date) {
|
|
1554
|
+
return value.toISOString().replace(/T00:00:00(?:\.000)?Z$/, "");
|
|
1555
|
+
}
|
|
1556
|
+
if (Array.isArray(value)) {
|
|
1557
|
+
return value.map((v) => formatValue(v)).join(", ");
|
|
1558
|
+
}
|
|
1559
|
+
if (typeof value === "object") {
|
|
1560
|
+
return JSON.stringify(value);
|
|
1561
|
+
}
|
|
1562
|
+
return String(value);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// src/converter/pre-processors/html-annotation.ts
|
|
1566
|
+
var UNDERLINE_REGEX = /<u>([\s\S]*?)<\/u>/g;
|
|
1567
|
+
var COLOR_SPAN_REGEX = /<span class="notion-(\w+?)(?:-bg)?">([\s\S]*?)<\/span>/g;
|
|
1568
|
+
var IM_NOBSIDIAN_COLOR_REGEX = /%% im-nobsidian:color:(\w+(?:_background)?) %%([\s\S]*?)%% im-nobsidian:end %%/g;
|
|
1569
|
+
var HtmlAnnotationStripper = class {
|
|
1570
|
+
name = "HtmlAnnotationStripper";
|
|
1571
|
+
order = 5;
|
|
1572
|
+
process(input) {
|
|
1573
|
+
if (input.context.direction !== "push") {
|
|
1574
|
+
return { content: input.content, metadata: input.metadata };
|
|
1575
|
+
}
|
|
1576
|
+
let content = input.content;
|
|
1577
|
+
content = content.replace(IM_NOBSIDIAN_COLOR_REGEX, (_m, _color, text) => text);
|
|
1578
|
+
content = content.replace(UNDERLINE_REGEX, (_m, text) => text);
|
|
1579
|
+
content = content.replace(COLOR_SPAN_REGEX, (_m, _color, text) => text);
|
|
1580
|
+
return { content, metadata: input.metadata };
|
|
1581
|
+
}
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
// src/converter/post-processors/mention-to-wikilink.ts
|
|
1585
|
+
var NOTION_LINK_REGEX = /\[([^\]]+)\]\((?:https:\/\/www\.notion\.so\/|notion:\/\/)([a-f0-9-]+)\)/g;
|
|
1586
|
+
var MentionToWikilink = class {
|
|
1587
|
+
name = "MentionToWikilink";
|
|
1588
|
+
order = 10;
|
|
1589
|
+
process(input) {
|
|
1590
|
+
const content = input.content.replace(
|
|
1591
|
+
NOTION_LINK_REGEX,
|
|
1592
|
+
(_match, display, _pageId) => {
|
|
1593
|
+
return `[[${display}]]`;
|
|
1594
|
+
}
|
|
1595
|
+
);
|
|
1596
|
+
return {
|
|
1597
|
+
content,
|
|
1598
|
+
metadata: input.metadata
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
};
|
|
1602
|
+
|
|
1603
|
+
// src/converter/post-processors/preserve-marker-injector.ts
|
|
1604
|
+
var PreserveMarkerInjector = class {
|
|
1605
|
+
name = "PreserveMarkerInjector";
|
|
1606
|
+
order = 100;
|
|
1607
|
+
process(input) {
|
|
1608
|
+
if (input.context.direction !== "pull") {
|
|
1609
|
+
return { content: input.content, metadata: input.metadata };
|
|
1610
|
+
}
|
|
1611
|
+
const markers = input.metadata.preserveMarkers;
|
|
1612
|
+
if (!markers || markers.length === 0) {
|
|
1613
|
+
return { content: input.content, metadata: input.metadata };
|
|
1614
|
+
}
|
|
1615
|
+
let content = input.content;
|
|
1616
|
+
const missingMarkers = markers.filter((m) => !content.includes(this.formatMarker(m)));
|
|
1617
|
+
if (missingMarkers.length === 0) {
|
|
1618
|
+
return { content, metadata: input.metadata };
|
|
1619
|
+
}
|
|
1620
|
+
const markerLines = missingMarkers.map((m) => this.formatMarker(m));
|
|
1621
|
+
const suffix = "\n\n" + markerLines.join("\n");
|
|
1622
|
+
content = content.trimEnd() + suffix + "\n";
|
|
1623
|
+
return { content, metadata: input.metadata };
|
|
1624
|
+
}
|
|
1625
|
+
formatMarker(marker) {
|
|
1626
|
+
const params = Object.entries(marker.params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
|
|
1627
|
+
return `%% im-nobsidian:${marker.type}:${params} %%`;
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
|
|
1631
|
+
// src/converter/post-processors/callout-restorer.ts
|
|
1632
|
+
var EMOJI_TYPE_MAP = {
|
|
1633
|
+
"\u{1F4DD}": "note",
|
|
1634
|
+
"\u{1F4CB}": "abstract",
|
|
1635
|
+
"\u2139\uFE0F": "info",
|
|
1636
|
+
"\u{1F4A1}": "tip",
|
|
1637
|
+
"\u2705": "success",
|
|
1638
|
+
"\u2753": "question",
|
|
1639
|
+
"\u26A0\uFE0F": "warning",
|
|
1640
|
+
"\u274C": "failure",
|
|
1641
|
+
"\u{1F525}": "danger",
|
|
1642
|
+
"\u{1F41B}": "bug",
|
|
1643
|
+
"\u{1F4CC}": "example",
|
|
1644
|
+
"\u{1F4AC}": "quote"
|
|
1645
|
+
};
|
|
1646
|
+
var CALLOUT_PRESERVE_REGEX = /%% im-nobsidian:callout:type=(\w+)&foldable=(open|closed) %%/g;
|
|
1647
|
+
var CalloutRestorer = class {
|
|
1648
|
+
name = "CalloutRestorer";
|
|
1649
|
+
order = 20;
|
|
1650
|
+
process(input) {
|
|
1651
|
+
let content = input.content;
|
|
1652
|
+
content = content.replace(CALLOUT_PRESERVE_REGEX, (_match, type, foldable) => {
|
|
1653
|
+
const foldChar = foldable === "open" ? "+" : "-";
|
|
1654
|
+
return `[!${type}]${foldChar}`;
|
|
1655
|
+
});
|
|
1656
|
+
for (const [emoji, type] of Object.entries(EMOJI_TYPE_MAP)) {
|
|
1657
|
+
const pattern = new RegExp(`> ${escapeRegex(emoji)} \\*\\*(.+?)\\*\\*`, "g");
|
|
1658
|
+
content = content.replace(pattern, (_match, title) => {
|
|
1659
|
+
if (title.toLowerCase() === type) {
|
|
1660
|
+
return `> [!${type}]`;
|
|
1661
|
+
}
|
|
1662
|
+
return `> [!${type}] ${title}`;
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
return {
|
|
1666
|
+
content,
|
|
1667
|
+
metadata: input.metadata
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
};
|
|
1671
|
+
function escapeRegex(str) {
|
|
1672
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// src/converter/post-processors/color-annotator.ts
|
|
1676
|
+
var COLOR_MARKER_REGEX = /%% im-nobsidian:color:(\w+(?:_background)?) %%([\s\S]*?)%% im-nobsidian:end %%/g;
|
|
1677
|
+
var ColorAnnotator = class {
|
|
1678
|
+
name = "ColorAnnotator";
|
|
1679
|
+
order = 30;
|
|
1680
|
+
process(input) {
|
|
1681
|
+
const content = input.content.replace(
|
|
1682
|
+
COLOR_MARKER_REGEX,
|
|
1683
|
+
(_match, color, text) => {
|
|
1684
|
+
const cssClass = color.endsWith("_background") ? `notion-${color.replace("_background", "-bg")}` : `notion-${color}`;
|
|
1685
|
+
return `<span class="${cssClass}">${text}</span>`;
|
|
1686
|
+
}
|
|
1687
|
+
);
|
|
1688
|
+
return {
|
|
1689
|
+
content,
|
|
1690
|
+
metadata: input.metadata
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
};
|
|
1694
|
+
var FrontmatterGenerator = class {
|
|
1695
|
+
name = "FrontmatterGenerator";
|
|
1696
|
+
order = 90;
|
|
1697
|
+
process(input) {
|
|
1698
|
+
const properties = input.metadata.properties;
|
|
1699
|
+
if (!properties || Object.keys(properties).length === 0) {
|
|
1700
|
+
return { content: input.content, metadata: input.metadata };
|
|
1701
|
+
}
|
|
1702
|
+
const content = matter.stringify(input.content, properties);
|
|
1703
|
+
return {
|
|
1704
|
+
content,
|
|
1705
|
+
metadata: input.metadata
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
|
|
1710
|
+
// src/converter/post-processors/local-image-restorer.ts
|
|
1711
|
+
var LOCAL_IMAGE_MARKER_REGEX = />\s*📎\s*[^\n]*\n>\s*%%\s*im-nobsidian:local-image:([^\s]+)\s*%%/g;
|
|
1712
|
+
var LocalImageRestorer = class {
|
|
1713
|
+
name = "LocalImageRestorer";
|
|
1714
|
+
order = 15;
|
|
1715
|
+
process(input) {
|
|
1716
|
+
if (input.context.direction !== "pull") {
|
|
1717
|
+
return { content: input.content, metadata: input.metadata };
|
|
1718
|
+
}
|
|
1719
|
+
const content = input.content.replace(LOCAL_IMAGE_MARKER_REGEX, (_match, target) => {
|
|
1720
|
+
return `![[${target}]]`;
|
|
1721
|
+
});
|
|
1722
|
+
return { content, metadata: input.metadata };
|
|
1723
|
+
}
|
|
1724
|
+
};
|
|
1725
|
+
|
|
1726
|
+
// src/converter/post-processors/properties-table-restorer.ts
|
|
1727
|
+
var PROPERTIES_TABLE_REGEX = /^\s*\| Property\s*\| Value\s*\|\n\s*\|\s*-{3,}\s*\|\s*-{3,}\s*\|\n((?:\|[^\n]+\|\n?)+)\n*---\n*/;
|
|
1728
|
+
var PropertiesTableRestorer = class {
|
|
1729
|
+
name = "PropertiesTableRestorer";
|
|
1730
|
+
order = 5;
|
|
1731
|
+
process(input) {
|
|
1732
|
+
if (input.context.direction !== "pull") {
|
|
1733
|
+
return { content: input.content, metadata: input.metadata };
|
|
1734
|
+
}
|
|
1735
|
+
const match = input.content.match(PROPERTIES_TABLE_REGEX);
|
|
1736
|
+
if (!match) {
|
|
1737
|
+
return { content: input.content, metadata: input.metadata };
|
|
1738
|
+
}
|
|
1739
|
+
const rows = match[1].trim().split("\n");
|
|
1740
|
+
const properties = {
|
|
1741
|
+
...input.metadata.properties
|
|
1742
|
+
};
|
|
1743
|
+
for (const row of rows) {
|
|
1744
|
+
const cells = row.split("|").map((c) => c.trim()).filter(Boolean);
|
|
1745
|
+
if (cells.length >= 2) {
|
|
1746
|
+
const key = cells[0];
|
|
1747
|
+
const rawValue = cells[1];
|
|
1748
|
+
properties[key] = parseValue(rawValue);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
const content = input.content.replace(PROPERTIES_TABLE_REGEX, "").trimStart();
|
|
1752
|
+
return {
|
|
1753
|
+
content,
|
|
1754
|
+
metadata: { ...input.metadata, properties }
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
};
|
|
1758
|
+
function stripQuotes(s) {
|
|
1759
|
+
if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
|
|
1760
|
+
return s.slice(1, -1);
|
|
1761
|
+
}
|
|
1762
|
+
return s;
|
|
1763
|
+
}
|
|
1764
|
+
var ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/;
|
|
1765
|
+
function parseValue(raw) {
|
|
1766
|
+
if (raw === "" || raw === "null") return null;
|
|
1767
|
+
if (raw === "true") return true;
|
|
1768
|
+
if (raw === "false") return false;
|
|
1769
|
+
const unquoted = stripQuotes(raw);
|
|
1770
|
+
if (ISO_DATE_REGEX.test(unquoted)) {
|
|
1771
|
+
return unquoted.replace(/T00:00:00(?:\.000)?Z?$/, "");
|
|
1772
|
+
}
|
|
1773
|
+
const num = Number(unquoted);
|
|
1774
|
+
if (!isNaN(num) && unquoted.trim() !== "") return num;
|
|
1775
|
+
if (unquoted.includes(", ")) {
|
|
1776
|
+
const parts = unquoted.split(", ");
|
|
1777
|
+
const looksLikeArray = parts.every((p) => p.split(/\s+/).length <= 3);
|
|
1778
|
+
if (looksLikeArray) {
|
|
1779
|
+
return parts.map((v) => {
|
|
1780
|
+
const n = Number(v);
|
|
1781
|
+
return !isNaN(n) && v.trim() !== "" ? n : v;
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
if (unquoted.startsWith("{") || unquoted.startsWith("[")) {
|
|
1786
|
+
try {
|
|
1787
|
+
return JSON.parse(unquoted);
|
|
1788
|
+
} catch {
|
|
1789
|
+
return unquoted;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
return unquoted;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// src/converter/pipeline-factory.ts
|
|
1796
|
+
function createDefaultPipeline() {
|
|
1797
|
+
const pipeline = new ConversionPipeline();
|
|
1798
|
+
pipeline.registerPreProcessor(new HtmlAnnotationStripper());
|
|
1799
|
+
pipeline.registerPreProcessor(new UnsupportedBlockStripper());
|
|
1800
|
+
pipeline.registerPreProcessor(new FrontmatterExtractor());
|
|
1801
|
+
pipeline.registerPreProcessor(new PropertiesTableInjector());
|
|
1802
|
+
pipeline.registerPreProcessor(new WikilinkResolver());
|
|
1803
|
+
pipeline.registerPreProcessor(new CalloutTransformer());
|
|
1804
|
+
pipeline.registerPreProcessor(new MathNormalizer());
|
|
1805
|
+
pipeline.registerPreProcessor(new EmbedResolver());
|
|
1806
|
+
pipeline.registerPreProcessor(new PreserveMarkerCollector());
|
|
1807
|
+
pipeline.registerPostProcessor(new PropertiesTableRestorer());
|
|
1808
|
+
pipeline.registerPostProcessor(new LocalImageRestorer());
|
|
1809
|
+
pipeline.registerPostProcessor(new PreserveMarkerInjector());
|
|
1810
|
+
pipeline.registerPostProcessor(new MentionToWikilink());
|
|
1811
|
+
pipeline.registerPostProcessor(new CalloutRestorer());
|
|
1812
|
+
pipeline.registerPostProcessor(new ColorAnnotator());
|
|
1813
|
+
pipeline.registerPostProcessor(new FrontmatterGenerator());
|
|
1814
|
+
return pipeline;
|
|
1815
|
+
}
|
|
1816
|
+
var TRULY_UNSUPPORTED_BLOCK_TYPES = ["unsupported", "template"];
|
|
1817
|
+
var DIVIDER_PLACEHOLDER = "\u200B%%IM-NOBSIDIAN_DIVIDER%%\u200B";
|
|
1818
|
+
var TOGGLE_START = "%%im-nobsidian:toggle:start%%";
|
|
1819
|
+
var TOGGLE_END = "%%im-nobsidian:toggle:end%%";
|
|
1820
|
+
var COLUMN_LIST_START = "%%im-nobsidian:column-list:start%%";
|
|
1821
|
+
var COLUMN_SEP = "%%im-nobsidian:column%%";
|
|
1822
|
+
var COLUMN_LIST_END = "%%im-nobsidian:column-list:end%%";
|
|
1823
|
+
var VIDEO_URL_PATTERNS = [
|
|
1824
|
+
/^https?:\/\/(www\.)?youtube\.com\/watch/,
|
|
1825
|
+
/^https?:\/\/youtu\.be\//,
|
|
1826
|
+
/^https?:\/\/(www\.)?vimeo\.com\//,
|
|
1827
|
+
/^https?:\/\/(www\.)?dailymotion\.com\//,
|
|
1828
|
+
/^https?:\/\/(www\.)?loom\.com\/share\//
|
|
1829
|
+
];
|
|
1830
|
+
var EMBED_URL_PATTERNS = [
|
|
1831
|
+
/^https?:\/\/(www\.)?figma\.com\//,
|
|
1832
|
+
/^https?:\/\/docs\.google\.com\//,
|
|
1833
|
+
/^https?:\/\/drive\.google\.com\//,
|
|
1834
|
+
/^https?:\/\/(www\.)?miro\.com\//,
|
|
1835
|
+
/^https?:\/\/(www\.)?twitter\.com\//,
|
|
1836
|
+
/^https?:\/\/(www\.)?x\.com\//,
|
|
1837
|
+
/^https?:\/\/codepen\.io\//,
|
|
1838
|
+
/^https?:\/\/gist\.github\.com\//,
|
|
1839
|
+
/^https?:\/\/(www\.)?spotify\.com\//,
|
|
1840
|
+
/^https?:\/\/(www\.)?soundcloud\.com\//
|
|
1841
|
+
];
|
|
1842
|
+
function escapeRegex2(str) {
|
|
1843
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1844
|
+
}
|
|
1845
|
+
function richTextToPlain(richText) {
|
|
1846
|
+
if (!richText) return "";
|
|
1847
|
+
return richText.map((t) => t.plain_text).join("");
|
|
1848
|
+
}
|
|
1849
|
+
function richTextToMarkdown(richText) {
|
|
1850
|
+
if (!richText) return "";
|
|
1851
|
+
return richText.map((t) => {
|
|
1852
|
+
if (t.type === "equation" && t.equation) {
|
|
1853
|
+
return `$${t.equation.expression}$`;
|
|
1854
|
+
}
|
|
1855
|
+
if (t.type === "mention" && t.mention) {
|
|
1856
|
+
return formatMention(t.mention);
|
|
1857
|
+
}
|
|
1858
|
+
let text = t.plain_text;
|
|
1859
|
+
const a = t.annotations;
|
|
1860
|
+
if (a?.code) text = `\`${text}\``;
|
|
1861
|
+
if (a?.bold) text = `**${text}**`;
|
|
1862
|
+
if (a?.italic) text = `*${text}*`;
|
|
1863
|
+
if (a?.strikethrough) text = `~~${text}~~`;
|
|
1864
|
+
if (a?.underline) text = `<u>${text}</u>`;
|
|
1865
|
+
if (t.href) text = `[${text}](${t.href})`;
|
|
1866
|
+
const color = a?.color;
|
|
1867
|
+
if (color && color !== "default") {
|
|
1868
|
+
text = `%% im-nobsidian:color:${color} %%${text}%% im-nobsidian:end %%`;
|
|
1869
|
+
}
|
|
1870
|
+
return text;
|
|
1871
|
+
}).join("");
|
|
1872
|
+
}
|
|
1873
|
+
function formatMention(mention) {
|
|
1874
|
+
switch (mention.type) {
|
|
1875
|
+
case "page":
|
|
1876
|
+
return mention.page ? `[[${mention.page.id}]]` : "";
|
|
1877
|
+
case "date": {
|
|
1878
|
+
if (!mention.date) return "";
|
|
1879
|
+
const start = mention.date.start;
|
|
1880
|
+
return mention.date.end ? `${start} \u2192 ${mention.date.end}` : start;
|
|
1881
|
+
}
|
|
1882
|
+
case "user":
|
|
1883
|
+
return mention.user?.name ? `@${mention.user.name}` : "@user";
|
|
1884
|
+
case "database":
|
|
1885
|
+
return mention.database ? `[[${mention.database.id}]]` : "";
|
|
1886
|
+
default:
|
|
1887
|
+
return "";
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
var BlockConverter = class {
|
|
1891
|
+
n2m = null;
|
|
1892
|
+
client = null;
|
|
1893
|
+
initNotionToMd(client) {
|
|
1894
|
+
this.client = client;
|
|
1895
|
+
this.n2m = new NotionToMarkdown({ notionClient: client });
|
|
1896
|
+
this.registerCustomTransformers();
|
|
1897
|
+
}
|
|
1898
|
+
registerCustomTransformers() {
|
|
1899
|
+
if (!this.n2m) return;
|
|
1900
|
+
for (const blockType of TRULY_UNSUPPORTED_BLOCK_TYPES) {
|
|
1901
|
+
this.n2m.setCustomTransformer(blockType, (block) => {
|
|
1902
|
+
const id = block.id ?? "unknown";
|
|
1903
|
+
return Promise.resolve(
|
|
1904
|
+
`> [!im-nobsidian-unsupported] Notion \uC804\uC6A9 \uBE14\uB85D
|
|
1905
|
+
> type: ${blockType}, id: ${id}
|
|
1906
|
+
> %%im-nobsidian:unsupported:type=${blockType}&id=${id}%%`
|
|
1907
|
+
);
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
this.registerToggleTransformer();
|
|
1911
|
+
this.registerColumnTransformer();
|
|
1912
|
+
this.registerSyncedBlockTransformer();
|
|
1913
|
+
this.registerBookmarkTransformer();
|
|
1914
|
+
this.registerVideoTransformer();
|
|
1915
|
+
this.registerAudioTransformer();
|
|
1916
|
+
this.registerFileTransformer();
|
|
1917
|
+
this.registerChildPageTransformer();
|
|
1918
|
+
this.registerChildDatabaseTransformer();
|
|
1919
|
+
this.registerLinkToPageTransformer();
|
|
1920
|
+
this.registerTocTransformer();
|
|
1921
|
+
this.registerBreadcrumbTransformer();
|
|
1922
|
+
this.registerLinkPreviewTransformer();
|
|
1923
|
+
this.registerCalloutTransformer();
|
|
1924
|
+
this.registerToggleHeadingTransformers();
|
|
1925
|
+
}
|
|
1926
|
+
registerToggleTransformer() {
|
|
1927
|
+
if (!this.n2m) return;
|
|
1928
|
+
const n2m = this.n2m;
|
|
1929
|
+
this.n2m.setCustomTransformer("toggle", async (block) => {
|
|
1930
|
+
const b = block;
|
|
1931
|
+
const title = richTextToMarkdown(b.toggle?.rich_text);
|
|
1932
|
+
const children = b.has_children ? await n2m.pageToMarkdown(b.id) : [];
|
|
1933
|
+
const childMd = (Array.isArray(children) ? n2m.toMarkdownString(children).parent : "") ?? "";
|
|
1934
|
+
const indented = childMd.split("\n").map((line) => line ? ` ${line}` : "").join("\n").trimEnd();
|
|
1935
|
+
return `${TOGGLE_START}
|
|
1936
|
+
- ${title}
|
|
1937
|
+
${indented}
|
|
1938
|
+
${TOGGLE_END}`;
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
registerToggleHeadingTransformers() {
|
|
1942
|
+
if (!this.n2m) return;
|
|
1943
|
+
const n2m = this.n2m;
|
|
1944
|
+
for (const level of [1, 2, 3]) {
|
|
1945
|
+
const headingKey = `heading_${level}`;
|
|
1946
|
+
this.n2m.setCustomTransformer(headingKey, async (block) => {
|
|
1947
|
+
const b = block;
|
|
1948
|
+
const headingData = b[headingKey];
|
|
1949
|
+
const text = richTextToMarkdown(headingData?.rich_text);
|
|
1950
|
+
const prefix = "#".repeat(level);
|
|
1951
|
+
if (headingData?.is_toggleable && b.has_children) {
|
|
1952
|
+
const children = await n2m.pageToMarkdown(b.id);
|
|
1953
|
+
const childMd = (Array.isArray(children) ? n2m.toMarkdownString(children).parent : "") ?? "";
|
|
1954
|
+
return `${prefix} ${text}
|
|
1955
|
+
|
|
1956
|
+
${childMd.trimEnd()}`;
|
|
1957
|
+
}
|
|
1958
|
+
return `${prefix} ${text}`;
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
registerColumnTransformer() {
|
|
1963
|
+
if (!this.n2m) return;
|
|
1964
|
+
const n2m = this.n2m;
|
|
1965
|
+
const client = this.client;
|
|
1966
|
+
this.n2m.setCustomTransformer("column_list", async (block) => {
|
|
1967
|
+
const b = block;
|
|
1968
|
+
const response = await client.blocks.children.list({ block_id: b.id });
|
|
1969
|
+
const columns = response.results;
|
|
1970
|
+
const parts = [];
|
|
1971
|
+
for (let i = 0; i < columns.length; i++) {
|
|
1972
|
+
const col = columns[i];
|
|
1973
|
+
const children = await n2m.pageToMarkdown(col.id);
|
|
1974
|
+
const childMd = (Array.isArray(children) ? n2m.toMarkdownString(children).parent : "") ?? "";
|
|
1975
|
+
parts.push(`${COLUMN_SEP}
|
|
1976
|
+
${childMd.trimEnd()}`);
|
|
1977
|
+
}
|
|
1978
|
+
return `${COLUMN_LIST_START}
|
|
1979
|
+
${parts.join("\n")}
|
|
1980
|
+
${COLUMN_LIST_END}`;
|
|
1981
|
+
});
|
|
1982
|
+
this.n2m.setCustomTransformer("column", async () => "");
|
|
1983
|
+
}
|
|
1984
|
+
registerSyncedBlockTransformer() {
|
|
1985
|
+
if (!this.n2m) return;
|
|
1986
|
+
const n2m = this.n2m;
|
|
1987
|
+
const client = this.client;
|
|
1988
|
+
this.n2m.setCustomTransformer("synced_block", async (block) => {
|
|
1989
|
+
const b = block;
|
|
1990
|
+
const sourceId = b.synced_block?.synced_from?.block_id ?? b.id;
|
|
1991
|
+
const response = await client.blocks.children.list({ block_id: sourceId });
|
|
1992
|
+
const mdBlocks = [];
|
|
1993
|
+
for (const child of response.results) {
|
|
1994
|
+
const result = await n2m.blockToMarkdown(child);
|
|
1995
|
+
if (result) mdBlocks.push(result);
|
|
1996
|
+
}
|
|
1997
|
+
const childMd = n2m.toMarkdownString(mdBlocks).parent ?? "";
|
|
1998
|
+
return childMd.trimEnd();
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
registerBookmarkTransformer() {
|
|
2002
|
+
if (!this.n2m) return;
|
|
2003
|
+
this.n2m.setCustomTransformer("bookmark", async (block) => {
|
|
2004
|
+
const b = block;
|
|
2005
|
+
const url = b.bookmark?.url ?? "";
|
|
2006
|
+
const caption = richTextToPlain(b.bookmark?.caption);
|
|
2007
|
+
if (caption) {
|
|
2008
|
+
return `[${caption}](${url})`;
|
|
2009
|
+
}
|
|
2010
|
+
return `[${url}](${url})`;
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
registerVideoTransformer() {
|
|
2014
|
+
if (!this.n2m) return;
|
|
2015
|
+
this.n2m.setCustomTransformer("video", async (block) => {
|
|
2016
|
+
const b = block;
|
|
2017
|
+
const url = b.video?.type === "external" ? b.video.external?.url : b.video?.file?.url;
|
|
2018
|
+
const caption = richTextToPlain(b.video?.caption);
|
|
2019
|
+
return ``;
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
registerAudioTransformer() {
|
|
2023
|
+
if (!this.n2m) return;
|
|
2024
|
+
this.n2m.setCustomTransformer("audio", async (block) => {
|
|
2025
|
+
const b = block;
|
|
2026
|
+
const url = b.audio?.type === "external" ? b.audio.external?.url : b.audio?.file?.url;
|
|
2027
|
+
const caption = richTextToPlain(b.audio?.caption);
|
|
2028
|
+
return ``;
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
registerFileTransformer() {
|
|
2032
|
+
if (!this.n2m) return;
|
|
2033
|
+
this.n2m.setCustomTransformer("file", async (block) => {
|
|
2034
|
+
const b = block;
|
|
2035
|
+
const url = b.file?.type === "external" ? b.file.external?.url : b.file?.file?.url;
|
|
2036
|
+
const name = b.file?.name ?? richTextToPlain(b.file?.caption) ?? "file";
|
|
2037
|
+
return `[${name}](${url ?? ""})`;
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
registerChildPageTransformer() {
|
|
2041
|
+
if (!this.n2m) return;
|
|
2042
|
+
this.n2m.setCustomTransformer("child_page", async (block) => {
|
|
2043
|
+
const b = block;
|
|
2044
|
+
const title = b.child_page?.title ?? "Untitled";
|
|
2045
|
+
return `[[${title}]]`;
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
registerChildDatabaseTransformer() {
|
|
2049
|
+
if (!this.n2m) return;
|
|
2050
|
+
this.n2m.setCustomTransformer("child_database", async (block) => {
|
|
2051
|
+
const b = block;
|
|
2052
|
+
const title = b.child_database?.title ?? "Database";
|
|
2053
|
+
return `> [!database] ${title}
|
|
2054
|
+
> %%im-nobsidian:child-database:id=${b.id}&title=${encodeURIComponent(title)}%%`;
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
registerLinkToPageTransformer() {
|
|
2058
|
+
if (!this.n2m) return;
|
|
2059
|
+
const client = this.client;
|
|
2060
|
+
this.n2m.setCustomTransformer("link_to_page", async (block) => {
|
|
2061
|
+
const b = block;
|
|
2062
|
+
const pageId = b.link_to_page?.page_id ?? b.link_to_page?.database_id;
|
|
2063
|
+
if (!pageId) return "";
|
|
2064
|
+
try {
|
|
2065
|
+
const page = await client.pages.retrieve({ page_id: pageId });
|
|
2066
|
+
const p = page;
|
|
2067
|
+
for (const prop of Object.values(p.properties)) {
|
|
2068
|
+
if (prop.type === "title" && prop.title?.[0]?.plain_text) {
|
|
2069
|
+
return `[[${prop.title[0].plain_text}]]`;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
} catch {
|
|
2073
|
+
}
|
|
2074
|
+
return `[[${pageId}]]`;
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
registerTocTransformer() {
|
|
2078
|
+
if (!this.n2m) return;
|
|
2079
|
+
this.n2m.setCustomTransformer("table_of_contents", async () => {
|
|
2080
|
+
return `%%im-nobsidian:toc%%`;
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
registerBreadcrumbTransformer() {
|
|
2084
|
+
if (!this.n2m) return;
|
|
2085
|
+
this.n2m.setCustomTransformer("breadcrumb", async () => "");
|
|
2086
|
+
}
|
|
2087
|
+
registerLinkPreviewTransformer() {
|
|
2088
|
+
if (!this.n2m) return;
|
|
2089
|
+
this.n2m.setCustomTransformer("link_preview", async (block) => {
|
|
2090
|
+
const b = block;
|
|
2091
|
+
const url = b.link_preview?.url ?? "";
|
|
2092
|
+
return `[${url}](${url})`;
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
registerCalloutTransformer() {
|
|
2096
|
+
if (!this.n2m) return;
|
|
2097
|
+
const n2m = this.n2m;
|
|
2098
|
+
this.n2m.setCustomTransformer("callout", async (block) => {
|
|
2099
|
+
const b = block;
|
|
2100
|
+
const text = richTextToMarkdown(b.callout?.rich_text);
|
|
2101
|
+
const emoji = b.callout?.icon?.emoji ?? "";
|
|
2102
|
+
const children = b.has_children ? await n2m.pageToMarkdown(b.id) : [];
|
|
2103
|
+
const childMd = (Array.isArray(children) ? n2m.toMarkdownString(children).parent : "") ?? "";
|
|
2104
|
+
const EMOJI_TO_TYPE = {
|
|
2105
|
+
"\u{1F4DD}": "note",
|
|
2106
|
+
"\u{1F4CB}": "abstract",
|
|
2107
|
+
"\u2139\uFE0F": "info",
|
|
2108
|
+
"\u{1F4A1}": "tip",
|
|
2109
|
+
"\u2705": "success",
|
|
2110
|
+
"\u2753": "question",
|
|
2111
|
+
"\u26A0\uFE0F": "warning",
|
|
2112
|
+
"\u274C": "failure",
|
|
2113
|
+
"\u{1F525}": "danger",
|
|
2114
|
+
"\u{1F41B}": "bug",
|
|
2115
|
+
"\u{1F4CC}": "example",
|
|
2116
|
+
"\u{1F4AC}": "quote"
|
|
2117
|
+
};
|
|
2118
|
+
const calloutType = EMOJI_TO_TYPE[emoji] ?? "note";
|
|
2119
|
+
let result = `> [!${calloutType}] ${text}`;
|
|
2120
|
+
if (childMd?.trim()) {
|
|
2121
|
+
const lines = childMd.trim().split("\n").map((l) => `> ${l}`);
|
|
2122
|
+
result += "\n" + lines.join("\n");
|
|
2123
|
+
}
|
|
2124
|
+
return result;
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
markdownToNotionBlocks(markdown) {
|
|
2128
|
+
const preprocessed = this.preProcessMarkdown(markdown);
|
|
2129
|
+
const blocks = markdownToBlocks(preprocessed);
|
|
2130
|
+
return this.postProcessBlocks(blocks);
|
|
2131
|
+
}
|
|
2132
|
+
toggleContents = /* @__PURE__ */ new Map();
|
|
2133
|
+
columnContents = /* @__PURE__ */ new Map();
|
|
2134
|
+
toggleCounter = 0;
|
|
2135
|
+
columnCounter = 0;
|
|
2136
|
+
preProcessMarkdown(markdown) {
|
|
2137
|
+
this.toggleContents.clear();
|
|
2138
|
+
this.columnContents.clear();
|
|
2139
|
+
this.toggleCounter = 0;
|
|
2140
|
+
this.columnCounter = 0;
|
|
2141
|
+
let processed = this.extractToggleBlocks(markdown);
|
|
2142
|
+
processed = this.extractColumnBlocks(processed);
|
|
2143
|
+
processed = this.replaceDividers(processed);
|
|
2144
|
+
return processed;
|
|
2145
|
+
}
|
|
2146
|
+
replaceDividers(markdown) {
|
|
2147
|
+
const lines = markdown.split("\n");
|
|
2148
|
+
const result = [];
|
|
2149
|
+
let inCodeBlock = false;
|
|
2150
|
+
for (const line of lines) {
|
|
2151
|
+
if (line.trimStart().startsWith("```")) {
|
|
2152
|
+
inCodeBlock = !inCodeBlock;
|
|
2153
|
+
}
|
|
2154
|
+
if (!inCodeBlock && /^---\s*$/.test(line.trim())) {
|
|
2155
|
+
result.push(DIVIDER_PLACEHOLDER);
|
|
2156
|
+
} else {
|
|
2157
|
+
result.push(line);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
return result.join("\n");
|
|
2161
|
+
}
|
|
2162
|
+
extractToggleBlocks(markdown) {
|
|
2163
|
+
const startRe = new RegExp(`^${escapeRegex2(TOGGLE_START)}$`, "gm");
|
|
2164
|
+
const endRe = new RegExp(`^${escapeRegex2(TOGGLE_END)}$`, "gm");
|
|
2165
|
+
let result = markdown;
|
|
2166
|
+
let match;
|
|
2167
|
+
while ((match = startRe.exec(result)) !== null) {
|
|
2168
|
+
endRe.lastIndex = match.index;
|
|
2169
|
+
const endMatch = endRe.exec(result);
|
|
2170
|
+
if (!endMatch) break;
|
|
2171
|
+
const content = result.slice(match.index + match[0].length + 1, endMatch.index).trimEnd();
|
|
2172
|
+
const id = this.toggleCounter++;
|
|
2173
|
+
this.toggleContents.set(id, content);
|
|
2174
|
+
const placeholder = `%%IM-NOBSIDIAN_TOGGLE_${id}%%`;
|
|
2175
|
+
result = result.slice(0, match.index) + placeholder + result.slice(endMatch.index + endMatch[0].length);
|
|
2176
|
+
startRe.lastIndex = match.index + placeholder.length;
|
|
2177
|
+
}
|
|
2178
|
+
return result;
|
|
2179
|
+
}
|
|
2180
|
+
extractColumnBlocks(markdown) {
|
|
2181
|
+
const startRe = new RegExp(`^${escapeRegex2(COLUMN_LIST_START)}$`, "gm");
|
|
2182
|
+
const endRe = new RegExp(`^${escapeRegex2(COLUMN_LIST_END)}$`, "gm");
|
|
2183
|
+
let result = markdown;
|
|
2184
|
+
let match;
|
|
2185
|
+
while ((match = startRe.exec(result)) !== null) {
|
|
2186
|
+
endRe.lastIndex = match.index;
|
|
2187
|
+
const endMatch = endRe.exec(result);
|
|
2188
|
+
if (!endMatch) break;
|
|
2189
|
+
const inner = result.slice(match.index + match[0].length + 1, endMatch.index);
|
|
2190
|
+
const columns = inner.split(new RegExp(`^${escapeRegex2(COLUMN_SEP)}$`, "m")).map((c) => c.trim()).filter(Boolean);
|
|
2191
|
+
const id = this.columnCounter++;
|
|
2192
|
+
this.columnContents.set(id, columns);
|
|
2193
|
+
const placeholder = `%%IM-NOBSIDIAN_COLLIST_${id}%%`;
|
|
2194
|
+
result = result.slice(0, match.index) + placeholder + result.slice(endMatch.index + endMatch[0].length);
|
|
2195
|
+
startRe.lastIndex = match.index + placeholder.length;
|
|
2196
|
+
}
|
|
2197
|
+
return result;
|
|
2198
|
+
}
|
|
2199
|
+
postProcessBlocks(blocks) {
|
|
2200
|
+
const result = [];
|
|
2201
|
+
for (const block of blocks) {
|
|
2202
|
+
const toggleConverted = this.convertTogglePlaceholder(block);
|
|
2203
|
+
if (toggleConverted) {
|
|
2204
|
+
result.push(toggleConverted);
|
|
2205
|
+
continue;
|
|
2206
|
+
}
|
|
2207
|
+
const columnConverted = this.convertColumnPlaceholder(block);
|
|
2208
|
+
if (columnConverted) {
|
|
2209
|
+
result.push(columnConverted);
|
|
2210
|
+
continue;
|
|
2211
|
+
}
|
|
2212
|
+
const dividerConverted = this.convertDividerPlaceholder(block);
|
|
2213
|
+
if (dividerConverted) {
|
|
2214
|
+
result.push(dividerConverted);
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
const videoConverted = this.convertImageToVideoOrEmbed(block);
|
|
2218
|
+
if (videoConverted) {
|
|
2219
|
+
result.push(videoConverted);
|
|
2220
|
+
continue;
|
|
2221
|
+
}
|
|
2222
|
+
const calloutConverted = this.convertQuoteToCallout(block);
|
|
2223
|
+
result.push(calloutConverted);
|
|
2224
|
+
}
|
|
2225
|
+
return result;
|
|
2226
|
+
}
|
|
2227
|
+
convertTogglePlaceholder(block) {
|
|
2228
|
+
if (block.type !== "paragraph") return null;
|
|
2229
|
+
const para = block.paragraph;
|
|
2230
|
+
const text = para?.rich_text?.[0]?.text?.content ?? "";
|
|
2231
|
+
const toggleMatch = text.match(/%%IM-NOBSIDIAN_TOGGLE_(\d+)%%/);
|
|
2232
|
+
if (!toggleMatch) return null;
|
|
2233
|
+
const id = parseInt(toggleMatch[1], 10);
|
|
2234
|
+
const content = this.toggleContents.get(id);
|
|
2235
|
+
if (content === void 0) return null;
|
|
2236
|
+
const titleMatch = content.match(/^- (.+)$/m);
|
|
2237
|
+
const title = titleMatch?.[1] ?? "Toggle";
|
|
2238
|
+
const childLines = content.split("\n").slice(1);
|
|
2239
|
+
const childMd = childLines.map((l) => l.startsWith(" ") ? l.slice(2) : l).join("\n").trim();
|
|
2240
|
+
const children = childMd ? markdownToBlocks(childMd) : [];
|
|
2241
|
+
return NotionBlockBuilder.toggle(
|
|
2242
|
+
NotionBlockBuilder.richText(title),
|
|
2243
|
+
children.length > 0 ? children : void 0
|
|
2244
|
+
);
|
|
2245
|
+
}
|
|
2246
|
+
convertColumnPlaceholder(block) {
|
|
2247
|
+
if (block.type !== "paragraph") return null;
|
|
2248
|
+
const para = block.paragraph;
|
|
2249
|
+
const text = para?.rich_text?.[0]?.text?.content ?? "";
|
|
2250
|
+
const colMatch = text.match(/%%IM-NOBSIDIAN_COLLIST_(\d+)%%/);
|
|
2251
|
+
if (!colMatch) return null;
|
|
2252
|
+
const id = parseInt(colMatch[1], 10);
|
|
2253
|
+
const columns = this.columnContents.get(id);
|
|
2254
|
+
if (!columns) return null;
|
|
2255
|
+
const columnBlocks = columns.map((colMd) => {
|
|
2256
|
+
const blocks = markdownToBlocks(colMd);
|
|
2257
|
+
return this.postProcessBlocks(blocks);
|
|
2258
|
+
});
|
|
2259
|
+
return NotionBlockBuilder.columnList(columnBlocks);
|
|
2260
|
+
}
|
|
2261
|
+
convertDividerPlaceholder(block) {
|
|
2262
|
+
if (block.type !== "paragraph") return null;
|
|
2263
|
+
const para = block.paragraph;
|
|
2264
|
+
const text = para?.rich_text?.[0]?.text?.content ?? "";
|
|
2265
|
+
if (text.includes("%%IM-NOBSIDIAN_DIVIDER%%")) {
|
|
2266
|
+
return NotionBlockBuilder.divider();
|
|
2267
|
+
}
|
|
2268
|
+
return null;
|
|
2269
|
+
}
|
|
2270
|
+
convertImageToVideoOrEmbed(block) {
|
|
2271
|
+
let url = "";
|
|
2272
|
+
let caption;
|
|
2273
|
+
if (block.type === "image") {
|
|
2274
|
+
const img = block.image;
|
|
2275
|
+
url = img?.external?.url ?? "";
|
|
2276
|
+
caption = img?.caption?.[0]?.text?.content;
|
|
2277
|
+
} else if (block.type === "paragraph") {
|
|
2278
|
+
const para = block.paragraph;
|
|
2279
|
+
const texts = para?.rich_text ?? [];
|
|
2280
|
+
if (texts.length === 1) {
|
|
2281
|
+
const content = texts[0]?.text?.content ?? "";
|
|
2282
|
+
if (/^https?:\/\//.test(content)) {
|
|
2283
|
+
url = content;
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
if (!url) return null;
|
|
2288
|
+
if (VIDEO_URL_PATTERNS.some((p) => p.test(url))) {
|
|
2289
|
+
return NotionBlockBuilder.video(url, caption);
|
|
2290
|
+
}
|
|
2291
|
+
if (EMBED_URL_PATTERNS.some((p) => p.test(url))) {
|
|
2292
|
+
return NotionBlockBuilder.embed(url, caption);
|
|
2293
|
+
}
|
|
2294
|
+
return null;
|
|
2295
|
+
}
|
|
2296
|
+
convertQuoteToCallout(block) {
|
|
2297
|
+
if (block.type !== "quote") return block;
|
|
2298
|
+
const quote = block.quote;
|
|
2299
|
+
if (!quote?.rich_text?.[0]) return block;
|
|
2300
|
+
const firstText = quote.rich_text[0]?.text?.content ?? "";
|
|
2301
|
+
const KNOWN_CALLOUT_EMOJIS = [
|
|
2302
|
+
"\u{1F4DD}",
|
|
2303
|
+
"\u{1F4CB}",
|
|
2304
|
+
"\u2139\uFE0F",
|
|
2305
|
+
"\u{1F4A1}",
|
|
2306
|
+
"\u2705",
|
|
2307
|
+
"\u2753",
|
|
2308
|
+
"\u26A0\uFE0F",
|
|
2309
|
+
"\u274C",
|
|
2310
|
+
"\u{1F525}",
|
|
2311
|
+
"\u{1F41B}",
|
|
2312
|
+
"\u{1F4CC}",
|
|
2313
|
+
"\u{1F4AC}"
|
|
2314
|
+
];
|
|
2315
|
+
const matchedEmoji = KNOWN_CALLOUT_EMOJIS.find((e) => firstText.startsWith(e));
|
|
2316
|
+
if (!matchedEmoji) return block;
|
|
2317
|
+
const emoji = matchedEmoji;
|
|
2318
|
+
const remainingText = firstText.slice(emoji.length).replace(/^\s+/, "");
|
|
2319
|
+
const newRichText = [...quote.rich_text];
|
|
2320
|
+
if (remainingText) {
|
|
2321
|
+
newRichText[0] = {
|
|
2322
|
+
...newRichText[0],
|
|
2323
|
+
text: { content: remainingText, link: null }
|
|
2324
|
+
};
|
|
2325
|
+
} else {
|
|
2326
|
+
newRichText.shift();
|
|
2327
|
+
}
|
|
2328
|
+
return {
|
|
2329
|
+
type: "callout",
|
|
2330
|
+
callout: {
|
|
2331
|
+
rich_text: newRichText,
|
|
2332
|
+
icon: { type: "emoji", emoji },
|
|
2333
|
+
children: quote.children ?? []
|
|
2334
|
+
}
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
async notionBlocksToMarkdown(pageId) {
|
|
2338
|
+
if (!this.n2m) {
|
|
2339
|
+
throw new Error(
|
|
2340
|
+
"NotionToMarkdown\uC774 \uCD08\uAE30\uD654\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. initNotionToMd()\uB97C \uBA3C\uC800 \uD638\uCD9C\uD558\uC138\uC694."
|
|
2341
|
+
);
|
|
2342
|
+
}
|
|
2343
|
+
const mdBlocks = await this.n2m.pageToMarkdown(pageId);
|
|
2344
|
+
const result = this.n2m.toMarkdownString(mdBlocks);
|
|
2345
|
+
return result.parent ?? "";
|
|
2346
|
+
}
|
|
2347
|
+
};
|
|
2348
|
+
var IMAGE_EXTENSIONS = {
|
|
2349
|
+
"image/png": ".png",
|
|
2350
|
+
"image/jpeg": ".jpg",
|
|
2351
|
+
"image/gif": ".gif",
|
|
2352
|
+
"image/webp": ".webp",
|
|
2353
|
+
"image/svg+xml": ".svg",
|
|
2354
|
+
"image/bmp": ".bmp",
|
|
2355
|
+
"image/tiff": ".tiff"
|
|
2356
|
+
};
|
|
2357
|
+
var ImageHandler = class {
|
|
2358
|
+
constructor(vaultFs, attachmentFolder = "attachments") {
|
|
2359
|
+
this.vaultFs = vaultFs;
|
|
2360
|
+
this.attachmentFolder = attachmentFolder;
|
|
2361
|
+
}
|
|
2362
|
+
vaultFs;
|
|
2363
|
+
attachmentFolder;
|
|
2364
|
+
async downloadImage(url, pageTitle) {
|
|
2365
|
+
const response = await fetch(url);
|
|
2366
|
+
if (!response.ok) {
|
|
2367
|
+
throw new Error(`\uC774\uBBF8\uC9C0 \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: ${response.status} ${response.statusText}`);
|
|
2368
|
+
}
|
|
2369
|
+
const contentType = response.headers.get("content-type") ?? "image/png";
|
|
2370
|
+
const extension = IMAGE_EXTENSIONS[contentType] ?? this.guessExtension(url) ?? ".png";
|
|
2371
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
2372
|
+
const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 12);
|
|
2373
|
+
const safeName = pageTitle.replace(/[^a-zA-Z0-9가-힣\s-]/g, "").replace(/\s+/g, "-").slice(0, 50);
|
|
2374
|
+
const fileName = `${safeName}-${hash}${extension}`;
|
|
2375
|
+
const localPath = `${this.attachmentFolder}/${fileName}`;
|
|
2376
|
+
await this.vaultFs.ensureFolder(this.attachmentFolder);
|
|
2377
|
+
await this.vaultFs.writeBinary(localPath, buffer);
|
|
2378
|
+
return {
|
|
2379
|
+
originalUrl: url,
|
|
2380
|
+
localPath,
|
|
2381
|
+
hash,
|
|
2382
|
+
size: buffer.length
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
async downloadAllImages(markdown, pageTitle) {
|
|
2386
|
+
const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
|
|
2387
|
+
const matches = [...markdown.matchAll(imageRegex)];
|
|
2388
|
+
const notionMatches = matches.filter((m) => m[2] && this.isNotionImageUrl(m[2]));
|
|
2389
|
+
if (notionMatches.length === 0) {
|
|
2390
|
+
return { content: markdown, downloads: [] };
|
|
2391
|
+
}
|
|
2392
|
+
const sema = new Sema(3);
|
|
2393
|
+
const results = await Promise.allSettled(
|
|
2394
|
+
notionMatches.map(async (match) => {
|
|
2395
|
+
await sema.acquire();
|
|
2396
|
+
try {
|
|
2397
|
+
return {
|
|
2398
|
+
match,
|
|
2399
|
+
download: await this.downloadImage(match[2], pageTitle)
|
|
2400
|
+
};
|
|
2401
|
+
} finally {
|
|
2402
|
+
sema.release();
|
|
2403
|
+
}
|
|
2404
|
+
})
|
|
2405
|
+
);
|
|
2406
|
+
const downloads = [];
|
|
2407
|
+
let result = markdown;
|
|
2408
|
+
for (const settled of results) {
|
|
2409
|
+
if (settled.status !== "fulfilled") continue;
|
|
2410
|
+
const { match, download } = settled.value;
|
|
2411
|
+
downloads.push(download);
|
|
2412
|
+
const obsidianEmbed = `![[${download.localPath}${match[1] ? `|${match[1]}` : ""}]]`;
|
|
2413
|
+
result = result.replace(match[0], obsidianEmbed);
|
|
2414
|
+
}
|
|
2415
|
+
return { content: result, downloads };
|
|
2416
|
+
}
|
|
2417
|
+
isNotionImageUrl(url) {
|
|
2418
|
+
return url.includes("secure.notion-static.com") || url.includes("prod-files-secure") || url.includes("s3.us-west-2.amazonaws.com/secure.notion-static.com");
|
|
2419
|
+
}
|
|
2420
|
+
guessExtension(url) {
|
|
2421
|
+
const pathname = new URL(url).pathname;
|
|
2422
|
+
const ext = pathname.match(/\.(png|jpg|jpeg|gif|webp|svg|bmp|tiff)(\?|$)/i);
|
|
2423
|
+
return ext ? `.${ext[1].toLowerCase()}` : null;
|
|
2424
|
+
}
|
|
2425
|
+
};
|
|
2426
|
+
|
|
2427
|
+
// src/utils/sanitize.ts
|
|
2428
|
+
var INVALID_CHARS = /[<>:"/\\|?*\x00-\x1f]/g;
|
|
2429
|
+
var TRAILING_DOTS = /\.+$/;
|
|
2430
|
+
var RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
2431
|
+
"CON",
|
|
2432
|
+
"PRN",
|
|
2433
|
+
"AUX",
|
|
2434
|
+
"NUL",
|
|
2435
|
+
"COM1",
|
|
2436
|
+
"COM2",
|
|
2437
|
+
"COM3",
|
|
2438
|
+
"COM4",
|
|
2439
|
+
"COM5",
|
|
2440
|
+
"COM6",
|
|
2441
|
+
"COM7",
|
|
2442
|
+
"COM8",
|
|
2443
|
+
"COM9",
|
|
2444
|
+
"LPT1",
|
|
2445
|
+
"LPT2",
|
|
2446
|
+
"LPT3",
|
|
2447
|
+
"LPT4",
|
|
2448
|
+
"LPT5",
|
|
2449
|
+
"LPT6",
|
|
2450
|
+
"LPT7",
|
|
2451
|
+
"LPT8",
|
|
2452
|
+
"LPT9"
|
|
2453
|
+
]);
|
|
2454
|
+
function sanitizeFileName(name) {
|
|
2455
|
+
let sanitized = name.replace(INVALID_CHARS, "_").replace(TRAILING_DOTS, "").trim().slice(0, 200);
|
|
2456
|
+
if (RESERVED_NAMES.has(sanitized.toUpperCase())) {
|
|
2457
|
+
sanitized = `_${sanitized}`;
|
|
2458
|
+
}
|
|
2459
|
+
if (sanitized.length === 0) {
|
|
2460
|
+
sanitized = "_untitled";
|
|
2461
|
+
}
|
|
2462
|
+
return sanitized;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// src/sync/orchestrator.ts
|
|
2466
|
+
var SyncOrchestrator = class {
|
|
2467
|
+
constructor(config, stateDb, notionClient, vaultFs) {
|
|
2468
|
+
this.config = config;
|
|
2469
|
+
this.stateDb = stateDb;
|
|
2470
|
+
this.notionClient = notionClient;
|
|
2471
|
+
this.vaultFs = vaultFs;
|
|
2472
|
+
this.changeDetector = new ChangeDetector(stateDb);
|
|
2473
|
+
this.pipeline = createDefaultPipeline();
|
|
2474
|
+
this.blockConverter = new BlockConverter();
|
|
2475
|
+
this.imageHandler = new ImageHandler(vaultFs, config.paths.attachments);
|
|
2476
|
+
this.propertyMapper = new PropertyMapper();
|
|
2477
|
+
this.blockConverter.initNotionToMd(this.notionClient.getInternalClient());
|
|
2478
|
+
}
|
|
2479
|
+
config;
|
|
2480
|
+
stateDb;
|
|
2481
|
+
notionClient;
|
|
2482
|
+
vaultFs;
|
|
2483
|
+
changeDetector;
|
|
2484
|
+
pipeline;
|
|
2485
|
+
blockConverter;
|
|
2486
|
+
imageHandler;
|
|
2487
|
+
propertyMapper;
|
|
2488
|
+
dbSchemaLoaded = false;
|
|
2489
|
+
async push(options) {
|
|
2490
|
+
const startTime = Date.now();
|
|
2491
|
+
const files = await this.vaultFs.listMarkdownFiles();
|
|
2492
|
+
const changes = this.changeDetector.detectLocalChanges(files);
|
|
2493
|
+
const conflictPaths = new Set(this.stateDb.getByStatus("conflict").map((r) => r.obsidianPath));
|
|
2494
|
+
const nonConflict = changes.filter((c) => !conflictPaths.has(c.path));
|
|
2495
|
+
const filtered = options?.paths ? nonConflict.filter((c) => options.paths.some((p) => c.path.startsWith(p))) : nonConflict;
|
|
2496
|
+
if (filtered.length === 0) {
|
|
2497
|
+
return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
|
|
2498
|
+
}
|
|
2499
|
+
if (options?.dryRun) {
|
|
2500
|
+
return { created: 0, updated: 0, deleted: 0, failed: [], duration: Date.now() - startTime };
|
|
2501
|
+
}
|
|
2502
|
+
this.stateDb.setMeta("push_in_progress", "true");
|
|
2503
|
+
const folderPaths = /* @__PURE__ */ new Set();
|
|
2504
|
+
for (const change of filtered) {
|
|
2505
|
+
const parts = change.path.split("/");
|
|
2506
|
+
if (parts.length > 1) {
|
|
2507
|
+
for (let i = 1; i < parts.length; i++) {
|
|
2508
|
+
folderPaths.add(parts.slice(0, i).join("/"));
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
const sortedFolders = [...folderPaths].sort(
|
|
2513
|
+
(a, b) => a.split("/").length - b.split("/").length
|
|
2514
|
+
);
|
|
2515
|
+
for (const folderPath of sortedFolders) {
|
|
2516
|
+
await this.ensureFolderPage(folderPath);
|
|
2517
|
+
}
|
|
2518
|
+
const sema = new Sema(this.config.advanced.concurrency);
|
|
2519
|
+
let created = 0;
|
|
2520
|
+
let updated = 0;
|
|
2521
|
+
let deleted = 0;
|
|
2522
|
+
const failed = [];
|
|
2523
|
+
let completed = 0;
|
|
2524
|
+
const total = filtered.length;
|
|
2525
|
+
const tasks = filtered.map((change) => async () => {
|
|
2526
|
+
await sema.acquire();
|
|
2527
|
+
try {
|
|
2528
|
+
options?.onProgress?.(++completed, total, change.path);
|
|
2529
|
+
switch (change.type) {
|
|
2530
|
+
case "created":
|
|
2531
|
+
await this.pushCreate(change.path);
|
|
2532
|
+
created++;
|
|
2533
|
+
break;
|
|
2534
|
+
case "modified":
|
|
2535
|
+
case "moved":
|
|
2536
|
+
await this.pushUpdate(change.path);
|
|
2537
|
+
updated++;
|
|
2538
|
+
break;
|
|
2539
|
+
case "deleted":
|
|
2540
|
+
await this.pushDelete(change.path);
|
|
2541
|
+
deleted++;
|
|
2542
|
+
break;
|
|
2543
|
+
}
|
|
2544
|
+
} catch (error) {
|
|
2545
|
+
failed.push({
|
|
2546
|
+
path: change.path,
|
|
2547
|
+
operation: change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update",
|
|
2548
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2549
|
+
});
|
|
2550
|
+
} finally {
|
|
2551
|
+
sema.release();
|
|
2552
|
+
}
|
|
2553
|
+
});
|
|
2554
|
+
await Promise.all(tasks.map((t) => t()));
|
|
2555
|
+
this.stateDb.setMeta("last_push_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
2556
|
+
this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
2557
|
+
this.stateDb.setMeta("push_in_progress", "");
|
|
2558
|
+
return { created, updated, deleted, failed, duration: Date.now() - startTime };
|
|
2559
|
+
}
|
|
2560
|
+
async pull(options) {
|
|
2561
|
+
const startTime = Date.now();
|
|
2562
|
+
let created = 0;
|
|
2563
|
+
let updated = 0;
|
|
2564
|
+
let deleted = 0;
|
|
2565
|
+
const conflicts = [];
|
|
2566
|
+
const writtenPaths = [];
|
|
2567
|
+
const failed = [];
|
|
2568
|
+
const remoteChanges = await this.detectRemoteChanges();
|
|
2569
|
+
const filtered = options?.paths ? remoteChanges.filter((c) => {
|
|
2570
|
+
const record = this.stateDb.getByNotionId(c.pageId);
|
|
2571
|
+
return record && options.paths.some((p) => record.obsidianPath.startsWith(p));
|
|
2572
|
+
}) : remoteChanges;
|
|
2573
|
+
if (options?.dryRun || filtered.length === 0) {
|
|
2574
|
+
return {
|
|
2575
|
+
created: 0,
|
|
2576
|
+
updated: 0,
|
|
2577
|
+
deleted: 0,
|
|
2578
|
+
conflicts: [],
|
|
2579
|
+
writtenPaths: [],
|
|
2580
|
+
failed: [],
|
|
2581
|
+
duration: Date.now() - startTime
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2584
|
+
this.stateDb.setMeta("pull_in_progress", "true");
|
|
2585
|
+
const sema = new Sema(this.config.advanced.concurrency);
|
|
2586
|
+
let pullCompleted = 0;
|
|
2587
|
+
const pullTotal = filtered.length;
|
|
2588
|
+
const tasks = filtered.map((change) => async () => {
|
|
2589
|
+
await sema.acquire();
|
|
2590
|
+
try {
|
|
2591
|
+
options?.onProgress?.(++pullCompleted, pullTotal, change.pageId);
|
|
2592
|
+
switch (change.type) {
|
|
2593
|
+
case "created": {
|
|
2594
|
+
const path = await this.pullCreate(change.pageId);
|
|
2595
|
+
writtenPaths.push(path);
|
|
2596
|
+
created++;
|
|
2597
|
+
break;
|
|
2598
|
+
}
|
|
2599
|
+
case "modified": {
|
|
2600
|
+
const result = await this.pullUpdate(change);
|
|
2601
|
+
if (result.conflict) {
|
|
2602
|
+
conflicts.push(result.conflict);
|
|
2603
|
+
} else if (result.path) {
|
|
2604
|
+
writtenPaths.push(result.path);
|
|
2605
|
+
updated++;
|
|
2606
|
+
}
|
|
2607
|
+
break;
|
|
2608
|
+
}
|
|
2609
|
+
case "deleted": {
|
|
2610
|
+
const path = await this.pullDelete(change.pageId);
|
|
2611
|
+
if (path) {
|
|
2612
|
+
deleted++;
|
|
2613
|
+
}
|
|
2614
|
+
break;
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
} catch (error) {
|
|
2618
|
+
const record = this.stateDb.getByNotionId(change.pageId);
|
|
2619
|
+
failed.push({
|
|
2620
|
+
path: record?.obsidianPath ?? change.pageId,
|
|
2621
|
+
operation: change.type === "created" ? "create" : change.type === "deleted" ? "delete" : "update",
|
|
2622
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2623
|
+
});
|
|
2624
|
+
} finally {
|
|
2625
|
+
sema.release();
|
|
2626
|
+
}
|
|
2627
|
+
});
|
|
2628
|
+
await Promise.all(tasks.map((t) => t()));
|
|
2629
|
+
this.stateDb.setMeta("last_pull_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
2630
|
+
this.stateDb.setMeta("last_sync_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
2631
|
+
this.stateDb.setMeta("pull_in_progress", "");
|
|
2632
|
+
return {
|
|
2633
|
+
created,
|
|
2634
|
+
updated,
|
|
2635
|
+
deleted,
|
|
2636
|
+
conflicts,
|
|
2637
|
+
writtenPaths,
|
|
2638
|
+
failed,
|
|
2639
|
+
duration: Date.now() - startTime
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
async sync(options) {
|
|
2643
|
+
const startTime = Date.now();
|
|
2644
|
+
const pullResult = await this.pull(options);
|
|
2645
|
+
const pushResult = await this.push({
|
|
2646
|
+
...options,
|
|
2647
|
+
paths: options?.paths
|
|
2648
|
+
});
|
|
2649
|
+
return {
|
|
2650
|
+
pull: pullResult,
|
|
2651
|
+
push: pushResult,
|
|
2652
|
+
conflicts: pullResult.conflicts,
|
|
2653
|
+
duration: Date.now() - startTime
|
|
2654
|
+
};
|
|
2655
|
+
}
|
|
2656
|
+
async status() {
|
|
2657
|
+
const files = await this.vaultFs.listMarkdownFiles();
|
|
2658
|
+
const localChanges = this.changeDetector.detectLocalChanges(files);
|
|
2659
|
+
const remoteChanges = await this.detectRemoteChanges();
|
|
2660
|
+
const conflictRecords = this.stateDb.getByStatus("conflict");
|
|
2661
|
+
const lastSyncAt = this.stateDb.getMeta("last_sync_at");
|
|
2662
|
+
const conflicts = await this.buildConflictsFromRecords(
|
|
2663
|
+
conflictRecords,
|
|
2664
|
+
localChanges,
|
|
2665
|
+
remoteChanges
|
|
2666
|
+
);
|
|
2667
|
+
return {
|
|
2668
|
+
localChanges,
|
|
2669
|
+
remoteChanges,
|
|
2670
|
+
conflicts,
|
|
2671
|
+
conflictRecords,
|
|
2672
|
+
pendingOperations: conflictRecords.length,
|
|
2673
|
+
lastSyncAt
|
|
2674
|
+
};
|
|
2675
|
+
}
|
|
2676
|
+
async buildConflictsFromRecords(records, localChanges, remoteChanges) {
|
|
2677
|
+
const conflicts = [];
|
|
2678
|
+
for (const record of records) {
|
|
2679
|
+
const localChange = localChanges.find((c) => c.path === record.obsidianPath) ?? {
|
|
2680
|
+
path: record.obsidianPath,
|
|
2681
|
+
type: "modified",
|
|
2682
|
+
currentHash: record.contentHash,
|
|
2683
|
+
previousHash: record.contentHash
|
|
2684
|
+
};
|
|
2685
|
+
const remoteChange = remoteChanges.find((c) => c.pageId === record.notionPageId) ?? {
|
|
2686
|
+
pageId: record.notionPageId ?? "",
|
|
2687
|
+
type: "modified",
|
|
2688
|
+
lastEdited: record.notionLastEdited ?? "",
|
|
2689
|
+
previousEdited: null
|
|
2690
|
+
};
|
|
2691
|
+
let localContent = "";
|
|
2692
|
+
try {
|
|
2693
|
+
localContent = await this.vaultFs.readFile(record.obsidianPath);
|
|
2694
|
+
} catch {
|
|
2695
|
+
}
|
|
2696
|
+
conflicts.push({
|
|
2697
|
+
syncRecord: record,
|
|
2698
|
+
localChange,
|
|
2699
|
+
remoteChange,
|
|
2700
|
+
baseContent: record.baseSnapshot?.toString("utf-8") ?? null,
|
|
2701
|
+
localContent,
|
|
2702
|
+
remoteContent: ""
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
return conflicts;
|
|
2706
|
+
}
|
|
2707
|
+
get isDatabaseMode() {
|
|
2708
|
+
return this.config.notion.parentMode === "database" && !!this.config.notion.databaseId;
|
|
2709
|
+
}
|
|
2710
|
+
async ensureDbSchema() {
|
|
2711
|
+
if (this.dbSchemaLoaded || !this.isDatabaseMode) return;
|
|
2712
|
+
const schema = await this.notionClient.getDatabaseSchema(this.config.notion.databaseId);
|
|
2713
|
+
this.propertyMapper.loadSchema(schema);
|
|
2714
|
+
this.dbSchemaLoaded = true;
|
|
2715
|
+
}
|
|
2716
|
+
async pushCreate(path) {
|
|
2717
|
+
const content = await this.vaultFs.readFile(path);
|
|
2718
|
+
const title = extractTitle(path);
|
|
2719
|
+
const parentId = await this.resolveNotionParent(path);
|
|
2720
|
+
const selectedPath = this.pipeline.selectPath(content);
|
|
2721
|
+
if (selectedPath === "block-api") {
|
|
2722
|
+
getLogger().warn(
|
|
2723
|
+
`[Im-Nobsidian] "${path}" contains block-api features (inline-db/column/toggle) \u2014 converted with reduced fidelity in v0.1.0`
|
|
2724
|
+
);
|
|
2725
|
+
}
|
|
2726
|
+
const conversionResult = this.pipeline.convertToNotion(content, {
|
|
2727
|
+
direction: "push",
|
|
2728
|
+
path: selectedPath,
|
|
2729
|
+
filePath: path,
|
|
2730
|
+
parentMode: this.config.notion.parentMode
|
|
2731
|
+
});
|
|
2732
|
+
const blocks = this.blockConverter.markdownToNotionBlocks(conversionResult.content);
|
|
2733
|
+
let effectiveParentId = parentId;
|
|
2734
|
+
let effectiveParentType = "page";
|
|
2735
|
+
let effectiveProperties = conversionResult.properties;
|
|
2736
|
+
if (this.isDatabaseMode) {
|
|
2737
|
+
await this.ensureDbSchema();
|
|
2738
|
+
effectiveParentId = this.config.notion.databaseId;
|
|
2739
|
+
effectiveParentType = "database";
|
|
2740
|
+
effectiveProperties = this.propertyMapper.toNotionProperties(
|
|
2741
|
+
conversionResult.properties,
|
|
2742
|
+
title
|
|
2743
|
+
);
|
|
2744
|
+
}
|
|
2745
|
+
const page = await this.notionClient.createPage({
|
|
2746
|
+
parentId: effectiveParentId,
|
|
2747
|
+
parentType: effectiveParentType,
|
|
2748
|
+
title,
|
|
2749
|
+
properties: effectiveProperties
|
|
2750
|
+
});
|
|
2751
|
+
if (blocks.length > 0) {
|
|
2752
|
+
await this.notionClient.appendChildren(page.id, blocks);
|
|
2753
|
+
}
|
|
2754
|
+
const hash = computeHash(content);
|
|
2755
|
+
this.stateDb.transaction(() => {
|
|
2756
|
+
this.stateDb.upsert({
|
|
2757
|
+
obsidianPath: path,
|
|
2758
|
+
notionPageId: page.id,
|
|
2759
|
+
notionParentId: parentId,
|
|
2760
|
+
contentHash: hash,
|
|
2761
|
+
notionLastEdited: page.last_edited_time,
|
|
2762
|
+
localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2763
|
+
syncDirection: "both",
|
|
2764
|
+
fileType: this.isFolderNote(path) ? "folder-note" : "file",
|
|
2765
|
+
status: "synced",
|
|
2766
|
+
baseSnapshot: Buffer.from(content, "utf-8")
|
|
2767
|
+
});
|
|
2768
|
+
this.stateDb.upsertWikilink({
|
|
2769
|
+
obsidianPath: path,
|
|
2770
|
+
notionPageId: page.id,
|
|
2771
|
+
title,
|
|
2772
|
+
aliases: []
|
|
2773
|
+
});
|
|
2774
|
+
this.stateDb.storePreserveMarkers(path, conversionResult.preserveMarkers);
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
async pushUpdate(path) {
|
|
2778
|
+
const content = await this.vaultFs.readFile(path);
|
|
2779
|
+
const record = this.stateDb.getByPath(path);
|
|
2780
|
+
if (!record?.notionPageId) return;
|
|
2781
|
+
const updatePath = this.pipeline.selectPath(content);
|
|
2782
|
+
if (updatePath === "block-api") {
|
|
2783
|
+
getLogger().warn(
|
|
2784
|
+
`[Im-Nobsidian] "${path}" contains block-api features (inline-db/column/toggle) \u2014 converted with reduced fidelity in v0.1.0`
|
|
2785
|
+
);
|
|
2786
|
+
}
|
|
2787
|
+
const conversionResult = this.pipeline.convertToNotion(content, {
|
|
2788
|
+
direction: "push",
|
|
2789
|
+
path: updatePath,
|
|
2790
|
+
filePath: path,
|
|
2791
|
+
parentMode: this.config.notion.parentMode
|
|
2792
|
+
});
|
|
2793
|
+
const blocks = this.blockConverter.markdownToNotionBlocks(conversionResult.content);
|
|
2794
|
+
const existingBlocks = await this.notionClient.fetchAllChildren(record.notionPageId);
|
|
2795
|
+
if (blocks.length > 0) {
|
|
2796
|
+
await this.notionClient.appendChildren(record.notionPageId, blocks);
|
|
2797
|
+
}
|
|
2798
|
+
for (const block of existingBlocks) {
|
|
2799
|
+
await this.notionClient.deleteBlock(block.id);
|
|
2800
|
+
}
|
|
2801
|
+
let propsToUpdate = conversionResult.properties;
|
|
2802
|
+
if (this.isDatabaseMode && propsToUpdate && Object.keys(propsToUpdate).length > 0) {
|
|
2803
|
+
await this.ensureDbSchema();
|
|
2804
|
+
const title = extractTitle(path);
|
|
2805
|
+
propsToUpdate = this.propertyMapper.toNotionProperties(propsToUpdate, title);
|
|
2806
|
+
}
|
|
2807
|
+
if (propsToUpdate && Object.keys(propsToUpdate).length > 0) {
|
|
2808
|
+
await this.notionClient.updatePageProperties(record.notionPageId, propsToUpdate);
|
|
2809
|
+
}
|
|
2810
|
+
const hash = computeHash(content);
|
|
2811
|
+
this.stateDb.transaction(() => {
|
|
2812
|
+
this.stateDb.updateHash(record.id, hash, Buffer.from(content, "utf-8"));
|
|
2813
|
+
this.stateDb.updateStatus(record.id, "synced");
|
|
2814
|
+
this.stateDb.storePreserveMarkers(path, conversionResult.preserveMarkers);
|
|
2815
|
+
});
|
|
2816
|
+
}
|
|
2817
|
+
async pushDelete(path) {
|
|
2818
|
+
const record = this.stateDb.getByPath(path);
|
|
2819
|
+
if (!record?.notionPageId) return;
|
|
2820
|
+
if (this.config.sync.deleteSync) {
|
|
2821
|
+
await this.notionClient.archivePage(record.notionPageId);
|
|
2822
|
+
}
|
|
2823
|
+
this.stateDb.delete(record.id);
|
|
2824
|
+
}
|
|
2825
|
+
async detectRemoteChanges() {
|
|
2826
|
+
const changes = [];
|
|
2827
|
+
const lastPull = this.stateDb.getMeta("last_pull_at");
|
|
2828
|
+
const syncedRecords = this.stateDb.getAll();
|
|
2829
|
+
const trackedPageIds = new Set(
|
|
2830
|
+
syncedRecords.filter((r) => r.notionPageId).map((r) => r.notionPageId)
|
|
2831
|
+
);
|
|
2832
|
+
let remotePages;
|
|
2833
|
+
if (this.isDatabaseMode) {
|
|
2834
|
+
const allPages = [];
|
|
2835
|
+
let cursor;
|
|
2836
|
+
do {
|
|
2837
|
+
const result = await this.notionClient.queryDatabase(this.config.notion.databaseId, {
|
|
2838
|
+
startCursor: cursor
|
|
2839
|
+
});
|
|
2840
|
+
allPages.push(
|
|
2841
|
+
...result.results.map((p) => ({ id: p.id, last_edited_time: p.last_edited_time }))
|
|
2842
|
+
);
|
|
2843
|
+
cursor = result.nextCursor ?? void 0;
|
|
2844
|
+
} while (cursor);
|
|
2845
|
+
remotePages = allPages;
|
|
2846
|
+
} else {
|
|
2847
|
+
remotePages = await this.notionClient.getChildPagesRecursive(this.config.notion.rootPageId);
|
|
2848
|
+
}
|
|
2849
|
+
for (const page of remotePages) {
|
|
2850
|
+
const record = this.stateDb.getByNotionId(page.id);
|
|
2851
|
+
if (!record) {
|
|
2852
|
+
changes.push({
|
|
2853
|
+
pageId: page.id,
|
|
2854
|
+
type: "created",
|
|
2855
|
+
lastEdited: page.last_edited_time,
|
|
2856
|
+
previousEdited: null
|
|
2857
|
+
});
|
|
2858
|
+
} else if (page.last_edited_time !== record.notionLastEdited) {
|
|
2859
|
+
changes.push({
|
|
2860
|
+
pageId: page.id,
|
|
2861
|
+
type: "modified",
|
|
2862
|
+
lastEdited: page.last_edited_time,
|
|
2863
|
+
previousEdited: record.notionLastEdited
|
|
2864
|
+
});
|
|
2865
|
+
}
|
|
2866
|
+
trackedPageIds.delete(page.id);
|
|
2867
|
+
}
|
|
2868
|
+
if (this.config.sync.deleteSync && lastPull) {
|
|
2869
|
+
for (const orphanId of trackedPageIds) {
|
|
2870
|
+
const record = syncedRecords.find((r) => r.notionPageId === orphanId);
|
|
2871
|
+
if (record) {
|
|
2872
|
+
changes.push({
|
|
2873
|
+
pageId: orphanId,
|
|
2874
|
+
type: "deleted",
|
|
2875
|
+
lastEdited: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2876
|
+
previousEdited: record.notionLastEdited
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
return changes;
|
|
2882
|
+
}
|
|
2883
|
+
async pullCreate(pageId) {
|
|
2884
|
+
const page = await this.notionClient.getPage(pageId);
|
|
2885
|
+
const title = this.notionClient.extractTitle(page);
|
|
2886
|
+
const safeName = sanitizeFileName(title);
|
|
2887
|
+
const parentPath = await this.resolveParentPath(page);
|
|
2888
|
+
const children = await this.notionClient.listChildren(pageId, { pageSize: 1 });
|
|
2889
|
+
const hasChildPages = children.results.some((b) => "type" in b && b.type === "child_page");
|
|
2890
|
+
const markdown = await this.blockConverter.notionBlocksToMarkdown(pageId);
|
|
2891
|
+
const hasContent = markdown.trim().length > 0;
|
|
2892
|
+
let filePath;
|
|
2893
|
+
let fileType;
|
|
2894
|
+
if (hasChildPages && hasContent) {
|
|
2895
|
+
const folderPath = parentPath ? `${parentPath}/${safeName}` : safeName;
|
|
2896
|
+
filePath = `${folderPath}/${safeName}.md`;
|
|
2897
|
+
fileType = "folder-note";
|
|
2898
|
+
await this.vaultFs.ensureFolder(folderPath);
|
|
2899
|
+
} else if (hasChildPages && !hasContent) {
|
|
2900
|
+
const folderPath = parentPath ? `${parentPath}/${safeName}` : safeName;
|
|
2901
|
+
filePath = `${folderPath}/${safeName}.md`;
|
|
2902
|
+
fileType = "folder-only";
|
|
2903
|
+
await this.vaultFs.ensureFolder(folderPath);
|
|
2904
|
+
} else {
|
|
2905
|
+
filePath = parentPath ? `${parentPath}/${safeName}.md` : `${safeName}.md`;
|
|
2906
|
+
fileType = "file";
|
|
2907
|
+
}
|
|
2908
|
+
let properties;
|
|
2909
|
+
if (this.isDatabaseMode) {
|
|
2910
|
+
await this.ensureDbSchema();
|
|
2911
|
+
properties = this.propertyMapper.fromNotionProperties(
|
|
2912
|
+
page.properties
|
|
2913
|
+
);
|
|
2914
|
+
} else {
|
|
2915
|
+
properties = this.notionClient.extractProperties(page);
|
|
2916
|
+
}
|
|
2917
|
+
let processedMarkdown = markdown;
|
|
2918
|
+
if (this.config.conversion.imageDownload === "immediate") {
|
|
2919
|
+
const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
|
|
2920
|
+
processedMarkdown = imageResult.content;
|
|
2921
|
+
}
|
|
2922
|
+
const finalContent = this.pipeline.convertToMarkdown(
|
|
2923
|
+
processedMarkdown,
|
|
2924
|
+
{
|
|
2925
|
+
direction: "pull",
|
|
2926
|
+
path: "markdown-api",
|
|
2927
|
+
filePath,
|
|
2928
|
+
parentMode: this.config.notion.parentMode
|
|
2929
|
+
},
|
|
2930
|
+
{ properties }
|
|
2931
|
+
);
|
|
2932
|
+
await this.vaultFs.writeFile(filePath, finalContent);
|
|
2933
|
+
const hash = computeHash(finalContent);
|
|
2934
|
+
this.stateDb.transaction(() => {
|
|
2935
|
+
this.stateDb.upsert({
|
|
2936
|
+
obsidianPath: filePath,
|
|
2937
|
+
notionPageId: pageId,
|
|
2938
|
+
notionParentId: this.extractParentId(page),
|
|
2939
|
+
contentHash: hash,
|
|
2940
|
+
notionLastEdited: page.last_edited_time,
|
|
2941
|
+
localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2942
|
+
syncDirection: "both",
|
|
2943
|
+
fileType,
|
|
2944
|
+
status: "synced",
|
|
2945
|
+
baseSnapshot: Buffer.from(finalContent, "utf-8")
|
|
2946
|
+
});
|
|
2947
|
+
this.stateDb.upsertWikilink({
|
|
2948
|
+
obsidianPath: filePath,
|
|
2949
|
+
notionPageId: pageId,
|
|
2950
|
+
title,
|
|
2951
|
+
aliases: []
|
|
2952
|
+
});
|
|
2953
|
+
});
|
|
2954
|
+
return filePath;
|
|
2955
|
+
}
|
|
2956
|
+
async pullUpdate(change) {
|
|
2957
|
+
const record = this.stateDb.getByNotionId(change.pageId);
|
|
2958
|
+
if (!record) return {};
|
|
2959
|
+
const page = await this.notionClient.getPage(change.pageId);
|
|
2960
|
+
let markdown = await this.blockConverter.notionBlocksToMarkdown(change.pageId);
|
|
2961
|
+
let properties;
|
|
2962
|
+
if (this.isDatabaseMode) {
|
|
2963
|
+
await this.ensureDbSchema();
|
|
2964
|
+
properties = this.propertyMapper.fromNotionProperties(
|
|
2965
|
+
page.properties
|
|
2966
|
+
);
|
|
2967
|
+
} else {
|
|
2968
|
+
properties = this.notionClient.extractProperties(page);
|
|
2969
|
+
}
|
|
2970
|
+
if (this.config.conversion.imageDownload === "immediate") {
|
|
2971
|
+
const title = this.notionClient.extractTitle(page);
|
|
2972
|
+
const imageResult = await this.imageHandler.downloadAllImages(markdown, title);
|
|
2973
|
+
markdown = imageResult.content;
|
|
2974
|
+
}
|
|
2975
|
+
const savedMarkers = this.stateDb.getPreserveMarkers(record.obsidianPath);
|
|
2976
|
+
const remoteContent = this.pipeline.convertToMarkdown(
|
|
2977
|
+
markdown,
|
|
2978
|
+
{
|
|
2979
|
+
direction: "pull",
|
|
2980
|
+
path: "markdown-api",
|
|
2981
|
+
filePath: record.obsidianPath,
|
|
2982
|
+
parentMode: this.config.notion.parentMode
|
|
2983
|
+
},
|
|
2984
|
+
{ properties, preserveMarkers: savedMarkers.length > 0 ? savedMarkers : void 0 }
|
|
2985
|
+
);
|
|
2986
|
+
let localContent;
|
|
2987
|
+
try {
|
|
2988
|
+
localContent = await this.vaultFs.readFile(record.obsidianPath);
|
|
2989
|
+
} catch {
|
|
2990
|
+
localContent = "";
|
|
2991
|
+
}
|
|
2992
|
+
const localHash = computeHash(localContent);
|
|
2993
|
+
const localModified = localHash !== record.contentHash;
|
|
2994
|
+
if (localModified) {
|
|
2995
|
+
const conflict = {
|
|
2996
|
+
syncRecord: record,
|
|
2997
|
+
localChange: {
|
|
2998
|
+
path: record.obsidianPath,
|
|
2999
|
+
type: "modified",
|
|
3000
|
+
currentHash: localHash,
|
|
3001
|
+
previousHash: record.contentHash
|
|
3002
|
+
},
|
|
3003
|
+
remoteChange: change,
|
|
3004
|
+
baseContent: record.baseSnapshot?.toString("utf-8") ?? null,
|
|
3005
|
+
localContent,
|
|
3006
|
+
remoteContent
|
|
3007
|
+
};
|
|
3008
|
+
this.stateDb.updateStatus(record.id, "conflict");
|
|
3009
|
+
return { conflict };
|
|
3010
|
+
}
|
|
3011
|
+
await this.vaultFs.writeFile(record.obsidianPath, remoteContent);
|
|
3012
|
+
const newHash = computeHash(remoteContent);
|
|
3013
|
+
this.stateDb.upsert({
|
|
3014
|
+
obsidianPath: record.obsidianPath,
|
|
3015
|
+
notionPageId: change.pageId,
|
|
3016
|
+
notionParentId: record.notionParentId,
|
|
3017
|
+
contentHash: newHash,
|
|
3018
|
+
notionLastEdited: page.last_edited_time,
|
|
3019
|
+
localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3020
|
+
syncDirection: record.syncDirection,
|
|
3021
|
+
fileType: record.fileType,
|
|
3022
|
+
status: "synced",
|
|
3023
|
+
baseSnapshot: Buffer.from(remoteContent, "utf-8")
|
|
3024
|
+
});
|
|
3025
|
+
return { path: record.obsidianPath };
|
|
3026
|
+
}
|
|
3027
|
+
async pullDelete(pageId) {
|
|
3028
|
+
const record = this.stateDb.getByNotionId(pageId);
|
|
3029
|
+
if (!record) return null;
|
|
3030
|
+
if (this.config.sync.deleteSync) {
|
|
3031
|
+
try {
|
|
3032
|
+
await this.vaultFs.deleteFile(record.obsidianPath);
|
|
3033
|
+
} catch {
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
this.stateDb.delete(record.id);
|
|
3037
|
+
return record.obsidianPath;
|
|
3038
|
+
}
|
|
3039
|
+
async ensureFolderPage(folderPath) {
|
|
3040
|
+
const existing = this.stateDb.getByPath(folderPath);
|
|
3041
|
+
if (existing?.notionPageId) return;
|
|
3042
|
+
const parts = folderPath.split("/");
|
|
3043
|
+
const folderName = parts[parts.length - 1];
|
|
3044
|
+
let parentId = this.config.notion.rootPageId;
|
|
3045
|
+
if (parts.length > 1) {
|
|
3046
|
+
const parentPath = parts.slice(0, -1).join("/");
|
|
3047
|
+
const parentRecord = this.stateDb.getByPath(parentPath);
|
|
3048
|
+
if (parentRecord?.notionPageId) {
|
|
3049
|
+
parentId = parentRecord.notionPageId;
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
const folderPage = await this.notionClient.createPage({
|
|
3053
|
+
parentId,
|
|
3054
|
+
parentType: "page",
|
|
3055
|
+
title: folderName
|
|
3056
|
+
});
|
|
3057
|
+
this.stateDb.upsert({
|
|
3058
|
+
obsidianPath: folderPath,
|
|
3059
|
+
notionPageId: folderPage.id,
|
|
3060
|
+
notionParentId: parentId,
|
|
3061
|
+
contentHash: "",
|
|
3062
|
+
notionLastEdited: folderPage.last_edited_time,
|
|
3063
|
+
localLastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3064
|
+
syncDirection: "both",
|
|
3065
|
+
fileType: "folder-note",
|
|
3066
|
+
status: "synced"
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
3069
|
+
async resolveNotionParent(filePath) {
|
|
3070
|
+
const parts = filePath.split("/");
|
|
3071
|
+
if (parts.length <= 1) return this.config.notion.rootPageId;
|
|
3072
|
+
const folderPath = parts.slice(0, -1).join("/");
|
|
3073
|
+
const folderRecord = this.stateDb.getByPath(folderPath);
|
|
3074
|
+
if (folderRecord?.notionPageId) return folderRecord.notionPageId;
|
|
3075
|
+
return this.config.notion.rootPageId;
|
|
3076
|
+
}
|
|
3077
|
+
isFolderNote(filePath) {
|
|
3078
|
+
const parts = filePath.split("/");
|
|
3079
|
+
if (parts.length < 2) return false;
|
|
3080
|
+
const fileName = parts[parts.length - 1].replace(/\.md$/, "");
|
|
3081
|
+
const folderName = parts[parts.length - 2];
|
|
3082
|
+
return fileName === folderName;
|
|
3083
|
+
}
|
|
3084
|
+
async resolveParentPath(page) {
|
|
3085
|
+
const parentId = this.extractParentId(page);
|
|
3086
|
+
if (!parentId || notionIdsEqual(parentId, this.config.notion.rootPageId)) return "";
|
|
3087
|
+
const parentRecord = this.stateDb.getByNotionId(parentId);
|
|
3088
|
+
if (parentRecord) {
|
|
3089
|
+
if (parentRecord.fileType === "folder-note" || parentRecord.fileType === "folder-only") {
|
|
3090
|
+
const pathParts2 = parentRecord.obsidianPath.split("/");
|
|
3091
|
+
pathParts2.pop();
|
|
3092
|
+
return pathParts2.join("/");
|
|
3093
|
+
}
|
|
3094
|
+
const pathParts = parentRecord.obsidianPath.split("/");
|
|
3095
|
+
pathParts.pop();
|
|
3096
|
+
const parentDir = pathParts.join("/");
|
|
3097
|
+
return parentDir || sanitizeFileName(this.notionClient.extractTitle(await this.notionClient.getPage(parentId)));
|
|
3098
|
+
}
|
|
3099
|
+
try {
|
|
3100
|
+
const parentPage = await this.notionClient.getPage(parentId);
|
|
3101
|
+
const parentTitle = this.notionClient.extractTitle(parentPage);
|
|
3102
|
+
return sanitizeFileName(parentTitle);
|
|
3103
|
+
} catch {
|
|
3104
|
+
return "";
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
extractParentId(page) {
|
|
3108
|
+
const parent = page.parent;
|
|
3109
|
+
if (parent.type === "page_id") return parent.page_id ?? null;
|
|
3110
|
+
if (parent.type === "database_id") return parent.database_id ?? null;
|
|
3111
|
+
return null;
|
|
3112
|
+
}
|
|
3113
|
+
};
|
|
3114
|
+
function extractTitle(filePath) {
|
|
3115
|
+
const parts = filePath.split("/");
|
|
3116
|
+
const filename = parts[parts.length - 1] ?? "";
|
|
3117
|
+
return filename.replace(/\.md$/, "");
|
|
3118
|
+
}
|
|
3119
|
+
var NodeVaultFS = class {
|
|
3120
|
+
constructor(rootPath, pathConfig) {
|
|
3121
|
+
this.rootPath = rootPath;
|
|
3122
|
+
this.includePatterns = pathConfig?.include ?? [];
|
|
3123
|
+
const configExclude = pathConfig?.exclude ?? [];
|
|
3124
|
+
const ignorePatterns = this.loadImNobsidianIgnore();
|
|
3125
|
+
this.excludePatterns = [...configExclude, ...ignorePatterns];
|
|
3126
|
+
}
|
|
3127
|
+
rootPath;
|
|
3128
|
+
includePatterns;
|
|
3129
|
+
excludePatterns;
|
|
3130
|
+
loadImNobsidianIgnore() {
|
|
3131
|
+
try {
|
|
3132
|
+
const ignorePath = join(this.rootPath, ".im-nobsidian-ignore");
|
|
3133
|
+
const content = readFileSync(ignorePath, "utf-8");
|
|
3134
|
+
return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
3135
|
+
} catch {
|
|
3136
|
+
return [];
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
async readFile(path) {
|
|
3140
|
+
const fullPath = join(this.rootPath, path);
|
|
3141
|
+
return readFile(fullPath, "utf-8");
|
|
3142
|
+
}
|
|
3143
|
+
async writeFile(path, content) {
|
|
3144
|
+
const fullPath = join(this.rootPath, path);
|
|
3145
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
3146
|
+
await writeFile(fullPath, content, "utf-8");
|
|
3147
|
+
}
|
|
3148
|
+
async writeBinary(path, data) {
|
|
3149
|
+
const fullPath = join(this.rootPath, path);
|
|
3150
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
3151
|
+
await writeFile(fullPath, data);
|
|
3152
|
+
}
|
|
3153
|
+
async deleteFile(path) {
|
|
3154
|
+
const fullPath = join(this.rootPath, path);
|
|
3155
|
+
await unlink(fullPath);
|
|
3156
|
+
}
|
|
3157
|
+
async moveFile(from, to) {
|
|
3158
|
+
const fromPath = join(this.rootPath, from);
|
|
3159
|
+
const toPath = join(this.rootPath, to);
|
|
3160
|
+
await mkdir(dirname(toPath), { recursive: true });
|
|
3161
|
+
await rename(fromPath, toPath);
|
|
3162
|
+
}
|
|
3163
|
+
async exists(path) {
|
|
3164
|
+
try {
|
|
3165
|
+
await stat(join(this.rootPath, path));
|
|
3166
|
+
return true;
|
|
3167
|
+
} catch {
|
|
3168
|
+
return false;
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
async ensureFolder(path) {
|
|
3172
|
+
await mkdir(join(this.rootPath, path), { recursive: true });
|
|
3173
|
+
}
|
|
3174
|
+
async listMarkdownFiles() {
|
|
3175
|
+
const files = [];
|
|
3176
|
+
await this.walkDir(this.rootPath, files);
|
|
3177
|
+
return files;
|
|
3178
|
+
}
|
|
3179
|
+
matchesPattern(relativePath, pattern) {
|
|
3180
|
+
if (pattern === "**/*" || pattern === "**") return true;
|
|
3181
|
+
if (pattern.endsWith("/**")) {
|
|
3182
|
+
const dir = pattern.slice(0, -3);
|
|
3183
|
+
return relativePath.startsWith(dir + "/") || relativePath === dir;
|
|
3184
|
+
} else if (pattern.startsWith("*.")) {
|
|
3185
|
+
const ext = pattern.slice(1);
|
|
3186
|
+
return relativePath.endsWith(ext);
|
|
3187
|
+
} else if (pattern.includes("*")) {
|
|
3188
|
+
const regexStr = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
|
|
3189
|
+
return new RegExp("^" + regexStr + "$").test(relativePath);
|
|
3190
|
+
} else {
|
|
3191
|
+
return relativePath === pattern || relativePath.startsWith(pattern + "/");
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
isExcluded(relativePath) {
|
|
3195
|
+
if (this.includePatterns.length > 0) {
|
|
3196
|
+
const included = this.includePatterns.some((p) => this.matchesPattern(relativePath, p));
|
|
3197
|
+
if (!included) return true;
|
|
3198
|
+
}
|
|
3199
|
+
return this.excludePatterns.some((p) => this.matchesPattern(relativePath, p));
|
|
3200
|
+
}
|
|
3201
|
+
async walkDir(dir, result) {
|
|
3202
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
3203
|
+
for (const entry of entries) {
|
|
3204
|
+
const fullPath = join(dir, entry.name);
|
|
3205
|
+
if (entry.name.startsWith(".")) continue;
|
|
3206
|
+
const relativePath = relative(this.rootPath, fullPath);
|
|
3207
|
+
if (this.isExcluded(relativePath)) continue;
|
|
3208
|
+
if (entry.isDirectory()) {
|
|
3209
|
+
await this.walkDir(fullPath, result);
|
|
3210
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
3211
|
+
const content = await readFile(fullPath, "utf-8");
|
|
3212
|
+
const fileStat = await stat(fullPath);
|
|
3213
|
+
result.push({
|
|
3214
|
+
path: relativePath,
|
|
3215
|
+
content,
|
|
3216
|
+
mtime: fileStat.mtime.toISOString()
|
|
3217
|
+
});
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
};
|
|
3222
|
+
|
|
3223
|
+
// src/converter/pre-processors/inline-db.ts
|
|
3224
|
+
var INLINE_DB_START = /%% im-nobsidian:inline-db:([^\s]+) %%/g;
|
|
3225
|
+
var INLINE_DB_END = /%% im-nobsidian:end %%/g;
|
|
3226
|
+
var InlineDBParser = class {
|
|
3227
|
+
name = "InlineDBParser";
|
|
3228
|
+
order = 40;
|
|
3229
|
+
process(input) {
|
|
3230
|
+
const inlineDbBlocks = [];
|
|
3231
|
+
const content = input.content;
|
|
3232
|
+
const startMatches = [...content.matchAll(INLINE_DB_START)];
|
|
3233
|
+
for (const match of startMatches) {
|
|
3234
|
+
const startIdx = match.index;
|
|
3235
|
+
const params = parseParams(match[1]);
|
|
3236
|
+
const afterStart = content.indexOf("\n", startIdx) + 1;
|
|
3237
|
+
const endMatch = INLINE_DB_END.exec(content.slice(afterStart));
|
|
3238
|
+
if (endMatch) {
|
|
3239
|
+
const tableContent = content.slice(afterStart, afterStart + endMatch.index);
|
|
3240
|
+
inlineDbBlocks.push({
|
|
3241
|
+
id: params["id"] ?? "",
|
|
3242
|
+
title: params["title"] ?? "",
|
|
3243
|
+
tableMarkdown: tableContent.trim(),
|
|
3244
|
+
startIdx,
|
|
3245
|
+
endIdx: afterStart + endMatch.index + endMatch[0].length
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3248
|
+
INLINE_DB_END.lastIndex = 0;
|
|
3249
|
+
}
|
|
3250
|
+
return {
|
|
3251
|
+
content,
|
|
3252
|
+
metadata: {
|
|
3253
|
+
...input.metadata,
|
|
3254
|
+
inlineDbBlocks
|
|
3255
|
+
}
|
|
3256
|
+
};
|
|
3257
|
+
}
|
|
3258
|
+
};
|
|
3259
|
+
function parseParams(paramString) {
|
|
3260
|
+
const params = {};
|
|
3261
|
+
for (const pair of paramString.split("&")) {
|
|
3262
|
+
const [key, value] = pair.split("=");
|
|
3263
|
+
if (key && value) {
|
|
3264
|
+
params[key] = decodeURIComponent(value);
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
return params;
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
// src/conflict/merger.ts
|
|
3271
|
+
function threeWayMerge(base, local, remote) {
|
|
3272
|
+
const baseLines = base.split("\n");
|
|
3273
|
+
const localLines = local.split("\n");
|
|
3274
|
+
const remoteLines = remote.split("\n");
|
|
3275
|
+
const localDiff = computeDiff(baseLines, localLines);
|
|
3276
|
+
const remoteDiff = computeDiff(baseLines, remoteLines);
|
|
3277
|
+
const merged = [];
|
|
3278
|
+
const conflicts = [];
|
|
3279
|
+
let success = true;
|
|
3280
|
+
let baseIdx = 0;
|
|
3281
|
+
while (baseIdx < baseLines.length || localDiff.length > 0 || remoteDiff.length > 0) {
|
|
3282
|
+
const localChange = localDiff.find((d) => d.baseStart === baseIdx);
|
|
3283
|
+
const remoteChange = remoteDiff.find((d) => d.baseStart === baseIdx);
|
|
3284
|
+
if (localChange && remoteChange) {
|
|
3285
|
+
if (arraysEqual(localChange.newLines, remoteChange.newLines)) {
|
|
3286
|
+
merged.push(...localChange.newLines);
|
|
3287
|
+
} else {
|
|
3288
|
+
success = false;
|
|
3289
|
+
conflicts.push({
|
|
3290
|
+
localLines: localChange.newLines,
|
|
3291
|
+
remoteLines: remoteChange.newLines
|
|
3292
|
+
});
|
|
3293
|
+
merged.push("<<<<<<< LOCAL");
|
|
3294
|
+
merged.push(...localChange.newLines);
|
|
3295
|
+
merged.push("=======");
|
|
3296
|
+
merged.push(...remoteChange.newLines);
|
|
3297
|
+
merged.push(">>>>>>> REMOTE");
|
|
3298
|
+
}
|
|
3299
|
+
baseIdx = Math.max(localChange.baseEnd, remoteChange.baseEnd);
|
|
3300
|
+
localDiff.splice(localDiff.indexOf(localChange), 1);
|
|
3301
|
+
remoteDiff.splice(remoteDiff.indexOf(remoteChange), 1);
|
|
3302
|
+
} else if (localChange) {
|
|
3303
|
+
merged.push(...localChange.newLines);
|
|
3304
|
+
baseIdx = localChange.baseEnd;
|
|
3305
|
+
localDiff.splice(localDiff.indexOf(localChange), 1);
|
|
3306
|
+
} else if (remoteChange) {
|
|
3307
|
+
merged.push(...remoteChange.newLines);
|
|
3308
|
+
baseIdx = remoteChange.baseEnd;
|
|
3309
|
+
remoteDiff.splice(remoteDiff.indexOf(remoteChange), 1);
|
|
3310
|
+
} else {
|
|
3311
|
+
if (baseIdx < baseLines.length) {
|
|
3312
|
+
merged.push(baseLines[baseIdx]);
|
|
3313
|
+
}
|
|
3314
|
+
baseIdx++;
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
return {
|
|
3318
|
+
success,
|
|
3319
|
+
merged: merged.join("\n"),
|
|
3320
|
+
conflicts
|
|
3321
|
+
};
|
|
3322
|
+
}
|
|
3323
|
+
function computeDiff(base, modified) {
|
|
3324
|
+
const hunks = [];
|
|
3325
|
+
let baseIdx = 0;
|
|
3326
|
+
let modIdx = 0;
|
|
3327
|
+
while (baseIdx < base.length && modIdx < modified.length) {
|
|
3328
|
+
if (base[baseIdx] === modified[modIdx]) {
|
|
3329
|
+
baseIdx++;
|
|
3330
|
+
modIdx++;
|
|
3331
|
+
} else {
|
|
3332
|
+
const hunkStart = baseIdx;
|
|
3333
|
+
const newLines = [];
|
|
3334
|
+
while (modIdx < modified.length && base[baseIdx] !== modified[modIdx]) {
|
|
3335
|
+
newLines.push(modified[modIdx]);
|
|
3336
|
+
modIdx++;
|
|
3337
|
+
}
|
|
3338
|
+
baseIdx++;
|
|
3339
|
+
hunks.push({ baseStart: hunkStart, baseEnd: baseIdx, newLines });
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
if (modIdx < modified.length) {
|
|
3343
|
+
hunks.push({
|
|
3344
|
+
baseStart: baseIdx,
|
|
3345
|
+
baseEnd: base.length,
|
|
3346
|
+
newLines: modified.slice(modIdx)
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
return hunks;
|
|
3350
|
+
}
|
|
3351
|
+
function arraysEqual(a, b) {
|
|
3352
|
+
if (a.length !== b.length) return false;
|
|
3353
|
+
return a.every((val, idx) => val === b[idx]);
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
// src/conflict/resolver.ts
|
|
3357
|
+
var ConflictResolver = class {
|
|
3358
|
+
constructor(stateDb, vaultFs) {
|
|
3359
|
+
this.stateDb = stateDb;
|
|
3360
|
+
this.vaultFs = vaultFs;
|
|
3361
|
+
}
|
|
3362
|
+
stateDb;
|
|
3363
|
+
vaultFs;
|
|
3364
|
+
async resolve(conflict, choice) {
|
|
3365
|
+
switch (choice) {
|
|
3366
|
+
case "local":
|
|
3367
|
+
return this.resolveLocal(conflict);
|
|
3368
|
+
case "remote":
|
|
3369
|
+
return this.resolveRemote(conflict);
|
|
3370
|
+
case "merge":
|
|
3371
|
+
return this.resolveMerge(conflict);
|
|
3372
|
+
case "duplicate":
|
|
3373
|
+
return this.resolveDuplicate(conflict);
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
async resolveByStrategy(conflict, strategy) {
|
|
3377
|
+
switch (strategy) {
|
|
3378
|
+
case "local-first":
|
|
3379
|
+
return this.resolve(conflict, "local");
|
|
3380
|
+
case "remote-first":
|
|
3381
|
+
return this.resolve(conflict, "remote");
|
|
3382
|
+
case "duplicate":
|
|
3383
|
+
return this.resolve(conflict, "duplicate");
|
|
3384
|
+
case "manual":
|
|
3385
|
+
return this.resolve(conflict, "merge");
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
async resolveAll(conflicts, strategy) {
|
|
3389
|
+
const results = [];
|
|
3390
|
+
for (const conflict of conflicts) {
|
|
3391
|
+
const result = await this.resolveByStrategy(conflict, strategy);
|
|
3392
|
+
results.push(result);
|
|
3393
|
+
}
|
|
3394
|
+
return results;
|
|
3395
|
+
}
|
|
3396
|
+
async resolveLocal(conflict) {
|
|
3397
|
+
const hash = computeHash(conflict.localContent);
|
|
3398
|
+
this.stateDb.updateHash(
|
|
3399
|
+
conflict.syncRecord.id,
|
|
3400
|
+
hash,
|
|
3401
|
+
Buffer.from(conflict.localContent, "utf-8")
|
|
3402
|
+
);
|
|
3403
|
+
this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
|
|
3404
|
+
return { path: conflict.syncRecord.obsidianPath, choice: "local", success: true };
|
|
3405
|
+
}
|
|
3406
|
+
async resolveRemote(conflict) {
|
|
3407
|
+
await this.vaultFs.writeFile(conflict.syncRecord.obsidianPath, conflict.remoteContent);
|
|
3408
|
+
const hash = computeHash(conflict.remoteContent);
|
|
3409
|
+
this.stateDb.updateHash(
|
|
3410
|
+
conflict.syncRecord.id,
|
|
3411
|
+
hash,
|
|
3412
|
+
Buffer.from(conflict.remoteContent, "utf-8")
|
|
3413
|
+
);
|
|
3414
|
+
this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
|
|
3415
|
+
return { path: conflict.syncRecord.obsidianPath, choice: "remote", success: true };
|
|
3416
|
+
}
|
|
3417
|
+
async resolveMerge(conflict) {
|
|
3418
|
+
const base = conflict.baseContent ?? "";
|
|
3419
|
+
const mergeResult = threeWayMerge(base, conflict.localContent, conflict.remoteContent);
|
|
3420
|
+
await this.vaultFs.writeFile(conflict.syncRecord.obsidianPath, mergeResult.merged);
|
|
3421
|
+
const hash = computeHash(mergeResult.merged);
|
|
3422
|
+
this.stateDb.updateHash(conflict.syncRecord.id, hash, Buffer.from(mergeResult.merged, "utf-8"));
|
|
3423
|
+
if (mergeResult.success) {
|
|
3424
|
+
this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
|
|
3425
|
+
}
|
|
3426
|
+
return {
|
|
3427
|
+
path: conflict.syncRecord.obsidianPath,
|
|
3428
|
+
choice: "merge",
|
|
3429
|
+
success: mergeResult.success,
|
|
3430
|
+
mergeHadConflicts: !mergeResult.success
|
|
3431
|
+
};
|
|
3432
|
+
}
|
|
3433
|
+
async resolveDuplicate(conflict) {
|
|
3434
|
+
const originalPath = conflict.syncRecord.obsidianPath;
|
|
3435
|
+
const ext = originalPath.endsWith(".md") ? ".md" : "";
|
|
3436
|
+
const baseName = originalPath.replace(/\.md$/, "");
|
|
3437
|
+
const conflictPath = `${baseName}.conflict${ext}`;
|
|
3438
|
+
await this.vaultFs.writeFile(conflictPath, conflict.remoteContent);
|
|
3439
|
+
const localHash = computeHash(conflict.localContent);
|
|
3440
|
+
this.stateDb.updateHash(
|
|
3441
|
+
conflict.syncRecord.id,
|
|
3442
|
+
localHash,
|
|
3443
|
+
Buffer.from(conflict.localContent, "utf-8")
|
|
3444
|
+
);
|
|
3445
|
+
this.stateDb.updateStatus(conflict.syncRecord.id, "synced");
|
|
3446
|
+
return { path: originalPath, choice: "duplicate", success: true };
|
|
3447
|
+
}
|
|
3448
|
+
generateDiff(conflict) {
|
|
3449
|
+
const localLines = conflict.localContent.split("\n");
|
|
3450
|
+
const remoteLines = conflict.remoteContent.split("\n");
|
|
3451
|
+
const lines = [];
|
|
3452
|
+
lines.push(`--- local: ${conflict.syncRecord.obsidianPath}`);
|
|
3453
|
+
lines.push(`+++ remote: Notion (${conflict.remoteChange.pageId})`);
|
|
3454
|
+
lines.push("");
|
|
3455
|
+
const maxLen = Math.max(localLines.length, remoteLines.length);
|
|
3456
|
+
for (let i = 0; i < maxLen; i++) {
|
|
3457
|
+
const localLine = localLines[i];
|
|
3458
|
+
const remoteLine = remoteLines[i];
|
|
3459
|
+
if (localLine === remoteLine) {
|
|
3460
|
+
lines.push(` ${localLine ?? ""}`);
|
|
3461
|
+
} else {
|
|
3462
|
+
if (localLine !== void 0) lines.push(`- ${localLine}`);
|
|
3463
|
+
if (remoteLine !== void 0) lines.push(`+ ${remoteLine}`);
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
return lines.join("\n");
|
|
3467
|
+
}
|
|
3468
|
+
};
|
|
3469
|
+
|
|
3470
|
+
// src/structure/tree-mapper.ts
|
|
3471
|
+
var TreeMapper = class {
|
|
3472
|
+
mapNotionToObsidian(root, basePath = "") {
|
|
3473
|
+
const results = [];
|
|
3474
|
+
this.mapRecursive(root, basePath, results);
|
|
3475
|
+
return results;
|
|
3476
|
+
}
|
|
3477
|
+
mapRecursive(node, parentPath, results) {
|
|
3478
|
+
const safeName = sanitizeFileName(node.title || "_untitled");
|
|
3479
|
+
const currentPath = parentPath ? `${parentPath}/${safeName}` : safeName;
|
|
3480
|
+
if (node.isDatabase) {
|
|
3481
|
+
results.push({
|
|
3482
|
+
type: "database-folder",
|
|
3483
|
+
obsidianPath: `${currentPath}/`,
|
|
3484
|
+
folderPath: `${currentPath}/`,
|
|
3485
|
+
schemaPath: `${currentPath}/_schema.yml`
|
|
3486
|
+
});
|
|
3487
|
+
} else if (node.hasContent && node.hasChildren) {
|
|
3488
|
+
results.push({
|
|
3489
|
+
type: "folder-note",
|
|
3490
|
+
obsidianPath: `${currentPath}/${safeName}.md`,
|
|
3491
|
+
folderPath: `${currentPath}/`,
|
|
3492
|
+
notePath: `${currentPath}/${safeName}.md`
|
|
3493
|
+
});
|
|
3494
|
+
} else if (!node.hasContent && node.hasChildren) {
|
|
3495
|
+
results.push({
|
|
3496
|
+
type: "folder-only",
|
|
3497
|
+
obsidianPath: `${currentPath}/`,
|
|
3498
|
+
folderPath: `${currentPath}/`
|
|
3499
|
+
});
|
|
3500
|
+
} else {
|
|
3501
|
+
results.push({
|
|
3502
|
+
type: "file",
|
|
3503
|
+
obsidianPath: `${currentPath}.md`
|
|
3504
|
+
});
|
|
3505
|
+
}
|
|
3506
|
+
for (const child of node.children) {
|
|
3507
|
+
this.mapRecursive(child, currentPath, results);
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
mapObsidianToNotion(entries, parentPageId) {
|
|
3511
|
+
const plans = [];
|
|
3512
|
+
this.planRecursive(entries, parentPageId, plans);
|
|
3513
|
+
return plans;
|
|
3514
|
+
}
|
|
3515
|
+
planRecursive(entries, parentPageId, plans) {
|
|
3516
|
+
for (const entry of entries) {
|
|
3517
|
+
if (entry.isDirectory) {
|
|
3518
|
+
const folderNote = this.findFolderNote(entry);
|
|
3519
|
+
if (folderNote) {
|
|
3520
|
+
plans.push({
|
|
3521
|
+
type: "page-with-children",
|
|
3522
|
+
title: entry.name,
|
|
3523
|
+
parentPageId,
|
|
3524
|
+
contentSource: folderNote.path,
|
|
3525
|
+
hasContent: true
|
|
3526
|
+
});
|
|
3527
|
+
const childEntries = entry.children.filter((c) => c.path !== folderNote.path);
|
|
3528
|
+
this.planRecursive(childEntries, `placeholder:${entry.path}`, plans);
|
|
3529
|
+
} else {
|
|
3530
|
+
plans.push({
|
|
3531
|
+
type: "empty-page-with-children",
|
|
3532
|
+
title: entry.name,
|
|
3533
|
+
parentPageId,
|
|
3534
|
+
hasContent: false
|
|
3535
|
+
});
|
|
3536
|
+
this.planRecursive(entry.children, `placeholder:${entry.path}`, plans);
|
|
3537
|
+
}
|
|
3538
|
+
} else if (entry.name.endsWith(".md") && !entry.name.startsWith("_")) {
|
|
3539
|
+
plans.push({
|
|
3540
|
+
type: "leaf-page",
|
|
3541
|
+
title: entry.name.replace(/\.md$/, ""),
|
|
3542
|
+
parentPageId,
|
|
3543
|
+
contentSource: entry.path,
|
|
3544
|
+
hasContent: true
|
|
3545
|
+
});
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
findFolderNote(dir) {
|
|
3550
|
+
return dir.children.find((c) => c.name === `${dir.name}.md` || c.name === "index.md") ?? null;
|
|
3551
|
+
}
|
|
3552
|
+
determineFileType(page) {
|
|
3553
|
+
if (page.isDatabase) return "database-folder";
|
|
3554
|
+
if (page.hasContent && page.hasChildren) return "folder-note";
|
|
3555
|
+
if (!page.hasContent && page.hasChildren) return "folder-only";
|
|
3556
|
+
return "file";
|
|
3557
|
+
}
|
|
3558
|
+
};
|
|
3559
|
+
var FileWatcher = class {
|
|
3560
|
+
constructor(rootPath, callback) {
|
|
3561
|
+
this.rootPath = rootPath;
|
|
3562
|
+
this.callback = callback;
|
|
3563
|
+
}
|
|
3564
|
+
rootPath;
|
|
3565
|
+
callback;
|
|
3566
|
+
watcher = null;
|
|
3567
|
+
start() {
|
|
3568
|
+
this.watcher = watch(this.rootPath, {
|
|
3569
|
+
ignored: [/(^|[/\\])\./, "**/node_modules/**", "**/.im-nobsidian/**"],
|
|
3570
|
+
persistent: true,
|
|
3571
|
+
ignoreInitial: true,
|
|
3572
|
+
awaitWriteFinish: {
|
|
3573
|
+
stabilityThreshold: 500,
|
|
3574
|
+
pollInterval: 100
|
|
3575
|
+
}
|
|
3576
|
+
});
|
|
3577
|
+
this.watcher.on("add", (path) => this.callback("add", path));
|
|
3578
|
+
this.watcher.on("change", (path) => this.callback("change", path));
|
|
3579
|
+
this.watcher.on("unlink", (path) => this.callback("unlink", path));
|
|
3580
|
+
}
|
|
3581
|
+
async stop() {
|
|
3582
|
+
if (this.watcher) {
|
|
3583
|
+
await this.watcher.close();
|
|
3584
|
+
this.watcher = null;
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
};
|
|
3588
|
+
|
|
3589
|
+
// src/watcher/watch-sync-service.ts
|
|
3590
|
+
var DEFAULT_DEBOUNCE_MS = 2e3;
|
|
3591
|
+
var WatchSyncService = class {
|
|
3592
|
+
constructor(rootPath, orchestrator, options = {}) {
|
|
3593
|
+
this.rootPath = rootPath;
|
|
3594
|
+
this.orchestrator = orchestrator;
|
|
3595
|
+
this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
3596
|
+
this.options = options;
|
|
3597
|
+
}
|
|
3598
|
+
rootPath;
|
|
3599
|
+
orchestrator;
|
|
3600
|
+
debounceMs;
|
|
3601
|
+
options;
|
|
3602
|
+
watcher = null;
|
|
3603
|
+
debounceTimer = null;
|
|
3604
|
+
pendingChanges = /* @__PURE__ */ new Map();
|
|
3605
|
+
syncing = false;
|
|
3606
|
+
syncQueued = false;
|
|
3607
|
+
running = false;
|
|
3608
|
+
start() {
|
|
3609
|
+
if (this.running) return;
|
|
3610
|
+
this.running = true;
|
|
3611
|
+
this.watcher = new FileWatcher(this.rootPath, (event, path) => {
|
|
3612
|
+
this.handleFileChange(event, path);
|
|
3613
|
+
});
|
|
3614
|
+
this.watcher.start();
|
|
3615
|
+
}
|
|
3616
|
+
async stop() {
|
|
3617
|
+
if (!this.running) return;
|
|
3618
|
+
this.running = false;
|
|
3619
|
+
this.clearDebounce();
|
|
3620
|
+
this.pendingChanges.clear();
|
|
3621
|
+
if (this.watcher) {
|
|
3622
|
+
await this.watcher.stop();
|
|
3623
|
+
this.watcher = null;
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
isRunning() {
|
|
3627
|
+
return this.running;
|
|
3628
|
+
}
|
|
3629
|
+
isSyncing() {
|
|
3630
|
+
return this.syncing;
|
|
3631
|
+
}
|
|
3632
|
+
getPendingCount() {
|
|
3633
|
+
return this.pendingChanges.size;
|
|
3634
|
+
}
|
|
3635
|
+
handleFileChange(event, path) {
|
|
3636
|
+
if (!path.endsWith(".md")) return;
|
|
3637
|
+
this.pendingChanges.set(path, event);
|
|
3638
|
+
this.options.onFileChange?.(event, path);
|
|
3639
|
+
this.scheduleSync();
|
|
3640
|
+
}
|
|
3641
|
+
scheduleSync() {
|
|
3642
|
+
this.clearDebounce();
|
|
3643
|
+
this.debounceTimer = setTimeout(() => {
|
|
3644
|
+
this.debounceTimer = null;
|
|
3645
|
+
void this.executeSync();
|
|
3646
|
+
}, this.debounceMs);
|
|
3647
|
+
}
|
|
3648
|
+
clearDebounce() {
|
|
3649
|
+
if (this.debounceTimer) {
|
|
3650
|
+
clearTimeout(this.debounceTimer);
|
|
3651
|
+
this.debounceTimer = null;
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
async executeSync() {
|
|
3655
|
+
if (this.syncing) {
|
|
3656
|
+
this.syncQueued = true;
|
|
3657
|
+
return;
|
|
3658
|
+
}
|
|
3659
|
+
this.syncing = true;
|
|
3660
|
+
const changedPaths = [...this.pendingChanges.keys()];
|
|
3661
|
+
this.pendingChanges.clear();
|
|
3662
|
+
try {
|
|
3663
|
+
this.options.onSyncStart?.();
|
|
3664
|
+
const result = await this.orchestrator.sync({
|
|
3665
|
+
paths: changedPaths.length > 0 ? changedPaths : void 0
|
|
3666
|
+
});
|
|
3667
|
+
this.options.onSyncComplete?.(result);
|
|
3668
|
+
} catch (error) {
|
|
3669
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3670
|
+
this.options.onSyncError?.(err);
|
|
3671
|
+
} finally {
|
|
3672
|
+
this.syncing = false;
|
|
3673
|
+
if (this.syncQueued) {
|
|
3674
|
+
this.syncQueued = false;
|
|
3675
|
+
void this.executeSync();
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
}
|
|
3679
|
+
};
|
|
3680
|
+
|
|
3681
|
+
export { BlockConverter, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConversionPipeline, DEFAULT_CONFIG, EmbedResolver, FileWatcher, FrontmatterExtractor, FrontmatterGenerator, HtmlAnnotationStripper, ImageHandler, InlineDBParser, LocalImageRestorer, MathNormalizer, MentionToWikilink, NodeVaultFS, NotionBlockBuilder, NotionClient, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, StateDB, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, WatchSyncService, WikilinkResolver, computeBufferHash, computeHash, createDefaultPipeline, generateId, getLogger, normalizeNotionId, notionIdsEqual, sanitizeFileName, setLogger, threeWayMerge };
|
|
3682
|
+
//# sourceMappingURL=index.js.map
|
|
3683
|
+
//# sourceMappingURL=index.js.map
|