@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/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 `![${target}](${encodeURI(target)})`;
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 `![${alt}](${url})`;
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 `![${caption || "video"}](${url ?? ""})`;
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 `![${caption || "audio"}](${url ?? ""})`;
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