@im-nobsidian/core 0.9.1 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { LogLevel, Client } from '@notionhq/client';
3
3
  import { readFile, mkdir, writeFile, unlink, rename, stat, readdir } from 'fs/promises';
4
- import { join, dirname, relative, sep } from 'path';
4
+ import { join, dirname, sep, relative } from 'path';
5
5
  import { mkdirSync, unlinkSync, utimesSync, openSync, writeSync, closeSync, readFileSync, statSync, existsSync, readlinkSync } from 'fs';
6
6
  import Database from 'better-sqlite3';
7
7
  import { randomUUID, createHash } from 'crypto';
@@ -455,6 +455,22 @@ function compactNotionId(id) {
455
455
  return id.replace(/-/g, "").toLowerCase();
456
456
  }
457
457
 
458
+ // src/utils/logger.ts
459
+ var consoleLogger = {
460
+ warn: (msg, ...args) => console.warn(msg, ...args),
461
+ error: (msg, ...args) => console.error(msg, ...args),
462
+ info: (msg, ...args) => console.info(msg, ...args),
463
+ debug: () => {
464
+ }
465
+ };
466
+ var currentLogger = consoleLogger;
467
+ function setLogger(logger) {
468
+ currentLogger = logger;
469
+ }
470
+ function getLogger() {
471
+ return currentLogger;
472
+ }
473
+
458
474
  // src/state/migrations/001-initial.ts
459
475
  var INITIAL_MIGRATION = `
460
476
  CREATE TABLE IF NOT EXISTS sync_state (
@@ -649,24 +665,6 @@ function wikilinkLookupsIn(parentIds) {
649
665
  }
650
666
  ];
651
667
  }
652
-
653
- // src/utils/logger.ts
654
- var consoleLogger = {
655
- warn: (msg, ...args) => console.warn(msg, ...args),
656
- error: (msg, ...args) => console.error(msg, ...args),
657
- info: (msg, ...args) => console.info(msg, ...args),
658
- debug: () => {
659
- }
660
- };
661
- var currentLogger = consoleLogger;
662
- function setLogger(logger) {
663
- currentLogger = logger;
664
- }
665
- function getLogger() {
666
- return currentLogger;
667
- }
668
-
669
- // src/state/state-lock.ts
670
668
  var STATE_LOCK_HEARTBEAT_MS = 3e4;
671
669
  var STATE_LOCK_STALE_MS = STATE_LOCK_HEARTBEAT_MS * 4;
672
670
  function stateDbLockPath(dbPath) {
@@ -903,7 +901,8 @@ var StateDB = class _StateDB {
903
901
  }
904
902
  }
905
903
  static openLocked(dbPath, lock) {
906
- const savedBytes = existsSync(dbPath) ? statSync(dbPath).size : null;
904
+ const saved = existsSync(dbPath) ? statSync(dbPath) : null;
905
+ const savedBytes = saved?.isFile() ? saved.size : null;
907
906
  if (savedBytes === 0) throw SavedStateDbError.noTables(0);
908
907
  const db = new Database(dbPath);
909
908
  try {
@@ -912,6 +911,7 @@ var StateDB = class _StateDB {
912
911
  }
913
912
  const stateDb = new _StateDB(db, lock);
914
913
  stateDb.migrate();
914
+ if (process.platform === "win32") stateDb.normalizePathSeparators();
915
915
  return stateDb;
916
916
  } catch (error) {
917
917
  db.close();
@@ -925,6 +925,57 @@ var StateDB = class _StateDB {
925
925
  if (version < migration.version) this.db.exec(migration.sql);
926
926
  }
927
927
  }
928
+ /**
929
+ * `\` 로 이은 경로를 `/` 로 고쳐 적는다 — Windows 에서만 부른다(이슈 #8). 0.9.1 까지의 CLI 는 Windows 에서
930
+ * 볼트 경로를 `relative()` 그대로(`00 Me\a.md`) 적었다. 이제 목록은 `/` 로 이으므로 그대로 두면 그
931
+ * 노트들이 「볼트에서 지움」 과 「새 노트」 로 보인다.
932
+ *
933
+ * 같은 노트가 이미 `/` 경로로도 추적되고 있으면(pull 이 `/` 로 적은 노트를 `\` 목록이 새 노트로 보고 또
934
+ * 올렸다) `\` 쪽 기록을 버린다 — 그 기록의 Notion 페이지는 그때 생긴 사본이다. 지우지 않고 추적만 놓는다.
935
+ * Linux · macOS 에서는 `\` 가 파일 이름의 글자일 수 있어 부르지 않는다.
936
+ *
937
+ * @returns 고쳐 적은 경로 수와 버린 사본 기록 수
938
+ */
939
+ normalizePathSeparators() {
940
+ let renamed = 0;
941
+ let dropped = 0;
942
+ const fix = (path) => path.replace(/\\/g, "/");
943
+ this.db.transaction(() => {
944
+ const unique = (table, column, key) => {
945
+ const rows = this.db.prepare(
946
+ `SELECT ${key} AS k, ${column} AS p FROM ${table} WHERE instr(${column}, '\\') > 0`
947
+ ).all();
948
+ for (const row of rows) {
949
+ const taken = this.db.prepare(`SELECT 1 FROM ${table} WHERE ${column} = ?`).get(fix(row.p));
950
+ if (taken) {
951
+ this.db.prepare(`DELETE FROM ${table} WHERE ${key} = ?`).run(row.k);
952
+ if (table === "sync_state") dropped++;
953
+ } else {
954
+ this.db.prepare(`UPDATE ${table} SET ${column} = ? WHERE ${key} = ?`).run(fix(row.p), row.k);
955
+ if (table === "sync_state") renamed++;
956
+ }
957
+ }
958
+ };
959
+ unique("sync_state", "obsidian_path", "id");
960
+ unique("wikilink_map", "obsidian_path", "obsidian_path");
961
+ unique("file_registry", "local_path", "id");
962
+ this.db.prepare("UPDATE image_registry SET local_path = replace(local_path, '\\', '/')").run();
963
+ const markers = this.db.prepare(
964
+ "SELECT key FROM sync_metadata WHERE key LIKE 'preserve_markers:%' AND instr(key, '\\') > 0"
965
+ ).all();
966
+ for (const { key } of markers) {
967
+ const taken = this.db.prepare("SELECT 1 FROM sync_metadata WHERE key = ?").get(fix(key));
968
+ if (taken) this.db.prepare("DELETE FROM sync_metadata WHERE key = ?").run(key);
969
+ else this.db.prepare("UPDATE sync_metadata SET key = ? WHERE key = ?").run(fix(key), key);
970
+ }
971
+ })();
972
+ if (renamed + dropped > 0) {
973
+ getLogger().warn(
974
+ `[Im-Nobsidian] Windows \uACBD\uB85C ${renamed}\uAC1C\uB97C / \uB85C \uACE0\uCCD0 \uC801\uC74C` + (dropped > 0 ? ` \xB7 \uAC19\uC740 \uB178\uD2B8\uB97C \uB610 \uC62C\uB9B0 \uC0AC\uBCF8 \uAE30\uB85D ${dropped}\uAC1C\uB294 \uCD94\uC801\uC744 \uB193\uC74C(Notion \uD398\uC774\uC9C0\uB294 \uB0A8\uC74C)` : "")
975
+ );
976
+ }
977
+ return { renamed, dropped };
978
+ }
928
979
  getSchemaVersion() {
929
980
  try {
930
981
  const row = this.db.prepare("SELECT value FROM sync_metadata WHERE key = 'schema_version'").get();
@@ -4605,6 +4656,15 @@ function folderNoteOf(folder) {
4605
4656
  function isFolderRecord(record) {
4606
4657
  return (record.fileType === "folder-note" || record.fileType === "folder-only") && !isMarkdownPath(record.obsidianPath);
4607
4658
  }
4659
+ function childFolderOf(record, lookup) {
4660
+ if (isFolderRecord(record) && !lookup.databaseAt(record.obsidianPath)) {
4661
+ return record.obsidianPath;
4662
+ }
4663
+ if (record.fileType === "folder-note" || record.fileType === "folder-only") {
4664
+ return parentFolderOf(record.obsidianPath);
4665
+ }
4666
+ return stripMarkdownExtension(record.obsidianPath);
4667
+ }
4608
4668
  function parentFolderOf(path) {
4609
4669
  const slash = path.lastIndexOf("/");
4610
4670
  return slash < 0 ? "" : path.slice(0, slash);
@@ -4835,10 +4895,16 @@ function hasRemotePage(record) {
4835
4895
  return record !== null && Boolean(record.notionPageId);
4836
4896
  }
4837
4897
  var ChangeDetector = class {
4838
- constructor(stateDb) {
4898
+ /**
4899
+ * @param excludes 설정이 동기화에서 뺀 경로(이슈 #8) — 목록에 없어도 지운 것이 아니다. 예전에는 뺀 폴더의
4900
+ * 추적 노트를 「삭제」 로 보고, deleteSync 면 Notion 페이지를 지울 수 있었다.
4901
+ */
4902
+ constructor(stateDb, excludes = () => false) {
4839
4903
  this.stateDb = stateDb;
4904
+ this.excludes = excludes;
4840
4905
  }
4841
4906
  stateDb;
4907
+ excludes;
4842
4908
  async detectLocalChangesFast(stats, readFile3, options) {
4843
4909
  return (await this.scanLocalChangesFast(stats, readFile3, options)).changes;
4844
4910
  }
@@ -4914,7 +4980,7 @@ var ChangeDetector = class {
4914
4980
  }
4915
4981
  const missing = this.stateDb.getByStatus("synced").filter(
4916
4982
  (record) => hasRemotePage(record) && !existing.has(record.obsidianPath) && // 폴더 페이지는 파일이 아니다 — 폴더가 사라진 것은 파일 목록으로 알 수 없다.
4917
- !isFolderRecord(record)
4983
+ !isFolderRecord(record) && !this.excludes(record.obsidianPath)
4918
4984
  );
4919
4985
  const pairs = pairLocalMoves(
4920
4986
  missing.map((record) => ({
@@ -5160,6 +5226,7 @@ var FrontmatterExtractor = class {
5160
5226
  };
5161
5227
 
5162
5228
  // src/utils/md-regions.ts
5229
+ var LIST_MARKER_SOURCE = String.raw`(?:[-*+]|\d{1,9}[.)])`;
5163
5230
  var BLANK_LINE_RE = /\n[ \t]*\n/;
5164
5231
  function computeAnchor(content, idx) {
5165
5232
  const before = content.slice(Math.max(0, idx - 32), idx);
@@ -5198,7 +5265,9 @@ function closingFence(line, open) {
5198
5265
  }
5199
5266
  return { lead: m[1], bar: m[2] };
5200
5267
  }
5201
- var QUOTED_FENCE_OPEN_RE = /^((?:[\t ]*>)*)([\t ]*)((?:[-*+]|\d{1,9}[.)])[\t ]+)?(`{3,}|~{3,})(.*)\r?$/;
5268
+ var QUOTED_FENCE_OPEN_RE = new RegExp(
5269
+ String.raw`^((?:[\t ]*>)*)([\t ]*)(${LIST_MARKER_SOURCE}[\t ]+)?(\`{3,}|~{3,})(.*)\r?$`
5270
+ );
5202
5271
  var QUOTE_MARK_RE = /^[\t ]*>/;
5203
5272
  var LEADING_SPACE_RE = /^[\t ]*/;
5204
5273
  function stripQuoteMarks(line, depth2) {
@@ -5295,7 +5364,7 @@ function codeLineMask(content) {
5295
5364
  }
5296
5365
  return inCode;
5297
5366
  }
5298
- var TOP_LIST_ITEM_RE = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:[\t ]|$)/;
5367
+ var TOP_LIST_ITEM_RE = new RegExp(String.raw`^ {0,3}${LIST_MARKER_SOURCE}(?:[\t ]|$)`);
5299
5368
  var INDENTED_CODE_WIDTH = 4;
5300
5369
  function indentedCodeLineMask(content) {
5301
5370
  const lines = content.split("\n");
@@ -5626,7 +5695,9 @@ function withoutPrefix(line, prefix) {
5626
5695
  return null;
5627
5696
  }
5628
5697
  var DOUBLE_DOLLAR_RE = /(?<![\\$])\$\$(?!\$)/g;
5629
- var OWN_LINE_DOLLARS_RE = /^(?:[ \t]*>)*[ \t]*(?:\[![^\]]*\][+-]?[ \t]*|(?:[-*+]|\d+[.)])[ \t]+)?\$\$[ \t]*$/;
5698
+ var OWN_LINE_DOLLARS_RE = new RegExp(
5699
+ String.raw`^(?:[ \t]*>)*[ \t]*(?:\[![^\]]*\][+-]?[ \t]*|${LIST_MARKER_SOURCE}[ \t]+)?\$\$[ \t]*$`
5700
+ );
5630
5701
  function settleDollars(content, final) {
5631
5702
  return mapOutsideCode(
5632
5703
  content,
@@ -6151,7 +6222,7 @@ function widenAmbiguousFences(lines, blocks) {
6151
6222
  }
6152
6223
  return widened;
6153
6224
  }
6154
- var LIST_ITEM_LINE_RE = /^[\t ]*(?:[-*+]|\d{1,9}[.)])(?:[\t ]|$)/;
6225
+ var LIST_ITEM_LINE_RE = new RegExp(String.raw`^[\t ]*${LIST_MARKER_SOURCE}(?:[\t ]|$)`);
6155
6226
  function alignNestedCodeBodies(text) {
6156
6227
  const lines = text.split("\n");
6157
6228
  const blocks = scanContainerBlocks(lines);
@@ -7995,7 +8066,7 @@ function clampCalloutIndent(content) {
7995
8066
  }
7996
8067
  return out.join("\n");
7997
8068
  }
7998
- var BODY_INDENT_RE = /^\t+(?![ \t]*(?:[-*+]|\d+[.)])(?:\s|$))/;
8069
+ var BODY_INDENT_RE = new RegExp(String.raw`^\t+(?![ \t]*${LIST_MARKER_SOURCE}(?:\s|$))`);
7999
8070
  function clampBodyIndent(line) {
8000
8071
  return line.replace(BODY_INDENT_RE, CLAMPED_INDENT);
8001
8072
  }
@@ -8196,10 +8267,10 @@ function stripQuotes(s) {
8196
8267
 
8197
8268
  // src/converter/lazy-continuation.ts
8198
8269
  var QUOTE_PREFIX_RE2 = /^[\t >]*>/;
8199
- var LIST_ITEM_RE = /^(?:[-*+]|\d+[.)])(?:[\t ]|$)/;
8270
+ var LIST_ITEM_RE = new RegExp(String.raw`^${LIST_MARKER_SOURCE}(?:[\t ]|$)`);
8200
8271
  var NOT_LAZY_RE = /^(?:#{1,6}(?:[\t ]|$)|>|\[!|(?:[-*_][\t ]*){3,}$|\||<br\s*\/?>[\t ]*$|%%)/;
8201
8272
  var NOT_PARAGRAPH_RE = /^(?:#{1,6}(?:[\t ]|$)|(?:[-*_][\t ]*){3,}$|\||<br\s*\/?>[\t ]*$|\$\$|%%)/;
8202
- var LIST_PARAGRAPH_RE = /^(?:[-*+]|\d+[.)])[\t ]+\S/;
8273
+ var LIST_PARAGRAPH_RE = new RegExp(String.raw`^${LIST_MARKER_SOURCE}[\t ]+\S`);
8203
8274
  var FENCE_LINE_RE2 = /^(?:`{3,}|~{3,})/;
8204
8275
  function continuesListParagraph(previous, line) {
8205
8276
  const prev = splitQuoted(previous);
@@ -8270,7 +8341,7 @@ var BlockSpacer = class {
8270
8341
  };
8271
8342
  }
8272
8343
  };
8273
- var LIST_ITEM_RE2 = /^[ \t]*(?:[-*+]|\d+[.)])(?:\s|$)/;
8344
+ var LIST_ITEM_RE2 = new RegExp(String.raw`^[ \t]*${LIST_MARKER_SOURCE}(?:\s|$)`);
8274
8345
  var QUOTE_RE = /^[ \t]*>/;
8275
8346
  var CALLOUT_START_RE = /^[ \t]*>\s*\[![^\]]*\]/;
8276
8347
  var TABLE_ROW_RE = /^[ \t]*\|/;
@@ -8645,7 +8716,7 @@ var RENDER_LINE_RULES = [
8645
8716
  var SEPARATOR_BODY = "[ \\t:|-]*-{3,}";
8646
8717
  var SEPARATOR_RE = new RegExp(`^[\\t ]*>?[\\t ]*\\|${SEPARATOR_BODY}[ \\t:|-]*\\|`);
8647
8718
  var DEEP_FENCE_RE = /^ {4,}```/;
8648
- var LIST_ITEM_RE3 = /^([\t ]*)([-*+]|\d{1,9}[.)])(?:[\t ]|$)/;
8719
+ var LIST_ITEM_RE3 = new RegExp(String.raw`^([\t ]*)(${LIST_MARKER_SOURCE})(?:[\t ]|$)`);
8649
8720
  var CODE_INDENT = 4;
8650
8721
  var FOOTNOTE_CONTENT_WIDTH = 4;
8651
8722
  var ATX_HEADING_RE = /^ {0,3}#{1,6}(?:[\t ]|$)/;
@@ -10152,7 +10223,7 @@ function pipeTablesToNfmTables(content) {
10152
10223
  // src/converter/empty-block.ts
10153
10224
  var EMPTY_BLOCK_LINE_RE = new RegExp(`^(${CONTAINER_PREFIX_SOURCE})<empty-block\\/>[\\t ]*$`);
10154
10225
  var BREAK_LINE_RE = new RegExp(`^(${CONTAINER_PREFIX_SOURCE})<br\\s*\\/?>[\\t ]*$`);
10155
- var LIST_ITEM_RE4 = /^(?:[-*+]|\d+[.)])(?:\s|$)/;
10226
+ var LIST_ITEM_RE4 = new RegExp(String.raw`^${LIST_MARKER_SOURCE}(?:\s|$)`);
10156
10227
  function mapProseLines(content, map) {
10157
10228
  const lines = content.split("\n");
10158
10229
  const kinds = classifyContainerLines(lines.map((line) => splitContainerPrefix(line).body));
@@ -11131,6 +11202,32 @@ function unquoteCode(lines, bodies) {
11131
11202
  return out;
11132
11203
  }
11133
11204
 
11205
+ // src/converter/literal-angles.ts
11206
+ function unescapeAngleBrackets(content) {
11207
+ if (!content.includes("\\<") && !content.includes("\\>")) return content;
11208
+ return mapOutsideCodeFences(
11209
+ content,
11210
+ (segment) => segment.split("\n").map(unescapeLine).join("\n")
11211
+ );
11212
+ }
11213
+ var BLOCK_PREFIX_RE = new RegExp(String.raw`^[ \t]*(?:(?:>|${LIST_MARKER_SOURCE})[ \t]*)*$`);
11214
+ var TAG_START_RE = /[A-Za-z/!?]/;
11215
+ function unescapeLine(line) {
11216
+ if (!line.includes("\\<") && !line.includes("\\>")) return line;
11217
+ const code = inlineCodeRanges(line);
11218
+ return line.replace(
11219
+ /(\\+)([<>])/g,
11220
+ (match, slashes, char, at) => {
11221
+ if (slashes.length % 2 === 0) return match;
11222
+ if (code.some(([start, end]) => at >= start && at < end)) return match;
11223
+ const escape = at + slashes.length - 1;
11224
+ if (char === ">" && BLOCK_PREFIX_RE.test(line.slice(0, escape))) return match;
11225
+ if (char === "<" && TAG_START_RE.test(line[at + match.length] ?? "")) return match;
11226
+ return `${slashes.slice(1)}${char}`;
11227
+ }
11228
+ );
11229
+ }
11230
+
11134
11231
  // src/converter/enhanced-md-converter.ts
11135
11232
  var NOTION_CALLOUT_RE = /^::: callout\n([\s\S]*?)\n:::/gm;
11136
11233
  var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
@@ -11167,6 +11264,7 @@ function notionEnhancedToObsidian(enhanced, options) {
11167
11264
  result = emptyBlocksToBreaks(result);
11168
11265
  result = unescapePipes(result);
11169
11266
  result = unescapeNotionChars(result);
11267
+ result = unescapeAngleBrackets(result);
11170
11268
  result = unescapeBrackets(result);
11171
11269
  result = receiveWikilinkText(result);
11172
11270
  result = receiveLiteralBackslashes(result);
@@ -15125,16 +15223,16 @@ var PageDecorations = class {
15125
15223
  *
15126
15224
  * @param baseName 내려받은 파일 이름의 앞부분 — 보통 노트 이름
15127
15225
  */
15128
- async pulled(page, baseName2, download) {
15226
+ async pulled(page, baseName3, download) {
15129
15227
  const out = {};
15130
15228
  const cover = this.notionClient.extractCover(page);
15131
15229
  if (cover) {
15132
- const local = download ? await this.imageHandler.localizeNotionFileUrl(cover.url, `${baseName2}-cover`) : null;
15230
+ const local = download ? await this.imageHandler.localizeNotionFileUrl(cover.url, `${baseName3}-cover`) : null;
15133
15231
  out.cover = local ? `[[${local}]]` : cover.url;
15134
15232
  }
15135
15233
  const icon = this.notionClient.extractIcon(page);
15136
15234
  if (icon) {
15137
- const local = download && icon.type === "file" ? await this.imageHandler.localizeNotionFileUrl(icon.value, `${baseName2}-icon`) : null;
15235
+ const local = download && icon.type === "file" ? await this.imageHandler.localizeNotionFileUrl(icon.value, `${baseName3}-icon`) : null;
15138
15236
  out.icon = local ? `[[${local}]]` : icon.value;
15139
15237
  }
15140
15238
  return out;
@@ -15579,6 +15677,8 @@ var DatabaseSyncer = class {
15579
15677
  if (!wholeDbInScope && !scope.some((path) => matchesPathScope(path, dbConfig.localFolder))) {
15580
15678
  return { kind: "out-of-scope" };
15581
15679
  }
15680
+ if (this.vaultFs.excludesPath?.(dbConfig.localFolder, "folder"))
15681
+ return { kind: "out-of-scope" };
15582
15682
  const meta = await this.notionClient.getDatabaseMeta(dbConfig.databaseId);
15583
15683
  const pages = await this.notionClient.queryAllDatabasePages(
15584
15684
  dbConfig.databaseId,
@@ -15615,6 +15715,7 @@ var DatabaseSyncer = class {
15615
15715
  */
15616
15716
  async rowAction(dbConfig, page, record, wholeDbInScope, scope) {
15617
15717
  if (!wholeDbInScope && !(record && inAnyPathScope(record.obsidianPath, scope))) return "skip";
15718
+ if (record && this.vaultFs.excludesPath?.(record.obsidianPath)) return "skip";
15618
15719
  if (!record) return "pull";
15619
15720
  const misplaced = !isDirectDbRowPath(dbConfig.localFolder, record.obsidianPath);
15620
15721
  const changed = misplaced || this.remoteVerdict(record, page) !== "unchanged";
@@ -15678,7 +15779,7 @@ var DatabaseSyncer = class {
15678
15779
  unlistedRows(dbConfig, pages, paths) {
15679
15780
  const listed = new Set(pages.map((page) => normalizeNotionId(page.id)));
15680
15781
  return this.stateDb.getAll().filter(
15681
- (record) => record.fileType === "db-row" && record.notionPageId !== null && record.notionParentId !== null && notionIdsEqual(record.notionParentId, dbConfig.databaseId) && !listed.has(normalizeNotionId(record.notionPageId)) && inAnyPathScope(record.obsidianPath, paths)
15782
+ (record) => record.fileType === "db-row" && record.notionPageId !== null && record.notionParentId !== null && notionIdsEqual(record.notionParentId, dbConfig.databaseId) && !listed.has(normalizeNotionId(record.notionPageId)) && inAnyPathScope(record.obsidianPath, paths) && !(this.vaultFs.excludesPath?.(record.obsidianPath) ?? false)
15682
15783
  );
15683
15784
  }
15684
15785
  /**
@@ -16631,6 +16732,14 @@ var ScopedVaultFS = class {
16631
16732
  const joined = normalizeVaultPath(this.folder === "" ? path : `${this.folder}/${path}`);
16632
16733
  return joined !== null && matchesPathScope(joined, this.folder) && connectionFolderOf(joined, this.nested) === null;
16633
16734
  }
16735
+ holdsOtherConnection(folder) {
16736
+ const joined = normalizeVaultPath(this.folder === "" ? folder : `${this.folder}/${folder}`);
16737
+ return joined !== null && this.nested.some((c) => matchesPathScope(c, joined));
16738
+ }
16739
+ excludesPath(path, kind) {
16740
+ const joined = normalizeVaultPath(this.folder === "" ? path : `${this.folder}/${path}`);
16741
+ return joined !== null && (this.base.excludesPath?.(joined, kind) ?? false);
16742
+ }
16634
16743
  /** 이 연결의 경로 → 볼트 경로. */
16635
16744
  toBase(path, write) {
16636
16745
  const joined = normalizeVaultPath(this.folder === "" ? path : `${this.folder}/${path}`);
@@ -18496,6 +18605,199 @@ var LocalPlanner = class {
18496
18605
  }
18497
18606
  };
18498
18607
 
18608
+ // src/sync/remote-moves.ts
18609
+ var MOVABLE_TYPES = /* @__PURE__ */ new Set([
18610
+ "file",
18611
+ "folder-note",
18612
+ "folder-only"
18613
+ ]);
18614
+ function movedInNotion(record, parentId) {
18615
+ return MOVABLE_TYPES.has(record.fileType) && record.notionParentId !== null && !notionIdsEqual(record.notionParentId, parentId);
18616
+ }
18617
+ var RemoteMoveFollower = class {
18618
+ constructor(config, stateDb, vaultFs, folderLookup) {
18619
+ this.config = config;
18620
+ this.stateDb = stateDb;
18621
+ this.vaultFs = vaultFs;
18622
+ this.folderLookup = folderLookup;
18623
+ }
18624
+ config;
18625
+ stateDb;
18626
+ vaultFs;
18627
+ folderLookup;
18628
+ /**
18629
+ * @param moved 페이지 id → Notion 의 새 부모 페이지 id.
18630
+ * @param options.dryRun 옮길 자리만 정하고 쓰지 않는다.
18631
+ */
18632
+ async follow(moved, options = {}) {
18633
+ const result = { moved: [], skipped: [] };
18634
+ for (const [pageId, parentId] of moved) {
18635
+ const record = this.stateDb.getByNotionId(pageId);
18636
+ if (!record || !movedInNotion(record, parentId)) continue;
18637
+ if (this.vaultFs.excludesPath?.(record.obsidianPath)) continue;
18638
+ let outcome;
18639
+ try {
18640
+ outcome = await this.followOne(record, parentId, options.dryRun === true);
18641
+ } catch (error) {
18642
+ outcome = {
18643
+ path: record.obsidianPath,
18644
+ reason: `\uC62E\uAE30\uC9C0 \uBABB\uD568: ${error instanceof Error ? error.message : String(error)}`
18645
+ };
18646
+ }
18647
+ if ("reason" in outcome) {
18648
+ result.skipped.push(outcome);
18649
+ getLogger().warn(
18650
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uC62E\uAE34 \uD398\uC774\uC9C0\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE30\uC9C0 \uC54A\uC74C (${outcome.path}): ${outcome.reason}`
18651
+ );
18652
+ } else if (outcome.moves.length > 0) {
18653
+ result.moved.push(...outcome.moves);
18654
+ }
18655
+ }
18656
+ return result;
18657
+ }
18658
+ async followOne(record, parentId, dryRun) {
18659
+ const skip = (reason) => ({ path: record.obsidianPath, reason });
18660
+ if (this.stateDb.getIncompleteOpByState(record.id, "move")) {
18661
+ return skip(
18662
+ "\uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE34 \uAC83\uC744 \uC544\uC9C1 \uC62C\uB9AC\uC9C0 \uC54A\uC558\uB2E4 \u2014 \uB2E4\uC74C push \uAC00 Notion \uC744 \uBCFC\uD2B8\uC758 \uC790\uB9AC\uB85C \uC62E\uAE34\uB2E4"
18663
+ );
18664
+ }
18665
+ const folder = this.parentFolder(parentId);
18666
+ if (folder === null) return skip("Notion \uC758 \uC0C8 \uBD80\uBAA8\uAC00 \uBCFC\uD2B8\uC5D0 \uC5C6\uB2E4");
18667
+ const units = this.unitsOf(record);
18668
+ const moves = units.map((from) => ({ from, to: joinPath(folder, baseName2(from)) }));
18669
+ if (moves.every((move) => move.from === move.to)) {
18670
+ if (!dryRun) this.stateDb.setNotionParentId(record.id, parentId);
18671
+ return { moves: [] };
18672
+ }
18673
+ for (const move of moves) {
18674
+ const blocked = await this.blocked(move);
18675
+ if (blocked) return skip(blocked);
18676
+ }
18677
+ if (dryRun) return { moves };
18678
+ const done = [];
18679
+ try {
18680
+ for (const move of moves) {
18681
+ await this.vaultFs.moveFile(move.from, move.to);
18682
+ done.push(move);
18683
+ }
18684
+ } catch (error) {
18685
+ for (const move of done.reverse())
18686
+ await this.vaultFs.moveFile(move.to, move.from).catch(() => {
18687
+ });
18688
+ throw error;
18689
+ }
18690
+ this.stateDb.transaction(() => {
18691
+ for (const move of moves) this.rewritePaths(move);
18692
+ this.stateDb.setNotionParentId(record.id, parentId);
18693
+ });
18694
+ for (const move of moves) {
18695
+ getLogger().info(
18696
+ `[Im-Nobsidian] Notion \uC5D0\uC11C \uC62E\uAE34 \uD398\uC774\uC9C0\uB97C \uBCFC\uD2B8\uC5D0\uC11C \uC62E\uAE40: ${move.from} \u2192 ${move.to}`
18697
+ );
18698
+ }
18699
+ return { moves };
18700
+ }
18701
+ /** 새 부모 아래의 자식이 놓일 폴더 — 새 페이지를 받을 때와 같은 규칙. 루트면 `""`, 볼트에 없는 부모면 null. */
18702
+ parentFolder(parentId) {
18703
+ if (notionIdsEqual(parentId, this.config.notion.rootPageId)) return "";
18704
+ const parent = this.stateDb.getByNotionId(parentId);
18705
+ return parent ? childFolderOf(parent, this.folderLookup()) : null;
18706
+ }
18707
+ /**
18708
+ * 함께 옮길 것 — 폴더로 추적하는 페이지는 그 폴더, 폴더 노트(`X/X.md`)는 든 폴더, 노트는 그 파일과 옆의 자식
18709
+ * 폴더(`X/`, 있으면).
18710
+ */
18711
+ unitsOf(record) {
18712
+ if (isFolderRecord(record)) return [record.obsidianPath];
18713
+ if (record.fileType !== "file") return [parentFolderOf(record.obsidianPath)];
18714
+ const children = stripMarkdownExtension(record.obsidianPath);
18715
+ const hasChildren = this.stateDb.getAll().some((r) => within(r.obsidianPath, children));
18716
+ return hasChildren ? [record.obsidianPath, children] : [record.obsidianPath];
18717
+ }
18718
+ /** 옮기지 못할 까닭 — 없으면 null. */
18719
+ async blocked(move) {
18720
+ if (within(move.to, move.from)) return "\uC81C \uC548\uC73C\uB85C \uC62E\uAE38 \uC218 \uC5C6\uB2E4";
18721
+ if (!await this.vaultFs.exists(move.from)) return "\uBCFC\uD2B8\uC5D0 \uB178\uD2B8\uAC00 \uC5C6\uB2E4";
18722
+ if (await this.vaultFs.exists(move.to) || this.stateDb.getAll().some((r) => within(r.obsidianPath, move.to))) {
18723
+ return `\uC62E\uAE38 \uC790\uB9AC(${move.to})\uC5D0 \uAC19\uC740 \uC774\uB984\uC774 \uC788\uB2E4`;
18724
+ }
18725
+ const isFolder = !isMarkdownPath(move.from);
18726
+ if (this.vaultFs.excludesPath?.(move.to, isFolder ? "folder" : "file")) {
18727
+ return `\uC62E\uAE38 \uC790\uB9AC(${move.to})\uAC00 \uB3D9\uAE30\uD654\uC5D0\uC11C \uBE60\uC9C4\uB2E4(paths \xB7 .im-nobsidian-ignore)`;
18728
+ }
18729
+ if (isFolder && this.holdsDatabaseOrConnection(move.from)) {
18730
+ return "\uD3F4\uB354 \uC548\uC5D0 DB \uB098 \uB2E4\uB978 \uC5F0\uACB0\uC774 \uC788\uB2E4 \u2014 DB \uBCF4\uAE30(.base)\uAC00 \uD3F4\uB354 \uACBD\uB85C\uB97C \uD488\uB294\uB2E4. Obsidian \uC5D0\uC11C \uC62E\uAE30\uBA74 \uB2E4\uC74C push \uAC00 Notion \uC744 \uB9DE\uCD98\uB2E4";
18731
+ }
18732
+ return null;
18733
+ }
18734
+ holdsDatabaseOrConnection(folder) {
18735
+ const databases = parseDiscoveredDbs(this.stateDb.getMeta(DISCOVERED_DBS_META_KEY));
18736
+ if (databases.some((db) => within(db.localFolder.replace(/\/+$/, ""), folder))) return true;
18737
+ const baseFiles = this.stateDb.getMeta(DB_BASE_FILES_META_KEY);
18738
+ if (baseFiles) {
18739
+ try {
18740
+ const entries = Object.values(
18741
+ JSON.parse(baseFiles)
18742
+ );
18743
+ if (entries.some((entry) => entry.basePath && within(entry.basePath, folder))) return true;
18744
+ } catch {
18745
+ }
18746
+ }
18747
+ if (this.stateDb.getAll().some((r) => r.fileType === "db-row" && within(r.obsidianPath, folder))) {
18748
+ return true;
18749
+ }
18750
+ return this.vaultFs.holdsOtherConnection?.(folder) ?? false;
18751
+ }
18752
+ /** 옮긴 것 아래의 레코드 · 위키링크 · 보존 마커 · 첨부 기록을 새 경로로 옮겨 적는다. */
18753
+ rewritePaths(move) {
18754
+ for (const record of this.stateDb.getAll()) {
18755
+ if (!within(record.obsidianPath, move.from)) continue;
18756
+ const from = record.obsidianPath;
18757
+ const to = move.to + from.slice(move.from.length);
18758
+ this.stateDb.updatePath(record.id, to);
18759
+ const entry = record.notionPageId ? this.stateDb.resolvePageId(record.notionPageId) : null;
18760
+ if (entry && entry.obsidianPath === from) {
18761
+ this.stateDb.deleteWikilink(from);
18762
+ this.stateDb.upsertWikilink({
18763
+ obsidianPath: to,
18764
+ notionPageId: entry.notionPageId,
18765
+ title: wikilinkTitleFromPath(to),
18766
+ aliases: entry.aliases
18767
+ });
18768
+ }
18769
+ const markers = this.stateDb.getPreserveMarkers(from);
18770
+ if (markers.length > 0) {
18771
+ this.stateDb.storePreserveMarkers(to, markers);
18772
+ this.stateDb.storePreserveMarkers(from, []);
18773
+ }
18774
+ if (record.notionPageId) {
18775
+ for (const file of this.stateDb.getFilesByPageId(record.notionPageId)) {
18776
+ if (!within(file.localPath, move.from)) continue;
18777
+ this.stateDb.registerFile({
18778
+ localPath: move.to + file.localPath.slice(move.from.length),
18779
+ notionPageId: file.notionPageId,
18780
+ fileUploadId: file.fileUploadId,
18781
+ fileType: file.fileType,
18782
+ fileHash: file.fileHash,
18783
+ fileSize: file.fileSize
18784
+ });
18785
+ this.stateDb.deleteFileRegistry(file.localPath);
18786
+ }
18787
+ }
18788
+ }
18789
+ }
18790
+ };
18791
+ function within(path, unit) {
18792
+ return path === unit || path.startsWith(`${unit}/`);
18793
+ }
18794
+ function baseName2(path) {
18795
+ return path.slice(path.lastIndexOf("/") + 1);
18796
+ }
18797
+ function joinPath(folder, name) {
18798
+ return folder === "" ? name : `${folder}/${name}`;
18799
+ }
18800
+
18499
18801
  // src/sync/change-inspector.ts
18500
18802
  var ChangeInspector = class {
18501
18803
  constructor(stateDb, vaultFs, notionClient, planner, puller) {
@@ -21569,17 +21871,7 @@ var PagePuller = class {
21569
21871
  const parentId = await extractParentId(this.notionClient, page);
21570
21872
  if (!parentId || notionIdsEqual(parentId, this.config.notion.rootPageId)) return "";
21571
21873
  const parentRecord = this.stateDb.getByNotionId(parentId);
21572
- if (parentRecord) {
21573
- if (isFolderRecord(parentRecord) && !this.placement.folderLookup().databaseAt(parentRecord.obsidianPath)) {
21574
- return parentRecord.obsidianPath;
21575
- }
21576
- if (parentRecord.fileType === "folder-note" || parentRecord.fileType === "folder-only") {
21577
- const pathParts = parentRecord.obsidianPath.split("/");
21578
- pathParts.pop();
21579
- return pathParts.join("/");
21580
- }
21581
- return stripMarkdownExtension(parentRecord.obsidianPath);
21582
- }
21874
+ if (parentRecord) return childFolderOf(parentRecord, this.placement.folderLookup());
21583
21875
  try {
21584
21876
  const parentPage = await this.notionClient.getPage(parentId);
21585
21877
  const parentTitle = this.notionClient.extractTitle(parentPage);
@@ -21957,6 +22249,9 @@ var RemoteDetector = class _RemoteDetector {
21957
22249
  // 감지가 빠뜨린 자식을 {@link takeUndiscoveredChildren} 가 둘의 차로 고른다.
21958
22250
  _bodyChildren = /* @__PURE__ */ new Map();
21959
22251
  _queued = /* @__PURE__ */ new Set();
22252
+ // Notion 에서 부모가 바뀐 추적 페이지(정규화 id → 새 부모 페이지 id) — pull 이 볼트에서도 옮긴다(ADR-039).
22253
+ // 옮겨도 수정 시각이 바뀌지 않아(실측) 변경으로는 보이지 않는다. 감지마다 다시 채운다.
22254
+ _moved = /* @__PURE__ */ new Map();
21960
22255
  /** 이번 감지에서 자식 페이지를 가진 것으로 본 페이지인가 — 폴더노트 판정의 1차 근거. */
21961
22256
  hasChildPages(pageId) {
21962
22257
  return this._childParentIds.has(normalizeNotionId(pageId));
@@ -22018,6 +22313,16 @@ var RemoteDetector = class _RemoteDetector {
22018
22313
  }
22019
22314
  return changes;
22020
22315
  }
22316
+ /** 이번 감지가 본, Notion 에서 옮긴 추적 페이지 — 페이지 id → 새 부모 페이지 id. */
22317
+ get movedPages() {
22318
+ return this._moved;
22319
+ }
22320
+ /** 받은 부모가 상태 DB 의 것과 다르면 옮긴 페이지로 적는다. */
22321
+ noteParent(pageId, parentId) {
22322
+ const record = this.stateDb.getByNotionId(pageId);
22323
+ if (record && movedInNotion(record, parentId))
22324
+ this._moved.set(normalizeNotionId(pageId), parentId);
22325
+ }
22021
22326
  /** 새 감지를 시작한다 — 지난 실행의 본문 자식과 받기로 한 페이지를 비우고 이번 변경을 적는다. */
22022
22327
  beginDetection(changes) {
22023
22328
  this._bodyChildren.clear();
@@ -22064,6 +22369,7 @@ var RemoteDetector = class _RemoteDetector {
22064
22369
  const changes = [];
22065
22370
  this._childParentIds.clear();
22066
22371
  this._pages.clear();
22372
+ this._moved.clear();
22067
22373
  const lastPull = this.stateDb.getMeta(LAST_PULL_META_KEY);
22068
22374
  const syncedRecords = this.stateDb.getAll();
22069
22375
  const trackedPageIds = new Set(
@@ -22121,7 +22427,10 @@ var RemoteDetector = class _RemoteDetector {
22121
22427
  for (const page of underRoot) {
22122
22428
  throwIfAborted(signal);
22123
22429
  const parentId = await extractParentId(this.notionClient, page);
22124
- if (parentId) this._childParentIds.add(normalizeNotionId(parentId));
22430
+ if (parentId) {
22431
+ this._childParentIds.add(normalizeNotionId(parentId));
22432
+ this.noteParent(page.id, parentId);
22433
+ }
22125
22434
  }
22126
22435
  for (const page of underRoot) {
22127
22436
  titles.set(normalizeNotionId(page.id), this.notionClient.extractTitle(page));
@@ -22206,6 +22515,7 @@ var RemoteDetector = class _RemoteDetector {
22206
22515
  const databaseIds = /* @__PURE__ */ new Set();
22207
22516
  this._childParentIds.clear();
22208
22517
  this._pages.clear();
22518
+ this._moved.clear();
22209
22519
  const searchSince = incrementalSearchSince(since);
22210
22520
  const recentPages = await this.notionClient.searchRecentPages(searchSince, options.signal);
22211
22521
  const untracked = [];
@@ -22228,6 +22538,7 @@ var RemoteDetector = class _RemoteDetector {
22228
22538
  }
22229
22539
  continue;
22230
22540
  }
22541
+ if (page.parent.type === "page_id") this.noteParent(page.id, page.parent.page_id);
22231
22542
  const modified = this.observation.modification(record, page);
22232
22543
  const rowDatabase = page.parentDatabaseId ?? (record.fileType === "db-row" ? record.notionParentId : null);
22233
22544
  if (modified && rowDatabase) databaseIds.add(compactNotionId(rowDatabase));
@@ -22348,7 +22659,7 @@ var RemoteDetector = class _RemoteDetector {
22348
22659
  // src/sync/rerender-targets.ts
22349
22660
  function rerenderTargets(stateDb, scope) {
22350
22661
  return stateDb.getAll().filter(
22351
- (record) => isRerenderable(record) && record.notionPageId !== null && !scope.queued.has(record.notionPageId) && !scope.adopting.has(record.id) && !(scope.localChangedPaths?.has(record.obsidianPath) ?? false) && inAnyPathScope(record.obsidianPath, scope.paths)
22662
+ (record) => isRerenderable(record) && record.notionPageId !== null && !scope.queued.has(record.notionPageId) && !scope.adopting.has(record.id) && !(scope.localChangedPaths?.has(record.obsidianPath) ?? false) && inAnyPathScope(record.obsidianPath, scope.paths) && !(scope.excludes?.(record.obsidianPath) ?? false)
22352
22663
  ).map((record) => ({
22353
22664
  pageId: record.notionPageId,
22354
22665
  type: "modified",
@@ -22376,7 +22687,8 @@ async function detectMissingLocalFiles(config, stateDb, vaultFs, paths) {
22376
22687
  // 존재 확인을 넣어 이 제외가 비로소 근거를 갖는다(tests/sync/db-row-restore-deleted).
22377
22688
  // push 가 만든 폴더 페이지(폴더 레코드)도 실체가 폴더다 — 안의 노트를 되살리면 폴더도
22378
22689
  // 생긴다. 예전에는 폴더 경로에 확장자 없는 파일을 썼다(S-17).
22379
- (r.fileType === "file" || r.fileType === "folder-note") && !isFolderRecord(r) && r.notionPageId !== null && inAnyPathScope(r.obsidianPath, paths)
22690
+ (r.fileType === "file" || r.fileType === "folder-note") && !isFolderRecord(r) && r.notionPageId !== null && inAnyPathScope(r.obsidianPath, paths) && // 설정이 뺀 노트는 목록에 없을 뿐 지운 것이 아니다 — 되살리지 않는다(이슈 #8).
22691
+ !(vaultFs.excludesPath?.(r.obsidianPath) ?? false)
22380
22692
  )
22381
22693
  );
22382
22694
  if (scoped.length === 0) return [];
@@ -22705,7 +23017,10 @@ var SyncOrchestrator = class {
22705
23017
  this.observation = new RunObservation(stateDb, notionClient);
22706
23018
  this.recovery = new InterruptedSyncRecovery(stateDb, notionClient);
22707
23019
  this.detector = new RemoteDetector(config, stateDb, notionClient, this.observation);
22708
- const changeDetector = new ChangeDetector(stateDb);
23020
+ const changeDetector = new ChangeDetector(
23021
+ stateDb,
23022
+ (path) => vaultFs.excludesPath?.(path) ?? false
23023
+ );
22709
23024
  const pipeline = createDefaultPipeline({
22710
23025
  wikilinkResolver: (text) => stateDb.resolveWikilink(text)
22711
23026
  });
@@ -22793,6 +23108,12 @@ var SyncOrchestrator = class {
22793
23108
  );
22794
23109
  this.placement.useDatabaseCreator(this.databaseCreator);
22795
23110
  this.planner = new LocalPlanner(config, stateDb, vaultFs, changeDetector, this.placement);
23111
+ this.remoteMoves = new RemoteMoveFollower(
23112
+ config,
23113
+ stateDb,
23114
+ vaultFs,
23115
+ () => this.placement.folderLookup()
23116
+ );
22796
23117
  this.puller = new PagePuller(
22797
23118
  config,
22798
23119
  stateDb,
@@ -22860,6 +23181,7 @@ var SyncOrchestrator = class {
22860
23181
  databaseCreator;
22861
23182
  // 이번 실행의 로컬 변경 — 옮긴 노트 · 폴더를 짝짓고 상태 DB 에 옮겨 적는다(S-11).
22862
23183
  planner;
23184
+ remoteMoves;
22863
23185
  // 원격에서 바뀐 것 — 전체 대조 · 증분 감지(ADR-027).
22864
23186
  detector;
22865
23187
  // 지난번에 본 뒤로 원격이 바뀌었나(N-05) — 덮어쓰기 · 지우기 전에, 변경을 보이기 전에 본다.
@@ -23204,6 +23526,17 @@ var SyncOrchestrator = class {
23204
23526
  async pull(options) {
23205
23527
  return this.gate.run("pull", () => this.executePull(options));
23206
23528
  }
23529
+ /** 감지가 본, Notion 에서 옮긴 페이지를 볼트에서 옮긴다 — 경로를 좁힌 pull 은 그 범위의 것만. */
23530
+ async followRemoteMoves(options) {
23531
+ const moved = new Map(
23532
+ [...this.detector.movedPages].filter(([pageId]) => {
23533
+ const record = this.stateDb.getByNotionId(pageId);
23534
+ return !!record && (!options?.paths || inAnyPathScope(record.obsidianPath, options.paths));
23535
+ })
23536
+ );
23537
+ if (moved.size === 0) return { moved: [], skipped: [] };
23538
+ return await this.remoteMoves.follow(moved, { dryRun: options?.dryRun });
23539
+ }
23207
23540
  async executePull(options) {
23208
23541
  const startTime = Date.now();
23209
23542
  this.assertConnectionScope();
@@ -23263,6 +23596,7 @@ var SyncOrchestrator = class {
23263
23596
  }
23264
23597
  const remoteChanges = detection.changes;
23265
23598
  detected = remoteChanges;
23599
+ const remoteMoves = await this.followRemoteMoves(options);
23266
23600
  const pendingDatabases = parsePendingDatabases(
23267
23601
  this.stateDb.getMeta(PENDING_DATABASES_META_KEY)
23268
23602
  );
@@ -23312,13 +23646,17 @@ var SyncOrchestrator = class {
23312
23646
  this.config.sync.deleteSync
23313
23647
  ),
23314
23648
  skippedDatabases: ledger.skipped
23315
- }
23649
+ },
23650
+ moved: remoteMoves.moved,
23651
+ moveSkipped: remoteMoves.skipped
23316
23652
  };
23317
23653
  };
23318
- const filtered = options?.paths ? remoteChanges.filter((c) => {
23654
+ const excludes = (path) => this.vaultFs.excludesPath?.(path) ?? false;
23655
+ const filtered = remoteChanges.filter((c) => {
23319
23656
  const record = this.stateDb.getByNotionId(c.pageId);
23320
- return !!record && inAnyPathScope(record.obsidianPath, options.paths);
23321
- }) : remoteChanges;
23657
+ if (record && excludes(record.obsidianPath)) return false;
23658
+ return !options?.paths || !!record && inAnyPathScope(record.obsidianPath, options.paths);
23659
+ });
23322
23660
  const queuedIds = new Set(filtered.map((c) => c.pageId));
23323
23661
  const missingLocal = localPlan ? await detectMissingLocalFiles(this.config, this.stateDb, this.vaultFs, options?.paths) : [];
23324
23662
  const restoreChanges = missingLocal.filter(
@@ -23336,11 +23674,12 @@ var SyncOrchestrator = class {
23336
23674
  localChangedPaths: localPlan ? new Set(localPlan.scan.changes.map((change) => change.path)) : null,
23337
23675
  queued: /* @__PURE__ */ new Set([...queuedIds, ...restoreIds]),
23338
23676
  adopting,
23339
- paths: options.paths
23677
+ paths: options.paths,
23678
+ excludes
23340
23679
  }) : [];
23341
23680
  const workItems = [...filtered, ...restoreChanges, ...rerenderChanges];
23342
23681
  if (options?.dryRun) {
23343
- return this.pullPlanner.planPull(
23682
+ const plan = await this.pullPlanner.planPull(
23344
23683
  filtered,
23345
23684
  restoreChanges,
23346
23685
  restoreIds,
@@ -23350,6 +23689,7 @@ var SyncOrchestrator = class {
23350
23689
  scan,
23351
23690
  ledger
23352
23691
  );
23692
+ return { ...plan, moved: remoteMoves.moved, moveSkipped: remoteMoves.skipped };
23353
23693
  }
23354
23694
  let pullTotal = workItems.length;
23355
23695
  let pullCompleted = 0;
@@ -23766,17 +24106,104 @@ function pushOperationOf(change) {
23766
24106
  return "update";
23767
24107
  }
23768
24108
  }
24109
+
24110
+ // src/utils/path-filter.ts
24111
+ var PathFilter = class {
24112
+ include;
24113
+ exclude;
24114
+ constructor(include, exclude) {
24115
+ this.include = compileAll(include);
24116
+ this.exclude = compileAll(exclude);
24117
+ }
24118
+ /** 동기화하지 않는 노트 · 첨부 — 포함 패턴 어느 것에도 맞지 않거나 제외 패턴에 맞는다. */
24119
+ excludesFile(path) {
24120
+ if (this.include.length > 0 && !this.include.some((p) => matchesWithin(path, p))) return true;
24121
+ return this.excludesFolder(path);
24122
+ }
24123
+ /**
24124
+ * 걷지 않을 폴더 — 제외 패턴만 본다. 포함 패턴은 안의 파일이 정한다: `00 Me/**\/*.md` 는 폴더
24125
+ * `00 Me` 에 맞지 않지만 그 안의 노트에 맞는다.
24126
+ */
24127
+ excludesFolder(path) {
24128
+ return this.exclude.some((p) => matchesWithin(path, p));
24129
+ }
24130
+ };
24131
+ function toVaultPath(relativePath, separator) {
24132
+ return separator === "/" ? relativePath : relativePath.split(separator).join("/");
24133
+ }
24134
+ function compileAll(patterns) {
24135
+ return patterns.flatMap((raw) => {
24136
+ const pattern = normalizePattern(raw);
24137
+ return pattern === null ? [] : [compile(pattern)];
24138
+ });
24139
+ }
24140
+ function normalizePattern(raw) {
24141
+ const pattern = raw.trim().replace(/\\/g, "/").replace(/^(?:\.\/|\/)+/, "").replace(/\/+$/, "");
24142
+ return pattern === "" ? null : pattern;
24143
+ }
24144
+ function compile(pattern) {
24145
+ if (pattern === "**" || pattern === "**/*") return { kind: "all" };
24146
+ if (!/[*?]/.test(pattern)) return { kind: "prefix", prefix: pattern };
24147
+ if (pattern.endsWith("/**") && !/[*?]/.test(pattern.slice(0, -3))) {
24148
+ return { kind: "prefix", prefix: pattern.slice(0, -3) };
24149
+ }
24150
+ const re = globToRegExp(pattern);
24151
+ return pattern.includes("/") ? { kind: "anchored", re } : { kind: "segment", re };
24152
+ }
24153
+ function globToRegExp(pattern) {
24154
+ let source = "";
24155
+ for (let i = 0; i < pattern.length; i++) {
24156
+ const char = pattern[i];
24157
+ if (char === "*" && pattern[i + 1] === "*") {
24158
+ const segmentStart = i === 0 || pattern[i - 1] === "/";
24159
+ if (segmentStart && pattern[i + 2] === "/") {
24160
+ source += "(?:.*/)?";
24161
+ i += 2;
24162
+ } else {
24163
+ source += ".*";
24164
+ i += 1;
24165
+ }
24166
+ } else if (char === "*") {
24167
+ source += "[^/]*";
24168
+ } else if (char === "?") {
24169
+ source += "[^/]";
24170
+ } else {
24171
+ source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
24172
+ }
24173
+ }
24174
+ return new RegExp(`^${source}$`);
24175
+ }
24176
+ function matchesWithin(path, pattern) {
24177
+ switch (pattern.kind) {
24178
+ case "all":
24179
+ return true;
24180
+ case "prefix":
24181
+ return path === pattern.prefix || path.startsWith(`${pattern.prefix}/`);
24182
+ case "segment":
24183
+ return path.split("/").some((segment) => pattern.re.test(segment));
24184
+ case "anchored": {
24185
+ const segments = path.split("/");
24186
+ for (let depth2 = 1; depth2 <= segments.length; depth2++) {
24187
+ if (pattern.re.test(segments.slice(0, depth2).join("/"))) return true;
24188
+ }
24189
+ return false;
24190
+ }
24191
+ }
24192
+ }
24193
+
24194
+ // src/sync/node-vault-fs.ts
23769
24195
  var NodeVaultFS = class {
23770
24196
  constructor(rootPath, pathConfig) {
23771
24197
  this.rootPath = rootPath;
23772
- this.includePatterns = pathConfig?.include ?? [];
23773
24198
  const configExclude = pathConfig?.exclude ?? [];
23774
24199
  const ignorePatterns = this.loadImNobsidianIgnore();
23775
- this.excludePatterns = [...configExclude, ...ignorePatterns];
24200
+ this.filter = new PathFilter(pathConfig?.include ?? [], [...configExclude, ...ignorePatterns]);
23776
24201
  }
23777
24202
  rootPath;
23778
- includePatterns;
23779
- excludePatterns;
24203
+ filter;
24204
+ excludesPath(path, kind = "file") {
24205
+ return kind === "folder" ? this.filter.excludesFolder(path) : this.filter.excludesFile(path);
24206
+ }
23780
24207
  loadImNobsidianIgnore() {
23781
24208
  try {
23782
24209
  const ignorePath = join(this.rootPath, IGNORE_FILE);
@@ -23850,38 +24277,20 @@ var NodeVaultFS = class {
23850
24277
  await this.walkDirNonMd(this.rootPath, files);
23851
24278
  return files;
23852
24279
  }
23853
- matchesPattern(relativePath, pattern) {
23854
- if (pattern === "**/*" || pattern === "**") return true;
23855
- if (pattern.endsWith("/**")) {
23856
- const dir = pattern.slice(0, -3);
23857
- return relativePath.startsWith(dir + "/") || relativePath === dir;
23858
- } else if (pattern.startsWith("*.")) {
23859
- const ext = pattern.slice(1);
23860
- return relativePath.endsWith(ext);
23861
- } else if (pattern.includes("*")) {
23862
- const regexStr = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
23863
- return new RegExp("^" + regexStr + "$").test(relativePath);
23864
- } else {
23865
- return relativePath === pattern || relativePath.startsWith(pattern + "/");
23866
- }
23867
- }
23868
- isExcluded(relativePath) {
23869
- if (this.includePatterns.length > 0) {
23870
- const included = this.includePatterns.some((p) => this.matchesPattern(relativePath, p));
23871
- if (!included) return true;
23872
- }
23873
- return this.excludePatterns.some((p) => this.matchesPattern(relativePath, p));
24280
+ /** 볼트 경로 — Windows 의 `relative()` 는 `\` 로 잇는다. 상태 DB · 패턴 · Notion 경로는 모두 `/` 다. */
24281
+ vaultPath(fullPath) {
24282
+ return toVaultPath(relative(this.rootPath, fullPath), sep);
23874
24283
  }
23875
24284
  async walkDirNonMd(dir, result) {
23876
24285
  const entries = await readdir(dir, { withFileTypes: true });
23877
24286
  for (const entry of entries) {
23878
24287
  const fullPath = join(dir, entry.name);
23879
24288
  if (entry.name.startsWith(".")) continue;
23880
- const relativePath = relative(this.rootPath, fullPath);
23881
- if (this.isExcluded(relativePath)) continue;
24289
+ const relativePath = this.vaultPath(fullPath);
23882
24290
  if (entry.isDirectory()) {
24291
+ if (this.filter.excludesFolder(relativePath)) continue;
23883
24292
  await this.walkDirNonMd(fullPath, result);
23884
- } else if (entry.isFile() && !isMarkdownPath(entry.name)) {
24293
+ } else if (entry.isFile() && !isMarkdownPath(entry.name) && !this.filter.excludesFile(relativePath)) {
23885
24294
  const fileStat = await stat(fullPath);
23886
24295
  result.push({
23887
24296
  path: relativePath,
@@ -23896,11 +24305,11 @@ var NodeVaultFS = class {
23896
24305
  for (const entry of entries) {
23897
24306
  const fullPath = join(dir, entry.name);
23898
24307
  if (entry.name.startsWith(".")) continue;
23899
- const relativePath = relative(this.rootPath, fullPath);
23900
- if (this.isExcluded(relativePath)) continue;
24308
+ const relativePath = this.vaultPath(fullPath);
23901
24309
  if (entry.isDirectory()) {
24310
+ if (this.filter.excludesFolder(relativePath)) continue;
23902
24311
  await this.walkDirStats(fullPath, result);
23903
- } else if (entry.isFile() && isMarkdownPath(entry.name)) {
24312
+ } else if (entry.isFile() && isMarkdownPath(entry.name) && !this.filter.excludesFile(relativePath)) {
23904
24313
  const fileStat = await stat(fullPath);
23905
24314
  result.push({
23906
24315
  path: relativePath,
@@ -23915,11 +24324,11 @@ var NodeVaultFS = class {
23915
24324
  for (const entry of entries) {
23916
24325
  const fullPath = join(dir, entry.name);
23917
24326
  if (entry.name.startsWith(".")) continue;
23918
- const relativePath = relative(this.rootPath, fullPath);
23919
- if (this.isExcluded(relativePath)) continue;
24327
+ const relativePath = this.vaultPath(fullPath);
23920
24328
  if (entry.isDirectory()) {
24329
+ if (this.filter.excludesFolder(relativePath)) continue;
23921
24330
  await this.walkDir(fullPath, result);
23922
- } else if (entry.isFile() && isMarkdownPath(entry.name)) {
24331
+ } else if (entry.isFile() && isMarkdownPath(entry.name) && !this.filter.excludesFile(relativePath)) {
23923
24332
  const content = await readFile(fullPath, "utf-8");
23924
24333
  const fileStat = await stat(fullPath);
23925
24334
  result.push({
@@ -24934,9 +25343,9 @@ function readColumnLayout(lines) {
24934
25343
  close(i);
24935
25344
  return;
24936
25345
  }
24937
- const sep2 = SEP_RE.exec(line);
24938
- if (sep2) {
24939
- const ratio = sep2[1] === void 0 ? void 0 : Number(sep2[1]);
25346
+ const sep3 = SEP_RE.exec(line);
25347
+ if (sep3) {
25348
+ const ratio = sep3[1] === void 0 ? void 0 : Number(sep3[1]);
24940
25349
  open.columns.push({ line: i, ...Number.isFinite(ratio) ? { ratio } : {} });
24941
25350
  }
24942
25351
  });
@@ -24951,6 +25360,6 @@ function formatRatio(ratio) {
24951
25360
  return String(Math.round(ratio * 100) / 100);
24952
25361
  }
24953
25362
 
24954
- export { BaseFileGenerator, BlankLineCollapser, CONFIG_FILE, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConnectionBoundaryError, ConnectionScopeError, ConversionPipeline, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DEFAULT_TITLE_PROPERTY, DEFAULT_WATCH_DEBOUNCE_MS, DIFF_SIGN, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FrontmatterExtractor, FrontmatterGenerator, GITIGNORE_ENTRY, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, ImageHandler, InlineAnnotationPreserver, InlineDBParser, LocalImageRestorer, MARKER_BRAND, MathNormalizer, MentionToWikilink, NOTION_COLOR_NAMES, NOTION_TOKEN_PREFIX, NOTION_TO_BASES_VIEW, NodeVaultFS, NotionBlockBuilder, NotionClient, OBSIDIAN_CONFIG_DIR, PendingWalError, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, RENDER_LINE_RULES, SAVED_STATE_TABLES_QUERY, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_MIGRATIONS, STATE_DB_PATH, SavedStateDbError, ScopedVaultFS, SidecarGenerator, StateDB, StateDbLockedError, StateDbUnavailableError, StateLock, SyncBusyError, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WIKILINK_LOOKUPS, WatchSyncService, WikilinkResolver, applicableChoices, assertNoPendingWal, basesViewMappingOf, basesViewTypeOf, choiceForStrategy, classifyBodyFidelity, columnLabel, compactNotionId, computeBufferHash, computeHash, configDirFrom, connectionFolderOf, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, findConnectionFolders, formatUnifiedDiff, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, hasColumnLayout, hasDesignMarker, inAnyPathScope, isInternalPath, isMarkdownPath, isRemoteDeletion, lineDiff, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, normalizeVaultPath, noteNameFromPath, notionIdFromInput, notionIdsEqual, operationLabel, parseDesignColor, pendingWalBytes, readColumnLayout, readDesignLine, resolvePropertyName, sanitizeFileName, setLogger, sortEntries, stateDbLockPath, stateDbWalPath, stripMarkdownExtension, summarizeFidelity, systemTimeZone, threeWayMerge, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness, wikilinkLookupsIn };
25363
+ export { BaseFileGenerator, BlankLineCollapser, CONFIG_FILE, CalloutRestorer, CalloutTransformer, ChangeDetector, ColorAnnotator, ConfigManager, ConfigSchema, ConflictResolver, ConnectionBoundaryError, ConnectionScopeError, ConversionPipeline, DB_VIEWS_FILE, DB_VIEWS_PATH, DEFAULT_CONFIG, DEFAULT_TITLE_PROPERTY, DEFAULT_WATCH_DEBOUNCE_MS, DIFF_SIGN, DatabaseSyncer, EmbedResolver, EntryEditor, FileHandler, FileWatcher, FrontmatterExtractor, FrontmatterGenerator, GITIGNORE_ENTRY, IGNORE_FILE, INTERNAL_DIR, INTERNAL_DIR_GLOB, ImageHandler, InlineAnnotationPreserver, InlineDBParser, LocalImageRestorer, MARKER_BRAND, MathNormalizer, MentionToWikilink, NOTION_COLOR_NAMES, NOTION_TOKEN_PREFIX, NOTION_TO_BASES_VIEW, NodeVaultFS, NotionBlockBuilder, NotionClient, OBSIDIAN_CONFIG_DIR, PathFilter, PendingWalError, PreserveMarkerCollector, PreserveMarkerInjector, PropertiesTableInjector, PropertiesTableRestorer, PropertyMapper, RENDER_LINE_RULES, SAVED_STATE_TABLES_QUERY, SEPARATOR_BODY, STATE_DB_FILE, STATE_DB_MIGRATIONS, STATE_DB_PATH, SavedStateDbError, ScopedVaultFS, SidecarGenerator, StateDB, StateDbLockedError, StateDbUnavailableError, StateLock, SyncBusyError, SyncOrchestrator, TreeMapper, UnsupportedBlockStripper, ViewDataProvider, WIKILINK_LOOKUPS, WatchSyncService, WikilinkResolver, applicableChoices, assertNoPendingWal, basesViewMappingOf, basesViewTypeOf, choiceForStrategy, classifyBodyFidelity, columnLabel, compactNotionId, computeBufferHash, computeHash, configDirFrom, connectionFolderOf, createDefaultPipeline, extractCalendarEntries, filterByMonth, filterEntries, findConnectionFolders, formatUnifiedDiff, generateColorCSS, generateId, getLogger, getNotionBgColor, getNotionColor, getVisibleProperties, groupEntries, hasColumnLayout, hasDesignMarker, inAnyPathScope, isInternalPath, isMarkdownPath, isRemoteDeletion, lineDiff, lintRenderedMarkdown, matchesPathScope, normalizeNotionId, normalizeVaultPath, noteNameFromPath, notionIdFromInput, notionIdsEqual, operationLabel, parseDesignColor, pendingWalBytes, readColumnLayout, readDesignLine, resolvePropertyName, sanitizeFileName, setLogger, sortEntries, stateDbLockPath, stateDbWalPath, stripMarkdownExtension, summarizeFidelity, systemTimeZone, threeWayMerge, toVaultPath, translateNotionFilter, verifyDatabaseCompleteness, verifyPageCompleteness, wikilinkLookupsIn };
24955
25364
  //# sourceMappingURL=index.js.map
24956
25365
  //# sourceMappingURL=index.js.map